Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9041b04484 |
@@ -46,6 +46,3 @@ GEOLOCATION_DEV_URL=
|
||||
|
||||
# live-events web worker URL
|
||||
LIVE_EVENTS_DEV_URL=
|
||||
|
||||
# app-config web worker URL
|
||||
APP_CONFIG_DEV_URL=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,4 +51,4 @@ jobs:
|
||||
# NOTE(sfn): we can add a custom system prompt here
|
||||
|
||||
claude_args: |
|
||||
--model claude-opus-4-7
|
||||
--model claude-opus-4-5-20251101
|
||||
|
||||
@@ -110,9 +110,7 @@ google-services.json
|
||||
|
||||
# i18n
|
||||
src/locale/locales/_build/
|
||||
src/locale/locales/**/messages.js
|
||||
src/locale/locales/**/messages.mjs
|
||||
src/locale/locales/**/messages.ts
|
||||
src/locale/locales/**/*.js
|
||||
|
||||
# local builds
|
||||
*.apk
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -24,7 +24,6 @@ yarn android # Run on Android
|
||||
yarn ios # Run on iOS
|
||||
|
||||
# Testing & Quality
|
||||
# IMPORTANT: Always use these yarn scripts, never call the underlying tools directly
|
||||
yarn test # Run Jest tests
|
||||
yarn lint # Run ESLint
|
||||
yarn typecheck # Run TypeScript type checking
|
||||
@@ -46,7 +45,6 @@ src/
|
||||
├── alf/ # Design system (ALF) - themes, atoms, tokens
|
||||
├── components/ # Shared UI components (Button, Dialog, Menu, etc.)
|
||||
├── screens/ # Full-page screen components (newer pattern)
|
||||
├── features/ # Macro-features that bridge components/screens
|
||||
├── view/
|
||||
│ ├── screens/ # Full-page screens (legacy location)
|
||||
│ ├── com/ # Reusable view components
|
||||
@@ -61,121 +59,6 @@ src/
|
||||
└── Navigation.tsx # Main navigation configuration
|
||||
```
|
||||
|
||||
### Project Structure in Depth
|
||||
|
||||
When building new things, follow these guidelines for where to put code.
|
||||
|
||||
#### Components vs Screens vs Features
|
||||
|
||||
**Components** are reusable UI elements that are not full screens. Should be
|
||||
platform-agnostic when possible. Examples: Button, Dialog, Menu, TextField. Put
|
||||
these in `/components` if they are shared across screens.
|
||||
|
||||
**Screens** are full-page components that represent a route in the app. They
|
||||
often contain multiple components and handle layout for a page. New screens
|
||||
should go in `/screens` (not `/view/screens`) to encourage better organization
|
||||
and separation from legacy code.
|
||||
|
||||
For complex screens that have specific components or data needs that _are not
|
||||
shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
|
||||
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
|
||||
`/screens/ProfileScreen/components/`.
|
||||
|
||||
**Features** are higher-level modules that may include context, data fetching,
|
||||
components, and utilities related to a specific feature e.g.
|
||||
`/features/liveNow`. They don't neatly fit into components or screens and often
|
||||
span multiple screens. This is an optional pattern for organizing complex
|
||||
features.
|
||||
|
||||
#### Legacy Directories
|
||||
|
||||
For the most part, avoid writing new files into the `/view` directory and
|
||||
subdirectories. This is the older pattern for organizing screens and components,
|
||||
and it has become a bit disorganized over time. New development should go into
|
||||
`/screens`, `/components`, and `/features`.
|
||||
|
||||
#### State
|
||||
|
||||
The `/state` directory is where we've historically put all our data fetching and
|
||||
state management logic. This is perfectly fine, but for new features, consider
|
||||
organizing state logic closer to the components that use it, either within a
|
||||
feature directory or co-located with a screen. The key is to keep related code
|
||||
together and avoid having "god files" with too much unrelated logic.
|
||||
|
||||
#### Lib
|
||||
|
||||
The `/lib` directory is for utilities and helpers that don't fit into other
|
||||
categories. This can include things like API clients, formatting functions,
|
||||
constants, and other shared logic.
|
||||
|
||||
#### Top Level Directories
|
||||
|
||||
Avoid writing new top-level subdirectories within `/src`. We've done this for a
|
||||
few things in the past that, but we have stronger patterns now. Examples:
|
||||
`/logger` should probably have been written into `/lib`. And `ageAssurance` is
|
||||
better classified within `/features`. We will probably migrate these things
|
||||
eventually.
|
||||
|
||||
### File and Directory Naming Conventions
|
||||
|
||||
Typically JS style for variables, functions, etc. We use ProudCamelCase for
|
||||
components, and camelCase directories and files.
|
||||
|
||||
When organizing new code, consider if it fits into a single file, or if it
|
||||
should be broken down into multiple files. For "macro" component cases, or
|
||||
things that live in `/features` or `/screens`, we often follow a pattern of
|
||||
having an `index.tsx` for the main component, and then co-locating related
|
||||
components, hooks, and utilities in the same directory. For example:
|
||||
|
||||
```
|
||||
src
|
||||
├── screens/
|
||||
│ ├── ProfileScreen/
|
||||
│ │ ├── index.tsx # Main screen component
|
||||
│ │ ├── components/ # Sub-components used only by this screen
|
||||
```
|
||||
|
||||
Similar patterns can be found in `/features` and `/components`. The idea here is
|
||||
to keep related code together and make it easier to navigate.
|
||||
|
||||
You should ask yourself: if someone new was looking for the code related to this
|
||||
feature or screen, where would they expect to find it? Organizing code in a way
|
||||
that matches developer expectations can make the codebase much more
|
||||
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
|
||||
easier to understand than having it scattered across multiple directories.
|
||||
|
||||
No need to go overboard with this. If a component or feature fits into a single
|
||||
file, there's no reason to have a `/Component/index.tsx` file when it could just
|
||||
be `/Component.tsx`. Use your judgment based on the complexity and amount of
|
||||
related code.
|
||||
|
||||
#### Platform Specific Files
|
||||
|
||||
We have conflicting patterns in the app for this. The preferred approach is to
|
||||
group platform-specific files into a directory as much as possible. For example,
|
||||
rather than having `Component.tsx`, `Component.web.tsx`, and
|
||||
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
|
||||
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
|
||||
related code together and gives us a better visual cue that there are probably
|
||||
other files contained within this "macro" feature, whereas `Component.tsx` on
|
||||
its own looks more like a single component file.
|
||||
|
||||
### Documentation and Tests Within Features
|
||||
|
||||
For larger features or components, it's helpful to include a README.md file
|
||||
within the directory that explains the purpose of the feature, how it works, and
|
||||
any important implementation details. The `/Component/index.tsx` pattern lends
|
||||
itself well to this, since the `index.tsx` can be the main component file, and
|
||||
the `README.md` can provide documentation for the whole feature. This is
|
||||
optional, but can be a nice way to keep documentation close to the code it
|
||||
describes.
|
||||
|
||||
Similarly, if there are tests that are specific to a component or feature, it
|
||||
can be helpful to include them in the same directory, either as
|
||||
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
|
||||
related to the component or feature in one place and makes it easier to find and
|
||||
maintain tests.
|
||||
|
||||
## Styling System (ALF)
|
||||
|
||||
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
|
||||
@@ -387,8 +270,7 @@ import * as TextField from '#/components/forms/TextField'
|
||||
All user-facing strings must be wrapped for translation using Lingui.
|
||||
|
||||
```tsx
|
||||
import {msg, plural} from '@lingui/core/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {msg, Trans, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
function MyComponent() {
|
||||
@@ -431,30 +313,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 +332,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 +341,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 +355,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 +373,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 +386,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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.26-bookworm AS build-env
|
||||
FROM golang:1.25-bookworm AS build-env
|
||||
|
||||
WORKDIR /usr/src/social-app
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.26-bookworm AS build-env
|
||||
FROM golang:1.25-bookworm AS build-env
|
||||
|
||||
WORKDIR /usr/src/social-app
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "editListNameInput"
|
||||
- eraseText
|
||||
- inputText: "Bad Ppl"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "editListDescriptionInput"
|
||||
- eraseText
|
||||
@@ -91,8 +92,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 +124,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:
|
||||
|
||||
@@ -35,12 +35,9 @@ appId: xyz.blueskyweb.app
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- tapOn:
|
||||
label: "Tap on down arrow"
|
||||
id: "feed-timeline-moveDown"
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
@@ -58,12 +55,9 @@ appId: xyz.blueskyweb.app
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- tapOn:
|
||||
label: "Tap on down arrow"
|
||||
id: "feed-feed-moveDown"
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
|
||||
@@ -15,11 +15,6 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "customServerTextInput"
|
||||
- inputText: "http://localhost:3000"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- tapOn: "Done"
|
||||
- tapOn:
|
||||
id: "loginUsernameInput"
|
||||
|
||||
@@ -26,7 +26,6 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "report:details"
|
||||
- inputText: "This is a test report"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
import {createServer as createHTTPServer} from 'node:http'
|
||||
import {parse} from 'node:url'
|
||||
|
||||
import {createServer, type TestPDS} from '../jest/test-pds'
|
||||
|
||||
async function main() {
|
||||
let server: TestPDS
|
||||
createHTTPServer(async (req, res) => {
|
||||
const url = parse(req.url || '/', true)
|
||||
if (req.method !== 'POST') {
|
||||
return res.writeHead(200).end()
|
||||
}
|
||||
try {
|
||||
console.log('Closing old server')
|
||||
await server?.close()
|
||||
console.log('Starting new server')
|
||||
const inviteRequired = url?.query && 'invite' in url.query
|
||||
server = await createServer({inviteRequired})
|
||||
console.log('Listening at', server.pdsUrl)
|
||||
if (url?.query) {
|
||||
if ('users' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
if (inviteRequired) {
|
||||
await server.mocker.createInvite(server.mocker.users.alice.did)
|
||||
}
|
||||
}
|
||||
if ('follows' in url.query) {
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
await server.mocker.follow('bob', 'alice')
|
||||
await server.mocker.follow('bob', 'carla')
|
||||
await server.mocker.follow('carla', 'alice')
|
||||
await server.mocker.follow('carla', 'bob')
|
||||
}
|
||||
if ('posts' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
for (let user in server.mocker.users) {
|
||||
await server.mocker.users[user].agent.post({text: 'Post'})
|
||||
}
|
||||
}
|
||||
if ('feeds' in url.query) {
|
||||
console.log('Generating mock feed')
|
||||
await server.mocker.createFeed('alice', 'alice-favs', [])
|
||||
}
|
||||
if ('thread' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
const res = await server.mocker.users.bob.agent.post({
|
||||
text: 'Thread root',
|
||||
})
|
||||
await server.mocker.users.carla.agent.post({
|
||||
text: 'Thread reply',
|
||||
reply: {
|
||||
parent: {cid: res.cid, uri: res.uri},
|
||||
root: {cid: res.cid, uri: res.uri},
|
||||
},
|
||||
})
|
||||
}
|
||||
if ('mergefeed' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.createUser('dan')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
await server.mocker.users.dan.agent.upsertProfile(() => ({
|
||||
displayName: 'Dan',
|
||||
description: 'Test user 4',
|
||||
}))
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
console.log('Generating mock posts')
|
||||
let posts: Record<string, any[]> = {
|
||||
alice: [],
|
||||
bob: [],
|
||||
carla: [],
|
||||
dan: [],
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
posts[user].push(
|
||||
await server.mocker.createPost(user, `Post ${i}`),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
if (i % 5 === 0) {
|
||||
await server.mocker.createReply(user, 'Self reply', {
|
||||
cid: posts[user][i].cid,
|
||||
uri: posts[user][i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 1) {
|
||||
await server.mocker.createReply(user, 'Reply to bob', {
|
||||
cid: posts.bob[i].cid,
|
||||
uri: posts.bob[i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 2) {
|
||||
await server.mocker.createReply(user, 'Reply to dan', {
|
||||
cid: posts.dan[i].cid,
|
||||
uri: posts.dan[i].uri,
|
||||
})
|
||||
}
|
||||
await server.mocker.users[user].agent.post({text: `Post ${i}`})
|
||||
}
|
||||
}
|
||||
console.log('Generating mock feeds')
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs2',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
}
|
||||
if ('labels' in url.query) {
|
||||
console.log('Generating naughty users with labels')
|
||||
|
||||
const anchorPost = await server.mocker.createPost(
|
||||
'alice',
|
||||
'Anchor post',
|
||||
)
|
||||
|
||||
for (const user of [
|
||||
'dmca-account',
|
||||
'dmca-profile',
|
||||
'dmca-posts',
|
||||
'porn-account',
|
||||
'porn-profile',
|
||||
'porn-posts',
|
||||
'nudity-account',
|
||||
'nudity-profile',
|
||||
'nudity-posts',
|
||||
'scam-account',
|
||||
'scam-profile',
|
||||
'scam-posts',
|
||||
'unknown-account',
|
||||
'unknown-profile',
|
||||
'unknown-posts',
|
||||
'hide-account',
|
||||
'hide-profile',
|
||||
'hide-posts',
|
||||
'no-promote-account',
|
||||
'no-promote-profile',
|
||||
'no-promote-posts',
|
||||
'warn-account',
|
||||
'warn-profile',
|
||||
'warn-posts',
|
||||
'muted-account',
|
||||
'muted-by-list-acc',
|
||||
'blocking-account',
|
||||
'blockedby-account',
|
||||
'mutual-block-acc',
|
||||
]) {
|
||||
await server.mocker.createUser(user)
|
||||
await server.mocker.follow('alice', user)
|
||||
await server.mocker.follow(user, 'alice')
|
||||
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
|
||||
await server.mocker.createReply(
|
||||
user,
|
||||
`Unlabeled reply from ${user}`,
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.like(user, anchorPost)
|
||||
}
|
||||
|
||||
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
|
||||
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createPost('dmca-posts', 'dmca post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createQuotePost(
|
||||
'dmca-posts',
|
||||
'dmca quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createReply(
|
||||
'dmca-posts',
|
||||
'dmca reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('porn', 'porn-account')
|
||||
await server.mocker.labelProfile('porn', 'porn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createImagePost('porn-posts', 'porn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createQuotePost(
|
||||
'porn-posts',
|
||||
'porn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createReply(
|
||||
'porn-posts',
|
||||
'porn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('nudity', 'nudity-account')
|
||||
await server.mocker.labelProfile('nudity', 'nudity-profile')
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createQuotePost(
|
||||
'nudity-posts',
|
||||
'nudity quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createReply(
|
||||
'nudity-posts',
|
||||
'nudity reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('scam', 'scam-account')
|
||||
await server.mocker.labelProfile('scam', 'scam-profile')
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createPost('scam-posts', 'scam post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createQuotePost(
|
||||
'scam-posts',
|
||||
'scam quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createReply(
|
||||
'scam-posts',
|
||||
'scam reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount(
|
||||
'not-a-real-label',
|
||||
'unknown-account',
|
||||
)
|
||||
await server.mocker.labelProfile(
|
||||
'not-a-real-label',
|
||||
'unknown-profile',
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createPost('unknown-posts', 'unknown post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createQuotePost(
|
||||
'unknown-posts',
|
||||
'unknown quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createReply(
|
||||
'unknown-posts',
|
||||
'unknown reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!hide', 'hide-account')
|
||||
await server.mocker.labelProfile('!hide', 'hide-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createPost('hide-posts', 'hide post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createQuotePost(
|
||||
'hide-posts',
|
||||
'hide quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createReply(
|
||||
'hide-posts',
|
||||
'hide reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
|
||||
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createPost(
|
||||
'no-promote-posts',
|
||||
'no-promote post',
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createQuotePost(
|
||||
'no-promote-posts',
|
||||
'no-promote quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createReply(
|
||||
'no-promote-posts',
|
||||
'no-promote reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!warn', 'warn-account')
|
||||
await server.mocker.labelProfile('!warn', 'warn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createPost('warn-posts', 'warn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createQuotePost(
|
||||
'warn-posts',
|
||||
'warn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createReply(
|
||||
'warn-posts',
|
||||
'warn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.users.alice.agent.mute('muted-account.test')
|
||||
await server.mocker.createPost('muted-account', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-account',
|
||||
'muted quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-account',
|
||||
'muted reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
const list = await server.mocker.createMuteList(
|
||||
'alice',
|
||||
'Muted Users',
|
||||
)
|
||||
await server.mocker.addToMuteList(
|
||||
'alice',
|
||||
list,
|
||||
server.mocker.users['muted-by-list-acc'].did,
|
||||
)
|
||||
await server.mocker.createPost('muted-by-list-acc', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-by-list-acc',
|
||||
'account quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-by-list-acc',
|
||||
'account reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blocking-account', 'blocking post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blocking-account',
|
||||
'blocking quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blocking-account',
|
||||
'blocking reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['blocking-account'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blockedby-account', 'blockedby post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blockedby-account',
|
||||
'blockedby quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blockedby-account',
|
||||
'blockedby reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users[
|
||||
'blockedby-account'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['blockedby-account'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost(
|
||||
'mutual-block-acc',
|
||||
'mutual-block post',
|
||||
)
|
||||
await server.mocker.createQuotePost(
|
||||
'mutual-block-acc',
|
||||
'mutual-block quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'mutual-block-acc',
|
||||
'mutual-block reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['mutual-block-acc'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
await server.mocker.users[
|
||||
'mutual-block-acc'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['mutual-block-acc'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
// flush caches
|
||||
await server.mocker.testNet.processAll()
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
return res
|
||||
.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
})
|
||||
.end(
|
||||
JSON.stringify({
|
||||
pdsUrl: server.pdsUrl,
|
||||
appviewDid: server.appviewDid,
|
||||
}),
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Error!', e)
|
||||
return res.writeHead(500).end()
|
||||
}
|
||||
}).listen(1986)
|
||||
console.log('Mock server manager listening on 1986')
|
||||
}
|
||||
main()
|
||||
@@ -3,29 +3,15 @@ appId: xyz.blueskyweb.app
|
||||
- launchApp:
|
||||
appId: "xyz.blueskyweb.app"
|
||||
clearState: true
|
||||
arguments:
|
||||
"-EXDevMenuIsOnboardingFinished": true
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- openLink: "exp+bluesky://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: 'Open in "Bluesky"'
|
||||
commands:
|
||||
- tapOn: Open
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- tapOn: 'http://localhost:8081'
|
||||
- runFlow:
|
||||
label: "Dismiss Expo dev menu"
|
||||
when:
|
||||
visible: "Continue"
|
||||
commands:
|
||||
- back
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "http://localhost:8081"
|
||||
- waitForAnimationToEnd
|
||||
- extendedWaitUntil:
|
||||
visible: "Continue"
|
||||
- swipe:
|
||||
from: "Bluesky"
|
||||
direction: DOWN
|
||||
duration: 100
|
||||
- tapOn:
|
||||
id: e2eProxyHeaderInput
|
||||
- inputText: ${output.result}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
requestPermission: jest.fn(),
|
||||
onForegroundEvent: jest.fn(),
|
||||
setBadgeCount: jest.fn(),
|
||||
displayNotification: jest.fn(),
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const CameraRoll = {
|
||||
getPhotos: jest.fn().mockResolvedValue({
|
||||
edges: [
|
||||
{node: {image: {uri: 'path/to/image1.jpg'}}},
|
||||
{node: {image: {uri: 'path/to/image2.jpg'}}},
|
||||
{node: {image: {uri: 'path/to/image3.jpg'}}},
|
||||
],
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
configure: jest.fn().mockResolvedValue(0),
|
||||
finish: jest.fn(),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default {}
|
||||
@@ -0,0 +1,10 @@
|
||||
jest.mock('rn-fetch-blob', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
fs: {
|
||||
unlink: jest.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export const DropdownMenu = jest.fn().mockImplementation(() => {})
|
||||
export const create = jest.fn().mockImplementation(() => {})
|
||||
@@ -1,5 +1,4 @@
|
||||
import {RichText} from '@atproto/api'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
|
||||
import {
|
||||
@@ -7,8 +6,6 @@ import {
|
||||
createStarterPackLinkFromAndroidReferrer,
|
||||
parseStarterPackUri,
|
||||
} from '#/lib/strings/starter-pack'
|
||||
import {messages} from '#/locale/locales/en/messages'
|
||||
import {klipyUrlToBskyGifUrl} from '#/state/queries/klipy'
|
||||
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
|
||||
import {cleanError} from '../../src/lib/strings/errors'
|
||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||
@@ -205,9 +202,6 @@ describe('enforceLen', () => {
|
||||
})
|
||||
|
||||
describe('cleanError', () => {
|
||||
// cleanError uses lingui
|
||||
i18n.loadAndActivate({locale: 'en', messages})
|
||||
|
||||
const inputs = [
|
||||
'TypeError: Network request failed',
|
||||
'Error: Aborted',
|
||||
@@ -333,7 +327,6 @@ describe('shortenLinks', () => {
|
||||
expect(outputRT.text).toEqual(outputs[i][0])
|
||||
expect(outputRT.facets?.length).toEqual(outputs[i][1].length)
|
||||
for (let j = 0; j < outputs[i][1].length; j++) {
|
||||
// @ts-expect-error whatever
|
||||
expect(outputRT.facets![j].features[0].uri).toEqual(outputs[i][1][j])
|
||||
}
|
||||
}
|
||||
@@ -444,20 +437,6 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
|
||||
'https://www.flickr.com/groups/898944@N23/',
|
||||
'https://www.flickr.com/groups',
|
||||
|
||||
'https://maxblansjaar.bandcamp.com/album/false-comforts',
|
||||
'https://grmnygrmny.bandcamp.com/track/fluid',
|
||||
'https://sufjanstevens.bandcamp.com/',
|
||||
'https://sufjanstevens.bandcamp.com',
|
||||
'https://bandcamp.com/',
|
||||
'https://bandcamp.com',
|
||||
|
||||
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
|
||||
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300&mp4=videoSlugMp4&webm=videoSlugWebm',
|
||||
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200',
|
||||
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
|
||||
'https://static.klipy.com/other/path.gif?hh=200&ww=300',
|
||||
'https://static.klipy.com',
|
||||
]
|
||||
|
||||
const outputs = [
|
||||
@@ -836,52 +815,6 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'bandcamp_album',
|
||||
source: 'bandcamp',
|
||||
playerUri:
|
||||
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fmaxblansjaar.bandcamp.com%2Falbum%2Ffalse-comforts/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
|
||||
},
|
||||
{
|
||||
type: 'bandcamp_track',
|
||||
source: 'bandcamp',
|
||||
playerUri:
|
||||
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fgrmnygrmny.bandcamp.com%2Ftrack%2Ffluid/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'klipy_gif',
|
||||
source: 'klipy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
|
||||
dimensions: {
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
},
|
||||
// With video slug params — on native (test env), keeps gif filename,
|
||||
// strips mp4/webm params. On web, would swap to video filename.
|
||||
{
|
||||
type: 'klipy_gif',
|
||||
source: 'klipy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
|
||||
dimensions: {
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
|
||||
it('correctly grabs the correct id from uri', () => {
|
||||
@@ -1086,31 +1019,3 @@ describe('tenorUrlToBskyGifUrl', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('klipyUrlToBskyGifUrl', () => {
|
||||
const inputs = [
|
||||
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
|
||||
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
|
||||
]
|
||||
|
||||
it.each(inputs)(
|
||||
'returns url with k.gifs.bsky.app as hostname for input url',
|
||||
input => {
|
||||
const out = klipyUrlToBskyGifUrl(input)
|
||||
expect(out.startsWith('https://k.gifs.bsky.app/')).toEqual(true)
|
||||
},
|
||||
)
|
||||
|
||||
it('preserves the path and query params when rewriting', () => {
|
||||
const out = klipyUrlToBskyGifUrl(
|
||||
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
|
||||
)
|
||||
expect(out).toEqual(
|
||||
'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty string for invalid URLs', () => {
|
||||
expect(klipyUrlToBskyGifUrl('not-a-url')).toEqual('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,13 +35,6 @@ module.exports = function (_config) {
|
||||
|
||||
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
|
||||
|
||||
const IOS_ICON_FILE =
|
||||
PLATFORM === 'web' // web build doesn't like .icon files
|
||||
? './assets/app-icons/ios_icon_default_next.png'
|
||||
: IS_TESTFLIGHT
|
||||
? './assets/app-icons/ios_icon_testflight.icon'
|
||||
: './assets/app-icons/ios_icon_default.icon'
|
||||
|
||||
return {
|
||||
expo: {
|
||||
version: VERSION,
|
||||
@@ -54,7 +47,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,
|
||||
@@ -62,9 +55,11 @@ module.exports = function (_config) {
|
||||
config: {
|
||||
usesNonExemptEncryption: false,
|
||||
},
|
||||
icon: IOS_ICON_FILE,
|
||||
icon:
|
||||
PLATFORM === 'web' // web build doesn't like .icon files
|
||||
? './assets/app-icons/ios_icon_default_next.png'
|
||||
: './assets/app-icons/ios_icon_default.icon',
|
||||
infoPlist: {
|
||||
CADisableMinimumFrameDurationOnPhone: true,
|
||||
UIBackgroundModes: ['remote-notification'],
|
||||
NSCameraUsageDescription:
|
||||
'Used for profile pictures, posts, and other kinds of content.',
|
||||
@@ -117,6 +112,7 @@ module.exports = function (_config) {
|
||||
'zh-Hans',
|
||||
'zh-Hant',
|
||||
],
|
||||
UIDesignRequiresCompatibility: true,
|
||||
},
|
||||
associatedDomains: ASSOCIATED_DOMAINS,
|
||||
entitlements: {
|
||||
@@ -260,16 +256,9 @@ module.exports = function (_config) {
|
||||
deploymentTarget: '15.1',
|
||||
buildReactNativeFromSource: true,
|
||||
ccacheEnabled: IS_DEV,
|
||||
extraPods: [
|
||||
{
|
||||
name: 'MCEmojiPicker',
|
||||
git: 'https://github.com/bluesky-social/MCEmojiPicker.git',
|
||||
branch: 'main',
|
||||
},
|
||||
],
|
||||
},
|
||||
android: {
|
||||
compileSdkVersion: 36,
|
||||
compileSdkVersion: 35,
|
||||
targetSdkVersion: 35,
|
||||
buildToolsVersion: '35.0.0',
|
||||
buildReactNativeFromSource: IS_PRODUCTION,
|
||||
@@ -297,6 +286,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',
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 771 KiB |
@@ -1,113 +0,0 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"blend-mode-specializations" : [
|
||||
{
|
||||
"value" : "overlay"
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : "screen"
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : "screen"
|
||||
}
|
||||
],
|
||||
"blur-material-specializations" : [
|
||||
{
|
||||
"value" : 0.5
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : 0.5
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : null
|
||||
}
|
||||
],
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "TestFlight notice.png",
|
||||
"name" : "TestFlight notice"
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"position" : {
|
||||
"scale" : 0.4,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
350
|
||||
]
|
||||
},
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular-specializations" : [
|
||||
{
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : false
|
||||
}
|
||||
],
|
||||
"translucency-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"fill" : "none",
|
||||
"glass" : false,
|
||||
"image-name" : "iOS transparent.png",
|
||||
"name" : "iOS transparent"
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17 3a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4h-2a1 1 0 1 1 0-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h2Zm-6.707 4.793a1 1 0 0 1 1.414 0l3.5 3.5a1 1 0 0 1 0 1.414l-3.5 3.5a1 1 0 1 1-1.414-1.414L12.086 13H4a1 1 0 1 1 0-2h8.086l-1.793-1.793a1 1 0 0 1 0-1.414Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 360 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 0a2 2 0 0 1 1 3.73V5h4.2c1.68 0 2.52 0 3.162.327a3 3 0 0 1 1.31 1.31C22 7.28 22 8.12 22 9.8v.25a2.501 2.501 0 0 1 0 4.9V15c0 2.8 0 4.2-.545 5.27a5 5 0 0 1-2.185 2.185C18.2 23 16.8 23 14 23h-4c-2.8 0-4.2 0-5.27-.545a5 5 0 0 1-2.185-2.185C2 19.2 2 17.8 2 15v-.05a2.5 2.5 0 0 1 0-4.9V9.8c0-1.68 0-2.52.327-3.162a3 3 0 0 1 1.31-1.31C4.28 5 5.12 5 6.8 5H11V3.73A2 2 0 0 1 12 0M8 10a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2m8 0a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 621 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a1 1 0 0 1 1 1v2h3.2l1.113.005c.975.015 1.568.077 2.05.322a3 3 0 0 1 1.31 1.31C21 7.28 21 8.12 21 9.8v.287a1.498 1.498 0 0 1-.005 2.827c-.006 2.204-.058 3.41-.54 4.356l-.093.174a5 5 0 0 1-2.092 2.011l-.205.096c-.766.33-1.72.417-3.21.44L13 20h-2l-1.854-.009c-1.49-.023-2.445-.11-3.211-.44l-.205-.096a5 5 0 0 1-2.185-2.185c-.409-.803-.51-1.79-.536-3.415l-.005-.94A1.498 1.498 0 0 1 3 10.086V9.8c0-1.575 0-2.412.27-3.04l.057-.122a3 3 0 0 1 1.105-1.196l.206-.115C5.279 5 6.12 5 7.8 5H11V3a1 1 0 0 1 1-1M7.8 7c-.873 0-1.408.002-1.808.034a3 3 0 0 0-.367.051l-.063.018-.016.006a1 1 0 0 0-.437.437l-.006.016-.018.063a3 3 0 0 0-.05.367C5.001 8.392 5 8.927 5 9.8V12c0 1.433.002 2.388.062 3.121.058.71.16 1.036.265 1.241a3 3 0 0 0 1.31 1.31c.207.106.532.209 1.242.267.733.06 1.688.061 3.121.061h2c1.433 0 2.388-.002 3.121-.061.71-.058 1.036-.161 1.241-.266a3 3 0 0 0 1.31-1.31c.106-.206.209-.532.267-1.242.06-.733.061-1.688.061-3.121V9.8c0-.873-.002-1.408-.034-1.808a2.5 2.5 0 0 0-.051-.367l-.017-.063-.007-.016a1 1 0 0 0-.437-.437l-.015-.006-.064-.018a3 3 0 0 0-.367-.05C17.608 7.001 17.073 7 16.2 7zM9 10a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1m6 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M14.3 23v-1.1a1 1 0 0 1 2 0V23a1 1 0 1 1-2 0Zm5.243-3.457a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM4.788 9.298a1 1 0 0 1 1.424 1.404l-.742.752-.004.005a5.003 5.003 0 1 0 7.075 7.075l.005-.004.752-.742a1 1 0 0 1 1.404 1.424l-.747.736a7.003 7.003 0 1 1-9.904-9.904l.737-.746ZM23 14.3a1 1 0 0 1 0 2h-1.1a1 1 0 1 1 0-2H23ZM10.044 4.05a7.005 7.005 0 0 1 9.905 9.906h0l-.737.746a1 1 0 0 1-1.424-1.404l.742-.752.004-.005a5.003 5.003 0 1 0-7.075-7.075l-.005.004-.752.742a1 1 0 0 1-1.404-1.424l.746-.737ZM2.1 7.7a1 1 0 1 1 0 2H1a1 1 0 0 1 0-2h1.1Zm-.157-5.757a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM7.7 2.1V1a1 1 0 1 1 2 0v1.1a1 1 0 0 1-2 0Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 807 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9 17a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-6-7a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM9 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 303 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 448 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M10.655 3.718c.55-1.116 2.14-1.116 2.69 0l7.548 15.317c.578 1.172-.515 2.471-1.768 2.103L13 19.336V15a1 1 0 0 0-2 0v4.336l-6.124 1.802c-1.254.369-2.346-.93-1.769-2.103l7.548-15.317Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 284 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867Zm0 10.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 357 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5Zm-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 446 B |
|
Before Width: | Height: | Size: 7.7 KiB |
@@ -16,7 +16,7 @@ module.exports = function (api) {
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
'@lingui/babel-plugin-lingui-macro',
|
||||
'macros',
|
||||
['babel-plugin-react-compiler', {target: '19'}],
|
||||
[
|
||||
'module:react-native-dotenv',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<svg width="120" height="28" viewBox="0 0 120 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.23031 1.75921C9.52558 4.31406 13.0698 9.49434 14.3713 12.2741C15.6727 9.49434 19.2169 4.31406 22.5122 1.75921C24.8899 -0.0842317 28.7425 -1.5106 28.7425 3.02818C28.7425 3.93462 28.2393 10.6429 27.9441 11.7321C26.9181 15.5183 23.1796 16.4841 19.8539 15.8995C25.667 16.9212 27.1457 20.3055 23.9521 23.6897C16.7665 31.3043 14.3713 19.6163 14.3713 19.6163C14.3713 19.6163 11.976 31.3043 4.79042 23.6897C1.59681 20.3055 3.07554 16.9212 8.88858 15.8995C5.56292 16.4841 1.82441 15.5183 0.798403 11.7321C0.503259 10.6429 0 3.93462 0 3.02818C0 -1.5106 3.85263 -0.0842317 6.23031 1.75921Z" fill="#006AFF"/>
|
||||
<path d="M46.662 12.8778C48.641 13.6012 49.6915 15.2726 49.6915 17.1435C49.6915 20.3116 47.6149 22.2324 43.6326 22.2324H35.497V4.47113H43.3638C47.1507 4.47113 48.983 6.44184 48.983 9.06113C48.983 10.8073 48.2012 12.0796 46.662 12.8778ZM43.1195 7.2401H38.7952V11.88H43.1195C44.8053 11.88 45.7092 10.9819 45.7092 9.48521C45.7092 8.1132 44.7808 7.2401 43.1195 7.2401ZM38.7952 19.4385H43.4616C45.3183 19.4385 46.32 18.5654 46.32 16.9938C46.32 15.3474 45.3672 14.5242 43.4616 14.5242H38.7952V19.4385Z" fill="#006AFF"/>
|
||||
<path d="M54.2645 22.2324H51.1862V4.47113H54.2645V22.2324Z" fill="#006AFF"/>
|
||||
<path d="M64.4712 16.5698V9.36048H67.5495V22.2324H64.5689V20.3615C63.6161 21.8084 62.2968 22.5318 60.6111 22.5318C57.9481 22.5318 56.2135 20.8854 56.2135 17.8919V9.36048H59.2918V17.368C59.2918 18.9895 60.0736 19.8127 61.6616 19.8127C63.1519 19.8127 64.4712 18.6902 64.4712 16.5698Z" fill="#006AFF"/>
|
||||
<path d="M81.5614 16.021V16.7693H72.131C72.3508 18.9895 73.548 20.0871 75.4047 20.0871C76.8217 20.0871 77.7746 19.4635 78.2876 18.2411H81.2438C80.5841 20.8604 78.3853 22.5318 75.3803 22.5318C73.4991 22.5318 71.9844 21.9081 70.8361 20.6858C69.6878 19.4635 69.1015 17.842 69.1015 15.7965C69.1015 13.7759 69.6634 12.1544 70.8117 10.9071C71.9599 9.68477 73.4502 9.06113 75.3314 9.06113C77.2371 9.06113 78.7518 9.70972 79.8756 10.9819C80.9995 12.2542 81.5614 13.9505 81.5614 16.021ZM75.307 11.5058C73.6213 11.5058 72.4486 12.5036 72.1554 14.5741H78.4831C78.2143 12.7032 77.0905 11.5058 75.307 11.5058Z" fill="#006AFF"/>
|
||||
<path d="M88.3842 22.5817C84.7195 22.5817 82.7894 21.1099 82.6184 18.1413H85.6234C85.7944 19.7379 86.5762 20.3366 88.433 20.3366C90.0943 20.3366 90.925 19.8127 90.925 18.7899C90.925 17.8669 90.3386 17.4179 88.4574 17.0936L87.016 16.8442C84.2553 16.3702 82.8871 15.073 82.8871 12.9527C82.8871 10.5329 84.7683 9.06113 88.1154 9.06113C91.7068 9.06113 93.5636 10.508 93.6857 13.4266H90.7784C90.7051 11.855 89.8012 11.3062 88.1154 11.3062C86.6495 11.3062 85.9166 11.8052 85.9166 12.803C85.9166 13.701 86.5518 14.1002 88.0177 14.3746L89.6057 14.624C92.6596 15.1978 93.9789 16.3453 93.9789 18.5405C93.9789 21.1348 91.9267 22.5817 88.3842 22.5817Z" fill="#006AFF"/>
|
||||
<path d="M107.49 22.2324H103.972L100.307 16.2455L98.4015 18.1912V22.2324H95.372V4.47113H98.4015V14.6988L103.532 9.36048H107.197L102.433 14.2249L107.49 22.2324Z" fill="#006AFF"/>
|
||||
<path d="M115.529 12.6034L116.555 9.36048H119.78L114.918 23.2802C114.405 24.7021 113.77 25.7248 112.964 26.2986C112.158 26.8723 111.009 27.1467 109.495 27.1467C108.982 27.1467 108.542 27.1218 108.151 27.0719V24.6023H109.324C110.716 24.6023 111.4 23.7292 111.4 22.5318C111.4 21.9331 111.205 21.06 110.814 19.9374L107.149 9.36048H110.472L111.498 12.5785C112.255 14.9982 112.915 17.393 113.501 19.7628C114.039 17.7173 114.723 15.3225 115.529 12.6034Z" fill="#006AFF"/>
|
||||
<svg width="105" height="32" viewBox="0 0 105 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.7901 9.77492C21.7947 11.2889 23.9508 14.3587 24.7425 16.0059C25.5342 14.3587 27.6903 11.2889 29.6949 9.77492C31.1413 8.68251 33.485 7.83725 33.485 10.5269C33.485 11.064 33.1789 15.0393 32.9993 15.6848C32.3752 17.9285 30.1009 18.5008 28.0778 18.1544C31.6141 18.7598 32.5136 20.7653 30.5709 22.7708C26.1996 27.2831 24.7425 20.3569 24.7425 20.3569C24.7425 20.3569 23.2854 27.2831 18.9142 22.7708C16.9714 20.7653 17.871 18.7598 21.4072 18.1544C19.3841 18.5008 17.1099 17.9285 16.4857 15.6848C16.3061 15.0393 16 11.064 16 10.5269C16 7.83725 18.3437 8.68251 19.7901 9.77492Z" fill="white"/>
|
||||
<path d="M44.3863 16.3646C45.5901 16.7932 46.2292 17.7837 46.2292 18.8924C46.2292 20.7698 44.9659 21.908 42.5434 21.908H37.5942V11.3828H42.3799C44.6835 11.3828 45.7982 12.5506 45.7982 14.1028C45.7982 15.1376 45.3226 15.8915 44.3863 16.3646ZM42.2313 13.0237H39.6006V15.7732H42.2313C43.2568 15.7732 43.8067 15.2411 43.8067 14.3541C43.8067 13.5411 43.2419 13.0237 42.2313 13.0237ZM39.6006 20.2524H42.4393C43.5689 20.2524 44.1782 19.735 44.1782 18.8037C44.1782 17.828 43.5986 17.3402 42.4393 17.3402H39.6006V20.2524Z" fill="white"/>
|
||||
<path d="M49.0111 21.908H47.1385V11.3828H49.0111V21.908Z" fill="white"/>
|
||||
<path d="M55.2202 18.5524V14.2802H57.0929V21.908H55.2797V20.7993C54.7 21.6567 53.8975 22.0854 52.872 22.0854C51.252 22.0854 50.1968 21.1098 50.1968 19.3359V14.2802H52.0694V19.0254C52.0694 19.9863 52.545 20.4741 53.5111 20.4741C54.4177 20.4741 55.2202 19.8089 55.2202 18.5524Z" fill="white"/>
|
||||
<path d="M65.6167 18.2272V18.6706H59.8799C60.0137 19.9863 60.7419 20.6367 61.8714 20.6367C62.7334 20.6367 63.3131 20.2672 63.6252 19.5428H65.4235C65.0222 21.095 63.6846 22.0854 61.8566 22.0854C60.7122 22.0854 59.7907 21.7159 59.0922 20.9915C58.3937 20.2672 58.037 19.3063 58.037 18.0941C58.037 16.8967 58.3788 15.9359 59.0773 15.1967C59.7759 14.4724 60.6825 14.1028 61.8269 14.1028C62.9861 14.1028 63.9076 14.4872 64.5912 15.2411C65.2749 15.995 65.6167 17.0002 65.6167 18.2272ZM61.812 15.5515C60.7865 15.5515 60.0731 16.1428 59.8948 17.3698H63.7441C63.5806 16.2611 62.8969 15.5515 61.812 15.5515Z" fill="white"/>
|
||||
<path d="M69.7673 22.115C67.5379 22.115 66.3638 21.2428 66.2598 19.4837H68.0878C68.1918 20.4298 68.6674 20.7846 69.797 20.7846C70.8076 20.7846 71.3129 20.4741 71.3129 19.868C71.3129 19.3211 70.9562 19.055 69.8118 18.8628L68.935 18.715C67.2555 18.4341 66.4232 17.6654 66.4232 16.4089C66.4232 14.975 67.5676 14.1028 69.6038 14.1028C71.7885 14.1028 72.9181 14.9602 72.9924 16.6898H71.2238C71.1792 15.7585 70.6293 15.4332 69.6038 15.4332C68.712 15.4332 68.2662 15.7289 68.2662 16.3202C68.2662 16.8524 68.6526 17.0889 69.5443 17.2515L70.5104 17.3993C72.3681 17.7393 73.1707 18.4193 73.1707 19.7202C73.1707 21.2576 71.9223 22.115 69.7673 22.115Z" fill="white"/>
|
||||
<path d="M81.3899 21.908H79.2497L77.0204 18.3602L75.8611 19.5132V21.908H74.0182V11.3828H75.8611V17.4437L78.9822 14.2802H81.2116L78.3134 17.1628L81.3899 21.908Z" fill="white"/>
|
||||
<path d="M86.2805 16.2019L86.9047 14.2802H88.8665L85.909 22.5289C85.5968 23.3715 85.2104 23.9776 84.72 24.3176C84.2295 24.6576 83.531 24.8202 82.6095 24.8202C82.2974 24.8202 82.0299 24.8054 81.7921 24.7759V23.3124H82.5055C83.3526 23.3124 83.7688 22.795 83.7688 22.0854C83.7688 21.7306 83.6499 21.2132 83.4121 20.548L81.1827 14.2802H83.204L83.8282 16.1872C84.289 17.6211 84.6902 19.0402 85.0469 20.4446C85.3739 19.2324 85.7901 17.8132 86.2805 16.2019Z" fill="white"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 182 B |
@@ -9,11 +9,7 @@ export function applyTheme(theme: 'light' | 'dark') {
|
||||
document.documentElement.classList.add(theme)
|
||||
}
|
||||
|
||||
export function initSystemColorMode({additionalBodyClasses = ''} = {}) {
|
||||
if (additionalBodyClasses) {
|
||||
document.body.classList.add(additionalBodyClasses)
|
||||
}
|
||||
|
||||
export function initSystemColorMode() {
|
||||
applyTheme(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Container({
|
||||
if (!entry) return
|
||||
|
||||
let {height} = entry.contentRect
|
||||
height += 4 // border-2 = 2px top + 2px bottom
|
||||
height += 2 // border top and bottom
|
||||
if (height !== prevHeight.current) {
|
||||
prevHeight.current = height
|
||||
window.parent.postMessage(
|
||||
@@ -37,7 +37,7 @@ export function Container({
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full border-2 border-brand text-black relative transition-colors max-w-[600px] min-w-[300px] flex items-center dark:text-slate-200 rounded-[32px] overflow-hidden cursor-pointer"
|
||||
className="w-full bg-brand text-black dark:bg-brand relative transition-colors max-w-[600px] min-w-[300px] flex items-center dark:text-slate-200 rounded-[20px] cursor-pointer hover:bg-opacity-90"
|
||||
onClick={() => {
|
||||
if (ref.current && href) {
|
||||
// forwardRef requires preact/compat - let's keep it simple
|
||||
@@ -49,7 +49,9 @@ export function Container({
|
||||
}
|
||||
}}>
|
||||
{href && <Link href={href} />}
|
||||
<div className="flex-1 max-w-full">{children}</div>
|
||||
<div className="flex-1 px-[6px] pt-[6px] pb-2.5 max-w-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,8 @@ import {ComponentChildren, h} from 'preact'
|
||||
import {useMemo} from 'preact/hooks'
|
||||
|
||||
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||
import playIcon from '../../assets/play_filled_corner0_rounded.svg'
|
||||
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
|
||||
import starterPackIcon from '../../assets/starterPack.svg'
|
||||
import {Globe} from '../icons/Globe'
|
||||
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
||||
import * as bsky from '../types/bsky'
|
||||
import {getRkey} from '../util/rkey'
|
||||
@@ -94,7 +93,7 @@ export function Embed({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center shrink min-w-0 min-h-0">
|
||||
<p className="text-sm shrink-0 font-semibold max-w-[70%] truncate">
|
||||
<p className="block text-sm shrink-0 font-bold max-w-[70%] line-clamp-1">
|
||||
{record.author.displayName?.trim() || record.author.handle}
|
||||
</p>
|
||||
{verification.isVerified && (
|
||||
@@ -104,7 +103,7 @@ export function Embed({
|
||||
size={12}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-textLight dark:text-textDimmed min-w-0 truncate ml-1">
|
||||
<p className="block line-clamp-1 text-sm text-textLight dark:text-textDimmed shrink-[10] ml-1">
|
||||
@{record.author.handle}
|
||||
</p>
|
||||
</div>
|
||||
@@ -335,18 +334,13 @@ function ExternalEmbed({
|
||||
/>
|
||||
)}
|
||||
<div className="py-3 px-4">
|
||||
<p className="font-semibold leading-tight line-clamp-3">
|
||||
{content.external.title}
|
||||
<p className="text-sm text-textLight dark:text-textDimmed line-clamp-1">
|
||||
{toNiceDomain(content.external.uri)}
|
||||
</p>
|
||||
<p className="text-sm leading-snug text-textLight dark:text-textDimmed line-clamp-2 mt-0.5">
|
||||
<p className="font-semibold line-clamp-3">{content.external.title}</p>
|
||||
<p className="text-sm text-textLight dark:text-textDimmed line-clamp-2 mt-0.5">
|
||||
{content.external.description}
|
||||
</p>
|
||||
<div className="flex flex-row items-center gap-1 border-t dark:border-slate-600 mt-1 pt-1.5">
|
||||
<Globe size={12} className="text-textLight dark:text-textDimmed" />
|
||||
<p className="text-sm leading-none text-textLight dark:text-textDimmed line-clamp-1">
|
||||
{toNiceDomain(content.external.uri)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
@@ -380,7 +374,7 @@ function GenericWithImageEmbed({
|
||||
<div className="w-8 h-8 rounded-md bg-brand shrink-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-sm">{title}</p>
|
||||
<p className="font-bold text-sm">{title}</p>
|
||||
<p className="text-textLight dark:text-textDimmed text-sm">
|
||||
{subtitle}
|
||||
</p>
|
||||
@@ -395,6 +389,7 @@ function GenericWithImageEmbed({
|
||||
)
|
||||
}
|
||||
|
||||
// just the thumbnail and a play button
|
||||
function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
|
||||
let aspectRatio = 1
|
||||
|
||||
@@ -403,28 +398,6 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
|
||||
aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
|
||||
}
|
||||
|
||||
const supportsHls = useMemo(() => {
|
||||
const video = document.createElement('video')
|
||||
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
|
||||
}, [])
|
||||
|
||||
if (supportsHls) {
|
||||
return (
|
||||
<video
|
||||
src={content.playlist}
|
||||
poster={content.thumbnail}
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
loading="lazy"
|
||||
aria-label={content.alt || undefined}
|
||||
onClickCapture={evt => evt.stopPropagation()}
|
||||
className="w-full rounded-xl bg-black"
|
||||
style={{aspectRatio: `${aspectRatio} / 1`}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-xl aspect-square relative"
|
||||
|
||||
@@ -10,7 +10,6 @@ import logo from '../../assets/logo_full_name.svg'
|
||||
import {Like as LikeIcon} from '../icons/Like'
|
||||
import {Reply as ReplyIcon} from '../icons/Reply'
|
||||
import {Repost as RepostIcon} from '../icons/Repost'
|
||||
import {Robot as RobotIcon} from '../icons/Robot'
|
||||
import {CONTENT_LABELS} from '../labels'
|
||||
import * as bsky from '../types/bsky'
|
||||
import {niceDate} from '../util/nice-date'
|
||||
@@ -44,16 +43,13 @@ export function Post({thread}: Props) {
|
||||
}
|
||||
|
||||
const verification = getVerificationState({profile: post.author})
|
||||
const isBot = post.author.labels?.some(
|
||||
l => l.val === 'bot' && l.src === post.author.did,
|
||||
)
|
||||
|
||||
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
|
||||
|
||||
return (
|
||||
<Container href={href}>
|
||||
<div
|
||||
className="flex-1 flex-col flex gap-4 bg-white dark:bg-black hover:bg-brandHover dark:hover:bg-brandHoverDark rounded-[30px] p-5"
|
||||
className="flex-1 flex-col flex gap-2 bg-neutral-50 dark:bg-black dark:hover:bg-slate-900 hover:bg-blue-50 rounded-[14px] p-4"
|
||||
lang={record?.langs?.[0]}>
|
||||
<div className="flex gap-2.5 items-center cursor-pointer w-full max-w-full ">
|
||||
<Link
|
||||
@@ -70,7 +66,7 @@ export function Post({thread}: Props) {
|
||||
<div className="flex flex-1 items-center">
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="block font-semibold text-[15px] min-[400px]:text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 text-ellipsis decoration-2">
|
||||
className="block font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 text-ellipsis decoration-2">
|
||||
{post.author.displayName?.trim() || post.author.handle}
|
||||
</Link>
|
||||
{verification.isVerified && (
|
||||
@@ -80,75 +76,73 @@ export function Post({thread}: Props) {
|
||||
size={15}
|
||||
/>
|
||||
)}
|
||||
{isBot && (
|
||||
<RobotIcon
|
||||
className="pl-[3px] mt-px shrink-0 text-slate-500 dark:text-slate-400"
|
||||
size={15}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[13px] min-[400px]:text-[15px] min-w-0">
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="text-textNeutral hover:underline line-clamp-1">
|
||||
@{post.author.handle}
|
||||
</Link>
|
||||
<span className="text-textNeutral shrink-0">·</span>
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="text-brand hover:underline shrink-0">
|
||||
Follow
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="block text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
|
||||
@{post.author.handle}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PostContent record={record} />
|
||||
<Embed content={post.embed} labels={post.labels} />
|
||||
|
||||
<div className="flex items-end justify-between w-full">
|
||||
<div className="flex flex-col min-[400px]:gap-0.5">
|
||||
<div className="flex items-center gap-3 text-sm cursor-pointer ml-[-2px]">
|
||||
{!!post.likeCount && (
|
||||
<div className="flex items-center gap-0.5 min-[400px]:gap-1 cursor-pointer group">
|
||||
<LikeIcon className="w-5 h-5 min-[400px]:w-[22px] min-[400px]:h-[22px] text-textLight dark:text-textDimmed group-hover:text-neutral-800 dark:group-hover:text-white transition-colors" />
|
||||
<p className="text-[11px] min-[400px]:text-[15px] font-semibold text-textLight dark:text-textDimmed mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
|
||||
{prettyNumber(post.likeCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!post.replyCount && (
|
||||
<div className="flex items-center gap-0.5 min-[400px]:gap-1 cursor-pointer group">
|
||||
<ReplyIcon className="w-5 h-5 min-[400px]:w-[22px] min-[400px]:h-[22px] text-textLight dark:text-textDimmed group-hover:text-neutral-800 dark:group-hover:text-white transition-colors" />
|
||||
<p className="text-[11px] min-[400px]:text-[15px] font-semibold text-textLight dark:text-textDimmed mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
|
||||
{prettyNumber(post.replyCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!post.repostCount && (
|
||||
<div className="flex items-center gap-0.5 min-[400px]:gap-1 cursor-pointer group">
|
||||
<RepostIcon className="w-5 h-5 min-[400px]:w-[22px] min-[400px]:h-[22px] text-textLight dark:text-textDimmed group-hover:text-neutral-800 dark:group-hover:text-white transition-colors" />
|
||||
<p className="text-[11px] min-[400px]:text-[15px] font-semibold text-textLight dark:text-textDimmed mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
|
||||
{prettyNumber(post.repostCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link href={href}>
|
||||
<time
|
||||
datetime={new Date(post.indexedAt).toISOString()}
|
||||
className="text-[11px] min-[400px]:text-[15px] text-textNeutral hover:underline">
|
||||
{niceDate(post.indexedAt)}
|
||||
</time>
|
||||
</Link>
|
||||
<div className="flex items-center justify-between w-full pt-2.5 text-sm">
|
||||
<div className="flex items-center gap-3 text-sm cursor-pointer">
|
||||
{!!post.likeCount && (
|
||||
<div className="flex items-center gap-1 cursor-pointer group">
|
||||
<LikeIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-slate-600 dark:text-slate-400 group-hover:text-neutral-800 dark:group-hover:text-white transition-colors"
|
||||
/>
|
||||
<p className="font-medium text-slate-600 text-neutral-600 dark:text-neutral-300 mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors dark:text-slate-400">
|
||||
{prettyNumber(post.likeCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!post.replyCount && (
|
||||
<div className="flex items-center gap-1 cursor-pointer group">
|
||||
<ReplyIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-slate-600 dark:text-slate-400 group-hover:text-neutral-800 dark:group-hover:text-white transition-colors"
|
||||
/>
|
||||
<p className="font-medium text-slate-600 text-neutral-600 dark:text-neutral-300 mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors dark:text-slate-400">
|
||||
{prettyNumber(post.replyCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!post.repostCount && (
|
||||
<div className="flex items-center gap-1 cursor-pointer group">
|
||||
<RepostIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-slate-600 dark:text-slate-400 group-hover:text-neutral-800 dark:group-hover:text-white transition-colors"
|
||||
/>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400 mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
|
||||
{prettyNumber(post.repostCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={href}
|
||||
className="transition-transform hover:scale-110 shrink-0">
|
||||
<img src={logo} className="h-5 min-[400px]:h-7" />
|
||||
<Link href={href}>
|
||||
<time
|
||||
datetime={new Date(post.indexedAt).toISOString()}
|
||||
className="text-slate-500 dark:text-textDimmed text-sm hover:underline dark:text-slate-500">
|
||||
{niceDate(post.indexedAt)}
|
||||
</time>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Link
|
||||
href={href}
|
||||
className="transition-transform hover:scale-110 shrink-0">
|
||||
<img src={logo} className="h-8" />
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -173,7 +167,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
<Link
|
||||
key={counter}
|
||||
href={segment.link.uri}
|
||||
className="text-brand hover:underline"
|
||||
className="text-blue-500 hover:underline"
|
||||
disableTracking={
|
||||
!segment.link.uri.startsWith('https://bsky.app') &&
|
||||
!segment.link.uri.startsWith('https://go.bsky.app')
|
||||
@@ -189,7 +183,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
<Link
|
||||
key={counter}
|
||||
href={`/profile/${segment.mention.did}`}
|
||||
className="text-brand hover:underline">
|
||||
className="text-blue-500 hover:underline">
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
@@ -201,7 +195,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
<Link
|
||||
key={counter}
|
||||
href={`/hashtag/${segment.tag.tag}`}
|
||||
className="text-brand hover:underline">
|
||||
className="text-blue-500 hover:underline">
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
@@ -213,7 +207,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-md min-[400px]:text-lg leading-snug min-[400px]:leading-snug break-word break-words whitespace-pre-wrap">
|
||||
<p className="min-[300px]:text-lg leading-6 min-[300px]:leading-6 break-word break-words whitespace-pre-wrap">
|
||||
{richText}
|
||||
</p>
|
||||
)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import {h} from 'preact'
|
||||
|
||||
export const Globe = ({
|
||||
size = 14,
|
||||
className,
|
||||
}: {
|
||||
size?: number
|
||||
className?: string
|
||||
}) => (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M4.4 9.493C4.14 10.28 4 11.124 4 12a8 8 0 1 0 10.899-7.459l-.953 3.81a1 1 0 0 1-.726.727l-3.444.866-.772 1.533a1 1 0 0 1-1.493.35L4.4 9.493Zm.883-1.84L7.756 9.51l.44-.874a1 1 0 0 1 .649-.52l3.306-.832.807-3.227a7.99 7.99 0 0 0-7.676 3.597ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8.43.162a1 1 0 0 1 .77-.29l1.89.121a1 1 0 0 1 .494.168l2.869 1.928a1 1 0 0 1 .336 1.277l-.973 1.946a1 1 0 0 1-.894.553h-2.92a1 1 0 0 1-.831-.445L9.225 14.5a1 1 0 0 1 .126-1.262l1.08-1.076Zm.915 1.913.177-.177 1.171.074 1.914 1.286-.303.607h-1.766l-1.194-1.79Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -1,22 +0,0 @@
|
||||
import {h} from 'preact'
|
||||
|
||||
export const Robot = ({
|
||||
size = 14,
|
||||
className,
|
||||
}: {
|
||||
size?: number
|
||||
className?: string
|
||||
}) => (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 0C13.1046 0 14 0.89543 14 2C14 2.73976 13.5971 3.3835 13 3.72949V5H17.2002C18.8802 5 19.7206 5.00018 20.3623 5.32715C20.9265 5.61472 21.3853 6.07347 21.6729 6.6377C21.9998 7.27941 22 8.11978 22 9.7998V10.0498C23.1411 10.2814 24 11.2905 24 12.5C24 13.7094 23.141 14.7175 22 14.9492V15C22 17.8 21.9999 19.2 21.4551 20.2695C20.9757 21.2103 20.2103 21.9757 19.2695 22.4551C18.2 22.9999 16.8 23 14 23H10C7.20005 23 5.79998 22.9999 4.73047 22.4551C3.78966 21.9757 3.02429 21.2103 2.54492 20.2695C2.00013 19.2 2 17.8 2 15V14.9492C0.858955 14.7175 0 13.7094 0 12.5C0 11.2905 0.85886 10.2814 2 10.0498V9.7998C2 8.11978 2.00018 7.27941 2.32715 6.6377C2.61472 6.07347 3.07347 5.61472 3.6377 5.32715C4.27941 5.00018 5.11978 5 6.7998 5H11V3.72949C10.4029 3.3835 10 2.73976 10 2C10 0.89543 10.8954 0 12 0ZM8 10C6.89543 10 6 10.8954 6 12V14C6 15.1046 6.89543 16 8 16C9.10457 16 10 15.1046 10 14V12C10 10.8954 9.10457 10 8 10ZM16 10C14.8954 10 14 10.8954 14 12V14C14 15.1046 14.8954 16 16 16C17.1046 16 18 15.1046 18 14V12C18 10.8954 17.1046 10 16 10Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -2,12 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.break-word {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
|
||||
initSystemColorMode({additionalBodyClasses: 'dark:bg-dimmedBgDarken'})
|
||||
initSystemColorMode()
|
||||
|
||||
const agent = new AtpAgent({
|
||||
service: 'https://public.api.bsky.app',
|
||||
@@ -39,7 +39,6 @@ render(<LandingPage />, root)
|
||||
function LandingPage() {
|
||||
const [uri, setUri] = useState('')
|
||||
const [colorMode, setColorMode] = useState<ColorModeValues>('system')
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
|
||||
@@ -119,7 +118,7 @@ function LandingPage() {
|
||||
}, [uri])
|
||||
|
||||
return (
|
||||
<main className="w-full min-h-dvh flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:text-slate-200">
|
||||
<main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:bg-dimmedBgDarken dark:text-slate-200">
|
||||
<Link
|
||||
href="https://bsky.social/about"
|
||||
className="transition-transform hover:scale-110">
|
||||
@@ -186,7 +185,7 @@ function LandingPage() {
|
||||
function Skeleton() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex-1 flex-col flex gap-2 p-5 pb-8">
|
||||
<div className="flex-1 flex-col flex gap-2 pb-8">
|
||||
<div className="flex gap-2.5 items-center">
|
||||
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-100 dark:bg-slate-700 shrink-0 animate-pulse" />
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export function niceDate(date: number | string | Date) {
|
||||
const d = new Date(date)
|
||||
return `${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})} · ${d.toLocaleDateString('en-us', {
|
||||
return `${d.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} at ${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
}
|
||||
|
||||
@@ -5,13 +5,10 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: 'rgb(0,106,255)',
|
||||
brandHover: 'rgb(245,249,255)',
|
||||
brandHoverDark: 'rgb(17,24,34)',
|
||||
brand: 'rgb(10,122,255)',
|
||||
brandLighten: 'rgb(32,139,254)',
|
||||
textLight: 'rgb(63,82,104)',
|
||||
textDimmed: 'rgb(164,179,197)',
|
||||
textNeutral: 'rgb(102,123,153)',
|
||||
textLight: 'rgb(66,87,108)',
|
||||
textDimmed: 'rgb(174,187,201)',
|
||||
dimmedBgLighten: 'rgb(30,41,54)',
|
||||
dimmedBg: 'rgb(22,30,39)',
|
||||
dimmedBgDarken: 'rgb(18,25,32)',
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,22 +1,10 @@
|
||||
import React from 'react'
|
||||
|
||||
// @NOTE satori does not currently support webp, see vercel/satori#273
|
||||
function detectMime(buf: Buffer): string {
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'
|
||||
if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png'
|
||||
if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp'
|
||||
if (buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif'
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
export function Img(
|
||||
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
|
||||
) {
|
||||
const {src, ...others} = props
|
||||
return (
|
||||
<img
|
||||
{...others}
|
||||
src={`data:${detectMime(src)};base64,${src.toString('base64')}`}
|
||||
/>
|
||||
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import React from 'react'
|
||||
import {type AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import resvg from '@resvg/resvg-js'
|
||||
import {type Express} from 'express'
|
||||
import {Express} from 'express'
|
||||
import satori from 'satori'
|
||||
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
STARTERPACK_HEIGHT,
|
||||
STARTERPACK_WIDTH,
|
||||
} from '../components/StarterPack.js'
|
||||
import {type AppContext} from '../context.js'
|
||||
import {AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {loadEmojiAsSvg} from '../util.js'
|
||||
import {handler, originVerifyMiddleware} from './util.js'
|
||||
@@ -83,18 +83,12 @@ export default function (ctx: AppContext, app: Express) {
|
||||
}
|
||||
|
||||
async function getImage(url: string) {
|
||||
const response = await fetch(ensureJpeg(url))
|
||||
const response = await fetch(url)
|
||||
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
|
||||
if (response.status !== 200) return null
|
||||
return Buffer.from(arrayBuf)
|
||||
}
|
||||
|
||||
// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp).
|
||||
// We want to ensure the image URLs we use are for jpegs, required for compat with satori.
|
||||
function ensureJpeg(url: string) {
|
||||
return url.replace(/(@[a-z]{3,5})?$/, '@jpeg')
|
||||
}
|
||||
|
||||
const hideAvatarLabels = new Set([
|
||||
'!hide',
|
||||
'!warn',
|
||||
|
||||
@@ -6,26 +6,21 @@ To build the SPA bundle (`bundle.web.js`), first get a JavaScript development
|
||||
environment set up. Either follow the top-level README, or something quick
|
||||
like:
|
||||
|
||||
```bash
|
||||
# install nodejs
|
||||
nvm install
|
||||
nvm use
|
||||
npm install --global yarn
|
||||
# install nodejs
|
||||
nvm install
|
||||
nvm use
|
||||
npm install --global yarn
|
||||
|
||||
# setup tools and deps (in top level of this repo)
|
||||
yarn install --frozen-lockfile
|
||||
# setup tools and deps (in top level of this repo)
|
||||
yarn install --frozen-lockfile
|
||||
|
||||
# run yarn web dev server, if you wanted
|
||||
yarn web
|
||||
```
|
||||
# run yarn web dev server, if you wanted
|
||||
yarn web
|
||||
|
||||
Then build and copy over the big 'ol `bundle.web.js` file:
|
||||
|
||||
|
||||
```bash
|
||||
# in the top level of this repo
|
||||
yarn build-web
|
||||
```
|
||||
# in the top level of this repo
|
||||
yarn build-web
|
||||
|
||||
### Golang Daemon
|
||||
|
||||
@@ -33,13 +28,11 @@ Install golang. We generally develop against the current stable release of the l
|
||||
|
||||
In this directory (`bskyweb/`):
|
||||
|
||||
```bash
|
||||
# re-build and run daemon
|
||||
go run ./cmd/bskyweb serve
|
||||
# re-build and run daemon
|
||||
go run ./cmd/bskyweb serve
|
||||
|
||||
# build and output a binary
|
||||
go build -o bskyweb ./cmd/bskyweb/
|
||||
```
|
||||
# build and output a binary
|
||||
go build -o bskyweb ./cmd/bskyweb/
|
||||
|
||||
The easiest way to configure the daemon is to copy `example.env` to `.env` and
|
||||
fill in auth values there.
|
||||
|
||||
@@ -2,14 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
)
|
||||
|
||||
func init() {
|
||||
pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL)
|
||||
pongo2.RegisterFilter("avatar_thumbnail", filterAvatarThumbnail)
|
||||
}
|
||||
|
||||
func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
|
||||
@@ -28,8 +26,3 @@ func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value
|
||||
// Return the cleaned URL
|
||||
return pongo2.AsValue(parsedURL.String()), nil
|
||||
}
|
||||
|
||||
func filterAvatarThumbnail(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
|
||||
urlStr := in.String()
|
||||
return pongo2.AsValue(strings.Replace(urlStr, "/img/avatar/plain/", "/img/avatar_thumbnail/plain/", 1)), nil
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func NewRenderer(prefix string, fs *embed.FS, debug bool) *Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
func (r Renderer) Render(w io.Writer, name string, data any, c echo.Context) error {
|
||||
func (r Renderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
||||
var ctx pongo2.Context
|
||||
|
||||
if data != nil {
|
||||
|
||||
@@ -292,7 +292,6 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/settings/accessibility", server.WebGeneric)
|
||||
e.GET("/settings/appearance", server.WebGeneric)
|
||||
e.GET("/settings/account", server.WebGeneric)
|
||||
e.GET("/settings/automation-label", server.WebGeneric)
|
||||
e.GET("/settings/privacy-and-security", server.WebGeneric)
|
||||
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
|
||||
e.GET("/settings/content-and-media", server.WebGeneric)
|
||||
@@ -575,10 +574,7 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
|
||||
if postView.Embed != nil && !isEmbedHidden {
|
||||
hasImages := postView.Embed.EmbedImages_View != nil
|
||||
hasVideo := postView.Embed.EmbedVideo_View != nil
|
||||
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil
|
||||
hasMediaImages := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
|
||||
hasMediaVideo := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil
|
||||
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
|
||||
|
||||
if hasImages {
|
||||
var thumbUrls []string
|
||||
@@ -586,36 +582,12 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
} else if hasVideo {
|
||||
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 {
|
||||
} else if hasMedia {
|
||||
var thumbUrls []string
|
||||
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
} else if hasMediaVideo {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,6 @@ type Template struct {
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
func (t *Template) Render(w io.Writer, name string, data any, c echo.Context) error {
|
||||
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
||||
return t.templates.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
@@ -178,13 +178,6 @@ func serve(cctx *cli.Context) error {
|
||||
return http.FS(fsys)
|
||||
}())
|
||||
|
||||
// Create CORS middleware for oembed
|
||||
oembedCORS := middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
|
||||
})
|
||||
|
||||
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
|
||||
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
|
||||
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
|
||||
@@ -212,7 +205,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/", server.WebHome)
|
||||
e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler))
|
||||
e.GET("/embed.js", echo.WrapHandler(staticHandler))
|
||||
e.GET("/oembed", server.WebOEmbed, oembedCORS)
|
||||
e.GET("/oembed", server.WebOEmbed)
|
||||
e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed)
|
||||
|
||||
// Start the server.
|
||||
@@ -271,7 +264,7 @@ func (srv *Server) errorHandler(err error, c echo.Context) {
|
||||
code = he.Code
|
||||
}
|
||||
c.Logger().Error(err)
|
||||
data := map[string]any{
|
||||
data := map[string]interface{}{
|
||||
"statusCode": code,
|
||||
}
|
||||
c.Render(code, "error.html", data)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/bluesky-social/social-app/bskyweb
|
||||
|
||||
go 1.26
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, viewport-fit=cover">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
|
||||
<meta name="referrer" content="origin-when-cross-origin">
|
||||
<!--
|
||||
Preconnect to essential domains
|
||||
@@ -148,11 +148,11 @@
|
||||
</head>
|
||||
<body>
|
||||
{%- block body_all %}
|
||||
<div id="splash">
|
||||
<!-- Bluesky SVG -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
|
||||
</div>
|
||||
<div id="root">
|
||||
<div id="splash">
|
||||
<!-- Bluesky SVG -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
|
||||
@@ -34,17 +34,9 @@
|
||||
<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 }}">
|
||||
<meta property="og:image" content="{{ postView.Author.Avatar }}">
|
||||
<meta property="twitter:image" content="{{ postView.Author.Avatar }}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% endif %}
|
||||
<meta name="twitter:label1" content="Posted At">
|
||||
|
||||
@@ -1,510 +0,0 @@
|
||||
import {createServer as createHTTPServer} from 'node:http'
|
||||
import {parse} from 'node:url'
|
||||
|
||||
import {createServer, type TestPDS} from './test-pds'
|
||||
|
||||
let server: TestPDS
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
createHTTPServer(async (req, res) => {
|
||||
const url = parse(req.url || '/', true)
|
||||
if (req.method !== 'POST') {
|
||||
return res.writeHead(200).end()
|
||||
}
|
||||
try {
|
||||
console.log('Closing old server')
|
||||
await server?.close()
|
||||
console.log('Starting new server')
|
||||
const inviteRequired = url?.query && 'invite' in url.query
|
||||
server = await createServer({inviteRequired})
|
||||
console.log('Listening at', server.pdsUrl)
|
||||
if (url?.query) {
|
||||
if ('users' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
if (inviteRequired) {
|
||||
await server.mocker.createInvite(server.mocker.users.alice.did)
|
||||
}
|
||||
}
|
||||
if ('follows' in url.query) {
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
await server.mocker.follow('bob', 'alice')
|
||||
await server.mocker.follow('bob', 'carla')
|
||||
await server.mocker.follow('carla', 'alice')
|
||||
await server.mocker.follow('carla', 'bob')
|
||||
}
|
||||
if ('posts' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
for (let user in server.mocker.users) {
|
||||
await server.mocker.users[user].agent.post({text: 'Post'})
|
||||
}
|
||||
}
|
||||
if ('feeds' in url.query) {
|
||||
console.log('Generating mock feed')
|
||||
await server.mocker.createFeed('alice', 'alice-favs', [])
|
||||
}
|
||||
if ('thread' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
const res = await server.mocker.users.bob.agent.post({
|
||||
text: 'Thread root',
|
||||
})
|
||||
await server.mocker.users.carla.agent.post({
|
||||
text: 'Thread reply',
|
||||
reply: {
|
||||
parent: {cid: res.cid, uri: res.uri},
|
||||
root: {cid: res.cid, uri: res.uri},
|
||||
},
|
||||
})
|
||||
}
|
||||
if ('mergefeed' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.createUser('dan')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
await server.mocker.users.dan.agent.upsertProfile(() => ({
|
||||
displayName: 'Dan',
|
||||
description: 'Test user 4',
|
||||
}))
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
console.log('Generating mock posts')
|
||||
let posts: Record<string, any[]> = {
|
||||
alice: [],
|
||||
bob: [],
|
||||
carla: [],
|
||||
dan: [],
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
posts[user].push(await server.mocker.createPost(user, `Post ${i}`))
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
if (i % 5 === 0) {
|
||||
await server.mocker.createReply(user, 'Self reply', {
|
||||
cid: posts[user][i].cid,
|
||||
uri: posts[user][i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 1) {
|
||||
await server.mocker.createReply(user, 'Reply to bob', {
|
||||
cid: posts.bob[i].cid,
|
||||
uri: posts.bob[i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 2) {
|
||||
await server.mocker.createReply(user, 'Reply to dan', {
|
||||
cid: posts.dan[i].cid,
|
||||
uri: posts.dan[i].uri,
|
||||
})
|
||||
}
|
||||
await server.mocker.users[user].agent.post({text: `Post ${i}`})
|
||||
}
|
||||
}
|
||||
console.log('Generating mock feeds')
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs2',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
}
|
||||
if ('labels' in url.query) {
|
||||
console.log('Generating naughty users with labels')
|
||||
|
||||
const anchorPost = await server.mocker.createPost(
|
||||
'alice',
|
||||
'Anchor post',
|
||||
)
|
||||
|
||||
for (const user of [
|
||||
'dmca-account',
|
||||
'dmca-profile',
|
||||
'dmca-posts',
|
||||
'porn-account',
|
||||
'porn-profile',
|
||||
'porn-posts',
|
||||
'nudity-account',
|
||||
'nudity-profile',
|
||||
'nudity-posts',
|
||||
'scam-account',
|
||||
'scam-profile',
|
||||
'scam-posts',
|
||||
'unknown-account',
|
||||
'unknown-profile',
|
||||
'unknown-posts',
|
||||
'hide-account',
|
||||
'hide-profile',
|
||||
'hide-posts',
|
||||
'no-promote-account',
|
||||
'no-promote-profile',
|
||||
'no-promote-posts',
|
||||
'warn-account',
|
||||
'warn-profile',
|
||||
'warn-posts',
|
||||
'muted-account',
|
||||
'muted-by-list-acc',
|
||||
'blocking-account',
|
||||
'blockedby-account',
|
||||
'mutual-block-acc',
|
||||
]) {
|
||||
await server.mocker.createUser(user)
|
||||
await server.mocker.follow('alice', user)
|
||||
await server.mocker.follow(user, 'alice')
|
||||
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
|
||||
await server.mocker.createReply(
|
||||
user,
|
||||
`Unlabeled reply from ${user}`,
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.like(user, anchorPost)
|
||||
}
|
||||
|
||||
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
|
||||
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createPost('dmca-posts', 'dmca post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createQuotePost(
|
||||
'dmca-posts',
|
||||
'dmca quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createReply(
|
||||
'dmca-posts',
|
||||
'dmca reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('porn', 'porn-account')
|
||||
await server.mocker.labelProfile('porn', 'porn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createImagePost('porn-posts', 'porn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createQuotePost(
|
||||
'porn-posts',
|
||||
'porn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createReply(
|
||||
'porn-posts',
|
||||
'porn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('nudity', 'nudity-account')
|
||||
await server.mocker.labelProfile('nudity', 'nudity-profile')
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createQuotePost(
|
||||
'nudity-posts',
|
||||
'nudity quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createReply(
|
||||
'nudity-posts',
|
||||
'nudity reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('scam', 'scam-account')
|
||||
await server.mocker.labelProfile('scam', 'scam-profile')
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createPost('scam-posts', 'scam post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createQuotePost(
|
||||
'scam-posts',
|
||||
'scam quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createReply(
|
||||
'scam-posts',
|
||||
'scam reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('not-a-real-label', 'unknown-account')
|
||||
await server.mocker.labelProfile('not-a-real-label', 'unknown-profile')
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createPost('unknown-posts', 'unknown post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createQuotePost(
|
||||
'unknown-posts',
|
||||
'unknown quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createReply(
|
||||
'unknown-posts',
|
||||
'unknown reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!hide', 'hide-account')
|
||||
await server.mocker.labelProfile('!hide', 'hide-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createPost('hide-posts', 'hide post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createQuotePost(
|
||||
'hide-posts',
|
||||
'hide quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createReply(
|
||||
'hide-posts',
|
||||
'hide reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
|
||||
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createPost('no-promote-posts', 'no-promote post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createQuotePost(
|
||||
'no-promote-posts',
|
||||
'no-promote quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createReply(
|
||||
'no-promote-posts',
|
||||
'no-promote reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!warn', 'warn-account')
|
||||
await server.mocker.labelProfile('!warn', 'warn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createPost('warn-posts', 'warn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createQuotePost(
|
||||
'warn-posts',
|
||||
'warn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createReply(
|
||||
'warn-posts',
|
||||
'warn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.users.alice.agent.mute('muted-account.test')
|
||||
await server.mocker.createPost('muted-account', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-account',
|
||||
'muted quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-account',
|
||||
'muted reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
const list = await server.mocker.createMuteList('alice', 'Muted Users')
|
||||
await server.mocker.addToMuteList(
|
||||
'alice',
|
||||
list,
|
||||
server.mocker.users['muted-by-list-acc'].did,
|
||||
)
|
||||
await server.mocker.createPost('muted-by-list-acc', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-by-list-acc',
|
||||
'account quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-by-list-acc',
|
||||
'account reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blocking-account', 'blocking post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blocking-account',
|
||||
'blocking quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blocking-account',
|
||||
'blocking reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['blocking-account'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blockedby-account', 'blockedby post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blockedby-account',
|
||||
'blockedby quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blockedby-account',
|
||||
'blockedby reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users[
|
||||
'blockedby-account'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['blockedby-account'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost('mutual-block-acc', 'mutual-block post')
|
||||
await server.mocker.createQuotePost(
|
||||
'mutual-block-acc',
|
||||
'mutual-block quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'mutual-block-acc',
|
||||
'mutual-block reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['mutual-block-acc'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
await server.mocker.users[
|
||||
'mutual-block-acc'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['mutual-block-acc'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
// flush caches
|
||||
await server.mocker.testNet.processAll()
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
return res
|
||||
.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
})
|
||||
.end(
|
||||
JSON.stringify({
|
||||
pdsUrl: server.pdsUrl,
|
||||
appviewDid: server.appviewDid,
|
||||
}),
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Error!', e)
|
||||
return res.writeHead(500).end()
|
||||
}
|
||||
}).listen(1986)
|
||||
console.log('Mock server manager listening on 1986')
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/dev-env": "^0.3.215",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
@@ -164,8 +164,8 @@ See [testing.md](./testing.md).
|
||||
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently, this includes:
|
||||
|
||||
- TextEncoder / TextDecoder
|
||||
- react-native-url-polyfill
|
||||
- Array#findLast (on web)
|
||||
- setImmediate (on web)
|
||||
|
||||
### Sentry sourcemaps
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -72,7 +73,7 @@ import { Text } from "react-native";
|
||||
```jsx
|
||||
// After
|
||||
import { Text } from "react-native";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Trans } from "@lingui/macro";
|
||||
|
||||
<Text><Trans>Hello World</Trans></Text>
|
||||
```
|
||||
@@ -89,33 +90,18 @@ const text = "Hello World";
|
||||
```
|
||||
In this case, you can use the `useLingui()` hook:
|
||||
```jsx
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { msg } from "@lingui/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
|
||||
const { _ } = useLingui();
|
||||
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
|
||||
```
|
||||
|
||||
NEW: the latest Lingui version introduced a new macro version of the `useLingui` hook which lets you do this:
|
||||
|
||||
If you want to do this outside of a React component, you can use the `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
|
||||
```jsx
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
const { t } = useLingui();
|
||||
return <Text accessibilityLabel={t`Label is here`}>{text}</Text>
|
||||
```
|
||||
|
||||
If you want to do this outside of a React component, you can use the global `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
|
||||
```jsx
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
// not ideal - t only gets called once at module evaluation time
|
||||
const text = t`Hello World`;
|
||||
|
||||
// however, this is suitable for strings that are ephemeral:
|
||||
function sayHello() {
|
||||
Toast.show(t`Hello World`); // Each time the toast shows, the current locale at that moment is used
|
||||
}
|
||||
```
|
||||
|
||||
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
|
||||
@@ -135,7 +121,7 @@ So the workflow is as follows:
|
||||
These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown.
|
||||
|
||||
```jsx
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { msg } from "@lingui/macro";
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
const welcomeMessage = msg`Welcome!`;
|
||||
|
||||
@@ -3,26 +3,12 @@
|
||||
Make sure you've copied `.env.example` to `.env.test` and provided any required
|
||||
values.
|
||||
|
||||
Install dependencies in `/dev-env`
|
||||
|
||||
```
|
||||
cd dev-env && yarn
|
||||
```
|
||||
|
||||
## Using Maestro
|
||||
|
||||
1. Install Maestro by following [these instructions](https://maestro.mobile.dev/getting-started/installing-maestro). This will help us run the E2E tests.
|
||||
2. You can write Maestro tests in `/.maestro/flows/` directory by creating a new `.yml` file or by modifying an existing one.
|
||||
3. You can also use [Maestro Studio](https://maestro.mobile.dev/getting-started/maestro-studio) which automatically generates commands by recording your actions on the app. Therefore, you can create realistic tests without having to manually write any code. Use the `maestro studio` command to start recording your actions.
|
||||
|
||||
### Running on Android
|
||||
|
||||
You will need to allow your device access to the port that the mock server is running on.
|
||||
|
||||
```
|
||||
adb reverse tcp:3000 tcp:3000
|
||||
```
|
||||
|
||||
### Running Maestro tests
|
||||
|
||||
- In one tab, run `yarn e2e:mock-server`
|
||||
|
||||
@@ -23,6 +23,8 @@ export default defineConfig(
|
||||
{
|
||||
ignores: [
|
||||
'**/__mocks__/*.ts',
|
||||
'src/platform/polyfills.ts',
|
||||
'src/third-party/**',
|
||||
'ios/**',
|
||||
'android/**',
|
||||
'coverage/**',
|
||||
@@ -37,7 +39,6 @@ export default defineConfig(
|
||||
'*.e2e.ts',
|
||||
'*.e2e.tsx',
|
||||
'eslint.config.mjs',
|
||||
'.jscodeshift/**',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -47,6 +48,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 +63,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 +128,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 +190,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
|
||||
@@ -250,14 +238,6 @@ export default defineConfig(
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
|
||||
'@typescript-eslint/await-thenable': 'warn',
|
||||
|
||||
"no-restricted-imports": ["error", {
|
||||
"paths": [{
|
||||
"name": "react",
|
||||
"importNames": ["React", "default"],
|
||||
"message": "React is already in the global type namespace. Use named imports for runtime modules."
|
||||
}]
|
||||
}],
|
||||
|
||||
/**
|
||||
* Turn off rules that we haven't enforced thus far
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* global jest */
|
||||
import 'react-native-gesture-handler/jestSetup'
|
||||
// IMPORTANT: this is what's used in the native runtime
|
||||
import 'react-native-url-polyfill/auto'
|
||||
|
||||
import {configure} from '@testing-library/react-native'
|
||||
|
||||
@@ -9,7 +11,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,
|
||||
@@ -35,7 +36,6 @@ jest.mock('react-native-safe-area-context', () => {
|
||||
jest.mock('expo-file-system/legacy', () => ({
|
||||
getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
|
||||
deleteAsync: jest.fn(),
|
||||
moveAsync: jest.fn().mockResolvedValue(undefined),
|
||||
createDownloadResumable: jest.fn(),
|
||||
}))
|
||||
|
||||
@@ -45,7 +45,6 @@ jest.mock('expo-image-manipulator', () => ({
|
||||
}),
|
||||
SaveFormat: {
|
||||
JPEG: 'jpeg',
|
||||
WEBP: 'webp',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -61,13 +60,9 @@ jest.mock('expo-media-library', () => ({
|
||||
usePermissions: jest.fn(() => [true]),
|
||||
}))
|
||||
|
||||
jest.mock('@bsky.app/expo-guess-language', () => ({
|
||||
guessLanguageSync: jest
|
||||
.fn()
|
||||
.mockReturnValue([{language: 'en', confidence: 1}]),
|
||||
guessLanguageAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{language: 'en', confidence: 1}]),
|
||||
jest.mock('lande', () => ({
|
||||
__esModule: true, // this property makes it work
|
||||
default: jest.fn().mockReturnValue([['eng']]),
|
||||
}))
|
||||
|
||||
jest.mock('sentry-expo', () => ({
|
||||
|
||||
@@ -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}
|
||||
@@ -1,7 +1,5 @@
|
||||
import {defineConfig} from '@lingui/cli'
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: 'en',
|
||||
/** @type {import('@lingui/conf').LinguiConfig} */
|
||||
module.exports = {
|
||||
locales: [
|
||||
'en',
|
||||
'an',
|
||||
@@ -51,5 +49,5 @@ export default defineConfig({
|
||||
include: ['src'],
|
||||
},
|
||||
],
|
||||
compileNamespace: 'ts',
|
||||
})
|
||||
format: 'po',
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
# BlueskyClip
|
||||
|
||||
An iOS App Clip implementation for Bluesky starter packs. App Clips are lightweight app experiences that allow users to preview and join Bluesky through starter packs without installing the full app.
|
||||
|
||||
## What It Does
|
||||
|
||||
BlueskyClip provides a minimal, on-demand iOS app experience for viewing and joining Bluesky starter packs. When a user encounters a starter pack link (e.g., `bsky.app/start/...` or `go.bsky.app/...`), iOS can present the App Clip instead of requiring a full app install. The App Clip:
|
||||
|
||||
1. Loads the starter pack web page in a WKWebView
|
||||
2. Allows users to browse the starter pack content
|
||||
3. Presents the App Store overlay when the user decides to join
|
||||
4. Passes the starter pack URI to the main app via shared UserDefaults
|
||||
|
||||
## Architecture
|
||||
|
||||
### Native iOS Implementation
|
||||
|
||||
The App Clip is a standalone iOS target with its own minimal Swift implementation:
|
||||
|
||||
- **AppDelegate.swift**: Standard app delegate that sets up the view controller and handles URL routing (both direct URL opens and universal links)
|
||||
- **ViewController.swift**: Main view controller that manages the WKWebView, detects starter pack URLs, and communicates with the web layer
|
||||
|
||||
### Communication Flow
|
||||
|
||||
```
|
||||
User taps starter pack link
|
||||
↓
|
||||
iOS presents BlueskyClip App Clip
|
||||
↓
|
||||
WKWebView loads bsky.app with ?clip=true parameter
|
||||
↓
|
||||
Web app detects clip mode and sends actions via postMessage
|
||||
↓
|
||||
ViewController receives messages and:
|
||||
- Presents App Store overlay (action: "present")
|
||||
- Stores starter pack URI in shared UserDefaults (action: "store")
|
||||
↓
|
||||
User downloads main app
|
||||
↓
|
||||
Main app reads starterPackUri from shared UserDefaults
|
||||
↓
|
||||
Main app displays starter pack onboarding flow
|
||||
```
|
||||
|
||||
### Key Implementation Details
|
||||
|
||||
**URL Detection** (`isStarterPackUrl`):
|
||||
- Matches `bsky.app/start/*` and `bsky.app/starter-pack/*` paths (4 path components)
|
||||
- Matches short links `go.bsky.app/*` (2 path components)
|
||||
|
||||
**WebView Communication** (`WKScriptMessageHandler`):
|
||||
- Listens for messages on the "onMessage" channel
|
||||
- Handles two action types:
|
||||
- `present`: Shows the App Store overlay using `SKOverlay`
|
||||
- `store`: Writes JSON data to shared UserDefaults with the specified key
|
||||
|
||||
**Data Sharing**:
|
||||
- Uses UserDefaults suite `group.app.bsky` (App Group)
|
||||
- Primary key: `starterPackUri` - stores the starter pack URL
|
||||
- The main app reads this value on launch via `SharedPrefs.getString('starterPackUri')` (see `src/components/hooks/useStarterPackEntry.native.ts`)
|
||||
|
||||
## Configuration
|
||||
|
||||
### Build Configuration
|
||||
|
||||
The App Clip target is automatically configured via Expo config plugins located in `/plugins/starterPackAppClipExtension/`:
|
||||
|
||||
- **withStarterPackAppClip.js**: Main plugin that orchestrates all configuration
|
||||
- **withXcodeTarget.js**: Creates the App Clip target in Xcode with proper build settings
|
||||
- **withAppEntitlements.js**: Configures main app entitlements for App Clip association
|
||||
- **withClipEntitlements.js**: Sets up App Clip entitlements (App Groups, parent app identifier, associated domains)
|
||||
- **withClipInfoPlist.js**: Generates the Info.plist for the App Clip target
|
||||
- **withFiles.js**: Copies Swift source files and assets from `modules/BlueskyClip/` to the iOS build directory
|
||||
|
||||
### Entitlements
|
||||
|
||||
**Main App** (`app.entitlements`):
|
||||
- `com.apple.security.application-groups`: `group.app.bsky`
|
||||
- `com.apple.developer.associated-appclip-app-identifiers`: Links to the App Clip bundle ID
|
||||
|
||||
**App Clip** (`BlueskyClip.entitlements`):
|
||||
- `com.apple.security.application-groups`: `group.app.bsky` (for data sharing)
|
||||
- `com.apple.developer.parent-application-identifiers`: Links to the main app bundle ID
|
||||
- `com.apple.developer.associated-domains`: Inherits from main app config (for universal links)
|
||||
|
||||
### Build Settings
|
||||
|
||||
- Deployment target: iOS 15.1+
|
||||
- Bundle ID: `[main-app-bundle-id].AppClip`
|
||||
- Product type: `com.apple.product-type.application.on-demand-install-capable`
|
||||
- Development team: `B3LX46C5HS`
|
||||
- Device family: iPhone only (1)
|
||||
|
||||
## Platform Support
|
||||
|
||||
- **iOS**: Full support via native App Clip
|
||||
- **Android**: Not applicable (no App Clip equivalent)
|
||||
- **Web**: Not applicable (web uses standard starter pack landing pages)
|
||||
|
||||
## Integration with Main App
|
||||
|
||||
The main app detects App Clip-originated starter packs through `useStarterPackEntry` hook:
|
||||
|
||||
**Native** (`src/components/hooks/useStarterPackEntry.native.ts`):
|
||||
- Reads `starterPackUri` from `SharedPrefs` (App Group)
|
||||
- Clears the value after reading to prevent re-use
|
||||
- Sets active starter pack in app state
|
||||
|
||||
**Web** (`src/components/hooks/useStarterPackEntry.ts`):
|
||||
- Detects `?clip=true` URL parameter
|
||||
- Extracts starter pack URI from URL
|
||||
- Sets active starter pack with `isClip: true` flag
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
modules/BlueskyClip/
|
||||
├── AppDelegate.swift # App lifecycle and URL handling
|
||||
├── ViewController.swift # WebView management and message handling
|
||||
└── Images.xcassets/ # App Clip icon assets
|
||||
├── AppIcon.appiconset/
|
||||
│ ├── App-Icon-1024x1024@1x.png
|
||||
│ └── Contents.json
|
||||
└── Contents.json
|
||||
```
|
||||
|
||||
## Development Notes
|
||||
|
||||
- The App Clip is built as part of the main Xcode project when running `yarn prebuild`
|
||||
- Source files are copied during the prebuild process, not directly referenced
|
||||
- Changes to Swift files require running `yarn prebuild` to take effect
|
||||
- The App Clip shares the same version number as the main app
|
||||
- App Clips have a 15MB size limit (enforced by Apple)
|
||||
- Users can convert an App Clip session into a full app install without losing data (via shared App Group)
|
||||
@@ -1,135 +0,0 @@
|
||||
# BlueskyNSE
|
||||
|
||||
BlueskyNSE is an iOS Notification Service Extension that processes push notifications before they are displayed to the user. NSE stands for "Notification Service Extension", a native iOS app extension type.
|
||||
|
||||
## What It Does
|
||||
|
||||
This extension intercepts incoming push notifications and performs processing before displaying them:
|
||||
|
||||
1. Manages badge counts for app icon
|
||||
2. Applies custom notification sounds based on user preferences
|
||||
3. Enables notification customization without requiring the main app to be running
|
||||
|
||||
## How It Works
|
||||
|
||||
When a push notification arrives on iOS, the system can invoke this extension to modify the notification content before displaying it. The extension runs in a separate process from the main app and has strict time limits (approximately 30 seconds) to complete its work.
|
||||
|
||||
### Architecture
|
||||
|
||||
The extension uses shared UserDefaults (via App Groups) to access preferences set by the main app:
|
||||
|
||||
- **App Group**: `group.app.bsky` allows data sharing between the main app and the extension
|
||||
- **Shared Preferences**: Stored in UserDefaults suite accessible by both processes
|
||||
- **Thread Safety**: Uses a dedicated serial DispatchQueue (`NSEPrefsQueue`) to prevent race conditions when multiple notifications arrive simultaneously
|
||||
|
||||
### Notification Processing Flow
|
||||
|
||||
1. System receives push notification
|
||||
2. `NotificationService.didReceive()` is called
|
||||
3. Extension creates mutable copy of notification content
|
||||
4. Based on notification type (determined by `reason` field):
|
||||
- **Chat messages** (`reason == "chat-message"`): Applies custom DM sound if user preference `playSoundChat` is enabled
|
||||
- **Other notifications**: Increments and applies badge count
|
||||
5. Extension delivers modified notification to system via `contentHandler`
|
||||
|
||||
### Badge Count Management
|
||||
|
||||
Badge counts are managed centrally by the extension:
|
||||
- Each non-chat notification increments the badge count
|
||||
- Count is synchronized across notification instances using the serial queue
|
||||
- Main app can reset the count via the `expo-background-notification-handler` module
|
||||
|
||||
### Notification Sounds
|
||||
|
||||
Two sound types are supported:
|
||||
- **Default system sound**: Standard iOS notification sound
|
||||
- **DM sound**: Custom `dm.aiff` sound file for chat messages
|
||||
|
||||
DM sound only plays if the user has enabled the `playSoundChat` preference in the main app's chat settings.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `NotificationService.swift` | Main service extension implementation |
|
||||
| `BlueskyNSE.entitlements` | iOS entitlements configuration for App Group access |
|
||||
| `Info.plist` | Extension metadata and configuration |
|
||||
|
||||
### NotificationService.swift
|
||||
|
||||
Contains two main classes:
|
||||
|
||||
**NotificationService**: The main extension class that implements `UNNotificationServiceExtension`
|
||||
- `didReceive(_:withContentHandler:)`: Processes incoming notifications
|
||||
- `serviceExtensionTimeWillExpire()`: Handles timeout scenarios
|
||||
- Mutation methods for modifying notification content
|
||||
|
||||
**NSEUtil**: Singleton utility class for shared state management
|
||||
- Provides shared `UserDefaults` instance for the App Group
|
||||
- Manages serial queue for thread-safe preference access
|
||||
- Helper methods for notification content manipulation
|
||||
|
||||
## Configuration
|
||||
|
||||
### App Group Setup
|
||||
|
||||
The extension requires the `group.app.bsky` App Group to be configured in:
|
||||
1. Main app target capabilities
|
||||
2. Extension target capabilities (defined in `BlueskyNSE.entitlements`)
|
||||
|
||||
### Shared Preferences
|
||||
|
||||
The following preferences are shared between the main app and extension:
|
||||
|
||||
| Preference Key | Type | Purpose |
|
||||
|----------------|------|---------|
|
||||
| `badgeCount` | Int | Current badge count for app icon |
|
||||
| `playSoundChat` | Bool | Whether to play sound for chat notifications |
|
||||
|
||||
These are managed by the `expo-background-notification-handler` module in the main app.
|
||||
|
||||
### Sound Files
|
||||
|
||||
The custom DM sound file (`dm.aiff`) must be included in the extension's bundle. The iOS project configuration handles copying this resource during the build.
|
||||
|
||||
## Platform Support
|
||||
|
||||
- **iOS**: Fully supported (primary platform for this extension)
|
||||
- **Android**: Not applicable (Android uses different notification handling mechanisms)
|
||||
- **Web**: Not applicable (web notifications are handled by browser APIs)
|
||||
|
||||
## Integration with Main App
|
||||
|
||||
The extension coordinates with the main app through:
|
||||
|
||||
1. **expo-background-notification-handler** module: Provides JavaScript API for managing shared preferences
|
||||
2. **App Group shared storage**: Enables data synchronization between processes
|
||||
3. **Push notification payload**: Must include `reason` field to determine notification type
|
||||
|
||||
### Setting User Preferences
|
||||
|
||||
Users can control notification sounds via the Chat Settings screen (`src/screens/Messages/Settings.tsx`):
|
||||
|
||||
```typescript
|
||||
import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
|
||||
const {preferences, setPref} = useBackgroundNotificationPreferences()
|
||||
setPref('playSoundChat', true) // Enable DM sounds
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Time constraints**: Extension must complete processing within ~30 seconds or the system will terminate it
|
||||
2. **Process isolation**: Runs in separate process with limited memory and resources
|
||||
3. **iOS only**: Notification Service Extensions are an iOS-specific feature
|
||||
4. **Concurrent processing**: Multiple notifications may arrive simultaneously, requiring careful state management
|
||||
|
||||
## Best Practices
|
||||
|
||||
When modifying this extension:
|
||||
|
||||
1. Keep processing fast and synchronous when possible
|
||||
2. Use the shared serial queue for any UserDefaults mutations
|
||||
3. Avoid network requests that could cause timeouts
|
||||
4. Always call `contentHandler` with modified content, even on errors
|
||||
5. Test with multiple concurrent notifications to verify thread safety
|
||||
@@ -1,140 +0,0 @@
|
||||
# Share-with-Bluesky
|
||||
|
||||
iOS Share Extension for the Bluesky Social app that enables users to share content from other apps directly to Bluesky.
|
||||
|
||||
## Overview
|
||||
|
||||
This module implements an iOS Share Extension (Action Extension) that appears in the system share sheet when users tap the share button in other iOS apps. It allows sharing text, URLs, images, and videos to create a new Bluesky post.
|
||||
|
||||
## Features
|
||||
|
||||
- Share plain text
|
||||
- Share URLs (web links)
|
||||
- Share images (up to 4 images, supports PNG, JPG, JPEG, GIF, HEIC)
|
||||
- Share videos (single video, supports MOV, MP4, M4V)
|
||||
- Automatic image dimension extraction
|
||||
- Automatic video dimension extraction
|
||||
- App group file sharing for media access
|
||||
|
||||
## Architecture
|
||||
|
||||
### iOS Share Extension
|
||||
|
||||
The extension is implemented as a native iOS Share Extension using Swift. When a user shares content:
|
||||
|
||||
1. The `ShareViewController` receives the shared content from the extension context
|
||||
2. Content is processed based on its type (text, URL, image, or video)
|
||||
3. Media files are copied to a shared App Group container (`group.app.bsky`) for access by the main app
|
||||
4. Image and video dimensions are extracted and encoded into the URI
|
||||
5. The extension constructs a deep link URL with the content encoded in query parameters
|
||||
6. The main Bluesky app is opened with the deep link
|
||||
7. The extension completes and dismisses
|
||||
|
||||
### Deep Link Format
|
||||
|
||||
The extension communicates with the main app using deep links with the `bluesky://` scheme:
|
||||
|
||||
```
|
||||
bluesky://intent/compose?text=<encoded-text>
|
||||
bluesky://intent/compose?imageUris=<uri1>|<width>|<height>,<uri2>|<width>|<height>
|
||||
bluesky://intent/compose?videoUri=<uri>|<width>|<height>
|
||||
```
|
||||
|
||||
The scheme can be customized by setting the `MainAppScheme` key in `Info.plist` to support forks.
|
||||
|
||||
### Main App Integration
|
||||
|
||||
The main app handles these deep links in `src/lib/hooks/useIntentHandler.ts`:
|
||||
|
||||
- Parses the deep link parameters
|
||||
- Validates image/video URIs for security (filters out external URLs)
|
||||
- Opens the composer with the pre-populated content
|
||||
- Supports up to 4 images or 1 video per share
|
||||
|
||||
## Key Files
|
||||
|
||||
### Module Files
|
||||
|
||||
- `ShareViewController.swift` - Main view controller that handles share requests and processes content
|
||||
- `Info.plist` - Extension configuration (activation rules, supported content types)
|
||||
- `Share-with-Bluesky.entitlements` - App group entitlements for shared file access
|
||||
|
||||
### App Integration
|
||||
|
||||
- `src/lib/hooks/useIntentHandler.ts` - Main app hook that handles incoming deep links
|
||||
- `android/app/src/main/AndroidManifest.xml` - Android share intent configuration (lines 57-76)
|
||||
|
||||
## Configuration
|
||||
|
||||
### Supported Content Types
|
||||
|
||||
Defined in `Info.plist` under `NSExtensionActivationRule`:
|
||||
|
||||
- Text: Plain text strings
|
||||
- Web URLs: Up to 1 URL
|
||||
- Images: Up to 10 images
|
||||
- Videos: Up to 1 video
|
||||
|
||||
### App Group
|
||||
|
||||
The extension uses the `group.app.bsky` App Group identifier to share files with the main app. This is configured in:
|
||||
|
||||
- `Share-with-Bluesky.entitlements`
|
||||
- Main app's entitlements file
|
||||
|
||||
### Custom Scheme
|
||||
|
||||
The `MainAppScheme` in `Info.plist` defaults to `bluesky` but can be changed for forks to use a custom URL scheme.
|
||||
|
||||
## Platform Support
|
||||
|
||||
- iOS: Native Share Extension (this module)
|
||||
- Android: Native share intents handled via MainActivity intent filters in AndroidManifest.xml
|
||||
- Web: Not applicable (browser share APIs use different mechanisms)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Image Processing
|
||||
|
||||
When images are shared:
|
||||
|
||||
1. Images are loaded from the extension's temporary directory or as UIImage objects
|
||||
2. Images are converted to JPEG format at maximum quality
|
||||
3. Dimensions are extracted from the UIImage
|
||||
4. Files are saved to the App Group container with unique names
|
||||
5. URIs are formatted as `<file-url>|<width>|<height>`
|
||||
|
||||
### Video Processing
|
||||
|
||||
When videos are shared:
|
||||
|
||||
1. Videos are copied from the source URL to the App Group container
|
||||
2. AVURLAsset is used to extract video track dimensions
|
||||
3. Track dimensions are adjusted for video rotation using preferredTransform
|
||||
4. URI is formatted as `<file-url>|<width>|<height>`
|
||||
|
||||
### Security
|
||||
|
||||
- External URLs in image URIs are filtered out in the main app to prevent potential security issues
|
||||
- Only file:// URLs from the App Group container are accepted
|
||||
- URI format is validated with a regex pattern before processing
|
||||
|
||||
## Development
|
||||
|
||||
This module is built as part of the main Xcode project. The extension target is included in the iOS build configuration.
|
||||
|
||||
To modify the extension:
|
||||
|
||||
1. Open the Xcode project in `/ios`
|
||||
2. Navigate to the Share-with-Bluesky target
|
||||
3. Edit `ShareViewController.swift` for logic changes
|
||||
4. Edit `Info.plist` for configuration changes
|
||||
5. Rebuild the iOS app
|
||||
|
||||
## Limitations
|
||||
|
||||
- Images: Maximum of 4 images per share (limited in main app handler)
|
||||
- Videos: Only 1 video per share
|
||||
- Mixed media: Cannot share images and videos together
|
||||
- File size: No explicit limits, but large files may cause issues
|
||||
- Formats: Only supports common image/video formats listed in constants
|
||||
@@ -1,248 +0,0 @@
|
||||
# Bottom Sheet Expo Module
|
||||
|
||||
A custom Expo module that provides native bottom sheet functionality for iOS and Android, using platform-specific native bottom sheet implementations (UISheetPresentationController on iOS, Material BottomSheetDialog on Android).
|
||||
|
||||
## Overview
|
||||
|
||||
This module wraps native bottom sheet components to provide a React Native interface with cross-platform consistency. It uses native presentation APIs rather than JavaScript-based animations for better performance and native behavior.
|
||||
|
||||
Key features:
|
||||
- Native bottom sheet presentation on iOS and Android
|
||||
- Automatic content height detection (no JS bridge round-trip)
|
||||
- Configurable snap points (hidden, partial, full)
|
||||
- Drag-to-dismiss with prevention controls
|
||||
- Portal-based rendering for proper z-index layering
|
||||
- Edge-to-edge support on modern Android versions
|
||||
- iOS 26+ zoom transition support
|
||||
|
||||
## Platform Support
|
||||
|
||||
- **iOS**: Uses `UISheetPresentationController` (iOS 15+)
|
||||
- **Android**: Uses Material Design `BottomSheetDialog` with `BottomSheetBehavior`
|
||||
- **Web**: Not supported (throws error)
|
||||
|
||||
## Architecture
|
||||
|
||||
### TypeScript Layer
|
||||
|
||||
The module exposes a React component that handles rendering and state management:
|
||||
|
||||
- **BottomSheet.tsx** (Native): Main component wrapping the native view
|
||||
- **BottomSheet.web.tsx** (Web): Stub that throws an error
|
||||
- **BottomSheetNativeComponent.tsx**: React wrapper with portal integration
|
||||
- **BottomSheetPortal.tsx**: Portal system for rendering sheets above app content
|
||||
- **Portal.tsx**: Generic portal implementation for managing component hierarchy
|
||||
|
||||
The component uses a class-based approach to expose imperative methods (`present()`, `dismiss()`, `dismissAll()`).
|
||||
|
||||
### Native Layer
|
||||
|
||||
#### iOS Implementation
|
||||
|
||||
- **BottomSheetModule.swift**: Expo module definition with event handlers and prop bindings
|
||||
- **SheetView.swift**: Main view component that creates and manages `SheetViewController`
|
||||
- Observes content height via KVO (Key-Value Observing) on bounds
|
||||
- Manages sheet lifecycle and state transitions
|
||||
- Implements `UISheetPresentationControllerDelegate` for drag events
|
||||
- **SheetViewController.swift**: UIViewController subclass with sheet presentation
|
||||
- Configures detents (snap points) based on content height
|
||||
- Handles iOS 26+ safe area adjustments for floating sheet style
|
||||
- Animates detent changes when content resizes
|
||||
- **SheetManager.swift**: Singleton that tracks all active sheets with weak references
|
||||
- **Util.swift**: Helper for calculating screen height minus safe area insets
|
||||
|
||||
#### Android Implementation
|
||||
|
||||
- **BottomSheetModule.kt**: Expo module definition mirroring iOS functionality
|
||||
- **BottomSheetView.kt**: Main view component managing Material BottomSheetDialog
|
||||
- Uses `OnLayoutChangeListener` to observe content height natively
|
||||
- Configures `BottomSheetBehavior` for drag and snap behavior
|
||||
- Handles edge-to-edge display across Android versions (API 29-35+)
|
||||
- Preserves status/nav bar appearance from host activity
|
||||
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
|
||||
- Forwards touch events to React Native event system
|
||||
- Updates shadow node size to match window dimensions
|
||||
- Based on React Native's ReactModalHostView pattern
|
||||
- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
|
||||
|
||||
### Content Height Detection
|
||||
|
||||
Both platforms detect content height changes natively without JS bridge round-trips:
|
||||
|
||||
- **iOS**: KVO observation on the content view's `bounds` property
|
||||
- **Android**: `OnLayoutChangeListener` on child views (catches React Native's direct `layout()` calls)
|
||||
|
||||
This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading).
|
||||
|
||||
## Props
|
||||
|
||||
```typescript
|
||||
interface BottomSheetViewProps {
|
||||
children: React.ReactNode
|
||||
|
||||
// Appearance
|
||||
cornerRadius?: number
|
||||
backgroundColor?: ColorValue
|
||||
containerBackgroundColor?: ColorValue
|
||||
|
||||
// Behavior
|
||||
preventDismiss?: boolean // Disable swipe-to-dismiss
|
||||
preventExpansion?: boolean // Lock to initial height (no full-screen)
|
||||
disableDrag?: boolean // Disable drag handle (Android only)
|
||||
fullHeight?: boolean // Start at full screen height
|
||||
|
||||
// Height constraints
|
||||
minHeight?: number // Minimum height in dp
|
||||
maxHeight?: number // Maximum height in dp
|
||||
|
||||
// iOS 26+ transition
|
||||
sourceViewTag?: number // View tag for zoom transition origin
|
||||
|
||||
// Events
|
||||
onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void
|
||||
onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void
|
||||
onStateChange?: (event: BottomSheetStateChangeEvent) => void
|
||||
}
|
||||
```
|
||||
|
||||
## States and Snap Points
|
||||
|
||||
### States
|
||||
- `closed`: Sheet is dismissed
|
||||
- `closing`: Sheet is animating closed
|
||||
- `open`: Sheet is fully visible
|
||||
- `opening`: Sheet is animating open
|
||||
|
||||
### Snap Points
|
||||
- `Hidden` (0): Dismissed
|
||||
- `Partial` (1): Half-expanded / content height
|
||||
- `Full` (2): Expanded to screen height
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```tsx
|
||||
import {BottomSheet, BottomSheetProvider, BottomSheetOutlet} from '@modules/bottom-sheet'
|
||||
|
||||
// In your app root:
|
||||
function App() {
|
||||
return (
|
||||
<BottomSheetProvider>
|
||||
<YourApp />
|
||||
<BottomSheetOutlet />
|
||||
</BottomSheetProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// In a component:
|
||||
function MyComponent() {
|
||||
const sheetRef = useRef<BottomSheet>(null)
|
||||
|
||||
const openSheet = () => {
|
||||
sheetRef.current?.present()
|
||||
}
|
||||
|
||||
const closeSheet = () => {
|
||||
sheetRef.current?.dismiss()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={openSheet} title="Open Sheet" />
|
||||
|
||||
<BottomSheet
|
||||
ref={sheetRef}
|
||||
cornerRadius={16}
|
||||
backgroundColor="white"
|
||||
onStateChange={(e) => console.log(e.nativeEvent.state)}
|
||||
>
|
||||
<View style={{padding: 20}}>
|
||||
<Text>Sheet content</Text>
|
||||
<Button onPress={closeSheet} title="Close" />
|
||||
</View>
|
||||
</BottomSheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Nested Sheets
|
||||
|
||||
The module supports nesting sheets by using `BottomSheetPortalProvider` within sheet content:
|
||||
|
||||
```tsx
|
||||
<BottomSheet ref={outerSheetRef}>
|
||||
<BottomSheetPortalProvider>
|
||||
<Button onPress={() => innerSheetRef.current?.present()} />
|
||||
<BottomSheet ref={innerSheetRef}>
|
||||
<Text>Inner sheet content</Text>
|
||||
</BottomSheet>
|
||||
</BottomSheetPortalProvider>
|
||||
</BottomSheet>
|
||||
```
|
||||
|
||||
### Dismiss All Sheets
|
||||
|
||||
```tsx
|
||||
import {BottomSheetNativeComponent} from '@modules/bottom-sheet'
|
||||
|
||||
BottomSheetNativeComponent.dismissAll()
|
||||
```
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### iOS Specific
|
||||
|
||||
1. **iOS 15 Compatibility**: On iOS 15, custom detents are not available, so the module uses `.medium()` detent and applies extra styling to prevent visual issues.
|
||||
|
||||
2. **iOS 26+ Zoom Transitions**: When `sourceViewTag` is provided on iOS 26+, the sheet zooms from the specified view.
|
||||
|
||||
3. **Detent Selection**: The module automatically chooses between custom detents, `.medium()`, and `.large()` based on content height and screen size.
|
||||
|
||||
### Android Specific
|
||||
|
||||
1. **Edge-to-Edge**: The module handles edge-to-edge display correctly across API levels:
|
||||
- API 35+: Mandatory edge-to-edge
|
||||
- API 30-34: Uses `currentWindowMetrics`
|
||||
- API <30: Uses deprecated `getRealSize()`
|
||||
|
||||
2. **Status/Nav Bar Appearance**: Preserves light/dark appearance from the host activity and reapplies it to the sheet dialog.
|
||||
|
||||
3. **Drag Handling**: On full-height sheets with `preventDismiss`, dragging is disabled to prevent accidental dismissal (since there's no half-expanded snap point to land on).
|
||||
|
||||
4. **Layout Updates During Gestures**: Content height changes are deferred during drag gestures to prevent fighting the user's input.
|
||||
|
||||
### Platform Differences
|
||||
|
||||
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
|
||||
- **disableDrag**: Android-only prop (iOS drag behavior is controlled via `preventDismiss` + `preventExpansion`)
|
||||
- **sourceViewTag**: iOS 26+ only (ignored on Android)
|
||||
|
||||
## Files Reference
|
||||
|
||||
### TypeScript
|
||||
- `index.ts` - Public API exports
|
||||
- `src/BottomSheet.types.ts` - TypeScript type definitions
|
||||
- `src/BottomSheet.tsx` - Native component (re-export)
|
||||
- `src/BottomSheet.web.tsx` - Web stub
|
||||
- `src/BottomSheetNativeComponent.tsx` - Native wrapper with portal integration
|
||||
- `src/BottomSheetNativeComponent.web.tsx` - Web stub for native component
|
||||
- `src/BottomSheetPortal.tsx` - Portal context and providers
|
||||
- `src/lib/Portal.tsx` - Generic portal implementation
|
||||
|
||||
### iOS
|
||||
- `ios/BottomSheetModule.swift` - Module definition
|
||||
- `ios/SheetView.swift` - Main view implementation
|
||||
- `ios/SheetViewController.swift` - View controller for sheet presentation
|
||||
- `ios/SheetManager.swift` - Singleton for tracking active sheets
|
||||
- `ios/Util.swift` - Screen height utility
|
||||
|
||||
### Android
|
||||
- `android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt` - Module definition
|
||||
- `android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt` - Main view implementation
|
||||
- `android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt` - Dialog root view group
|
||||
- `android/src/main/java/expo/modules/bottomsheet/SheetManager.kt` - Sheet tracking singleton
|
||||
|
||||
### Configuration
|
||||
- `expo-module.config.json` - Expo module configuration
|
||||
@@ -25,8 +25,8 @@ class BottomSheetModule : Module() {
|
||||
view.dismiss()
|
||||
}
|
||||
|
||||
Prop("fullHeight") { view: BottomSheetView, prop: Boolean ->
|
||||
view.fullHeight = prop
|
||||
AsyncFunction("updateLayout") { view: BottomSheetView ->
|
||||
view.updateLayout()
|
||||
}
|
||||
|
||||
Prop("disableDrag") { view: BottomSheetView, prop: Boolean ->
|
||||
@@ -48,8 +48,6 @@ class BottomSheetModule : Module() {
|
||||
Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
|
||||
view.preventExpansion = prop
|
||||
}
|
||||
|
||||
Prop("sourceViewTag") { _: BottomSheetView, _: Int? -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import android.view.ViewStructure
|
||||
import android.view.Window
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.allViews
|
||||
import com.facebook.react.bridge.LifecycleEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
@@ -31,30 +34,11 @@ class BottomSheetView(
|
||||
|
||||
private lateinit var dialogRootViewGroup: DialogRootViewGroup
|
||||
private var eventDispatcher: EventDispatcher? = null
|
||||
private var isKeyboardVisible: Boolean = false
|
||||
|
||||
// Native content height observation (eliminates JS bridge round-trip)
|
||||
private var contentLayoutListener: OnLayoutChangeListener? = null
|
||||
private var observedChildren: List<View> = emptyList()
|
||||
private var lastObservedContentHeight: Float = 0f
|
||||
private var pendingLayoutUpdate: Boolean = false
|
||||
|
||||
private val screenHeight: Float =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
||||
context.resources.displayMetrics.heightPixels.toFloat()
|
||||
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
wm.currentWindowMetrics.bounds.height().toFloat()
|
||||
} else {
|
||||
// API < 30: currentWindowMetrics not available, use getRealSize
|
||||
// which includes system bars (heightPixels may exclude them)
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
val size = android.graphics.Point()
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getRealSize(size)
|
||||
size.y.toFloat()
|
||||
}
|
||||
private val screenHeight =
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
|
||||
private fun getNavigationBarHeight(): Int {
|
||||
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
@@ -80,15 +64,8 @@ class BottomSheetView(
|
||||
set(value) {
|
||||
field = value
|
||||
this.dialog?.setCancelable(!value)
|
||||
// Full-height sheets have no half-expanded snap point, so any drag
|
||||
// would dismiss. Disable dragging when dismiss is prevented.
|
||||
if (fullHeight) {
|
||||
this.setDraggable(!value && !disableDrag)
|
||||
}
|
||||
}
|
||||
|
||||
var fullHeight = false
|
||||
|
||||
var preventExpansion = false
|
||||
|
||||
var minHeight = 0f
|
||||
@@ -152,7 +129,6 @@ class BottomSheetView(
|
||||
}
|
||||
|
||||
private fun destroy() {
|
||||
this.stopObservingContentHeight()
|
||||
this.isClosing = false
|
||||
this.isOpen = false
|
||||
this.dialog = null
|
||||
@@ -217,40 +193,31 @@ class BottomSheetView(
|
||||
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
bottomSheet?.let {
|
||||
it.setBackgroundColor(0)
|
||||
it.elevation = 0f
|
||||
|
||||
val behavior = BottomSheetBehavior.from(it)
|
||||
behavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
behavior.isFitToContents = true
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.skipCollapsed = true
|
||||
behavior.isDraggable = true
|
||||
behavior.isHideable = true
|
||||
if (fullHeight) {
|
||||
behavior.isFitToContents = false
|
||||
behavior.expandedOffset = getStatusBarHeight()
|
||||
|
||||
if (preventExpansion) {
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
} else {
|
||||
behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt()
|
||||
}
|
||||
|
||||
val targetHeight = this.getTargetHeight()
|
||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||
val shouldBeExpanded = targetHeight >= availableHeight
|
||||
|
||||
if (shouldBeExpanded) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
this.selectedSnapPoint = 2
|
||||
} else if (preventExpansion) {
|
||||
behavior.isFitToContents = true
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
} else {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
this.selectedSnapPoint = 1
|
||||
} else {
|
||||
behavior.isFitToContents = false
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.expandedOffset = getStatusBarHeight()
|
||||
|
||||
val targetHeight = this.getTargetHeight()
|
||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||
val shouldBeExpanded = targetHeight >= availableHeight
|
||||
|
||||
if (shouldBeExpanded) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
this.selectedSnapPoint = 2
|
||||
} else {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
this.selectedSnapPoint = 1
|
||||
}
|
||||
}
|
||||
|
||||
behavior.addBottomSheetCallback(
|
||||
@@ -259,23 +226,12 @@ class BottomSheetView(
|
||||
bottomSheet: View,
|
||||
newState: Int,
|
||||
) {
|
||||
if (newState == BottomSheetBehavior.STATE_EXPANDED && preventExpansion) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
return
|
||||
}
|
||||
when (newState) {
|
||||
BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
|
||||
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
|
||||
}
|
||||
// Apply deferred layout update after gesture completes
|
||||
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
|
||||
newState != BottomSheetBehavior.STATE_SETTLING &&
|
||||
pendingLayoutUpdate) {
|
||||
pendingLayoutUpdate = false
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(
|
||||
@@ -289,14 +245,25 @@ class BottomSheetView(
|
||||
this.isOpening = true
|
||||
dialog.show()
|
||||
this.dialog = dialog
|
||||
if (!fullHeight) {
|
||||
this.startObservingContentHeight()
|
||||
}
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(dialogRootViewGroup) { view, insets ->
|
||||
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
||||
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) }
|
||||
|
||||
val wasKeyboardVisible = isKeyboardVisible
|
||||
isKeyboardVisible = imeVisible
|
||||
|
||||
if (imeVisible && behavior?.state == BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (!imeVisible && wasKeyboardVisible) {
|
||||
updateLayout()
|
||||
}
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLayout() {
|
||||
if (fullHeight) return
|
||||
val dialog = this.dialog ?: return
|
||||
val contentHeight = this.getContentHeight()
|
||||
|
||||
@@ -307,34 +274,21 @@ class BottomSheetView(
|
||||
|
||||
val oldRatio = behavior.halfExpandedRatio
|
||||
val newRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.halfExpandedRatio = newRatio
|
||||
|
||||
if (preventExpansion) {
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
}
|
||||
|
||||
val targetHeight = this.getTargetHeight()
|
||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||
val shouldBeExpanded = targetHeight >= availableHeight
|
||||
|
||||
// Don't update during user gestures — defer until the gesture completes.
|
||||
if (currentState == BottomSheetBehavior.STATE_DRAGGING) {
|
||||
pendingLayoutUpdate = true
|
||||
return
|
||||
}
|
||||
|
||||
behavior.halfExpandedRatio = newRatio
|
||||
|
||||
if (preventExpansion) {
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
it.requestLayout()
|
||||
}
|
||||
|
||||
// During settling (programmatic animation from our own state change),
|
||||
// redirect the animation to the new position if the ratio changed.
|
||||
if (currentState == BottomSheetBehavior.STATE_SETTLING) {
|
||||
if (oldRatio != newRatio) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
if (isKeyboardVisible) {
|
||||
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
|
||||
} else if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
@@ -345,77 +299,21 @@ class BottomSheetView(
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
val dialog = this.dialog ?: return
|
||||
// Mark as closing so the content observer doesn't fight the dismiss
|
||||
// animation by calling updateLayout() mid-hide.
|
||||
this.isClosing = true
|
||||
// Temporarily make cancelable so cancel() works — cancel() gives the
|
||||
// slide-out animation, while dismiss() does a plain fade.
|
||||
dialog.setCancelable(true)
|
||||
dialog.cancel()
|
||||
}
|
||||
|
||||
// Observe each direct child of innerView via OnLayoutChangeListener so that
|
||||
// height updates are detected purely on the native side. We use OnLayoutChangeListener
|
||||
// (not OnGlobalLayoutListener) because React Native calls view.layout() directly
|
||||
// via Yoga, bypassing requestLayout()/performTraversals(). OnLayoutChangeListener
|
||||
// fires from setFrame() which IS called by layout(), so it catches RN updates.
|
||||
private fun startObservingContentHeight() {
|
||||
stopObservingContentHeight()
|
||||
|
||||
val innerViewGroup = this.innerView as? ViewGroup ?: return
|
||||
|
||||
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
||||
val newHeight = bottom - top
|
||||
val oldHeight = oldBottom - oldTop
|
||||
if (newHeight != oldHeight) {
|
||||
val contentHeight = getContentHeight()
|
||||
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
||||
lastObservedContentHeight = contentHeight
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val children = mutableListOf<View>()
|
||||
for (i in 0 until innerViewGroup.childCount) {
|
||||
val child = innerViewGroup.getChildAt(i)
|
||||
child.addOnLayoutChangeListener(listener)
|
||||
children.add(child)
|
||||
}
|
||||
|
||||
this.contentLayoutListener = listener
|
||||
this.observedChildren = children
|
||||
|
||||
// Pick up current height if content is already laid out
|
||||
val contentHeight = getContentHeight()
|
||||
if (contentHeight > 0 && contentHeight != lastObservedContentHeight) {
|
||||
lastObservedContentHeight = contentHeight
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopObservingContentHeight() {
|
||||
contentLayoutListener?.let { listener ->
|
||||
observedChildren.forEach { it.removeOnLayoutChangeListener(listener) }
|
||||
}
|
||||
contentLayoutListener = null
|
||||
observedChildren = emptyList()
|
||||
lastObservedContentHeight = 0f
|
||||
this.dialog?.dismiss()
|
||||
}
|
||||
|
||||
// Util
|
||||
|
||||
private fun getContentHeight(): Float {
|
||||
val innerView = this.innerView as? ViewGroup ?: return 0f
|
||||
// Use the tallest direct child's height. The handle is absolutely positioned
|
||||
// (overlaps the content), so summing would double-count its height as padding.
|
||||
var maxChildHeight = 0f
|
||||
for (i in 0 until innerView.childCount) {
|
||||
val h = innerView.getChildAt(i).height.toFloat()
|
||||
if (h > maxChildHeight) maxChildHeight = h
|
||||
val innerView = this.innerView ?: return 0f
|
||||
var index = 0
|
||||
innerView.allViews.forEach {
|
||||
if (index == 1) {
|
||||
return it.height.toFloat()
|
||||
}
|
||||
index++
|
||||
}
|
||||
return maxChildHeight
|
||||
return 0f
|
||||
}
|
||||
|
||||
private fun getTargetHeight(): Float {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="ThemeOverlay.Material3.DayNight.BottomSheetDialog">
|
||||
<!-- Enable edge-to-edge, matching react-native-edge-to-edge's setup -->
|
||||
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="Theme.Material3.DayNight.BottomSheetDialog">
|
||||
<!-- Enable edge-to-edge -->
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowIsFloating">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:fitsSystemWindows">false</item>
|
||||
<item name="enableEdgeToEdge">true</item>
|
||||
|
||||
<!-- Configure bottom sheet to respect system window insets -->
|
||||
@@ -18,6 +16,5 @@
|
||||
<item name="paddingLeftSystemWindowInsets">true</item>
|
||||
<item name="paddingRightSystemWindowInsets">true</item>
|
||||
<item name="paddingTopSystemWindowInsets">false</item>
|
||||
<item name="backgroundTint">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -19,8 +19,8 @@ public class BottomSheetModule: Module {
|
||||
view.dismiss()
|
||||
}
|
||||
|
||||
Prop("fullHeight") { (view: SheetView, prop: Bool) in
|
||||
view.fullHeight = prop
|
||||
AsyncFunction("updateLayout") { (view: SheetView) in
|
||||
view.updateLayout()
|
||||
}
|
||||
|
||||
Prop("cornerRadius") { (view: SheetView, prop: Float) in
|
||||
@@ -42,10 +42,6 @@ public class BottomSheetModule: Module {
|
||||
Prop("preventExpansion") { (view: SheetView, prop: Bool) in
|
||||
view.preventExpansion = prop
|
||||
}
|
||||
|
||||
Prop("sourceViewTag") { (view: SheetView, prop: Int?) in
|
||||
view.sourceViewTag = prop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||