From 4e8d86db317864257f6416cd867d7bb07baca6d0 Mon Sep 17 00:00:00 2001
From: Mary <148872143+mary-ext@users.noreply.github.com>
Date: Mon, 23 Sep 2024 21:38:04 +0700
Subject: [PATCH 01/26] Let Expo/Webpack handle CSS assets (#3942)
* chore: handle built css assets
* chore: let prettier handle css code
* refactor: let webpack build css assets
* chore: prettier on bskyembed
* chore: touch empty.txt on css directory
* chore: do the same to the workflow
---
.github/workflows/golang-test-lint.yml | 4 +-
.prettierignore | 4 +-
Dockerfile.embedr | 1 +
bskyembed/src/index.css | 2 +-
bskyweb/.gitignore | 4 +
bskyweb/static/css/.gitkeep | 0
bskyweb/templates/base.html | 381 -----------------------
package.json | 2 +-
scripts/post-web-build.js | 25 +-
src/App.web.tsx | 1 +
src/style.css | 403 +++++++++++++++++++++++++
web/index.html | 382 -----------------------
12 files changed, 440 insertions(+), 769 deletions(-)
create mode 100644 bskyweb/static/css/.gitkeep
create mode 100644 src/style.css
diff --git a/.github/workflows/golang-test-lint.yml b/.github/workflows/golang-test-lint.yml
index 36e28841d5..c8124dbade 100644
--- a/.github/workflows/golang-test-lint.yml
+++ b/.github/workflows/golang-test-lint.yml
@@ -21,7 +21,7 @@ jobs:
with:
go-version: '1.22'
- name: Dummy Static Files
- run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
+ run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Check
run: cd bskyweb/ && make check
- name: Build (binary)
@@ -38,6 +38,6 @@ jobs:
with:
go-version: '1.22'
- name: Dummy Static Files
- run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
+ run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Lint
run: cd bskyweb/ && make lint
diff --git a/.prettierignore b/.prettierignore
index 641a1b8bfc..8ccbae2148 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,4 +1,4 @@
-# Ignore everything except JS-ey code.
+# Ignore everything except JS-ey or CSS code.
# Based on https://stackoverflow.com/a/70715829/458193
*
!**/*.js
@@ -7,6 +7,8 @@
!**/*.tsx
!*/
+!**/*.css
+
# More specific ignores go below.
.expo
android
diff --git a/Dockerfile.embedr b/Dockerfile.embedr
index 9ff04aa5c7..663cbcfc51 100644
--- a/Dockerfile.embedr
+++ b/Dockerfile.embedr
@@ -40,6 +40,7 @@ RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bs
# hack around issue with empty directory and go:embed
RUN touch bskyweb/static/js/empty.txt
+RUN touch bskyweb/static/css/empty.txt
RUN touch bskyweb/static/media/empty.txt
#
diff --git a/bskyembed/src/index.css b/bskyembed/src/index.css
index 23457ec28d..22b2b8be5c 100644
--- a/bskyembed/src/index.css
+++ b/bskyembed/src/index.css
@@ -4,4 +4,4 @@
.break-word {
word-break: break-word;
-}
\ No newline at end of file
+}
diff --git a/bskyweb/.gitignore b/bskyweb/.gitignore
index 05b3ad7ab5..a63a381f94 100644
--- a/bskyweb/.gitignore
+++ b/bskyweb/.gitignore
@@ -10,6 +10,10 @@ static/js/*.js
static/js/*.map
static/js/*.js.LICENSE.txt
static/js/empty.txt
+static/css/*.css
+static/css/*.map
+static/css/*.css.LICENSE.txt
+static/css/empty.txt
static/media/*.png
static/media/empty.txt
templates/scripts.html
diff --git a/bskyweb/static/css/.gitkeep b/bskyweb/static/css/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 609c17c7ce..eaa31aa4a3 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -32,387 +32,6 @@
-->
-
{% include "scripts.html" %}
diff --git a/package.json b/package.json
index 05b5f086db..e3aff1fe0c 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"web": "expo start --web",
"use-build-number": "./scripts/useBuildNumberEnv.sh",
"use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh",
- "build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/ && cp -v ./web-build/static/media/* ./bskyweb/static/media/",
+ "build-web": "expo export:web && node ./scripts/post-web-build.js",
"build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all",
"build-ios": "yarn use-build-number-with-bump eas build -p ios",
"build-android": "yarn use-build-number-with-bump eas build -p android",
diff --git a/scripts/post-web-build.js b/scripts/post-web-build.js
index baaa7cb8b7..7bbee38554 100644
--- a/scripts/post-web-build.js
+++ b/scripts/post-web-build.js
@@ -20,7 +20,30 @@ console.log(`Writing ${templateFile}`)
const outputFile = entrypoints
.map(name => {
const file = path.basename(name)
- return ``
+ const ext = path.extname(file)
+
+ if (ext === '.js') {
+ return ``
+ }
+ if (ext === '.css') {
+ return ``
+ }
+
+ return ''
})
.join('\n')
fs.writeFileSync(templateFile, outputFile)
+
+function copyFiles(sourceDir, targetDir) {
+ const files = fs.readdirSync(path.join(projectRoot, sourceDir))
+ files.forEach(file => {
+ const sourcePath = path.join(projectRoot, sourceDir, file)
+ const targetPath = path.join(projectRoot, targetDir, file)
+ fs.copyFileSync(sourcePath, targetPath)
+ console.log(`Copied ${sourcePath} to ${targetPath}`)
+ })
+}
+
+copyFiles('web-build/static/js', 'bskyweb/static/js')
+copyFiles('web-build/static/css', 'bskyweb/static/css')
+copyFiles('web-build/static/media', 'bskyweb/static/media')
diff --git a/src/App.web.tsx b/src/App.web.tsx
index c81ed10d33..7d98737a3b 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -1,5 +1,6 @@
import 'lib/sentry' // must be near top
import 'view/icons'
+import './style.css'
import React, {useEffect, useState} from 'react'
import {KeyboardProvider} from 'react-native-keyboard-controller'
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000000..29e9770e32
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,403 @@
+@font-face {
+ font-family: 'Inter-Regular';
+ src: local('Inter-Regular'),
+ url(/static/media/Inter-Regular.1f5ed03b6dd9fd1f9982.otf) format('font/otf');
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: 'Inter-Italic';
+ src: local('Inter-Italic'),
+ url(/static/media/Inter-Italic.95778eb0c75dc956257e.otf) format('font/otf');
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+}
+/*
+@font-face {
+ font-family: "Inter-Medium";
+ src: local("Inter-Medium"), url(/static/media/Inter-Medium.296aa2d65964269836b3.otf) format("font/otf");
+ font-weight: 500;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: "Inter-MediumItalic";
+ src: local("Inter-MediumItalic"), url(/static/media/Inter-MediumItalic.0e57e17a6311368e2114.otf) format("font/otf");
+ font-weight: 500;
+ font-style: italic;
+ font-display: swap;
+}
+*/
+@font-face {
+ font-family: 'Inter-SemiBold';
+ src: local('Inter-SemiBold'),
+ url(/static/media/Inter-SemiBold.2277990330981b8409bb.otf)
+ format('font/otf');
+ font-weight: 600;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: 'Inter-SemiBoldItalic';
+ src: local('Inter-SemiBoldItalic'),
+ url(/static/media/Inter-SemiBoldItalic.f62fea3df3a521d6c8a7.otf)
+ format('font/otf');
+ font-weight: 600;
+ font-style: italic;
+ font-display: swap;
+}
+/*
+@font-face {
+ font-family: "Inter-Bold";
+ src: local("Inter-Bold"), url(/static/media/Inter-Bold.8d330503e1d034ad68de.otf) format("font/otf");
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: "Inter-BoldItalic";
+ src: local("Inter-BoldItalic"), url(/static/media/Inter-BoldItalic.bb17e63f9baa0d861a20.otf) format("font/otf");
+ font-weight: 700;
+ font-style: italic;
+ font-display: swap;
+}
+*/
+@font-face {
+ font-family: 'Inter-ExtraBold';
+ src: local('Inter-ExtraBold'),
+ url(/static/media/Inter-ExtraBold.ff2581a193bf6b7e0b06.otf)
+ format('font/otf');
+ font-weight: 800;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: 'Inter-ExtraBoldItalic';
+ src: local('Inter-ExtraBoldItalic'),
+ url(/static/media/Inter-ExtraBoldItalic.0e50b40728d24d40fdf4.otf)
+ format('font/otf');
+ font-weight: 800;
+ font-style: italic;
+ font-display: swap;
+}
+/*
+@font-face {
+ font-family: "Inter-Black";
+ src: local("Inter-Black"), url(/static/media/Inter-Black.66e9a87f1c921e844ed4.otf) format("font/otf");
+ font-weight: 900;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: "Inter-BlackItalic";
+ src: local("Inter-BlackItalic"), url(/static/media/Inter-BlackItalic.27b9f0ad06fd13a7b9da.otf) format("font/otf");
+ font-weight: 900;
+ font-style: italic;
+ font-display: swap;
+}
+*/
+
+/**
+ * Extend the react-native-web reset:
+ * https://github.com/necolas/react-native-web/blob/master/packages/react-native-web/src/exports/StyleSheet/initialRules.js
+ */
+html,
+body,
+#root {
+ width: 100%;
+ /* To smooth any scrolling behavior */
+ -webkit-overflow-scrolling: touch;
+ margin: 0px;
+ padding: 0px;
+ /* Allows content to fill the viewport and go beyond the bottom */
+ min-height: 100%;
+}
+#root {
+ flex-shrink: 0;
+ flex-basis: auto;
+ flex-grow: 1;
+ display: flex;
+ flex: 1;
+}
+
+html {
+ /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */
+ -webkit-text-size-adjust: 100%;
+ height: calc(100% + env(safe-area-inset-top));
+ scrollbar-gutter: stable both-edges;
+}
+html,
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Liberation Sans', Helvetica, Arial, sans-serif;
+}
+
+#preload {
+ width: 100px;
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+}
+
+/* Buttons and inputs have a font set by UA, so we'll have to reset that */
+button,
+input,
+textarea {
+ font: inherit;
+ line-height: inherit;
+}
+
+/* Color theming */
+/* Default will always be white */
+:root {
+ --text: black;
+ --background: white;
+ --backgroundLight: hsl(211, 20%, 95%);
+}
+/* This gives us a black background when system is dark and we have not loaded the theme/color scheme values in JS */
+@media (prefers-color-scheme: dark) {
+ :root {
+ --text: white;
+ --background: black;
+ --backgroundLight: hsl(211, 20%, 20%);
+ color-scheme: dark;
+ }
+}
+
+/* Overwrite those preferences with the selected theme */
+html.theme--light {
+ --text: black;
+ --background: white;
+ --backgroundLight: hsl(211, 20%, 95%);
+}
+html.theme--dark {
+ --text: white;
+ --background: black;
+ --backgroundLight: hsl(211, 20%, 20%);
+ color-scheme: dark;
+}
+html.theme--dim {
+ --text: white;
+ --background: hsl(211, 20%, 4%);
+ --backgroundLight: hsl(211, 20%, 10%);
+ color-scheme: dark;
+}
+
+/* Remove autofill styles on Webkit */
+input:autofill,
+input:-webkit-autofill,
+input:-webkit-autofill:hover,
+input:-webkit-autofill:focus,
+input:-webkit-autofill:active {
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: var(--text);
+ transition: background-color 5000s ease-in-out 0s;
+ box-shadow: inset 0 0 20px 20px var(--background);
+ background: var(--background);
+ color: var(--text);
+}
+/* Force left-align date/time inputs on iOS mobile */
+input::-webkit-date-and-time-value {
+ text-align: left;
+}
+
+body {
+ display: flex;
+ /* Allows you to scroll below the viewport; default value is visible */
+ overflow-y: auto;
+ overscroll-behavior-y: none;
+ text-rendering: optimizeLegibility;
+ background-color: var(--background);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -ms-overflow-style: scrollbar;
+ font-synthesis-weight: none;
+}
+
+/* Remove default link styling */
+a {
+ color: inherit;
+}
+a[role='link']:hover {
+ text-decoration: underline;
+}
+a[role='link'][data-no-underline='1']:hover {
+ text-decoration: none;
+}
+
+/* Styling hacks */
+*[data-word-wrap] {
+ word-break: break-word;
+}
+*[data-stable-gutters] {
+ scrollbar-gutter: stable both-edges;
+}
+
+/* ProseMirror */
+.ProseMirror {
+ font: 18px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Liberation Sans', Helvetica, Arial, sans-serif;
+ min-height: 140px;
+}
+.ProseMirror-dark {
+ color: white;
+}
+.ProseMirror p {
+ margin: 0;
+}
+.ProseMirror p.is-editor-empty:first-child::before {
+ color: #8d8e96;
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+}
+.ProseMirror .mention {
+ color: #0085ff;
+}
+.ProseMirror a,
+.ProseMirror .autolink {
+ color: #0085ff;
+}
+/* OLLIE: TODO -- this is not accessible */
+/* Remove focus state on inputs */
+.ProseMirror-focused {
+ outline: 0;
+}
+textarea:focus,
+input:focus {
+ outline: 0;
+}
+.tippy-content .items {
+ width: fit-content;
+}
+
+/* Tooltips */
+[data-tooltip] {
+ position: relative;
+ z-index: 10;
+}
+[data-tooltip]::after {
+ content: attr(data-tooltip);
+ display: none;
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ transform: translateY(100%) translateY(8px) translateX(-50%);
+ padding: 4px 10px;
+ border-radius: 10px;
+ background: var(--backgroundLight);
+ color: var(--text);
+ text-align: center;
+ white-space: nowrap;
+ font-size: 12px;
+ z-index: 10;
+}
+[data-tooltip]::before {
+ content: '';
+ display: none;
+ position: absolute;
+ border-bottom: 6px solid var(--backgroundLight);
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ bottom: 0;
+ left: 50%;
+ transform: translateY(100%) translateY(2px) translateX(-50%);
+ z-index: 10;
+}
+[data-tooltip]:hover::after,
+[data-tooltip]:hover::before {
+ display: block;
+}
+
+/* NativeDropdown component */
+.radix-dropdown-item:focus,
+.nativeDropdown-item:focus {
+ outline: none;
+}
+
+/* Spinner component */
+@keyframes rotate {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+.rotate-500ms {
+ position: absolute;
+ inset: 0;
+ animation: rotate 500ms linear infinite;
+}
+
+@keyframes avatarHoverFadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes avatarHoverFadeOut {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
+
+.force-no-clicks > *,
+.force-no-clicks * {
+ pointer-events: none !important;
+}
+
+input[type='range'][orient='vertical'] {
+ writing-mode: vertical-lr;
+ direction: rtl;
+ appearance: slider-vertical;
+ width: 16px;
+ vertical-align: bottom;
+ -webkit-appearance: none;
+ appearance: none;
+ background: transparent;
+ cursor: pointer;
+}
+
+input[type='range'][orient='vertical']::-webkit-slider-runnable-track {
+ background: white;
+ height: 100%;
+ width: 4px;
+ border-radius: 4px;
+}
+
+input[type='range'][orient='vertical']::-moz-range-track {
+ background: white;
+ height: 100%;
+ width: 4px;
+ border-radius: 4px;
+}
+
+input[type='range']::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ border-radius: 50%;
+ background-color: white;
+ height: 16px;
+ width: 16px;
+ margin-left: -6px;
+}
+
+input[type='range'][orient='vertical']::-moz-range-thumb {
+ border: none;
+ border-radius: 50%;
+ background-color: white;
+ height: 16px;
+ width: 16px;
+ margin-left: -6px;
+}
diff --git a/web/index.html b/web/index.html
index 3a132d25b8..512178327f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -36,388 +36,6 @@
-->
-
-
From 87d601e68d38b1337639b0d89cbd074147fa6236 Mon Sep 17 00:00:00 2001
From: "whey.party" <132627503+rimar1337@users.noreply.github.com>
Date: Mon, 23 Sep 2024 22:24:36 +0700
Subject: [PATCH 02/26] changed white (gray_0) text to offwhite (gray_25)
(#5453)
---
src/alf/themes.ts | 2 +-
src/lib/themes.ts | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/alf/themes.ts b/src/alf/themes.ts
index f5d2247f9f..9f7ec5c673 100644
--- a/src/alf/themes.ts
+++ b/src/alf/themes.ts
@@ -183,7 +183,7 @@ export function createThemes({
} as const
const darkPalette: Palette = {
- white: color.gray_0,
+ white: color.gray_25,
black: color.trueBlack,
contrast_25: color.gray_975,
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
index 5030799932..eb11872fa3 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -325,11 +325,11 @@ export const darkTheme: Theme = {
textInverted: colors.green2,
},
inverted: {
- background: lightPalette.white,
+ background: darkPalette.white,
backgroundLight: lightPalette.contrast_50,
text: lightPalette.black,
textLight: lightPalette.contrast_700,
- textInverted: lightPalette.white,
+ textInverted: darkPalette.white,
link: lightPalette.primary_500,
border: lightPalette.contrast_100,
borderDark: lightPalette.contrast_200,
From 5e333d4dfcc434f76fd54def2f965f54e112257b Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Mon, 23 Sep 2024 10:35:07 -0500
Subject: [PATCH 03/26] Resolve source files for fonts, remove hack (#5454)
* Resolve source files for fonts, remove hack
* Prettier
* Prettier, add to hook
---------
Co-authored-by: Dan Abramov
---
package.json | 4 +++-
src/alf/fonts.ts | 35 -----------------------------------
src/style.css | 28 ++++++++++++----------------
3 files changed, 15 insertions(+), 52 deletions(-)
diff --git a/package.json b/package.json
index e3aff1fe0c..bb9706c39a 100644
--- a/package.json
+++ b/package.json
@@ -338,7 +338,9 @@
},
"lint-staged": {
"*{.js,.jsx,.ts,.tsx}": [
- "eslint --cache --fix",
+ "eslint --cache --fix"
+ ],
+ "*{.js,.jsx,.ts,.tsx,.css}": [
"prettier --cache --write --ignore-unknown"
]
}
diff --git a/src/alf/fonts.ts b/src/alf/fonts.ts
index 08cfd9f42d..b11ce939f8 100644
--- a/src/alf/fonts.ts
+++ b/src/alf/fonts.ts
@@ -1,5 +1,3 @@
-import {useFonts as defaultUseFonts} from 'expo-font'
-
import {isWeb} from '#/platform/detection'
import {Device, device} from '#/storage'
@@ -34,39 +32,6 @@ export function setFontFamily(fontFamily: Device['fontFamily']) {
device.set(['fontFamily'], fontFamily)
}
-/*
- * IMPORTANT: This is unused. Expo statically extracts these fonts, but we load
- * them manually so that we can parallelize the loading along with the JS
- * bundle.
- *
- * See `#/alf/util/useFonts` for the actually used hooks.
- *
- * All used fonts MUST be configured here. Unused fonts are commented out, but
- * the files are there if we need them.
- */
-export function DO_NOT_USE() {
- return defaultUseFonts({
- // 'Inter-Thin': require('../../assets/fonts/inter/Inter-Thin.otf'),
- // 'Inter-ThinItalic': require('../../assets/fonts/inter/Inter-ThinItalic.otf'),
- // 'Inter-ExtraLight': require('../../assets/fonts/inter/Inter-ExtraLight.otf'),
- // 'Inter-ExtraLightItalic': require('../../assets/fonts/inter/Inter-ExtraLightItalic.otf'),
- // 'Inter-Light': require('../../assets/fonts/inter/Inter-Light.otf'),
- // 'Inter-LightItalic': require('../../assets/fonts/inter/Inter-LightItalic.otf'),
- 'Inter-Regular': require('../../assets/fonts/inter/Inter-Regular.otf'),
- 'Inter-Italic': require('../../assets/fonts/inter/Inter-Italic.otf'),
- // 'Inter-Medium': require('../../assets/fonts/inter/Inter-Medium.otf'),
- // 'Inter-MediumItalic': require('../../assets/fonts/inter/Inter-MediumItalic.otf'),
- 'Inter-SemiBold': require('../../assets/fonts/inter/Inter-SemiBold.otf'),
- 'Inter-SemiBoldItalic': require('../../assets/fonts/inter/Inter-SemiBoldItalic.otf'),
- // 'Inter-Bold': require('../../assets/fonts/inter/Inter-Bold.otf'),
- // 'Inter-BoldItalic': require('../../assets/fonts/inter/Inter-BoldItalic.otf'),
- 'Inter-ExtraBold': require('../../assets/fonts/inter/Inter-ExtraBold.otf'),
- 'Inter-ExtraBoldItalic': require('../../assets/fonts/inter/Inter-ExtraBoldItalic.otf'),
- // 'Inter-Black': require('../../assets/fonts/inter/Inter-Black.otf'),
- // 'Inter-BlackItalic': require('../../assets/fonts/inter/Inter-BlackItalic.otf'),
- })
-}
-
/*
* Unused fonts are commented out, but the files are there if we need them.
*/
diff --git a/src/style.css b/src/style.css
index 29e9770e32..ebb0471584 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,7 +1,7 @@
@font-face {
font-family: 'Inter-Regular';
src: local('Inter-Regular'),
- url(/static/media/Inter-Regular.1f5ed03b6dd9fd1f9982.otf) format('font/otf');
+ url(/assets/fonts/inter/Inter-Regular.otf) format('font/otf');
font-weight: 400;
font-style: normal;
font-display: swap;
@@ -9,7 +9,7 @@
@font-face {
font-family: 'Inter-Italic';
src: local('Inter-Italic'),
- url(/static/media/Inter-Italic.95778eb0c75dc956257e.otf) format('font/otf');
+ url(/assets/fonts/inter/Inter-Italic.otf) format('font/otf');
font-weight: 400;
font-style: italic;
font-display: swap;
@@ -17,14 +17,14 @@
/*
@font-face {
font-family: "Inter-Medium";
- src: local("Inter-Medium"), url(/static/media/Inter-Medium.296aa2d65964269836b3.otf) format("font/otf");
+ src: local("Inter-Medium"), url(/assets/fonts/inter/Inter-Medium.otf) format("font/otf");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter-MediumItalic";
- src: local("Inter-MediumItalic"), url(/static/media/Inter-MediumItalic.0e57e17a6311368e2114.otf) format("font/otf");
+ src: local("Inter-MediumItalic"), url(/assets/fonts/inter/Inter-MediumItalic.otf) format("font/otf");
font-weight: 500;
font-style: italic;
font-display: swap;
@@ -33,8 +33,7 @@
@font-face {
font-family: 'Inter-SemiBold';
src: local('Inter-SemiBold'),
- url(/static/media/Inter-SemiBold.2277990330981b8409bb.otf)
- format('font/otf');
+ url(/assets/fonts/inter/Inter-SemiBold.otf) format('font/otf');
font-weight: 600;
font-style: normal;
font-display: swap;
@@ -42,8 +41,7 @@
@font-face {
font-family: 'Inter-SemiBoldItalic';
src: local('Inter-SemiBoldItalic'),
- url(/static/media/Inter-SemiBoldItalic.f62fea3df3a521d6c8a7.otf)
- format('font/otf');
+ url(/assets/fonts/inter/Inter-SemiBoldItalic.otf) format('font/otf');
font-weight: 600;
font-style: italic;
font-display: swap;
@@ -51,14 +49,14 @@
/*
@font-face {
font-family: "Inter-Bold";
- src: local("Inter-Bold"), url(/static/media/Inter-Bold.8d330503e1d034ad68de.otf) format("font/otf");
+ src: local("Inter-Bold"), url(/assets/fonts/inter/Inter-Bold.otf) format("font/otf");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter-BoldItalic";
- src: local("Inter-BoldItalic"), url(/static/media/Inter-BoldItalic.bb17e63f9baa0d861a20.otf) format("font/otf");
+ src: local("Inter-BoldItalic"), url(/assets/fonts/inter/Inter-BoldItalic.otf) format("font/otf");
font-weight: 700;
font-style: italic;
font-display: swap;
@@ -67,8 +65,7 @@
@font-face {
font-family: 'Inter-ExtraBold';
src: local('Inter-ExtraBold'),
- url(/static/media/Inter-ExtraBold.ff2581a193bf6b7e0b06.otf)
- format('font/otf');
+ url(/assets/fonts/inter/Inter-ExtraBold.otf) format('font/otf');
font-weight: 800;
font-style: normal;
font-display: swap;
@@ -76,8 +73,7 @@
@font-face {
font-family: 'Inter-ExtraBoldItalic';
src: local('Inter-ExtraBoldItalic'),
- url(/static/media/Inter-ExtraBoldItalic.0e50b40728d24d40fdf4.otf)
- format('font/otf');
+ url(/assets/fonts/inter/Inter-ExtraBoldItalic.otf) format('font/otf');
font-weight: 800;
font-style: italic;
font-display: swap;
@@ -85,14 +81,14 @@
/*
@font-face {
font-family: "Inter-Black";
- src: local("Inter-Black"), url(/static/media/Inter-Black.66e9a87f1c921e844ed4.otf) format("font/otf");
+ src: local("Inter-Black"), url(/assets/fonts/inter/Inter-Black.otf) format("font/otf");
font-weight: 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter-BlackItalic";
- src: local("Inter-BlackItalic"), url(/static/media/Inter-BlackItalic.27b9f0ad06fd13a7b9da.otf) format("font/otf");
+ src: local("Inter-BlackItalic"), url(/assets/fonts/inter/Inter-BlackItalic.otf) format("font/otf");
font-weight: 900;
font-style: italic;
font-display: swap;
From 443f3a64069f081764c2f49578108a9570e8e834 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Mon, 23 Sep 2024 16:35:16 +0100
Subject: [PATCH 04/26] Use pressable for video controls (#5452)
* use pressable for video controls
* add `as any` to preexisiting bad type
* stop mutating prop
---
src/view/com/pager/TabBar.tsx | 6 +--
src/view/com/util/PressableWithHover.tsx | 46 +++++++++----------
.../web-controls/ControlButton.tsx | 22 +++++----
.../web-controls/VideoControls.tsx | 11 +++--
4 files changed, 43 insertions(+), 42 deletions(-)
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index 59bb77e367..d36d794b72 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -1,9 +1,9 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {isNative} from '#/platform/detection'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {PressableWithHover} from '../util/PressableWithHover'
import {Text} from '../util/text/Text'
import {DraggableScrollView} from './DraggableScrollView'
@@ -131,7 +131,7 @@ export function TabBar({
(itemRefs.current[i] = node)}
+ ref={node => (itemRefs.current[i] = node as any)}
onLayout={e => onItemLayout(e, i)}
style={styles.item}
hoverStyle={pal.viewLight}
diff --git a/src/view/com/util/PressableWithHover.tsx b/src/view/com/util/PressableWithHover.tsx
index 77276f1843..48659e2295 100644
--- a/src/view/com/util/PressableWithHover.tsx
+++ b/src/view/com/util/PressableWithHover.tsx
@@ -1,39 +1,35 @@
-import React, {
- useState,
- useCallback,
- PropsWithChildren,
- forwardRef,
- Ref,
-} from 'react'
+import React, {forwardRef, PropsWithChildren} from 'react'
import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'
-import {addStyle} from 'lib/styles'
+import {View} from 'react-native'
+
+import {addStyle} from '#/lib/styles'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
interface PressableWithHover extends PressableProps {
hoverStyle: StyleProp
}
-export const PressableWithHover = forwardRef(function PressableWithHoverImpl(
- {
- children,
- style,
- hoverStyle,
- ...props
- }: PropsWithChildren,
- ref: Ref,
+export const PressableWithHover = forwardRef<
+ View,
+ PropsWithChildren
+>(function PressableWithHoverImpl(
+ {children, style, hoverStyle, ...props},
+ ref,
) {
- const [isHovering, setIsHovering] = useState(false)
-
- const onHoverIn = useCallback(() => setIsHovering(true), [setIsHovering])
- const onHoverOut = useCallback(() => setIsHovering(false), [setIsHovering])
- style =
- typeof style !== 'function' && isHovering
- ? addStyle(style, hoverStyle)
- : style
+ const {
+ state: hovered,
+ onIn: onHoverIn,
+ onOut: onHoverOut,
+ } = useInteractionState()
return (
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx
index 6b509d09a3..8ffe482a8f 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx
@@ -1,8 +1,8 @@
import React from 'react'
import {SvgProps} from 'react-native-svg'
-import {atoms as a, useTheme} from '#/alf'
-import {Button} from '#/components/Button'
+import {atoms as a, useTheme, web} from '#/alf'
+import {PressableWithHover} from '../../../PressableWithHover'
export function ControlButton({
active,
@@ -21,19 +21,21 @@ export function ControlButton({
}) {
const t = useTheme()
return (
-
+
)
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
index 5bd7e0d179..2d1427347d 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
@@ -358,9 +358,8 @@ export function Controls({
style={[
a.flex_1,
a.px_xs,
- a.pt_2xs,
- a.pb_md,
- a.gap_md,
+ a.pb_sm,
+ a.gap_sm,
a.flex_row,
a.align_center,
]}>
@@ -373,7 +372,11 @@ export function Controls({
onPress={onPressPlayPause}
/>
-
+
{formatTime(currentTime)} / {formatTime(duration)}
{hasSubtitleTrack && (
From 5eb294488f08534abac3335acfa366cffea9259e Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Mon, 23 Sep 2024 10:40:37 -0500
Subject: [PATCH 05/26] [Neue] Handle emoji within custom font (#5449)
* Support emoji in text with custom font
* Add emoji support to elements that need it
* Remove unused file causing lint failure
* Fix a few more emoji locations
* Couple more
* No throw
---
package.json | 1 +
src/components/FeedCard.tsx | 13 ++-
src/components/KnownFollowers.tsx | 14 +--
src/components/LabelingServiceCard/index.tsx | 11 +-
src/components/ListCard.tsx | 24 ++--
src/components/Pills.tsx | 1 +
src/components/ProfileCard.tsx | 12 +-
src/components/ReportDialog/SubmitView.tsx | 1 +
src/components/RichText.tsx | 9 +-
.../StarterPack/StarterPackCard.tsx | 27 ++---
.../StarterPack/Wizard/WizardListCard.tsx | 11 +-
src/components/Typography.tsx | 108 +++++++++++++++++-
src/components/dms/MessagesListHeader.tsx | 17 +--
.../moderation/LabelsOnMeDialog.tsx | 6 +-
.../moderation/ModerationDetailsDialog.tsx | 8 +-
src/screens/Messages/List/ChatListItem.tsx | 10 +-
src/screens/Profile/Header/DisplayName.tsx | 1 +
src/screens/Profile/Header/Handle.tsx | 11 +-
.../com/composer/text-input/TextInput.tsx | 19 +--
.../composer/text-input/web/Autocomplete.tsx | 15 +--
src/view/com/feeds/FeedSourceCard.tsx | 12 +-
src/view/com/modals/UserAddRemoveLists.tsx | 32 +++---
src/view/com/notifications/FeedItem.tsx | 23 ++--
src/view/com/pager/TabBar.tsx | 1 +
src/view/com/post-thread/PostThreadItem.tsx | 31 ++---
src/view/com/posts/FeedItem.tsx | 26 +++--
src/view/com/profile/ProfileCard.tsx | 19 +--
src/view/com/util/PostMeta.tsx | 44 ++++---
src/view/com/util/UserInfoText.tsx | 33 +++---
.../util/post-embeds/ExternalLinkEmbed.tsx | 27 ++---
src/view/com/util/text/Text.tsx | 50 +++++---
src/view/com/util/text/ThemedText.tsx | 80 -------------
src/view/screens/Search/Search.tsx | 1 +
src/view/shell/desktop/Search.tsx | 11 +-
yarn.lock | 5 +
35 files changed, 424 insertions(+), 290 deletions(-)
delete mode 100644 src/view/com/util/text/ThemedText.tsx
diff --git a/package.json b/package.json
index bb9706c39a..245c095f19 100644
--- a/package.json
+++ b/package.json
@@ -116,6 +116,7 @@
"deprecated-react-native-prop-types": "^5.0.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.5.2",
+ "emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "^51.0.8",
"expo-application": "^5.9.1",
diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx
index e6d664cfda..b28f66f839 100644
--- a/src/components/FeedCard.tsx
+++ b/src/components/FeedCard.tsx
@@ -11,17 +11,17 @@ import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
+import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
+import {precacheFeedFromGeneratorView} from '#/state/queries/feed'
import {
useAddSavedFeedsMutation,
usePreferencesQuery,
useRemoveFeedMutation,
} from '#/state/queries/preferences'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {precacheFeedFromGeneratorView} from 'state/queries/feed'
-import {useSession} from 'state/session'
+import {useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
-import * as Toast from 'view/com/util/Toast'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
@@ -121,7 +121,10 @@ export function TitleAndByline({
return (
-
+
{title}
{creator && (
diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx
index 4017a7b0be..35a346c3a5 100644
--- a/src/components/KnownFollowers.tsx
+++ b/src/components/KnownFollowers.tsx
@@ -5,7 +5,7 @@ import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {makeProfileLink} from '#/lib/routes/links'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Link, LinkProps} from '#/components/Link'
@@ -185,11 +185,11 @@ function KnownFollowersInner({
serverCount > 2 ? (
Followed by{' '}
-
+
{slice[0].profile.displayName}
,{' '}
-
+
{slice[1].profile.displayName}
, and{' '}
@@ -203,11 +203,11 @@ function KnownFollowersInner({
// only 2
Followed by{' '}
-
+
{slice[0].profile.displayName}
{' '}
and{' '}
-
+
{slice[1].profile.displayName}
@@ -216,7 +216,7 @@ function KnownFollowersInner({
// 1-n followers, including blocks
Followed by{' '}
-
+
{slice[0].profile.displayName}
{' '}
and{' '}
@@ -230,7 +230,7 @@ function KnownFollowersInner({
// only 1
Followed by{' '}
-
+
{slice[0].profile.displayName}
diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx
index 851645a48c..03b8ece6b1 100644
--- a/src/components/LabelingServiceCard/index.tsx
+++ b/src/components/LabelingServiceCard/index.tsx
@@ -44,17 +44,22 @@ export function Avatar({avatar}: {avatar?: string}) {
}
export function Title({value}: {value: string}) {
- return {value}
+ return (
+
+ {value}
+
+ )
}
export function Description({value, handle}: {value?: string; handle: string}) {
+ const {_} = useLingui()
return value ? (
) : (
-
- By {sanitizeHandle(handle, '@')}
+
+ {_(msg`By ${sanitizeHandle(handle, '@')}`)}
)
}
diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx
index 829f36d471..ed5838fb04 100644
--- a/src/components/ListCard.tsx
+++ b/src/components/ListCard.tsx
@@ -7,13 +7,14 @@ import {
moderateUserList,
ModerationUI,
} from '@atproto/api'
-import {Trans} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {useModerationOpts} from 'state/preferences/moderation-opts'
-import {precacheList} from 'state/queries/feed'
-import {useSession} from 'state/session'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {precacheList} from '#/state/queries/feed'
+import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {
Avatar,
@@ -111,6 +112,7 @@ export function TitleAndByline({
modUi?: ModerationUI
}) {
const t = useTheme()
+ const {_} = useLingui()
const {currentAccount} = useSession()
return (
@@ -130,6 +132,7 @@ export function TitleAndByline({
{title}
@@ -139,15 +142,12 @@ export function TitleAndByline({
{creator && (
- {purpose === MODLIST ? (
-
- Moderation list by {sanitizeHandle(creator.handle, '@')}
-
- ) : (
- List by {sanitizeHandle(creator.handle, '@')}
- )}
+ {purpose === MODLIST
+ ? _(msg`Moderation list by ${sanitizeHandle(creator.handle, '@')}`)
+ : _(msg`List by ${sanitizeHandle(creator.handle, '@')}`)}
)}
diff --git a/src/components/Pills.tsx b/src/components/Pills.tsx
index 6c8084743f..974d83593f 100644
--- a/src/components/Pills.tsx
+++ b/src/components/Pills.tsx
@@ -130,6 +130,7 @@ export function Label({
)}
{name}
{handle}
diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx
index 2def0fa4b4..e323d15042 100644
--- a/src/components/ReportDialog/SubmitView.tsx
+++ b/src/components/ReportDialog/SubmitView.tsx
@@ -256,6 +256,7 @@ function LabelerToggle({title}: {title: string}) {
a.z_10,
]}>
,
)
} else {
- els.push(segment.text)
+ els.push(
+
+ {segment.text}
+ ,
+ )
}
key++
}
@@ -213,6 +219,7 @@ function RichTextTag({
{!noIcon ? : null}
-
+
{record.name}
-
-
- Starter pack by{' '}
- {creator?.did === currentAccount?.did
- ? _(msg`you`)
- : `@${sanitizeHandle(creator.handle)}`}
-
+
+ {creator?.did === currentAccount?.did
+ ? _(msg`Starter pack by you`)
+ : _(msg`Starter pack by ${sanitizeHandle(creator.handle, '@')}`)}
{!noDescription && record.description ? (
-
+
{record.description}
) : null}
diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx
index ad02cdc306..44f01a1545 100644
--- a/src/components/StarterPack/Wizard/WizardListCard.tsx
+++ b/src/components/StarterPack/Wizard/WizardListCard.tsx
@@ -12,11 +12,11 @@ import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from 'lib/constants'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {useSession} from 'state/session'
-import {UserAvatar} from 'view/com/util/UserAvatar'
+import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {useSession} from '#/state/session'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -78,6 +78,7 @@ function WizardListCard({
/>
& {
/**
* Lets the user select text, to use the native copy and paste functionality.
*/
selectable?: boolean
+ /**
+ * Provides `data-*` attributes to the underlying `UITextView` component on
+ * web only.
+ */
+ dataSet?: Record
+ /**
+ * Appears as a small tooltip on web hover.
+ */
+ title?: string
+} & (
+ | {
+ emoji: true
+ children: StringChild
+ }
+ | {
+ emoji?: false
+ children: RNTextProps['children']
+ }
+ )
+
+const EMOJI = createEmojiRegex()
+
+export function childHasEmoji(children: React.ReactNode) {
+ return (Array.isArray(children) ? children : [children]).some(
+ child => typeof child === 'string' && createEmojiRegex().test(child),
+ )
+}
+
+export function childIsString(
+ children: React.ReactNode,
+): children is StringChild {
+ return (
+ typeof children === 'string' ||
+ (Array.isArray(children) &&
+ children.every(child => typeof child === 'string' || child === null))
+ )
+}
+
+export function renderChildrenWithEmoji(children: StringChild) {
+ const normalized = Array.isArray(children) ? children : [children]
+
+ return (
+
+ {normalized.map(child => {
+ if (typeof child !== 'string') return child
+
+ const emojis = child.match(EMOJI)
+
+ if (emojis === null) {
+ return child
+ }
+
+ return child.split(EMOJI).map((stringPart, index) => (
+
+ {stringPart}
+ {emojis[index] ? (
+
+ {emojis[index]}
+
+ ) : null}
+
+ ))
+ })}
+
+ )
}
/**
@@ -64,7 +134,15 @@ export function normalizeTextStyles(
/**
* Our main text component. Use this most of the time.
*/
-export function Text({style, selectable, ...rest}: TextProps) {
+export function Text({
+ children,
+ emoji,
+ style,
+ selectable,
+ title,
+ dataSet,
+ ...rest
+}: TextProps) {
const {fonts, flags} = useAlf()
const t = useTheme()
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)], {
@@ -73,7 +151,29 @@ export function Text({style, selectable, ...rest}: TextProps) {
flags,
})
- return
+ if (IS_DEV) {
+ if (!emoji && childHasEmoji(children)) {
+ logger.warn(
+ `Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add '`,
+ )
+ }
+
+ if (emoji && !childIsString(children)) {
+ logger.error('Text: when , children can only be strings.')
+ }
+ }
+
+ return (
+
+ {isIOS && emoji ? renderChildrenWithEmoji(children) : children}
+
+ )
}
export function createHeadingElement({level}: {level: number}) {
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index 1a6bbbe601..ab9ec16e4d 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -10,14 +10,14 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
-import {BACK_HITSLOP} from 'lib/constants'
-import {makeProfileLink} from 'lib/routes/links'
-import {NavigationProp} from 'lib/routes/types'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {isWeb} from 'platform/detection'
-import {useProfileShadow} from 'state/cache/profile-shadow'
-import {isConvoActive, useConvo} from 'state/messages/convo'
-import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
+import {BACK_HITSLOP} from '#/lib/constants'
+import {makeProfileLink} from '#/lib/routes/links'
+import {NavigationProp} from '#/lib/routes/types'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {isWeb} from '#/platform/detection'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {isConvoActive, useConvo} from '#/state/messages/convo'
+import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
@@ -170,6 +170,7 @@ function HeaderReady({
- {strings.name}
-
+
+ {strings.name}
+
+
{strings.description}
diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx
index d95717cf43..2259178538 100644
--- a/src/components/moderation/ModerationDetailsDialog.tsx
+++ b/src/components/moderation/ModerationDetailsDialog.tsx
@@ -118,7 +118,11 @@ function ModerationDetailsDialogInner({
: _(msg`The author of this thread has hidden this reply.`)
} else if (modcause.type === 'label') {
name = desc.name
- description = desc.description
+ description = (
+
+ {desc.description}
+
+ )
} else {
// should never happen
name = ''
@@ -127,7 +131,7 @@ function ModerationDetailsDialogInner({
return (
-
+
{name}
diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx
index c45cc28d7a..e9668b4e11 100644
--- a/src/screens/Messages/List/ChatListItem.tsx
+++ b/src/screens/Messages/List/ChatListItem.tsx
@@ -10,6 +10,10 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {useHaptics} from '#/lib/haptics'
+import {decrementBadgeCount} from '#/lib/notifications/notifications'
+import {logEvent} from '#/lib/statsig/statsig'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {
postUriToRelativePath,
toBskyAppUrl,
@@ -19,10 +23,6 @@ import {isNative} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
-import {useHaptics} from 'lib/haptics'
-import {decrementBadgeCount} from 'lib/notifications/notifications'
-import {logEvent} from 'lib/statsig/statsig'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
@@ -248,6 +248,7 @@ function ChatListItemReady({
numberOfLines={1}
style={[{maxWidth: '85%'}, web([a.leading_normal])]}>
{sanitizeDisplayName(
diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx
index 0344f1a234..ba869b6626 100644
--- a/src/screens/Profile/Header/Handle.tsx
+++ b/src/screens/Profile/Header/Handle.tsx
@@ -1,11 +1,12 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
-import {Trans} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {isInvalidHandle} from '#/lib/strings/handles'
+import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
-import {isInvalidHandle} from 'lib/strings/handles'
-import {isIOS} from 'platform/detection'
import {atoms as a, useTheme, web} from '#/alf'
import {NewskieDialog} from '#/components/NewskieDialog'
import {Text} from '#/components/Typography'
@@ -18,6 +19,7 @@ export function ProfileHeaderHandle({
disableTaps?: boolean
}) {
const t = useTheme()
+ const {_} = useLingui()
const invalidHandle = isInvalidHandle(profile.handle)
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
return (
@@ -33,6 +35,7 @@ export function ProfileHeaderHandle({
) : undefined}
- {invalidHandle ? ⚠Invalid Handle : `@${profile.handle}`}
+ {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
)
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 778439259e..95c57ad899 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -19,19 +19,19 @@ import PasteInput, {
PasteInputRef,
} from '@mattermost/react-native-paste-input'
+import {POST_IMG_MAX} from '#/lib/constants'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {downloadAndResize} from '#/lib/media/manip'
+import {isUriImage} from '#/lib/media/util'
+import {cleanError} from '#/lib/strings/errors'
+import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
+import {useTheme} from '#/lib/ThemeContext'
import {isAndroid} from '#/platform/detection'
-import {POST_IMG_MAX} from 'lib/constants'
-import {usePalette} from 'lib/hooks/usePalette'
-import {downloadAndResize} from 'lib/media/manip'
-import {isUriImage} from 'lib/media/util'
-import {cleanError} from 'lib/strings/errors'
-import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
-import {useTheme} from 'lib/ThemeContext'
import {
LinkFacetMatch,
suggestLinkCardUri,
-} from 'view/com/composer/text-input/text-input-util'
-import {Text} from 'view/com/util/text/Text'
+} from '#/view/com/composer/text-input/text-input-util'
+import {Text} from '#/view/com/util/text/Text'
import {atoms as a, useAlf} from '#/alf'
import {normalizeTextStyles} from '#/components/Typography'
import {Autocomplete} from './mobile/Autocomplete'
@@ -216,6 +216,7 @@ export const TextInput = forwardRef(function TextInputImpl(
return Array.from(richtext.segments()).map(segment => {
return (
{segment.text}
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index 29b8f0bc65..a43e67c044 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -5,19 +5,20 @@ import React, {
useState,
} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
+import {Trans} from '@lingui/macro'
import {ReactRenderer} from '@tiptap/react'
-import tippy, {Instance as TippyInstance} from 'tippy.js'
import {
+ SuggestionKeyDownProps,
SuggestionOptions,
SuggestionProps,
- SuggestionKeyDownProps,
} from '@tiptap/suggestion'
+import tippy, {Instance as TippyInstance} from 'tippy.js'
+
+import {usePalette} from '#/lib/hooks/usePalette'
import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Text} from 'view/com/util/text/Text'
-import {UserAvatar} from 'view/com/util/UserAvatar'
+import {Text} from '#/view/com/util/text/Text'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
import {useGrapheme} from '../hooks/useGrapheme'
-import {Trans} from '@lingui/macro'
interface MentionListRef {
onKeyDown: (props: SuggestionKeyDownProps) => boolean
@@ -180,7 +181,7 @@ const MentionList = forwardRef(
size={26}
type={item.associated?.labeler ? 'labeler' : 'user'}
/>
-
+
{displayName}
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 68437c37a0..3276cf8821 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -12,6 +12,10 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {s} from '#/lib/styles'
import {logger} from '#/logger'
import {shouldClickOpenNewTab} from '#/platform/urls'
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
@@ -21,12 +25,8 @@ import {
UsePreferencesQueryResponse,
useRemoveFeedMutation,
} from '#/state/queries/preferences'
-import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
-import {usePalette} from 'lib/hooks/usePalette'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {s} from 'lib/styles'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
-import * as Toast from 'view/com/util/Toast'
+import * as Toast from '#/view/com/util/Toast'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import * as Prompt from '#/components/Prompt'
@@ -242,7 +242,7 @@ export function FeedSourceCardLoaded({
-
+
{feed.displayName}
diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx
index 29caf46609..b0b76644f0 100644
--- a/src/view/com/modals/UserAddRemoveLists.tsx
+++ b/src/view/com/modals/UserAddRemoveLists.tsx
@@ -65,21 +65,27 @@ export function Component({
return [pal.border, {flex: 1, borderTopWidth: StyleSheet.hairlineWidth}]
}, [pal.border, screenHeight])
+ const headerStyles = [
+ {
+ textAlign: 'center',
+ fontWeight: '600',
+ fontSize: 20,
+ marginBottom: 12,
+ paddingHorizontal: 12,
+ } as const,
+ pal.text,
+ ]
+
return (
-
- Update {displayName} in Lists
+
+
+ Update{' '}
+
+ {displayName}
+ {' '}
+ in Lists
+
+ {forceLTR(firstAuthorName)}
+
+ }
disableMismatchWarning
/>
{authors.length > 1 ? (
@@ -570,12 +574,13 @@ function ExpandedAuthorsList({
numberOfLines={1}
style={pal.text}
lineHeight={1.2}>
- {sanitizeDisplayName(
- author.profile.displayName || author.profile.handle,
- )}
-
+
+ {sanitizeDisplayName(
+ author.profile.displayName || author.profile.handle,
+ )}
+ {' '}
- {sanitizeHandle(author.profile.handle)}
+ {sanitizeHandle(author.profile.handle, '@')}
@@ -592,7 +597,11 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
return (
<>
- {text?.length > 0 && {text}}
+ {text?.length > 0 && (
+
+ {text}
+
+ )}
onPressItem(i)}>
-
+
{sanitizeHandle(post.author.handle, '@')}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 7537a46448..b1509b2719 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -316,11 +316,19 @@ let FeedItemInner = ({
style={pal.textLight}
lineHeight={1.2}
numberOfLines={1}
- text={sanitizeDisplayName(
- reason.by.displayName ||
- sanitizeHandle(reason.by.handle),
- moderation.ui('displayName'),
- )}
+ text={
+
+ {sanitizeDisplayName(
+ reason.by.displayName ||
+ sanitizeHandle(reason.by.handle),
+ moderation.ui('displayName'),
+ )}
+
+ }
href={makeProfileLink(reason.by)}
onBeforePress={onOpenReposter}
/>
@@ -527,9 +535,11 @@ function ReplyToLabel({
numberOfLines={1}
href={makeProfileLink(profile)}
text={
- profile.displayName
- ? sanitizeDisplayName(profile.displayName)
- : sanitizeHandle(profile.handle)
+
+ {profile.displayName
+ ? sanitizeDisplayName(profile.displayName)
+ : sanitizeHandle(profile.handle)}
+
}
/>
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index fd32e37a42..eab8611dd4 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -7,17 +7,17 @@ import {
} from '@atproto/api'
import {useQueryClient} from '@tanstack/react-query'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {getModerationCauseKey, isJustAMute} from '#/lib/moderation'
+import {makeProfileLink} from '#/lib/routes/links'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {s} from '#/lib/styles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {Shadow} from '#/state/cache/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {precacheProfile} from '#/state/queries/profile'
import {useSession} from '#/state/session'
-import {usePalette} from 'lib/hooks/usePalette'
-import {getModerationCauseKey, isJustAMute} from 'lib/moderation'
-import {makeProfileLink} from 'lib/routes/links'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {s} from 'lib/styles'
-import {precacheProfile} from 'state/queries/profile'
import {atoms as a} from '#/alf'
import {
KnownFollowers,
@@ -103,6 +103,7 @@ export function ProfileCard({
-
+
{sanitizeHandle(profile.handle, '@')}
{profile.description ? (
-
+
{profile.description as string}
) : null}
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index 3bd350bf32..f2d717e962 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -4,16 +4,16 @@ import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {makeProfileLink} from '#/lib/routes/links'
+import {forceLTR} from '#/lib/strings/bidi'
+import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {niceDate} from '#/lib/strings/time'
+import {TypographyVariant} from '#/lib/ThemeContext'
+import {isAndroid} from '#/platform/detection'
import {precacheProfile} from '#/state/queries/profile'
-import {usePalette} from 'lib/hooks/usePalette'
-import {makeProfileLink} from 'lib/routes/links'
-import {forceLTR} from 'lib/strings/bidi'
-import {NON_BREAKING_SPACE} from 'lib/strings/constants'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {niceDate} from 'lib/strings/time'
-import {TypographyVariant} from 'lib/ThemeContext'
-import {isAndroid} from 'platform/detection'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {TextLinkOnWebOnly} from './Link'
import {Text} from './text/Text'
@@ -73,12 +73,20 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
style={[pal.text]}
lineHeight={1.2}
disableMismatchWarning
- text={forceLTR(
- sanitizeDisplayName(
- displayName,
- opts.moderation?.ui('displayName'),
- ),
- )}
+ text={
+
+ {forceLTR(
+ sanitizeDisplayName(
+ displayName,
+ opts.moderation?.ui('displayName'),
+ ),
+ )}
+
+ }
href={profileLink}
onBeforePress={onBeforePressAuthor}
/>
@@ -86,7 +94,11 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
type="md"
disableMismatchWarning
style={[pal.textLight, {flexShrink: 4}]}
- text={NON_BREAKING_SPACE + sanitizeHandle(handle, '@')}
+ text={
+
+ {NON_BREAKING_SPACE + sanitizeHandle(handle, '@')}
+
+ }
href={profileLink}
onBeforePress={onBeforePressAuthor}
anchorNoUnderline
diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx
index 9cb9997f60..8a444d5901 100644
--- a/src/view/com/util/UserInfoText.tsx
+++ b/src/view/com/util/UserInfoText.tsx
@@ -1,15 +1,16 @@
import React from 'react'
-import {AppBskyActorGetProfile as GetProfile} from '@atproto/api'
import {StyleProp, StyleSheet, TextStyle} from 'react-native'
-import {TextLinkOnWebOnly} from './Link'
-import {Text} from './text/Text'
-import {LoadingPlaceholder} from './LoadingPlaceholder'
-import {TypographyVariant} from 'lib/ThemeContext'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {makeProfileLink} from 'lib/routes/links'
-import {useProfileQuery} from '#/state/queries/profile'
+import {AppBskyActorGetProfile as GetProfile} from '@atproto/api'
+
+import {makeProfileLink} from '#/lib/routes/links'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {TypographyVariant} from '#/lib/ThemeContext'
import {STALE} from '#/state/queries'
+import {useProfileQuery} from '#/state/queries/profile'
+import {TextLinkOnWebOnly} from './Link'
+import {LoadingPlaceholder} from './LoadingPlaceholder'
+import {Text} from './text/Text'
export function UserInfoText({
type = 'md',
@@ -50,11 +51,15 @@ export function UserInfoText({
lineHeight={1.2}
numberOfLines={1}
href={makeProfileLink(profile)}
- text={`${prefix || ''}${sanitizeDisplayName(
- typeof profile[attr] === 'string' && profile[attr]
- ? (profile[attr] as string)
- : sanitizeHandle(profile.handle),
- )}`}
+ text={
+
+ {`${prefix || ''}${sanitizeDisplayName(
+ typeof profile[attr] === 'string' && profile[attr]
+ ? (profile[attr] as string)
+ : sanitizeHandle(profile.handle),
+ )}`}
+
+ }
/>
)
} else {
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index 54e1eb4d55..98332c33b0 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -5,21 +5,21 @@ import {AppBskyEmbedExternal} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {shareUrl} from 'lib/sharing'
-import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {shareUrl} from '#/lib/sharing'
+import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
import {
getStarterPackOgCard,
parseStarterPackUri,
-} from 'lib/strings/starter-pack'
-import {toNiceDomain} from 'lib/strings/url-helpers'
-import {isNative} from 'platform/detection'
-import {useExternalEmbedsPrefs} from 'state/preferences'
-import {Link} from 'view/com/util/Link'
-import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
-import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
-import {GifEmbed} from 'view/com/util/post-embeds/GifEmbed'
+} from '#/lib/strings/starter-pack'
+import {toNiceDomain} from '#/lib/strings/url-helpers'
+import {isNative} from '#/platform/detection'
+import {useExternalEmbedsPrefs} from '#/state/preferences'
+import {Link} from '#/view/com/util/Link'
+import {ExternalGifEmbed} from '#/view/com/util/post-embeds/ExternalGifEmbed'
+import {ExternalPlayer} from '#/view/com/util/post-embeds/ExternalPlayerEmbed'
+import {GifEmbed} from '#/view/com/util/post-embeds/GifEmbed'
import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '../text/Text'
@@ -115,12 +115,13 @@ export const ExternalLinkEmbed = ({
{!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
-
+
{link.title || link.uri}
)}
{link.description ? (
diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx
index 52a45b0e2e..3d885480cc 100644
--- a/src/view/com/util/text/Text.tsx
+++ b/src/view/com/util/text/Text.tsx
@@ -2,27 +2,40 @@ import React from 'react'
import {StyleSheet, Text as RNText, TextProps} from 'react-native'
import {UITextView} from 'react-native-uitextview'
-import {lh, s} from 'lib/styles'
-import {TypographyVariant, useTheme} from 'lib/ThemeContext'
-import {isIOS, isWeb} from 'platform/detection'
+import {lh, s} from '#/lib/styles'
+import {TypographyVariant, useTheme} from '#/lib/ThemeContext'
+import {logger} from '#/logger'
+import {isIOS} from '#/platform/detection'
import {applyFonts, useAlf} from '#/alf'
+import {
+ childHasEmoji,
+ childIsString,
+ renderChildrenWithEmoji,
+ StringChild,
+} from '#/components/Typography'
+import {IS_DEV} from '#/env'
-export type CustomTextProps = TextProps & {
+export type CustomTextProps = Omit & {
type?: TypographyVariant
lineHeight?: number
title?: string
dataSet?: Record
selectable?: boolean
-}
-
-const fontFamilyStyle = {
- fontFamily:
- '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif',
-}
+} & (
+ | {
+ emoji: true
+ children: StringChild
+ }
+ | {
+ emoji?: false
+ children: TextProps['children']
+ }
+ )
export function Text({
type = 'md',
children,
+ emoji,
lineHeight,
style,
title,
@@ -35,6 +48,18 @@ export function Text({
const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined
const {fonts} = useAlf()
+ if (IS_DEV) {
+ if (!emoji && childHasEmoji(children)) {
+ logger.warn(
+ `Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add '`,
+ )
+ }
+
+ if (emoji && !childIsString(children)) {
+ logger.error('Text: when , children can only be strings.')
+ }
+ }
+
if (selectable && isIOS) {
const flattened = StyleSheet.flatten([
s.black,
@@ -58,7 +83,7 @@ export function Text({
selectable={selectable}
uiTextView
{...props}>
- {children}
+ {isIOS && emoji ? renderChildrenWithEmoji(children) : children}
)
}
@@ -66,7 +91,6 @@ export function Text({
const flattened = StyleSheet.flatten([
s.black,
typography,
- isWeb && fontFamilyStyle,
lineHeightStyle,
style,
])
@@ -87,7 +111,7 @@ export function Text({
dataSet={Object.assign({tooltip: title}, dataSet || {})}
selectable={selectable}
{...props}>
- {children}
+ {isIOS && emoji ? renderChildrenWithEmoji(children) : children}
)
}
diff --git a/src/view/com/util/text/ThemedText.tsx b/src/view/com/util/text/ThemedText.tsx
deleted file mode 100644
index 2844d273c2..0000000000
--- a/src/view/com/util/text/ThemedText.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import React from 'react'
-import {CustomTextProps, Text} from './Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {addStyle} from 'lib/styles'
-
-export type ThemedTextProps = CustomTextProps & {
- fg?: 'default' | 'light' | 'error' | 'inverted' | 'inverted-light'
- bg?: 'default' | 'light' | 'error' | 'inverted' | 'inverted-light'
- border?: 'default' | 'dark' | 'error' | 'inverted' | 'inverted-dark'
- lineHeight?: number
-}
-
-export function ThemedText({
- fg,
- bg,
- border,
- style,
- children,
- ...props
-}: React.PropsWithChildren) {
- const pal = usePalette('default')
- const palInverted = usePalette('inverted')
- const palError = usePalette('error')
- switch (fg) {
- case 'default':
- style = addStyle(style, pal.text)
- break
- case 'light':
- style = addStyle(style, pal.textLight)
- break
- case 'error':
- style = addStyle(style, {color: palError.colors.background})
- break
- case 'inverted':
- style = addStyle(style, palInverted.text)
- break
- case 'inverted-light':
- style = addStyle(style, palInverted.textLight)
- break
- }
- switch (bg) {
- case 'default':
- style = addStyle(style, pal.view)
- break
- case 'light':
- style = addStyle(style, pal.viewLight)
- break
- case 'error':
- style = addStyle(style, palError.view)
- break
- case 'inverted':
- style = addStyle(style, palInverted.view)
- break
- case 'inverted-light':
- style = addStyle(style, palInverted.viewLight)
- break
- }
- switch (border) {
- case 'default':
- style = addStyle(style, pal.border)
- break
- case 'dark':
- style = addStyle(style, pal.borderDark)
- break
- case 'error':
- style = addStyle(style, palError.border)
- break
- case 'inverted':
- style = addStyle(style, palInverted.border)
- break
- case 'inverted-dark':
- style = addStyle(style, palInverted.borderDark)
- break
- }
- return (
-
- {children}
-
- )
-}
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index e1e412648b..07d762c0fe 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -959,6 +959,7 @@ function SearchHistory({
accessibilityIgnoresInvertColors
/>
{profile.displayName || profile.handle}
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 1ba2d3f3db..b43dbcce32 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -16,19 +16,19 @@ import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
+import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
+import {NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {s} from '#/lib/styles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
-import {usePalette} from 'lib/hooks/usePalette'
-import {NavigationProp} from 'lib/routes/types'
-import {precacheProfile} from 'state/queries/profile'
+import {precacheProfile} from '#/state/queries/profile'
+import {SearchInput} from '#/view/com/util/forms/SearchInput'
import {Link} from '#/view/com/util/Link'
+import {Text} from '#/view/com/util/text/Text'
import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {SearchInput} from 'view/com/util/forms/SearchInput'
-import {Text} from 'view/com/util/text/Text'
import {atoms as a} from '#/alf'
let SearchLinkCard = ({
@@ -126,6 +126,7 @@ let SearchProfileCard = ({
/>
Date: Mon, 23 Sep 2024 20:30:34 +0100
Subject: [PATCH 06/26] [Video] Flush low quality segments once focused (#5430)
* Update VideoEmbedInnerWeb.tsx
* keep proper track and flush properly
* consistent current
* use current in listener
* manually loop
---
.../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 77 +++++++++++++++++--
1 file changed, 69 insertions(+), 8 deletions(-)
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index fa577fb509..82b2503eb1 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -1,7 +1,7 @@
import React, {useEffect, useId, useRef, useState} from 'react'
import {View} from 'react-native'
import {AppBskyEmbedVideo} from '@atproto/api'
-import Hls from 'hls.js'
+import Hls, {Events, FragChangedData, Fragment} from 'hls.js'
import {atoms as a} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
@@ -19,7 +19,7 @@ export function VideoEmbedInnerWeb({
onScreen: boolean
}) {
const containerRef = useRef(null)
- const ref = useRef(null)
+ const videoRef = useRef(null)
const [focused, setFocused] = useState(false)
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
const figId = useId()
@@ -31,13 +31,13 @@ export function VideoEmbedInnerWeb({
}
const hlsRef = useRef(undefined)
+ const [lowQualityFragments, setLowQualityFragments] = useState([])
useEffect(() => {
- if (!ref.current) return
+ if (!videoRef.current) return
if (!Hls.isSupported()) throw new HLSUnsupportedError()
const hls = new Hls({
- capLevelToPlayerSize: true,
maxMaxBufferLength: 10, // only load 10s ahead
// note: the amount buffered is affected by both maxBufferLength and maxBufferSize
// it will buffer until it it's greater than *both* of those values
@@ -45,18 +45,36 @@ export function VideoEmbedInnerWeb({
})
hlsRef.current = hls
- hls.attachMedia(ref.current)
+ hls.attachMedia(videoRef.current)
hls.loadSource(embed.playlist)
// initial value, later on it's managed by Controls
hls.autoLevelCapping = 0
+ // manually loop, so if we've flushed the first buffer it doesn't get confused
+ const abortController = new AbortController()
+ const {signal} = abortController
+ videoRef.current.addEventListener(
+ 'ended',
+ function () {
+ this.currentTime = 0
+ this.play()
+ },
+ {signal},
+ )
+
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
if (data.subtitleTracks.length > 0) {
setHasSubtitleTrack(true)
}
})
+ hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => {
+ if (frag.level === 0) {
+ setLowQualityFragments(prev => [...prev, frag])
+ }
+ })
+
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
if (
@@ -67,6 +85,8 @@ export function VideoEmbedInnerWeb({
} else {
setError(data.error)
}
+ } else {
+ console.error(data.error)
}
})
@@ -74,20 +94,61 @@ export function VideoEmbedInnerWeb({
hlsRef.current = undefined
hls.detachMedia()
hls.destroy()
+ abortController.abort()
}
}, [embed.playlist])
+ // purge low quality segments from buffer on next frag change
+ useEffect(() => {
+ if (!hlsRef.current) return
+
+ const current = hlsRef.current
+
+ if (focused) {
+ function fragChanged(
+ _event: Events.FRAG_CHANGED,
+ {frag}: FragChangedData,
+ ) {
+ // if the current quality level goes above 0, flush the low quality segments
+ if (current.nextAutoLevel > 0) {
+ const flushed: Fragment[] = []
+
+ for (const lowQualFrag of lowQualityFragments) {
+ // avoid if close to the current fragment
+ if (Math.abs(frag.start - lowQualFrag.start) < 0.1) {
+ return
+ }
+
+ current.trigger(Hls.Events.BUFFER_FLUSHING, {
+ startOffset: lowQualFrag.start,
+ endOffset: lowQualFrag.end,
+ type: 'video',
+ })
+
+ flushed.push(lowQualFrag)
+ }
+
+ setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f)))
+ }
+ }
+ current.on(Hls.Events.FRAG_CHANGED, fragChanged)
+
+ return () => {
+ current.off(Hls.Events.FRAG_CHANGED, fragChanged)
+ }
+ }
+ }, [focused, lowQualityFragments])
+
return (
@@ -110,7 +171,7 @@ export function VideoEmbedInnerWeb({
)}
Date: Mon, 23 Sep 2024 21:05:23 +0100
Subject: [PATCH 07/26] add sideborders to (#4995)
---
src/view/screens/Profile.tsx | 33 +++++++++++++++++----------------
1 file changed, 17 insertions(+), 16 deletions(-)
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 5ef6459810..879632e9ef 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -16,9 +16,18 @@ import {
useQueryClient,
} from '@tanstack/react-query'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {useSetTitle} from '#/lib/hooks/useSetTitle'
+import {ComposeIcon2} from '#/lib/icons'
+import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {combinedDisplayName} from '#/lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors'
+import {isInvalidHandle} from '#/lib/strings/handles'
+import {colors, s} from '#/lib/styles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {listenSoftReset} from '#/state/events'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
import {useLabelerInfoQuery} from '#/state/queries/labeler'
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {useProfileQuery} from '#/state/queries/profile'
@@ -26,29 +35,21 @@ import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useAgent, useSession} from '#/state/session'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {useSetTitle} from 'lib/hooks/useSetTitle'
-import {ComposeIcon2} from 'lib/icons'
-import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
-import {combinedDisplayName} from 'lib/strings/display-names'
-import {isInvalidHandle} from 'lib/strings/handles'
-import {colors, s} from 'lib/styles'
-import {listenSoftReset} from 'state/events'
-import {useActorStarterPacksQuery} from 'state/queries/actor-starter-packs'
+import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens'
+import {ProfileLists} from '#/view/com/lists/ProfileLists'
+import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
+import {FAB} from '#/view/com/util/fab/FAB'
+import {ListRef} from '#/view/com/util/List'
+import {CenteredView} from '#/view/com/util/Views'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
+import {web} from '#/alf'
import {ScreenHider} from '#/components/moderation/ScreenHider'
import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks'
import {navigate} from '#/Navigation'
import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder'
-import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
-import {ProfileLists} from '../com/lists/ProfileLists'
-import {ErrorScreen} from '../com/util/error/ErrorScreen'
-import {FAB} from '../com/util/fab/FAB'
-import {ListRef} from '../com/util/List'
-import {CenteredView} from '../com/util/Views'
interface SectionRef {
scrollToTop: () => void
@@ -107,7 +108,7 @@ export function ProfileScreen({route}: Props) {
// Most pushes will happen here, since we will have only placeholder data
if (isLoadingDid || isLoadingProfile || starterPacksQuery.isLoading) {
return (
-
+
)
From b77031a074161f8c6cd7e666db324eea81a38af1 Mon Sep 17 00:00:00 2001
From: Hailey
Date: Mon, 23 Sep 2024 13:06:22 -0700
Subject: [PATCH 08/26] invert the fab animation, play a haptic (#4309)
---
src/lib/haptics.ts | 24 +++++++++++---------
src/view/com/util/fab/FABInner.tsx | 36 +++++++++++++++++++++++++-----
2 files changed, 44 insertions(+), 16 deletions(-)
diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts
index 02940f793d..390b76a0e7 100644
--- a/src/lib/haptics.ts
+++ b/src/lib/haptics.ts
@@ -4,17 +4,21 @@ import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
import {isIOS, isWeb} from 'platform/detection'
import {useHapticsDisabled} from 'state/preferences/disable-haptics'
-const hapticImpact: ImpactFeedbackStyle = isIOS
- ? ImpactFeedbackStyle.Medium
- : ImpactFeedbackStyle.Light // Users said the medium impact was too strong on Android; see APP-537s
-
export function useHaptics() {
const isHapticsDisabled = useHapticsDisabled()
- return React.useCallback(() => {
- if (isHapticsDisabled || isWeb) {
- return
- }
- impactAsync(hapticImpact)
- }, [isHapticsDisabled])
+ return React.useCallback(
+ (strength: 'Light' | 'Medium' | 'Heavy' = 'Medium') => {
+ if (isHapticsDisabled || isWeb) {
+ return
+ }
+
+ // Users said the medium impact was too strong on Android; see APP-537s
+ const style = isIOS
+ ? ImpactFeedbackStyle[strength]
+ : ImpactFeedbackStyle.Light
+ impactAsync(style)
+ },
+ [isHapticsDisabled],
+ )
}
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index ee8e1f47a2..d1675b428c 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -1,6 +1,10 @@
import React, {ComponentProps} from 'react'
import {StyleSheet, TouchableWithoutFeedback} from 'react-native'
-import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ withTiming,
+} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {LinearGradient} from 'expo-linear-gradient'
@@ -9,6 +13,8 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {clamp} from '#/lib/numbers'
import {gradients} from '#/lib/styles'
import {isWeb} from '#/platform/detection'
+import {useHaptics} from 'lib/haptics'
+import {useHapticsDisabled} from 'state/preferences'
import {useInteractionState} from '#/components/hooks/useInteractionState'
export interface FABProps
@@ -17,15 +23,17 @@ export interface FABProps
icon: JSX.Element
}
-export function FABInner({testID, icon, ...props}: FABProps) {
+export function FABInner({testID, icon, onPress, ...props}: FABProps) {
const insets = useSafeAreaInsets()
const {isMobile, isTablet} = useWebMediaQueries()
const fabMinimalShellTransform = useMinimalShellFabTransform()
const {
- state: pressed,
+ state: isPressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
+ const playHaptic = useHaptics()
+ const isHapticsDisabled = useHapticsDisabled()
const size = isTablet ? styles.sizeLarge : styles.sizeRegular
@@ -33,13 +41,29 @@ export function FABInner({testID, icon, ...props}: FABProps) {
? {right: 50, bottom: 50}
: {right: 24, bottom: clamp(insets.bottom, 15, 60) + 15}
- const scale = useAnimatedStyle(() => ({
- transform: [{scale: withTiming(pressed ? 0.95 : 1)}],
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ {
+ scale: withTiming(isPressed ? 1.1 : 1, {
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ }),
+ },
+ ],
}))
return (
{
+ playHaptic()
+ setTimeout(
+ () => {
+ onPress?.(e)
+ },
+ isHapticsDisabled ? 0 : 75,
+ )
+ }}
onPressIn={onPressIn}
onPressOut={onPressOut}
{...props}>
@@ -50,7 +74,7 @@ export function FABInner({testID, icon, ...props}: FABProps) {
tabletSpacing,
isMobile && fabMinimalShellTransform,
]}>
-
+
Date: Mon, 23 Sep 2024 15:21:32 -0500
Subject: [PATCH 09/26] Fix web splash (#5456)
* Fix web splash
* Untangle base styles
* Fix id name, remove log
---
bskyweb/templates/base.html | 49 ++++++++++++++-
src/style.css | 122 ++++++++++++------------------------
web/index.html | 49 ++++++++++++++-
3 files changed, 135 insertions(+), 85 deletions(-)
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index eaa31aa4a3..03686ef5c4 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -32,6 +32,53 @@
-->
+
+
{% include "scripts.html" %}
@@ -48,7 +95,7 @@
{%- block body_all %}
-
+
diff --git a/src/style.css b/src/style.css
index ebb0471584..980d92ef77 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,3 +1,11 @@
+/**
+ * IMPORTANT
+ *
+ * Some of these styles are duplicated in the `web/index.html` and
+ * `bskyweb/templates/base.html` files. Depending on what you're updating, you
+ * may need to touch all three. Ask Eric if you aren't sure.
+ */
+
@font-face {
font-family: 'Inter-Regular';
src: local('Inter-Regular'),
@@ -96,46 +104,43 @@
*/
/**
- * Extend the react-native-web reset:
- * https://github.com/necolas/react-native-web/blob/master/packages/react-native-web/src/exports/StyleSheet/initialRules.js
+ * BEGIN STYLES
+ *
+ * HTML & BODY STYLES IN `web/index.html` and `bskyweb/templates/base.html`
*/
-html,
-body,
-#root {
- width: 100%;
- /* To smooth any scrolling behavior */
- -webkit-overflow-scrolling: touch;
- margin: 0px;
- padding: 0px;
- /* Allows content to fill the viewport and go beyond the bottom */
- min-height: 100%;
+:root {
+ --text: black;
+ --background: white;
+ --backgroundLight: hsl(211, 20%, 95%);
}
-#root {
- flex-shrink: 0;
- flex-basis: auto;
- flex-grow: 1;
- display: flex;
- flex: 1;
+@media (prefers-color-scheme: dark) {
+ :root {
+ color-scheme: dark;
+ --text: white;
+ --background: black;
+ --backgroundLight: hsl(211, 20%, 20%);
+ }
}
-html {
- /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */
- -webkit-text-size-adjust: 100%;
- height: calc(100% + env(safe-area-inset-top));
- scrollbar-gutter: stable both-edges;
+html.theme--light {
+ --text: black;
+ --background: white;
+ --backgroundLight: hsl(211, 20%, 95%);
+ background-color: white;
}
-html,
-body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
- 'Liberation Sans', Helvetica, Arial, sans-serif;
+html.theme--dark {
+ color-scheme: dark;
+ background-color: black;
+ --text: white;
+ --background: black;
+ --backgroundLight: hsl(211, 20%, 20%);
}
-
-#preload {
- width: 100px;
- position: fixed;
- left: 50%;
- top: 50%;
- transform: translate(-50%, -50%);
+html.theme--dim {
+ color-scheme: dark;
+ background-color: hsl(211, 28%, 12%);
+ --text: white;
+ --background: hsl(211, 20%, 4%);
+ --backgroundLight: hsl(211, 20%, 10%);
}
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
@@ -146,42 +151,6 @@ textarea {
line-height: inherit;
}
-/* Color theming */
-/* Default will always be white */
-:root {
- --text: black;
- --background: white;
- --backgroundLight: hsl(211, 20%, 95%);
-}
-/* This gives us a black background when system is dark and we have not loaded the theme/color scheme values in JS */
-@media (prefers-color-scheme: dark) {
- :root {
- --text: white;
- --background: black;
- --backgroundLight: hsl(211, 20%, 20%);
- color-scheme: dark;
- }
-}
-
-/* Overwrite those preferences with the selected theme */
-html.theme--light {
- --text: black;
- --background: white;
- --backgroundLight: hsl(211, 20%, 95%);
-}
-html.theme--dark {
- --text: white;
- --background: black;
- --backgroundLight: hsl(211, 20%, 20%);
- color-scheme: dark;
-}
-html.theme--dim {
- --text: white;
- --background: hsl(211, 20%, 4%);
- --backgroundLight: hsl(211, 20%, 10%);
- color-scheme: dark;
-}
-
/* Remove autofill styles on Webkit */
input:autofill,
input:-webkit-autofill,
@@ -200,19 +169,6 @@ input::-webkit-date-and-time-value {
text-align: left;
}
-body {
- display: flex;
- /* Allows you to scroll below the viewport; default value is visible */
- overflow-y: auto;
- overscroll-behavior-y: none;
- text-rendering: optimizeLegibility;
- background-color: var(--background);
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- -ms-overflow-style: scrollbar;
- font-synthesis-weight: none;
-}
-
/* Remove default link styling */
a {
color: inherit;
diff --git a/web/index.html b/web/index.html
index 512178327f..71e5ac0892 100644
--- a/web/index.html
+++ b/web/index.html
@@ -36,6 +36,53 @@
-->
+
+
@@ -90,7 +137,7 @@
-
+
From e93cbbd56a70ab3fd44866009400c7b3df24286b Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Mon, 23 Sep 2024 16:34:59 -0500
Subject: [PATCH 10/26] Don't use flex on inputs (#5458)
---
src/components/dialogs/Embed.tsx | 22 ++++++++++++----------
src/components/forms/TextField.tsx | 4 ++--
src/view/screens/Storybook/Forms.tsx | 21 +++++++++++++++++++++
src/view/screens/Storybook/index.tsx | 10 +++++-----
4 files changed, 40 insertions(+), 17 deletions(-)
diff --git a/src/components/dialogs/Embed.tsx b/src/components/dialogs/Embed.tsx
index 73ecf6616b..ca75b01390 100644
--- a/src/components/dialogs/Embed.tsx
+++ b/src/components/dialogs/Embed.tsx
@@ -106,16 +106,18 @@ function EmbedDialogInner({
-
-
-
-
+
+
+
+
+
+
From 8ea89469ef1a7988a7b3d05716da55e9da680c35 Mon Sep 17 00:00:00 2001
From: Mary <148872143+mary-ext@users.noreply.github.com>
Date: Tue, 24 Sep 2024 23:14:15 +0700
Subject: [PATCH 16/26] MobX removal take 2 (#5381)
* mobx removal take 2
* Actually rm mobx
---------
Co-authored-by: Dan Abramov
---
package.json | 3 -
src/lib/api/index.ts | 53 +--
src/lib/media/picker.shared.ts | 2 +-
src/state/gallery.ts | 299 ++++++++++++++
src/state/modals/index.tsx | 13 +-
src/state/models/media/gallery.ts | 110 -----
src/state/models/media/image.e2e.ts | 146 -------
src/state/models/media/image.ts | 310 --------------
src/state/shell/composer/index.tsx | 7 +-
src/view/com/composer/Composer.tsx | 67 +--
src/view/com/composer/ExternalEmbed.tsx | 2 +-
src/view/com/composer/GifAltText.tsx | 2 +-
src/view/com/composer/photos/Gallery.tsx | 336 ++++++++--------
.../com/composer/photos/OpenCameraBtn.tsx | 13 +-
.../com/composer/photos/SelectPhotoBtn.tsx | 21 +-
src/view/com/composer/useExternalLinkFetch.ts | 7 +-
src/view/com/modals/AltImage.tsx | 29 +-
src/view/com/modals/EditImage.tsx | 380 ------------------
src/view/com/modals/Modal.tsx | 4 -
src/view/com/modals/Modal.web.tsx | 9 +-
src/view/shell/Composer.ios.tsx | 7 +-
src/view/shell/Composer.tsx | 15 +-
yarn.lock | 15 -
23 files changed, 594 insertions(+), 1256 deletions(-)
create mode 100644 src/state/gallery.ts
delete mode 100644 src/state/models/media/gallery.ts
delete mode 100644 src/state/models/media/image.e2e.ts
delete mode 100644 src/state/models/media/image.ts
delete mode 100644 src/view/com/modals/EditImage.tsx
diff --git a/package.json b/package.json
index d3a94eb8d3..117fc0b190 100644
--- a/package.json
+++ b/package.json
@@ -161,9 +161,6 @@
"lodash.set": "^4.3.2",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
- "mobx": "^6.6.1",
- "mobx-react-lite": "^3.4.0",
- "mobx-utils": "^6.0.6",
"nanoid": "^5.0.5",
"normalize-url": "^8.0.0",
"patch-package": "^6.5.1",
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index f6537e3d1c..1e51c7f255 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -14,6 +14,7 @@ import {
} from '@atproto/api'
import {logger} from '#/logger'
+import {ComposerImage, compressImage} from '#/state/gallery'
import {writePostgateRecord} from '#/state/queries/postgate'
import {
createThreadgateRecord,
@@ -23,10 +24,7 @@ import {
} from '#/state/queries/threadgate'
import {isNetworkError} from 'lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
-import {isNative} from 'platform/detection'
-import {ImageModel} from 'state/models/media/image'
import {LinkMeta} from '../link-meta/link-meta'
-import {safeDeleteAsync} from '../media/manip'
import {uploadBlob} from './upload-blob'
export {uploadBlob}
@@ -36,7 +34,7 @@ export interface ExternalEmbedDraft {
isLoading: boolean
meta?: LinkMeta
embed?: AppBskyEmbedRecord.Main
- localThumb?: ImageModel
+ localThumb?: ComposerImage
}
interface PostOpts {
@@ -53,7 +51,7 @@ interface PostOpts {
aspectRatio?: AppBskyEmbedDefs.AspectRatio
}
extLink?: ExternalEmbedDraft
- images?: ImageModel[]
+ images?: ComposerImage[]
labels?: string[]
threadgate: ThreadgateAllowUISetting[]
postgate: AppBskyFeedPostgate.Record
@@ -99,18 +97,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
const images: AppBskyEmbedImages.Image[] = []
for (const image of opts.images) {
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
+
logger.debug(`Compressing image`)
- await image.compress()
- const path = image.compressed?.path ?? image.path
- const {width, height} = image.compressed || image
+ const {path, width, height, mime} = await compressImage(image)
+
logger.debug(`Uploading image`)
- const res = await uploadBlob(agent, path, 'image/jpeg')
- if (isNative) {
- safeDeleteAsync(path)
- }
+ const res = await uploadBlob(agent, path, mime)
+
images.push({
image: res.data.blob,
- alt: image.altText ?? '',
+ alt: image.alt,
aspectRatio: {width, height},
})
}
@@ -175,32 +171,11 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
let thumb
if (opts.extLink.localThumb) {
opts.onStateChange?.('Uploading link thumbnail...')
- let encoding
- if (opts.extLink.localThumb.mime) {
- encoding = opts.extLink.localThumb.mime
- } else if (opts.extLink.localThumb.path.endsWith('.png')) {
- encoding = 'image/png'
- } else if (
- opts.extLink.localThumb.path.endsWith('.jpeg') ||
- opts.extLink.localThumb.path.endsWith('.jpg')
- ) {
- encoding = 'image/jpeg'
- } else {
- logger.warn('Unexpected image format for thumbnail, skipping', {
- thumbnail: opts.extLink.localThumb.path,
- })
- }
- if (encoding) {
- const thumbUploadRes = await uploadBlob(
- agent,
- opts.extLink.localThumb.path,
- encoding,
- )
- thumb = thumbUploadRes.data.blob
- if (isNative) {
- safeDeleteAsync(opts.extLink.localThumb.path)
- }
- }
+
+ const {path, mime} = opts.extLink.localThumb.source
+ const res = await uploadBlob(agent, path, mime)
+
+ thumb = res.data.blob
}
if (opts.quote) {
diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts
index 9146cd7787..b959ce8be9 100644
--- a/src/lib/media/picker.shared.ts
+++ b/src/lib/media/picker.shared.ts
@@ -28,7 +28,7 @@ export async function openPicker(opts?: ImagePickerOptions) {
return false
})
.map(image => ({
- mime: 'image/jpeg',
+ mime: image.mimeType || 'image/jpeg',
height: image.height,
width: image.width,
path: image.uri,
diff --git a/src/state/gallery.ts b/src/state/gallery.ts
new file mode 100644
index 0000000000..f4c8b712ef
--- /dev/null
+++ b/src/state/gallery.ts
@@ -0,0 +1,299 @@
+import {
+ cacheDirectory,
+ deleteAsync,
+ makeDirectoryAsync,
+ moveAsync,
+} from 'expo-file-system'
+import {
+ Action,
+ ActionCrop,
+ manipulateAsync,
+ SaveFormat,
+} from 'expo-image-manipulator'
+import {nanoid} from 'nanoid/non-secure'
+
+import {POST_IMG_MAX} from '#/lib/constants'
+import {getImageDim} from '#/lib/media/manip'
+import {openCropper} from '#/lib/media/picker'
+import {getDataUriSize} from '#/lib/media/util'
+import {isIOS, isNative} from '#/platform/detection'
+
+export type ImageTransformation = {
+ crop?: ActionCrop['crop']
+}
+
+export type ImageMeta = {
+ path: string
+ width: number
+ height: number
+ mime: string
+}
+
+export type ImageSource = ImageMeta & {
+ id: string
+}
+
+type ComposerImageBase = {
+ alt: string
+ source: ImageSource
+}
+type ComposerImageWithoutTransformation = ComposerImageBase & {
+ transformed?: undefined
+ manips?: undefined
+}
+type ComposerImageWithTransformation = ComposerImageBase & {
+ transformed: ImageMeta
+ manips?: ImageTransformation
+}
+
+export type ComposerImage =
+ | ComposerImageWithoutTransformation
+ | ComposerImageWithTransformation
+
+let _imageCacheDirectory: string
+
+function getImageCacheDirectory(): string | null {
+ if (isNative) {
+ return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer'))
+ }
+
+ return null
+}
+
+export async function createComposerImage(
+ raw: ImageMeta,
+): Promise {
+ return {
+ alt: '',
+ source: {
+ id: nanoid(),
+ path: await moveIfNecessary(raw.path),
+ width: raw.width,
+ height: raw.height,
+ mime: raw.mime,
+ },
+ }
+}
+
+export type InitialImage = {
+ uri: string
+ width: number
+ height: number
+ altText?: string
+}
+
+export function createInitialImages(
+ uris: InitialImage[] = [],
+): ComposerImageWithoutTransformation[] {
+ return uris.map(({uri, width, height, altText = ''}) => {
+ return {
+ alt: altText,
+ source: {
+ id: nanoid(),
+ path: uri,
+ width: width,
+ height: height,
+ mime: 'image/jpeg',
+ },
+ }
+ })
+}
+
+export async function pasteImage(
+ uri: string,
+): Promise {
+ const {width, height} = await getImageDim(uri)
+ const match = /^data:(.+?);/.exec(uri)
+
+ return {
+ alt: '',
+ source: {
+ id: nanoid(),
+ path: uri,
+ width: width,
+ height: height,
+ mime: match ? match[1] : 'image/jpeg',
+ },
+ }
+}
+
+export async function cropImage(img: ComposerImage): Promise {
+ if (!isNative) {
+ return img
+ }
+
+ // NOTE
+ // on ios, react-native-image-crop-picker gives really bad quality
+ // without specifying width and height. on android, however, the
+ // crop stretches incorrectly if you do specify it. these are
+ // both separate bugs in the library. we deal with that by
+ // providing width & height for ios only
+ // -prf
+
+ const source = img.source
+ const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
+
+ // @todo: we're always passing the original image here, does image-cropper
+ // allows for setting initial crop dimensions? -mary
+ try {
+ const cropped = await openCropper({
+ mediaType: 'photo',
+ path: source.path,
+ freeStyleCropEnabled: true,
+ ...(isIOS ? {width: w, height: h} : {}),
+ })
+
+ return {
+ alt: img.alt,
+ source: source,
+ transformed: {
+ path: await moveIfNecessary(cropped.path),
+ width: cropped.width,
+ height: cropped.height,
+ mime: cropped.mime,
+ },
+ }
+ } catch (e) {
+ if (e instanceof Error && e.message.includes('User cancelled')) {
+ return img
+ }
+
+ throw e
+ }
+}
+
+export async function manipulateImage(
+ img: ComposerImage,
+ trans: ImageTransformation,
+): Promise {
+ const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}]
+
+ const actions = rawActions.filter((a): a is Action => a !== undefined)
+
+ if (actions.length === 0) {
+ if (img.transformed === undefined) {
+ return img
+ }
+
+ return {alt: img.alt, source: img.source}
+ }
+
+ const source = img.source
+ const result = await manipulateAsync(source.path, actions, {
+ format: SaveFormat.PNG,
+ })
+
+ return {
+ alt: img.alt,
+ source: img.source,
+ transformed: {
+ path: await moveIfNecessary(result.uri),
+ width: result.width,
+ height: result.height,
+ mime: 'image/png',
+ },
+ manips: trans,
+ }
+}
+
+export function resetImageManipulation(
+ img: ComposerImage,
+): ComposerImageWithoutTransformation {
+ if (img.transformed !== undefined) {
+ return {alt: img.alt, source: img.source}
+ }
+
+ return img
+}
+
+export async function compressImage(img: ComposerImage): Promise {
+ const source = img.transformed || img.source
+
+ const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
+ const cacheDir = isNative && getImageCacheDirectory()
+
+ for (let i = 10; i > 0; i--) {
+ // Float precision
+ const factor = i / 10
+
+ const res = await manipulateAsync(
+ source.path,
+ [{resize: {width: w, height: h}}],
+ {
+ compress: factor,
+ format: SaveFormat.JPEG,
+ base64: true,
+ },
+ )
+
+ const base64 = res.base64
+
+ if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) {
+ return {
+ path: await moveIfNecessary(res.uri),
+ width: res.width,
+ height: res.height,
+ mime: 'image/jpeg',
+ }
+ }
+
+ if (cacheDir) {
+ await deleteAsync(res.uri)
+ }
+ }
+
+ throw new Error(`Unable to compress image`)
+}
+
+async function moveIfNecessary(from: string) {
+ const cacheDir = isNative && getImageCacheDirectory()
+
+ if (cacheDir && from.startsWith(cacheDir)) {
+ const to = joinPath(cacheDir, nanoid(36))
+
+ await makeDirectoryAsync(cacheDir, {intermediates: true})
+ await moveAsync({from, to})
+
+ return to
+ }
+
+ return from
+}
+
+/** Purge files that were created to accomodate image manipulation */
+export async function purgeTemporaryImageFiles() {
+ const cacheDir = isNative && getImageCacheDirectory()
+
+ if (cacheDir) {
+ await deleteAsync(cacheDir, {idempotent: true})
+ await makeDirectoryAsync(cacheDir)
+ }
+}
+
+function joinPath(a: string, b: string) {
+ if (a.endsWith('/')) {
+ if (b.startsWith('/')) {
+ return a.slice(0, -1) + b
+ }
+ return a + b
+ } else if (b.startsWith('/')) {
+ return a + b
+ }
+ return a + '/' + b
+}
+
+function containImageRes(
+ w: number,
+ h: number,
+ {width: maxW, height: maxH}: {width: number; height: number},
+): [width: number, height: number] {
+ let scale = 1
+
+ if (w > maxW || h > maxH) {
+ scale = w > h ? maxW / w : maxH / h
+ w = Math.floor(w * scale)
+ h = Math.floor(h * scale)
+ }
+
+ return [w, h]
+}
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 529dc55907..467853a258 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -3,8 +3,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-import {GalleryModel} from '#/state/models/media/gallery'
-import {ImageModel} from '#/state/models/media/image'
+import {ComposerImage} from '../gallery'
export interface EditProfileModal {
name: 'edit-profile'
@@ -37,12 +36,6 @@ export interface ListAddRemoveUsersModal {
) => void
}
-export interface EditImageModal {
- name: 'edit-image'
- image: ImageModel
- gallery: GalleryModel
-}
-
export interface CropImageModal {
name: 'crop-image'
uri: string
@@ -52,7 +45,8 @@ export interface CropImageModal {
export interface AltTextImageModal {
name: 'alt-text-image'
- image: ImageModel
+ image: ComposerImage
+ onChange: (next: ComposerImage) => void
}
export interface DeleteAccountModal {
@@ -139,7 +133,6 @@ export type Modal =
// Posts
| AltTextImageModal
| CropImageModal
- | EditImageModal
| SelfLabelModal
// Bluesky access
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
deleted file mode 100644
index 828905002e..0000000000
--- a/src/state/models/media/gallery.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-
-import {getImageDim} from 'lib/media/manip'
-import {openPicker} from 'lib/media/picker'
-import {ImageInitOptions, ImageModel} from './image'
-
-interface InitialImageUri {
- uri: string
- width: number
- height: number
- altText?: string
-}
-
-export class GalleryModel {
- images: ImageModel[] = []
-
- constructor(uris?: InitialImageUri[]) {
- makeAutoObservable(this)
-
- if (uris) {
- this.addFromUris(uris)
- }
- }
-
- get isEmpty() {
- return this.size === 0
- }
-
- get size() {
- return this.images.length
- }
-
- get needsAltText() {
- return this.images.some(image => image.altText.trim() === '')
- }
-
- *add(image_: ImageInitOptions) {
- if (this.size >= 4) {
- return
- }
-
- // Temporarily enforce uniqueness but can eventually also use index
- if (!this.images.some(i => i.path === image_.path)) {
- const image = new ImageModel(image_)
-
- // Initial resize
- image.manipulate({})
- this.images.push(image)
- }
- }
-
- async paste(uri: string) {
- if (this.size >= 4) {
- return
- }
-
- const {width, height} = await getImageDim(uri)
-
- const image = {
- path: uri,
- height,
- width,
- }
-
- runInAction(() => {
- this.add(image)
- })
- }
-
- setAltText(image: ImageModel, altText: string) {
- image.setAltText(altText)
- }
-
- crop(image: ImageModel) {
- image.crop()
- }
-
- remove(image: ImageModel) {
- const index = this.images.findIndex(image_ => image_.path === image.path)
- this.images.splice(index, 1)
- }
-
- async previous(image: ImageModel) {
- image.previous()
- }
-
- async pick() {
- const images = await openPicker({
- selectionLimit: 4 - this.size,
- allowsMultipleSelection: true,
- })
-
- return await Promise.all(
- images.map(image => {
- this.add(image)
- }),
- )
- }
-
- async addFromUris(uris: InitialImageUri[]) {
- for (const uriObj of uris) {
- this.add({
- height: uriObj.height,
- width: uriObj.width,
- path: uriObj.uri,
- altText: uriObj.altText,
- })
- }
- }
-}
diff --git a/src/state/models/media/image.e2e.ts b/src/state/models/media/image.e2e.ts
deleted file mode 100644
index ccabd50475..0000000000
--- a/src/state/models/media/image.e2e.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import {makeAutoObservable} from 'mobx'
-import {POST_IMG_MAX} from 'lib/constants'
-import {ActionCrop} from 'expo-image-manipulator'
-import {Position} from 'react-avatar-editor'
-import {Dimensions} from 'lib/media/types'
-
-export interface ImageManipulationAttributes {
- aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
- rotate?: number
- scale?: number
- position?: Position
- flipHorizontal?: boolean
- flipVertical?: boolean
-}
-
-export class ImageModel implements Omit {
- path: string
- mime = 'image/jpeg'
- width: number
- height: number
- altText = ''
- cropped?: RNImage = undefined
- compressed?: RNImage = undefined
-
- // Web manipulation
- prev?: RNImage
- attributes: ImageManipulationAttributes = {
- aspectRatio: 'None',
- scale: 1,
- flipHorizontal: false,
- flipVertical: false,
- rotate: 0,
- }
- prevAttributes: ImageManipulationAttributes = {}
-
- constructor(image: Omit) {
- makeAutoObservable(this)
-
- this.path = image.path
- this.width = image.width
- this.height = image.height
- }
-
- setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
- this.attributes.aspectRatio = aspectRatio
- }
-
- setRotate(degrees: number) {
- this.attributes.rotate = degrees
- this.manipulate({})
- }
-
- flipVertical() {
- this.attributes.flipVertical = !this.attributes.flipVertical
- this.manipulate({})
- }
-
- flipHorizontal() {
- this.attributes.flipHorizontal = !this.attributes.flipHorizontal
- this.manipulate({})
- }
-
- get ratioMultipliers() {
- return {
- '4:3': 4 / 3,
- '1:1': 1,
- '3:4': 3 / 4,
- None: this.width / this.height,
- }
- }
-
- getUploadDimensions(
- dimensions: Dimensions,
- maxDimensions: Dimensions = POST_IMG_MAX,
- as: ImageManipulationAttributes['aspectRatio'] = 'None',
- ) {
- const {width, height} = dimensions
- const {width: maxWidth, height: maxHeight} = maxDimensions
-
- return width < maxWidth && height < maxHeight
- ? {
- width,
- height,
- }
- : this.getResizedDimensions(as, POST_IMG_MAX.width)
- }
-
- getResizedDimensions(
- as: ImageManipulationAttributes['aspectRatio'] = 'None',
- maxSide: number,
- ) {
- const ratioMultiplier = this.ratioMultipliers[as]
-
- if (ratioMultiplier === 1) {
- return {
- height: maxSide,
- width: maxSide,
- }
- }
-
- if (ratioMultiplier < 1) {
- return {
- width: maxSide * ratioMultiplier,
- height: maxSide,
- }
- }
-
- return {
- width: maxSide,
- height: maxSide / ratioMultiplier,
- }
- }
-
- setAltText(altText: string) {
- this.altText = altText.trim()
- }
-
- // Only compress prior to upload
- async compress() {
- // do nothing
- }
-
- // Mobile
- async crop() {
- // do nothing
- }
-
- // Web manipulation
- async manipulate(
- _attributes: {
- crop?: ActionCrop['crop']
- } & ImageManipulationAttributes,
- ) {
- // do nothing
- }
-
- resetCropped() {
- this.manipulate({})
- }
-
- previous() {
- this.cropped = this.prev
- this.attributes = this.prevAttributes
- }
-}
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
deleted file mode 100644
index 55f6364911..0000000000
--- a/src/state/models/media/image.ts
+++ /dev/null
@@ -1,310 +0,0 @@
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import * as ImageManipulator from 'expo-image-manipulator'
-import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
-import {makeAutoObservable, runInAction} from 'mobx'
-import {Position} from 'react-avatar-editor'
-
-import {logger} from '#/logger'
-import {POST_IMG_MAX} from 'lib/constants'
-import {openCropper} from 'lib/media/picker'
-import {Dimensions} from 'lib/media/types'
-import {getDataUriSize} from 'lib/media/util'
-import {isIOS} from 'platform/detection'
-
-export interface ImageManipulationAttributes {
- aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
- rotate?: number
- scale?: number
- position?: Position
- flipHorizontal?: boolean
- flipVertical?: boolean
-}
-
-export interface ImageInitOptions {
- path: string
- width: number
- height: number
- altText?: string
-}
-
-const MAX_IMAGE_SIZE_IN_BYTES = 976560
-
-export class ImageModel implements Omit {
- path: string
- mime = 'image/jpeg'
- width: number
- height: number
- altText = ''
- cropped?: RNImage = undefined
- compressed?: RNImage = undefined
-
- // Web manipulation
- prev?: RNImage
- attributes: ImageManipulationAttributes = {
- aspectRatio: 'None',
- scale: 1,
- flipHorizontal: false,
- flipVertical: false,
- rotate: 0,
- }
- prevAttributes: ImageManipulationAttributes = {}
-
- constructor(image: ImageInitOptions) {
- makeAutoObservable(this)
-
- this.path = image.path
- this.width = image.width
- this.height = image.height
- if (image.altText !== undefined) {
- this.setAltText(image.altText)
- }
- }
-
- setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
- this.attributes.aspectRatio = aspectRatio
- }
-
- setRotate(degrees: number) {
- this.attributes.rotate = degrees
- this.manipulate({})
- }
-
- flipVertical() {
- this.attributes.flipVertical = !this.attributes.flipVertical
- this.manipulate({})
- }
-
- flipHorizontal() {
- this.attributes.flipHorizontal = !this.attributes.flipHorizontal
- this.manipulate({})
- }
-
- get ratioMultipliers() {
- return {
- '4:3': 4 / 3,
- '1:1': 1,
- '3:4': 3 / 4,
- None: this.width / this.height,
- }
- }
-
- getUploadDimensions(
- dimensions: Dimensions,
- maxDimensions: Dimensions = POST_IMG_MAX,
- as: ImageManipulationAttributes['aspectRatio'] = 'None',
- ) {
- const {width, height} = dimensions
- const {width: maxWidth, height: maxHeight} = maxDimensions
-
- return width < maxWidth && height < maxHeight
- ? {
- width,
- height,
- }
- : this.getResizedDimensions(as, POST_IMG_MAX.width)
- }
-
- getResizedDimensions(
- as: ImageManipulationAttributes['aspectRatio'] = 'None',
- maxSide: number,
- ) {
- const ratioMultiplier = this.ratioMultipliers[as]
-
- if (ratioMultiplier === 1) {
- return {
- height: maxSide,
- width: maxSide,
- }
- }
-
- if (ratioMultiplier < 1) {
- return {
- width: maxSide * ratioMultiplier,
- height: maxSide,
- }
- }
-
- return {
- width: maxSide,
- height: maxSide / ratioMultiplier,
- }
- }
-
- setAltText(altText: string) {
- this.altText = altText.trim()
- }
-
- // Only compress prior to upload
- async compress() {
- for (let i = 10; i > 0; i--) {
- // Float precision
- const factor = Math.round(i) / 10
- const compressed = await ImageManipulator.manipulateAsync(
- this.cropped?.path ?? this.path,
- undefined,
- {
- compress: factor,
- base64: true,
- format: SaveFormat.JPEG,
- },
- )
-
- if (compressed.base64 !== undefined) {
- const size = getDataUriSize(compressed.base64)
-
- if (size < MAX_IMAGE_SIZE_IN_BYTES) {
- runInAction(() => {
- this.compressed = {
- mime: 'image/jpeg',
- path: compressed.uri,
- size,
- ...compressed,
- }
- })
- return
- }
- }
- }
-
- // Compression fails when removing redundant information is not possible.
- // This can be tested with images that have high variance in noise.
- throw new Error('Failed to compress image')
- }
-
- // Mobile
- async crop() {
- try {
- // NOTE
- // on ios, react-native-image-crop-picker gives really bad quality
- // without specifying width and height. on android, however, the
- // crop stretches incorrectly if you do specify it. these are
- // both separate bugs in the library. we deal with that by
- // providing width & height for ios only
- // -prf
- const {width, height} = this.getUploadDimensions({
- width: this.width,
- height: this.height,
- })
-
- const cropped = await openCropper({
- mediaType: 'photo',
- path: this.path,
- freeStyleCropEnabled: true,
- ...(isIOS ? {width, height} : {}),
- })
-
- runInAction(() => {
- this.cropped = cropped
- })
- } catch (err) {
- logger.error('Failed to crop photo', {message: err})
- }
- }
-
- // Web manipulation
- async manipulate(
- attributes: {
- crop?: ActionCrop['crop']
- } & ImageManipulationAttributes,
- ) {
- let uploadWidth: number | undefined
- let uploadHeight: number | undefined
-
- const {aspectRatio, crop, position, scale} = attributes
- const modifiers = []
-
- if (this.attributes.flipHorizontal) {
- modifiers.push({flip: FlipType.Horizontal})
- }
-
- if (this.attributes.flipVertical) {
- modifiers.push({flip: FlipType.Vertical})
- }
-
- if (this.attributes.rotate !== undefined) {
- modifiers.push({rotate: this.attributes.rotate})
- }
-
- if (crop !== undefined) {
- const croppedHeight = crop.height * this.height
- const croppedWidth = crop.width * this.width
- modifiers.push({
- crop: {
- originX: crop.originX * this.width,
- originY: crop.originY * this.height,
- height: croppedHeight,
- width: croppedWidth,
- },
- })
-
- const uploadDimensions = this.getUploadDimensions(
- {width: croppedWidth, height: croppedHeight},
- POST_IMG_MAX,
- aspectRatio,
- )
-
- uploadWidth = uploadDimensions.width
- uploadHeight = uploadDimensions.height
- } else {
- const uploadDimensions = this.getUploadDimensions(
- {width: this.width, height: this.height},
- POST_IMG_MAX,
- aspectRatio,
- )
-
- uploadWidth = uploadDimensions.width
- uploadHeight = uploadDimensions.height
- }
-
- if (scale !== undefined) {
- this.attributes.scale = scale
- }
-
- if (position !== undefined) {
- this.attributes.position = position
- }
-
- if (aspectRatio !== undefined) {
- this.attributes.aspectRatio = aspectRatio
- }
-
- const ratioMultiplier =
- this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
-
- const result = await ImageManipulator.manipulateAsync(
- this.path,
- [
- ...modifiers,
- {
- resize:
- ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight},
- },
- ],
- {
- base64: true,
- format: SaveFormat.JPEG,
- },
- )
-
- runInAction(() => {
- this.cropped = {
- mime: 'image/jpeg',
- path: result.uri,
- size:
- result.base64 !== undefined
- ? getDataUriSize(result.base64)
- : MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails
- ...result,
- }
- })
- }
-
- resetCropped() {
- this.manipulate({})
- }
-
- previous() {
- this.cropped = this.prev
- this.attributes = this.prevAttributes
- }
-}
diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx
index 6755ec9a66..8e12386bd3 100644
--- a/src/state/shell/composer/index.tsx
+++ b/src/state/shell/composer/index.tsx
@@ -9,6 +9,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {purgeTemporaryImageFiles} from '#/state/gallery'
import * as Toast from '#/view/com/util/Toast'
export interface ComposerOptsPostRef {
@@ -77,7 +78,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const closeComposer = useNonReactiveCallback(() => {
let wasOpen = !!state
- setState(undefined)
+ if (wasOpen) {
+ setState(undefined)
+ purgeTemporaryImageFiles()
+ }
+
return wasOpen
})
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index dfdfb3ebdf..3b7cf13851 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -44,7 +44,6 @@ import {RichText} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {observer} from 'mobx-react-lite'
import {useAnalytics} from '#/lib/analytics/analytics'
import * as apilib from '#/lib/api/index'
@@ -68,9 +67,9 @@ import {logger} from '#/logger'
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
import {useDialogStateControlContext} from '#/state/dialogs'
import {emitPostCreated} from '#/state/events'
+import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery'
import {useModalControls} from '#/state/modals'
import {useModals} from '#/state/modals'
-import {GalleryModel} from '#/state/models/media/gallery'
import {useRequireAltTextEnabled} from '#/state/preferences'
import {
toPostLanguages,
@@ -122,12 +121,14 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography'
+const MAX_IMAGES = 4
+
type CancelRef = {
onPressCancel: () => void
}
type Props = ComposerOpts
-export const ComposePost = observer(function ComposePost({
+export const ComposePost = ({
replyTo,
onPost,
quote: initQuote,
@@ -139,7 +140,7 @@ export const ComposePost = observer(function ComposePost({
cancelRef,
}: Props & {
cancelRef?: React.RefObject
-}) {
+}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -212,9 +213,8 @@ export const ComposePost = observer(function ComposePost({
)
const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
- const gallery = useMemo(
- () => new GalleryModel(initImageUris),
- [initImageUris],
+ const [images, setImages] = useState(() =>
+ createInitialImages(initImageUris),
)
const onClose = useCallback(() => {
closeComposer()
@@ -233,7 +233,7 @@ export const ComposePost = observer(function ComposePost({
const onPressCancel = useCallback(() => {
if (
graphemeLength > 0 ||
- !gallery.isEmpty ||
+ images.length !== 0 ||
extGif ||
videoUploadState.status !== 'idle'
) {
@@ -246,7 +246,7 @@ export const ComposePost = observer(function ComposePost({
}, [
extGif,
graphemeLength,
- gallery.isEmpty,
+ images.length,
closeAllDialogs,
discardPromptControl,
onClose,
@@ -299,22 +299,31 @@ export const ComposePost = observer(function ComposePost({
[extLink, setExtLink],
)
+ const onImageAdd = useCallback(
+ (next: ComposerImage[]) => {
+ setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length)))
+ },
+ [setImages],
+ )
+
const onPhotoPasted = useCallback(
async (uri: string) => {
track('Composer:PastedPhotos')
if (uri.startsWith('data:video/')) {
selectVideo({uri, type: 'video', height: 0, width: 0})
} else {
- await gallery.paste(uri)
+ const res = await pasteImage(uri)
+ onImageAdd([res])
}
},
- [gallery, track, selectVideo],
+ [track, selectVideo, onImageAdd],
)
const isAltTextRequiredAndMissing = useMemo(() => {
if (!requireAltTextEnabled) return false
- if (gallery.needsAltText) return true
+ if (images.some(img => img.alt === '')) return true
+
if (extGif) {
if (!extLink?.meta?.description) return true
@@ -322,7 +331,7 @@ export const ComposePost = observer(function ComposePost({
if (!parsedAlt.isPreferred) return true
}
return false
- }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
+ }, [images, extLink, extGif, requireAltTextEnabled])
const onPressPublish = React.useCallback(
async (finishedUploading?: boolean) => {
@@ -347,7 +356,7 @@ export const ComposePost = observer(function ComposePost({
if (
richtext.text.trim().length === 0 &&
- gallery.isEmpty &&
+ images.length === 0 &&
!extLink &&
!quote &&
videoUploadState.status === 'idle'
@@ -368,7 +377,7 @@ export const ComposePost = observer(function ComposePost({
await apilib.post(agent, {
rawText: richtext.text,
replyTo: replyTo?.uri,
- images: gallery.images,
+ images,
quote,
extLink,
labels,
@@ -405,7 +414,7 @@ export const ComposePost = observer(function ComposePost({
} catch (e: any) {
logger.error(e, {
message: `Composer: create post failed`,
- hasImages: gallery.size > 0,
+ hasImages: images.length > 0,
})
if (extLink) {
@@ -427,7 +436,7 @@ export const ComposePost = observer(function ComposePost({
} finally {
if (postUri) {
logEvent('post:create', {
- imageCount: gallery.size,
+ imageCount: images.length,
isReply: replyTo != null,
hasLink: extLink != null,
hasQuote: quote != null,
@@ -436,7 +445,7 @@ export const ComposePost = observer(function ComposePost({
})
}
track('Create Post', {
- imageCount: gallery.size,
+ imageCount: images.length,
})
if (replyTo && replyTo.uri) track('Post:Reply')
}
@@ -472,9 +481,7 @@ export const ComposePost = observer(function ComposePost({
agent,
captions,
extLink,
- gallery.images,
- gallery.isEmpty,
- gallery.size,
+ images,
graphemeLength,
isAltTextRequiredAndMissing,
isProcessing,
@@ -516,12 +523,12 @@ export const ComposePost = observer(function ComposePost({
: _(msg`What's up?`)
const canSelectImages =
- gallery.size < 4 &&
+ images.length < MAX_IMAGES &&
!extLink &&
videoUploadState.status === 'idle' &&
!videoUploadState.video
const hasMedia =
- gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
+ images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
const onEmojiButtonPress = useCallback(() => {
openEmojiPicker?.(textInput.current?.getCursorPosition())
@@ -716,8 +723,8 @@ export const ComposePost = observer(function ComposePost({
/>
-
- {gallery.isEmpty && extLink && (
+
+ {images.length === 0 && extLink && (
) : (
-
+
-
+
)
-})
+}
export function useComposerCancelRef() {
return useRef(null)
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 4801ca0abf..f61d410dfc 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -26,7 +26,7 @@ export const ExternalEmbed = ({
title: link.meta?.title ?? link.uri,
uri: link.uri,
description: link.meta?.description ?? '',
- thumb: link.localThumb?.path,
+ thumb: link.localThumb?.source.path,
},
[link],
)
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index a37452604f..a05607c76c 100644
--- a/src/view/com/composer/GifAltText.tsx
+++ b/src/view/com/composer/GifAltText.tsx
@@ -43,7 +43,7 @@ export function GifAltText({
title: linkProp.meta?.title ?? linkProp.uri,
uri: linkProp.uri,
description: linkProp.meta?.description ?? '',
- thumb: linkProp.localThumb?.path,
+ thumb: linkProp.localThumb?.source.path,
},
params: parseEmbedPlayerFromUrl(linkProp.uri),
}
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 422a4dd937..775413e817 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -1,29 +1,36 @@
-import React, {useState} from 'react'
-import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import React from 'react'
+import {
+ ImageStyle,
+ Keyboard,
+ LayoutChangeEvent,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+ ViewStyle,
+} from 'react-native'
import {Image} from 'expo-image'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {observer} from 'mobx-react-lite'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {Dimensions} from '#/lib/media/types'
import {colors, s} from '#/lib/styles'
import {isNative} from '#/platform/detection'
+import {ComposerImage, cropImage} from '#/state/gallery'
import {useModalControls} from '#/state/modals'
-import {GalleryModel} from '#/state/models/media/gallery'
import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf'
const IMAGE_GAP = 8
interface GalleryProps {
- gallery: GalleryModel
+ images: ComposerImage[]
+ onChange: (next: ComposerImage[]) => void
}
-export const Gallery = (props: GalleryProps) => {
- const [containerInfo, setContainerInfo] = useState()
+export let Gallery = (props: GalleryProps): React.ReactNode => {
+ const [containerInfo, setContainerInfo] = React.useState()
const onLayout = (evt: LayoutChangeEvent) => {
const {width, height} = evt.nativeEvent.layout
@@ -41,177 +48,190 @@ export const Gallery = (props: GalleryProps) => {
)
}
+Gallery = React.memo(Gallery)
interface GalleryInnerProps extends GalleryProps {
containerInfo: Dimensions
}
-const GalleryInner = observer(function GalleryImpl({
- gallery,
- containerInfo,
-}: GalleryInnerProps) {
- const {_} = useLingui()
+const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => {
const {isMobile} = useWebMediaQueries()
- const {openModal} = useModalControls()
- const t = useTheme()
- let side: number
+ const {altTextControlStyle, imageControlsStyle, imageStyle} =
+ React.useMemo(() => {
+ const side =
+ images.length === 1
+ ? 250
+ : (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
+ images.length
- if (gallery.size === 1) {
- side = 250
- } else {
- side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size
- }
+ const isOverflow = isMobile && images.length > 2
- const imageStyle = {
- height: side,
- width: side,
- }
-
- const isOverflow = isMobile && gallery.size > 2
-
- const altTextControlStyle = isOverflow
- ? {
- left: 4,
- bottom: 4,
- }
- : !isMobile && gallery.size < 3
- ? {
- left: 8,
- top: 8,
- }
- : {
- left: 4,
- top: 4,
+ return {
+ altTextControlStyle: isOverflow
+ ? {left: 4, bottom: 4}
+ : !isMobile && images.length < 3
+ ? {left: 8, top: 8}
+ : {left: 4, top: 4},
+ imageControlsStyle: {
+ display: 'flex' as const,
+ flexDirection: 'row' as const,
+ position: 'absolute' as const,
+ ...(isOverflow
+ ? {top: 4, right: 4, gap: 4}
+ : !isMobile && images.length < 3
+ ? {top: 8, right: 8, gap: 8}
+ : {top: 4, right: 4, gap: 4}),
+ zIndex: 1,
+ },
+ imageStyle: {
+ height: side,
+ width: side,
+ },
}
+ }, [images.length, containerInfo, isMobile])
- const imageControlsStyle = {
- display: 'flex' as const,
- flexDirection: 'row' as const,
- position: 'absolute' as const,
- ...(isOverflow
- ? {
- top: 4,
- right: 4,
- gap: 4,
- }
- : !isMobile && gallery.size < 3
- ? {
- top: 8,
- right: 8,
- gap: 8,
- }
- : {
- top: 4,
- right: 4,
- gap: 4,
- }),
- zIndex: 1,
- }
-
- return !gallery.isEmpty ? (
+ return images.length !== 0 ? (
<>
- {gallery.images.map(image => (
-
- {
- Keyboard.dismiss()
- openModal({
- name: 'alt-text-image',
- image,
- })
+ {images.map((image, index) => {
+ return (
+ {
+ onChange(
+ images.map(i => (i.source === image.source ? next : i)),
+ )
}}
- style={[styles.altTextControl, altTextControlStyle]}>
- {image.altText.length > 0 ? (
-
- ) : (
-
- )}
-
- ALT
-
-
-
- {
- if (isNative) {
- gallery.crop(image)
- } else {
- openModal({
- name: 'edit-image',
- image,
- gallery,
- })
- }
- }}
- style={styles.imageControl}>
-
-
- gallery.remove(image)}
- style={styles.imageControl}>
-
-
-
- {
- Keyboard.dismiss()
- openModal({
- name: 'alt-text-image',
- image,
- })
- }}
- style={styles.altTextHiddenRegion}
- />
+ onRemove={() => {
+ const next = images.slice()
+ next.splice(index, 1)
-
-
- ))}
+ )
+ })}
>
) : null
-})
+}
+
+type GalleryItemProps = {
+ image: ComposerImage
+ altTextControlStyle?: ViewStyle
+ imageControlsStyle?: ViewStyle
+ imageStyle?: ViewStyle
+ onChange: (next: ComposerImage) => void
+ onRemove: () => void
+}
+
+const GalleryItem = ({
+ image,
+ altTextControlStyle,
+ imageControlsStyle,
+ imageStyle,
+ onChange,
+ onRemove,
+}: GalleryItemProps): React.ReactNode => {
+ const {_} = useLingui()
+ const t = useTheme()
+ const {openModal} = useModalControls()
+
+ const onImageEdit = () => {
+ if (isNative) {
+ cropImage(image).then(next => {
+ onChange(next)
+ })
+ }
+ }
+
+ const onAltTextEdit = () => {
+ Keyboard.dismiss()
+ openModal({name: 'alt-text-image', image, onChange})
+ }
+
+ return (
+
+
+ {image.alt.length !== 0 ? (
+
+ ) : (
+
+ )}
+
+ ALT
+
+
+
+ {isNative && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
export function AltTextReminder() {
const t = useTheme()
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index f1f984103e..2183ca7902 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -9,17 +9,17 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions'
import {openCamera} from '#/lib/media/picker'
import {logger} from '#/logger'
import {isMobileWeb, isNative} from '#/platform/detection'
-import {GalleryModel} from '#/state/models/media/gallery'
+import {ComposerImage, createComposerImage} from '#/state/gallery'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
type Props = {
- gallery: GalleryModel
disabled?: boolean
+ onAdd: (next: ComposerImage[]) => void
}
-export function OpenCameraBtn({gallery, disabled}: Props) {
+export function OpenCameraBtn({disabled, onAdd}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission()
@@ -48,13 +48,16 @@ export function OpenCameraBtn({gallery, disabled}: Props) {
if (mediaPermissionRes) {
await MediaLibrary.createAssetAsync(img.path)
}
- gallery.add(img)
+
+ const res = await createComposerImage(img)
+
+ onAdd([res])
} catch (err: any) {
// ignore
logger.warn('Error using camera', {error: err})
}
}, [
- gallery,
+ onAdd,
track,
requestCameraAccessIfNeeded,
mediaPermissionRes,
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index 747653fc8d..95d2df022c 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -5,18 +5,20 @@ import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
+import {openPicker} from '#/lib/media/picker'
import {isNative} from '#/platform/detection'
-import {GalleryModel} from '#/state/models/media/gallery'
+import {ComposerImage, createComposerImage} from '#/state/gallery'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
type Props = {
- gallery: GalleryModel
+ size: number
disabled?: boolean
+ onAdd: (next: ComposerImage[]) => void
}
-export function SelectPhotoBtn({gallery, disabled}: Props) {
+export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
@@ -29,8 +31,17 @@ export function SelectPhotoBtn({gallery, disabled}: Props) {
return
}
- gallery.pick()
- }, [track, requestPhotoAccessIfNeeded, gallery])
+ const images = await openPicker({
+ selectionLimit: 4 - size,
+ allowsMultipleSelection: true,
+ })
+
+ const results = await Promise.all(
+ images.map(img => createComposerImage(img)),
+ )
+
+ onAdd(results)
+ }, [track, requestPhotoAccessIfNeeded, size, onAdd])
return (
undefined)
- .then(localThumb => {
+ .then(thumb => (thumb ? createComposerImage(thumb) : undefined))
+ .then(thumb => {
if (aborted) {
return
}
setExtLink({
...extLink,
isLoading: false, // done
- localThumb: localThumb ? new ImageModel(localThumb) : undefined,
+ localThumb: thumb,
})
})
return cleanup
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
index ba489cde7b..c711f73a57 100644
--- a/src/view/com/modals/AltImage.tsx
+++ b/src/view/com/modals/AltImage.tsx
@@ -13,6 +13,7 @@ import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {ComposerImage} from '#/state/gallery'
import {useModalControls} from '#/state/modals'
import {MAX_ALT_TEXT} from 'lib/constants'
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
@@ -21,21 +22,21 @@ import {enforceLen} from 'lib/strings/helpers'
import {gradients, s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {isAndroid, isWeb} from 'platform/detection'
-import {ImageModel} from 'state/models/media/image'
import {Text} from '../util/text/Text'
import {ScrollView, TextInput} from './util'
export const snapPoints = ['100%']
interface Props {
- image: ImageModel
+ image: ComposerImage
+ onChange: (next: ComposerImage) => void
}
-export function Component({image}: Props) {
+export function Component({image, onChange}: Props) {
const pal = usePalette('default')
const theme = useTheme()
const {_} = useLingui()
- const [altText, setAltText] = useState(image.altText)
+ const [altText, setAltText] = useState(image.alt)
const windim = useWindowDimensions()
const {closeModal} = useModalControls()
const inputRef = React.useRef(null)
@@ -60,7 +61,8 @@ export function Component({image}: Props) {
const imageStyles = useMemo(() => {
const maxWidth = isWeb ? 450 : windim.width
- if (image.height > image.width) {
+ const media = image.transformed ?? image.source
+ if (media.height > media.width) {
return {
resizeMode: 'contain',
width: '100%',
@@ -70,7 +72,7 @@ export function Component({image}: Props) {
}
return {
width: '100%',
- height: (maxWidth / image.width) * image.height,
+ height: (maxWidth / media.width) * media.height,
borderRadius: 8,
}
}, [image, windim])
@@ -79,15 +81,18 @@ export function Component({image}: Props) {
(v: string) => {
v = enforceLen(v, MAX_ALT_TEXT)
setAltText(v)
- image.setAltText(v)
},
- [setAltText, image],
+ [setAltText],
)
const onPressSave = useCallback(() => {
- image.setAltText(altText)
+ onChange({
+ ...image,
+ alt: altText,
+ })
+
closeModal()
- }, [closeModal, image, altText])
+ }, [closeModal, image, altText, onChange])
return (
(null)
- const [scale, setScale] = useState(image.attributes.scale ?? 1)
- const [position, setPosition] = useState(
- image.attributes.position,
- )
- const [altText, setAltText] = useState(image?.altText ?? '')
-
- const onFlipHorizontal = useCallback(() => {
- image.flipHorizontal()
- }, [image])
-
- const onFlipVertical = useCallback(() => {
- image.flipVertical()
- }, [image])
-
- // const onSetRotate = useCallback(
- // (direction: 'left' | 'right') => {
- // const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360
- // image.setRotate(rotation)
- // },
- // [rotate, image],
- // )
-
- const onSetRatio = useCallback(
- (ratio: AspectRatio) => {
- image.setRatio(ratio)
- },
- [image],
- )
-
- const adjustments = useMemo(
- () => [
- // {
- // name: 'rotate-left' as const,
- // label: 'Rotate left',
- // onPress: () => {
- // onSetRotate('left')
- // },
- // },
- // {
- // name: 'rotate-right' as const,
- // label: 'Rotate right',
- // onPress: () => {
- // onSetRotate('right')
- // },
- // },
- {
- icon: FlipHorizontal,
- label: _(msg`Flip horizontal`),
- onPress: onFlipHorizontal,
- },
- {
- icon: FlipVertical,
- label: _(msg`Flip vertically`),
- onPress: onFlipVertical,
- },
- ],
- [onFlipHorizontal, onFlipVertical, _],
- )
-
- useEffect(() => {
- image.prev = image.cropped
- image.prevAttributes = image.attributes
- image.resetCropped()
- }, [image])
-
- const onCloseModal = useCallback(() => {
- closeModal()
- }, [closeModal])
-
- const onPressCancel = useCallback(async () => {
- await gallery.previous(image)
- onCloseModal()
- }, [onCloseModal, gallery, image])
-
- const onPressSave = useCallback(async () => {
- image.setAltText(altText)
-
- const crop = editorRef.current?.getCroppingRect()
-
- await image.manipulate({
- ...(crop !== undefined
- ? {
- crop: {
- originX: crop.x,
- originY: crop.y,
- width: crop.width,
- height: crop.height,
- },
- ...(scale !== 1 ? {scale} : {}),
- ...(position !== undefined ? {position} : {}),
- }
- : {}),
- })
-
- image.prev = image.cropped
- image.prevAttributes = image.attributes
- onCloseModal()
- }, [altText, image, position, scale, onCloseModal])
-
- if (image.cropped === undefined) {
- return null
- }
-
- const computedWidth =
- windowDimensions.width > 500 ? 410 : windowDimensions.width - 80
- const sideLength = isMobile ? computedWidth : 300
-
- const dimensions = image.getResizedDimensions(aspectRatio, sideLength)
- const imgContainerStyles = {width: sideLength, height: sideLength}
-
- const imgControlStyles = {
- alignItems: 'center' as const,
- flexDirection: isMobile ? ('column' as const) : ('row' as const),
- gap: isMobile ? 0 : 5,
- }
-
- return (
-
-
- Edit image
-
-
-
-
-
-
-
- setScale(Array.isArray(v) ? v[0] : v)
- }
- minimumValue={1}
- maximumValue={3}
- />
-
-
- {!isMobile ? (
-
- Ratios
-
- ) : null}
-
- {getKeys(RATIOS).map(ratio => {
- const {icon} = RATIOS[ratio]
- const isSelected = aspectRatio === ratio
-
- return (
- {
- onSetRatio(ratio)
- }}>
-
-
- {ratio}
-
-
- )
- })}
-
- {!isMobile ? (
-
- Transformations
-
- ) : null}
-
- {adjustments.map(({label, icon, onPress}) => (
-
-
-
- ))}
-
-
-
-
-
- Accessibility
-
- setAltText(enforceLen(text, MAX_ALT_TEXT))}
- accessibilityLabel={_(msg`Alt text`)}
- accessibilityHint=""
- accessibilityLabelledBy="alt-text"
- />
-
-
-
-
- Cancel
-
-
-
-
-
- Done
-
-
-
-
-
- )
-})
-
-const styles = StyleSheet.create({
- container: {
- gap: 18,
- height: '100%',
- width: '100%',
- },
- subsection: {marginTop: 12},
- gap18: {gap: 18},
- title: {
- fontWeight: '600',
- fontSize: 24,
- },
- btns: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- },
- btn: {
- borderRadius: 4,
- paddingVertical: 8,
- paddingHorizontal: 24,
- },
- imgEditor: {
- maxWidth: '100%',
- },
- imgContainer: {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- borderWidth: 1,
- borderStyle: 'solid',
- marginBottom: 4,
- },
- flipVertical: {
- transform: [{rotate: '90deg'}],
- },
- flipBtn: {
- paddingHorizontal: 4,
- paddingVertical: 8,
- },
- textArea: {
- borderWidth: 1,
- borderRadius: 6,
- paddingTop: 10,
- paddingHorizontal: 12,
- fontSize: 16,
- height: 100,
- textAlignVertical: 'top',
- },
- bottomSection: {
- borderTopWidth: 1,
- paddingTop: 18,
- },
-})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 3455e1cdf8..fd881ebc4b 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -9,7 +9,6 @@ import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
import * as AddAppPassword from './AddAppPasswords'
import * as AltImageModal from './AltImage'
-import * as EditImageModal from './AltImage'
import * as ChangeEmailModal from './ChangeEmail'
import * as ChangeHandleModal from './ChangeHandle'
import * as ChangePasswordModal from './ChangePassword'
@@ -78,9 +77,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'alt-text-image') {
snapPoints = AltImageModal.snapPoints
element =
- } else if (activeModal?.name === 'edit-image') {
- snapPoints = AltImageModal.snapPoints
- element =
} else if (activeModal?.name === 'change-handle') {
snapPoints = ChangeHandleModal.snapPoints
element =
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index c4bab6fb18..fe24695d2c 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -15,7 +15,6 @@ import * as ChangePasswordModal from './ChangePassword'
import * as CreateOrEditListModal from './CreateOrEditList'
import * as CropImageModal from './crop-image/CropImage.web'
import * as DeleteAccountModal from './DeleteAccount'
-import * as EditImageModal from './EditImage'
import * as EditProfileModal from './EditProfile'
import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
@@ -54,11 +53,7 @@ function Modal({modal}: {modal: ModalIface}) {
}
const onPressMask = () => {
- if (
- modal.name === 'crop-image' ||
- modal.name === 'edit-image' ||
- modal.name === 'alt-text-image'
- ) {
+ if (modal.name === 'crop-image' || modal.name === 'alt-text-image') {
return // dont close on mask presses during crop
}
closeModal()
@@ -95,8 +90,6 @@ function Modal({modal}: {modal: ModalIface}) {
element =
} else if (modal.name === 'alt-text-image') {
element =
- } else if (modal.name === 'edit-image') {
- element =
} else if (modal.name === 'verify-email') {
element =
} else if (modal.name === 'change-email') {
diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx
index 7d3780801a..bbb837f1fe 100644
--- a/src/view/shell/Composer.ios.tsx
+++ b/src/view/shell/Composer.ios.tsx
@@ -2,16 +2,13 @@ import React, {useLayoutEffect} from 'react'
import {Modal, View} from 'react-native'
import {StatusBar} from 'expo-status-bar'
import * as SystemUI from 'expo-system-ui'
-import {observer} from 'mobx-react-lite'
import {useComposerState} from '#/state/shell/composer'
import {atoms as a, useTheme} from '#/alf'
import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme'
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
-export const Composer = observer(function ComposerImpl({}: {
- winHeight: number
-}) {
+export function Composer({}: {winHeight: number}) {
const t = useTheme()
const state = useComposerState()
const ref = useComposerCancelRef()
@@ -42,7 +39,7 @@ export const Composer = observer(function ComposerImpl({}: {
)
-})
+}
function Providers({
children,
diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx
index 1c97df9c39..049f35d35d 100644
--- a/src/view/shell/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -1,17 +1,12 @@
import React, {useEffect} from 'react'
import {Animated, Easing, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useComposerState} from 'state/shell/composer'
+import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useComposerState} from '#/state/shell/composer'
import {ComposePost} from '../com/composer/Composer'
-export const Composer = observer(function ComposerImpl({
- winHeight,
-}: {
- winHeight: number
-}) {
+export function Composer({winHeight}: {winHeight: number}) {
const state = useComposerState()
const pal = usePalette('default')
const initInterp = useAnimatedValue(0)
@@ -62,7 +57,7 @@ export const Composer = observer(function ComposerImpl({
/>
)
-})
+}
const styles = StyleSheet.create({
wrapper: {
diff --git a/yarn.lock b/yarn.lock
index 65b24915dc..860b49daec 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -16760,21 +16760,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
-mobx-react-lite@^3.4.0:
- version "3.4.3"
- resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz#3a4c22c30bfaa8b1b2aa48d12b2ba811c0947ab7"
- integrity sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg==
-
-mobx-utils@^6.0.6:
- version "6.0.8"
- resolved "https://registry.yarnpkg.com/mobx-utils/-/mobx-utils-6.0.8.tgz#843e222c7694050c2e42842682fd24a84fdb7024"
- integrity sha512-fPNt0vJnHwbQx9MojJFEnJLfM3EMGTtpy4/qOOW6xueh1mPofMajrbYAUvByMYAvCJnpy1A5L0t+ZVB5niKO4g==
-
-mobx@^6.6.1:
- version "6.10.0"
- resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.10.0.tgz#3537680fe98d45232cc19cc8f76280bd8bb6b0b7"
- integrity sha512-WMbVpCMFtolbB8swQ5E2YRrU+Yu8iLozCVx3CdGjbBKlP7dFiCSuiG06uea3JCFN5DnvtAX7+G5Bp82e2xu0ww==
-
moo@^0.5.1:
version "0.5.2"
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c"
From ed512d6dc5390555232bb4ac3f96f477751c33b1 Mon Sep 17 00:00:00 2001
From: Mary <148872143+mary-ext@users.noreply.github.com>
Date: Tue, 24 Sep 2024 23:21:06 +0700
Subject: [PATCH 17/26] Revamp edit image alt text dialog (#5461)
* revamp alt dialog
* readd the limit check
don't trim with enforceLen, it ruins copy-pasting long text and it's overall annoying behavior
* Update src/view/com/composer/photos/ImageAltTextDialog.tsx
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
---------
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
---
src/state/modals/index.tsx | 8 -
src/view/com/composer/photos/Gallery.tsx | 14 +-
.../composer/photos/ImageAltTextDialog.tsx | 121 +++++++++++
src/view/com/modals/AltImage.tsx | 189 ------------------
src/view/com/modals/Modal.tsx | 6 +-
src/view/com/modals/Modal.web.tsx | 9 +-
6 files changed, 136 insertions(+), 211 deletions(-)
create mode 100644 src/view/com/composer/photos/ImageAltTextDialog.tsx
delete mode 100644 src/view/com/modals/AltImage.tsx
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 467853a258..9bc96cf5e4 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -3,7 +3,6 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-import {ComposerImage} from '../gallery'
export interface EditProfileModal {
name: 'edit-profile'
@@ -43,12 +42,6 @@ export interface CropImageModal {
onSelect: (img?: RNImage) => void
}
-export interface AltTextImageModal {
- name: 'alt-text-image'
- image: ComposerImage
- onChange: (next: ComposerImage) => void
-}
-
export interface DeleteAccountModal {
name: 'delete-account'
}
@@ -131,7 +124,6 @@ export type Modal =
| ListAddRemoveUsersModal
// Posts
- | AltTextImageModal
| CropImageModal
| SelfLabelModal
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 775413e817..83c1e3c809 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -18,9 +18,10 @@ import {Dimensions} from '#/lib/media/types'
import {colors, s} from '#/lib/styles'
import {isNative} from '#/platform/detection'
import {ComposerImage, cropImage} from '#/state/gallery'
-import {useModalControls} from '#/state/modals'
import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {ImageAltTextDialog} from './ImageAltTextDialog'
const IMAGE_GAP = 8
@@ -141,7 +142,8 @@ const GalleryItem = ({
}: GalleryItemProps): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
- const {openModal} = useModalControls()
+
+ const altTextControl = Dialog.useDialogControl()
const onImageEdit = () => {
if (isNative) {
@@ -153,7 +155,7 @@ const GalleryItem = ({
const onAltTextEdit = () => {
Keyboard.dismiss()
- openModal({name: 'alt-text-image', image, onChange})
+ altTextControl.open()
}
return (
@@ -229,6 +231,12 @@ const GalleryItem = ({
accessible={true}
accessibilityIgnoresInvertColors
/>
+
+
)
}
diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx
new file mode 100644
index 0000000000..123e1066a5
--- /dev/null
+++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx
@@ -0,0 +1,121 @@
+import React from 'react'
+import {ImageStyle, useWindowDimensions, View} from 'react-native'
+import {Image} from 'expo-image'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {MAX_ALT_TEXT} from '#/lib/constants'
+import {isWeb} from '#/platform/detection'
+import {ComposerImage} from '#/state/gallery'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import {Text} from '#/components/Typography'
+
+type Props = {
+ control: Dialog.DialogOuterProps['control']
+ image: ComposerImage
+ onChange: (next: ComposerImage) => void
+}
+
+export const ImageAltTextDialog = (props: Props): React.ReactNode => {
+ return (
+
+
+
+
+
+ )
+}
+
+const ImageAltTextInner = ({
+ control,
+ image,
+ onChange,
+}: Props): React.ReactNode => {
+ const {_} = useLingui()
+ const t = useTheme()
+
+ const windim = useWindowDimensions()
+
+ const [altText, setAltText] = React.useState(image.alt)
+
+ const onPressSubmit = React.useCallback(() => {
+ control.close()
+ onChange({...image, alt: altText.trim()})
+ }, [control, image, altText, onChange])
+
+ const imageStyle = React.useMemo(() => {
+ const maxWidth = isWeb ? 450 : windim.width
+ const source = image.transformed ?? image.source
+
+ if (source.height > source.width) {
+ return {
+ resizeMode: 'contain',
+ width: '100%',
+ aspectRatio: 1,
+ borderRadius: 8,
+ }
+ }
+ return {
+ width: '100%',
+ height: (maxWidth / source.width) * source.height,
+ borderRadius: 8,
+ }
+ }, [image, windim])
+
+ return (
+
+
+
+
+
+ Add alt text
+
+
+
+
+
+
+
+
+
+
+ Descriptive alt text
+
+
+ setAltText(text)}
+ value={altText}
+ multiline
+ numberOfLines={3}
+ autoFocus
+ />
+
+
+ MAX_ALT_TEXT || altText === image.alt}
+ size="large"
+ color="primary"
+ variant="solid"
+ onPress={onPressSubmit}>
+
+ Save
+
+
+
+
+ )
+}
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
deleted file mode 100644
index c711f73a57..0000000000
--- a/src/view/com/modals/AltImage.tsx
+++ /dev/null
@@ -1,189 +0,0 @@
-import React, {useCallback, useMemo, useState} from 'react'
-import {
- ImageStyle,
- ScrollView as RNScrollView,
- StyleSheet,
- TextInput as RNTextInput,
- TouchableOpacity,
- useWindowDimensions,
- View,
-} from 'react-native'
-import {Image} from 'expo-image'
-import {LinearGradient} from 'expo-linear-gradient'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {ComposerImage} from '#/state/gallery'
-import {useModalControls} from '#/state/modals'
-import {MAX_ALT_TEXT} from 'lib/constants'
-import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
-import {usePalette} from 'lib/hooks/usePalette'
-import {enforceLen} from 'lib/strings/helpers'
-import {gradients, s} from 'lib/styles'
-import {useTheme} from 'lib/ThemeContext'
-import {isAndroid, isWeb} from 'platform/detection'
-import {Text} from '../util/text/Text'
-import {ScrollView, TextInput} from './util'
-
-export const snapPoints = ['100%']
-
-interface Props {
- image: ComposerImage
- onChange: (next: ComposerImage) => void
-}
-
-export function Component({image, onChange}: Props) {
- const pal = usePalette('default')
- const theme = useTheme()
- const {_} = useLingui()
- const [altText, setAltText] = useState(image.alt)
- const windim = useWindowDimensions()
- const {closeModal} = useModalControls()
- const inputRef = React.useRef(null)
- const scrollViewRef = React.useRef(null)
- const keyboardShown = useIsKeyboardVisible()
-
- // Autofocus hack when we open the modal. We have to wait for the animation to complete first
- React.useEffect(() => {
- if (isAndroid) return
- setTimeout(() => {
- inputRef.current?.focus()
- }, 500)
- }, [])
-
- // We'd rather be at the bottom here so that we can easily dismiss the modal instead of having to scroll
- // (especially on android, it acts weird)
- React.useEffect(() => {
- if (keyboardShown[0]) {
- scrollViewRef.current?.scrollToEnd()
- }
- }, [keyboardShown])
-
- const imageStyles = useMemo(() => {
- const maxWidth = isWeb ? 450 : windim.width
- const media = image.transformed ?? image.source
- if (media.height > media.width) {
- return {
- resizeMode: 'contain',
- width: '100%',
- aspectRatio: 1,
- borderRadius: 8,
- }
- }
- return {
- width: '100%',
- height: (maxWidth / media.width) * media.height,
- borderRadius: 8,
- }
- }, [image, windim])
-
- const onUpdate = useCallback(
- (v: string) => {
- v = enforceLen(v, MAX_ALT_TEXT)
- setAltText(v)
- },
- [setAltText],
- )
-
- const onPressSave = useCallback(() => {
- onChange({
- ...image,
- alt: altText,
- })
-
- closeModal()
- }, [closeModal, image, altText, onChange])
-
- return (
-
-
-
-
-
-
-
-
-
-
- Done
-
-
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- scrollContainer: {
- flex: 1,
- height: '100%',
- paddingHorizontal: isWeb ? 0 : 12,
- paddingVertical: isWeb ? 0 : 24,
- },
- scrollInner: {
- gap: 12,
- paddingTop: isWeb ? 0 : 12,
- },
- imageContainer: {
- borderRadius: 8,
- },
- textArea: {
- borderWidth: 1,
- borderRadius: 6,
- paddingTop: 10,
- paddingHorizontal: 12,
- fontSize: 16,
- height: 100,
- textAlignVertical: 'top',
- },
- button: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- width: '100%',
- borderRadius: 32,
- padding: 10,
- },
- buttonControls: {
- gap: 8,
- paddingBottom: isWeb ? 0 : 50,
- },
-})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index fd881ebc4b..90e93821c5 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -3,12 +3,11 @@ import {StyleSheet} from 'react-native'
import {SafeAreaView} from 'react-native-safe-area-context'
import BottomSheet from '@discord/bottom-sheet/src'
+import {usePalette} from '#/lib/hooks/usePalette'
import {useModalControls, useModals} from '#/state/modals'
-import {usePalette} from 'lib/hooks/usePalette'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
import * as AddAppPassword from './AddAppPasswords'
-import * as AltImageModal from './AltImage'
import * as ChangeEmailModal from './ChangeEmail'
import * as ChangeHandleModal from './ChangeHandle'
import * as ChangePasswordModal from './ChangePassword'
@@ -74,9 +73,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'self-label') {
snapPoints = SelfLabelModal.snapPoints
element =
- } else if (activeModal?.name === 'alt-text-image') {
- snapPoints = AltImageModal.snapPoints
- element =
} else if (activeModal?.name === 'change-handle') {
snapPoints = ChangeHandleModal.snapPoints
element =
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index fe24695d2c..c1024751f8 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -2,13 +2,12 @@ import React from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
+import {usePalette} from '#/lib/hooks/usePalette'
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import type {Modal as ModalIface} from '#/state/modals'
import {useModalControls, useModals} from '#/state/modals'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import * as AddAppPassword from './AddAppPasswords'
-import * as AltTextImageModal from './AltImage'
import * as ChangeEmailModal from './ChangeEmail'
import * as ChangeHandleModal from './ChangeHandle'
import * as ChangePasswordModal from './ChangePassword'
@@ -53,7 +52,7 @@ function Modal({modal}: {modal: ModalIface}) {
}
const onPressMask = () => {
- if (modal.name === 'crop-image' || modal.name === 'alt-text-image') {
+ if (modal.name === 'crop-image') {
return // dont close on mask presses during crop
}
closeModal()
@@ -88,8 +87,6 @@ function Modal({modal}: {modal: ModalIface}) {
element =
} else if (modal.name === 'post-languages-settings') {
element =
- } else if (modal.name === 'alt-text-image') {
- element =
} else if (modal.name === 'verify-email') {
element =
} else if (modal.name === 'change-email') {
From b9516202fa17325a3d54e54372ddd56149be129c Mon Sep 17 00:00:00 2001
From: Mary <148872143+mary-ext@users.noreply.github.com>
Date: Tue, 24 Sep 2024 23:27:40 +0700
Subject: [PATCH 18/26] Revamp image editor (#5462)
* new image editor
* Rm react-avatar-editor
---------
Co-authored-by: Dan Abramov
---
package.json | 3 +-
src/lib/media/picker.web.tsx | 4 +-
src/lib/media/types.ts | 5 +-
src/state/modals/index.tsx | 2 +
.../com/composer/photos/EditImageDialog.tsx | 14 ++
.../composer/photos/EditImageDialog.web.tsx | 105 ++++++++
src/view/com/composer/photos/Gallery.tsx | 34 +--
src/view/com/modals/CropImage.web.tsx | 145 +++++++++++
src/view/com/modals/Modal.web.tsx | 2 +-
.../com/modals/crop-image/CropImage.web.tsx | 228 ------------------
.../com/modals/crop-image/cropImageUtil.ts | 13 -
src/view/com/util/UserAvatar.tsx | 18 +-
src/view/com/util/UserBanner.tsx | 15 +-
yarn.lock | 23 +-
14 files changed, 318 insertions(+), 293 deletions(-)
create mode 100644 src/view/com/composer/photos/EditImageDialog.tsx
create mode 100644 src/view/com/composer/photos/EditImageDialog.web.tsx
create mode 100644 src/view/com/modals/CropImage.web.tsx
delete mode 100644 src/view/com/modals/crop-image/CropImage.web.tsx
delete mode 100644 src/view/com/modals/crop-image/cropImageUtil.ts
diff --git a/package.json b/package.json
index 117fc0b190..e1c0f99d8e 100644
--- a/package.json
+++ b/package.json
@@ -167,9 +167,9 @@
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"react": "18.2.0",
- "react-avatar-editor": "^13.0.0",
"react-compiler-runtime": "file:./lib/react-compiler-runtime",
"react-dom": "^18.2.0",
+ "react-image-crop": "^11.0.7",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.74.1",
"react-native-compressor": "^1.8.24",
@@ -236,7 +236,6 @@
"@types/lodash.set": "^4.3.7",
"@types/lodash.shuffle": "^4.2.7",
"@types/psl": "^1.1.1",
- "@types/react-avatar-editor": "^13.0.0",
"@types/react-dom": "^18.2.18",
"@types/react-responsive": "^8.0.5",
"@types/react-test-renderer": "^17.0.1",
diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx
index 8782e14570..a53ffc9614 100644
--- a/src/lib/media/picker.web.tsx
+++ b/src/lib/media/picker.web.tsx
@@ -18,9 +18,11 @@ export async function openCropper(opts: CropperOptions): Promise {
name: 'crop-image',
uri: opts.path,
dimensions:
- opts.height && opts.width
+ opts.width && opts.height
? {width: opts.width, height: opts.height}
: undefined,
+ aspect: opts.webAspectRatio,
+ circular: opts.webCircularCrop,
onSelect: (img?: RNImage) => {
if (img) {
resolve(img)
diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts
index e6f442759f..ec94256ea1 100644
--- a/src/lib/media/types.ts
+++ b/src/lib/media/types.ts
@@ -18,4 +18,7 @@ export interface CameraOpts {
cropperCircleOverlay?: boolean
}
-export type CropperOptions = Parameters[0]
+export type CropperOptions = Parameters[0] & {
+ webAspectRatio?: number
+ webCircularCrop?: boolean
+}
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 9bc96cf5e4..5be21dfd39 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -39,6 +39,8 @@ export interface CropImageModal {
name: 'crop-image'
uri: string
dimensions?: {width: number; height: number}
+ aspect?: number
+ circular?: boolean
onSelect: (img?: RNImage) => void
}
diff --git a/src/view/com/composer/photos/EditImageDialog.tsx b/src/view/com/composer/photos/EditImageDialog.tsx
new file mode 100644
index 0000000000..4263587fd4
--- /dev/null
+++ b/src/view/com/composer/photos/EditImageDialog.tsx
@@ -0,0 +1,14 @@
+import React from 'react'
+
+import {ComposerImage} from '#/state/gallery'
+import * as Dialog from '#/components/Dialog'
+
+export type EditImageDialogProps = {
+ control: Dialog.DialogOuterProps['control']
+ image: ComposerImage
+ onChange: (next: ComposerImage) => void
+}
+
+export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
+ return null
+}
diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx
new file mode 100644
index 0000000000..0afb83ed96
--- /dev/null
+++ b/src/view/com/composer/photos/EditImageDialog.web.tsx
@@ -0,0 +1,105 @@
+import 'react-image-crop/dist/ReactCrop.css'
+
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import ReactCrop, {PercentCrop} from 'react-image-crop'
+
+import {
+ ImageSource,
+ ImageTransformation,
+ manipulateImage,
+} from '#/state/gallery'
+import {atoms as a} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Text} from '#/components/Typography'
+import {EditImageDialogProps} from './EditImageDialog'
+
+export const EditImageDialog = (props: EditImageDialogProps) => {
+ return (
+
+
+
+ )
+}
+
+const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
+ const {_} = useLingui()
+
+ const source = image.source
+
+ const initialCrop = getInitialCrop(source, image.manips)
+ const [crop, setCrop] = React.useState(initialCrop)
+
+ const isEmpty = !crop || (crop.width || crop.height) === 0
+ const isNew = initialCrop ? true : !isEmpty
+
+ const onPressSubmit = React.useCallback(async () => {
+ const result = await manipulateImage(image, {
+ crop:
+ crop && (crop.width || crop.height) !== 0
+ ? {
+ originX: (crop.x * source.width) / 100,
+ originY: (crop.y * source.height) / 100,
+ width: (crop.width * source.width) / 100,
+ height: (crop.height * source.height) / 100,
+ }
+ : undefined,
+ })
+
+ onChange(result)
+ control.close()
+ }, [crop, image, source, control, onChange])
+
+ return (
+
+
+
+
+ Edit image
+
+
+
+ setCrop(percentCrop)}
+ className="ReactCrop--no-animate">
+
+
+
+
+
+
+
+ Save
+
+
+
+
+ )
+}
+
+const getInitialCrop = (
+ source: ImageSource,
+ manips: ImageTransformation | undefined,
+): PercentCrop | undefined => {
+ const initialArea = manips?.crop
+
+ if (initialArea) {
+ return {
+ unit: '%',
+ x: (initialArea.originX / source.width) * 100,
+ y: (initialArea.originY / source.height) * 100,
+ width: (initialArea.width / source.width) * 100,
+ height: (initialArea.height / source.height) * 100,
+ }
+ }
+}
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 83c1e3c809..369f08d745 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -21,6 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery'
import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
+import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog'
const IMAGE_GAP = 8
@@ -144,12 +145,15 @@ const GalleryItem = ({
const t = useTheme()
const altTextControl = Dialog.useDialogControl()
+ const editControl = Dialog.useDialogControl()
const onImageEdit = () => {
if (isNative) {
cropImage(image).then(next => {
onChange(next)
})
+ } else {
+ editControl.open()
}
}
@@ -185,21 +189,15 @@ const GalleryItem = ({
- {isNative && (
-
-
-
- )}
+
+
+
+
+
)
}
diff --git a/src/view/com/modals/CropImage.web.tsx b/src/view/com/modals/CropImage.web.tsx
new file mode 100644
index 0000000000..41ca306573
--- /dev/null
+++ b/src/view/com/modals/CropImage.web.tsx
@@ -0,0 +1,145 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
+import {LinearGradient} from 'expo-linear-gradient'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import ReactCrop, {PercentCrop} from 'react-image-crop'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {getDataUriSize} from '#/lib/media/util'
+import {gradients, s} from '#/lib/styles'
+import {useModalControls} from '#/state/modals'
+import {Text} from '#/view/com/util/text/Text'
+
+export const snapPoints = ['0%']
+
+export function Component({
+ uri,
+ aspect,
+ circular,
+ onSelect,
+}: {
+ uri: string
+ aspect?: number
+ circular?: boolean
+ onSelect: (img?: RNImage) => void
+}) {
+ const pal = usePalette('default')
+ const {_} = useLingui()
+
+ const {closeModal} = useModalControls()
+ const {isMobile} = useWebMediaQueries()
+
+ const imageRef = React.useRef(null)
+ const [crop, setCrop] = React.useState()
+
+ const isEmpty = !crop || (crop.width || crop.height) === 0
+
+ const onPressCancel = () => {
+ onSelect(undefined)
+ closeModal()
+ }
+ const onPressDone = async () => {
+ const img = imageRef.current!
+
+ const result = await manipulateAsync(
+ uri,
+ isEmpty
+ ? []
+ : [
+ {
+ crop: {
+ originX: (crop.x * img.naturalWidth) / 100,
+ originY: (crop.y * img.naturalHeight) / 100,
+ width: (crop.width * img.naturalWidth) / 100,
+ height: (crop.height * img.naturalHeight) / 100,
+ },
+ },
+ ],
+ {
+ base64: true,
+ format: SaveFormat.JPEG,
+ },
+ )
+
+ onSelect({
+ path: result.uri,
+ mime: 'image/jpeg',
+ size: result.base64 !== undefined ? getDataUriSize(result.base64) : 0,
+ width: result.width,
+ height: result.height,
+ })
+
+ closeModal()
+ }
+
+ return (
+
+
+ setCrop(percentCrop)}
+ circularCrop={circular}>
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
+
+ Done
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ cropper: {
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ borderWidth: 1,
+ borderRadius: 4,
+ overflow: 'hidden',
+ alignItems: 'center',
+ },
+ ctrls: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 10,
+ },
+ btns: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 10,
+ },
+ btn: {
+ borderRadius: 4,
+ paddingVertical: 8,
+ paddingHorizontal: 24,
+ },
+})
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index c1024751f8..a2acc23bb9 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -12,7 +12,7 @@ import * as ChangeEmailModal from './ChangeEmail'
import * as ChangeHandleModal from './ChangeHandle'
import * as ChangePasswordModal from './ChangePassword'
import * as CreateOrEditListModal from './CreateOrEditList'
-import * as CropImageModal from './crop-image/CropImage.web'
+import * as CropImageModal from './CropImage.web'
import * as DeleteAccountModal from './DeleteAccount'
import * as EditProfileModal from './EditProfile'
import * as InviteCodesModal from './InviteCodes'
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
deleted file mode 100644
index 10cae2f174..0000000000
--- a/src/view/com/modals/crop-image/CropImage.web.tsx
+++ /dev/null
@@ -1,228 +0,0 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import {LinearGradient} from 'expo-linear-gradient'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {Slider} from '@miblanchard/react-native-slider'
-import ImageEditor from 'react-avatar-editor'
-
-import {useModalControls} from '#/state/modals'
-import {usePalette} from 'lib/hooks/usePalette'
-import {RectTallIcon, RectWideIcon, SquareIcon} from 'lib/icons'
-import {Dimensions} from 'lib/media/types'
-import {getDataUriSize} from 'lib/media/util'
-import {gradients, s} from 'lib/styles'
-import {Text} from 'view/com/util/text/Text'
-import {calculateDimensions} from './cropImageUtil'
-
-enum AspectRatio {
- Square = 'square',
- Wide = 'wide',
- Tall = 'tall',
- Custom = 'custom',
-}
-
-const DIMS: Record = {
- [AspectRatio.Square]: {width: 1000, height: 1000},
- [AspectRatio.Wide]: {width: 1000, height: 750},
- [AspectRatio.Tall]: {width: 750, height: 1000},
-}
-
-export const snapPoints = ['0%']
-
-export function Component({
- uri,
- dimensions,
- onSelect,
-}: {
- uri: string
- dimensions?: Dimensions
- onSelect: (img?: RNImage) => void
-}) {
- const {closeModal} = useModalControls()
- const pal = usePalette('default')
- const {_} = useLingui()
- const defaultAspectStyle = dimensions
- ? AspectRatio.Custom
- : AspectRatio.Square
- const [as, setAs] = React.useState(defaultAspectStyle)
- const [scale, setScale] = React.useState(1)
- const editorRef = React.useRef(null)
- const imageEditorWidth = dimensions ? dimensions.width : DIMS[as].width
- const imageEditorHeight = dimensions ? dimensions.height : DIMS[as].height
-
- const doSetAs = (v: AspectRatio) => () => setAs(v)
-
- const onPressCancel = () => {
- onSelect(undefined)
- closeModal()
- }
- const onPressDone = () => {
- const canvas = editorRef.current?.getImageScaledToCanvas()
- if (canvas) {
- const dataUri = canvas.toDataURL('image/jpeg')
- onSelect({
- path: dataUri,
- mime: 'image/jpeg',
- size: getDataUriSize(dataUri),
- width: imageEditorWidth,
- height: imageEditorHeight,
- })
- } else {
- onSelect(undefined)
- }
- closeModal()
- }
-
- let cropperStyle
- if (as === AspectRatio.Square) {
- cropperStyle = styles.cropperSquare
- } else if (as === AspectRatio.Wide) {
- cropperStyle = styles.cropperWide
- } else if (as === AspectRatio.Tall) {
- cropperStyle = styles.cropperTall
- } else if (as === AspectRatio.Custom) {
- const cropperDimensions = calculateDimensions(
- 550,
- imageEditorHeight,
- imageEditorWidth,
- )
- cropperStyle = {
- width: cropperDimensions.width,
- height: cropperDimensions.height,
- }
- }
-
- return (
-
-
-
-
-
-
- setScale(Array.isArray(v) ? v[0] : v)
- }
- minimumValue={1}
- maximumValue={3}
- containerStyle={styles.slider}
- />
- {as === AspectRatio.Custom ? null : (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
-
-
-
-
- Cancel
-
-
-
-
-
-
- Done
-
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- cropper: {
- marginLeft: 'auto',
- marginRight: 'auto',
- borderWidth: 1,
- borderRadius: 4,
- overflow: 'hidden',
- },
- cropperSquare: {
- width: 400,
- height: 400,
- },
- cropperWide: {
- width: 400,
- height: 300,
- },
- cropperTall: {
- width: 300,
- height: 400,
- },
- imageEditor: {
- maxWidth: '100%',
- },
- ctrls: {
- flexDirection: 'row',
- alignItems: 'center',
- marginTop: 10,
- },
- slider: {
- flex: 1,
- marginRight: 10,
- },
- btns: {
- flexDirection: 'row',
- alignItems: 'center',
- marginTop: 10,
- },
- btn: {
- borderRadius: 4,
- paddingVertical: 8,
- paddingHorizontal: 24,
- },
-})
diff --git a/src/view/com/modals/crop-image/cropImageUtil.ts b/src/view/com/modals/crop-image/cropImageUtil.ts
deleted file mode 100644
index 303d15ba5b..0000000000
--- a/src/view/com/modals/crop-image/cropImageUtil.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export const calculateDimensions = (
- maxWidth: number,
- originalHeight: number,
- originalWidth: number,
-) => {
- const aspectRatio = originalWidth / originalHeight
- const newHeight = maxWidth / aspectRatio
- const newWidth = maxWidth
- return {
- width: newWidth,
- height: newHeight,
- }
-}
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index b2f56c1385..76d9d1503e 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -8,17 +8,17 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
-import {logger} from '#/logger'
-import {usePalette} from 'lib/hooks/usePalette'
+import {usePalette} from '#/lib/hooks/usePalette'
import {
useCameraPermission,
usePhotoLibraryPermission,
-} from 'lib/hooks/usePermissions'
-import {makeProfileLink} from 'lib/routes/links'
-import {colors} from 'lib/styles'
-import {isAndroid, isNative, isWeb} from 'platform/detection'
-import {precacheProfile} from 'state/queries/profile'
-import {HighPriorityImage} from 'view/com/util/images/Image'
+} from '#/lib/hooks/usePermissions'
+import {makeProfileLink} from '#/lib/routes/links'
+import {colors} from '#/lib/styles'
+import {logger} from '#/logger'
+import {isAndroid, isNative, isWeb} from '#/platform/detection'
+import {precacheProfile} from '#/state/queries/profile'
+import {HighPriorityImage} from '#/view/com/util/images/Image'
import {tokens, useTheme} from '#/alf'
import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
@@ -321,6 +321,8 @@ let EditableUserAvatar = ({
height: 1000,
width: 1000,
path: item.path,
+ webAspectRatio: 1,
+ webCircularCrop: true,
})
onSelectNewAvatar(croppedImage)
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 93ea32750d..13f4081fce 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -6,16 +6,16 @@ import {ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {logger} from '#/logger'
-import {usePalette} from 'lib/hooks/usePalette'
+import {usePalette} from '#/lib/hooks/usePalette'
import {
useCameraPermission,
usePhotoLibraryPermission,
-} from 'lib/hooks/usePermissions'
-import {colors} from 'lib/styles'
-import {useTheme} from 'lib/ThemeContext'
-import {isAndroid, isNative} from 'platform/detection'
-import {EventStopper} from 'view/com/util/EventStopper'
+} from '#/lib/hooks/usePermissions'
+import {colors} from '#/lib/styles'
+import {useTheme} from '#/lib/ThemeContext'
+import {logger} from '#/logger'
+import {isAndroid, isNative} from '#/platform/detection'
+import {EventStopper} from '#/view/com/util/EventStopper'
import {tokens, useTheme as useAlfTheme} from '#/alf'
import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
@@ -72,6 +72,7 @@ export function UserBanner({
path: items[0].path,
width: 3000,
height: 1000,
+ webAspectRatio: 3,
}),
)
} catch (e: any) {
diff --git a/yarn.lock b/yarn.lock
index 860b49daec..f3d6ae5fd3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2570,7 +2570,7 @@
dependencies:
"@babel/helper-plugin-utils" "^7.22.5"
-"@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.12.1", "@babel/plugin-transform-runtime@^7.16.4":
+"@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.16.4":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.22.10.tgz#89eda6daf1d3af6f36fb368766553054c8d7cd46"
integrity sha512-RchI7HePu1eu0CYNKHHHQdfenZcM4nz8rew5B1VWqeRKdcwW5aQ5HeG9eTUbWiAS1UrmHVLmoxTWHt3iLD/NhA==
@@ -8262,13 +8262,6 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
-"@types/react-avatar-editor@^13.0.0":
- version "13.0.0"
- resolved "https://registry.yarnpkg.com/@types/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#5963e16c931746c47e478d669dd72d388b427393"
- integrity sha512-5ymOayy6mfT35xTqzni7UjXvCNEg8/pH4pI5RenITp9PBc02KGTYjSV1WboXiQDYSh5KomLT0ngBLEAIhV1QoQ==
- dependencies:
- "@types/react" "*"
-
"@types/react-dom@^18.2.18":
version "18.2.18"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd"
@@ -18935,15 +18928,6 @@ react-app-polyfill@^3.0.0:
regenerator-runtime "^0.13.9"
whatwg-fetch "^3.6.2"
-react-avatar-editor@^13.0.0:
- version "13.0.0"
- resolved "https://registry.yarnpkg.com/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#55013625ee9ae715c1fe2dc553b8079994d8a5f2"
- integrity sha512-0xw63MbRRQdDy7YI1IXU9+7tTFxYEFLV8CABvryYOGjZmXRTH2/UA0mafe57ns62uaEFX181kA4XlGlxCaeXKA==
- dependencies:
- "@babel/plugin-transform-runtime" "^7.12.1"
- "@babel/runtime" "^7.12.5"
- prop-types "^15.7.2"
-
"react-compiler-runtime@file:./lib/react-compiler-runtime":
version "0.0.1"
@@ -19003,6 +18987,11 @@ react-freeze@^1.0.0:
resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.3.tgz#5e3ca90e682fed1d73a7cb50c2c7402b3e85618d"
integrity sha512-ZnXwLQnGzrDpHBHiC56TXFXvmolPeMjTn1UOm610M4EXGzbEDR7oOIyS2ZiItgbs6eZc4oU/a0hpk8PrcKvv5g==
+react-image-crop@^11.0.7:
+ version "11.0.7"
+ resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-11.0.7.tgz#25f3d37ccbb65a05d19d23b4740a5912835c741e"
+ integrity sha512-ZciKWHDYzmm366JDL18CbrVyjnjH0ojufGDmScfS4ZUqLHg4nm6ATY+K62C75W4ZRNt4Ii+tX0bSjNk9LQ2xzQ==
+
"react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0, react-is@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
From d2fae81b33ae0a73d0b9f87700365d60bc51f094 Mon Sep 17 00:00:00 2001
From: Hailey
Date: Tue, 24 Sep 2024 09:28:12 -0700
Subject: [PATCH 19/26] Remove `react-native-fs` (#5463)
* remove rnfs
* tweak e2e
* log
* use `safeDeleteAsync`
---
package.json | 1 -
src/lib/api/upload-blob.ts | 8 +++++---
src/lib/media/picker.e2e.tsx | 34 +++++++++++++++++++++++-----------
yarn.lock | 15 +--------------
4 files changed, 29 insertions(+), 29 deletions(-)
diff --git a/package.json b/package.json
index e1c0f99d8e..5b2369d2a1 100644
--- a/package.json
+++ b/package.json
@@ -175,7 +175,6 @@
"react-native-compressor": "^1.8.24",
"react-native-date-picker": "^4.4.2",
"react-native-drawer-layout": "^4.0.0-alpha.3",
- "react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.16.2",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "0.41.2",
diff --git a/src/lib/api/upload-blob.ts b/src/lib/api/upload-blob.ts
index 0814d5185b..07aeaf1a7e 100644
--- a/src/lib/api/upload-blob.ts
+++ b/src/lib/api/upload-blob.ts
@@ -1,6 +1,8 @@
-import RNFS from 'react-native-fs'
+import {copyAsync} from 'expo-file-system'
import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api'
+import {safeDeleteAsync} from '#/lib/media/manip'
+
/**
* @param encoding Allows overriding the blob's type
*/
@@ -65,7 +67,7 @@ async function withSafeFile(
// temporary file).
const newPath = uri.replace(/\.jpe?g$/, '.bin')
try {
- await RNFS.copyFile(uri, newPath)
+ await copyAsync({from: uri, to: newPath})
} catch {
// Failed to copy the file, just use the original
return await fn(uri)
@@ -74,7 +76,7 @@ async function withSafeFile(
return await fn(newPath)
} finally {
// Remove the temporary file
- await RNFS.unlink(newPath)
+ await safeDeleteAsync(newPath)
}
} else {
return fn(uri)
diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx
index e6b46ba774..fc6fcde45e 100644
--- a/src/lib/media/picker.e2e.tsx
+++ b/src/lib/media/picker.e2e.tsx
@@ -1,25 +1,37 @@
-import RNFS from 'react-native-fs'
import {
Image as RNImage,
openCropper as openCropperFn,
} from 'react-native-image-crop-picker'
+import {
+ documentDirectory,
+ getInfoAsync,
+ readDirectoryAsync,
+} from 'expo-file-system'
import {compressIfNeeded} from './manip'
import {CropperOptions} from './types'
async function getFile() {
- let files = await RNFS.readDir(
- RNFS.LibraryDirectoryPath.split('/')
- .slice(0, -5)
- .concat(['Media', 'DCIM', '100APPLE'])
- .join('/'),
- )
- files = files.filter(file => file.path.endsWith('.JPG'))
- const file = files[0]
+ const imagesDir = documentDirectory!
+ .split('/')
+ .slice(0, -6)
+ .concat(['Media', 'DCIM', '100APPLE'])
+ .join('/')
+
+ let files = await readDirectoryAsync(imagesDir)
+ files = files.filter(file => file.endsWith('.JPG'))
+ const file = `${imagesDir}/${files[0]}`
+
+ const fileInfo = await getInfoAsync(file)
+
+ if (!fileInfo.exists) {
+ throw new Error('Failed to get file info')
+ }
+
return await compressIfNeeded({
- path: file.path,
+ path: file,
mime: 'image/jpeg',
- size: file.size,
+ size: fileInfo.size,
width: 4288,
height: 2848,
})
diff --git a/yarn.lock b/yarn.lock
index f3d6ae5fd3..225f109f74 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9540,7 +9540,7 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
-base-64@0.1.0, base-64@^0.1.0:
+base-64@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb"
integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==
@@ -19038,14 +19038,6 @@ react-native-drawer-layout@^4.0.0-alpha.3:
dependencies:
use-latest-callback "^0.1.9"
-react-native-fs@^2.20.0:
- version "2.20.0"
- resolved "https://registry.yarnpkg.com/react-native-fs/-/react-native-fs-2.20.0.tgz#05a9362b473bfc0910772c0acbb73a78dbc810f6"
- integrity sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ==
- dependencies:
- base-64 "^0.1.0"
- utf8 "^3.0.0"
-
react-native-gesture-handler@~2.16.2:
version "2.16.2"
resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.16.2.tgz#032bd2a07334292d7f6cff1dc9d1ec928f72e26d"
@@ -21830,11 +21822,6 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0"
tslib "^2.0.0"
-utf8@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/utf8/-/utf8-3.0.0.tgz#f052eed1364d696e769ef058b183df88c87f69d1"
- integrity sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==
-
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
From ea43d20c61547523e34ae864ca4ddffdedd8dfb1 Mon Sep 17 00:00:00 2001
From: Hailey
Date: Tue, 24 Sep 2024 10:15:33 -0700
Subject: [PATCH 20/26] Remove image resizer (#5464)
---
__tests__/lib/images.test.ts | 128 +++++++++---------
jest/jestSetup.js | 12 +-
package.json | 1 -
src/lib/media/manip.ts | 74 +++++++---
src/view/com/composer/useExternalLinkFetch.ts | 26 ++--
yarn.lock | 5 -
6 files changed, 147 insertions(+), 99 deletions(-)
diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts
index 595f566c47..a5acad25f6 100644
--- a/__tests__/lib/images.test.ts
+++ b/__tests__/lib/images.test.ts
@@ -1,26 +1,30 @@
-import ImageResizer from '@bam.tech/react-native-image-resizer'
+import {deleteAsync} from 'expo-file-system'
+import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import RNFetchBlob from 'rn-fetch-blob'
import {
downloadAndResize,
DownloadAndResizeOpts,
+ getResizedDimensions,
} from '../../src/lib/media/manip'
+const mockResizedImage = {
+ path: 'file://resized-image.jpg',
+ size: 100,
+ width: 100,
+ height: 100,
+ mime: 'image/jpeg',
+}
+
describe('downloadAndResize', () => {
const errorSpy = jest.spyOn(global.console, 'error')
- const mockResizedImage = {
- path: jest.fn().mockReturnValue('file://resized-image.jpg'),
- size: 100,
- width: 50,
- height: 50,
- mime: 'image/jpeg',
- }
-
beforeEach(() => {
- const mockedCreateResizedImage =
- ImageResizer.createResizedImage as jest.Mock
- mockedCreateResizedImage.mockResolvedValue(mockResizedImage)
+ const mockedCreateResizedImage = manipulateAsync as jest.Mock
+ mockedCreateResizedImage.mockResolvedValue({
+ uri: 'file://resized-image.jpg',
+ ...mockResizedImage,
+ })
})
afterEach(() => {
@@ -54,17 +58,17 @@ describe('downloadAndResize', () => {
'GET',
'https://example.com/image.jpg',
)
- expect(ImageResizer.createResizedImage).toHaveBeenCalledWith(
- 'file://downloaded-image.jpg',
- 100,
- 100,
- 'JPEG',
- 100,
- undefined,
- undefined,
- undefined,
- {mode: 'cover'},
+
+ // First time it gets called is to get dimensions
+ expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
+ expect(manipulateAsync).toHaveBeenCalledWith(
+ expect.any(String),
+ [{resize: {height: opts.height, width: opts.width}}],
+ {format: SaveFormat.JPEG, compress: 1.0},
)
+ expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
+ idempotent: true,
+ })
})
it('should return undefined for invalid URI', async () => {
@@ -82,46 +86,6 @@ describe('downloadAndResize', () => {
expect(result).toBeUndefined()
})
- it('should return undefined for unsupported file type', async () => {
- const mockedFetch = RNFetchBlob.fetch as jest.Mock
- mockedFetch.mockResolvedValueOnce({
- path: jest.fn().mockReturnValue('file://downloaded-image'),
- info: jest.fn().mockReturnValue({status: 200}),
- flush: jest.fn(),
- })
-
- const opts: DownloadAndResizeOpts = {
- uri: 'https://example.com/image',
- width: 100,
- height: 100,
- maxSize: 500000,
- mode: 'cover',
- timeout: 10000,
- }
-
- const result = await downloadAndResize(opts)
- expect(result).toEqual(mockResizedImage)
- expect(RNFetchBlob.config).toHaveBeenCalledWith({
- fileCache: true,
- appendExt: 'jpeg',
- })
- expect(RNFetchBlob.fetch).toHaveBeenCalledWith(
- 'GET',
- 'https://example.com/image',
- )
- expect(ImageResizer.createResizedImage).toHaveBeenCalledWith(
- 'file://downloaded-image',
- 100,
- 100,
- 'JPEG',
- 100,
- undefined,
- undefined,
- undefined,
- {mode: 'cover'},
- )
- })
-
it('should return undefined for non-200 response', async () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
@@ -143,4 +107,44 @@ describe('downloadAndResize', () => {
expect(errorSpy).not.toHaveBeenCalled()
expect(result).toBeUndefined()
})
+
+ it('should not downsize whenever dimensions are below the max dimensions', () => {
+ const initialDimensionsOne = {
+ width: 1200,
+ height: 1000,
+ }
+ const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
+
+ const initialDimensionsTwo = {
+ width: 1000,
+ height: 1200,
+ }
+ const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
+
+ expect(resizedDimensionsOne).toEqual(initialDimensionsOne)
+ expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo)
+ })
+
+ it('should resize dimensions and maintain aspect ratio if they are above the max dimensons', () => {
+ const initialDimensionsOne = {
+ width: 3000,
+ height: 1500,
+ }
+ const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
+
+ const initialDimensionsTwo = {
+ width: 2000,
+ height: 4000,
+ }
+ const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
+
+ expect(resizedDimensionsOne).toEqual({
+ width: 2000,
+ height: 1000,
+ })
+ expect(resizedDimensionsTwo).toEqual({
+ width: 1000,
+ height: 2000,
+ })
+ })
})
diff --git a/jest/jestSetup.js b/jest/jestSetup.js
index a68c1dc4bf..50a33589ea 100644
--- a/jest/jestSetup.js
+++ b/jest/jestSetup.js
@@ -42,8 +42,16 @@ jest.mock('rn-fetch-blob', () => ({
fetch: jest.fn(),
}))
-jest.mock('@bam.tech/react-native-image-resizer', () => ({
- createResizedImage: jest.fn(),
+jest.mock('expo-file-system', () => ({
+ getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
+ deleteAsync: jest.fn(),
+}))
+
+jest.mock('expo-image-manipulator', () => ({
+ manipulateAsync: jest.fn().mockResolvedValue({
+ uri: 'file://resized-image',
+ }),
+ SaveFormat: jest.requireActual('expo-image-manipulator').SaveFormat,
}))
jest.mock('@segment/analytics-react-native', () => ({
diff --git a/package.json b/package.json
index 5b2369d2a1..4b3486545e 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,6 @@
},
"dependencies": {
"@atproto/api": "^0.13.7",
- "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts
index 3f01e98c5e..e75f13755f 100644
--- a/src/lib/media/manip.ts
+++ b/src/lib/media/manip.ts
@@ -6,18 +6,20 @@ import {
copyAsync,
deleteAsync,
EncodingType,
+ getInfoAsync,
makeDirectoryAsync,
StorageAccessFramework,
writeAsStringAsync,
} from 'expo-file-system'
+import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import * as MediaLibrary from 'expo-media-library'
import * as Sharing from 'expo-sharing'
-import ImageResizer from '@bam.tech/react-native-image-resizer'
import {Buffer} from 'buffer'
import RNFetchBlob from 'rn-fetch-blob'
+import {POST_IMG_MAX} from '#/lib/constants'
import {logger} from '#/logger'
-import {isAndroid, isIOS} from 'platform/detection'
+import {isAndroid, isIOS} from '#/platform/detection'
import {Dimensions} from './types'
export async function compressIfNeeded(
@@ -165,29 +167,47 @@ interface DoResizeOpts {
}
async function doResize(localUri: string, opts: DoResizeOpts): Promise {
+ // We need to get the dimensions of the image before we resize it. Previously, the library we used allowed us to enter
+ // a "max size", and it would do the "best possible size" calculation for us.
+ // Now instead, we have to supply the final dimensions to the manipulation function instead.
+ // Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize()
+ // does not work for local files...
+ const imageRes = await manipulateAsync(localUri, [], {})
+ const newDimensions = getResizedDimensions({
+ width: imageRes.width,
+ height: imageRes.height,
+ })
+
for (let i = 0; i < 9; i++) {
- const quality = 100 - i * 10
- const resizeRes = await ImageResizer.createResizedImage(
+ // nearest 10th
+ const quality = Math.round((1 - 0.1 * i) * 10) / 10
+ const resizeRes = await manipulateAsync(
localUri,
- opts.width,
- opts.height,
- 'JPEG',
- quality,
- undefined,
- undefined,
- undefined,
- {mode: opts.mode},
+ [{resize: newDimensions}],
+ {
+ format: SaveFormat.JPEG,
+ compress: quality,
+ },
)
- if (resizeRes.size < opts.maxSize) {
+
+ const fileInfo = await getInfoAsync(resizeRes.uri)
+ if (!fileInfo.exists) {
+ throw new Error(
+ 'The image manipulation library failed to create a new image.',
+ )
+ }
+
+ if (fileInfo.size < opts.maxSize) {
+ safeDeleteAsync(imageRes.uri)
return {
- path: normalizePath(resizeRes.path),
+ path: normalizePath(resizeRes.uri),
mime: 'image/jpeg',
- size: resizeRes.size,
+ size: fileInfo.size,
width: resizeRes.width,
height: resizeRes.height,
}
} else {
- safeDeleteAsync(resizeRes.path)
+ safeDeleteAsync(resizeRes.uri)
}
}
throw new Error(
@@ -311,3 +331,25 @@ async function withTempFile(
safeDeleteAsync(tmpDirUri)
}
}
+
+export function getResizedDimensions(originalDims: {
+ width: number
+ height: number
+}) {
+ if (
+ originalDims.width <= POST_IMG_MAX.width &&
+ originalDims.height <= POST_IMG_MAX.height
+ ) {
+ return originalDims
+ }
+
+ const ratio = Math.min(
+ POST_IMG_MAX.width / originalDims.width,
+ POST_IMG_MAX.height / originalDims.height,
+ )
+
+ return {
+ width: Math.round(originalDims.width * ratio),
+ height: Math.round(originalDims.height * ratio),
+ }
+}
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index 1a36b50348..60afadefea 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -2,23 +2,18 @@ import {useEffect, useState} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {logger} from '#/logger'
-import {createComposerImage} from '#/state/gallery'
-import {useFetchDid} from '#/state/queries/handle'
-import {useGetPost} from '#/state/queries/post'
-import {useAgent} from '#/state/session'
-import * as apilib from 'lib/api/index'
-import {POST_IMG_MAX} from 'lib/constants'
+import * as apilib from '#/lib/api/index'
+import {POST_IMG_MAX} from '#/lib/constants'
import {
EmbeddingDisabledError,
getFeedAsEmbed,
getListAsEmbed,
getPostAsQuote,
getStarterPackAsEmbed,
-} from 'lib/link-meta/bsky'
-import {getLinkMeta} from 'lib/link-meta/link-meta'
-import {resolveShortLink} from 'lib/link-meta/resolve-short-link'
-import {downloadAndResize} from 'lib/media/manip'
+} from '#/lib/link-meta/bsky'
+import {getLinkMeta} from '#/lib/link-meta/link-meta'
+import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
+import {downloadAndResize} from '#/lib/media/manip'
import {
isBskyCustomFeedUrl,
isBskyListUrl,
@@ -26,8 +21,13 @@ import {
isBskyStarterPackUrl,
isBskyStartUrl,
isShortLink,
-} from 'lib/strings/url-helpers'
-import {ComposerOpts} from 'state/shell/composer'
+} from '#/lib/strings/url-helpers'
+import {logger} from '#/logger'
+import {createComposerImage} from '#/state/gallery'
+import {useFetchDid} from '#/state/queries/handle'
+import {useGetPost} from '#/state/queries/post'
+import {useAgent} from '#/state/session'
+import {ComposerOpts} from '#/state/shell/composer'
export function useExternalLinkFetch({
setQuote,
diff --git a/yarn.lock b/yarn.lock
index 225f109f74..17fe862372 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2983,11 +2983,6 @@
"@babel/helper-validator-identifier" "^7.24.6"
to-fast-properties "^2.0.0"
-"@bam.tech/react-native-image-resizer@^3.0.4":
- version "3.0.5"
- resolved "https://registry.yarnpkg.com/@bam.tech/react-native-image-resizer/-/react-native-image-resizer-3.0.5.tgz#6661ba020de156268f73bdc92fbb93ef86f88a13"
- integrity sha512-u5QGUQGGVZiVCJ786k9/kd7pPRZ6eYfJCYO18myVCH8FbVI7J8b5GT2Svjj2x808DlWeqfaZOOzxPqo27XYvrQ==
-
"@bcoe/v8-coverage@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
From 6338083a73847a08dcbcfaa9497f5771448dd9e1 Mon Sep 17 00:00:00 2001
From: Hailey
Date: Tue, 24 Sep 2024 13:25:05 -0700
Subject: [PATCH 21/26] [React Native] Patch `RCTFileReaderModule`
`readAsDataURL` to prevent crash when `type` is `nil` (#5475)
---
patches/react-native+0.74.1.patch | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/patches/react-native+0.74.1.patch b/patches/react-native+0.74.1.patch
index 789ba84ace..c91e88c3e8 100644
--- a/patches/react-native+0.74.1.patch
+++ b/patches/react-native+0.74.1.patch
@@ -1,5 +1,18 @@
+diff --git a/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm b/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm
+index caa5540..6027825 100644
+--- a/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm
++++ b/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm
+@@ -71,7 +71,7 @@ @implementation RCTFileReaderModule
+ [NSString stringWithFormat:@"Unable to resolve data for blob: %@", [RCTConvert NSString:blob[@"blobId"]]],
+ nil);
+ } else {
+- NSString *type = [RCTConvert NSString:blob[@"type"]];
++ NSString *type = RCTNilIfNull([RCTConvert NSString:blob[@"type"]]);
+ NSString *text = [NSString stringWithFormat:@"data:%@;base64,%@",
+ type != nil && [type length] > 0 ? type : @"application/octet-stream",
+ [data base64EncodedStringWithOptions:0]];
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
-index b0d71dc..9974932 100644
+index b0d71dc..41b9a0e 100644
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
@@ -377,10 +377,6 @@ - (void)textInputDidBeginEditing
@@ -36,7 +49,7 @@ index e9b330f..1ecdf0a 100644
+
@end
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
-index b09e653..4c32b31 100644
+index b09e653..f93cb46 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
From 4f0217403d2812971f8ae89c4adb8c2419d978f0 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Tue, 24 Sep 2024 21:26:48 +0100
Subject: [PATCH 22/26] add a bunch of settings-related icons (#5471)
---
assets/icons/accessibility_stroke2_corner2_rounded.svg | 1 +
assets/icons/arrowBoxLeft_stroke2_corner2_rounded.svg | 1 +
assets/icons/at_stroke2_corner2_rounded.svg | 1 +
assets/icons/birthdayCake_stroke2_corner2_rounded.svg | 1 +
assets/icons/bubbleInfo_stroke2_corner2_rounded.svg | 1 +
assets/icons/circleQuestion_stroke2_corner2_rounded.svg | 1 +
assets/icons/envelope_stroke2_corner2_rounded.svg | 1 +
assets/icons/eye_stroke2_corner2_rounded.svg | 1 +
assets/icons/lock_stroke2_corner2_rounded.svg | 1 +
assets/icons/paintRoller_stroke2_corner2_rounded.svg | 1 +
assets/icons/person_stroke2_corner2_rounded.svg | 1 +
assets/icons/trash_stroke2_corner2_rounded.svg | 1 +
assets/icons/verified_stroke2_corner2_rounded.svg | 1 +
assets/icons/window_stroke2_corner2_rounded.svg | 1 +
src/components/icons/Accessibility.tsx | 5 +++++
src/components/icons/ArrowBoxLeft.tsx | 4 ++++
src/components/icons/At.tsx | 6 +++++-
src/components/icons/BirthdayCake.tsx | 5 +++++
src/components/icons/BubbleInfo.tsx | 5 +++++
src/components/icons/CircleQuestion.tsx | 5 +++++
src/components/icons/Envelope.tsx | 4 ++++
src/components/icons/Eye.tsx | 4 ++++
src/components/icons/Lock.tsx | 4 ++++
src/components/icons/PaintRoller.tsx | 5 +++++
src/components/icons/Person.tsx | 4 ++++
src/components/icons/Trash.tsx | 4 ++++
src/components/icons/Verified.tsx | 5 +++++
src/components/icons/Window.tsx | 5 +++++
28 files changed, 78 insertions(+), 1 deletion(-)
create mode 100644 assets/icons/accessibility_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/arrowBoxLeft_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/at_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/birthdayCake_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/bubbleInfo_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/circleQuestion_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/envelope_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/eye_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/lock_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/paintRoller_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/person_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/trash_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/verified_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/window_stroke2_corner2_rounded.svg
create mode 100644 src/components/icons/Accessibility.tsx
create mode 100644 src/components/icons/BirthdayCake.tsx
create mode 100644 src/components/icons/BubbleInfo.tsx
create mode 100644 src/components/icons/CircleQuestion.tsx
create mode 100644 src/components/icons/PaintRoller.tsx
create mode 100644 src/components/icons/Verified.tsx
create mode 100644 src/components/icons/Window.tsx
diff --git a/assets/icons/accessibility_stroke2_corner2_rounded.svg b/assets/icons/accessibility_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..62184bd8d9
--- /dev/null
+++ b/assets/icons/accessibility_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/arrowBoxLeft_stroke2_corner2_rounded.svg b/assets/icons/arrowBoxLeft_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..ea9afbc60f
--- /dev/null
+++ b/assets/icons/arrowBoxLeft_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/at_stroke2_corner2_rounded.svg b/assets/icons/at_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..37ccbda238
--- /dev/null
+++ b/assets/icons/at_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/birthdayCake_stroke2_corner2_rounded.svg b/assets/icons/birthdayCake_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..542e1552f4
--- /dev/null
+++ b/assets/icons/birthdayCake_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/bubbleInfo_stroke2_corner2_rounded.svg b/assets/icons/bubbleInfo_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..2cc08924f8
--- /dev/null
+++ b/assets/icons/bubbleInfo_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/circleQuestion_stroke2_corner2_rounded.svg b/assets/icons/circleQuestion_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..a534f98716
--- /dev/null
+++ b/assets/icons/circleQuestion_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/envelope_stroke2_corner2_rounded.svg b/assets/icons/envelope_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..39331f8a12
--- /dev/null
+++ b/assets/icons/envelope_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/eye_stroke2_corner2_rounded.svg b/assets/icons/eye_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..81e31ba032
--- /dev/null
+++ b/assets/icons/eye_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/lock_stroke2_corner2_rounded.svg b/assets/icons/lock_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..8e34c3b05c
--- /dev/null
+++ b/assets/icons/lock_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/paintRoller_stroke2_corner2_rounded.svg b/assets/icons/paintRoller_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..3ebb36aa82
--- /dev/null
+++ b/assets/icons/paintRoller_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/person_stroke2_corner2_rounded.svg b/assets/icons/person_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..7088c2880c
--- /dev/null
+++ b/assets/icons/person_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/trash_stroke2_corner2_rounded.svg b/assets/icons/trash_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..e97dfe90c4
--- /dev/null
+++ b/assets/icons/trash_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/verified_stroke2_corner2_rounded.svg b/assets/icons/verified_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..048b2816e3
--- /dev/null
+++ b/assets/icons/verified_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/window_stroke2_corner2_rounded.svg b/assets/icons/window_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..859c00c4a5
--- /dev/null
+++ b/assets/icons/window_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/icons/Accessibility.tsx b/src/components/icons/Accessibility.tsx
new file mode 100644
index 0000000000..1e5ec0c090
--- /dev/null
+++ b/src/components/icons/Accessibility.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Accessibility_Stroke2_Corner2_Rounded = createSinglePathSVG({
+ path: 'M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm8-10C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 7.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm-2.86.26.014.002c.944.125 1.893.238 2.846.238.95 0 1.904-.113 2.846-.238l.014-.002h.003a1 1 0 0 1 .273 1.98l-.006.002-.017.002c-.67.089-1.341.162-2.014.21.195 1.32.65 2.33 1.626 3.357a1 1 0 0 1-1.45 1.378 8.3 8.3 0 0 1-1.234-1.647 8.2 8.2 0 0 1-1.342 1.673 1 1 0 0 1-1.398-1.43c.673-.658 1.088-1.274 1.342-1.922.163-.42.269-.878.32-1.404a33 33 0 0 1-2.075-.215l-.017-.002-.006-.001a1 1 0 0 1 .271-1.982l.004.001Z',
+})
diff --git a/src/components/icons/ArrowBoxLeft.tsx b/src/components/icons/ArrowBoxLeft.tsx
index 011bf6afa3..82e0d6e7f6 100644
--- a/src/components/icons/ArrowBoxLeft.tsx
+++ b/src/components/icons/ArrowBoxLeft.tsx
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z',
})
+
+export const ArrowBoxLeft_Stroke2_Corner2_Rounded = createSinglePathSVG({
+ path: 'M6 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h5.25a1 1 0 1 1 0 2H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h5.25a1 1 0 1 1 0 2H6Zm8.793 1.793a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/components/icons/At.tsx b/src/components/icons/At.tsx
index 2487250545..ef0d1003f1 100644
--- a/src/components/icons/At.tsx
+++ b/src/components/icons/At.tsx
@@ -1,5 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
export const At_Stroke2_Corner0_Rounded = createSinglePathSVG({
- path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z',
+ path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.2 4.2 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458s2.514-4.586 5.044-4.23c.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.22 2.22 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529s.784 3.03 1.98 3.198 2.543-.819 2.784-2.529-.784-3.03-1.98-3.198Z',
+})
+
+export const At_Stroke2_Corner2_Rounded = createSinglePathSVG({
+ path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.2 4.2 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458s2.514-4.586 5.044-4.23c.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.22 2.22 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529s.784 3.03 1.98 3.198 2.544-.819 2.784-2.529-.784-3.03-1.98-3.198Z',
})
diff --git a/src/components/icons/BirthdayCake.tsx b/src/components/icons/BirthdayCake.tsx
new file mode 100644
index 0000000000..8e41cbac11
--- /dev/null
+++ b/src/components/icons/BirthdayCake.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const BirthdayCake_Stroke2_Corner2_Rounded = createSinglePathSVG({
+ path: 'm12 .757 2.122 2.122A3 3 0 0 1 13 7.829V9h4.5a3 3 0 0 1 3 3v1.646c0 .603-.18 1.177-.5 1.658V19a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-3.696a3 3 0 0 1-.5-1.658V12a3 3 0 0 1 3-3H11V7.829a3 3 0 0 1-1.121-4.95L12 .757ZM6.5 11a1 1 0 0 0-1 1v1.646a1 1 0 0 0 .629.928l.5.2a1 1 0 0 0 .742 0l1.015-.405a3 3 0 0 1 2.228 0l1.015.405a1 1 0 0 0 .742 0l1.015-.405a3 3 0 0 1 2.228 0l1.015.405a1 1 0 0 0 .742 0l.5-.2a1 1 0 0 0 .629-.928V12a1 1 0 0 0-1-1h-11ZM6 16.674V19a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2.326a3 3 0 0 1-2.114-.043l-1.015-.405a1 1 0 0 0-.742 0l-1.015.405a3 3 0 0 1-2.228 0l-1.015-.405a1 1 0 0 0-.742 0l-1.015.405A3 3 0 0 1 6 16.674ZM12.002 6a1 1 0 0 0 .706-1.707L12 3.586l-.707.707A1 1 0 0 0 12.002 6Z',
+})
diff --git a/src/components/icons/BubbleInfo.tsx b/src/components/icons/BubbleInfo.tsx
new file mode 100644
index 0000000000..2865713743
--- /dev/null
+++ b/src/components/icons/BubbleInfo.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const BubbleInfo_Stroke2_Corner2_Rounded = createSinglePathSVG({
+ path: 'M6.002 5h12a1 1 0 0 1 1 1v10.036a1 1 0 0 1-1 1h-2.626a2 2 0 0 0-1.276.46l-2.098 1.738-2.065-1.731a2 2 0 0 0-1.285-.467h-2.65a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm12-2h-12a3 3 0 0 0-3 3v10.036a3 3 0 0 0 3 3h2.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h2.626a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3ZM13 11.75a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2ZM12 10a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
+})
diff --git a/src/components/icons/CircleQuestion.tsx b/src/components/icons/CircleQuestion.tsx
new file mode 100644
index 0000000000..4eb369379b
--- /dev/null
+++ b/src/components/icons/CircleQuestion.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const CircleQuestion_Stroke2_Corner2_Rounded = createSinglePathSVG({
+ path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Z" clip-rule="evenodd"/> 0 ? type : @"application/octet-stream",
+- type != nil && [type length] > 0 ? type : @"application/octet-stream",
++ ![type isEqual:[NSNull null]] && [type length] > 0 ? type : @"application/octet-stream",
[data base64EncodedStringWithOptions:0]];
+
+ resolve(text);
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
index b0d71dc..41b9a0e 100644
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
From 2429d5d1ae51fa21d46970263fa581fd2eb19cdd Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Tue, 24 Sep 2024 18:10:32 -0500
Subject: [PATCH 25/26] Fix composer jumpiness on native (#5476)
---
.../com/composer/text-input/TextInput.tsx | 50 ++++++++-----------
1 file changed, 20 insertions(+), 30 deletions(-)
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 95c57ad899..3df9cfca47 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -8,7 +8,7 @@ import React, {
} from 'react'
import {
NativeSyntheticEvent,
- StyleSheet,
+ Text as RNText,
TextInput as RNTextInput,
TextInputSelectionChangeEventData,
View,
@@ -20,18 +20,16 @@ import PasteInput, {
} from '@mattermost/react-native-paste-input'
import {POST_IMG_MAX} from '#/lib/constants'
-import {usePalette} from '#/lib/hooks/usePalette'
import {downloadAndResize} from '#/lib/media/manip'
import {isUriImage} from '#/lib/media/util'
import {cleanError} from '#/lib/strings/errors'
import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
import {useTheme} from '#/lib/ThemeContext'
-import {isAndroid} from '#/platform/detection'
+import {isAndroid, isNative} from '#/platform/detection'
import {
LinkFacetMatch,
suggestLinkCardUri,
} from '#/view/com/composer/text-input/text-input-util'
-import {Text} from '#/view/com/util/text/Text'
import {atoms as a, useAlf} from '#/alf'
import {normalizeTextStyles} from '#/components/Typography'
import {Autocomplete} from './mobile/Autocomplete'
@@ -70,7 +68,6 @@ export const TextInput = forwardRef(function TextInputImpl(
ref,
) {
const {theme: t, fonts} = useAlf()
- const pal = usePalette('default')
const textInput = useRef(null)
const textInputSelection = useRef({start: 0, end: 0})
const theme = useTheme()
@@ -193,10 +190,12 @@ export const TextInput = forwardRef(function TextInputImpl(
},
)
- /*
- * `PasteInput` appears to prefer no `lineHeight`
+ /**
+ * PasteInput doesn't like `lineHeight`, results in jumpiness
*/
- style.lineHeight = undefined
+ if (isNative) {
+ style.lineHeight = undefined
+ }
/*
* Android impl of `PasteInput` doesn't support the array syntax for `fontVariant`
@@ -215,18 +214,23 @@ export const TextInput = forwardRef(function TextInputImpl(
return Array.from(richtext.segments()).map(segment => {
return (
-
+ style={[
+ inputTextStyle,
+ {
+ color: segment.facet ? t.palette.primary_500 : t.atoms.text.color,
+ marginTop: -1,
+ },
+ ]}>
{segment.text}
-
+
)
})
- }, [richtext, pal.link, pal.text, inputTextStyle])
+ }, [t, richtext, inputTextStyle])
return (
-
+
{textDecorated}
@@ -252,17 +256,3 @@ export const TextInput = forwardRef(function TextInputImpl(
)
})
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- textInput: {
- flex: 1,
- width: '100%',
- padding: 5,
- paddingBottom: 20,
- marginLeft: 8,
- alignSelf: 'flex-start',
- },
-})
From b38d4697b7a42a5e4c48d86a6528a20ace9c034e Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Tue, 24 Sep 2024 20:10:13 -0500
Subject: [PATCH 26/26] [Neue] Post avi, `PostMeta` cleanup (#5450)
* Support emoji in text with custom font
* Add emoji support to elements that need it
* Remove unused file causing lint failure
* Add web only link variant
* Refactor PostMeta
* Reduce avi size in feeds
* Fix alignment, emoji, in PostMeta
* Smaller avis in notifications
* Shrink post placeholder avi
* Handle the handle again
* Link cleanup
* Cleanup unused props
* Fix text wrapping in timestamp
* Fix underline color
* Tighten up spacing
* Web only whiteSpace
---
src/App.web.tsx | 4 +-
src/components/Link.tsx | 36 ++++-
.../Conversation/MessageInputEmbed.tsx | 1 -
src/view/com/post-thread/PostThreadItem.tsx | 8 +-
src/view/com/post/Post.tsx | 3 +-
src/view/com/posts/FeedItem.tsx | 5 +-
src/view/com/posts/FeedSlice.tsx | 6 +-
src/view/com/util/LoadingPlaceholder.tsx | 10 +-
src/view/com/util/PostMeta.tsx | 153 +++++++++---------
src/view/com/util/post-embeds/QuoteEmbed.tsx | 11 +-
10 files changed, 120 insertions(+), 117 deletions(-)
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 7d98737a3b..1664812d08 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -1,5 +1,5 @@
-import 'lib/sentry' // must be near top
-import 'view/icons'
+import '#/lib/sentry' // must be near top
+import '#/view/icons'
import './style.css'
import React, {useEffect, useState} from 'react'
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 6c25faffb8..c80b9f3707 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -9,6 +9,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions, useLinkProps} from '@react-navigation/native'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
+import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {AllNavigatorParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {
@@ -17,11 +18,10 @@ import {
isExternalUrl,
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
-import {isNative} from '#/platform/detection'
+import {isNative, isWeb} from '#/platform/detection'
import {shouldClickOpenNewTab} from '#/platform/urls'
import {useModalControls} from '#/state/modals'
import {useOpenLink} from '#/state/preferences/in-app-browser'
-import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'
@@ -244,7 +244,10 @@ export function Link({
export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps & TextStyleProp & Pick
> &
- Pick
+ Pick & {
+ disableUnderline?: boolean
+ title?: TextProps['title']
+ }
export function InlineLinkText({
children,
@@ -257,6 +260,7 @@ export function InlineLinkText({
selectable,
label,
shareOnLongPress,
+ disableUnderline,
...rest
}: InlineLinkProps) {
const t = useTheme()
@@ -290,11 +294,12 @@ export function InlineLinkText({
{...rest}
style={[
{color: t.palette.primary_500},
- (hovered || focused || pressed) && {
- ...web({outline: 0}),
- textDecorationLine: 'underline',
- textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
- },
+ (hovered || focused || pressed) &&
+ !disableUnderline && {
+ ...web({outline: 0}),
+ textDecorationLine: 'underline',
+ textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
+ },
flattenedStyle,
]}
role="link"
@@ -365,3 +370,18 @@ export function BaseLink({
)
}
+
+export function WebOnlyInlineLinkText({
+ children,
+ to,
+ onPress,
+ ...props
+}: InlineLinkProps) {
+ return isWeb ? (
+
+ {children}
+
+ ) : (
+ {children}
+ )
+}
diff --git a/src/screens/Messages/Conversation/MessageInputEmbed.tsx b/src/screens/Messages/Conversation/MessageInputEmbed.tsx
index bf28ed4fe9..2d1551019e 100644
--- a/src/screens/Messages/Conversation/MessageInputEmbed.tsx
+++ b/src/screens/Messages/Conversation/MessageInputEmbed.tsx
@@ -174,7 +174,6 @@ export function MessageInputEmbed({
showAvatar
author={post.author}
moderation={moderation}
- authorHasWarning={!!post.author.labels?.length}
timestamp={post.indexedAt}
postHref={itemHref}
style={a.flex_0}
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 3fb2309b96..ead9df1161 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -558,18 +558,14 @@ let PostThreadItemLoaded = ({
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 9033fb96f7..ec730a5e16 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -163,7 +163,7 @@ function PostInner({
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index b1509b2719..fb9cdb065e 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -245,7 +245,7 @@ let FeedItemInner = ({
onBeforePress={onBeforePress}
dataSet={{feedContext}}>
-
+
{isThreadChild && (
onOpenAuthor?: () => void
style?: StyleProp
}
let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
- const {i18n} = useLingui()
+ const t = useTheme()
+ const {i18n, _} = useLingui()
- const pal = usePalette('default')
const displayName = opts.author.displayName || opts.author.handle
const handle = opts.author.handle
const profileLink = makeProfileLink(opts.author)
@@ -53,9 +49,18 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
}, [queryClient, opts.author])
return (
-
+
{opts.showAvatar && (
-
+
{
)}
-
-
+
- {forceLTR(
- sanitizeDisplayName(
- displayName,
- opts.moderation?.ui('displayName'),
- ),
- )}
-
- }
- href={profileLink}
- onBeforePress={onBeforePressAuthor}
- />
-
+
+ {forceLTR(
+ sanitizeDisplayName(
+ displayName,
+ opts.moderation?.ui('displayName'),
+ ),
+ )}
+
+
+
- {NON_BREAKING_SPACE + sanitizeHandle(handle, '@')}
-
- }
- href={profileLink}
- onBeforePress={onBeforePressAuthor}
- anchorNoUnderline
- />
+ disableUnderline
+ onPress={onBeforePressAuthor}
+ style={[a.text_md, t.atoms.text_contrast_medium, a.leading_tight]}>
+
+ {NON_BREAKING_SPACE + sanitizeHandle(handle, '@')}
+
+
- {!isAndroid && (
-
- ·
-
- )}
+
+
+ ·
+
+
{({timeElapsed}) => (
-
+ disableMismatchWarning
+ disableUnderline
+ onPress={onBeforePressPost}
+ style={[
+ a.text_md,
+ t.atoms.text_contrast_medium,
+ a.leading_tight,
+ web({
+ whiteSpace: 'nowrap',
+ }),
+ ]}>
+ {timeElapsed}
+
)}
@@ -129,21 +138,3 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
}
PostMeta = memo(PostMeta)
export {PostMeta}
-
-const styles = StyleSheet.create({
- container: {
- flexDirection: 'row',
- alignItems: 'flex-end',
- paddingBottom: 2,
- gap: 4,
- zIndex: 1,
- flex: 1,
- },
- avatar: {
- alignSelf: 'center',
- },
- maxWidth: {
- flex: isAndroid ? 1 : undefined,
- flexShrink: isAndroid ? undefined : 1,
- },
-})
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 79e3264046..3b8152c8b8 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -24,15 +24,15 @@ import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {HITSLOP_20} from '#/lib/constants'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {InfoCircleIcon} from '#/lib/icons'
import {moderatePost_wrapped} from '#/lib/moderatePost_wrapped'
+import {makeProfileLink} from '#/lib/routes/links'
import {s} from '#/lib/styles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {precacheProfile} from '#/state/queries/profile'
import {useSession} from '#/state/session'
-import {usePalette} from 'lib/hooks/usePalette'
-import {InfoCircleIcon} from 'lib/icons'
-import {makeProfileLink} from 'lib/routes/links'
-import {precacheProfile} from 'state/queries/profile'
-import {ComposerOptsQuote} from 'state/shell/composer'
+import {ComposerOptsQuote} from '#/state/shell/composer'
import {atoms as a, useTheme} from '#/alf'
import {RichText} from '#/components/RichText'
import {ContentHider} from '../../../../components/moderation/ContentHider'
@@ -238,7 +238,6 @@ export function QuoteEmbed({
author={quote.author}
moderation={moderation}
showAvatar
- authorHasWarning={false}
postHref={itemHref}
timestamp={quote.indexedAt}
/>