diff options
Diffstat (limited to 'src/view')
29 files changed, 1746 insertions, 264 deletions
diff --git a/src/view/com/Button.tsx b/src/view/com/Button.tsx new file mode 100644 index 000000000..d1f70d4ae --- /dev/null +++ b/src/view/com/Button.tsx @@ -0,0 +1,204 @@ +import React from 'react' +import {Pressable, Text, PressableProps, TextProps} from 'react-native' +import * as tokens from '#/alf/tokens' +import {atoms} from '#/alf' + +export type ButtonType = + | 'primary' + | 'secondary' + | 'tertiary' + | 'positive' + | 'negative' +export type ButtonSize = 'small' | 'large' + +export type VariantProps = { + type?: ButtonType + size?: ButtonSize +} +type ButtonState = { + pressed: boolean + hovered: boolean + focused: boolean +} +export type ButtonProps = Omit<PressableProps, 'children'> & + VariantProps & { + children: + | ((props: { + state: ButtonState + type?: ButtonType + size?: ButtonSize + }) => React.ReactNode) + | React.ReactNode + | string + } +export type ButtonTextProps = TextProps & VariantProps + +export function Button({children, style, type, size, ...rest}: ButtonProps) { + const {baseStyles, hoverStyles} = React.useMemo(() => { + const baseStyles = [] + const hoverStyles = [] + + switch (type) { + case 'primary': + baseStyles.push({ + backgroundColor: tokens.color.blue_500, + }) + break + case 'secondary': + baseStyles.push({ + backgroundColor: tokens.color.gray_200, + }) + hoverStyles.push({ + backgroundColor: tokens.color.gray_100, + }) + break + default: + } + + switch (size) { + case 'large': + baseStyles.push( + atoms.py_md, + atoms.px_xl, + atoms.rounded_md, + atoms.gap_sm, + ) + break + case 'small': + baseStyles.push( + atoms.py_sm, + atoms.px_md, + atoms.rounded_sm, + atoms.gap_xs, + ) + break + default: + } + + return { + baseStyles, + hoverStyles, + } + }, [type, size]) + + const [state, setState] = React.useState({ + pressed: false, + hovered: false, + focused: false, + }) + + const onPressIn = React.useCallback(() => { + setState(s => ({ + ...s, + pressed: true, + })) + }, [setState]) + const onPressOut = React.useCallback(() => { + setState(s => ({ + ...s, + pressed: false, + })) + }, [setState]) + const onHoverIn = React.useCallback(() => { + setState(s => ({ + ...s, + hovered: true, + })) + }, [setState]) + const onHoverOut = React.useCallback(() => { + setState(s => ({ + ...s, + hovered: false, + })) + }, [setState]) + const onFocus = React.useCallback(() => { + setState(s => ({ + ...s, + focused: true, + })) + }, [setState]) + const onBlur = React.useCallback(() => { + setState(s => ({ + ...s, + focused: false, + })) + }, [setState]) + + return ( + <Pressable + {...rest} + style={state => [ + atoms.flex_row, + atoms.align_center, + ...baseStyles, + ...(state.hovered ? hoverStyles : []), + typeof style === 'function' ? style(state) : style, + ]} + onPressIn={onPressIn} + onPressOut={onPressOut} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut} + onFocus={onFocus} + onBlur={onBlur}> + {typeof children === 'string' ? ( + <ButtonText type={type} size={size}> + {children} + </ButtonText> + ) : typeof children === 'function' ? ( + children({state, type, size}) + ) : ( + children + )} + </Pressable> + ) +} + +export function ButtonText({ + children, + style, + type, + size, + ...rest +}: ButtonTextProps) { + const textStyles = React.useMemo(() => { + const base = [] + + switch (type) { + case 'primary': + base.push({color: tokens.color.white}) + break + case 'secondary': + base.push({ + color: tokens.color.gray_700, + }) + break + default: + } + + switch (size) { + case 'small': + base.push(atoms.text_sm, {paddingBottom: 1}) + break + case 'large': + base.push(atoms.text_md, {paddingBottom: 1}) + break + default: + } + + return base + }, [type, size]) + + return ( + <Text + {...rest} + style={[ + atoms.flex_1, + atoms.font_semibold, + atoms.text_center, + ...textStyles, + style, + ]}> + {children} + </Text> + ) +} diff --git a/src/view/com/Typography.tsx b/src/view/com/Typography.tsx new file mode 100644 index 000000000..6579c2e51 --- /dev/null +++ b/src/view/com/Typography.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import {Text as RNText, TextProps} from 'react-native' +import {useTheme, atoms, web} from '#/alf' + +export function Text({style, ...rest}: TextProps) { + const t = useTheme() + return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} /> +} + +export function H1({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 1, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_xl, atoms.font_bold, t.atoms.text, style]} + /> + ) +} + +export function H2({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 2, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_lg, atoms.font_bold, t.atoms.text, style]} + /> + ) +} + +export function H3({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 3, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_md, atoms.font_bold, t.atoms.text, style]} + /> + ) +} + +export function H4({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 4, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_sm, atoms.font_bold, t.atoms.text, style]} + /> + ) +} + +export function H5({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 5, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_xs, atoms.font_bold, t.atoms.text, style]} + /> + ) +} + +export function H6({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 6, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_xxs, atoms.font_bold, t.atoms.text, style]} + /> + ) +} diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts index a77d2a44f..62a8495b3 100644 --- a/src/view/com/auth/create/state.ts +++ b/src/view/com/auth/create/state.ts @@ -136,7 +136,13 @@ export async function submit({ msg`Invite code not accepted. Check that you input it correctly and try again.`, ) } - logger.error('Failed to create account', {error: e}) + + if ([400, 429].includes(e.status)) { + logger.warn('Failed to create account', {error: e}) + } else { + logger.error(`Failed to create account (${e.status} status)`, {error: e}) + } + uiDispatch({type: 'set-processing', value: false}) uiDispatch({type: 'set-error', value: cleanError(errMsg)}) throw e diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx index 727a0e945..98c5eb374 100644 --- a/src/view/com/auth/login/LoginForm.tsx +++ b/src/view/com/auth/login/LoginForm.tsx @@ -107,17 +107,21 @@ export const LoginForm = ({ }) } catch (e: any) { const errMsg = e.toString() - logger.warn('Failed to login', {error: e}) setIsProcessing(false) if (errMsg.includes('Authentication Required')) { + logger.info('Failed to login due to invalid credentials', { + error: errMsg, + }) setError(_(msg`Invalid username or password`)) } else if (isNetworkError(e)) { + logger.warn('Failed to login due to network error', {error: errMsg}) setError( _( msg`Unable to contact your service. Please check your Internet connection.`, ), ) } else { + logger.warn('Failed to login', {error: errMsg}) setError(cleanError(errMsg)) } } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 9f60923d6..b15afe6f0 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -6,6 +6,7 @@ import { Keyboard, KeyboardAvoidingView, Platform, + Pressable, ScrollView, StyleSheet, TouchableOpacity, @@ -46,7 +47,6 @@ import {Gallery} from './photos/Gallery' import {MAX_GRAPHEME_LENGTH} from 'lib/constants' import {LabelsBtn} from './labels/LabelsBtn' import {SelectLangBtn} from './select-language/SelectLangBtn' -import {EmojiPickerButton} from './text-input/web/EmojiPicker.web' import {insertMentionAt} from 'lib/strings/mention-manip' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -70,6 +70,7 @@ export const ComposePost = observer(function ComposePost({ onPost, quote: initQuote, mention: initMention, + openPicker, }: Props) { const {currentAccount} = useSession() const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) @@ -274,6 +275,10 @@ export const ComposePost = observer(function ComposePost({ const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size]) const hasMedia = gallery.size > 0 || Boolean(extLink) + const onEmojiButtonPress = useCallback(() => { + openPicker?.(textInput.current?.getCursorPosition()) + }, [openPicker]) + return ( <KeyboardAvoidingView testID="composePostView" @@ -456,7 +461,19 @@ export const ComposePost = observer(function ComposePost({ <OpenCameraBtn gallery={gallery} /> </> ) : null} - {!isMobile ? <EmojiPickerButton /> : null} + {!isMobile ? ( + <Pressable + onPress={onEmojiButtonPress} + accessibilityRole="button" + accessibilityLabel={_(msg`Open emoji picker`)} + accessibilityHint={_(msg`Open emoji picker`)}> + <FontAwesomeIcon + icon={['far', 'face-smile']} + color={pal.colors.link} + size={22} + /> + </Pressable> + ) : null} <View style={s.flex1} /> <SelectLangBtn /> <CharProgress count={graphemeLength} /> diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 7e39f6aed..57bfd0a88 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -32,6 +32,7 @@ import {POST_IMG_MAX} from 'lib/constants' export interface TextInputRef { focus: () => void blur: () => void + getCursorPosition: () => DOMRect | undefined } interface TextInputProps extends ComponentProps<typeof RNTextInput> { @@ -74,6 +75,7 @@ export const TextInput = forwardRef(function TextInputImpl( blur: () => { textInput.current?.blur() }, + getCursorPosition: () => undefined, // Not implemented on native })) const onChangeText = useCallback( diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 206a3205b..ec3a042a3 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -22,6 +22,7 @@ import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' export interface TextInputRef { focus: () => void blur: () => void + getCursorPosition: () => DOMRect | undefined } interface TextInputProps { @@ -169,6 +170,10 @@ export const TextInput = React.forwardRef(function TextInputImpl( React.useImperativeHandle(ref, () => ({ focus: () => {}, // TODO blur: () => {}, // TODO + getCursorPosition: () => { + const pos = editor?.state.selection.$anchor.pos + return pos ? editor?.view.coordsAtPos(pos) : undefined + }, })) return ( diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx index f4b2d99b0..6d16403ff 100644 --- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx +++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx @@ -1,11 +1,17 @@ import React from 'react' import Picker from '@emoji-mart/react' -import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { + StyleSheet, + TouchableWithoutFeedback, + useWindowDimensions, + View, +} from 'react-native' import {textInputWebEmitter} from '../TextInput.web' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from 'lib/hooks/usePalette' -import {useMediaQuery} from 'react-responsive' + +const HEIGHT_OFFSET = 40 +const WIDTH_OFFSET = 100 +const PICKER_HEIGHT = 435 + HEIGHT_OFFSET +const PICKER_WIDTH = 350 + WIDTH_OFFSET export type Emoji = { aliases?: string[] @@ -18,59 +24,87 @@ export type Emoji = { unified: string } -export function EmojiPickerButton() { - const pal = usePalette('default') - const [open, setOpen] = React.useState(false) - const onOpenChange = (o: boolean) => { - setOpen(o) - } - const close = () => { - setOpen(false) - } +export interface EmojiPickerState { + isOpen: boolean + pos: {top: number; left: number; right: number; bottom: number} +} - return ( - <DropdownMenu.Root open={open} onOpenChange={onOpenChange}> - <DropdownMenu.Trigger style={styles.trigger}> - <FontAwesomeIcon - icon={['far', 'face-smile']} - color={pal.colors.link} - size={22} - /> - </DropdownMenu.Trigger> - - <DropdownMenu.Portal> - <EmojiPicker close={close} /> - </DropdownMenu.Portal> - </DropdownMenu.Root> - ) +interface IProps { + state: EmojiPickerState + close: () => void } -export function EmojiPicker({close}: {close: () => void}) { +export function EmojiPicker({state, close}: IProps) { + const {height, width} = useWindowDimensions() + + const isShiftDown = React.useRef(false) + + const position = React.useMemo(() => { + const fitsBelow = state.pos.top + PICKER_HEIGHT < height + const fitsAbove = PICKER_HEIGHT < state.pos.top + const placeOnLeft = PICKER_WIDTH < state.pos.left + const screenYMiddle = height / 2 - PICKER_HEIGHT / 2 + + if (fitsBelow) { + return { + top: state.pos.top + HEIGHT_OFFSET, + } + } else if (fitsAbove) { + return { + bottom: height - state.pos.bottom + HEIGHT_OFFSET, + } + } else { + return { + top: screenYMiddle, + left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined, + right: !placeOnLeft + ? width - state.pos.right - PICKER_WIDTH + : undefined, + } + } + }, [state.pos, height, width]) + + React.useEffect(() => { + if (!state.isOpen) return + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + isShiftDown.current = true + } + } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + isShiftDown.current = false + } + } + window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('keyup', onKeyUp, true) + + return () => { + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('keyup', onKeyUp, true) + } + }, [state.isOpen]) + const onInsert = (emoji: Emoji) => { textInputWebEmitter.emit('emoji-inserted', emoji) - close() + + if (!isShiftDown.current) { + close() + } } - const reducedPadding = useMediaQuery({query: '(max-height: 750px)'}) - const noPadding = useMediaQuery({query: '(max-height: 550px)'}) - const noPicker = useMediaQuery({query: '(max-height: 350px)'}) + + if (!state.isOpen) return null return ( - // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors - <TouchableWithoutFeedback onPress={close} accessibilityViewIsModal> + <TouchableWithoutFeedback + accessibilityRole="button" + onPress={close} + accessibilityViewIsModal> <View style={styles.mask}> {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} - <TouchableWithoutFeedback - onPress={e => { - e.stopPropagation() // prevent event from bubbling up to the mask - }}> - <View - style={[ - styles.picker, - { - paddingTop: noPadding ? 0 : reducedPadding ? 150 : 325, - display: noPicker ? 'none' : 'flex', - }, - ]}> + <TouchableWithoutFeedback onPress={e => e.stopPropagation()}> + <View style={[{position: 'absolute'}, position]}> <Picker data={async () => { return (await import('./EmojiPickerData.json')).default @@ -93,15 +127,7 @@ const styles = StyleSheet.create({ right: 0, width: '100%', height: '100%', - }, - trigger: { - backgroundColor: 'transparent', - // @ts-ignore web only -prf - border: 'none', - paddingTop: 4, - paddingLeft: 12, - paddingRight: 12, - cursor: 'pointer', + alignItems: 'center', }, picker: { marginHorizontal: 'auto', diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 99e2b474f..338ffc3d0 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -229,7 +229,7 @@ export function FeedSourceCardLoaded({ </View> {showSaveBtn && feed.type === 'feed' && ( - <View> + <View style={[s.justifyCenter]}> <Pressable testID={`feed-${feed.displayName}-toggleSave`} disabled={isSavePending || isPinPending || isRemovePending} diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx index a2e918317..5156511d6 100644 --- a/src/view/com/modals/AltImage.tsx +++ b/src/view/com/modals/AltImage.tsx @@ -1,14 +1,12 @@ import React, {useMemo, useCallback, useState} from 'react' import { ImageStyle, - KeyboardAvoidingView, - ScrollView, StyleSheet, - TextInput, TouchableOpacity, View, useWindowDimensions, } from 'react-native' +import {ScrollView, TextInput} from './util' import {Image} from 'expo-image' import {usePalette} from 'lib/hooks/usePalette' import {gradients, s} from 'lib/styles' @@ -17,13 +15,13 @@ import {MAX_ALT_TEXT} from 'lib/constants' import {useTheme} from 'lib/ThemeContext' import {Text} from '../util/text/Text' import LinearGradient from 'react-native-linear-gradient' -import {isAndroid, isWeb} from 'platform/detection' +import {isWeb} from 'platform/detection' import {ImageModel} from 'state/models/media/image' import {useLingui} from '@lingui/react' import {Trans, msg} from '@lingui/macro' import {useModalControls} from '#/state/modals' -export const snapPoints = ['fullscreen'] +export const snapPoints = ['100%'] interface Props { image: ImageModel @@ -54,102 +52,86 @@ export function Component({image}: Props) { } }, [image, windim]) + const onUpdate = useCallback( + (v: string) => { + v = enforceLen(v, MAX_ALT_TEXT) + setAltText(v) + image.setAltText(v) + }, + [setAltText, image], + ) + const onPressSave = useCallback(() => { image.setAltText(altText) closeModal() }, [closeModal, image, altText]) - const onPressCancel = () => { - closeModal() - } - return ( - <KeyboardAvoidingView - behavior={isAndroid ? 'height' : 'padding'} - style={[pal.view, styles.container]}> - <ScrollView - testID="altTextImageModal" - style={styles.scrollContainer} - keyboardShouldPersistTaps="always" - nativeID="imageAltText"> - <View style={styles.scrollInner}> - <View style={[pal.viewLight, styles.imageContainer]}> - <Image - testID="selectedPhotoImage" - style={imageStyles} - source={{ - uri: image.cropped?.path ?? image.path, - }} - contentFit="contain" - accessible={true} - accessibilityIgnoresInvertColors - /> - </View> - <TextInput - testID="altTextImageInput" - style={[styles.textArea, pal.border, pal.text]} - keyboardAppearance={theme.colorScheme} - multiline - placeholder="Add alt text" - placeholderTextColor={pal.colors.textLight} - value={altText} - onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} - accessibilityLabel={_(msg`Image alt text`)} - accessibilityHint="" - accessibilityLabelledBy="imageAltText" - autoFocus + <ScrollView + testID="altTextImageModal" + style={[pal.view, styles.scrollContainer]} + keyboardShouldPersistTaps="always" + nativeID="imageAltText"> + <View style={styles.scrollInner}> + <View style={[pal.viewLight, styles.imageContainer]}> + <Image + testID="selectedPhotoImage" + style={imageStyles} + source={{ + uri: image.cropped?.path ?? image.path, + }} + contentFit="contain" + accessible={true} + accessibilityIgnoresInvertColors /> - <View style={styles.buttonControls}> - <TouchableOpacity - testID="altTextImageSaveBtn" - onPress={onPressSave} - accessibilityLabel={_(msg`Save alt text`)} - accessibilityHint={`Saves alt text, which reads: ${altText}`} - accessibilityRole="button"> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.button]}> - <Text type="button-lg" style={[s.white, s.bold]}> - <Trans>Save</Trans> - </Text> - </LinearGradient> - </TouchableOpacity> - <TouchableOpacity - testID="altTextImageCancelBtn" - onPress={onPressCancel} - accessibilityRole="button" - accessibilityLabel={_(msg`Cancel add image alt text`)} - accessibilityHint="" - onAccessibilityEscape={onPressCancel}> - <View style={[styles.button]}> - <Text type="button-lg" style={[pal.textLight]}> - <Trans>Cancel</Trans> - </Text> - </View> - </TouchableOpacity> - </View> </View> - </ScrollView> - </KeyboardAvoidingView> + <TextInput + testID="altTextImageInput" + style={[styles.textArea, pal.border, pal.text]} + keyboardAppearance={theme.colorScheme} + multiline + placeholder={_(msg`Add alt text`)} + placeholderTextColor={pal.colors.textLight} + value={altText} + onChangeText={onUpdate} + accessibilityLabel={_(msg`Image alt text`)} + accessibilityHint="" + accessibilityLabelledBy="imageAltText" + autoFocus + /> + <View style={styles.buttonControls}> + <TouchableOpacity + testID="altTextImageSaveBtn" + onPress={onPressSave} + accessibilityLabel={_(msg`Save alt text`)} + accessibilityHint="" + accessibilityRole="button"> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.button]}> + <Text type="button-lg" style={[s.white, s.bold]}> + <Trans>Done</Trans> + </Text> + </LinearGradient> + </TouchableOpacity> + </View> + </View> + </ScrollView> ) } const styles = StyleSheet.create({ - container: { - flex: 1, - height: '100%', - width: '100%', - paddingVertical: isWeb ? 0 : 18, - }, scrollContainer: { flex: 1, height: '100%', paddingHorizontal: isWeb ? 0 : 12, + paddingVertical: isWeb ? 0 : 24, }, scrollInner: { gap: 12, + paddingTop: isWeb ? 0 : 12, }, imageContainer: { borderRadius: 8, @@ -173,5 +155,6 @@ const styles = StyleSheet.create({ }, buttonControls: { gap: 8, + paddingBottom: isWeb ? 0 : 50, }, }) diff --git a/src/view/com/modals/EmbedConsent.tsx b/src/view/com/modals/EmbedConsent.tsx new file mode 100644 index 000000000..04104c52e --- /dev/null +++ b/src/view/com/modals/EmbedConsent.tsx @@ -0,0 +1,153 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {s, colors, gradients} from 'lib/styles' +import {Text} from '../util/text/Text' +import {ScrollView} from './util' +import {usePalette} from 'lib/hooks/usePalette' +import { + EmbedPlayerSource, + embedPlayerSources, + externalEmbedLabels, +} from '#/lib/strings/embed-player' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useSetExternalEmbedPref} from '#/state/preferences/external-embeds-prefs' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' + +export const snapPoints = [450] + +export function Component({ + onAccept, + source, +}: { + onAccept: () => void + source: EmbedPlayerSource +}) { + const pal = usePalette('default') + const {closeModal} = useModalControls() + const {_} = useLingui() + const setExternalEmbedPref = useSetExternalEmbedPref() + const {isMobile} = useWebMediaQueries() + + const onShowAllPress = React.useCallback(() => { + for (const key of embedPlayerSources) { + setExternalEmbedPref(key, 'show') + } + onAccept() + closeModal() + }, [closeModal, onAccept, setExternalEmbedPref]) + + const onShowPress = React.useCallback(() => { + setExternalEmbedPref(source, 'show') + onAccept() + closeModal() + }, [closeModal, onAccept, setExternalEmbedPref, source]) + + const onHidePress = React.useCallback(() => { + setExternalEmbedPref(source, 'hide') + closeModal() + }, [closeModal, setExternalEmbedPref, source]) + + return ( + <ScrollView + testID="embedConsentModal" + style={[ + s.flex1, + pal.view, + isMobile + ? {paddingHorizontal: 20, paddingTop: 10} + : {paddingHorizontal: 30}, + ]}> + <Text style={[pal.text, styles.title]}> + <Trans>External Media</Trans> + </Text> + + <Text style={pal.text}> + <Trans> + This content is hosted by {externalEmbedLabels[source]}. Do you want + to enable external media? + </Trans> + </Text> + <View style={[s.mt10]} /> + <Text style={pal.textLight}> + <Trans> + External media may allow websites to collect information about you and + your device. No information is sent or requested until you press the + "play" button. + </Trans> + </Text> + <View style={[s.mt20]} /> + <TouchableOpacity + testID="enableAllBtn" + onPress={onShowAllPress} + accessibilityRole="button" + accessibilityLabel={_( + msg`Show embeds from ${externalEmbedLabels[source]}`, + )} + accessibilityHint="" + onAccessibilityEscape={closeModal}> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.btn]}> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Enable External Media</Trans> + </Text> + </LinearGradient> + </TouchableOpacity> + <View style={[s.mt10]} /> + <TouchableOpacity + testID="enableSourceBtn" + onPress={onShowPress} + accessibilityRole="button" + accessibilityLabel={_( + msg`Never load embeds from ${externalEmbedLabels[source]}`, + )} + accessibilityHint="" + onAccessibilityEscape={closeModal}> + <View style={[styles.btn, pal.btn]}> + <Text style={[pal.text, s.bold, s.f18]}> + <Trans>Enable {externalEmbedLabels[source]} only</Trans> + </Text> + </View> + </TouchableOpacity> + <View style={[s.mt10]} /> + <TouchableOpacity + testID="disableSourceBtn" + onPress={onHidePress} + accessibilityRole="button" + accessibilityLabel={_( + msg`Never load embeds from ${externalEmbedLabels[source]}`, + )} + accessibilityHint="" + onAccessibilityEscape={closeModal}> + <View style={[styles.btn, pal.btn]}> + <Text style={[pal.text, s.bold, s.f18]}> + <Trans>No thanks</Trans> + </Text> + </View> + </TouchableOpacity> + </ScrollView> + ) +} + +const styles = StyleSheet.create({ + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 12, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + borderRadius: 32, + padding: 14, + backgroundColor: colors.gray1, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 2aac20dac..f9d211d07 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -38,6 +38,7 @@ import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' import * as SwitchAccountModal from './SwitchAccount' import * as LinkWarningModal from './LinkWarning' +import * as EmbedConsentModal from './EmbedConsent' const DEFAULT_SNAPPOINTS = ['90%'] const HANDLE_HEIGHT = 24 @@ -176,6 +177,9 @@ export function ModalsContainer() { } else if (activeModal?.name === 'link-warning') { snapPoints = LinkWarningModal.snapPoints element = <LinkWarningModal.Component {...activeModal} /> + } else if (activeModal?.name === 'embed-consent') { + snapPoints = EmbedConsentModal.snapPoints + element = <EmbedConsentModal.Component {...activeModal} /> } else { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 12138f54d..c43a8a6ce 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -34,6 +34,7 @@ import * as BirthDateSettingsModal from './BirthDateSettings' import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' import * as LinkWarningModal from './LinkWarning' +import * as EmbedConsentModal from './EmbedConsent' export function ModalsContainer() { const {isModalActive, activeModals} = useModals() @@ -129,6 +130,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <ChangeEmailModal.Component /> } else if (modal.name === 'link-warning') { element = <LinkWarningModal.Component {...modal} /> + } else if (modal.name === 'embed-consent') { + element = <EmbedConsentModal.Component {...modal} /> } else { return null } diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx new file mode 100644 index 000000000..f06c8b794 --- /dev/null +++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx @@ -0,0 +1,170 @@ +import {EmbedPlayerParams, getGifDims} from 'lib/strings/embed-player' +import React from 'react' +import {Image, ImageLoadEventData} from 'expo-image' +import { + ActivityIndicator, + GestureResponderEvent, + LayoutChangeEvent, + Pressable, + StyleSheet, + View, +} from 'react-native' +import {isIOS, isNative, isWeb} from '#/platform/detection' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useExternalEmbedsPrefs} from 'state/preferences' +import {useModalControls} from 'state/modals' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {AppBskyEmbedExternal} from '@atproto/api' + +export function ExternalGifEmbed({ + link, + params, +}: { + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams +}) { + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const {openModal} = useModalControls() + const {_} = useLingui() + + const thumbHasLoaded = React.useRef(false) + const viewWidth = React.useRef(0) + + // Tracking if the placer has been activated + const [isPlayerActive, setIsPlayerActive] = React.useState(false) + // Tracking whether the gif has been loaded yet + const [isPrefetched, setIsPrefetched] = React.useState(false) + // Tracking whether the image is animating + const [isAnimating, setIsAnimating] = React.useState(true) + const [imageDims, setImageDims] = React.useState({height: 100, width: 1}) + + // Used for controlling animation + const imageRef = React.useRef<Image>(null) + + const load = React.useCallback(() => { + setIsPlayerActive(true) + Image.prefetch(params.playerUri).then(() => { + // Replace the image once it's fetched + setIsPrefetched(true) + }) + }, [params.playerUri]) + + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Don't propagate on web + event.preventDefault() + + // Show consent if this is the first load + if (externalEmbedsPrefs?.[params.source] === undefined) { + openModal({ + name: 'embed-consent', + source: params.source, + onAccept: load, + }) + return + } + // If the player isn't active, we want to activate it and prefetch the gif + if (!isPlayerActive) { + load() + return + } + // Control animation on native + setIsAnimating(prev => { + if (prev) { + if (isNative) { + imageRef.current?.stopAnimating() + } + return false + } else { + if (isNative) { + imageRef.current?.startAnimating() + } + return true + } + }) + }, + [externalEmbedsPrefs, isPlayerActive, load, openModal, params.source], + ) + + const onLoad = React.useCallback((e: ImageLoadEventData) => { + if (thumbHasLoaded.current) return + setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current)) + thumbHasLoaded.current = true + }, []) + + const onLayout = React.useCallback((e: LayoutChangeEvent) => { + viewWidth.current = e.nativeEvent.layout.width + }, []) + + return ( + <Pressable + style={[ + {height: imageDims.height}, + styles.topRadius, + styles.gifContainer, + ]} + onPress={onPlayPress} + onLayout={onLayout} + accessibilityRole="button" + accessibilityHint={_(msg`Plays the GIF`)} + accessibilityLabel={_(msg`Play ${link.title}`)}> + {(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay + <View style={[styles.layer, styles.overlayLayer]}> + <View style={[styles.overlayContainer, styles.topRadius]}> + {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active + <FontAwesomeIcon icon="play" size={42} color="white" /> + ) : ( + // Activity indicator while gif loads + <ActivityIndicator size="large" color="white" /> + )} + </View> + </View> + )} + <Image + source={{ + uri: + !isPrefetched || (isWeb && !isAnimating) + ? link.thumb + : params.playerUri, + }} // Web uses the thumb to control playback + style={{flex: 1}} + ref={imageRef} + onLoad={onLoad} + autoplay={isAnimating} + contentFit="contain" + accessibilityIgnoresInvertColors + accessibilityLabel={link.title} + accessibilityHint={link.title} + cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios + /> + </Pressable> + ) +} + +const styles = StyleSheet.create({ + topRadius: { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + }, + layer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + overlayContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + overlayLayer: { + zIndex: 2, + }, + gifContainer: { + width: '100%', + overflow: 'hidden', + }, +}) diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index 27aa804d3..af62aa2b3 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -8,6 +8,8 @@ import {AppBskyEmbedExternal} from '@atproto/api' import {toNiceDomain} from 'lib/strings/url-helpers' import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed' +import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed' +import {useExternalEmbedsPrefs} from 'state/preferences' export const ExternalLinkEmbed = ({ link, @@ -16,36 +18,27 @@ export const ExternalLinkEmbed = ({ }) => { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const externalEmbedPrefs = useExternalEmbedsPrefs() - const embedPlayerParams = React.useMemo( - () => parseEmbedPlayerFromUrl(link.uri), - [link.uri], - ) + const embedPlayerParams = React.useMemo(() => { + const params = parseEmbedPlayerFromUrl(link.uri) + + if (params && externalEmbedPrefs?.[params.source] !== 'hide') { + return params + } + }, [link.uri, externalEmbedPrefs]) return ( - <View - style={{ - flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column', - }}> + <View style={{flexDirection: 'column'}}> {link.thumb && !embedPlayerParams ? ( <View - style={ - !isMobile - ? { - borderTopLeftRadius: 6, - borderBottomLeftRadius: 6, - width: 120, - aspectRatio: 1, - overflow: 'hidden', - } - : { - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - width: '100%', - height: 200, - overflow: 'hidden', - } - }> + style={{ + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + width: '100%', + height: isMobile ? 200 : 300, + overflow: 'hidden', + }}> <Image style={styles.extImage} source={{uri: link.thumb}} @@ -53,15 +46,17 @@ export const ExternalLinkEmbed = ({ /> </View> ) : undefined} - {embedPlayerParams && ( - <ExternalPlayer link={link} params={embedPlayerParams} /> - )} + {(embedPlayerParams?.isGif && ( + <ExternalGifEmbed link={link} params={embedPlayerParams} /> + )) || + (embedPlayerParams && ( + <ExternalPlayer link={link} params={embedPlayerParams} /> + ))} <View style={{ paddingHorizontal: isMobile ? 10 : 14, paddingTop: 8, paddingBottom: 10, - flex: !isMobile ? 1 : undefined, }}> <Text type="sm" @@ -69,16 +64,15 @@ export const ExternalLinkEmbed = ({ style={[pal.textLight, styles.extUri]}> {toNiceDomain(link.uri)} </Text> - <Text - type="lg-bold" - numberOfLines={isMobile ? 4 : 2} - style={[pal.text]}> - {link.title || link.uri} - </Text> - {link.description ? ( + {!embedPlayerParams?.isGif && ( + <Text type="lg-bold" numberOfLines={4} style={[pal.text]}> + {link.title || link.uri} + </Text> + )} + {link.description && !embedPlayerParams?.hideDetails ? ( <Text type="md" - numberOfLines={isMobile ? 4 : 2} + numberOfLines={4} style={[pal.text, styles.extDescription]}> {link.description} </Text> @@ -90,8 +84,7 @@ export const ExternalLinkEmbed = ({ const styles = StyleSheet.create({ extImage: { - width: '100%', - height: 200, + flex: 1, }, extUri: { marginTop: 2, diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx index 580cf363a..8b0858b69 100644 --- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx @@ -1,22 +1,32 @@ import React from 'react' import { ActivityIndicator, - Dimensions, GestureResponderEvent, Pressable, StyleSheet, + useWindowDimensions, View, } from 'react-native' +import Animated, { + measure, + runOnJS, + useAnimatedRef, + useFrameCallback, +} from 'react-native-reanimated' import {Image} from 'expo-image' import {WebView} from 'react-native-webview' -import YoutubePlayer from 'react-native-youtube-iframe' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {AppBskyEmbedExternal} from '@atproto/api' import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player' import {EventStopper} from '../EventStopper' -import {AppBskyEmbedExternal} from '@atproto/api' import {isNative} from 'platform/detection' -import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' +import {useExternalEmbedsPrefs} from 'state/preferences' +import {useModalControls} from 'state/modals' interface ShouldStartLoadRequest { url: string @@ -32,6 +42,8 @@ function PlaceholderOverlay({ isPlayerActive: boolean onPress: (event: GestureResponderEvent) => void }) { + const {_} = useLingui() + // If the player is active and not loading, we don't want to show the overlay. if (isPlayerActive && !isLoading) return null @@ -39,8 +51,8 @@ function PlaceholderOverlay({ <View style={[styles.layer, styles.overlayLayer]}> <Pressable accessibilityRole="button" - accessibilityLabel="Play Video" - accessibilityHint="" + accessibilityLabel={_(msg`Play Video`)} + accessibilityHint={_(msg`Play Video`)} onPress={onPress} style={[styles.overlayContainer, styles.topRadius]}> {!isPlayerActive ? ( @@ -77,31 +89,21 @@ function Player({ return ( <View style={[styles.layer, styles.playerLayer]}> <EventStopper> - {isNative && params.type === 'youtube_video' ? ( - <YoutubePlayer - videoId={params.videoId} - play - height={height} - onReady={onLoad} - webViewStyle={[styles.webview, styles.topRadius]} + <View style={{height, width: '100%'}}> + <WebView + javaScriptEnabled={true} + onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} + mediaPlaybackRequiresUserAction={false} + allowsInlineMediaPlayback + bounces={false} + allowsFullscreenVideo + nestedScrollEnabled + source={{uri: params.playerUri}} + onLoad={onLoad} + setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) + style={[styles.webview, styles.topRadius]} /> - ) : ( - <View style={{height, width: '100%'}}> - <WebView - javaScriptEnabled={true} - onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} - mediaPlaybackRequiresUserAction={false} - allowsInlineMediaPlayback - bounces={false} - allowsFullscreenVideo - nestedScrollEnabled - source={{uri: params.playerUri}} - onLoad={onLoad} - setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) - style={[styles.webview, styles.topRadius]} - /> - </View> - )} + </View> </EventStopper> </View> ) @@ -116,6 +118,10 @@ export function ExternalPlayer({ params: EmbedPlayerParams }) { const navigation = useNavigation<NavigationProp>() + const insets = useSafeAreaInsets() + const windowDims = useWindowDimensions() + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const {openModal} = useModalControls() const [isPlayerActive, setPlayerActive] = React.useState(false) const [isLoading, setIsLoading] = React.useState(true) @@ -124,34 +130,51 @@ export function ExternalPlayer({ height: 0, }) - const viewRef = React.useRef<View>(null) + const viewRef = useAnimatedRef() + + const frameCallback = useFrameCallback(() => { + const measurement = measure(viewRef) + if (!measurement) return + + const {height: winHeight, width: winWidth} = windowDims + + // Get the proper screen height depending on what is going on + const realWinHeight = isNative // If it is native, we always want the larger number + ? winHeight > winWidth + ? winHeight + : winWidth + : winHeight // On web, we always want the actual screen height + + const top = measurement.pageY + const bot = measurement.pageY + measurement.height + + // We can use the same logic on all platforms against the screenHeight that we get above + const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top + + if (!isVisible) { + runOnJS(setPlayerActive)(false) + } + }, false) // False here disables autostarting the callback // watch for leaving the viewport due to scrolling React.useEffect(() => { + // We don't want to do anything if the player isn't active + if (!isPlayerActive) return + // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will // continue playing. We need to watch for the blur event const unsubscribe = navigation.addListener('blur', () => { setPlayerActive(false) }) - const interval = setInterval(() => { - viewRef.current?.measure((x, y, w, h, pageX, pageY) => { - const window = Dimensions.get('window') - const top = pageY - const bot = pageY + h - const isVisible = isNative - ? top >= 0 && bot <= window.height - : !(top >= window.height || bot <= 0) - if (!isVisible) { - setPlayerActive(false) - } - }) - }, 1e3) + // Start watching for changes + frameCallback.setActive(true) + return () => { unsubscribe() - clearInterval(interval) + frameCallback.setActive(false) } - }, [viewRef, navigation]) + }, [navigation, isPlayerActive, frameCallback]) // calculate height for the player and the screen size const height = React.useMemo( @@ -168,12 +191,26 @@ export function ExternalPlayer({ setIsLoading(false) }, []) - const onPlayPress = React.useCallback((event: GestureResponderEvent) => { - // Prevent this from propagating upward on web - event.preventDefault() + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Prevent this from propagating upward on web + event.preventDefault() - setPlayerActive(true) - }, []) + if (externalEmbedsPrefs?.[params.source] === undefined) { + openModal({ + name: 'embed-consent', + source: params.source, + onAccept: () => { + setPlayerActive(true) + }, + }) + return + } + + setPlayerActive(true) + }, + [externalEmbedsPrefs, openModal, params.source], + ) // measure the layout to set sizing const onLayout = React.useCallback( @@ -187,7 +224,7 @@ export function ExternalPlayer({ ) return ( - <View + <Animated.View ref={viewRef} style={{height}} collapsable={false} @@ -205,7 +242,6 @@ export function ExternalPlayer({ accessibilityIgnoresInvertColors /> )} - <PlaceholderOverlay isLoading={isLoading} isPlayerActive={isPlayerActive} @@ -217,7 +253,7 @@ export function ExternalPlayer({ height={height} onLoad={onLoad} /> - </View> + </Animated.View> ) } @@ -248,4 +284,8 @@ const styles = StyleSheet.create({ webview: { backgroundColor: 'transparent', }, + gifContainer: { + width: '100%', + overflow: 'hidden', + }, }) diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index e793f983e..fbb89af27 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -98,6 +98,7 @@ export function QuoteEmbed({ return ( <Link style={[styles.container, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}} href={itemHref} title={itemTitle}> <PostMeta diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index c94ce9684..00a102e7b 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -63,7 +63,7 @@ export function PostEmbeds({ const mediaModeration = isModOnQuote ? {} : moderation const quoteModeration = isModOnQuote ? moderation : {} return ( - <View style={[styles.stackContainer, style]}> + <View style={style}> <PostEmbeds embed={embed.media} moderation={mediaModeration} /> <ContentHider moderation={quoteModeration}> <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} /> @@ -168,11 +168,14 @@ export function PostEmbeds({ const link = embed.external return ( - <View style={[styles.extOuter, pal.view, pal.border, style]}> - <Link asAnchor href={link.uri}> - <ExternalLinkEmbed link={link} /> - </Link> - </View> + <Link + asAnchor + anchorNoUnderline + href={link.uri} + style={[styles.extOuter, pal.view, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}}> + <ExternalLinkEmbed link={link} /> + </Link> ) } @@ -180,9 +183,6 @@ export function PostEmbeds({ } const styles = StyleSheet.create({ - stackContainer: { - gap: 6, - }, imagesContainer: { marginTop: 8, }, diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx index 089d3f0a8..221b9702c 100644 --- a/src/view/icons/index.tsx +++ b/src/view/icons/index.tsx @@ -29,9 +29,10 @@ import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight' import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle' import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck' import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck' +import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot' import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation' +import {faCirclePlay} from '@fortawesome/free-regular-svg-icons/faCirclePlay' import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser' -import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot' import {faClone} from '@fortawesome/free-solid-svg-icons/faClone' import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone' import {faComment} from '@fortawesome/free-regular-svg-icons/faComment' @@ -129,9 +130,10 @@ library.add( faCircle, faCircleCheck, farCircleCheck, + faCircleDot, faCircleExclamation, + faCirclePlay, faCircleUser, - faCircleDot, faClone, farClone, faComment, diff --git a/src/view/screens/DebugNew.tsx b/src/view/screens/DebugNew.tsx new file mode 100644 index 000000000..0b7c5f03b --- /dev/null +++ b/src/view/screens/DebugNew.tsx @@ -0,0 +1,541 @@ +import React from 'react' +import {View} from 'react-native' +import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' + +import {useSetColorMode} from '#/state/shell' +import * as tokens from '#/alf/tokens' +import {atoms as a, useTheme, useBreakpoints, ThemeProvider as Alf} from '#/alf' +import {Button, ButtonText} from '#/view/com/Button' +import {Text, H1, H2, H3, H4, H5, H6} from '#/view/com/Typography' + +function ThemeSelector() { + const setColorMode = useSetColorMode() + + return ( + <View style={[a.flex_row, a.gap_md]}> + <Button + type="secondary" + size="small" + onPress={() => setColorMode('system')}> + System + </Button> + <Button + type="secondary" + size="small" + onPress={() => setColorMode('light')}> + Light + </Button> + <Button + type="secondary" + size="small" + onPress={() => setColorMode('dark')}> + Dark + </Button> + </View> + ) +} + +function BreakpointDebugger() { + const t = useTheme() + const breakpoints = useBreakpoints() + + return ( + <View> + <H3 style={[a.pb_md]}>Breakpoint Debugger</H3> + <Text style={[a.pb_md]}> + Current breakpoint: {!breakpoints.gtMobile && <Text>mobile</Text>} + {breakpoints.gtMobile && !breakpoints.gtTablet && <Text>tablet</Text>} + {breakpoints.gtTablet && <Text>desktop</Text>} + </Text> + <Text + style={[a.p_md, t.atoms.bg_contrast_100, {fontFamily: 'monospace'}]}> + {JSON.stringify(breakpoints, null, 2)} + </Text> + </View> + ) +} + +function ThemedSection() { + const t = useTheme() + + return ( + <View style={[t.atoms.bg, a.gap_md, a.p_xl]}> + <H3 style={[a.font_bold]}>theme.atoms.text</H3> + <View style={[a.flex_1, t.atoms.border, a.border_t]} /> + <H3 style={[a.font_bold, t.atoms.text_contrast_700]}> + theme.atoms.text_contrast_700 + </H3> + <View style={[a.flex_1, t.atoms.border, a.border_t]} /> + <H3 style={[a.font_bold, t.atoms.text_contrast_500]}> + theme.atoms.text_contrast_500 + </H3> + <View style={[a.flex_1, t.atoms.border_contrast_500, a.border_t]} /> + + <View style={[a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_1, + t.atoms.bg, + a.align_center, + a.justify_center, + {height: 60}, + ]}> + <Text>theme.bg</Text> + </View> + <View + style={[ + a.flex_1, + t.atoms.bg_contrast_100, + a.align_center, + a.justify_center, + {height: 60}, + ]}> + <Text>theme.bg_contrast_100</Text> + </View> + </View> + <View style={[a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_1, + t.atoms.bg_contrast_200, + a.align_center, + a.justify_center, + {height: 60}, + ]}> + <Text>theme.bg_contrast_200</Text> + </View> + <View + style={[ + a.flex_1, + t.atoms.bg_contrast_300, + a.align_center, + a.justify_center, + {height: 60}, + ]}> + <Text>theme.bg_contrast_300</Text> + </View> + </View> + <View style={[a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_1, + t.atoms.bg_positive, + a.align_center, + a.justify_center, + {height: 60}, + ]}> + <Text>theme.bg_positive</Text> + </View> + <View + style={[ + a.flex_1, + t.atoms.bg_negative, + a.align_center, + a.justify_center, + {height: 60}, + ]}> + <Text>theme.bg_negative</Text> + </View> + </View> + </View> + ) +} + +export function DebugScreen() { + const t = useTheme() + + return ( + <ScrollView> + <CenteredView style={[t.atoms.bg]}> + <View style={[a.p_xl, a.gap_xxl, {paddingBottom: 200}]}> + <ThemeSelector /> + + <Alf theme="light"> + <ThemedSection /> + </Alf> + <Alf theme="dark"> + <ThemedSection /> + </Alf> + + <H1>Heading 1</H1> + <H2>Heading 2</H2> + <H3>Heading 3</H3> + <H4>Heading 4</H4> + <H5>Heading 5</H5> + <H6>Heading 6</H6> + + <Text style={[a.text_xxl]}>atoms.text_xxl</Text> + <Text style={[a.text_xl]}>atoms.text_xl</Text> + <Text style={[a.text_lg]}>atoms.text_lg</Text> + <Text style={[a.text_md]}>atoms.text_md</Text> + <Text style={[a.text_sm]}>atoms.text_sm</Text> + <Text style={[a.text_xs]}>atoms.text_xs</Text> + <Text style={[a.text_xxs]}>atoms.text_xxs</Text> + + <View style={[a.gap_md, a.align_start]}> + <Button> + {({state}) => ( + <View style={[a.p_md, a.rounded_full, t.atoms.bg_contrast_300]}> + <Text>Unstyled button, state: {JSON.stringify(state)}</Text> + </View> + )} + </Button> + + <Button type="primary" size="small"> + Button + </Button> + <Button type="secondary" size="small"> + Button + </Button> + + <Button type="primary" size="large"> + Button + </Button> + <Button type="secondary" size="large"> + Button + </Button> + + <Button type="secondary" size="small"> + {({type, size}) => ( + <> + <FontAwesomeIcon icon={['fas', 'plus']} size={12} /> + <ButtonText type={type} size={size}> + With an icon + </ButtonText> + </> + )} + </Button> + <Button type="primary" size="large"> + {({state: _state, ...rest}) => ( + <> + <FontAwesomeIcon icon={['fas', 'plus']} /> + <ButtonText {...rest}>With an icon</ButtonText> + </> + )} + </Button> + </View> + + <View style={[a.gap_md]}> + <View style={[a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_0}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_1000}, + ]} + /> + </View> + + <View style={[a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_0}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_1000}, + ]} + /> + </View> + <View style={[a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_0}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_1000}, + ]} + /> + </View> + <View style={[a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_0}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_1000}, + ]} + /> + </View> + </View> + + <View> + <H3 style={[a.pb_md, a.font_bold]}>Spacing</H3> + + <View style={[a.gap_md]}> + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>xxs (2px)</Text> + <View style={[a.flex_1, a.pt_xxs, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>xs (4px)</Text> + <View style={[a.flex_1, a.pt_xs, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>sm (8px)</Text> + <View style={[a.flex_1, a.pt_sm, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>md (12px)</Text> + <View style={[a.flex_1, a.pt_md, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>lg (18px)</Text> + <View style={[a.flex_1, a.pt_lg, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>xl (24px)</Text> + <View style={[a.flex_1, a.pt_xl, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>xxl (32px)</Text> + <View style={[a.flex_1, a.pt_xxl, t.atoms.bg_contrast_300]} /> + </View> + </View> + </View> + + <BreakpointDebugger /> + </View> + </CenteredView> + </ScrollView> + ) +} diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index b8033f0b4..0e20a9cf7 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -19,6 +19,7 @@ import {useSession} from '#/state/session' import {loadString, saveString} from '#/lib/storage' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {clamp} from '#/lib/numbers' +import {PROD_DEFAULT_FEED} from '#/lib/constants' type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> export function HomeScreen(props: Props) { @@ -109,7 +110,9 @@ function HomeScreenReady({ const homeFeedParams = React.useMemo<FeedParams>(() => { return { mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled), - mergeFeedSources: preferences.feeds.saved, + mergeFeedSources: preferences.feedViewPrefs.lab_mergeFeedEnabled + ? preferences.feeds.saved + : [PROD_DEFAULT_FEED('whats-hot')], } }, [preferences]) diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx index 1bf8db2e0..96bb46cef 100644 --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -62,7 +62,7 @@ export function ModerationScreen({}: Props) { ]} testID="moderationScreen"> <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> - <ScrollView> + <ScrollView contentContainerStyle={[styles.noBorder]}> <View style={styles.spacer} /> <TouchableOpacity testID="contentFilteringBtn" @@ -275,4 +275,10 @@ const styles = StyleSheet.create({ borderRadius: 30, marginRight: 12, }, + noBorder: { + borderBottomWidth: 0, + borderRightWidth: 0, + borderLeftWidth: 0, + borderTopWidth: 0, + }, }) diff --git a/src/view/screens/PreferencesExternalEmbeds.tsx b/src/view/screens/PreferencesExternalEmbeds.tsx new file mode 100644 index 000000000..24e7d56df --- /dev/null +++ b/src/view/screens/PreferencesExternalEmbeds.tsx @@ -0,0 +1,138 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {s} from 'lib/styles' +import {Text} from '../com/util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics/analytics' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import { + EmbedPlayerSource, + externalEmbedLabels, +} from '#/lib/strings/embed-player' +import {useSetMinimalShellMode} from '#/state/shell' +import {Trans} from '@lingui/macro' +import {ScrollView} from '../com/util/Views' +import { + useExternalEmbedsPrefs, + useSetExternalEmbedPref, +} from 'state/preferences' +import {ToggleButton} from 'view/com/util/forms/ToggleButton' +import {SimpleViewHeader} from '../com/util/SimpleViewHeader' + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'PreferencesExternalEmbeds' +> +export function PreferencesExternalEmbeds({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {screen} = useAnalytics() + const {isMobile} = useWebMediaQueries() + + useFocusEffect( + React.useCallback(() => { + screen('PreferencesExternalEmbeds') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) + + return ( + <View style={s.hContentRegion} testID="preferencesExternalEmbedsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={[ + pal.border, + {borderBottomWidth: 1}, + !isMobile && {borderLeftWidth: 1, borderRightWidth: 1}, + ]}> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + <Trans>External Media Preferences</Trans> + </Text> + <Text style={pal.textLight}> + <Trans>Customize media from external sites.</Trans> + </Text> + </View> + </SimpleViewHeader> + <ScrollView + // @ts-ignore web only -prf + dataSet={{'stable-gutters': 1}} + contentContainerStyle={[pal.viewLight, {paddingBottom: 200}]}> + <View style={[pal.view]}> + <View style={styles.infoCard}> + <Text style={pal.text}> + <Trans> + External media may allow websites to collect information about + you and your device. No information is sent or requested until + you press the "play" button. + </Trans> + </Text> + </View> + </View> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + Enable media players for + </Text> + {Object.entries(externalEmbedLabels).map(([key, label]) => ( + <PrefSelector + source={key as EmbedPlayerSource} + label={label} + key={key} + /> + ))} + </ScrollView> + </View> + ) +} + +function PrefSelector({ + source, + label, +}: { + source: EmbedPlayerSource + label: string +}) { + const pal = usePalette('default') + const setExternalEmbedPref = useSetExternalEmbedPref() + const sources = useExternalEmbedsPrefs() + + return ( + <View> + <View style={[pal.view, styles.toggleCard]}> + <ToggleButton + type="default-light" + label={label} + labelType="lg" + isSelected={sources?.[source] === 'show'} + onPress={() => + setExternalEmbedPref( + source, + sources?.[source] === 'show' ? 'hide' : 'show', + ) + } + /> + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + heading: { + paddingHorizontal: 18, + paddingTop: 14, + paddingBottom: 14, + }, + spacer: { + height: 8, + }, + infoCard: { + paddingHorizontal: 20, + paddingVertical: 14, + }, + toggleCard: { + paddingVertical: 8, + paddingHorizontal: 6, + marginBottom: 1, + }, +}) diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 20ef72923..874272831 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -29,6 +29,7 @@ function RepliesThresholdInput({ const pal = usePalette('default') const [value, setValue] = useState(initialValue) const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation() + const preValue = React.useRef(initialValue) const save = React.useMemo( () => debounce( @@ -46,7 +47,12 @@ function RepliesThresholdInput({ <Slider value={value} onValueChange={(v: number | number[]) => { - const threshold = Math.floor(Array.isArray(v) ? v[0] : v) + let threshold = Array.isArray(v) ? v[0] : v + if (threshold > preValue.current) threshold = Math.floor(threshold) + else threshold = Math.ceil(threshold) + + preValue.current = threshold + setValue(threshold) save(threshold) }} diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx index 73d941932..35a010b55 100644 --- a/src/view/screens/PreferencesThreads.tsx +++ b/src/view/screens/PreferencesThreads.tsx @@ -75,10 +75,16 @@ export function PreferencesThreads({navigation}: Props) { <RadioGroup type="default-light" items={[ - {key: 'oldest', label: 'Oldest replies first'}, - {key: 'newest', label: 'Newest replies first'}, - {key: 'most-likes', label: 'Most-liked replies first'}, - {key: 'random', label: 'Random (aka "Poster\'s Roulette")'}, + {key: 'oldest', label: _(msg`Oldest replies first`)}, + {key: 'newest', label: _(msg`Newest replies first`)}, + { + key: 'most-likes', + label: _(msg`Most-liked replies first`), + }, + { + key: 'random', + label: _(msg`Random (aka "Poster's Roulette")`), + }, ]} onSelect={key => setThreadViewPrefs({sort: key})} initialSelection={preferences?.threadViewPrefs?.sort} @@ -97,7 +103,7 @@ export function PreferencesThreads({navigation}: Props) { </Text> <ToggleButton type="default-light" - label={prioritizeFollowedUsers ? 'Yes' : 'No'} + label={prioritizeFollowedUsers ? _(msg`Yes`) : _(msg`No`)} isSelected={prioritizeFollowedUsers} onPress={() => setThreadViewPrefs({ @@ -120,7 +126,7 @@ export function PreferencesThreads({navigation}: Props) { </Text> <ToggleButton type="default-light" - label={treeViewEnabled ? 'Yes' : 'No'} + label={treeViewEnabled ? _(msg`Yes`) : _(msg`No`)} isSelected={treeViewEnabled} onPress={() => setThreadViewPrefs({ diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index bbac30689..8a16ffdf2 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -82,7 +82,7 @@ export function SavedFeeds({}: Props) { isTabletOrDesktop && styles.desktopContainer, ]}> <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder /> - <ScrollView style={s.flex1}> + <ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}> <View style={[pal.text, pal.border, styles.title]}> <Text type="title" style={pal.text}> <Trans>Pinned Feeds</Trans> @@ -288,7 +288,7 @@ function ListItem({ <FeedSourceCard key={feedUri} feedUri={feedUri} - style={styles.noBorder} + style={styles.noTopBorder} showSaveBtn showMinimalPlaceholder /> @@ -344,7 +344,7 @@ const styles = StyleSheet.create({ webArrowUpButton: { marginBottom: 10, }, - noBorder: { + noTopBorder: { borderTopWidth: 0, }, footerText: { @@ -352,4 +352,10 @@ const styles = StyleSheet.create({ paddingTop: 22, paddingBottom: 100, }, + noBorder: { + borderBottomWidth: 0, + borderRightWidth: 0, + borderLeftWidth: 0, + borderTopWidth: 0, + }, }) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index f85ce44b2..d413e234f 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -431,7 +431,7 @@ export function SettingsScreen({}: Props) { <View style={[pal.view, styles.toggleCard]}> <ToggleButton type="default-light" - label="Require alt text before posting" + label={_(msg`Require alt text before posting`)} labelType="lg" isSelected={requireAltTextEnabled} onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)} @@ -575,6 +575,39 @@ export function SettingsScreen({}: Props) { <Trans>Moderation</Trans> </Text> </TouchableOpacity> + + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Privacy</Trans> + </Text> + + <TouchableOpacity + testID="externalEmbedsBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={ + isSwitchingAccounts + ? undefined + : () => navigation.navigate('PreferencesExternalEmbeds') + } + accessibilityRole="button" + accessibilityHint="" + accessibilityLabel={_(msg`Opens external embeds settings`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon={['far', 'circle-play']} + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>External Media Preferences</Trans> + </Text> + </TouchableOpacity> + <View style={styles.spacer20} /> <Text type="xl-bold" style={[pal.text, styles.heading]}> diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index 73f9f540e..ed64bc799 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -5,6 +5,10 @@ import {ComposePost} from '../com/composer/Composer' import {useComposerState} from 'state/shell/composer' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import { + EmojiPicker, + EmojiPickerState, +} from 'view/com/composer/text-input/web/EmojiPicker.web.tsx' const BOTTOM_BAR_HEIGHT = 61 @@ -13,6 +17,26 @@ export function Composer({}: {winHeight: number}) { const {isMobile} = useWebMediaQueries() const state = useComposerState() + const [pickerState, setPickerState] = React.useState<EmojiPickerState>({ + isOpen: false, + pos: {top: 0, left: 0, right: 0, bottom: 0}, + }) + + const onOpenPicker = React.useCallback((pos: DOMRect | undefined) => { + if (!pos) return + setPickerState({ + isOpen: true, + pos, + }) + }, []) + + const onClosePicker = React.useCallback(() => { + setPickerState(prev => ({ + ...prev, + isOpen: false, + })) + }, []) + // rendering // = @@ -41,8 +65,10 @@ export function Composer({}: {winHeight: number}) { quote={state.quote} onPost={state.onPost} mention={state.mention} + openPicker={onOpenPicker} /> </Animated.View> + <EmojiPicker state={pickerState} close={onClosePicker} /> </Animated.View> ) } diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 02b742dc9..894624a6e 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -180,14 +180,14 @@ const styles = StyleSheet.create({ position: 'absolute', // @ts-ignore web only left: 'calc(50vw + 320px)', - width: 304, + width: 300, maxHeight: '100%', overflowY: 'auto', }, message: { paddingVertical: 18, - paddingHorizontal: 10, + paddingHorizontal: 12, }, messageLine: { marginBottom: 10, @@ -195,7 +195,7 @@ const styles = StyleSheet.create({ inviteCodes: { borderTopWidth: 1, - paddingHorizontal: 16, + paddingHorizontal: 12, paddingVertical: 12, flexDirection: 'row', }, |