Compare commits
222 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b90ec079b | |||
| f5a888acd5 | |||
| 0b08128e4a | |||
| aca89d4aea | |||
| a0ff9b52aa | |||
| 5c59ec14c2 | |||
| 9760d3c1da | |||
| 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 | |||
| d155b718b5 | |||
| 166c56c4cb | |||
| 131578ca3a | |||
| 0014b8b9cd | |||
| f06a78017a | |||
| 27ae829698 | |||
| ada2ac8b91 | |||
| fd8ac1da7d | |||
| beb0fce56c | |||
| ac43b666cb | |||
| 618c4e4a56 | |||
| 153fc17676 | |||
| 8eb1f8f1fc | |||
| da2b6f4797 | |||
| 1c16c6ef0a | |||
| a7db0bac6f | |||
| 29eef6188a | |||
| ba3cff62f1 | |||
| 3be87fbf3b | |||
| 9fde3957e7 | |||
| dae152da85 | |||
| a42efd15d7 | |||
| d0c2003cc7 | |||
| 1cfdd2a7a0 | |||
| 663f16dfc2 | |||
| 947c876fdb | |||
| a62fe855c3 | |||
| c5d9e282e5 | |||
| f5f02aa4b3 | |||
| 2d854091b9 | |||
| 01a51c3275 | |||
| dda0db9963 | |||
| 67c394bc82 | |||
| c995eb2f2f | |||
| 5c14f69566 | |||
| 66c09d9953 | |||
| c82f768374 | |||
| 7222d6fe52 | |||
| 8b6dde17fd | |||
| 07dd57d1ea | |||
| 1c582c59f0 | |||
| 84fcbe398b | |||
| 3be9dde92d | |||
| 96f4f6359a | |||
| 9e59a2eef2 | |||
| 462708e207 | |||
| 36a7a8d8c6 | |||
| 7c36ea1158 | |||
| 9e9ffd5c6e | |||
| 3f963cfed7 | |||
| a2b71e3a4b | |||
| a3d36393a1 | |||
| b996b304c3 | |||
| ec75cbc01f | |||
| b924c53b23 | |||
| c5b831d5d8 | |||
| 3795d85591 | |||
| 7dc8ba8e4e | |||
| 22aee9704c | |||
| 3409a72548 | |||
| 595a894551 | |||
| 0d1373bb71 | |||
| cc8369e868 | |||
| 0437838649 | |||
| 23f0092440 | |||
| 9c5d92088d | |||
| e40656128d | |||
| d76cd88d91 | |||
| 6254cf020a | |||
| 61a14043e5 | |||
| 3fc4c32ac6 | |||
| a383b9ba28 | |||
| bf4d3e89c2 | |||
| 1fd3af3c46 | |||
| 3f879f0315 | |||
| 7c14b54ab2 | |||
| 9c739912a3 | |||
| c4dec63960 | |||
| a7e1913e22 | |||
| a1699da1a9 | |||
| ae9176c9c2 | |||
| 3d954a00e0 | |||
| f9392d4a96 | |||
| bdcddee7a5 | |||
| 5c3576dee7 | |||
| 7a3a1a2bd0 | |||
| e6193d7036 | |||
| cb85768e9c | |||
| ca7116ec3b | |||
| 80e59d31d0 | |||
| ca70aacb8a | |||
| e6bfeb1bab | |||
| 3b4f1e91e3 | |||
| fff625167f | |||
| 5d30111b78 | |||
| 798bf47827 | |||
| b0f7f51b04 | |||
| ea9f2dfbdd | |||
| 1fda7d0553 | |||
| 7ff65df417 | |||
| f8f88878dc | |||
| 8f63cb86b1 | |||
| ed4add7072 | |||
| 48fd397d46 | |||
| f23a621c7e | |||
| da45c42b6f | |||
| 63ba0a436b | |||
| a51fc8e434 | |||
| e2c0f78719 | |||
| 4146f9fe02 | |||
| a28791680f | |||
| d805dec08f | |||
| 326445d3af | |||
| 40d05ac866 | |||
| cb862bffea | |||
| 1e3b7feccf | |||
| ab4be7a9e1 | |||
| a154459b8f | |||
| d793abf8cd | |||
| 11616846ca | |||
| a14704ddfe | |||
| 22a902e0de | |||
| 99051f0ab1 | |||
| f45f9afab7 | |||
| a3f79cc714 | |||
| ae8ddc2c41 | |||
| d56efe250e | |||
| db25f95c33 | |||
| b37199a5a0 |
+5
-20
@@ -1,23 +1,8 @@
|
||||
# The env the app is running in e.g. development, testflight, production
|
||||
EXPO_PUBLIC_ENV=development
|
||||
# This is the commit hash that the current bundle was made from. The user can
|
||||
# see the commit hash in the app's settings along with the other version info.
|
||||
# Useful for debugging/reporting.
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER=
|
||||
# This will always be in the format of YYMMDD, so that it always increases for
|
||||
# each build. This should only be used for Statsig reporting and shouldn't be
|
||||
# used to identify a specific bundle.
|
||||
EXPO_PUBLIC_BUNDLE_DATE=0
|
||||
# The log level for the app's logger transports
|
||||
EXPO_PUBLIC_LOG_LEVEL=info
|
||||
# Currently unused. Enable debug for a subset of the debug logs.
|
||||
EXPO_PUBLIC_LOG_DEBUG=
|
||||
# Copy this to `.env` and `.env.test` files
|
||||
|
||||
#
|
||||
# Bluesky specific
|
||||
#
|
||||
|
||||
# Bitdrift
|
||||
BITDRIFT_API_KEY=
|
||||
# Sentry
|
||||
SENTRY_AUTH_TOKEN=
|
||||
EXPO_PUBLIC_LOG_LEVEL=debug
|
||||
EXPO_PUBLIC_LOG_DEBUG=
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER=
|
||||
EXPO_PUBLIC_BUNDLE_DATE=0
|
||||
|
||||
+11
-13
@@ -15,6 +15,7 @@ module.exports = {
|
||||
'simple-import-sort',
|
||||
'bsky-internal',
|
||||
'eslint-plugin-react-compiler',
|
||||
'import',
|
||||
],
|
||||
rules: {
|
||||
'react/no-unescaped-entities': 0,
|
||||
@@ -32,6 +33,7 @@ module.exports = {
|
||||
'H6',
|
||||
'P',
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
],
|
||||
impliedTextProps: [],
|
||||
suggestedTextWrappers: {
|
||||
@@ -80,20 +82,16 @@ module.exports = {
|
||||
],
|
||||
'simple-import-sort/exports': 'error',
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
'no-restricted-imports': [
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@atproto/api',
|
||||
importNames: ['moderatePost'],
|
||||
message:
|
||||
'Please use `moderatePost_wrapped` from `#/lib/moderatePost_wrapped` instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{argsIgnorePattern: '^_', varsIgnorePattern: '^_'},
|
||||
],
|
||||
'@typescript-eslint/ban-ts-comment': 'warn',
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
|
||||
],
|
||||
'import/consistent-type-specifier-style': ['warn', 'prefer-inline'],
|
||||
},
|
||||
ignorePatterns: [
|
||||
'**/__mocks__/*.ts',
|
||||
@@ -105,9 +103,9 @@ module.exports = {
|
||||
'*.lock',
|
||||
'.husky',
|
||||
'patches',
|
||||
'bskyweb',
|
||||
'*.html',
|
||||
'bskyweb',
|
||||
'bskyembed',
|
||||
'src/locale/locales/_build/',
|
||||
'src/locale/locales/**/*.js',
|
||||
],
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-14
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '15.3'
|
||||
xcode-version: '16.2'
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
@@ -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 }}
|
||||
@@ -134,7 +143,7 @@ jobs:
|
||||
# GitHub actions are horrible so let's just copy paste this in
|
||||
buildIfNecessaryIOS:
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-14
|
||||
runs-on: macos-15
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
|
||||
cancel-in-progress: true
|
||||
@@ -176,7 +185,7 @@ jobs:
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '15.3'
|
||||
xcode-version: '16.2'
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
|
||||
@@ -36,3 +36,14 @@ jobs:
|
||||
with:
|
||||
commit_message: Nightly source-language update
|
||||
file_pattern: ./src/locale/locales/en/messages.po
|
||||
- name: Push source lang to Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_sources_args: "-b main"
|
||||
upload_translations: false
|
||||
push_translations: false
|
||||
push_sources: false
|
||||
create_pull_request: false
|
||||
env:
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
+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
|
||||
|
||||
@@ -37,6 +37,6 @@ deps: ## Installs dependent libs using 'yarn install'
|
||||
|
||||
.PHONY: nvm-setup
|
||||
nvm-setup: ## Use NVM to install and activate node+yarn
|
||||
nvm install 18
|
||||
nvm use 18
|
||||
nvm install 20
|
||||
nvm use 20
|
||||
npm install --global yarn
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
flows:
|
||||
- "flows/*"
|
||||
- "flows/**"
|
||||
|
||||
@@ -17,7 +17,7 @@ appId: xyz.blueskyweb.app
|
||||
- inputText: "Post with an image"
|
||||
- tapOn:
|
||||
id: "openGalleryBtn"
|
||||
- tapOn: "Content warnings"
|
||||
- tapOn: "Allow Full Access"
|
||||
- tapOn: "Content warnings"
|
||||
- tapOn: "Porn"
|
||||
- tapOn:
|
||||
|
||||
@@ -24,12 +24,10 @@ appId: xyz.blueskyweb.app
|
||||
- inputText: "example@test.com"
|
||||
- tapOn:
|
||||
id: "passwordInput"
|
||||
- inputText: "hunter2"
|
||||
- pressKey: Enter
|
||||
- inputText: "hunter22"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "nextBtn"
|
||||
- tapOn:
|
||||
id: "handleInput"
|
||||
- inputText: "e2e-test"
|
||||
- tapOn:
|
||||
id: "nextBtn"
|
||||
|
||||
@@ -160,7 +160,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
label: "Adds and removes users on curatelists from the profile"
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn: "Search"
|
||||
- tapOn: "Search for posts, users, or feeds"
|
||||
- inputText: "bob"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
|
||||
@@ -16,7 +16,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "profileCardButton"
|
||||
- tapOn:
|
||||
id: "profilePager-selector-4"
|
||||
id: "profilePager-selector-5"
|
||||
- tapOn: "alice-favs"
|
||||
- tapOn: "Pin to Home"
|
||||
- tapOn:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users&follows&posts"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
index: 0
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn: "Create report for Misleading Post"
|
||||
- tapOn: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
point: "50%,90%"
|
||||
@@ -15,7 +15,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn:
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn: "Search"
|
||||
- tapOn: "Search for posts, users, or feeds"
|
||||
- inputText: "b"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
|
||||
- tapOn: "Carla's avatar"
|
||||
- tapOn:
|
||||
id: "profileHeaderDropdownBtn"
|
||||
- tapOn:
|
||||
id: "profileHeaderDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
@@ -0,0 +1,24 @@
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
index: 0
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
@@ -0,0 +1,35 @@
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
index: 0
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonOther"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
# reason "other" defaults with details open
|
||||
- assertVisible:
|
||||
id: "report:details"
|
||||
- tapOn:
|
||||
id: "report:clearOption"
|
||||
- assertNotVisible:
|
||||
id: "report:details"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
@@ -0,0 +1,30 @@
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
index: 0
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonOther"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
# reason "other" defaults with details open
|
||||
- assertVisible:
|
||||
id: "report:details"
|
||||
- tapOn:
|
||||
id: "report:details"
|
||||
- inputText: "This is a test report"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
@@ -12,8 +12,8 @@ appId: xyz.blueskyweb.app
|
||||
# Navigate to another user profile via autocomplete
|
||||
- tapOn:
|
||||
id: "bottomBarSearchBtn"
|
||||
- assertVisible: "Search"
|
||||
- tapOn: "Search"
|
||||
- assertVisible: "Search for posts, users, or feeds"
|
||||
- tapOn: "Search for posts, users, or feeds"
|
||||
- inputText: "b"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
|
||||
+7
-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: {
|
||||
@@ -75,14 +72,17 @@ module.exports = function (config) {
|
||||
'an',
|
||||
'ast',
|
||||
'ca',
|
||||
'cy',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'eo',
|
||||
'es',
|
||||
'eu',
|
||||
'fi',
|
||||
'fr',
|
||||
'ga',
|
||||
'gd',
|
||||
'gl',
|
||||
'hi',
|
||||
'hu',
|
||||
@@ -201,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',
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -237,11 +236,13 @@ module.exports = function (config) {
|
||||
},
|
||||
],
|
||||
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||
'./plugins/withGradleJVMHeapSizeIncrease.js',
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
'./plugins/withAndroidStylesAccentColorPlugin.js',
|
||||
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
|
||||
'./plugins/withAndroidNoJitpackPlugin.js',
|
||||
'./plugins/withNoBundleCompression.js',
|
||||
'./plugins/shareExtension/withShareExtensions.js',
|
||||
'./plugins/notificationsExtension/withNotificationsExtension.js',
|
||||
'./plugins/withAppDelegateReferrer.js',
|
||||
@@ -402,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.293 4.293a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1-1.414 1.414L12 6.414 8.707 9.707a1 1 0 0 1-1.414-1.414l4-4Zm-4 10a1 1 0 0 1 1.414 0L12 17.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 363 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="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.293-3.707a1 1 0 0 1 1.414 0L12 10.586l2.293-2.293a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 0 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 454 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="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,6 +68,9 @@ function scan(node = document) {
|
||||
if (ref_url.startsWith('http')) {
|
||||
searchParams.set('ref_url', encodeURIComponent(ref_url))
|
||||
}
|
||||
if (embed.dataset.blueskyEmbedColorMode) {
|
||||
searchParams.set('colorMode', embed.dataset.blueskyEmbedColorMode)
|
||||
}
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.setAttribute('data-bluesky-id', id)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
export type ColorModeValues = 'system' | 'light' | 'dark'
|
||||
|
||||
export function assertColorModeValues(value: string): value is ColorModeValues {
|
||||
return ['system', 'light', 'dark'].includes(value)
|
||||
}
|
||||
|
||||
export function applyTheme(theme: 'light' | 'dark') {
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(theme)
|
||||
}
|
||||
|
||||
export function initColorMode() {
|
||||
export function initSystemColorMode() {
|
||||
applyTheme(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
|
||||
+12
-1
@@ -6,6 +6,17 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:root {
|
||||
#app {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' height='14px' width='14px' fill='none' viewBox='0 0 24 24'><path fill='black' fill-rule='evenodd' d='M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z' clip-rule='evenodd'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: calc(100% - 0.75rem) center;
|
||||
padding-right: 2rem;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' height='14px' width='14px' fill='none' viewBox='0 0 24 24'><path fill='white' fill-rule='evenodd' d='M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z' clip-rule='evenodd'/></svg>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export function labelsToInfo(
|
||||
return 'Adult Content'
|
||||
case 'nudity':
|
||||
return 'Non-sexual Nudity'
|
||||
case 'gore':
|
||||
case 'graphic-media':
|
||||
return 'Graphic Media'
|
||||
default:
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import '../index.css'
|
||||
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, AtpAgent, AtUri} from '@atproto/api'
|
||||
import {h, render} from 'preact'
|
||||
import {useEffect, useMemo, useRef, useState} from 'preact/hooks'
|
||||
|
||||
import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg'
|
||||
import logo from '../../assets/logo.svg'
|
||||
import {initColorMode} from '../color-mode'
|
||||
import {
|
||||
assertColorModeValues,
|
||||
ColorModeValues,
|
||||
initSystemColorMode,
|
||||
} from '../color-mode'
|
||||
import {Container} from '../components/container'
|
||||
import {Link} from '../components/link'
|
||||
import {Post} from '../components/post'
|
||||
@@ -22,9 +26,9 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
|
||||
initColorMode()
|
||||
initSystemColorMode()
|
||||
|
||||
const agent = new BskyAgent({
|
||||
const agent = new AtpAgent({
|
||||
service: 'https://public.api.bsky.app',
|
||||
})
|
||||
|
||||
@@ -32,6 +36,7 @@ render(<LandingPage />, root)
|
||||
|
||||
function LandingPage() {
|
||||
const [uri, setUri] = useState('')
|
||||
const [colorMode, setColorMode] = useState<ColorModeValues>('system')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
|
||||
@@ -120,24 +125,50 @@ function LandingPage() {
|
||||
|
||||
<h1 className="text-4xl font-bold text-center">Embed a Bluesky Post</h1>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={uri}
|
||||
onInput={e => setUri(e.currentTarget.value)}
|
||||
className="border rounded-lg py-3 w-full max-w-[600px] px-4 dark:bg-dimmedBg dark:border-slate-500"
|
||||
placeholder={DEFAULT_POST}
|
||||
/>
|
||||
<div className="flex flex-col w-full max-w-[600px] gap-6">
|
||||
<input
|
||||
type="text"
|
||||
value={uri}
|
||||
onInput={e => setUri(e.currentTarget.value)}
|
||||
className="border rounded-lg py-3 px-4 dark:bg-dimmedBg dark:border-slate-500"
|
||||
placeholder={DEFAULT_POST}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium" for="colorModeSelect">
|
||||
Theme
|
||||
</label>
|
||||
<select
|
||||
value={colorMode}
|
||||
onChange={e => {
|
||||
const value = e.currentTarget.value
|
||||
if (assertColorModeValues(value)) {
|
||||
setColorMode(value)
|
||||
}
|
||||
}}
|
||||
id="colorModeSelect"
|
||||
className="appearance-none bg-white border w-full rounded-lg text-sm px-3 py-2 dark:bg-dimmedBg dark:border-slate-500">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img src={arrowBottom} className="w-6 dark:invert" />
|
||||
|
||||
{loading ? (
|
||||
<div className="w-full max-w-[600px]">
|
||||
<div className={`${colorMode} w-full max-w-[600px]`}>
|
||||
<Skeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-[600px] gap-8 flex flex-col">
|
||||
{!error && thread && uri && <Snippet thread={thread} />}
|
||||
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
|
||||
{!error && thread && uri && (
|
||||
<Snippet thread={thread} colorMode={colorMode} />
|
||||
)}
|
||||
<div className={colorMode}>
|
||||
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="w-full border border-red-500 bg-red-500/10 px-4 py-3 rounded-lg">
|
||||
<p className="text-red-500 text-center">{error}</p>
|
||||
@@ -168,7 +199,13 @@ function Skeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
function Snippet({
|
||||
thread,
|
||||
colorMode,
|
||||
}: {
|
||||
thread: AppBskyFeedDefs.ThreadViewPost
|
||||
colorMode: ColorModeValues
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -204,9 +241,11 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
||||
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
|
||||
thread.post.uri,
|
||||
)}" data-bluesky-cid="${escapeHtml(thread.post.cid)}"><p lang="${escapeHtml(
|
||||
lang,
|
||||
)}">${escapeHtml(record.text)}${
|
||||
)}" data-bluesky-cid="${escapeHtml(
|
||||
thread.post.cid,
|
||||
)}" data-bluesky-embed-color-mode="${escapeHtml(
|
||||
colorMode,
|
||||
)}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${
|
||||
record.embed
|
||||
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
|
||||
: ''
|
||||
@@ -217,7 +256,7 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
|
||||
niceDate(thread.post.indexedAt),
|
||||
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
|
||||
}, [thread])
|
||||
}, [thread, colorMode])
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 w-full">
|
||||
|
||||
@@ -4,7 +4,7 @@ import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
|
||||
import {h, render} from 'preact'
|
||||
|
||||
import logo from '../../assets/logo.svg'
|
||||
import {initColorMode} from '../color-mode'
|
||||
import {applyTheme, initSystemColorMode} from '../color-mode'
|
||||
import {Container} from '../components/container'
|
||||
import {Link} from '../components/link'
|
||||
import {Post} from '../components/post'
|
||||
@@ -22,7 +22,23 @@ if (!uri) {
|
||||
throw new Error('No uri in path')
|
||||
}
|
||||
|
||||
initColorMode()
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
|
||||
// theme - default to light mode
|
||||
const colorMode = query.get('colorMode')
|
||||
|
||||
switch (colorMode) {
|
||||
case 'dark':
|
||||
applyTheme('dark')
|
||||
break
|
||||
case 'system':
|
||||
initSystemColorMode()
|
||||
break
|
||||
case 'light':
|
||||
default:
|
||||
applyTheme('light')
|
||||
break
|
||||
}
|
||||
|
||||
agent
|
||||
.getPostThread({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {default as create} from './create.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'
|
||||
|
||||
export * from './util.js'
|
||||
@@ -11,7 +13,9 @@ export * from './util.js'
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
app = health(ctx, app) // GET /_health
|
||||
app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
|
||||
app = create(ctx, app) // POST /link
|
||||
app = redirect(ctx, app) // GET /:linkId (should go last due to permissive matching)
|
||||
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,54 +1,52 @@
|
||||
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'
|
||||
import {handler} from './util.js'
|
||||
|
||||
const INTERNAL_IP_REGEX = new RegExp(
|
||||
'(^127.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^10.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.1[6-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.2[0-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.3[0-1]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^192.168.[0-9]{1,3}.[0-9]{1,3}$)|^localhost',
|
||||
'i',
|
||||
)
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/:linkId',
|
||||
'/redirect',
|
||||
handler(async (req, res) => {
|
||||
const linkId = req.params.linkId
|
||||
const contentType = req.accepts(['html', 'json'])
|
||||
let link = req.query.u
|
||||
assert(
|
||||
typeof linkId === 'string',
|
||||
'express guarantees id parameter is a string',
|
||||
typeof link === 'string',
|
||||
'express guarantees link query parameter is a string',
|
||||
)
|
||||
const found = await ctx.db.db
|
||||
.selectFrom('link')
|
||||
.selectAll()
|
||||
.where('id', '=', linkId)
|
||||
.executeTakeFirst()
|
||||
if (!found) {
|
||||
// potentially broken or mistyped link
|
||||
|
||||
let url: URL | undefined
|
||||
try {
|
||||
url = new URL(link)
|
||||
} catch {}
|
||||
|
||||
if (
|
||||
!url ||
|
||||
(url.protocol !== 'http:' && url.protocol !== 'https:') || // is a http(s) url
|
||||
(ctx.cfg.service.hostnames.includes(url.hostname.toLowerCase()) &&
|
||||
url.pathname === '/redirect') || // is a redirect loop
|
||||
INTERNAL_IP_REGEX.test(url.hostname) // isn't directing to an internal location
|
||||
) {
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
if (contentType === 'json') {
|
||||
return res
|
||||
.status(404)
|
||||
.json({
|
||||
error: 'NotFound',
|
||||
message: 'Link not found',
|
||||
})
|
||||
.end()
|
||||
}
|
||||
// send the user to the app
|
||||
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||
return res.status(302).end()
|
||||
}
|
||||
// build url from original url in order to preserve query params
|
||||
const url = new URL(
|
||||
req.originalUrl,
|
||||
`https://${ctx.cfg.service.appHostname}`,
|
||||
)
|
||||
url.pathname = found.path
|
||||
|
||||
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
|
||||
if (contentType === 'json') {
|
||||
return res.json({url: url.href}).end()
|
||||
}
|
||||
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()
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {DAY, SECOND} from '@atproto/common'
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/:linkId',
|
||||
handler(async (req, res) => {
|
||||
const linkId = req.params.linkId
|
||||
const contentType = req.accepts(['html', 'json'])
|
||||
assert(
|
||||
typeof linkId === 'string',
|
||||
'express guarantees id parameter is a string',
|
||||
)
|
||||
const found = await ctx.db.db
|
||||
.selectFrom('link')
|
||||
.selectAll()
|
||||
.where('id', '=', linkId)
|
||||
.executeTakeFirst()
|
||||
if (!found) {
|
||||
// potentially broken or mistyped link
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
if (contentType === 'json') {
|
||||
return res
|
||||
.status(404)
|
||||
.json({
|
||||
error: 'NotFound',
|
||||
message: 'Link not found',
|
||||
})
|
||||
.end()
|
||||
}
|
||||
// send the user to the app
|
||||
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||
return res.status(302).end()
|
||||
}
|
||||
// build url from original url in order to preserve query params
|
||||
const url = new URL(
|
||||
req.originalUrl,
|
||||
`https://${ctx.cfg.service.appHostname}`,
|
||||
)
|
||||
url.pathname = found.path
|
||||
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
|
||||
if (contentType === 'json') {
|
||||
return res.json({url: url.href}).end()
|
||||
}
|
||||
res.setHeader('Location', url.href)
|
||||
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==
|
||||
|
||||
@@ -97,6 +97,7 @@ const hideAvatarLabels = new Set([
|
||||
'nudity',
|
||||
'sexual-figurative',
|
||||
'graphic-media',
|
||||
'gore',
|
||||
'self-harm',
|
||||
'sensitive',
|
||||
'security',
|
||||
|
||||
@@ -94,6 +94,20 @@ func run(args []string) {
|
||||
Value: "",
|
||||
EnvVars: []string{"STATIC_CDN_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "bsky-canonical-instance",
|
||||
Usage: "Enable if this is the canonical deployment (bsky.app)",
|
||||
Value: false,
|
||||
Required: false,
|
||||
EnvVars: []string{"BSKY_CANONICAL_INSTANCE"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "robots-disallow-all",
|
||||
Usage: "Crawling is allowed by default. Enable this flag to Disallow all",
|
||||
Value: false,
|
||||
Required: false,
|
||||
EnvVars: []string{"ROBOTS_DISALLOW_ALL"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ func serve(cctx *cli.Context) error {
|
||||
corsOrigins := cctx.StringSlice("cors-allowed-origins")
|
||||
staticCDNHost := cctx.String("static-cdn-host")
|
||||
staticCDNHost = strings.TrimSuffix(staticCDNHost, "/")
|
||||
canonicalInstance := cctx.Bool("bsky-canonical-instance")
|
||||
robotsDisallowAll := cctx.Bool("robots-disallow-all")
|
||||
|
||||
// Echo
|
||||
e := echo.New()
|
||||
@@ -204,13 +206,23 @@ func serve(cctx *cli.Context) error {
|
||||
return http.FS(fsys)
|
||||
}())
|
||||
|
||||
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
|
||||
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
|
||||
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
|
||||
e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
|
||||
e.GET("/security.txt", func(c echo.Context) error {
|
||||
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
|
||||
})
|
||||
// enable some special endpoints for the "canonical" deployment (bsky.app). not having these enabled should *not* impact regular operation
|
||||
if canonicalInstance {
|
||||
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
|
||||
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
|
||||
e.GET("/security.txt", func(c echo.Context) error {
|
||||
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
|
||||
})
|
||||
e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
|
||||
}
|
||||
|
||||
// default to permissive, but Disallow all if flag set
|
||||
if robotsDisallowAll {
|
||||
e.File("/robots.txt", "static/robots-disallow-all.txt")
|
||||
} else {
|
||||
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
|
||||
}
|
||||
|
||||
e.GET("/iframe/youtube.html", echo.WrapHandler(staticHandler))
|
||||
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
@@ -263,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)
|
||||
@@ -283,6 +296,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/profile/:handleOrDID/follows", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/followers", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/known-followers", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/search", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# This is an development or self-hosted instance of the bsky web app, and crawling has been disallowed by the operator team.
|
||||
User-Agent: *
|
||||
Disallow: /
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"project_id": "664276",
|
||||
"project_id": "1",
|
||||
"base_url": "https://bluesky.api.crowdin.com",
|
||||
"base_path": ".",
|
||||
"preserve_hierarchy": true,
|
||||
"files": [
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# 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 open a PR or 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/u/projects/1) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
|
||||
|
||||
## Tools
|
||||
|
||||
- We use Lingui to implement translations. You can find the documentation [here](https://lingui.dev/).
|
||||
- We use Crowdin to manage translations.
|
||||
- Bluesky Crowdin: https://crowdin.com/project/bluesky-social
|
||||
- Bluesky Crowdin: https://bluesky.crowdin.com/u/projects/1
|
||||
- Introduction to Crowdin: https://support.crowdin.com/for-translators/
|
||||
- We use Lingui to implement translations. You can find the documentation [here](https://lingui.dev/).
|
||||
|
||||
## Translators
|
||||
|
||||
@@ -15,7 +15,7 @@ Much of the app is translated by community contributions. (We <3 our translators
|
||||
|
||||
### Using Crowdin
|
||||
|
||||
[Crowdin](https://crowdin.com/project/bluesky-social) is our primary tool for managing translations. There are two roles:
|
||||
[Crowdin](https://bluesky.crowdin.com/u/projects/1) 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
|
||||
|
||||
Create a new [Crowdin discussion](https://crowdin.com/project/bluesky-social/discussions) or [GitHub issue](https://github.com/bluesky-social/social-app/issues) requesting the new language be added to the project.
|
||||
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).
|
||||
|
||||
Please only request a new language when you are certain you will be able to contribute a substantive portion of translations for the language.
|
||||
|
||||
@@ -47,7 +47,8 @@ Every night, a GitHub action will run `yarn intl:extract` to update the english
|
||||
### Release process
|
||||
|
||||
1. Pull main and create a branch.
|
||||
1. Run `yarn intl:pull` to fetch all translation updates from Crowdin.
|
||||
1. Run `yarn intl:pull` to fetch all translation updates from Crowdin. Commit.
|
||||
1. Run `yarn intl:extract:all` to ensure all `.po` files are synced with the current state of the code. Commit.
|
||||
1. Create a PR, ensure the translations all look correct, and merge.
|
||||
1. If needed:
|
||||
1. Merge all approved translation PRs (contributions from outside crowdin).
|
||||
|
||||
+1
-1
@@ -119,5 +119,5 @@ jest.mock('statsig-react-native-expo', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../src/lib/bitdrift', () => ({}))
|
||||
jest.mock('../src/logger/bitdrift/lib', () => ({}))
|
||||
jest.mock('../src/lib/statsig/statsig', () => ({}))
|
||||
|
||||
@@ -5,15 +5,18 @@ module.exports = {
|
||||
'an',
|
||||
'ast',
|
||||
'ca',
|
||||
'cy',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'en-GB',
|
||||
'eo',
|
||||
'es',
|
||||
'eu',
|
||||
'fi',
|
||||
'fr',
|
||||
'ga',
|
||||
'gd',
|
||||
'gl',
|
||||
'hi',
|
||||
'hu',
|
||||
|
||||
+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)
|
||||
|
||||
+28
-11
@@ -31,9 +31,13 @@ class BottomSheetView(
|
||||
private lateinit var dialogRootViewGroup: DialogRootViewGroup
|
||||
private var eventDispatcher: EventDispatcher? = null
|
||||
|
||||
private val screenHeight =
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
private val rawScreenHeight = context.resources.displayMetrics.heightPixels.toFloat()
|
||||
private val safeScreenHeight = (rawScreenHeight - getNavigationBarHeight()).toFloat()
|
||||
|
||||
private fun getNavigationBarHeight(): Int {
|
||||
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
|
||||
}
|
||||
|
||||
private val onAttemptDismiss by EventDispatcher()
|
||||
private val onSnapPointChange by EventDispatcher()
|
||||
@@ -63,12 +67,12 @@ class BottomSheetView(
|
||||
}
|
||||
}
|
||||
|
||||
var maxHeight = this.screenHeight
|
||||
var maxHeight = this.safeScreenHeight
|
||||
set(value) {
|
||||
val px = dpToPx(value)
|
||||
field =
|
||||
if (px > this.screenHeight) {
|
||||
this.screenHeight
|
||||
if (px > this.safeScreenHeight) {
|
||||
this.safeScreenHeight
|
||||
} else {
|
||||
px
|
||||
}
|
||||
@@ -153,6 +157,19 @@ class BottomSheetView(
|
||||
|
||||
// Presentation
|
||||
|
||||
private fun getHalfExpandedRatio(contentHeight: Float): Float {
|
||||
return when {
|
||||
// Full height sheets
|
||||
contentHeight >= safeScreenHeight -> 0.99f
|
||||
// Medium height sheets (>50% but <100%)
|
||||
contentHeight >= safeScreenHeight / 2 ->
|
||||
this.clampRatio(this.getTargetHeight() / safeScreenHeight)
|
||||
// Small height sheets (<50%)
|
||||
else ->
|
||||
this.clampRatio(this.getTargetHeight() / rawScreenHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private fun present() {
|
||||
if (this.isOpen || this.isOpening || this.isClosing) return
|
||||
|
||||
@@ -172,12 +189,12 @@ class BottomSheetView(
|
||||
val behavior = BottomSheetBehavior.from(it)
|
||||
behavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
behavior.isFitToContents = true
|
||||
behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight)
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.skipCollapsed = true
|
||||
behavior.isDraggable = true
|
||||
behavior.isHideable = true
|
||||
|
||||
if (contentHeight >= this.screenHeight || this.minHeight >= this.screenHeight) {
|
||||
if (contentHeight >= this.safeScreenHeight || this.minHeight >= this.safeScreenHeight) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
this.selectedSnapPoint = 2
|
||||
} else {
|
||||
@@ -227,11 +244,11 @@ class BottomSheetView(
|
||||
bottomSheet?.let {
|
||||
val behavior = BottomSheetBehavior.from(it)
|
||||
|
||||
behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight)
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
|
||||
if (contentHeight > this.screenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (contentHeight < this.screenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
LayoutChangeEvent,
|
||||
NativeSyntheticEvent,
|
||||
Platform,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
|
||||
import {BottomSheetPortalProvider} from './BottomSheetPortal'
|
||||
import {Context as PortalContext} from './BottomSheetPortal'
|
||||
@@ -81,12 +84,10 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
return null
|
||||
}
|
||||
|
||||
const {children, backgroundColor, ...rest} = this.props
|
||||
const cornerRadius = rest.cornerRadius ?? 0
|
||||
|
||||
let extraStyles
|
||||
if (isIOS15 && this.state.viewHeight) {
|
||||
const {viewHeight} = this.state
|
||||
const cornerRadius = this.props.cornerRadius ?? 0
|
||||
if (viewHeight < screenHeight / 2) {
|
||||
extraStyles = {
|
||||
height: viewHeight,
|
||||
@@ -99,39 +100,70 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<NativeView
|
||||
{...rest}
|
||||
<BottomSheetNativeComponentInner
|
||||
{...this.props}
|
||||
nativeViewRef={this.ref}
|
||||
onStateChange={this.onStateChange}
|
||||
ref={this.ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: screenHeight,
|
||||
width: '100%',
|
||||
extraStyles={extraStyles}
|
||||
onLayout={e => {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
this.updateLayout()
|
||||
}}
|
||||
containerBackgroundColor={backgroundColor}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor,
|
||||
},
|
||||
Platform.OS === 'android' && {
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
},
|
||||
extraStyles,
|
||||
]}>
|
||||
<View
|
||||
onLayout={e => {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
this.updateLayout()
|
||||
}}>
|
||||
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
|
||||
</View>
|
||||
</View>
|
||||
</NativeView>
|
||||
/>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function BottomSheetNativeComponentInner({
|
||||
children,
|
||||
backgroundColor,
|
||||
onLayout,
|
||||
onStateChange,
|
||||
nativeViewRef,
|
||||
extraStyles,
|
||||
...rest
|
||||
}: BottomSheetViewProps & {
|
||||
extraStyles?: StyleProp<ViewStyle>
|
||||
onStateChange: (
|
||||
event: NativeSyntheticEvent<{state: BottomSheetState}>,
|
||||
) => void
|
||||
nativeViewRef: React.RefObject<View>
|
||||
onLayout: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const cornerRadius = rest.cornerRadius ?? 0
|
||||
|
||||
const sheetHeight = isIOS ? screenHeight - insets.top : screenHeight
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
{...rest}
|
||||
onStateChange={onStateChange}
|
||||
ref={nativeViewRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: sheetHeight,
|
||||
width: '100%',
|
||||
}}
|
||||
containerBackgroundColor={backgroundColor}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor,
|
||||
},
|
||||
Platform.OS === 'android' && {
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
},
|
||||
extraStyles,
|
||||
]}>
|
||||
<View onLayout={onLayout}>
|
||||
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
|
||||
</View>
|
||||
</View>
|
||||
</NativeView>
|
||||
)
|
||||
}
|
||||
|
||||
+15
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.98.0",
|
||||
"version": "1.100.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -42,22 +42,24 @@
|
||||
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"yarn perf:test\" --duration 150000 --resultsFilePath .perf/results.json",
|
||||
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
|
||||
"perf:measure": "NODE_ENV=test flashlight measure",
|
||||
"intl:build": "yarn intl:extract && yarn intl:compile",
|
||||
"intl:build": "yarn intl:extract:all && yarn intl:compile",
|
||||
"intl:extract": "lingui extract --clean --locale en",
|
||||
"intl:extract:all": "lingui extract --clean",
|
||||
"intl:compile": "lingui compile",
|
||||
"intl:pull": "crowdin download translations --verbose -b main",
|
||||
"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.13.35",
|
||||
"@bitdrift/react-native": "^0.6.2",
|
||||
"@atproto/api": "^0.14.20",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -78,10 +80,10 @@
|
||||
"@mattermost/react-native-paste-input": "^0.7.1",
|
||||
"@miblanchard/react-native-slider": "^2.3.1",
|
||||
"@mozzius/expo-dynamic-app-icon": "^1.5.0",
|
||||
"@radix-ui/react-dismissable-layer": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "2.0.1",
|
||||
"@radix-ui/react-dismissable-layer": "^1.1.5",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.6",
|
||||
"@radix-ui/react-focus-guards": "^1.1.1",
|
||||
"@radix-ui/react-focus-scope": "^1.1.0",
|
||||
"@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",
|
||||
@@ -89,7 +91,7 @@
|
||||
"@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.9.1",
|
||||
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||
"@tanstack/react-query": "^5.8.1",
|
||||
"@tanstack/react-query-persist-client": "^5.25.0",
|
||||
@@ -176,6 +178,7 @@
|
||||
"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",
|
||||
@@ -211,7 +214,7 @@
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.67",
|
||||
"@atproto/dev-env": "^0.3.87",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
@@ -222,6 +225,7 @@
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@react-native/eslint-config": "^0.76.2",
|
||||
"@react-native/typescript-config": "^0.76.1",
|
||||
"@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",
|
||||
@@ -243,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",
|
||||
@@ -270,7 +275,6 @@
|
||||
"@expo/image-utils": "0.6.3",
|
||||
"@react-native/babel-preset": "0.76.1",
|
||||
"@react-native/normalize-colors": "0.76.1",
|
||||
"@radix-ui/react-focus-scope": "1.1.0",
|
||||
"@types/react": "^18",
|
||||
"**/expo-constants": "17.0.3",
|
||||
"**/expo-device": "7.0.1",
|
||||
|
||||
@@ -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,
|
||||
@@ -0,0 +1,34 @@
|
||||
const {withGradleProperties} = require('expo/config-plugins')
|
||||
|
||||
function setGradlePropertiesValue(config, key, value) {
|
||||
return withGradleProperties(config, exportedConfig => {
|
||||
const keyIdx = exportedConfig.modResults.findIndex(
|
||||
item => item.type === 'property' && item.key === key,
|
||||
)
|
||||
if (keyIdx >= 0) {
|
||||
exportedConfig.modResults.splice(keyIdx, 1, {
|
||||
type: 'property',
|
||||
key,
|
||||
value,
|
||||
})
|
||||
} else {
|
||||
exportedConfig.modResults.push({
|
||||
type: 'property',
|
||||
key,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
return exportedConfig
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = function withGradleJVMHeapSizeIncrease(config) {
|
||||
config = setGradlePropertiesValue(
|
||||
config,
|
||||
'org.gradle.jvmargs',
|
||||
'-Xmx4096m -XX:MaxMetaspaceSize=1024m', //Set data of your choice
|
||||
)
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
const {withAppBuildGradle} = require('@expo/config-plugins')
|
||||
|
||||
/**
|
||||
* A Config Plugin to disable bundle compression in Android build.gradle.
|
||||
* @param {import('@expo/config-plugins').ConfigPlugin} config
|
||||
* @returns {import('@expo/config-plugins').ConfigPlugin}
|
||||
*/
|
||||
module.exports = function withNoBundleCompression(config) {
|
||||
return withAppBuildGradle(config, androidConfig => {
|
||||
let buildGradle = androidConfig.modResults.contents
|
||||
|
||||
const hasAndroidResources = buildGradle.includes('androidResources {')
|
||||
const hasNoCompress = buildGradle.includes('noCompress')
|
||||
|
||||
if (hasAndroidResources) {
|
||||
if (hasNoCompress) {
|
||||
if (
|
||||
buildGradle.includes('noCompress += ["bundle"]') ||
|
||||
buildGradle.includes("noCompress += 'bundle'") ||
|
||||
buildGradle.includes('noCompress += "bundle"')
|
||||
) {
|
||||
return androidConfig
|
||||
}
|
||||
|
||||
const lines = buildGradle.split('\n')
|
||||
const modifiedLines = lines.map(line => {
|
||||
if (line.trim().startsWith('noCompress')) {
|
||||
if (line.includes('+=')) {
|
||||
return line.replace(/\]/, ', "bundle"]')
|
||||
} else if (line.includes('=')) {
|
||||
return line.replace('=', '+= ["bundle",') + ']'
|
||||
}
|
||||
}
|
||||
return line
|
||||
})
|
||||
androidConfig.modResults.contents = modifiedLines.join('\n')
|
||||
} else {
|
||||
const androidResources = buildGradle.indexOf('androidResources {')
|
||||
if (androidResources === -1) {
|
||||
throw new Error(
|
||||
`Cannot find androidResources { block in build.gradle!`,
|
||||
)
|
||||
}
|
||||
const insertPosition = buildGradle.indexOf('\n', androidResources) + 1
|
||||
const newContent =
|
||||
buildGradle.slice(0, insertPosition) +
|
||||
' noCompress += ["bundle"]\n' +
|
||||
buildGradle.slice(insertPosition)
|
||||
|
||||
androidConfig.modResults.contents = newContent
|
||||
}
|
||||
} else {
|
||||
const androidBlock = buildGradle.indexOf('android {')
|
||||
if (androidBlock === -1) {
|
||||
throw new Error(`Cannot find android { block in build.gradle!`)
|
||||
}
|
||||
const insertPosition = buildGradle.indexOf('\n', androidBlock) + 1
|
||||
const newContent =
|
||||
buildGradle.slice(0, insertPosition) +
|
||||
' androidResources {\n' +
|
||||
' noCompress += ["bundle"]\n' +
|
||||
' }\n' +
|
||||
buildGradle.slice(insertPosition)
|
||||
|
||||
androidConfig.modResults.contents = newContent
|
||||
}
|
||||
|
||||
return androidConfig
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+57
-56
@@ -1,6 +1,6 @@
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import '#/lib/sentry' // must be near top
|
||||
import '#/lib/bitdrift' // must be near top
|
||||
import '#/logger/sentry/setup'
|
||||
import '#/logger/bitdrift/setup'
|
||||
import '#/view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
@@ -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,17 +66,15 @@ 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'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import * as env from '#/env'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
|
||||
logger.info('env', env)
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
if (isIOS) {
|
||||
SystemUI.setBackgroundColorAsync('black')
|
||||
@@ -121,7 +120,7 @@ function InnerApp() {
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(
|
||||
_(msg`Sorry! Your session expired. Please log in again.`),
|
||||
_(msg`Sorry! Your session expired. Please sign in again.`),
|
||||
'info',
|
||||
)
|
||||
})
|
||||
@@ -130,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>
|
||||
)
|
||||
@@ -239,4 +240,4 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default Sentry.wrap(App)
|
||||
|
||||
+55
-51
@@ -1,4 +1,4 @@
|
||||
import '#/lib/sentry' // must be near top
|
||||
import '#/logger/sentry/setup' // must be near top
|
||||
import '#/view/icons'
|
||||
import './style.css'
|
||||
|
||||
@@ -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'
|
||||
@@ -94,7 +96,7 @@ function InnerApp() {
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(
|
||||
_(msg`Sorry! Your session expired. Please log in again.`),
|
||||
_(msg`Sorry! Your session expired. Please sign in again.`),
|
||||
'info',
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
+42
-18
@@ -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,18 +19,19 @@ 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'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
@@ -58,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'
|
||||
@@ -68,6 +67,7 @@ import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTest
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
||||
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
|
||||
@@ -79,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,
|
||||
@@ -91,6 +93,7 @@ import {VideoFeed} from '#/screens/VideoFeed'
|
||||
import {useTheme} from '#/alf'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
import {ProfileSearchScreen} from './screens/Profile/ProfileSearch'
|
||||
import {AboutSettingsScreen} from './screens/Settings/AboutSettings'
|
||||
import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings'
|
||||
import {AccountSettingsScreen} from './screens/Settings/AccountSettings'
|
||||
@@ -207,6 +210,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => ProfileListScreen}
|
||||
options={{title: title(msg`List`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileSearch"
|
||||
getComponent={() => ProfileSearchScreen}
|
||||
options={({route}) => ({
|
||||
title: title(msg`Search @${route.params.name}'s posts`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostThread"
|
||||
getComponent={() => PostThreadScreen}
|
||||
@@ -366,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}
|
||||
@@ -402,6 +420,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MessagesInbox"
|
||||
getComponent={() => MessagesInboxScreen}
|
||||
options={{title: title(msg`Chat request inbox`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="NotificationSettings"
|
||||
getComponent={() => NotificationSettingsScreen}
|
||||
@@ -614,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"
|
||||
@@ -721,15 +744,16 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
const routeName = getCurrentRouteName()
|
||||
if (routeName === 'Notifications') {
|
||||
logEvent('router:navigate:notifications', {})
|
||||
}
|
||||
logger.metric('router:navigate', {
|
||||
from: prevLoggedRouteName.current,
|
||||
})
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
}}
|
||||
onReady={() => {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
logModuleInitTime()
|
||||
onReady()
|
||||
logger.metric('router:navigate', {})
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
|
||||
+40
-2
@@ -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'
|
||||
@@ -31,6 +36,18 @@ export const atoms = {
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
top_0: {
|
||||
top: 0,
|
||||
},
|
||||
right_0: {
|
||||
right: 0,
|
||||
},
|
||||
bottom_0: {
|
||||
bottom: 0,
|
||||
},
|
||||
left_0: {
|
||||
left: 0,
|
||||
},
|
||||
z_10: {
|
||||
zIndex: 10,
|
||||
},
|
||||
@@ -50,9 +67,15 @@ export const atoms = {
|
||||
overflow_hidden: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
/**
|
||||
* @platform web
|
||||
*/
|
||||
overflow_auto: web({
|
||||
overflow: 'auto',
|
||||
}),
|
||||
|
||||
/*
|
||||
* Width
|
||||
* Width & Height
|
||||
*/
|
||||
w_full: {
|
||||
width: '100%',
|
||||
@@ -63,6 +86,12 @@ export const atoms = {
|
||||
h_full_vh: web({
|
||||
height: '100vh',
|
||||
}),
|
||||
max_w_full: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
max_h_full: {
|
||||
maxHeight: '100%',
|
||||
},
|
||||
|
||||
/**
|
||||
* Used for the outermost components on screens, to ensure that they can fill
|
||||
@@ -87,6 +116,9 @@ export const atoms = {
|
||||
/*
|
||||
* Border radius
|
||||
*/
|
||||
rounded_0: {
|
||||
borderRadius: 0,
|
||||
},
|
||||
rounded_2xs: {
|
||||
borderRadius: tokens.borderRadius._2xs,
|
||||
},
|
||||
@@ -99,6 +131,9 @@ export const atoms = {
|
||||
rounded_md: {
|
||||
borderRadius: tokens.borderRadius.md,
|
||||
},
|
||||
rounded_lg: {
|
||||
borderRadius: tokens.borderRadius.lg,
|
||||
},
|
||||
rounded_full: {
|
||||
borderRadius: tokens.borderRadius.full,
|
||||
},
|
||||
@@ -331,6 +366,9 @@ export const atoms = {
|
||||
border_r: {
|
||||
borderRightWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
border_transparent: {
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
curve_circular: ios({
|
||||
borderCurve: 'circular',
|
||||
}),
|
||||
|
||||
@@ -26,3 +26,18 @@ export function useBreakpoints(): Record<Breakpoint, boolean> & {
|
||||
}
|
||||
}, [gtPhone, gtMobile, gtTablet])
|
||||
}
|
||||
|
||||
/**
|
||||
* Fine-tuned breakpoints for the shell layout
|
||||
*/
|
||||
export function useLayoutBreakpoints() {
|
||||
const rightNavVisible = useMediaQuery({minWidth: 1100})
|
||||
const centerColumnOffset = useMediaQuery({minWidth: 1100, maxWidth: 1300})
|
||||
const leftNavMinimal = useMediaQuery({maxWidth: 1300})
|
||||
|
||||
return {
|
||||
rightNavVisible,
|
||||
centerColumnOffset,
|
||||
leftNavMinimal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export const borderRadius = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
full: 999,
|
||||
} as const
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export function AccountList({
|
||||
testID="chooseAddAccountBtn"
|
||||
style={[a.flex_1]}
|
||||
onPress={pendingDid ? undefined : onPressAddAccount}
|
||||
label={_(msg`Login to account that is not listed`)}>
|
||||
label={_(msg`Sign in to account that is not listed`)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button as BaseButton, ButtonProps} from '#/components/Button'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||
import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye'
|
||||
import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf'
|
||||
@@ -49,22 +50,29 @@ export function Text({
|
||||
return (
|
||||
<BaseText
|
||||
{...rest}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
{
|
||||
paddingTop: 1,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
style={[a.flex_1, a.text_sm, a.leading_snug, a.pr_md, style]}>
|
||||
{children}
|
||||
</BaseText>
|
||||
)
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
...props
|
||||
}: Omit<ButtonProps, 'size' | 'variant' | 'color'>) {
|
||||
return (
|
||||
<BaseButton size="tiny" variant="outline" color="secondary" {...props}>
|
||||
{children}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export function Row({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.flex_row, a.gap_sm]}>{children}</View>
|
||||
return (
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outer({
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import {View} from 'react-native'
|
||||
import {moderateProfile} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
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 type * as bsky from '#/types/bsky'
|
||||
|
||||
export function AvatarStack({
|
||||
profiles,
|
||||
size = 26,
|
||||
numPending,
|
||||
backgroundColor,
|
||||
}: {
|
||||
profiles: string[]
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
size?: number
|
||||
numPending?: number
|
||||
backgroundColor?: string
|
||||
}) {
|
||||
const halfSize = size / 2
|
||||
const {data, error} = useProfilesQuery({handles: profiles})
|
||||
const translation = size / 3 // overlap by 1/3
|
||||
const t = useTheme()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
|
||||
const isPending = !data || !moderationOpts
|
||||
const isPending = (numPending && profiles.length === 0) || !moderationOpts
|
||||
|
||||
const items = isPending
|
||||
? Array.from({length: profiles.length}).map((_, i) => ({
|
||||
? Array.from({length: numPending ?? profiles.length}).map((_, i) => ({
|
||||
key: i,
|
||||
profile: null,
|
||||
moderation: null,
|
||||
}))
|
||||
: data.profiles.map(item => ({
|
||||
: profiles.map(item => ({
|
||||
key: item.did,
|
||||
profile: item,
|
||||
moderation: moderateProfile(item, moderationOpts),
|
||||
@@ -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,9 +54,9 @@ export function AvatarStack({
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
left: i * -halfSize,
|
||||
left: i * -translation,
|
||||
borderWidth: 1,
|
||||
borderColor: t.atoms.bg.backgroundColor,
|
||||
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
|
||||
borderRadius: 999,
|
||||
zIndex: 3 - i,
|
||||
},
|
||||
@@ -74,3 +74,33 @@ export function AvatarStack({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function AvatarStackWithFetch({
|
||||
profiles,
|
||||
size,
|
||||
backgroundColor,
|
||||
}: {
|
||||
profiles: string[]
|
||||
size?: number
|
||||
backgroundColor?: string
|
||||
}) {
|
||||
const {data, error} = useProfilesQuery({handles: profiles})
|
||||
|
||||
if (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
logger.error('Error fetching profiles for AvatarStack', {
|
||||
safeMessage: error,
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AvatarStack
|
||||
numPending={profiles.length}
|
||||
profiles={data?.profiles || []}
|
||||
size={size}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const Context = React.createContext<DialogContextProps>({
|
||||
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
isWithinDialog: false,
|
||||
})
|
||||
|
||||
export function useDialogContext() {
|
||||
|
||||
@@ -26,7 +26,7 @@ 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 {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {useThemeName} from '#/alf/util/useColorModeTheme'
|
||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||
import {
|
||||
@@ -46,7 +46,7 @@ export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/shared'
|
||||
export * from '#/components/Dialog/types'
|
||||
export * from '#/components/Dialog/utils'
|
||||
// @ts-ignore
|
||||
|
||||
export const Input = createInput(TextInput)
|
||||
|
||||
export function Outer({
|
||||
@@ -154,6 +154,7 @@ export function Outer({
|
||||
nativeSnapPoint: snapPoint,
|
||||
disableDrag,
|
||||
setDisableDrag,
|
||||
isWithinDialog: true,
|
||||
}),
|
||||
[close, snapPoint, disableDrag, setDisableDrag],
|
||||
)
|
||||
@@ -168,7 +169,9 @@ export function Outer({
|
||||
onStateChange={onStateChange}
|
||||
disableDrag={disableDrag}>
|
||||
<Context.Provider value={context}>
|
||||
<View testID={testID}>{children}</View>
|
||||
<View testID={testID} style={[a.relative]}>
|
||||
{children}
|
||||
</View>
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
)
|
||||
@@ -196,7 +199,7 @@ export function Inner({children, style, header}: DialogInnerProps) {
|
||||
|
||||
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
function ScrollableInner(
|
||||
{children, style, contentContainerStyle, header, ...props},
|
||||
{children, contentContainerStyle, header, ...props},
|
||||
ref,
|
||||
) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
@@ -216,13 +219,21 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
[],
|
||||
)
|
||||
|
||||
const basePading =
|
||||
(isIOS ? 30 : 50) + (isIOS ? keyboardHeight / 4 : keyboardHeight)
|
||||
const fullPaddingBase = insets.bottom + insets.top + basePading
|
||||
const fullPadding = isIOS ? fullPaddingBase : fullPaddingBase + 50
|
||||
|
||||
const paddingBottom =
|
||||
nativeSnapPoint === BottomSheetSnapPoint.Full ? fullPadding : basePading
|
||||
let paddingBottom = 0
|
||||
if (isIOS) {
|
||||
paddingBottom += keyboardHeight / 4
|
||||
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
|
||||
paddingBottom += insets.bottom + tokens.space.md
|
||||
}
|
||||
paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
|
||||
} else {
|
||||
paddingBottom += keyboardHeight
|
||||
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
|
||||
paddingBottom += insets.top
|
||||
}
|
||||
paddingBottom +=
|
||||
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
|
||||
}
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!isAndroid) {
|
||||
@@ -238,7 +249,6 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
|
||||
return (
|
||||
<KeyboardAwareScrollView
|
||||
style={[style]}
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
a.px_xl,
|
||||
@@ -316,7 +326,7 @@ export function Handle() {
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{
|
||||
top: 10,
|
||||
top: tokens.space._2xl / 2 - 2.5,
|
||||
width: 35,
|
||||
height: 5,
|
||||
alignSelf: 'center',
|
||||
|
||||
@@ -41,6 +41,7 @@ export function Outer({
|
||||
children,
|
||||
control,
|
||||
onClose,
|
||||
webOptions,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
@@ -96,6 +97,7 @@ export function Outer({
|
||||
nativeSnapPoint: 0,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
isWithinDialog: true,
|
||||
}),
|
||||
[close],
|
||||
)
|
||||
@@ -115,20 +117,26 @@ export function Outer({
|
||||
web(a.fixed),
|
||||
a.inset_0,
|
||||
a.z_10,
|
||||
a.px_xl,
|
||||
webOptions?.alignCenter ? a.justify_center : undefined,
|
||||
a.align_center,
|
||||
gtMobile ? a.p_lg : a.p_md,
|
||||
{overflowY: 'auto'},
|
||||
{
|
||||
overflowY: 'auto',
|
||||
paddingVertical: gtMobile ? '10vh' : a.pt_xl.paddingTop,
|
||||
},
|
||||
]}>
|
||||
<Backdrop />
|
||||
{/**
|
||||
* This is needed to prevent centered dialogs from overflowing
|
||||
* above the screen, and provides a "natural" centering so that
|
||||
* stacked dialogs appear relatively aligned.
|
||||
*/}
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.z_20,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
{
|
||||
minHeight: web('calc(90vh - 36px)') || undefined,
|
||||
},
|
||||
web({minHeight: '60vh', position: 'static'}),
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
@@ -217,8 +225,8 @@ export const InnerFlatList = React.forwardRef<
|
||||
style={[
|
||||
a.overflow_hidden,
|
||||
a.px_0,
|
||||
// @ts-expect-error web only -sfn
|
||||
{maxHeight: 'calc(-36px + 100vh)'},
|
||||
// 100 minus 10vh of paddingVertical
|
||||
web({maxHeight: '80vh'}),
|
||||
webInnerStyle,
|
||||
]}
|
||||
contentContainerStyle={[a.px_0, webInnerContentContainerStyle]}>
|
||||
|
||||
@@ -44,6 +44,8 @@ export type DialogContextProps = {
|
||||
nativeSnapPoint: BottomSheetSnapPoint
|
||||
disableDrag: boolean
|
||||
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
|
||||
// in the event that the hook is used outside of a dialog
|
||||
isWithinDialog: boolean
|
||||
}
|
||||
|
||||
export type DialogControlOpenOptions = {
|
||||
@@ -60,11 +62,16 @@ export type DialogOuterProps = {
|
||||
control: DialogControlProps
|
||||
onClose?: () => void
|
||||
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
||||
webOptions?: {}
|
||||
webOptions?: {
|
||||
alignCenter?: boolean
|
||||
}
|
||||
testID?: string
|
||||
}
|
||||
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> &
|
||||
T & {
|
||||
testID?: string
|
||||
}
|
||||
export type DialogInnerProps =
|
||||
| DialogInnerPropsBase<{
|
||||
label?: undefined
|
||||
|
||||
+52
-24
@@ -1,9 +1,8 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyGraphDefs,
|
||||
AtUri,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
@@ -24,14 +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 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}
|
||||
@@ -115,7 +120,7 @@ export function TitleAndByline({
|
||||
creator,
|
||||
}: {
|
||||
title: string
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
creator?: bsky.profile.AnyProfileView
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -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} =
|
||||
@@ -269,9 +279,9 @@ function SaveButtonInner({
|
||||
},
|
||||
])
|
||||
}
|
||||
Toast.show(_(msg`Feeds updated!`))
|
||||
Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
|
||||
} catch (err: any) {
|
||||
logger.error(err, {context: `FeedCard: failed to update feeds`, pin})
|
||||
logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
|
||||
Toast.show(_(msg`Failed to update feeds`), 'xmark')
|
||||
}
|
||||
},
|
||||
@@ -294,20 +304,38 @@ 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>
|
||||
|
||||
<Prompt.Basic
|
||||
control={removePromptControl}
|
||||
title={_(msg`Remove from my feeds?`)}
|
||||
title={_(msg`Remove from your feeds?`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to remove this from your feeds?`,
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {View} from 'react-native'
|
||||
import {ScrollView} from 'react-native-gesture-handler'
|
||||
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
@@ -26,6 +27,7 @@ import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/P
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {ProgressGuideList} from './ProgressGuide/List'
|
||||
|
||||
const MOBILE_CARD_WIDTH = 300
|
||||
@@ -227,7 +229,7 @@ export function ProfileGrid({
|
||||
viewContext = 'feed',
|
||||
}: {
|
||||
isSuggestionsLoading: boolean
|
||||
profiles: AppBskyActorDefs.ProfileViewDetailed[]
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
recId?: number
|
||||
error: Error | null
|
||||
viewContext: 'profile' | 'feed'
|
||||
@@ -358,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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Link, LinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
const AVI_SIZE = 30
|
||||
const AVI_SIZE_SMALL = 20
|
||||
@@ -32,11 +33,13 @@ export function KnownFollowers({
|
||||
moderationOpts,
|
||||
onLinkPress,
|
||||
minimal,
|
||||
showIfEmpty,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
minimal?: boolean
|
||||
showIfEmpty?: boolean
|
||||
}) {
|
||||
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
|
||||
new Map(),
|
||||
@@ -63,11 +66,12 @@ export function KnownFollowers({
|
||||
moderationOpts={moderationOpts}
|
||||
onLinkPress={onLinkPress}
|
||||
minimal={minimal}
|
||||
showIfEmpty={showIfEmpty}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
return <EmptyFallback show={showIfEmpty} />
|
||||
}
|
||||
|
||||
function KnownFollowersInner({
|
||||
@@ -76,22 +80,19 @@ function KnownFollowersInner({
|
||||
cachedKnownFollowers,
|
||||
onLinkPress,
|
||||
minimal,
|
||||
showIfEmpty,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
minimal?: boolean
|
||||
showIfEmpty?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const textStyle = [
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]
|
||||
const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]
|
||||
|
||||
const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
|
||||
const moderation = moderateProfile(f, moderationOpts)
|
||||
@@ -114,7 +115,7 @@ function KnownFollowersInner({
|
||||
* We check above too, but here for clarity and a reminder to _check for
|
||||
* valid indices_
|
||||
*/
|
||||
if (slice.length === 0) return null
|
||||
if (slice.length === 0) return <EmptyFallback show={showIfEmpty} />
|
||||
|
||||
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
|
||||
|
||||
@@ -126,7 +127,7 @@ function KnownFollowersInner({
|
||||
onPress={onLinkPress}
|
||||
to={makeProfileLink(profile, 'known-followers')}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.max_w_full,
|
||||
a.flex_row,
|
||||
minimal ? a.gap_sm : a.gap_md,
|
||||
a.align_center,
|
||||
@@ -171,6 +172,7 @@ function KnownFollowersInner({
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.flex_shrink,
|
||||
textStyle,
|
||||
hovered && {
|
||||
textDecorationLine: 'underline',
|
||||
@@ -242,3 +244,15 @@ function KnownFollowersInner({
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyFallback({show}: {show?: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!show) return null
|
||||
|
||||
return (
|
||||
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Not followed by anyone you're following</Trans>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {createContext, useCallback, useContext} from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {GestureResponderEvent, Keyboard, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TextStyleProp,
|
||||
useBreakpoints,
|
||||
useGutters,
|
||||
useLayoutBreakpoints,
|
||||
useTheme,
|
||||
web,
|
||||
} from '#/alf'
|
||||
@@ -22,7 +23,9 @@ 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'
|
||||
import {ScrollbarOffsetContext} from '#/components/Layout/context'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -42,6 +45,7 @@ export function Outer({
|
||||
const gutters = useGutters([0, 'base'])
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
|
||||
const {centerColumnOffset} = useLayoutBreakpoints()
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -60,7 +64,12 @@ export function Outer({
|
||||
}),
|
||||
t.atoms.border_contrast_low,
|
||||
gtMobile && [a.mx_auto, {maxWidth: 600}],
|
||||
!isWithinOffsetView && a.scrollbar_offset,
|
||||
!isWithinOffsetView && {
|
||||
transform: [
|
||||
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
|
||||
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
|
||||
],
|
||||
},
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
@@ -140,6 +149,7 @@ export function MenuButton() {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
setDrawerOpen(true)
|
||||
}, [setDrawerOpen])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,15 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {
|
||||
atoms as a,
|
||||
useBreakpoints,
|
||||
useLayoutBreakpoints,
|
||||
useTheme,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
import {CENTER_COLUMN_OFFSET, SCROLLBAR_OFFSET} from '#/components/Layout/const'
|
||||
import {ScrollbarOffsetContext} from '#/components/Layout/context'
|
||||
|
||||
export * from '#/components/Layout/const'
|
||||
@@ -47,6 +55,7 @@ export const Screen = React.memo(function Screen({
|
||||
export type ContentProps = AnimatedScrollViewProps & {
|
||||
style?: StyleProp<ViewStyle>
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
ignoreTabletLayoutOffset?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +65,7 @@ export const Content = React.memo(function Content({
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ignoreTabletLayoutOffset,
|
||||
...props
|
||||
}: ContentProps) {
|
||||
const t = useTheme()
|
||||
@@ -84,8 +94,10 @@ export const Content = React.memo(function Content({
|
||||
]}
|
||||
{...props}>
|
||||
{isWeb ? (
|
||||
// @ts-ignore web only -esb
|
||||
<Center>{children}</Center>
|
||||
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
|
||||
{/* @ts-expect-error web only -esb */}
|
||||
{children}
|
||||
</Center>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
@@ -138,10 +150,13 @@ export const KeyboardAwareContent = React.memo(function LayoutScrollView({
|
||||
export const Center = React.memo(function LayoutContent({
|
||||
children,
|
||||
style,
|
||||
ignoreTabletLayoutOffset,
|
||||
...props
|
||||
}: ViewProps) {
|
||||
}: ViewProps & {ignoreTabletLayoutOffset?: boolean}) {
|
||||
const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {centerColumnOffset} = useLayoutBreakpoints()
|
||||
const {isWithinDialog} = useDialogContext()
|
||||
const ctx = useMemo(() => ({isWithinOffsetView: true}), [])
|
||||
return (
|
||||
<View
|
||||
@@ -151,8 +166,20 @@ export const Center = React.memo(function LayoutContent({
|
||||
gtMobile && {
|
||||
maxWidth: 600,
|
||||
},
|
||||
!isWithinOffsetView && {
|
||||
transform: [
|
||||
{
|
||||
translateX:
|
||||
centerColumnOffset &&
|
||||
!ignoreTabletLayoutOffset &&
|
||||
!isWithinDialog
|
||||
? CENTER_COLUMN_OFFSET
|
||||
: 0,
|
||||
},
|
||||
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
|
||||
],
|
||||
},
|
||||
style,
|
||||
!isWithinOffsetView && a.scrollbar_offset,
|
||||
]}
|
||||
{...props}>
|
||||
<ScrollbarOffsetContext.Provider value={ctx}>
|
||||
@@ -168,6 +195,7 @@ export const Center = React.memo(function LayoutContent({
|
||||
const WebCenterBorders = React.memo(function LayoutContent() {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {centerColumnOffset} = useLayoutBreakpoints()
|
||||
return gtMobile ? (
|
||||
<View
|
||||
style={[
|
||||
@@ -180,9 +208,8 @@ const WebCenterBorders = React.memo(function LayoutContent() {
|
||||
width: 602,
|
||||
left: '50%',
|
||||
transform: [
|
||||
{
|
||||
translateX: '-50%',
|
||||
},
|
||||
{translateX: '-50%'},
|
||||
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
|
||||
...a.scrollbar_offset.transform,
|
||||
],
|
||||
}),
|
||||
|
||||
+13
-1
@@ -69,6 +69,11 @@ type BaseLinkProps = Pick<
|
||||
* Native-only attribute. If true, will open the share sheet on long press.
|
||||
*/
|
||||
shareOnLongPress?: boolean
|
||||
|
||||
/**
|
||||
* Whether the link should be opened through the redirect proxy.
|
||||
*/
|
||||
shouldProxy?: boolean
|
||||
}
|
||||
|
||||
export function useLink({
|
||||
@@ -80,9 +85,11 @@ export function useLink({
|
||||
onLongPress: outerOnLongPress,
|
||||
shareOnLongPress,
|
||||
overridePresentation,
|
||||
shouldProxy,
|
||||
}: BaseLinkProps & {
|
||||
displayText: string
|
||||
overridePresentation?: boolean
|
||||
shouldProxy?: boolean
|
||||
}) {
|
||||
const navigation = useNavigationDeduped()
|
||||
const {href} = useLinkProps<AllNavigatorParams>({
|
||||
@@ -118,7 +125,7 @@ export function useLink({
|
||||
})
|
||||
} else {
|
||||
if (isExternal) {
|
||||
openLink(href, overridePresentation)
|
||||
openLink(href, overridePresentation, shouldProxy)
|
||||
} else {
|
||||
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
|
||||
|
||||
@@ -161,6 +168,7 @@ export function useLink({
|
||||
action,
|
||||
navigation,
|
||||
overridePresentation,
|
||||
shouldProxy,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -219,6 +227,7 @@ export function Link({
|
||||
onPress: outerOnPress,
|
||||
onLongPress: outerOnLongPress,
|
||||
download,
|
||||
shouldProxy,
|
||||
...rest
|
||||
}: LinkProps) {
|
||||
const {href, isExternal, onPress, onLongPress} = useLink({
|
||||
@@ -227,6 +236,7 @@ export function Link({
|
||||
action,
|
||||
onPress: outerOnPress,
|
||||
onLongPress: outerOnLongPress,
|
||||
shouldProxy: shouldProxy,
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -279,6 +289,7 @@ export function InlineLinkText({
|
||||
shareOnLongPress,
|
||||
disableUnderline,
|
||||
overridePresentation,
|
||||
shouldProxy,
|
||||
...rest
|
||||
}: InlineLinkProps) {
|
||||
const t = useTheme()
|
||||
@@ -292,6 +303,7 @@ export function InlineLinkText({
|
||||
onLongPress: outerOnLongPress,
|
||||
shareOnLongPress,
|
||||
overridePresentation,
|
||||
shouldProxy: shouldProxy,
|
||||
})
|
||||
const {
|
||||
state: hovered,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphDefs,
|
||||
AtUri,
|
||||
moderateUserList,
|
||||
@@ -26,7 +25,7 @@ import {
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import * as Hider from '#/components/moderation/Hider'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ButtonProps} from './Button'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
/*
|
||||
* This component is based on `FeedCard` and is tightly coupled with that
|
||||
@@ -49,7 +48,7 @@ const MODLIST = 'app.bsky.graph.defs#modlist'
|
||||
type Props = {
|
||||
view: AppBskyGraphDefs.ListView
|
||||
showPinButton?: boolean
|
||||
} & Omit<LinkProps, 'to' | 'label' | 'children'>
|
||||
}
|
||||
|
||||
export function Default(props: Props) {
|
||||
const {view, showPinButton} = props
|
||||
@@ -83,7 +82,7 @@ export function Link({
|
||||
view,
|
||||
children,
|
||||
...props
|
||||
}: Props & Pick<ButtonProps, 'children'>) {
|
||||
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
@@ -108,7 +107,7 @@ export function TitleAndByline({
|
||||
modUi,
|
||||
}: {
|
||||
title: string
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
creator?: bsky.profile.AnyProfileView
|
||||
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||
modUi?: ModerationUI
|
||||
}) {
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedVideo,
|
||||
} from '@atproto/api'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||
import {isTenorGifUri} from '#/lib/strings/embed-player'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
/**
|
||||
* Streamlined MediaPreview component which just handles images, gifs, and videos
|
||||
@@ -22,20 +18,17 @@ export function Embed({
|
||||
embed,
|
||||
style,
|
||||
}: {
|
||||
embed?:
|
||||
| AppBskyEmbedImages.View
|
||||
| AppBskyEmbedRecordWithMedia.View
|
||||
| AppBskyEmbedExternal.View
|
||||
| AppBskyEmbedVideo.View
|
||||
| {[k: string]: unknown}
|
||||
embed: AppBskyFeedDefs.PostView['embed']
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
let media = AppBskyEmbedRecordWithMedia.isView(embed) ? embed.media : embed
|
||||
const e = bsky.post.parseEmbed(embed)
|
||||
|
||||
if (AppBskyEmbedImages.isView(media)) {
|
||||
if (!e) return null
|
||||
|
||||
if (e.type === 'images') {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
{media.images.map(image => (
|
||||
{e.view.images.map(image => (
|
||||
<ImageItem
|
||||
key={image.thumb}
|
||||
thumbnail={image.thumb}
|
||||
@@ -44,30 +37,31 @@ export function Embed({
|
||||
))}
|
||||
</Outer>
|
||||
)
|
||||
} else if (AppBskyEmbedExternal.isView(media) && media.external.thumb) {
|
||||
let url: URL | undefined
|
||||
try {
|
||||
url = new URL(media.external.uri)
|
||||
} catch {}
|
||||
if (url) {
|
||||
const {success} = parseTenorGif(url)
|
||||
if (success) {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<GifItem
|
||||
thumbnail={media.external.thumb}
|
||||
alt={media.external.title}
|
||||
/>
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (AppBskyEmbedVideo.isView(media)) {
|
||||
} else if (e.type === 'link') {
|
||||
if (!e.view.external.thumb) return null
|
||||
if (!isTenorGifUri(e.view.external.uri)) return null
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<VideoItem thumbnail={media.thumbnail} alt={media.alt} />
|
||||
<GifItem
|
||||
thumbnail={e.view.external.thumb}
|
||||
alt={e.view.external.title}
|
||||
/>
|
||||
</Outer>
|
||||
)
|
||||
} else if (e.type === 'video') {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<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>(
|
||||
@@ -100,7 +102,7 @@ export function Outer({
|
||||
<Dialog.Handle />
|
||||
{/* Re-wrap with context since Dialogs are portal-ed to root */}
|
||||
<Context.Provider value={context}>
|
||||
<Dialog.ScrollableInner label={_(msg`Menu`)} style={[a.py_sm]}>
|
||||
<Dialog.ScrollableInner label={_(msg`Menu`)}>
|
||||
<View style={[a.gap_lg]}>
|
||||
{children}
|
||||
{isNative && showCancel && <Cancel />}
|
||||
|
||||
@@ -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()
|
||||
@@ -184,9 +186,10 @@ export function Outer({
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
sideOffset={5}
|
||||
collisionPadding={{left: 5, right: 5, bottom: 5}}
|
||||
loop
|
||||
aria-label="Test"
|
||||
className="dropdown-menu-transform-origin">
|
||||
className="dropdown-menu-transform-origin dropdown-menu-constrain-size">
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
@@ -195,6 +198,7 @@ export function Outer({
|
||||
t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
|
||||
t.atoms.shadow_md,
|
||||
t.atoms.border_contrast_low,
|
||||
a.overflow_auto,
|
||||
!reduceMotionEnabled && a.zoom_fade_in,
|
||||
style,
|
||||
]}>
|
||||
@@ -380,9 +384,8 @@ export function Divider() {
|
||||
style={flatten([
|
||||
a.my_xs,
|
||||
t.atoms.bg_contrast_100,
|
||||
{
|
||||
height: 1,
|
||||
},
|
||||
a.flex_shrink_0,
|
||||
{height: 1},
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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,15 +1,14 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
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'
|
||||
@@ -19,19 +18,25 @@ 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 type * as bsky from '#/types/bsky'
|
||||
|
||||
export function Default({
|
||||
profile,
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
}) {
|
||||
@@ -51,7 +56,7 @@ export function Card({
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
}) {
|
||||
@@ -101,7 +106,7 @@ export function Link({
|
||||
style,
|
||||
...rest
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
} & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
@@ -126,14 +131,14 @@ export function Avatar({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -161,7 +166,7 @@ export function NameAndHandle({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const t = useTheme()
|
||||
@@ -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,
|
||||
@@ -224,17 +229,16 @@ export function Description({
|
||||
profile: profileUnshadowed,
|
||||
numberOfLines = 3,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
numberOfLines?: number
|
||||
}) {
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {description} = profile
|
||||
const rt = React.useMemo(() => {
|
||||
if (!description) return
|
||||
const rt = new RichTextApi({text: description || ''})
|
||||
if (!('description' in profile)) return
|
||||
const rt = new RichTextApi({text: profile.description || ''})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
return rt
|
||||
}, [description])
|
||||
}, [profile])
|
||||
if (!rt) return null
|
||||
if (
|
||||
profile.viewer &&
|
||||
@@ -262,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) => (
|
||||
@@ -271,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%'},
|
||||
]}
|
||||
/>
|
||||
@@ -281,12 +285,13 @@ export function DescriptionPlaceholder({
|
||||
}
|
||||
|
||||
export type FollowButtonProps = {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext']
|
||||
colorInverted?: boolean
|
||||
onFollow?: () => void
|
||||
withIcon?: boolean
|
||||
} & Partial<ButtonProps>
|
||||
|
||||
export function FollowButton(props: FollowButtonProps) {
|
||||
@@ -302,6 +307,7 @@ export function FollowButtonInner({
|
||||
onPress: onPressProp,
|
||||
onFollow,
|
||||
colorInverted,
|
||||
withIcon = true,
|
||||
...rest
|
||||
}: FollowButtonProps) {
|
||||
const {_} = useLingui()
|
||||
@@ -387,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>
|
||||
) : (
|
||||
@@ -398,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,11 +1,18 @@
|
||||
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {ScrollView, TextInput, useWindowDimensions, View} from 'react-native'
|
||||
import {
|
||||
ScrollView,
|
||||
type StyleProp,
|
||||
TextInput,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
ZoomInEasyDown,
|
||||
} from 'react-native-reanimated'
|
||||
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
|
||||
import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -19,8 +26,8 @@ import {useActorSearchPaginated} from '#/state/queries/actor-search'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||
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 +38,7 @@ import {
|
||||
tokens,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
ViewStyleProp,
|
||||
type ViewStyleProp,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
@@ -452,12 +459,16 @@ 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)
|
||||
@@ -518,7 +529,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={
|
||||
@@ -532,7 +543,7 @@ let Tabs = ({
|
||||
{interests.map((interest, i) => {
|
||||
const active = interest === selectedInterest && !hasSearchText
|
||||
return (
|
||||
<Tab
|
||||
<TabComponent
|
||||
key={interest}
|
||||
onSelectTab={handleSelectTab}
|
||||
active={active}
|
||||
@@ -547,6 +558,7 @@ let Tabs = ({
|
||||
)
|
||||
}
|
||||
Tabs = memo(Tabs)
|
||||
export {Tabs}
|
||||
|
||||
let Tab = ({
|
||||
onSelectTab,
|
||||
@@ -822,7 +834,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
|
||||
|
||||
@@ -45,6 +45,7 @@ export function Outer({
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID={testID}
|
||||
webOptions={{alignCenter: true}}
|
||||
nativeOptions={{preventExpansion: true, ...nativeOptions}}>
|
||||
<Dialog.Handle />
|
||||
<Context.Provider value={context}>
|
||||
|
||||
@@ -23,6 +23,7 @@ export type RichTextProps = TextStyleProp &
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
interactiveStyle?: TextStyle
|
||||
emojiMultiplier?: number
|
||||
shouldProxyLinks?: boolean
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
@@ -39,6 +40,7 @@ export function RichText({
|
||||
emojiMultiplier = 1.85,
|
||||
onLayout,
|
||||
onTextLayout,
|
||||
shouldProxyLinks,
|
||||
}: RichTextProps) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
@@ -110,6 +112,7 @@ export function RichText({
|
||||
style={interactiveStyles}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
shouldProxy={shouldProxyLinks}
|
||||
onPress={onLinkPress}>
|
||||
{segment.text}
|
||||
</InlineLinkText>
|
||||
@@ -128,6 +131,7 @@ export function RichText({
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
shareOnLongPress
|
||||
shouldProxy={shouldProxyLinks}
|
||||
onPress={onLinkPress}
|
||||
emoji>
|
||||
{toShortUrl(segment.text)}
|
||||
|
||||
@@ -1,46 +1,49 @@
|
||||
import React from 'react'
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
findNodeHandle,
|
||||
ListRenderItemInfo,
|
||||
StyleProp,
|
||||
type ListRenderItemInfo,
|
||||
type StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {AppBskyGraphDefs, AppBskyGraphGetActorStarterPacks} 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'
|
||||
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
||||
|
||||
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 {List, ListRef} from '#/view/com/util/List'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
|
||||
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'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
|
||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog'
|
||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
interface SectionRef {
|
||||
scrollToTop: () => void
|
||||
}
|
||||
|
||||
interface ProfileFeedgensProps {
|
||||
starterPacksQuery: UseInfiniteQueryResult<
|
||||
InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>,
|
||||
Error
|
||||
>
|
||||
scrollElRef: ListRef
|
||||
did: string
|
||||
headerOffset: number
|
||||
enabled?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
@@ -58,8 +61,8 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
ProfileFeedgensProps
|
||||
>(function ProfileFeedgensImpl(
|
||||
{
|
||||
starterPacksQuery: query,
|
||||
scrollElRef,
|
||||
did,
|
||||
headerOffset,
|
||||
enabled,
|
||||
style,
|
||||
@@ -71,17 +74,24 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
) {
|
||||
const t = useTheme()
|
||||
const bottomBarOffset = useBottomBarOffset(100)
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const {data, refetch, isFetching, hasNextPage, fetchNextPage} = query
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
const {
|
||||
data,
|
||||
refetch,
|
||||
isError,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useActorStarterPacksQuery({did, enabled})
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
|
||||
const items = data?.pages.flatMap(page => page.starterPacks)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToTop: () => {},
|
||||
}))
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
const onRefresh = useCallback(async () => {
|
||||
setIsPTRing(true)
|
||||
try {
|
||||
await refetch()
|
||||
@@ -92,37 +102,36 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetching || !hasNextPage) return
|
||||
|
||||
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])
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (enabled && scrollElRef.current) {
|
||||
const nativeTag = findNodeHandle(scrollElRef.current)
|
||||
setScrollViewTag(nativeTag)
|
||||
}
|
||||
}, [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}>
|
||||
@@ -140,9 +149,11 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
desktopFixedHeight
|
||||
onEndReached={onEndReached}
|
||||
onRefresh={onRefresh}
|
||||
ListEmptyComponent={Empty}
|
||||
ListEmptyComponent={
|
||||
data ? (isMe ? Empty : undefined) : FeedLoadingPlaceholder
|
||||
}
|
||||
ListFooterComponent={
|
||||
items?.length !== 0 && isMe ? CreateAnother : undefined
|
||||
!!data && items?.length !== 0 && isMe ? CreateAnother : undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
@@ -181,7 +192,6 @@ function CreateAnother() {
|
||||
|
||||
function Empty() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const confirmDialogControl = useDialogControl()
|
||||
const followersDialogControl = useDialogControl()
|
||||
@@ -190,7 +200,7 @@ function Empty() {
|
||||
const {needsEmailVerification} = useEmail()
|
||||
const verifyEmailControl = useDialogControl()
|
||||
|
||||
const [isGenerating, setIsGenerating] = React.useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
const {mutate: generateStarterPack} = useGenerateStarterPackMutation({
|
||||
onSuccess: ({uri}) => {
|
||||
@@ -227,16 +237,10 @@ function Empty() {
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.shadow_lg,
|
||||
{marginTop: 1},
|
||||
{marginTop: a.border.borderWidth},
|
||||
]}>
|
||||
<View style={[a.gap_xs]}>
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.text_lg,
|
||||
t.atoms.text_contrast_medium,
|
||||
{color: 'white'},
|
||||
]}>
|
||||
<Text style={[a.font_bold, a.text_lg, {color: 'white'}]}>
|
||||
<Trans>You haven't created a starter pack yet!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, {color: 'white'}]}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
const LazyViewShot = React.lazy(
|
||||
// @ts-expect-error dynamic import
|
||||
@@ -30,7 +31,12 @@ export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
|
||||
) {
|
||||
const {record} = starterPack
|
||||
|
||||
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||
if (
|
||||
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
|
||||
record,
|
||||
AppBskyGraphStarterpack.isRecord,
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import * as Dialog from '#/components/Dialog'
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {QrCode} from '#/components/StarterPack/QrCode'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function QrCodeDialog({
|
||||
starterPack,
|
||||
@@ -77,7 +78,12 @@ export function QrCodeDialog({
|
||||
} else {
|
||||
setIsProcessing(true)
|
||||
|
||||
if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) {
|
||||
if (
|
||||
!bsky.validate(
|
||||
starterPack.record,
|
||||
AppBskyGraphStarterpack.validateRecord,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
@@ -13,13 +13,17 @@ 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'
|
||||
|
||||
export function Default({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||
starterPack?: bsky.starterPack.AnyStarterPackView
|
||||
}) {
|
||||
if (!starterPack) return null
|
||||
return (
|
||||
@@ -32,7 +36,7 @@ export function Default({
|
||||
export function Notification({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||
starterPack?: bsky.starterPack.AnyStarterPackView
|
||||
}) {
|
||||
if (!starterPack) return null
|
||||
return (
|
||||
@@ -47,7 +51,7 @@ export function Card({
|
||||
noIcon,
|
||||
noDescription,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
starterPack: bsky.starterPack.AnyStarterPackView
|
||||
noIcon?: boolean
|
||||
noDescription?: boolean
|
||||
}) {
|
||||
@@ -57,7 +61,12 @@ export function Card({
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||
if (
|
||||
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
|
||||
record,
|
||||
AppBskyGraphStarterpack.isRecord,
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -89,18 +98,46 @@ 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,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
starterPack: bsky.starterPack.AnyStarterPackView
|
||||
onPress?: () => void
|
||||
children: BaseLinkProps['children']
|
||||
}) {
|
||||
@@ -139,7 +176,7 @@ export function Link({
|
||||
export function Embed({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
starterPack: bsky.starterPack.AnyStarterPackView
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const imageUri = getStarterPackOgCard(starterPack)
|
||||
|
||||
@@ -38,7 +38,7 @@ export function WizardEditListDialog({
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
moderationOpts: ModerationOpts
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
@@ -22,6 +22,7 @@ import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Checkbox} from '#/components/forms/Toggle'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
function WizardListCard({
|
||||
type,
|
||||
@@ -123,7 +124,7 @@ export function WizardProfileCard({
|
||||
btnType: 'checkbox' | 'remove'
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
@@ -27,6 +27,7 @@ import {Link} from '#/components/Link'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import * as Hider from '#/components/moderation/Hider'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
function getBlackColor(t: ReturnType<typeof useTheme>) {
|
||||
return select(t.name, {
|
||||
@@ -78,7 +79,12 @@ export function VideoPostCard({
|
||||
if (!AppBskyEmbedVideo.isView(embed)) return null
|
||||
|
||||
const author = post.author
|
||||
const text = AppBskyFeedPost.isRecord(post.record) ? post.record?.text : ''
|
||||
const text = bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
)
|
||||
? post.record?.text
|
||||
: ''
|
||||
const likeCount = post?.likeCount ?? 0
|
||||
const repostCount = post?.repostCount ?? 0
|
||||
const {thumbnail} = embed
|
||||
|
||||
@@ -29,6 +29,7 @@ import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
|
||||
|
||||
interface WhoCanReplyProps {
|
||||
@@ -48,7 +49,10 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
* unexpectedly, we should check to make sure it's for sure the root URI.
|
||||
*/
|
||||
const rootUri =
|
||||
AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
) && post.record.reply?.root
|
||||
? post.record.reply.root.uri
|
||||
: post.uri
|
||||
const settings = React.useMemo(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user