580 lines
22 KiB
TypeScript
580 lines
22 KiB
TypeScript
import * as React from 'react';
|
|
import type { TextLayoutEvent } from 'react-native';
|
|
import {
|
|
Dimensions,
|
|
Image,
|
|
Platform,
|
|
StyleSheet,
|
|
Text,
|
|
Modal,
|
|
View,
|
|
TouchableOpacity,
|
|
TouchableWithoutFeedback
|
|
} from 'react-native';
|
|
import { ButtonGroup } from '@rneui/themed';
|
|
|
|
import QRCode, { QRCodeProps } from 'react-native-qrcode-svg';
|
|
|
|
import QRSpeedMeterButton from './QRSpeedButton';
|
|
import Amount from './Amount';
|
|
import Button from './Button';
|
|
import CopyButton from './CopyButton';
|
|
import ShareButton from './ShareButton';
|
|
import NFCButton from './NFCButton';
|
|
import { localeString } from './../utils/LocaleUtils';
|
|
import { themeColor } from './../utils/ThemeUtils';
|
|
import Touchable from './Touchable';
|
|
import Conversion from './Conversion';
|
|
import { QRAnimationSpeed } from '../utils/QRAnimationUtils';
|
|
|
|
import Turtle from '../assets/images/SVG/Turtle.svg';
|
|
import Rabbit from '../assets/images/SVG/Rabbit.svg';
|
|
import Gazelle from '../assets/images/SVG/Gazelle.svg';
|
|
|
|
const defaultLogo = require('../assets/images/icon_black.png');
|
|
const defaultLogoWhite = require('../assets/images/icon_white.png');
|
|
|
|
type QRCodeElement = React.ElementRef<typeof QRCode>;
|
|
|
|
interface ExtendedQRCodeProps
|
|
extends QRCodeProps,
|
|
React.RefAttributes<QRCodeElement> {
|
|
onLoad?: () => void;
|
|
parent?: CollapsedQR;
|
|
}
|
|
|
|
interface ValueTextProps {
|
|
value: string;
|
|
truncateLongValue?: boolean;
|
|
valueStyle?: any;
|
|
}
|
|
|
|
// Custom QR code component that forwards refs and handles component readiness
|
|
// Sets qrReady state on first valid component mount to prevent remounting cycles
|
|
const ForwardedQRCode = React.forwardRef<QRCodeElement, ExtendedQRCodeProps>(
|
|
(props, ref) => (
|
|
<QRCode
|
|
{...props}
|
|
getRef={(c) => {
|
|
if (c && c.toDataURL && !(ref as any).current) {
|
|
(ref as any).current = c;
|
|
Image.getSize(
|
|
Image.resolveAssetSource(defaultLogo).uri,
|
|
() => {
|
|
props.parent?.setState({ qrReady: true });
|
|
}
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
) as React.FC<ExtendedQRCodeProps>;
|
|
|
|
function ValueText({ value, truncateLongValue, valueStyle }: ValueTextProps) {
|
|
const [state, setState] = React.useState<{
|
|
numberOfValueLines: number | undefined;
|
|
}>({ numberOfValueLines: truncateLongValue ? 3 : undefined });
|
|
const [isSingleLine, setIsSingleLine] = React.useState(false);
|
|
|
|
const computedStyle = {
|
|
...styles.value,
|
|
...valueStyle,
|
|
color: themeColor('secondaryText'),
|
|
textAlign: isSingleLine ? ('center' as const) : ('left' as const)
|
|
};
|
|
|
|
const onTextLayout = (e: TextLayoutEvent) =>
|
|
setIsSingleLine(e.nativeEvent.lines.length === 1);
|
|
|
|
return truncateLongValue ? (
|
|
<Touchable
|
|
touch={() =>
|
|
setState({
|
|
numberOfValueLines: state.numberOfValueLines ? undefined : 3
|
|
})
|
|
}
|
|
highlight={false}
|
|
>
|
|
<Text
|
|
style={computedStyle}
|
|
numberOfLines={state.numberOfValueLines}
|
|
onTextLayout={onTextLayout}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</Touchable>
|
|
) : (
|
|
<Text style={computedStyle} onTextLayout={onTextLayout}>
|
|
{value}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
interface CollapsedQRProps {
|
|
value: string;
|
|
valueStyle?: any;
|
|
showText?: string;
|
|
collapseText?: string;
|
|
copyText?: string;
|
|
copyValue?: string;
|
|
iconContainerStyle?: any;
|
|
showSpeed?: boolean;
|
|
iconOnly?: boolean;
|
|
hideText?: boolean;
|
|
expanded?: boolean;
|
|
textBottom?: boolean;
|
|
truncateLongValue?: boolean;
|
|
logo?: any;
|
|
nfcSupported?: boolean;
|
|
satAmount?: string | number;
|
|
displayAmount?: boolean;
|
|
labelBottom?: string;
|
|
qrAnimationSpeed?: QRAnimationSpeed;
|
|
onQRAnimationSpeedChange?: (speed: QRAnimationSpeed) => void;
|
|
onShareGiftLink?: () => void;
|
|
}
|
|
|
|
interface CollapsedQRState {
|
|
collapsed: boolean;
|
|
enlargeQR: boolean;
|
|
tempQRRef: React.RefObject<QRCodeElement | null> | null;
|
|
qrReady: boolean;
|
|
showSpeedOptions: boolean;
|
|
}
|
|
|
|
export default class CollapsedQR extends React.Component<
|
|
CollapsedQRProps,
|
|
CollapsedQRState
|
|
> {
|
|
qrRef = React.createRef<QRCodeElement>();
|
|
|
|
state = {
|
|
collapsed: this.props.expanded ? false : true,
|
|
enlargeQR: false,
|
|
tempQRRef: null,
|
|
qrReady: false,
|
|
qrAnimationSpeed: this.props.qrAnimationSpeed,
|
|
showSpeedOptions: false
|
|
};
|
|
|
|
toggleCollapse = () => {
|
|
this.setState({
|
|
collapsed: !this.state.collapsed
|
|
});
|
|
};
|
|
|
|
handleQRCodeTap = () => {
|
|
this.setState({ enlargeQR: !this.state.enlargeQR });
|
|
};
|
|
|
|
render() {
|
|
const { collapsed, enlargeQR, tempQRRef, showSpeedOptions } =
|
|
this.state;
|
|
const {
|
|
value,
|
|
valueStyle,
|
|
showText,
|
|
copyText,
|
|
copyValue,
|
|
collapseText,
|
|
iconContainerStyle,
|
|
iconOnly,
|
|
hideText,
|
|
expanded,
|
|
textBottom,
|
|
truncateLongValue,
|
|
logo,
|
|
satAmount,
|
|
labelBottom,
|
|
qrAnimationSpeed,
|
|
onQRAnimationSpeedChange,
|
|
showSpeed,
|
|
onShareGiftLink
|
|
} = this.props;
|
|
|
|
const { width, height } = Dimensions.get('window');
|
|
const qrSize = height > width ? width * 0.75 : height * 0.6;
|
|
|
|
const QR_ANIMATION_SPEED_OPTIONS = [
|
|
{
|
|
value: 'slow',
|
|
icon: Turtle
|
|
},
|
|
{
|
|
value: 'medium',
|
|
icon: Rabbit
|
|
},
|
|
{
|
|
value: 'fast',
|
|
icon: Gazelle
|
|
}
|
|
];
|
|
// Creates a temporary QR code for sharing and waits for component to be ready
|
|
// Returns a promise that resolves when QR is fully rendered and ready to be captured
|
|
const handleShare = () =>
|
|
new Promise<void>((resolve) => {
|
|
const tempRef = React.createRef<QRCodeElement>();
|
|
this.setState({ tempQRRef: tempRef, qrReady: false }, () => {
|
|
const checkReady = () => {
|
|
if (this.state.qrReady) {
|
|
resolve();
|
|
} else {
|
|
requestAnimationFrame(checkReady);
|
|
}
|
|
};
|
|
checkReady();
|
|
});
|
|
});
|
|
|
|
const supportsNFC =
|
|
Platform.OS === 'android' && this.props.nfcSupported;
|
|
|
|
const qrSpeedSelectedIndex = QR_ANIMATION_SPEED_OPTIONS.findIndex(
|
|
(option) => option.value === qrAnimationSpeed
|
|
);
|
|
|
|
return (
|
|
<React.Fragment>
|
|
{/* Temporary QR for sharing — positioned off-screen so Fabric
|
|
doesn't render the 800px source QR over the visible UI when
|
|
a zero-sized parent fails to clip its oversized child. */}
|
|
{tempQRRef && (
|
|
<View
|
|
style={{
|
|
position: 'absolute',
|
|
left: -10000,
|
|
top: -10000,
|
|
width: 800,
|
|
height: 800
|
|
}}
|
|
pointerEvents="none"
|
|
>
|
|
<ForwardedQRCode
|
|
ref={tempQRRef}
|
|
value={value}
|
|
size={800}
|
|
logo={defaultLogo}
|
|
backgroundColor={'white'}
|
|
logoBackgroundColor={'white'}
|
|
logoMargin={10}
|
|
quietZone={40}
|
|
parent={this}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{!hideText && !textBottom && (
|
|
<ValueText
|
|
value={value}
|
|
truncateLongValue={truncateLongValue}
|
|
valueStyle={valueStyle}
|
|
/>
|
|
)}
|
|
{!collapsed && value && (
|
|
<View>
|
|
<TouchableOpacity
|
|
style={{
|
|
...styles.qrPadding,
|
|
backgroundColor: themeColor('qr') || 'white'
|
|
}}
|
|
onPress={() => this.handleQRCodeTap()}
|
|
>
|
|
{enlargeQR && (
|
|
<Modal
|
|
transparent={true}
|
|
animationType="fade"
|
|
visible={enlargeQR}
|
|
>
|
|
<TouchableWithoutFeedback
|
|
onPress={() => this.handleQRCodeTap()}
|
|
>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
justifyContent: 'center'
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
...StyleSheet.absoluteFill,
|
|
backgroundColor: 'black',
|
|
opacity: 0.6
|
|
}}
|
|
/>
|
|
<View>
|
|
<QRCode
|
|
value={value}
|
|
size={width}
|
|
logo={
|
|
logo
|
|
? logo
|
|
: themeColor(
|
|
'invertQrIcons'
|
|
)
|
|
? defaultLogoWhite
|
|
: defaultLogo
|
|
}
|
|
backgroundColor={'white'}
|
|
logoBackgroundColor={
|
|
themeColor(
|
|
'invertQrIcons'
|
|
)
|
|
? 'black'
|
|
: 'white'
|
|
}
|
|
logoMargin={10}
|
|
quietZone={width / 20}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</TouchableWithoutFeedback>
|
|
</Modal>
|
|
)}
|
|
<QRCode
|
|
value={value}
|
|
size={qrSize}
|
|
logo={
|
|
logo
|
|
? logo
|
|
: themeColor('invertQrIcons')
|
|
? defaultLogoWhite
|
|
: defaultLogo
|
|
}
|
|
color={themeColor('qr') || 'black'}
|
|
backgroundColor={
|
|
themeColor('qrBackground') || 'white'
|
|
}
|
|
logoBackgroundColor={
|
|
themeColor('qrLogoBackground') || 'white'
|
|
}
|
|
logoMargin={10}
|
|
quietZone={width / 40}
|
|
/>
|
|
</TouchableOpacity>
|
|
{satAmount != null &&
|
|
satAmount != 0 &&
|
|
this.props.displayAmount && (
|
|
<View
|
|
style={{
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
alignSelf: 'center',
|
|
backgroundColor:
|
|
themeColor('buttonBackground') ||
|
|
themeColor('secondary'),
|
|
width: qrSize,
|
|
borderBottomLeftRadius: 12,
|
|
borderBottomRightRadius: 12,
|
|
marginTop: -10,
|
|
margin: 15,
|
|
padding: 15
|
|
}}
|
|
>
|
|
<Amount
|
|
sats={satAmount}
|
|
toggleable
|
|
jumboText
|
|
colorOverride={themeColor('buttonText')}
|
|
></Amount>
|
|
<View>
|
|
<Conversion
|
|
sats={satAmount}
|
|
colorOverride={themeColor(
|
|
'buttonText'
|
|
)}
|
|
/>
|
|
</View>
|
|
</View>
|
|
)}
|
|
{labelBottom && (
|
|
<View
|
|
style={[
|
|
styles.labelBottomContainer,
|
|
{
|
|
backgroundColor:
|
|
themeColor('buttonBackground') ||
|
|
themeColor('secondary'),
|
|
width: qrSize
|
|
}
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.labelBottomText,
|
|
{ color: themeColor('buttonText') }
|
|
]}
|
|
>
|
|
{labelBottom}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
{!hideText && textBottom && (
|
|
<ValueText
|
|
value={value}
|
|
truncateLongValue={truncateLongValue}
|
|
valueStyle={valueStyle}
|
|
/>
|
|
)}
|
|
{!expanded && (
|
|
<Button
|
|
title={
|
|
collapsed
|
|
? showText ||
|
|
localeString('components.CollapsedQr.show')
|
|
: collapseText ||
|
|
localeString('components.CollapsedQr.hide')
|
|
}
|
|
icon={{
|
|
name: 'qrcode',
|
|
type: 'font-awesome',
|
|
size: 25
|
|
}}
|
|
containerStyle={{
|
|
margin: 10
|
|
}}
|
|
onPress={() => this.toggleCollapse()}
|
|
/>
|
|
)}
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
gap: showSpeed ? 8 : 40
|
|
}}
|
|
>
|
|
{showSpeed && (
|
|
<QRSpeedMeterButton
|
|
showOptions={showSpeedOptions}
|
|
onPress={() =>
|
|
this.setState({
|
|
showSpeedOptions: !showSpeedOptions
|
|
})
|
|
}
|
|
iconOnly={iconOnly}
|
|
/>
|
|
)}
|
|
<CopyButton
|
|
iconContainerStyle={
|
|
!showSpeed ? iconContainerStyle : undefined
|
|
}
|
|
copyValue={copyValue || value}
|
|
title={copyText}
|
|
iconOnly={iconOnly}
|
|
/>
|
|
<ShareButton
|
|
iconContainerStyle={
|
|
supportsNFC && !showSpeed
|
|
? iconContainerStyle
|
|
: undefined
|
|
}
|
|
value={copyValue || value}
|
|
qrRef={tempQRRef}
|
|
iconOnly={iconOnly}
|
|
onPress={handleShare}
|
|
onShareComplete={() =>
|
|
this.setState({ tempQRRef: null })
|
|
}
|
|
onShareGiftLink={onShareGiftLink}
|
|
/>
|
|
{supportsNFC && (
|
|
<NFCButton
|
|
value={copyValue || value}
|
|
iconOnly={iconOnly}
|
|
/>
|
|
)}
|
|
</View>
|
|
{showSpeedOptions && onQRAnimationSpeedChange && showSpeed && (
|
|
<View style={styles.speedOptions}>
|
|
<ButtonGroup
|
|
onPress={(index: number) => {
|
|
const selectedOption =
|
|
QR_ANIMATION_SPEED_OPTIONS[index];
|
|
onQRAnimationSpeedChange(
|
|
selectedOption.value as QRAnimationSpeed
|
|
);
|
|
}}
|
|
selectedIndex={qrSpeedSelectedIndex}
|
|
buttons={QR_ANIMATION_SPEED_OPTIONS.map(
|
|
(option, index) =>
|
|
React.createElement(option.icon, {
|
|
width: 36,
|
|
height: 36,
|
|
fill:
|
|
qrSpeedSelectedIndex === index
|
|
? themeColor('secondary')
|
|
: themeColor('secondaryText')
|
|
})
|
|
)}
|
|
selectedButtonStyle={{
|
|
backgroundColor: themeColor('highlight'),
|
|
borderRadius: 12,
|
|
...(Platform.OS === 'ios' && {
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
})
|
|
}}
|
|
buttonStyle={{
|
|
...(Platform.OS === 'ios' && {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 4
|
|
})
|
|
}}
|
|
containerStyle={{
|
|
backgroundColor: themeColor('secondary'),
|
|
borderRadius: 12,
|
|
borderColor: themeColor('secondary'),
|
|
minWidth: 240,
|
|
alignSelf: 'center',
|
|
...(Platform.OS === 'ios' && {
|
|
height: 48
|
|
})
|
|
}}
|
|
innerBorderStyle={{
|
|
color: themeColor('secondary')
|
|
}}
|
|
/>
|
|
</View>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
value: {
|
|
marginBottom: 15,
|
|
paddingHorizontal: 20,
|
|
fontFamily: 'PPNeueMontreal-Book'
|
|
},
|
|
qrPadding: {
|
|
alignItems: 'center',
|
|
alignSelf: 'center',
|
|
padding: 0,
|
|
margin: 10
|
|
},
|
|
speedOptions: {
|
|
marginTop: 10
|
|
},
|
|
labelBottomContainer: {
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
alignSelf: 'center',
|
|
borderBottomLeftRadius: 12,
|
|
borderBottomRightRadius: 12,
|
|
marginTop: -10,
|
|
margin: 15,
|
|
paddingHorizontal: 15,
|
|
paddingVertical: 18
|
|
},
|
|
labelBottomText: {
|
|
fontFamily: 'PPNeueMontreal-Medium',
|
|
fontSize: 20,
|
|
textAlign: 'center'
|
|
}
|
|
});
|