Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey 4adeb4befc Patch build version 2025-03-07 13:42:36 -06:00
234 changed files with 36097 additions and 51190 deletions
-11
View File
@@ -15,7 +15,6 @@ module.exports = {
'simple-import-sort',
'bsky-internal',
'eslint-plugin-react-compiler',
'import',
],
rules: {
'react/no-unescaped-entities': 0,
@@ -82,16 +81,6 @@ module.exports = {
],
'simple-import-sort/exports': 'error',
'react-compiler/react-compiler': 'warn',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{argsIgnorePattern: '^_', varsIgnorePattern: '^_'},
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
],
'import/consistent-type-specifier-style': ['warn', 'prefer-inline'],
},
ignorePatterns: [
'**/__mocks__/*.ts',
@@ -45,10 +45,7 @@ jobs:
- name: Set outputs
id: vars
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
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Build and push Docker image
id: build-and-push
@@ -63,6 +60,3 @@ 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 }}
+3 -12
View File
@@ -56,10 +56,7 @@ jobs:
run: yarn install
- 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
run: yarn intl:build
- name: ✏️ Write environment variables
run: |
@@ -70,14 +67,8 @@ 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: 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
run: 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' }}
@@ -139,7 +130,7 @@ jobs:
- name: 🏗️ Build Production APK
if: ${{ inputs.profile == 'production' }}
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
run: yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
- name: 🚀 Upload Production APK Artifact
id: upload-artifact-production-apk
+2 -11
View File
@@ -69,10 +69,7 @@ jobs:
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
- 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
run: yarn intl:build
- name: ✏️ Write environment variables
run: |
@@ -82,14 +79,8 @@ 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: 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
run: 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
+3 -12
View File
@@ -75,11 +75,8 @@ jobs:
- name: Prettier check
run: yarn prettier --check .
- 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: Check & compile i18n
run: yarn intl:build
- name: Type check
run: yarn typecheck
@@ -110,15 +107,9 @@ 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: 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
run: EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
- name: 📦 Package Bundle and 🚀 Deploy
if: ${{ !steps.fingerprint.outputs.includes-changes }}
+2 -27
View File
@@ -4,45 +4,21 @@ 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 . .
#
@@ -62,9 +38,8 @@ 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 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
yarn intl:build && \
EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER EXPO_PUBLIC_BUNDLE_DATE=$() yarn build-web
# DEBUG
RUN find ./bskyweb/static && find ./web-build/static
+1
View File
@@ -17,6 +17,7 @@ appId: xyz.blueskyweb.app
- inputText: "Post with an image"
- tapOn:
id: "openGalleryBtn"
- tapOn: "Allow Full Access"
- tapOn: "Content warnings"
- tapOn: "Porn"
- tapOn:
+22 -2
View File
@@ -36,6 +36,9 @@ 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: {
@@ -201,8 +204,9 @@ module.exports = function (config) {
'@sentry/react-native/expo',
{
organization: 'blueskyweb',
project: 'app',
url: 'https://sentry.io',
project: 'react-native',
release: VERSION,
dist: SENTRY_DIST,
},
],
[
@@ -403,6 +407,22 @@ 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,
},
},
],
},
},
}
}
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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>

Before

Width:  |  Height:  |  Size: 791 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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>

Before

Width:  |  Height:  |  Size: 336 B

