Compare commits

..

1 Commits

Author SHA1 Message Date
Dan Abramov 397335be53 Hook debugTopics to override X-Bsky-Topics 2024-11-01 16:38:38 +00:00
138 changed files with 5508 additions and 3469 deletions
+1 -2
View File
@@ -79,8 +79,7 @@ module.exports = {
},
],
'simple-import-sort/exports': 'error',
// TODO: Reenable when we figure out why it gets stuck on CI.
// 'react-compiler/react-compiler': 'error',
'react-compiler/react-compiler': 'warn',
'no-restricted-imports': [
'error',
{
@@ -3,6 +3,8 @@ on:
push:
branches:
- main
- bnewbold/embedr
- bnewbold/embedr-rebase
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
-4
View File
@@ -16,10 +16,6 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v3
- name: Install node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
+1 -1
View File
@@ -1 +1 @@
20
18
+2 -2
View File
@@ -5,7 +5,7 @@ WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive
# Node
ENV NODE_VERSION=20
ENV NODE_VERSION=18
ENV NVM_DIR=/usr/share/nvm
# Go
@@ -17,7 +17,7 @@ ENV GOEXPERIMENT="loopvar"
# Expo
ARG EXPO_PUBLIC_BUNDLE_IDENTIFIER
ENV EXPO_PUBLIC_BUNDLE_IDENTIFIER=${EXPO_PUBLIC_BUNDLE_IDENTIFIER:-dev}
ENV EXPO_PUBLIC_BUNDLE_IDENTIFIER ${EXPO_PUBLIC_BUNDLE_IDENTIFIER:-dev}
COPY . .
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive
# Node
ENV NODE_VERSION=20
ENV NODE_VERSION=18
ENV NVM_DIR=/usr/share/nvm
# Go
+2 -2
View File
@@ -17,8 +17,8 @@ appId: xyz.blueskyweb.app
- inputText: "Post with an image"
- tapOn:
id: "openGalleryBtn"
- tapOn: "Content warnings"
- tapOn: "Content warnings"
- tapOn:
id: "labelsBtn"
- tapOn: "Porn"
- tapOn:
label: "Tap on confirm"
+1
View File
@@ -29,6 +29,7 @@ appId: xyz.blueskyweb.app
- pressKey: Enter
- tapOn:
id: "nextBtn"
- tapOn: "Not now"
- tapOn:
id: "handleInput"
- inputText: "e2e-test"
-4
View File
@@ -23,7 +23,6 @@ appId: xyz.blueskyweb.app
id: "editDescriptionInput"
- inputText: "They good"
- tapOn: "Save"
- tapOn: "Save"
- assertNotVisible:
id: "createOrEditListModal"
- tapOn: "About"
@@ -46,7 +45,6 @@ appId: xyz.blueskyweb.app
- eraseText
- inputText: "They bad"
- tapOn: "Save"
- tapOn: "Save"
- assertNotVisible:
id: "createOrEditListModal"
- assertVisible: Bad Ppl
@@ -62,7 +60,6 @@ appId: xyz.blueskyweb.app
id: "editDescriptionInput"
- eraseText
- tapOn: "Save"
- tapOn: "Save"
- assertNotVisible:
id: "createOrEditListModal"
- assertNotVisible:
@@ -90,7 +87,6 @@ appId: xyz.blueskyweb.app
id: "editDescriptionInput"
- inputText: "They good"
- tapOn: "Save"
- tapOn: "Save"
- assertNotVisible:
id: "createOrEditListModal"
- tapOn: "About"
+1
View File
@@ -23,4 +23,5 @@ appId: xyz.blueskyweb.app
id: "loginPasswordInput"
- inputText: "hunter2"
- pressKey: Enter
- tapOn: "Not now"
- assertVisible: "Following"
-1
View File
@@ -22,7 +22,6 @@ appId: xyz.blueskyweb.app
id: "editDescriptionInput"
- inputText: "Shhh"
- tapOn: "Save"
- tapOn: "Save"
# view modlist
- assertVisible: "Muted Users"
+1 -1
View File
@@ -13,7 +13,7 @@ appId: xyz.blueskyweb.app
- tapOn: "Select an avatar"
- waitForAnimationToEnd
- tapOn:
point: "50%,22%"
point: "16%,22%"
- waitForAnimationToEnd
- tapOn: "Choose"
- waitForAnimationToEnd
-12
View File
@@ -335,10 +335,8 @@ describe('shortenLinks', () => {
describe('parseEmbedPlayerFromUrl', () => {
const inputs = [
'https://youtu.be/videoId',
'https://youtu.be/videoId?t=1s',
'https://www.youtube.com/watch?v=videoId',
'https://www.youtube.com/watch?v=videoId&feature=share',
'https://www.youtube.com/watch?v=videoId&t=1s',
'https://youtube.com/watch?v=videoId',
'https://youtube.com/watch?v=videoId&feature=share',
'https://youtube.com/shorts/videoId',
@@ -443,11 +441,6 @@ describe('parseEmbedPlayerFromUrl', () => {
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=1',
},
{
type: 'youtube_video',
source: 'youtube',
@@ -458,11 +451,6 @@ describe('parseEmbedPlayerFromUrl', () => {
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=1',
},
{
type: 'youtube_video',
source: 'youtube',
-2
View File
@@ -99,8 +99,6 @@ module.exports = function (config) {
dark: DARK_SPLASH_CONFIG,
},
entitlements: {
'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky',
},
privacyManifests: {
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M15.439 3.148a1 1 0 0 1 .41.645l.568 3.22a7 7 0 1 1-6.174 10.97L4.32 19.027a1 1 0 0 1-1.159-.811L1.078 6.398a1 1 0 0 1 .81-1.158l12.803-2.258a1 1 0 0 1 .748.166ZM9.325 16.114A7 7 0 0 1 9 14c0-1.56.51-3 1.372-4.164l-6.456 1.139 1.041 5.909 4.368-.77ZM3.568 9.005l10.833-1.91-.347-1.97L3.22 7.036l.347 1.97ZM16 9a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm0 2a1 1 0 0 1 1 1v1.586l1.374 1.374a1 1 0 0 1-1.414 1.414l-1.667-1.667A1 1 0 0 1 15 14v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 589 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 14c-2.95 0-5.163 1.733-6.08 4.21a.47.47 0 0 0 .09.493.9.9 0 0 0 .687.297H11a1 1 0 1 1 0 2H6.697a2.9 2.9 0 0 1-2.219-1.011 2.46 2.46 0 0 1-.433-2.473C5.235 14.296 8.168 12 12 12c.787 0 1.54.097 2.252.282a1 1 0 1 1-.504 1.936A7 7 0 0 0 12 14Zm6 0a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 569 B

+1 -6
View File
@@ -17,12 +17,7 @@ module.exports = function (api) {
],
plugins: [
'macros',
[
'babel-plugin-react-compiler',
{
runtimeModule: 'react-compiler-runtime',
},
],
['babel-plugin-react-compiler', {target: '18'}],
[
'module:react-native-dotenv',
{
+2 -2
View File
@@ -112,8 +112,8 @@ func serve(cctx *cli.Context) error {
Skipper: middleware.DefaultSkipper,
Store: middleware.NewRateLimiterMemoryStoreWithConfig(
middleware.RateLimiterMemoryStoreConfig{
Rate: 20, // requests per second
Burst: 150, // allow bursts
Rate: 10, // requests per second
Burst: 30, // allow bursts
ExpiresIn: 3 * time.Minute, // garbage collect entries older than 3 minutes
},
),
-21
View File
@@ -1,21 +0,0 @@
const React = require('react')
const $empty = Symbol.for('react.memo_cache_sentinel')
/**
* DANGER: this hook is NEVER meant to be called directly!
*
* Note that this is a temporary userspace implementation of this function
* from React 19. It is not as efficient and may invalidate more frequently
* than the official API. Please upgrade to React 19 as soon as you can.
**/
export function c(size) {
// eslint-disable-next-line react-hooks/rules-of-hooks
return React.useState(() => {
const $ = new Array(size)
for (let ii = 0; ii < size; ii++) {
$[ii] = $empty
}
// @ts-ignore
$[$empty] = true
return $
})[0]
}
-9
View File
@@ -1,9 +0,0 @@
{
"name": "react-compiler-runtime",
"version": "0.0.1",
"license": "MIT",
"main": "index.js",
"peerDependencies": {
"react": "^18.2.0"
}
}
@@ -64,14 +64,11 @@ class SheetViewController: UIViewController {
func updateDetents(contentHeight: CGFloat, preventExpansion: Bool) {
if let sheet = self.sheetPresentationController {
// Capture `self` weakly to prevent retain cycles.
// Also, capture `sheet` weakly to avoid potential strong references held by animateChanges.
sheet.animateChanges { [weak self, weak sheet] in
guard let weakSelf = self, let weakSheet = sheet else { return }
weakSelf.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion)
if #available(iOS 16.0, *) {
weakSheet.invalidateDetents()
}
sheet.animateChanges {
self.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion)
if #available(iOS 16.0, *) {
sheet.invalidateDetents()
}
}
}
}
+24 -1
View File
@@ -1 +1,24 @@
export {BottomSheetNativeComponent as BottomSheet} from './BottomSheetNativeComponent'
import React from 'react'
import {BottomSheetViewProps} from './BottomSheet.types'
import {BottomSheetNativeComponent} from './BottomSheetNativeComponent'
import {useBottomSheetPortal_INTERNAL} from './BottomSheetPortal'
export const BottomSheet = React.forwardRef<
BottomSheetNativeComponent,
BottomSheetViewProps
>(function BottomSheet(props, ref) {
const Portal = useBottomSheetPortal_INTERNAL()
if (__DEV__ && !Portal) {
throw new Error(
'BottomSheet: You need to wrap your component tree with a <BottomSheetPortalProvider> to use the bottom sheet.',
)
}
return (
<Portal>
<BottomSheetNativeComponent {...props} ref={ref} />
</Portal>
)
})
@@ -11,7 +11,6 @@ import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
import {BottomSheetPortalProvider} from './BottomSheetPortal'
import {Context as PortalContext} from './BottomSheetPortal'
const screenHeight = Dimensions.get('screen').height
@@ -35,8 +34,6 @@ export class BottomSheetNativeComponent extends React.Component<
> {
ref = React.createRef<any>()
static contextType = PortalContext
constructor(props: BottomSheetViewProps) {
super(props)
this.state = {
@@ -70,17 +67,6 @@ export class BottomSheetNativeComponent extends React.Component<
}
render() {
const Portal = this.context as React.ContextType<typeof PortalContext>
if (!Portal) {
throw new Error(
'BottomSheet: You need to wrap your component tree with a <BottomSheetPortalProvider> to use the bottom sheet.',
)
}
if (!this.state.open) {
return null
}
const {children, backgroundColor, ...rest} = this.props
const cornerRadius = rest.cornerRadius ?? 0
@@ -97,41 +83,43 @@ export class BottomSheetNativeComponent extends React.Component<
}
}
if (!this.state.open) {
return null
}
return (
<Portal>
<NativeView
{...rest}
onStateChange={this.onStateChange}
ref={this.ref}
style={{
position: 'absolute',
height: screenHeight,
width: '100%',
}}
containerBackgroundColor={backgroundColor}>
<NativeView
{...rest}
onStateChange={this.onStateChange}
ref={this.ref}
style={{
position: 'absolute',
height: screenHeight,
width: '100%',
}}
containerBackgroundColor={backgroundColor}>
<View
style={[
{
flex: 1,
backgroundColor,
},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
},
extraStyles,
]}>
<View
style={[
{
flex: 1,
backgroundColor,
},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
},
extraStyles,
]}>
<View
onLayout={e => {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
this.updateLayout()
}}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
onLayout={e => {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
this.updateLayout()
}}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</NativeView>
</Portal>
</View>
</NativeView>
)
}
}
@@ -4,7 +4,7 @@ import {createPortalGroup_INTERNAL} from './lib/Portal'
type PortalContext = React.ElementType<{children: React.ReactNode}>
export const Context = React.createContext({} as PortalContext)
const Context = React.createContext({} as PortalContext)
export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context)
+7 -7
View File
@@ -3,7 +3,7 @@
"version": "1.94.0",
"private": true,
"engines": {
"node": ">=20"
"node": ">=18"
},
"packageManager": "yarn@1.22.19",
"scripts": {
@@ -103,7 +103,7 @@
"@tiptap/suggestion": "^2.6.6",
"@types/invariant": "^2.2.37",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^20.14.3",
"@types/node": "^18.16.2",
"@zxing/text-encoding": "^0.9.0",
"array.prototype.findlast": "^1.2.3",
"await-lock": "^2.2.2",
@@ -166,14 +166,14 @@
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"react": "18.2.0",
"react-compiler-runtime": "file:./lib/react-compiler-runtime",
"react-compiler-runtime": "^19.0.0-beta-6fc168f-20241025",
"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",
"react-native-date-picker": "^4.4.2",
"react-native-drawer-layout": "^4.0.1",
"react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-gesture-handler": "2.20.0",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "0.41.2",
@@ -184,7 +184,7 @@
"react-native-picker-select": "^9.1.3",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.1",
"react-native-reanimated": "^3.16.1",
"react-native-reanimated": "^3.11.0",
"react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "~3.31.1",
@@ -243,7 +243,7 @@
"babel-loader": "^9.1.2",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-module-resolver": "^5.0.0",
"babel-plugin-react-compiler": "^0.0.0-experimental-592953e-20240517",
"babel-plugin-react-compiler": "^19.0.0-beta-6fc168f-20241025",
"babel-plugin-react-native-web": "^0.18.12",
"babel-preset-expo": "^10.0.0",
"eslint": "^8.19.0",
@@ -251,7 +251,7 @@
"eslint-plugin-ft-flow": "^2.0.3",
"eslint-plugin-lingui": "^0.2.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-compiler": "^0.0.0-experimental-c8b3f72-20240517",
"eslint-plugin-react-compiler": "^19.0.0-beta-6fc168f-20241025",
"eslint-plugin-react-native-a11y": "^3.3.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"html-webpack-plugin": "^5.5.0",
-18
View File
@@ -1,18 +0,0 @@
diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/exporters/RawImageExporter.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/exporters/RawImageExporter.kt
index f339e5f..fa35e82 100644
--- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/exporters/RawImageExporter.kt
+++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/exporters/RawImageExporter.kt
@@ -16,7 +16,12 @@ class RawImageExporter : ImageExporter {
copyFile(source, output, contentResolver)
val exifInterface = ExifInterface(output.absolutePath)
val imageRotation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)
- val isRotatedLandscape = (imageRotation == ExifInterface.ORIENTATION_ROTATE_90 || imageRotation == ExifInterface.ORIENTATION_ROTATE_270)
+ val isRotatedLandscape = (
+ imageRotation == ExifInterface.ORIENTATION_ROTATE_90 ||
+ imageRotation == ExifInterface.ORIENTATION_ROTATE_270 ||
+ imageRotation == ExifInterface.ORIENTATION_TRANSPOSE ||
+ imageRotation == ExifInterface.ORIENTATION_TRANSVERSE
+ )
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(output.absolutePath, options)
+12 -125
View File
@@ -9,7 +9,7 @@ index caa5540..c5d4e67 100644
- 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
@@ -18,7 +18,7 @@ index b0d71dc..41b9a0e 100644
@@ -377,10 +377,6 @@ - (void)textInputDidBeginEditing
self.backedTextInputView.attributedText = [NSAttributedString new];
}
- if (_selectTextOnFocus) {
- [self.backedTextInputView selectAll:nil];
- }
@@ -35,7 +35,7 @@ index b0d71dc..41b9a0e 100644
+ [self.backedTextInputView selectAll:nil];
+ }
}
- (void)reactBlur
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
@@ -48,10 +48,10 @@ index e9b330f..ec5f58c 100644
+@property (nonatomic, copy) UIColor *customTintColor;
+
+- (void)forwarderBeginRefreshing;
@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..d2b4e05 100644
index b09e653..288e60c 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -22,6 +22,7 @@ @implementation RCTRefreshControl {
@@ -60,12 +60,12 @@ index b09e653..d2b4e05 100644
CGFloat _progressViewOffset;
+ UIColor *_customTintColor;
}
- (instancetype)init
@@ -56,6 +57,12 @@ - (void)layoutSubviews
_isInitialRender = false;
}
+- (void)didMoveToSuperview
+{
+ [super didMoveToSuperview];
@@ -78,7 +78,7 @@ index b09e653..d2b4e05 100644
@@ -203,4 +210,58 @@ - (void)refreshControlValueChanged
}
}
+- (void)setCustomTintColor:(UIColor *)customTintColor
+{
+ _customTintColor = customTintColor;
@@ -139,139 +139,26 @@ index 40aaf9c..1c60164 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -22,11 +22,12 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
+RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
+
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/RCTScrollView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
index 1aead51..39e6244 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
@@ -159,31 +159,6 @@ - (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view
return !shouldDisableScrollInteraction;
}
-/*
- * Automatically centers the content such that if the content is smaller than the
- * ScrollView, we force it to be centered, but when you zoom or the content otherwise
- * becomes larger than the ScrollView, there is no padding around the content but it
- * can still fill the whole view.
- */
-- (void)setContentOffset:(CGPoint)contentOffset
-{
- UIView *contentView = [self contentView];
- if (contentView && _centerContent && !CGSizeEqualToSize(contentView.frame.size, CGSizeZero)) {
- CGSize subviewSize = contentView.frame.size;
- CGSize scrollViewSize = self.bounds.size;
- if (subviewSize.width <= scrollViewSize.width) {
- contentOffset.x = -(scrollViewSize.width - subviewSize.width) / 2.0;
- }
- if (subviewSize.height <= scrollViewSize.height) {
- contentOffset.y = -(scrollViewSize.height - subviewSize.height) / 2.0;
- }
- }
-
- super.contentOffset = CGPointMake(
- RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
- RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
-}
-
- (void)setFrame:(CGRect)frame
{
// Preserving and revalidating `contentOffset`.
@@ -427,6 +402,11 @@ - (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
// Does nothing
}
+- (void)setFrame:(CGRect)frame {
+ [super setFrame:frame];
+ [self centerContentIfNeeded];
+}
+
- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
{
[super insertReactSubview:view atIndex:atIndex];
@@ -444,6 +424,8 @@ - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
RCTApplyTransformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
[_scrollView addSubview:view];
}
+
+ [self centerContentIfNeeded];
}
- (void)removeReactSubview:(UIView *)subview
@@ -652,9 +634,46 @@ -(void)delegateMethod : (UIScrollView *)scrollView \
}
RCT_SCROLL_EVENT_HANDLER(scrollViewWillBeginDecelerating, onMomentumScrollBegin)
-RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll)
RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
+-(void)scrollViewDidZoom : (UIScrollView *)scrollView
+{
+ [self centerContentIfNeeded];
+
+ RCT_SEND_SCROLL_EVENT(onScroll, nil);
+ RCT_FORWARD_SCROLL_EVENT(scrollViewDidZoom : scrollView);
+}
+
+/*
+ * Automatically centers the content such that if the content is smaller than the
+ * ScrollView, we force it to be centered, but when you zoom or the content otherwise
+ * becomes larger than the ScrollView, there is no padding around the content but it
+ * can still fill the whole view.
+ *
+ * PATCHED: This deviates from the original React Native implementation to fix two issues:
+ *
+ * - The scroll view was swallowing any taps immediately after pinching
+ * - The scroll view was jittering when crossing the full screen threshold while pinching
+ *
+ * This implementation is based on https://petersteinberger.com/blog/2013/how-to-center-uiscrollview/.
+ */
+-(void)centerContentIfNeeded
+{
+ if (_scrollView.centerContent &&
+ !CGSizeEqualToSize(self.contentSize, CGSizeZero) &&
+ !CGSizeEqualToSize(self.bounds.size, CGSizeZero)
+ ) {
+ CGFloat top = 0, left = 0;
+ if (self.contentSize.width < self.bounds.size.width) {
+ left = (self.bounds.size.width - self.contentSize.width) * 0.5f;
+ }
+ if (self.contentSize.height < self.bounds.size.height) {
+ top = (self.bounds.size.height - self.contentSize.height) * 0.5f;
+ }
+ _scrollView.contentInset = UIEdgeInsetsMake(top, left, top, left);
+ }
+}
+
- (void)addScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
{
[_scrollListeners addObject:scrollListener];
@@ -913,6 +932,7 @@ - (void)updateContentSizeIfNeeded
CGSize contentSize = self.contentSize;
if (!CGSizeEqualToSize(_scrollView.contentSize, contentSize)) {
_scrollView.contentSize = contentSize;
+ [self centerContentIfNeeded];
}
}
diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
index 5f5e1ab..aac00b6 100644
--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
@@ -99,8 +99,9 @@ public class JavaTimerManager {
}
// If the JS thread is busy for multiple frames we cancel any other pending runnable.
- if (mCurrentIdleCallbackRunnable != null) {
- mCurrentIdleCallbackRunnable.cancel();
@@ -279,5 +166,5 @@ index 5f5e1ab..aac00b6 100644
+ if (currentRunnable != null) {
+ currentRunnable.cancel();
}
mCurrentIdleCallbackRunnable = new IdleCallbackRunnable(frameTimeNanos);
@@ -0,0 +1,209 @@
diff --git a/node_modules/react-native-reanimated/src/createAnimatedComponent/commonTypes.ts b/node_modules/react-native-reanimated/src/createAnimatedComponent/commonTypes.ts
index 92ebe62..5f8207e 100644
--- a/node_modules/react-native-reanimated/src/createAnimatedComponent/commonTypes.ts
+++ b/node_modules/react-native-reanimated/src/createAnimatedComponent/commonTypes.ts
@@ -96,7 +96,8 @@ export interface AnimatedComponentRef extends Component {
export interface IAnimatedComponentInternal {
_styles: StyleProps[] | null;
_animatedProps?: Partial<AnimatedComponentProps<AnimatedProps>>;
- _viewTag: number;
+ _componentViewTag: number;
+ _eventViewTag: number;
_isFirstRender: boolean;
jestAnimatedStyle: { value: StyleProps };
_component: AnimatedComponentRef | HTMLElement | null;
diff --git a/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx b/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx
index 88b3fdf..2488ebc 100644
--- a/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx
+++ b/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx
@@ -122,7 +122,8 @@ export function createAnimatedComponent(
{
_styles: StyleProps[] | null = null;
_animatedProps?: Partial<AnimatedComponentProps<AnimatedProps>>;
- _viewTag = -1;
+ _componentViewTag = -1;
+ _eventViewTag = -1;
_isFirstRender = true;
jestAnimatedStyle: { value: StyleProps } = { value: {} };
_component: AnimatedComponentRef | HTMLElement | null = null;
@@ -143,7 +144,8 @@ export function createAnimatedComponent(
}
componentDidMount() {
- this._viewTag = this._getViewInfo().viewTag as number;
+ this._setComponentViewTag();
+ this._setEventViewTag();
this._attachNativeEvents();
this._jsPropsUpdater.addOnJSPropsChangeListener(this);
this._attachAnimatedStyles();
@@ -185,7 +187,10 @@ export function createAnimatedComponent(
if (this.props.sharedTransitionTag) {
this._configureSharedTransition(true);
}
- this._sharedElementTransition?.unregisterTransition(this._viewTag, true);
+ this._sharedElementTransition?.unregisterTransition(
+ this._componentViewTag,
+ true
+ );
const exiting = this.props.exiting;
if (
@@ -209,7 +214,7 @@ export function createAnimatedComponent(
: getReduceMotionFromConfig();
if (!reduceMotionInExiting) {
updateLayoutAnimations(
- this._viewTag,
+ this._componentViewTag,
LayoutAnimationType.EXITING,
maybeBuild(
exiting,
@@ -221,12 +226,22 @@ export function createAnimatedComponent(
}
}
- _getEventViewRef() {
- // Make sure to get the scrollable node for components that implement
- // `ScrollResponder.Mixin`.
- return (this._component as AnimatedComponentRef)?.getScrollableNode
- ? (this._component as AnimatedComponentRef).getScrollableNode?.()
- : this._component;
+ _setComponentViewTag() {
+ this._componentViewTag = this._getViewInfo().viewTag as number;
+ }
+
+ _setEventViewTag() {
+ // Setting the tag for registering events - since the event emitting view can be nested inside the main component
+ const componentAnimatedRef = this._component as AnimatedComponentRef;
+ if (componentAnimatedRef.getScrollableNode) {
+ const scrollableNode = componentAnimatedRef.getScrollableNode();
+ this._eventViewTag = findNodeHandle(scrollableNode) ?? -1;
+ } else {
+ this._eventViewTag =
+ findNodeHandle(
+ options?.setNativeProps ? this : componentAnimatedRef
+ ) ?? -1;
+ }
}
_attachNativeEvents() {
@@ -236,7 +251,7 @@ export function createAnimatedComponent(
has('workletEventHandler', prop) &&
prop.workletEventHandler instanceof WorkletEventHandler
) {
- prop.workletEventHandler.registerForEvents(this._viewTag, key);
+ prop.workletEventHandler.registerForEvents(this._eventViewTag, key);
}
}
}
@@ -248,7 +263,7 @@ export function createAnimatedComponent(
has('workletEventHandler', prop) &&
prop.workletEventHandler instanceof WorkletEventHandler
) {
- prop.workletEventHandler.unregisterFromEvents(this._viewTag);
+ prop.workletEventHandler.unregisterFromEvents(this._eventViewTag);
}
}
}
@@ -258,15 +273,17 @@ export function createAnimatedComponent(
for (const style of this._styles) {
style.viewsRef.remove(this);
}
- } else if (this._viewTag !== -1 && this._styles !== null) {
+ } else if (this._componentViewTag !== -1 && this._styles !== null) {
for (const style of this._styles) {
- style.viewDescriptors.remove(this._viewTag);
+ style.viewDescriptors.remove(this._componentViewTag);
}
if (this.props.animatedProps?.viewDescriptors) {
- this.props.animatedProps.viewDescriptors.remove(this._viewTag);
+ this.props.animatedProps.viewDescriptors.remove(
+ this._componentViewTag
+ );
}
if (isFabric()) {
- removeFromPropsRegistry(this._viewTag);
+ removeFromPropsRegistry(this._componentViewTag);
}
}
}
@@ -283,15 +300,19 @@ export function createAnimatedComponent(
const newProp = this.props[key];
if (!newProp) {
// Prop got deleted
- prevProp.workletEventHandler.unregisterFromEvents(this._viewTag);
+ prevProp.workletEventHandler.unregisterFromEvents(
+ this._eventViewTag
+ );
} else if (
has('workletEventHandler', newProp) &&
newProp.workletEventHandler instanceof WorkletEventHandler &&
newProp.workletEventHandler !== prevProp.workletEventHandler
) {
// Prop got changed
- prevProp.workletEventHandler.unregisterFromEvents(this._viewTag);
- newProp.workletEventHandler.registerForEvents(this._viewTag);
+ prevProp.workletEventHandler.unregisterFromEvents(
+ this._eventViewTag
+ );
+ newProp.workletEventHandler.registerForEvents(this._eventViewTag);
}
}
}
@@ -304,7 +325,7 @@ export function createAnimatedComponent(
!prevProps[key]
) {
// Prop got added
- newProp.workletEventHandler.registerForEvents(this._viewTag);
+ newProp.workletEventHandler.registerForEvents(this._eventViewTag);
}
}
}
@@ -381,7 +402,7 @@ export function createAnimatedComponent(
adaptViewConfig(viewConfig);
}
- this._viewTag = viewTag as number;
+ this._componentViewTag = viewTag as number;
// remove old styles
if (prevStyles) {
@@ -487,7 +508,11 @@ export function createAnimatedComponent(
AnimatedComponent.displayName
)
: undefined;
- updateLayoutAnimations(this._viewTag, LayoutAnimationType.LAYOUT, layout);
+ updateLayoutAnimations(
+ this._componentViewTag,
+ LayoutAnimationType.LAYOUT,
+ layout
+ );
}
_configureSharedTransition(isUnmounting = false) {
@@ -497,7 +522,7 @@ export function createAnimatedComponent(
const { sharedTransitionTag } = this.props;
if (!sharedTransitionTag) {
this._sharedElementTransition?.unregisterTransition(
- this._viewTag,
+ this._componentViewTag,
isUnmounting
);
this._sharedElementTransition = null;
@@ -508,7 +533,7 @@ export function createAnimatedComponent(
this._sharedElementTransition ??
new SharedTransition();
sharedElementTransition.registerTransition(
- this._viewTag,
+ this._componentViewTag,
sharedTransitionTag,
isUnmounting
);
@@ -527,7 +552,7 @@ export function createAnimatedComponent(
? (ref as HTMLElement)
: findNodeHandle(ref as Component);
- this._viewTag = tag as number;
+ this._componentViewTag = tag as number;
const { layout, entering, exiting, sharedTransitionTag } = this.props;
if (
+11 -11
View File
@@ -40,11 +40,14 @@ import {
shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from '#/state/shell/reminders'
import {AccessibilitySettingsScreen} from '#/view/screens/AccessibilitySettings'
import {AppPasswords} from '#/view/screens/AppPasswords'
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
import {DebugModScreen} from '#/view/screens/DebugMod'
import {FeedsScreen} from '#/view/screens/Feeds'
import {HomeScreen} from '#/view/screens/Home'
import {LanguageSettingsScreen} from '#/view/screens/LanguageSettings'
import {ListsScreen} from '#/view/screens/Lists'
import {LogScreen} from '#/view/screens/Log'
import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts'
@@ -53,6 +56,9 @@ import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
import {NotFoundScreen} from '#/view/screens/NotFound'
import {NotificationsScreen} from '#/view/screens/Notifications'
import {PostThreadScreen} from '#/view/screens/PostThread'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed'
import {PreferencesThreads} from '#/view/screens/PreferencesThreads'
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
import {ProfileScreen} from '#/view/screens/Profile'
import {ProfileFeedScreen} from '#/view/screens/ProfileFeed'
@@ -62,6 +68,7 @@ import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows'
import {ProfileListScreen} from '#/view/screens/ProfileList'
import {SavedFeeds} from '#/view/screens/SavedFeeds'
import {SearchScreen} from '#/view/screens/Search'
import {SettingsScreen} from '#/view/screens/Settings'
import {Storybook} from '#/view/screens/Storybook'
import {SupportScreen} from '#/view/screens/Support'
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
@@ -89,16 +96,9 @@ import {useTheme} from '#/alf'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {AboutSettingsScreen} from './screens/Settings/AboutSettings'
import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings'
import {AccountSettingsScreen} from './screens/Settings/AccountSettings'
import {AppPasswordsScreen} from './screens/Settings/AppPasswords'
import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings'
import {ExternalMediaPreferencesScreen} from './screens/Settings/ExternalMediaPreferences'
import {FollowingFeedPreferencesScreen} from './screens/Settings/FollowingFeedPreferences'
import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings'
import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings'
import {SettingsScreen} from './screens/Settings/Settings'
import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -285,7 +285,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
/>
<Stack.Screen
name="AppPasswords"
getComponent={() => AppPasswordsScreen}
getComponent={() => AppPasswords}
options={{title: title(msg`App Passwords`), requireAuth: true}}
/>
<Stack.Screen
@@ -295,7 +295,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
/>
<Stack.Screen
name="PreferencesFollowingFeed"
getComponent={() => FollowingFeedPreferencesScreen}
getComponent={() => PreferencesFollowingFeed}
options={{
title: title(msg`Following Feed Preferences`),
requireAuth: true,
@@ -303,12 +303,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
/>
<Stack.Screen
name="PreferencesThreads"
getComponent={() => ThreadPreferencesScreen}
getComponent={() => PreferencesThreads}
options={{title: title(msg`Threads Preferences`), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesExternalEmbeds"
getComponent={() => ExternalMediaPreferencesScreen}
getComponent={() => PreferencesExternalEmbeds}
options={{
title: title(msg`External Media Preferences`),
requireAuth: true,
-1
View File
@@ -56,7 +56,6 @@ type Props = {
const AnimatedLogo = Animated.createAnimatedComponent(Logo)
export function Splash(props: React.PropsWithChildren<Props>) {
'use no memo'
const insets = useSafeAreaInsets()
const intro = useSharedValue(0)
const outroLogo = useSharedValue(0)
+1 -4
View File
@@ -1,4 +1,4 @@
import {Platform, StyleProp, StyleSheet, ViewStyle} from 'react-native'
import {Platform, StyleSheet, ViewStyle} from 'react-native'
import * as tokens from '#/alf/tokens'
import {native, web} from '#/alf/util/platform'
@@ -887,9 +887,6 @@ export const atoms = {
user_select_all: {
userSelect: 'all',
},
outline_inset_1: {
outlineOffset: '-1px',
} as StyleProp<ViewStyle>,
/*
* Text decoration
+28 -25
View File
@@ -103,32 +103,35 @@ export function ThemeProvider({
})
}, [])
const value = React.useMemo<Alf>(
() => ({
themes,
themeName: themeName,
theme: themes[themeName],
fonts: {
scale: fontScale,
scaleMultiplier: fontScaleMultiplier,
family: fontFamily,
setFontScale: setFontScaleAndPersist,
setFontFamily: setFontFamilyAndPersist,
},
flags: {},
}),
[
themeName,
themes,
fontScale,
setFontScaleAndPersist,
fontFamily,
setFontFamilyAndPersist,
fontScaleMultiplier,
],
return (
<Context.Provider
value={React.useMemo<Alf>(
() => ({
themes,
themeName: themeName,
theme: themes[themeName],
fonts: {
scale: fontScale,
scaleMultiplier: fontScaleMultiplier,
family: fontFamily,
setFontScale: setFontScaleAndPersist,
setFontFamily: setFontFamilyAndPersist,
},
flags: {},
}),
[
themeName,
themes,
fontScale,
setFontScaleAndPersist,
fontFamily,
setFontFamilyAndPersist,
fontScaleMultiplier,
],
)}>
{children}
</Context.Provider>
)
return <Context.Provider value={value}>{children}</Context.Provider>
}
export function useAlf() {
-76
View File
@@ -1,76 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {moderateProfile} from '@atproto/api'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
export function AvatarStack({
profiles,
size = 26,
}: {
profiles: string[]
size?: number
}) {
const halfSize = size / 2
const {data, error} = useProfilesQuery({handles: profiles})
const t = useTheme()
const moderationOpts = useModerationOpts()
if (error) {
console.error(error)
return null
}
const isPending = !data || !moderationOpts
const items = isPending
? Array.from({length: profiles.length}).map((_, i) => ({
key: i,
profile: null,
moderation: null,
}))
: data.profiles.map(item => ({
key: item.did,
profile: item,
moderation: moderateProfile(item, moderationOpts),
}))
return (
<View
style={[
a.flex_row,
a.align_center,
a.relative,
{width: size + (items.length - 1) * halfSize},
]}>
{items.map((item, i) => (
<View
key={item.key}
style={[
t.atoms.bg_contrast_25,
a.relative,
{
width: size,
height: size,
left: i * -halfSize,
borderWidth: 1,
borderColor: t.atoms.bg.backgroundColor,
borderRadius: 999,
zIndex: 3 - i,
},
]}>
{item.profile && (
<UserAvatar
size={size - 2}
avatar={item.profile.avatar}
moderation={item.moderation.ui('avatar')}
/>
)}
</View>
))}
</View>
)
}
+1 -1
View File
@@ -15,7 +15,7 @@ import {
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {runOnJS} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+1 -1
View File
@@ -32,7 +32,7 @@ export function Error({
return (
<CenteredView
style={[
a.h_full_vh,
a.flex_1,
a.align_center,
a.gap_5xl,
!gtMobile && a.justify_between,
+1 -2
View File
@@ -46,7 +46,7 @@ export function Root({
return <Context.Provider value={context}>{children}</Context.Provider>
}
export function Trigger({children, label, role = 'button'}: TriggerProps) {
export function Trigger({children, label}: TriggerProps) {
const {control} = React.useContext(Context)
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
@@ -70,7 +70,6 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) {
onPressIn,
onPressOut,
accessibilityLabel: label,
accessibilityRole: role,
},
})
}
+1 -2
View File
@@ -111,7 +111,7 @@ const RadixTriggerPassThrough = React.forwardRef(
)
RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough'
export function Trigger({children, label, role = 'button'}: TriggerProps) {
export function Trigger({children, label}: TriggerProps) {
const {control} = React.useContext(Context)
const {
state: hovered,
@@ -155,7 +155,6 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) {
onMouseEnter,
onMouseLeave,
accessibilityLabel: label,
accessibilityRole: role,
},
})
}
-4
View File
@@ -1,7 +1,6 @@
import React from 'react'
import {
AccessibilityProps,
AccessibilityRole,
GestureResponderEvent,
PressableProps,
} from 'react-native'
@@ -37,7 +36,6 @@ export type RadixPassThroughTriggerProps = {
export type TriggerProps = {
children(props: TriggerChildProps): React.ReactNode
label: string
role?: AccessibilityRole
}
export type TriggerChildProps =
| {
@@ -65,7 +63,6 @@ export type TriggerChildProps =
onPressIn: () => void
onPressOut: () => void
accessibilityLabel: string
accessibilityRole: AccessibilityRole
}
}
| {
@@ -86,7 +83,6 @@ export type TriggerChildProps =
onMouseEnter: () => void
onMouseLeave: () => void
accessibilityLabel: string
accessibilityRole: AccessibilityRole
}
}
@@ -14,7 +14,6 @@ import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useEmail} from '#/lib/hooks/useEmail'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
import {parseStarterPackUri} from '#/lib/strings/starter-pack'
@@ -28,7 +27,6 @@ import {LinearGradientBackground} from '#/components/LinearGradientBackground'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus'
interface SectionRef {
@@ -188,9 +186,6 @@ function Empty() {
const followersDialogControl = useDialogControl()
const errorDialogControl = useDialogControl()
const {needsEmailVerification} = useEmail()
const verifyEmailControl = useDialogControl()
const [isGenerating, setIsGenerating] = React.useState(false)
const {mutate: generateStarterPack} = useGenerateStarterPackMutation({
@@ -254,13 +249,7 @@ function Empty() {
color="primary"
size="small"
disabled={isGenerating}
onPress={() => {
if (needsEmailVerification) {
verifyEmailControl.open()
} else {
confirmDialogControl.open()
}
}}
onPress={confirmDialogControl.open}
style={{backgroundColor: 'transparent'}}>
<ButtonText style={{color: 'white'}}>
<Trans>Make one for me</Trans>
@@ -273,13 +262,7 @@ function Empty() {
color="primary"
size="small"
disabled={isGenerating}
onPress={() => {
if (needsEmailVerification) {
verifyEmailControl.open()
} else {
navigation.navigate('StarterPackWizard')
}
}}
onPress={() => navigation.navigate('StarterPackWizard')}
style={{
backgroundColor: 'white',
borderColor: 'white',
@@ -335,12 +318,6 @@ function Empty() {
onConfirm={generate}
confirmButtonCta={_(msg`Retry`)}
/>
<VerifyEmailDialog
reasonText={_(
msg`Before creating a starter pack, you must first verify your email.`,
)}
control={verifyEmailControl}
/>
</LinearGradientBackground>
)
}
+1 -3
View File
@@ -1,5 +1,3 @@
import {ViewStyleProp} from '#/alf'
export function SubtleWebHover({}: ViewStyleProp & {hover: boolean}) {
export function SubtleWebHover({}: {hover: boolean}) {
return null
}
+5 -7
View File
@@ -2,12 +2,9 @@ import React from 'react'
import {StyleSheet, View} from 'react-native'
import {isTouchDevice} from '#/lib/browser'
import {useTheme, ViewStyleProp} from '#/alf'
import {useTheme} from '#/alf'
export function SubtleWebHover({
style,
hover,
}: ViewStyleProp & {hover: boolean}) {
export function SubtleWebHover({hover}: {hover: boolean}) {
const t = useTheme()
if (isTouchDevice) {
return null
@@ -29,8 +26,9 @@ export function SubtleWebHover({
style={[
t.atoms.bg_contrast_25,
styles.container,
{opacity: hover ? opacity : 0},
style,
{
opacity: hover ? opacity : 0,
},
]}
/>
)
+21 -41
View File
@@ -18,14 +18,8 @@ import {Text} from '#/components/Typography'
export function VerifyEmailDialog({
control,
onCloseWithoutVerifying,
onCloseAfterVerifying,
reasonText,
}: {
control: Dialog.DialogControlProps
onCloseWithoutVerifying?: () => void
onCloseAfterVerifying?: () => void
reasonText?: string
}) {
const agent = useAgent()
@@ -36,24 +30,18 @@ export function VerifyEmailDialog({
control={control}
onClose={async () => {
if (!didVerify) {
onCloseWithoutVerifying?.()
return
}
try {
await agent.resumeSession(agent.session!)
onCloseAfterVerifying?.()
} catch (e: unknown) {
logger.error(String(e))
return
}
}}>
<Dialog.Handle />
<Inner
control={control}
setDidVerify={setDidVerify}
reasonText={reasonText}
/>
<Inner control={control} setDidVerify={setDidVerify} />
</Dialog.Outer>
)
}
@@ -61,11 +49,9 @@ export function VerifyEmailDialog({
export function Inner({
control,
setDidVerify,
reasonText,
}: {
control: Dialog.DialogControlProps
setDidVerify: (value: boolean) => void
reasonText?: string
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
@@ -149,32 +135,26 @@ export function Inner({
<Text style={[a.text_md, a.leading_snug]}>
{currentStep === 'StepOne' ? (
<>
{!reasonText ? (
<>
<Trans>
You'll receive an email at{' '}
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
{currentAccount?.email}
</Text>{' '}
to verify it's you.
</Trans>{' '}
<InlineLinkText
to="#"
label={_(msg`Change email address`)}
style={[a.text_md, a.leading_snug]}
onPress={e => {
e.preventDefault()
control.close(() => {
openModal({name: 'change-email'})
})
return false
}}>
<Trans>Need to change it?</Trans>
</InlineLinkText>
</>
) : (
reasonText
)}
<Trans>
You'll receive an email at{' '}
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
{currentAccount?.email}
</Text>{' '}
to verify it's you.
</Trans>{' '}
<InlineLinkText
to="#"
label={_(msg`Change email address`)}
style={[a.text_md, a.leading_snug]}
onPress={e => {
e.preventDefault()
control.close(() => {
openModal({name: 'change-email'})
})
return false
}}>
<Trans>Need to change it?</Trans>
</InlineLinkText>
</>
) : (
uiStrings[currentStep].message
+1 -2
View File
@@ -53,11 +53,9 @@ export function ActionsWrapper({
.numberOfTaps(2)
.hitSlop(HITSLOP_10)
.onEnd(open)
.runOnJS(true)
const pressAndHoldGesture = Gesture.LongPress()
.onStart(() => {
'worklet'
scale.value = withTiming(1.05, {duration: 200}, finished => {
if (!finished) return
runOnJS(open)()
@@ -67,6 +65,7 @@ export function ActionsWrapper({
.onTouchesUp(shrink)
.onTouchesMove(shrink)
.cancelsTouchesInView(false)
.runOnJS(true)
const composedGestures = Gesture.Exclusive(
doubleTapGesture,
+15 -41
View File
@@ -3,18 +3,14 @@ import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useEmail} from '#/lib/hooks/useEmail'
import {NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {ButtonIcon} from '#/components/Button'
import {canBeMessaged} from '#/components/dms/util'
import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
import {useDialogControl} from '../Dialog'
import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog'
import {Link} from '#/components/Link'
export function MessageProfileButton({
profile,
@@ -23,29 +19,15 @@ export function MessageProfileButton({
}) {
const {_} = useLingui()
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
const {needsEmailVerification} = useEmail()
const verifyEmailControl = useDialogControl()
const {data: convo, isPending} = useMaybeConvoForUser(profile.did)
const onPress = React.useCallback(() => {
if (!convo?.id) {
return
}
if (needsEmailVerification) {
verifyEmailControl.open()
return
}
if (convo && !convo.lastMessage) {
logEvent('chat:create', {logContext: 'ProfileHeader'})
}
logEvent('chat:open', {logContext: 'ProfileHeader'})
navigation.navigate('MessagesConversation', {conversation: convo.id})
}, [needsEmailVerification, verifyEmailControl, convo, navigation])
}, [convo])
if (isPending) {
// show pending state based on declaration
@@ -71,26 +53,18 @@ export function MessageProfileButton({
if (convo) {
return (
<>
<Button
accessibilityRole="button"
testID="dmBtn"
size="small"
color="secondary"
variant="solid"
shape="round"
label={_(msg`Message ${profile.handle}`)}
style={[a.justify_center]}
onPress={onPress}>
<ButtonIcon icon={Message} size="md" />
</Button>
<VerifyEmailDialog
reasonText={_(
msg`Before you may message another user, you must first verify your email.`,
)}
control={verifyEmailControl}
/>
</>
<Link
testID="dmBtn"
size="small"
color="secondary"
variant="solid"
shape="round"
label={_(msg`Message ${profile.handle}`)}
to={`/messages/${convo.id}`}
style={[a.justify_center]}
onPress={onPress}>
<ButtonIcon icon={Message} size="md" />
</Link>
)
} else {
return null
+1 -1
View File
@@ -147,7 +147,7 @@ function HeaderReady({
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
? _(msg`Deleted Account`)
? 'Deleted Account'
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
+1 -19
View File
@@ -2,7 +2,6 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useEmail} from '#/lib/hooks/useEmail'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
@@ -10,8 +9,6 @@ import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SearchablePeopleList} from './SearchablePeopleList'
@@ -24,8 +21,6 @@ export function NewChat({
}) {
const t = useTheme()
const {_} = useLingui()
const {needsEmailVerification} = useEmail()
const verifyEmailControl = useDialogControl()
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
@@ -53,13 +48,7 @@ export function NewChat({
<>
<FAB
testID="newChatFAB"
onPress={() => {
if (needsEmailVerification) {
verifyEmailControl.open()
} else {
control.open()
}
}}
onPress={control.open}
icon={<Plus size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New chat`)}
@@ -73,13 +62,6 @@ export function NewChat({
onSelectChat={onCreateChat}
/>
</Dialog.Outer>
<VerifyEmailDialog
reasonText={_(
msg`Before you may message another user, you must first verify your email.`,
)}
control={verifyEmailControl}
/>
</>
)
}
+1 -1
View File
@@ -57,7 +57,7 @@ export const SearchInput = React.forwardRef<TextInput, SearchInputProps>(
<View
style={[
a.absolute,
a.z_20,
a.z_10,
a.my_auto,
a.inset_0,
a.justify_center,
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CalendarClock_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M15.439 3.148a1 1 0 0 1 .41.645l.568 3.22a7 7 0 1 1-6.174 10.97L4.32 19.027a1 1 0 0 1-1.159-.811L1.078 6.398a1 1 0 0 1 .81-1.158l12.803-2.258a1 1 0 0 1 .748.166ZM9.325 16.114A7 7 0 0 1 9 14c0-1.56.51-3 1.372-4.164l-6.456 1.139 1.041 5.909 4.368-.77ZM3.568 9.005l10.833-1.91-.347-1.97L3.22 7.036l.347 1.97ZM16 9a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm0 2a1 1 0 0 1 1 1v1.586l1.374 1.374a1 1 0 0 1-1.414 1.414l-1.667-1.667A1 1 0 0 1 15 14v-2a1 1 0 0 1 1-1Z',
})
-4
View File
@@ -24,10 +24,6 @@ export const PersonPlus_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H15a3 3 0 1 1 0-6c0-.824.332-1.571.87-2.113C14.739 12.32 13.435 12 12 12Zm6 2a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
})
export const PersonPlus_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 14c-2.95 0-5.163 1.733-6.08 4.21a.47.47 0 0 0 .09.493.9.9 0 0 0 .687.297H11a1 1 0 1 1 0 2H6.697a2.9 2.9 0 0 1-2.219-1.011 2.46 2.46 0 0 1-.433-2.473C5.235 14.296 8.168 12 12 12c.787 0 1.54.097 2.252.282a1 1 0 1 1-.504 1.936A7 7 0 0 0 12 14Zm6 0a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
})
export const PersonGroup_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm7.301 9.7c-.836-2.6-2.88-3.503-4.575-3.111a1 1 0 0 1-.451-1.949c2.815-.651 5.81.966 6.93 4.448a2.49 2.49 0 0 1-.506 2.43A2.92 2.92 0 0 1 20 20h-2a1 1 0 1 1 0-2h2a.92.92 0 0 0 .69-.295.49.49 0 0 0 .112-.505ZM8 14c-1.865 0-3.878 1.274-4.681 4.151a.57.57 0 0 0 .132.55c.15.171.4.299.695.299h7.708a.93.93 0 0 0 .695-.299.57.57 0 0 0 .132-.55C11.878 15.274 9.865 14 8 14Zm0-2c2.87 0 5.594 1.98 6.607 5.613.53 1.9-1.09 3.387-2.753 3.387H4.146c-1.663 0-3.283-1.487-2.753-3.387C2.406 13.981 5.129 12 8 12Z',
})
-8
View File
@@ -23,14 +23,6 @@ export const STARTER_PACK_MAX_SIZE = 150
// -prf
export const JOINED_THIS_WEEK = 650000 // estimate as of 10/28/24
export const DISCOVER_DEBUG_DIDS: Record<string, true> = {
'did:plc:oisofpd7lj26yvgiivf3lxsi': true, // hailey.at
'did:plc:fpruhuo22xkm5o7ttr2ktxdo': true, // danabra.mov
'did:plc:p2cp5gopk7mgjegy6wadk3ep': true, // samuel.bsky.team
'did:plc:ragtjsm2j2vknwkz3zp4oxrd': true, // pfrazee.com
'did:plc:vpkhqolt662uhesyj6nxm7ys': true, // why.bsky.team
}
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({
email,
+3 -3
View File
@@ -91,15 +91,15 @@ export function CountWheel({
likeCount,
big,
isLiked,
hasBeenToggled,
isToggle,
}: {
likeCount: number
big?: boolean
isLiked: boolean
hasBeenToggled: boolean
isToggle: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && hasBeenToggled
const shouldAnimate = !useReducedMotion() && isToggle
const shouldRoll = decideShouldRoll(isLiked, likeCount)
// Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
+3 -3
View File
@@ -39,15 +39,15 @@ export function CountWheel({
likeCount,
big,
isLiked,
hasBeenToggled,
isToggle,
}: {
likeCount: number
big?: boolean
isLiked: boolean
hasBeenToggled: boolean
isToggle: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && hasBeenToggled
const shouldAnimate = !useReducedMotion() && isToggle
const shouldRoll = decideShouldRoll(isLiked, likeCount)
const countView = React.useRef<HTMLDivElement>(null)
+10 -6
View File
@@ -71,15 +71,15 @@ const circle2Keyframe = new Keyframe({
export function AnimatedLikeIcon({
isLiked,
big,
hasBeenToggled,
isToggle,
}: {
isLiked: boolean
big?: boolean
hasBeenToggled: boolean
isToggle: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion() && hasBeenToggled
const shouldAnimate = !useReducedMotion() && isToggle
return (
<View>
@@ -95,10 +95,12 @@ export function AnimatedLikeIcon({
width={size}
/>
)}
{isLiked && shouldAnimate ? (
{isLiked ? (
<>
<Animated.View
entering={circle1Keyframe.duration(300)}
entering={
shouldAnimate ? circle1Keyframe.duration(300) : undefined
}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
@@ -112,7 +114,9 @@ export function AnimatedLikeIcon({
}}
/>
<Animated.View
entering={circle2Keyframe.duration(300)}
entering={
shouldAnimate ? circle2Keyframe.duration(300) : undefined
}
style={{
position: 'absolute',
backgroundColor: t.atoms.bg.backgroundColor,
+3 -3
View File
@@ -41,15 +41,15 @@ const circle2Keyframe = [
export function AnimatedLikeIcon({
isLiked,
big,
hasBeenToggled,
isToggle,
}: {
isLiked: boolean
big?: boolean
hasBeenToggled: boolean
isToggle: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion() && hasBeenToggled
const shouldAnimate = !useReducedMotion() && isToggle
const prevIsLiked = React.useRef(isLiked)
const likeIconRef = React.useRef<HTMLDivElement>(null)
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import {choose} from '#/lib/functions'
import {useTheme} from '#/lib/ThemeContext'
export function useCustomPalette<T>({light, dark}: {light: T; dark: T}) {
const theme = useTheme()
return React.useMemo(() => {
return choose<T, Record<string, T>>(theme.colorScheme, {
dark,
light,
})
}, [theme.colorScheme, dark, light])
}
-19
View File
@@ -1,19 +0,0 @@
import {useServiceConfigQuery} from '#/state/queries/email-verification-required'
import {useSession} from '#/state/session'
import {BSKY_SERVICE} from '../constants'
import {getHostnameFromUrl} from '../strings/url-helpers'
export function useEmail() {
const {currentAccount} = useSession()
const {data: serviceConfig} = useServiceConfigQuery()
const isSelfHost =
serviceConfig?.checkEmailConfirmed &&
currentAccount &&
getHostnameFromUrl(currentAccount.service) !==
getHostnameFromUrl(BSKY_SERVICE)
const needsEmailVerification = !isSelfHost && !currentAccount?.emailConfirmed
return {needsEmailVerification}
}
+35
View File
@@ -0,0 +1,35 @@
import {Image} from 'react-native'
import type {Dimensions} from '#/lib/media/types'
const sizes: Map<string, Dimensions> = new Map()
const activeRequests: Map<string, Promise<Dimensions>> = new Map()
export function get(uri: string): Dimensions | undefined {
return sizes.get(uri)
}
export async function fetch(uri: string): Promise<Dimensions> {
const Dimensions = sizes.get(uri)
if (Dimensions) {
return Dimensions
}
const prom =
activeRequests.get(uri) ||
new Promise<Dimensions>(resolve => {
Image.getSize(
uri,
(width: number, height: number) => resolve({width, height}),
(err: any) => {
console.error('Failed to fetch image dimensions for', uri, err)
resolve({width: 0, height: 0})
},
)
})
activeRequests.set(uri, prom)
const res = await prom
activeRequests.delete(uri)
sizes.set(uri, res)
return res
}
+1 -1
View File
@@ -3,7 +3,7 @@ import {ImagePickerAsset} from 'expo-image-picker'
import {VideoTooLargeError} from '#/lib/media/video/errors'
import {CompressedVideo} from './types'
const MAX_VIDEO_SIZE = 1000 * 1000 * 50 // 50mb
const MAX_VIDEO_SIZE = 1024 * 1024 * 50 // 50mb
// doesn't actually compress, converts to ArrayBuffer
export async function compressVideo(
-5
View File
@@ -122,16 +122,11 @@ export type LogEvents = {
'post:create': {
imageCount: number
isReply: boolean
isPartOfThread: boolean
hasLink: boolean
hasQuote: boolean
langs: string
logContext: 'Composer'
}
'thread:create': {
postCount: number
isReply: boolean
}
'post:like:sampled': {
doesLikerFollowPoster: boolean | undefined
doesPosterFollowLiker: boolean | undefined
+2 -5
View File
@@ -88,9 +88,7 @@ export function parseEmbedPlayerFromUrl(
// youtube
if (urlp.hostname === 'youtu.be') {
const videoId = urlp.pathname.split('/')[1]
const t = urlp.searchParams.get('t') ?? '0'
const seek = encodeURIComponent(t.replace(/s$/, ''))
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
if (videoId) {
return {
type: 'youtube_video',
@@ -113,8 +111,7 @@ export function parseEmbedPlayerFromUrl(
isShorts || isLive
? shortOrLiveVideoId
: (urlp.searchParams.get('v') as string)
const t = urlp.searchParams.get('t') ?? '0'
const seek = encodeURIComponent(t.replace(/s$/, ''))
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
if (videoId) {
return {
+5 -25
View File
@@ -4,11 +4,10 @@ import {useKeyboardController} from 'react-native-keyboard-controller'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useEmail} from '#/lib/hooks/useEmail'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {isWeb} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
@@ -20,8 +19,6 @@ import {useSetMinimalShellMode} from '#/state/shell'
import {CenteredView} from '#/view/com/util/Views'
import {MessagesList} from '#/screens/Messages/components/MessagesList'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter'
import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
import {Error} from '#/components/Error'
@@ -130,7 +127,9 @@ function Inner() {
setHasScrolled={setHasScrolled}
/>
) : (
<View style={[a.align_center, a.gap_sm, a.flex_1]} />
<>
<View style={[a.align_center, a.gap_sm, a.flex_1]} />
</>
)}
{!readyToShow && (
<View
@@ -164,12 +163,8 @@ function InnerReady({
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
}) {
const {_} = useLingui()
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
const recipient = useProfileShadow(recipientUnshadowed)
const verifyEmailControl = useDialogControl()
const {needsEmailVerification} = useEmail()
const moderation = React.useMemo(() => {
return moderateProfile(recipient, moderationOpts)
@@ -186,12 +181,6 @@ function InnerReady({
}
}, [moderation])
React.useEffect(() => {
if (needsEmailVerification) {
verifyEmailControl.open()
}
}, [needsEmailVerification, verifyEmailControl])
return (
<>
<MessagesListHeader
@@ -214,15 +203,6 @@ function InnerReady({
}
/>
)}
<VerifyEmailDialog
reasonText={_(
msg`Before you may message another user, you must first verify your email.`,
)}
control={verifyEmailControl}
onCloseWithoutVerifying={() => {
navigation.navigate('Home')
}}
/>
</>
)
}
@@ -104,7 +104,7 @@ function ChatListItemReady({
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? _(msg`Deleted Account`)
? 'Deleted Account'
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
@@ -18,7 +18,6 @@ import Graphemer from 'graphemer'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useEmail} from '#/lib/hooks/useEmail'
import {isIOS} from '#/platform/detection'
import {
useMessageDraft,
@@ -62,15 +61,10 @@ export function MessageInput({
const [message, setMessage] = React.useState(getDraft)
const inputRef = useAnimatedRef<TextInput>()
const {needsEmailVerification} = useEmail()
useSaveMessageDraft(message)
useExtractEmbedFromFacets(message, setEmbed)
const onSubmit = React.useCallback(() => {
if (needsEmailVerification) {
return
}
if (!hasEmbed && message.trim() === '') {
return
}
@@ -90,7 +84,6 @@ export function MessageInput({
inputRef.current?.focus()
}, 100)
}, [
needsEmailVerification,
hasEmbed,
message,
clearDraft,
@@ -166,7 +159,6 @@ export function MessageInput({
ref={inputRef}
hitSlop={HITSLOP_10}
animatedProps={animatedProps}
editable={!needsEmailVerification}
/>
<Pressable
accessibilityRole="button"
@@ -179,8 +171,7 @@ export function MessageInput({
a.justify_center,
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
]}
onPress={onSubmit}
disabled={needsEmailVerification}>
onPress={onSubmit}>
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
</Pressable>
</View>
@@ -11,7 +11,7 @@ import {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
@@ -202,9 +202,9 @@ export function MessagesList({
convoState.items.length,
// these are stable
flatListRef,
isAtTop,
isAtBottom,
layoutHeight,
isAtTop.value,
isAtBottom.value,
layoutHeight.value,
],
)
@@ -212,7 +212,7 @@ export function MessagesList({
if (hasScrolled && prevContentHeight.current > layoutHeight.value) {
convoState.fetchMessageHistory()
}
}, [convoState, hasScrolled, layoutHeight])
}, [convoState, hasScrolled, layoutHeight.value])
const onScroll = React.useCallback(
(e: ReanimatedScrollEvent) => {
@@ -374,7 +374,7 @@ export function MessagesList({
},
[
flatListRef,
keyboardIsOpening,
keyboardIsOpening.value,
layoutScrollWithoutAnimation,
layoutHeight,
],
+131
View File
@@ -1,11 +1,13 @@
import React from 'react'
import {Linking, View} from 'react-native'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {LABELS} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {IS_INTERNAL} from '#/lib/app-info'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {logger} from '#/logger'
@@ -16,6 +18,11 @@ import {
UsePreferencesQueryResponse,
usePreferencesSetAdultContentMutation,
} from '#/state/queries/preferences'
import {
useProfileQuery,
useProfileUpdateMutation,
} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
import {useSetMinimalShellMode} from '#/state/shell'
import {ViewHeader} from '#/view/com/util/ViewHeader'
@@ -462,7 +469,131 @@ export function ModerationScreenInner({
})}
</View>
)}
{!IS_INTERNAL && (
<>
<Text
style={[
a.text_md,
a.font_bold,
a.pt_2xl,
a.pb_md,
t.atoms.text_contrast_high,
]}>
<Trans>Logged-out visibility</Trans>
</Text>
<PwiOptOut />
</>
)}
<View style={{height: 200}} />
</ScrollView>
)
}
function PwiOptOut() {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const updateProfile = useProfileUpdateMutation()
const isOptedOut =
profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
const canToggle = profile && !updateProfile.isPending
const onToggleOptOut = React.useCallback(() => {
if (!profile) {
return
}
let wasAdded = false
updateProfile.mutate({
profile,
updates: existing => {
// create labels attr if needed
existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
? existing.labels
: {
$type: 'com.atproto.label.defs#selfLabels',
values: [],
}
// toggle the label
const hasLabel = existing.labels.values.some(
l => l.val === '!no-unauthenticated',
)
if (hasLabel) {
wasAdded = false
existing.labels.values = existing.labels.values.filter(
l => l.val !== '!no-unauthenticated',
)
} else {
wasAdded = true
existing.labels.values.push({val: '!no-unauthenticated'})
}
// delete if no longer needed
if (existing.labels.values.length === 0) {
delete existing.labels
}
return existing
},
checkCommitted: res => {
const exists = !!res.data.labels?.some(
l => l.val === '!no-unauthenticated',
)
return exists === wasAdded
},
})
}, [updateProfile, profile])
return (
<View style={[a.pt_sm]}>
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
<Toggle.Item
disabled={!canToggle}
value={isOptedOut}
onChange={onToggleOptOut}
name="logged_out_visibility"
style={a.flex_1}
label={_(
msg`Discourage apps from showing my account to logged-out users`,
)}>
<Toggle.Switch />
<Toggle.LabelText style={[a.text_md, a.flex_1]}>
<Trans>
Discourage apps from showing my account to logged-out users
</Trans>
</Toggle.LabelText>
</Toggle.Item>
{updateProfile.isPending && <Loader />}
</View>
<View style={[a.pt_md, a.gap_md, {paddingLeft: 38}]}>
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
Bluesky will not show your profile and posts to logged-out users.
Other apps may not honor this request. This does not make your
account private.
</Trans>
</Text>
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
Note: Bluesky is an open and public network. This setting only
limits the visibility of your content on the Bluesky app and
website, and other apps may not respect this setting. Your content
may still be shown to logged-out users by other apps and websites.
</Trans>
</Text>
<InlineLinkText
label={_(msg`Learn more about what is public on Bluesky.`)}
to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
<Trans>Learn more about what is public on Bluesky.</Trans>
</InlineLinkText>
</View>
</View>
)
}
+1 -1
View File
@@ -47,7 +47,7 @@ export function ProfileHeaderHandle({
a.rounded_xs,
{borderColor: t.palette.contrast_200},
]
: [a.text_md, a.leading_snug, t.atoms.text_contrast_medium],
: [a.text_md, a.leading_tight, t.atoms.text_contrast_medium],
web({wordBreak: 'break-all'}),
]}>
{invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
+13 -47
View File
@@ -1,12 +1,5 @@
import React, {memo} from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import Animated, {
measure,
MeasuredDimensions,
runOnJS,
runOnUI,
useAnimatedRef,
} from 'react-native-reanimated'
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
@@ -49,7 +42,6 @@ let ProfileHeaderShell = ({
const {openLightbox} = useLightboxControls()
const navigation = useNavigation<NavigationProp>()
const {isDesktop} = useWebMediaQueries()
const aviRef = useAnimatedRef()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
@@ -59,40 +51,16 @@ let ProfileHeaderShell = ({
}
}, [navigation])
const _openLightbox = React.useCallback(
(uri: string, thumbRect: MeasuredDimensions | null) => {
openLightbox({
images: [
{
uri,
thumbUri: uri,
thumbRect,
dimensions: {
// It's fine if it's actually smaller but we know it's 1:1.
height: 1000,
width: 1000,
},
thumbDimensions: null,
type: 'circle-avi',
},
],
index: 0,
})
},
[openLightbox],
)
const onPressAvi = React.useCallback(() => {
const modui = moderation.ui('avatar')
const avatar = profile.avatar
if (avatar && !(modui.blur && modui.noOverride)) {
runOnUI(() => {
'worklet'
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect)
})()
if (profile.avatar && !(modui.blur && modui.noOverride)) {
openLightbox({
type: 'profile-image',
profile: profile,
thumbDims: null,
})
}
}, [profile, moderation, _openLightbox, aviRef])
}, [openLightbox, profile, moderation])
const isMe = React.useMemo(
() => currentAccount?.did === profile.did,
@@ -170,14 +138,12 @@ let ProfileHeaderShell = ({
styles.avi,
profile.associated?.labeler && styles.aviLabeler,
]}>
<Animated.View ref={aviRef} collapsable={false}>
<UserAvatar
type={profile.associated?.labeler ? 'labeler' : 'user'}
size={90}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</Animated.View>
<UserAvatar
type={profile.associated?.labeler ? 'labeler' : 'user'}
size={90}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</View>
</TouchableWithoutFeedback>
</GrowableAvatar>
+32 -36
View File
@@ -6,14 +6,15 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session'
import {ExportCarDialog} from '#/view/screens/Settings/ExportCarDialog'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
import {BirthdayCake_Stroke2_Corner2_Rounded as BirthdayCakeIcon} from '#/components/icons/BirthdayCake'
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {Freeze_Stroke2_Corner2_Rounded as FreezeIcon} from '#/components/icons/Freeze'
import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
@@ -23,7 +24,6 @@ import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/ico
import * as Layout from '#/components/Layout'
import {ChangeHandleDialog} from './components/ChangeHandleDialog'
import {DeactivateAccountDialog} from './components/DeactivateAccountDialog'
import {ExportCarDialog} from './components/ExportCarDialog'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'>
export function AccountSettingsScreen({}: Props) {
@@ -31,7 +31,6 @@ export function AccountSettingsScreen({}: Props) {
const {_} = useLingui()
const {currentAccount} = useSession()
const {openModal} = useModalControls()
const verifyEmailControl = useDialogControl()
const birthdayControl = useDialogControl()
const changeHandleControl = useDialogControl()
const exportCarControl = useDialogControl()
@@ -44,48 +43,25 @@ export function AccountSettingsScreen({}: Props) {
<SettingsList.Container>
<SettingsList.Item>
<SettingsList.ItemIcon icon={EnvelopeIcon} />
{/* Tricky flexbox situation here: we want the email to truncate, but by default it will make the "Email" text wrap instead.
For numberOfLines to work, we need flex: 1 on the BadgeText, but that means it goes to width: 50% because the
ItemText is also flex: 1. So we need to set flex: 0 on the ItemText to prevent it from growing, but if we did that everywhere
it wouldn't push the BadgeText/Chevron/whatever to the right.
TODO: find a general solution for this. workaround in this case is to set the ItemText to flex: 1 and BadgeText to flex: 0 -sfn */}
<SettingsList.ItemText style={[a.flex_0]}>
<SettingsList.ItemText>
<Trans>Email</Trans>
</SettingsList.ItemText>
{currentAccount && (
<>
<SettingsList.BadgeText style={[a.flex_1]}>
<SettingsList.BadgeText>
{currentAccount.email || <Trans>(no email)</Trans>}
</SettingsList.BadgeText>
{currentAccount.emailConfirmed && (
<VerifiedIcon fill={t.palette.primary_500} size="md" />
{currentAccount.emailConfirmed ? (
<CheckIcon color={t.palette.positive_500} size="sm" />
) : (
<SettingsList.BadgeButton
label={_(msg`Verify`)}
onPress={() => {}}
/>
)}
</>
)}
</SettingsList.Item>
{currentAccount && !currentAccount.emailConfirmed && (
<SettingsList.PressableItem
label={_(msg`Verify your email`)}
onPress={() => verifyEmailControl.open()}
style={[
a.my_xs,
a.mx_lg,
a.rounded_md,
{backgroundColor: t.palette.primary_50},
]}
hoverStyle={[{backgroundColor: t.palette.primary_100}]}
contentContainerStyle={[a.rounded_md, a.px_lg]}>
<SettingsList.ItemIcon
icon={VerifiedIcon}
color={t.palette.primary_500}
/>
<SettingsList.ItemText
style={[{color: t.palette.primary_500}, a.font_bold]}>
<Trans>Verify your email</Trans>
</SettingsList.ItemText>
<SettingsList.Chevron color={t.palette.primary_500} />
</SettingsList.PressableItem>
)}
<SettingsList.PressableItem
label={_(msg`Change email`)}
onPress={() => openModal({name: 'change-email'})}>
@@ -95,6 +71,27 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.ItemText>
<SettingsList.Chevron />
</SettingsList.PressableItem>
<SettingsList.LinkItem
to="/settings/privacy-and-security"
label={_(msg`Protect your account`)}
style={[
a.my_xs,
a.mx_lg,
a.rounded_md,
{backgroundColor: t.palette.primary_50},
]}
chevronColor={t.palette.primary_500}
hoverStyle={[{backgroundColor: t.palette.primary_100}]}
contentContainerStyle={[a.rounded_md, a.px_lg]}>
<SettingsList.ItemIcon
icon={VerifiedIcon}
color={t.palette.primary_500}
/>
<SettingsList.ItemText
style={[{color: t.palette.primary_500}, a.font_bold]}>
<Trans>Protect your account</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
<SettingsList.Divider />
<SettingsList.Item>
<SettingsList.ItemIcon icon={BirthdayCakeIcon} />
@@ -158,7 +155,6 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.Container>
</Layout.Content>
<VerifyEmailDialog control={verifyEmailControl} />
<BirthDateSettingsDialog control={birthdayControl} />
<ChangeHandleDialog control={changeHandleControl} />
<ExportCarDialog control={exportCarControl} />
@@ -86,13 +86,13 @@ export function ContentAndMediaSettingsScreen({}: Props) {
)}
<Toggle.Item
name="disable_autoplay"
label={_(msg`Autoplay videos and GIFs`)}
value={!autoplayDisabledPref}
onChange={value => setAutoplayDisabledPref(!value)}>
label={_(msg`Disable autoplay for videos and GIFs`)}
value={autoplayDisabledPref}
onChange={value => setAutoplayDisabledPref(value)}>
<SettingsList.Item>
<SettingsList.ItemIcon icon={PlayIcon} />
<SettingsList.ItemText>
<Trans>Autoplay videos and GIFs</Trans>
<Trans>Disable autoplay for videos and GIFs</Trans>
</SettingsList.ItemText>
<Toggle.Platform />
</SettingsList.Item>
+92 -272
View File
@@ -1,75 +1,56 @@
import React, {useState} from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native'
import React from 'react'
import {View} from 'react-native'
import {Linking} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {IS_INTERNAL} from '#/lib/app-info'
import {HELP_DESK_URL} from '#/lib/constants'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {sanitizeHandle} from '#/lib/strings/handles'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {clearStorage} from '#/state/persisted'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration'
import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile'
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {useSession, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {ProfileHeaderDisplayName} from '#/screens/Profile/Header/DisplayName'
import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, tokens, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo'
import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron'
import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion'
import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller'
import {
Person_Stroke2_Corner2_Rounded as PersonIcon,
PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon,
PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon,
PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
} from '#/components/icons/Person'
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
export function SettingsScreen({}: Props) {
const {_} = useLingui()
const reducedMotion = useReducedMotion()
const {logoutEveryAccount} = useSessionApi()
const {accounts, currentAccount} = useSession()
const switchAccountControl = useDialogControl()
const signOutPromptControl = Prompt.usePromptControl()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const {data: otherProfiles} = useProfilesQuery({
handles: accounts
.filter(acc => acc.did !== currentAccount?.did)
.map(acc => acc.handle),
})
const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
const [showAccounts, setShowAccounts] = useState(false)
const [showDevOptions, setShowDevOptions] = useState(false)
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeEverything = useCloseAllActiveElements()
const onAddAnotherAccount = () => {
setShowLoggedOut(true)
closeEverything()
}
return (
<Layout.Screen>
@@ -88,59 +69,34 @@ export function SettingsScreen({}: Props) {
]}>
{profile && <ProfilePreview profile={profile} />}
</View>
{accounts.length > 1 ? (
<>
<SettingsList.PressableItem
label={_(msg`Switch account`)}
accessibilityHint={_(
msg`Show other accounts you can switch to`,
)}
onPress={() => {
if (!reducedMotion) {
LayoutAnimation.configureNext(
LayoutAnimation.Presets.easeInEaseOut,
)
}
setShowAccounts(s => !s)
}}>
<SettingsList.ItemIcon icon={PersonGroupIcon} />
<SettingsList.ItemText>
<Trans>Switch account</Trans>
</SettingsList.ItemText>
{showAccounts ? (
<SettingsList.ItemIcon icon={ChevronUpIcon} size="md" />
) : (
<AvatarStack
profiles={accounts
.map(acc => acc.did)
.filter(did => did !== currentAccount?.did)
.slice(0, 5)}
/>
)}
</SettingsList.PressableItem>
{showAccounts && (
<>
<SettingsList.Divider />
{accounts
.filter(acc => acc.did !== currentAccount?.did)
.map(account => (
<AccountRow
key={account.did}
account={account}
profile={otherProfiles?.profiles?.find(
p => p.did === account.did,
)}
pendingDid={pendingDid}
onPressSwitchAccount={onPressSwitchAccount}
/>
))}
<AddAccountRow />
</>
<SettingsList.PressableItem
label={
accounts.length > 1
? _(msg`Switch account`)
: _(msg`Add another account`)
}
onPress={() =>
accounts.length > 1
? switchAccountControl.open()
: onAddAnotherAccount()
}>
<SettingsList.ItemIcon icon={PersonGroupIcon} />
<SettingsList.ItemText>
{accounts.length > 1 ? (
<Trans>Switch account</Trans>
) : (
<Trans>Add another account</Trans>
)}
</>
) : (
<AddAccountRow />
)}
</SettingsList.ItemText>
{accounts.length > 1 && (
<AvatarStack
profiles={accounts
.map(acc => acc.did)
.filter(did => did !== currentAccount?.did)
.slice(0, 5)}
/>
)}
</SettingsList.PressableItem>
<SettingsList.Divider />
<SettingsList.LinkItem to="/settings/account" label={_(msg`Account`)}>
<SettingsList.ItemIcon icon={PersonIcon} />
@@ -219,27 +175,6 @@ export function SettingsScreen({}: Props) {
<Trans>Sign out</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
{IS_INTERNAL && (
<>
<SettingsList.Divider />
<SettingsList.PressableItem
onPress={() => {
if (!reducedMotion) {
LayoutAnimation.configureNext(
LayoutAnimation.Presets.easeInEaseOut,
)
}
setShowDevOptions(d => !d)
}}
label={_(msg`Developer options`)}>
<SettingsList.ItemIcon icon={CodeBracketsIcon} />
<SettingsList.ItemText>
<Trans>Developer options</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
{showDevOptions && <DevOptions />}
</>
)}
</SettingsList.Container>
</Layout.Content>
@@ -283,181 +218,66 @@ function ProfilePreview({
)
}
function DevOptions() {
const {_} = useLingui()
const onboardingDispatch = useOnboardingDispatch()
const navigation = useNavigation<NavigationProp>()
const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration()
const AVI_SIZE = 26
const HALF_AVI_SIZE = AVI_SIZE / 2
const resetOnboarding = async () => {
navigation.navigate('Home')
onboardingDispatch({type: 'start'})
Toast.show(_(msg`Onboarding reset`))
}
const clearAllStorage = async () => {
await clearStorage()
Toast.show(_(msg`Storage cleared, you need to restart the app now.`))
}
return (
<>
<SettingsList.PressableItem
onPress={() => navigation.navigate('Log')}
label={_(msg`Open system log`)}>
<SettingsList.ItemText>
<Trans>System log</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
<SettingsList.PressableItem
onPress={() => navigation.navigate('Debug')}
label={_(msg`Open storybook page`)}>
<SettingsList.ItemText>
<Trans>Storybook</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
<SettingsList.PressableItem
onPress={() => navigation.navigate('DebugMod')}
label={_(msg`Open moderation debug page`)}>
<SettingsList.ItemText>
<Trans>Debug Moderation</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
<SettingsList.PressableItem
onPress={() => deleteChatDeclarationRecord()}
label={_(msg`Open storybook page`)}>
<SettingsList.ItemText>
<Trans>Delete chat declaration record</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
<SettingsList.PressableItem
onPress={() => resetOnboarding()}
label={_(msg`Reset onboarding state`)}>
<SettingsList.ItemText>
<Trans>Reset onboarding state</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
<SettingsList.PressableItem
onPress={() => clearAllStorage()}
label={_(msg`Clear all storage data`)}>
<SettingsList.ItemText>
<Trans>Clear all storage data (restart after this)</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
</>
)
}
function AddAccountRow() {
const {_} = useLingui()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeEverything = useCloseAllActiveElements()
const onAddAnotherAccount = () => {
setShowLoggedOut(true)
closeEverything()
}
return (
<SettingsList.PressableItem
onPress={onAddAnotherAccount}
label={_(msg`Add another account`)}>
<SettingsList.ItemIcon icon={PersonPlusIcon} />
<SettingsList.ItemText>
<Trans>Add another account</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
)
}
function AccountRow({
profile,
account,
pendingDid,
onPressSwitchAccount,
}: {
profile?: AppBskyActorDefs.ProfileViewDetailed
account: SessionAccount
pendingDid: string | null
onPressSwitchAccount: (
account: SessionAccount,
logContext: 'Settings',
) => void
}) {
const {_} = useLingui()
function AvatarStack({profiles}: {profiles: string[]}) {
const {data, error} = useProfilesQuery({handles: profiles})
const t = useTheme()
const moderationOpts = useModerationOpts()
const removePromptControl = Prompt.usePromptControl()
const {removeAccount} = useSessionApi()
const onSwitchAccount = () => {
if (pendingDid) return
onPressSwitchAccount(account, 'Settings')
if (error) {
console.error(error)
return null
}
return (
<View style={[a.relative]}>
<SettingsList.PressableItem
onPress={onSwitchAccount}
label={_(msg`Switch account`)}>
{moderationOpts && profile ? (
<UserAvatar
size={28}
avatar={profile.avatar}
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
/>
) : (
<View style={[{width: 28}]} />
)}
<SettingsList.ItemText>
<Trans>{sanitizeHandle(account.handle, '@')}</Trans>
</SettingsList.ItemText>
{pendingDid === account.did && <SettingsList.ItemIcon icon={Loader} />}
</SettingsList.PressableItem>
{!pendingDid && (
<Menu.Root>
<Menu.Trigger label={_(msg`Account options`)}>
{({props, state}) => (
<Pressable
{...props}
style={[
a.absolute,
{top: 10, right: tokens.space.lg},
a.p_xs,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<DotsHorizontal size="md" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
<Menu.Outer showCancel>
<Menu.Item
label={_(msg`Remove account`)}
onPress={() => removePromptControl.open()}>
<Menu.ItemText>
<Trans>Remove account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PersonXIcon} />
</Menu.Item>
</Menu.Outer>
</Menu.Root>
)}
const isPending = !data || !moderationOpts
<Prompt.Basic
control={removePromptControl}
title={_(msg`Remove from quick access?`)}
description={_(
msg`This will remove @${account.handle} from the quick access list.`,
)}
onConfirm={() => {
removeAccount(account)
Toast.show(_(msg`Account removed from quick access`))
}}
confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative"
/>
const items = isPending
? Array.from({length: profiles.length}).map((_, i) => ({
key: i,
profile: null,
moderation: null,
}))
: data.profiles.map(item => ({
key: item.did,
profile: item,
moderation: moderateProfile(item, moderationOpts),
}))
return (
<View
style={[
a.flex_row,
a.align_center,
a.relative,
{width: AVI_SIZE + (items.length - 1) * HALF_AVI_SIZE},
]}>
{items.map((item, i) => (
<View
key={item.key}
style={[
t.atoms.bg_contrast_25,
a.relative,
{
width: AVI_SIZE,
height: AVI_SIZE,
left: i * -HALF_AVI_SIZE,
borderWidth: 1,
borderColor: t.atoms.bg.backgroundColor,
borderRadius: 999,
zIndex: 3 - i,
},
]}>
{item.profile && (
<UserAvatar
size={AVI_SIZE - 2}
avatar={item.profile.avatar}
moderation={item.moderation.ui('avatar')}
/>
)}
</View>
))}
</View>
)
}
+2 -4
View File
@@ -44,9 +44,7 @@ export function ThreadPreferencesScreen({}: Props) {
<SettingsList.Container>
<SettingsList.Group>
<SettingsList.ItemIcon icon={BubblesIcon} />
<SettingsList.ItemText>
<Trans>Sort replies</Trans>
</SettingsList.ItemText>
<SettingsList.ItemText>Sort replies</SettingsList.ItemText>
<View style={[a.w_full, a.gap_md]}>
<Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
<Trans>Sort replies to the same post by:</Trans>
@@ -111,7 +109,7 @@ export function ThreadPreferencesScreen({}: Props) {
style={[a.w_full, a.gap_md]}>
<Toggle.LabelText style={[a.flex_1]}>
<Trans>
Show replies by people you follow before all other replies
Show replies by people you follow before all other replies.
</Trans>
</Toggle.LabelText>
<Toggle.Platform />
+3 -11
View File
@@ -1,10 +1,6 @@
import React, {useCallback, useEffect, useState} from 'react'
import {GestureResponderEvent, View} from 'react-native'
import Animated, {
FadeOutUp,
useReducedMotion,
ZoomIn,
} from 'react-native-reanimated'
import Animated, {FadeOutUp, ZoomIn} from 'react-native-reanimated'
import * as Clipboard from 'expo-clipboard'
import {Trans} from '@lingui/macro'
@@ -20,17 +16,13 @@ export function CopyButton({
}: ButtonProps & {value: string}) {
const [hasBeenCopied, setHasBeenCopied] = useState(false)
const t = useTheme()
const isReducedMotionEnabled = useReducedMotion()
useEffect(() => {
if (hasBeenCopied) {
const timeout = setTimeout(
() => setHasBeenCopied(false),
isReducedMotionEnabled ? 2000 : 100,
)
const timeout = setTimeout(() => setHasBeenCopied(false), 100)
return () => clearTimeout(timeout)
}
}, [hasBeenCopied, isReducedMotionEnabled])
}, [hasBeenCopied])
const onPress = useCallback(
(evt: GestureResponderEvent) => {
@@ -4,9 +4,9 @@ import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useAgent, useSession} from '#/state/session'
import {DisableEmail2FADialog} from '#/view/screens/Settings/DisableEmail2FADialog'
import {useDialogControl} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import {DisableEmail2FADialog} from './DisableEmail2FADialog'
import * as SettingsList from './SettingsList'
export function Email2FAToggle() {
@@ -264,13 +264,7 @@ export function Chevron({color: colorProp}: {color?: string}) {
return <ItemIcon icon={ChevronRightIcon} size="md" color={color} />
}
export function BadgeText({
children,
style,
}: {
children: React.ReactNode
style?: StyleProp<ViewStyle>
}) {
export function BadgeText({children}: {children: React.ReactNode}) {
const t = useTheme()
return (
<Text
@@ -279,7 +273,6 @@ export function BadgeText({
a.text_md,
a.text_right,
a.leading_snug,
style,
]}
numberOfLines={1}>
{children}
@@ -429,12 +429,11 @@ function Header({
color="primary"
size="small"
disabled={isProcessing}
onPress={onFollowAll}
style={[a.flex_row, a.gap_xs, a.align_center]}>
onPress={onFollowAll}>
<ButtonText>
<Trans>Follow all</Trans>
{isProcessing && <Loader size="xs" />}
</ButtonText>
{isProcessing && <Loader size="xs" />}
</Button>
)}
<OverflowMenu
+1 -1
View File
@@ -72,7 +72,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
const sendToFeed = React.useMemo(
() =>
throttle(sendToFeedNoDelay, 10e3, {
throttle(sendToFeedNoDelay, 15e3, {
leading: false,
trailing: true,
}),
+24 -11
View File
@@ -1,15 +1,30 @@
import React from 'react'
import {nanoid} from 'nanoid/non-secure'
import type {MeasuredDimensions} from 'react-native-reanimated'
import {AppBskyActorDefs} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {ImageSource} from '#/view/com/lightbox/ImageViewing/@types'
export type Lightbox = {
id: string
images: ImageSource[]
type ProfileImageLightbox = {
type: 'profile-image'
profile: AppBskyActorDefs.ProfileViewDetailed
thumbDims: null
}
type ImagesLightboxItem = {
uri: string
thumbUri: string
alt?: string
}
type ImagesLightbox = {
type: 'images'
images: ImagesLightboxItem[]
thumbDims: MeasuredDimensions | null
index: number
}
type Lightbox = ProfileImageLightbox | ImagesLightbox
const LightboxContext = React.createContext<{
activeLightbox: Lightbox | null
}>({
@@ -17,7 +32,7 @@ const LightboxContext = React.createContext<{
})
const LightboxControlContext = React.createContext<{
openLightbox: (lightbox: Omit<Lightbox, 'id'>) => void
openLightbox: (lightbox: Lightbox) => void
closeLightbox: () => boolean
}>({
openLightbox: () => {},
@@ -29,11 +44,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
null,
)
const openLightbox = useNonReactiveCallback(
(lightbox: Omit<Lightbox, 'id'>) => {
setActiveLightbox({...lightbox, id: nanoid()})
},
)
const openLightbox = useNonReactiveCallback((lightbox: Lightbox) => {
setActiveLightbox(lightbox)
})
const closeLightbox = useNonReactiveCallback(() => {
let wasActive = !!activeLightbox
+11
View File
@@ -48,6 +48,11 @@ export interface DeleteAccountModal {
name: 'delete-account'
}
export interface ChangeHandleModal {
name: 'change-handle'
onChanged: () => void
}
export interface WaitlistModal {
name: 'waitlist'
}
@@ -56,6 +61,10 @@ export interface InviteCodesModal {
name: 'invite-codes'
}
export interface AddAppPasswordModal {
name: 'add-app-password'
}
export interface ContentLanguagesSettingsModal {
name: 'content-languages-settings'
}
@@ -92,6 +101,8 @@ export interface InAppBrowserConsentModal {
export type Modal =
// Account
| AddAppPasswordModal
| ChangeHandleModal
| DeleteAccountModal
| VerifyEmailModal
| ChangeEmailModal
@@ -1,25 +0,0 @@
import {useQuery} from '@tanstack/react-query'
interface ServiceConfig {
checkEmailConfirmed: boolean
}
export function useServiceConfigQuery() {
return useQuery({
queryKey: ['service-config'],
queryFn: async () => {
const res = await fetch(
'https://api.bsky.app/xrpc/app.bsky.unspecced.getConfig',
)
if (!res.ok) {
return {
checkEmailConfirmed: false,
}
}
const json = await res.json()
return json as ServiceConfig
},
staleTime: 5 * 60 * 1000,
})
}
-8
View File
@@ -18,20 +18,12 @@ const stateContext = React.createContext<StateContext>({
addListener() {},
removeListener() {},
modify() {},
get() {
return 0
},
set() {},
},
footerMode: {
value: 0,
addListener() {},
removeListener() {},
modify() {},
get() {
return 0
},
set() {},
},
})
const setContext = React.createContext<SetContext>((_: boolean) => {})
-8
View File
@@ -12,20 +12,12 @@ const stateContext = React.createContext<StateContext>({
addListener() {},
removeListener() {},
modify() {},
get() {
return 0
},
set() {},
},
footerHeight: {
value: 0,
addListener() {},
removeListener() {},
modify() {},
get() {
return 0
},
set() {},
},
})
+5 -49
View File
@@ -58,7 +58,6 @@ import {EmbeddingDisabledError} from '#/lib/api/resolve'
import {until} from '#/lib/async/until'
import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useEmail} from '#/lib/hooks/useEmail'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {usePalette} from '#/lib/hooks/usePalette'
@@ -111,8 +110,6 @@ import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -300,15 +297,6 @@ export const ComposePost = ({
}
}, [onPressCancel, closeAllDialogs, closeAllModals])
const {needsEmailVerification} = useEmail()
const emailVerificationControl = useDialogControl()
useEffect(() => {
if (needsEmailVerification) {
emailVerificationControl.open()
}
}, [needsEmailVerification, emailVerificationControl])
const missingAltError = useMemo(() => {
if (!requireAltTextEnabled) {
return
@@ -417,7 +405,6 @@ export const ComposePost = ({
? post.embed.media.images.length
: 0,
isReply: index > 0 || !!replyTo,
isPartOfThread: thread.posts.length > 1,
hasLink: !!post.embed.link,
hasQuote: !!post.embed.quote,
langs: langPrefs.postLanguage,
@@ -426,12 +413,6 @@ export const ComposePost = ({
index++
}
}
if (thread.posts.length > 1) {
logEvent('thread:create', {
postCount: thread.posts.length,
isReply: !!replyTo,
})
}
}
if (postUri && !replyTo) {
emitPostCreated()
@@ -455,9 +436,7 @@ export const ComposePost = ({
}
onClose()
Toast.show(
thread.posts.length > 1
? _(msg`Your posts have been published`)
: replyTo
replyTo
? _(msg`Your reply has been published`)
: _(msg`Your post has been published`),
)
@@ -537,7 +516,6 @@ export const ComposePost = ({
}
}, [composerState])
const isLastThreadedPost = thread.posts.length > 1 && nextPost === undefined
const {
scrollHandler,
onScrollViewContentSizeChange,
@@ -546,7 +524,7 @@ export const ComposePost = ({
bottomBarAnimatedStyle,
} = useScrollTracker({
scrollViewRef,
stickyBottom: isLastThreadedPost,
stickyBottom: true,
})
const keyboardVerticalOffset = useKeyboardVerticalOffset()
@@ -579,18 +557,9 @@ export const ComposePost = ({
</>
)
const isWebFooterSticky = !isNative && thread.posts.length > 1
const isFooterSticky = !isNative && thread.posts.length > 1
return (
<BottomSheetPortalProvider>
<VerifyEmailDialog
control={emailVerificationControl}
onCloseWithoutVerifying={() => {
onClose()
}}
reasonText={_(
msg`Before creating a post, you must first verify your email.`,
)}
/>
<KeyboardAvoidingView
testID="composePostView"
behavior={isIOS ? 'padding' : 'height'}
@@ -639,7 +608,6 @@ export const ComposePost = ({
dispatch={composerDispatch}
textInput={post.id === activePost.id ? textInput : null}
isFirstPost={index === 0}
isPartOfThread={thread.posts.length > 1}
isReply={index > 0 || !!replyTo}
isActive={post.id === activePost.id}
canRemovePost={thread.posts.length > 1}
@@ -649,13 +617,11 @@ export const ComposePost = ({
onPublish={onComposerPostPublish}
onError={setError}
/>
{isWebFooterSticky && post.id === activePost.id && (
<View style={styles.stickyFooterWeb}>{footer}</View>
)}
{isFooterSticky && post.id === activePost.id && footer}
</React.Fragment>
))}
</Animated.ScrollView>
{!isWebFooterSticky && footer}
{!isFooterSticky && footer}
</View>
<Prompt.Basic
@@ -678,7 +644,6 @@ let ComposerPost = React.memo(function ComposerPost({
isActive,
isReply,
isFirstPost,
isPartOfThread,
canRemovePost,
canRemoveQuote,
onClearVideo,
@@ -692,7 +657,6 @@ let ComposerPost = React.memo(function ComposerPost({
isActive: boolean
isReply: boolean
isFirstPost: boolean
isPartOfThread: boolean
canRemovePost: boolean
canRemoveQuote: boolean
onClearVideo: (postId: string) => void
@@ -772,9 +736,6 @@ let ComposerPost = React.memo(function ComposerPost({
placeholder={selectTextInputPlaceholder}
autoFocus
webForceMinHeight={forceMinHeight}
// To avoid overlap with the close button:
hasRightPadding={isPartOfThread}
isActive={isActive}
setRichText={rt => {
dispatchPost({type: 'update_richtext', richtext: rt})
}}
@@ -1426,11 +1387,6 @@ const styles = StyleSheet.create({
paddingVertical: 6,
marginLeft: 12,
},
stickyFooterWeb: {
// @ts-ignore web-only
position: 'sticky',
bottom: 0,
},
errorLine: {
flexDirection: 'row',
alignItems: 'center',
+21 -23
View File
@@ -144,40 +144,36 @@ function DialogInner({
}}>
<View style={[a.gap_sm]}>
<Toggle.Item name="sexual" label={_(msg`Suggestive`)}>
<Toggle.Checkbox />
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Suggestive</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item name="nudity" label={_(msg`Nudity`)}>
<Toggle.Checkbox />
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Nudity</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item name="porn" label={_(msg`Porn`)}>
<Toggle.Checkbox />
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Adult</Trans>
<Trans>Porn</Trans>
</Toggle.LabelText>
</Toggle.Item>
</View>
</Toggle.Group>
{labels.includes('sexual') ||
labels.includes('nudity') ||
labels.includes('porn') ? (
<Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
{labels.includes('sexual') ? (
<Trans>Pictures meant for adults.</Trans>
) : labels.includes('nudity') ? (
<Trans>Artistic or non-erotic nudity.</Trans>
) : labels.includes('porn') ? (
<Trans>Sexual activity or erotic nudity.</Trans>
) : (
''
)}
</Text>
) : null}
<Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
{labels.includes('sexual') ? (
<Trans>Pictures meant for adults.</Trans>
) : labels.includes('nudity') ? (
<Trans>Artistic or non-erotic nudity.</Trans>
) : labels.includes('porn') ? (
<Trans>Sexual activity or erotic nudity.</Trans>
) : (
<Trans>Does not contain adult content.</Trans>
)}
</Text>
</View>
</View>
<View>
@@ -207,14 +203,16 @@ function DialogInner({
</Toggle.LabelText>
</Toggle.Item>
</Toggle.Group>
{labels.includes('graphic-media') ? (
<Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
<Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
{labels.includes('graphic-media') ? (
<Trans>
Media that may be disturbing or inappropriate for some
audiences.
</Trans>
</Text>
) : null}
) : (
<Trans>Does not contain graphic or disturbing content.</Trans>
)}
</Text>
</View>
</View>
</View>
+2 -6
View File
@@ -224,7 +224,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
return {
...state,
richtext: action.richtext,
shortenedGraphemeLength: getShortenedLength(action.richtext),
shortenedGraphemeLength: shortenLinks(action.richtext).graphemeLength,
}
}
case 'update_labels': {
@@ -516,7 +516,7 @@ export function createComposerState({
{
id: nanoid(),
richtext: initRichText,
shortenedGraphemeLength: getShortenedLength(initRichText),
shortenedGraphemeLength: 0,
labels: [],
embed: {
quote,
@@ -530,7 +530,3 @@ export function createComposerState({
},
}
}
function getShortenedLength(rt: RichText) {
return shortenLinks(rt).graphemeLength
}
@@ -44,8 +44,6 @@ interface TextInputProps extends ComponentProps<typeof RNTextInput> {
richtext: RichText
placeholder: string
webForceMinHeight: boolean
hasRightPadding: boolean
isActive: boolean
setRichText: (v: RichText) => void
onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => void
@@ -62,7 +60,6 @@ export const TextInput = forwardRef(function TextInputImpl(
{
richtext,
placeholder,
hasRightPadding,
setRichText,
onPhotoPasted,
onNewLink,
@@ -234,7 +231,7 @@ export const TextInput = forwardRef(function TextInputImpl(
}, [t, richtext, inputTextStyle])
return (
<View style={[a.flex_1, a.pl_md, hasRightPadding && a.pr_4xl]}>
<View style={[a.flex_1, a.pl_md]}>
<PasteInput
testID="composerTextInput"
ref={textInput}
@@ -252,11 +249,7 @@ export const TextInput = forwardRef(function TextInputImpl(
style={[
inputTextStyle,
a.w_full,
{
textAlignVertical: 'top',
minHeight: 60,
includeFontPadding: false,
},
{textAlignVertical: 'top', minHeight: 60},
]}
{...props}>
{textDecorated}
@@ -42,8 +42,6 @@ interface TextInputProps {
placeholder: string
suggestedLinks: Set<string>
webForceMinHeight: boolean
hasRightPadding: boolean
isActive: boolean
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => void
@@ -57,8 +55,6 @@ export const TextInput = React.forwardRef(function TextInputImpl(
richtext,
placeholder,
webForceMinHeight,
hasRightPadding,
isActive,
setRichText,
onPhotoPasted,
onPressPublish,
@@ -98,30 +94,19 @@ export const TextInput = React.forwardRef(function TextInputImpl(
)
React.useEffect(() => {
if (!isActive) {
return
}
textInputWebEmitter.addListener('publish', onPressPublish)
return () => {
textInputWebEmitter.removeListener('publish', onPressPublish)
}
}, [onPressPublish, isActive])
}, [onPressPublish])
React.useEffect(() => {
if (!isActive) {
return
}
textInputWebEmitter.addListener('media-pasted', onPhotoPasted)
return () => {
textInputWebEmitter.removeListener('media-pasted', onPhotoPasted)
}
}, [isActive, onPhotoPasted])
}, [onPhotoPasted])
React.useEffect(() => {
if (!isActive) {
return
}
const handleDrop = (event: DragEvent) => {
const transfer = event.dataTransfer
if (transfer) {
@@ -159,7 +144,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
document.body.removeEventListener('dragover', handleDragEnter)
document.body.removeEventListener('dragleave', handleDragLeave)
}
}, [setIsDropping, isActive])
}, [setIsDropping])
const pastSuggestedUris = useRef(new Set<string>())
const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
@@ -257,14 +242,11 @@ export const TextInput = React.forwardRef(function TextInputImpl(
[editor],
)
React.useEffect(() => {
if (!isActive) {
return
}
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
return () => {
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
}
}, [onEmojiInserted, isActive])
}, [onEmojiInserted])
React.useImperativeHandle(ref, () => ({
focus: () => {
@@ -309,7 +291,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
return (
<>
<View style={[styles.container, hasRightPadding && styles.rightPadding]}>
<View style={styles.container}>
{/* @ts-ignore inputStyle is fine */}
<EditorContent editor={editor} style={inputStyle} />
</View>
@@ -375,9 +357,6 @@ const styles = StyleSheet.create({
marginLeft: 8,
marginBottom: 10,
},
rightPadding: {
paddingRight: 32,
},
dropContainer: {
backgroundColor: '#0007',
pointerEvents: 'none',
+1 -4
View File
@@ -162,10 +162,7 @@ export function FeedSourceCardLoaded({
style={[
pal.border,
{
borderTopWidth:
showMinimalPlaceholder || hideTopBorder
? 0
: StyleSheet.hairlineWidth,
borderTopWidth: showMinimalPlaceholder || hideTopBorder ? 0 : 1,
flexDirection: 'row',
alignItems: 'center',
flex: 1,
@@ -6,9 +6,6 @@
*
*/
import {TransformsStyle} from 'react-native'
import {MeasuredDimensions} from 'react-native-reanimated'
export type Dimensions = {
width: number
height: number
@@ -19,17 +16,4 @@ export type Position = {
y: number
}
export type ImageSource = {
uri: string
dimensions: Dimensions | null
thumbUri: string
thumbDimensions: Dimensions | null
thumbRect: MeasuredDimensions | null
alt?: string
type: 'image' | 'circle-avi' | 'rect-avi'
}
export type Transform = Exclude<
TransformsStyle['transform'],
string | undefined
>
export type ImageSource = {uri: string; thumbUri: string; alt?: string}
@@ -1,26 +1,19 @@
import React, {useState} from 'react'
import {ActivityIndicator, StyleSheet} from 'react-native'
import {
Gesture,
GestureDetector,
PanGesture,
} from 'react-native-gesture-handler'
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, {
runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
withDecay,
withSpring,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import type {
Dimensions as ImageDimensions,
ImageSource,
Transform,
} from '../../@types'
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import useImageDimensions from '../../hooks/useImageDimensions'
import {
applyRounding,
createTransform,
@@ -31,7 +24,14 @@ import {
TransformMatrix,
} from '../../transforms'
const MIN_SCREEN_ZOOM = 2
const windowDim = Dimensions.get('window')
const screenDim = Dimensions.get('screen')
const statusBarHeight = windowDim.height - screenDim.height
const SCREEN = {
width: windowDim.width,
height: windowDim.height + statusBarHeight,
}
const MIN_DOUBLE_TAP_SCALE = 2
const MAX_ORIGINAL_IMAGE_ZOOM = 2
const initialTransform = createTransform()
@@ -41,46 +41,24 @@ type Props = {
onRequestClose: () => void
onTap: () => void
onZoom: (isZoomed: boolean) => void
onLoad: (dims: ImageDimensions) => void
isScrollViewBeingDragged: boolean
showControls: boolean
measureSafeArea: () => {
x: number
y: number
width: number
height: number
}
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
}
const ImageItem = ({
imageSrc,
onTap,
onZoom,
onLoad,
onRequestClose,
isScrollViewBeingDragged,
measureSafeArea,
imageAspect,
imageDimensions,
dismissSwipePan,
transforms,
}: Props) => {
const [isScaled, setIsScaled] = useState(false)
const imageDimensions = useImageDimensions(imageSrc)
const committedTransform = useSharedValue(initialTransform)
const panTranslation = useSharedValue({x: 0, y: 0})
const pinchOrigin = useSharedValue({x: 0, y: 0})
const pinchScale = useSharedValue(1)
const pinchTranslation = useSharedValue({x: 0, y: 0})
const dismissSwipeTranslateY = useSharedValue(0)
const containerRef = useAnimatedRef()
// Keep track of when we're entering or leaving scaled rendering.
@@ -111,32 +89,51 @@ const ImageItem = ({
onZoom(nextIsScaled)
}
const animatedStyle = useAnimatedStyle(() => {
// Apply the active adjustments on top of the committed transform before the gestures.
// This is matrix multiplication, so operations are applied in the reverse order.
let t = createTransform()
prependPan(t, panTranslation.value)
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value)
const [translateX, translateY, scale] = readTransform(t)
const dismissDistance = dismissSwipeTranslateY.value
const dismissProgress = Math.min(
Math.abs(dismissDistance) / (SCREEN.height / 2),
1,
)
return {
opacity: 1 - dismissProgress,
transform: [
{translateX},
{translateY: translateY + dismissDistance},
{scale},
],
}
})
// On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
function getExtraTranslationToStayInBounds(
candidateTransform: TransformMatrix,
screenSize: {width: number; height: number},
) {
'worklet'
if (!imageAspect) {
if (!imageDimensions) {
return [0, 0]
}
const [nextTranslateX, nextTranslateY, nextScale] =
readTransform(candidateTransform)
const scaledDimensions = getScaledDimensions(
imageAspect,
nextScale,
screenSize,
)
const scaledDimensions = getScaledDimensions(imageDimensions, nextScale)
const clampedTranslateX = clampTranslation(
nextTranslateX,
scaledDimensions.width,
screenSize.width,
SCREEN.width,
)
const clampedTranslateY = clampTranslation(
nextTranslateY,
scaledDimensions.height,
screenSize.height,
SCREEN.height,
)
const dx = clampedTranslateX - nextTranslateX
const dy = clampedTranslateY - nextTranslateY
@@ -146,25 +143,21 @@ const ImageItem = ({
const pinch = Gesture.Pinch()
.onStart(e => {
'worklet'
const screenSize = measureSafeArea()
pinchOrigin.value = {
x: e.focalX - screenSize.width / 2,
y: e.focalY - screenSize.height / 2,
x: e.focalX - SCREEN.width / 2,
y: e.focalY - SCREEN.height / 2,
}
})
.onChange(e => {
'worklet'
const screenSize = measureSafeArea()
if (!imageDimensions) {
return
}
// Don't let the picture zoom in so close that it gets blurry.
// Also, like in stock Android apps, don't let the user zoom out further than 1:1.
const [, , committedScale] = readTransform(committedTransform.value)
const maxCommittedScale = Math.max(
MIN_SCREEN_ZOOM,
(imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
)
const maxCommittedScale =
(imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
const minPinchScale = 1 / committedScale
const maxPinchScale = maxCommittedScale / committedScale
const nextPinchScale = Math.min(
@@ -179,7 +172,7 @@ const ImageItem = ({
prependPan(t, panTranslation.value)
prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value)
const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
const [dx, dy] = getExtraTranslationToStayInBounds(t)
if (dx !== 0 || dy !== 0) {
pinchTranslation.value = {
x: pinchTranslation.value.x + dx,
@@ -213,11 +206,9 @@ const ImageItem = ({
.minPointers(isScaled ? 1 : 2)
.onChange(e => {
'worklet'
const screenSize = measureSafeArea()
if (!imageDimensions) {
return
}
const nextPanTranslation = {x: e.translationX, y: e.translationY}
let t = createTransform()
prependPan(t, nextPanTranslation)
@@ -230,7 +221,7 @@ const ImageItem = ({
prependTransform(t, committedTransform.value)
// Prevent panning from going out of bounds.
const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
const [dx, dy] = getExtraTranslationToStayInBounds(t)
nextPanTranslation.x += dx
nextPanTranslation.y += dy
panTranslation.value = nextPanTranslation
@@ -257,8 +248,7 @@ const ImageItem = ({
.numberOfTaps(2)
.onEnd(e => {
'worklet'
const screenSize = measureSafeArea()
if (!imageDimensions || !imageAspect) {
if (!imageDimensions) {
return
}
const [, , committedScale] = readTransform(committedTransform.value)
@@ -270,38 +260,56 @@ const ImageItem = ({
}
// Try to zoom in so that we get rid of the black bars (whatever the orientation was).
const screenAspect = screenSize.width / screenSize.height
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
const candidateScale = Math.max(
imageAspect / screenAspect,
screenAspect / imageAspect,
MIN_SCREEN_ZOOM,
MIN_DOUBLE_TAP_SCALE,
)
// But don't zoom in so close that the picture gets blurry.
const maxScale = Math.max(
MIN_SCREEN_ZOOM,
(imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
)
const maxScale =
(imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
const scale = Math.min(candidateScale, maxScale)
// Calculate where we would be if the user pinched into the double tapped point.
// We won't use this transform directly because it may go out of bounds.
const candidateTransform = createTransform()
const origin = {
x: e.absoluteX - screenSize.width / 2,
y: e.absoluteY - screenSize.height / 2,
x: e.absoluteX - SCREEN.width / 2,
y: e.absoluteY - SCREEN.height / 2,
}
prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
// Now we know how much we went out of bounds, so we can shoot correctly.
const [dx, dy] = getExtraTranslationToStayInBounds(
candidateTransform,
screenSize,
)
const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform)
const finalTransform = createTransform()
prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
committedTransform.value = withClampedSpring(finalTransform)
})
const dismissSwipePan = Gesture.Pan()
.enabled(!isScaled)
.activeOffsetY([-10, 10])
.failOffsetX([-10, 10])
.maxPointers(1)
.onUpdate(e => {
'worklet'
dismissSwipeTranslateY.value = e.translationY
})
.onEnd(e => {
'worklet'
if (Math.abs(e.velocityY) > 1000) {
dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY})
runOnJS(onRequestClose)()
} else {
dismissSwipeTranslateY.value = withSpring(0, {
stiffness: 700,
damping: 50,
})
}
})
const composedGesture = isScrollViewBeingDragged
? // If the parent is not at rest, provide a no-op gesture.
Gesture.Manual()
@@ -312,116 +320,38 @@ const ImageItem = ({
singleTap,
)
const containerStyle = useAnimatedStyle(() => {
const {scaleAndMoveTransform, isHidden} = transforms.value
// Apply the active adjustments on top of the committed transform before the gestures.
// This is matrix multiplication, so operations are applied in the reverse order.
let t = createTransform()
prependPan(t, panTranslation.value)
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value)
const [translateX, translateY, scale] = readTransform(t)
const manipulationTransform = [
{translateX},
{translateY: translateY},
{scale},
]
const screenSize = measureSafeArea()
return {
opacity: isHidden ? 0 : 1,
transform: scaleAndMoveTransform.concat(manipulationTransform),
width: screenSize.width,
maxHeight: screenSize.height,
alignSelf: 'center',
aspectRatio: imageAspect ?? 1 /* force onLoad */,
}
})
const imageCropStyle = useAnimatedStyle(() => {
const {cropFrameTransform} = transforms.value
return {
flex: 1,
overflow: 'hidden',
transform: cropFrameTransform,
}
})
const imageStyle = useAnimatedStyle(() => {
const {cropContentTransform} = transforms.value
return {
flex: 1,
transform: cropContentTransform,
opacity: imageAspect === undefined ? 0 : 1,
}
})
const [showLoader, setShowLoader] = useState(false)
const [hasLoaded, setHasLoaded] = useState(false)
useAnimatedReaction(
() => {
return transforms.value.isResting && !hasLoaded
},
(show, prevShow) => {
if (show && !prevShow) {
runOnJS(setShowLoader)(false)
} else if (!prevShow && show) {
runOnJS(setShowLoader)(true)
}
},
)
const type = imageSrc.type
const borderRadius =
type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
return (
<GestureDetector gesture={composedGesture}>
<Animated.View
ref={containerRef}
style={[styles.container]}
renderToHardwareTextureAndroid>
<Animated.View style={containerStyle}>
{showLoader && (
<ActivityIndicator
size="small"
color="#FFF"
style={styles.loading}
/>
)}
<Animated.View style={imageCropStyle}>
<Animated.View style={imageStyle}>
<Image
contentFit="cover"
source={{uri: imageSrc.uri}}
placeholderContentFit="cover"
placeholder={{uri: imageSrc.thumbUri}}
accessibilityLabel={imageSrc.alt}
onLoad={
hasLoaded
? undefined
: e => {
setHasLoaded(true)
onLoad({width: e.source.width, height: e.source.height})
}
}
style={{flex: 1, borderRadius}}
accessibilityHint=""
accessibilityIgnoresInvertColors
cachePolicy="memory"
/>
</Animated.View>
</Animated.View>
</Animated.View>
</Animated.View>
</GestureDetector>
<Animated.View
ref={containerRef}
// Necessary to make opacity work for both children together.
renderToHardwareTextureAndroid
style={[styles.container, animatedStyle]}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<GestureDetector gesture={composedGesture}>
<Image
contentFit="contain"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={styles.image}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
cachePolicy="memory"
/>
</GestureDetector>
</Animated.View>
)
}
const styles = StyleSheet.create({
container: {
height: '100%',
width: SCREEN.width,
height: SCREEN.height,
overflow: 'hidden',
justifyContent: 'center',
},
image: {
flex: 1,
},
loading: {
position: 'absolute',
@@ -429,27 +359,26 @@ const styles = StyleSheet.create({
right: 0,
top: 0,
bottom: 0,
justifyContent: 'center',
},
})
function getScaledDimensions(
imageAspect: number,
imageDimensions: ImageDimensions,
scale: number,
screenSize: {width: number; height: number},
): ImageDimensions {
'worklet'
const screenAspect = screenSize.width / screenSize.height
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
const isLandscape = imageAspect > screenAspect
if (isLandscape) {
return {
width: scale * screenSize.width,
height: (scale * screenSize.width) / imageAspect,
width: scale * SCREEN.width,
height: (scale * SCREEN.width) / imageAspect,
}
} else {
return {
width: scale * screenSize.height * imageAspect,
height: scale * screenSize.height,
width: scale * SCREEN.height * imageAspect,
height: scale * SCREEN.height,
}
}
}
@@ -7,90 +7,79 @@
*/
import React, {useState} from 'react'
import {ActivityIndicator, StyleSheet} from 'react-native'
import {
Gesture,
GestureDetector,
PanGesture,
} from 'react-native-gesture-handler'
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, {
interpolate,
runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {Image} from 'expo-image'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {
Dimensions as ImageDimensions,
ImageSource,
Transform,
} from '../../@types'
import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import useImageDimensions from '../../hooks/useImageDimensions'
const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1
const SCREEN = Dimensions.get('screen')
const MAX_ORIGINAL_IMAGE_ZOOM = 2
const MIN_SCREEN_ZOOM = 2
const MIN_DOUBLE_TAP_SCALE = 2
type Props = {
imageSrc: ImageSource
onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void
onLoad: (dims: ImageDimensions) => void
isScrollViewBeingDragged: boolean
showControls: boolean
measureSafeArea: () => {
x: number
y: number
width: number
height: number
}
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
}
const ImageItem = ({
imageSrc,
onTap,
onZoom,
onLoad,
onRequestClose,
showControls,
measureSafeArea,
imageAspect,
imageDimensions,
dismissSwipePan,
transforms,
}: Props) => {
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
const translationY = useSharedValue(0)
const [scaled, setScaled] = useState(false)
const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame()
const maxZoomScale = Math.max(
MIN_SCREEN_ZOOM,
imageDimensions
? (imageDimensions.width / screenSizeDelayedForJSThreadOnly.width) *
MAX_ORIGINAL_IMAGE_ZOOM
: 1,
)
const imageDimensions = useImageDimensions(imageSrc)
const maxZoomScale = imageDimensions
? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
: 1
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: interpolate(
translationY.value,
[-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
[0.5, 1, 0.5],
),
}
})
const scrollHandler = useAnimatedScrollHandler({
onScroll(e) {
const nextIsScaled = e.zoomScale > 1
translationY.value = nextIsScaled ? 0 : e.contentOffset.y
if (scaled !== nextIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
}
},
onEndDrag(e) {
const velocityY = e.velocity?.y ?? 0
const nextIsScaled = e.zoomScale > 1
if (scaled !== nextIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
}
if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
runOnJS(onRequestClose)()
}
},
})
function handleZoom(nextIsScaled: boolean) {
@@ -98,13 +87,24 @@ const ImageItem = ({
setScaled(nextIsScaled)
}
function zoomTo(nextZoomRect: {
x: number
y: number
width: number
height: number
}) {
function handleDoubleTap(absoluteX: number, absoluteY: number) {
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
let nextZoomRect = {
x: 0,
y: 0,
width: SCREEN.width,
height: SCREEN.height,
}
const willZoom = !scaled
if (willZoom) {
nextZoomRect = getZoomRectAfterDoubleTap(
imageDimensions,
absoluteX,
absoluteY,
)
}
// @ts-ignore
scrollResponderRef?.scrollResponderZoomTo({
...nextZoomRect, // This rect is in screen coordinates
@@ -121,123 +121,36 @@ const ImageItem = ({
.numberOfTaps(2)
.onEnd(e => {
'worklet'
const screenSize = measureSafeArea()
const {absoluteX, absoluteY} = e
let nextZoomRect = {
x: 0,
y: 0,
width: screenSize.width,
height: screenSize.height,
}
const willZoom = !scaled
if (willZoom) {
nextZoomRect = getZoomRectAfterDoubleTap(
imageAspect,
absoluteX,
absoluteY,
screenSize,
)
}
runOnJS(zoomTo)(nextZoomRect)
runOnJS(handleDoubleTap)(absoluteX, absoluteY)
})
const composedGesture = Gesture.Exclusive(
dismissSwipePan,
doubleTap,
singleTap,
)
const containerStyle = useAnimatedStyle(() => {
const {scaleAndMoveTransform, isHidden} = transforms.value
return {
flex: 1,
transform: scaleAndMoveTransform,
opacity: isHidden ? 0 : 1,
}
})
const imageCropStyle = useAnimatedStyle(() => {
const screenSize = measureSafeArea()
const {cropFrameTransform} = transforms.value
return {
overflow: 'hidden',
transform: cropFrameTransform,
width: screenSize.width,
maxHeight: screenSize.height,
alignSelf: 'center',
aspectRatio: imageAspect ?? 1 /* force onLoad */,
opacity: imageAspect === undefined ? 0 : 1,
}
})
const imageStyle = useAnimatedStyle(() => {
const {cropContentTransform} = transforms.value
return {
transform: cropContentTransform,
width: '100%',
aspectRatio: imageAspect ?? 1 /* force onLoad */,
opacity: imageAspect === undefined ? 0 : 1,
}
})
const [showLoader, setShowLoader] = useState(false)
const [hasLoaded, setHasLoaded] = useState(false)
useAnimatedReaction(
() => {
return transforms.value.isResting && !hasLoaded
},
(show, prevShow) => {
if (show && !prevShow) {
runOnJS(setShowLoader)(false)
} else if (!prevShow && show) {
runOnJS(setShowLoader)(true)
}
},
)
const type = imageSrc.type
const borderRadius =
type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
return (
<GestureDetector gesture={composedGesture}>
<Animated.ScrollView
// @ts-ignore Something's up with the types here
ref={scrollViewRef}
style={styles.listItem}
pinchGestureEnabled
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
maximumZoomScale={maxZoomScale}
onScroll={scrollHandler}
style={containerStyle}
bounces={scaled}
bouncesZoom={true}
centerContent>
{showLoader && (
onScroll={scrollHandler}>
<Animated.View style={[styles.imageScrollContainer, animatedStyle]}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
)}
<Animated.View style={imageCropStyle}>
<Animated.View style={imageStyle}>
<Image
contentFit="contain"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={{flex: 1, borderRadius}}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled}
accessibilityIgnoresInvertColors
onLoad={
hasLoaded
? undefined
: e => {
setHasLoaded(true)
onLoad({width: e.source.width, height: e.source.height})
}
}
/>
</Animated.View>
<Image
contentFit="contain"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={styles.image}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled}
accessibilityIgnoresInvertColors
/>
</Animated.View>
</Animated.ScrollView>
</GestureDetector>
@@ -245,6 +158,17 @@ const ImageItem = ({
}
const styles = StyleSheet.create({
imageScrollContainer: {
height: SCREEN.height,
},
listItem: {
width: SCREEN.width,
height: SCREEN.height,
},
image: {
width: SCREEN.width,
height: SCREEN.height,
},
loading: {
position: 'absolute',
top: 0,
@@ -252,64 +176,60 @@ const styles = StyleSheet.create({
right: 0,
bottom: 0,
},
image: {
flex: 1,
},
})
const getZoomRectAfterDoubleTap = (
imageAspect: number | undefined,
imageDimensions: ImageDimensions | null,
touchX: number,
touchY: number,
screenSize: {width: number; height: number},
): {
x: number
y: number
width: number
height: number
} => {
'worklet'
if (!imageAspect) {
if (!imageDimensions) {
return {
x: 0,
y: 0,
width: screenSize.width,
height: screenSize.height,
width: SCREEN.width,
height: SCREEN.height,
}
}
// First, let's figure out how much we want to zoom in.
// We want to try to zoom in at least close enough to get rid of black bars.
const screenAspect = screenSize.width / screenSize.height
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
const zoom = Math.max(
imageAspect / screenAspect,
screenAspect / imageAspect,
MIN_SCREEN_ZOOM,
MIN_DOUBLE_TAP_SCALE,
)
// Unlike in the Android version, we don't constrain the *max* zoom level here.
// Instead, this is done in the ScrollView props so that it constraints pinch too.
// Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
// We already know the zoom level, so this gives us the rectangle size.
let rectWidth = screenSize.width / zoom
let rectHeight = screenSize.height / zoom
let rectWidth = SCREEN.width / zoom
let rectHeight = SCREEN.height / zoom
// Before we settle on the zoomed rect, figure out the safe area it has to be inside.
// We don't want to introduce new black bars or make existing black bars unbalanced.
let minX = 0
let minY = 0
let maxX = screenSize.width - rectWidth
let maxY = screenSize.height - rectHeight
let maxX = SCREEN.width - rectWidth
let maxY = SCREEN.height - rectHeight
if (imageAspect >= screenAspect) {
// The image has horizontal black bars. Exclude them from the safe area.
const renderedHeight = screenSize.width / imageAspect
const horizontalBarHeight = (screenSize.height - renderedHeight) / 2
const renderedHeight = SCREEN.width / imageAspect
const horizontalBarHeight = (SCREEN.height - renderedHeight) / 2
minY += horizontalBarHeight
maxY -= horizontalBarHeight
} else {
// The image has vertical black bars. Exclude them from the safe area.
const renderedWidth = screenSize.height * imageAspect
const verticalBarWidth = (screenSize.width - renderedWidth) / 2
const renderedWidth = SCREEN.height * imageAspect
const verticalBarWidth = (SCREEN.width - renderedWidth) / 2
minX += verticalBarWidth
maxX -= verticalBarWidth
}
@@ -324,7 +244,7 @@ const getZoomRectAfterDoubleTap = (
rectX = Math.max(rectX, minX)
} else {
// Keep the rect centered on the screen so that black bars are balanced.
rectX = screenSize.width / 2 - rectWidth / 2
rectX = SCREEN.width / 2 - rectWidth / 2
}
let rectY
if (maxY >= minY) {
@@ -335,7 +255,7 @@ const getZoomRectAfterDoubleTap = (
rectY = Math.max(rectY, minY)
} else {
// Keep the rect centered on the screen so that black bars are balanced.
rectY = screenSize.height / 2 - rectHeight / 2
rectY = SCREEN.height / 2 - rectHeight / 2
}
return {
@@ -2,42 +2,16 @@
import React from 'react'
import {View} from 'react-native'
import {PanGesture} from 'react-native-gesture-handler'
import {SharedValue} from 'react-native-reanimated'
import {Dimensions} from '#/lib/media/types'
import {
Dimensions as ImageDimensions,
ImageSource,
Transform,
} from '../../@types'
import {ImageSource} from '../../@types'
type Props = {
imageSrc: ImageSource
onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void
onLoad: (dims: Dimensions) => void
isScrollViewBeingDragged: boolean
showControls: boolean
measureSafeArea: () => {
x: number
y: number
width: number
height: number
}
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
}
const ImageItem = (_props: Props) => {
@@ -0,0 +1,93 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useEffect, useState} from 'react'
import {Image, ImageURISource} from 'react-native'
import {Dimensions, ImageSource} from '../@types'
const CACHE_SIZE = 50
type CacheStorageItem = {key: string; value: any}
const createCache = (cacheSize: number) => ({
_storage: [] as CacheStorageItem[],
get(key: string): any {
const {value} =
this._storage.find(({key: storageKey}) => storageKey === key) || {}
return value
},
set(key: string, value: any) {
if (this._storage.length >= cacheSize) {
this._storage.shift()
}
this._storage.push({key, value})
},
})
const imageDimensionsCache = createCache(CACHE_SIZE)
const useImageDimensions = (image: ImageSource): Dimensions | null => {
const [dimensions, setDimensions] = useState<Dimensions | null>(null)
const getImageDimensions = (
image: ImageSource,
): Promise<Dimensions | null> => {
return new Promise(resolve => {
if (image.uri) {
const source = image as ImageURISource
const cacheKey = source.uri as string
const imageDimensions = imageDimensionsCache.get(cacheKey)
if (imageDimensions) {
resolve(imageDimensions)
} else {
Image.getSizeWithHeaders(
// @ts-ignore
source.uri,
source.headers,
(width: number, height: number) => {
if (width > 0 && height > 0) {
imageDimensionsCache.set(cacheKey, {width, height})
resolve({width, height})
} else {
resolve(null)
}
},
() => {
resolve(null)
},
)
}
} else {
resolve(null)
}
})
}
let isImageUnmounted = false
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-shadow
getImageDimensions(image).then(dimensions => {
if (!isImageUnmounted) {
setDimensions(dimensions)
}
})
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
isImageUnmounted = true
}
}, [image])
return dimensions
}
export default useImageDimensions
+109 -638
View File
@@ -8,229 +8,63 @@
// Original code copied and simplified from the link below as the codebase is currently not maintained:
// https://github.com/jobtoday/react-native-image-viewing
import React, {useCallback, useState} from 'react'
import {
LayoutAnimation,
PixelRatio,
Platform,
StyleSheet,
View,
} from 'react-native'
import {Gesture} from 'react-native-gesture-handler'
import React, {ComponentType, useCallback, useMemo, useState} from 'react'
import {Platform, StyleSheet, View} from 'react-native'
import PagerView from 'react-native-pager-view'
import Animated, {
AnimatedRef,
cancelAnimation,
interpolate,
measure,
runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withDecay,
withSpring,
} from 'react-native-reanimated'
import {
Edge,
SafeAreaView,
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/macro'
import {MeasuredDimensions} from 'react-native-reanimated'
import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated'
import {Edge, SafeAreaView} from 'react-native-safe-area-context'
import {Dimensions} from '#/lib/media/types'
import {colors, s} from '#/lib/styles'
import {isIOS} from '#/platform/detection'
import {Lightbox} from '#/state/lightbox'
import {Button} from '#/view/com/util/forms/Button'
import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from '#/view/com/util/Views'
import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
import {ImageSource, Transform} from './@types'
import {ImageSource} from './@types'
import ImageDefaultHeader from './components/ImageDefaultHeader'
import ImageItem from './components/ImageItem/ImageItem'
type Rect = {x: number; y: number; width: number; height: number}
const PIXEL_RATIO = PixelRatio.get()
const EDGES =
Platform.OS === 'android'
? (['top', 'bottom', 'left', 'right'] satisfies Edge[])
: (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area
const SLOW_SPRING = {stiffness: isIOS ? 180 : 250}
const FAST_SPRING = {stiffness: 700}
export default function ImageViewRoot({
lightbox: nextLightbox,
onRequestClose,
onPressSave,
onPressShare,
}: {
lightbox: Lightbox | null
type Props = {
images: ImageSource[]
thumbDims: MeasuredDimensions | null
initialImageIndex: number
visible: boolean
onRequestClose: () => void
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}) {
'use no memo'
const ref = useAnimatedRef<View>()
const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
const openProgress = useSharedValue(0)
if (!activeLightbox && nextLightbox) {
setActiveLightbox(nextLightbox)
}
React.useEffect(() => {
if (!nextLightbox) {
return
}
const canAnimate =
!PlatformInfo.getIsReducedMotionEnabled() &&
nextLightbox.images.every(
img => img.thumbRect && (img.dimensions || img.thumbDimensions),
)
// https://github.com/software-mansion/react-native-reanimated/issues/6677
requestAnimationFrame(() => {
openProgress.value = canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1
})
return () => {
// https://github.com/software-mansion/react-native-reanimated/issues/6677
requestAnimationFrame(() => {
openProgress.value = canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0
})
}
}, [nextLightbox, openProgress])
useAnimatedReaction(
() => openProgress.value === 0,
(isGone, wasGone) => {
if (isGone && !wasGone) {
runOnJS(setActiveLightbox)(null)
}
},
)
const onFlyAway = React.useCallback(() => {
'worklet'
openProgress.value = 0
runOnJS(onRequestClose)()
}, [onRequestClose, openProgress])
return (
// Keep it always mounted to avoid flicker on the first frame.
<SafeAreaView
style={[styles.screen, !activeLightbox && styles.screenHidden]}
edges={EDGES}
aria-modal
accessibilityViewIsModal
aria-hidden={!activeLightbox}>
<Animated.View ref={ref} style={{flex: 1}} collapsable={false}>
{activeLightbox && (
<ImageView
key={activeLightbox.id}
lightbox={activeLightbox}
onRequestClose={onRequestClose}
onPressSave={onPressSave}
onPressShare={onPressShare}
onFlyAway={onFlyAway}
safeAreaRef={ref}
openProgress={openProgress}
/>
)}
</Animated.View>
</SafeAreaView>
)
backgroundColor?: string
HeaderComponent?: ComponentType<{imageIndex: number}>
FooterComponent?: ComponentType<{imageIndex: number}>
}
function ImageView({
lightbox,
const DEFAULT_BG_COLOR = '#000'
function ImageViewing({
images,
thumbDims: _thumbDims, // TODO: Pass down and use for animation.
initialImageIndex,
visible,
onRequestClose,
onPressSave,
onPressShare,
onFlyAway,
safeAreaRef,
openProgress,
}: {
lightbox: Lightbox
onRequestClose: () => void
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
onFlyAway: () => void
safeAreaRef: AnimatedRef<View>
openProgress: SharedValue<number>
}) {
const {images, index: initialImageIndex} = lightbox
backgroundColor = DEFAULT_BG_COLOR,
HeaderComponent,
FooterComponent,
}: Props) {
const [isScaled, setIsScaled] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [imageIndex, setImageIndex] = useState(initialImageIndex)
const [showControls, setShowControls] = useState(true)
const [isAltExpanded, setAltExpanded] = React.useState(false)
const dismissSwipeTranslateY = useSharedValue(0)
const isFlyingAway = useSharedValue(false)
const containerStyle = useAnimatedStyle(() => {
if (openProgress.value < 1 || isFlyingAway.value) {
return {pointerEvents: 'none'}
}
return {pointerEvents: 'auto'}
})
const backdropStyle = useAnimatedStyle(() => {
const screenSize = measure(safeAreaRef)
let opacity = 1
if (openProgress.value < 1) {
opacity = Math.sqrt(openProgress.value)
} else if (screenSize) {
const dragProgress = Math.min(
Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2),
1,
)
opacity -= dragProgress
}
const factor = isIOS ? 100 : 50
return {
opacity: Math.round(opacity * factor) / factor,
}
})
const animatedHeaderStyle = useAnimatedStyle(() => {
const show = showControls && dismissSwipeTranslateY.value === 0
return {
pointerEvents: show ? 'box-none' : 'none',
opacity: withClampedSpring(
show && openProgress.value === 1 ? 1 : 0,
FAST_SPRING,
),
transform: [
{
translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING),
},
],
}
})
const animatedFooterStyle = useAnimatedStyle(() => {
const show = showControls && dismissSwipeTranslateY.value === 0
return {
flexGrow: 1,
pointerEvents: show ? 'box-none' : 'none',
opacity: withClampedSpring(
show && openProgress.value === 1 ? 1 : 0,
FAST_SPRING,
),
transform: [
{
translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING),
},
],
}
})
const animatedHeaderStyle = useAnimatedStyle(() => ({
pointerEvents: showControls ? 'auto' : 'none',
opacity: withClampedSpring(showControls ? 1 : 0),
transform: [
{
translateY: withClampedSpring(showControls ? 0 : -30),
},
],
}))
const animatedFooterStyle = useAnimatedStyle(() => ({
pointerEvents: showControls ? 'auto' : 'none',
opacity: withClampedSpring(showControls ? 1 : 0),
transform: [
{
translateY: withClampedSpring(showControls ? 0 : 30),
},
],
}))
const onTap = useCallback(() => {
setShowControls(show => !show)
@@ -243,309 +77,67 @@ function ImageView({
}
}, [])
useAnimatedReaction(
() => {
const screenSize = measure(safeAreaRef)
return (
!screenSize ||
Math.abs(dismissSwipeTranslateY.value) > screenSize.height
)
},
(isOut, wasOut) => {
if (isOut && !wasOut) {
// Stop the animation from blocking the screen forever.
cancelAnimation(dismissSwipeTranslateY)
onFlyAway()
}
},
)
return (
<Animated.View style={[styles.container, containerStyle]}>
<Animated.View
style={[styles.backdrop, backdropStyle]}
renderToHardwareTextureAndroid
/>
<PagerView
scrollEnabled={!isScaled}
initialPage={initialImageIndex}
onPageSelected={e => {
setImageIndex(e.nativeEvent.position)
setIsScaled(false)
}}
onPageScrollStateChanged={e => {
setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
}}
overdrag={true}
style={styles.pager}>
{images.map((imageSrc, i) => (
<View key={imageSrc.uri}>
<LightboxImage
onTap={onTap}
onZoom={onZoom}
imageSrc={imageSrc}
onRequestClose={onRequestClose}
isScrollViewBeingDragged={isDragging}
showControls={showControls}
safeAreaRef={safeAreaRef}
isScaled={isScaled}
isFlyingAway={isFlyingAway}
isActive={i === imageIndex}
dismissSwipeTranslateY={dismissSwipeTranslateY}
openProgress={openProgress}
/>
</View>
))}
</PagerView>
<View style={styles.controls}>
<Animated.View
style={animatedHeaderStyle}
renderToHardwareTextureAndroid>
<ImageDefaultHeader onRequestClose={onRequestClose} />
</Animated.View>
<Animated.View
style={animatedFooterStyle}
renderToHardwareTextureAndroid={!isAltExpanded}>
<LightboxFooter
images={images}
index={imageIndex}
isAltExpanded={isAltExpanded}
toggleAltExpanded={() => setAltExpanded(e => !e)}
onPressSave={onPressSave}
onPressShare={onPressShare}
/>
</Animated.View>
</View>
</Animated.View>
)
}
function LightboxImage({
imageSrc,
onTap,
onZoom,
onRequestClose,
isScrollViewBeingDragged,
isScaled,
isFlyingAway,
isActive,
showControls,
safeAreaRef,
openProgress,
dismissSwipeTranslateY,
}: {
imageSrc: ImageSource
onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void
isScrollViewBeingDragged: boolean
isScaled: boolean
isActive: boolean
isFlyingAway: SharedValue<boolean>
showControls: boolean
safeAreaRef: AnimatedRef<View>
openProgress: SharedValue<number>
dismissSwipeTranslateY: SharedValue<number>
}) {
const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(null)
const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions
let imageAspect: number | undefined
if (dims) {
imageAspect = dims.width / dims.height
if (Number.isNaN(imageAspect)) {
imageAspect = undefined
const edges = useMemo(() => {
if (Platform.OS === 'android') {
return ['top', 'bottom', 'left', 'right'] satisfies Edge[]
}
return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area
}, [])
if (!visible) {
return null
}
const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame()
const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets()
const measureSafeArea = React.useCallback(() => {
'worklet'
let safeArea: Rect | null = measure(safeAreaRef)
if (!safeArea) {
if (_WORKLET) {
console.error('Expected to always be able to measure safe area.')
}
const frame = safeFrameDelayedForJSThreadOnly
const insets = safeInsetsDelayedForJSThreadOnly
safeArea = {
x: frame.x + insets.left,
y: frame.y + insets.top,
width: frame.width - insets.left - insets.right,
height: frame.height - insets.top - insets.bottom,
}
}
return safeArea
}, [
safeFrameDelayedForJSThreadOnly,
safeInsetsDelayedForJSThreadOnly,
safeAreaRef,
])
const {thumbRect} = imageSrc
const transforms = useDerivedValue(() => {
'worklet'
const safeArea = measureSafeArea()
const dismissTranslateY =
isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0
if (openProgress.value === 0 && isFlyingAway.value) {
return {
isHidden: true,
isResting: false,
scaleAndMoveTransform: [],
cropFrameTransform: [],
cropContentTransform: [],
}
}
if (isActive && thumbRect && imageAspect && openProgress.value < 1) {
return interpolateTransform(
openProgress.value,
thumbRect,
safeArea,
imageAspect,
)
}
return {
isHidden: false,
isResting: dismissTranslateY === 0,
scaleAndMoveTransform: [{translateY: dismissTranslateY}],
cropFrameTransform: [],
cropContentTransform: [],
}
})
const dismissSwipePan = Gesture.Pan()
.enabled(isActive && !isScaled)
.activeOffsetY([-10, 10])
.failOffsetX([-10, 10])
.maxPointers(1)
.onUpdate(e => {
'worklet'
if (openProgress.value !== 1 || isFlyingAway.value) {
return
}
dismissSwipeTranslateY.value = e.translationY
})
.onEnd(e => {
'worklet'
if (openProgress.value !== 1 || isFlyingAway.value) {
return
}
if (Math.abs(e.velocityY) > 200) {
isFlyingAway.value = true
if (dismissSwipeTranslateY.value === 0) {
// HACK: If the initial value is 0, withDecay() animation doesn't start.
// This is a bug in Reanimated, but for now we'll work around it like this.
dismissSwipeTranslateY.value = 1
}
dismissSwipeTranslateY.value = withDecay({
velocity: e.velocityY,
velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
deceleration: 1, // Danger! This relies on the reaction below stopping it.
})
} else {
dismissSwipeTranslateY.value = withSpring(0, {
stiffness: 700,
damping: 50,
})
}
})
return (
<ImageItem
imageSrc={imageSrc}
onTap={onTap}
onZoom={onZoom}
onRequestClose={onRequestClose}
onLoad={setFetchedDims}
isScrollViewBeingDragged={isScrollViewBeingDragged}
showControls={showControls}
measureSafeArea={measureSafeArea}
imageAspect={imageAspect}
imageDimensions={dims ?? undefined}
dismissSwipePan={dismissSwipePan}
transforms={transforms}
/>
)
}
function LightboxFooter({
images,
index,
isAltExpanded,
toggleAltExpanded,
onPressSave,
onPressShare,
}: {
images: ImageSource[]
index: number
isAltExpanded: boolean
toggleAltExpanded: () => void
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}) {
const {alt: altText, uri} = images[index]
const isMomentumScrolling = React.useRef(false)
return (
<ScrollView
style={styles.footerScrollView}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={{
paddingVertical: 12,
paddingHorizontal: 24,
}}>
<SafeAreaView edges={['bottom']}>
{altText ? (
<View accessibilityRole="button" style={styles.footerText}>
<Text
style={[s.gray3]}
numberOfLines={isAltExpanded ? undefined : 3}
selectable
onPress={() => {
if (isMomentumScrolling.current) {
return
}
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
toggleAltExpanded()
}}
onLongPress={() => {}}>
{altText}
</Text>
</View>
) : null}
<View style={styles.footerBtns}>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => onPressSave(uri)}>
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Save</Trans>
</Text>
</Button>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => onPressShare(uri)}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Share</Trans>
</Text>
</Button>
</View>
</SafeAreaView>
</ScrollView>
<SafeAreaView
style={styles.screen}
edges={edges}
aria-modal
accessibilityViewIsModal>
<View style={[styles.container, {backgroundColor}]}>
<Animated.View style={[styles.header, animatedHeaderStyle]}>
{typeof HeaderComponent !== 'undefined' ? (
React.createElement(HeaderComponent, {
imageIndex,
})
) : (
<ImageDefaultHeader onRequestClose={onRequestClose} />
)}
</Animated.View>
<PagerView
scrollEnabled={!isScaled}
initialPage={initialImageIndex}
onPageSelected={e => {
setImageIndex(e.nativeEvent.position)
setIsScaled(false)
}}
onPageScrollStateChanged={e => {
setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
}}
overdrag={true}
style={styles.pager}>
{images.map(imageSrc => (
<View key={imageSrc.uri}>
<ImageItem
onTap={onTap}
onZoom={onZoom}
imageSrc={imageSrc}
onRequestClose={onRequestClose}
isScrollViewBeingDragged={isDragging}
showControls={showControls}
/>
</View>
))}
</PagerView>
{typeof FooterComponent !== 'undefined' && (
<Animated.View style={[styles.footer, animatedFooterStyle]}>
{React.createElement(FooterComponent, {
imageIndex,
})}
</Animated.View>
)}
</View>
</SafeAreaView>
)
}
@@ -557,30 +149,9 @@ const styles = StyleSheet.create({
bottom: 0,
right: 0,
},
screenHidden: {
opacity: 0,
pointerEvents: 'none',
},
container: {
flex: 1,
},
backdrop: {
backgroundColor: '#000',
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
},
controls: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
gap: 20,
zIndex: 1,
pointerEvents: 'box-none',
},
pager: {
flex: 1,
@@ -588,125 +159,25 @@ const styles = StyleSheet.create({
header: {
position: 'absolute',
width: '100%',
zIndex: 1,
top: 0,
pointerEvents: 'box-none',
},
footer: {
position: 'absolute',
width: '100%',
maxHeight: '100%',
zIndex: 1,
bottom: 0,
},
footerScrollView: {
backgroundColor: '#000d',
flex: 1,
position: 'absolute',
bottom: 0,
width: '100%',
maxHeight: '100%',
},
footerText: {
paddingBottom: isIOS ? 20 : 16,
},
footerBtns: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'transparent',
borderColor: colors.white,
},
})
function interpolatePx(
px: number,
inputRange: readonly number[],
outputRange: readonly number[],
) {
const EnhancedImageViewing = (props: Props) => (
<ImageViewing key={props.initialImageIndex} {...props} />
)
function withClampedSpring(value: any) {
'worklet'
const value = interpolate(px, inputRange, outputRange)
return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO
return withSpring(value, {overshootClamping: true, stiffness: 300})
}
function interpolateTransform(
progress: number,
thumbnailDims: {
pageX: number
width: number
pageY: number
height: number
},
safeArea: {width: number; height: number; x: number; y: number},
imageAspect: number,
): {
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
} {
'worklet'
const thumbAspect = thumbnailDims.width / thumbnailDims.height
let uncroppedInitialWidth
let uncroppedInitialHeight
if (imageAspect > thumbAspect) {
uncroppedInitialWidth = thumbnailDims.height * imageAspect
uncroppedInitialHeight = thumbnailDims.height
} else {
uncroppedInitialWidth = thumbnailDims.width
uncroppedInitialHeight = thumbnailDims.width / imageAspect
}
const safeAreaAspect = safeArea.width / safeArea.height
let finalWidth
let finalHeight
if (safeAreaAspect > imageAspect) {
finalWidth = safeArea.height * imageAspect
finalHeight = safeArea.height
} else {
finalWidth = safeArea.width
finalHeight = safeArea.width / imageAspect
}
const initialScale = Math.min(
uncroppedInitialWidth / finalWidth,
uncroppedInitialHeight / finalHeight,
)
const croppedFinalWidth = thumbnailDims.width / initialScale
const croppedFinalHeight = thumbnailDims.height / initialScale
const screenCenterX = safeArea.width / 2
const screenCenterY = safeArea.height / 2
const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x
const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y
const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2
const thumbnailCenterY = thumbnailSafeAreaY + thumbnailDims.height / 2
const initialTranslateX = thumbnailCenterX - screenCenterX
const initialTranslateY = thumbnailCenterY - screenCenterY
const scale = interpolate(progress, [0, 1], [initialScale, 1])
const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0])
const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0])
const cropScaleX = interpolate(
progress,
[0, 1],
[croppedFinalWidth / finalWidth, 1],
)
const cropScaleY = interpolate(
progress,
[0, 1],
[croppedFinalHeight / finalHeight, 1],
)
return {
isHidden: false,
isResting: progress === 1,
scaleAndMoveTransform: [{translateX}, {translateY}, {scale}],
cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}],
cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}],
}
}
function withClampedSpring(value: any, {stiffness}: {stiffness: number}) {
'worklet'
return withSpring(value, {overshootClamping: true, stiffness})
}
export default EnhancedImageViewing
+147 -8
View File
@@ -1,25 +1,74 @@
import React from 'react'
import {Dimensions, LayoutAnimation, StyleSheet, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as MediaLibrary from 'expo-media-library'
import {msg} from '@lingui/macro'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {saveImageToMediaLibrary, shareImageModal} from '#/lib/media/manip'
import {colors, s} from '#/lib/styles'
import {isIOS} from '#/platform/detection'
import {useLightbox, useLightboxControls} from '#/state/lightbox'
import {ScrollView} from '#/view/com/util/Views'
import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import ImageView from './ImageViewing'
const SCREEN_HEIGHT = Dimensions.get('window').height
export function Lightbox() {
const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls()
const onClose = React.useCallback(() => {
closeLightbox()
}, [closeLightbox])
if (!activeLightbox) {
return null
} else if (activeLightbox.type === 'profile-image') {
const opts = activeLightbox
return (
<ImageView
images={[
{uri: opts.profile.avatar || '', thumbUri: opts.profile.avatar || ''},
]}
initialImageIndex={0}
thumbDims={opts.thumbDims}
visible
onRequestClose={onClose}
FooterComponent={LightboxFooter}
/>
)
} else if (activeLightbox.type === 'images') {
const opts = activeLightbox
return (
<ImageView
images={opts.images.map(img => ({...img}))}
initialImageIndex={opts.index}
thumbDims={opts.thumbDims}
visible
onRequestClose={onClose}
FooterComponent={LightboxFooter}
/>
)
} else {
return null
}
}
function LightboxFooter({imageIndex}: {imageIndex: number}) {
const {_} = useLingui()
const {activeLightbox} = useLightbox()
const [isAltExpanded, setAltExpanded] = React.useState(false)
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions({
granularPermissions: ['photo'],
})
const insets = useSafeAreaInsets()
const svMaxHeight = SCREEN_HEIGHT - insets.top - 50
const isMomentumScrolling = React.useRef(false)
const saveImageToAlbumWithToasts = React.useCallback(
async (uri: string) => {
if (!permissionResponse || permissionResponse.granted === false) {
@@ -39,6 +88,7 @@ export function Lightbox() {
}
return
}
try {
await saveImageToMediaLibrary({uri})
Toast.show(_(msg`Saved to your camera roll`))
@@ -49,12 +99,101 @@ export function Lightbox() {
[permissionResponse, requestPermission, _],
)
const lightbox = activeLightbox
if (!lightbox) {
return null
}
let altText = ''
let uri = ''
if (lightbox.type === 'images') {
const opts = lightbox
uri = opts.images[imageIndex].uri
altText = opts.images[imageIndex].alt || ''
} else if (lightbox.type === 'profile-image') {
const opts = lightbox
uri = opts.profile.avatar || ''
}
return (
<ImageView
lightbox={activeLightbox}
onRequestClose={onClose}
onPressSave={saveImageToAlbumWithToasts}
onPressShare={uri => shareImageModal({uri})}
/>
<ScrollView
style={[
{
backgroundColor: '#000d',
},
{maxHeight: svMaxHeight},
]}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={{
paddingTop: 16,
paddingBottom: insets.bottom + 10,
paddingHorizontal: 24,
}}>
{altText ? (
<View accessibilityRole="button" style={styles.footerText}>
<Text
style={[s.gray3]}
numberOfLines={isAltExpanded ? undefined : 3}
selectable
onPress={() => {
if (isMomentumScrolling.current) {
return
}
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
setAltExpanded(prev => !prev)
}}
onLongPress={() => {}}>
{altText}
</Text>
</View>
) : null}
<View style={styles.footerBtns}>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => saveImageToAlbumWithToasts(uri)}>
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Save</Trans>
</Text>
</Button>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => shareImageModal({uri})}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Share</Trans>
</Text>
</Button>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
footerText: {
paddingBottom: isIOS ? 20 : 16,
},
footerBtns: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'transparent',
borderColor: colors.white,
},
})
+73 -89
View File
@@ -21,9 +21,13 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {colors, s} from '#/lib/styles'
import {useLightbox, useLightboxControls} from '#/state/lightbox'
import {Text} from '../util/text/Text'
import {ImageSource} from './ImageViewing/@types'
import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader'
interface Img {
uri: string
alt?: string
}
export function Lightbox() {
const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls()
@@ -34,8 +38,24 @@ export function Lightbox() {
return null
}
const initialIndex = activeLightbox.index
const imgs = activeLightbox.images
const initialIndex =
activeLightbox.type === 'images' ? activeLightbox.index : 0
let imgs: Img[] | undefined
if (activeLightbox.type === 'profile-image') {
const opts = activeLightbox
if (opts.profile.avatar) {
imgs = [{uri: opts.profile.avatar}]
}
} else if (activeLightbox.type === 'images') {
const opts = activeLightbox
imgs = opts.images
}
if (!imgs) {
return null
}
return (
<LightboxInner
imgs={imgs}
@@ -50,7 +70,7 @@ function LightboxInner({
initialIndex = 0,
onClose,
}: {
imgs: ImageSource[]
imgs: Img[]
initialIndex: number
onClose: () => void
}) {
@@ -97,8 +117,6 @@ function LightboxInner({
return isTabletOrDesktop ? 32 : 24
}, [isTabletOrDesktop])
const img = imgs[index]
const isAvi = img.type === 'circle-avi' || img.type === 'rect-avi'
return (
<View style={styles.mask}>
<TouchableWithoutFeedback
@@ -107,76 +125,55 @@ function LightboxInner({
accessibilityLabel={_(msg`Close image viewer`)}
accessibilityHint={_(msg`Exits image view`)}
onAccessibilityEscape={onClose}>
{isAvi ? (
<View style={styles.aviCenterer}>
<img
src={img.uri}
// @ts-ignore web-only
style={
{
...styles.avi,
borderRadius:
img.type === 'circle-avi'
? '50%'
: img.type === 'rect-avi'
? '10%'
: 0,
} as ImageStyle
}
alt={img.alt}
/>
</View>
) : (
<View style={styles.imageCenterer}>
<Image
accessibilityIgnoresInvertColors
source={img}
style={styles.image as ImageStyle}
accessibilityLabel={img.alt}
accessibilityHint=""
/>
{canGoLeft && (
<TouchableOpacity
onPress={onPressLeft}
style={[
styles.btn,
btnStyle,
styles.leftBtn,
styles.blurredBackground,
]}
accessibilityRole="button"
accessibilityLabel={_(msg`Previous image`)}
accessibilityHint="">
<FontAwesomeIcon
icon="angle-left"
style={styles.icon as FontAwesomeIconStyle}
size={iconSize}
/>
</TouchableOpacity>
)}
{canGoRight && (
<TouchableOpacity
onPress={onPressRight}
style={[
styles.btn,
btnStyle,
styles.rightBtn,
styles.blurredBackground,
]}
accessibilityRole="button"
accessibilityLabel={_(msg`Next image`)}
accessibilityHint="">
<FontAwesomeIcon
icon="angle-right"
style={styles.icon as FontAwesomeIconStyle}
size={iconSize}
/>
</TouchableOpacity>
)}
</View>
)}
<View style={styles.imageCenterer}>
<Image
accessibilityIgnoresInvertColors
source={imgs[index]}
style={styles.image as ImageStyle}
accessibilityLabel={imgs[index].alt}
accessibilityHint=""
/>
{canGoLeft && (
<TouchableOpacity
onPress={onPressLeft}
style={[
styles.btn,
btnStyle,
styles.leftBtn,
styles.blurredBackground,
]}
accessibilityRole="button"
accessibilityLabel={_(msg`Previous image`)}
accessibilityHint="">
<FontAwesomeIcon
icon="angle-left"
style={styles.icon as FontAwesomeIconStyle}
size={iconSize}
/>
</TouchableOpacity>
)}
{canGoRight && (
<TouchableOpacity
onPress={onPressRight}
style={[
styles.btn,
btnStyle,
styles.rightBtn,
styles.blurredBackground,
]}
accessibilityRole="button"
accessibilityLabel={_(msg`Next image`)}
accessibilityHint="">
<FontAwesomeIcon
icon="angle-right"
style={styles.icon as FontAwesomeIconStyle}
size={iconSize}
/>
</TouchableOpacity>
)}
</View>
</TouchableWithoutFeedback>
{img.alt ? (
{imgs[index].alt ? (
<View style={styles.footer}>
<Pressable
accessibilityLabel={_(msg`Expand alt text`)}
@@ -190,7 +187,7 @@ function LightboxInner({
style={s.white}
numberOfLines={isAltExpanded ? 0 : 3}
ellipsizeMode="tail">
{img.alt}
{imgs[index].alt}
</Text>
</Pressable>
</View>
@@ -222,19 +219,6 @@ const styles = StyleSheet.create({
height: '100%',
resizeMode: 'contain',
},
aviCenterer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
avi: {
// @ts-ignore web-only
maxWidth: `calc(min(400px, 100vw))`,
// @ts-ignore web-only
maxHeight: `calc(min(400px, 100vh))`,
padding: 16,
boxSizing: 'border-box',
},
icon: {
color: colors.white,
},
+307
View File
@@ -0,0 +1,307 @@
import React, {useState} from 'react'
import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native'
import {setStringAsync} from 'expo-clipboard'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {usePalette} from '#/lib/hooks/usePalette'
import {s} from '#/lib/styles'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {
useAppPasswordCreateMutation,
useAppPasswordsQuery,
} from '#/state/queries/app-passwords'
import {Button} from '#/view/com/util/forms/Button'
import {Text} from '#/view/com/util/text/Text'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
export const snapPoints = ['90%']
const shadesOfBlue: string[] = [
'AliceBlue',
'Aqua',
'Aquamarine',
'Azure',
'BabyBlue',
'Blue',
'BlueViolet',
'CadetBlue',
'CornflowerBlue',
'Cyan',
'DarkBlue',
'DarkCyan',
'DarkSlateBlue',
'DeepSkyBlue',
'DodgerBlue',
'ElectricBlue',
'LightBlue',
'LightCyan',
'LightSkyBlue',
'LightSteelBlue',
'MediumAquaMarine',
'MediumBlue',
'MediumSlateBlue',
'MidnightBlue',
'Navy',
'PowderBlue',
'RoyalBlue',
'SkyBlue',
'SlateBlue',
'SteelBlue',
'Teal',
'Turquoise',
]
export function Component({}: {}) {
const pal = usePalette('default')
const {_} = useLingui()
const {closeModal} = useModalControls()
const {data: passwords} = useAppPasswordsQuery()
const {mutateAsync: mutateAppPassword, isPending} =
useAppPasswordCreateMutation()
const [name, setName] = useState(
shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)],
)
const [appPassword, setAppPassword] = useState<string>()
const [wasCopied, setWasCopied] = useState(false)
const [privileged, setPrivileged] = useState(false)
const onCopy = React.useCallback(() => {
if (appPassword) {
setStringAsync(appPassword)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
setWasCopied(true)
}
}, [appPassword, _])
const onDone = React.useCallback(() => {
closeModal()
}, [closeModal])
const createAppPassword = async () => {
// if name is all whitespace, we don't allow it
if (!name || !name.trim()) {
Toast.show(
_(
msg`Please enter a name for your app password. All spaces is not allowed.`,
),
'xmark',
)
return
}
// if name is too short (under 4 chars), we don't allow it
if (name.length < 4) {
Toast.show(
_(msg`App Password names must be at least 4 characters long.`),
'xmark',
)
return
}
if (passwords?.find(p => p.name === name)) {
Toast.show(_(msg`This name is already in use`), 'xmark')
return
}
try {
const newPassword = await mutateAppPassword({name, privileged})
if (newPassword) {
setAppPassword(newPassword.password)
} else {
Toast.show(_(msg`Failed to create app password.`), 'xmark')
// TODO: better error handling (?)
}
} catch (e) {
Toast.show(_(msg`Failed to create app password.`), 'xmark')
logger.error('Failed to create app password', {message: e})
}
}
const _onChangeText = (text: string) => {
// sanitize input
// we only all alphanumeric characters, spaces, dashes, and underscores
// if the user enters anything else, we ignore it and shake the input container
// also, it cannot start with a space
if (text.match(/^[a-zA-Z0-9-_ ]*$/)) {
setName(text)
} else {
Toast.show(
_(
msg`App Password names can only contain letters, numbers, spaces, dashes, and underscores.`,
),
'xmark',
)
}
}
return (
<View style={[styles.container, pal.view]} testID="addAppPasswordsModal">
{!appPassword ? (
<>
<View>
<Text type="lg" style={[pal.text]}>
<Trans>
Please enter a unique name for this App Password or use our
randomly generated one.
</Trans>
</Text>
<View style={[pal.btn, styles.textInputWrapper]}>
<TextInput
style={[styles.input, pal.text]}
onChangeText={_onChangeText}
value={name}
placeholder={_(msg`Enter a name for this App Password`)}
placeholderTextColor={pal.colors.textLight}
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
autoFocus={true}
maxLength={32}
selectTextOnFocus={true}
blurOnSubmit={true}
editable={!isPending}
returnKeyType="done"
onSubmitEditing={createAppPassword}
accessible={true}
accessibilityLabel={_(msg`Name`)}
accessibilityHint={_(msg`Input name for app password`)}
/>
</View>
</View>
<Text type="xs" style={[pal.textLight, s.mb10, s.mt2]}>
<Trans>
Can only contain letters, numbers, spaces, dashes, and
underscores. Must be at least 4 characters long, but no more than
32 characters long.
</Trans>
</Text>
<Toggle.Item
type="checkbox"
label={_(msg`Allow access to your direct messages`)}
value={privileged}
onChange={val => setPrivileged(val)}
name="privileged"
style={a.my_md}>
<Toggle.Checkbox />
<Toggle.LabelText>
<Trans>Allow access to your direct messages</Trans>
</Toggle.LabelText>
</Toggle.Item>
</>
) : (
<>
<View>
<Text type="lg" style={[pal.text]}>
<Text type="lg-bold" style={[pal.text, s.mr5]}>
<Trans>Here is your app password.</Trans>
</Text>
<Trans>
Use this to sign into the other app along with your handle.
</Trans>
</Text>
<TouchableOpacity
style={[pal.border, styles.passwordContainer, pal.btn]}
onPress={onCopy}
accessibilityRole="button"
accessibilityLabel={_(msg`Copy`)}
accessibilityHint={_(msg`Copies app password`)}>
<Text type="2xl-bold" style={[pal.text]}>
{appPassword}
</Text>
{wasCopied ? (
<Text style={[pal.textLight]}>
<Trans>Copied</Trans>
</Text>
) : (
<FontAwesomeIcon
icon={['far', 'clone']}
style={pal.text as FontAwesomeIconStyle}
size={18}
/>
)}
</TouchableOpacity>
</View>
<Text type="lg" style={[pal.textLight, s.mb10]}>
<Trans>
For security reasons, you won't be able to view this again. If you
lose this password, you'll need to generate a new one.
</Trans>
</Text>
</>
)}
<View style={styles.btnContainer}>
<Button
type="primary"
label={!appPassword ? _(msg`Create App Password`) : _(msg`Done`)}
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={!appPassword ? createAppPassword : onDone}
/>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isNative ? 50 : 0,
paddingHorizontal: 16,
},
textInputWrapper: {
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
marginTop: 16,
marginBottom: 8,
},
input: {
flex: 1,
width: '100%',
paddingVertical: 10,
paddingHorizontal: 8,
fontSize: 17,
letterSpacing: 0.25,
fontWeight: '400',
borderRadius: 10,
},
passwordContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
paddingHorizontal: 16,
alignItems: 'center',
borderRadius: 10,
marginTop: 16,
marginBottom: 12,
},
btnContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 12,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
paddingHorizontal: 60,
paddingVertical: 14,
},
btnLabel: {
fontSize: 18,
},
groupContent: {
borderTopWidth: 1,
flexDirection: 'row',
alignItems: 'center',
},
})
+614
View File
@@ -0,0 +1,614 @@
import React, {useState} from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {setStringAsync} from 'expo-clipboard'
import {ComAtprotoServerDescribeServer} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {usePalette} from '#/lib/hooks/usePalette'
import {cleanError} from '#/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '#/lib/strings/handles'
import {s} from '#/lib/styles'
import {useTheme} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle'
import {useServiceQuery} from '#/state/queries/service'
import {SessionAccount, useAgent, useSession} from '#/state/session'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Button} from '../util/forms/Button'
import {SelectableBtn} from '../util/forms/SelectableBtn'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {ScrollView, TextInput} from './util'
export const snapPoints = ['100%']
export type Props = {onChanged: () => void}
export function Component(props: Props) {
const {currentAccount} = useSession()
const agent = useAgent()
const {
isLoading,
data: serviceInfo,
error: serviceInfoError,
} = useServiceQuery(agent.service.toString())
return isLoading || !currentAccount ? (
<View style={{padding: 18}}>
<ActivityIndicator />
</View>
) : serviceInfoError || !serviceInfo ? (
<ErrorMessage message={cleanError(serviceInfoError)} />
) : (
<Inner
{...props}
currentAccount={currentAccount}
serviceInfo={serviceInfo}
/>
)
}
export function Inner({
currentAccount,
serviceInfo,
onChanged,
}: Props & {
currentAccount: SessionAccount
serviceInfo: ComAtprotoServerDescribeServer.OutputSchema
}) {
const {_} = useLingui()
const pal = usePalette('default')
const {closeModal} = useModalControls()
const {mutateAsync: updateHandle, isPending: isUpdateHandlePending} =
useUpdateHandleMutation()
const agent = useAgent()
const [error, setError] = useState<string>('')
const [isCustom, setCustom] = React.useState<boolean>(false)
const [handle, setHandle] = React.useState<string>('')
const [canSave, setCanSave] = React.useState<boolean>(false)
const userDomain = serviceInfo.availableUserDomains?.[0]
// events
// =
const onPressCancel = React.useCallback(() => {
closeModal()
}, [closeModal])
const onToggleCustom = React.useCallback(() => {
// toggle between a provided domain vs a custom one
setHandle('')
setCanSave(false)
setCustom(!isCustom)
}, [setCustom, isCustom])
const onPressSave = React.useCallback(async () => {
if (!userDomain) {
logger.error(`ChangeHandle: userDomain is undefined`, {
service: serviceInfo,
})
setError(`The service you've selected has no domains configured.`)
return
}
try {
const newHandle = isCustom ? handle : createFullHandle(handle, userDomain)
logger.debug(`Updating handle to ${newHandle}`)
await updateHandle({
handle: newHandle,
})
await agent.resumeSession(agent.session!)
closeModal()
onChanged()
} catch (err: any) {
setError(cleanError(err))
logger.error('Failed to update handle', {handle, message: err})
} finally {
}
}, [
setError,
handle,
userDomain,
isCustom,
onChanged,
closeModal,
updateHandle,
serviceInfo,
agent,
])
// rendering
// =
return (
<View style={[s.flex1, pal.view]}>
<View style={[styles.title, pal.border]}>
<View style={styles.titleLeft}>
<TouchableOpacity
onPress={onPressCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel change handle`)}
accessibilityHint={_(msg`Exits handle change process`)}
onAccessibilityEscape={onPressCancel}>
<Text type="lg" style={pal.textLight}>
<Trans>Cancel</Trans>
</Text>
</TouchableOpacity>
</View>
<Text
type="2xl-bold"
style={[styles.titleMiddle, pal.text]}
numberOfLines={1}>
<Trans>Change Handle</Trans>
</Text>
<View style={styles.titleRight}>
{isUpdateHandlePending ? (
<ActivityIndicator />
) : canSave ? (
<TouchableOpacity
onPress={onPressSave}
accessibilityRole="button"
accessibilityLabel={_(msg`Save handle change`)}
accessibilityHint={_(msg`Saves handle change to ${handle}`)}>
<Text type="2xl-medium" style={pal.link}>
<Trans>Save</Trans>
</Text>
</TouchableOpacity>
) : undefined}
</View>
</View>
<ScrollView style={styles.inner}>
{error !== '' && (
<View style={styles.errorContainer}>
<ErrorMessage message={error} />
</View>
)}
{isCustom ? (
<CustomHandleForm
currentAccount={currentAccount}
handle={handle}
isProcessing={isUpdateHandlePending}
canSave={canSave}
onToggleCustom={onToggleCustom}
setHandle={setHandle}
setCanSave={setCanSave}
onPressSave={onPressSave}
/>
) : (
<ProvidedHandleForm
handle={handle}
userDomain={userDomain}
isProcessing={isUpdateHandlePending}
onToggleCustom={onToggleCustom}
setHandle={setHandle}
setCanSave={setCanSave}
/>
)}
</ScrollView>
</View>
)
}
/**
* The form for using a domain allocated by the PDS
*/
function ProvidedHandleForm({
userDomain,
handle,
isProcessing,
setHandle,
onToggleCustom,
setCanSave,
}: {
userDomain: string
handle: string
isProcessing: boolean
setHandle: (v: string) => void
onToggleCustom: () => void
setCanSave: (v: boolean) => void
}) {
const pal = usePalette('default')
const theme = useTheme()
const {_} = useLingui()
// events
// =
const onChangeHandle = React.useCallback(
(v: string) => {
const newHandle = makeValidHandle(v)
setHandle(newHandle)
setCanSave(newHandle.length > 0)
},
[setHandle, setCanSave],
)
// rendering
// =
return (
<>
<View style={[pal.btn, styles.textInputWrapper]}>
<FontAwesomeIcon
icon="at"
style={[pal.textLight, styles.textInputIcon]}
/>
<TextInput
testID="setHandleInput"
style={[pal.text, styles.textInput]}
placeholder={_(msg`e.g. alice`)}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
keyboardAppearance={theme.colorScheme}
value={handle}
onChangeText={onChangeHandle}
editable={!isProcessing}
accessible={true}
accessibilityLabel={_(msg`Handle`)}
accessibilityHint={_(msg`Sets Bluesky username`)}
/>
</View>
<Text type="md" style={[pal.textLight, s.pl10, s.pt10]}>
<Trans>
Your full handle will be{' '}
<Text type="md-bold" style={pal.textLight}>
@{createFullHandle(handle, userDomain)}
</Text>
</Trans>
</Text>
<TouchableOpacity
onPress={onToggleCustom}
accessibilityRole="button"
accessibilityLabel={_(msg`Hosting provider`)}
accessibilityHint={_(msg`Opens modal for using custom domain`)}>
<Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
<Trans>I have my own domain</Trans>
</Text>
</TouchableOpacity>
</>
)
}
/**
* The form for using a custom domain
*/
function CustomHandleForm({
currentAccount,
handle,
canSave,
isProcessing,
setHandle,
onToggleCustom,
onPressSave,
setCanSave,
}: {
currentAccount: SessionAccount
handle: string
canSave: boolean
isProcessing: boolean
setHandle: (v: string) => void
onToggleCustom: () => void
onPressSave: () => void
setCanSave: (v: boolean) => void
}) {
const pal = usePalette('default')
const palSecondary = usePalette('secondary')
const palError = usePalette('error')
const theme = useTheme()
const {_} = useLingui()
const [isVerifying, setIsVerifying] = React.useState(false)
const [error, setError] = React.useState<string>('')
const [isDNSForm, setDNSForm] = React.useState<boolean>(true)
const fetchDid = useFetchDid()
// events
// =
const onPressCopy = React.useCallback(() => {
setStringAsync(isDNSForm ? `did=${currentAccount.did}` : currentAccount.did)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}, [currentAccount, isDNSForm, _])
const onChangeHandle = React.useCallback(
(v: string) => {
setHandle(v)
setCanSave(false)
},
[setHandle, setCanSave],
)
const onPressVerify = React.useCallback(async () => {
if (canSave) {
onPressSave()
}
try {
setIsVerifying(true)
setError('')
const did = await fetchDid(handle)
if (did === currentAccount.did) {
setCanSave(true)
} else {
setError(`Incorrect DID returned (got ${did})`)
}
} catch (err: any) {
setError(cleanError(err))
logger.error('Failed to verify domain', {handle, error: err})
} finally {
setIsVerifying(false)
}
}, [
handle,
currentAccount,
setIsVerifying,
setCanSave,
setError,
canSave,
onPressSave,
fetchDid,
])
// rendering
// =
return (
<>
<Text type="md" style={[pal.text, s.pb5, s.pl5]} nativeID="customDomain">
<Trans>Enter the domain you want to use</Trans>
</Text>
<View style={[pal.btn, styles.textInputWrapper]}>
<FontAwesomeIcon
icon="at"
style={[pal.textLight, styles.textInputIcon]}
/>
<TextInput
testID="setHandleInput"
style={[pal.text, styles.textInput]}
placeholder={_(msg`e.g. alice.com`)}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
keyboardAppearance={theme.colorScheme}
value={handle}
onChangeText={onChangeHandle}
editable={!isProcessing}
accessibilityLabelledBy="customDomain"
accessibilityLabel={_(msg`Custom domain`)}
accessibilityHint={_(msg`Input your preferred hosting provider`)}
/>
</View>
<View style={styles.spacer} />
<View style={[styles.selectableBtns]}>
<SelectableBtn
selected={isDNSForm}
label={_(msg`DNS Panel`)}
left
onSelect={() => setDNSForm(true)}
accessibilityHint={_(msg`Use the DNS panel`)}
style={s.flex1}
/>
<SelectableBtn
selected={!isDNSForm}
label={_(msg`No DNS Panel`)}
right
onSelect={() => setDNSForm(false)}
accessibilityHint={_(msg`Use a file on your server`)}
style={s.flex1}
/>
</View>
<View style={styles.spacer} />
{isDNSForm ? (
<>
<Text type="md" style={[pal.text, s.pb5, s.pl5]}>
<Trans>Add the following DNS record to your domain:</Trans>
</Text>
<View style={[styles.dnsTable, pal.btn]}>
<Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
<Trans>Host:</Trans>
</Text>
<View style={[styles.dnsValue]}>
<Text type="mono" style={[styles.monoText, pal.text]}>
_atproto
</Text>
</View>
<Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
<Trans>Type:</Trans>
</Text>
<View style={[styles.dnsValue]}>
<Text type="mono" style={[styles.monoText, pal.text]}>
TXT
</Text>
</View>
<Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
<Trans>Value:</Trans>
</Text>
<View style={[styles.dnsValue]}>
<Text type="mono" style={[styles.monoText, pal.text]}>
did={currentAccount.did}
</Text>
</View>
</View>
<Text type="md" style={[pal.text, s.pt20, s.pl5]}>
<Trans>This should create a domain record at:</Trans>
</Text>
<Text type="mono" style={[styles.monoText, pal.text, s.pt5, s.pl5]}>
_atproto.{handle}
</Text>
</>
) : (
<>
<Text type="md" style={[pal.text, s.pb5, s.pl5]}>
<Trans>Upload a text file to:</Trans>
</Text>
<View style={[styles.valueContainer, pal.btn]}>
<View style={[styles.dnsValue]}>
<Text type="mono" style={[styles.monoText, pal.text]}>
https://{handle}/.well-known/atproto-did
</Text>
</View>
</View>
<View style={styles.spacer} />
<Text type="md" style={[pal.text, s.pb5, s.pl5]}>
<Trans>That contains the following:</Trans>
</Text>
<View style={[styles.valueContainer, pal.btn]}>
<View style={[styles.dnsValue]}>
<Text type="mono" style={[styles.monoText, pal.text]}>
{currentAccount.did}
</Text>
</View>
</View>
</>
)}
<View style={styles.spacer} />
<Button type="default" style={[s.p20, s.mb10]} onPress={onPressCopy}>
<Text type="xl" style={[pal.link, s.textCenter]}>
<Trans>
Copy {isDNSForm ? _(msg`Domain Value`) : _(msg`File Contents`)}
</Trans>
</Text>
</Button>
{canSave === true && (
<View style={[styles.message, palSecondary.view]}>
<Text type="md-medium" style={palSecondary.text}>
<Trans>Domain verified!</Trans>
</Text>
</View>
)}
{error ? (
<View style={[styles.message, palError.view]}>
<Text type="md-medium" style={palError.text}>
{error}
</Text>
</View>
) : null}
<Button
type="primary"
style={[s.p20, isVerifying && styles.dimmed]}
onPress={onPressVerify}>
{isVerifying ? (
<ActivityIndicator color="white" />
) : (
<Text type="xl-medium" style={[s.white, s.textCenter]}>
{canSave
? _(msg`Update to ${handle}`)
: isDNSForm
? _(msg`Verify DNS Record`)
: _(msg`Verify Text File`)}
</Text>
)}
</Button>
<View style={styles.spacer} />
<TouchableOpacity
onPress={onToggleCustom}
accessibilityLabel={_(msg`Use default provider`)}
accessibilityHint={_(msg`Use bsky.social as hosting provider`)}>
<Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
<Trans>Nevermind, create a handle for me</Trans>
</Text>
</TouchableOpacity>
</>
)
}
const styles = StyleSheet.create({
inner: {
padding: 14,
},
footer: {
padding: 14,
},
spacer: {
height: 20,
},
dimmed: {
opacity: 0.7,
},
selectableBtns: {
flexDirection: 'row',
},
title: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 25,
paddingHorizontal: 20,
paddingBottom: 15,
borderBottomWidth: 1,
},
titleLeft: {
width: 80,
},
titleRight: {
width: 80,
flexDirection: 'row',
justifyContent: 'flex-end',
},
titleMiddle: {
flex: 1,
textAlign: 'center',
fontSize: 21,
},
textInputWrapper: {
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
},
textInputIcon: {
marginLeft: 12,
},
textInput: {
flex: 1,
width: '100%',
paddingVertical: 10,
paddingHorizontal: 8,
fontSize: 17,
letterSpacing: 0.25,
fontWeight: '400',
borderRadius: 10,
},
valueContainer: {
borderRadius: 4,
paddingVertical: 16,
},
dnsTable: {
borderRadius: 4,
paddingTop: 2,
paddingBottom: 16,
},
dnsLabel: {
paddingHorizontal: 14,
paddingTop: 10,
},
dnsValue: {
paddingHorizontal: 14,
borderRadius: 4,
},
monoText: {
fontSize: 18,
lineHeight: 20,
},
message: {
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 8,
marginBottom: 10,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 10,
marginBottom: 10,
},
errorContainer: {marginBottom: 10},
})
+8
View File
@@ -7,7 +7,9 @@ import {usePalette} from '#/lib/hooks/usePalette'
import {useModalControls, useModals} from '#/state/modals'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
import * as AddAppPassword from './AddAppPasswords'
import * as ChangeEmailModal from './ChangeEmail'
import * as ChangeHandleModal from './ChangeHandle'
import * as ChangePasswordModal from './ChangePassword'
import * as CreateOrEditListModal from './CreateOrEditList'
import * as DeleteAccountModal from './DeleteAccount'
@@ -67,9 +69,15 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'delete-account') {
snapPoints = DeleteAccountModal.snapPoints
element = <DeleteAccountModal.Component />
} else if (activeModal?.name === 'change-handle') {
snapPoints = ChangeHandleModal.snapPoints
element = <ChangeHandleModal.Component {...activeModal} />
} else if (activeModal?.name === 'invite-codes') {
snapPoints = InviteCodesModal.snapPoints
element = <InviteCodesModal.Component />
} else if (activeModal?.name === 'add-app-password') {
snapPoints = AddAppPassword.snapPoints
element = <AddAppPassword.Component />
} else if (activeModal?.name === 'content-languages-settings') {
snapPoints = ContentLanguagesSettingsModal.snapPoints
element = <ContentLanguagesSettingsModal.Component />
+6
View File
@@ -7,7 +7,9 @@ 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 * as AddAppPassword from './AddAppPasswords'
import * as ChangeEmailModal from './ChangeEmail'
import * as ChangeHandleModal from './ChangeHandle'
import * as ChangePasswordModal from './ChangePassword'
import * as CreateOrEditListModal from './CreateOrEditList'
import * as CropImageModal from './CropImage.web'
@@ -72,8 +74,12 @@ function Modal({modal}: {modal: ModalIface}) {
element = <CropImageModal.Component {...modal} />
} else if (modal.name === 'delete-account') {
element = <DeleteAccountModal.Component />
} else if (modal.name === 'change-handle') {
element = <ChangeHandleModal.Component {...modal} />
} else if (modal.name === 'invite-codes') {
element = <InviteCodesModal.Component />
} else if (modal.name === 'add-app-password') {
element = <AddAppPassword.Component />
} else if (modal.name === 'content-languages-settings') {
element = <ContentLanguagesSettingsModal.Component />
} else if (modal.name === 'post-languages-settings') {
+4 -9
View File
@@ -451,9 +451,8 @@ let FeedItem = ({
<TimeElapsed timestamp={item.notification.indexedAt}>
{({timeElapsed}) => (
<>
{/* make sure there's whitespace around the middot -sfn */}
<Text style={[pal.textLight]}> &middot; </Text>
<Text style={[pal.textLight]} title={niceTimestamp}>
<Text style={[a.ml_xs, pal.textLight]}>&middot;</Text>
<Text style={[a.ml_xs, pal.textLight]} title={niceTimestamp}>
{timeElapsed}
</Text>
</>
@@ -467,12 +466,7 @@ let FeedItem = ({
{item.type === 'feedgen-like' && item.subjectUri ? (
<FeedSourceCard
feedUri={item.subjectUri}
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
a.border,
styles.feedcard,
]}
style={[pal.view, pal.border, styles.feedcard]}
showLikes
/>
) : null}
@@ -783,6 +777,7 @@ const styles = StyleSheet.create({
opacity: 0.8,
},
feedcard: {
borderWidth: 1,
borderRadius: 8,
paddingVertical: 12,
marginTop: 6,

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