diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ed51f0..c1331375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - added: (Android) Detect if user restricted background battery usage +- changed: `SecurityAlertsScene` and `SecurityAlertsModal` redesign ## 3.23.0 (2024-11-01) diff --git a/src/common/locales/strings/enUS.json b/src/common/locales/strings/enUS.json index bb7a99e2..878c190f 100644 --- a/src/common/locales/strings/enUS.json +++ b/src/common/locales/strings/enUS.json @@ -1,6 +1,8 @@ { "account_confirmation": "Account Confirmation", "account_locked_for": "Account locked for \n%1$s more seconds", + "access_confirmation_title": "Access Confirmation", + "access_confirmation_body": "I understand that granting access to this login attempt gives this person full access to my funds and that this action is not reversible.", "alert_dropdown_alert": "Alert! ", "alert_dropdown_warning": "Warning! ", "alert_modal_action": "\nPlease log in to approve or deny this alert", @@ -15,7 +17,11 @@ "alert_scene_message": "A new device would like to log in to your account.", "alert_scene_reset_date": "Unless denied, access will be granted on: ", "alert_scene_reset_message": "Someone is trying to remove 2-factor security from your account.", - "alert_scene_warning": "If you did not request this login, your password may have been stolen and you could lose funds. Please deny the request and change your password.", + "alert_scene_message_many_new": "Several new devices would like to log in to your account.", + "alert_scene_warning_review": "If you did not request this login, your password may have been stolen and you could lose funds. Please review this request.", + "allow": "Allow", + "deny": "Deny", + "deny_all": "Deny All", "answer_case_sensitive": "Answers are case sensitive", "answers_four_chanracters": "Answers should be minimum of 4 characters", "app_logo_hint": "App logo", @@ -73,6 +79,7 @@ "great_job": "Great Job!", "hang_tight": "Hang tight while we create", "hide_account_info": "Hide account information", + "i_understand_agree": "I understand and agree", "initiate_password_recovery": "Enter Recovery Token. You can find the recovery token in an email that was sent from yourself if password recovery was setup prior to losing access.", "invalid_account": "Account does not exist", "invalid_credentials": "Invalid username or password", @@ -228,5 +235,8 @@ "welcome": "Welcome to %s!", "your_answer_label": "Your Answer", "complete_captcha_title": "Are you a human?", - "failed_captcha_error": "Incorrect solution" + "failed_captcha_error": "Incorrect solution", + "review_login_request": "Review Login Request", + "login_attempt_detected": "A login attempt has been detected for the following account(s):", + "twofa_attempt_detected": "An attempt to remove 2-factor security has been detected for the following account(s):" } diff --git a/src/components/common/SceneButtons.tsx b/src/components/common/SceneButtons.tsx index 308b4de7..02340838 100644 --- a/src/components/common/SceneButtons.tsx +++ b/src/components/common/SceneButtons.tsx @@ -5,11 +5,10 @@ import * as React from 'react' -import { ButtonsViewUi4, ButtonsViewUi4Props } from '../ui4/ButtonsViewUi4' +import { ButtonsView, ButtonsViewProps } from '../ui4/ButtonsView' -interface Props - extends Omit, 'layout'> {} +interface Props extends Omit, 'layout'> {} export const SceneButtons = (props: Props) => { - return + return } diff --git a/src/components/modals/ButtonsModal.tsx b/src/components/modals/ButtonsModal.tsx index f5d6977f..c19be998 100644 --- a/src/components/modals/ButtonsModal.tsx +++ b/src/components/modals/ButtonsModal.tsx @@ -7,7 +7,7 @@ import { showError } from '../services/AirshipInstance' import { useTheme } from '../services/ThemeContext' import { MainButton } from '../themed/MainButton' import { ModalMessage } from '../themed/ModalParts' -import { ModalUi4 } from '../ui4/ModalUi4' +import { EdgeModal } from '../ui4/EdgeModal' export interface ButtonInfo { label: string @@ -89,7 +89,7 @@ export function ButtonsModal( } return ( - ( })} - + ) } diff --git a/src/components/modals/ListModal.tsx b/src/components/modals/ListModal.tsx index 03e73414..8da96d4f 100644 --- a/src/components/modals/ListModal.tsx +++ b/src/components/modals/ListModal.tsx @@ -7,7 +7,7 @@ import { useFilter } from '../../hooks/useFilter' import { useTheme } from '../services/ThemeContext' import { FilledTextInput } from '../themed/FilledTextInput' import { ModalFooter, ModalMessage } from '../themed/ModalParts' -import { ModalUi4 } from '../ui4/ModalUi4' +import { EdgeModal } from '../ui4/EdgeModal' interface Props { bridge: AirshipBridge @@ -80,7 +80,7 @@ export function ListModal({ }, [theme]) return ( - + {message == null ? null : {message}} {!textInput ? null : ( ({ onScroll={() => Keyboard.dismiss()} onViewableItemsChanged={onViewableItemsChanged} /> - + ) } diff --git a/src/components/modals/QrCodeModal.tsx b/src/components/modals/QrCodeModal.tsx index 7812b9bd..dd9acab7 100644 --- a/src/components/modals/QrCodeModal.tsx +++ b/src/components/modals/QrCodeModal.tsx @@ -16,7 +16,7 @@ import { QrCode } from '../common/QrCode' import { showError } from '../services/AirshipInstance' import { Theme, useTheme } from '../services/ThemeContext' import { ModalMessage } from '../themed/ModalParts' -import { ModalUi4 } from '../ui4/ModalUi4' +import { EdgeModal } from '../ui4/EdgeModal' interface Props { bridge: AirshipBridge @@ -103,7 +103,7 @@ export function QrCodeModal(props: Props) { const handleCancel = useHandler(() => bridge.resolve(undefined)) return ( - )} - + ) } diff --git a/src/components/modals/RequestPermissionsModal.tsx b/src/components/modals/RequestPermissionsModal.tsx index 7902fcad..02578d9a 100644 --- a/src/components/modals/RequestPermissionsModal.tsx +++ b/src/components/modals/RequestPermissionsModal.tsx @@ -4,8 +4,8 @@ import { AirshipBridge } from 'react-native-airship' import { lstrings } from '../../common/locales/strings' import { Checkbox } from '../themed/Checkbox' import { ModalMessage } from '../themed/ModalParts' -import { ButtonsViewUi4 } from '../ui4/ButtonsViewUi4' -import { ModalUi4 } from '../ui4/ModalUi4' +import { ButtonsView } from '../ui4/ButtonsView' +import { EdgeModal } from '../ui4/EdgeModal' interface Props { bridge: AirshipBridge | undefined> @@ -47,7 +47,7 @@ export function RequestPermissionsModal(props: Props) { } return ( - {lstrings.notifications_opt_in_marketing} - handlePress('enable', true) @@ -80,6 +80,6 @@ export function RequestPermissionsModal(props: Props) { }} parentType="modal" /> - + ) } diff --git a/src/components/modals/SecurityAlertsModal.tsx b/src/components/modals/SecurityAlertsModal.tsx index 0f033f1f..4652ecbf 100644 --- a/src/components/modals/SecurityAlertsModal.tsx +++ b/src/components/modals/SecurityAlertsModal.tsx @@ -1,21 +1,19 @@ -/** - * IMPORTANT: Changes in this file MUST be duplicated in edge-react-gui! - */ import { EdgeLoginMessage } from 'edge-core-js' import * as React from 'react' -import { Text } from 'react-native' +import { View } from 'react-native' import { AirshipBridge } from 'react-native-airship' import { cacheStyles } from 'react-native-patina' -import AntDesignIcon from 'react-native-vector-icons/AntDesign' -import FontAwesome from 'react-native-vector-icons/FontAwesome' -import { sprintf } from 'sprintf-js' +import FontAwesome5 from 'react-native-vector-icons/FontAwesome5' +import Ionicons from 'react-native-vector-icons/Ionicons' import { lstrings } from '../../common/locales/strings' import { useHandler } from '../../hooks/useHandler' -import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { Theme, useTheme } from '../services/ThemeContext' -import { ModalScrollArea, ModalTitle } from '../themed/ModalParts' -import { ModalUi4 } from '../ui4/ModalUi4' +import { EdgeText, Paragraph } from '../themed/EdgeText' +import { ModalTitle } from '../themed/ModalParts' +import { EdgeCard } from '../ui4/EdgeCard' +import { EdgeModal } from '../ui4/EdgeModal' +import { SectionView } from '../ui4/SectionView' interface Props { bridge: AirshipBridge @@ -25,97 +23,99 @@ interface Props { export const SecurityAlertsModal = (props: Props) => { const { bridge, messages } = props const theme = useTheme() + const styles = getStyles(theme) + + const otpResetNames = messages + .filter(message => message.otpResetPending && message.username != null) + .map(message => message.username) + const voucherMessageNames = messages + .filter( + message => message.pendingVouchers.length > 0 && message.username != null + ) + .map(message => message.username) const handleCancel = useHandler(() => bridge.resolve(undefined)) - const renderList = () => { - const out: React.ReactNode[] = [] + const renderRow = (username: string, index: number) => { + return ( + bridge.resolve(username)} + > + + {username} + + + + ) + } - let isFirst = true - for (const message of messages) { - const { otpResetPending, username } = message - if (otpResetPending && username != null) { - out.push(renderRow(username, true, isFirst)) - isFirst = false - } - } - for (const message of messages) { - const { pendingVouchers = [], username } = message - if (pendingVouchers.length > 0 && username != null) { - out.push(renderRow(username, false, isFirst)) - isFirst = false - } - } + const renderLoginRequests = () => { + if (voucherMessageNames.length === 0) return null - return out + return ( + <> + {lstrings.login_attempt_detected} + {voucherMessageNames.map((name, index) => renderRow(name ?? '', index))} + + ) } - const renderRow = (username: string, isReset: boolean, isFirst: boolean) => { - const styles = getStyles(theme) + // TODO: Pending removal after server endpoint removal + const renderOtpResetRequests = () => { + if (otpResetNames.length === 0) return null return ( - bridge.resolve(username)} - > - - - - {isReset - ? sprintf(lstrings.alert_modal_reset_s, username) - : sprintf(lstrings.alert_modal_voucher_s, username)} - - {lstrings.alert_modal_action} - - - + <> + {lstrings.twofa_attempt_detected} + {otpResetNames.map((name, index) => renderRow(name ?? '', index))} + ) } return ( - - {lstrings.security_is_our_priority_modal_title} - {renderList()} - + + } + > + {lstrings.review_login_request} + + } + onCancel={handleCancel} + > + + {renderLoginRequests()} + {renderOtpResetRequests()} + + ) } const getStyles = cacheStyles((theme: Theme) => ({ - row: { - alignItems: 'center', - flexDirection: 'row' - }, - rowBorder: { - alignItems: 'center', - borderTopColor: theme.lineDivider, - borderTopWidth: theme.thinLineWidth, + cardContainer: { + width: '100%', flexDirection: 'row', - marginTop: theme.rem(0.5), - paddingTop: theme.rem(0.5) - }, - rowIcon: { - margin: theme.rem(0.5) - }, - rowText: { - color: theme.primaryText, - flexGrow: 1, - flexShrink: 1, - fontFamily: theme.fontFaceDefault, - fontSize: theme.rem(1), + alignItems: 'center', + justifyContent: 'space-between', margin: theme.rem(0.5) }, - bold: { - fontWeight: 'bold' + chevron: { + alignSelf: 'center', + flexShrink: 0, + marginHorizontal: theme.rem(1) } })) diff --git a/src/components/modals/TextInputModal.tsx b/src/components/modals/TextInputModal.tsx index 7e3490c0..2d1905d2 100644 --- a/src/components/modals/TextInputModal.tsx +++ b/src/components/modals/TextInputModal.tsx @@ -10,8 +10,8 @@ import { showError } from '../services/AirshipInstance' import { FilledTextInput } from '../themed/FilledTextInput' import { MainButton } from '../themed/MainButton' import { ModalMessage, ModalTitle } from '../themed/ModalParts' -import { AlertCardUi4 } from '../ui4/AlertUi4' -import { ModalUi4 } from '../ui4/ModalUi4' +import { AlertCard } from '../ui4/AlertCard' +import { EdgeModal } from '../ui4/EdgeModal' interface Props { // Resolves to the entered string, or void if cancelled. @@ -100,7 +100,7 @@ export function TextInputModal(props: Props) { } return ( - bridge.resolve(undefined)} @@ -112,7 +112,7 @@ export function TextInputModal(props: Props) { <>{message} )} {warningMessage != null ? ( - )} - + ) } diff --git a/src/components/scenes/LandingScene.tsx b/src/components/scenes/LandingScene.tsx index c599c5fb..f0e05561 100644 --- a/src/components/scenes/LandingScene.tsx +++ b/src/components/scenes/LandingScene.tsx @@ -11,7 +11,7 @@ import { SceneProps } from '../../types/routerTypes' import { scale } from '../../util/scaling' import { LogoImageHeader } from '../abSpecific/LogoImageHeader' import { ThemedScene } from '../themed/ThemedScene' -import { ButtonsViewUi4 } from '../ui4/ButtonsViewUi4' +import { ButtonsView } from '../ui4/ButtonsView' interface Props extends SceneProps<'landing'> { branding: Branding @@ -56,7 +56,7 @@ export const LandingScene = (props: Props) => { - { return ( - { + if (__DEV__) { + return ( + + ) + } else { + return pendingVouchers.length <= 1 ? null : ( + + ) + } + } + return ( - + ( - + )} > {count > 1 - ? lstrings.alert_scene_message_many + ? lstrings.alert_scene_message_many_new : lstrings.alert_scene_message} - - {lstrings.alert_scene_warning} - + {this.renderVouchers()} {this.renderReset()} - {showSkip ? ( - null} - /> - ) : null} - + {renderButtons()} ) } + // TODO: Pending removal after server endpoint removal renderReset(): React.ReactNode { const { theme } = this.props const { otpResetDate, spinReset } = this.state @@ -143,9 +178,6 @@ export class SecurityAlertsSceneComponent extends React.Component< ( - - )} /> )} @@ -158,32 +190,52 @@ export class SecurityAlertsSceneComponent extends React.Component< const styles = getStyles(theme) return pendingVouchers.map(voucher => ( - - - {voucher.deviceDescription != null - ? lstrings.alert_scene_device + voucher.deviceDescription + '\n' - : null} - {lstrings.alert_scene_ip + voucher.ipDescription + '\n'} - {lstrings.alert_scene_date + toLocalTime(voucher.created)} - - - {lstrings.alert_scene_reset_date + toLocalTime(voucher.activates)} - + + + {voucher.deviceDescription != null ? ( + + {lstrings.alert_scene_device + voucher.deviceDescription} + + ) : null} + {lstrings.alert_scene_ip + voucher.ipDescription} + + {lstrings.alert_scene_date + toLocalTime(voucher.created)} + + + + + + + {lstrings.alert_scene_reset_date + toLocalTime(voucher.activates)} + + + {spinVoucher[voucher.voucherId] ? ( ) : ( - this.handleApproveVoucher(voucher.voucherId)} - renderIcon={theme => ( - - )} - /> + // TODO: Codify this into a ButtonView variant if we need this + // combination elsewhere. Hard-coded because it's assumed to be a + // weird one-off situation. + + + + + )} - + )) } @@ -196,30 +248,52 @@ export class SecurityAlertsSceneComponent extends React.Component< .catch(error => showError(error)) .then(() => this.setState({ - showSkip: true, spinReset: false }) ) } - handleApproveVoucher = (voucherId: string) => { + handleDeny = (pendingVoucher: EdgePendingVoucher) => async () => { const { account } = this.props - this.setState(state => ({ - spinVoucher: { ...state.spinVoucher, [voucherId]: true } - })) + this.setState({ needsResecure: true }) - const { approveVoucher = nopVoucher } = account - approveVoucher(voucherId) - .catch(error => showError(error)) - .then(() => + const { rejectVoucher = nopVoucher } = account + + await rejectVoucher(pendingVoucher.voucherId) + } + + handleApproveVoucher = (pendingVoucher: EdgePendingVoucher) => async () => { + const { account } = this.props + + const modalResult = await Airship.show<'allow' | 'deny' | 'dismiss'>( + (bridge: AirshipBridge<'allow' | 'deny' | 'dismiss'>) => ( + + ) + ) + + if (modalResult === 'allow') { + this.setState(state => ({ + spinVoucher: { ...state.spinVoucher, [pendingVoucher.voucherId]: true } + })) + + const { approveVoucher = nopVoucher } = account + try { + await approveVoucher(pendingVoucher.voucherId) this.setState(state => ({ - showSkip: true, - spinVoucher: { ...state.spinVoucher, [voucherId]: false } + spinVoucher: { + ...state.spinVoucher, + [pendingVoucher.voucherId]: false + } })) - ) + } catch (error) { + showError(error) + } + } else if (modalResult === 'deny') { + this.handleDeny(pendingVoucher) + } } - handleDeny = async () => { + handleDenyAll = async () => { const { account } = this.props const { otpResetDate, pendingVouchers } = this.state this.setState({ needsResecure: true }) @@ -232,11 +306,7 @@ export class SecurityAlertsSceneComponent extends React.Component< promises.push(account.cancelOtpReset()) } - try { - await Promise.all(promises) - } finally { - this.setState({ showSkip: true }) - } + await Promise.all(promises) } handleSkip = () => { @@ -255,11 +325,134 @@ export class SecurityAlertsSceneComponent extends React.Component< } } +const ApproveVoucherModal = (props: { + bridge: AirshipBridge<'allow' | 'deny' | 'dismiss'> +}) => { + const { bridge } = props + const [isAgreed, setIsAgreed] = React.useState(false) + const theme = useTheme() + const styles = getStyles(theme) + + return ( + + } + > + {lstrings.access_confirmation_title} + + } + scroll + onCancel={() => bridge.resolve('dismiss')} + > + {lstrings.access_confirmation_body} + setIsAgreed(!isAgreed)}> + + + {lstrings.i_understand_agree} + + + {isAgreed && ( + + )} + + + + + bridge.resolve('allow')} + > + + + {lstrings.allow} + + + + + bridge.resolve('deny')} + /> + + + ) +} + async function nopVoucher(voucherId: string): Promise { return await Promise.resolve() } const getStyles = cacheStyles((theme: Theme) => ({ + allowButton: { + flexDirection: 'row' + }, + allowButtonText: { + marginHorizontal: theme.rem(0.5) + }, + checkBoxContainer: { + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: theme.rem(0.5), + marginTop: theme.rem(1), + padding: theme.rem(1), + borderWidth: theme.thinLineWidth, + borderColor: theme.primaryText, + borderRadius: theme.cardBorderRadius + }, + checkCircleContainer: { + justifyContent: 'center', + alignItems: 'center', + width: theme.rem(1.25), + height: theme.rem(1.25), + borderWidth: theme.thinLineWidth, + borderColor: theme.icon, + borderRadius: theme.rem(0.75) + }, + checkCircleContainerAgreed: { + borderColor: theme.iconTappable + }, + checkboxText: { + flex: 1, + fontFamily: theme.fontFaceDefault, + fontSize: theme.rem(0.75) + }, + buttonContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + flexGrow: 1, + flexShrink: 1, + margin: theme.rem(0.5) + }, tile: { backgroundColor: theme.tileBackground, borderRadius: theme.rem(0.5), @@ -270,6 +463,9 @@ const getStyles = cacheStyles((theme: Theme) => ({ alignSelf: 'flex-end', height: theme.rem(1.5), margin: theme.rem(0.5) + }, + textBlock: { + margin: theme.rem(0.5) } })) diff --git a/src/components/themed/EdgeText.tsx b/src/components/themed/EdgeText.tsx index 8a5c2462..826a3b6d 100644 --- a/src/components/themed/EdgeText.tsx +++ b/src/components/themed/EdgeText.tsx @@ -3,65 +3,156 @@ */ import * as React from 'react' -import { Platform, Text, TextProps, TextStyle } from 'react-native' +import { Platform, StyleProp, Text, TextProps, TextStyle } from 'react-native' -import { - cacheStyles, - Theme, - ThemeProps, - withTheme -} from '../services/ThemeContext' +import { fixSides, mapSides, sidesToMargin } from '../../util/sides' +import { cacheStyles, Theme, useTheme } from '../services/ThemeContext' -interface OwnProps { +export const androidAdjustTextStyle = (theme: Theme) => { + const styles = getStyles(theme) + return Platform.OS === 'android' ? styles.androidAdjust : null +} + +// #region Spacing & Alignment ================================================= + +/** A properly spaced block of text with default font color and size. Children + * that are wrapped in other `___Text` component types will override those defaults. + * + * A `Paragraph` *can* have `marginRem`, but *only* to avoid an extra `View` for + * spacing out `Paragraph(s)` in relation to their parent, *NOT* to give special + * spacing *between* `Paragraphs` */ +export const Paragraph = (props: { + children: React.ReactNode + + center?: boolean + /** @deprecated A `Paragraph` *can* have `marginRem`, but *only* to avoid an extra `View` for spacing out `Paragraph(s)` in relation to their parent, *NOT* to give special spacing *between* `Paragraphs`. It's still preferable to have the parents deal with spacing outside of `Paragraphs`. */ + marginRem?: number[] | number +}) => { + const { center = false, children, marginRem } = props + const theme = useTheme() + const styles = getStyles(theme) + const margin = sidesToMargin(mapSides(fixSides(marginRem, 0.5), theme.rem)) + + return ( + + {children} + + ) +} + +// #endregion Spacing & Alignment + +// #region Typography ========================================================== + +interface LabelProps extends TextProps { children: React.ReactNode ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip' numberOfLines?: number - style?: TextStyle disableFontScaling?: boolean minimumFontScale?: number + + /** @deprecated Use or create an appropriate `___Text` component instead */ + style?: StyleProp } -export class EdgeTextComponent extends React.PureComponent< - OwnProps & ThemeProps & TextProps -> { - render() { - const { - children, - style, - theme, - disableFontScaling = false, - ...props - } = this.props - const { text, androidAdjust } = getStyles(theme) - let { numberOfLines = 1 } = this.props - if (typeof children === 'string' && children.includes('\n')) { - numberOfLines = numberOfLines + (children.match(/\n/g) || []).length - } - - return ( - - {children} - - ) +// TODO: Rename to LabelText +export const EdgeText = (props: LabelProps) => { + const { children, style, disableFontScaling = false, ...rest } = props + const theme = useTheme() + const styles = getStyles(theme) + + let { numberOfLines = 1 } = props + if (typeof children === 'string' && children.includes('\n')) { + numberOfLines = numberOfLines + (children.match(/\n/g) ?? []).length } + + return ( + + {children} + + ) } +/** Makes the contents of an `EdgeText` or `Paragraph` smaller (0.75rem). + * Unless used within a `Paragraph` block, provides no outer spacing. */ +export const SmallText = (props: { children: React.ReactNode }) => { + const { children } = props + const theme = useTheme() + const styles = getStyles(theme) + + return ( + + {children} + + ) +} + +/** Makes the contents of an `EdgeText` or `Paragraph` orange, for warnings. + * Unless used within a `Paragraph` block, provides no outer spacing. */ +export const WarningText = (props: { children: React.ReactNode }) => { + const { children } = props + const theme = useTheme() + const styles = getStyles(theme) + + return ( + + {children} + + ) +} + +/** Makes the contents of an `EdgeText` or `Paragraph` large (1.5rem). + * Unless used within a `Paragraph` block, provides no outer spacing. */ +export const HeaderText = (props: { children: React.ReactNode }) => { + const { children } = props + const theme = useTheme() + const styles = getStyles(theme) + + return ( + + {children} + + ) +} + +// #endregion Typography + const getStyles = cacheStyles((theme: Theme) => ({ - text: { + androidAdjust: { + top: -1 + }, + common: { color: theme.primaryText, fontFamily: theme.fontFaceDefault, fontSize: theme.rem(1), includeFontPadding: false }, - androidAdjust: { - top: -1 + + colorWarning: { + color: theme.warningText + }, + sizeSmall: { + fontSize: theme.rem(0.75) + }, + sizeHeader: { + fontSize: theme.rem(1.5) + }, + alignCenter: { + textAlign: 'center' } })) - -export const EdgeText = withTheme(EdgeTextComponent) diff --git a/src/components/themed/MainButton.tsx b/src/components/themed/MainButton.tsx index a668c7a2..680fcd37 100644 --- a/src/components/themed/MainButton.tsx +++ b/src/components/themed/MainButton.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { ButtonUi4 } from '../ui4/ButtonUi4' +import { EdgeButton } from '../ui4/EdgeButton' export type MainButtonType = 'primary' | 'secondary' | 'escape' @@ -22,52 +22,44 @@ interface Props { // using the same logic as the web `margin` property. Defaults to 0. marginRem?: number[] | number - // The gap inside the button. Takes 0-4 numbers (top, right, bottom, left), - // using the same logic as the web `padding` property. Defaults to 0.5. - paddingRem?: number[] | number - // True to show a spinner after the contents: spinner?: boolean // Which visual style to use. Defaults to primary (solid): type?: MainButtonType - // From ButtonUi4 + // From EdgeButton layout?: 'row' | 'column' | 'solo' - - testID?: string } /** - * A stand-alone button to perform the primary action in a modal or scene. + * @deprecated + * Use EdgeButton instead, and consider whether there is a genuine need for + * special margins in MainButton use cases from a UI4 design perspective. */ export function MainButton(props: Props) { const { children, disabled = false, label, - marginRem = [0, 1, 0, 1], + marginRem, onPress, type = 'primary', - paddingRem, layout, - spinner = false, - testID + spinner = false } = props return ( - {children} - + ) } diff --git a/src/components/ui4/AlertUi4.tsx b/src/components/ui4/AlertCard.tsx similarity index 80% rename from src/components/ui4/AlertUi4.tsx rename to src/components/ui4/AlertCard.tsx index 8339f2d7..83c7f733 100644 --- a/src/components/ui4/AlertUi4.tsx +++ b/src/components/ui4/AlertCard.tsx @@ -4,16 +4,15 @@ import * as React from 'react' import { View } from 'react-native' -import { cacheStyles } from 'react-native-patina' import IonIcon from 'react-native-vector-icons/Ionicons' -import { Theme, useTheme } from '../services/ThemeContext' +import { cacheStyles, Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from '../themed/EdgeText' -import { CardUi4 } from './CardUi4' +import { EdgeCard } from './EdgeCard' interface Props { body?: string[] | string // Bullet point messages if an array is provided - title: string + title?: string type: 'error' | 'warning' footer?: string header?: string @@ -41,7 +40,7 @@ interface Props { * | This is the footer text | * |___________________________| */ -export function AlertCardUi4(props: Props) { +export function AlertCard(props: Props) { const { title, type, header, body, footer, marginRem, onPress } = props const theme = useTheme() const styles = getStyles(theme) @@ -58,7 +57,7 @@ export function AlertCardUi4(props: Props) { } return ( - - - - - {title} - - + {title == null ? null : ( + + + + {title} + + + )} {header == null ? null : ( {header} @@ -98,7 +99,7 @@ export function AlertCardUi4(props: Props) { )} - + ) } @@ -108,21 +109,21 @@ const getStyles = (theme: Theme) => margin: theme.rem(0.5) }, titleContainer: { - display: 'flex', flexDirection: 'row', alignItems: 'center' }, titleText: { marginLeft: theme.rem(0.2), - fontFamily: theme.fontFaceMedium + fontFamily: theme.fontFaceMedium, + flexShrink: 1, + marginBottom: theme.rem(0.5) }, icon: { marginRight: theme.rem(0.2) }, text: { fontSize: theme.rem(0.75), - marginHorizontal: theme.rem(0.25), - marginTop: theme.rem(0.5) + marginHorizontal: theme.rem(0.25) }, bulletPointContainer: { marginTop: theme.rem(0.5) diff --git a/src/components/ui4/ButtonsViewUi4.tsx b/src/components/ui4/ButtonsView.tsx similarity index 96% rename from src/components/ui4/ButtonsViewUi4.tsx rename to src/components/ui4/ButtonsView.tsx index b76b61d2..ae71ec5a 100644 --- a/src/components/ui4/ButtonsViewUi4.tsx +++ b/src/components/ui4/ButtonsView.tsx @@ -10,7 +10,7 @@ import { EdgeAnim } from '../common/EdgeAnim' import { maybeComponent } from '../hoc/maybeComponent' import { styled } from '../hoc/styled' import { Space } from '../layout/Space' -import { ButtonTypeUi4, ButtonUi4 } from './ButtonUi4' +import { EdgeButton, EdgeButtonType } from './EdgeButton' const INTER_BUTTON_SPACING_REM = 1 const ANIM_DURATION = 1000 @@ -26,7 +26,7 @@ export interface ButtonInfo { animDistanceStart?: number } -export interface ButtonsViewUi4Props { +export interface ButtonsViewProps { // Specifies whether the component should be positioned absolutely. // Default value is false. absolute?: boolean @@ -55,7 +55,7 @@ export interface ButtonsViewUi4Props { /** * A consistently styled view for displaying button layouts. */ -export const ButtonsViewUi4 = React.memo( +export const ButtonsView = React.memo( ({ absolute = false, primary, @@ -65,7 +65,7 @@ export const ButtonsViewUi4 = React.memo( layout, parentType, animDistanceStart - }: ButtonsViewUi4Props) => { + }: ButtonsViewProps) => { const buttonInfos = [primary, secondary, secondary2, tertiary].filter( key => key != null ) @@ -75,7 +75,7 @@ export const ButtonsViewUi4 = React.memo( const spacing = const renderButton = ( - type: ButtonTypeUi4, + type: EdgeButtonType, buttonProps?: ButtonInfo, index: number = 0 ) => { @@ -86,6 +86,7 @@ export const ButtonsViewUi4 = React.memo( animDistanceStart != null ? animDistanceStart + index * ANIM_DISTANCE_INCREMENT : undefined + // TODO: Sync EdgeAnim w/ LoginUi const disableAnimation = Platform.OS === 'android' return ( @@ -94,7 +95,7 @@ export const ButtonsViewUi4 = React.memo( disableAnimation={disableAnimation} enter={{ type: 'fadeInDown', duration: ANIM_DURATION, distance }} > - ) + // Use margin props as padding for the invisible container to increase + // tappable area while visually looking like margins + const customMarginPadding = React.useMemo(() => { + if (marginRem == null) return undefined + + // Use margin as padding to increase tappable area + return sidesToPadding(mapSides(fixSides(marginRem, 0), theme.rem)) + }, [marginRem, theme]) + const touchContainerStyle = React.useMemo(() => { const retStyle: ViewStyle[] = [styles.touchContainerCommon] @@ -135,24 +150,14 @@ export function ButtonUi4(props: Props) { if (layout === 'row') retStyle.push(styles.touchContainerRow) if (layout === 'solo') retStyle.push(styles.touchContainerSolo) - const customMargin = - marginRem == null - ? undefined - : sidesToMargin(mapSides(fixSides(marginRem, 0), theme.rem)) retStyle.push( - customMargin != null - ? { - // Use margin as padding to increase tappable area - paddingLeft: customMargin.marginLeft, - paddingRight: customMargin.marginRight, - paddingTop: customMargin.marginTop, - paddingBottom: customMargin.marginBottom - } + customMarginPadding != null + ? customMarginPadding : styles.touchContainerSpacing ) return retStyle - }, [layout, marginRem, styles, theme]) + }, [layout, customMarginPadding, styles]) const visibleContainerStyle = React.useMemo(() => { const retStyle: ViewStyle[] = [styles.visibleContainerCommon] @@ -169,19 +174,12 @@ export function ButtonUi4(props: Props) { ? styles.visibleSizeTertiary : styles.visibleSizeDefault ) - - if (paddingRem != null) { - retStyle.push( - sidesToPadding(mapSides(fixSides(paddingRem, 0), theme.rem)) - ) - } - retStyle.push({ opacity: disabled ? 0.3 : hideContent ? 0.7 : 1 }) return retStyle - }, [disabled, hideContent, layout, mini, paddingRem, styles, theme, type]) + }, [disabled, hideContent, layout, mini, styles, type]) return ( {!hideContent ? null : ( - + )} ) @@ -298,12 +299,16 @@ const getStyles = cacheStyles((theme: Theme) => { fontSize: theme.rem(theme.escapeButtonFontSizeRem), color: theme.escapeButtonText }, + dangerText: { + fontFamily: theme.dangerButtonFont, + fontSize: theme.rem(theme.dangerButtonFontSizeRem), + color: theme.dangerButtonText + }, leftMarginedText: { marginLeft: theme.rem(0.5) }, spinner: { - position: 'absolute', - height: theme.rem(2) + position: 'absolute' } } }) diff --git a/src/components/ui4/CardUi4.tsx b/src/components/ui4/EdgeCard.tsx similarity index 76% rename from src/components/ui4/CardUi4.tsx rename to src/components/ui4/EdgeCard.tsx index 10863c38..a9c9486d 100644 --- a/src/components/ui4/CardUi4.tsx +++ b/src/components/ui4/EdgeCard.tsx @@ -7,7 +7,6 @@ import { StyleSheet, View } from 'react-native' import LinearGradient, { LinearGradientProps } from 'react-native-linear-gradient' -import { cacheStyles } from 'react-native-patina' import AntDesignIcon from 'react-native-vector-icons/AntDesign' import { useHandler } from '../../hooks/useHandler' @@ -19,9 +18,8 @@ import { sidesToPadding } from '../../util/sides' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' -import { showError } from '../services/AirshipInstance' -import { Theme, useTheme } from '../services/ThemeContext' -import { SectionView } from './SectionViewUi4' +import { cacheStyles, Theme, useTheme } from '../services/ThemeContext' +import { SectionView } from './SectionView' interface Props { // Top layer: @@ -29,7 +27,9 @@ interface Props { // children & icon share the same 2nd layer: children: React.ReactNode | React.ReactNode[] - icon?: React.ReactNode + + // TODO: Not implemented, but not used yet anyway. + // icon?: React.ReactNode | string // Everything else underneath, in order: gradientBackground?: LinearGradientProps // 3rd layer @@ -41,6 +41,7 @@ interface Props { paddingRem?: number[] | number // Options: + fill?: boolean // Set flex to 1 for tiling sections?: boolean // Automatic section dividers, only if chilren are multiple nodes onClose?: () => Promise | void // If specified, adds a close button, absolutely positioned in the top right @@ -60,16 +61,16 @@ interface Props { * * onClose: If specified, adds a close button */ -export const CardUi4 = (props: Props) => { +export const EdgeCard = (props: Props) => { const { children, - icon, marginRem, paddingRem, overlay, sections, gradientBackground, nodeBackground, + fill = false, onClose, onLongPress, onPress @@ -78,36 +79,42 @@ export const CardUi4 = (props: Props) => { const styles = getStyles(theme) const margin = sidesToMargin(mapSides(fixSides(marginRem, 0.5), theme.rem)) - const padding = sidesToPadding(mapSides(fixSides(paddingRem, 0.5), theme.rem)) + const fillStyle = fill ? styles.fill : undefined + const isPressable = onPress != null || onLongPress != null const handlePress = useHandler(async () => { if (onPress != null) { triggerHaptic('impactLight') - try { - await onPress() - } catch (err) { - showError(err) - } + await onPress() } }) const handleLongPress = useHandler(async () => { if (onLongPress != null) { triggerHaptic('impactLight') - try { - await onLongPress() - } catch (err) { - showError(err) - } + await onLongPress() } }) - const handleClose = useHandler(() => { - triggerHaptic('impactLight') + const handleClose = useHandler(async () => { + if (onClose != null) { + triggerHaptic('impactLight') + await onClose() + } }) + const viewStyle = React.useMemo( + () => [styles.cardContainer, margin, padding, fillStyle], + [styles.cardContainer, margin, padding, fillStyle] + ) + + const nonNullChildren = React.Children.toArray(children).filter( + child => child != null && React.isValidElement(child) + ) + if (nonNullChildren.length === 0) return null + const background = ( {nodeBackground} @@ -120,9 +127,6 @@ export const CardUi4 = (props: Props) => { ) - const maybeIcon = - icon == null ? null : {icon} - const content = sections ? {children} : children const maybeCloseButton = @@ -144,37 +148,26 @@ export const CardUi4 = (props: Props) => { {overlay} ) - const allContent = - icon == null ? ( - <> - {background} - {content} - {maybeCloseButton} - {maybeOverlay} - - ) : ( - <> - {background} - - {maybeIcon} - {content} - - {maybeCloseButton} - {maybeOverlay} - - ) + const allContent = ( + <> + {background} + {content} + {maybeCloseButton} + {maybeOverlay} + + ) return isPressable ? ( {allContent} ) : ( - {allContent} + {allContent} ) } @@ -187,7 +180,7 @@ const getStyles = cacheStyles((theme: Theme) => ({ }, cardContainer: { borderRadius: theme.cardBorderRadius, - flex: 1 + alignSelf: 'stretch' }, cornerContainer: { margin: theme.rem(1), @@ -204,14 +197,7 @@ const getStyles = cacheStyles((theme: Theme) => ({ margin: 2, pointerEvents: 'none' }, - rowContainer: { - flex: 1, - flexDirection: 'row', - alignItems: 'center' - }, - iconContainer: { - margin: theme.rem(0.25), - justifyContent: 'center', - alignContent: 'center' + fill: { + flex: 1 } })) diff --git a/src/components/ui4/ModalUi4.tsx b/src/components/ui4/EdgeModal.tsx similarity index 81% rename from src/components/ui4/ModalUi4.tsx rename to src/components/ui4/EdgeModal.tsx index 124ed2bb..c57304aa 100644 --- a/src/components/ui4/ModalUi4.tsx +++ b/src/components/ui4/EdgeModal.tsx @@ -1,7 +1,3 @@ -/** - * IMPORTANT: Changes in this file MUST be synced with edge-react-gui! - */ - import * as React from 'react' import { BackHandler, Dimensions, View } from 'react-native' import { AirshipBridge } from 'react-native-airship' @@ -17,18 +13,17 @@ import Animated, { useSharedValue, withTiming } from 'react-native-reanimated' -import AntDesignIcon from 'react-native-vector-icons/AntDesign' import { useHandler } from '../../hooks/useHandler' -import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { EdgeTouchableWithoutFeedback } from '../common/EdgeTouchableWithoutFeedback' import { Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from '../themed/EdgeText' import { BlurBackground } from './BlurBackground' const BACKGROUND_ALPHA = 0.7 +const SCROLL_INDICATOR_INSET_FIX = { right: 1 } -export interface ModalPropsUi4 { +export interface EdgeModalProps { bridge: AirshipBridge // If a non-string title is provided, it's up to the caller to ensure no close @@ -37,9 +32,6 @@ export interface ModalPropsUi4 { children?: React.ReactNode - // Disable the swipe-to-close gesture: - noSwiping?: boolean - // Include a scroll area: scroll?: boolean @@ -58,12 +50,11 @@ const duration = 300 * A modal that slides a modal up from the bottom of the screen * and dims the rest of the app. */ -export function ModalUi4(props: ModalPropsUi4): JSX.Element { +export function EdgeModal(props: EdgeModalProps): JSX.Element { const { bridge, title, children, - noSwiping = false, scroll = false, warning = false, onCancel @@ -123,7 +114,6 @@ export function ModalUi4(props: ModalPropsUi4): JSX.Element { }, [handleCancel]) const gesture = Gesture.Pan() - .enabled(!noSwiping) .onUpdate(e => { offset.value = e.translationY }) @@ -148,7 +138,6 @@ export function ModalUi4(props: ModalPropsUi4): JSX.Element { const bottomGap = safeAreaGap + dragSlop const isHeaderless = title == null && onCancel == null - const isCustomTitle = title != null && typeof title !== 'string' const modalLayout = { borderColor: warning ? theme.warningText : theme.modalBorderColor, @@ -180,27 +169,17 @@ export function ModalUi4(props: ModalPropsUi4): JSX.Element { ) : ( title ?? undefined )} - {onCancel == null ? null : ( - - - - )} )} {scroll ? ( - {children} + + {children} + ) : ( children )} @@ -255,15 +234,27 @@ const getStyles = cacheStyles((theme: Theme) => ({ alignSelf: 'flex-start', alignItems: 'flex-end', justifyContent: 'flex-start', - paddingTop: theme.rem(0.15), // Bake in margins to align with 1 line of text, no matter the number of lines - marginRight: theme.rem(0.25) // Less margins because the icon itself comes with whitespace + // Increase tappable area with padding, while net X with negative margin to visually appear as if X padding + paddingTop: theme.rem(1.15), // Bake in margins to align with 1 line of text, no matter the number of lines + paddingRight: theme.rem(1.25), // Less margins because the icon itself comes with whitespace + paddingBottom: theme.rem(0.75), + marginTop: -theme.rem(1), + marginRight: -theme.rem(1), + marginBottom: -theme.rem(0.75) }, closeIconContainerAbsolute: { // Used when the caller passes a special title that may span the entire // width. It's up to the caller to ensure there's no overlap with the close button. position: 'absolute', - top: theme.rem(0.15), // Bake in margins to align with 1 line of text, which is often supplied in custom headers. - right: theme.rem(0.25) + top: 0, + right: 0, + paddingTop: theme.rem(1), // Bake in margins to align with 1 line of text, no matter the number of lines + paddingRight: theme.rem(1.25), // Less margins because the icon itself comes with whitespace + paddingBottom: theme.rem(0.75), + paddingLeft: theme.rem(1), + marginTop: -theme.rem(1), + marginRight: -theme.rem(1), + marginBottom: -theme.rem(0.75) }, titleContainer: { flexDirection: 'row', diff --git a/src/components/ui4/ModalButtons.tsx b/src/components/ui4/ModalButtons.tsx new file mode 100644 index 00000000..9c975f2f --- /dev/null +++ b/src/components/ui4/ModalButtons.tsx @@ -0,0 +1,14 @@ +/** + * IMPORTANT: Changes in this file MUST be synced between edge-react-gui and + * edge-login-ui-rn! + */ + +import * as React from 'react' + +import { ButtonsView, ButtonsViewProps } from './ButtonsView' + +interface Props extends Omit, 'layout'> {} + +export const ModalButtons = (props: Props) => { + return +} diff --git a/src/components/ui4/SectionViewUi4.tsx b/src/components/ui4/SectionView.tsx similarity index 88% rename from src/components/ui4/SectionViewUi4.tsx rename to src/components/ui4/SectionView.tsx index 734fea87..05f80556 100644 --- a/src/components/ui4/SectionViewUi4.tsx +++ b/src/components/ui4/SectionView.tsx @@ -4,10 +4,9 @@ import * as React from 'react' import { View } from 'react-native' -import { cacheStyles } from 'react-native-patina' import { fixSides, mapSides, sidesToMargin } from '../../util/sides' -import { Theme, useTheme } from '../services/ThemeContext' +import { cacheStyles, Theme, useTheme } from '../services/ThemeContext' interface Props { children: React.ReactNode | React.ReactNode[] @@ -20,7 +19,7 @@ interface Props { marginRem?: number[] | number /** @deprecated Only to be used during the UI4 transition */ - dividerVerticalRem?: number[] | number + dividerMarginRem?: number[] | number } const DEFAULT_MARGIN_REM = 0.5 @@ -33,7 +32,7 @@ const DEFAULT_MARGIN_REM = 0.5 * between sections. */ export const SectionView = (props: Props): JSX.Element | null => { - const { children, extendRight = false, marginRem, dividerVerticalRem } = props + const { children, extendRight = false, marginRem, dividerMarginRem } = props const theme = useTheme() const styles = getStyles(theme) @@ -44,8 +43,8 @@ export const SectionView = (props: Props): JSX.Element | null => { ? styles.marginScene : styles.marginCard const dividerMargin = - dividerVerticalRem != null - ? sidesToMargin(mapSides(fixSides(dividerVerticalRem, 0), theme.rem)) + dividerMarginRem != null + ? sidesToMargin(mapSides(fixSides(dividerMarginRem, 0), theme.rem)) : extendRight ? styles.dividerMarginScene : styles.dividerMarginCard @@ -82,7 +81,8 @@ export const SectionView = (props: Props): JSX.Element | null => { const getStyles = cacheStyles((theme: Theme) => ({ container: { flexDirection: 'column', - flex: 1 + flexGrow: 1, + flexShrink: 1 }, marginCard: { marginVertical: theme.rem(0) diff --git a/src/constants/themes/edgeDark.ts b/src/constants/themes/edgeDark.ts index 57d66d62..db92277d 100644 --- a/src/constants/themes/edgeDark.ts +++ b/src/constants/themes/edgeDark.ts @@ -31,16 +31,24 @@ const palette = { // Fonts SFUITextRegular: 'SF-UI-Text-Regular', + QuicksandMedium: 'Quicksand-Medium', + QuicksandRegular: 'Quicksand-Regular', // UI4 palette whiteOp37: 'rgba(255, 255, 255, .37)', darkGreyOp30: 'hsla(0, 0%, 53%, 0.3)', blackOp65: 'rgba(0, 0, 0, .65)', + graySecondary: 'hsla(0, 0%, 100%, 0.20)', // Background: backgroundBlack: '#1a1a1a', // Gradients + darkestGreen: '#20312f', + darkGreen: '#00604d', + + dangerOuter: 'rgba(240, 81, 35, 0.44)', + dangerInner: 'rgba(237, 28, 36, 0.44)', warningOuter: 'rgba(119, 43, 15, 0.44)', warningInner: 'rgba(130, 91, 33, 0.44)', errorOuter: 'rgba(148, 71, 46, 0.44)', @@ -104,26 +112,40 @@ export const edgeDark: Theme = { keypadButtonFont: 'System', primaryButtonOutline: palette.transparent, - primaryButtonOutlineWidth: 1, - primaryButton: [palette.edgeMint, palette.edgeMint], + primaryButtonOutlineWidth: 0, + primaryButton: [palette.darkestGreen, palette.darkGreen], primaryButtonColorStart: { x: 0, y: 0 }, primaryButtonColorEnd: { x: 1, y: 0 }, - primaryButtonText: palette.edgeBlue, + primaryButtonText: palette.white, primaryButtonTextShadow: textNoShadow, - primaryButtonShadow: themeNoShadow, + primaryButtonShadow: { + shadowColor: palette.black, + shadowOffset: { width: -1.5, height: 1.5 }, + shadowOpacity: 0.5, + shadowRadius: 2, + /** @deprecated */ + elevation: 0 + }, primaryButtonFontSizeRem: 1, - primaryButtonFont: 'System', + primaryButtonFont: palette.QuicksandMedium, - secondaryButtonOutline: palette.edgeMint, - secondaryButtonOutlineWidth: 1, - secondaryButton: [palette.transparent, palette.transparent], + secondaryButtonOutline: palette.graySecondary, + secondaryButtonOutlineWidth: 0, + secondaryButton: [palette.graySecondary, palette.graySecondary], secondaryButtonColorStart: { x: 0, y: 0 }, secondaryButtonColorEnd: { x: 1, y: 1 }, - secondaryButtonText: palette.edgeMint, + secondaryButtonText: palette.white, secondaryButtonTextShadow: textNoShadow, - secondaryButtonShadow: themeNoShadow, + secondaryButtonShadow: { + shadowColor: palette.black, + shadowOffset: { width: -1.5, height: 1.5 }, + shadowOpacity: 0.5, + shadowRadius: 2, + /** @deprecated */ + elevation: 0 + }, secondaryButtonFontSizeRem: 1, - secondaryButtonFont: 'System', + secondaryButtonFont: palette.QuicksandRegular, escapeButtonOutline: palette.transparent, escapeButtonOutlineWidth: 0, @@ -134,7 +156,18 @@ export const edgeDark: Theme = { escapeButtonTextShadow: textNoShadow, escapeButtonShadow: themeNoShadow, escapeButtonFontSizeRem: 1, - escapeButtonFont: 'System', + escapeButtonFont: palette.QuicksandRegular, + + dangerButtonOutline: palette.transparent, + dangerButtonOutlineWidth: 0, + dangerButton: [palette.dangerOuter, palette.dangerInner, palette.dangerOuter], + dangerButtonColorStart: { x: 0, y: 0.25 }, + dangerButtonColorEnd: { x: 1, y: 0.75 }, + dangerButtonText: palette.white, + dangerButtonTextShadow: textNoShadow, + dangerButtonShadow: themeNoShadow, + dangerButtonFontSizeRem: 1, + dangerButtonFont: 'System', pinUsernameButtonOutline: palette.transparent, pinUsernameButtonOutlineWidth: 0, diff --git a/src/types/Theme.ts b/src/types/Theme.ts index c2738f7e..a5b806d8 100644 --- a/src/types/Theme.ts +++ b/src/types/Theme.ts @@ -317,6 +317,17 @@ export interface Theme { escapeButtonFontSizeRem: number escapeButtonFont: string + dangerButtonOutline: string + dangerButtonOutlineWidth: number + dangerButton: string[] + dangerButtonColorStart: GradientCoords + dangerButtonColorEnd: GradientCoords + dangerButtonText: string + dangerButtonTextShadow: TextShadowParams + dangerButtonShadow: ThemeShadowParams + dangerButtonFontSizeRem: number + dangerButtonFont: string + pinUsernameButtonOutline: string pinUsernameButtonOutlineWidth: number pinUsernameButton: string[]