+21
View File
@@ -0,0 +1,21 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "simple-import-sort"],
"extends": [
"eslint:recommended",
"preact",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"rules": {
"simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn",
'no-else-return': 'off'
},
"parserOptions": {
"sourceType": "module",
"ecmaVersion": "latest",
"project": "./bskyembed/tsconfig.json"
}
}
-22
View File
@@ -1,22 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'simple-import-sort'],
extends: [
'eslint:recommended',
'preact',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
rules: {
'simple-import-sort/imports': 'warn',
'simple-import-sort/exports': 'warn',
'no-else-return': 'off',
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
}
+1 -3
View File
@@ -68,9 +68,7 @@ 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)
}
searchParams.set('colorMode', embed.dataset.blueskyColorMode || 'system')
const iframe = document.createElement('iframe')
iframe.setAttribute('data-bluesky-id', id)
+13 -13
View File
@@ -78,7 +78,7 @@ export function Embed({
return (
<Link
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-xl p-2 gap-1.5 w-full flex flex-col">
className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-lg p-2 gap-1.5 w-full flex flex-col">
<div className="flex gap-1.5 items-center">
<div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
<img
@@ -207,7 +207,7 @@ export function Embed({
function Info({children}: {children: ComponentChildren}) {
return (
<div className="w-full rounded-xl border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
<div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
<img src={infoIcon} className="w-4 h-4 shrink-0 mt-0.5" />
<p className="text-sm text-textLight dark:text-textDimmed">{children}</p>
</div>
@@ -231,12 +231,12 @@ function ImageEmbed({
<img
src={content.images[0].thumb}
alt={content.images[0].alt}
className="w-full rounded-xl overflow-hidden object-cover h-auto max-h-[1000px]"
className="w-full rounded-lg overflow-hidden object-cover h-auto max-h-[1000px]"
/>
)
case 2:
return (
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
{content.images.map((image, i) => (
<img
key={i}
@@ -249,13 +249,13 @@ function ImageEmbed({
)
case 3:
return (
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
<img
src={content.images[0].thumb}
alt={content.images[0].alt}
className="flex-1 aspect-square object-cover rounded-sm"
className="flex-[3] object-cover rounded-sm"
/>
<div className="flex flex-col gap-1 flex-1 aspect-square">
<div className="flex flex-col gap-1 flex-[2]">
{content.images.slice(1).map((image, i) => (
<img
key={i}
@@ -269,13 +269,13 @@ function ImageEmbed({
)
case 4:
return (
<div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden">
<div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
{content.images.map((image, i) => (
<img
key={i}
src={image.thumb}
alt={image.alt}
className="aspect-video w-full object-cover rounded-sm"
className="aspect-square w-full object-cover rounded-sm"
/>
))}
</div>
@@ -308,7 +308,7 @@ function ExternalEmbed({
return (
<Link
href={content.external.uri}
className="w-full rounded-xl overflow-hidden border dark:border-slate-600 flex flex-col items-stretch"
className="w-full rounded-lg overflow-hidden border dark:border-slate-600 flex flex-col items-stretch"
disableTracking>
{content.external.thumb && (
<img
@@ -345,7 +345,7 @@ function GenericWithImageEmbed({
return (
<Link
href={href}
className="w-full rounded-xl border dark:border-slate-600 py-2 px-3 flex flex-col gap-2">
className="w-full rounded-lg border dark:border-slate-600 py-2 px-3 flex flex-col gap-2">
<div className="flex gap-2.5 items-center">
{image ? (
<img
@@ -383,7 +383,7 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
return (
<div
className="w-full overflow-hidden rounded-xl aspect-square relative"
className="w-full overflow-hidden rounded-lg aspect-square relative"
style={{aspectRatio: `${aspectRatio} / 1`}}>
<img
src={content.thumbnail}
@@ -412,7 +412,7 @@ function StarterPackEmbed({
return (
<Link
href={starterPackHref}
className="w-full rounded-xl overflow-hidden border dark:border-slate-600 flex flex-col items-stretch">
className="w-full rounded-lg overflow-hidden border dark:border-slate-600 flex flex-col items-stretch">
<img src={imageUri} className="aspect-[1.91/1] object-cover" />
<div className="py-3 px-4">
<div className="flex space-x-2 items-center">
+1 -1
View File
@@ -173,7 +173,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
}
return (
<p className="min-[300px]:text-lg leading-6 min-[300px]:leading-6 break-word break-words whitespace-pre-wrap">
<p className="min-[300px]:text-lg leading-6 break-word break-words whitespace-pre-wrap">
{richText}
</p>
)
+1 -1
View File
@@ -6,7 +6,7 @@
word-break: break-word;
}
#app {
:root {
color-scheme: light dark;
}
-2
View File
@@ -9,10 +9,8 @@
},
"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",
-2
View File
@@ -4,7 +4,6 @@ import {AppContext} from '../context.js'
import {default as createShortLink} from './createShortLink.js'
import {default as health} from './health.js'
import {default as redirect} from './redirect.js'
import {default as root} from './root.js'
import {default as shortLink} from './shortLink.js'
import {default as siteAssociation} from './siteAssociation.js'
@@ -15,7 +14,6 @@ export default function (ctx: AppContext, app: Express) {
app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
app = redirect(ctx, app) // GET /redirect
app = createShortLink(ctx, app) // POST /link
app = root(ctx, app) // GET / (redirect to bsky.app on root)
app = shortLink(ctx, app) // GET /:linkId (should go last due to permissive matching)
return app
}
+3 -8
View File
@@ -1,7 +1,6 @@
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'
@@ -21,6 +20,7 @@ export default function (ctx: AppContext, app: Express) {
typeof link === 'string',
'express guarantees link query parameter is a string',
)
link = decodeURIComponent(link)
let url: URL | undefined
try {
@@ -40,13 +40,8 @@ export default function (ctx: AppContext, app: Express) {
}
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
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>`,
)
res.setHeader('Location', url.href)
return res.status(301).end()
}),
)
}
-14
View File
@@ -1,14 +0,0 @@
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()
}),
)
}
+1 -6
View File
@@ -69,11 +69,6 @@
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"
@@ -281,7 +276,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==
-1
View File
@@ -275,7 +275,6 @@ 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)
+4 -4
View File
@@ -1,11 +1,11 @@
# Internationalization
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/u/projects/1) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
## Tools
- We use Crowdin to manage translations.
- Bluesky Crowdin: https://bluesky.crowdin.com/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/).
@@ -15,7 +15,7 @@ Much of the app is translated by community contributions. (We <3 our translators
### Using Crowdin
[Crowdin](https://bluesky.crowdin.com/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
You can request a new language be added to the project by clicking **Request New Language** on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
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.
+2 -2
View File
@@ -1,6 +1,6 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const {getSentryExpoConfig} = require('@sentry/react-native/metro')
const cfg = getSentryExpoConfig(__dirname)
const {getDefaultConfig} = require('expo/metro-config')
const cfg = getDefaultConfig(__dirname)
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
+46 -50
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.101.0",
"version": "1.99.1",
"private": true,
"engines": {
"node": ">=20"
@@ -50,15 +50,14 @@
"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 --dump-sourcemap && yarn upload-native-sourcemaps",
"upload-native-sourcemaps": "npx sentry-expo-upload-sourcemaps dist",
"export": "npx expo export",
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 yarn build-web",
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web",
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.14.21",
"@atproto/api": "^0.14.7",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -86,12 +85,12 @@
"@radix-ui/react-focus-scope": "^1.1.2",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-menu/menu": "^1.1.7",
"@react-native-picker/picker": "2.10.3",
"@react-native-picker/picker": "2.10.2",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/drawer": "^6.6.15",
"@react-navigation/native": "^6.1.17",
"@react-navigation/native-stack": "^6.9.26",
"@sentry/react-native": "~6.10.0",
"@sentry/react-native": "5.24.3",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "^5.8.1",
"@tanstack/react-query-persist-client": "^5.25.0",
@@ -123,35 +122,35 @@
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "~52.0.42",
"expo-application": "~6.0.2",
"expo": "~52.0.19",
"expo-application": "^6.0.1",
"expo-blur": "^14.0.3",
"expo-build-properties": "~0.13.2",
"expo-camera": "~16.0.18",
"expo-clipboard": "~7.0.1",
"expo-dev-client": "~5.0.19",
"expo-device": "~7.0.3",
"expo-file-system": "~18.0.12",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-image-manipulator": "~13.0.6",
"expo-image-picker": "~16.0.6",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.5",
"expo-localization": "~16.0.1",
"expo-media-library": "~17.0.6",
"expo-navigation-bar": "~4.0.9",
"expo-notifications": "~0.29.14",
"expo-screen-orientation": "~8.0.4",
"expo-sharing": "~13.0.1",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.9",
"expo-task-manager": "~12.0.6",
"expo-updates": "~0.27.4",
"expo-video": "~2.0.6",
"expo-web-browser": "~14.0.2",
"expo-build-properties": "^0.13.1",
"expo-camera": "~16.0.7",
"expo-clipboard": "^7.0.0",
"expo-dev-client": "^5.0.4",
"expo-device": "~7.0.1",
"expo-file-system": "^18.0.6",
"expo-font": "~13.0.1",
"expo-haptics": "^14.0.0",
"expo-image": "~2.0.3",
"expo-image-manipulator": "^13.0.5",
"expo-image-picker": "~16.0.3",
"expo-linear-gradient": "^14.0.1",
"expo-linking": "^7.0.3",
"expo-localization": "~16.0.0",
"expo-media-library": "~17.0.3",
"expo-navigation-bar": "~4.0.4",
"expo-notifications": "~0.29.11",
"expo-screen-orientation": "^8.0.2",
"expo-sharing": "^13.0.0",
"expo-splash-screen": "~0.29.18",
"expo-status-bar": "~2.0.0",
"expo-system-ui": "^4.0.4",
"expo-task-manager": "~12.0.3",
"expo-updates": "~0.26.10",
"expo-video": "^2.0.5",
"expo-web-browser": "~14.0.1",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls.js": "^1.5.11",
@@ -169,31 +168,30 @@
"patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"react": "18.3.1",
"react": "18.2.0",
"react-compiler-runtime": "19.0.0-beta-a7bf2bd-20241110",
"react-dom": "18.3.1",
"react-dom": "^18.2.0",
"react-image-crop": "^11.0.7",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.76.9",
"react-native": "0.76.6",
"react-native-compressor": "1.10.3",
"react-native-date-picker": "^5.0.7",
"react-native-drawer-layout": "^4.1.1",
"react-native-emoji-popup": "^0.1.2",
"react-native-gesture-handler": "2.20.2",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "^0.41.6",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.14.5",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.5.1",
"react-native-pager-view": "6.7.0",
"react-native-picker-select": "^9.3.1",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "~3.16.1",
"react-native-reanimated": "3.17.0-nightly-20241211-17e89ca24",
"react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0",
"react-native-safe-area-context": "4.14.0",
"react-native-screens": "~4.3.0",
"react-native-svg": "^15.8.0",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.2",
@@ -201,7 +199,7 @@
"react-native-vision-camera": "^4.6.3",
"react-native-web": "~0.19.13",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.12.5",
"react-native-webview": "13.12.3",
"react-remove-scroll-bar": "^2.3.6",
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
@@ -223,9 +221,8 @@
"@lingui/cli": "^4.14.1",
"@lingui/macro": "^4.14.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native/eslint-config": "^0.76.9",
"@react-native/typescript-config": "^0.76.9",
"@sentry/webpack-plugin": "^3.2.2",
"@react-native/eslint-config": "^0.76.2",
"@react-native/typescript-config": "^0.76.1",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^12.8.1",
"@types/jest": "^29.4.0",
@@ -247,7 +244,6 @@
"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",
@@ -257,7 +253,7 @@
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
"jest-expo": "~52.0.6",
"jest-expo": "^52.0.2",
"jest-junit": "^16.0.0",
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
@@ -273,7 +269,7 @@
},
"resolutions": {
"@expo/image-utils": "0.6.3",
"@react-native/babel-preset": "0.76.9",
"@react-native/babel-preset": "0.76.1",
"@react-native/normalize-colors": "0.76.1",
"@types/react": "^18",
"**/expo-constants": "17.0.3",
+36
View File
@@ -0,0 +1,36 @@
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,
-13
View File
@@ -1,13 +0,0 @@
diff --git a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
index c282ade..8777755 100755
--- a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
+++ b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
@@ -215,7 +215,7 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
const isHermes = assets.find(asset => asset.endsWith('.hbc'));
const windowsCallback = process.platform === "win32" ? 'node ' : '';
- execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')}`, {
+ execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')} --dist ${process.env.SENTRY_DIST}`, {
env: {
...process.env,
[SENTRY_PROJECT]: sentryProject,
@@ -1,8 +1,8 @@
diff --git a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
index 64e6efe..e61485e 100644
index b928b8f..7175cf6 100644
--- a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
+++ b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
@@ -111,12 +111,12 @@ class MediaLibraryModule : Module() {
@@ -99,12 +99,12 @@ class MediaLibraryModule : Module() {
}
AsyncFunction("createAssetAsync") { localUri: String, promise: Promise ->
@@ -1,5 +1,5 @@
diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle
index 13bffbb..5ebbede 100644
index 5dd0c61..bf536dd 100644
--- a/node_modules/expo-notifications/android/build.gradle
+++ b/node_modules/expo-notifications/android/build.gradle
@@ -46,6 +46,7 @@ dependencies {
@@ -41,7 +41,7 @@ diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Scro
index 93af874..106f8ec 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
@@ -66,28 +66,51 @@
@@ -66,28 +66,51 @@ - (void)preserveContentOffsetWithBlock:(void (^)())block
* ScrollView, we force it to be centered, but when you zoom or the content otherwise
* becomes larger than the ScrollView, there is no padding around the content but it
* can still fill the whole view.
@@ -103,7 +103,7 @@ index 93af874..106f8ec 100644
- (BOOL)touchesShouldCancelInContentView:(UIView *)view
{
if ([_overridingDelegate respondsToSelector:@selector(touchesShouldCancelInContentView:)]) {
@@ -257,6 +280,10 @@
@@ -257,6 +280,10 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
}
}
@@ -131,7 +131,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 53bfd04..ff1b1ed 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@ @implementation RCTRefreshControl {
UIColor *_titleColor;
CGFloat _progressViewOffset;
BOOL _hasMovedToWindow;
@@ -139,7 +139,7 @@ index 53bfd04..ff1b1ed 100644
}
- (instancetype)init
@@ -58,6 +59,12 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
@@ -58,6 +59,12 @@ - (void)layoutSubviews
_isInitialRender = false;
}
@@ -152,7 +152,7 @@ index 53bfd04..ff1b1ed 100644
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -221,4 +228,50 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
}
}
@@ -207,7 +207,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 40aaf9c..1c60164 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -22,11 +22,12 @@ RCT_EXPORT_MODULE()
@@ -22,11 +22,12 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
@@ -222,10 +222,10 @@ index 40aaf9c..1c60164 100644
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
index 6f41b5c..9b4f77f 100644
index e9ce48c..84a6fca 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
@@ -159,26 +159,8 @@
@@ -159,26 +159,8 @@ - (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view
return !shouldDisableScrollInteraction;
}
@@ -252,7 +252,7 @@ index 6f41b5c..9b4f77f 100644
super.contentOffset = CGPointMake(
RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
@@ -433,6 +415,11 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
@@ -427,6 +409,11 @@ - (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
// Does nothing
}
@@ -264,7 +264,7 @@ index 6f41b5c..9b4f77f 100644
- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
{
[super insertReactSubview:view atIndex:atIndex];
@@ -449,6 +436,8 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
@@ -443,6 +430,8 @@ - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
_contentView = view;
RCTApplyTransformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
[_scrollView addSubview:view];
@@ -273,7 +273,7 @@ index 6f41b5c..9b4f77f 100644
}
}
@@ -658,9 +647,46 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
@@ -652,9 +641,46 @@ -(void)delegateMethod : (UIScrollView *)scrollView \
}
RCT_SCROLL_EVENT_HANDLER(scrollViewWillBeginDecelerating, onMomentumScrollBegin)
@@ -321,7 +321,7 @@ index 6f41b5c..9b4f77f 100644
- (void)addScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
{
[_scrollListeners addObject:scrollListener];
@@ -939,6 +965,7 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
@@ -933,6 +959,7 @@ - (void)updateContentSizeIfNeeded
CGSize contentSize = self.contentSize;
if (!CGSizeEqualToSize(_scrollView.contentSize, contentSize)) {
_scrollView.contentSize = contentSize;
@@ -329,7 +329,7 @@ index 6f41b5c..9b4f77f 100644
}
}
@@ -1061,6 +1088,22 @@ RCT_SET_AND_PRESERVE_OFFSET(setShowsHorizontalScrollIndicator, showsHorizontalSc
@@ -1055,6 +1082,22 @@ -(type)getter \
RCT_SET_AND_PRESERVE_OFFSET(setShowsVerticalScrollIndicator, showsVerticalScrollIndicator, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setZoomScale, zoomScale, CGFloat);
@@ -356,7 +356,7 @@ diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManag
index cd1e7eb..c1d0172 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
@@ -83,6 +83,7 @@ RCT_EXPORT_VIEW_PROPERTY(showsVerticalScrollIndicator, BOOL)
@@ -83,6 +83,7 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(scrollEventThrottle, NSTimeInterval)
RCT_EXPORT_VIEW_PROPERTY(zoomScale, CGFloat)
RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets)
@@ -0,0 +1,34 @@
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)
}
}
+50 -54
View File
@@ -15,7 +15,6 @@ 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'
@@ -66,7 +65,6 @@ 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'
@@ -129,57 +127,55 @@ function InnerApp() {
return (
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<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>
<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>
</ThemeProvider>
</Alf>
)
@@ -240,4 +236,4 @@ function App() {
)
}
export default Sentry.wrap(App)
export default App
+49 -53
View File
@@ -7,7 +7,6 @@ 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'
@@ -56,7 +55,6 @@ 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'
@@ -108,56 +106,54 @@ function InnerApp() {
return (
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<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>
<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>
</ThemeProvider>
</Alf>
)
@@ -211,4 +207,4 @@ function App() {
)
}
export default Sentry.wrap(App)
export default App
+14 -22
View File
@@ -1,8 +1,9 @@
import * as React from 'react'
import {i18n, type MessageDescriptor} from '@lingui/core'
import {JSX} from 'react/jsx-runtime'
import {i18n, MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/macro'
import {
type BottomTabBarProps,
BottomTabBarProps,
createBottomTabNavigator,
} from '@react-navigation/bottom-tabs'
import {
@@ -19,16 +20,16 @@ import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {buildStateObject} from '#/lib/routes/helpers'
import {
type AllNavigatorParams,
type BottomTabNavigatorParams,
type FlatNavigatorParams,
type HomeTabNavigatorParams,
type MessagesTabNavigatorParams,
type MyProfileTabNavigatorParams,
type NotificationsTabNavigatorParams,
type SearchTabNavigatorParams,
AllNavigatorParams,
BottomTabNavigatorParams,
FlatNavigatorParams,
HomeTabNavigatorParams,
MessagesTabNavigatorParams,
MyProfileTabNavigatorParams,
NotificationsTabNavigatorParams,
SearchTabNavigatorParams,
} from '#/lib/routes/types'
import {type RouteParams, type State} from '#/lib/routes/types'
import {RouteParams, State} from '#/lib/routes/types'
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
import {bskyTitle} from '#/lib/strings/headings'
import {logger} from '#/logger'
@@ -58,6 +59,7 @@ 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'
@@ -79,11 +81,9 @@ import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed'
import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers'
import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {SearchScreen} from '#/screens/Search'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
import {SettingsInterests} from '#/screens/Settings/SettingsInterests'
import {
StarterPackScreen,
StarterPackScreenShort,
@@ -376,14 +376,6 @@ 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}
@@ -637,7 +629,7 @@ const FlatNavigator = () => {
<Flat.Screen
name="Search"
getComponent={() => SearchScreen}
options={{title: title(msg`Explore`)}}
options={{title: title(msg`Search`)}}
/>
<Flat.Screen
name="Notifications"
+1 -12
View File
@@ -1,9 +1,4 @@
import {
Platform,
type StyleProp,
StyleSheet,
type ViewStyle,
} from 'react-native'
import {Platform, StyleProp, StyleSheet, ViewStyle} from 'react-native'
import * as tokens from '#/alf/tokens'
import {ios, native, platform, web} from '#/alf/util/platform'
@@ -131,9 +126,6 @@ export const atoms = {
rounded_md: {
borderRadius: tokens.borderRadius.md,
},
rounded_lg: {
borderRadius: tokens.borderRadius.lg,
},
rounded_full: {
borderRadius: tokens.borderRadius.full,
},
@@ -366,9 +358,6 @@ export const atoms = {
border_r: {
borderRightWidth: StyleSheet.hairlineWidth,
},
border_transparent: {
borderColor: 'transparent',
},
curve_circular: ios({
borderCurve: 'circular',
}),
+2 -2
View File
@@ -31,8 +31,8 @@ export function useBreakpoints(): Record<Breakpoint, boolean> & {
* Fine-tuned breakpoints for the shell layout
*/
export function useLayoutBreakpoints() {
const rightNavVisible = useMediaQuery({minWidth: 1100})
const centerColumnOffset = useMediaQuery({minWidth: 1100, maxWidth: 1300})
const rightNavVisible = useMediaQuery({minWidth: 1075})
const centerColumnOffset = useMediaQuery({minWidth: 1075, maxWidth: 1300})
const leftNavMinimal = useMediaQuery({maxWidth: 1300})
return {
-1
View File
@@ -44,7 +44,6 @@ export const borderRadius = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
full: 999,
} as const
+4 -4
View File
@@ -6,7 +6,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import type * as bsky from '#/types/bsky'
import * as bsky from '#/types/bsky'
export function AvatarStack({
profiles,
@@ -19,7 +19,7 @@ export function AvatarStack({
numPending?: number
backgroundColor?: string
}) {
const translation = size / 3 // overlap by 1/3
const halfSize = size / 2
const t = useTheme()
const moderationOpts = useModerationOpts()
@@ -43,7 +43,7 @@ export function AvatarStack({
a.flex_row,
a.align_center,
a.relative,
{width: size + (items.length - 1) * (size - translation)},
{width: size + (items.length - 1) * halfSize},
]}>
{items.map((item, i) => (
<View
@@ -54,7 +54,7 @@ export function AvatarStack({
{
width: size,
height: size,
left: i * -translation,
left: i * -halfSize,
borderWidth: 1,
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
borderRadius: 999,
@@ -1,85 +0,0 @@
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>
)
}
-51
View File
@@ -1,51 +0,0 @@
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>
)
}
-49
View File
@@ -1,49 +0,0 @@
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
}
-904
View File
@@ -1,904 +0,0 @@
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
}
-12
View File
@@ -1,12 +0,0 @@
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
}
-136
View File
@@ -1,136 +0,0 @@
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 -14
View File
@@ -1,20 +1,20 @@
import React, {useImperativeHandle} from 'react'
import {
type NativeScrollEvent,
type NativeSyntheticEvent,
NativeScrollEvent,
NativeSyntheticEvent,
Pressable,
type ScrollView,
type StyleProp,
ScrollView,
StyleProp,
TextInput,
View,
type ViewStyle,
ViewStyle,
} from 'react-native'
import {
KeyboardAwareScrollView,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {runOnJS} from 'react-native-reanimated'
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -25,22 +25,22 @@ import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {atoms as a, tokens, useTheme} from '#/alf'
import {useThemeName} from '#/alf/util/useColorModeTheme'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {
type DialogControlProps,
type DialogInnerProps,
type DialogOuterProps,
DialogControlProps,
DialogInnerProps,
DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
import {
type BottomSheetSnapPointChangeEvent,
type BottomSheetStateChangeEvent,
BottomSheetSnapPointChangeEvent,
BottomSheetStateChangeEvent,
} from '../../../modules/bottom-sheet/src/BottomSheet.types'
import {type BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/shared'
@@ -299,7 +299,7 @@ export const InnerFlatList = React.forwardRef<
keyboardShouldPersistTaps="handled"
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={
<View style={{height: insets.bottom + a.pt_5xl.paddingTop + 50}} />
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
}
ref={ref}
{...props}
+20 -48
View File
@@ -1,8 +1,8 @@
import React from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {GestureResponderEvent, View} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyGraphDefs,
AppBskyFeedDefs,
AppBskyGraphDefs,
AtUri,
RichText as RichTextApi,
} from '@atproto/api'
@@ -23,20 +23,15 @@ 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,
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 {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 {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {RichText, type RichTextProps} from '#/components/RichText'
import {RichText, 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'
import * as bsky from '#/types/bsky'
type Props = {
view: AppBskyFeedDefs.GeneratorView
@@ -86,11 +81,11 @@ export function Link({
}
export function Outer({children}: {children: React.ReactNode}) {
return <View style={[a.w_full, a.gap_sm]}>{children}</View>
return <View style={[a.w_full, a.gap_md]}>{children}</View>
}
export function Header({children}: {children: React.ReactNode}) {
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
}
export type AvatarProps = {src: string | undefined; size?: number}
@@ -225,27 +220,22 @@ 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} {...props} />
return <SaveButtonInner view={view} pin={pin} />
}
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} =
@@ -304,32 +294,14 @@ function SaveButtonInner({
disabled={isPending}
label={_(msg`Add this feed to your feeds`)}
size="small"
variant="solid"
color={savedFeedConfig ? 'secondary' : 'primary'}
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}
{...buttonProps}>
variant="ghost"
color="secondary"
shape="square"
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
{savedFeedConfig ? (
<>
{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 : Trash} />
) : (
<>
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
{text && (
<ButtonText>
<Trans>Pin Feed</Trans>
</ButtonText>
)}
</>
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
)}
</Button>
+26 -28
View File
@@ -360,37 +360,35 @@ export function ProfileGrid({
</View>
) : (
<BlockDrawerGesture>
<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}
<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" />
</View>
<Arrow size="xl" />
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
</BlockDrawerGesture>
)}
</View>
+1 -2
View File
@@ -23,7 +23,6 @@ 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'
@@ -66,7 +65,7 @@ export function Outer({
gtMobile && [a.mx_auto, {maxWidth: 600}],
!isWithinOffsetView && {
transform: [
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
{translateX: centerColumnOffset ? -150 : 0},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
],
},
-5
View File
@@ -14,8 +14,3 @@ 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
+54 -60
View File
@@ -1,12 +1,12 @@
import {forwardRef, memo, useContext, useMemo} from 'react'
import {StyleSheet, View, type ViewProps, type ViewStyle} from 'react-native'
import {type StyleProp} from 'react-native'
import React, {useContext, useMemo} from 'react'
import {StyleSheet, View, ViewProps, ViewStyle} from 'react-native'
import {StyleProp} from 'react-native'
import {
KeyboardAwareScrollView,
type KeyboardAwareScrollViewProps,
KeyboardAwareScrollViewProps,
} from 'react-native-keyboard-controller'
import Animated, {
type AnimatedScrollViewProps,
AnimatedScrollViewProps,
useAnimatedProps,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
@@ -21,7 +21,7 @@ import {
web,
} from '#/alf'
import {useDialogContext} from '#/components/Dialog'
import {CENTER_COLUMN_OFFSET, SCROLLBAR_OFFSET} from '#/components/Layout/const'
import {SCROLLBAR_OFFSET} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context'
export * from '#/components/Layout/const'
@@ -35,7 +35,7 @@ export type ScreenProps = React.ComponentProps<typeof View> & {
/**
* Outermost component of every screen
*/
export const Screen = memo(function Screen({
export const Screen = React.memo(function Screen({
style,
noInsetTop,
...props
@@ -61,55 +61,49 @@ export type ContentProps = AnimatedScrollViewProps & {
/**
* Default scroll view for simple pages
*/
export const Content = memo(
forwardRef<Animated.ScrollView, ContentProps>(function Content(
{
children,
style,
contentContainerStyle,
ignoreTabletLayoutOffset,
...props
},
ref,
) {
const t = useTheme()
const {footerHeight} = useShellLayout()
const animatedProps = useAnimatedProps(() => {
return {
scrollIndicatorInsets: {
bottom: footerHeight.get(),
top: 0,
right: 1,
},
} satisfies AnimatedScrollViewProps
})
export const Content = React.memo(function Content({
children,
style,
contentContainerStyle,
ignoreTabletLayoutOffset,
...props
}: ContentProps) {
const t = useTheme()
const {footerHeight} = useShellLayout()
const animatedProps = useAnimatedProps(() => {
return {
scrollIndicatorInsets: {
bottom: footerHeight.get(),
top: 0,
right: 1,
},
} satisfies AnimatedScrollViewProps
})
return (
<Animated.ScrollView
ref={ref}
id="content"
automaticallyAdjustsScrollIndicatorInsets={false}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
// sets the scroll inset to the height of the footer
animatedProps={animatedProps}
style={[scrollViewStyles.common, style]}
contentContainerStyle={[
scrollViewStyles.contentContainer,
contentContainerStyle,
]}
{...props}>
{isWeb ? (
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
{/* @ts-expect-error web only -esb */}
{children}
</Center>
) : (
children
)}
</Animated.ScrollView>
)
}),
)
return (
<Animated.ScrollView
id="content"
automaticallyAdjustsScrollIndicatorInsets={false}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
// sets the scroll inset to the height of the footer
animatedProps={animatedProps}
style={[scrollViewStyles.common, style]}
contentContainerStyle={[
scrollViewStyles.contentContainer,
contentContainerStyle,
]}
{...props}>
{isWeb ? (
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
{/* @ts-expect-error web only -esb */}
{children}
</Center>
) : (
children
)}
</Animated.ScrollView>
)
})
const scrollViewStyles = StyleSheet.create({
common: {
@@ -130,7 +124,7 @@ export type KeyboardAwareContentProps = KeyboardAwareScrollViewProps & {
*
* BE SURE TO TEST THIS WHEN USING, it's untested as of writing this comment.
*/
export const KeyboardAwareContent = memo(function LayoutKeyboardAwareContent({
export const KeyboardAwareContent = React.memo(function LayoutScrollView({
children,
style,
contentContainerStyle,
@@ -153,7 +147,7 @@ export const KeyboardAwareContent = memo(function LayoutKeyboardAwareContent({
/**
* Utility component to center content within the screen
*/
export const Center = memo(function LayoutCenter({
export const Center = React.memo(function LayoutContent({
children,
style,
ignoreTabletLayoutOffset,
@@ -179,7 +173,7 @@ export const Center = memo(function LayoutCenter({
centerColumnOffset &&
!ignoreTabletLayoutOffset &&
!isWithinDialog
? CENTER_COLUMN_OFFSET
? -150
: 0,
},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
@@ -198,7 +192,7 @@ export const Center = memo(function LayoutCenter({
/**
* Only used within `Layout.Screen`, not for reuse
*/
const WebCenterBorders = memo(function LayoutWebCenterBorders() {
const WebCenterBorders = React.memo(function LayoutContent() {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {centerColumnOffset} = useLayoutBreakpoints()
@@ -215,7 +209,7 @@ const WebCenterBorders = memo(function LayoutWebCenterBorders() {
left: '50%',
transform: [
{translateX: '-50%'},
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
{translateX: centerColumnOffset ? -150 : 0},
...a.scrollbar_offset.transform,
],
}),
-8
View File
@@ -54,14 +54,6 @@ export function Embed({
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
</Outer>
)
} else if (
e.type === 'post_with_media' &&
// ignore further "nested" RecordWithMedia
e.media.type !== 'post_with_media' &&
// ignore any unknowns
e.media.view !== null
) {
return <Embed embed={e.media.view} style={style} />
}
return null
+7 -2
View File
@@ -2,9 +2,14 @@ import React from 'react'
import type {ContextType, ItemContextType} from '#/components/Menu/types'
export const Context = React.createContext<ContextType | null>(null)
export const Context = React.createContext<ContextType>({
// @ts-ignore
control: null,
})
export const ItemContext = React.createContext<ItemContextType | null>(null)
export const ItemContext = React.createContext<ItemContextType>({
disabled: false,
})
export function useMenuContext() {
const context = React.useContext(Context)
+1 -3
View File
@@ -30,13 +30,11 @@ export {
useDialogControl as useMenuControl,
} from '#/components/Dialog'
export {useMenuContext}
export function Root({
children,
control,
}: React.PropsWithChildren<{
control?: Dialog.DialogControlProps
control?: Dialog.DialogOuterProps['control']
}>) {
const defaultControl = Dialog.useDialogControl()
const context = React.useMemo<ContextType>(
+1 -3
View File
@@ -26,8 +26,6 @@ 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)
@@ -52,7 +50,7 @@ export function Root({
children,
control,
}: React.PropsWithChildren<{
control?: Dialog.DialogControlProps
control?: Dialog.DialogOuterProps['control']
}>) {
const {_} = useLingui()
const defaultControl = useMenuControl()
+2 -2
View File
@@ -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, web} from '#/alf'
import {atoms as a, useTheme} 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={web({width: 'auto', maxWidth: 400, minWidth: 200})}>
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_md]}>
<View style={[a.align_center]}>
<View
+16 -27
View File
@@ -1,14 +1,14 @@
import React from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {GestureResponderEvent, View} from 'react-native'
import {
moderateProfile,
type ModerationOpts,
ModerationOpts,
RichText as RichTextApi,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type LogEvents} from '#/lib/statsig/statsig'
import {LogEvents} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -18,18 +18,13 @@ 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,
type ButtonProps,
ButtonText,
} from '#/components/Button'
import {Button, ButtonIcon, 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, type LinkProps} from '#/components/Link'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import * as bsky from '#/types/bsky'
export function Default({
profile,
@@ -138,7 +133,7 @@ export function Avatar({
return (
<UserAvatar
size={40}
size={42}
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
moderation={moderation.ui('avatar')}
@@ -152,10 +147,10 @@ export function AvatarPlaceholder() {
<View
style={[
a.rounded_full,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
{
width: 40,
height: 40,
width: 42,
height: 42,
},
]}
/>
@@ -203,7 +198,7 @@ export function NameAndHandlePlaceholder() {
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
{
width: '60%',
height: 14,
@@ -214,7 +209,7 @@ export function NameAndHandlePlaceholder() {
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
{
width: '40%',
height: 10,
@@ -266,7 +261,7 @@ export function DescriptionPlaceholder({
}) {
const t = useTheme()
return (
<View style={[a.pt_2xs, {gap: 6}]}>
<View style={[{gap: 8}]}>
{Array(numberOfLines)
.fill(0)
.map((_, i) => (
@@ -275,7 +270,7 @@ export function DescriptionPlaceholder({
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
]}
/>
@@ -291,7 +286,6 @@ export type FollowButtonProps = {
LogEvents['profile:unfollow']['logContext']
colorInverted?: boolean
onFollow?: () => void
withIcon?: boolean
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
@@ -307,7 +301,6 @@ export function FollowButtonInner({
onPress: onPressProp,
onFollow,
colorInverted,
withIcon = true,
...rest
}: FollowButtonProps) {
const {_} = useLingui()
@@ -393,9 +386,7 @@ export function FollowButtonInner({
color="secondary"
{...rest}
onPress={onPressUnfollow}>
{withIcon && (
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
)}
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
</Button>
) : (
@@ -406,9 +397,7 @@ export function FollowButtonInner({
color={colorInverted ? 'secondary_inverted' : 'primary'}
{...rest}
onPress={onPressFollow}>
{withIcon && (
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
)}
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
</Button>
)}
+183 -121
View File
@@ -1,26 +1,26 @@
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {
ScrollView,
type StyleProp,
TextInput,
useWindowDimensions,
View,
type ViewStyle,
} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {ScrollView, TextInput, useWindowDimensions, View} from 'react-native'
import Animated, {
LayoutAnimationConfig,
LinearTransition,
ZoomInEasyDown,
} from 'react-native-reanimated'
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {type ListMethods} from '#/view/com/util/List'
import {Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {ListMethods} from '#/view/com/util/List'
import {
popularInterests,
useInterestsDisplayNames,
@@ -31,7 +31,7 @@ import {
tokens,
useBreakpoints,
useTheme,
type ViewStyleProp,
ViewStyleProp,
web,
} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -42,14 +42,15 @@ import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/componen
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {ListFooter} from '../Lists'
import {ProgressGuideTask} from './Task'
type Item =
| {
type: 'profile'
key: string
profile: bsky.profile.AnyProfileView
profile: AppBskyActorDefs.ProfileView
isSuggestion: boolean
}
| {
type: 'empty'
@@ -120,39 +121,94 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
const inputRef = useRef<TextInput>(null)
const [headerHeight, setHeaderHeight] = useState(0)
const {currentAccount} = useSession()
const [suggestedAccounts, setSuggestedAccounts] = useState<
Map<string, AppBskyActorDefs.ProfileView[]>
>(() => new Map())
useEffect(() => {
lastSearchText = searchText
lastSelectedInterest = selectedInterest
}, [searchText, selectedInterest])
const {
data: suggestions,
isFetching: isFetchingSuggestions,
error: suggestionsError,
} = useGetSuggestedUsersQuery({
category: selectedInterest,
limit: 50,
})
const query = searchText || selectedInterest
const {
data: searchResults,
isFetching: isFetchingSearchResults,
error: searchResultsError,
isError: isSearchResultsError,
isFetching,
error,
isError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useActorSearchPaginated({
enabled: !!searchText,
query: searchText,
query,
})
const hasSearchText = !!searchText
const resultsKey = searchText || selectedInterest
const items = useMemo(() => {
const results = hasSearchText
? searchResults?.pages.flatMap(p => p.actors)
: suggestions?.actors
let _items: Item[] = []
if (isFetchingSuggestions || isFetchingSearchResults) {
const items = useMemo(() => {
const results = searchResults?.pages.flatMap(r => r.actors)
let _items: Item[] = []
const seen = new Set<string>()
if (isError) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else if (results) {
// First pass: search results
for (const profile of results) {
if (profile.did === currentAccount?.did) continue
if (profile.viewer?.following) continue
// my sincere apologies to Jake Gold - your bio is too keyword-filled and
// your page-rank too high, so you're at the top of half the categories -sfn
if (
!hasSearchText &&
profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' &&
// constrain to 'tech'
selectedInterest !== 'tech'
) {
continue
}
seen.add(profile.did)
_items.push({
type: 'profile',
// Don't share identity across tabs or typing attempts
key: query + ':' + profile.did,
profile,
isSuggestion: false,
})
}
// Second pass: suggestions
_items = _items.flatMap(item => {
if (item.type !== 'profile') {
return item
}
const suggestions = suggestedAccounts.get(item.profile.did)
if (!suggestions) {
return item
}
const itemWithSuggestions = [item]
for (const suggested of suggestions) {
if (seen.has(suggested.did)) {
// Skip search results from previous step or already seen suggestions
continue
}
seen.add(suggested.did)
itemWithSuggestions.push({
type: 'profile',
key: suggested.did,
profile: suggested,
isSuggestion: true,
})
if (itemWithSuggestions.length === 1 + 3) {
break
}
}
return itemWithSuggestions
})
} else {
const placeholders: Item[] = Array(10)
.fill(0)
.map((__, i) => ({
@@ -161,54 +217,21 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
}))
_items.push(...placeholders)
} else if (
(hasSearchText && searchResultsError) ||
(!hasSearchText && suggestionsError) ||
!results?.length
) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else {
const seen = new Set<string>()
for (const profile of results) {
if (seen.has(profile.did)) continue
if (profile.did === currentAccount?.did) continue
if (profile.viewer?.following) continue
seen.add(profile.did)
_items.push({
type: 'profile',
// Don't share identity across tabs or typing attempts
key: resultsKey + ':' + profile.did,
profile,
})
}
}
return _items
}, [
_,
suggestions,
suggestionsError,
isFetchingSuggestions,
searchResults,
searchResultsError,
isFetchingSearchResults,
isError,
currentAccount?.did,
hasSearchText,
resultsKey,
selectedInterest,
suggestedAccounts,
query,
])
if (
searchText &&
!isFetchingSearchResults &&
!items.length &&
!isSearchResultsError
) {
if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
@@ -219,7 +242,9 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
return (
<FollowProfileCard
profile={item.profile}
isSuggestion={item.isSuggestion}
moderationOpts={moderationOpts!}
setSuggestedAccounts={setSuggestedAccounts}
noBorder={index === 0}
/>
)
@@ -265,6 +290,15 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
/>
)
const onEndReached = useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more people to follow', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
return (
<Dialog.InnerFlatList
ref={listRef}
@@ -284,6 +318,15 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
scrollIndicatorInsets={{top: headerHeight}}
initialNumToRender={8}
maxToRenderPerBatch={8}
onEndReached={onEndReached}
itemLayoutAnimation={LinearTransition}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
/>
}
/>
)
}
@@ -409,18 +452,15 @@ let Tabs = ({
selectedInterest,
hasSearchText,
interestsDisplayNames,
TabComponent = Tab,
contentContainerStyle,
}: {
onSelectTab: (tab: string) => void
interests: string[]
selectedInterest: string
hasSearchText: boolean
interestsDisplayNames: Record<string, string>
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
contentContainerStyle?: StyleProp<ViewStyle>
}): React.ReactNode => {
const listRef = useRef<ScrollView>(null)
const [scrollX, setScrollX] = useState(0)
const [totalWidth, setTotalWidth] = useState(0)
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
@@ -439,11 +479,24 @@ let Tabs = ({
function scrollIntoViewIfNeeded(index: number) {
const btnLayout = tabOffsets[index]
if (!btnLayout) return
listRef.current?.scrollTo({
// centered
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
animated: true,
})
const viewportLeftEdge = scrollX
const viewportRightEdge = scrollX + totalWidth
const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x
const shouldScrollToRightEdge =
viewportRightEdge < btnLayout.x + btnLayout.width
if (shouldScrollToLeftEdge) {
listRef.current?.scrollTo({
x: btnLayout.x - tokens.space.lg,
animated: true,
})
} else if (shouldScrollToRightEdge) {
listRef.current?.scrollTo({
x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg,
animated: true,
})
}
}
function handleSelectTab(index: number) {
@@ -465,7 +518,7 @@ let Tabs = ({
<ScrollView
ref={listRef}
horizontal
contentContainerStyle={[a.gap_sm, a.px_lg, contentContainerStyle]}
contentContainerStyle={[a.gap_sm, a.px_lg]}
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToOffsets={
@@ -475,11 +528,11 @@ let Tabs = ({
}
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
scrollEventThrottle={200} // big throttle
>
onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}>
{interests.map((interest, i) => {
const active = interest === selectedInterest && !hasSearchText
return (
<TabComponent
<Tab
key={interest}
onSelectTab={handleSelectTab}
active={active}
@@ -494,7 +547,6 @@ let Tabs = ({
)
}
Tabs = memo(Tabs)
export {Tabs}
let Tab = ({
onSelectTab,
@@ -511,7 +563,6 @@ let Tab = ({
interestsDisplayName: string
onLayout: (index: number, x: number, width: number) => void
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const activeText = active ? _(msg` (active)`) : ''
return (
@@ -522,32 +573,12 @@ let Tab = ({
}>
<Button
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
variant={active ? 'solid' : 'outline'}
color={active ? 'primary' : 'secondary'}
size="small"
onPress={() => onSelectTab(index)}>
{({hovered, pressed, focused}) => (
<View
style={[
a.rounded_full,
a.px_lg,
a.py_sm,
a.border,
active || hovered || pressed || focused
? [
t.atoms.bg_contrast_25,
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
]
: [t.atoms.bg, t.atoms.border_contrast_low],
]}>
<Text
style={[
/* TODO: medium weight */
active || hovered || pressed || focused
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{interestsDisplayName}
</Text>
</View>
)}
<ButtonIcon icon={SearchIcon} />
<ButtonText>{interestsDisplayName}</ButtonText>
</Button>
</View>
)
@@ -557,18 +588,49 @@ Tab = memo(Tab)
let FollowProfileCard = ({
profile,
moderationOpts,
isSuggestion,
setSuggestedAccounts,
noBorder,
}: {
profile: bsky.profile.AnyProfileView
profile: AppBskyActorDefs.ProfileView
moderationOpts: ModerationOpts
isSuggestion: boolean
setSuggestedAccounts: (
updater: (
v: Map<string, AppBskyActorDefs.ProfileView[]>,
) => Map<string, AppBskyActorDefs.ProfileView[]>,
) => void
noBorder?: boolean
}): React.ReactNode => {
const [hasFollowed, setHasFollowed] = useState(false)
const followupSuggestion = useSuggestedFollowsByActorQuery({
did: profile.did,
enabled: hasFollowed,
})
const candidates = followupSuggestion.data?.suggestions
useEffect(() => {
// TODO: Move out of effect.
if (hasFollowed && candidates && candidates.length > 0) {
setSuggestedAccounts(suggestions => {
const newSuggestions = new Map(suggestions)
newSuggestions.set(profile.did, candidates)
return newSuggestions
})
}
}, [hasFollowed, profile.did, candidates, setSuggestedAccounts])
return (
<FollowProfileCardInner
profile={profile}
moderationOpts={moderationOpts}
noBorder={noBorder}
/>
<LayoutAnimationConfig skipEntering={!isSuggestion}>
<Animated.View entering={native(ZoomInEasyDown)}>
<FollowProfileCardInner
profile={profile}
moderationOpts={moderationOpts}
onFollow={() => setHasFollowed(true)}
noBorder={noBorder}
/>
</Animated.View>
</LayoutAnimationConfig>
)
}
FollowProfileCard = memo(FollowProfileCard)
@@ -579,7 +641,7 @@ function FollowProfileCardInner({
onFollow,
noBorder,
}: {
profile: bsky.profile.AnyProfileView
profile: AppBskyActorDefs.ProfileView
moderationOpts: ModerationOpts
onFollow?: () => void
noBorder?: boolean
@@ -596,7 +658,7 @@ function FollowProfileCardInner({
style={[
a.flex_1,
noBorder && a.border_t_0,
(hovered || pressed) && t.atoms.bg_contrast_25,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
@@ -760,7 +822,7 @@ function Empty({message}: {message: string}) {
)
}
export function boostInterests(boosts?: string[]) {
function boostInterests(boosts?: string[]) {
return (_a: string, _b: string) => {
const indexA = boosts?.indexOf(_a) ?? -1
const indexB = boosts?.indexOf(_b) ?? -1
@@ -6,12 +6,12 @@ import React, {
} from 'react'
import {
findNodeHandle,
type ListRenderItemInfo,
type StyleProp,
ListRenderItemInfo,
StyleProp,
View,
type ViewStyle,
ViewStyle,
} from 'react-native'
import {type AppBskyGraphDefs} from '@atproto/api'
import {AppBskyGraphDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -20,11 +20,11 @@ import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useEmail} from '#/lib/hooks/useEmail'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {type NavigationProp} from '#/lib/routes/types'
import {NavigationProp} from '#/lib/routes/types'
import {parseStarterPackUri} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
import {List, type ListRef} from '#/view/com/util/List'
import {List, ListRef} from '#/view/com/util/List'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, ios, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -75,14 +75,8 @@ export const ProfileStarterPacks = React.forwardRef<
const t = useTheme()
const bottomBarOffset = useBottomBarOffset(100)
const [isPTRing, setIsPTRing] = useState(false)
const {
data,
refetch,
isError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useActorStarterPacksQuery({did, enabled})
const {data, refetch, isFetching, hasNextPage, fetchNextPage} =
useActorStarterPacksQuery({did, enabled})
const {isTabletOrDesktop} = useWebMediaQueries()
const items = data?.pages.flatMap(page => page.starterPacks)
@@ -101,14 +95,15 @@ export const ProfileStarterPacks = React.forwardRef<
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = React.useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
const onEndReached = useCallback(async () => {
if (isFetching || !hasNextPage) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more starter packs', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
}, [isFetching, hasNextPage, fetchNextPage])
useEffect(() => {
if (enabled && scrollElRef.current) {
@@ -117,21 +112,21 @@ export const ProfileStarterPacks = React.forwardRef<
}
}, [enabled, scrollElRef, setScrollViewTag])
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],
)
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>
)
}
return (
<View testID={testID} style={style}>
+3 -34
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -13,10 +13,7 @@ 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,
type LinkProps as BaseLinkProps,
} from '#/components/Link'
import {Link as BaseLink, LinkProps as BaseLinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
@@ -98,41 +95,13 @@ export function Card({
) : null}
{!!joinedAllTimeCount && joinedAllTimeCount >= 50 && (
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
<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>
{joinedAllTimeCount} users have joined!
</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,
-34
View File
@@ -1,34 +0,0 @@
import {View} from 'react-native'
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
export function SubtleHover({style, hover}: ViewStyleProp & {hover: boolean}) {
const t = useTheme()
let opacity: number
switch (t.name) {
case 'dark':
opacity = 0.4
break
case 'dim':
opacity = 0.45
break
case 'light':
opacity = 0.5
break
}
return (
<View
style={[
a.absolute,
a.inset_0,
a.pointer_events_none,
a.transition_opacity,
t.atoms.bg_contrast_25,
style,
{opacity: hover ? opacity : 0},
]}
/>
)
}
+78 -25
View File
@@ -1,10 +1,22 @@
import {View} from 'react-native'
import React from 'react'
import {Keyboard} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, {
cancelAnimation,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {atoms as a} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
export function ActionsWrapper({
message,
@@ -16,30 +28,71 @@ export function ActionsWrapper({
children: React.ReactNode
}) {
const {_} = useLingui()
const playHaptic = useHaptics()
const menuControl = useMenuControl()
const scale = useSharedValue(1)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{scale: scale.get()}],
}))
const open = React.useCallback(() => {
playHaptic()
Keyboard.dismiss()
menuControl.open()
}, [menuControl, playHaptic])
const shrink = React.useCallback(() => {
'worklet'
cancelAnimation(scale)
scale.set(() => withTiming(1, {duration: 200}))
}, [scale])
const doubleTapGesture = Gesture.Tap()
.numberOfTaps(2)
.hitSlop(HITSLOP_10)
.onEnd(open)
.runOnJS(true)
const pressAndHoldGesture = Gesture.LongPress()
.onStart(() => {
'worklet'
scale.set(() =>
withTiming(1.05, {duration: 200}, finished => {
if (!finished) return
runOnJS(open)()
shrink()
}),
)
})
.onTouchesUp(shrink)
.onTouchesMove(shrink)
.cancelsTouchesInView(false)
const composedGestures = Gesture.Exclusive(
doubleTapGesture,
pressAndHoldGesture,
)
return (
<MessageContextMenu message={message}>
{trigger =>
// will always be true, since this file is platform split
trigger.isNative && (
<View style={[a.flex_1, a.relative]}>
<View
style={[
{maxWidth: '80%'},
isFromSelf
? [a.self_end, a.align_end]
: [a.self_start, a.align_start],
]}
accessible={true}
accessibilityActions={[
{name: 'activate', label: _(msg`Open message options`)},
]}
onAccessibilityAction={() => trigger.control.open('full')}>
{children}
</View>
</View>
)
}
</MessageContextMenu>
<GestureDetector gesture={composedGestures}>
<Animated.View
style={[
{
maxWidth: '80%',
},
isFromSelf ? a.self_end : a.self_start,
animatedStyle,
]}
accessible={true}
accessibilityActions={[
{name: 'activate', label: _(msg`Open message options`)},
]}
onAccessibilityAction={open}>
{children}
<MessageMenu message={message} control={menuControl} />
</Animated.View>
</GestureDetector>
)
}
+41 -104
View File
@@ -1,19 +1,10 @@
import {useCallback, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import type React from 'react'
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto/api'
import {useConvoActive} from '#/state/messages/convo'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util'
import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
export function ActionsWrapper({
message,
@@ -24,53 +15,26 @@ export function ActionsWrapper({
isFromSelf: boolean
children: React.ReactNode
}) {
const viewRef = useRef(null)
const t = useTheme()
const {_} = useLingui()
const convo = useConvoActive()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const viewRef = React.useRef(null)
const [showActions, setShowActions] = useState(false)
const [showActions, setShowActions] = React.useState(false)
const onMouseEnter = useCallback(() => {
const onMouseEnter = React.useCallback(() => {
setShowActions(true)
}, [])
const onMouseLeave = useCallback(() => {
const onMouseLeave = React.useCallback(() => {
setShowActions(false)
}, [])
// We need to handle the `onFocus` separately because we want to know if there is a related target (the element
// that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
const onFocus = useCallback<React.FocusEventHandler>(e => {
const onFocus = React.useCallback<React.FocusEventHandler>(e => {
if (e.nativeEvent.relatedTarget == null) return
setShowActions(true)
}, [])
const onEmojiSelect = useCallback(
(emoji: string) => {
if (
message.reactions?.find(
reaction =>
reaction.value === emoji &&
reaction.sender.did === currentAccount?.did,
)
) {
convo
.removeReaction(message.id, emoji)
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo
.addReaction(message.id, emoji)
.catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
)
}
},
[_, convo, message, currentAccount?.did],
)
return (
<View
// @ts-expect-error web only
@@ -78,66 +42,39 @@ export function ActionsWrapper({
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onMouseLeave}
style={[a.flex_1, isFromSelf ? a.flex_row : a.flex_row_reverse]}
style={StyleSheet.flatten([a.flex_1, a.flex_row])}
ref={viewRef}>
{isFromSelf && (
<View
style={[
a.mr_xl,
a.justify_center,
{
marginLeft: 'auto',
},
]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
/>
</View>
)}
<View
style={[
a.justify_center,
a.flex_row,
a.align_center,
isFromSelf
? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse]
: [a.ml_xs, {marginRight: 'auto'}],
]}>
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
{({props, state, isNative, control}) => {
// always false, file is platform split
if (isNative) return null
const showMenuTrigger = showActions || control.isOpen ? 1 : 0
return (
<Pressable
{...props}
style={[
{opacity: showMenuTrigger},
a.p_xs,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<EmojiSmileIcon
size="md"
style={t.atoms.text_contrast_medium}
/>
</Pressable>
)
}}
</EmojiReactionPicker>
<MessageContextMenu message={message}>
{({props, state, isNative, control}) => {
// always false, file is platform split
if (isNative) return null
const showMenuTrigger = showActions || control.isOpen ? 1 : 0
return (
<Pressable
{...props}
style={[
{opacity: showMenuTrigger},
a.p_xs,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<DotsHorizontalIcon
size="md"
style={t.atoms.text_contrast_medium}
/>
</Pressable>
)
}}
</MessageContextMenu>
</View>
<View
style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}>
style={{
maxWidth: '80%',
}}>
{children}
</View>
{!isFromSelf && (
<View style={[a.flex_row, a.align_center, a.ml_xl]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
/>
</View>
)}
</View>
)
}
-82
View File
@@ -1,82 +0,0 @@
import {useState} from 'react'
import {Modal, Pressable, View} from 'react-native'
// @ts-expect-error internal component, not supposed to be used directly
// waiting on more customisability: https://github.com/okwasniewski/react-native-emoji-popup/issues/1#issuecomment-2737463753
import EmojiPopupView from 'react-native-emoji-popup/src/EmojiPopupViewNativeComponent'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
export function EmojiPopup({
children,
onEmojiSelected,
}: {
children: React.ReactNode
onEmojiSelected: (emoji: string) => void
}) {
const [modalVisible, setModalVisible] = useState(false)
const {_} = useLingui()
const t = useTheme()
return (
<>
<Pressable
accessibilityLabel={_(msg`Open full emoji list`)}
accessibilityHint=""
accessibilityRole="button"
onPress={() => setModalVisible(true)}>
{children}
</Pressable>
<Modal
animationType="slide"
transparent={true}
visible={modalVisible}
onRequestClose={() => setModalVisible(false)}>
<View style={[a.flex_1, {backgroundColor: t.palette.white}]}>
<View
style={[
t.atoms.bg,
a.pl_lg,
a.pr_md,
a.py_sm,
a.w_full,
a.align_center,
a.flex_row,
a.justify_between,
a.border_b,
t.atoms.border_contrast_low,
]}>
<Text style={[a.font_bold, a.text_md]}>
<Trans>Add Reaction</Trans>
</Text>
<Button
label={_(msg`Close`)}
onPress={() => setModalVisible(false)}
size="small"
variant="ghost"
color="secondary"
shape="round">
<ButtonIcon icon={TimesLarge_Stroke2_Corner0_Rounded} />
</Button>
</View>
<EmojiPopupView
onEmojiSelected={({
nativeEvent: {emoji},
}: {
nativeEvent: {emoji: string}
}) => {
setModalVisible(false)
onEmojiSelected(emoji)
}}
style={[a.flex_1, a.w_full]}
/>
</View>
</Modal>
</>
)
}
-1
View File
@@ -1 +0,0 @@
export {EmojiPopup} from 'react-native-emoji-popup'
-137
View File
@@ -1,137 +0,0 @@
import {useMemo, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
import {atoms as a, tokens, useTheme} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu'
import {
useContextMenuContext,
useContextMenuMenuContext,
} from '#/components/ContextMenu/context'
import {
EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyesIcon,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon,
} from '#/components/icons/Emoji'
import {type TriggerProps} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
import {EmojiPopup} from './EmojiPopup'
import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
export function EmojiReactionPicker({
message,
onEmojiSelect,
}: {
message: ChatBskyConvoDefs.MessageView
children?: TriggerProps['children']
onEmojiSelect: (emoji: string) => void
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const t = useTheme()
const isFromSelf = message.sender?.did === currentAccount?.did
const {measurement, close} = useContextMenuContext()
const {align} = useContextMenuMenuContext()
const [layout, setLayout] = useState({width: 0, height: 0})
const {width: screenWidth} = useWindowDimensions()
// 1 in 100 chance of showing heart eyes icon
const EmojiIcon = useMemo(() => {
return Math.random() < 0.01 ? EmojiHeartEyesIcon : EmojiSmileIcon
}, [])
const position = useMemo(() => {
return {
x: align === 'left' ? 12 : screenWidth - layout.width - 12,
y: (measurement?.y ?? 0) - tokens.space.xs - layout.height,
height: layout.height,
width: layout.width,
}
}, [measurement, align, screenWidth, layout])
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
const bgColor = t.scheme === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25
return (
<View
onLayout={evt => setLayout(evt.nativeEvent.layout)}
style={[
bgColor,
a.rounded_full,
a.absolute,
{bottom: '100%'},
isFromSelf ? a.right_0 : a.left_0,
a.flex_row,
a.p_xs,
a.gap_xs,
a.mb_xs,
a.z_20,
a.border,
t.atoms.border_contrast_low,
a.shadow_md,
]}>
{['👍', '😆', '❤️', '👀', '😢'].map(emoji => {
const alreadyReacted = hasAlreadyReacted(
message,
currentAccount?.did,
emoji,
)
return (
<ContextMenu.Item
position={position}
label={_(msg`React with ${emoji}`)}
key={emoji}
onPress={() => onEmojiSelect(emoji)}
unstyled
disabled={limitReacted ? !alreadyReacted : false}>
{hovered => (
<View
style={[
a.rounded_full,
hovered
? {
backgroundColor: alreadyReacted
? t.palette.negative_100
: t.palette.primary_500,
}
: alreadyReacted
? {backgroundColor: t.palette.primary_200}
: bgColor,
{height: 40, width: 40},
a.justify_center,
a.align_center,
]}>
<Text style={[a.text_center, {fontSize: 30}]} emoji>
{emoji}
</Text>
</View>
)}
</ContextMenu.Item>
)
})}
<EmojiPopup
onEmojiSelected={emoji => {
close()
onEmojiSelect(emoji)
}}>
<View
style={[
a.rounded_full,
t.scheme === 'light'
? t.atoms.bg_contrast_25
: t.atoms.bg_contrast_50,
{height: 40, width: 40},
a.justify_center,
a.align_center,
a.border,
t.atoms.border_contrast_low,
]}>
<EmojiIcon size="xl" fill={t.palette.contrast_400} />
</View>
</EmojiPopup>
</View>
)
}
@@ -1,131 +0,0 @@
import {useState} from 'react'
import {Pressable, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import EmojiPicker from '@emoji-mart/react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {useSession} from '#/state/session'
import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji'
import {atoms as a, flatten, useTheme} from '#/alf'
import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
import * as Menu from '#/components/Menu'
import {type TriggerProps} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
export function EmojiReactionPicker({
message,
children,
onEmojiSelect,
}: {
message: ChatBskyConvoDefs.MessageView
children?: TriggerProps['children']
onEmojiSelect: (emoji: string) => void
}) {
if (!children)
throw new Error('EmojiReactionPicker requires the children prop on web')
const {_} = useLingui()
return (
<Menu.Root>
<Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger>
<Menu.Outer>
<MenuInner message={message} onEmojiSelect={onEmojiSelect} />
</Menu.Outer>
</Menu.Root>
)
}
function MenuInner({
message,
onEmojiSelect,
}: {
message: ChatBskyConvoDefs.MessageView
onEmojiSelect: (emoji: string) => void
}) {
const t = useTheme()
const {control} = Menu.useMenuContext()
const {currentAccount} = useSession()
useWebPreloadEmoji({immediate: true})
const [expanded, setExpanded] = useState(false)
const handleEmojiPickerResponse = (emoji: Emoji) => {
handleEmojiSelect(emoji.native)
}
const handleEmojiSelect = (emoji: string) => {
control.close()
onEmojiSelect(emoji)
}
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
return expanded ? (
<EmojiPicker onEmojiSelect={handleEmojiPickerResponse} autoFocus={true} />
) : (
<Menu.Outer style={[a.rounded_full]}>
<View style={[a.flex_row, a.gap_xs]}>
{['👍', '😆', '❤️', '👀', '😢'].map(emoji => {
const alreadyReacted = hasAlreadyReacted(
message,
currentAccount?.did,
emoji,
)
return (
<DropdownMenu.Item
key={emoji}
className={[
'EmojiReactionPicker__Pressable',
alreadyReacted && '__selected',
limitReacted && '__disabled',
]
.filter(Boolean)
.join(' ')}
onSelect={() => handleEmojiSelect(emoji)}
style={flatten([
a.flex,
a.flex_col,
a.rounded_full,
a.justify_center,
a.align_center,
a.transition_transform,
{
width: 34,
height: 34,
},
alreadyReacted && {
backgroundColor: t.atoms.bg_contrast_100.backgroundColor,
},
])}>
<Text style={[a.text_center, {fontSize: 28}]} emoji>
{emoji}
</Text>
</DropdownMenu.Item>
)
})}
<DropdownMenu.Item
asChild
className="EmojiReactionPicker__PickerButton">
<Pressable
accessibilityRole="button"
role="button"
onPress={() => setExpanded(true)}
style={flatten([
a.rounded_full,
{height: 34, width: 34},
a.justify_center,
a.align_center,
])}>
<DotGridIcon size="lg" style={t.atoms.text_contrast_medium} />
</Pressable>
</DropdownMenu.Item>
</View>
</Menu.Outer>
)
}
-194
View File
@@ -1,194 +0,0 @@
import {memo, useCallback} from 'react'
import {LayoutAnimation} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types'
import {ReportDialog} from '#/components/dms/ReportDialog'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({
message,
children,
}: {
message: ChatBskyConvoDefs.MessageView
children: TriggerProps['children']
}): React.ReactNode => {
const {_} = useLingui()
const {currentAccount} = useSession()
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const langPrefs = useLanguagePrefs()
const openLink = useOpenLink()
const isFromSelf = message.sender?.did === currentAccount?.did
const onCopyMessage = useCallback(() => {
const str = richTextToString(
new RichText({
text: message.text,
facets: message.facets,
}),
true,
)
Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}, [_, message.text, message.facets])
const onPressTranslateMessage = useCallback(() => {
const translatorUrl = getTranslatorLink(
message.text,
langPrefs.primaryLanguage,
)
openLink(translatorUrl, true)
logger.metric('translate', {
sourceLanguages: [],
targetLanguage: langPrefs.primaryLanguage,
textLength: message.text.length,
})
}, [langPrefs.primaryLanguage, message.text, openLink])
const onDelete = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
.then(() =>
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
)
.catch(() => Toast.show(_(msg`Failed to delete message`)))
}, [_, convo, message.id])
const onEmojiSelect = useCallback(
(emoji: string) => {
if (
message.reactions?.find(
reaction =>
reaction.value === emoji &&
reaction.sender.did === currentAccount?.did,
)
) {
convo
.removeReaction(message.id, emoji)
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo
.addReaction(message.id, emoji)
.catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
)
}
},
[_, convo, message, currentAccount?.did],
)
const sender = convo.convo.members.find(
member => member.did === message.sender.did,
)
return (
<>
<ContextMenu.Root>
{isNative && (
<ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}>
<EmojiReactionPicker
message={message}
onEmojiSelect={onEmojiSelect}
/>
</ContextMenu.AuxiliaryView>
)}
<ContextMenu.Trigger
label={_(msg`Message options`)}
contentLabel={_(
msg`Message from @${
sender?.handle ?? // should always be defined
'unknown'
}: ${message.text}`,
)}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Outer align={isFromSelf ? 'right' : 'left'}>
{message.text.length > 0 && (
<>
<ContextMenu.Item
testID="messageDropdownTranslateBtn"
label={_(msg`Translate`)}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemText>{_(msg`Translate`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Translate} position="right" />
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={_(msg`Copy message text`)}
onPress={onCopyMessage}>
<ContextMenu.ItemText>
{_(msg`Copy message text`)}
</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="right" />
</ContextMenu.Item>
<ContextMenu.Divider />
</>
)}
<ContextMenu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
onPress={() => deleteControl.open()}>
<ContextMenu.ItemText>{_(msg`Delete for me`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Trash} position="right" />
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={() => reportControl.open()}>
<ContextMenu.ItemText>{_(msg`Report`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Warning} position="right" />
</ContextMenu.Item>
)}
</ContextMenu.Outer>
</ContextMenu.Root>
<ReportDialog
currentScreen="conversation"
params={{type: 'convoMessage', convoId: convo.convo.id, message}}
control={reportControl}
/>
<Prompt.Basic
control={deleteControl}
title={_(msg`Delete message`)}
description={_(
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`,
)}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
</>
)
}
MessageContextMenu = memo(MessageContextMenu)
+15 -91
View File
@@ -1,37 +1,29 @@
import React, {useCallback, useMemo} from 'react'
import React, {useCallback, useMemo, useRef} from 'react'
import {
type GestureResponderEvent,
type StyleProp,
type TextStyle,
GestureResponderEvent,
LayoutAnimation,
StyleProp,
TextStyle,
View,
} from 'react-native'
import Animated, {
LayoutAnimationConfig,
LinearTransition,
ZoomIn,
ZoomOut,
} from 'react-native-reanimated'
import {
AppBskyEmbedRecord,
ChatBskyConvoDefs,
RichText as RichTextAPI,
} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isNative} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {ConvoItem} from '#/state/messages/convo/types'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {atoms as a, native, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {RichText} from '../RichText'
import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
import {localDateString} from './util'
@@ -43,8 +35,6 @@ let MessageItem = ({
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
const {_} = useLingui()
const {convo} = useConvoActive()
const {message, nextMessage, prevMessage} = item
const isPending = item.type === 'pending-message'
@@ -98,80 +88,18 @@ let MessageItem = ({
return true
}, [message, nextMessage, isPending])
const lastInGroupRef = useRef(isLastInGroup)
if (lastInGroupRef.current !== isLastInGroup) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
lastInGroupRef.current = isLastInGroup
}
const pendingColor = t.palette.primary_200
const rt = useMemo(() => {
return new RichTextAPI({text: message.text, facets: message.facets})
}, [message.text, message.facets])
const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting>
{message.reactions && message.reactions.length > 0 && (
<View
style={[isFromSelf ? a.align_end : a.align_start, a.px_sm, a.pb_2xs]}>
<View
style={[
a.flex_row,
a.gap_2xs,
a.py_xs,
a.px_xs,
a.justify_center,
isFromSelf ? a.justify_end : a.justify_start,
a.flex_wrap,
a.pb_xs,
t.atoms.bg_contrast_25,
a.border,
t.atoms.border_contrast_low,
a.rounded_lg,
t.atoms.shadow_sm,
{
// vibe coded number
transform: [{translateY: -11}],
},
]}>
{message.reactions.map((reaction, _i, reactions) => {
let label
if (reaction.sender.did === currentAccount?.did) {
label = _(msg`You reacted ${reaction.value}`)
} else {
const senderDid = reaction.sender.did
const sender = convo.members.find(
member => member.did === senderDid,
)
if (sender) {
label = _(
msg`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${reaction.value}`,
)
} else {
label = _(msg`Someone reacted ${reaction.value}`)
}
}
return (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
exiting={reactions.length > 1 && native(ZoomOut.delay(200))}
layout={native(LinearTransition.delay(300))}
key={reaction.sender.did + reaction.value}
style={[a.p_2xs]}
accessible={true}
accessibilityLabel={label}
accessibilityHint={_(
msg`Double tap or long press the message to add a reaction`,
)}>
<Text emoji style={[a.text_sm]}>
{reaction.value}
</Text>
</Animated.View>
)
})}
</View>
</View>
)}
</LayoutAnimationConfig>
)
return (
<>
{isNewDay && <DateDivider date={message.sentAt} />}
@@ -216,12 +144,8 @@ let MessageItem = ({
/>
</View>
)}
{isNative && appliedReactions}
</ActionsWrapper>
{!isNative && appliedReactions}
{isLastInGroup && (
<MessageItemMetadata
item={item}
+8 -25
View File
@@ -1,9 +1,9 @@
import React from 'react'
import {useWindowDimensions, View} from 'react-native'
import {View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
import {atoms as a, native, useTheme} from '#/alf'
import {MessageContextProvider} from './MessageContext'
let MessageItemEmbed = ({
@@ -12,32 +12,15 @@ let MessageItemEmbed = ({
embed: AppBskyEmbedRecord.View
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
return (
<MessageContextProvider>
<View
style={[
a.my_xs,
t.atoms.bg,
a.rounded_md,
native({
flexBasis: 0,
width: Math.min(screen.width, 600) / 1.4,
}),
web({
width: '100%',
minWidth: 280,
maxWidth: 360,
}),
]}>
<View style={{marginTop: tokens.space.sm * -1}}>
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
</MessageContextProvider>
)
+161
View File
@@ -0,0 +1,161 @@
import React from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {isWeb} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {ReportDialog} from '#/components/dms/ReportDialog'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
export let MessageMenu = ({
message,
control,
triggerOpacity,
}: {
triggerOpacity?: number
message: ChatBskyConvoDefs.MessageView
control: Menu.MenuControlProps
}): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const langPrefs = useLanguagePrefs()
const openLink = useOpenLink()
const isFromSelf = message.sender?.did === currentAccount?.did
const onCopyMessage = React.useCallback(() => {
const str = richTextToString(
new RichText({
text: message.text,
facets: message.facets,
}),
true,
)
Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}, [_, message.text, message.facets])
const onPressTranslateMessage = React.useCallback(() => {
const translatorUrl = getTranslatorLink(
message.text,
langPrefs.primaryLanguage,
)
openLink(translatorUrl, true)
}, [langPrefs.primaryLanguage, message.text, openLink])
const onDelete = React.useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
.then(() =>
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
)
.catch(() => Toast.show(_(msg`Failed to delete message`)))
}, [_, convo, message.id])
return (
<>
<Menu.Root control={control}>
{isWeb && (
<View style={{opacity: triggerOpacity}}>
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
style={[
a.p_sm,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<DotsHorizontal size="md" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
</View>
)}
<Menu.Outer>
{message.text.length > 0 && (
<>
<Menu.Group>
<Menu.Item
testID="messageDropdownTranslateBtn"
label={_(msg`Translate`)}
onPress={onPressTranslateMessage}>
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
<Menu.Item
testID="messageDropdownCopyBtn"
label={_(msg`Copy message text`)}
onPress={onCopyMessage}>
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
</>
)}
<Menu.Group>
<Menu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
onPress={() => deleteControl.open()}>
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
{!isFromSelf && (
<Menu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={() => reportControl.open()}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
)}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<ReportDialog
currentScreen="conversation"
params={{type: 'convoMessage', convoId: convo.convo.id, message}}
control={reportControl}
/>
<Prompt.Basic
control={deleteControl}
title={_(msg`Delete message`)}
description={_(
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`,
)}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
</>
)
}
MessageMenu = React.memo(MessageMenu)
+1 -30
View File
@@ -1,7 +1,4 @@
import {type ChatBskyConvoDefs} from '@atproto/api'
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import type * as bsky from '#/types/bsky'
import * as bsky from '#/types/bsky'
export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
switch (profile.associated?.chat?.allowIncoming) {
@@ -28,29 +25,3 @@ export function localDateString(date: Date) {
// not padding with 0s because it's not necessary, it's just used for comparison
return `${yyyy}-${mm}-${dd}`
}
export function hasAlreadyReacted(
message: ChatBskyConvoDefs.MessageView,
myDid: string | undefined,
emoji: string,
): boolean {
if (!message.reactions) {
return false
}
return !!message.reactions.find(
reaction => reaction.value === emoji && reaction.sender.did === myDid,
)
}
export function hasReachedReactionLimit(
message: ChatBskyConvoDefs.MessageView,
myDid: string | undefined,
): boolean {
if (!message.reactions) {
return false
}
const myReactions = message.reactions.filter(
reaction => reaction.sender.did === myDid,
)
return myReactions.length >= EMOJI_REACTION_LIMIT
}
+2 -1
View File
@@ -4,6 +4,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {isAndroid} from '#/platform/detection'
import {ServerInputDialog} from '#/view/com/auth/server-input'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -74,10 +75,10 @@ export function HostingProvider({
a.flex_row,
a.align_center,
a.rounded_sm,
a.py_sm,
a.pl_md,
a.pr_sm,
a.gap_xs,
{paddingVertical: isAndroid ? 14 : 8},
]}
onPress={onPressSelectService}>
{({hovered, pressed}) => {
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Flame_Stroke2_Corner1_Rounded = createSinglePathSVG({
path: 'M11.158 2.879c.584-.835 1.757-1.137 2.673-.507.951.654 2.597 1.92 4.013 3.694S20.5 10.194 20.5 13c0 4.997-3.752 9-8.5 9s-8.5-4.003-8.5-9c0-2.035.874-4.636 2.578-6.712.746-.91 2.034-.855 2.786-.133l2.294-3.276Zm-3.04 15.758C6.538 17.386 5.5 15.37 5.5 13c0-1.511.666-3.616 2.042-5.342.87.797 2.254.653 2.939-.325l2.286-3.265c.871.606 2.299 1.723 3.514 3.246C17.53 8.879 18.5 10.804 18.5 13c0 2.369-1.038 4.386-2.618 5.637q.117-.518.118-1.061c0-2.601-2.038-4.382-2.911-5.04a1.8 1.8 0 0 0-2.177 0C10.038 13.195 8 14.976 8 17.577q0 .543.118 1.061ZM12 14.222c-.825.648-2 1.859-2 3.354C10 19.043 11.016 20 12 20s2-.957 2-2.424c0-1.495-1.175-2.706-2-3.354Z',
})
@@ -3,7 +3,3 @@ import {createSinglePathSVG} from './TEMPLATE'
export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'm18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z',
})
export const Trending3_Stroke2_Corner1_Rounded = createSinglePathSVG({
path: 'M15 7a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9.414L14.414 15a2 2 0 0 1-2.828 0L9 12.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L7.586 11a2 2 0 0 1 2.828 0L13 13.586 18.586 8H16a1 1 0 0 1-1-1Z',
})
+3 -3
View File
@@ -1,5 +1,5 @@
import {StyleSheet, type TextProps} from 'react-native'
import {type PathProps, type SvgProps} from 'react-native-svg'
import {StyleSheet, TextProps} from 'react-native'
import type {PathProps, SvgProps} from 'react-native-svg'
import {Defs, LinearGradient, Stop} from 'react-native-svg'
import {nanoid} from 'nanoid/non-secure'
@@ -19,7 +19,7 @@ export const sizes = {
lg: 24,
xl: 28,
'2xl': 32,
} as const
}
export function useCommonSVGProps(props: Props) {
const t = useTheme()
+1 -1
View File
@@ -15,7 +15,7 @@ import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
import * as Prompt from '#/components/Prompt'
import {TrendingTopicLink} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography'
@@ -16,7 +16,7 @@ import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
+1 -3
View File
@@ -114,9 +114,7 @@ export function PostHider({
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
</Pressable>
<Text
style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]}
numberOfLines={1}>
<Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}>
{desc.name}
</Text>
{!modui.noOverride && (
+2 -2
View File
@@ -1,4 +1,4 @@
import packageDotJson from '../../package.json'
import {version} from '../../package.json'
export const IS_TESTFLIGHT = false
export const IS_INTERNAL = __DEV__
@@ -14,5 +14,5 @@ export const BUNDLE_DATE = __DEV__
? 0
: Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
export const appVersion = packageDotJson.version
export const appVersion = version
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${__DEV__ ? 'dev' : 'prod'})`
+2 -1
View File
@@ -3,6 +3,7 @@ export const isSafari = /^((?!chrome|android).)*safari/i.test(
navigator.userAgent,
)
export const isFirefox = /firefox|fxios/i.test(navigator.userAgent)
export const isTouchDevice = window.matchMedia('(pointer: coarse)').matches
export const isTouchDevice =
'ontouchstart' in window || navigator.maxTouchPoints > 1
export const isAndroidWeb =
/android/i.test(navigator.userAgent) && isTouchDevice
+2 -14
View File
@@ -1,5 +1,5 @@
import {type Insets, Platform} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
import {Insets, Platform} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
@@ -23,16 +23,6 @@ export const STARTER_PACK_MAX_SIZE = 150
// -prf
export const JOINED_THIS_WEEK = 560000 // estimate as of 12/18/24
export const DISCOVER_DEBUG_DIDS: Record<string, true> = {
'did:plc:oisofpd7lj26yvgiivf3lxsi': true, // hailey.at
'did:plc:p2cp5gopk7mgjegy6wadk3ep': true, // samuel.bsky.team
'did:plc:ragtjsm2j2vknwkz3zp4oxrd': true, // pfrazee.com
'did:plc:vpkhqolt662uhesyj6nxm7ys': true, // why.bsky.team
'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol
'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social
}
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({
email,
@@ -190,5 +180,3 @@ export const SUPPORTED_MIME_TYPES = [
] as const
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
export const EMOJI_REACTION_LIMIT = 5
+1 -1
View File
@@ -1,4 +1,4 @@
import {type StyleProp, type TextStyle, type ViewStyle} from 'react-native'
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg'
// Copyright (c) 2020 Refactoring UI Inc.
+1 -1
View File
@@ -1,6 +1,6 @@
export class VideoTooLargeError extends Error {
constructor() {
super('Videos cannot be larger than 100 MB')
super('Videos cannot be larger than 50mb')
this.name = 'VideoTooLargeError'
}
}
+3 -4
View File
@@ -1,7 +1,7 @@
import {type NavigationState, type PartialState} from '@react-navigation/native'
import {type NativeStackNavigationProp} from '@react-navigation/native-stack'
import {NavigationState, PartialState} from '@react-navigation/native'
import type {NativeStackNavigationProp} from '@react-navigation/native-stack'
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
export type {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -51,7 +51,6 @@ export type CommonNavigatorParams = {
AccountSettings: undefined
PrivacyAndSecuritySettings: undefined
ContentAndMediaSettings: undefined
SettingsInterests: undefined
AboutSettings: undefined
AppIconSettings: undefined
Search: {q?: string}
-1
View File
@@ -2,7 +2,6 @@ export type Gate =
// Keep this alphabetic please.
| 'debug_show_feedcontext'
| 'debug_subscriptions'
| 'explore_show_suggested_feeds'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'remove_show_latest_button'
+2 -2
View File
@@ -323,7 +323,7 @@ export function createBskyAppAbsoluteUrl(path: string): string {
export function createProxiedUrl(url: string): string {
let u
try {
u = new URL(url)
u = URL.parse(url)
} catch {
return url
}
@@ -332,7 +332,7 @@ export function createProxiedUrl(url: string): string {
return url
}
return `https://go.bsky.app/redirect?u=${encodeURIComponent(url)}`
return `https://go.bsky.app/redirect?u=${url}`
}
export function isShortLink(url: string): boolean {
+1 -1
View File
@@ -169,7 +169,7 @@ export const LANGUAGES: Language[] = [
{code3: 'cad', code2: '', name: 'Caddo'},
{code3: 'cai', code2: '', name: 'Central American Indian languages'},
{code3: 'car', code2: '', name: 'Galibi Carib'},
{code3: 'cat', code2: 'ca', name: 'Catalan-Valencian'},
{code3: 'cat', code2: 'ca', name: 'Catalan'},
{code3: 'cau', code2: '', name: 'Caucasian languages'},
{code3: 'ceb', code2: '', name: 'Cebuano'},
{code3: 'cel', code2: '', name: 'Celtic languages'},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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