Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Charlotte Som <charlotte@som.codes>
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
hailey 2025-06-12 10:46:22 -07:00 committed by GitHub
parent a26b20b56c
commit 477e5f4ecf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1013 additions and 911 deletions

View File

@ -1,10 +1,9 @@
import {deleteAsync} from 'expo-file-system'
import {createDownloadResumable, deleteAsync} from 'expo-file-system'
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import RNFetchBlob from 'rn-fetch-blob'
import {
downloadAndResize,
DownloadAndResizeOpts,
type DownloadAndResizeOpts,
getResizedDimensions,
} from '../../src/lib/media/manip'
@ -32,11 +31,12 @@ describe('downloadAndResize', () => {
})
it('should return resized image for valid URI and options', async () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image.jpg'),
info: jest.fn().mockReturnValue({status: 200}),
flush: jest.fn(),
const mockedFetch = createDownloadResumable as jest.Mock
mockedFetch.mockReturnValue({
cancelAsync: jest.fn(),
downloadAsync: jest
.fn()
.mockResolvedValue({uri: 'file://resized-image.jpg'}),
})
const opts: DownloadAndResizeOpts = {
@ -50,13 +50,12 @@ describe('downloadAndResize', () => {
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.jpg',
expect(createDownloadResumable).toHaveBeenCalledWith(
opts.uri,
expect.anything(),
{
cache: true,
},
)
// First time it gets called is to get dimensions
@ -86,28 +85,6 @@ describe('downloadAndResize', () => {
expect(result).toBeUndefined()
})
it('should return undefined for non-200 response', async () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image'),
info: jest.fn().mockReturnValue({status: 400}),
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(errorSpy).not.toHaveBeenCalled()
expect(result).toBeUndefined()
})
it('should not downsize whenever dimensions are below the max dimensions', () => {
const initialDimensionsOne = {
width: 1200,

View File

@ -219,7 +219,7 @@ module.exports = function (_config) {
compileSdkVersion: 35,
targetSdkVersion: 35,
buildToolsVersion: '35.0.0',
newArchEnabled: false,
newArchEnabled: true,
},
},
],

View File

@ -33,15 +33,10 @@ jest.mock('react-native-safe-area-context', () => {
}
})
jest.mock('rn-fetch-blob', () => ({
config: jest.fn().mockReturnThis(),
cancel: jest.fn(),
fetch: jest.fn(),
}))
jest.mock('expo-file-system', () => ({
getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
deleteAsync: jest.fn(),
createDownloadResumable: jest.fn(),
}))
jest.mock('expo-image-manipulator', () => ({
@ -101,7 +96,7 @@ jest.mock('expo-modules-core', () => ({
}
}
}),
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
requireNativeViewManager: jest.fn().mockImplementation(_ => {
return () => null
}),
}))

View File

@ -74,7 +74,7 @@
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.12.4",
"@expo/html-elements": "^0.12.5",
"@expo/webpack-config": "^19.0.1",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
@ -85,12 +85,12 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@haileyok/bluesky-video": "0.2.6",
"@haileyok/bluesky-video": "0.3.1",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/react": "^4.14.1",
"@mattermost/react-native-paste-input": "^0.7.1",
"@miblanchard/react-native-slider": "^2.3.1",
"@mozzius/expo-dynamic-app-icon": "^1.5.0",
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "1.5.0",
"@react-native-async-storage/async-storage": "2.1.2",
"@react-native-menu/menu": "^1.2.3",
"@react-native-picker/picker": "2.11.0",
@ -98,7 +98,7 @@
"@react-navigation/drawer": "^7.3.12",
"@react-navigation/native": "^7.1.9",
"@react-navigation/native-stack": "^7.3.13",
"@sentry/react-native": "~6.10.0",
"@sentry/react-native": "~6.14.0",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "^5.8.1",
"@tanstack/react-query-persist-client": "^5.25.0",
@ -130,33 +130,33 @@
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "^53.0.5",
"expo": "53.0.11",
"expo-application": "~6.1.4",
"expo-blur": "~14.1.4",
"expo-blur": "~14.1.5",
"expo-build-properties": "~0.14.6",
"expo-camera": "~16.1.6",
"expo-camera": "~16.1.8",
"expo-clipboard": "~7.1.4",
"expo-dev-client": "~5.1.7",
"expo-dev-client": "~5.2.0",
"expo-device": "~7.1.4",
"expo-file-system": "~18.1.8",
"expo-font": "~13.3.0",
"expo-file-system": "~18.1.10",
"expo-font": "~13.3.1",
"expo-haptics": "~14.1.4",
"expo-image": "~2.1.6",
"expo-image": "~2.2.1",
"expo-image-crop-tool": "^0.1.8",
"expo-image-manipulator": "~13.1.5",
"expo-image-manipulator": "~13.1.7",
"expo-image-picker": "~16.1.4",
"expo-linear-gradient": "~14.1.4",
"expo-linking": "~7.1.4",
"expo-linear-gradient": "~14.1.5",
"expo-linking": "~7.1.5",
"expo-localization": "~16.1.5",
"expo-media-library": "~17.1.6",
"expo-notifications": "~0.31.1",
"expo-screen-orientation": "~8.1.5",
"expo-media-library": "~17.1.7",
"expo-notifications": "~0.31.3",
"expo-screen-orientation": "~8.1.7",
"expo-sharing": "~13.1.5",
"expo-splash-screen": "~0.30.8",
"expo-system-ui": "~5.0.7",
"expo-splash-screen": "~0.30.9",
"expo-system-ui": "~5.0.8",
"expo-task-manager": "~13.1.5",
"expo-updates": "~0.28.12",
"expo-video": "~2.1.8",
"expo-updates": "~0.28.14",
"expo-video": "~2.2.1",
"expo-web-browser": "~14.1.6",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
@ -182,35 +182,34 @@
"react-image-crop": "^11.0.7",
"react-is": "19",
"react-keyed-flatten-children": "^5.0.0",
"react-native": "0.79.2",
"react-native-compressor": "1.11.0",
"react-native": "^0.79.3",
"react-native-compressor": "^1.11.0",
"react-native-date-picker": "^5.0.12",
"react-native-drawer-layout": "^4.1.6",
"react-native-drawer-layout": "^4.1.8",
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "2.25.0",
"react-native-get-random-values": "~1.11.0",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.17.1",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.7.1",
"react-native-pager-view": "^6.7.1",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "~3.17.5",
"react-native-root-siblings": "^4.1.1",
"react-native-root-siblings": "^5.0.1",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "^4.11.1",
"react-native-svg": "15.11.2",
"react-native-svg": "15.12.0",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.3",
"react-native-view-shot": "^4.0.3",
"react-native-web": "~0.20.0",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.13.5",
"react-native-webview": "^13.13.5",
"react-remove-scroll-bar": "^2.3.8",
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
"rn-fetch-blob": "^0.12.0",
"statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
@ -227,8 +226,9 @@
"@lingui/cli": "^4.14.1",
"@lingui/macro": "^4.14.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native/eslint-config": "^0.79.2",
"@react-native/typescript-config": "^0.79.2",
"@react-native/babel-preset": "0.79.3",
"@react-native/eslint-config": "^0.79.3",
"@react-native/typescript-config": "^0.79.3",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.2.0",
@ -247,6 +247,7 @@
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^19.1.0-rc.1",
"babel-preset-expo": "~13.1.11",
"browserslist": "^4.25.0",
"eslint": "^8.19.0",
"eslint-plugin-bsky-internal": "link:./eslint",
"eslint-plugin-ft-flow": "^2.0.3",
@ -260,7 +261,7 @@
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
"jest-expo": "~53.0.3",
"jest-expo": "~53.0.7",
"jest-junit": "^16.0.0",
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
@ -275,11 +276,11 @@
},
"resolutions": {
"@expo/image-utils": "0.6.3",
"@react-native/babel-preset": "0.79.2",
"@react-native/normalize-colors": "0.79.2",
"@react-native/babel-preset": "0.79.3",
"@react-native/normalize-colors": "0.79.3",
"@types/react": "^18",
"**/expo-constants": "17.0.3",
"**/expo-device": "7.0.1",
"**/expo-device": "7.1.4",
"**/zod": "3.23.8",
"**/multiformats": "9.9.0"
},
@ -359,7 +360,8 @@
],
"allowedUrls": [
"https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908",
"https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4"
"https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4",
"https://codeload.github.com/mattermost/react-native-paste-input/tar.gz/f260447edc645a817ab1ba7b46d8341d84dba8e9"
],
"emptyHostname": false,
"validatePackageNames": true,

