Compare commits
114 Commits
patch-version
...
lbl
| Author | SHA1 | Date | |
|---|---|---|---|
| 238b00d193 | |||
| bf69672674 | |||
| 5371114609 | |||
| 4c0213b3e9 | |||
| 02a05d9948 | |||
| 4c142a86b4 | |||
| f3153b8acb | |||
| a59fb91a99 | |||
| c8568e3004 | |||
| 09111ef272 | |||
| b9ed94c7f4 | |||
| 872fcd1d20 | |||
| d496b5bdbf | |||
| 4879a07701 | |||
| 28876a4b23 | |||
| 1e8a96e297 | |||
| 815a1e4c44 | |||
| 4013855c10 | |||
| e46b78eb85 | |||
| 94fd12ff49 | |||
| a181b0b935 | |||
| a301e31f76 | |||
| 502d660eb4 | |||
| 6840fbb237 | |||
| 0b08128e4a | |||
| aca89d4aea | |||
| a0ff9b52aa | |||
| 5c59ec14c2 | |||
| 94bc677ef5 | |||
| 148bfa803d | |||
| 0004e27427 | |||
| ceb51c7e8f | |||
| 22a7199436 | |||
| fa08428428 | |||
| 93af013210 | |||
| a0c644acfe | |||
| a15658cb66 | |||
| 898065d0a2 | |||
| faa5f40c53 | |||
| b6185039f7 | |||
| 87da619aaa | |||
| 8d1f97b5ff | |||
| 4a7fa07e87 | |||
| 0897a692da | |||
| c5900034a4 | |||
| 0874d9602a | |||
| 1289707b17 | |||
| 758cb731f0 | |||
| 5130f932ed | |||
| 8dfdee80bb | |||
| df9909baa3 | |||
| 7a041da3a8 | |||
| ffbcbf814e | |||
| 98fe7edbc8 | |||
| 5cb49314d0 | |||
| 318b29d3fd | |||
| 4db3ccbec7 | |||
| 3d7fce563e | |||
| fffcb609c6 | |||
| 07d667dba8 | |||
| a9675b372e | |||
| 036bbfbaf9 | |||
| 7c124a8705 | |||
| 78220e29d9 | |||
| c2d6fb82f3 | |||
| a84ba121fa | |||
| 152bc3c1ec | |||
| 55a40c2436 | |||
| ac2c2a9a1d | |||
| 5ceaee5793 | |||
| 7d1ebf6a02 | |||
| 1e688dee24 | |||
| c265e0fb96 | |||
| 5dc4f0450c | |||
| a48e64e72a | |||
| 412b107420 | |||
| 1b8719293c | |||
| dba6f4bb1f | |||
| 6a5770ee89 | |||
| 7daf7a703e | |||
| 6fcf97979b | |||
| 951a8cff3c | |||
| c4785ef96e | |||
| f6f253b4c9 | |||
| 3cfac98f95 | |||
| 52429b651f | |||
| 0e658dbdfd | |||
| d3c44ebc5b | |||
| 950e2e5f74 | |||
| 57d91373f7 | |||
| 6d8124c7ea | |||
| aa86a8619c | |||
| c612fca32a | |||
| 4b7bda705e | |||
| 799205e6c2 | |||
| 5bff031e72 | |||
| 9cea7f0393 | |||
| dcc8b35146 | |||
| cb54c9833e | |||
| 1f1c1cfbcd | |||
| 6cbd9fbc45 | |||
| c7ba0e4f66 | |||
| 4b94a8a8f2 | |||
| c402ae741f | |||
| b2a6281b36 | |||
| 597e3c1abd | |||
| 5e1c262edc | |||
| 810e85c073 | |||
| d16c80d928 | |||
| e7212f9904 | |||
| 490e381270 | |||
| 26fb0a7f17 | |||
| 94d7bc986f | |||
| 8cb55adf8c |
@@ -15,6 +15,7 @@ module.exports = {
|
||||
'simple-import-sort',
|
||||
'bsky-internal',
|
||||
'eslint-plugin-react-compiler',
|
||||
'import',
|
||||
],
|
||||
rules: {
|
||||
'react/no-unescaped-entities': 0,
|
||||
@@ -81,6 +82,16 @@ module.exports = {
|
||||
],
|
||||
'simple-import-sort/exports': 'error',
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{argsIgnorePattern: '^_', varsIgnorePattern: '^_'},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
|
||||
],
|
||||
'import/consistent-type-specifier-style': ['warn', 'prefer-inline'],
|
||||
},
|
||||
ignorePatterns: [
|
||||
'**/__mocks__/*.ts',
|
||||
|
||||
@@ -45,7 +45,10 @@ jobs:
|
||||
|
||||
- name: Set outputs
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
@@ -60,3 +63,6 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER=${{ steps.vars.outputs.sha_short }}
|
||||
SENTRY_DIST=${{ steps.vars.outputs.SENTRY_DIST }}
|
||||
SENTRY_RELEASE=${{ steps.vars.outputs.SENTRY_RELEASE }}
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
@@ -56,7 +56,10 @@ jobs:
|
||||
run: yarn install
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build
|
||||
run: yarn intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
@@ -67,8 +70,14 @@ jobs:
|
||||
echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: Setup Sentry vars for build-time injection
|
||||
id: sentry
|
||||
run: |
|
||||
echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
|
||||
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
|
||||
|
||||
- name: ✍️ Rename Testflight bundle
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
@@ -130,7 +139,7 @@ jobs:
|
||||
|
||||
- name: 🏗️ Build Production APK
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
run: yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
|
||||
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
|
||||
|
||||
- name: 🚀 Upload Production APK Artifact
|
||||
id: upload-artifact-production-apk
|
||||
|
||||
@@ -69,7 +69,10 @@ jobs:
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build
|
||||
run: yarn intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
@@ -79,8 +82,14 @@ jobs:
|
||||
echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: Setup Sentry vars for build-time injection
|
||||
id: sentry
|
||||
run: |
|
||||
echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive
|
||||
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p ios --non-interactive --path build.ipa
|
||||
|
||||
@@ -75,8 +75,11 @@ jobs:
|
||||
- name: Prettier check
|
||||
run: yarn prettier --check .
|
||||
|
||||
- name: Check & compile i18n
|
||||
run: yarn intl:build
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
- name: Type check
|
||||
run: yarn typecheck
|
||||
@@ -107,9 +110,15 @@ jobs:
|
||||
echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: Setup Sentry vars for build-time injection
|
||||
id: sentry
|
||||
run: |
|
||||
echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 🏗️ Create Bundle
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
run: EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
|
||||
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
|
||||
|
||||
- name: 📦 Package Bundle and 🚀 Deploy
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
|
||||
+27
-2
@@ -4,21 +4,45 @@ WORKDIR /usr/src/social-app
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
#
|
||||
# Node
|
||||
#
|
||||
ENV NODE_VERSION=20
|
||||
ENV NVM_DIR=/usr/share/nvm
|
||||
|
||||
#
|
||||
# Go
|
||||
#
|
||||
ENV GODEBUG="netdns=go"
|
||||
ENV GOOS="linux"
|
||||
ENV GOARCH="amd64"
|
||||
ENV CGO_ENABLED=1
|
||||
ENV GOEXPERIMENT="loopvar"
|
||||
|
||||
#
|
||||
# Expo
|
||||
#
|
||||
ARG EXPO_PUBLIC_BUNDLE_IDENTIFIER
|
||||
ENV EXPO_PUBLIC_BUNDLE_IDENTIFIER=${EXPO_PUBLIC_BUNDLE_IDENTIFIER:-dev}
|
||||
|
||||
# The latest git hash of the preview branch on render.com
|
||||
ARG RENDER_GIT_COMMIT
|
||||
|
||||
#
|
||||
# Sentry
|
||||
#
|
||||
ARG SENTRY_AUTH_TOKEN
|
||||
ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN:-unknown}
|
||||
# Will fall back to package.json#version, but this is handled elsewhere
|
||||
ARG SENTRY_RELEASE
|
||||
ENV SENTRY_RELEASE=$SENTRY_RELEASE
|
||||
ARG SENTRY_DIST
|
||||
# Default to RENDER_GIT_COMMIT if not set by GitHub workflows
|
||||
ENV SENTRY_DIST=${SENTRY_DIST:-$RENDER_GIT_COMMIT}
|
||||
|
||||
#
|
||||
# Copy everything into the container
|
||||
#
|
||||
COPY . .
|
||||
|
||||
#
|
||||
@@ -38,8 +62,9 @@ RUN \. "$NVM_DIR/nvm.sh" && \
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env && \
|
||||
npm install --global yarn && \
|
||||
yarn && \
|
||||
yarn intl:build && \
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER EXPO_PUBLIC_BUNDLE_DATE=$() yarn build-web
|
||||
yarn intl:build 2>&1 | tee i18n.log && \
|
||||
if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compile errors!\n\n"; fi && \
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER EXPO_PUBLIC_BUNDLE_DATE=$() SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN SENTRY_RELEASE=$SENTRY_RELEASE SENTRY_DIST=$SENTRY_DIST yarn build-web
|
||||
|
||||
# DEBUG
|
||||
RUN find ./bskyweb/static && find ./web-build/static
|
||||
|
||||
@@ -17,7 +17,6 @@ appId: xyz.blueskyweb.app
|
||||
- inputText: "Post with an image"
|
||||
- tapOn:
|
||||
id: "openGalleryBtn"
|
||||
- tapOn: "Allow Full Access"
|
||||
- tapOn: "Content warnings"
|
||||
- tapOn: "Porn"
|
||||
- tapOn:
|
||||
|
||||
+2
-22
@@ -36,9 +36,6 @@ module.exports = function (config) {
|
||||
const UPDATES_ENABLED = !!UPDATES_CHANNEL
|
||||
|
||||
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
|
||||
const SENTRY_DIST = `${PLATFORM}.${VERSION}.${IS_TESTFLIGHT ? 'tf' : ''}${
|
||||
IS_DEV ? 'dev' : ''
|
||||
}`
|
||||
|
||||
return {
|
||||
expo: {
|
||||
@@ -204,9 +201,8 @@ module.exports = function (config) {
|
||||
'@sentry/react-native/expo',
|
||||
{
|
||||
organization: 'blueskyweb',
|
||||
project: 'react-native',
|
||||
release: VERSION,
|
||||
dist: SENTRY_DIST,
|
||||
project: 'app',
|
||||
url: 'https://sentry.io',
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -407,22 +403,6 @@ module.exports = function (config) {
|
||||
projectId: '55bd077a-d905-4184-9c7f-94789ba0f302',
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
postPublish: [
|
||||
/*
|
||||
* @see https://docs.expo.dev/guides/using-sentry/#app-configuration
|
||||
*/
|
||||
{
|
||||
file: './postHooks/uploadSentrySourcemapsPostHook',
|
||||
config: {
|
||||
organization: 'blueskyweb',
|
||||
project: 'react-native',
|
||||
release: VERSION,
|
||||
dist: SENTRY_DIST,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.158 2.879c.584-.835 1.757-1.137 2.673-.507.951.654 2.597 1.92 4.013 3.694S20.5 10.194 20.5 13c0 4.997-3.752 9-8.5 9s-8.5-4.003-8.5-9c0-2.035.874-4.636 2.578-6.712.746-.91 2.034-.855 2.786-.133l2.294-3.276Zm-3.04 15.758C6.538 17.386 5.5 15.37 5.5 13c0-1.511.666-3.616 2.042-5.342.87.797 2.254.653 2.939-.325l2.286-3.265c.871.606 2.299 1.723 3.514 3.246C17.53 8.879 18.5 10.804 18.5 13c0 2.369-1.038 4.386-2.618 5.637q.117-.518.118-1.061c0-2.601-2.038-4.382-2.911-5.04a1.8 1.8 0 0 0-2.177 0C10.038 13.195 8 14.976 8 17.577q0 .543.118 1.061ZM12 14.222c-.825.648-2 1.859-2 3.354C10 19.043 11.016 20 12 20s2-.957 2-2.424c0-1.495-1.175-2.706-2-3.354Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 791 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M15 7a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9.414L14.414 15a2 2 0 0 1-2.828 0L9 12.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L7.586 11a2 2 0 0 1 2.828 0L13 13.586 18.586 8H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 336 B |
@@ -68,7 +68,9 @@ function scan(node = document) {
|
||||
if (ref_url.startsWith('http')) {
|
||||
searchParams.set('ref_url', encodeURIComponent(ref_url))
|
||||
}
|
||||
searchParams.set('colorMode', embed.dataset.blueskyColorMode || 'system')
|
||||
if (embed.dataset.blueskyEmbedColorMode) {
|
||||
searchParams.set('colorMode', embed.dataset.blueskyEmbedColorMode)
|
||||
}
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.setAttribute('data-bluesky-id', id)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:root {
|
||||
#app {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/common": "^0.4.0",
|
||||
"@types/escape-html": "^1.0.4",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"escape-html": "^1.0.3",
|
||||
"express": "^4.19.2",
|
||||
"http-terminator": "^3.2.0",
|
||||
"kysely": "^0.27.3",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {AppContext} from '../context.js'
|
||||
import {default as createShortLink} from './createShortLink.js'
|
||||
import {default as health} from './health.js'
|
||||
import {default as redirect} from './redirect.js'
|
||||
import {default as root} from './root.js'
|
||||
import {default as shortLink} from './shortLink.js'
|
||||
import {default as siteAssociation} from './siteAssociation.js'
|
||||
|
||||
@@ -14,6 +15,7 @@ export default function (ctx: AppContext, app: Express) {
|
||||
app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
|
||||
app = redirect(ctx, app) // GET /redirect
|
||||
app = createShortLink(ctx, app) // POST /link
|
||||
app = root(ctx, app) // GET / (redirect to bsky.app on root)
|
||||
app = shortLink(ctx, app) // GET /:linkId (should go last due to permissive matching)
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {DAY, SECOND} from '@atproto/common'
|
||||
import escapeHTML from 'escape-html'
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
@@ -20,7 +21,6 @@ export default function (ctx: AppContext, app: Express) {
|
||||
typeof link === 'string',
|
||||
'express guarantees link query parameter is a string',
|
||||
)
|
||||
link = decodeURIComponent(link)
|
||||
|
||||
let url: URL | undefined
|
||||
try {
|
||||
@@ -40,8 +40,13 @@ export default function (ctx: AppContext, app: Express) {
|
||||
}
|
||||
|
||||
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
|
||||
res.setHeader('Location', url.href)
|
||||
return res.status(301).end()
|
||||
res.type('html')
|
||||
res.status(200)
|
||||
|
||||
const escaped = escapeHTML(url.href)
|
||||
return res.send(
|
||||
`<html><head><meta http-equiv="refresh" content="0; URL='${escaped}'" /><style>:root { color-scheme: light dark; }</style></head></html>`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/',
|
||||
handler(async (_req, res) => {
|
||||
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||
return res.status(301).end()
|
||||
}),
|
||||
)
|
||||
}
|
||||
+6
-1
@@ -69,6 +69,11 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/escape-html@^1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-1.0.4.tgz#dc7c166b76c7b03b27e32f80edf01d91eb5d9af2"
|
||||
integrity sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==
|
||||
|
||||
"@types/node@*":
|
||||
version "20.14.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18"
|
||||
@@ -276,7 +281,7 @@ es-errors@^1.3.0:
|
||||
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
|
||||
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
|
||||
|
||||
escape-html@~1.0.3:
|
||||
escape-html@^1.0.3, escape-html@~1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
|
||||
|
||||
@@ -275,6 +275,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/settings/account", server.WebGeneric)
|
||||
e.GET("/settings/privacy-and-security", server.WebGeneric)
|
||||
e.GET("/settings/content-and-media", server.WebGeneric)
|
||||
e.GET("/settings/interests", server.WebGeneric)
|
||||
e.GET("/settings/about", server.WebGeneric)
|
||||
e.GET("/settings/app-icon", server.WebGeneric)
|
||||
e.GET("/sys/debug", server.WebGeneric)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Internationalization
|
||||
|
||||
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/u/projects/1) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
|
||||
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
|
||||
|
||||
## Tools
|
||||
|
||||
- We use Crowdin to manage translations.
|
||||
- Bluesky Crowdin: https://bluesky.crowdin.com/u/projects/1
|
||||
- Bluesky Crowdin: https://bluesky.crowdin.com/bluesky-social
|
||||
- Introduction to Crowdin: https://support.crowdin.com/for-translators/
|
||||
- We use Lingui to implement translations. You can find the documentation [here](https://lingui.dev/).
|
||||
|
||||
@@ -15,7 +15,7 @@ Much of the app is translated by community contributions. (We <3 our translators
|
||||
|
||||
### Using Crowdin
|
||||
|
||||
[Crowdin](https://bluesky.crowdin.com/u/projects/1) is our primary tool for managing translations. There are two roles:
|
||||
[Crowdin](https://bluesky.crowdin.com/bluesky-social) is our primary tool for managing translations. There are two roles:
|
||||
|
||||
- **Proof-readers**. Can create new translations and approve submitted translations.
|
||||
- **Translators**. Can create new translations.
|
||||
@@ -32,7 +32,7 @@ Please treat everyone with respect. Proof-readers are given final say on transla
|
||||
|
||||
### Adding a new language
|
||||
|
||||
You can request a new language be added to the project by clicking **Request New Language** on Crowdin or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
|
||||
You can request a new language be added to the project by clicking **Request New Language** on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
|
||||
|
||||
Please only request a new language when you are certain you will be able to contribute a substantive portion of translations for the language.
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const {getDefaultConfig} = require('expo/metro-config')
|
||||
const cfg = getDefaultConfig(__dirname)
|
||||
const {getSentryExpoConfig} = require('@sentry/react-native/metro')
|
||||
const cfg = getSentryExpoConfig(__dirname)
|
||||
|
||||
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
|
||||
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
|
||||
|
||||
+50
-46
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.99.0",
|
||||
"version": "1.100.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -50,14 +50,15 @@
|
||||
"intl:push": "crowdin push translations --verbose -b main",
|
||||
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
|
||||
"update-extensions": "bash scripts/updateExtensions.sh",
|
||||
"export": "npx expo export",
|
||||
"export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps",
|
||||
"upload-native-sourcemaps": "npx sentry-expo-upload-sourcemaps dist",
|
||||
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
|
||||
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 yarn build-web",
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web",
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.14.7",
|
||||
"@atproto/api": "^0.14.21",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
@@ -85,12 +86,12 @@
|
||||
"@radix-ui/react-focus-scope": "^1.1.2",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-menu/menu": "^1.1.7",
|
||||
"@react-native-picker/picker": "2.10.2",
|
||||
"@react-native-picker/picker": "2.10.3",
|
||||
"@react-navigation/bottom-tabs": "^6.5.20",
|
||||
"@react-navigation/drawer": "^6.6.15",
|
||||
"@react-navigation/native": "^6.1.17",
|
||||
"@react-navigation/native-stack": "^6.9.26",
|
||||
"@sentry/react-native": "5.24.3",
|
||||
"@sentry/react-native": "~6.10.0",
|
||||
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||
"@tanstack/react-query": "^5.8.1",
|
||||
"@tanstack/react-query-persist-client": "^5.25.0",
|
||||
@@ -122,35 +123,35 @@
|
||||
"emoji-mart": "^5.5.2",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "~52.0.19",
|
||||
"expo-application": "^6.0.1",
|
||||
"expo": "~52.0.42",
|
||||
"expo-application": "~6.0.2",
|
||||
"expo-blur": "^14.0.3",
|
||||
"expo-build-properties": "^0.13.1",
|
||||
"expo-camera": "~16.0.7",
|
||||
"expo-clipboard": "^7.0.0",
|
||||
"expo-dev-client": "^5.0.4",
|
||||
"expo-device": "~7.0.1",
|
||||
"expo-file-system": "^18.0.6",
|
||||
"expo-font": "~13.0.1",
|
||||
"expo-haptics": "^14.0.0",
|
||||
"expo-image": "~2.0.3",
|
||||
"expo-image-manipulator": "^13.0.5",
|
||||
"expo-image-picker": "~16.0.3",
|
||||
"expo-linear-gradient": "^14.0.1",
|
||||
"expo-linking": "^7.0.3",
|
||||
"expo-localization": "~16.0.0",
|
||||
"expo-media-library": "~17.0.3",
|
||||
"expo-navigation-bar": "~4.0.4",
|
||||
"expo-notifications": "~0.29.11",
|
||||
"expo-screen-orientation": "^8.0.2",
|
||||
"expo-sharing": "^13.0.0",
|
||||
"expo-splash-screen": "~0.29.18",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"expo-system-ui": "^4.0.4",
|
||||
"expo-task-manager": "~12.0.3",
|
||||
"expo-updates": "~0.26.10",
|
||||
"expo-video": "^2.0.5",
|
||||
"expo-web-browser": "~14.0.1",
|
||||
"expo-build-properties": "~0.13.2",
|
||||
"expo-camera": "~16.0.18",
|
||||
"expo-clipboard": "~7.0.1",
|
||||
"expo-dev-client": "~5.0.19",
|
||||
"expo-device": "~7.0.3",
|
||||
"expo-file-system": "~18.0.12",
|
||||
"expo-font": "~13.0.4",
|
||||
"expo-haptics": "~14.0.1",
|
||||
"expo-image": "~2.0.7",
|
||||
"expo-image-manipulator": "~13.0.6",
|
||||
"expo-image-picker": "~16.0.6",
|
||||
"expo-linear-gradient": "~14.0.2",
|
||||
"expo-linking": "~7.0.5",
|
||||
"expo-localization": "~16.0.1",
|
||||
"expo-media-library": "~17.0.6",
|
||||
"expo-navigation-bar": "~4.0.9",
|
||||
"expo-notifications": "~0.29.14",
|
||||
"expo-screen-orientation": "~8.0.4",
|
||||
"expo-sharing": "~13.0.1",
|
||||
"expo-splash-screen": "~0.29.22",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "~4.0.9",
|
||||
"expo-task-manager": "~12.0.6",
|
||||
"expo-updates": "~0.27.4",
|
||||
"expo-video": "~2.0.6",
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
"hls.js": "^1.5.11",
|
||||
@@ -168,30 +169,31 @@
|
||||
"patch-package": "^6.5.1",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"psl": "^1.9.0",
|
||||
"react": "18.2.0",
|
||||
"react": "18.3.1",
|
||||
"react-compiler-runtime": "19.0.0-beta-a7bf2bd-20241110",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-image-crop": "^11.0.7",
|
||||
"react-keyed-flatten-children": "^3.0.0",
|
||||
"react-native": "0.76.6",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-compressor": "1.10.3",
|
||||
"react-native-date-picker": "^5.0.7",
|
||||
"react-native-drawer-layout": "^4.1.1",
|
||||
"react-native-emoji-popup": "^0.1.2",
|
||||
"react-native-gesture-handler": "2.20.2",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-image-crop-picker": "^0.41.6",
|
||||
"react-native-ios-context-menu": "^1.15.3",
|
||||
"react-native-keyboard-controller": "^1.14.5",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.7.0",
|
||||
"react-native-pager-view": "6.5.1",
|
||||
"react-native-picker-select": "^9.3.1",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
"react-native-reanimated": "3.17.0-nightly-20241211-17e89ca24",
|
||||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-root-siblings": "^4.1.1",
|
||||
"react-native-safe-area-context": "4.14.0",
|
||||
"react-native-screens": "~4.3.0",
|
||||
"react-native-svg": "^15.8.0",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
"react-native-svg": "15.8.0",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
@@ -199,7 +201,7 @@
|
||||
"react-native-vision-camera": "^4.6.3",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-web-webview": "^1.0.2",
|
||||
"react-native-webview": "13.12.3",
|
||||
"react-native-webview": "13.12.5",
|
||||
"react-remove-scroll-bar": "^2.3.6",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
@@ -221,8 +223,9 @@
|
||||
"@lingui/cli": "^4.14.1",
|
||||
"@lingui/macro": "^4.14.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@react-native/eslint-config": "^0.76.2",
|
||||
"@react-native/typescript-config": "^0.76.1",
|
||||
"@react-native/eslint-config": "^0.76.9",
|
||||
"@react-native/typescript-config": "^0.76.9",
|
||||
"@sentry/webpack-plugin": "^3.2.2",
|
||||
"@testing-library/jest-native": "^5.4.3",
|
||||
"@testing-library/react-native": "^12.8.1",
|
||||
"@types/jest": "^29.4.0",
|
||||
@@ -244,6 +247,7 @@
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-bsky-internal": "link:./eslint",
|
||||
"eslint-plugin-ft-flow": "^2.0.3",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-lingui": "^0.2.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
|
||||
@@ -253,7 +257,7 @@
|
||||
"husky": "^8.0.3",
|
||||
"is-ci": "^3.0.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "^52.0.2",
|
||||
"jest-expo": "~52.0.6",
|
||||
"jest-junit": "^16.0.0",
|
||||
"lint-staged": "^13.2.3",
|
||||
"lockfile-lint": "^4.14.0",
|
||||
@@ -269,7 +273,7 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"@expo/image-utils": "0.6.3",
|
||||
"@react-native/babel-preset": "0.76.1",
|
||||
"@react-native/babel-preset": "0.76.9",
|
||||
"@react-native/normalize-colors": "0.76.1",
|
||||
"@types/react": "^18",
|
||||
"**/expo-constants": "17.0.3",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
diff --git a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
|
||||
index 7e0b4cd..177454c 100644
|
||||
--- a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
|
||||
+++ b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
|
||||
@@ -3,6 +3,8 @@ import { LogBox } from 'react-native';
|
||||
* This is a workaround for using fetch on RN, this is a known issue in react-native and only generates a warning.
|
||||
*/
|
||||
export function ignoreRequireCycleLogs() {
|
||||
- LogBox.ignoreLogs(['Require cycle:']);
|
||||
+ try {
|
||||
+ LogBox.ignoreLogs(['Require cycle:']);
|
||||
+ } catch (e) {}
|
||||
}
|
||||
//# sourceMappingURL=ignorerequirecyclelogs.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
|
||||
index 0f244f2..ae7dfb3 100755
|
||||
--- a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
|
||||
+++ b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
|
||||
@@ -174,6 +174,7 @@ if (!outputDir) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
+const otherArgs = process.argv.slice(3);
|
||||
const files = getAssetPathsSync(outputDir);
|
||||
const groupedAssets = groupAssets(files);
|
||||
|
||||
@@ -195,7 +196,7 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
|
||||
|
||||
const isHermes = assets.find(asset => asset.endsWith('.hbc'));
|
||||
const windowsCallback = process.platform === "win32" ? 'node ' : '';
|
||||
- execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')}`, {
|
||||
+ execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')} ${otherArgs.join(' ')}`, {
|
||||
env: {
|
||||
...process.env,
|
||||
[SENTRY_PROJECT]: sentryProject,
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
|
||||
index c282ade..8777755 100755
|
||||
--- a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
|
||||
+++ b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
|
||||
@@ -215,7 +215,7 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
|
||||
|
||||
const isHermes = assets.find(asset => asset.endsWith('.hbc'));
|
||||
const windowsCallback = process.platform === "win32" ? 'node ' : '';
|
||||
- execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')}`, {
|
||||
+ execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')} --dist ${process.env.SENTRY_DIST}`, {
|
||||
env: {
|
||||
...process.env,
|
||||
[SENTRY_PROJECT]: sentryProject,
|
||||
@@ -1,8 +1,8 @@
|
||||
diff --git a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
|
||||
index b928b8f..7175cf6 100644
|
||||
index 64e6efe..e61485e 100644
|
||||
--- a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
|
||||
+++ b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
|
||||
@@ -99,12 +99,12 @@ class MediaLibraryModule : Module() {
|
||||
@@ -111,12 +111,12 @@ class MediaLibraryModule : Module() {
|
||||
}
|
||||
|
||||
AsyncFunction("createAssetAsync") { localUri: String, promise: Promise ->
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle
|
||||
index 5dd0c61..bf536dd 100644
|
||||
index 13bffbb..5ebbede 100644
|
||||
--- a/node_modules/expo-notifications/android/build.gradle
|
||||
+++ b/node_modules/expo-notifications/android/build.gradle
|
||||
@@ -46,6 +46,7 @@ dependencies {
|
||||
@@ -41,7 +41,7 @@ diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Scro
|
||||
index 93af874..106f8ec 100644
|
||||
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
||||
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
||||
@@ -66,28 +66,51 @@ - (void)preserveContentOffsetWithBlock:(void (^)())block
|
||||
@@ -66,28 +66,51 @@
|
||||
* ScrollView, we force it to be centered, but when you zoom or the content otherwise
|
||||
* becomes larger than the ScrollView, there is no padding around the content but it
|
||||
* can still fill the whole view.
|
||||
@@ -103,7 +103,7 @@ index 93af874..106f8ec 100644
|
||||
- (BOOL)touchesShouldCancelInContentView:(UIView *)view
|
||||
{
|
||||
if ([_overridingDelegate respondsToSelector:@selector(touchesShouldCancelInContentView:)]) {
|
||||
@@ -257,6 +280,10 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
|
||||
@@ -257,6 +280,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
|
||||
index 53bfd04..ff1b1ed 100644
|
||||
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
@@ -23,6 +23,7 @@ @implementation RCTRefreshControl {
|
||||
@@ -23,6 +23,7 @@
|
||||
UIColor *_titleColor;
|
||||
CGFloat _progressViewOffset;
|
||||
BOOL _hasMovedToWindow;
|
||||
@@ -139,7 +139,7 @@ index 53bfd04..ff1b1ed 100644
|
||||
}
|
||||
|
||||
- (instancetype)init
|
||||
@@ -58,6 +59,12 @@ - (void)layoutSubviews
|
||||
@@ -58,6 +59,12 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
|
||||
_isInitialRender = false;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ index 53bfd04..ff1b1ed 100644
|
||||
- (void)didMoveToWindow
|
||||
{
|
||||
[super didMoveToWindow];
|
||||
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
|
||||
@@ -221,4 +228,50 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
|
||||
index 40aaf9c..1c60164 100644
|
||||
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
|
||||
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
|
||||
@@ -22,11 +22,12 @@ - (UIView *)view
|
||||
@@ -22,11 +22,12 @@ RCT_EXPORT_MODULE()
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
|
||||
@@ -222,10 +222,10 @@ index 40aaf9c..1c60164 100644
|
||||
{
|
||||
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
|
||||
diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
|
||||
index e9ce48c..84a6fca 100644
|
||||
index 6f41b5c..9b4f77f 100644
|
||||
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
|
||||
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
|
||||
@@ -159,26 +159,8 @@ - (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view
|
||||
@@ -159,26 +159,8 @@
|
||||
return !shouldDisableScrollInteraction;
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ index e9ce48c..84a6fca 100644
|
||||
super.contentOffset = CGPointMake(
|
||||
RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
|
||||
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
|
||||
@@ -427,6 +409,11 @@ - (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
|
||||
@@ -433,6 +415,11 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
|
||||
// Does nothing
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ index e9ce48c..84a6fca 100644
|
||||
- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
|
||||
{
|
||||
[super insertReactSubview:view atIndex:atIndex];
|
||||
@@ -443,6 +430,8 @@ - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
|
||||
@@ -449,6 +436,8 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
|
||||
_contentView = view;
|
||||
RCTApplyTransformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
|
||||
[_scrollView addSubview:view];
|
||||
@@ -273,7 +273,7 @@ index e9ce48c..84a6fca 100644
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,9 +641,46 @@ -(void)delegateMethod : (UIScrollView *)scrollView \
|
||||
@@ -658,9 +647,46 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
|
||||
}
|
||||
|
||||
RCT_SCROLL_EVENT_HANDLER(scrollViewWillBeginDecelerating, onMomentumScrollBegin)
|
||||
@@ -321,7 +321,7 @@ index e9ce48c..84a6fca 100644
|
||||
- (void)addScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
|
||||
{
|
||||
[_scrollListeners addObject:scrollListener];
|
||||
@@ -933,6 +959,7 @@ - (void)updateContentSizeIfNeeded
|
||||
@@ -939,6 +965,7 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
|
||||
CGSize contentSize = self.contentSize;
|
||||
if (!CGSizeEqualToSize(_scrollView.contentSize, contentSize)) {
|
||||
_scrollView.contentSize = contentSize;
|
||||
@@ -329,7 +329,7 @@ index e9ce48c..84a6fca 100644
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,6 +1082,22 @@ -(type)getter \
|
||||
@@ -1061,6 +1088,22 @@ RCT_SET_AND_PRESERVE_OFFSET(setShowsHorizontalScrollIndicator, showsHorizontalSc
|
||||
RCT_SET_AND_PRESERVE_OFFSET(setShowsVerticalScrollIndicator, showsVerticalScrollIndicator, BOOL)
|
||||
RCT_SET_AND_PRESERVE_OFFSET(setZoomScale, zoomScale, CGFloat);
|
||||
|
||||
@@ -356,7 +356,7 @@ diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManag
|
||||
index cd1e7eb..c1d0172 100644
|
||||
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
|
||||
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
|
||||
@@ -83,6 +83,7 @@ - (UIView *)view
|
||||
@@ -83,6 +83,7 @@ RCT_EXPORT_VIEW_PROPERTY(showsVerticalScrollIndicator, BOOL)
|
||||
RCT_EXPORT_VIEW_PROPERTY(scrollEventThrottle, NSTimeInterval)
|
||||
RCT_EXPORT_VIEW_PROPERTY(zoomScale, CGFloat)
|
||||
RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets)
|
||||
@@ -1,34 +0,0 @@
|
||||
const exec = require('child_process').execSync
|
||||
|
||||
const SENTRY_AUTH_TOKEN = process.env.SENTRY_AUTH_TOKEN
|
||||
|
||||
module.exports = ({config}) => {
|
||||
if (!SENTRY_AUTH_TOKEN) {
|
||||
console.log(
|
||||
'SENTRY_AUTH_TOKEN environment variable must be set to upload sourcemaps. Skipping.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const org = config.organization
|
||||
const project = config.project
|
||||
const release = config.release
|
||||
const dist = config.dist
|
||||
|
||||
if (!org || !project || !release || !dist) {
|
||||
console.log(
|
||||
'"organization", "project", "release", and "dist" must be set in the hook config to upload sourcemaps. Skipping.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Uploading sourcemaps to Sentry...')
|
||||
exec(
|
||||
`node node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps dist --url https://sentry.io/ -o ${org} -p ${project} -r ${release} -d ${dist}`,
|
||||
)
|
||||
console.log('Sourcemaps uploaded to Sentry.')
|
||||
} catch (e) {
|
||||
console.error('Error uploading sourcemaps to Sentry:', e)
|
||||
}
|
||||
}
|
||||
+54
-50
@@ -15,6 +15,7 @@ import * as SplashScreen from 'expo-splash-screen'
|
||||
import * as SystemUI from 'expo-system-ui'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
|
||||
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
|
||||
import {QueryProvider} from '#/lib/react-query'
|
||||
@@ -65,6 +66,7 @@ import * as Toast from '#/view/com/util/Toast'
|
||||
import {Shell} from '#/view/shell'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
@@ -127,55 +129,57 @@ function InnerApp() {
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<RootSiblingParent>
|
||||
<VideoVolumeProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<ComposerProvider>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<TrendingConfigProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</GestureHandlerRootView>
|
||||
</TrendingConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</ComposerProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</VideoVolumeProvider>
|
||||
</RootSiblingParent>
|
||||
</Splash>
|
||||
<ContextMenuProvider>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<RootSiblingParent>
|
||||
<VideoVolumeProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<ComposerProvider>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<TrendingConfigProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</GestureHandlerRootView>
|
||||
</TrendingConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</ComposerProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</VideoVolumeProvider>
|
||||
</RootSiblingParent>
|
||||
</Splash>
|
||||
</ContextMenuProvider>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
)
|
||||
@@ -236,4 +240,4 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default Sentry.wrap(App)
|
||||
|
||||
+53
-49
@@ -7,6 +7,7 @@ import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
|
||||
import {QueryProvider} from '#/lib/react-query'
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
@@ -55,6 +56,7 @@ import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||
import {Shell} from '#/view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
@@ -106,54 +108,56 @@ function InnerApp() {
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<RootSiblingParent>
|
||||
<VideoVolumeProvider>
|
||||
<ActiveVideoProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<ComposerProvider>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<TrendingConfigProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</TrendingConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</ComposerProvider>
|
||||
</QueryProvider>
|
||||
<ToastContainer />
|
||||
</React.Fragment>
|
||||
</ActiveVideoProvider>
|
||||
</VideoVolumeProvider>
|
||||
</RootSiblingParent>
|
||||
<ContextMenuProvider>
|
||||
<RootSiblingParent>
|
||||
<VideoVolumeProvider>
|
||||
<ActiveVideoProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<ComposerProvider>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<TrendingConfigProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</TrendingConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</ComposerProvider>
|
||||
</QueryProvider>
|
||||
<ToastContainer />
|
||||
</React.Fragment>
|
||||
</ActiveVideoProvider>
|
||||
</VideoVolumeProvider>
|
||||
</RootSiblingParent>
|
||||
</ContextMenuProvider>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
)
|
||||
@@ -207,4 +211,4 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default Sentry.wrap(App)
|
||||
|
||||
+22
-14
@@ -1,9 +1,8 @@
|
||||
import * as React from 'react'
|
||||
import {JSX} from 'react/jsx-runtime'
|
||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||
import {i18n, type MessageDescriptor} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {
|
||||
BottomTabBarProps,
|
||||
type BottomTabBarProps,
|
||||
createBottomTabNavigator,
|
||||
} from '@react-navigation/bottom-tabs'
|
||||
import {
|
||||
@@ -20,16 +19,16 @@ import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
|
||||
import {buildStateObject} from '#/lib/routes/helpers'
|
||||
import {
|
||||
AllNavigatorParams,
|
||||
BottomTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
HomeTabNavigatorParams,
|
||||
MessagesTabNavigatorParams,
|
||||
MyProfileTabNavigatorParams,
|
||||
NotificationsTabNavigatorParams,
|
||||
SearchTabNavigatorParams,
|
||||
type AllNavigatorParams,
|
||||
type BottomTabNavigatorParams,
|
||||
type FlatNavigatorParams,
|
||||
type HomeTabNavigatorParams,
|
||||
type MessagesTabNavigatorParams,
|
||||
type MyProfileTabNavigatorParams,
|
||||
type NotificationsTabNavigatorParams,
|
||||
type SearchTabNavigatorParams,
|
||||
} from '#/lib/routes/types'
|
||||
import {RouteParams, State} from '#/lib/routes/types'
|
||||
import {type RouteParams, type State} from '#/lib/routes/types'
|
||||
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
|
||||
import {bskyTitle} from '#/lib/strings/headings'
|
||||
import {logger} from '#/logger'
|
||||
@@ -59,7 +58,6 @@ import {ProfileScreen} from '#/view/screens/Profile'
|
||||
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
|
||||
import {ProfileListScreen} from '#/view/screens/ProfileList'
|
||||
import {SavedFeeds} from '#/view/screens/SavedFeeds'
|
||||
import {SearchScreen} from '#/view/screens/Search'
|
||||
import {Storybook} from '#/view/screens/Storybook'
|
||||
import {SupportScreen} from '#/view/screens/Support'
|
||||
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
|
||||
@@ -81,9 +79,11 @@ import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed'
|
||||
import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {SearchScreen} from '#/screens/Search'
|
||||
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
|
||||
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
|
||||
import {SettingsInterests} from '#/screens/Settings/SettingsInterests'
|
||||
import {
|
||||
StarterPackScreen,
|
||||
StarterPackScreenShort,
|
||||
@@ -376,6 +376,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SettingsInterests"
|
||||
getComponent={() => SettingsInterests}
|
||||
options={{
|
||||
title: title(msg`Your interests`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AboutSettings"
|
||||
getComponent={() => AboutSettingsScreen}
|
||||
@@ -629,7 +637,7 @@ const FlatNavigator = () => {
|
||||
<Flat.Screen
|
||||
name="Search"
|
||||
getComponent={() => SearchScreen}
|
||||
options={{title: title(msg`Search`)}}
|
||||
options={{title: title(msg`Explore`)}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Notifications"
|
||||
|
||||
+12
-1
@@ -1,4 +1,9 @@
|
||||
import {Platform, StyleProp, StyleSheet, ViewStyle} from 'react-native'
|
||||
import {
|
||||
Platform,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import {ios, native, platform, web} from '#/alf/util/platform'
|
||||
@@ -126,6 +131,9 @@ export const atoms = {
|
||||
rounded_md: {
|
||||
borderRadius: tokens.borderRadius.md,
|
||||
},
|
||||
rounded_lg: {
|
||||
borderRadius: tokens.borderRadius.lg,
|
||||
},
|
||||
rounded_full: {
|
||||
borderRadius: tokens.borderRadius.full,
|
||||
},
|
||||
@@ -358,6 +366,9 @@ export const atoms = {
|
||||
border_r: {
|
||||
borderRightWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
border_transparent: {
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
curve_circular: ios({
|
||||
borderCurve: 'circular',
|
||||
}),
|
||||
|
||||
@@ -31,8 +31,8 @@ export function useBreakpoints(): Record<Breakpoint, boolean> & {
|
||||
* Fine-tuned breakpoints for the shell layout
|
||||
*/
|
||||
export function useLayoutBreakpoints() {
|
||||
const rightNavVisible = useMediaQuery({minWidth: 1075})
|
||||
const centerColumnOffset = useMediaQuery({minWidth: 1075, maxWidth: 1300})
|
||||
const rightNavVisible = useMediaQuery({minWidth: 1100})
|
||||
const centerColumnOffset = useMediaQuery({minWidth: 1100, maxWidth: 1300})
|
||||
const leftNavMinimal = useMediaQuery({maxWidth: 1300})
|
||||
|
||||
return {
|
||||
|
||||
@@ -44,6 +44,7 @@ export const borderRadius = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
full: 999,
|
||||
} as const
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function AvatarStack({
|
||||
profiles,
|
||||
@@ -19,7 +19,7 @@ export function AvatarStack({
|
||||
numPending?: number
|
||||
backgroundColor?: string
|
||||
}) {
|
||||
const halfSize = size / 2
|
||||
const translation = size / 3 // overlap by 1/3
|
||||
const t = useTheme()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
@@ -43,7 +43,7 @@ export function AvatarStack({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.relative,
|
||||
{width: size + (items.length - 1) * halfSize},
|
||||
{width: size + (items.length - 1) * (size - translation)},
|
||||
]}>
|
||||
{items.map((item, i) => (
|
||||
<View
|
||||
@@ -54,7 +54,7 @@ export function AvatarStack({
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
left: i * -halfSize,
|
||||
left: i * -translation,
|
||||
borderWidth: 1,
|
||||
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
|
||||
borderRadius: 999,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {Pressable} from 'react-native'
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
type SharedValue,
|
||||
useAnimatedProps,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {BlurView} from 'expo-blur'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useContextMenuContext} from './context'
|
||||
|
||||
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
|
||||
|
||||
type Props = {
|
||||
animation: SharedValue<number>
|
||||
intensity?: number
|
||||
onPress?: () => void
|
||||
}
|
||||
|
||||
export function Backdrop(props: Props) {
|
||||
const {mode} = useContextMenuContext()
|
||||
switch (mode) {
|
||||
case 'full':
|
||||
return <BlurredBackdrop {...props} />
|
||||
case 'auxiliary-only':
|
||||
return <OpacityBackdrop {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
function BlurredBackdrop({animation, intensity = 50, onPress}: Props) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const animatedProps = useAnimatedProps(() => ({
|
||||
intensity: interpolate(
|
||||
animation.get(),
|
||||
[0, 1],
|
||||
[0, intensity],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<AnimatedBlurView
|
||||
animatedProps={animatedProps}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
tint="systemMaterialDark">
|
||||
<Pressable
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={_(msg`Close menu`)}
|
||||
accessibilityHint={_(msg`Tap to close context menu`)}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</AnimatedBlurView>
|
||||
)
|
||||
}
|
||||
|
||||
function OpacityBackdrop({animation, onPress}: Props) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
animation.get(),
|
||||
[0, 1],
|
||||
[0, 0.05],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[a.absolute, a.inset_0, t.atoms.bg_contrast_975, animatedStyle]}>
|
||||
<Pressable
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={_(msg`Close menu`)}
|
||||
accessibilityHint={_(msg`Tap to close context menu`)}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {Pressable} from 'react-native'
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
SharedValue,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useContextMenuContext} from './context'
|
||||
|
||||
export function Backdrop({
|
||||
animation,
|
||||
intensity = 50,
|
||||
onPress,
|
||||
}: {
|
||||
animation: SharedValue<number>
|
||||
intensity?: number
|
||||
onPress?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {mode} = useContextMenuContext()
|
||||
|
||||
const reduced = mode === 'auxiliary-only'
|
||||
|
||||
const target = reduced ? 0.05 : intensity / 100
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
animation.get(),
|
||||
[0, 1],
|
||||
[0, target],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[a.absolute, a.inset_0, t.atoms.bg_contrast_975, animatedStyle]}>
|
||||
<Pressable
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={_(msg`Close menu`)}
|
||||
accessibilityHint={_(msg`Tap to close context menu`)}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
type ContextType,
|
||||
type ItemContextType,
|
||||
type MenuContextType,
|
||||
} from '#/components/ContextMenu/types'
|
||||
|
||||
export const Context = React.createContext<ContextType | null>(null)
|
||||
|
||||
export const MenuContext = React.createContext<MenuContextType | null>(null)
|
||||
|
||||
export const ItemContext = React.createContext<ItemContextType | null>(null)
|
||||
|
||||
export function useContextMenuContext() {
|
||||
const context = React.useContext(Context)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useContextMenuContext must be used within a Context.Provider',
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export function useContextMenuMenuContext() {
|
||||
const context = React.useContext(MenuContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useContextMenuMenuContext must be used within a Context.Provider',
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export function useContextMenuItemContext() {
|
||||
const context = React.useContext(ItemContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useContextMenuItemContext must be used within a Context.Provider',
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,904 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
BackHandler,
|
||||
Keyboard,
|
||||
type LayoutChangeEvent,
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
type GestureStateChangeEvent,
|
||||
type GestureUpdateEvent,
|
||||
type PanGestureHandlerEventPayload,
|
||||
} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
clamp,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
type WithSpringConfig,
|
||||
} from 'react-native-reanimated'
|
||||
import {
|
||||
useSafeAreaFrame,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context'
|
||||
import {captureRef} from 'react-native-view-shot'
|
||||
import {Image, type ImageErrorEventData} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
import flattenReactChildren from 'react-keyed-flatten-children'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {atoms as a, platform, tokens, useTheme} from '#/alf'
|
||||
import {
|
||||
Context,
|
||||
ItemContext,
|
||||
MenuContext,
|
||||
useContextMenuContext,
|
||||
useContextMenuItemContext,
|
||||
useContextMenuMenuContext,
|
||||
} from '#/components/ContextMenu/context'
|
||||
import {
|
||||
type AuxiliaryViewProps,
|
||||
type ContextType,
|
||||
type ItemIconProps,
|
||||
type ItemProps,
|
||||
type ItemTextProps,
|
||||
type Measurement,
|
||||
type TriggerProps,
|
||||
} from '#/components/ContextMenu/types'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {createPortalGroup} from '#/components/Portal'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Backdrop} from './Backdrop'
|
||||
|
||||
export {
|
||||
type DialogControlProps as ContextMenuControlProps,
|
||||
useDialogControl as useContextMenuControl,
|
||||
} from '#/components/Dialog'
|
||||
|
||||
const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
|
||||
|
||||
const SPRING_IN: WithSpringConfig = {
|
||||
mass: isIOS ? 1.25 : 0.75,
|
||||
damping: 50,
|
||||
stiffness: 1100,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
const SPRING_OUT: WithSpringConfig = {
|
||||
mass: isIOS ? 1.25 : 0.75,
|
||||
damping: 150,
|
||||
stiffness: 1000,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
/**
|
||||
* Needs placing near the top of the provider stack, but BELOW the theme provider.
|
||||
*/
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<PortalProvider>
|
||||
{children}
|
||||
<Outlet />
|
||||
</PortalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Root({children}: {children: React.ReactNode}) {
|
||||
const playHaptic = useHaptics()
|
||||
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
|
||||
const [measurement, setMeasurement] = useState<Measurement | null>(null)
|
||||
const animationSV = useSharedValue(0)
|
||||
const translationSV = useSharedValue(0)
|
||||
const isFocused = useIsFocused()
|
||||
const hoverables = useRef<
|
||||
Map<string, {id: string; rect: Measurement; onTouchUp: () => void}>
|
||||
>(new Map())
|
||||
const hoverablesSV = useSharedValue<
|
||||
Record<string, {id: string; rect: Measurement}>
|
||||
>({})
|
||||
const syncHoverablesThrottleRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const [hoveredMenuItem, setHoveredMenuItem] = useState<string | null>(null)
|
||||
|
||||
const onHoverableTouchUp = useCallback((id: string) => {
|
||||
const hoverable = hoverables.current.get(id)
|
||||
if (!hoverable) {
|
||||
logger.warn(`No such hoverable with id ${id}`)
|
||||
return
|
||||
}
|
||||
hoverable.onTouchUp()
|
||||
}, [])
|
||||
|
||||
const onCompletedClose = useCallback(() => {
|
||||
hoverables.current.clear()
|
||||
setMeasurement(null)
|
||||
}, [])
|
||||
|
||||
const context = useMemo(
|
||||
() =>
|
||||
({
|
||||
isOpen: !!measurement && isFocused,
|
||||
measurement,
|
||||
animationSV,
|
||||
translationSV,
|
||||
mode,
|
||||
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => {
|
||||
setMeasurement(evt)
|
||||
setMode(mode)
|
||||
animationSV.set(withSpring(1, SPRING_IN))
|
||||
},
|
||||
close: () => {
|
||||
animationSV.set(
|
||||
withSpring(0, SPRING_OUT, finished => {
|
||||
if (finished) {
|
||||
hoverablesSV.set({})
|
||||
translationSV.set(0)
|
||||
runOnJS(onCompletedClose)()
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
registerHoverable: (
|
||||
id: string,
|
||||
rect: Measurement,
|
||||
onTouchUp: () => void,
|
||||
) => {
|
||||
hoverables.current.set(id, {id, rect, onTouchUp})
|
||||
// we need this data on the UI thread, but we want to limit cross-thread communication
|
||||
// and this function will be called in quick succession, so we need to throttle it
|
||||
if (syncHoverablesThrottleRef.current)
|
||||
clearTimeout(syncHoverablesThrottleRef.current)
|
||||
syncHoverablesThrottleRef.current = setTimeout(() => {
|
||||
syncHoverablesThrottleRef.current = undefined
|
||||
hoverablesSV.set(
|
||||
Object.fromEntries(
|
||||
// eslint-ignore
|
||||
[...hoverables.current.entries()].map(([id, {rect}]) => [
|
||||
id,
|
||||
{id, rect},
|
||||
]),
|
||||
),
|
||||
)
|
||||
}, 1)
|
||||
},
|
||||
hoverablesSV,
|
||||
onTouchUpMenuItem: onHoverableTouchUp,
|
||||
hoveredMenuItem,
|
||||
setHoveredMenuItem: item => {
|
||||
if (item) playHaptic('Light')
|
||||
setHoveredMenuItem(item)
|
||||
},
|
||||
} satisfies ContextType),
|
||||
[
|
||||
measurement,
|
||||
setMeasurement,
|
||||
onCompletedClose,
|
||||
isFocused,
|
||||
animationSV,
|
||||
translationSV,
|
||||
hoverablesSV,
|
||||
onHoverableTouchUp,
|
||||
hoveredMenuItem,
|
||||
setHoveredMenuItem,
|
||||
playHaptic,
|
||||
mode,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAndroid && context.isOpen) {
|
||||
const listener = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
context.close()
|
||||
return true
|
||||
})
|
||||
|
||||
return () => listener.remove()
|
||||
}
|
||||
}, [context])
|
||||
|
||||
return <Context.Provider value={context}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
const context = useContextMenuContext()
|
||||
const playHaptic = useHaptics()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const ref = useRef<View>(null)
|
||||
const isFocused = useIsFocused()
|
||||
const [image, setImage] = useState<string | null>(null)
|
||||
const [pendingMeasurement, setPendingMeasurement] = useState<{
|
||||
measurement: Measurement
|
||||
mode: 'full' | 'auxiliary-only'
|
||||
} | null>(null)
|
||||
|
||||
const open = useNonReactiveCallback(
|
||||
async (mode: 'full' | 'auxiliary-only') => {
|
||||
playHaptic()
|
||||
Keyboard.dismiss()
|
||||
const [measurement, capture] = await Promise.all([
|
||||
new Promise<Measurement>(resolve => {
|
||||
ref.current?.measureInWindow((x, y, width, height) =>
|
||||
resolve({
|
||||
x,
|
||||
y:
|
||||
y +
|
||||
platform({
|
||||
default: 0,
|
||||
android: topInset, // not included in measurement
|
||||
}),
|
||||
width,
|
||||
height,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
captureRef(ref, {result: 'data-uri'}).catch(err => {
|
||||
logger.error(err instanceof Error ? err : String(err), {
|
||||
message: 'Failed to capture image of context menu trigger',
|
||||
})
|
||||
// will cause the image to fail to load, but it will get handled gracefully
|
||||
return '<failed capture>'
|
||||
}),
|
||||
])
|
||||
setImage(capture)
|
||||
setPendingMeasurement({measurement, mode})
|
||||
},
|
||||
)
|
||||
|
||||
const doubleTapGesture = useMemo(() => {
|
||||
return Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.hitSlop(HITSLOP_10)
|
||||
.onEnd(() => open('auxiliary-only'))
|
||||
.runOnJS(true)
|
||||
}, [open])
|
||||
|
||||
const {
|
||||
hoverablesSV,
|
||||
setHoveredMenuItem,
|
||||
onTouchUpMenuItem,
|
||||
translationSV,
|
||||
animationSV,
|
||||
} = context
|
||||
const hoveredItemSV = useSharedValue<string | null>(null)
|
||||
|
||||
useAnimatedReaction(
|
||||
() => hoveredItemSV.get(),
|
||||
(hovered, prev) => {
|
||||
if (hovered !== prev) {
|
||||
runOnJS(setHoveredMenuItem)(hovered)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const pressAndHoldGesture = useMemo(() => {
|
||||
return Gesture.Pan()
|
||||
.activateAfterLongPress(500)
|
||||
.cancelsTouchesInView(false)
|
||||
.averageTouches(true)
|
||||
.onStart(() => {
|
||||
'worklet'
|
||||
runOnJS(open)('full')
|
||||
})
|
||||
.onUpdate(evt => {
|
||||
'worklet'
|
||||
const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
|
||||
hoveredItemSV.set(item)
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet'
|
||||
// don't recalculate hovered item - if they haven't moved their finger from
|
||||
// the initial press, it's jarring to then select the item underneath
|
||||
// as the menu may have slid into place beneath their finger
|
||||
const item = hoveredItemSV.get()
|
||||
if (item) {
|
||||
runOnJS(onTouchUpMenuItem)(item)
|
||||
}
|
||||
})
|
||||
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
|
||||
|
||||
const composedGestures = Gesture.Exclusive(
|
||||
doubleTapGesture,
|
||||
pressAndHoldGesture,
|
||||
)
|
||||
|
||||
const measurement = context.measurement || pendingMeasurement?.measurement
|
||||
|
||||
return (
|
||||
<>
|
||||
<GestureDetector gesture={composedGestures}>
|
||||
<View ref={ref} style={[{opacity: context.isOpen ? 0 : 1}, style]}>
|
||||
{children({
|
||||
isNative: true,
|
||||
control: {isOpen: context.isOpen, open},
|
||||
state: {
|
||||
pressed: false,
|
||||
hovered: false,
|
||||
focused: false,
|
||||
},
|
||||
props: {
|
||||
ref: null,
|
||||
onPress: null,
|
||||
onFocus: null,
|
||||
onBlur: null,
|
||||
onPressIn: null,
|
||||
onPressOut: null,
|
||||
accessibilityHint: null,
|
||||
accessibilityLabel: label,
|
||||
accessibilityRole: null,
|
||||
},
|
||||
})}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
{isFocused && image && measurement && (
|
||||
<Portal>
|
||||
<TriggerClone
|
||||
label={contentLabel}
|
||||
translation={translationSV}
|
||||
animation={animationSV}
|
||||
image={image}
|
||||
measurement={measurement}
|
||||
onDisplay={() => {
|
||||
if (pendingMeasurement) {
|
||||
context.open(
|
||||
pendingMeasurement.measurement,
|
||||
pendingMeasurement.mode,
|
||||
)
|
||||
setPendingMeasurement(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* an image of the underlying trigger with a grow animation
|
||||
*/
|
||||
function TriggerClone({
|
||||
translation,
|
||||
animation,
|
||||
image,
|
||||
measurement,
|
||||
onDisplay,
|
||||
label,
|
||||
}: {
|
||||
translation: SharedValue<number>
|
||||
animation: SharedValue<number>
|
||||
image: string
|
||||
measurement: Measurement
|
||||
onDisplay: () => void
|
||||
label: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const animatedStyles = useAnimatedStyle(() => ({
|
||||
transform: [{translateY: translation.get() * animation.get()}],
|
||||
}))
|
||||
|
||||
const handleError = useCallback(
|
||||
(evt: ImageErrorEventData) => {
|
||||
logger.error('Context menu image load error', {message: evt.error})
|
||||
onDisplay()
|
||||
},
|
||||
[onDisplay],
|
||||
)
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
top: measurement.y,
|
||||
left: measurement.x,
|
||||
width: measurement.width,
|
||||
height: measurement.height,
|
||||
},
|
||||
a.z_10,
|
||||
a.pointer_events_none,
|
||||
animatedStyles,
|
||||
]}>
|
||||
<Image
|
||||
onDisplay={onDisplay}
|
||||
onError={handleError}
|
||||
source={image}
|
||||
style={{
|
||||
width: measurement.width,
|
||||
height: measurement.height,
|
||||
}}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={_(msg`The subject of the context menu`)}
|
||||
accessibilityIgnoresInvertColors={false}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
|
||||
const context = useContextMenuContext()
|
||||
const {width: screenWidth} = useWindowDimensions()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const ensureOnScreenTranslationSV = useSharedValue(0)
|
||||
|
||||
const {isOpen, mode, measurement, translationSV, animationSV} = context
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: clamp(animationSV.get(), 0, 1),
|
||||
transform: [
|
||||
{
|
||||
translateY:
|
||||
(ensureOnScreenTranslationSV.get() || translationSV.get()) *
|
||||
animationSV.get(),
|
||||
},
|
||||
{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const menuContext = useMemo(() => ({align}), [align])
|
||||
|
||||
const onLayout = useCallback(() => {
|
||||
if (!measurement) return
|
||||
|
||||
let translation = 0
|
||||
|
||||
// vibes based, just assuming it'll fit within this space. revisit if we use
|
||||
// AuxiliaryView for something tall
|
||||
const TOP_INSET = topInset + 80
|
||||
|
||||
const distanceMessageFromTop = measurement.y - TOP_INSET
|
||||
if (distanceMessageFromTop < 0) {
|
||||
translation = -distanceMessageFromTop
|
||||
}
|
||||
|
||||
// normally, the context menu is responsible for measuring itself and moving everything into the right place
|
||||
// however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
|
||||
if (mode === 'auxiliary-only') {
|
||||
translationSV.set(translation)
|
||||
ensureOnScreenTranslationSV.set(0)
|
||||
}
|
||||
// however, we also need to make sure that for super tall triggers, we don't go off the screen
|
||||
// so we have an additional cap on the standard transform every other element has
|
||||
// note: this breaks the press-and-hold gesture for the reaction items. unfortunately I think
|
||||
// we'll just have to live with it for now, fixing it would be possible but be a large complexity
|
||||
// increase for an edge case
|
||||
else {
|
||||
ensureOnScreenTranslationSV.set(translation)
|
||||
}
|
||||
}, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
|
||||
|
||||
if (!isOpen || !measurement) return null
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Context.Provider value={context}>
|
||||
<MenuContext.Provider value={menuContext}>
|
||||
<Animated.View
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
top: measurement.y,
|
||||
transformOrigin:
|
||||
align === 'left' ? 'bottom left' : 'bottom right',
|
||||
},
|
||||
align === 'left'
|
||||
? {left: measurement.x}
|
||||
: {right: screenWidth - measurement.x - measurement.width},
|
||||
animatedStyle,
|
||||
a.z_20,
|
||||
]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</MenuContext.Provider>
|
||||
</Context.Provider>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
const MENU_WIDTH = 240
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
style,
|
||||
align = 'left',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
style?: StyleProp<ViewStyle>
|
||||
align?: 'left' | 'right'
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const context = useContextMenuContext()
|
||||
const insets = useSafeAreaInsets()
|
||||
const frame = useSafeAreaFrame()
|
||||
const {width: screenWidth} = useWindowDimensions()
|
||||
|
||||
const {animationSV, translationSV} = context
|
||||
|
||||
const animatedContainerStyle = useAnimatedStyle(() => ({
|
||||
transform: [{translateY: translationSV.get() * animationSV.get()}],
|
||||
}))
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: clamp(animationSV.get(), 0, 1),
|
||||
transform: [{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}],
|
||||
}))
|
||||
|
||||
const onLayout = useCallback(
|
||||
(evt: LayoutChangeEvent) => {
|
||||
if (!context.measurement) return // should not happen
|
||||
let translation = 0
|
||||
|
||||
// pure vibes based
|
||||
const TOP_INSET = insets.top + 80
|
||||
const BOTTOM_INSET_IOS = insets.bottom + 20
|
||||
const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn
|
||||
|
||||
const {height} = evt.nativeEvent.layout
|
||||
const topPosition =
|
||||
context.measurement.y + context.measurement.height + tokens.space.xs
|
||||
const bottomPosition = topPosition + height
|
||||
const safeAreaBottomLimit =
|
||||
frame.height -
|
||||
platform({
|
||||
ios: BOTTOM_INSET_IOS,
|
||||
android: BOTTOM_INSET_ANDROID,
|
||||
default: 0,
|
||||
})
|
||||
const diff = bottomPosition - safeAreaBottomLimit
|
||||
if (diff > 0) {
|
||||
translation = -diff
|
||||
} else {
|
||||
const distanceMessageFromTop = context.measurement.y - TOP_INSET
|
||||
if (distanceMessageFromTop < 0) {
|
||||
translation = -Math.max(distanceMessageFromTop, diff)
|
||||
}
|
||||
}
|
||||
|
||||
if (translation !== 0) {
|
||||
translationSV.set(translation)
|
||||
}
|
||||
},
|
||||
[context.measurement, frame.height, insets, translationSV],
|
||||
)
|
||||
|
||||
const menuContext = useMemo(() => ({align}), [align])
|
||||
|
||||
if (!context.isOpen || !context.measurement) return null
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Context.Provider value={context}>
|
||||
<MenuContext.Provider value={menuContext}>
|
||||
<Backdrop animation={animationSV} onPress={context.close} />
|
||||
{context.mode === 'full' && (
|
||||
/* containing element - stays the same size, so we measure it
|
||||
to determine if a translation is necessary. also has the positioning */
|
||||
<Animated.View
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_10,
|
||||
a.mt_xs,
|
||||
{
|
||||
width: MENU_WIDTH,
|
||||
top: context.measurement.y + context.measurement.height,
|
||||
},
|
||||
align === 'left'
|
||||
? {left: context.measurement.x}
|
||||
: {
|
||||
right:
|
||||
screenWidth -
|
||||
context.measurement.x -
|
||||
context.measurement.width,
|
||||
},
|
||||
animatedContainerStyle,
|
||||
]}>
|
||||
{/* scaling element - has the scale/fade animation on it */}
|
||||
<Animated.View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.shadow_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.w_full,
|
||||
// @ts-ignore react-native-web expects string, and this file is platform-split -sfn
|
||||
// note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
|
||||
// in the typecheck CI - presumably because of RNW overriding the types
|
||||
{
|
||||
transformOrigin:
|
||||
// "top right" doesn't seem to work on android, so set explicitly in pixels
|
||||
align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
|
||||
},
|
||||
animatedStyle,
|
||||
style,
|
||||
]}>
|
||||
{/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
|
||||
so put the shadow on the scaling element and the overflow on the innermost element */}
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{flattenReactChildren(children).map((child, i) => {
|
||||
return React.isValidElement(child) &&
|
||||
(child.type === Item || child.type === Divider) ? (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<View
|
||||
style={[a.border_b, t.atoms.border_contrast_low]}
|
||||
/>
|
||||
) : null}
|
||||
{React.cloneElement(child, {
|
||||
// @ts-expect-error not typed
|
||||
style: {
|
||||
borderRadius: 0,
|
||||
borderWidth: 0,
|
||||
},
|
||||
})}
|
||||
</React.Fragment>
|
||||
) : null
|
||||
})}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</MenuContext.Provider>
|
||||
</Context.Provider>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export function Item({
|
||||
children,
|
||||
label,
|
||||
unstyled,
|
||||
style,
|
||||
onPress,
|
||||
position,
|
||||
...rest
|
||||
}: ItemProps) {
|
||||
const t = useTheme()
|
||||
const context = useContextMenuContext()
|
||||
const playHaptic = useHaptics()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const id = useId()
|
||||
const {align} = useContextMenuMenuContext()
|
||||
|
||||
const {close, measurement, registerHoverable} = context
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(evt: LayoutChangeEvent) => {
|
||||
if (!measurement) return // should be impossible
|
||||
|
||||
const layout = evt.nativeEvent.layout
|
||||
|
||||
const yOffset = position
|
||||
? position.y
|
||||
: measurement.y + measurement.height + tokens.space.xs
|
||||
const xOffset = position
|
||||
? position.x
|
||||
: align === 'left'
|
||||
? measurement.x
|
||||
: measurement.x + measurement.width - layout.width
|
||||
|
||||
registerHoverable(
|
||||
id,
|
||||
{
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
y: yOffset + layout.y,
|
||||
x: xOffset + layout.x,
|
||||
},
|
||||
() => {
|
||||
close()
|
||||
onPress()
|
||||
},
|
||||
)
|
||||
},
|
||||
[id, measurement, registerHoverable, close, onPress, align, position],
|
||||
)
|
||||
|
||||
const itemContext = useMemo(
|
||||
() => ({disabled: Boolean(rest.disabled)}),
|
||||
[rest.disabled],
|
||||
)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...rest}
|
||||
onLayout={handleLayout}
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={label}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onPress={e => {
|
||||
close()
|
||||
onPress?.(e)
|
||||
}}
|
||||
onPressIn={e => {
|
||||
onPressIn()
|
||||
rest.onPressIn?.(e)
|
||||
playHaptic('Light')
|
||||
}}
|
||||
onPressOut={e => {
|
||||
onPressOut()
|
||||
rest.onPressOut?.(e)
|
||||
}}
|
||||
style={[
|
||||
!unstyled && [
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.px_md,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
{minHeight: 44, paddingVertical: 10},
|
||||
(focused || pressed || context.hoveredMenuItem === id) &&
|
||||
!rest.disabled &&
|
||||
t.atoms.bg_contrast_50,
|
||||
],
|
||||
style,
|
||||
]}>
|
||||
<ItemContext.Provider value={itemContext}>
|
||||
{typeof children === 'function'
|
||||
? children(
|
||||
(focused || pressed || context.hoveredMenuItem === id) &&
|
||||
!rest.disabled,
|
||||
)
|
||||
: children}
|
||||
</ItemContext.Provider>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemText({children, style}: ItemTextProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = useContextMenuItemContext()
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
{paddingTop: 3},
|
||||
style,
|
||||
disabled && t.atoms.text_contrast_low,
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = useContextMenuItemContext()
|
||||
return (
|
||||
<Comp
|
||||
size="lg"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemRadio({selected}: {selected: boolean}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
t.atoms.border_contrast_high,
|
||||
{
|
||||
borderWidth: 1,
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
]}>
|
||||
{selected ? (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{height: 14, width: 14},
|
||||
selected ? {backgroundColor: t.palette.primary_500} : {},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function LabelText({children}: {children: React.ReactNode}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text
|
||||
style={[a.font_bold, t.atoms.text_contrast_medium, {marginBottom: -8}]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function Divider() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[t.atoms.border_contrast_low, a.flex_1, {borderTopWidth: 3}]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function getHoveredHoverable(
|
||||
evt:
|
||||
| GestureStateChangeEvent<PanGestureHandlerEventPayload>
|
||||
| GestureUpdateEvent<PanGestureHandlerEventPayload>,
|
||||
hoverables: SharedValue<Record<string, {id: string; rect: Measurement}>>,
|
||||
translation: SharedValue<number>,
|
||||
) {
|
||||
'worklet'
|
||||
|
||||
const x = evt.absoluteX
|
||||
const y = evt.absoluteY
|
||||
const yOffset = translation.get()
|
||||
|
||||
const rects = Object.values(hoverables.get())
|
||||
|
||||
for (const {id, rect} of rects) {
|
||||
const isWithinLeftBound = x >= rect.x
|
||||
const isWithinRightBound = x <= rect.x + rect.width
|
||||
const isWithinTopBound = y >= rect.y + yOffset
|
||||
const isWithinBottomBound = y <= rect.y + rect.height + yOffset
|
||||
|
||||
if (
|
||||
isWithinLeftBound &&
|
||||
isWithinRightBound &&
|
||||
isWithinTopBound &&
|
||||
isWithinBottomBound
|
||||
) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {type AuxiliaryViewProps} from './types'
|
||||
|
||||
export * from '#/components/Menu'
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
// native only
|
||||
export function AuxiliaryView({}: AuxiliaryViewProps) {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
type AccessibilityRole,
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {type SharedValue} from 'react-native-reanimated'
|
||||
import type React from 'react'
|
||||
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
import {
|
||||
type ItemProps as MenuItemProps,
|
||||
type RadixPassThroughTriggerProps,
|
||||
} from '#/components/Menu/types'
|
||||
|
||||
export type {
|
||||
GroupProps,
|
||||
ItemIconProps,
|
||||
ItemTextProps,
|
||||
} from '#/components/Menu/types'
|
||||
|
||||
export type AuxiliaryViewProps = {
|
||||
children?: React.ReactNode
|
||||
align?: 'left' | 'right'
|
||||
}
|
||||
|
||||
export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & {
|
||||
// remove default styles (i.e. for emoji reactions)
|
||||
unstyled?: boolean
|
||||
onPress: (evt?: GestureResponderEvent) => void
|
||||
children?: React.ReactNode | ((hovered: boolean) => React.ReactNode)
|
||||
// absolute position of the parent element. if undefined, assumed to
|
||||
// be in the context menu. use this if using AuxiliaryView
|
||||
position?: Measurement
|
||||
}
|
||||
|
||||
export type Measurement = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type ContextType = {
|
||||
isOpen: boolean
|
||||
measurement: Measurement | null
|
||||
/* Spring animation between 0 and 1 */
|
||||
animationSV: SharedValue<number>
|
||||
/* Translation in Y axis to ensure everything's onscreen */
|
||||
translationSV: SharedValue<number>
|
||||
mode: 'full' | 'auxiliary-only'
|
||||
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void
|
||||
close: () => void
|
||||
registerHoverable: (
|
||||
id: string,
|
||||
rect: Measurement,
|
||||
onTouchUp: () => void,
|
||||
) => void
|
||||
hoverablesSV: SharedValue<Record<string, {id: string; rect: Measurement}>>
|
||||
hoveredMenuItem: string | null
|
||||
setHoveredMenuItem: React.Dispatch<React.SetStateAction<string | null>>
|
||||
onTouchUpMenuItem: (id: string) => void
|
||||
}
|
||||
|
||||
export type MenuContextType = {
|
||||
align: 'left' | 'right'
|
||||
}
|
||||
|
||||
export type ItemContextType = {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type TriggerProps = {
|
||||
children(props: TriggerChildProps): React.ReactNode
|
||||
label: string
|
||||
/**
|
||||
* When activated, this is the accessibility label for the entire thing that has been triggered.
|
||||
* For example, if the trigger is a message bubble, use the message content.
|
||||
*
|
||||
* @platform ios, android
|
||||
*/
|
||||
contentLabel: string
|
||||
hint?: string
|
||||
role?: AccessibilityRole
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
export type TriggerChildProps =
|
||||
| {
|
||||
isNative: true
|
||||
control: {
|
||||
isOpen: boolean
|
||||
open: (mode: 'full' | 'auxiliary-only') => void
|
||||
}
|
||||
state: {
|
||||
hovered: false
|
||||
focused: false
|
||||
pressed: false
|
||||
}
|
||||
/**
|
||||
* We don't necessarily know what these will be spread on to, so we
|
||||
* should add props one-by-one.
|
||||
*
|
||||
* On web, these properties are applied to a parent `Pressable`, so this
|
||||
* object is empty.
|
||||
*/
|
||||
props: {
|
||||
ref: null
|
||||
onPress: null
|
||||
onFocus: null
|
||||
onBlur: null
|
||||
onPressIn: null
|
||||
onPressOut: null
|
||||
accessibilityHint: null
|
||||
accessibilityLabel: string
|
||||
accessibilityRole: null
|
||||
}
|
||||
}
|
||||
| {
|
||||
isNative: false
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
state: {
|
||||
hovered: false
|
||||
focused: false
|
||||
pressed: false
|
||||
}
|
||||
props: RadixPassThroughTriggerProps & {
|
||||
onPress: () => void
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
onMouseEnter: () => void
|
||||
onMouseLeave: () => void
|
||||
accessibilityHint?: string
|
||||
accessibilityLabel: string
|
||||
accessibilityRole: AccessibilityRole
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleProp,
|
||||
type ScrollView,
|
||||
type StyleProp,
|
||||
TextInput,
|
||||
View,
|
||||
ViewStyle,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
useKeyboardHandler,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import {runOnJS} from 'react-native-reanimated'
|
||||
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
|
||||
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -25,22 +25,22 @@ import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {List, ListMethods, ListProps} from '#/view/com/util/List'
|
||||
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {useThemeName} from '#/alf/util/useColorModeTheme'
|
||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||
import {
|
||||
DialogControlProps,
|
||||
DialogInnerProps,
|
||||
DialogOuterProps,
|
||||
type DialogControlProps,
|
||||
type DialogInnerProps,
|
||||
type DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
|
||||
import {
|
||||
BottomSheetSnapPointChangeEvent,
|
||||
BottomSheetStateChangeEvent,
|
||||
type BottomSheetSnapPointChangeEvent,
|
||||
type BottomSheetStateChangeEvent,
|
||||
} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
||||
import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
|
||||
import {type BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
|
||||
|
||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/shared'
|
||||
@@ -299,7 +299,7 @@ export const InnerFlatList = React.forwardRef<
|
||||
keyboardShouldPersistTaps="handled"
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
ListFooterComponent={
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop + 50}} />
|
||||
}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
+48
-20
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyGraphDefs,
|
||||
AtUri,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
@@ -23,15 +23,20 @@ import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
type ButtonProps,
|
||||
ButtonText,
|
||||
} from '#/components/Button'
|
||||
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
||||
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText, RichTextProps} from '#/components/RichText'
|
||||
import {RichText, type RichTextProps} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
|
||||
|
||||
type Props = {
|
||||
view: AppBskyFeedDefs.GeneratorView
|
||||
@@ -81,11 +86,11 @@ export function Link({
|
||||
}
|
||||
|
||||
export function Outer({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.w_full, a.gap_md]}>{children}</View>
|
||||
return <View style={[a.w_full, a.gap_sm]}>{children}</View>
|
||||
}
|
||||
|
||||
export function Header({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
|
||||
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
|
||||
}
|
||||
|
||||
export type AvatarProps = {src: string | undefined; size?: number}
|
||||
@@ -220,22 +225,27 @@ export function Likes({count}: {count: number}) {
|
||||
export function SaveButton({
|
||||
view,
|
||||
pin,
|
||||
...props
|
||||
}: {
|
||||
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||
pin?: boolean
|
||||
}) {
|
||||
text?: boolean
|
||||
} & Partial<ButtonProps>) {
|
||||
const {hasSession} = useSession()
|
||||
if (!hasSession) return null
|
||||
return <SaveButtonInner view={view} pin={pin} />
|
||||
return <SaveButtonInner view={view} pin={pin} {...props} />
|
||||
}
|
||||
|
||||
function SaveButtonInner({
|
||||
view,
|
||||
pin,
|
||||
text = true,
|
||||
...buttonProps
|
||||
}: {
|
||||
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||
pin?: boolean
|
||||
}) {
|
||||
text?: boolean
|
||||
} & Partial<ButtonProps>) {
|
||||
const {_} = useLingui()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
||||
@@ -294,14 +304,32 @@ function SaveButtonInner({
|
||||
disabled={isPending}
|
||||
label={_(msg`Add this feed to your feeds`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="square"
|
||||
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
|
||||
variant="solid"
|
||||
color={savedFeedConfig ? 'secondary' : 'primary'}
|
||||
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}
|
||||
{...buttonProps}>
|
||||
{savedFeedConfig ? (
|
||||
<ButtonIcon size="md" icon={isPending ? Loader : Trash} />
|
||||
<>
|
||||
{isPending ? (
|
||||
<ButtonIcon size="md" icon={Loader} />
|
||||
) : (
|
||||
!text && <ButtonIcon size="md" icon={TrashIcon} />
|
||||
)}
|
||||
{text && (
|
||||
<ButtonText>
|
||||
<Trans>Unpin Feed</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
|
||||
<>
|
||||
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
|
||||
{text && (
|
||||
<ButtonText>
|
||||
<Trans>Pin Feed</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -360,35 +360,37 @@ export function ProfileGrid({
|
||||
</View>
|
||||
) : (
|
||||
<BlockDrawerGesture>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
<View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
|
||||
{content}
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
<View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
|
||||
{content}
|
||||
|
||||
<Button
|
||||
label={_(msg`Browse more accounts on the Explore page`)}
|
||||
onPress={() => {
|
||||
navigation.navigate('SearchTab')
|
||||
}}>
|
||||
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
|
||||
<View style={[a.flex_1, a.justify_center]}>
|
||||
<View style={[a.flex_row, a.px_lg]}>
|
||||
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
||||
<Trans>
|
||||
Browse more suggestions on the Explore page
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Browse more accounts on the Explore page`)}
|
||||
onPress={() => {
|
||||
navigation.navigate('SearchTab')
|
||||
}}>
|
||||
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
|
||||
<View style={[a.flex_1, a.justify_center]}>
|
||||
<View style={[a.flex_row, a.px_lg]}>
|
||||
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
||||
<Trans>
|
||||
Browse more suggestions on the Explore page
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Arrow size="xl" />
|
||||
<Arrow size="xl" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</CardOuter>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</CardOuter>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</BlockDrawerGesture>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons
|
||||
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||
import {
|
||||
BUTTON_VISUAL_ALIGNMENT_OFFSET,
|
||||
CENTER_COLUMN_OFFSET,
|
||||
HEADER_SLOT_SIZE,
|
||||
SCROLLBAR_OFFSET,
|
||||
} from '#/components/Layout/const'
|
||||
@@ -65,7 +66,7 @@ export function Outer({
|
||||
gtMobile && [a.mx_auto, {maxWidth: 600}],
|
||||
!isWithinOffsetView && {
|
||||
transform: [
|
||||
{translateX: centerColumnOffset ? -150 : 0},
|
||||
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
|
||||
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -14,3 +14,8 @@ export const BUTTON_VISUAL_ALIGNMENT_OFFSET = 3
|
||||
* Corresponds to the width of a small square or round button
|
||||
*/
|
||||
export const HEADER_SLOT_SIZE = 34
|
||||
|
||||
/**
|
||||
* How far to shift the center column when in the tablet breakpoint
|
||||
*/
|
||||
export const CENTER_COLUMN_OFFSET = -105
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
import {SCROLLBAR_OFFSET} from '#/components/Layout/const'
|
||||
import {CENTER_COLUMN_OFFSET, SCROLLBAR_OFFSET} from '#/components/Layout/const'
|
||||
import {ScrollbarOffsetContext} from '#/components/Layout/context'
|
||||
|
||||
export * from '#/components/Layout/const'
|
||||
@@ -173,7 +173,7 @@ export const Center = React.memo(function LayoutContent({
|
||||
centerColumnOffset &&
|
||||
!ignoreTabletLayoutOffset &&
|
||||
!isWithinDialog
|
||||
? -150
|
||||
? CENTER_COLUMN_OFFSET
|
||||
: 0,
|
||||
},
|
||||
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
|
||||
@@ -209,7 +209,7 @@ const WebCenterBorders = React.memo(function LayoutContent() {
|
||||
left: '50%',
|
||||
transform: [
|
||||
{translateX: '-50%'},
|
||||
{translateX: centerColumnOffset ? -150 : 0},
|
||||
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
|
||||
...a.scrollbar_offset.transform,
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -54,6 +54,14 @@ export function Embed({
|
||||
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
</Outer>
|
||||
)
|
||||
} else if (
|
||||
e.type === 'post_with_media' &&
|
||||
// ignore further "nested" RecordWithMedia
|
||||
e.media.type !== 'post_with_media' &&
|
||||
// ignore any unknowns
|
||||
e.media.view !== null
|
||||
) {
|
||||
return <Embed embed={e.media.view} style={style} />
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -2,14 +2,9 @@ import React from 'react'
|
||||
|
||||
import type {ContextType, ItemContextType} from '#/components/Menu/types'
|
||||
|
||||
export const Context = React.createContext<ContextType>({
|
||||
// @ts-ignore
|
||||
control: null,
|
||||
})
|
||||
export const Context = React.createContext<ContextType | null>(null)
|
||||
|
||||
export const ItemContext = React.createContext<ItemContextType>({
|
||||
disabled: false,
|
||||
})
|
||||
export const ItemContext = React.createContext<ItemContextType | null>(null)
|
||||
|
||||
export function useMenuContext() {
|
||||
const context = React.useContext(Context)
|
||||
|
||||
@@ -30,11 +30,13 @@ export {
|
||||
useDialogControl as useMenuControl,
|
||||
} from '#/components/Dialog'
|
||||
|
||||
export {useMenuContext}
|
||||
|
||||
export function Root({
|
||||
children,
|
||||
control,
|
||||
}: React.PropsWithChildren<{
|
||||
control?: Dialog.DialogOuterProps['control']
|
||||
control?: Dialog.DialogControlProps
|
||||
}>) {
|
||||
const defaultControl = Dialog.useDialogControl()
|
||||
const context = React.useMemo<ContextType>(
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {useMenuContext}
|
||||
|
||||
export function useMenuControl(): Dialog.DialogControlProps {
|
||||
const id = React.useId()
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
@@ -50,7 +52,7 @@ export function Root({
|
||||
children,
|
||||
control,
|
||||
}: React.PropsWithChildren<{
|
||||
control?: Dialog.DialogOuterProps['control']
|
||||
control?: Dialog.DialogControlProps
|
||||
}>) {
|
||||
const {_} = useLingui()
|
||||
const defaultControl = useMenuControl()
|
||||
|
||||
@@ -11,7 +11,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
@@ -81,7 +81,7 @@ export function NewskieDialog({
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`New user info dialog`)}
|
||||
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
||||
style={web({width: 'auto', maxWidth: 400, minWidth: 200})}>
|
||||
<View style={[a.gap_md]}>
|
||||
<View style={[a.align_center]}>
|
||||
<View
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
moderateProfile,
|
||||
ModerationOpts,
|
||||
type ModerationOpts,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {LogEvents} from '#/lib/statsig/statsig'
|
||||
import {type LogEvents} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
@@ -18,13 +18,18 @@ import {ProfileCardPills} from '#/view/com/profile/ProfileCard'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
type ButtonProps,
|
||||
ButtonText,
|
||||
} from '#/components/Button'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function Default({
|
||||
profile,
|
||||
@@ -133,7 +138,7 @@ export function Avatar({
|
||||
|
||||
return (
|
||||
<UserAvatar
|
||||
size={42}
|
||||
size={40}
|
||||
avatar={profile.avatar}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
moderation={moderation.ui('avatar')}
|
||||
@@ -147,10 +152,10 @@ export function AvatarPlaceholder() {
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: 42,
|
||||
height: 42,
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -198,7 +203,7 @@ export function NameAndHandlePlaceholder() {
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: '60%',
|
||||
height: 14,
|
||||
@@ -209,7 +214,7 @@ export function NameAndHandlePlaceholder() {
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: '40%',
|
||||
height: 10,
|
||||
@@ -261,7 +266,7 @@ export function DescriptionPlaceholder({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[{gap: 8}]}>
|
||||
<View style={[a.pt_2xs, {gap: 6}]}>
|
||||
{Array(numberOfLines)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
@@ -270,7 +275,7 @@ export function DescriptionPlaceholder({
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
a.w_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
t.atoms.bg_contrast_25,
|
||||
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
|
||||
]}
|
||||
/>
|
||||
@@ -286,6 +291,7 @@ export type FollowButtonProps = {
|
||||
LogEvents['profile:unfollow']['logContext']
|
||||
colorInverted?: boolean
|
||||
onFollow?: () => void
|
||||
withIcon?: boolean
|
||||
} & Partial<ButtonProps>
|
||||
|
||||
export function FollowButton(props: FollowButtonProps) {
|
||||
@@ -301,6 +307,7 @@ export function FollowButtonInner({
|
||||
onPress: onPressProp,
|
||||
onFollow,
|
||||
colorInverted,
|
||||
withIcon = true,
|
||||
...rest
|
||||
}: FollowButtonProps) {
|
||||
const {_} = useLingui()
|
||||
@@ -386,7 +393,9 @@ export function FollowButtonInner({
|
||||
color="secondary"
|
||||
{...rest}
|
||||
onPress={onPressUnfollow}>
|
||||
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
|
||||
{withIcon && (
|
||||
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
|
||||
)}
|
||||
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -397,7 +406,9 @@ export function FollowButtonInner({
|
||||
color={colorInverted ? 'secondary_inverted' : 'primary'}
|
||||
{...rest}
|
||||
onPress={onPressFollow}>
|
||||
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
||||
{withIcon && (
|
||||
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
||||
)}
|
||||
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {ScrollView, TextInput, useWindowDimensions, View} from 'react-native'
|
||||
import Animated, {
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
ZoomInEasyDown,
|
||||
} from 'react-native-reanimated'
|
||||
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
|
||||
import {
|
||||
ScrollView,
|
||||
type StyleProp,
|
||||
TextInput,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {type ModerationOpts} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorSearchPaginated} from '#/state/queries/actor-search'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
|
||||
import {useSession} from '#/state/session'
|
||||
import {Follow10ProgressGuide} from '#/state/shell/progress-guide'
|
||||
import {ListMethods} from '#/view/com/util/List'
|
||||
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
|
||||
import {type ListMethods} from '#/view/com/util/List'
|
||||
import {
|
||||
popularInterests,
|
||||
useInterestsDisplayNames,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
tokens,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
ViewStyleProp,
|
||||
type ViewStyleProp,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
@@ -42,15 +42,14 @@ import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/componen
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ListFooter} from '../Lists'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {ProgressGuideTask} from './Task'
|
||||
|
||||
type Item =
|
||||
| {
|
||||
type: 'profile'
|
||||
key: string
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
isSuggestion: boolean
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}
|
||||
| {
|
||||
type: 'empty'
|
||||
@@ -121,94 +120,39 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
const {currentAccount} = useSession()
|
||||
const [suggestedAccounts, setSuggestedAccounts] = useState<
|
||||
Map<string, AppBskyActorDefs.ProfileView[]>
|
||||
>(() => new Map())
|
||||
|
||||
useEffect(() => {
|
||||
lastSearchText = searchText
|
||||
lastSelectedInterest = selectedInterest
|
||||
}, [searchText, selectedInterest])
|
||||
|
||||
const query = searchText || selectedInterest
|
||||
const {
|
||||
data: suggestions,
|
||||
isFetching: isFetchingSuggestions,
|
||||
error: suggestionsError,
|
||||
} = useGetSuggestedUsersQuery({
|
||||
category: selectedInterest,
|
||||
limit: 50,
|
||||
})
|
||||
const {
|
||||
data: searchResults,
|
||||
isFetching,
|
||||
error,
|
||||
isError,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
isFetching: isFetchingSearchResults,
|
||||
error: searchResultsError,
|
||||
isError: isSearchResultsError,
|
||||
} = useActorSearchPaginated({
|
||||
query,
|
||||
enabled: !!searchText,
|
||||
query: searchText,
|
||||
})
|
||||
|
||||
const hasSearchText = !!searchText
|
||||
|
||||
const resultsKey = searchText || selectedInterest
|
||||
const items = useMemo(() => {
|
||||
const results = searchResults?.pages.flatMap(r => r.actors)
|
||||
const results = hasSearchText
|
||||
? searchResults?.pages.flatMap(p => p.actors)
|
||||
: suggestions?.actors
|
||||
let _items: Item[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
if (isError) {
|
||||
_items.push({
|
||||
type: 'empty',
|
||||
key: 'empty',
|
||||
message: _(msg`We're having network issues, try again`),
|
||||
})
|
||||
} else if (results) {
|
||||
// First pass: search results
|
||||
for (const profile of results) {
|
||||
if (profile.did === currentAccount?.did) continue
|
||||
if (profile.viewer?.following) continue
|
||||
// my sincere apologies to Jake Gold - your bio is too keyword-filled and
|
||||
// your page-rank too high, so you're at the top of half the categories -sfn
|
||||
if (
|
||||
!hasSearchText &&
|
||||
profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' &&
|
||||
// constrain to 'tech'
|
||||
selectedInterest !== 'tech'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
seen.add(profile.did)
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
// Don't share identity across tabs or typing attempts
|
||||
key: query + ':' + profile.did,
|
||||
profile,
|
||||
isSuggestion: false,
|
||||
})
|
||||
}
|
||||
// Second pass: suggestions
|
||||
_items = _items.flatMap(item => {
|
||||
if (item.type !== 'profile') {
|
||||
return item
|
||||
}
|
||||
const suggestions = suggestedAccounts.get(item.profile.did)
|
||||
if (!suggestions) {
|
||||
return item
|
||||
}
|
||||
const itemWithSuggestions = [item]
|
||||
for (const suggested of suggestions) {
|
||||
if (seen.has(suggested.did)) {
|
||||
// Skip search results from previous step or already seen suggestions
|
||||
continue
|
||||
}
|
||||
seen.add(suggested.did)
|
||||
itemWithSuggestions.push({
|
||||
type: 'profile',
|
||||
key: suggested.did,
|
||||
profile: suggested,
|
||||
isSuggestion: true,
|
||||
})
|
||||
if (itemWithSuggestions.length === 1 + 3) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return itemWithSuggestions
|
||||
})
|
||||
} else {
|
||||
if (isFetchingSuggestions || isFetchingSearchResults) {
|
||||
const placeholders: Item[] = Array(10)
|
||||
.fill(0)
|
||||
.map((__, i) => ({
|
||||
@@ -217,21 +161,54 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
|
||||
}))
|
||||
|
||||
_items.push(...placeholders)
|
||||
} else if (
|
||||
(hasSearchText && searchResultsError) ||
|
||||
(!hasSearchText && suggestionsError) ||
|
||||
!results?.length
|
||||
) {
|
||||
_items.push({
|
||||
type: 'empty',
|
||||
key: 'empty',
|
||||
message: _(msg`We're having network issues, try again`),
|
||||
})
|
||||
} else {
|
||||
const seen = new Set<string>()
|
||||
for (const profile of results) {
|
||||
if (seen.has(profile.did)) continue
|
||||
if (profile.did === currentAccount?.did) continue
|
||||
if (profile.viewer?.following) continue
|
||||
|
||||
seen.add(profile.did)
|
||||
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
// Don't share identity across tabs or typing attempts
|
||||
key: resultsKey + ':' + profile.did,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return _items
|
||||
}, [
|
||||
_,
|
||||
suggestions,
|
||||
suggestionsError,
|
||||
isFetchingSuggestions,
|
||||
searchResults,
|
||||
isError,
|
||||
searchResultsError,
|
||||
isFetchingSearchResults,
|
||||
currentAccount?.did,
|
||||
hasSearchText,
|
||||
selectedInterest,
|
||||
suggestedAccounts,
|
||||
query,
|
||||
resultsKey,
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
if (
|
||||
searchText &&
|
||||
!isFetchingSearchResults &&
|
||||
!items.length &&
|
||||
!isSearchResultsError
|
||||
) {
|
||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
}
|
||||
|
||||
@@ -242,9 +219,7 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
|
||||
return (
|
||||
<FollowProfileCard
|
||||
profile={item.profile}
|
||||
isSuggestion={item.isSuggestion}
|
||||
moderationOpts={moderationOpts!}
|
||||
setSuggestedAccounts={setSuggestedAccounts}
|
||||
noBorder={index === 0}
|
||||
/>
|
||||
)
|
||||
@@ -290,15 +265,6 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
|
||||
/>
|
||||
)
|
||||
|
||||
const onEndReached = useCallback(async () => {
|
||||
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more people to follow', {message: err})
|
||||
}
|
||||
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
return (
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
@@ -318,15 +284,6 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
|
||||
scrollIndicatorInsets={{top: headerHeight}}
|
||||
initialNumToRender={8}
|
||||
maxToRenderPerBatch={8}
|
||||
onEndReached={onEndReached}
|
||||
itemLayoutAnimation={LinearTransition}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -452,15 +409,18 @@ let Tabs = ({
|
||||
selectedInterest,
|
||||
hasSearchText,
|
||||
interestsDisplayNames,
|
||||
TabComponent = Tab,
|
||||
contentContainerStyle,
|
||||
}: {
|
||||
onSelectTab: (tab: string) => void
|
||||
interests: string[]
|
||||
selectedInterest: string
|
||||
hasSearchText: boolean
|
||||
interestsDisplayNames: Record<string, string>
|
||||
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
}): React.ReactNode => {
|
||||
const listRef = useRef<ScrollView>(null)
|
||||
const [scrollX, setScrollX] = useState(0)
|
||||
const [totalWidth, setTotalWidth] = useState(0)
|
||||
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
|
||||
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
|
||||
@@ -479,24 +439,11 @@ let Tabs = ({
|
||||
function scrollIntoViewIfNeeded(index: number) {
|
||||
const btnLayout = tabOffsets[index]
|
||||
if (!btnLayout) return
|
||||
|
||||
const viewportLeftEdge = scrollX
|
||||
const viewportRightEdge = scrollX + totalWidth
|
||||
const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x
|
||||
const shouldScrollToRightEdge =
|
||||
viewportRightEdge < btnLayout.x + btnLayout.width
|
||||
|
||||
if (shouldScrollToLeftEdge) {
|
||||
listRef.current?.scrollTo({
|
||||
x: btnLayout.x - tokens.space.lg,
|
||||
animated: true,
|
||||
})
|
||||
} else if (shouldScrollToRightEdge) {
|
||||
listRef.current?.scrollTo({
|
||||
x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg,
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
listRef.current?.scrollTo({
|
||||
// centered
|
||||
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
|
||||
function handleSelectTab(index: number) {
|
||||
@@ -518,7 +465,7 @@ let Tabs = ({
|
||||
<ScrollView
|
||||
ref={listRef}
|
||||
horizontal
|
||||
contentContainerStyle={[a.gap_sm, a.px_lg]}
|
||||
contentContainerStyle={[a.gap_sm, a.px_lg, contentContainerStyle]}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate="fast"
|
||||
snapToOffsets={
|
||||
@@ -528,11 +475,11 @@ let Tabs = ({
|
||||
}
|
||||
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
|
||||
scrollEventThrottle={200} // big throttle
|
||||
onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}>
|
||||
>
|
||||
{interests.map((interest, i) => {
|
||||
const active = interest === selectedInterest && !hasSearchText
|
||||
return (
|
||||
<Tab
|
||||
<TabComponent
|
||||
key={interest}
|
||||
onSelectTab={handleSelectTab}
|
||||
active={active}
|
||||
@@ -547,6 +494,7 @@ let Tabs = ({
|
||||
)
|
||||
}
|
||||
Tabs = memo(Tabs)
|
||||
export {Tabs}
|
||||
|
||||
let Tab = ({
|
||||
onSelectTab,
|
||||
@@ -563,6 +511,7 @@ let Tab = ({
|
||||
interestsDisplayName: string
|
||||
onLayout: (index: number, x: number, width: number) => void
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const activeText = active ? _(msg` (active)`) : ''
|
||||
return (
|
||||
@@ -573,12 +522,32 @@ let Tab = ({
|
||||
}>
|
||||
<Button
|
||||
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
|
||||
variant={active ? 'solid' : 'outline'}
|
||||
color={active ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
onPress={() => onSelectTab(index)}>
|
||||
<ButtonIcon icon={SearchIcon} />
|
||||
<ButtonText>{interestsDisplayName}</ButtonText>
|
||||
{({hovered, pressed, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.px_lg,
|
||||
a.py_sm,
|
||||
a.border,
|
||||
active || hovered || pressed || focused
|
||||
? [
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
|
||||
]
|
||||
: [t.atoms.bg, t.atoms.border_contrast_low],
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
/* TODO: medium weight */
|
||||
active || hovered || pressed || focused
|
||||
? t.atoms.text
|
||||
: t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{interestsDisplayName}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
@@ -588,49 +557,18 @@ Tab = memo(Tab)
|
||||
let FollowProfileCard = ({
|
||||
profile,
|
||||
moderationOpts,
|
||||
isSuggestion,
|
||||
setSuggestedAccounts,
|
||||
noBorder,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
isSuggestion: boolean
|
||||
setSuggestedAccounts: (
|
||||
updater: (
|
||||
v: Map<string, AppBskyActorDefs.ProfileView[]>,
|
||||
) => Map<string, AppBskyActorDefs.ProfileView[]>,
|
||||
) => void
|
||||
noBorder?: boolean
|
||||
}): React.ReactNode => {
|
||||
const [hasFollowed, setHasFollowed] = useState(false)
|
||||
const followupSuggestion = useSuggestedFollowsByActorQuery({
|
||||
did: profile.did,
|
||||
enabled: hasFollowed,
|
||||
})
|
||||
const candidates = followupSuggestion.data?.suggestions
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Move out of effect.
|
||||
if (hasFollowed && candidates && candidates.length > 0) {
|
||||
setSuggestedAccounts(suggestions => {
|
||||
const newSuggestions = new Map(suggestions)
|
||||
newSuggestions.set(profile.did, candidates)
|
||||
return newSuggestions
|
||||
})
|
||||
}
|
||||
}, [hasFollowed, profile.did, candidates, setSuggestedAccounts])
|
||||
|
||||
return (
|
||||
<LayoutAnimationConfig skipEntering={!isSuggestion}>
|
||||
<Animated.View entering={native(ZoomInEasyDown)}>
|
||||
<FollowProfileCardInner
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
onFollow={() => setHasFollowed(true)}
|
||||
noBorder={noBorder}
|
||||
/>
|
||||
</Animated.View>
|
||||
</LayoutAnimationConfig>
|
||||
<FollowProfileCardInner
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
noBorder={noBorder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
FollowProfileCard = memo(FollowProfileCard)
|
||||
@@ -641,7 +579,7 @@ function FollowProfileCardInner({
|
||||
onFollow,
|
||||
noBorder,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
onFollow?: () => void
|
||||
noBorder?: boolean
|
||||
@@ -658,7 +596,7 @@ function FollowProfileCardInner({
|
||||
style={[
|
||||
a.flex_1,
|
||||
noBorder && a.border_t_0,
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
@@ -822,7 +760,7 @@ function Empty({message}: {message: string}) {
|
||||
)
|
||||
}
|
||||
|
||||
function boostInterests(boosts?: string[]) {
|
||||
export function boostInterests(boosts?: string[]) {
|
||||
return (_a: string, _b: string) => {
|
||||
const indexA = boosts?.indexOf(_a) ?? -1
|
||||
const indexB = boosts?.indexOf(_b) ?? -1
|
||||
|
||||
@@ -6,12 +6,12 @@ import React, {
|
||||
} from 'react'
|
||||
import {
|
||||
findNodeHandle,
|
||||
ListRenderItemInfo,
|
||||
StyleProp,
|
||||
type ListRenderItemInfo,
|
||||
type StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {AppBskyGraphDefs} from '@atproto/api'
|
||||
import {type AppBskyGraphDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
@@ -20,11 +20,11 @@ import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
|
||||
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||
import {useEmail} from '#/lib/hooks/useEmail'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {parseStarterPackUri} from '#/lib/strings/starter-pack'
|
||||
import {logger} from '#/logger'
|
||||
import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
|
||||
import {List, ListRef} from '#/view/com/util/List'
|
||||
import {List, type ListRef} from '#/view/com/util/List'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {atoms as a, ios, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
@@ -75,8 +75,14 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
const t = useTheme()
|
||||
const bottomBarOffset = useBottomBarOffset(100)
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
const {data, refetch, isFetching, hasNextPage, fetchNextPage} =
|
||||
useActorStarterPacksQuery({did, enabled})
|
||||
const {
|
||||
data,
|
||||
refetch,
|
||||
isError,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useActorStarterPacksQuery({did, enabled})
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
|
||||
const items = data?.pages.flatMap(page => page.starterPacks)
|
||||
@@ -95,15 +101,14 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
setIsPTRing(false)
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = useCallback(async () => {
|
||||
if (isFetching || !hasNextPage) return
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more starter packs', {message: err})
|
||||
}
|
||||
}, [isFetching, hasNextPage, fetchNextPage])
|
||||
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && scrollElRef.current) {
|
||||
@@ -112,21 +117,21 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
}
|
||||
}, [enabled, scrollElRef, setScrollViewTag])
|
||||
|
||||
const renderItem = ({
|
||||
item,
|
||||
index,
|
||||
}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
(isTabletOrDesktop || index !== 0) && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<StarterPackCard starterPack={item} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const renderItem = useCallback(
|
||||
({item, index}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
(isTabletOrDesktop || index !== 0) && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<StarterPackCard starterPack={item} />
|
||||
</View>
|
||||
)
|
||||
},
|
||||
[isTabletOrDesktop, t.atoms.border_contrast_low],
|
||||
)
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
@@ -13,7 +13,10 @@ import {precacheStarterPack} from '#/state/queries/starter-packs'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack'
|
||||
import {Link as BaseLink, LinkProps as BaseLinkProps} from '#/components/Link'
|
||||
import {
|
||||
Link as BaseLink,
|
||||
type LinkProps as BaseLinkProps,
|
||||
} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
@@ -95,13 +98,41 @@ export function Card({
|
||||
) : null}
|
||||
{!!joinedAllTimeCount && joinedAllTimeCount >= 50 && (
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
{joinedAllTimeCount} users have joined!
|
||||
<Trans comment="Number of users (always at least 50) who have joined Bluesky using a specific starter pack">
|
||||
<Plural value={joinedAllTimeCount} other="# users have" /> joined!
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function useStarterPackLink({
|
||||
view,
|
||||
}: {
|
||||
view: bsky.starterPack.AnyStarterPackView
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const qc = useQueryClient()
|
||||
const {rkey, handleOrDid} = React.useMemo(() => {
|
||||
const rkey = new AtUri(view.uri).rkey
|
||||
const {creator} = view
|
||||
return {rkey, handleOrDid: creator.handle || creator.did}
|
||||
}, [view])
|
||||
const precache = () => {
|
||||
precacheResolvedUri(qc, view.creator.handle, view.creator.did)
|
||||
precacheStarterPack(qc, view)
|
||||
}
|
||||
|
||||
return {
|
||||
to: `/starter-pack/${handleOrDid}/${rkey}`,
|
||||
label: AppBskyGraphStarterpack.isRecord(view.record)
|
||||
? _(msg`Navigate to ${view.record.name}`)
|
||||
: _(msg`Navigate to starter pack`),
|
||||
precache,
|
||||
}
|
||||
}
|
||||
|
||||
export function Link({
|
||||
starterPack,
|
||||
children,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
|
||||
|
||||
export function SubtleHover({style, hover}: ViewStyleProp & {hover: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
let opacity: number
|
||||
switch (t.name) {
|
||||
case 'dark':
|
||||
opacity = 0.4
|
||||
break
|
||||
case 'dim':
|
||||
opacity = 0.45
|
||||
break
|
||||
case 'light':
|
||||
opacity = 0.5
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.pointer_events_none,
|
||||
a.transition_opacity,
|
||||
t.atoms.bg_contrast_25,
|
||||
style,
|
||||
{opacity: hover ? opacity : 0},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,10 @@
|
||||
import React from 'react'
|
||||
import {Keyboard} from 'react-native'
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
@@ -28,71 +16,30 @@ export function ActionsWrapper({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const playHaptic = useHaptics()
|
||||
const menuControl = useMenuControl()
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{scale: scale.get()}],
|
||||
}))
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
playHaptic()
|
||||
Keyboard.dismiss()
|
||||
menuControl.open()
|
||||
}, [menuControl, playHaptic])
|
||||
|
||||
const shrink = React.useCallback(() => {
|
||||
'worklet'
|
||||
cancelAnimation(scale)
|
||||
scale.set(() => withTiming(1, {duration: 200}))
|
||||
}, [scale])
|
||||
|
||||
const doubleTapGesture = Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.hitSlop(HITSLOP_10)
|
||||
.onEnd(open)
|
||||
.runOnJS(true)
|
||||
|
||||
const pressAndHoldGesture = Gesture.LongPress()
|
||||
.onStart(() => {
|
||||
'worklet'
|
||||
scale.set(() =>
|
||||
withTiming(1.05, {duration: 200}, finished => {
|
||||
if (!finished) return
|
||||
runOnJS(open)()
|
||||
shrink()
|
||||
}),
|
||||
)
|
||||
})
|
||||
.onTouchesUp(shrink)
|
||||
.onTouchesMove(shrink)
|
||||
.cancelsTouchesInView(false)
|
||||
|
||||
const composedGestures = Gesture.Exclusive(
|
||||
doubleTapGesture,
|
||||
pressAndHoldGesture,
|
||||
)
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={composedGestures}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
maxWidth: '80%',
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
animatedStyle,
|
||||
]}
|
||||
accessible={true}
|
||||
accessibilityActions={[
|
||||
{name: 'activate', label: _(msg`Open message options`)},
|
||||
]}
|
||||
onAccessibilityAction={open}>
|
||||
{children}
|
||||
<MessageMenu message={message} control={menuControl} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<MessageContextMenu message={message}>
|
||||
{trigger =>
|
||||
// will always be true, since this file is platform split
|
||||
trigger.isNative && (
|
||||
<View style={[a.flex_1, a.relative]}>
|
||||
<View
|
||||
style={[
|
||||
{maxWidth: '80%'},
|
||||
isFromSelf
|
||||
? [a.self_end, a.align_end]
|
||||
: [a.self_start, a.align_start],
|
||||
]}
|
||||
accessible={true}
|
||||
accessibilityActions={[
|
||||
{name: 'activate', label: _(msg`Open message options`)},
|
||||
]}
|
||||
onAccessibilityAction={() => trigger.control.open('full')}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
</MessageContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {useCallback, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type React from 'react'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
||||
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
||||
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
||||
import {hasReachedReactionLimit} from './util'
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
@@ -15,26 +24,53 @@ export function ActionsWrapper({
|
||||
isFromSelf: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const menuControl = useMenuControl()
|
||||
const viewRef = React.useRef(null)
|
||||
const viewRef = useRef(null)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const convo = useConvoActive()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const [showActions, setShowActions] = React.useState(false)
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
|
||||
const onMouseEnter = React.useCallback(() => {
|
||||
const onMouseEnter = useCallback(() => {
|
||||
setShowActions(true)
|
||||
}, [])
|
||||
|
||||
const onMouseLeave = React.useCallback(() => {
|
||||
const onMouseLeave = useCallback(() => {
|
||||
setShowActions(false)
|
||||
}, [])
|
||||
|
||||
// We need to handle the `onFocus` separately because we want to know if there is a related target (the element
|
||||
// that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
|
||||
const onFocus = React.useCallback<React.FocusEventHandler>(e => {
|
||||
const onFocus = useCallback<React.FocusEventHandler>(e => {
|
||||
if (e.nativeEvent.relatedTarget == null) return
|
||||
setShowActions(true)
|
||||
}, [])
|
||||
|
||||
const onEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
if (
|
||||
message.reactions?.find(
|
||||
reaction =>
|
||||
reaction.value === emoji &&
|
||||
reaction.sender.did === currentAccount?.did,
|
||||
)
|
||||
) {
|
||||
convo
|
||||
.removeReaction(message.id, emoji)
|
||||
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
|
||||
} else {
|
||||
if (hasReachedReactionLimit(message, currentAccount?.did)) return
|
||||
convo
|
||||
.addReaction(message.id, emoji)
|
||||
.catch(() =>
|
||||
Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
|
||||
)
|
||||
}
|
||||
},
|
||||
[_, convo, message, currentAccount?.did],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
// @ts-expect-error web only
|
||||
@@ -42,39 +78,66 @@ export function ActionsWrapper({
|
||||
onMouseLeave={onMouseLeave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onMouseLeave}
|
||||
style={StyleSheet.flatten([a.flex_1, a.flex_row])}
|
||||
style={[a.flex_1, isFromSelf ? a.flex_row : a.flex_row_reverse]}
|
||||
ref={viewRef}>
|
||||
{isFromSelf && (
|
||||
<View
|
||||
style={[
|
||||
a.mr_xl,
|
||||
a.justify_center,
|
||||
{
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
]}>
|
||||
<MessageMenu
|
||||
message={message}
|
||||
control={menuControl}
|
||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
maxWidth: '80%',
|
||||
}}>
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
isFromSelf
|
||||
? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse]
|
||||
: [a.ml_xs, {marginRight: 'auto'}],
|
||||
]}>
|
||||
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
|
||||
{({props, state, isNative, control}) => {
|
||||
// always false, file is platform split
|
||||
if (isNative) return null
|
||||
const showMenuTrigger = showActions || control.isOpen ? 1 : 0
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
style={[
|
||||
{opacity: showMenuTrigger},
|
||||
a.p_xs,
|
||||
a.rounded_full,
|
||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<EmojiSmileIcon
|
||||
size="md"
|
||||
style={t.atoms.text_contrast_medium}
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
</EmojiReactionPicker>
|
||||
<MessageContextMenu message={message}>
|
||||
{({props, state, isNative, control}) => {
|
||||
// always false, file is platform split
|
||||
if (isNative) return null
|
||||
const showMenuTrigger = showActions || control.isOpen ? 1 : 0
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
style={[
|
||||
{opacity: showMenuTrigger},
|
||||
a.p_xs,
|
||||
a.rounded_full,
|
||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<DotsHorizontalIcon
|
||||
size="md"
|
||||
style={t.atoms.text_contrast_medium}
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
</MessageContextMenu>
|
||||
</View>
|
||||
<View
|
||||
style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}>
|
||||
{children}
|
||||
</View>
|
||||
{!isFromSelf && (
|
||||
<View style={[a.flex_row, a.align_center, a.ml_xl]}>
|
||||
<MessageMenu
|
||||
message={message}
|
||||
control={menuControl}
|
||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {useState} from 'react'
|
||||
import {Modal, Pressable, View} from 'react-native'
|
||||
// @ts-expect-error internal component, not supposed to be used directly
|
||||
// waiting on more customisability: https://github.com/okwasniewski/react-native-emoji-popup/issues/1#issuecomment-2737463753
|
||||
import EmojiPopupView from 'react-native-emoji-popup/src/EmojiPopupViewNativeComponent'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function EmojiPopup({
|
||||
children,
|
||||
onEmojiSelected,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onEmojiSelected: (emoji: string) => void
|
||||
}) {
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
accessibilityLabel={_(msg`Open full emoji list`)}
|
||||
accessibilityHint=""
|
||||
accessibilityRole="button"
|
||||
onPress={() => setModalVisible(true)}>
|
||||
{children}
|
||||
</Pressable>
|
||||
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
visible={modalVisible}
|
||||
onRequestClose={() => setModalVisible(false)}>
|
||||
<View style={[a.flex_1, {backgroundColor: t.palette.white}]}>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
a.pl_lg,
|
||||
a.pr_md,
|
||||
a.py_sm,
|
||||
a.w_full,
|
||||
a.align_center,
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Text style={[a.font_bold, a.text_md]}>
|
||||
<Trans>Add Reaction</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
onPress={() => setModalVisible(false)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round">
|
||||
<ButtonIcon icon={TimesLarge_Stroke2_Corner0_Rounded} />
|
||||
</Button>
|
||||
</View>
|
||||
<EmojiPopupView
|
||||
onEmojiSelected={({
|
||||
nativeEvent: {emoji},
|
||||
}: {
|
||||
nativeEvent: {emoji: string}
|
||||
}) => {
|
||||
setModalVisible(false)
|
||||
onEmojiSelected(emoji)
|
||||
}}
|
||||
style={[a.flex_1, a.w_full]}
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {EmojiPopup} from 'react-native-emoji-popup'
|
||||
@@ -0,0 +1,137 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import * as ContextMenu from '#/components/ContextMenu'
|
||||
import {
|
||||
useContextMenuContext,
|
||||
useContextMenuMenuContext,
|
||||
} from '#/components/ContextMenu/context'
|
||||
import {
|
||||
EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyesIcon,
|
||||
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon,
|
||||
} from '#/components/icons/Emoji'
|
||||
import {type TriggerProps} from '#/components/Menu/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {EmojiPopup} from './EmojiPopup'
|
||||
import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
|
||||
|
||||
export function EmojiReactionPicker({
|
||||
message,
|
||||
onEmojiSelect,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
children?: TriggerProps['children']
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const t = useTheme()
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
const {measurement, close} = useContextMenuContext()
|
||||
const {align} = useContextMenuMenuContext()
|
||||
const [layout, setLayout] = useState({width: 0, height: 0})
|
||||
const {width: screenWidth} = useWindowDimensions()
|
||||
|
||||
// 1 in 100 chance of showing heart eyes icon
|
||||
const EmojiIcon = useMemo(() => {
|
||||
return Math.random() < 0.01 ? EmojiHeartEyesIcon : EmojiSmileIcon
|
||||
}, [])
|
||||
|
||||
const position = useMemo(() => {
|
||||
return {
|
||||
x: align === 'left' ? 12 : screenWidth - layout.width - 12,
|
||||
y: (measurement?.y ?? 0) - tokens.space.xs - layout.height,
|
||||
height: layout.height,
|
||||
width: layout.width,
|
||||
}
|
||||
}, [measurement, align, screenWidth, layout])
|
||||
|
||||
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
|
||||
|
||||
const bgColor = t.scheme === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25
|
||||
|
||||
return (
|
||||
<View
|
||||
onLayout={evt => setLayout(evt.nativeEvent.layout)}
|
||||
style={[
|
||||
bgColor,
|
||||
a.rounded_full,
|
||||
a.absolute,
|
||||
{bottom: '100%'},
|
||||
isFromSelf ? a.right_0 : a.left_0,
|
||||
a.flex_row,
|
||||
a.p_xs,
|
||||
a.gap_xs,
|
||||
a.mb_xs,
|
||||
a.z_20,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.shadow_md,
|
||||
]}>
|
||||
{['👍', '😆', '❤️', '👀', '😢'].map(emoji => {
|
||||
const alreadyReacted = hasAlreadyReacted(
|
||||
message,
|
||||
currentAccount?.did,
|
||||
emoji,
|
||||
)
|
||||
return (
|
||||
<ContextMenu.Item
|
||||
position={position}
|
||||
label={_(msg`React with ${emoji}`)}
|
||||
key={emoji}
|
||||
onPress={() => onEmojiSelect(emoji)}
|
||||
unstyled
|
||||
disabled={limitReacted ? !alreadyReacted : false}>
|
||||
{hovered => (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
hovered
|
||||
? {
|
||||
backgroundColor: alreadyReacted
|
||||
? t.palette.negative_100
|
||||
: t.palette.primary_500,
|
||||
}
|
||||
: alreadyReacted
|
||||
? {backgroundColor: t.palette.primary_200}
|
||||
: bgColor,
|
||||
{height: 40, width: 40},
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
]}>
|
||||
<Text style={[a.text_center, {fontSize: 30}]} emoji>
|
||||
{emoji}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ContextMenu.Item>
|
||||
)
|
||||
})}
|
||||
<EmojiPopup
|
||||
onEmojiSelected={emoji => {
|
||||
close()
|
||||
onEmojiSelect(emoji)
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
t.scheme === 'light'
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg_contrast_50,
|
||||
{height: 40, width: 40},
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<EmojiIcon size="xl" fill={t.palette.contrast_400} />
|
||||
</View>
|
||||
</EmojiPopup>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import EmojiPicker from '@emoji-mart/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||
import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {type TriggerProps} from '#/components/Menu/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
|
||||
|
||||
export function EmojiReactionPicker({
|
||||
message,
|
||||
children,
|
||||
onEmojiSelect,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
children?: TriggerProps['children']
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
}) {
|
||||
if (!children)
|
||||
throw new Error('EmojiReactionPicker requires the children prop on web')
|
||||
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<MenuInner message={message} onEmojiSelect={onEmojiSelect} />
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuInner({
|
||||
message,
|
||||
onEmojiSelect,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {control} = Menu.useMenuContext()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
useWebPreloadEmoji({immediate: true})
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const handleEmojiPickerResponse = (emoji: Emoji) => {
|
||||
handleEmojiSelect(emoji.native)
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
control.close()
|
||||
onEmojiSelect(emoji)
|
||||
}
|
||||
|
||||
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
|
||||
|
||||
return expanded ? (
|
||||
<EmojiPicker onEmojiSelect={handleEmojiPickerResponse} autoFocus={true} />
|
||||
) : (
|
||||
<Menu.Outer style={[a.rounded_full]}>
|
||||
<View style={[a.flex_row, a.gap_xs]}>
|
||||
{['👍', '😆', '❤️', '👀', '😢'].map(emoji => {
|
||||
const alreadyReacted = hasAlreadyReacted(
|
||||
message,
|
||||
currentAccount?.did,
|
||||
emoji,
|
||||
)
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
key={emoji}
|
||||
className={[
|
||||
'EmojiReactionPicker__Pressable',
|
||||
alreadyReacted && '__selected',
|
||||
limitReacted && '__disabled',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onSelect={() => handleEmojiSelect(emoji)}
|
||||
style={flatten([
|
||||
a.flex,
|
||||
a.flex_col,
|
||||
a.rounded_full,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.transition_transform,
|
||||
{
|
||||
width: 34,
|
||||
height: 34,
|
||||
},
|
||||
alreadyReacted && {
|
||||
backgroundColor: t.atoms.bg_contrast_100.backgroundColor,
|
||||
},
|
||||
])}>
|
||||
<Text style={[a.text_center, {fontSize: 28}]} emoji>
|
||||
{emoji}
|
||||
</Text>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
})}
|
||||
<DropdownMenu.Item
|
||||
asChild
|
||||
className="EmojiReactionPicker__PickerButton">
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
role="button"
|
||||
onPress={() => setExpanded(true)}
|
||||
style={flatten([
|
||||
a.rounded_full,
|
||||
{height: 34, width: 34},
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
])}>
|
||||
<DotGridIcon size="lg" style={t.atoms.text_contrast_medium} />
|
||||
</Pressable>
|
||||
</DropdownMenu.Item>
|
||||
</View>
|
||||
</Menu.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import {memo, useCallback} from 'react'
|
||||
import {LayoutAnimation} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {getTranslatorLink} from '#/locale/helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import * as ContextMenu from '#/components/ContextMenu'
|
||||
import {type TriggerProps} from '#/components/ContextMenu/types'
|
||||
import {ReportDialog} from '#/components/dms/ReportDialog'
|
||||
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
||||
import {hasReachedReactionLimit} from './util'
|
||||
|
||||
export let MessageContextMenu = ({
|
||||
message,
|
||||
children,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
children: TriggerProps['children']
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const convo = useConvoActive()
|
||||
const deleteControl = usePromptControl()
|
||||
const reportControl = usePromptControl()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
|
||||
const onCopyMessage = useCallback(() => {
|
||||
const str = richTextToString(
|
||||
new RichText({
|
||||
text: message.text,
|
||||
facets: message.facets,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
Clipboard.setStringAsync(str)
|
||||
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
|
||||
}, [_, message.text, message.facets])
|
||||
|
||||
const onPressTranslateMessage = useCallback(() => {
|
||||
const translatorUrl = getTranslatorLink(
|
||||
message.text,
|
||||
langPrefs.primaryLanguage,
|
||||
)
|
||||
openLink(translatorUrl, true)
|
||||
|
||||
logger.metric('translate', {
|
||||
sourceLanguages: [],
|
||||
targetLanguage: langPrefs.primaryLanguage,
|
||||
textLength: message.text.length,
|
||||
})
|
||||
}, [langPrefs.primaryLanguage, message.text, openLink])
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(message.id)
|
||||
.then(() =>
|
||||
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
|
||||
)
|
||||
.catch(() => Toast.show(_(msg`Failed to delete message`)))
|
||||
}, [_, convo, message.id])
|
||||
|
||||
const onEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
if (
|
||||
message.reactions?.find(
|
||||
reaction =>
|
||||
reaction.value === emoji &&
|
||||
reaction.sender.did === currentAccount?.did,
|
||||
)
|
||||
) {
|
||||
convo
|
||||
.removeReaction(message.id, emoji)
|
||||
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
|
||||
} else {
|
||||
if (hasReachedReactionLimit(message, currentAccount?.did)) return
|
||||
convo
|
||||
.addReaction(message.id, emoji)
|
||||
.catch(() =>
|
||||
Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
|
||||
)
|
||||
}
|
||||
},
|
||||
[_, convo, message, currentAccount?.did],
|
||||
)
|
||||
|
||||
const sender = convo.convo.members.find(
|
||||
member => member.did === message.sender.did,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
{isNative && (
|
||||
<ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}>
|
||||
<EmojiReactionPicker
|
||||
message={message}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
/>
|
||||
</ContextMenu.AuxiliaryView>
|
||||
)}
|
||||
|
||||
<ContextMenu.Trigger
|
||||
label={_(msg`Message options`)}
|
||||
contentLabel={_(
|
||||
msg`Message from @${
|
||||
sender?.handle ?? // should always be defined
|
||||
'unknown'
|
||||
}: ${message.text}`,
|
||||
)}>
|
||||
{children}
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Outer align={isFromSelf ? 'right' : 'left'}>
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownTranslateBtn"
|
||||
label={_(msg`Translate`)}
|
||||
onPress={onPressTranslateMessage}>
|
||||
<ContextMenu.ItemText>{_(msg`Translate`)}</ContextMenu.ItemText>
|
||||
<ContextMenu.ItemIcon icon={Translate} position="right" />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={_(msg`Copy message text`)}
|
||||
onPress={onCopyMessage}>
|
||||
<ContextMenu.ItemText>
|
||||
{_(msg`Copy message text`)}
|
||||
</ContextMenu.ItemText>
|
||||
<ContextMenu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Divider />
|
||||
</>
|
||||
)}
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={_(msg`Delete message for me`)}
|
||||
onPress={() => deleteControl.open()}>
|
||||
<ContextMenu.ItemText>{_(msg`Delete for me`)}</ContextMenu.ItemText>
|
||||
<ContextMenu.ItemIcon icon={Trash} position="right" />
|
||||
</ContextMenu.Item>
|
||||
{!isFromSelf && (
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownReportBtn"
|
||||
label={_(msg`Report message`)}
|
||||
onPress={() => reportControl.open()}>
|
||||
<ContextMenu.ItemText>{_(msg`Report`)}</ContextMenu.ItemText>
|
||||
<ContextMenu.ItemIcon icon={Warning} position="right" />
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.Outer>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<ReportDialog
|
||||
currentScreen="conversation"
|
||||
params={{type: 'convoMessage', convoId: convo.convo.id, message}}
|
||||
control={reportControl}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={deleteControl}
|
||||
title={_(msg`Delete message`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Delete`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
MessageContextMenu = memo(MessageContextMenu)
|
||||
@@ -1,29 +1,37 @@
|
||||
import React, {useCallback, useMemo, useRef} from 'react'
|
||||
import React, {useCallback, useMemo} from 'react'
|
||||
import {
|
||||
GestureResponderEvent,
|
||||
LayoutAnimation,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from 'react-native-reanimated'
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
ChatBskyConvoDefs,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {I18n} from '@lingui/core'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ConvoItem} from '#/state/messages/convo/types'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {type ConvoItem} from '#/state/messages/convo/types'
|
||||
import {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {isOnlyEmoji} from '#/alf/typography'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {RichText} from '../RichText'
|
||||
import {DateDivider} from './DateDivider'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
import {localDateString} from './util'
|
||||
@@ -35,6 +43,8 @@ let MessageItem = ({
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
const {convo} = useConvoActive()
|
||||
|
||||
const {message, nextMessage, prevMessage} = item
|
||||
const isPending = item.type === 'pending-message'
|
||||
@@ -88,18 +98,80 @@ let MessageItem = ({
|
||||
return true
|
||||
}, [message, nextMessage, isPending])
|
||||
|
||||
const lastInGroupRef = useRef(isLastInGroup)
|
||||
if (lastInGroupRef.current !== isLastInGroup) {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
lastInGroupRef.current = isLastInGroup
|
||||
}
|
||||
|
||||
const pendingColor = t.palette.primary_200
|
||||
|
||||
const rt = useMemo(() => {
|
||||
return new RichTextAPI({text: message.text, facets: message.facets})
|
||||
}, [message.text, message.facets])
|
||||
|
||||
const appliedReactions = (
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
{message.reactions && message.reactions.length > 0 && (
|
||||
<View
|
||||
style={[isFromSelf ? a.align_end : a.align_start, a.px_sm, a.pb_2xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_2xs,
|
||||
a.py_xs,
|
||||
a.px_xs,
|
||||
a.justify_center,
|
||||
isFromSelf ? a.justify_end : a.justify_start,
|
||||
a.flex_wrap,
|
||||
a.pb_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_lg,
|
||||
t.atoms.shadow_sm,
|
||||
{
|
||||
// vibe coded number
|
||||
transform: [{translateY: -11}],
|
||||
},
|
||||
]}>
|
||||
{message.reactions.map((reaction, _i, reactions) => {
|
||||
let label
|
||||
if (reaction.sender.did === currentAccount?.did) {
|
||||
label = _(msg`You reacted ${reaction.value}`)
|
||||
} else {
|
||||
const senderDid = reaction.sender.did
|
||||
const sender = convo.members.find(
|
||||
member => member.did === senderDid,
|
||||
)
|
||||
if (sender) {
|
||||
label = _(
|
||||
msg`${sanitizeDisplayName(
|
||||
sender.displayName || sender.handle,
|
||||
)} reacted ${reaction.value}`,
|
||||
)
|
||||
} else {
|
||||
label = _(msg`Someone reacted ${reaction.value}`)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Animated.View
|
||||
entering={native(ZoomIn.springify(200).delay(400))}
|
||||
exiting={reactions.length > 1 && native(ZoomOut.delay(200))}
|
||||
layout={native(LinearTransition.delay(300))}
|
||||
key={reaction.sender.did + reaction.value}
|
||||
style={[a.p_2xs]}
|
||||
accessible={true}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={_(
|
||||
msg`Double tap or long press the message to add a reaction`,
|
||||
)}>
|
||||
<Text emoji style={[a.text_sm]}>
|
||||
{reaction.value}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</LayoutAnimationConfig>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isNewDay && <DateDivider date={message.sentAt} />}
|
||||
@@ -144,8 +216,12 @@ let MessageItem = ({
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isNative && appliedReactions}
|
||||
</ActionsWrapper>
|
||||
|
||||
{!isNative && appliedReactions}
|
||||
|
||||
{isLastInGroup && (
|
||||
<MessageItemMetadata
|
||||
item={item}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||
|
||||
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
|
||||
import {MessageContextProvider} from './MessageContext'
|
||||
|
||||
let MessageItemEmbed = ({
|
||||
@@ -12,15 +12,32 @@ let MessageItemEmbed = ({
|
||||
embed: AppBskyEmbedRecord.View
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const screen = useWindowDimensions()
|
||||
|
||||
return (
|
||||
<MessageContextProvider>
|
||||
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
||||
<PostEmbeds
|
||||
embed={embed}
|
||||
allowNestedQuotes
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.my_xs,
|
||||
t.atoms.bg,
|
||||
a.rounded_md,
|
||||
native({
|
||||
flexBasis: 0,
|
||||
width: Math.min(screen.width, 600) / 1.4,
|
||||
}),
|
||||
web({
|
||||
width: '100%',
|
||||
minWidth: 280,
|
||||
maxWidth: 360,
|
||||
}),
|
||||
]}>
|
||||
<View style={{marginTop: tokens.space.sm * -1}}>
|
||||
<PostEmbeds
|
||||
embed={embed}
|
||||
allowNestedQuotes
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</MessageContextProvider>
|
||||
)
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import React from 'react'
|
||||
import {LayoutAnimation, Pressable, View} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {ChatBskyConvoDefs, RichText} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {getTranslatorLink} from '#/locale/helpers'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ReportDialog} from '#/components/dms/ReportDialog'
|
||||
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
|
||||
|
||||
export let MessageMenu = ({
|
||||
message,
|
||||
control,
|
||||
triggerOpacity,
|
||||
}: {
|
||||
triggerOpacity?: number
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
control: Menu.MenuControlProps
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const convo = useConvoActive()
|
||||
const deleteControl = usePromptControl()
|
||||
const reportControl = usePromptControl()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
|
||||
const onCopyMessage = React.useCallback(() => {
|
||||
const str = richTextToString(
|
||||
new RichText({
|
||||
text: message.text,
|
||||
facets: message.facets,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
Clipboard.setStringAsync(str)
|
||||
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
|
||||
}, [_, message.text, message.facets])
|
||||
|
||||
const onPressTranslateMessage = React.useCallback(() => {
|
||||
const translatorUrl = getTranslatorLink(
|
||||
message.text,
|
||||
langPrefs.primaryLanguage,
|
||||
)
|
||||
openLink(translatorUrl, true)
|
||||
}, [langPrefs.primaryLanguage, message.text, openLink])
|
||||
|
||||
const onDelete = React.useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(message.id)
|
||||
.then(() =>
|
||||
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
|
||||
)
|
||||
.catch(() => Toast.show(_(msg`Failed to delete message`)))
|
||||
}, [_, convo, message.id])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Root control={control}>
|
||||
{isWeb && (
|
||||
<View style={{opacity: triggerOpacity}}>
|
||||
<Menu.Trigger label={_(msg`Chat settings`)}>
|
||||
{({props, state}) => (
|
||||
<Pressable
|
||||
{...props}
|
||||
style={[
|
||||
a.p_sm,
|
||||
a.rounded_full,
|
||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<DotsHorizontal size="md" style={t.atoms.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Menu.Outer>
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownTranslateBtn"
|
||||
label={_(msg`Translate`)}
|
||||
onPress={onPressTranslateMessage}>
|
||||
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Translate} position="right" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={_(msg`Copy message text`)}
|
||||
onPress={onCopyMessage}>
|
||||
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={_(msg`Delete message for me`)}
|
||||
onPress={() => deleteControl.open()}>
|
||||
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Trash} position="right" />
|
||||
</Menu.Item>
|
||||
{!isFromSelf && (
|
||||
<Menu.Item
|
||||
testID="messageDropdownReportBtn"
|
||||
label={_(msg`Report message`)}
|
||||
onPress={() => reportControl.open()}>
|
||||
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Warning} position="right" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
|
||||
<ReportDialog
|
||||
currentScreen="conversation"
|
||||
params={{type: 'convoMessage', convoId: convo.convo.id, message}}
|
||||
control={reportControl}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={deleteControl}
|
||||
title={_(msg`Delete message`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Delete`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
MessageMenu = React.memo(MessageMenu)
|
||||
@@ -1,4 +1,7 @@
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
|
||||
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
|
||||
switch (profile.associated?.chat?.allowIncoming) {
|
||||
@@ -25,3 +28,29 @@ export function localDateString(date: Date) {
|
||||
// not padding with 0s because it's not necessary, it's just used for comparison
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
export function hasAlreadyReacted(
|
||||
message: ChatBskyConvoDefs.MessageView,
|
||||
myDid: string | undefined,
|
||||
emoji: string,
|
||||
): boolean {
|
||||
if (!message.reactions) {
|
||||
return false
|
||||
}
|
||||
return !!message.reactions.find(
|
||||
reaction => reaction.value === emoji && reaction.sender.did === myDid,
|
||||
)
|
||||
}
|
||||
|
||||
export function hasReachedReactionLimit(
|
||||
message: ChatBskyConvoDefs.MessageView,
|
||||
myDid: string | undefined,
|
||||
): boolean {
|
||||
if (!message.reactions) {
|
||||
return false
|
||||
}
|
||||
const myReactions = message.reactions.filter(
|
||||
reaction => reaction.sender.did === myDid,
|
||||
)
|
||||
return myReactions.length >= EMOJI_REACTION_LIMIT
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Flame_Stroke2_Corner1_Rounded = createSinglePathSVG({
|
||||
path: 'M11.158 2.879c.584-.835 1.757-1.137 2.673-.507.951.654 2.597 1.92 4.013 3.694S20.5 10.194 20.5 13c0 4.997-3.752 9-8.5 9s-8.5-4.003-8.5-9c0-2.035.874-4.636 2.578-6.712.746-.91 2.034-.855 2.786-.133l2.294-3.276Zm-3.04 15.758C6.538 17.386 5.5 15.37 5.5 13c0-1.511.666-3.616 2.042-5.342.87.797 2.254.653 2.939-.325l2.286-3.265c.871.606 2.299 1.723 3.514 3.246C17.53 8.879 18.5 10.804 18.5 13c0 2.369-1.038 4.386-2.618 5.637q.117-.518.118-1.061c0-2.601-2.038-4.382-2.911-5.04a1.8 1.8 0 0 0-2.177 0C10.038 13.195 8 14.976 8 17.577q0 .543.118 1.061ZM12 14.222c-.825.648-2 1.859-2 3.354C10 19.043 11.016 20 12 20s2-.957 2-2.424c0-1.495-1.175-2.706-2-3.354Z',
|
||||
})
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'm18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z',
|
||||
})
|
||||
|
||||
export const Trending3_Stroke2_Corner1_Rounded = createSinglePathSVG({
|
||||
path: 'M15 7a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9.414L14.414 15a2 2 0 0 1-2.828 0L9 12.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L7.586 11a2 2 0 0 1 2.828 0L13 13.586 18.586 8H16a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import {StyleSheet, TextProps} from 'react-native'
|
||||
import type {PathProps, SvgProps} from 'react-native-svg'
|
||||
import {StyleSheet, type TextProps} from 'react-native'
|
||||
import {type PathProps, type SvgProps} from 'react-native-svg'
|
||||
import {Defs, LinearGradient, Stop} from 'react-native-svg'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
@@ -19,7 +19,7 @@ export const sizes = {
|
||||
lg: 24,
|
||||
xl: 28,
|
||||
'2xl': 32,
|
||||
}
|
||||
} as const
|
||||
|
||||
export function useCommonSVGProps(props: Props) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -15,7 +15,7 @@ import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {atoms as a, useGutters, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
|
||||
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {TrendingTopicLink} from '#/components/TrendingTopics'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -16,7 +16,7 @@ import {atoms as a, useGutters, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
|
||||
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
|
||||
import {Link} from '#/components/Link'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -114,7 +114,9 @@ export function PostHider({
|
||||
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
</Pressable>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{desc.name}
|
||||
</Text>
|
||||
{!modui.noOverride && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {version} from '../../package.json'
|
||||
import packageDotJson from '../../package.json'
|
||||
|
||||
export const IS_TESTFLIGHT = false
|
||||
export const IS_INTERNAL = __DEV__
|
||||
@@ -14,5 +14,5 @@ export const BUNDLE_DATE = __DEV__
|
||||
? 0
|
||||
: Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
|
||||
|
||||
export const appVersion = version
|
||||
export const appVersion = packageDotJson.version
|
||||
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${__DEV__ ? 'dev' : 'prod'})`
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@ export const isSafari = /^((?!chrome|android).)*safari/i.test(
|
||||
navigator.userAgent,
|
||||
)
|
||||
export const isFirefox = /firefox|fxios/i.test(navigator.userAgent)
|
||||
export const isTouchDevice =
|
||||
'ontouchstart' in window || navigator.maxTouchPoints > 1
|
||||
export const isTouchDevice = window.matchMedia('(pointer: coarse)').matches
|
||||
export const isAndroidWeb =
|
||||
/android/i.test(navigator.userAgent) && isTouchDevice
|
||||
|
||||
+14
-2
@@ -1,5 +1,5 @@
|
||||
import {Insets, Platform} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {type Insets, Platform} from 'react-native'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
export const LOCAL_DEV_SERVICE =
|
||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||
@@ -23,6 +23,16 @@ export const STARTER_PACK_MAX_SIZE = 150
|
||||
// -prf
|
||||
export const JOINED_THIS_WEEK = 560000 // estimate as of 12/18/24
|
||||
|
||||
export const DISCOVER_DEBUG_DIDS: Record<string, true> = {
|
||||
'did:plc:oisofpd7lj26yvgiivf3lxsi': true, // hailey.at
|
||||
'did:plc:p2cp5gopk7mgjegy6wadk3ep': true, // samuel.bsky.team
|
||||
'did:plc:ragtjsm2j2vknwkz3zp4oxrd': true, // pfrazee.com
|
||||
'did:plc:vpkhqolt662uhesyj6nxm7ys': true, // why.bsky.team
|
||||
'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol
|
||||
'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
|
||||
'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social
|
||||
}
|
||||
|
||||
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||
export function FEEDBACK_FORM_URL({
|
||||
email,
|
||||
@@ -180,3 +190,5 @@ export const SUPPORTED_MIME_TYPES = [
|
||||
] as const
|
||||
|
||||
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
|
||||
|
||||
export const EMOJI_REACTION_LIMIT = 5
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
|
||||
import {type StyleProp, type TextStyle, type ViewStyle} from 'react-native'
|
||||
import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg'
|
||||
|
||||
// Copyright (c) 2020 Refactoring UI Inc.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export class VideoTooLargeError extends Error {
|
||||
constructor() {
|
||||
super('Videos cannot be larger than 50mb')
|
||||
super('Videos cannot be larger than 100 MB')
|
||||
this.name = 'VideoTooLargeError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {NavigationState, PartialState} from '@react-navigation/native'
|
||||
import type {NativeStackNavigationProp} from '@react-navigation/native-stack'
|
||||
import {type NavigationState, type PartialState} from '@react-navigation/native'
|
||||
import {type NativeStackNavigationProp} from '@react-navigation/native-stack'
|
||||
|
||||
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
|
||||
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
|
||||
|
||||
export type {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
@@ -51,6 +51,7 @@ export type CommonNavigatorParams = {
|
||||
AccountSettings: undefined
|
||||
PrivacyAndSecuritySettings: undefined
|
||||
ContentAndMediaSettings: undefined
|
||||
SettingsInterests: undefined
|
||||
AboutSettings: undefined
|
||||
AppIconSettings: undefined
|
||||
Search: {q?: string}
|
||||
|
||||
@@ -2,6 +2,7 @@ export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'debug_show_feedcontext'
|
||||
| 'debug_subscriptions'
|
||||
| 'explore_show_suggested_feeds'
|
||||
| 'old_postonboarding'
|
||||
| 'onboarding_add_video_feed'
|
||||
| 'remove_show_latest_button'
|
||||
|
||||
@@ -323,7 +323,7 @@ export function createBskyAppAbsoluteUrl(path: string): string {
|
||||
export function createProxiedUrl(url: string): string {
|
||||
let u
|
||||
try {
|
||||
u = URL.parse(url)
|
||||
u = new URL(url)
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
@@ -332,7 +332,7 @@ export function createProxiedUrl(url: string): string {
|
||||
return url
|
||||
}
|
||||
|
||||
return `https://go.bsky.app/redirect?u=${url}`
|
||||
return `https://go.bsky.app/redirect?u=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
export function isShortLink(url: string): boolean {
|
||||
|
||||
@@ -169,7 +169,7 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'cad', code2: '', name: 'Caddo'},
|
||||
{code3: 'cai', code2: '', name: 'Central American Indian languages'},
|
||||
{code3: 'car', code2: '', name: 'Galibi Carib'},
|
||||
{code3: 'cat', code2: 'ca', name: 'Catalan'},
|
||||
{code3: 'cat', code2: 'ca', name: 'Catalan-Valencian'},
|
||||
{code3: 'cau', code2: '', name: 'Caucasian languages'},
|
||||
{code3: 'ceb', code2: '', name: 'Cebuano'},
|
||||
{code3: 'cel', code2: '', name: 'Celtic languages'},
|
||||
|
||||
+948
-721
File diff suppressed because it is too large
Load Diff
+927
-700
File diff suppressed because it is too large
Load Diff
+995
-768
File diff suppressed because it is too large
Load Diff
+963
-736
File diff suppressed because it is too large
Load Diff
+961
-734
File diff suppressed because it is too large
Load Diff
+2001
-1774
File diff suppressed because it is too large
Load Diff
+948
-721
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+898
-671
File diff suppressed because it is too large
Load Diff
+2141
-1914
File diff suppressed because it is too large
Load Diff
+1001
-774
File diff suppressed because it is too large
Load Diff
+964
-737
File diff suppressed because it is too large
Load Diff
+942
-715
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user