From 1cf49517b517e8bee90bf937d033dff35cc9f690 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 7 Mar 2024 09:04:02 -0800 Subject: [PATCH 01/13] Allow all encoding for hashtags in URL (#3131) --- src/components/TagMenu/index.tsx | 4 ++-- src/components/TagMenu/index.web.tsx | 4 ++-- src/screens/Hashtag.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index c9ced9a5..849a3f42 100644 --- a/src/components/TagMenu/index.tsx +++ b/src/components/TagMenu/index.tsx @@ -98,7 +98,7 @@ export function TagMenu({ control.close(() => { navigation.push('Hashtag', { - tag: tag.replaceAll('#', '%23'), + tag: encodeURIComponent(tag), }) }) @@ -153,7 +153,7 @@ export function TagMenu({ control.close(() => { navigation.push('Hashtag', { - tag: tag.replaceAll('#', '%23'), + tag: encodeURIComponent(tag), author: authorHandle, }) }) diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx index a0dc2bce..8245bd01 100644 --- a/src/components/TagMenu/index.web.tsx +++ b/src/components/TagMenu/index.web.tsx @@ -66,7 +66,7 @@ export function TagMenu({ label: _(msg`See ${truncatedTag} posts`), onPress() { navigation.push('Hashtag', { - tag: tag.replaceAll('#', '%23'), + tag: encodeURIComponent(tag), }) }, testID: 'tagMenuSearch', @@ -83,7 +83,7 @@ export function TagMenu({ label: _(msg`See ${truncatedTag} posts by user`), onPress() { navigation.push('Hashtag', { - tag: tag.replaceAll('#', '%23'), + tag: encodeURIComponent(tag), author: authorHandle, }) }, diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx index f1b81737..776cc585 100644 --- a/src/screens/Hashtag.tsx +++ b/src/screens/Hashtag.tsx @@ -42,7 +42,7 @@ export default function HashtagScreen({ const [isPTR, setIsPTR] = React.useState(false) const fullTag = React.useMemo(() => { - return `#${tag.replaceAll('%23', '#')}` + return `#${decodeURIComponent(tag)}` }, [tag]) const queryParam = React.useMemo(() => { @@ -83,7 +83,7 @@ export default function HashtagScreen({ const onShare = React.useCallback(() => { const url = new URL('https://bsky.app') - url.pathname = `/hashtag/${tag}` + url.pathname = `/hashtag/${decodeURIComponent(tag)}` if (author) { url.searchParams.set('author', author) } From c8e0fa9c97d276a917996627f163c267783e9411 Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 8 Mar 2024 04:13:36 +0000 Subject: [PATCH 02/13] Mark bundle start time on web (#3147) * Mark bundle start time on web * TS --- index.web.js | 2 ++ src/platform/markBundleStartTime.web.ts | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 src/platform/markBundleStartTime.web.ts diff --git a/index.web.js b/index.web.js index 4dee831c..96237345 100644 --- a/index.web.js +++ b/index.web.js @@ -1,3 +1,5 @@ +import '#/platform/markBundleStartTime' + import '#/platform/polyfills' import {registerRootComponent} from 'expo' import {doPolyfill} from '#/lib/api/api-polyfill' diff --git a/src/platform/markBundleStartTime.web.ts b/src/platform/markBundleStartTime.web.ts new file mode 100644 index 00000000..cd64c9f1 --- /dev/null +++ b/src/platform/markBundleStartTime.web.ts @@ -0,0 +1,2 @@ +// @ts-ignore Web-only. On RN, this is set by Metro. +window.__BUNDLE_START_TIME__ = performance.now() From 31826633cb1d1180875b20e218a39ce341ab2ec0 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 7 Mar 2024 20:14:24 -0800 Subject: [PATCH 03/13] rm waitlist modal, button during sign up (#3148) --- src/view/com/auth/create/Step1.tsx | 27 +--- src/view/com/modals/Modal.tsx | 4 - src/view/com/modals/Modal.web.tsx | 3 - src/view/com/modals/Waitlist.tsx | 190 ----------------------------- 4 files changed, 2 insertions(+), 222 deletions(-) delete mode 100644 src/view/com/modals/Waitlist.tsx diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index 4c701848..c1bec535 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -4,7 +4,6 @@ import { Keyboard, StyleSheet, TouchableOpacity, - TouchableWithoutFeedback, View, } from 'react-native' import {CreateAccountState, CreateAccountDispatch, is18} from './state' @@ -19,7 +18,6 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {isWeb} from 'platform/detection' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' import {logger} from '#/logger' import { FontAwesomeIcon, @@ -49,7 +47,6 @@ export function Step1({ }) { const pal = usePalette('default') const {_} = useLingui() - const {openModal} = useModalControls() const serverInputControl = useDialogControl() const onPressSelectService = React.useCallback(() => { @@ -57,10 +54,6 @@ export function Step1({ Keyboard.dismiss() }, [serverInputControl]) - const onPressWaitlist = React.useCallback(() => { - openModal({name: 'waitlist'}) - }, [openModal]) - const birthDate = React.useMemo(() => { return sanitizeDate(uiState.birthDate) }, [uiState.birthDate]) @@ -164,23 +157,7 @@ export function Step1({ )} - {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( - - - Don't have an invite code?{' '} - - - - - Join the waitlist. - - - - - ) : ( + {uiState.inviteCode ? ( <> )} - )} + ) : undefined} )} diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 8da91c75..100444ff 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -20,7 +20,6 @@ import * as ReportModal from './report/Modal' import * as AppealLabelModal from './AppealLabel' import * as DeleteAccountModal from './DeleteAccount' import * as ChangeHandleModal from './ChangeHandle' -import * as WaitlistModal from './Waitlist' import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' @@ -109,9 +108,6 @@ export function ModalsContainer() { } else if (activeModal?.name === 'change-handle') { snapPoints = ChangeHandleModal.snapPoints element = - } else if (activeModal?.name === 'waitlist') { - snapPoints = WaitlistModal.snapPoints - element = } else if (activeModal?.name === 'invite-codes') { snapPoints = InviteCodesModal.snapPoints element = diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 97a60be9..0ced894a 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -22,7 +22,6 @@ import * as CropImageModal from './crop-image/CropImage.web' import * as AltTextImageModal from './AltImage' import * as EditImageModal from './EditImage' import * as ChangeHandleModal from './ChangeHandle' -import * as WaitlistModal from './Waitlist' import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' @@ -105,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'change-handle') { element = - } else if (modal.name === 'waitlist') { - element = } else if (modal.name === 'invite-codes') { element = } else if (modal.name === 'add-app-password') { diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx deleted file mode 100644 index 263dd27a..00000000 --- a/src/view/com/modals/Waitlist.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {TextInput} from './util' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import LinearGradient from 'react-native-linear-gradient' -import {Text} from '../util/text/Text' -import {s, gradients} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {cleanError} from 'lib/strings/errors' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' - -export const snapPoints = ['80%'] - -export function Component({}: {}) { - const pal = usePalette('default') - const theme = useTheme() - const {_} = useLingui() - const {closeModal} = useModalControls() - const [email, setEmail] = React.useState('') - const [isEmailSent, setIsEmailSent] = React.useState(false) - const [isProcessing, setIsProcessing] = React.useState(false) - const [error, setError] = React.useState('') - - const onPressSignup = async () => { - setError('') - setIsProcessing(true) - try { - const res = await fetch('https://bsky.app/api/waitlist', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({email}), - }) - const resBody = await res.json() - if (resBody.success) { - setIsEmailSent(true) - } else { - setError( - resBody.error || - _(msg`Something went wrong. Check your email and try again.`), - ) - } - } catch (e: any) { - setError(cleanError(e)) - } - setIsProcessing(false) - } - const onCancel = () => { - closeModal() - } - - return ( - - - - Join the waitlist - - - - Bluesky uses invites to build a healthier community. If you don't - know anybody with an invite, you can sign up for the waitlist and - we'll send one soon. - - - - {error ? ( - - - - ) : undefined} - {isProcessing ? ( - - - - ) : isEmailSent ? ( - - - - - Your email has been saved! We'll be in touch soon. - - - - ) : ( - <> - - - - Join Waitlist - - - - - - Cancel - - - - )} - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - innerContainer: { - paddingBottom: 20, - }, - title: { - textAlign: 'center', - marginTop: 12, - marginBottom: 12, - }, - description: { - textAlign: 'center', - paddingHorizontal: 22, - marginBottom: 10, - }, - textInput: { - borderWidth: 1, - borderRadius: 6, - paddingHorizontal: 16, - paddingVertical: 12, - fontSize: 20, - marginHorizontal: 20, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - marginHorizontal: 20, - }, - error: { - borderRadius: 6, - marginHorizontal: 20, - marginBottom: 20, - }, -}) From dd86d0964d391e9748843aa1a6400d73c3a6d9f9 Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 8 Mar 2024 04:33:42 +0000 Subject: [PATCH 04/13] Enable gating and experimentation on native, send init event (#3149) * Add the mobile fork * Add init event --- src/Navigation.tsx | 4 +++ src/lib/statsig/statsig.tsx | 62 ++++++++++++++++++++++++++++++--- src/lib/statsig/statsig.web.tsx | 14 +++++++- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index b30f8f98..8a9f69b5 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -78,6 +78,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack import {msg} from '@lingui/macro' import {i18n, MessageDescriptor} from '@lingui/core' import HashtagScreen from '#/screens/Hashtag' +import {logEvent} from './lib/statsig/statsig' const navigationRef = createNavigationContainerRef() @@ -649,11 +650,14 @@ function logModuleInitTime() { return } didInit = true + const initMs = Math.round( // @ts-ignore Emitted by Metro in the bundle prelude performance.now() - global.__BUNDLE_START_TIME__, ) console.log(`Time to first paint: ${initMs} ms`) + logEvent('init', initMs) + if (__DEV__) { // This log is noisy, so keep false committed const shouldLog = false diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 88a57c3f..43822dda 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -1,11 +1,63 @@ import React from 'react' +import { + Statsig, + StatsigProvider, + useGate as useStatsigGate, +} from 'statsig-react-native-expo' +import {useSession} from '../../state/session' +import {sha256} from 'js-sha256' -export function useGate(_gateName: string) { - // Not enabled for native yet. - return false +const statsigOptions = { + environment: { + tier: process.env.NODE_ENV === 'development' ? 'development' : 'production', + }, + // Don't block on waiting for network. The fetched config will kick in on next load. + // This ensures the UI is always consistent and doesn't update mid-session. + // Note this makes cold load (no local storage) and private mode return `false` for all gates. + initTimeoutMs: 1, +} + +export function logEvent( + eventName: string, + value?: string | number | null, + metadata?: Record | null, +) { + Statsig.logEvent(eventName, value, metadata) +} + +export function useGate(gateName: string) { + const {isLoading, value} = useStatsigGate(gateName) + if (isLoading) { + // This should not happen because of waitForInitialization={true}. + console.error('Did not expected isLoading to ever be true.') + } + return value +} + +function toStatsigUser(did: string | undefined) { + let userID: string | undefined + if (did) { + userID = sha256(did) + } + return {userID} } export function Provider({children}: {children: React.ReactNode}) { - // Not enabled for native yet. - return children + const {currentAccount} = useSession() + const currentStatsigUser = React.useMemo( + () => toStatsigUser(currentAccount?.did), + [currentAccount?.did], + ) + return ( + + {children} + + ) } diff --git a/src/lib/statsig/statsig.web.tsx b/src/lib/statsig/statsig.web.tsx index 6508131c..fc66e8d9 100644 --- a/src/lib/statsig/statsig.web.tsx +++ b/src/lib/statsig/statsig.web.tsx @@ -1,5 +1,9 @@ import React from 'react' -import {StatsigProvider, useGate as useStatsigGate} from 'statsig-react' +import { + Statsig, + StatsigProvider, + useGate as useStatsigGate, +} from 'statsig-react' import {useSession} from '../../state/session' import {sha256} from 'js-sha256' @@ -13,6 +17,14 @@ const statsigOptions = { initTimeoutMs: 1, } +export function logEvent( + eventName: string, + value?: string | number | null, + metadata?: Record | null, +) { + Statsig.logEvent(eventName, value, metadata) +} + export function useGate(gateName: string) { const {isLoading, value} = useStatsigGate(gateName) if (isLoading) { From 8f623c3bdf8dbbdc4c4f10f19b0b2c134b4160cb Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 8 Mar 2024 14:45:59 -0600 Subject: [PATCH 05/13] Refactor `PostDropdownBtn` to use new `Menu` (#3112) * Refactor PostDropdownBtn (cherry picked from commit 0adf6cb75e3d4b7c1630cf6153c0d7e289e1b859) * Update icons (cherry picked from commit ac89ef9b28721c00736b1388455f3f5f092de0ad) * Port over fixes * fix scrollbar disappearing * Try CSS solution * Disable arrow for now --------- Co-authored-by: Hailey --- ...bubbleQuestion_stroke2_corner0_rounded.svg | 1 + .../icons/filter_stroke2_corner0_rounded.svg | 1 + ...akerVolumeFull_stroke2_corner0_rounded.svg | 1 + .../icons/trash_stroke2_corner0_rounded.svg | 1 + .../icons/warning_stroke2_corner0_rounded.svg | 1 + src/components/Menu/index.web.tsx | 8 +- src/components/icons/Bubble.tsx | 5 + src/components/icons/Filter.tsx | 5 + src/components/icons/Speaker.tsx | 5 + src/components/icons/Trash.tsx | 5 + src/components/icons/Warning.tsx | 5 + src/view/com/util/EventStopper.tsx | 11 +- src/view/com/util/forms/PostDropdownBtn.tsx | 376 ++++++++++-------- src/view/com/util/post-ctrls/PostCtrls.tsx | 1 + 14 files changed, 249 insertions(+), 177 deletions(-) create mode 100644 assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg create mode 100644 assets/icons/filter_stroke2_corner0_rounded.svg create mode 100644 assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg create mode 100644 assets/icons/trash_stroke2_corner0_rounded.svg create mode 100644 assets/icons/warning_stroke2_corner0_rounded.svg create mode 100644 src/components/icons/Bubble.tsx create mode 100644 src/components/icons/Filter.tsx create mode 100644 src/components/icons/Speaker.tsx create mode 100644 src/components/icons/Trash.tsx create mode 100644 src/components/icons/Warning.tsx diff --git a/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..0bfcc48a --- /dev/null +++ b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/filter_stroke2_corner0_rounded.svg b/assets/icons/filter_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..1fbcfc57 --- /dev/null +++ b/assets/icons/filter_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..81357a12 --- /dev/null +++ b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/trash_stroke2_corner0_rounded.svg b/assets/icons/trash_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..d4b32f81 --- /dev/null +++ b/assets/icons/trash_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/warning_stroke2_corner0_rounded.svg b/assets/icons/warning_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..d5b6f13d --- /dev/null +++ b/assets/icons/warning_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index ca2e4056..054e51b0 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -92,10 +92,8 @@ export function Trigger({children, label, style}: TriggerProps) { accessibilityLabel={label} onFocus={onFocus} onBlur={onBlur} - style={flatten([style, web({outline: 0})])} - onPointerDown={() => { - control.open() - }} + style={flatten([style, focused && web({outline: 0})])} + onPointerDown={() => control.open()} {...web({ onMouseEnter, onMouseLeave, @@ -131,6 +129,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) { {children} + {/* Disabled until we can fix positioning ) { .backgroundColor } /> + */} ) diff --git a/src/components/icons/Bubble.tsx b/src/components/icons/Bubble.tsx new file mode 100644 index 00000000..d4e08f6d --- /dev/null +++ b/src/components/icons/Bubble.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const BubbleQuestion_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z', +}) diff --git a/src/components/icons/Filter.tsx b/src/components/icons/Filter.tsx new file mode 100644 index 00000000..02ac1c71 --- /dev/null +++ b/src/components/icons/Filter.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Filter_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v4a1 1 0 0 1-.293.707L15 14.414V20a1 1 0 0 1-.758.97l-4 1A1 1 0 0 1 9 21v-6.586L3.293 8.707A1 1 0 0 1 3 8V4Zm2 1v2.586l5.707 5.707A1 1 0 0 1 11 14v5.72l2-.5V14a1 1 0 0 1 .293-.707L19 7.586V5H5Z', +}) diff --git a/src/components/icons/Speaker.tsx b/src/components/icons/Speaker.tsx new file mode 100644 index 00000000..365d5e11 --- /dev/null +++ b/src/components/icons/Speaker.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const SpeakerVolumeFull_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12.472 3.118A1 1 0 0 1 13 4v16a1 1 0 0 1-1.555.832L5.697 17H2a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h3.697l5.748-3.832a1 1 0 0 1 1.027-.05ZM11 5.868 6.555 8.833A1 1 0 0 1 6 9H3v6h3a1 1 0 0 1 .555.168L11 18.131V5.87Zm7.364-1.645a1 1 0 0 1 1.414 0A10.969 10.969 0 0 1 23 12c0 3.037-1.232 5.788-3.222 7.778a1 1 0 1 1-1.414-1.414A8.969 8.969 0 0 0 21 12a8.969 8.969 0 0 0-2.636-6.364 1 1 0 0 1 0-1.414Zm-3.182 3.181a1 1 0 0 1 1.414 0A6.483 6.483 0 0 1 18.5 12a6.483 6.483 0 0 1-1.904 4.597 1 1 0 0 1-1.414-1.415A4.483 4.483 0 0 0 16.5 12a4.483 4.483 0 0 0-1.318-3.182 1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/Trash.tsx b/src/components/icons/Trash.tsx new file mode 100644 index 00000000..d09a3311 --- /dev/null +++ b/src/components/icons/Trash.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Trash_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M7.416 5H3a1 1 0 0 0 0 2h1.064l.938 14.067A1 1 0 0 0 6 22h12a1 1 0 0 0 .998-.933L19.936 7H21a1 1 0 1 0 0-2h-4.416a5 5 0 0 0-9.168 0Zm2.348 0h4.472c-.55-.614-1.348-1-2.236-1-.888 0-1.687.386-2.236 1Zm6.087 2H6.07l.867 13h10.128l.867-13h-2.036a1 1 0 0 1-.044 0ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z', +}) diff --git a/src/components/icons/Warning.tsx b/src/components/icons/Warning.tsx new file mode 100644 index 00000000..fc84b289 --- /dev/null +++ b/src/components/icons/Warning.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Warning_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z', +}) diff --git a/src/view/com/util/EventStopper.tsx b/src/view/com/util/EventStopper.tsx index e743e89b..8f5f5cf5 100644 --- a/src/view/com/util/EventStopper.tsx +++ b/src/view/com/util/EventStopper.tsx @@ -8,7 +8,14 @@ import {View, ViewStyle} from 'react-native' export function EventStopper({ children, style, -}: React.PropsWithChildren<{style?: ViewStyle | ViewStyle[]}>) { + onKeyDown = true, +}: React.PropsWithChildren<{ + style?: ViewStyle | ViewStyle[] + /** + * Default `true`. Set to `false` to allow onKeyDown to propagate + */ + onKeyDown?: boolean +}>) { const stop = (e: any) => { e.stopPropagation() } @@ -18,7 +25,7 @@ export function EventStopper({ onTouchEnd={stop} // @ts-ignore web only -prf onClick={stop} - onKeyDown={stop} + onKeyDown={onKeyDown ? stop : undefined} style={style}> {children} diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 09850a7f..6f2ae55b 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,5 +1,11 @@ import React, {memo} from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' +import { + StyleProp, + ViewStyle, + Pressable, + View, + PressableProps, +} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' @@ -12,10 +18,6 @@ import { import {toShareUrl} from 'lib/strings/url-helpers' import {useTheme} from 'lib/ThemeContext' import {shareUrl} from 'lib/sharing' -import { - NativeDropdown, - DropdownItem as NativeDropdownItem, -} from './NativeDropdown' import * as Toast from '../Toast' import {EventStopper} from '../EventStopper' import {useModalControls} from '#/state/modals' @@ -36,6 +38,19 @@ import {isWeb} from '#/platform/detection' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {atoms as a, useTheme as useAlf, web} from '#/alf' +import * as Menu from '#/components/Menu' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' +import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' + let PostDropdownBtn = ({ testID, postAuthor, @@ -45,6 +60,7 @@ let PostDropdownBtn = ({ richText, style, showAppealLabelItem, + hitSlop, }: { testID: string postAuthor: AppBskyActorDefs.ProfileViewBasic @@ -54,9 +70,11 @@ let PostDropdownBtn = ({ richText: RichTextAPI style?: StyleProp showAppealLabelItem?: boolean + hitSlop?: PressableProps['hitSlop'] }): React.ReactNode => { const {hasSession, currentAccount} = useSession() const theme = useTheme() + const alf = useAlf() const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl const {openModal} = useModalControls() @@ -151,173 +169,189 @@ let PostDropdownBtn = ({ hidePost({uri: postUri}) }, [postUri, hidePost]) - const dropdownItems: NativeDropdownItem[] = [ - { - label: _(msg`Translate`), - onPress() { - onOpenTranslate() - }, - testID: 'postDropdownTranslateBtn', - icon: { - ios: { - name: 'character.book.closed', - }, - android: 'ic_menu_sort_alphabetically', - web: 'language', - }, - }, - { - label: _(msg`Copy post text`), - onPress() { - onCopyPostText() - }, - testID: 'postDropdownCopyTextBtn', - icon: { - ios: { - name: 'doc.on.doc', - }, - android: 'ic_menu_edit', - web: ['far', 'paste'], - }, - }, - { - label: isWeb ? _(msg`Copy link to post`) : _(msg`Share`), - onPress() { - const url = toShareUrl(href) - shareUrl(url) - }, - testID: 'postDropdownShareBtn', - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', - }, - }, - hasSession && { - label: 'separator', - }, - hasSession && { - label: isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`), - onPress() { - onToggleThreadMute() - }, - testID: 'postDropdownMuteThreadBtn', - icon: { - ios: { - name: 'speaker.slash', - }, - android: 'ic_lock_silent_mode', - web: 'comment-slash', - }, - }, - hasSession && { - label: _(msg`Mute words & tags`), - onPress() { - mutedWordsDialogControl.open() - }, - testID: 'postDropdownMuteWordsBtn', - icon: { - ios: { - name: 'speaker.slash', - }, - android: 'ic_lock_silent_mode', - web: 'filter', - }, - }, - hasSession && - !isAuthor && - !isPostHidden && { - label: _(msg`Hide post`), - onPress() { - openModal({ - name: 'confirm', - title: _(msg`Hide this post?`), - message: _(msg`This will hide this post from your feeds.`), - onPressConfirm: onHidePost, - }) - }, - testID: 'postDropdownHideBtn', - icon: { - ios: { - name: 'eye.slash', - }, - android: 'ic_menu_delete', - web: ['far', 'eye-slash'], - }, - }, - { - label: 'separator', - }, - !isAuthor && - hasSession && { - label: _(msg`Report post`), - onPress() { - openModal({ - name: 'report', - uri: postUri, - cid: postCid, - }) - }, - testID: 'postDropdownReportBtn', - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }, - isAuthor && { - label: _(msg`Delete post`), - onPress() { - openModal({ - name: 'confirm', - title: _(msg`Delete this post?`), - message: _(msg`Are you sure? This cannot be undone.`), - onPressConfirm: onDeletePost, - }) - }, - testID: 'postDropdownDeleteBtn', - icon: { - ios: { - name: 'trash', - }, - android: 'ic_menu_delete', - web: ['far', 'trash-can'], - }, - }, - showAppealLabelItem && { - label: 'separator', - }, - showAppealLabelItem && { - label: _(msg`Appeal content warning`), - onPress() { - openModal({name: 'appeal-label', uri: postUri, cid: postCid}) - }, - testID: 'postDropdownAppealBtn', - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }, - ].filter(Boolean) as NativeDropdownItem[] - return ( - - - - - - + + + + {({props, state}) => { + const styles = [ + style, + a.rounded_full, + (state.hovered || state.focused || state.pressed) && [ + web({outline: 0}), + alf.atoms.bg_contrast_25, + ], + ] + return isWeb ? ( + + + + ) : ( + + + + ) + }} + + + + + + {_(msg`Translate`)} + + + + + {_(msg`Copy post text`)} + + + + { + const url = toShareUrl(href) + shareUrl(url) + }}> + + {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} + + + + + + {hasSession && ( + <> + + + + + + {isThreadMuted + ? _(msg`Unmute thread`) + : _(msg`Mute thread`)} + + + + + mutedWordsDialogControl.open()}> + {_(msg`Mute words & tags`)} + + + + {!isAuthor && !isPostHidden && ( + { + openModal({ + name: 'confirm', + title: _(msg`Hide this post?`), + message: _( + msg`This will hide this post from your feeds.`, + ), + onPressConfirm: onHidePost, + }) + }}> + {_(msg`Hide post`)} + + + )} + + + )} + + + + + {!isAuthor && ( + { + openModal({ + name: 'report', + uri: postUri, + cid: postCid, + }) + }}> + {_(msg`Report post`)} + + + )} + + {isAuthor && ( + { + openModal({ + name: 'confirm', + title: _(msg`Delete this post?`), + message: _(msg`Are you sure? This cannot be undone.`), + onPressConfirm: onDeletePost, + }) + }}> + {_(msg`Delete post`)} + + + )} + + {showAppealLabelItem && ( + <> + + + { + openModal({ + name: 'appeal-label', + uri: postUri, + cid: postCid, + }) + }}> + + {_(msg`Appeal content warning`)} + + + + + )} + + + ) } diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index bd21ddda..b1ec32b3 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -231,6 +231,7 @@ let PostCtrls = ({ richText={richText} showAppealLabelItem={showAppealLabelItem} style={styles.btnPad} + hitSlop={big ? HITSLOP_20 : HITSLOP_10} /> From 0f9f08b1ef795215975c7b041d0e94a992d22124 Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 8 Mar 2024 14:31:24 -0800 Subject: [PATCH 06/13] Fix reactivity of dialogs (Dialogs Pt. 1) (#3146) * Improve a11y on ios * Format * Remove android * Fix android * Revert some changes * use sharedvalue for `importantForAccessibility` * add back `isOpen` * fix some more types --------- Co-authored-by: Eric Bailey --- src/components/Dialog/context.ts | 6 ++-- src/components/Dialog/types.ts | 2 +- src/state/dialogs/index.tsx | 60 ++++++++++++++++++++++---------- src/view/shell/index.tsx | 19 +++------- 4 files changed, 49 insertions(+), 38 deletions(-) diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index 9b571e8e..859f8edd 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -21,8 +21,7 @@ export function useDialogControl(): DialogOuterProps['control'] { open: () => {}, close: () => {}, }) - const {activeDialogs, openDialogs} = useDialogStateContext() - const isOpen = openDialogs.includes(id) + const {activeDialogs} = useDialogStateContext() React.useEffect(() => { activeDialogs.current.set(id, control) @@ -36,7 +35,6 @@ export function useDialogControl(): DialogOuterProps['control'] { () => ({ id, ref: control, - isOpen, open: () => { control.current.open() }, @@ -44,6 +42,6 @@ export function useDialogControl(): DialogOuterProps['control'] { control.current.close(cb) }, }), - [id, control, isOpen], + [id, control], ) } diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index fa9398fe..4fc60ec3 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -22,7 +22,7 @@ export type DialogControlRefProps = { export type DialogControlProps = DialogControlRefProps & { id: string ref: React.RefObject - isOpen: boolean + isOpen?: boolean } export type DialogContextProps = { diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx index 90aaca4f..951105a5 100644 --- a/src/state/dialogs/index.tsx +++ b/src/state/dialogs/index.tsx @@ -1,8 +1,9 @@ import React from 'react' +import {SharedValue, useSharedValue} from 'react-native-reanimated' import {DialogControlRefProps} from '#/components/Dialog' import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' -const DialogContext = React.createContext<{ +interface IDialogContext { /** * The currently active `useDialogControl` hooks. */ @@ -13,13 +14,18 @@ const DialogContext = React.createContext<{ * The currently open dialogs, referenced by their IDs, generated from * `useId`. */ - openDialogs: string[] -}>({ - activeDialogs: { - current: new Map(), - }, - openDialogs: [], -}) + openDialogs: React.MutableRefObject> + /** + * The counterpart to `accessibilityViewIsModal` for Android. This property + * applies to the parent of all non-modal views, and prevents TalkBack from + * navigating within content beneath an open dialog. + * + * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android + */ + importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'> +} + +const DialogContext = React.createContext({} as IDialogContext) const DialogControlContext = React.createContext<{ closeAllDialogs(): boolean @@ -41,26 +47,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const activeDialogs = React.useRef< Map> >(new Map()) - const [openDialogs, setOpenDialogs] = React.useState([]) + const openDialogs = React.useRef>(new Set()) + const importantForAccessibility = useSharedValue< + 'auto' | 'no-hide-descendants' + >('auto') const closeAllDialogs = React.useCallback(() => { activeDialogs.current.forEach(dialog => dialog.current.close()) - return openDialogs.length > 0 - }, [openDialogs]) + return openDialogs.current.size > 0 + }, []) const setDialogIsOpen = React.useCallback( (id: string, isOpen: boolean) => { - setOpenDialogs(prev => { - const filtered = prev.filter(dialogId => dialogId !== id) as string[] - return isOpen ? [...filtered, id] : filtered - }) + if (isOpen) { + openDialogs.current.add(id) + importantForAccessibility.value = 'no-hide-descendants' + } else { + openDialogs.current.delete(id) + if (openDialogs.current.size < 1) { + importantForAccessibility.value = 'auto' + } + } }, - [setOpenDialogs], + [importantForAccessibility], ) - const context = React.useMemo( - () => ({activeDialogs, openDialogs}), - [openDialogs], + const context = React.useMemo( + () => ({ + activeDialogs: { + current: new Map(), + }, + openDialogs: { + current: new Set(), + }, + importantForAccessibility, + }), + [importantForAccessibility], ) const controls = React.useMemo( () => ({closeAllDialogs, setDialogIsOpen}), diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index bdba7917..76a7f8fb 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -30,7 +30,8 @@ import {useCloseAnyActiveElement} from '#/state/util' import * as notifications from 'lib/notifications/notifications' import {Outlet as PortalOutlet} from '#/components/Portal' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' -import {useDialogStateContext} from '#/state/dialogs' +import {useDialogStateContext} from 'state/dialogs' +import Animated from 'react-native-reanimated' function ShellInner() { const isDrawerOpen = useIsDrawerOpen() @@ -54,9 +55,9 @@ function ShellInner() { const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) const {hasSession, currentAccount} = useSession() const closeAnyActiveElement = useCloseAnyActiveElement() + const {importantForAccessibility} = useDialogStateContext() // start undefined const currentAccountDid = React.useRef(undefined) - const {openDialogs} = useDialogStateContext() React.useEffect(() => { let listener = {remove() {}} @@ -80,19 +81,9 @@ function ShellInner() { } }, [currentAccount]) - /** - * The counterpart to `accessibilityViewIsModal` for Android. This property - * applies to the parent of all non-modal views, and prevents TalkBack from - * navigating within content beneath an open dialog. - * - * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android - */ - const importantForAccessibility = - openDialogs.length > 0 ? 'no-hide-descendants' : undefined - return ( <> - @@ -106,7 +97,7 @@ function ShellInner() { - + From 8ee325e73d927a34ef23a776c5404e9556f0d94a Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 8 Mar 2024 14:31:50 -0800 Subject: [PATCH 07/13] Make ALF prompt scrollable for accessibility (#3150) * make alf prompt scrollable * padding --- src/components/Prompt.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 8e55bd83..28ec2d03 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -3,7 +3,7 @@ import {View, PressableProps} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useTheme, atoms as a} from '#/alf' +import {useTheme, atoms as a, useBreakpoints} from '#/alf' import {Text} from '#/components/Typography' import {Button} from '#/components/Button' @@ -25,6 +25,7 @@ export function Outer({ }: React.PropsWithChildren<{ control: Dialog.DialogOuterProps['control'] }>) { + const {gtMobile} = useBreakpoints() const titleId = React.useId() const descriptionId = React.useId() @@ -38,12 +39,12 @@ export function Outer({ - + style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}> {children} - + ) @@ -71,8 +72,17 @@ export function Description({children}: React.PropsWithChildren<{}>) { } export function Actions({children}: React.PropsWithChildren<{}>) { + const {gtMobile} = useBreakpoints() + return ( - + {children} ) From 62e57c3b08020e17b3266876de342996c8bd12db Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 8 Mar 2024 14:43:28 -0800 Subject: [PATCH 08/13] Adjustments to ALF prompt buttons (Dialogs Pt. 2) (#3144) * Improve a11y on ios * Format * Remove android * Fix android * small adjustment to buttons in prompt * full width below gtmobile * Revert some changes * use sharedvalue for `importantForAccessibility` * add back `isOpen` * fix some more types * small adjustment to buttons in prompt * full width below gtmobile --------- Co-authored-by: Eric Bailey --- src/components/Button.tsx | 4 +++- src/components/Prompt.tsx | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 5361be96..d3bf73cc 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -27,7 +27,7 @@ export type ButtonColor = | 'gradient_sunset' | 'gradient_nordic' | 'gradient_bonfire' -export type ButtonSize = 'tiny' | 'small' | 'large' +export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large' export type ButtonShape = 'round' | 'square' | 'default' export type VariantProps = { /** @@ -274,6 +274,8 @@ export function Button({ if (shape === 'default') { if (size === 'large') { baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md) + } else if (size === 'medium') { + baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md) } else if (size === 'small') { baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm) } else if (size === 'tiny') { diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 28ec2d03..3b245c44 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -78,10 +78,9 @@ export function Actions({children}: React.PropsWithChildren<{}>) { {children} @@ -92,12 +91,13 @@ export function Cancel({ children, }: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) { const {_} = useLingui() + const {gtMobile} = useBreakpoints() const {close} = Dialog.useDialogContext() return (