Merge branch 'bluesky-social:main' into patch-3

zio/stable
Minseo Lee 2024-03-10 22:50:48 +09:00 committed by GitHub
commit 236223d02a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 476 additions and 465 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 738 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 717 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 503 B

View File

@ -43,6 +43,9 @@
height: calc(100% + env(safe-area-inset-top)); height: calc(100% + env(safe-area-inset-top));
scrollbar-gutter: stable both-edges; scrollbar-gutter: stable both-edges;
} }
html, body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
/* Buttons and inputs have a font set by UA, so we'll have to reset that */ /* Buttons and inputs have a font set by UA, so we'll have to reset that */
button, input, textarea { button, input, textarea {

View File

@ -1,3 +1,5 @@
import '#/platform/markBundleStartTime'
import '#/platform/polyfills' import '#/platform/polyfills'
import {registerRootComponent} from 'expo' import {registerRootComponent} from 'expo'
import {doPolyfill} from '#/lib/api/api-polyfill' import {doPolyfill} from '#/lib/api/api-polyfill'

View File

@ -78,6 +78,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {i18n, MessageDescriptor} from '@lingui/core' import {i18n, MessageDescriptor} from '@lingui/core'
import HashtagScreen from '#/screens/Hashtag' import HashtagScreen from '#/screens/Hashtag'
import {logEvent} from './lib/statsig/statsig'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>() const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@ -649,11 +650,14 @@ function logModuleInitTime() {
return return
} }
didInit = true didInit = true
const initMs = Math.round( const initMs = Math.round(
// @ts-ignore Emitted by Metro in the bundle prelude // @ts-ignore Emitted by Metro in the bundle prelude
performance.now() - global.__BUNDLE_START_TIME__, performance.now() - global.__BUNDLE_START_TIME__,
) )
console.log(`Time to first paint: ${initMs} ms`) console.log(`Time to first paint: ${initMs} ms`)
logEvent('init', initMs)
if (__DEV__) { if (__DEV__) {
// This log is noisy, so keep false committed // This log is noisy, so keep false committed
const shouldLog = false const shouldLog = false

View File

@ -27,7 +27,7 @@ export type ButtonColor =
| 'gradient_sunset' | 'gradient_sunset'
| 'gradient_nordic' | 'gradient_nordic'
| 'gradient_bonfire' | 'gradient_bonfire'
export type ButtonSize = 'tiny' | 'small' | 'large' export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
export type ButtonShape = 'round' | 'square' | 'default' export type ButtonShape = 'round' | 'square' | 'default'
export type VariantProps = { export type VariantProps = {
/** /**
@ -274,6 +274,8 @@ export function Button({
if (shape === 'default') { if (shape === 'default') {
if (size === 'large') { if (size === 'large') {
baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md) 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') { } else if (size === 'small') {
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm) baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
} else if (size === 'tiny') { } else if (size === 'tiny') {

View File

@ -21,8 +21,7 @@ export function useDialogControl(): DialogOuterProps['control'] {
open: () => {}, open: () => {},
close: () => {}, close: () => {},
}) })
const {activeDialogs, openDialogs} = useDialogStateContext() const {activeDialogs} = useDialogStateContext()
const isOpen = openDialogs.includes(id)
React.useEffect(() => { React.useEffect(() => {
activeDialogs.current.set(id, control) activeDialogs.current.set(id, control)
@ -36,7 +35,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
() => ({ () => ({
id, id,
ref: control, ref: control,
isOpen,
open: () => { open: () => {
control.current.open() control.current.open()
}, },
@ -44,6 +42,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
control.current.close(cb) control.current.close(cb)
}, },
}), }),
[id, control, isOpen], [id, control],
) )
} }

View File

@ -22,7 +22,7 @@ export type DialogControlRefProps = {
export type DialogControlProps = DialogControlRefProps & { export type DialogControlProps = DialogControlRefProps & {
id: string id: string
ref: React.RefObject<DialogControlRefProps> ref: React.RefObject<DialogControlRefProps>
isOpen: boolean isOpen?: boolean
} }
export type DialogContextProps = { export type DialogContextProps = {

View File

@ -92,10 +92,8 @@ export function Trigger({children, label, style}: TriggerProps) {
accessibilityLabel={label} accessibilityLabel={label}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
style={flatten([style, web({outline: 0})])} style={flatten([style, focused && web({outline: 0})])}
onPointerDown={() => { onPointerDown={() => control.open()}
control.open()
}}
{...web({ {...web({
onMouseEnter, onMouseEnter,
onMouseLeave, onMouseLeave,
@ -131,6 +129,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
{children} {children}
</View> </View>
{/* Disabled until we can fix positioning
<DropdownMenu.Arrow <DropdownMenu.Arrow
className="DropdownMenuArrow" className="DropdownMenuArrow"
fill={ fill={
@ -138,6 +137,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
.backgroundColor .backgroundColor
} }
/> />
*/}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
) )

View File

@ -3,7 +3,7 @@ import {View, PressableProps} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {Text} from '#/components/Typography'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
@ -25,6 +25,7 @@ export function Outer({
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
}>) { }>) {
const {gtMobile} = useBreakpoints()
const titleId = React.useId() const titleId = React.useId()
const descriptionId = React.useId() const descriptionId = React.useId()
@ -38,12 +39,12 @@ export function Outer({
<Context.Provider value={context}> <Context.Provider value={context}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.Inner <Dialog.ScrollableInner
accessibilityLabelledBy={titleId} accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId} accessibilityDescribedBy={descriptionId}
style={[{width: 'auto', maxWidth: 400}]}> style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
{children} {children}
</Dialog.Inner> </Dialog.ScrollableInner>
</Context.Provider> </Context.Provider>
</Dialog.Outer> </Dialog.Outer>
) )
@ -71,8 +72,16 @@ export function Description({children}: React.PropsWithChildren<{}>) {
} }
export function Actions({children}: React.PropsWithChildren<{}>) { export function Actions({children}: React.PropsWithChildren<{}>) {
const {gtMobile} = useBreakpoints()
return ( return (
<View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}> <View
style={[
a.w_full,
a.gap_sm,
a.justify_end,
gtMobile ? [a.flex_row] : [a.flex_col, a.pt_md, a.pb_4xl],
]}>
{children} {children}
</View> </View>
) )
@ -82,12 +91,13 @@ export function Cancel({
children, children,
}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) { }: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
const {_} = useLingui() const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext() const {close} = Dialog.useDialogContext()
return ( return (
<Button <Button
variant="solid" variant="solid"
color="secondary" color="secondary"
size="small" size={gtMobile ? 'small' : 'medium'}
label={_(msg`Cancel`)} label={_(msg`Cancel`)}
onPress={() => close()}> onPress={() => close()}>
{children} {children}
@ -100,6 +110,7 @@ export function Action({
onPress, onPress,
}: React.PropsWithChildren<{onPress?: () => void}>) { }: React.PropsWithChildren<{onPress?: () => void}>) {
const {_} = useLingui() const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext() const {close} = Dialog.useDialogContext()
const handleOnPress = React.useCallback(() => { const handleOnPress = React.useCallback(() => {
close() close()
@ -109,7 +120,7 @@ export function Action({
<Button <Button
variant="solid" variant="solid"
color="primary" color="primary"
size="small" size={gtMobile ? 'small' : 'medium'}
label={_(msg`Confirm`)} label={_(msg`Confirm`)}
onPress={handleOnPress}> onPress={handleOnPress}>
{children} {children}

View File

@ -98,7 +98,7 @@ export function TagMenu({
control.close(() => { control.close(() => {
navigation.push('Hashtag', { navigation.push('Hashtag', {
tag: tag.replaceAll('#', '%23'), tag: encodeURIComponent(tag),
}) })
}) })
@ -153,7 +153,7 @@ export function TagMenu({
control.close(() => { control.close(() => {
navigation.push('Hashtag', { navigation.push('Hashtag', {
tag: tag.replaceAll('#', '%23'), tag: encodeURIComponent(tag),
author: authorHandle, author: authorHandle,
}) })
}) })

View File

@ -66,7 +66,7 @@ export function TagMenu({
label: _(msg`See ${truncatedTag} posts`), label: _(msg`See ${truncatedTag} posts`),
onPress() { onPress() {
navigation.push('Hashtag', { navigation.push('Hashtag', {
tag: tag.replaceAll('#', '%23'), tag: encodeURIComponent(tag),
}) })
}, },
testID: 'tagMenuSearch', testID: 'tagMenuSearch',
@ -83,7 +83,7 @@ export function TagMenu({
label: _(msg`See ${truncatedTag} posts by user`), label: _(msg`See ${truncatedTag} posts by user`),
onPress() { onPress() {
navigation.push('Hashtag', { navigation.push('Hashtag', {
tag: tag.replaceAll('#', '%23'), tag: encodeURIComponent(tag),
author: authorHandle, author: authorHandle,
}) })
}, },

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -2,9 +2,15 @@ import {RouteParams, Route} from './types'
export class Router { export class Router {
routes: [string, Route][] = [] routes: [string, Route][] = []
constructor(description: Record<string, string>) { constructor(description: Record<string, string | string[]>) {
for (const [screen, pattern] of Object.entries(description)) { for (const [screen, pattern] of Object.entries(description)) {
if (typeof pattern === 'string') {
this.routes.push([screen, createRoute(pattern)]) this.routes.push([screen, createRoute(pattern)])
} else {
pattern.forEach(subPattern => {
this.routes.push([screen, createRoute(subPattern)])
})
}
} }
} }

View File

@ -1,11 +1,75 @@
import React from 'react' 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) { const statsigOptions = {
// Not enabled for native yet. environment: {
return false 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<string, string> | 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}) { export function Provider({children}: {children: React.ReactNode}) {
// Not enabled for native yet. const {currentAccount} = useSession()
return children const currentStatsigUser = React.useMemo(
() => toStatsigUser(currentAccount?.did),
[currentAccount?.did],
)
React.useEffect(() => {
function refresh() {
// Intentionally refetching the config using the JS SDK rather than React SDK
// so that the new config is stored in cache but isn't used during this session.
// It will kick in for the next reload.
Statsig.updateUser(currentStatsigUser)
}
const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
return () => clearInterval(id)
}, [currentStatsigUser])
return (
<StatsigProvider
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
mountKey={currentStatsigUser.userID}
user={currentStatsigUser}
// This isn't really blocking due to short initTimeoutMs above.
// However, it ensures `isLoading` is always `false`.
waitForInitialization={true}
options={statsigOptions}>
{children}
</StatsigProvider>
)
} }

View File

@ -1,5 +1,9 @@
import React from 'react' 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 {useSession} from '../../state/session'
import {sha256} from 'js-sha256' import {sha256} from 'js-sha256'
@ -13,6 +17,14 @@ const statsigOptions = {
initTimeoutMs: 1, initTimeoutMs: 1,
} }
export function logEvent(
eventName: string,
value?: string | number | null,
metadata?: Record<string, string> | null,
) {
Statsig.logEvent(eventName, value, metadata)
}
export function useGate(gateName: string) { export function useGate(gateName: string) {
const {isLoading, value} = useStatsigGate(gateName) const {isLoading, value} = useStatsigGate(gateName)
if (isLoading) { if (isLoading) {
@ -36,6 +48,18 @@ export function Provider({children}: {children: React.ReactNode}) {
() => toStatsigUser(currentAccount?.did), () => toStatsigUser(currentAccount?.did),
[currentAccount?.did], [currentAccount?.did],
) )
React.useEffect(() => {
function refresh() {
// Intentionally refetching the config using the JS SDK rather than React SDK
// so that the new config is stored in cache but isn't used during this session.
// It will kick in for the next reload.
Statsig.updateUser(currentStatsigUser)
}
const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
return () => clearInterval(id)
}, [currentStatsigUser])
return ( return (
<StatsigProvider <StatsigProvider
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV" sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"

View File

@ -3,6 +3,8 @@ import {BSKY_SERVICE} from 'lib/constants'
import TLDs from 'tlds' import TLDs from 'tlds'
import psl from 'psl' import psl from 'psl'
export const BSKY_APP_HOST = 'https://bsky.app'
export function isValidDomain(str: string): boolean { export function isValidDomain(str: string): boolean {
return !!TLDs.find(tld => { return !!TLDs.find(tld => {
let i = str.lastIndexOf(tld) let i = str.lastIndexOf(tld)
@ -67,8 +69,21 @@ export function isBskyAppUrl(url: string): boolean {
return url.startsWith('https://bsky.app/') return url.startsWith('https://bsky.app/')
} }
export function isRelativeUrl(url: string): boolean {
return /^\/[^/]/.test(url)
}
export function isBskyRSSUrl(url: string): boolean {
return (
(url.startsWith('https://bsky.app/') || isRelativeUrl(url)) &&
/\/rss\/?$/.test(url)
)
}
export function isExternalUrl(url: string): boolean { export function isExternalUrl(url: string): boolean {
return !isBskyAppUrl(url) && url.startsWith('http') const external = !isBskyAppUrl(url) && url.startsWith('http')
const rss = isBskyRSSUrl(url)
return external || rss
} }
export function isBskyPostUrl(url: string): boolean { export function isBskyPostUrl(url: string): boolean {
@ -149,7 +164,7 @@ export function linkRequiresWarning(uri: string, label: string) {
const labelDomain = labelToDomain(label) const labelDomain = labelToDomain(label)
// If the uri started with a / we know it is internal. // If the uri started with a / we know it is internal.
if (uri.startsWith('/')) { if (isRelativeUrl(uri)) {
return false return false
} }
@ -222,3 +237,8 @@ export function splitApexDomain(hostname: string): [string, string] {
hostnamep.domain, hostnamep.domain,
] ]
} }
export function createBskyAppAbsoluteUrl(path: string): string {
const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
}

View File

@ -0,0 +1,2 @@
// @ts-ignore Web-only. On RN, this is set by Metro.
window.__BUNDLE_START_TIME__ = performance.now()

View File

@ -12,7 +12,7 @@ export const router = new Router({
ModerationModlists: '/moderation/modlists', ModerationModlists: '/moderation/modlists',
ModerationMutedAccounts: '/moderation/muted-accounts', ModerationMutedAccounts: '/moderation/muted-accounts',
ModerationBlockedAccounts: '/moderation/blocked-accounts', ModerationBlockedAccounts: '/moderation/blocked-accounts',
Profile: '/profile/:name', Profile: ['/profile/:name', '/profile/:name/rss'],
ProfileFollowers: '/profile/:name/followers', ProfileFollowers: '/profile/:name/followers',
ProfileFollows: '/profile/:name/follows', ProfileFollows: '/profile/:name/follows',
ProfileList: '/profile/:name/lists/:rkey', ProfileList: '/profile/:name/lists/:rkey',

View File

@ -42,7 +42,7 @@ export default function HashtagScreen({
const [isPTR, setIsPTR] = React.useState(false) const [isPTR, setIsPTR] = React.useState(false)
const fullTag = React.useMemo(() => { const fullTag = React.useMemo(() => {
return `#${tag.replaceAll('%23', '#')}` return `#${decodeURIComponent(tag)}`
}, [tag]) }, [tag])
const queryParam = React.useMemo(() => { const queryParam = React.useMemo(() => {
@ -83,7 +83,7 @@ export default function HashtagScreen({
const onShare = React.useCallback(() => { const onShare = React.useCallback(() => {
const url = new URL('https://bsky.app') const url = new URL('https://bsky.app')
url.pathname = `/hashtag/${tag}` url.pathname = `/hashtag/${decodeURIComponent(tag)}`
if (author) { if (author) {
url.searchParams.set('author', author) url.searchParams.set('author', author)
} }

View File

@ -1,8 +1,9 @@
import React from 'react' import React from 'react'
import {SharedValue, useSharedValue} from 'react-native-reanimated'
import {DialogControlRefProps} from '#/components/Dialog' import {DialogControlRefProps} from '#/components/Dialog'
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
const DialogContext = React.createContext<{ interface IDialogContext {
/** /**
* The currently active `useDialogControl` hooks. * The currently active `useDialogControl` hooks.
*/ */
@ -13,13 +14,18 @@ const DialogContext = React.createContext<{
* The currently open dialogs, referenced by their IDs, generated from * The currently open dialogs, referenced by their IDs, generated from
* `useId`. * `useId`.
*/ */
openDialogs: string[] openDialogs: React.MutableRefObject<Set<string>>
}>({ /**
activeDialogs: { * The counterpart to `accessibilityViewIsModal` for Android. This property
current: new Map(), * applies to the parent of all non-modal views, and prevents TalkBack from
}, * navigating within content beneath an open dialog.
openDialogs: [], *
}) * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
*/
importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'>
}
const DialogContext = React.createContext<IDialogContext>({} as IDialogContext)
const DialogControlContext = React.createContext<{ const DialogControlContext = React.createContext<{
closeAllDialogs(): boolean closeAllDialogs(): boolean
@ -41,26 +47,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const activeDialogs = React.useRef< const activeDialogs = React.useRef<
Map<string, React.MutableRefObject<DialogControlRefProps>> Map<string, React.MutableRefObject<DialogControlRefProps>>
>(new Map()) >(new Map())
const [openDialogs, setOpenDialogs] = React.useState<string[]>([]) const openDialogs = React.useRef<Set<string>>(new Set())
const importantForAccessibility = useSharedValue<
'auto' | 'no-hide-descendants'
>('auto')
const closeAllDialogs = React.useCallback(() => { const closeAllDialogs = React.useCallback(() => {
activeDialogs.current.forEach(dialog => dialog.current.close()) activeDialogs.current.forEach(dialog => dialog.current.close())
return openDialogs.length > 0 return openDialogs.current.size > 0
}, [openDialogs]) }, [])
const setDialogIsOpen = React.useCallback( const setDialogIsOpen = React.useCallback(
(id: string, isOpen: boolean) => { (id: string, isOpen: boolean) => {
setOpenDialogs(prev => { if (isOpen) {
const filtered = prev.filter(dialogId => dialogId !== id) as string[] openDialogs.current.add(id)
return isOpen ? [...filtered, id] : filtered importantForAccessibility.value = 'no-hide-descendants'
}) } else {
openDialogs.current.delete(id)
if (openDialogs.current.size < 1) {
importantForAccessibility.value = 'auto'
}
}
}, },
[setOpenDialogs], [importantForAccessibility],
) )
const context = React.useMemo( const context = React.useMemo<IDialogContext>(
() => ({activeDialogs, openDialogs}), () => ({
[openDialogs], activeDialogs: {
current: new Map(),
},
openDialogs: {
current: new Set(),
},
importantForAccessibility,
}),
[importantForAccessibility],
) )
const controls = React.useMemo( const controls = React.useMemo(
() => ({closeAllDialogs, setDialogIsOpen}), () => ({closeAllDialogs, setDialogIsOpen}),

View File

@ -5,6 +5,11 @@ import * as WebBrowser from 'expo-web-browser'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useModalControls} from '../modals' import {useModalControls} from '../modals'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {
isBskyRSSUrl,
isRelativeUrl,
createBskyAppAbsoluteUrl,
} from 'lib/strings/url-helpers'
type StateContext = persisted.Schema['useInAppBrowser'] type StateContext = persisted.Schema['useInAppBrowser']
type SetContext = (v: persisted.Schema['useInAppBrowser']) => void type SetContext = (v: persisted.Schema['useInAppBrowser']) => void
@ -57,6 +62,10 @@ export function useOpenLink() {
const openLink = React.useCallback( const openLink = React.useCallback(
(url: string, override?: boolean) => { (url: string, override?: boolean) => {
if (isBskyRSSUrl(url) && isRelativeUrl(url)) {
url = createBskyAppAbsoluteUrl(url)
}
if (isNative && !url.startsWith('mailto:')) { if (isNative && !url.startsWith('mailto:')) {
if (override === undefined && enabled === undefined) { if (override === undefined && enabled === undefined) {
openModal({ openModal({

View File

@ -4,7 +4,6 @@ import {
Keyboard, Keyboard,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
TouchableWithoutFeedback,
View, View,
} from 'react-native' } from 'react-native'
import {CreateAccountState, CreateAccountDispatch, is18} from './state' import {CreateAccountState, CreateAccountDispatch, is18} from './state'
@ -19,7 +18,6 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {logger} from '#/logger' import {logger} from '#/logger'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
@ -49,7 +47,6 @@ export function Step1({
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {openModal} = useModalControls()
const serverInputControl = useDialogControl() const serverInputControl = useDialogControl()
const onPressSelectService = React.useCallback(() => { const onPressSelectService = React.useCallback(() => {
@ -57,10 +54,6 @@ export function Step1({
Keyboard.dismiss() Keyboard.dismiss()
}, [serverInputControl]) }, [serverInputControl])
const onPressWaitlist = React.useCallback(() => {
openModal({name: 'waitlist'})
}, [openModal])
const birthDate = React.useMemo(() => { const birthDate = React.useMemo(() => {
return sanitizeDate(uiState.birthDate) return sanitizeDate(uiState.birthDate)
}, [uiState.birthDate]) }, [uiState.birthDate])
@ -164,23 +157,7 @@ export function Step1({
</View> </View>
)} )}
{!uiState.inviteCode && uiState.isInviteCodeRequired ? ( {!uiState.isInviteCodeRequired || uiState.inviteCode ? (
<View style={[s.flexRow, s.alignCenter]}>
<Text style={pal.text}>
<Trans>Don't have an invite code?</Trans>{' '}
</Text>
<TouchableWithoutFeedback
onPress={onPressWaitlist}
accessibilityLabel={_(msg`Join the waitlist.`)}
accessibilityHint="">
<View style={styles.touchable}>
<Text style={pal.link}>
<Trans>Join the waitlist.</Trans>
</Text>
</View>
</TouchableWithoutFeedback>
</View>
) : (
<> <>
<View style={s.pb20}> <View style={s.pb20}>
<Text <Text
@ -260,7 +237,7 @@ export function Step1({
/> />
)} )}
</> </>
)} ) : undefined}
</> </>
)} )}
</View> </View>

View File

@ -20,7 +20,6 @@ import * as ReportModal from './report/Modal'
import * as AppealLabelModal from './AppealLabel' import * as AppealLabelModal from './AppealLabel'
import * as DeleteAccountModal from './DeleteAccount' import * as DeleteAccountModal from './DeleteAccount'
import * as ChangeHandleModal from './ChangeHandle' import * as ChangeHandleModal from './ChangeHandle'
import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes' import * as InviteCodesModal from './InviteCodes'
import * as AddAppPassword from './AddAppPasswords' import * as AddAppPassword from './AddAppPasswords'
import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
@ -109,9 +108,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'change-handle') { } else if (activeModal?.name === 'change-handle') {
snapPoints = ChangeHandleModal.snapPoints snapPoints = ChangeHandleModal.snapPoints
element = <ChangeHandleModal.Component {...activeModal} /> element = <ChangeHandleModal.Component {...activeModal} />
} else if (activeModal?.name === 'waitlist') {
snapPoints = WaitlistModal.snapPoints
element = <WaitlistModal.Component />
} else if (activeModal?.name === 'invite-codes') { } else if (activeModal?.name === 'invite-codes') {
snapPoints = InviteCodesModal.snapPoints snapPoints = InviteCodesModal.snapPoints
element = <InviteCodesModal.Component /> element = <InviteCodesModal.Component />

View File

@ -22,7 +22,6 @@ import * as CropImageModal from './crop-image/CropImage.web'
import * as AltTextImageModal from './AltImage' import * as AltTextImageModal from './AltImage'
import * as EditImageModal from './EditImage' import * as EditImageModal from './EditImage'
import * as ChangeHandleModal from './ChangeHandle' import * as ChangeHandleModal from './ChangeHandle'
import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes' import * as InviteCodesModal from './InviteCodes'
import * as AddAppPassword from './AddAppPasswords' import * as AddAppPassword from './AddAppPasswords'
import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
@ -105,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ThreadgateModal.Component {...modal} /> element = <ThreadgateModal.Component {...modal} />
} else if (modal.name === 'change-handle') { } else if (modal.name === 'change-handle') {
element = <ChangeHandleModal.Component {...modal} /> element = <ChangeHandleModal.Component {...modal} />
} else if (modal.name === 'waitlist') {
element = <WaitlistModal.Component />
} else if (modal.name === 'invite-codes') { } else if (modal.name === 'invite-codes') {
element = <InviteCodesModal.Component /> element = <InviteCodesModal.Component />
} else if (modal.name === 'add-app-password') { } else if (modal.name === 'add-app-password') {

View File

@ -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<string>('')
const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
const [error, setError] = React.useState<string>('')
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 (
<View style={[styles.container, pal.view]}>
<View style={[styles.innerContainer, pal.view]}>
<Text type="title-xl" style={[styles.title, pal.text]}>
<Trans>Join the waitlist</Trans>
</Text>
<Text type="lg" style={[styles.description, pal.text]}>
<Trans>
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.
</Trans>
</Text>
<TextInput
style={[styles.textInput, pal.borderDark, pal.text, s.mb10, s.mt10]}
placeholder={_(msg`Enter your email`)}
placeholderTextColor={pal.textLight.color}
autoCapitalize="none"
autoCorrect={false}
keyboardAppearance={theme.colorScheme}
value={email}
onChangeText={setEmail}
onSubmitEditing={onPressSignup}
enterKeyHint="done"
accessible={true}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(
msg`Input your email to get on the Bluesky waitlist`,
)}
/>
{error ? (
<View style={s.mt10}>
<ErrorMessage message={error} style={styles.error} />
</View>
) : undefined}
{isProcessing ? (
<View style={[styles.btn, s.mt10]}>
<ActivityIndicator />
</View>
) : isEmailSent ? (
<View style={[styles.btn, s.mt10]}>
<FontAwesomeIcon
icon="check"
style={pal.text as FontAwesomeIconStyle}
/>
<Text style={[s.ml10, pal.text]}>
<Trans>
Your email has been saved! We&apos;ll be in touch soon.
</Trans>
</Text>
</View>
) : (
<>
<TouchableOpacity
onPress={onPressSignup}
accessibilityRole="button"
accessibilityHint={_(
msg`Confirms signing up ${email} to the waitlist`,
)}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text type="button-lg" style={[s.white, s.bold]}>
<Trans>Join Waitlist</Trans>
</Text>
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, s.mt10]}
onPress={onCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel waitlist signup`)}
accessibilityHint={_(
msg`Exits signing up for waitlist with ${email}`,
)}
onAccessibilityEscape={onCancel}>
<Text type="button-lg" style={pal.textLight}>
<Trans>Cancel</Trans>
</Text>
</TouchableOpacity>
</>
)}
</View>
</View>
)
}
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,
},
})

View File

@ -8,7 +8,14 @@ import {View, ViewStyle} from 'react-native'
export function EventStopper({ export function EventStopper({
children, children,
style, 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) => { const stop = (e: any) => {
e.stopPropagation() e.stopPropagation()
} }
@ -18,7 +25,7 @@ export function EventStopper({
onTouchEnd={stop} onTouchEnd={stop}
// @ts-ignore web only -prf // @ts-ignore web only -prf
onClick={stop} onClick={stop}
onKeyDown={stop} onKeyDown={onKeyDown ? stop : undefined}
style={style}> style={style}>
{children} {children}
</View> </View>

View File

@ -1,5 +1,11 @@
import React, {memo} from 'react' 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 Clipboard from '@react-native-clipboard/clipboard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
@ -12,10 +18,6 @@ import {
import {toShareUrl} from 'lib/strings/url-helpers' import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
import {
NativeDropdown,
DropdownItem as NativeDropdownItem,
} from './NativeDropdown'
import * as Toast from '../Toast' import * as Toast from '../Toast'
import {EventStopper} from '../EventStopper' import {EventStopper} from '../EventStopper'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
@ -36,6 +38,19 @@ import {isWeb} from '#/platform/detection'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 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 = ({ let PostDropdownBtn = ({
testID, testID,
postAuthor, postAuthor,
@ -45,6 +60,7 @@ let PostDropdownBtn = ({
richText, richText,
style, style,
showAppealLabelItem, showAppealLabelItem,
hitSlop,
}: { }: {
testID: string testID: string
postAuthor: AppBskyActorDefs.ProfileViewBasic postAuthor: AppBskyActorDefs.ProfileViewBasic
@ -54,9 +70,11 @@ let PostDropdownBtn = ({
richText: RichTextAPI richText: RichTextAPI
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
showAppealLabelItem?: boolean showAppealLabelItem?: boolean
hitSlop?: PressableProps['hitSlop']
}): React.ReactNode => { }): React.ReactNode => {
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const theme = useTheme() const theme = useTheme()
const alf = useAlf()
const {_} = useLingui() const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls() const {openModal} = useModalControls()
@ -151,173 +169,189 @@ let PostDropdownBtn = ({
hidePost({uri: postUri}) hidePost({uri: postUri})
}, [postUri, hidePost]) }, [postUri, hidePost])
const dropdownItems: NativeDropdownItem[] = [ return (
{ <EventStopper onKeyDown={false}>
label: _(msg`Translate`), <Menu.Root>
onPress() { <Menu.Trigger label={_(msg`Open post options menu`)}>
onOpenTranslate() {({props, state}) => {
}, const styles = [
testID: 'postDropdownTranslateBtn', style,
icon: { a.rounded_full,
ios: { (state.hovered || state.focused || state.pressed) && [
name: 'character.book.closed', web({outline: 0}),
}, alf.atoms.bg_contrast_25,
android: 'ic_menu_sort_alphabetically', ],
web: 'language', ]
}, return isWeb ? (
}, <View {...props} testID={testID} style={styles}>
{ <FontAwesomeIcon
label: _(msg`Copy post text`), icon="ellipsis"
onPress() { size={20}
onCopyPostText() color={defaultCtrlColor}
}, style={{pointerEvents: 'none'}}
testID: 'postDropdownCopyTextBtn', />
icon: { </View>
ios: { ) : (
name: 'doc.on.doc', <Pressable
}, {...props}
android: 'ic_menu_edit', hitSlop={hitSlop}
web: ['far', 'paste'], testID={testID}
}, style={styles}>
}, <FontAwesomeIcon
{ icon="ellipsis"
label: isWeb ? _(msg`Copy link to post`) : _(msg`Share`), size={20}
onPress() { color={defaultCtrlColor}
style={{pointerEvents: 'none'}}
/>
</Pressable>
)
}}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
testID="postDropdownTranslateBtn"
label={_(msg`Translate`)}
onPress={onOpenTranslate}>
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownCopyTextBtn"
label={_(msg`Copy post text`)}
onPress={onCopyPostText}>
<Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownShareBtn"
label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
onPress={() => {
const url = toShareUrl(href) const url = toShareUrl(href)
shareUrl(url) shareUrl(url)
}, }}>
testID: 'postDropdownShareBtn', <Menu.ItemText>
icon: { {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
ios: { </Menu.ItemText>
name: 'square.and.arrow.up', <Menu.ItemIcon icon={Share} position="right" />
}, </Menu.Item>
android: 'ic_menu_share', </Menu.Group>
web: 'share',
}, {hasSession && (
}, <>
hasSession && { <Menu.Divider />
label: 'separator',
}, <Menu.Group>
hasSession && { <Menu.Item
label: isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`), testID="postDropdownMuteThreadBtn"
onPress() { label={
onToggleThreadMute() isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
}, }
testID: 'postDropdownMuteThreadBtn', onPress={onToggleThreadMute}>
icon: { <Menu.ItemText>
ios: { {isThreadMuted
name: 'speaker.slash', ? _(msg`Unmute thread`)
}, : _(msg`Mute thread`)}
android: 'ic_lock_silent_mode', </Menu.ItemText>
web: 'comment-slash', <Menu.ItemIcon
}, icon={isThreadMuted ? Unmute : Mute}
}, position="right"
hasSession && { />
label: _(msg`Mute words & tags`), </Menu.Item>
onPress() {
mutedWordsDialogControl.open() <Menu.Item
}, testID="postDropdownMuteWordsBtn"
testID: 'postDropdownMuteWordsBtn', label={_(msg`Mute words & tags`)}
icon: { onPress={() => mutedWordsDialogControl.open()}>
ios: { <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
name: 'speaker.slash', <Menu.ItemIcon icon={Filter} position="right" />
}, </Menu.Item>
android: 'ic_lock_silent_mode',
web: 'filter', {!isAuthor && !isPostHidden && (
}, <Menu.Item
}, testID="postDropdownHideBtn"
hasSession && label={_(msg`Hide post`)}
!isAuthor && onPress={() => {
!isPostHidden && {
label: _(msg`Hide post`),
onPress() {
openModal({ openModal({
name: 'confirm', name: 'confirm',
title: _(msg`Hide this post?`), title: _(msg`Hide this post?`),
message: _(msg`This will hide this post from your feeds.`), message: _(
msg`This will hide this post from your feeds.`,
),
onPressConfirm: onHidePost, onPressConfirm: onHidePost,
}) })
}, }}>
testID: 'postDropdownHideBtn', <Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText>
icon: { <Menu.ItemIcon icon={EyeSlash} position="right" />
ios: { </Menu.Item>
name: 'eye.slash', )}
}, </Menu.Group>
android: 'ic_menu_delete', </>
web: ['far', 'eye-slash'], )}
},
}, <Menu.Divider />
{
label: 'separator', <Menu.Group>
}, {!isAuthor && (
!isAuthor && <Menu.Item
hasSession && { testID="postDropdownReportBtn"
label: _(msg`Report post`), label={_(msg`Report post`)}
onPress() { onPress={() => {
openModal({ openModal({
name: 'report', name: 'report',
uri: postUri, uri: postUri,
cid: postCid, cid: postCid,
}) })
}, }}>
testID: 'postDropdownReportBtn', <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
icon: { <Menu.ItemIcon icon={Warning} position="right" />
ios: { </Menu.Item>
name: 'exclamationmark.triangle', )}
},
android: 'ic_menu_report_image', {isAuthor && (
web: 'circle-exclamation', <Menu.Item
}, testID="postDropdownDeleteBtn"
}, label={_(msg`Delete post`)}
isAuthor && { onPress={() => {
label: _(msg`Delete post`),
onPress() {
openModal({ openModal({
name: 'confirm', name: 'confirm',
title: _(msg`Delete this post?`), title: _(msg`Delete this post?`),
message: _(msg`Are you sure? This cannot be undone.`), message: _(msg`Are you sure? This cannot be undone.`),
onPressConfirm: onDeletePost, onPressConfirm: onDeletePost,
}) })
}, }}>
testID: 'postDropdownDeleteBtn', <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
icon: { <Menu.ItemIcon icon={Trash} position="right" />
ios: { </Menu.Item>
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 ( {showAppealLabelItem && (
<EventStopper> <>
<NativeDropdown <Menu.Divider />
testID={testID}
items={dropdownItems} <Menu.Item
accessibilityLabel={_(msg`More post options`)} testID="postDropdownAppealBtn"
accessibilityHint=""> label={_(msg`Appeal content warning`)}
<View style={style}> onPress={() => {
<FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} /> openModal({
</View> name: 'appeal-label',
</NativeDropdown> uri: postUri,
cid: postCid,
})
}}>
<Menu.ItemText>
{_(msg`Appeal content warning`)}
</Menu.ItemText>
<Menu.ItemIcon icon={CircleInfo} position="right" />
</Menu.Item>
</>
)}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
</EventStopper> </EventStopper>
) )
} }

View File

@ -212,9 +212,7 @@ let PostCtrls = ({
style={[styles.btn]} style={[styles.btn]}
onPress={onShare} onPress={onShare}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`${ accessibilityLabel={`${_(msg`Share`)}`}
post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`)
} (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`}
accessibilityHint="" accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}> hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
<ArrowOutOfBox style={[defaultCtrlColor, styles.mt1]} width={22} /> <ArrowOutOfBox style={[defaultCtrlColor, styles.mt1]} width={22} />
@ -231,6 +229,7 @@ let PostCtrls = ({
richText={richText} richText={richText}
showAppealLabelItem={showAppealLabelItem} showAppealLabelItem={showAppealLabelItem}
style={styles.btnPad} style={styles.btnPad}
hitSlop={big ? HITSLOP_20 : HITSLOP_10}
/> />
</View> </View>
</View> </View>

View File

@ -30,7 +30,8 @@ import {useCloseAnyActiveElement} from '#/state/util'
import * as notifications from 'lib/notifications/notifications' import * as notifications from 'lib/notifications/notifications'
import {Outlet as PortalOutlet} from '#/components/Portal' import {Outlet as PortalOutlet} from '#/components/Portal'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {useDialogStateContext} from '#/state/dialogs' import {useDialogStateContext} from 'state/dialogs'
import Animated from 'react-native-reanimated'
function ShellInner() { function ShellInner() {
const isDrawerOpen = useIsDrawerOpen() const isDrawerOpen = useIsDrawerOpen()
@ -54,9 +55,9 @@ function ShellInner() {
const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const closeAnyActiveElement = useCloseAnyActiveElement() const closeAnyActiveElement = useCloseAnyActiveElement()
const {importantForAccessibility} = useDialogStateContext()
// start undefined // start undefined
const currentAccountDid = React.useRef<string | undefined>(undefined) const currentAccountDid = React.useRef<string | undefined>(undefined)
const {openDialogs} = useDialogStateContext()
React.useEffect(() => { React.useEffect(() => {
let listener = {remove() {}} let listener = {remove() {}}
@ -80,19 +81,9 @@ function ShellInner() {
} }
}, [currentAccount]) }, [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 ( return (
<> <>
<View <Animated.View
style={containerPadding} style={containerPadding}
importantForAccessibility={importantForAccessibility}> importantForAccessibility={importantForAccessibility}>
<ErrorBoundary> <ErrorBoundary>
@ -106,7 +97,7 @@ function ShellInner() {
<TabsNavigator /> <TabsNavigator />
</Drawer> </Drawer>
</ErrorBoundary> </ErrorBoundary>
</View> </Animated.View>
<Composer winHeight={winDim.height} /> <Composer winHeight={winDim.height} />
<ModalsContainer /> <ModalsContainer />
<MutedWordsDialog /> <MutedWordsDialog />

View File

@ -47,6 +47,9 @@
height: calc(100% + env(safe-area-inset-top)); height: calc(100% + env(safe-area-inset-top));
scrollbar-gutter: stable both-edges; scrollbar-gutter: stable both-edges;
} }
html, body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
/* Buttons and inputs have a font set by UA, so we'll have to reset that */ /* Buttons and inputs have a font set by UA, so we'll have to reset that */
button, input, textarea { button, input, textarea {