Merge remote-tracking branch 'origin/main' into next/base

* origin/main: (100 commits)
  Adding webp to regex list. Added function return type for consistency with the rest of the functions (#7649)
  bump version (#7650)
  use did for userID (#7647)
  Upgrade RN to 0.76.6 (#7557)
  Update expo-blur (#7596)
  Fix slow Hosting Provider dialog (#7594)
  make convo menu lazy (#7604)
  Fix convo header loading state (#7603)
  Spring cleaning (#7640)
  Date input improvements (#7639)
  Add example account store (#7641)
  rm refs in render (#7589)
  change search cancel behaviour (#7624)
  reduce padding on video overlay (#7617)
  Typo fix (#7637)
  no follow self (#7606)
  env var tweaks (#7457)
  Test fix
  Add Danish localization (#7220)
  Add Swedish localization (#7232)
  ...
This commit is contained in:
Eric Bailey
2025-02-05 09:37:10 -06:00
205 changed files with 41940 additions and 12720 deletions
+5 -1
View File
@@ -131,7 +131,11 @@ function AccountItem({
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
]}>
<View style={a.p_md}>
<UserAvatar avatar={profile?.avatar} size={24} />
<UserAvatar
avatar={profile?.avatar}
size={24}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
<Text emoji style={[a.font_bold]}>
+1
View File
@@ -65,6 +65,7 @@ export function AvatarStack({
<UserAvatar
size={size - 2}
avatar={item.profile.avatar}
type={item.profile.associated?.labeler ? 'labeler' : 'user'}
moderation={item.moderation.ui('avatar')}
/>
)}
+30 -16
View File
@@ -3,10 +3,12 @@ import {
AccessibilityProps,
GestureResponderEvent,
MouseEvent,
NativeSyntheticEvent,
Pressable,
PressableProps,
StyleProp,
StyleSheet,
TargetedEvent,
TextProps,
TextStyle,
View,
@@ -76,6 +78,8 @@ export type ButtonProps = Pick<
| 'onHoverOut'
| 'onPressIn'
| 'onPressOut'
| 'onFocus'
| 'onBlur'
> &
AccessibilityProps &
VariantProps & {
@@ -116,6 +120,12 @@ export const Button = React.forwardRef<View, ButtonProps>(
style,
hoverStyle: hoverStyleProp,
PressableComponent = Pressable,
onPressIn: onPressInOuter,
onPressOut: onPressOutOuter,
onHoverIn: onHoverInOuter,
onHoverOut: onHoverOutOuter,
onFocus: onFocusOuter,
onBlur: onBlurOuter,
...rest
},
ref,
@@ -127,7 +137,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
focused: false,
})
const onPressInOuter = rest.onPressIn
const onPressIn = React.useCallback(
(e: GestureResponderEvent) => {
setState(s => ({
@@ -138,7 +147,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onPressInOuter],
)
const onPressOutOuter = rest.onPressOut
const onPressOut = React.useCallback(
(e: GestureResponderEvent) => {
setState(s => ({
@@ -149,7 +157,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onPressOutOuter],
)
const onHoverInOuter = rest.onHoverIn
const onHoverIn = React.useCallback(
(e: MouseEvent) => {
setState(s => ({
@@ -160,7 +167,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onHoverInOuter],
)
const onHoverOutOuter = rest.onHoverOut
const onHoverOut = React.useCallback(
(e: MouseEvent) => {
setState(s => ({
@@ -171,18 +177,26 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onHoverOutOuter],
)
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
const onFocus = React.useCallback(
(e: NativeSyntheticEvent<TargetedEvent>) => {
setState(s => ({
...s,
focused: true,
}))
onFocusOuter?.(e)
},
[setState, onFocusOuter],
)
const onBlur = React.useCallback(
(e: NativeSyntheticEvent<TargetedEvent>) => {
setState(s => ({
...s,
focused: false,
}))
onBlurOuter?.(e)
},
[setState, onBlurOuter],
)
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
+3 -1
View File
@@ -27,6 +27,7 @@ import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {useThemeName} from '#/alf/util/useColorModeTheme'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {
DialogControlProps,
@@ -55,7 +56,8 @@ export function Outer({
nativeOptions,
testID,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const themeName = useThemeName()
const t = useTheme(themeName)
const ref = React.useRef<BottomSheetNativeComponent>(null)
const closeCallbacks = React.useRef<(() => void)[]>([])
const {setDialogIsOpen, setFullyExpandedCount} =
+81 -51
View File
@@ -17,6 +17,7 @@ import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows
import {useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
@@ -197,6 +198,7 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={data?.suggestions ?? []}
recId={data?.recId}
error={error}
viewContext="profile"
/>
@@ -223,10 +225,12 @@ export function ProfileGrid({
isSuggestionsLoading,
error,
profiles,
recId,
viewContext = 'feed',
}: {
isSuggestionsLoading: boolean
profiles: atp.profile.AnyProfileView[]
recId?: number
error: Error | null
viewContext: 'profile' | 'feed'
}) {
@@ -250,12 +254,19 @@ export function ProfileGrid({
))
) : error || !profiles.length ? null : (
<>
{profiles.slice(0, maxLength).map(profile => (
{profiles.slice(0, maxLength).map((profile, index) => (
<ProfileCard.Link
key={profile.did}
profile={profile}
onPress={() => {
logEvent('feed:interstitial:profileCard:press', {})
logEvent('suggestedUser:press', {
logContext:
viewContext === 'feed'
? 'InterstitialDiscover'
: 'InterstitialProfile',
recId,
position: index,
})
}}
style={[
a.flex_1,
@@ -283,6 +294,17 @@ export function ProfileGrid({
logContext="FeedInterstitial"
shape="round"
colorInverted
onFollow={() => {
logEvent('suggestedUser:follow', {
logContext:
viewContext === 'feed'
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
recId,
position: index,
})
}}
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} numberOfLines={2} />
@@ -337,33 +359,37 @@ export function ProfileGrid({
</View>
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
{content}
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more accounts on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on the Explore page</Trans>
</Text>
<Button
label={_(msg`Browse more accounts on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>
Browse more suggestions on the Explore page
</Trans>
</Text>
<Arrow size="xl" />
<Arrow size="xl" />
</View>
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
</CardOuter>
</Button>
</View>
</ScrollView>
</BlockDrawerGesture>
)}
</View>
)
@@ -470,34 +496,38 @@ export function SuggestedFeeds() {
</View>
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more feeds on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}
style={[a.flex_col]}>
<CardOuter style={[a.flex_1]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on the Explore page</Trans>
</Text>
<Button
label={_(msg`Browse more feeds on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}
style={[a.flex_col]}>
<CardOuter style={[a.flex_1]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>
Browse more suggestions on the Explore page
</Trans>
</Text>
<Arrow size="xl" />
<Arrow size="xl" />
</View>
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
</CardOuter>
</Button>
</View>
</ScrollView>
</BlockDrawerGesture>
)}
</View>
)
+59
View File
@@ -0,0 +1,59 @@
import {createContext, useContext, useMemo} from 'react'
import {View} from 'react-native'
import {atoms as a, ViewStyleProp} from '#/alf'
const Context = createContext({
gap: 0,
})
export function Row({
children,
gap = 0,
style,
}: ViewStyleProp & {
children: React.ReactNode
gap?: number
}) {
return (
<Context.Provider value={useMemo(() => ({gap}), [gap])}>
<View
style={[
a.flex_row,
a.flex_1,
{
marginLeft: -gap / 2,
marginRight: -gap / 2,
},
style,
]}>
{children}
</View>
</Context.Provider>
)
}
export function Col({
children,
width = 1,
style,
}: ViewStyleProp & {
children: React.ReactNode
width?: number
}) {
const {gap} = useContext(Context)
return (
<View
style={[
a.flex_col,
{
paddingLeft: gap / 2,
paddingRight: gap / 2,
width: `${width * 100}%`,
},
style,
]}>
{children}
</View>
)
}
+1
View File
@@ -163,6 +163,7 @@ function KnownFollowersInner({
size={SIZE}
avatar={prof.avatar}
moderation={moderation.ui('avatar')}
type={prof.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
))}
+5 -1
View File
@@ -122,7 +122,11 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
shape="square"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, style]}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
+2
View File
@@ -58,6 +58,7 @@ export const Content = React.memo(function Content({
contentContainerStyle,
...props
}: ContentProps) {
const t = useTheme()
const {footerHeight} = useShellLayout()
const animatedProps = useAnimatedProps(() => {
return {
@@ -73,6 +74,7 @@ export const Content = React.memo(function Content({
<Animated.ScrollView
id="content"
automaticallyAdjustsScrollIndicatorInsets={false}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
// sets the scroll inset to the height of the footer
animatedProps={animatedProps}
style={[scrollViewStyles.common, style]}
+10 -4
View File
@@ -6,12 +6,18 @@ import {gradients} from '#/alf/tokens'
export function LinearGradientBackground({
style,
gradient = 'sky',
children,
start,
end,
}: {
style: StyleProp<ViewStyle>
children: React.ReactNode
style?: StyleProp<ViewStyle>
gradient?: keyof typeof gradients
children?: React.ReactNode
start?: [number, number]
end?: [number, number]
}) {
const gradient = gradients.sky.values.map(([_, color]) => {
const colors = gradients[gradient].values.map(([_, color]) => {
return color
}) as [string, string, ...string[]]
@@ -20,7 +26,7 @@ export function LinearGradientBackground({
}
return (
<LinearGradient colors={gradient} style={style}>
<LinearGradient colors={colors} style={style} start={start} end={end}>
{children}
</LinearGradient>
)
+120 -24
View File
@@ -15,7 +15,6 @@ import {
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
import {isNative, isWeb} from '#/platform/detection'
import {shouldClickOpenNewTab} from '#/platform/urls'
import {useModalControls} from '#/state/modals'
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
@@ -55,6 +54,12 @@ type BaseLinkProps = Pick<
*/
onPress?: (e: GestureResponderEvent) => void | false
/**
* Callback for when the link is long pressed (on native). Prevent default
* and return `false` to exit early and prevent default long press hander.
*/
onLongPress?: (e: GestureResponderEvent) => void | false
/**
* Web-only attribute. Sets `download` attr on web.
*/
@@ -72,9 +77,12 @@ export function useLink({
action = 'push',
disableMismatchWarning,
onPress: outerOnPress,
onLongPress: outerOnLongPress,
shareOnLongPress,
overridePresentation,
}: BaseLinkProps & {
displayText: string
overridePresentation?: boolean
}) {
const navigation = useNavigationDeduped()
const {href} = useLinkProps<AllNavigatorParams>({
@@ -110,7 +118,7 @@ export function useLink({
})
} else {
if (isExternal) {
openLink(href)
openLink(href, overridePresentation)
} else {
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
@@ -152,6 +160,7 @@ export function useLink({
closeModal,
action,
navigation,
overridePresentation,
],
)
@@ -175,8 +184,14 @@ export function useLink({
}
}, [disableMismatchWarning, displayText, href, isExternal, openModal])
const onLongPress =
isNative && isExternal && shareOnLongPress ? handleLongPress : undefined
const onLongPress = React.useCallback(
(e: GestureResponderEvent) => {
const exitEarlyIfFalse = outerOnLongPress?.(e)
if (exitEarlyIfFalse === false) return
return isNative && shareOnLongPress ? handleLongPress() : undefined
},
[outerOnLongPress, handleLongPress, shareOnLongPress],
)
return {
isExternal,
@@ -202,14 +217,16 @@ export function Link({
to,
action = 'push',
onPress: outerOnPress,
onLongPress: outerOnLongPress,
download,
...rest
}: LinkProps) {
const {href, isExternal, onPress} = useLink({
const {href, isExternal, onPress, onLongPress} = useLink({
to,
displayText: typeof children === 'string' ? children : '',
action,
onPress: outerOnPress,
onLongPress: outerOnLongPress,
})
return (
@@ -220,6 +237,7 @@ export function Link({
accessibilityRole="link"
href={href}
onPress={download ? undefined : onPress}
onLongPress={onLongPress}
{...web({
hrefAttrs: {
target: download ? undefined : isExternal ? 'blank' : undefined,
@@ -239,12 +257,13 @@ export function Link({
export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps &
TextStyleProp &
Pick<TextProps, 'selectable' | 'numberOfLines'>
> &
Pick<ButtonProps, 'label'> & {
disableUnderline?: boolean
title?: TextProps['title']
}
Pick<TextProps, 'selectable' | 'numberOfLines'> &
Pick<ButtonProps, 'label' | 'accessibilityHint'> & {
disableUnderline?: boolean
title?: TextProps['title']
overridePresentation?: boolean
}
>
export function InlineLinkText({
children,
@@ -253,11 +272,13 @@ export function InlineLinkText({
disableMismatchWarning,
style,
onPress: outerOnPress,
onLongPress: outerOnLongPress,
download,
selectable,
label,
shareOnLongPress,
disableUnderline,
overridePresentation,
...rest
}: InlineLinkProps) {
const t = useTheme()
@@ -268,7 +289,9 @@ export function InlineLinkText({
action,
disableMismatchWarning,
onPress: outerOnPress,
onLongPress: outerOnLongPress,
shareOnLongPress,
overridePresentation,
})
const {
state: hovered,
@@ -319,6 +342,21 @@ export function InlineLinkText({
)
}
export function WebOnlyInlineLinkText({
children,
to,
onPress,
...props
}: Omit<InlineLinkProps, 'onLongPress'>) {
return isWeb ? (
<InlineLinkText {...props} to={to} onPress={onPress}>
{children}
</InlineLinkText>
) : (
<Text {...props}>{children}</Text>
)
}
/**
* Utility to create a static `onPress` handler for a `Link` that would otherwise link to a URI
*
@@ -327,7 +365,10 @@ export function InlineLinkText({
*/
export function createStaticClick(
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
): Pick<BaseLinkProps, 'to' | 'onPress'> {
): {
to: BaseLinkProps['to']
onPress: Exclude<BaseLinkProps['onPress'], undefined>
} {
return {
to: '#',
onPress(e: GestureResponderEvent) {
@@ -338,17 +379,72 @@ export function createStaticClick(
}
}
export function WebOnlyInlineLinkText({
children,
to,
onPress,
...props
}: InlineLinkProps) {
return isWeb ? (
<InlineLinkText {...props} to={to} onPress={onPress}>
{children}
</InlineLinkText>
) : (
<Text {...props}>{children}</Text>
/**
* Utility to create a static `onPress` handler for a `Link`, but only if the
* click was not modified in some way e.g. `Cmd` or a middle click.
*
* On native, this behaves the same as `createStaticClick` because there are no
* options to "modify" the click in this sense.
*
* Example:
* `<Link {...createStaticClick(e => {...})} />`
*/
export function createStaticClickIfUnmodified(
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
): {onPress: Exclude<BaseLinkProps['onPress'], undefined>} {
return {
onPress(e: GestureResponderEvent) {
if (!isWeb || !isModifiedClickEvent(e)) {
e.preventDefault()
onPressHandler(e)
return false
}
},
}
}
/**
* Determines if the click event has a meta key pressed, indicating the user
* intends to deviate from default behavior.
*/
export function isClickEventWithMetaKey(e: GestureResponderEvent) {
if (!isWeb) return false
const event = e as unknown as MouseEvent
return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey
}
/**
* Determines if the web click target is anything other than `_self`
*/
export function isClickTargetExternal(e: GestureResponderEvent) {
if (!isWeb) return false
const event = e as unknown as MouseEvent
const el = event.currentTarget as HTMLAnchorElement
return el && el.target && el.target !== '_self'
}
/**
* Determines if a click event has been modified in some way from its default
* behavior, e.g. `Cmd` or a middle click.
* {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
*/
export function isModifiedClickEvent(e: GestureResponderEvent): boolean {
if (!isWeb) return false
const event = e as unknown as MouseEvent
const isPrimaryButton = event.button === 0
return (
isClickEventWithMetaKey(e) || isClickTargetExternal(e) || !isPrimaryButton
)
}
/**
* Determines if a click event has been modified in a way that should indiciate
* that the user intends to open a new tab.
* {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
*/
export function shouldClickOpenNewTab(e: GestureResponderEvent) {
if (!isWeb) return false
const event = e as unknown as MouseEvent
const isMiddleClick = isWeb && event.button === 1
return isClickEventWithMetaKey(e) || isClickTargetExternal(e) || isMiddleClick
}
+9 -3
View File
@@ -20,6 +20,7 @@ export function ListFooter({
style,
showEndMessage = false,
endMessageText,
renderEndMessage,
}: {
isFetchingNextPage?: boolean
hasNextPage?: boolean
@@ -29,6 +30,7 @@ export function ListFooter({
style?: StyleProp<ViewStyle>
showEndMessage?: boolean
endMessageText?: string
renderEndMessage?: () => React.ReactNode
}) {
const t = useTheme()
@@ -48,9 +50,13 @@ export function ListFooter({
) : error ? (
<ListFooterMaybeError error={error} onRetry={onRetry} />
) : !hasNextPage && showEndMessage ? (
<Text style={[a.text_sm, t.atoms.text_contrast_low]}>
{endMessageText ?? <Trans>You have reached the end</Trans>}
</Text>
renderEndMessage ? (
renderEndMessage()
) : (
<Text style={[a.text_sm, t.atoms.text_contrast_low]}>
{endMessageText ?? <Trans>You have reached the end</Trans>}
</Text>
)
) : null}
</View>
)
+8 -1
View File
@@ -47,7 +47,12 @@ export function Root({
return <Context.Provider value={context}>{children}</Context.Provider>
}
export function Trigger({children, label, role = 'button'}: TriggerProps) {
export function Trigger({
children,
label,
role = 'button',
hint,
}: TriggerProps) {
const context = useMenuContext()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
@@ -65,11 +70,13 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) {
pressed,
},
props: {
ref: null,
onPress: context.control.open,
onFocus,
onBlur,
onPressIn,
onPressOut,
accessibilityHint: hint,
accessibilityLabel: label,
accessibilityRole: role,
},
+9 -2
View File
@@ -110,7 +110,12 @@ const RadixTriggerPassThrough = React.forwardRef(
)
RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough'
export function Trigger({children, label, role = 'button'}: TriggerProps) {
export function Trigger({
children,
label,
role = 'button',
hint,
}: TriggerProps) {
const {control} = useMenuContext()
const {
state: hovered,
@@ -153,6 +158,7 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) {
onBlur: onBlur,
onMouseEnter,
onMouseLeave,
accessibilityHint: hint,
accessibilityLabel: label,
accessibilityRole: role,
},
@@ -202,7 +208,7 @@ export function Outer({
)
}
export function Item({children, label, onPress, ...rest}: ItemProps) {
export function Item({children, label, onPress, style, ...rest}: ItemProps) {
const t = useTheme()
const {control} = useMenuContext()
const {
@@ -248,6 +254,7 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
? t.atoms.bg_contrast_25
: t.atoms.bg_contrast_50,
],
style,
])}
{...web({
onMouseEnter,
+5
View File
@@ -19,6 +19,7 @@ export type ItemContextType = {
}
export type RadixPassThroughTriggerProps = {
ref: React.RefObject<any>
id: string
type: 'button'
disabled: boolean
@@ -37,6 +38,7 @@ export type RadixPassThroughTriggerProps = {
export type TriggerProps = {
children(props: TriggerChildProps): React.ReactNode
label: string
hint?: string
role?: AccessibilityRole
}
export type TriggerChildProps =
@@ -59,11 +61,13 @@ export type TriggerChildProps =
* object is empty.
*/
props: {
ref: null
onPress: () => void
onFocus: () => void
onBlur: () => void
onPressIn: () => void
onPressOut: () => void
accessibilityHint?: string
accessibilityLabel: string
accessibilityRole: AccessibilityRole
}
@@ -85,6 +89,7 @@ export type TriggerChildProps =
onBlur: () => void
onMouseEnter: () => void
onMouseLeave: () => void
accessibilityHint?: string
accessibilityLabel: string
accessibilityRole: AccessibilityRole
}
+1 -1
View File
@@ -126,7 +126,7 @@ export function Label({
fill={t.atoms.text_contrast_medium.color}
/>
) : (
<UserAvatar avatar={desc.sourceAvi} size={avi} />
<UserAvatar avatar={desc.sourceAvi} type="user" size={avi} />
)}
<Text
+3
View File
@@ -285,6 +285,7 @@ export type FollowButtonProps = {
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
colorInverted?: boolean
onFollow?: () => void
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
@@ -298,6 +299,7 @@ export function FollowButtonInner({
moderationOpts,
logContext,
onPress: onPressProp,
onFollow,
colorInverted,
...rest
}: FollowButtonProps) {
@@ -324,6 +326,7 @@ export function FollowButtonInner({
),
)
onPressProp?.(e)
onFollow?.()
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
@@ -25,9 +25,7 @@ import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from
import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types'
export function SelectReportOptionView({
...props
}: {
export function SelectReportOptionView(props: {
params: ReportDialogProps['params']
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
onSelectReportOption: (reportOption: ReportOption) => void
+2 -1
View File
@@ -16,6 +16,7 @@ import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {PaperPlane_Stroke2_Corner0_Rounded as SendIcon} from '#/components/icons/PaperPlane'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types'
@@ -223,7 +224,7 @@ export function SubmitView({
<ButtonText>
<Trans>Send report</Trans>
</ButtonText>
{submitting && <ButtonIcon icon={Loader} />}
<ButtonIcon icon={submitting ? Loader : SendIcon} />
</Button>
</View>
{/* Maybe fix this later -h */}
+13 -91
View File
@@ -1,25 +1,19 @@
import React from 'react'
import {TextStyle} from 'react-native'
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {toShortUrl} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf'
import {atoms as a, flatten, TextStyleProp} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {InlineLinkText, LinkProps} from '#/components/Link'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
import {RichTextTag} from '#/components/RichTextTag'
import {Text, TextProps} from '#/components/Typography'
const WORD_WRAP = {wordWrap: 1}
export type RichTextProps = TextStyleProp &
Pick<TextProps, 'selectable'> & {
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
value: RichTextAPI | string
testID?: string
numberOfLines?: number
@@ -43,6 +37,8 @@ export function RichText({
onLinkPress,
interactiveStyle,
emojiMultiplier = 1.85,
onLayout,
onTextLayout,
}: RichTextProps) {
const richText = React.useMemo(
() =>
@@ -70,6 +66,8 @@ export function RichText({
selectable={selectable}
testID={testID}
style={[plainStyles, {fontSize}]}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{text}
@@ -83,6 +81,8 @@ export function RichText({
testID={testID}
style={plainStyles}
numberOfLines={numberOfLines}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{text}
@@ -143,10 +143,9 @@ export function RichText({
els.push(
<RichTextTag
key={key}
text={segment.text}
display={segment.text}
tag={tag.tag}
style={interactiveStyles}
selectable={selectable}
textStyle={interactiveStyles}
authorHandle={authorHandle}
/>,
)
@@ -163,88 +162,11 @@ export function RichText({
testID={testID}
style={plainStyles}
numberOfLines={numberOfLines}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{els}
</Text>
)
}
function RichTextTag({
text,
tag,
style,
selectable,
authorHandle,
}: {
text: string
tag: string
selectable?: boolean
authorHandle?: string
} & TextStyleProp) {
const t = useTheme()
const {_} = useLingui()
const control = useTagMenuControl()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const navigation = useNavigation<NavigationProp>()
const navigateToPage = React.useCallback(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
}, [navigation, tag])
const openDialog = React.useCallback(() => {
control.open()
}, [control])
/*
* N.B. On web, this is wrapped in another pressable comopnent with a11y
* labels, etc. That's why only some of these props are applied here.
*/
return (
<React.Fragment>
<TagMenu control={control} tag={tag} authorHandle={authorHandle}>
<Text
emoji
selectable={selectable}
{...native({
accessibilityLabel: _(msg`Hashtag: #${tag}`),
accessibilityHint: _(msg`Long press to open tag menu for #${tag}`),
accessibilityRole: isNative ? 'button' : undefined,
onPress: navigateToPage,
onLongPress: openDialog,
})}
{...web({
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
})}
// @ts-ignore
onFocus={onFocus}
onBlur={onBlur}
style={[
web({
cursor: 'pointer',
}),
{color: t.palette.primary_500},
(hovered || focused) && {
...web({
outline: 0,
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
}),
},
style,
]}>
{text}
</Text>
</TagMenu>
</React.Fragment>
)
}
+160
View File
@@ -0,0 +1,160 @@
import React from 'react'
import {StyleProp, Text as RNText, TextStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {isInvalidHandle} from '#/lib/strings/handles'
import {isNative, isWeb} from '#/platform/detection'
import {
usePreferencesQuery,
useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
} from '#/state/queries/preferences'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {
createStaticClick,
createStaticClickIfUnmodified,
InlineLinkText,
} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
export function RichTextTag({
tag,
display,
authorHandle,
textStyle,
}: {
tag: string
display: string
authorHandle?: string
textStyle: StyleProp<TextStyle>
}) {
const {_} = useLingui()
const {isLoading: isPreferencesLoading, data: preferences} =
usePreferencesQuery()
const {
mutateAsync: upsertMutedWord,
variables: optimisticUpsert,
reset: resetUpsert,
} = useUpsertMutedWordsMutation()
const {
mutateAsync: removeMutedWords,
variables: optimisticRemove,
reset: resetRemove,
} = useRemoveMutedWordsMutation()
const navigation = useNavigation<NavigationProp>()
const label = _(msg`Hashtag ${tag}`)
const hint = isNative
? _(msg`Long press to open tag menu for #${tag}`)
: _(msg`Click to open tag menu for ${tag}`)
const isMuted = Boolean(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!optimisticRemove?.find(m => m?.value === tag),
)
/*
* Mute word records that exactly match the tag in question.
*/
const removeableMuteWords = React.useMemo(() => {
return (
preferences?.moderationPrefs.mutedWords?.filter(word => {
return word.value === tag
}) || []
)
}, [tag, preferences?.moderationPrefs?.mutedWords])
return (
<Menu.Root>
<Menu.Trigger label={label} hint={hint}>
{({props: menuProps}) => (
<InlineLinkText
to={{
screen: 'Hashtag',
params: {tag: encodeURIComponent(tag)},
}}
{...menuProps}
onPress={e => {
if (isWeb) {
return createStaticClickIfUnmodified(() => {
if (!isNative) {
menuProps.onPress()
}
}).onPress(e)
}
}}
onLongPress={createStaticClick(menuProps.onPress).onPress}
accessibilityHint={hint}
label={label}
style={textStyle}>
{isNative ? (
display
) : (
<RNText ref={menuProps.ref}>{display}</RNText>
)}
</InlineLinkText>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`See ${tag} posts`)}
onPress={() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
}}>
<Menu.ItemText>
<Trans>See #{tag} posts</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Search} />
</Menu.Item>
{authorHandle && !isInvalidHandle(authorHandle) && (
<Menu.Item
label={_(msg`See ${tag} posts by user`)}
onPress={() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
author: authorHandle,
})
}}>
<Menu.ItemText>
<Trans>See #{tag} posts by user</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
)}
</Menu.Group>
<Menu.Divider />
<Menu.Item
label={isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)}
onPress={() => {
if (isMuted) {
resetUpsert()
removeMutedWords(removeableMuteWords)
} else {
resetRemove()
upsertMutedWord([
{value: tag, targets: ['tag'], actorTarget: 'all'},
])
}
}}>
<Menu.ItemText>
{isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)}
</Menu.ItemText>
<Menu.ItemIcon icon={isPreferencesLoading ? Loader : Mute} />
</Menu.Item>
</Menu.Outer>
</Menu.Root>
)
}
@@ -136,7 +136,6 @@ export const ProfileStarterPacks = React.forwardRef<
headerOffset={headerOffset}
progressViewOffset={ios(0)}
contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}}
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true}
desktopFixedHeight
onEndReached={onEndReached}
-290
View File
@@ -1,290 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {isInvalidHandle} from '#/lib/strings/handles'
import {
usePreferencesQuery,
useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
} from '#/state/queries/preferences'
import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {createStaticClick, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function useTagMenuControl() {
return Dialog.useDialogControl()
}
export function TagMenu({
children,
control,
tag,
authorHandle,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
/**
* This should be the sanitized tag value from the facet itself, not the
* "display" value with a leading `#`.
*/
tag: string
authorHandle?: string
}>) {
const navigation = useNavigation<NavigationProp>()
return (
<>
{children}
<Dialog.Outer control={control}>
<Dialog.Handle />
<TagMenuInner
control={control}
tag={tag}
authorHandle={authorHandle}
navigation={navigation}
/>
</Dialog.Outer>
</>
)
}
function TagMenuInner({
control,
tag,
authorHandle,
navigation,
}: {
control: Dialog.DialogOuterProps['control']
tag: string
authorHandle?: string
// Passed down because on native, we don't use real portals (and context would be wrong).
navigation: NavigationProp
}) {
const {_} = useLingui()
const t = useTheme()
const {isLoading: isPreferencesLoading, data: preferences} =
usePreferencesQuery()
const {
mutateAsync: upsertMutedWord,
variables: optimisticUpsert,
reset: resetUpsert,
} = useUpsertMutedWordsMutation()
const {
mutateAsync: removeMutedWords,
variables: optimisticRemove,
reset: resetRemove,
} = useRemoveMutedWordsMutation()
const displayTag = '#' + tag
const isMuted = Boolean(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!optimisticRemove?.find(m => m?.value === tag),
)
/*
* Mute word records that exactly match the tag in question.
*/
const removeableMuteWords = React.useMemo(() => {
return (
preferences?.moderationPrefs.mutedWords?.filter(word => {
return word.value === tag
}) || []
)
}, [tag, preferences?.moderationPrefs?.mutedWords])
return (
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
{isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}>
<Loader size="lg" />
</View>
) : (
<>
<View
style={[
a.rounded_md,
a.border,
a.mb_md,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}>
<Link
label={_(msg`View all posts with tag ${displayTag}`)}
{...createStaticClick(() => {
control.close(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
})
})}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Search size="lg" style={[t.atoms.text_contrast_medium]} />
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
<Trans>
See{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
posts
</Trans>
</Text>
</View>
</Link>
{authorHandle && !isInvalidHandle(authorHandle) && (
<>
<Divider />
<Link
label={_(
msg`View all posts by @${authorHandle} with tag ${displayTag}`,
)}
{...createStaticClick(() => {
control.close(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
author: authorHandle,
})
})
})}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Person size="lg" style={[t.atoms.text_contrast_medium]} />
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
<Trans>
See{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
posts by this user
</Trans>
</Text>
</View>
</Link>
</>
)}
{preferences ? (
<>
<Divider />
<Button
label={
isMuted
? _(msg`Unmute all ${displayTag} posts`)
: _(msg`Mute all ${displayTag} posts`)
}
onPress={() => {
control.close(() => {
if (isMuted) {
resetUpsert()
removeMutedWords(removeableMuteWords)
} else {
resetRemove()
upsertMutedWord([
{
value: tag,
targets: ['tag'],
actorTarget: 'all',
},
])
}
})
}}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Mute size="lg" style={[t.atoms.text_contrast_medium]} />
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
<Trans>posts</Trans>
</Text>
</View>
</Button>
</>
) : null}
</View>
<Button
label={_(msg`Close this dialog`)}
size="small"
variant="ghost"
color="secondary"
onPress={() => control.close()}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</>
)}
</Dialog.Inner>
)
}
-163
View File
@@ -1,163 +0,0 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {isInvalidHandle} from '#/lib/strings/handles'
import {enforceLen} from '#/lib/strings/helpers'
import {
usePreferencesQuery,
useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
} from '#/state/queries/preferences'
import {EventStopper} from '#/view/com/util/EventStopper'
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
import {web} from '#/alf'
import * as Dialog from '#/components/Dialog'
export function useTagMenuControl(): Dialog.DialogControlProps {
return {
id: '',
// @ts-ignore
ref: null,
open: () => {
throw new Error(`TagMenu controls are only available on native platforms`)
},
close: () => {
throw new Error(`TagMenu controls are only available on native platforms`)
},
}
}
export function TagMenu({
children,
tag,
authorHandle,
}: React.PropsWithChildren<{
/**
* This should be the sanitized tag value from the facet itself, not the
* "display" value with a leading `#`.
*/
tag: string
authorHandle?: string
}>) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {data: preferences} = usePreferencesQuery()
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
useUpsertMutedWordsMutation()
const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
useRemoveMutedWordsMutation()
const isMuted = Boolean(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!optimisticRemove?.find(m => m?.value === tag),
)
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
/*
* Mute word records that exactly match the tag in question.
*/
const removeableMuteWords = React.useMemo(() => {
return (
preferences?.moderationPrefs.mutedWords?.filter(word => {
return word.value === tag
}) || []
)
}, [tag, preferences?.moderationPrefs?.mutedWords])
const dropdownItems = React.useMemo(() => {
return [
{
label: _(msg`See ${truncatedTag} posts`),
onPress() {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
},
testID: 'tagMenuSearch',
icon: {
ios: {
name: 'magnifyingglass',
},
android: '',
web: 'magnifying-glass',
},
},
authorHandle &&
!isInvalidHandle(authorHandle) && {
label: _(msg`See ${truncatedTag} posts by user`),
onPress() {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
author: authorHandle,
})
},
testID: 'tagMenuSearchByUser',
icon: {
ios: {
name: 'magnifyingglass',
},
android: '',
web: ['far', 'user'],
},
},
preferences && {
label: 'separator',
},
preferences && {
label: isMuted
? _(msg`Unmute ${truncatedTag}`)
: _(msg`Mute ${truncatedTag}`),
onPress() {
if (isMuted) {
removeMutedWords(removeableMuteWords)
} else {
upsertMutedWord([
{value: tag, targets: ['tag'], actorTarget: 'all'},
])
}
},
testID: 'tagMenuMute',
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_menu_sort_alphabetically',
web: isMuted ? 'eye' : ['far', 'eye-slash'],
},
},
].filter(Boolean)
}, [
_,
authorHandle,
isMuted,
navigation,
preferences,
tag,
truncatedTag,
upsertMutedWord,
removeMutedWords,
removeableMuteWords,
])
return (
<EventStopper>
<NativeDropdown
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
accessibilityHint=""
// @ts-ignore
items={dropdownItems}
triggerStyle={web({
textAlign: 'left',
})}>
{children}
</NativeDropdown>
</EventStopper>
)
}
+540
View File
@@ -0,0 +1,540 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {
AppBskyActorDefs,
AppBskyEmbedVideo,
AppBskyFeedDefs,
AppBskyFeedPost,
ModerationDecision,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {sanitizeHandle} from '#/lib/strings/handles'
import {formatCount} from '#/view/com/util/numeric/format'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {atoms as a, useTheme} from '#/alf'
import {BLUE_HUE} from '#/alf/util/colorGeneration'
import {select} from '#/alf/util/themeSelector'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash'
import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
import {Link} from '#/components/Link'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography'
function getBlackColor(t: ReturnType<typeof useTheme>) {
return select(t.name, {
light: t.palette.black,
dark: t.atoms.bg_contrast_25.backgroundColor,
dim: `hsl(${BLUE_HUE}, 28%, 6%)`,
})
}
export function VideoPostCard({
post,
sourceContext,
moderation,
onInteract,
}: {
post: AppBskyFeedDefs.PostView
sourceContext: VideoFeedSourceContext
moderation: ModerationDecision
/**
* Callback for metrics etc
*/
onInteract?: () => void
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const embed = post.embed
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const listModUi = moderation.ui('contentList')
const mergedModui = useMemo(() => {
const modui = moderation.ui('contentList')
const mediaModui = moderation.ui('contentMedia')
modui.alerts = [...modui.alerts, ...mediaModui.alerts]
modui.blurs = [...modui.blurs, ...mediaModui.blurs]
modui.filters = [...modui.filters, ...mediaModui.filters]
modui.informs = [...modui.informs, ...mediaModui.informs]
return modui
}, [moderation])
/**
* Filtering should be done at a higher level, such as `PostFeed` or
* `PostFeedVideoGridRow`, but we need to protect here as well.
*/
if (!AppBskyEmbedVideo.isView(embed)) return null
const author = post.author
const text = AppBskyFeedPost.isRecord(post.record) ? post.record?.text : ''
const likeCount = post?.likeCount ?? 0
const repostCount = post?.repostCount ?? 0
const {thumbnail} = embed
const black = getBlackColor(t)
const textAndAuthor = (
<View style={[a.pr_xs, {paddingTop: 6, gap: 4}]}>
{text && (
<Text style={[a.text_md, a.leading_snug]} numberOfLines={2} emoji>
{text}
</Text>
)}
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
<View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
<UserAvatar type="user" size={20} avatar={post.author.avatar} />
<MediaInsetBorder />
</View>
<Text
style={[
a.flex_1,
a.text_sm,
a.leading_tight,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
</Text>
</View>
</View>
)
return (
<Link
accessibilityHint={_(msg`Tap to view video in immersive mode.`)}
label={_(msg`Video from ${author.handle}: ${text}`)}
to={{
screen: 'VideoFeed',
params: {
...sourceContext,
initialPostUri: post.uri,
},
}}
onPress={() => {
onInteract?.()
}}
onPressIn={onPressIn}
onPressOut={onPressOut}
style={[
a.flex_col,
{
alignItems: undefined,
justifyContent: undefined,
},
]}>
<Hider.Outer modui={mergedModui}>
<Hider.Mask>
<View
style={[
a.justify_center,
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<Image
source={{uri: thumbnail}}
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
accessibilityIgnoresInvertColors
blurRadius={100}
/>
<MediaInsetBorder />
<View
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.align_center,
{
backgroundColor: 'black',
opacity: 0.2,
},
]}
/>
<View style={[a.align_center, a.gap_xs]}>
<Eye size="lg" fill="white" />
<Text style={[a.text_sm, {color: 'white'}]}>
{_(msg`Hidden`)}
</Text>
</View>
</View>
</View>
{listModUi.blur ? (
<VideoPostCardTextPlaceholder author={post.author} />
) : (
textAndAuthor
)}
</Hider.Mask>
<Hider.Content>
<View
style={[
a.justify_center,
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<Image
source={{uri: thumbnail}}
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
accessibilityIgnoresInvertColors
/>
<MediaInsetBorder />
<View style={[a.absolute, a.inset_0]}>
<View
style={[
a.absolute,
a.inset_0,
a.pt_2xl,
{
top: 'auto',
},
]}>
<LinearGradient
colors={[black, 'rgba(0, 0, 0, 0)']}
locations={[0.02, 1]}
start={{x: 0, y: 1}}
end={{x: 0, y: 0}}
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
/>
<View
style={[a.relative, a.z_10, a.p_md, a.flex_row, a.gap_md]}>
{likeCount > 0 && (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Heart size="sm" fill="white" />
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
{formatCount(i18n, likeCount)}
</Text>
</View>
)}
{repostCount > 0 && (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Repost size="sm" fill="white" />
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
{formatCount(i18n, repostCount)}
</Text>
</View>
)}
</View>
</View>
</View>
</View>
{textAndAuthor}
</Hider.Content>
</Hider.Outer>
</Link>
)
}
export function VideoPostCardPlaceholder() {
const t = useTheme()
const black = getBlackColor(t)
return (
<View style={[a.flex_1]}>
<View
style={[
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<MediaInsetBorder />
</View>
<VideoPostCardTextPlaceholder />
</View>
)
}
export function VideoPostCardTextPlaceholder({
author,
}: {
author?: AppBskyActorDefs.ProfileViewBasic
}) {
const t = useTheme()
return (
<View style={[a.flex_1]}>
<View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}>
<View
style={[
a.w_full,
a.rounded_xs,
t.atoms.bg_contrast_50,
{
height: 14,
},
]}
/>
<View
style={[
a.w_full,
a.rounded_xs,
t.atoms.bg_contrast_50,
{
height: 14,
width: '70%',
},
]}
/>
{author ? (
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
<View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
<UserAvatar type="user" size={20} avatar={author.avatar} />
<MediaInsetBorder />
</View>
<Text
style={[
a.flex_1,
a.text_sm,
a.leading_tight,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(author.handle, '@')}
</Text>
</View>
) : (
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
<View
style={[
a.rounded_full,
t.atoms.bg_contrast_50,
{
width: 20,
height: 20,
},
]}
/>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
{
height: 12,
width: '75%',
},
]}
/>
</View>
)}
</View>
</View>
)
}
export function CompactVideoPostCard({
post,
sourceContext,
moderation,
onInteract,
}: {
post: AppBskyFeedDefs.PostView
sourceContext: VideoFeedSourceContext
moderation: ModerationDecision
/**
* Callback for metrics etc
*/
onInteract?: () => void
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const embed = post.embed
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const mergedModui = useMemo(() => {
const modui = moderation.ui('contentList')
const mediaModui = moderation.ui('contentMedia')
modui.alerts = [...modui.alerts, ...mediaModui.alerts]
modui.blurs = [...modui.blurs, ...mediaModui.blurs]
modui.filters = [...modui.filters, ...mediaModui.filters]
modui.informs = [...modui.informs, ...mediaModui.informs]
return modui
}, [moderation])
/**
* Filtering should be done at a higher level, such as `PostFeed` or
* `PostFeedVideoGridRow`, but we need to protect here as well.
*/
if (!AppBskyEmbedVideo.isView(embed)) return null
const likeCount = post?.likeCount ?? 0
const {thumbnail} = embed
const black = getBlackColor(t)
return (
<Link
label={_(msg`View video`)}
to={{
screen: 'VideoFeed',
params: {
...sourceContext,
initialPostUri: post.uri,
},
}}
onPress={() => {
onInteract?.()
}}
onPressIn={onPressIn}
onPressOut={onPressOut}
style={[
a.flex_col,
{
alignItems: undefined,
justifyContent: undefined,
},
]}>
<Hider.Outer modui={mergedModui}>
<Hider.Mask>
<View
style={[
a.justify_center,
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<Image
source={{uri: thumbnail}}
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
accessibilityIgnoresInvertColors
blurRadius={100}
/>
<MediaInsetBorder />
<View
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.align_center,
{
backgroundColor: 'black',
opacity: 0.2,
},
]}
/>
<View style={[a.align_center, a.gap_xs]}>
<Eye size="lg" fill="white" />
<Text style={[a.text_sm, {color: 'white'}]}>
{_(msg`Hidden`)}
</Text>
</View>
</View>
</View>
</Hider.Mask>
<Hider.Content>
<View
style={[
a.justify_center,
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<Image
source={{uri: thumbnail}}
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
accessibilityIgnoresInvertColors
/>
<MediaInsetBorder />
<View style={[a.absolute, a.inset_0]}>
<View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
<View
style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
<UserAvatar
type="user"
size={20}
avatar={post.author.avatar}
/>
<MediaInsetBorder />
</View>
</View>
<View
style={[
a.absolute,
a.inset_0,
a.pt_2xl,
{
top: 'auto',
},
]}>
<LinearGradient
colors={[black, 'rgba(0, 0, 0, 0)']}
locations={[0.02, 1]}
start={{x: 0, y: 1}}
end={{x: 0, y: 0}}
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
/>
<View
style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
{likeCount > 0 && (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Heart size="sm" fill="white" />
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
{formatCount(i18n, likeCount)}
</Text>
</View>
)}
</View>
</View>
</View>
</View>
</Hider.Content>
</Hider.Outer>
</Link>
)
}
export function CompactVideoPostCardPlaceholder() {
const t = useTheme()
const black = getBlackColor(t)
return (
<View style={[a.flex_1]}>
<View
style={[
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<MediaInsetBorder />
</View>
</View>
)
}
+6 -10
View File
@@ -12,12 +12,12 @@ import {
usePreferencesSetBirthDateMutation,
} from '#/state/queries/preferences'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {DateInput} from '#/view/com/util/forms/DateInput'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {DateField} from '#/components/forms/DateField'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {Text} from '../Typography'
export function BirthDateSettingsDialog({
control,
@@ -95,17 +95,13 @@ function BirthdayInner({
return (
<View style={a.gap_lg} testID="birthDateSettingsDialog">
<View style={isIOS && [a.w_full, a.align_center]}>
<DateInput
handleAsUTC
<DateField
testID="birthdayInput"
value={date}
onChange={setDate}
buttonType="default-light"
buttonStyle={[a.rounded_sm]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
onChangeDate={newDate => setDate(new Date(newDate))}
label={_(msg`Birthday`)}
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
maximumDate={new Date()}
/>
</View>
+171 -125
View File
@@ -32,15 +32,17 @@ import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import * as atp from '#/types/atproto'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
import {ReportDialog} from './ReportDialog'
let ConvoMenu = ({
convo: initialConvo,
convo,
profile,
control,
currentScreen,
showMarkAsRead,
hideTrigger,
blockInfo,
latestReportableMessage,
style,
}: {
convo: ChatBskyConvoDefs.ConvoView
@@ -53,27 +55,122 @@ let ConvoMenu = ({
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
latestReportableMessage?: ChatBskyConvoDefs.MessageView
style?: ViewStyleProp['style']
}): React.ReactNode => {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const t = useTheme()
const leaveConvoControl = Prompt.usePromptControl()
const reportControl = Prompt.usePromptControl()
const blockedByListControl = Prompt.usePromptControl()
const {listBlocks} = blockInfo
return (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<View style={[style]}>
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
onPress={() => {
Keyboard.dismiss()
props.onPress()
}}
style={[
a.p_sm,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
// make sure pfp is in the middle
{marginLeft: -10},
]}>
<DotsHorizontal size="md" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
</View>
)}
<Menu.Outer>
<MenuContent
profile={profile}
showMarkAsRead={showMarkAsRead}
blockInfo={blockInfo}
convo={convo}
leaveConvoControl={leaveConvoControl}
reportControl={reportControl}
blockedByListControl={blockedByListControl}
/>
</Menu.Outer>
</Menu.Root>
<LeaveConvoPrompt
control={leaveConvoControl}
convoId={convo.id}
currentScreen={currentScreen}
/>
{latestReportableMessage ? (
<ReportDialog
currentScreen={currentScreen}
params={{
type: 'convoMessage',
convoId: convo.id,
message: latestReportableMessage,
}}
control={reportControl}
/>
) : (
<ReportConversationPrompt control={reportControl} />
)}
<BlockedByListDialog
control={blockedByListControl}
listBlocks={listBlocks}
/>
</>
)
}
ConvoMenu = React.memo(ConvoMenu)
function MenuContent({
convo: initialConvo,
profile,
showMarkAsRead,
blockInfo,
leaveConvoControl,
reportControl,
blockedByListControl,
}: {
convo: ChatBskyConvoDefs.ConvoView
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
showMarkAsRead?: boolean
blockInfo: {
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
leaveConvoControl: Prompt.PromptControlProps
reportControl: Prompt.PromptControlProps
blockedByListControl: Prompt.PromptControlProps
}) {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const {mutate: markAsRead} = useMarkAsReadMutation()
const {listBlocks, userBlock} = blockInfo
const isBlocking = userBlock || !!listBlocks.length
const isDeletedAccount = profile.handle === 'missing.invalid'
const convoId = initialConvo.id
const {data: convo} = useConvoQuery(initialConvo)
const onNavigateToProfile = useCallback(() => {
navigation.navigate('Profile', {name: profile.did})
}, [navigation, profile.did])
const {mutate: muteConvo} = useMuteConvo(convo?.id, {
const {mutate: muteConvo} = useMuteConvo(convoId, {
onSuccess: data => {
if (data.convo.muted) {
Toast.show(_(msg`Chat muted`))
@@ -101,132 +198,81 @@ let ConvoMenu = ({
}
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
return (
return isDeletedAccount ? (
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
) : (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<View style={[style]}>
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
onPress={() => {
Keyboard.dismiss()
props.onPress()
}}
style={[
a.p_sm,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
// make sure pfp is in the middle
{marginLeft: -10},
]}>
<DotsHorizontal size="md" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
</View>
<Menu.Group>
{showMarkAsRead && (
<Menu.Item
label={_(msg`Mark as read`)}
onPress={() => markAsRead({convoId})}>
<Menu.ItemText>
<Trans>Mark as read</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Bubble} />
</Menu.Item>
)}
{isDeletedAccount ? (
<Menu.Outer>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
</Menu.Outer>
) : (
<Menu.Outer>
<Menu.Group>
{showMarkAsRead && (
<Menu.Item
label={_(msg`Mark as read`)}
onPress={() =>
markAsRead({
convoId: convo?.id,
})
}>
<Menu.ItemText>
<Trans>Mark as read</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Bubble} />
</Menu.Item>
)}
<Menu.Item
label={_(msg`Go to user's profile`)}
onPress={onNavigateToProfile}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
<Menu.Item
label={_(msg`Mute conversation`)}
onPress={() => muteConvo({mute: !convo?.muted})}>
<Menu.ItemText>
{convo?.muted ? (
<Trans>Unmute conversation</Trans>
) : (
<Trans>Mute conversation</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={
isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)
}
onPress={toggleBlock}>
<Menu.ItemText>
{isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)}
</Menu.ItemText>
<Menu.ItemIcon icon={isBlocking ? PersonCheck : PersonX} />
</Menu.Item>
<Menu.Item
label={_(msg`Report conversation`)}
onPress={() => reportControl.open()}>
<Menu.ItemText>
<Trans>Report conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
)}
</Menu.Root>
<LeaveConvoPrompt
control={leaveConvoControl}
convoId={convo.id}
currentScreen={currentScreen}
/>
<ReportConversationPrompt control={reportControl} />
<BlockedByListDialog
control={blockedByListControl}
listBlocks={listBlocks}
/>
<Menu.Item
label={_(msg`Go to user's profile`)}
onPress={onNavigateToProfile}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
<Menu.Item
label={_(msg`Mute conversation`)}
onPress={() => muteConvo({mute: !convo?.muted})}>
<Menu.ItemText>
{convo?.muted ? (
<Trans>Unmute conversation</Trans>
) : (
<Trans>Mute conversation</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)}
onPress={toggleBlock}>
<Menu.ItemText>
{isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)}
</Menu.ItemText>
<Menu.ItemIcon icon={isBlocking ? PersonCheck : PersonX} />
</Menu.Item>
<Menu.Item
label={_(msg`Report conversation`)}
onPress={() => reportControl.open()}>
<Menu.ItemText>
<Trans>Report conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
</Menu.Group>
</>
)
}
ConvoMenu = React.memo(ConvoMenu)
export {ConvoMenu}
+4 -9
View File
@@ -1,6 +1,6 @@
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {StackActions, useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {isNative} from '#/platform/detection'
@@ -22,15 +22,10 @@ export function LeaveConvoPrompt({
const navigation = useNavigation<NavigationProp>()
const {mutate: leaveConvo} = useLeaveConvo(convoId, {
onSuccess: () => {
onMutate: () => {
if (currentScreen === 'conversation') {
navigation.replace(
'Messages',
isNative
? {
animation: 'pop',
}
: {},
navigation.dispatch(
StackActions.replace('Messages', isNative ? {animation: 'pop'} : {}),
)
}
},
+1
View File
@@ -138,6 +138,7 @@ export let MessageMenu = ({
</Menu.Root>
<ReportDialog
currentScreen="conversation"
params={{type: 'convoMessage', convoId: convo.convo.id, message}}
control={reportControl}
/>
@@ -1,6 +1,6 @@
import React from 'react'
import {View} from 'react-native'
import {ModerationCause} from '@atproto/api'
import {ModerationDecision} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -20,15 +20,12 @@ export function MessagesListBlockedFooter({
recipient: initialRecipient,
convoId,
hasMessages,
blockInfo,
moderation,
}: {
recipient: atp.profile.AnyProfileView
convoId: string
hasMessages: boolean
blockInfo: {
listBlocks: ModerationCause[]
userBlock: ModerationCause | undefined
}
moderation: ModerationDecision
}) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
@@ -40,7 +37,17 @@ export function MessagesListBlockedFooter({
const reportControl = useDialogControl()
const blockedByListControl = useDialogControl()
const {listBlocks, userBlock} = blockInfo
const {listBlocks, userBlock} = React.useMemo(() => {
const modui = moderation.ui('profileView')
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
const userBlock = blocks.find(alert => alert.source.type === 'user')
return {
listBlocks,
userBlock,
}
}, [moderation])
const isBlocking = !!userBlock || !!listBlocks.length
const onUnblockPress = React.useCallback(() => {
+31 -12
View File
@@ -1,6 +1,10 @@
import React, {useCallback} from 'react'
import {TouchableOpacity, View} from 'react-native'
import {ModerationCause, ModerationDecision} from '@atproto/api'
import {
AppBskyActorDefs,
ModerationCause,
ModerationDecision,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -11,7 +15,7 @@ import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isWeb} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
@@ -20,27 +24,33 @@ import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/
import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Text} from '#/components/Typography'
import * as atp from '#/types/atproto'
const PFP_SIZE = isWeb ? 40 : 34
export let MessagesListHeader = ({
profile,
moderation,
blockInfo,
}: {
profile?: atp.profile.AnyProfileView
profile?: Shadow<AppBskyActorDefs.ProfileViewBasic>
moderation?: ModerationDecision
blockInfo?: {
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {gtTablet} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
const blockInfo = React.useMemo(() => {
if (!moderation) return
const modui = moderation.ui('profileView')
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
const userBlock = blocks.find(alert => alert.source.type === 'user')
return {
listBlocks,
userBlock,
}
}, [moderation])
const onPressBack = useCallback(() => {
if (isWeb) {
navigation.replace('Messages', {})
@@ -124,11 +134,11 @@ export let MessagesListHeader = ({
MessagesListHeader = React.memo(MessagesListHeader)
function HeaderReady({
profile: profileUnshadowed,
profile,
moderation,
blockInfo,
}: {
profile: atp.profile.AnyProfileView
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
moderation: ModerationDecision
blockInfo: {
listBlocks: ModerationCause[]
@@ -138,7 +148,6 @@ function HeaderReady({
const {_} = useLingui()
const t = useTheme()
const convoState = useConvo()
const profile = useProfileShadow(profileUnshadowed)
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
@@ -148,6 +157,15 @@ function HeaderReady({
moderation.ui('displayName'),
)
const latestMessageFromOther = convoState.items.findLast(
item => item.type === 'message' && item.message.sender.did === profile.did,
)
const latestReportableMessage =
latestMessageFromOther?.type === 'message'
? latestMessageFromOther.message
: undefined
return (
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
@@ -205,6 +223,7 @@ function HeaderReady({
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)}
</View>
+170 -22
View File
@@ -2,27 +2,39 @@ import React, {memo, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
$Typed,
AppBskyActorDefs,
ChatBskyConvoDefs,
ComAtprotoModerationCreateReport,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native'
import {useMutation} from '@tanstack/react-query'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {NavigationProp} from '#/lib/routes/types'
import {isNative} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {
useProfileBlockMutationQueue,
useProfileQuery,
} from '#/state/queries/profile'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {Divider} from '../Divider'
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
import {Loader} from '../Loader'
import {SelectReportOptionView} from '../ReportDialog/SelectReportOptionView'
import {RichText} from '../RichText'
import {Text} from '../Typography'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
import {PaperPlane_Stroke2_Corner0_Rounded as SendIcon} from '#/components/icons/PaperPlane'
import {Loader} from '#/components/Loader'
import {SelectReportOptionView} from '#/components/ReportDialog/SelectReportOptionView'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {MessageItemMetadata} from './MessageItem'
type ReportDialogParams = {
@@ -34,16 +46,18 @@ type ReportDialogParams = {
let ReportDialog = ({
control,
params,
currentScreen,
}: {
control: Dialog.DialogControlProps
params: ReportDialogParams
currentScreen: 'list' | 'conversation'
}): React.ReactNode => {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Report this message`)}>
<DialogInner params={params} />
<DialogInner params={params} currentScreen={currentScreen} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
@@ -52,14 +66,44 @@ let ReportDialog = ({
ReportDialog = memo(ReportDialog)
export {ReportDialog}
function DialogInner({params}: {params: ReportDialogParams}) {
function DialogInner({
params,
currentScreen,
}: {
params: ReportDialogParams
currentScreen: 'list' | 'conversation'
}) {
const {data: profile, isError} = useProfileQuery({
did: params.message.sender.did,
})
const [reportOption, setReportOption] = useState<ReportOption | null>(null)
const [done, setDone] = useState(false)
const control = Dialog.useDialogContext()
return reportOption ? (
return done ? (
profile ? (
<DoneStep
convoId={params.convoId}
currentScreen={currentScreen}
profile={profile}
/>
) : (
<View style={[a.w_full, a.py_5xl, a.align_center]}>
<Loader />
</View>
)
) : reportOption ? (
<SubmitStep
params={params}
reportOption={reportOption}
goBack={() => setReportOption(null)}
onComplete={() => {
if (isError) {
control.close()
} else {
setDone(true)
}
}}
/>
) : (
<ReasonStep params={params} setReportOption={setReportOption} />
@@ -90,16 +134,17 @@ function SubmitStep({
params,
reportOption,
goBack,
onComplete,
}: {
params: ReportDialogParams
reportOption: ReportOption
goBack: () => void
onComplete: () => void
}) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const t = useTheme()
const [details, setDetails] = useState('')
const control = Dialog.useDialogContext()
const agent = useAgent()
const {
@@ -126,11 +171,7 @@ function SubmitStep({
await agent.createModerationReport(report)
}
},
onSuccess: () => {
control.close(() => {
Toast.show(_(msg`Thank you. Your report has been sent.`))
})
},
onSuccess: onComplete,
})
const copy = useMemo(() => {
@@ -183,13 +224,12 @@ function SubmitStep({
<View style={[a.relative, a.w_full]}>
<Dialog.Input
multiline
value={details}
defaultValue={details}
onChangeText={setDetails}
label="Text field"
label={_(msg`Text field`)}
style={{paddingRight: 60}}
numberOfLines={6}
numberOfLines={5}
/>
<View
style={[
a.absolute,
@@ -233,7 +273,115 @@ function SubmitStep({
<ButtonText>
<Trans>Send report</Trans>
</ButtonText>
{submitting && <ButtonIcon icon={Loader} />}
<ButtonIcon icon={submitting ? Loader : SendIcon} />
</Button>
</View>
</View>
)
}
function DoneStep({
convoId,
currentScreen,
profile,
}: {
convoId: string
currentScreen: 'list' | 'conversation'
profile: AppBskyActorDefs.ProfileViewBasic
}) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const control = Dialog.useDialogContext()
const {gtMobile} = useBreakpoints()
const t = useTheme()
const [actions, setActions] = useState<string[]>(['block', 'leave'])
const shadow = useProfileShadow(profile)
const [queueBlock] = useProfileBlockMutationQueue(shadow)
const {mutate: leaveConvo} = useLeaveConvo(convoId, {
onMutate: () => {
if (currentScreen === 'conversation') {
navigation.dispatch(
StackActions.replace('Messages', isNative ? {animation: 'pop'} : {}),
)
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`), 'xmark')
},
})
const onPressPrimaryAction = () => {
control.close(() => {
if (actions.includes('block')) {
queueBlock()
}
if (actions.includes('leave')) {
leaveConvo()
}
})
}
let btnText = _(msg`Done`)
if (actions.includes('leave') && actions.includes('block')) {
btnText = _(msg`Block and Delete`)
} else if (actions.includes('leave')) {
btnText = _(msg`Delete Conversation`)
} else if (actions.includes('block')) {
btnText = _(msg`Block User`)
}
return (
<View style={a.gap_2xl}>
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Report submitted</Trans>
</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Our moderation team has received your report.</Trans>
</Text>
</View>
<Toggle.Group
label={_(msg`Block and/or delete this conversation`)}
values={actions}
onChange={setActions}>
<View style={[a.gap_md]}>
<Toggle.Item name="block" label={_(msg`Block user`)}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.text_md]}>
<Trans>Block user</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item name="leave" label={_(msg`Delete conversation`)}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.text_md]}>
<Trans>Delete conversation</Trans>
</Toggle.LabelText>
</Toggle.Item>
</View>
</Toggle.Group>
<View style={[a.gap_md, web([a.flex_row_reverse])]}>
<Button
label={btnText}
onPress={onPressPrimaryAction}
size="large"
variant="solid"
color={actions.length > 0 ? 'negative' : 'primary'}>
<ButtonText>{btnText}</ButtonText>
</Button>
<Button
label={_(msg`Close`)}
onPress={() => control.close()}
size={platform({native: 'small', web: 'large'})}
variant={platform({
native: 'ghost',
web: 'solid',
})}
color="secondary">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
</View>
@@ -0,0 +1,67 @@
import {View} from 'react-native'
import {AppBskyEmbedVideo} from '@atproto/api'
import {logEvent} from '#/lib/statsig/statsig'
import {FeedPostSliceItem} from '#/state/queries/post-feed'
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {atoms as a, useGutters} from '#/alf'
import * as Grid from '#/components/Grid'
import {
VideoPostCard,
VideoPostCardPlaceholder,
} from '#/components/VideoPostCard'
export function PostFeedVideoGridRow({
items: slices,
sourceContext,
}: {
items: FeedPostSliceItem[]
sourceContext: VideoFeedSourceContext
}) {
const gutters = useGutters(['base', 'base', 0, 'base'])
const posts = slices
.filter(slice => AppBskyEmbedVideo.isView(slice.post.embed))
.map(slice => ({
post: slice.post,
moderation: slice.moderation,
}))
/**
* This should not happen because we should be filtering out posts without
* videos within the `PostFeed` component.
*/
if (posts.length !== slices.length) return null
return (
<View style={[gutters]}>
<View style={[a.flex_row, a.gap_sm]}>
<Grid.Row gap={a.gap_sm.gap}>
{posts.map(post => (
<Grid.Col key={post.post.uri} width={1 / 2}>
<VideoPostCard
post={post.post}
sourceContext={sourceContext}
moderation={post.moderation}
onInteract={() => {
logEvent('videoCard:click', {context: 'feed'})
}}
/>
</Grid.Col>
))}
</Grid.Row>
</View>
</View>
)
}
export function PostFeedVideoGridRowPlaceholder() {
const gutters = useGutters(['base', 'base', 0, 'base'])
return (
<View style={[gutters]}>
<View style={[a.flex_row, a.gap_sm]}>
<VideoPostCardPlaceholder />
<VideoPostCardPlaceholder />
</View>
</View>
)
}
@@ -17,6 +17,7 @@ export function DateField({
isInvalid,
testID,
accessibilityHint,
maximumDate,
}: DateFieldProps) {
const t = useTheme()
const [open, setOpen] = React.useState(false)
@@ -67,6 +68,9 @@ export function DateField({
aria-label={label}
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
maximumDate={
maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined
}
/>
)}
</>
@@ -19,7 +19,7 @@ export function DateFieldButton({
accessibilityHint,
}: {
label: string
value: string
value: string | Date
onPress: () => void
isInvalid?: boolean
accessibilityHint?: string
+17 -7
View File
@@ -16,10 +16,11 @@ export * as utils from '#/components/forms/DateField/utils'
export const LabelText = TextField.LabelText
/**
* Date-only input. Accepts a date in the format YYYY-MM-DD, and reports date
* changes in the same format.
* Date-only input. Accepts a string in the format YYYY-MM-DD, or a Date object.
* Date objects are converted to strings in the format YYYY-MM-DD.
* Returns a string in the format YYYY-MM-DD.
*
* For dates of unknown format, convert with the
* To generate a string in the format YYYY-MM-DD from a Date object, use the
* `utils.toSimpleDateString(Date)` export of this file.
*/
export function DateField({
@@ -29,6 +30,7 @@ export function DateField({
label,
isInvalid,
accessibilityHint,
maximumDate,
}: DateFieldProps) {
const {_} = useLingui()
const t = useTheme()
@@ -56,21 +58,29 @@ export function DateField({
isInvalid={isInvalid}
accessibilityHint={accessibilityHint}
/>
<Dialog.Outer control={control} testID={testID}>
<Dialog.Outer
control={control}
testID={testID}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.Inner label={label}>
<Dialog.ScrollableInner label={label}>
<View style={a.gap_lg}>
<View style={[a.relative, a.w_full, a.align_center]}>
<DatePicker
timeZoneOffsetInMinutes={0}
theme={t.name === 'light' ? 'light' : 'dark'}
date={new Date(value)}
date={new Date(toSimpleDateString(value))}
onDateChange={onChangeInternal}
mode="date"
testID={`${testID}-datepicker`}
aria-label={label}
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
maximumDate={
maximumDate
? new Date(toSimpleDateString(maximumDate))
: undefined
}
/>
</View>
<Button
@@ -84,7 +94,7 @@ export function DateField({
</ButtonText>
</Button>
</View>
</Dialog.Inner>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
+5 -2
View File
@@ -1,6 +1,6 @@
import React from 'react'
import {StyleSheet, TextInput, TextInputProps} from 'react-native'
// @ts-ignore
// @ts-expect-error untyped
import {unstable_createElement} from 'react-native-web'
import {DateFieldProps} from '#/components/forms/DateField/types'
@@ -39,6 +39,7 @@ export function DateField({
isInvalid,
testID,
accessibilityHint,
maximumDate,
}: DateFieldProps) {
const handleOnChange = React.useCallback(
(e: any) => {
@@ -56,12 +57,14 @@ export function DateField({
<TextField.Root isInvalid={isInvalid}>
<TextField.Icon icon={CalendarDays} />
<Input
value={value}
value={toSimpleDateString(value)}
label={label}
onChange={handleOnChange}
onChangeText={() => {}}
testID={testID}
accessibilityHint={accessibilityHint}
// @ts-expect-error not typed as <input type="date"> even though it is one
max={maximumDate ? toSimpleDateString(maximumDate) : undefined}
/>
</TextField.Root>
)
+2 -1
View File
@@ -1,8 +1,9 @@
export type DateFieldProps = {
value: string
value: string | Date
onChangeDate: (date: string) => void
label: string
isInvalid?: boolean
testID?: string
accessibilityHint?: string
maximumDate?: string | Date
}
+83 -60
View File
@@ -6,21 +6,23 @@ import {useLingui} from '@lingui/react'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {isAndroid} from '#/platform/detection'
import {ServerInputDialog} from '#/view/com/auth/server-input'
import {atoms as a, useTheme} from '#/alf'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {PencilLine_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
import {Button} from '../Button'
import {useDialogControl} from '../Dialog'
import {Text} from '../Typography'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
import {Text} from '#/components/Typography'
export function HostingProvider({
serviceUrl,
onSelectServiceUrl,
onOpenDialog,
minimal,
}: {
serviceUrl: string
onSelectServiceUrl: (provider: string) => void
onOpenDialog?: () => void
minimal?: boolean
}) {
const serverInputControl = useDialogControl()
const t = useTheme()
@@ -29,9 +31,7 @@ export function HostingProvider({
const onPressSelectService = React.useCallback(() => {
Keyboard.dismiss()
serverInputControl.open()
if (onOpenDialog) {
onOpenDialog()
}
onOpenDialog?.()
}, [onOpenDialog, serverInputControl])
return (
@@ -40,57 +40,80 @@ export function HostingProvider({
control={serverInputControl}
onSelect={onSelectServiceUrl}
/>
<Button
testID="selectServiceButton"
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Press to change hosting provider`)}
variant="solid"
color="secondary"
style={[
a.w_full,
a.flex_row,
a.align_center,
a.rounded_sm,
a.px_md,
a.pr_sm,
a.gap_xs,
{paddingVertical: isAndroid ? 14 : 9},
]}
onPress={onPressSelectService}>
{({hovered, pressed}) => {
const interacted = hovered || pressed
return (
<>
<View style={a.pr_xs}>
<Globe
size="md"
fill={
interacted ? t.palette.contrast_800 : t.palette.contrast_500
}
/>
</View>
<Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text>
<View
style={[
a.rounded_sm,
interacted
? t.atoms.bg_contrast_300
: t.atoms.bg_contrast_100,
{marginLeft: 'auto', padding: 6},
]}>
<Pencil
size="sm"
style={{
color: interacted
? t.palette.contrast_800
: t.palette.contrast_500,
}}
/>
</View>
</>
)
}}
</Button>
{minimal ? (
<View style={[a.flex_row, a.align_center, a.flex_wrap]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
You are creating an account on{' '}
</Text>
<Button
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Press to change hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
color="secondary"
size="tiny"
style={[a.px_xs, {marginLeft: tokens.space.xs * -1}]}>
<ButtonText style={[a.text_sm]}>
{toNiceDomain(serviceUrl)}
</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</View>
) : (
<Button
testID="selectServiceButton"
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Press to change hosting provider`)}
variant="solid"
color="secondary"
style={[
a.w_full,
a.flex_row,
a.align_center,
a.rounded_sm,
a.pl_md,
a.pr_sm,
a.gap_xs,
{paddingVertical: isAndroid ? 14 : 8},
]}
onPress={onPressSelectService}>
{({hovered, pressed}) => {
const interacted = hovered || pressed
return (
<>
<View style={a.pr_xs}>
<GlobeIcon
size="md"
fill={
interacted
? t.palette.contrast_800
: t.palette.contrast_500
}
/>
</View>
<Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text>
<View
style={[
a.rounded_sm,
interacted
? t.atoms.bg_contrast_300
: t.atoms.bg_contrast_100,
{marginLeft: 'auto', padding: 6},
]}>
<PencilIcon
size="sm"
style={{
color: interacted
? t.palette.contrast_800
: t.palette.contrast_500,
}}
/>
</View>
</>
)
}}
</Button>
)}
</>
)
}
+6
View File
@@ -11,10 +11,12 @@ import React from 'react'
import {formatDistance, Locale} from 'date-fns'
import {
ca,
da,
de,
el,
enGB,
es,
eu,
fi,
fr,
gl,
@@ -30,6 +32,7 @@ import {
ptBR,
ro,
ru,
sv,
th,
tr,
uk,
@@ -50,10 +53,12 @@ const locales: Record<AppLanguage, Locale | undefined> = {
an: undefined,
ast: undefined,
ca,
da,
de,
el,
['en-GB']: enGB,
es,
eu,
fi,
fr,
ga: undefined,
@@ -71,6 +76,7 @@ const locales: Record<AppLanguage, Locale | undefined> = {
['pt-BR']: ptBR,
ro,
ru,
sv,
th,
tr,
uk,
+1 -1
View File
@@ -1,5 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Ticket_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z',
path: 'M3 4a1 1 0 0 0-1 1v4.17a1 1 0 0 0 .667.944 2.001 2.001 0 0 1 0 3.772A1 1 0 0 0 2 14.83V19a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-4.17a1 1 0 0 0-.667-.944 2.001 2.001 0 0 1 0-3.772A1 1 0 0 0 22 9.17V5a1 1 0 0 0-1-1H3Zm1 4.535V6h16v2.535A4 4 0 0 0 18 12c0 1.482.805 2.773 2 3.465V18H4v-2.535A4 4 0 0 0 6 12a4 4 0 0 0-2-3.465ZM15 15a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-1-3a1 1 0 1 1 2 0 1 1 0 0 1-2 0Zm1-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z',
})
+82 -76
View File
@@ -1,6 +1,5 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {ScrollView, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -12,6 +11,7 @@ import {
import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
import {useTrendingConfig} from '#/state/trending-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -42,81 +42,87 @@ export function Inner() {
return error || noTopics ? null : (
<View style={[t.atoms.border_contrast_low, a.border_t]}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast">
<View style={[gutters, a.flex_row, a.align_center, a.gap_lg]}>
<View style={{paddingLeft: 4, paddingRight: 2}}>
<Graph size="sm" />
</View>
{isLoading ? (
<View style={[a.py_lg, a.flex_row, a.gap_lg, a.align_center]}>
<LoadingPlaceholder
width={80}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={50}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={120}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={30}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={180}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<Text
style={[t.atoms.text_contrast_medium, a.text_sm, a.font_bold]}>
{' '}
</Text>
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast">
<View style={[gutters, a.flex_row, a.align_center, a.gap_lg]}>
<View style={{paddingLeft: 4, paddingRight: 2}}>
<Graph size="sm" />
</View>
) : !trending?.topics ? null : (
<>
{trending.topics.map(topic => (
<TrendingTopicLink
key={topic.link}
topic={topic}
onPress={() => {
logEvent('trendingTopic:click', {context: 'interstitial'})
}}>
<View style={[a.py_lg]}>
<Text
style={[
t.atoms.text,
a.text_sm,
a.font_bold,
{opacity: 0.7}, // NOTE: we use opacity 0.7 instead of a color to match the color of the home pager tab bar
]}>
{topic.topic}
</Text>
</View>
</TrendingTopicLink>
))}
<Button
label={_(msg`Hide trending topics`)}
size="tiny"
variant="ghost"
color="secondary"
shape="round"
onPress={() => trendingPrompt.open()}>
<ButtonIcon icon={X} />
</Button>
</>
)}
</View>
</ScrollView>
{isLoading ? (
<View style={[a.py_lg, a.flex_row, a.gap_lg, a.align_center]}>
<LoadingPlaceholder
width={80}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={50}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={120}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={30}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={180}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<Text
style={[
t.atoms.text_contrast_medium,
a.text_sm,
a.font_bold,
]}>
{' '}
</Text>
</View>
) : !trending?.topics ? null : (
<>
{trending.topics.map(topic => (
<TrendingTopicLink
key={topic.link}
topic={topic}
onPress={() => {
logEvent('trendingTopic:click', {context: 'interstitial'})
}}>
<View style={[a.py_lg]}>
<Text
style={[
t.atoms.text,
a.text_sm,
a.font_bold,
{opacity: 0.7}, // NOTE: we use opacity 0.7 instead of a color to match the color of the home pager tab bar
]}>
{topic.topic}
</Text>
</View>
</TrendingTopicLink>
))}
<Button
label={_(msg`Hide trending topics`)}
size="tiny"
variant="ghost"
color="secondary"
shape="round"
onPress={() => trendingPrompt.open()}>
<ButtonIcon icon={X} />
</Button>
</>
)}
</View>
</ScrollView>
</BlockDrawerGesture>
<Prompt.Basic
control={trendingPrompt}
@@ -0,0 +1,231 @@
import React, {useEffect} from 'react'
import {ScrollView, View} from 'react-native'
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URI} from '#/lib/constants'
import {makeCustomFeedLink} from '#/lib/routes/links'
import {logEvent} from '#/lib/statsig/statsig'
import {useTrendingSettingsApi} from '#/state/preferences/trending'
import {usePostFeedQuery} from '#/state/queries/post-feed'
import {RQKEY} from '#/state/queries/post-feed'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {
CompactVideoPostCard,
CompactVideoPostCardPlaceholder,
} from '#/components/VideoPostCard'
const CARD_WIDTH = 100
const FEED_DESC = `feedgen|${VIDEO_FEED_URI}`
const FEED_PARAMS: {
feedCacheKey: 'discover'
} = {
feedCacheKey: 'discover',
}
export function TrendingVideos() {
const t = useTheme()
const {_} = useLingui()
const gutters = useGutters([0, 'base'])
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
// Refetch on unmount if nothing else is using this query.
const queryClient = useQueryClient()
useEffect(() => {
return () => {
const query = queryClient
.getQueryCache()
.find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
if (query && query.getObserversCount() <= 1) {
query.fetch()
}
}
}, [queryClient])
const {setTrendingVideoDisabled} = useTrendingSettingsApi()
const trendingPrompt = Prompt.usePromptControl()
const onConfirmHide = React.useCallback(() => {
setTrendingVideoDisabled(true)
logEvent('trendingVideos:hide', {context: 'interstitial:discover'})
}, [setTrendingVideoDisabled])
if (error) {
return null
}
return (
<View
style={[
a.pt_lg,
a.pb_lg,
a.border_t,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}>
<View
style={[
gutters,
a.pb_sm,
a.flex_row,
a.align_center,
a.justify_between,
]}>
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_xs]}>
<Graph />
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
<Trans>Trending Videos</Trans>
</Text>
</View>
<Button
label={_(msg`Dismiss this section`)}
size="tiny"
variant="ghost"
color="secondary"
shape="round"
onPress={() => trendingPrompt.open()}>
<ButtonIcon icon={X} />
</Button>
</View>
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToInterval={CARD_WIDTH + a.gap_sm.gap}>
<View
style={[
a.flex_row,
a.gap_sm,
{
paddingLeft: gutters.paddingLeft,
paddingRight: gutters.paddingRight,
},
]}>
{isLoading ? (
Array(10)
.fill(0)
.map((_, i) => (
<View key={i} style={[{width: CARD_WIDTH}]}>
<CompactVideoPostCardPlaceholder />
</View>
))
) : error || !data ? (
<Text>
<Trans>Whoops! Trending videos failed to load.</Trans>
</Text>
) : (
<VideoCards data={data} />
)}
</View>
</ScrollView>
</BlockDrawerGesture>
<Prompt.Basic
control={trendingPrompt}
title={_(msg`Hide trending videos?`)}
description={_(msg`You can update this later from your settings.`)}
confirmButtonCta={_(msg`Hide`)}
onConfirm={onConfirmHide}
/>
</View>
)
}
function VideoCards({
data,
}: {
data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
}) {
const t = useTheme()
const {_} = useLingui()
const items = React.useMemo(() => {
return data.pages
.flatMap(page => page.slices)
.map(slice => slice.items[0])
.filter(Boolean)
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
.slice(0, 8)
}, [data])
const href = React.useMemo(() => {
const urip = new AtUri(VIDEO_FEED_URI)
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover')
}, [])
return (
<>
{items.map(item => (
<View key={item.post.uri} style={[{width: CARD_WIDTH}]}>
<CompactVideoPostCard
post={item.post}
moderation={item.moderation}
sourceContext={{
type: 'feedgen',
uri: VIDEO_FEED_URI,
sourceInterstitial: 'discover',
}}
onInteract={() => {
logEvent('videoCard:click', {
context: 'interstitial:discover',
})
}}
/>
</View>
))}
<View style={[{width: CARD_WIDTH * 2}]}>
<Link
to={href}
label={_(msg`View more`)}
style={[
a.justify_center,
a.align_center,
a.flex_1,
a.rounded_md,
t.atoms.bg,
]}>
{({pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.gap_md,
{
opacity: pressed ? 0.6 : 1,
},
]}>
<Text style={[a.text_md]}>
<Trans>View more</Trans>
</Text>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
{
width: 34,
height: 34,
backgroundColor: t.palette.primary_500,
},
]}>
<ButtonIcon icon={ChevronRight} />
</View>
</View>
)}
</Link>
</View>
</>
)
}