View File

@ -114,7 +114,7 @@ index e916023..5049c33 100644
}
#pragma mark - UIScrollViewDelegate
@@ -62,7 +92,6 @@
@@ -62,7 +92,6 @@ - (void)setSmartPunctuation:(NSString *)smartPunctuation {
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
RCTDirectEventBlock onScroll = self.onScroll;
@ -122,7 +122,7 @@ index e916023..5049c33 100644
if (onScroll) {
CGPoint contentOffset = scrollView.contentOffset;
CGSize contentSize = scrollView.contentSize;
@@ -71,22 +100,22 @@
@@ -71,22 +100,22 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView
onScroll(@{
@"contentOffset": @{
@ -155,3 +155,110 @@ index e916023..5049c33 100644
},
@"zoomScale": @(scrollView.zoomScale ?: 1),
});
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
index dd50053..2ed7017 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
@@ -122,8 +122,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
const auto &newTextInputProps = static_cast<const PasteTextInputProps &>(*props);
// Traits:
- if (newTextInputProps.traits.multiline != oldTextInputProps.traits.multiline) {
- [self _setMultiline:newTextInputProps.traits.multiline];
+ if (newTextInputProps.multiline != oldTextInputProps.multiline) {
+ [self _setMultiline:newTextInputProps.multiline];
}
if (newTextInputProps.traits.autocapitalizationType != oldTextInputProps.traits.autocapitalizationType) {
@@ -421,7 +421,7 @@ - (void)textInputDidChangeSelection
return;
}
const auto &props = static_cast<const PasteTextInputProps &>(*_props);
- if (props.traits.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
+ if (props.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
[self textInputDidChange];
_ignoreNextTextInputCall = YES;
}
@@ -708,11 +708,11 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe
- (SubmitBehavior)getSubmitBehavior
{
const auto &props = static_cast<const PasteTextInputProps &>(*_props);
- const SubmitBehavior submitBehaviorDefaultable = props.traits.submitBehavior;
+ const SubmitBehavior submitBehaviorDefaultable = props.submitBehavior;
// We should always have a non-default `submitBehavior`, but in case we don't, set it based on multiline.
if (submitBehaviorDefaultable == SubmitBehavior::Default) {
- return props.traits.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit;
+ return props.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit;
}
return submitBehaviorDefaultable;
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
index 29e094f..7ef519a 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
@@ -22,8 +22,7 @@ PasteTextInputProps::PasteTextInputProps(
const PropsParserContext &context,
const PasteTextInputProps &sourceProps,
const RawProps& rawProps)
- : ViewProps(context, sourceProps, rawProps),
- BaseTextProps(context, sourceProps, rawProps),
+ : BaseTextInputProps(context, sourceProps, rawProps),
traits(convertRawProp(context, rawProps, sourceProps.traits, {})),
smartPunctuation(convertRawProp(context, rawProps, "smartPunctuation", sourceProps.smartPunctuation, {})),
disableCopyPaste(convertRawProp(context, rawProps, "disableCopyPaste", sourceProps.disableCopyPaste, {false})),
@@ -133,7 +132,7 @@ TextAttributes PasteTextInputProps::getEffectiveTextAttributes(Float fontSizeMul
ParagraphAttributes PasteTextInputProps::getEffectiveParagraphAttributes() const {
auto result = paragraphAttributes;
- if (!traits.multiline) {
+ if (!multiline) {
result.maximumNumberOfLines = 1;
}
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
index 723d00c..31cfe66 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
@@ -15,6 +15,7 @@
#include <react/renderer/components/iostextinput/conversions.h>
#include <react/renderer/components/iostextinput/primitives.h>
#include <react/renderer/components/text/BaseTextProps.h>
+#include <react/renderer/components/textinput/BaseTextInputProps.h>
#include <react/renderer/components/view/ViewProps.h>
#include <react/renderer/core/Props.h>
#include <react/renderer/core/PropsParserContext.h>
@@ -25,7 +26,7 @@
namespace facebook::react {
-class PasteTextInputProps final : public ViewProps, public BaseTextProps {
+class PasteTextInputProps final : public BaseTextInputProps {
public:
PasteTextInputProps() = default;
PasteTextInputProps(const PropsParserContext& context, const PasteTextInputProps& sourceProps, const RawProps& rawProps);
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
index 31e07e3..7f0ebfb 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
@@ -91,20 +91,11 @@ void PasteTextInputShadowNode::updateStateIfNeeded(
const auto& state = getStateData();
react_native_assert(textLayoutManager_);
- react_native_assert(
- (!state.layoutManager || state.layoutManager == textLayoutManager_) &&
- "`StateData` refers to a different `TextLayoutManager`");
-
- if (state.reactTreeAttributedString == reactTreeAttributedString &&
- state.layoutManager == textLayoutManager_) {
- return;
- }
auto newState = TextInputState{};
newState.attributedStringBox = AttributedStringBox{reactTreeAttributedString};
newState.paragraphAttributes = getConcreteProps().paragraphAttributes;
newState.reactTreeAttributedString = reactTreeAttributedString;
- newState.layoutManager = textLayoutManager_;
newState.mostRecentEventCount = getConcreteProps().mostRecentEventCount;
setStateData(std::move(newState));
}

View File

@ -1,3 +1,32 @@
diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
index 914a249..0deac55 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
@@ -19,6 +19,8 @@ NS_ASSUME_NONNULL_BEGIN
*/
@interface RCTPullToRefreshViewComponentView : RCTViewComponentView <RCTCustomPullToRefreshViewProtocol>
+- (void)beginRefreshingProgrammatically;
+
@end
NS_ASSUME_NONNULL_END
diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
index d029337..0f63ea3 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
@@ -1003,6 +1003,11 @@ - (void)_adjustForMaintainVisibleContentPosition
}
}
++ (BOOL)shouldBeRecycled
+{
+ return NO;
+}
+
@end
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
index e9b330f..ec5f58c 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@ -15,7 +44,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 53bfd04..ff1b1ed 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@ @implementation RCTRefreshControl {
UIColor *_titleColor;
CGFloat _progressViewOffset;
BOOL _hasMovedToWindow;
@ -23,7 +52,7 @@ index 53bfd04..ff1b1ed 100644
}
- (instancetype)init
@@ -58,6 +59,12 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
@@ -58,6 +59,12 @@ - (void)layoutSubviews
_isInitialRender = false;
}
@ -36,7 +65,7 @@ index 53bfd04..ff1b1ed 100644
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -221,4 +228,50 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
}
}
@ -91,7 +120,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 40aaf9c..1c60164 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -22,11 +22,12 @@ RCT_EXPORT_MODULE()
@@ -22,11 +22,12 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
@ -105,15 +134,3 @@ index 40aaf9c..1c60164 100644
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
index cd1e7eb..c1d0172 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
@@ -83,6 +83,7 @@ RCT_EXPORT_VIEW_PROPERTY(showsVerticalScrollIndicator, BOOL)
RCT_EXPORT_VIEW_PROPERTY(scrollEventThrottle, NSTimeInterval)
RCT_EXPORT_VIEW_PROPERTY(zoomScale, CGFloat)
RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets)
+RCT_EXPORT_VIEW_PROPERTY(scrollIndicatorInsets, UIEdgeInsets)
RCT_EXPORT_VIEW_PROPERTY(verticalScrollIndicatorInsets, UIEdgeInsets)
RCT_EXPORT_VIEW_PROPERTY(scrollToOverflowEnabled, BOOL)
RCT_EXPORT_VIEW_PROPERTY(snapToInterval, int)

View File

@ -1,12 +1,12 @@
import React from 'react'
import {
AccessibilityProps,
type AccessibilityProps,
StyleSheet,
TextInput,
TextInputProps,
TextStyle,
type TextInputProps,
type TextStyle,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {HITSLOP_20} from '#/lib/constants'
@ -16,13 +16,13 @@ import {
applyFonts,
atoms as a,
ios,
TextStyleProp,
type TextStyleProp,
useAlf,
useTheme,
web,
} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography'
const Context = React.createContext<{
@ -196,7 +196,7 @@ export function createInput(Component: typeof TextInput) {
minWidth: 0,
},
ios({paddingTop: 12, paddingBottom: 13}),
android(a.py_sm),
android(a.py_md),
// fix for autofill styles covering border
web({
paddingTop: 10,

View File

@ -1,39 +0,0 @@
import {useState} from 'react'
import {AnimatedRef, measure, MeasuredDimensions} from 'react-native-reanimated'
export type HandleRef = {
(node: any): void
current: null | number
}
// This is a lighterweight alternative to `useAnimatedRef()` for imperative UI thread actions.
// Render it like <View ref={ref} />, then pass `ref.current` to `measureHandle()` and such.
export function useHandleRef(): HandleRef {
return useState(() => {
const ref = (node: any) => {
if (node) {
ref.current =
node._nativeTag ??
node.__nativeTag ??
node.canonical?.nativeTag ??
null
} else {
ref.current = null
}
}
ref.current = null
return ref
})[0] as HandleRef
}
// When using this version, you need to read ref.current on the JS thread, and pass it to UI.
export function measureHandle(
current: number | null,
): MeasuredDimensions | null {
'worklet'
if (current !== null) {
return measure((() => current) as AnimatedRef<any>)
} else {
return null
}
}

View File

@ -1,8 +1,9 @@
import {Image as RNImage, Share as RNShare} from 'react-native'
import {Image as RNImage} from 'react-native'
import uuid from 'react-native-uuid'
import {
cacheDirectory,
copyAsync,
createDownloadResumable,
deleteAsync,
EncodingType,
getInfoAsync,
@ -14,7 +15,6 @@ import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import * as MediaLibrary from 'expo-media-library'
import * as Sharing from 'expo-sharing'
import {Buffer} from 'buffer'
import RNFetchBlob from 'rn-fetch-blob'
import {POST_IMG_MAX} from '#/lib/constants'
import {logger} from '#/logger'
@ -68,28 +68,13 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
return
}
let downloadRes
const path = createPath(appendExt)
try {
const downloadResPromise = RNFetchBlob.config({
fileCache: true,
appendExt,
}).fetch('GET', opts.uri)
const to1 = setTimeout(() => downloadResPromise.cancel(), opts.timeout)
downloadRes = await downloadResPromise
clearTimeout(to1)
const status = downloadRes.info().status
if (status !== 200) {
return
}
const localUri = normalizePath(downloadRes.path(), true)
return await doResize(localUri, opts)
await downloadImage(opts.uri, path, opts.timeout)
return await doResize(path, opts)
} finally {
// TODO Whenever we remove `rn-fetch-blob`, we will need to replace this `flush()` with a `deleteAsync()` -hailey
if (downloadRes) {
downloadRes.flush()
}
safeDeleteAsync(path)
}
}
@ -98,32 +83,16 @@ export async function shareImageModal({uri}: {uri: string}) {
// TODO might need to give an error to the user in this case -prf
return
}
const downloadResponse = await RNFetchBlob.config({
fileCache: true,
}).fetch('GET', uri)
// NOTE
// assuming PNG
// we're currently relying on the fact our CDN only serves pngs
// -prf
let imagePath = downloadResponse.path()
imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true)
// NOTE
// for some reason expo-sharing refuses to work on iOS
// ...and visa versa
// -prf
if (isIOS) {
await RNShare.share({url: imagePath})
} else {
await Sharing.shareAsync(imagePath, {
mimeType: 'image/png',
UTI: 'image/png',
})
}
safeDeleteAsync(imagePath)
const imageUri = await downloadImage(uri, createPath('png'), 5e3)
const imagePath = await moveToPermanentPath(imageUri, '.png')
safeDeleteAsync(imageUri)
await Sharing.shareAsync(imagePath, {
mimeType: 'image/png',
UTI: 'image/png',
})
}
const ALBUM_NAME = 'Bluesky'
@ -134,11 +103,8 @@ export async function saveImageToMediaLibrary({uri}: {uri: string}) {
// assuming PNG
// we're currently relying on the fact our CDN only serves pngs
// -prf
const downloadResponse = await RNFetchBlob.config({
fileCache: true,
}).fetch('GET', uri)
let imagePath = downloadResponse.path()
imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true)
const imageUri = await downloadImage(uri, createPath('png'), 5e3)
const imagePath = await moveToPermanentPath(imageUri, '.png')
// save
try {
@ -403,3 +369,24 @@ export function getResizedDimensions(originalDims: {
height: Math.round(originalDims.height * ratio),
}
}
function createPath(ext: string) {
// cacheDirectory will never be null on native, so the null check here is not necessary except for typescript.
// we use a web-only function for downloadAndResize on web
return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}`
}
async function downloadImage(uri: string, path: string, timeout: number) {
const dlResumable = createDownloadResumable(uri, path, {cache: true})
const to1 = setTimeout(() => dlResumable.cancelAsync(), timeout)
const dlRes = await dlResumable.downloadAsync()
clearTimeout(to1)
if (!dlRes?.uri) {
throw new Error('Failed to download image - dlRes is undefined')
}
return normalizePath(dlRes.uri)
}

View File

@ -1,9 +1,11 @@
import React, {memo, useEffect} from 'react'
import {memo, useCallback, useEffect, useMemo} from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import {
import Animated, {
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
useAnimatedRef,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api'
@ -14,7 +16,6 @@ import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {BACK_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef'
import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
@ -59,9 +60,9 @@ let ProfileHeaderShell = ({
const playHaptic = useHaptics()
const liveStatusControl = useDialogControl()
const aviRef = useHandleRef()
const aviRef = useAnimatedRef()
const onPressBack = React.useCallback(() => {
const onPressBack = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
@ -69,7 +70,7 @@ let ProfileHeaderShell = ({
}
}, [navigation])
const _openLightbox = React.useCallback(
const _openLightbox = useCallback(
(uri: string, thumbRect: MeasuredDimensions | null) => {
openLightbox({
images: [
@ -92,7 +93,7 @@ let ProfileHeaderShell = ({
[openLightbox],
)
const isMe = React.useMemo(
const isMe = useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
@ -109,7 +110,7 @@ let ProfileHeaderShell = ({
}
}, [live.isActive, profile.did])
const onPressAvi = React.useCallback(() => {
const onPressAvi = useCallback(() => {
if (live.isActive) {
playHaptic('Light')
logger.metric(
@ -122,10 +123,9 @@ let ProfileHeaderShell = ({
const modui = moderation.ui('avatar')
const avatar = profile.avatar
if (avatar && !(modui.blur && modui.noOverride)) {
const aviHandle = aviRef.current
runOnUI(() => {
'worklet'
const rect = measureHandle(aviHandle)
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect)
})()
}
@ -223,7 +223,7 @@ let ProfileHeaderShell = ({
styles.avi,
profile.associated?.labeler && styles.aviLabeler,
]}>
<View ref={aviRef} collapsable={false}>
<Animated.View ref={aviRef} collapsable={false}>
<UserAvatar
type={profile.associated?.labeler ? 'labeler' : 'user'}
size={live.isActive ? 88 : 90}
@ -231,7 +231,7 @@ let ProfileHeaderShell = ({
moderation={moderation.ui('avatar')}
/>
{live.isActive && <LiveIndicator size="large" />}
</View>
</Animated.View>
</View>
</TouchableWithoutFeedback>
</GrowableAvatar>

View File

@ -1,5 +1,5 @@
import React, {
ComponentProps,
type ComponentProps,
forwardRef,
useCallback,
useMemo,
@ -7,16 +7,16 @@ import React, {
useState,
} from 'react'
import {
NativeSyntheticEvent,
type NativeSyntheticEvent,
Text as RNText,
TextInput as RNTextInput,
TextInputSelectionChangeEventData,
type TextInput as RNTextInput,
type TextInputSelectionChangeEventData,
View,
} from 'react-native'
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import PasteInput, {
PastedFile,
PasteInputRef,
type PastedFile,
type PasteInputRef, // @ts-expect-error no types when installing from github
} from '@mattermost/react-native-paste-input'
import {POST_IMG_MAX} from '#/lib/constants'
@ -27,7 +27,7 @@ import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
import {useTheme} from '#/lib/ThemeContext'
import {isAndroid, isNative} from '#/platform/detection'
import {
LinkFacetMatch,
type LinkFacetMatch,
suggestLinkCardUri,
} from '#/view/com/composer/text-input/text-input-util'
import {atoms as a, useAlf} from '#/alf'

View File

@ -1,16 +1,22 @@
import React, {forwardRef, useCallback, useContext} from 'react'
import {
useCallback,
useContext,
useImperativeHandle,
useRef,
useState,
} from 'react'
import {View} from 'react-native'
import {DrawerGestureContext} from 'react-native-drawer-layout'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import PagerView, {
PagerViewOnPageScrollEventData,
PagerViewOnPageSelectedEvent,
PagerViewOnPageSelectedEventData,
PageScrollStateChangedNativeEventData,
type PagerViewOnPageScrollEventData,
type PagerViewOnPageSelectedEvent,
type PagerViewOnPageSelectedEventData,
type PageScrollStateChangedNativeEventData,
} from 'react-native-pager-view'
import Animated, {
runOnJS,
SharedValue,
type SharedValue,
useEvent,
useHandler,
useSharedValue,
@ -36,8 +42,12 @@ export interface RenderTabBarFnProps {
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
interface Props {
ref?: React.Ref<PagerRef>
initialPage?: number
renderTabBar: RenderTabBarFn
// tab pressed, yet to scroll to page
onTabPressed?: (index: number) => void
// scroll settled
onPageSelected?: (index: number) => void
onPageScrollStateChanged?: (
scrollState: 'idle' | 'dragging' | 'settling',
@ -47,114 +57,112 @@ interface Props {
const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
function PagerImpl(
export function Pager({
ref,
children,
initialPage = 0,
renderTabBar,
onPageSelected: parentOnPageSelected,
onTabPressed: parentOnTabPressed,
onPageScrollStateChanged: parentOnPageScrollStateChanged,
testID,
}: React.PropsWithChildren<Props>) {
const [selectedPage, setSelectedPage] = useState(initialPage)
const pagerView = useRef<PagerView>(null)
const [isIdle, setIsIdle] = useState(true)
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
useFocusEffect(
useCallback(() => {
const canSwipeDrawer = selectedPage === 0 && isIdle
setDrawerSwipeDisabled(!canSwipeDrawer)
return () => {
setDrawerSwipeDisabled(false)
}
}, [setDrawerSwipeDisabled, selectedPage, isIdle]),
)
useImperativeHandle(ref, () => ({
setPage: (index: number) => {
pagerView.current?.setPage(index)
},
}))
const onPageSelectedJSThread = useCallback(
(nextPosition: number) => {
setSelectedPage(nextPosition)
parentOnPageSelected?.(nextPosition)
},
[setSelectedPage, parentOnPageSelected],
)
const onTabBarSelect = useCallback(
(index: number) => {
parentOnTabPressed?.(index)
pagerView.current?.setPage(index)
},
[pagerView, parentOnTabPressed],
)
const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle')
const dragProgress = useSharedValue(selectedPage)
const didInit = useSharedValue(false)
const handlePageScroll = usePagerHandlers(
{
children,
initialPage = 0,
renderTabBar,
onPageScrollStateChanged: parentOnPageScrollStateChanged,
onPageSelected: parentOnPageSelected,
testID,
}: React.PropsWithChildren<Props>,
ref,
) {
const [selectedPage, setSelectedPage] = React.useState(initialPage)
const pagerView = React.useRef<PagerView>(null)
const [isIdle, setIsIdle] = React.useState(true)
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
useFocusEffect(
useCallback(() => {
const canSwipeDrawer = selectedPage === 0 && isIdle
setDrawerSwipeDisabled(!canSwipeDrawer)
return () => {
setDrawerSwipeDisabled(false)
onPageScroll(e: PagerViewOnPageScrollEventData) {
'worklet'
if (didInit.get() === false) {
// On iOS, there's a spurious scroll event with 0 position
// even if a different page was supplied as the initial page.
// Ignore it and wait for the first confirmed selection instead.
return
}
}, [setDrawerSwipeDisabled, selectedPage, isIdle]),
)
React.useImperativeHandle(ref, () => ({
setPage: (index: number) => {
pagerView.current?.setPage(index)
dragProgress.set(e.offset + e.position)
},
}))
const onPageSelectedJSThread = React.useCallback(
(nextPosition: number) => {
setSelectedPage(nextPosition)
parentOnPageSelected?.(nextPosition)
onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) {
'worklet'
runOnJS(setIsIdle)(e.pageScrollState === 'idle')
if (dragState.get() === 'idle' && e.pageScrollState === 'settling') {
// This is a programmatic scroll on Android.
// Stay "idle" to match iOS and avoid confusing downstream code.
return
}
dragState.set(e.pageScrollState)
parentOnPageScrollStateChanged?.(e.pageScrollState)
},
[setSelectedPage, parentOnPageSelected],
)
const onTabBarSelect = React.useCallback(
(index: number) => {
pagerView.current?.setPage(index)
onPageSelected(e: PagerViewOnPageSelectedEventData) {
'worklet'
didInit.set(true)
runOnJS(onPageSelectedJSThread)(e.position)
},
[pagerView],
)
},
[parentOnPageScrollStateChanged],
)
const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle')
const dragProgress = useSharedValue(selectedPage)
const didInit = useSharedValue(false)
const handlePageScroll = usePagerHandlers(
{
onPageScroll(e: PagerViewOnPageScrollEventData) {
'worklet'
if (didInit.get() === false) {
// On iOS, there's a spurious scroll event with 0 position
// even if a different page was supplied as the initial page.
// Ignore it and wait for the first confirmed selection instead.
return
}
dragProgress.set(e.offset + e.position)
},
onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) {
'worklet'
runOnJS(setIsIdle)(e.pageScrollState === 'idle')
if (dragState.get() === 'idle' && e.pageScrollState === 'settling') {
// This is a programmatic scroll on Android.
// Stay "idle" to match iOS and avoid confusing downstream code.
return
}
dragState.set(e.pageScrollState)
parentOnPageScrollStateChanged?.(e.pageScrollState)
},
onPageSelected(e: PagerViewOnPageSelectedEventData) {
'worklet'
didInit.set(true)
runOnJS(onPageSelectedJSThread)(e.position)
},
},
[parentOnPageScrollStateChanged],
)
const drawerGesture = useContext(DrawerGestureContext) ?? Gesture.Native() // noop for web
const nativeGesture =
Gesture.Native().requireExternalGestureToFail(drawerGesture)
const drawerGesture = useContext(DrawerGestureContext) ?? Gesture.Native() // noop for web
const nativeGesture =
Gesture.Native().requireExternalGestureToFail(drawerGesture)
return (
<View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}>
{renderTabBar({
selectedPage,
onSelect: onTabBarSelect,
dragProgress,
dragState,
})}
<GestureDetector gesture={nativeGesture}>
<AnimatedPagerView
ref={pagerView}
style={[a.flex_1]}
initialPage={initialPage}
onPageScroll={handlePageScroll}>
{children}
</AnimatedPagerView>
</GestureDetector>
</View>
)
},
)
return (
<View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}>
{renderTabBar({
selectedPage,
onSelect: onTabBarSelect,
dragProgress,
dragState,
})}
<GestureDetector gesture={nativeGesture}>
<AnimatedPagerView
ref={pagerView}
style={[a.flex_1]}
initialPage={initialPage}
onPageScroll={handlePageScroll}>
{children}
</AnimatedPagerView>
</GestureDetector>
</View>
)
}
function usePagerHandlers(
handlers: {

View File

@ -1,8 +1,19 @@
import React from 'react'
import {
Children,
useCallback,
useImperativeHandle,
useRef,
useState,
} from 'react'
import {View} from 'react-native'
import {flushSync} from 'react-dom'
import {s} from '#/lib/styles'
import {atoms as a} from '#/alf'
export interface PagerRef {
setPage: (index: number) => void
}
export interface RenderTabBarFnProps {
selectedPage: number
@ -12,30 +23,30 @@ export interface RenderTabBarFnProps {
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
interface Props {
ref?: React.Ref<PagerRef>
initialPage?: number
renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void
}
export const Pager = React.forwardRef(function PagerImpl(
{
children,
initialPage = 0,
renderTabBar,
onPageSelected,
}: React.PropsWithChildren<Props>,
ref,
) {
const [selectedPage, setSelectedPage] = React.useState(initialPage)
const scrollYs = React.useRef<Array<number | null>>([])
const anchorRef = React.useRef(null)
React.useImperativeHandle(ref, () => ({
export function Pager({
ref,
children,
initialPage = 0,
renderTabBar,
onPageSelected,
}: React.PropsWithChildren<Props>) {
const [selectedPage, setSelectedPage] = useState(initialPage)
const scrollYs = useRef<Array<number | null>>([])
const anchorRef = useRef(null)
useImperativeHandle(ref, () => ({
setPage: (index: number) => {
onTabBarSelect(index)
},
}))
const onTabBarSelect = React.useCallback(
const onTabBarSelect = useCallback(
(index: number) => {
const scrollY = window.scrollY
// We want to determine if the tabbar is already "sticking" at the top (in which
@ -75,11 +86,13 @@ export const Pager = React.forwardRef(function PagerImpl(
tabBarAnchor: <View ref={anchorRef} />,
onSelect: e => onTabBarSelect(e),
})}
{React.Children.map(children, (child, i) => (
<View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
{Children.map(children, (child, i) => (
<View
style={selectedPage === i ? a.flex_1 : a.hidden}
key={`page-${i}`}>
{child}
</View>
))}
</View>
)
})
}

View File

@ -1,17 +1,16 @@
import * as React from 'react'
import {memo, useCallback, useEffect, useRef, useState} from 'react'
import {
LayoutChangeEvent,
NativeScrollEvent,
ScrollView,
type LayoutChangeEvent,
type NativeScrollEvent,
type ScrollView,
StyleSheet,
View,
} from 'react-native'
import Animated, {
AnimatedRef,
runOnJS,
type AnimatedRef,
runOnUI,
scrollTo,
SharedValue,
type SharedValue,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
@ -20,9 +19,13 @@ import Animated, {
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {ScrollProvider} from '#/lib/ScrollContext'
import {isIOS} from '#/platform/detection'
import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager'
import {
Pager,
type PagerRef,
type RenderTabBarFnProps,
} from '#/view/com/pager/Pager'
import {useTheme} from '#/alf'
import {ListMethods} from '../util/List'
import {type ListMethods} from '../util/List'
import {PagerHeaderProvider} from './PagerHeaderContext'
import {TabBar} from './TabBar'
@ -33,6 +36,7 @@ export interface PagerWithHeaderChildParams {
}
export interface PagerWithHeaderProps {
ref?: React.Ref<PagerRef>
testID?: string
children:
| (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
@ -49,97 +53,94 @@ export interface PagerWithHeaderProps {
onCurrentPageSelected?: (index: number) => void
allowHeaderOverScroll?: boolean
}
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
function PageWithHeaderImpl(
{
children,
testID,
export function PagerWithHeader({
ref,
children,
testID,
items,
isHeaderReady,
renderHeader,
initialPage,
onPageSelected,
onCurrentPageSelected,
allowHeaderOverScroll,
}: PagerWithHeaderProps) {
const [currentPage, setCurrentPage] = useState(0)
const [tabBarHeight, setTabBarHeight] = useState(0)
const [headerOnlyHeight, setHeaderOnlyHeight] = useState(0)
const scrollY = useSharedValue(0)
const headerHeight = headerOnlyHeight + tabBarHeight
// capture the header bar sizing
const onTabBarLayout = useNonReactiveCallback((evt: LayoutChangeEvent) => {
const height = evt.nativeEvent.layout.height
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setTabBarHeight(Math.round(height * 2) / 2)
}
})
const onHeaderOnlyLayout = useNonReactiveCallback((height: number) => {
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setHeaderOnlyHeight(Math.round(height * 2) / 2)
}
})
const renderTabBar = useCallback(
(props: RenderTabBarFnProps) => {
return (
<PagerHeaderProvider scrollY={scrollY} headerHeight={headerOnlyHeight}>
<PagerTabBar
headerOnlyHeight={headerOnlyHeight}
items={items}
isHeaderReady={isHeaderReady}
renderHeader={renderHeader}
currentPage={currentPage}
onCurrentPageSelected={onCurrentPageSelected}
onTabBarLayout={onTabBarLayout}
onHeaderOnlyLayout={onHeaderOnlyLayout}
onSelect={props.onSelect}
scrollY={scrollY}
testID={testID}
allowHeaderOverScroll={allowHeaderOverScroll}
dragProgress={props.dragProgress}
dragState={props.dragState}
/>
</PagerHeaderProvider>
)
},
[
headerOnlyHeight,
items,
isHeaderReady,
renderHeader,
initialPage,
onPageSelected,
currentPage,
onCurrentPageSelected,
onTabBarLayout,
onHeaderOnlyLayout,
scrollY,
testID,
allowHeaderOverScroll,
}: PagerWithHeaderProps,
ref,
) {
const [currentPage, setCurrentPage] = React.useState(0)
const [tabBarHeight, setTabBarHeight] = React.useState(0)
const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0)
const scrollY = useSharedValue(0)
const headerHeight = headerOnlyHeight + tabBarHeight
],
)
// capture the header bar sizing
const onTabBarLayout = useNonReactiveCallback((evt: LayoutChangeEvent) => {
const height = evt.nativeEvent.layout.height
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setTabBarHeight(Math.round(height * 2) / 2)
}
})
const onHeaderOnlyLayout = useNonReactiveCallback((height: number) => {
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setHeaderOnlyHeight(Math.round(height * 2) / 2)
}
})
const scrollRefs = useSharedValue<Array<AnimatedRef<any> | null>>([])
const registerRef = useCallback(
(scrollRef: AnimatedRef<any> | null, atIndex: number) => {
scrollRefs.modify(refs => {
'worklet'
refs[atIndex] = scrollRef
return refs
})
},
[scrollRefs],
)
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
<PagerHeaderProvider
scrollY={scrollY}
headerHeight={headerOnlyHeight}>
<PagerTabBar
headerOnlyHeight={headerOnlyHeight}
items={items}
isHeaderReady={isHeaderReady}
renderHeader={renderHeader}
currentPage={currentPage}
onCurrentPageSelected={onCurrentPageSelected}
onTabBarLayout={onTabBarLayout}
onHeaderOnlyLayout={onHeaderOnlyLayout}
onSelect={props.onSelect}
scrollY={scrollY}
testID={testID}
allowHeaderOverScroll={allowHeaderOverScroll}
dragProgress={props.dragProgress}
dragState={props.dragState}
/>
</PagerHeaderProvider>
)
},
[
headerOnlyHeight,
items,
isHeaderReady,
renderHeader,
currentPage,
onCurrentPageSelected,
onTabBarLayout,
onHeaderOnlyLayout,
scrollY,
testID,
allowHeaderOverScroll,
],
)
const scrollRefs = useSharedValue<Array<AnimatedRef<any> | null>>([])
const registerRef = React.useCallback(
(scrollRef: AnimatedRef<any> | null, atIndex: number) => {
scrollRefs.modify(refs => {
'worklet'
refs[atIndex] = scrollRef
return refs
})
},
[scrollRefs],
)
const lastForcedScrollY = useSharedValue(0)
const adjustScrollForOtherPages = () => {
const lastForcedScrollY = useSharedValue(0)
const adjustScrollForOtherPages = useCallback(
(scrollState: 'idle' | 'dragging' | 'settling') => {
'worklet'
if (scrollState !== 'dragging') return
const currentScrollY = scrollY.get()
const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight)
if (lastForcedScrollY.get() !== forcedScrollY) {
@ -152,75 +153,69 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
}
}
}
}
},
[currentPage, headerOnlyHeight, lastForcedScrollY, scrollRefs, scrollY],
)
const throttleTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const queueThrottledOnScroll = useNonReactiveCallback(() => {
if (!throttleTimeout.current) {
throttleTimeout.current = setTimeout(() => {
throttleTimeout.current = null
runOnUI(adjustScrollForOtherPages)()
}, 80 /* Sync often enough you're unlikely to catch it unsynced */)
const onScrollWorklet = useCallback(
(e: NativeScrollEvent) => {
'worklet'
const nextScrollY = e.contentOffset.y
// HACK: onScroll is reporting some strange values on load (negative header height).
// Highly improbable that you'd be overscrolled by over 400px -
// in fact, I actually can't do it, so let's just ignore those. -sfn
const isPossiblyInvalid =
headerHeight > 0 && Math.round(nextScrollY * 2) / 2 === -headerHeight
if (!isPossiblyInvalid) {
scrollY.set(nextScrollY)
}
})
},
[scrollY, headerHeight],
)
const onScrollWorklet = React.useCallback(
(e: NativeScrollEvent) => {
'worklet'
const nextScrollY = e.contentOffset.y
// HACK: onScroll is reporting some strange values on load (negative header height).
// Highly improbable that you'd be overscrolled by over 400px -
// in fact, I actually can't do it, so let's just ignore those. -sfn
const isPossiblyInvalid =
headerHeight > 0 && Math.round(nextScrollY * 2) / 2 === -headerHeight
if (!isPossiblyInvalid) {
scrollY.set(nextScrollY)
runOnJS(queueThrottledOnScroll)()
}
},
[scrollY, queueThrottledOnScroll, headerHeight],
)
const onPageSelectedInner = useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
},
[onPageSelected, setCurrentPage],
)
const onPageSelectedInner = React.useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
},
[onPageSelected, setCurrentPage],
)
const onTabPressed = useCallback(() => {
runOnUI(adjustScrollForOtherPages)('dragging')
}, [adjustScrollForOtherPages])
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}
renderTabBar={renderTabBar}>
{toArray(children)
.filter(Boolean)
.map((child, i) => {
const isReady =
isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0
return (
<View key={i} collapsable={false}>
<PagerItem
headerHeight={headerHeight}
index={i}
isReady={isReady}
isFocused={i === currentPage}
onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
registerRef={registerRef}
renderTab={child}
/>
</View>
)
})}
</Pager>
)
},
)
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onTabPressed={onTabPressed}
onPageSelected={onPageSelectedInner}
renderTabBar={renderTabBar}
onPageScrollStateChanged={adjustScrollForOtherPages}>
{toArray(children)
.filter(Boolean)
.map((child, i) => {
const isReady =
isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0
return (
<View key={i} collapsable={false}>
<PagerItem
headerHeight={headerHeight}
index={i}
isReady={isReady}
isFocused={i === currentPage}
onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
registerRef={registerRef}
renderTab={child}
/>
</View>
)
})}
</Pager>
)
}
let PagerTabBar = ({
currentPage,
@ -258,7 +253,7 @@ let PagerTabBar = ({
dragState: SharedValue<'idle' | 'dragging' | 'settling'>
}): React.ReactNode => {
const t = useTheme()
const [minimumHeaderHeight, setMinimumHeaderHeight] = React.useState(0)
const [minimumHeaderHeight, setMinimumHeaderHeight] = useState(0)
const headerTransform = useAnimatedStyle(() => {
const translateY =
Math.min(
@ -275,7 +270,7 @@ let PagerTabBar = ({
],
}
})
const headerRef = React.useRef(null)
const headerRef = useRef(null)
return (
<Animated.View
pointerEvents={isIOS ? 'auto' : 'box-none'}
@ -327,7 +322,7 @@ let PagerTabBar = ({
</Animated.View>
)
}
PagerTabBar = React.memo(PagerTabBar)
PagerTabBar = memo(PagerTabBar)
function PagerItem({
headerHeight,
@ -348,7 +343,7 @@ function PagerItem({
}) {
const scrollElRef = useAnimatedRef()
React.useEffect(() => {
useEffect(() => {
registerRef(scrollElRef, index)
return () => {
registerRef(null, index)

View File

@ -1,23 +1,28 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated'
import {AppBskyGraphDefs} from '@atproto/api'
import Animated, {
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
useAnimatedRef,
} from 'react-native-reanimated'
import {type AppBskyGraphDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeHandle} from '#/lib/strings/handles'
import {emitSoftReset} from '#/state/events'
import {useLightboxControls} from '#/state/lightbox'
import {TextLink} from '#/view/com/util/Link'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {Text} from '#/view/com/util/text/Text'
import {UserAvatar, UserAvatarType} from '#/view/com/util/UserAvatar'
import {UserAvatar, type UserAvatarType} from '#/view/com/util/UserAvatar'
import {StarterPack} from '#/components/icons/StarterPack'
import * as Layout from '#/components/Layout'
@ -52,7 +57,7 @@ export function ProfileSubpageHeader({
const {openLightbox} = useLightboxControls()
const pal = usePalette('default')
const canGoBack = navigation.canGoBack()
const aviRef = useHandleRef()
const aviRef = useAnimatedRef()
const _openLightbox = React.useCallback(
(uri: string, thumbRect: MeasuredDimensions | null) => {
@ -81,10 +86,9 @@ export function ProfileSubpageHeader({
if (
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) {
const aviHandle = aviRef.current
runOnUI(() => {
'worklet'
const rect = measureHandle(aviHandle)
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect)
})()
}
@ -111,7 +115,7 @@ export function ProfileSubpageHeader({
paddingBottom: 14,
paddingHorizontal: isMobile ? 12 : 14,
}}>
<View ref={aviRef} collapsable={false}>
<Animated.View ref={aviRef} collapsable={false}>
<Pressable
testID="headerAviButton"
onPress={onPressAvi}
@ -125,7 +129,7 @@ export function ProfileSubpageHeader({
<UserAvatar type={avatarType} size={58} avatar={avatar} />
)}
</Pressable>
</View>
</Animated.View>
<View style={{flex: 1, gap: 4}}>
{isLoading ? (
<LoadingPlaceholder

View File

@ -9,7 +9,6 @@ import {
type FontAwesomeIconStyle,
type Props as FontAwesomeProps,
} from '@fortawesome/react-native-fontawesome'
import type React from 'react'
const DURATION = 3500

View File

@ -1,12 +1,15 @@
import React, {useRef} from 'react'
import {DimensionValue, Pressable, View} from 'react-native'
import {type DimensionValue, Pressable, View} from 'react-native'
import Animated, {
type AnimatedRef,
useAnimatedRef,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
import {type AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef'
import type {Dimensions} from '#/lib/media/types'
import {type Dimensions} from '#/lib/media/types'
import {isNative} from '#/platform/detection'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
@ -68,14 +71,17 @@ export function AutoSizedImage({
image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained'
hideBadge?: boolean
onPress?: (containerRef: HandleRef, fetchedDims: Dimensions | null) => void
onPress?: (
containerRef: AnimatedRef<any>,
fetchedDims: Dimensions | null,
) => void
onLongPress?: () => void
onPressIn?: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const largeAlt = useLargeAltBadgeEnabled()
const containerRef = useHandleRef()
const containerRef = useAnimatedRef()
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
let aspectRatio: number | undefined
@ -103,7 +109,7 @@ export function AutoSizedImage({
const hasAlt = !!image.alt
const contents = (
<View ref={containerRef} collapsable={false} style={{flex: 1}}>
<Animated.View ref={containerRef} collapsable={false} style={{flex: 1}}>
<Image
contentFit={isContain ? 'contain' : 'cover'}
style={[a.w_full, a.h_full]}
@ -185,7 +191,7 @@ export function AutoSizedImage({
)}
</View>
) : null}
</View>
</Animated.View>
)
if (cropDisabled) {

View File

@ -1,12 +1,12 @@
import React from 'react'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import {Image, ImageStyle} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated'
import {Image, type ImageStyle} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import type React from 'react'
import {HandleRef} from '#/lib/hooks/useHandleRef'
import {Dimensions} from '#/lib/media/types'
import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
import {atoms as a, useTheme} from '#/alf'
@ -20,7 +20,7 @@ interface Props {
index: number
onPress?: (
index: number,
containerRefs: HandleRef[],
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onLongPress?: EventFunction
@ -28,7 +28,7 @@ interface Props {
imageStyle?: StyleProp<ImageStyle>
viewContext?: PostEmbedViewContext
insetBorderStyle?: StyleProp<ViewStyle>
containerRefs: HandleRef[]
containerRefs: AnimatedRef<any>[]
thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]>
}

View File

@ -1,18 +1,18 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {AppBskyEmbedImages} from '@atproto/api'
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
import {type AppBskyEmbedImages} from '@atproto/api'
import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef'
import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
import {atoms as a, useBreakpoints} from '#/alf'
import {Dimensions} from '../../lightbox/ImageViewing/@types'
import {type Dimensions} from '../../lightbox/ImageViewing/@types'
import {GalleryItem} from './Gallery'
interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]
onPress?: (
index: number,
containerRefs: HandleRef[],
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onLongPress?: (index: number) => void
@ -43,7 +43,7 @@ interface ImageLayoutGridInnerProps {
images: AppBskyEmbedImages.ViewImage[]
onPress?: (
index: number,
containerRefs: HandleRef[],
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onLongPress?: (index: number) => void
@ -56,10 +56,10 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
const gap = props.gap
const count = props.images.length
const containerRef1 = useHandleRef()
const containerRef2 = useHandleRef()
const containerRef3 = useHandleRef()
const containerRef4 = useHandleRef()
const containerRef1 = useAnimatedRef()
const containerRef2 = useAnimatedRef()
const containerRef3 = useAnimatedRef()
const containerRef4 = useAnimatedRef()
const thumbDimsRef = React.useRef<(Dimensions | null)[]>([])
switch (count) {

View File

@ -7,6 +7,8 @@ import {
type ViewStyle,
} from 'react-native'
import {
type AnimatedRef,
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
@ -25,7 +27,6 @@ import {
type ModerationDecision,
} from '@atproto/api'
import {type HandleRef, measureHandle} from '#/lib/hooks/useHandleRef'
import {usePalette} from '#/lib/hooks/usePalette'
import {useLightboxControls} from '#/state/lightbox'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@ -162,13 +163,15 @@ export function PostEmbeds({
}
const onPress = (
index: number,
refs: HandleRef[],
refs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => {
const handles = refs.map(r => r.current)
runOnUI(() => {
'worklet'
const rects = handles.map(measureHandle)
const rects: (MeasuredDimensions | null)[] = []
for (const r of refs) {
rects.push(measure(r))
}
runOnJS(_openLightbox)(index, rects, fetchedDims)
})()
}

766
yarn.lock

File diff suppressed because it is too large Load Diff