Merge branch 'main' into hailey/tweak-env-var

This commit is contained in:
Hailey
2025-01-26 15:23:55 -08:00
116 changed files with 40432 additions and 11315 deletions
+1
View File
@@ -93,6 +93,7 @@ module.exports = {
],
},
],
'@typescript-eslint/ban-ts-comment': 'warn',
},
ignorePatterns: [
'**/__mocks__/*.ts',
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright 20232024 Bluesky PBC
Copyright 20232025 Bluesky PBC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+1 -2
View File
@@ -10,8 +10,7 @@ appId: xyz.blueskyweb.app
id: "e2eOpenLoggedOutView"
- tapOn:
id: "createAccountButton"
- tapOn:
id: "selectServiceButton"
- tapOn: "Bluesky Social"
- tapOn:
id: "customSelectBtn"
- tapOn:
+1 -1
View File
@@ -21,7 +21,7 @@ appId: xyz.blueskyweb.app
label: "Feeds button disappears after pinning a feed"
id: "bottomBarProfileBtn"
- tapOn:
id: "profilePager-selector-4"
id: "profilePager-selector-5"
- tapOn: "alice-favs"
- tapOn: "Pin to Home"
- tapOn:
+21 -3
View File
@@ -75,9 +75,11 @@ module.exports = function (config) {
'an',
'ast',
'ca',
'da',
'de',
'el',
'es',
'eu',
'fi',
'fr',
'ga',
@@ -95,11 +97,12 @@ module.exports = function (config) {
'pt-BR',
'ro',
'ru',
'sv',
'th',
'tr',
'uk',
'vi',
'yue-Hant',
'yue',
'zh-Hans',
'zh-Hant',
],
@@ -191,6 +194,7 @@ module.exports = function (config) {
channel: UPDATES_CHANNEL,
},
plugins: [
'expo-video',
'expo-localization',
USE_SENTRY && [
'@sentry/react-native/expo',
@@ -225,8 +229,12 @@ module.exports = function (config) {
},
],
'react-native-compressor',
// TODO: Reenable when the build issue is fixed.
// '@bitdrift/react-native',
[
'@bitdrift/react-native',
{
networkInstrumentation: true,
},
],
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',
@@ -347,6 +355,16 @@ module.exports = function (config) {
},
],
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
[
'react-native-vision-camera',
{
enableLocation: false,
cameraPermissionText: 'Bluesky needs access to your camera.',
enableMicrophonePermission: true,
microphonePermissionText:
'Bluesky needs access to your microphone.',
},
],
].filter(Boolean),
extra: {
eas: {
+13 -7
View File
@@ -214,15 +214,21 @@ func serve(cctx *cli.Context) error {
e.GET("/iframe/youtube.html", echo.WrapHandler(staticHandler))
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
path := c.Request().URL.Path
maxAge := 1 * (60 * 60) // default is 1 hour
c.Response().Before(func() {
if c.Response().Status >= 300 {
return
}
// all assets in /static/js, /static/css, /static/media are content-hashed and can be cached for a long time
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/css/") || strings.HasPrefix(path, "/static/media/") {
maxAge = 365 * (60 * 60 * 24) // 1 year
}
path := c.Request().URL.Path
maxAge := 1 * (60 * 60) // default is 1 hour
c.Response().Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
// all assets in /static/js, /static/css, /static/media are content-hashed and can be cached for a long time
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/css/") || strings.HasPrefix(path, "/static/media/") {
maxAge = 365 * (60 * 60 * 24) // 1 year
}
c.Response().Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
})
return next(c)
}
})
-1
View File
@@ -8,7 +8,6 @@
Preconnect to essential domains
-->
<link rel="preconnect" href="https://bsky.social">
<link rel="preconnect" href="https://bsky.network">
<title>{%- block head_title -%}Bluesky{%- endblock -%}</title>
<!-- Hello Humans! API docs at https://atproto.com -->
+3
View File
@@ -5,10 +5,12 @@ module.exports = {
'an',
'ast',
'ca',
'da',
'de',
'el',
'en-GB',
'es',
'eu',
'fi',
'fr',
'ga',
@@ -26,6 +28,7 @@ module.exports = {
'pt-BR',
'ro',
'ru',
'sv',
'th',
'tr',
'uk',
+5 -2
View File
@@ -54,7 +54,8 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.13.21",
"@atproto/api": "^0.13.31",
"@bitdrift/react-native": "^0.6.2",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
@@ -145,6 +146,7 @@
"expo-system-ui": "^4.0.4",
"expo-task-manager": "~12.0.3",
"expo-updates": "~0.26.10",
"expo-video": "^2.0.5",
"expo-web-browser": "~14.0.1",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
@@ -178,7 +180,7 @@
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.14.5",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.6.1",
"react-native-pager-view": "6.7.0",
"react-native-picker-select": "^9.3.1",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
@@ -191,6 +193,7 @@
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.2",
"react-native-view-shot": "^4.0.3",
"react-native-vision-camera": "^4.6.3",
"react-native-web": "~0.19.13",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.12.3",
+9
View File
@@ -86,6 +86,7 @@ import {
StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard'
import {VideoFeed} from '#/screens/VideoFeed'
import {useTheme} from '#/alf'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
@@ -422,6 +423,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => Wizard}
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
/>
<Stack.Screen
name="VideoFeed"
getComponent={() => VideoFeed}
options={{
title: title(msg`Video Feed`),
requireAuth: true,
}}
/>
</>
)
}
+1 -1
View File
@@ -497,7 +497,7 @@ export function createThemes({
color: dimPalette.contrast_400,
},
text_contrast_medium: {
color: dimPalette.contrast_700,
color: dimPalette.contrast_600,
},
text_contrast_high: {
color: dimPalette.contrast_900,
+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} =
+59 -51
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 {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -17,6 +16,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'
@@ -358,33 +358,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>
)
@@ -491,34 +495,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>
)
}
+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,
},
+7 -1
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,
},
+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
}
+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>
)
}
+1
View File
@@ -227,6 +227,7 @@ let ConvoMenu = ({
/>
{latestReportableMessage ? (
<ReportDialog
currentScreen={currentScreen}
params={{
type: 'convoMessage',
convoId: convo.id,
+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}
/>
+170 -22
View File
@@ -1,27 +1,39 @@
import React, {memo, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
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 = {
@@ -33,16 +45,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>
@@ -51,14 +65,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} />
@@ -89,16 +133,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 {
@@ -124,11 +169,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(() => {
@@ -181,13 +222,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,
@@ -231,7 +271,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 recieved 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>
)
}
+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,
+4 -9
View File
@@ -1,7 +1,5 @@
import React, {useContext} from 'react'
import React from 'react'
import {ScrollView, View} from 'react-native'
import {DrawerGestureContext} from 'react-native-drawer-layout'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -13,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'
@@ -41,13 +40,9 @@ export function Inner() {
setTrendingDisabled(true)
}, [setTrendingDisabled])
const drawerGesture = useContext(DrawerGestureContext) ?? Gesture.Native() // noop for web
const trendingScrollGesture =
Gesture.Native().blocksExternalGesture(drawerGesture)
return error || noTopics ? null : (
<View style={[t.atoms.border_contrast_low, a.border_t]}>
<GestureDetector gesture={trendingScrollGesture}>
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
@@ -127,7 +122,7 @@ export function Inner() {
)}
</View>
</ScrollView>
</GestureDetector>
</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>
</>
)
}
+2
View File
@@ -41,6 +41,7 @@ export class FeedViewPostsSlice {
isFallbackMarker: boolean
isOrphan: boolean
rootUri: string
feedPostUri: string
constructor(feedPost: FeedViewPost) {
const {post, reply, reason} = feedPost
@@ -48,6 +49,7 @@ export class FeedViewPostsSlice {
this.isIncompleteThread = false
this.isFallbackMarker = false
this.isOrphan = false
this.feedPostUri = post.uri
if (AppBskyFeedDefs.isPostView(reply?.root)) {
this.rootUri = reply.root.uri
} else {
+3 -1
View File
@@ -23,7 +23,9 @@ export class AuthorFeedAPI implements FeedAPI {
get params() {
const params = {...this._params}
params.includePins = params.filter !== 'posts_with_media'
params.includePins =
params.filter === 'posts_with_replies' ||
params.filter === 'posts_and_author_threads'
return params
}
+6 -1
View File
@@ -193,7 +193,12 @@ export async function post(
}
async function resolveRT(agent: BskyAgent, richtext: RichText) {
let rt = new RichText({text: richtext.text.trimEnd()}, {cleanNewlines: true})
const trimmedText = richtext.text
// Trim leading whitespace-only lines (but don't break ASCII art).
.replace(/^(\s*\n)+/, '')
// Trim any trailing whitespace.
.trimEnd()
let rt = new RichText({text: trimmedText}, {cleanNewlines: true})
await rt.detectFacets(agent)
rt = shortenLinks(rt)
+20 -24
View File
@@ -1,27 +1,23 @@
// import {init} from '@bitdrift/react-native'
// import {Statsig} from 'statsig-react-native-expo'
// export {debug, error, info, warn} from '@bitdrift/react-native'
import {init, SessionStrategy} from '@bitdrift/react-native'
import {Statsig} from 'statsig-react-native-expo'
export {debug, error, info, warn} from '@bitdrift/react-native'
// import {initPromise} from './statsig/statsig'
import {initPromise} from './statsig/statsig'
// const BITDRIFT_API_KEY = process.env.BITDRIFT_API_KEY
const BITDRIFT_API_KEY = process.env.BITDRIFT_API_KEY
// initPromise.then(() => {
// let isEnabled = false
// try {
// if (Statsig.checkGate('enable_bitdrift')) {
// isEnabled = true
// }
// } catch (e) {
// // Statsig may complain about it being called too early.
// }
// if (isEnabled && BITDRIFT_API_KEY) {
// init(BITDRIFT_API_KEY, {url: 'https://api-bsky.bitdrift.io'})
// }
// })
// TODO: Reenable when the build issue is fixed.
export function debug(_message: string) {}
export function error(_message: string) {}
export function info(_message: string) {}
export function warn(_message: string) {}
initPromise.then(() => {
let isEnabled = false
try {
if (Statsig.checkGate('enable_bitdrift')) {
isEnabled = true
}
} catch (e) {
// Statsig may complain about it being called too early.
}
if (isEnabled && BITDRIFT_API_KEY) {
init(BITDRIFT_API_KEY, SessionStrategy.Activity, {
url: 'https://api-bsky.bitdrift.io',
})
}
})
+27
View File
@@ -52,6 +52,8 @@ export const MAX_DM_GRAPHEME_LENGTH = 1000
// but increasing limit per user feedback
export const MAX_ALT_TEXT = 2000
export const MAX_REPORT_REASON_GRAPHEME_LENGTH = 2000
export function IS_TEST_USER(handle?: string) {
return handle && handle?.endsWith('.test')
}
@@ -63,6 +65,21 @@ export function IS_PROD_SERVICE(url?: string) {
export const PROD_DEFAULT_FEED = (rkey: string) =>
`at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
export const STAGING_DEFAULT_FEED = (rkey: string) =>
`at://did:plc:yofh3kx63drvfljkibw5zuxo/app.bsky.feed.generator/${rkey}`
export const PROD_FEEDS = [
`feedgen|${PROD_DEFAULT_FEED('whats-hot')}`,
`feedgen|${PROD_DEFAULT_FEED('thevids')}`,
]
export const STAGING_FEEDS = [
`feedgen|${STAGING_DEFAULT_FEED('whats-hot')}`,
`feedgen|${STAGING_DEFAULT_FEED('thevids')}`,
]
export const FEEDBACK_FEEDS = [...PROD_FEEDS, ...STAGING_FEEDS]
export const POST_IMG_MAX = {
width: 2000,
height: 2000,
@@ -109,6 +126,11 @@ export const BSKY_FEED_OWNER_DIDS = [
export const DISCOVER_FEED_URI =
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
export const VIDEO_FEED_URI =
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/thevids'
export const STAGING_VIDEO_FEED_URI =
'at://did:plc:yofh3kx63drvfljkibw5zuxo/app.bsky.feed.generator/thevids'
export const VIDEO_FEED_URIS = [VIDEO_FEED_URI, STAGING_VIDEO_FEED_URI]
export const DISCOVER_SAVED_FEED = {
type: 'feed',
value: DISCOVER_FEED_URI,
@@ -119,6 +141,11 @@ export const TIMELINE_SAVED_FEED = {
value: 'following',
pinned: true,
}
export const VIDEO_SAVED_FEED = {
type: 'feed',
value: VIDEO_FEED_URI,
pinned: true,
}
export const RECOMMENDED_SAVED_FEEDS: Pick<
AppBskyActorDefs.SavedFeed,
+6 -2
View File
@@ -19,9 +19,13 @@ export function makeProfileLink(
export function makeCustomFeedLink(
did: string,
rkey: string,
...segments: string[]
segment?: string | undefined,
feedCacheKey?: 'discover' | 'explore' | undefined,
) {
return [`/profile`, did, 'feed', rkey, ...segments].join('/')
return (
[`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') +
(feedCacheKey ? `?feedCacheKey=${encodeURIComponent(feedCacheKey)}` : '')
)
}
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
+8 -1
View File
@@ -1,6 +1,8 @@
import {NavigationState, PartialState} from '@react-navigation/native'
import type {NativeStackNavigationProp} from '@react-navigation/native-stack'
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
export type {NativeStackScreenProps} from '@react-navigation/native-stack'
export type CommonNavigatorParams = {
@@ -20,7 +22,11 @@ export type CommonNavigatorParams = {
PostLikedBy: {name: string; rkey: string}
PostRepostedBy: {name: string; rkey: string}
PostQuotes: {name: string; rkey: string}
ProfileFeed: {name: string; rkey: string}
ProfileFeed: {
name: string
rkey: string
feedCacheKey?: 'discover' | 'explore' | undefined
}
ProfileFeedLikedBy: {name: string; rkey: string}
ProfileLabelerLikedBy: {name: string}
Debug: undefined
@@ -57,6 +63,7 @@ export type CommonNavigatorParams = {
StarterPackShort: {code: string}
StarterPackWizard: undefined
StarterPackEdit: {rkey?: string}
VideoFeed: VideoFeedSourceContext
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
+21 -5
View File
@@ -13,7 +13,12 @@ export type LogEvents = {
withPassword: boolean
}
'account:loggedOut': {
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
logContext:
| 'SwitchAccount'
| 'Settings'
| 'SignupQueued'
| 'Deactivated'
| 'Takendown'
scope: 'current' | 'every'
}
'notifications:openApp': {}
@@ -131,16 +136,16 @@ export type LogEvents = {
doesPosterFollowLiker: boolean | undefined
likerClout: number | undefined
postClout: number | undefined
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}
'post:repost': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}
'post:unlike': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}
'post:unrepost': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}
'post:mute': {}
'post:unmute': {}
@@ -163,6 +168,7 @@ export type LogEvents = {
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
}
'suggestedUser:follow': {
logContext:
@@ -199,6 +205,7 @@ export type LogEvents = {
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -269,6 +276,15 @@ export type LogEvents = {
'recommendedTopic:click': {
context: 'explore'
}
'trendingVideos:show': {
context: 'settings'
}
'trendingVideos:hide': {
context: 'settings' | 'interstitial:discover' | 'interstitial:explore'
}
'videoCard:click': {
context: 'interstitial:discover' | 'interstitial:explore' | 'feed'
}
'progressGuide:hide': {}
'progressGuide:followDialog:open': {}
+1
View File
@@ -3,6 +3,7 @@ export type Gate =
| 'debug_show_feedcontext'
| 'debug_subscriptions'
| 'new_postonboarding'
| 'onboarding_add_video_feed'
| 'remove_show_latest_button'
| 'test_gate_1'
| 'test_gate_2'
+2 -4
View File
@@ -5,8 +5,7 @@ import {sha256} from 'js-sha256'
import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info'
// TODO: Reenable when the build issue is fixed.
// import * as bitdrift from '#/lib/bitdrift'
import * as bitdrift from '#/lib/bitdrift'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
@@ -108,8 +107,7 @@ export function logEvent<E extends keyof LogEvents>(
console.groupCollapsed(eventName)
console.log(fullMetadata)
console.groupEnd()
// TODO: Reenable when the build issue is fixed.
// bitdrift.info(eventName, fullMetadata)
bitdrift.info(eventName, fullMetadata)
} catch (e) {
// A log should never interrupt the calling code, whatever happens.
logger.error('Failed to log an event', {message: e})
+6
View File
@@ -161,6 +161,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.ast
case 'ca':
return AppLanguage.ca
case 'da':
return AppLanguage.da
case 'de':
return AppLanguage.de
case 'el':
@@ -169,6 +171,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.en_GB
case 'es':
return AppLanguage.es
case 'eu':
return AppLanguage.eu
case 'fi':
return AppLanguage.fi
case 'fr':
@@ -203,6 +207,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.ro
case 'ru':
return AppLanguage.ru
case 'sv':
return AppLanguage.sv
case 'th':
return AppLanguage.th
case 'tr':
+27
View File
@@ -14,11 +14,13 @@ import {AppLanguage} from '#/locale/languages'
import {messages as messagesAn} from '#/locale/locales/an/messages'
import {messages as messagesAst} from '#/locale/locales/ast/messages'
import {messages as messagesCa} from '#/locale/locales/ca/messages'
import {messages as messagesDa} from '#/locale/locales/da/messages'
import {messages as messagesDe} from '#/locale/locales/de/messages'
import {messages as messagesEl} from '#/locale/locales/el/messages'
import {messages as messagesEn} from '#/locale/locales/en/messages'
import {messages as messagesEn_GB} from '#/locale/locales/en-GB/messages'
import {messages as messagesEs} from '#/locale/locales/es/messages'
import {messages as messagesEu} from '#/locale/locales/eu/messages'
import {messages as messagesFi} from '#/locale/locales/fi/messages'
import {messages as messagesFr} from '#/locale/locales/fr/messages'
import {messages as messagesGa} from '#/locale/locales/ga/messages'
@@ -36,6 +38,7 @@ import {messages as messagesPl} from '#/locale/locales/pl/messages'
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
import {messages as messagesRo} from '#/locale/locales/ro/messages'
import {messages as messagesRu} from '#/locale/locales/ru/messages'
import {messages as messagesSv} from '#/locale/locales/sv/messages'
import {messages as messagesTh} from '#/locale/locales/th/messages'
import {messages as messagesTr} from '#/locale/locales/tr/messages'
import {messages as messagesUk} from '#/locale/locales/uk/messages'
@@ -74,6 +77,14 @@ export async function dynamicActivate(locale: AppLanguage) {
])
break
}
case AppLanguage.da: {
i18n.loadAndActivate({locale, messages: messagesDa})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/da'),
import('@formatjs/intl-numberformat/locale-data/da'),
])
break
}
case AppLanguage.de: {
i18n.loadAndActivate({locale, messages: messagesDe})
await Promise.all([
@@ -106,6 +117,14 @@ export async function dynamicActivate(locale: AppLanguage) {
])
break
}
case AppLanguage.eu: {
i18n.loadAndActivate({locale, messages: messagesEu})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/eu'),
import('@formatjs/intl-numberformat/locale-data/eu'),
])
break
}
case AppLanguage.fi: {
i18n.loadAndActivate({locale, messages: messagesFi})
await Promise.all([
@@ -238,6 +257,14 @@ export async function dynamicActivate(locale: AppLanguage) {
])
break
}
case AppLanguage.sv: {
i18n.loadAndActivate({locale, messages: messagesSv})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/sv'),
import('@formatjs/intl-numberformat/locale-data/sv'),
])
break
}
case AppLanguage.th: {
i18n.loadAndActivate({locale, messages: messagesTh})
await Promise.all([
+12
View File
@@ -24,6 +24,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/ca/messages`)
break
}
case AppLanguage.da: {
mod = await import(`./locales/da/messages`)
break
}
case AppLanguage.de: {
mod = await import(`./locales/de/messages`)
break
@@ -40,6 +44,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/es/messages`)
break
}
case AppLanguage.eu: {
mod = await import(`./locales/eu/messages`)
break
}
case AppLanguage.fi: {
mod = await import(`./locales/fi/messages`)
break
@@ -108,6 +116,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/ru/messages`)
break
}
case AppLanguage.sv: {
mod = await import(`./locales/sv/messages`)
break
}
case AppLanguage.th: {
mod = await import(`./locales/th/messages`)
break
+6
View File
@@ -9,10 +9,12 @@ export enum AppLanguage {
an = 'an',
ast = 'ast',
ca = 'ca',
da = 'da',
de = 'de',
el = 'el',
en_GB = 'en-GB',
es = 'es',
eu = 'eu',
fi = 'fi',
fr = 'fr',
ga = 'ga',
@@ -30,6 +32,7 @@ export enum AppLanguage {
pt_BR = 'pt-BR',
ro = 'ro',
ru = 'ru',
sv = 'sv',
th = 'th',
tr = 'tr',
uk = 'uk',
@@ -49,10 +52,12 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.an, name: 'Aragonés Aragonese'},
{code2: AppLanguage.ast, name: 'Asturianu Asturian'},
{code2: AppLanguage.ca, name: 'Català Catalan'},
{code2: AppLanguage.da, name: 'Dansk Danish'},
{code2: AppLanguage.de, name: 'Deutsch German'},
{code2: AppLanguage.el, name: 'Ελληνικά Greek'},
{code2: AppLanguage.en_GB, name: 'English (UK)'},
{code2: AppLanguage.es, name: 'Español Spanish'},
{code2: AppLanguage.eu, name: 'Euskera Basque'},
{code2: AppLanguage.fi, name: 'Suomi Finnish'},
{code2: AppLanguage.fr, name: 'Français French'},
{code2: AppLanguage.ga, name: 'Gaeilge Irish'},
@@ -70,6 +75,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.pt_BR, name: 'Português (BR) Portuguese (BR)'},
{code2: AppLanguage.ro, name: 'Română Romanian'},
{code2: AppLanguage.ru, name: 'Русский Russian'},
{code2: AppLanguage.sv, name: 'Svenska Swedish'},
{code2: AppLanguage.th, name: 'ภาษาไทย Thai'},
{code2: AppLanguage.tr, name: 'Türkçe Turkish'},
{code2: AppLanguage.uk, name: 'Українська Ukrainian'},
+5 -5
View File
@@ -1936,7 +1936,7 @@ msgstr "Tanca el diàleg"
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:137
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:173
msgid "Close emoji picker"
msgstr ""
msgstr "Tanca el selector d'emojis"
#: src/components/dialogs/GifSelect.tsx:169
msgid "Close GIF dialog"
@@ -2429,7 +2429,7 @@ msgstr "Creat {0}"
#: src/screens/List/ListHiddenScreen.tsx:127
msgid "Creator has been blocked"
msgstr ""
msgstr "S'ha bloquejat al creador"
#: src/screens/Onboarding/index.tsx:26
#: src/screens/Onboarding/state.ts:99
@@ -3049,7 +3049,7 @@ msgstr "Ensenyament"
#: src/screens/List/ListHiddenScreen.tsx:141
msgid "Either the creator of this list has blocked you or you have blocked the creator."
msgstr ""
msgstr "El creador d'aquesta llista t'ha bloquejat o l'has bloquejat tu."
#: src/screens/Settings/AccountSettings.tsx:60
#: src/screens/Signup/StepInfo/index.tsx:170
@@ -4837,7 +4837,7 @@ msgstr "Llista per <0/>"
#: src/view/com/profile/ProfileSubpageHeader.tsx:154
msgid "List by you"
msgstr ""
msgstr "Llista teva"
#: src/view/screens/ProfileList.tsx:455
msgid "List deleted"
@@ -8675,7 +8675,7 @@ msgstr "Toca per canviar la icona de l'aplicació"
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:138
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:174
msgid "Tap to close the emoji picker"
msgstr ""
msgstr "Toca per a tancar el selector d'emojis"
#: src/components/ProgressGuide/Toast.tsx:156
msgid "Tap to dismiss"
File diff suppressed because it is too large Load Diff
+115 -115
View File
@@ -1,6 +1,5 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2023-12-22 01:56+0530\n"
msgstr "POT-Creation-Date: 2023-12-22 01:56+0530\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -9,13 +8,14 @@ msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: brodieavoult\n"
"Last-Translator: hogaza\n"
"Language-Team: brodieavoult\n"
"Plural-Forms: \n"
"Plural-Forms:\n"
" \n"
#: src/components/ProgressGuide/FollowDialog.tsx:567
msgid "(active)"
msgstr ""
msgstr "(activo)"
#: src/screens/Messages/components/ChatListItem.tsx:130
msgid "(contains embedded content)"
@@ -37,11 +37,11 @@ msgstr "{0, plural, one {# día} other {# días}}"
#: src/screens/Profile/ProfileFollowers.tsx:40
msgid "{0, plural, one {# follower} other {# followers}}"
msgstr ""
msgstr "{0, plural, one {# seguidor} other {# seguidores}}"
#: src/screens/Profile/ProfileFollows.tsx:40
msgid "{0, plural, one {# following} other {# following}}"
msgstr ""
msgstr "{0, plural, one {# siguiendo} other {# siguiendo}}"
#: src/lib/hooks/useTimeAgo.ts:146
msgid "{0, plural, one {# hour} other {# hours}}"
@@ -65,15 +65,15 @@ msgstr "{0, plural, one {# hora} other {# horas}}"
#: src/components/moderation/LabelsOnMe.tsx:53
msgid "{0, plural, one {# label has} other {# labels have}} been placed on this account"
msgstr ""
msgstr "Se ha marcado esta cuenta con {0, plural, one {# etiqueta} other {# etiquetas}}"
#: src/components/moderation/LabelsOnMe.tsx:62
msgid "{0, plural, one {# label has} other {# labels have}} been placed on this content"
msgstr ""
msgstr "Se ha marcado este contenido con {0, plural, one {# etiqueta} other {# etiquetas}}"
#: src/screens/Post/PostLikedBy.tsx:41
msgid "{0, plural, one {# like} other {# likes}}"
msgstr ""
msgstr "{0, plural, one {# me gusta} other {# me gusta}}"
#: src/lib/hooks/useTimeAgo.ts:136
msgid "{0, plural, one {# minute} other {# minutes}}"
@@ -85,7 +85,7 @@ msgstr "{0, plural, one {# mes} other {# meses}}"
#: src/screens/Post/PostQuotes.tsx:41
msgid "{0, plural, one {# quote} other {# quotes}}"
msgstr ""
msgstr "{0, plural, one {# cita} other {# citas}}"
#: src/screens/Post/PostRepostedBy.tsx:41
msgid "{0, plural, one {# repost} other {# reposts}}"
@@ -115,7 +115,7 @@ msgstr "{0, plural, one {siguiendo} other {siguiendo}}"
#: src/view/com/post-thread/PostThreadItem.tsx:447
msgid "{0, plural, one {like} other {likes}}"
msgstr "{0, plural, one {\"me gusta\"} other {\"me gusta\"}}"
msgstr "{0, plural, one {me gusta} other {me gusta}}"
#: src/components/FeedCard.tsx:213
#: src/view/com/feeds/FeedSourceCard.tsx:303
@@ -264,11 +264,11 @@ msgstr "{firstAuthorLink} y <0>{additionalAuthorsCount, plural, one {{formattedA
#: src/view/com/notifications/NotificationFeedItem.tsx:327
msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}}</0> liked your custom feed"
msgstr "{firstAuthorLink} y <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} más} other {{formattedAuthorsCount} más}}</0> dieron \"me gusta\" a tu feed"
msgstr "A {firstAuthorLink} y <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} más} other {{formattedAuthorsCount} más}}</0> les gusta tu feed"
#: src/view/com/notifications/NotificationFeedItem.tsx:223
msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}}</0> liked your post"
msgstr "{firstAuthorLink} y <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} más} other {{formattedAuthorsCount} más}}</0> dieron \"me gusta\" a tu publicación"
msgstr "A {firstAuthorLink} y <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} más} other {{formattedAuthorsCount} más}}</0> les gusta tu publicación"
#: src/view/com/notifications/NotificationFeedItem.tsx:247
msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}}</0> reposted your post"
@@ -288,11 +288,11 @@ msgstr "{firstAuthorLink} te siguió de vuelta"
#: src/view/com/notifications/NotificationFeedItem.tsx:339
msgid "{firstAuthorLink} liked your custom feed"
msgstr "{firstAuthorLink} dio \"me gusta\" a tu feed personalizado"
msgstr "A {firstAuthorLink} le gusta tu feed personalizado"
#: src/view/com/notifications/NotificationFeedItem.tsx:235
msgid "{firstAuthorLink} liked your post"
msgstr "{firstAuthorLink} dio \"me gusta\" a tu publicación"
msgstr "A {firstAuthorLink} le gusta tu publicación"
#: src/view/com/notifications/NotificationFeedItem.tsx:259
msgid "{firstAuthorLink} reposted your post"
@@ -308,11 +308,11 @@ msgstr "{firstAuthorName} y {formattedAuthorsCount} más te siguieron"
#: src/view/com/notifications/NotificationFeedItem.tsx:320
msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your custom feed"
msgstr "{firstAuthorName} y {formattedAuthorsCount} más dieron \"me gusta\" a tu feed personalizado"
msgstr "A {firstAuthorName} y {formattedAuthorsCount} más les gusta tu feed personalizado"
#: src/view/com/notifications/NotificationFeedItem.tsx:216
msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your post"
msgstr "{firstAuthorName} y {formattedAuthorsCount} más dieron \"me gusta\" a tu publicación"
msgstr "A {firstAuthorName} y {formattedAuthorsCount} les gusta tu publicación"
#: src/view/com/notifications/NotificationFeedItem.tsx:240
msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your post"
@@ -332,11 +332,11 @@ msgstr "{firstAuthorName} te siguió de vuelta"
#: src/view/com/notifications/NotificationFeedItem.tsx:325
msgid "{firstAuthorName} liked your custom feed"
msgstr "{firstAuthorName} dio \"me gusta\" a tu feed personalizado"
msgstr "A {firstAuthorName} le gusta tu feed personalizado"
#: src/view/com/notifications/NotificationFeedItem.tsx:221
msgid "{firstAuthorName} liked your post"
msgstr "{firstAuthorName} dio \"me gusta\" a tu publicación"
msgstr "A {firstAuthorName} le gusta tu publicación"
#: src/view/com/notifications/NotificationFeedItem.tsx:245
msgid "{firstAuthorName} reposted your post"
@@ -363,7 +363,7 @@ msgstr "No se puede enviar un mensaje a {handle}"
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:260
msgid "{notificationCount} unread items"
msgstr ""
msgstr "{notificationCount} elementos sin leer"
#: src/view/shell/Drawer.tsx:448
msgid "{numUnreadNotifications} unread"
@@ -532,16 +532,16 @@ msgstr "Cuenta seguida"
#: src/view/com/profile/ProfileMenu.tsx:108
msgid "Account muted"
msgstr "Cuenta muteada"
msgstr "Cuenta silenciada"
#: src/components/moderation/ModerationDetailsDialog.tsx:101
#: src/lib/moderation/useModerationCauseDescription.ts:96
msgid "Account Muted"
msgstr "Cuenta muteada"
msgstr "Cuenta silenciada"
#: src/components/moderation/ModerationDetailsDialog.tsx:87
msgid "Account Muted by List"
msgstr "Cuenta muteada por lista"
msgstr "Cuenta silenciada por lista"
#: src/screens/Settings/Settings.tsx:428
msgid "Account options"
@@ -645,7 +645,7 @@ msgstr "Agregar nueva publicación"
#: src/view/screens/ProfileList.tsx:903
#: src/view/screens/ProfileList.tsx:921
msgid "Add people"
msgstr ""
msgstr "Añadir personas"
#: src/screens/StarterPack/Wizard/index.tsx:197
#~ msgid "Add people to your starter pack that you think others will enjoy following"
@@ -710,7 +710,7 @@ msgstr "El contenido para adultos solo se puede habilitar a través de la web en
#: src/components/moderation/LabelPreference.tsx:242
msgid "Adult content is disabled."
msgstr "El contenido adulto esta desactivado."
msgstr "El contenido adulto está desactivado."
#: src/view/com/composer/labels/LabelsBtn.tsx:139
#: src/view/com/composer/labels/LabelsBtn.tsx:197
@@ -727,7 +727,7 @@ msgstr "Avanzado"
#: src/view/screens/Notifications.tsx:86
msgid "All"
msgstr ""
msgstr "Todas"
#: src/screens/StarterPack/StarterPackScreen.tsx:381
msgid "All accounts have been followed!"
@@ -754,7 +754,7 @@ msgstr "Permitir nuevos mensajes de"
#: src/components/dialogs/PostInteractionSettingsDialog.tsx:314
msgid "Allow quote posts"
msgstr ""
msgstr "Permitir citas"
#: src/components/dialogs/PostInteractionSettingsDialog.tsx:359
msgid "Allow replies from:"
@@ -933,7 +933,7 @@ msgstr "Cualquiera puede interactuar"
#: src/screens/Settings/AppIconSettings/SettingsListItem.tsx:18
#: src/screens/Settings/AppIconSettings/SettingsListItem.tsx:23
msgid "App Icon"
msgstr ""
msgstr "Icono de la app"
#: src/screens/Settings/LanguageSettings.tsx:80
msgid "App Language"
@@ -1165,7 +1165,7 @@ msgstr "Antes de contactar a otro usuario, debes verificar tu correo electrónic
#: src/screens/Search/components/ExploreTrendingTopics.tsx:74
msgid "BETA"
msgstr ""
msgstr "BETA"
#: src/components/dialogs/BirthDateSettings.tsx:106
#: src/screens/Settings/AccountSettings.tsx:109
@@ -1289,11 +1289,11 @@ msgstr "Bluesky no mostrará tu perfil o posts a usuarios que no hayan iniciado
#: src/screens/Settings/AppIconSettings/index.tsx:99
msgid "Bluesky+"
msgstr ""
msgstr "Bluesky+"
#: src/screens/Settings/AppIconSettings/index.tsx:102
msgid "Bluesky+ icons"
msgstr ""
msgstr "Iconos Bluesky+"
#: src/lib/moderation/useLabelBehaviorDescription.ts:53
msgid "Blur images"
@@ -1339,15 +1339,15 @@ msgstr "Explorar otros feeds"
#: src/components/TrendingTopics.tsx:176
msgid "Browse posts about {displayName}"
msgstr ""
msgstr "Explorar posts sobre {displayName}"
#: src/components/TrendingTopics.tsx:184
msgid "Browse posts tagged with {displayName}"
msgstr ""
msgstr "Explorar posts etiquetados con {displayName}"
#: src/components/TrendingTopics.tsx:222
msgid "Browse topic {displayName}"
msgstr ""
msgstr "Explorar el tema {displayName}"
#: src/view/com/auth/SplashScreen.web.tsx:168
msgid "Business"
@@ -1371,7 +1371,7 @@ msgstr "Por {0}"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:447
msgid "By <0>{0}</0>"
msgstr ""
msgstr "Por <0>{0}</0>"
#: src/screens/Signup/StepInfo/Policies.tsx:80
#~ msgid "By creating an account you agree to the {els}."
@@ -1504,12 +1504,12 @@ msgstr "Cambiar"
#: src/screens/Settings/AppIconSettings/index.tsx:39
msgid "Change app icon"
msgstr ""
msgstr "Cambiar icono de la app"
#: src/screens/Settings/AppIconSettings/index.tsx:38
#: src/screens/Settings/AppIconSettings/index.tsx:221
msgid "Change app icon to \"{0}\""
msgstr ""
msgstr "Cambiar icono de la app a \"{0}\""
#: src/screens/Settings/AccountSettings.tsx:97
#: src/screens/Settings/AccountSettings.tsx:101
@@ -1578,7 +1578,7 @@ msgstr "Configuración de chat"
#: src/components/dms/ConvoMenu.tsx:84
msgid "Chat unmuted"
msgstr "Chat demuteado"
msgstr "Chat no silenciado"
#: src/screens/SignupQueued.tsx:78
#: src/screens/SignupQueued.tsx:82
@@ -1649,7 +1649,7 @@ msgstr "Elige este color como tu avatar"
#: src/view/screens/Feeds.tsx:728
#: src/view/screens/Search/Explore.tsx:411
msgid "Choose your own timeline! Feeds built by the community help you find content you love."
msgstr ""
msgstr "Elige tu propia cronología: encuentra el contenido que más te gusta gracias a los feeds de la comunidad."
#: src/screens/Signup/StepInfo/index.tsx:201
msgid "Choose your password"
@@ -1761,11 +1761,11 @@ msgstr "Cerrar ventana"
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:137
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:173
msgid "Close emoji picker"
msgstr ""
msgstr "Cerrar el selector de emoji"
#: src/components/dialogs/GifSelect.tsx:169
msgid "Close GIF dialog"
msgstr "Cerrar ventana de GIF"
msgstr "Cerrar la ventana de GIF"
#: src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx:31
msgid "Close image"
@@ -1934,7 +1934,7 @@ msgstr "Contactar a soporte"
#: src/screens/Settings/ContentAndMediaSettings.tsx:49
msgid "Content & Media"
msgstr ""
msgstr "Contenido y medios"
#: src/screens/Settings/AccessibilitySettings.tsx:109
#: src/screens/Settings/Settings.tsx:175
@@ -2212,7 +2212,7 @@ msgstr "Creado {0}"
#: src/screens/List/ListHiddenScreen.tsx:127
msgid "Creator has been blocked"
msgstr ""
msgstr "Se ha bloqueado al creador"
#: src/screens/Onboarding/index.tsx:26
#: src/screens/Onboarding/state.ts:99
@@ -2286,7 +2286,7 @@ msgstr "Por Defecto"
#: src/screens/Settings/AppIconSettings/index.tsx:75
msgid "Default icons"
msgstr ""
msgstr "Iconos por defecto"
#: src/components/dms/MessageMenu.tsx:151
#: src/screens/Settings/AppPasswords.tsx:212
@@ -2720,7 +2720,7 @@ msgstr "Editar los detalles de la lista"
#: src/view/com/modals/CreateOrEditList.tsx:230
msgid "Edit Moderation List"
msgstr "Editar lista de Moderación"
msgstr "Editar lista de moderación"
#: src/Navigation.tsx:296
#: src/view/screens/Feeds.tsx:515
@@ -2792,7 +2792,7 @@ msgstr "Educación"
#: src/screens/List/ListHiddenScreen.tsx:141
msgid "Either the creator of this list has blocked you or you have blocked the creator."
msgstr ""
msgstr "La persona que creó esta lista te ha bloqueado, o tú la has bloqueado a ella."
#: src/screens/Settings/AccountSettings.tsx:60
#: src/screens/Signup/StepInfo/index.tsx:170
@@ -2910,7 +2910,7 @@ msgstr "Habilitar solo esta fuente"
#: src/screens/Settings/ContentAndMediaSettings.tsx:122
#: src/screens/Settings/ContentAndMediaSettings.tsx:136
msgid "Enable trending topics"
msgstr ""
msgstr "Activar las tendencias"
#: src/screens/Messages/Settings.tsx:130
#: src/screens/Messages/Settings.tsx:133
@@ -3261,7 +3261,7 @@ msgstr "Feed por {0}"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:353
msgid "Feed menu"
msgstr ""
msgstr "Menú de feed"
#: src/view/screens/Feeds.tsx:709
#~ msgid "Feed offline"
@@ -3308,7 +3308,7 @@ msgstr "¡Feeds actualizados!"
#: src/screens/Search/components/ExploreRecommendations.tsx:54
msgid "Feeds we think you might like."
msgstr ""
msgstr "Creemos que estos estos feeds te gustarán."
#: src/view/com/modals/ChangeHandle.tsx:468
#~ msgid "File Contents"
@@ -3340,7 +3340,7 @@ msgstr "Buscar cuentas para seguir"
#: src/components/ProgressGuide/FollowDialog.tsx:88
#: src/components/ProgressGuide/FollowDialog.tsx:417
msgid "Find people to follow"
msgstr ""
msgstr "Buscar a gente para seguir"
#: src/view/screens/Search/Search.tsx:595
msgid "Find posts and users on Bluesky"
@@ -3405,7 +3405,7 @@ msgstr "Seguir {name}"
#: src/components/ProgressGuide/List.tsx:52
#: src/state/shell/progress-guide.tsx:229
msgid "Follow 10 accounts"
msgstr ""
msgstr "Sigue 10 cuentas"
#: src/components/ProgressGuide/List.tsx:69
msgid "Follow 7 accounts"
@@ -3856,13 +3856,13 @@ msgstr "¿Ocultar esta respuesta?"
#: src/screens/Search/components/ExploreTrendingTopics.tsx:83
#: src/view/shell/desktop/SidebarTrendingTopics.tsx:62
msgid "Hide trending topics"
msgstr ""
msgstr "Ocultar las tendencias"
#: src/components/interstitials/Trending.tsx:99
#: src/screens/Search/components/ExploreTrendingTopics.tsx:135
#: src/view/shell/desktop/SidebarTrendingTopics.tsx:108
msgid "Hide trending topics?"
msgstr ""
msgstr "¿Quieres ocultar las tendencias?"
#: src/view/com/notifications/NotificationFeedItem.tsx:592
msgid "Hide user list"
@@ -4322,11 +4322,11 @@ msgstr "Claro"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:514
msgid "Like"
msgstr ""
msgstr "Me gusta"
#: src/view/com/util/post-ctrls/PostCtrls.tsx:311
msgid "Like ({0, plural, one {# like} other {# likes}})"
msgstr ""
msgstr "Like ({0, plural, one {# me gusta} other {# me gusta}})"
#: src/components/ProgressGuide/List.tsx:63
msgid "Like 10 posts"
@@ -4339,7 +4339,7 @@ msgstr "Dale \"me gusta\" a 10 publicaciones para entrenar el feed de Descubrimi
#: src/screens/Profile/components/ProfileFeedHeader.tsx:501
msgid "Like feed"
msgstr ""
msgstr "Me gusta este feed"
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:275
msgid "Like this feed"
@@ -4349,18 +4349,18 @@ msgstr "Dar \"me gusta\" a este feed"
#: src/Navigation.tsx:236
#: src/Navigation.tsx:241
msgid "Liked by"
msgstr "Le ha gustado a"
msgstr "Le gusta a"
#: src/screens/Post/PostLikedBy.tsx:38
#: src/screens/Profile/ProfileLabelerLikedBy.tsx:29
#: src/view/screens/ProfileFeedLikedBy.tsx:30
msgid "Liked By"
msgstr "Le ha gustado a"
msgstr "Le gusta a"
#: src/components/FeedCard.tsx:213
#: src/view/com/feeds/FeedSourceCard.tsx:303
msgid "Liked by {0, plural, one {# user} other {# users}}"
msgstr ""
msgstr "Le gusta a {0, plural, one {# usuario} other {# usuarios}}"
#: src/view/com/feeds/FeedSourceCard.tsx:268
#~ msgid "Liked by {0} {1}"
@@ -4375,7 +4375,7 @@ msgstr ""
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:295
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:309
msgid "Liked by {likeCount, plural, one {# user} other {# users}}"
msgstr ""
msgstr "Le gusta a {likeCount, plural, one {# usuario} other {# usuarios}}"
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:287
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:301
@@ -4393,7 +4393,7 @@ msgstr ""
#: src/view/screens/Profile.tsx:234
msgid "Likes"
msgstr "\"Me gusta\""
msgstr "Me gusta"
#: src/view/com/post-thread/PostThreadItem.tsx:217
msgid "Likes on this post"
@@ -4402,7 +4402,7 @@ msgstr "\"Me gusta\" en este post"
#: src/view/com/post-thread/PostThread.tsx:626
#: src/view/com/post-thread/PostThread.tsx:631
msgid "Linear"
msgstr ""
msgstr "Lineal"
#: src/Navigation.tsx:198
msgid "List"
@@ -4423,11 +4423,11 @@ msgstr "Lista por {0}"
#: src/view/com/profile/ProfileSubpageHeader.tsx:156
msgid "List by <0/>"
msgstr ""
msgstr "Lista de {0}"
#: src/view/com/profile/ProfileSubpageHeader.tsx:154
msgid "List by you"
msgstr ""
msgstr "Lista tuya"
#: src/view/screens/ProfileList.tsx:455
msgid "List deleted"
@@ -4523,7 +4523,7 @@ msgstr "Acceder a una cuenta que no está en la lista"
#: src/view/shell/desktop/RightNav.tsx:121
msgid "Logo by @sawaratsuki.bsky.social"
msgstr ""
msgstr "Logo de @sawaratsuki.bsky.social</0>"
#: src/view/shell/desktop/RightNav.tsx:103
#~ msgid "Logo by <0/>"
@@ -4532,7 +4532,7 @@ msgstr ""
#: src/view/shell/desktop/RightNav.tsx:118
#: src/view/shell/Drawer.tsx:629
msgid "Logo by <0>@sawaratsuki.bsky.social</0>"
msgstr "Logo por <0>@sawaratsuki.bsky.social</0>"
msgstr "Logo de <0>@sawaratsuki.bsky.social</0>"
#: src/components/RichText.tsx:219
msgid "Long press to open tag menu for #{tag}"
@@ -4540,7 +4540,7 @@ msgstr "Mantén presionado para abrir el menú de etiquetas para #{tag}"
#: src/screens/Login/SetNewPasswordForm.tsx:110
msgid "Looks like XXXXX-XXXXX"
msgstr "Es como XXXXX-XXXXX"
msgstr "Es algo así: XXXXX-XXXXX"
#: src/screens/Feeds/NoSavedFeedsOfAnyType.tsx:39
msgid "Looks like you haven't saved any feeds! Use our recommendations or browse more below."
@@ -4598,7 +4598,7 @@ msgstr "Usuarios mencionados"
#: src/view/screens/Notifications.tsx:99
msgid "Mentions"
msgstr ""
msgstr "Menciones"
#: src/components/Menu/index.tsx:96
#: src/view/screens/Search/Search.tsx:863
@@ -4972,7 +4972,7 @@ msgstr "Nuevo nombre de usuario"
#: src/view/screens/Lists.tsx:66
#: src/view/screens/ModerationModlists.tsx:66
msgid "New list"
msgstr ""
msgstr "Nueva lista"
#: src/components/dms/NewMessagesPill.tsx:92
msgid "New messages"
@@ -5394,31 +5394,31 @@ msgstr "Abrir el creador de avatares"
#: src/screens/Settings/AccountSettings.tsx:127
msgid "Open change handle dialog"
msgstr "Abrir diálogo de cambio de nombre de usuario"
msgstr "Abrir el diálogo de cambio de nombre de usuario"
#: src/screens/Messages/components/ChatListItem.tsx:272
#: src/screens/Messages/components/ChatListItem.tsx:273
msgid "Open conversation options"
msgstr "Abrir opciones de conversación"
msgstr "Abrir las opciones de conversación"
#: src/components/Layout/Header/index.tsx:145
msgid "Open drawer menu"
msgstr ""
msgstr "Abrir el menú lateral"
#: src/screens/Messages/components/MessageInput.web.tsx:181
#: src/view/com/composer/Composer.tsx:1228
#: src/view/com/composer/Composer.tsx:1229
msgid "Open emoji picker"
msgstr "Abrir selector de emoji"
msgstr "Abrir el selector de emoji"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:190
msgid "Open feed info screen"
msgstr ""
msgstr "Abrir la pantalla de información del feed"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:290
#: src/screens/Profile/components/ProfileFeedHeader.tsx:295
msgid "Open feed options menu"
msgstr "Abrir menú de opciones del feed"
msgstr "Abrir el menú de opciones del feed"
#: src/screens/Settings/Settings.tsx:208
msgid "Open helpdesk in browser"
@@ -5745,7 +5745,7 @@ msgstr "Imágenes destinadas a adultos."
#: src/screens/Profile/components/ProfileFeedHeader.tsx:519
#: src/screens/Profile/components/ProfileFeedHeader.tsx:526
msgid "Pin feed"
msgstr ""
msgstr "Anclar el feed"
#: src/view/screens/ProfileList.tsx:685
msgid "Pin to home"
@@ -5767,7 +5767,7 @@ msgstr "Anclado"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:162
#: src/screens/Profile/components/ProfileFeedHeader.tsx:174
msgid "Pinned {0} to Home"
msgstr ""
msgstr "{0} anclados en inicio"
#: src/view/screens/SavedFeeds.tsx:123
msgid "Pinned Feeds"
@@ -6097,11 +6097,11 @@ msgstr "Público"
#: src/view/com/lists/MyLists.tsx:77
msgid "Public, sharable lists of users to mute or block in bulk."
msgstr ""
msgstr "Listas públicas y compartibles para bloquear o silenciar a usuarios en grupo."
#: src/view/com/lists/MyLists.tsx:72
msgid "Public, sharable lists which can be used to drive feeds."
msgstr ""
msgstr "Listas públicas y compartibles que se pueden usar para impulsar feeds."
#: src/view/screens/ModerationModlists.tsx:75
#~ msgid "Public, shareable lists of users to mute or block in bulk."
@@ -6230,7 +6230,7 @@ msgstr "Búsquedas Recientes"
#: src/screens/Search/components/ExploreRecommendations.tsx:50
msgid "Recommended"
msgstr ""
msgstr "Recomendaciones"
#: src/screens/Messages/components/MessageListError.tsx:20
msgid "Reconnect"
@@ -6426,7 +6426,7 @@ msgstr "Responder"
#: src/view/com/util/post-ctrls/PostCtrls.tsx:263
msgid "Reply ({0, plural, one {# reply} other {# replies}})"
msgstr ""
msgstr "Responder ({0, plural, one {# respuesta} other {# respuestas}})"
#: src/view/screens/PreferencesFollowingFeed.tsx:142
#~ msgid "Reply Filters"
@@ -6452,7 +6452,7 @@ msgstr "La configuración de respuestas es elegida por el autor del hilo"
#: src/view/com/post-thread/PostThread.tsx:648
msgid "Reply sorting"
msgstr ""
msgstr "Orden de las respuestas"
#: src/view/com/post/Post.tsx:204
#: src/view/com/posts/PostFeedItem.tsx:553
@@ -6576,7 +6576,7 @@ msgstr "Republicar"
#: src/view/com/util/post-ctrls/RepostButton.tsx:74
msgid "Repost ({0, plural, one {# repost} other {# reposts}})"
msgstr ""
msgstr "Republicar ({0, plural, one {# repost} other {# reposts}})"
#: src/screens/StarterPack/StarterPackScreen.tsx:548
#: src/view/com/util/post-ctrls/RepostButton.tsx:148
@@ -6860,15 +6860,15 @@ msgstr "Buscar"
#: src/components/ProgressGuide/FollowDialog.tsx:745
msgid "Search by name or interest"
msgstr ""
msgstr "Buscar por nombre o interés"
#: src/view/screens/Feeds.tsx:441
msgid "Search feeds"
msgstr ""
msgstr "Buscar feeds"
#: src/components/ProgressGuide/FollowDialog.tsx:575
msgid "Search for \"{interestsDisplayName}\"{activeText}"
msgstr ""
msgstr "Buscar \"{interestsDisplayName}\"{activeText}"
#: src/view/shell/desktop/Search.tsx:201
msgid "Search for \"{query}\""
@@ -7148,7 +7148,7 @@ msgstr "Dirección del servidor"
#: src/screens/Settings/AppIconSettings/index.tsx:178
msgid "Set app icon to {0}"
msgstr ""
msgstr "Cambiar el icono de la app a {0}"
#: src/screens/Moderation/index.tsx:290
msgid "Set birthdate"
@@ -7305,7 +7305,7 @@ msgstr "Compartir código QR"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:470
msgid "Share this feed"
msgstr ""
msgstr "Compartir este feed"
#: src/screens/StarterPack/StarterPackScreen.tsx:416
msgid "Share this starter pack"
@@ -7436,11 +7436,11 @@ msgstr "Mostrar respuestas"
#: src/view/com/post-thread/PostThread.tsx:622
msgid "Show replies as"
msgstr ""
msgstr "Mostrar las respuestas como"
#: src/screens/Settings/ThreadPreferences.tsx:151
msgid "Show replies as threaded"
msgstr ""
msgstr "Mostrar las respuestas como hilos"
#: src/screens/Settings/ThreadPreferences.tsx:126
msgid "Show replies by people you follow before all other replies"
@@ -7570,7 +7570,7 @@ msgstr "¿Cerrar sesión?"
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Create account"
msgstr "Crear cuenta"
msgstr "Crear una cuenta"
#: src/view/shell/NavSignupCard.tsx:47
#~ msgid "Sign up or sign in to join the conversation"
@@ -7655,7 +7655,7 @@ msgstr "¡Se produjo un error!"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:538
msgid "Something wrong? Let us know."
msgstr ""
msgstr "¿Algo va mal? Avísanos."
#: src/App.native.tsx:122
#: src/App.web.tsx:97
@@ -7719,12 +7719,12 @@ msgstr "Iniciar un nuevo chat"
#: src/view/screens/ProfileList.tsx:817
#: src/view/screens/ProfileList.tsx:938
msgid "Start adding people"
msgstr ""
msgstr "Empieza a añadir personas"
#: src/view/screens/ProfileList.tsx:824
#: src/view/screens/ProfileList.tsx:945
msgid "Start adding people!"
msgstr ""
msgstr "¡Empieza a añadir personas!"
#: src/components/dms/dialogs/SearchablePeopleList.tsx:353
msgid "Start chat with {displayName}"
@@ -7750,7 +7750,7 @@ msgstr "Paquete de inicio de {0}"
#: src/view/com/profile/ProfileSubpageHeader.tsx:182
msgid "Starter pack by <0/>"
msgstr ""
msgstr "Paquete de inicio de <0/>"
#: src/components/StarterPack/StarterPackCard.tsx:80
#: src/view/com/profile/ProfileSubpageHeader.tsx:180
@@ -7902,12 +7902,12 @@ msgstr "Solo etiquetas"
#: src/screens/Settings/AppIconSettings/index.tsx:216
msgid "Tap to change app icon"
msgstr ""
msgstr "Toca para cambiar el icono de la app"
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:138
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:174
msgid "Tap to close the emoji picker"
msgstr ""
msgstr "Toca para cerrar el selector de emoji"
#: src/components/ProgressGuide/Toast.tsx:156
msgid "Tap to dismiss"
@@ -7936,11 +7936,11 @@ msgstr "Toca para ver la imagen completa"
#: src/state/shell/progress-guide.tsx:233
msgid "Task complete - 10 follows!"
msgstr ""
msgstr "¡Tarea completada: 10 cuentas seguidas!"
#: src/state/shell/progress-guide.tsx:223
msgid "Task complete - 10 likes!"
msgstr "¡Tarea completada - 10 \"me gusta\"!"
msgstr "¡Tarea completada: 10 me gusta!"
#: src/components/ProgressGuide/List.tsx:64
msgid "Teach our algorithm what you like"
@@ -8051,7 +8051,7 @@ msgstr "La cuenta podrá interactuar contigo tras desbloquearla."
#: src/screens/Settings/AppIconSettings/index.tsx:41
#: src/screens/Settings/AppIconSettings/index.tsx:222
msgid "The app will be restarted"
msgstr ""
msgstr "Se reiniciará la app"
#: src/components/moderation/ModerationDetailsDialog.tsx:127
#~ msgid "the author"
@@ -8417,7 +8417,7 @@ msgstr "Esta lista - creada por <0>{0}</0> - contiene posibles violaciones de la
#: src/view/screens/ProfileList.tsx:933
msgid "This list is empty."
msgstr ""
msgstr "Esta lista está vacía"
#: src/screens/Profile/ErrorState.tsx:40
msgid "This moderation service is unavailable. See below for more details. If this issue persists, contact us."
@@ -8524,7 +8524,7 @@ msgstr "Esto eliminará tu publicación de esta cita para todos los usuarios y l
#: src/view/com/post-thread/PostThread.tsx:606
#: src/view/com/post-thread/PostThread.tsx:609
msgid "Thread options"
msgstr ""
msgstr "Opciones de hilos"
#: src/screens/Settings/ContentAndMediaSettings.tsx:66
#: src/screens/Settings/ContentAndMediaSettings.tsx:69
@@ -8542,7 +8542,7 @@ msgstr "Preferencias de hilos"
#: src/view/com/post-thread/PostThread.tsx:636
#: src/view/com/post-thread/PostThread.tsx:641
msgid "Threaded"
msgstr ""
msgstr "En hilos"
#: src/screens/Settings/ThreadPreferences.tsx:142
msgid "Threaded mode"
@@ -8604,7 +8604,7 @@ msgstr "Top"
#: src/Navigation.tsx:383
msgid "Topic"
msgstr ""
msgstr "Tema"
#: src/view/com/modals/EditImage.tsx:272
#~ msgid "Transformations"
@@ -8622,7 +8622,7 @@ msgstr "Traducir"
#: src/screens/Search/components/ExploreTrendingTopics.tsx:69
#: src/view/shell/desktop/SidebarTrendingTopics.tsx:59
msgid "Trending"
msgstr ""
msgstr "Tendencias"
#: src/view/com/util/error/ErrorScreen.tsx:103
msgctxt "action"
@@ -8716,7 +8716,7 @@ msgstr "Deshacer repost"
#: src/view/com/util/post-ctrls/RepostButton.tsx:68
msgid "Undo repost ({0, plural, one {# repost} other {# reposts}})"
msgstr ""
msgstr "Deshacer republicación ({0, plural, one {# repost} other {# reposts}})"
#: src/view/com/profile/FollowButton.tsx:60
msgctxt "action"
@@ -8742,7 +8742,7 @@ msgstr "No me gusta"
#: src/view/com/util/post-ctrls/PostCtrls.tsx:305
msgid "Unlike ({0, plural, one {# like} other {# likes}})"
msgstr ""
msgstr "No me gusta ({0, plural, one {# me gusta} other {# me gusta}})"
#: src/view/screens/ProfileFeed.tsx:576
#~ msgid "Unlike this feed"
@@ -8800,7 +8800,7 @@ msgstr "Desfijar"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:519
#: src/screens/Profile/components/ProfileFeedHeader.tsx:526
msgid "Unpin feed"
msgstr ""
msgstr "Desfijar feed"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:309
#: src/screens/Profile/components/ProfileFeedHeader.tsx:311
@@ -8818,7 +8818,7 @@ msgstr "Desfijar lista de moderación"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:164
msgid "Unpinned {0} from Home"
msgstr ""
msgstr "{0} desfijado de inicio"
#: src/view/screens/ProfileList.tsx:352
msgid "Unpinned from your feeds"
@@ -9284,7 +9284,7 @@ msgstr "No pudimos encontrar resultados para ese hashtag."
#: src/screens/Topic.tsx:178
msgid "We couldn't find any results for that topic."
msgstr ""
msgstr "No hemos encontrado resultados para ese tema."
#: src/screens/Messages/Conversation.tsx:103
msgid "We couldn't load this conversation"
@@ -9398,7 +9398,7 @@ msgstr "¿Cómo quieres llamar a tu paquete de inicio?"
#: src/screens/Search/components/ExploreTrendingTopics.tsx:79
msgid "What people are posting about."
msgstr ""
msgstr "Los temas del momento."
#: src/view/com/auth/SplashScreen.tsx:38
#: src/view/com/auth/SplashScreen.web.tsx:99
@@ -9586,7 +9586,7 @@ msgstr "Puedes reactivar tu cuenta para continuar iniciando sesión. Tu perfil y
#: src/screens/Search/components/ExploreTrendingTopics.tsx:136
#: src/view/shell/desktop/SidebarTrendingTopics.tsx:109
msgid "You can update this later from your settings."
msgstr ""
msgstr "Puedes cambiar esta opción en Ajustes."
#: src/view/com/profile/ProfileFollowers.tsx:94
msgid "You do not have any followers."
@@ -9832,7 +9832,7 @@ msgstr "Has elegido ocultar una palabra o etiqueta dentro de esta publicación."
#: src/state/shell/progress-guide.tsx:234
msgid "You've found some people to follow"
msgstr ""
msgstr "Has encontrado algunas personas a las que seguir"
#: src/view/com/posts/FollowingEndOfFeed.tsx:44
msgid "You've reached the end of your feed! Find some more accounts to follow."
@@ -9880,7 +9880,7 @@ msgstr "Tu elección será guardada. Puedes cambiar esto en los ajustes luego."
#: src/screens/Settings/components/ChangeHandleDialog.tsx:496
msgid "Your current handle <0>{0}</0> will automatically remain reserved for you. You can switch back to it at any time from this account."
msgstr ""
msgstr "Tu nombre de usuario actual, <0>{0}</0>, quedará automáticamente reservado para ti. Puedes volver a él cuando quieras desde esta cuenta."
#: src/screens/Onboarding/StepFollowingFeed.tsx:62
#~ msgid "Your default feed is \"Following\""
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -1437,7 +1437,7 @@ msgstr "Sulje valintaikkuna"
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:137
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:173
msgid "Close emoji picker"
msgstr ""
msgstr "Sulje emojinvalitsin"
#: src/components/dialogs/GifSelect.tsx:169
msgid "Close GIF dialog"
@@ -1823,7 +1823,7 @@ msgstr "Luotu {0}"
#: src/screens/List/ListHiddenScreen.tsx:127
msgid "Creator has been blocked"
msgstr ""
msgstr "Tekijä on estetty"
#: src/screens/Onboarding/index.tsx:26
#: src/screens/Onboarding/state.ts:99
@@ -2321,7 +2321,7 @@ msgstr "Koulutus"
#: src/screens/List/ListHiddenScreen.tsx:141
msgid "Either the creator of this list has blocked you or you have blocked the creator."
msgstr ""
msgstr "Joko listan tekijä on estänyt sinut tai sitten sinä olet estänyt listan tekijän."
#: src/screens/Settings/AccountSettings.tsx:60
#: src/screens/Signup/StepInfo/index.tsx:170
@@ -3410,7 +3410,7 @@ msgstr "Virheellinen tai ei-tuettu julkaisutietue"
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Incorrect username or password"
msgstr "Virheellinen käyttäjätunnus tai salasana"
msgstr "Väärä käyttäjätunnus tai salasana"
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
msgid "Invalid Verification Code"
@@ -4549,7 +4549,7 @@ msgstr "Hups!"
#: src/screens/Onboarding/StepFinished.tsx:260
msgid "Open"
msgstr "Avaa"
msgstr "Avoin"
#: src/view/com/posts/AviFollowButton.tsx:86
msgid "Open {name} profile shortcut menu"
@@ -4576,7 +4576,7 @@ msgstr "Avaa alavalikko"
#: src/view/com/composer/Composer.tsx:1228
#: src/view/com/composer/Composer.tsx:1229
msgid "Open emoji picker"
msgstr "Avaa emoji-valitsin"
msgstr "Avaa emojinvalitsin"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:190
msgid "Open feed info screen"
@@ -6283,7 +6283,7 @@ msgstr "Kirjaudutaanko ulos?"
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Create account"
msgstr "Rekisteröidy"
msgstr "Luo tili"
#: src/components/moderation/ScreenHider.tsx:91
#: src/lib/moderation/useGlobalLabelStrings.ts:28
@@ -6543,7 +6543,7 @@ msgstr "Napauta vaihtaaksesi sovelluskuvakkeen"
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:138
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:174
msgid "Tap to close the emoji picker"
msgstr ""
msgstr "Napauta sulkeaksesi emojinvalitsimen"
#: src/components/ProgressGuide/Toast.tsx:156
msgid "Tap to dismiss"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8062,7 +8062,7 @@ msgstr "Вы включили игнорирование этого пользо
#: src/screens/Messages/ChatList.tsx:190
msgid "You have no conversations yet. Start one!"
msgstr "У вас ещё нет бесед. Начните одну!"
msgstr "У вас ещё нет бесед. Создайте первую!"
#: src/view/com/feeds/ProfileFeedgens.tsx:143
msgid "You have no feeds."
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+88 -88
View File
@@ -8,14 +8,14 @@ msgstr ""
"Language: vi\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-11-20T08:45:59.857Z\n"
"Last-Translator: vnphanquang\n"
"PO-Revision-Date: 2025-01-22T08:45:59.857Z\n"
"Last-Translator: vinhphm\n"
"Language-Team: Translators in PR #2524, ManhNguyen98, vinhphm, vnphanquang\n"
"Plural-Forms: \n"
#: src/components/ProgressGuide/FollowDialog.tsx:567
msgid "(active)"
msgstr ""
msgstr "(hoạt động)"
#: src/screens/Messages/components/ChatListItem.tsx:130
msgid "(contains embedded content)"
@@ -32,11 +32,11 @@ msgstr "{0, plural, one {# ngày} other {# ngày}}"
#: src/screens/Profile/ProfileFollowers.tsx:40
msgid "{0, plural, one {# follower} other {# followers}}"
msgstr ""
msgstr "{0, plural, one {# người theo dõi} other {# người theo dõi}}"
#: src/screens/Profile/ProfileFollows.tsx:40
msgid "{0, plural, one {# following} other {# following}}"
msgstr ""
msgstr "{0, plural, one {# đang theo dõi} other {# đang theo dõi}}"
#: src/lib/hooks/useTimeAgo.ts:146
msgid "{0, plural, one {# hour} other {# hours}}"
@@ -52,15 +52,15 @@ msgstr "{0, plural, one {# giờ} other {# giờ}}"
#: src/components/moderation/LabelsOnMe.tsx:53
msgid "{0, plural, one {# label has} other {# labels have}} been placed on this account"
msgstr ""
msgstr "{0, plural, one {# nhãn} other {# nhãn}} được gắn lên tài khoản này"
#: src/components/moderation/LabelsOnMe.tsx:62
msgid "{0, plural, one {# label has} other {# labels have}} been placed on this content"
msgstr ""
msgstr "{0, plural, one {# nhãn} other {# nhãn}} được gắn lên nội dung này"
#: src/screens/Post/PostLikedBy.tsx:41
msgid "{0, plural, one {# like} other {# likes}}"
msgstr ""
msgstr "{0, plural, one {# lượt thích} other {# lượt thích}}"
#: src/lib/hooks/useTimeAgo.ts:136
msgid "{0, plural, one {# minute} other {# minutes}}"
@@ -72,7 +72,7 @@ msgstr "{0, plural, one {# tháng} other {# tháng}}"
#: src/screens/Post/PostQuotes.tsx:41
msgid "{0, plural, one {# quote} other {# quotes}}"
msgstr ""
msgstr "{0, plural, one {# trích dẫn} other {# trích dẫn}}"
#: src/screens/Post/PostRepostedBy.tsx:41
msgid "{0, plural, one {# repost} other {# reposts}}"
@@ -322,7 +322,7 @@ msgstr "không thể nhắn tin cho {handle}"
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:260
msgid "{notificationCount} unread items"
msgstr ""
msgstr "{notificationCount} mục chưa đọc"
#: src/view/shell/Drawer.tsx:448
msgid "{numUnreadNotifications} unread"
@@ -555,7 +555,7 @@ msgstr "Thêm bài mới"
#: src/view/screens/ProfileList.tsx:903
#: src/view/screens/ProfileList.tsx:921
msgid "Add people"
msgstr ""
msgstr "Thêm người"
#: src/screens/Home/NoFeedsPinned.tsx:99
msgid "Add recommended feeds"
@@ -629,7 +629,7 @@ msgstr "Nâng cao"
#: src/view/screens/Notifications.tsx:86
msgid "All"
msgstr ""
msgstr "Tất cả"
#: src/screens/StarterPack/StarterPackScreen.tsx:381
msgid "All accounts have been followed!"
@@ -651,7 +651,7 @@ msgstr "Cho phép tin nhắn mới từ"
#: src/components/dialogs/PostInteractionSettingsDialog.tsx:314
msgid "Allow quote posts"
msgstr ""
msgstr "Cho phép trích dẫn bài đăng"
#: src/components/dialogs/PostInteractionSettingsDialog.tsx:359
msgid "Allow replies from:"
@@ -998,7 +998,7 @@ msgstr "Bạn cần xác minh email trước khi có thể nhắn tin với ngư
#: src/screens/Search/components/ExploreTrendingTopics.tsx:74
msgid "BETA"
msgstr ""
msgstr "Thử nghiệm"
#: src/components/dialogs/BirthDateSettings.tsx:106
#: src/screens/Settings/AccountSettings.tsx:109
@@ -1114,7 +1114,7 @@ msgstr ""
#: src/screens/Settings/AppIconSettings/index.tsx:102
msgid "Bluesky+ icons"
msgstr ""
msgstr "Biểu tượng Bluesky+"
#: src/lib/moderation/useLabelBehaviorDescription.ts:53
msgid "Blur images"
@@ -1156,15 +1156,15 @@ msgstr "Xem các bảng tin khác"
#: src/components/TrendingTopics.tsx:176
msgid "Browse posts about {displayName}"
msgstr ""
msgstr "Tìm bài đăng về {displayName}"
#: src/components/TrendingTopics.tsx:184
msgid "Browse posts tagged with {displayName}"
msgstr ""
msgstr "Tìm bài đăng được gắn thẻ với {displayName}"
#: src/components/TrendingTopics.tsx:222
msgid "Browse topic {displayName}"
msgstr ""
msgstr "Tìm chủ đề {displayName}"
#: src/view/com/auth/SplashScreen.web.tsx:168
msgid "Business"
@@ -1184,7 +1184,7 @@ msgstr "Bởi {0}"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:447
msgid "By <0>{0}</0>"
msgstr ""
msgstr "Bởi <0>{0}</0>"
#: src/screens/Signup/StepInfo/Policies.tsx:81
msgid "By creating an account you agree to the <0>Privacy Policy</0>."
@@ -1296,7 +1296,7 @@ msgstr "Thay đổi"
#: src/screens/Settings/AppIconSettings/index.tsx:39
msgid "Change app icon"
msgstr ""
msgstr "Thay đổi biểu tượng ứng dụng"
#: src/screens/Settings/AppIconSettings/index.tsx:38
#: src/screens/Settings/AppIconSettings/index.tsx:221
@@ -1412,7 +1412,7 @@ msgstr "Chọn màu này làm hình đại diện của bạn"
#: src/view/screens/Feeds.tsx:728
#: src/view/screens/Search/Explore.tsx:411
msgid "Choose your own timeline! Feeds built by the community help you find content you love."
msgstr ""
msgstr "Chọn bảng tin của riêng bạn! Các bảng tin được xây dựng bởi cộng đồng giúp bạn tìm nội dung bạn yêu thích."
#: src/screens/Signup/StepInfo/index.tsx:201
msgid "Choose your password"
@@ -1468,7 +1468,7 @@ msgstr "Khí hậu"
#: src/components/dms/ChatEmptyPill.tsx:39
msgid "Clip 🐴 clop 🐴"
msgstr "Cộp 🐴 cộp 🐴"
msgstr "Lộp 🐴 cộp 🐴"
#: src/components/dialogs/GifSelect.tsx:281
#: src/components/dialogs/VerifyEmailDialog.tsx:289
@@ -1504,7 +1504,7 @@ msgstr "Đóng hộp thoại"
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:137
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:173
msgid "Close emoji picker"
msgstr ""
msgstr "Đóng bảng chọn emoji"
#: src/components/dialogs/GifSelect.tsx:169
msgid "Close GIF dialog"
@@ -1657,7 +1657,7 @@ msgstr "Liên hệ hỗ trợ"
#: src/screens/Settings/ContentAndMediaSettings.tsx:49
msgid "Content & Media"
msgstr ""
msgstr "Nội dung & phương tiện"
#: src/screens/Settings/AccessibilitySettings.tsx:109
#: src/screens/Settings/Settings.tsx:175
@@ -1890,7 +1890,7 @@ msgstr "Đã tạo {0}"
#: src/screens/List/ListHiddenScreen.tsx:127
msgid "Creator has been blocked"
msgstr ""
msgstr "Người tạo đã bị chặn"
#: src/screens/Onboarding/index.tsx:26
#: src/screens/Onboarding/state.ts:99
@@ -1948,7 +1948,7 @@ msgstr "Mặc định"
#: src/screens/Settings/AppIconSettings/index.tsx:75
msgid "Default icons"
msgstr ""
msgstr "Biểu tượng mặc định"
#: src/components/dms/MessageMenu.tsx:151
#: src/screens/Settings/AppPasswords.tsx:212
@@ -2246,7 +2246,7 @@ msgstr "Thả vào để thêm hình ảnh"
#: src/components/dialogs/MutedWords.tsx:153
msgid "Duration:"
msgstr "Thời gian:"
msgstr "Thời hạn:"
#: src/screens/Settings/components/ChangeHandleDialog.tsx:213
msgid "e.g. alice"
@@ -2393,7 +2393,7 @@ msgstr "Giáo dục"
#: src/screens/List/ListHiddenScreen.tsx:141
msgid "Either the creator of this list has blocked you or you have blocked the creator."
msgstr ""
msgstr "Người tạo danh sách này đã chặn bạn hoặc bạn đã chặn người đó."
#: src/screens/Settings/AccountSettings.tsx:60
#: src/screens/Signup/StepInfo/index.tsx:170
@@ -2494,7 +2494,7 @@ msgstr "Chỉ bật nguồn này"
#: src/screens/Settings/ContentAndMediaSettings.tsx:122
#: src/screens/Settings/ContentAndMediaSettings.tsx:136
msgid "Enable trending topics"
msgstr ""
msgstr "Mở chủ đề nổi trội"
#: src/screens/Messages/Settings.tsx:130
#: src/screens/Messages/Settings.tsx:133
@@ -2808,7 +2808,7 @@ msgstr "Bảng tin bởi {0}"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:353
msgid "Feed menu"
msgstr ""
msgstr "Menu bảng tin"
#: src/components/StarterPack/Wizard/WizardListCard.tsx:54
msgid "Feed toggle"
@@ -2847,7 +2847,7 @@ msgstr "Đã cập nhật bảng tin"
#: src/screens/Search/components/ExploreRecommendations.tsx:54
msgid "Feeds we think you might like."
msgstr ""
msgstr "Bảng tin mà bạn có thể thích."
#: src/screens/Settings/components/ExportCarDialog.tsx:43
msgid "File saved successfully!"
@@ -2871,7 +2871,7 @@ msgstr "Tìm tài khoản đề theo dõi"
#: src/components/ProgressGuide/FollowDialog.tsx:88
#: src/components/ProgressGuide/FollowDialog.tsx:417
msgid "Find people to follow"
msgstr ""
msgstr "Tìm người để theo dõi"
#: src/view/screens/Search/Search.tsx:595
msgid "Find posts and users on Bluesky"
@@ -2915,7 +2915,7 @@ msgstr "Theo dõi {name}"
#: src/components/ProgressGuide/List.tsx:52
#: src/state/shell/progress-guide.tsx:229
msgid "Follow 10 accounts"
msgstr ""
msgstr "Theo dõi 10 tài khoản"
#: src/components/ProgressGuide/List.tsx:69
msgid "Follow 7 accounts"
@@ -3289,13 +3289,13 @@ msgstr "Ẩn trả lời này?"
#: src/screens/Search/components/ExploreTrendingTopics.tsx:83
#: src/view/shell/desktop/SidebarTrendingTopics.tsx:62
msgid "Hide trending topics"
msgstr ""
msgstr "Ẩn chủ đề nổi trội"
#: src/components/interstitials/Trending.tsx:99
#: src/screens/Search/components/ExploreTrendingTopics.tsx:135
#: src/view/shell/desktop/SidebarTrendingTopics.tsx:108
msgid "Hide trending topics?"
msgstr ""
msgstr "Ẩn chủ đề nổi trội?"
#: src/view/com/notifications/NotificationFeedItem.tsx:592
msgid "Hide user list"
@@ -3356,7 +3356,7 @@ msgstr "Nhà cung cấp lưu trữ"
#: src/view/com/post-thread/PostThread.tsx:652
#: src/view/com/post-thread/PostThread.tsx:657
msgid "Hot replies first"
msgstr "Tr lời nổi bật trước"
msgstr "Tr lời nổi bật trước"
#: src/view/com/modals/InAppBrowserConsent.tsx:41
msgid "How should we open this link?"
@@ -3405,7 +3405,7 @@ msgstr "Danh sách không thể được khôi phục sau khi đã bị xóa."
#: src/screens/Settings/components/ChangeHandleDialog.tsx:250
msgid "If you have your own domain, you can use that as your handle. This lets you self-verify your identity. <0>Learn more here.</0>"
msgstr ""
msgstr "Nếu bạn có tên miền riêng, bạn có thể sử dụng nó làm tên người dùng. Điều này giúp bạn tự xác minh danh tính của mình. <0>Tìm hiểu thêm tại đây.</0>"
#: src/view/com/util/forms/PostDropdownBtnMenuItems.tsx:658
msgid "If you remove this post, you won't be able to recover it."
@@ -3703,11 +3703,11 @@ msgstr "Sáng"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:514
msgid "Like"
msgstr ""
msgstr "Thích"
#: src/view/com/util/post-ctrls/PostCtrls.tsx:311
msgid "Like ({0, plural, one {# like} other {# likes}})"
msgstr ""
msgstr "Thích ({0, plural, one {# lượt thích} other {# lượt thích}})"
#: src/components/ProgressGuide/List.tsx:63
msgid "Like 10 posts"
@@ -3720,7 +3720,7 @@ msgstr "Thích 10 bài để dạy cho bảng tin Khám phá"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:501
msgid "Like feed"
msgstr ""
msgstr "Thích bảng tin"
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:275
msgid "Like this feed"
@@ -3741,14 +3741,14 @@ msgstr "Thích bởi"
#: src/components/FeedCard.tsx:213
#: src/view/com/feeds/FeedSourceCard.tsx:303
msgid "Liked by {0, plural, one {# user} other {# users}}"
msgstr ""
msgstr "Thích bởi {0, plural, one {# người dùng} other {# người dùng}}"
#: src/components/LabelingServiceCard/index.tsx:96
#: src/screens/Profile/components/ProfileFeedHeader.tsx:489
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:295
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:309
msgid "Liked by {likeCount, plural, one {# user} other {# users}}"
msgstr ""
msgstr "Thích bởi {likeCount, plural, one {# người dùng} other {# người dùng}}"
#: src/view/screens/Profile.tsx:234
msgid "Likes"
@@ -3761,7 +3761,7 @@ msgstr "Lượt thích cho bài đăng này"
#: src/view/com/post-thread/PostThread.tsx:626
#: src/view/com/post-thread/PostThread.tsx:631
msgid "Linear"
msgstr ""
msgstr "Ngang hàng"
#: src/Navigation.tsx:198
msgid "List"
@@ -3782,11 +3782,11 @@ msgstr "Danh sách bởi {0}"
#: src/view/com/profile/ProfileSubpageHeader.tsx:156
msgid "List by <0/>"
msgstr ""
msgstr "Danh sách tạo bởi <0/>"
#: src/view/com/profile/ProfileSubpageHeader.tsx:154
msgid "List by you"
msgstr ""
msgstr "Danh sách của bạn"
#: src/view/screens/ProfileList.tsx:455
msgid "List deleted"
@@ -3882,7 +3882,7 @@ msgstr "Đăng nhập vào tải khoản chưa được liệt kê"
#: src/view/shell/desktop/RightNav.tsx:121
msgid "Logo by @sawaratsuki.bsky.social"
msgstr ""
msgstr "Logo bởi @sawaratsuki.bsky.social"
#: src/view/shell/desktop/RightNav.tsx:103
#~ msgid "Logo by <0/>"
@@ -3953,7 +3953,7 @@ msgstr "Người dùng được đề cập"
#: src/view/screens/Notifications.tsx:99
msgid "Mentions"
msgstr ""
msgstr "Nhắc đến"
#: src/components/Menu/index.tsx:96
#: src/view/screens/Search/Search.tsx:863
@@ -4282,7 +4282,7 @@ msgstr "Tên người dùng mới"
#: src/view/screens/Lists.tsx:66
#: src/view/screens/ModerationModlists.tsx:66
msgid "New list"
msgstr ""
msgstr "Danh sách mới"
#: src/components/dms/NewMessagesPill.tsx:92
msgid "New messages"
@@ -4668,7 +4668,7 @@ msgstr "Mở tùy chọn hội thoại"
#: src/components/Layout/Header/index.tsx:145
msgid "Open drawer menu"
msgstr ""
msgstr "Mở trình đơn"
#: src/screens/Messages/components/MessageInput.web.tsx:181
#: src/view/com/composer/Composer.tsx:1228
@@ -4678,7 +4678,7 @@ msgstr "Mở trình chọn biểu tượng cảm xúc"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:190
msgid "Open feed info screen"
msgstr ""
msgstr "Mở màn hình thông tin bảng tin"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:290
#: src/screens/Profile/components/ProfileFeedHeader.tsx:295
@@ -4912,7 +4912,7 @@ msgstr "Hình ảnh cho người lớn"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:519
#: src/screens/Profile/components/ProfileFeedHeader.tsx:526
msgid "Pin feed"
msgstr ""
msgstr "Ghim bảng tin"
#: src/view/screens/ProfileList.tsx:685
msgid "Pin to home"
@@ -4934,7 +4934,7 @@ msgstr "Đã ghim"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:162
#: src/screens/Profile/components/ProfileFeedHeader.tsx:174
msgid "Pinned {0} to Home"
msgstr ""
msgstr "Đã ghim {0} vào trang chủ"
#: src/view/screens/SavedFeeds.tsx:123
msgid "Pinned Feeds"
@@ -5226,11 +5226,11 @@ msgstr "Công khai"
#: src/view/com/lists/MyLists.tsx:77
msgid "Public, sharable lists of users to mute or block in bulk."
msgstr ""
msgstr "Danh sách công khai, chia sẻ được của người dùng để cho việc ẩn hoặc chặn hàng loạt."
#: src/view/com/lists/MyLists.tsx:72
msgid "Public, sharable lists which can be used to drive feeds."
msgstr ""
msgstr "Danh sách công khai và chia sẻ được có thể thúc đẩy bảng tin."
#: src/view/screens/ModerationModlists.tsx:75
#~ msgid "Public, shareable lists of users to mute or block in bulk."
@@ -5334,7 +5334,7 @@ msgstr "Tìm kiếm gần đây"
#: src/screens/Search/components/ExploreRecommendations.tsx:50
msgid "Recommended"
msgstr ""
msgstr "Đề xuất"
#: src/screens/Messages/components/MessageListError.tsx:20
msgid "Reconnect"
@@ -5506,7 +5506,7 @@ msgstr "Trả lời"
#: src/view/com/util/post-ctrls/PostCtrls.tsx:263
msgid "Reply ({0, plural, one {# reply} other {# replies}})"
msgstr ""
msgstr "Trả lời ({0, plural, one {# trả lời} other {# trả lời}})"
#: src/components/moderation/ModerationDetailsDialog.tsx:114
#: src/lib/moderation/useModerationCauseDescription.ts:123
@@ -5528,7 +5528,7 @@ msgstr "Cài đặt trả lời được chọn bởi tác giả của thảo lu
#: src/view/com/post-thread/PostThread.tsx:648
msgid "Reply sorting"
msgstr ""
msgstr "Sắp xếp trả lời"
#: src/view/com/post/Post.tsx:204
#: src/view/com/posts/PostFeedItem.tsx:553
@@ -5647,7 +5647,7 @@ msgstr "Đăng lại"
#: src/view/com/util/post-ctrls/RepostButton.tsx:74
msgid "Repost ({0, plural, one {# repost} other {# reposts}})"
msgstr ""
msgstr "Đăng lại ({0, plural, one {# đăng lại} other {# đăng lại}})"
#: src/screens/StarterPack/StarterPackScreen.tsx:548
#: src/view/com/util/post-ctrls/RepostButton.tsx:148
@@ -5890,15 +5890,15 @@ msgstr "Tìm kiếm"
#: src/components/ProgressGuide/FollowDialog.tsx:745
msgid "Search by name or interest"
msgstr ""
msgstr "Tìm theo tên hoặc sở thích"
#: src/view/screens/Feeds.tsx:441
msgid "Search feeds"
msgstr ""
msgstr "Tìm bảng tin"
#: src/components/ProgressGuide/FollowDialog.tsx:575
msgid "Search for \"{interestsDisplayName}\"{activeText}"
msgstr ""
msgstr "Tìm kiếm \"{interestsDisplayName}\"{activeText}"
#: src/view/shell/desktop/Search.tsx:201
msgid "Search for \"{query}\""
@@ -5961,7 +5961,7 @@ msgstr "Xem hướng dẫn này"
#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx:194
msgid "Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause"
msgstr ""
msgstr "Thanh trượt. Sử dụng các phím mũi tên để tìm kiếm về phía trước và phía sau, và phím cách để phát / tạm dừng"
#: src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx:67
msgid "Select a color"
@@ -6125,7 +6125,7 @@ msgstr "Địa chỉ máy chủ"
#: src/screens/Settings/AppIconSettings/index.tsx:178
msgid "Set app icon to {0}"
msgstr ""
msgstr "Đặt biểu tượng ứng dụng thành {0}"
#: src/screens/Moderation/index.tsx:290
msgid "Set birthdate"
@@ -6218,7 +6218,7 @@ msgstr "Chia sẻ mã QR"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:470
msgid "Share this feed"
msgstr ""
msgstr "Chia sẻ bảng tin này"
#: src/screens/StarterPack/StarterPackScreen.tsx:416
msgid "Share this starter pack"
@@ -6313,11 +6313,11 @@ msgstr "Hiển thị trả lời"
#: src/view/com/post-thread/PostThread.tsx:622
msgid "Show replies as"
msgstr ""
msgstr "Hiển thị trả lời dưới dạng"
#: src/screens/Settings/ThreadPreferences.tsx:151
msgid "Show replies as threaded"
msgstr ""
msgstr "Hiển thị trả lời dưới dạng phân luồng"
#: src/screens/Settings/ThreadPreferences.tsx:126
msgid "Show replies by people you follow before all other replies"
@@ -6479,7 +6479,7 @@ msgstr "Có gì đó không đúng!"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:538
msgid "Something wrong? Let us know."
msgstr ""
msgstr "Có gì không đúng? Hãy cho chúng tôi biết."
#: src/App.native.tsx:122
#: src/App.web.tsx:97
@@ -6523,12 +6523,12 @@ msgstr "Bắt đầu cuộc trò chuyện mới"
#: src/view/screens/ProfileList.tsx:817
#: src/view/screens/ProfileList.tsx:938
msgid "Start adding people"
msgstr ""
msgstr "Bắt đầu thêm người"
#: src/view/screens/ProfileList.tsx:824
#: src/view/screens/ProfileList.tsx:945
msgid "Start adding people!"
msgstr ""
msgstr "Bắt đầu thêm người!"
#: src/components/dms/dialogs/SearchablePeopleList.tsx:353
msgid "Start chat with {displayName}"
@@ -6546,7 +6546,7 @@ msgstr "Gói khởi đầu bởi {0}"
#: src/view/com/profile/ProfileSubpageHeader.tsx:182
msgid "Starter pack by <0/>"
msgstr ""
msgstr "Gói khởi đầu bởi <0/>"
#: src/components/StarterPack/StarterPackCard.tsx:80
#: src/view/com/profile/ProfileSubpageHeader.tsx:180
@@ -6670,7 +6670,7 @@ msgstr "Chạm để thay đổi biểu tượng ứng dụng"
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:138
#: src/view/com/composer/text-input/web/EmojiPicker.web.tsx:174
msgid "Tap to close the emoji picker"
msgstr ""
msgstr "Chạm để đóng bảng chọn biểu tượng cảm xúc"
#: src/components/ProgressGuide/Toast.tsx:156
msgid "Tap to dismiss"
@@ -6695,7 +6695,7 @@ msgstr "Nhấn để xem ảnh đầy đủ"
#: src/state/shell/progress-guide.tsx:233
msgid "Task complete - 10 follows!"
msgstr ""
msgstr "Hoàn thành nhiệm vụ - 10 người theo dõi!"
#: src/state/shell/progress-guide.tsx:223
msgid "Task complete - 10 likes!"
@@ -7099,7 +7099,7 @@ msgstr "Danh sách này - tạo bởi <0>{0}</0> - có thể chứa các vi ph
#: src/view/screens/ProfileList.tsx:933
msgid "This list is empty."
msgstr ""
msgstr "Danh sách này đang trống."
#: src/screens/Profile/ErrorState.tsx:40
msgid "This moderation service is unavailable. See below for more details. If this issue persists, contact us."
@@ -7190,7 +7190,7 @@ msgstr "Tác vụ này sẽ xóa bài đăng của bạn khỏi bài đăng trí
#: src/view/com/post-thread/PostThread.tsx:606
#: src/view/com/post-thread/PostThread.tsx:609
msgid "Thread options"
msgstr ""
msgstr "Tùy chọn thảo luận"
#: src/screens/Settings/ContentAndMediaSettings.tsx:66
#: src/screens/Settings/ContentAndMediaSettings.tsx:69
@@ -7204,7 +7204,7 @@ msgstr "Cài đặt thảo luận"
#: src/view/com/post-thread/PostThread.tsx:636
#: src/view/com/post-thread/PostThread.tsx:641
msgid "Threaded"
msgstr ""
msgstr "Phân luồng"
#: src/screens/Settings/ThreadPreferences.tsx:142
msgid "Threaded mode"
@@ -7254,7 +7254,7 @@ msgstr "Hàng đầu"
#: src/Navigation.tsx:383
msgid "Topic"
msgstr ""
msgstr "Chủ đề"
#: src/components/dms/MessageMenu.tsx:103
#: src/components/dms/MessageMenu.tsx:105
@@ -7268,7 +7268,7 @@ msgstr "Dịch"
#: src/screens/Search/components/ExploreTrendingTopics.tsx:69
#: src/view/shell/desktop/SidebarTrendingTopics.tsx:59
msgid "Trending"
msgstr ""
msgstr "Chủ đề nổi trội"
#: src/view/com/util/error/ErrorScreen.tsx:103
msgctxt "action"
@@ -7293,7 +7293,7 @@ msgstr "Gõ tin nhắn của bạn ở đây"
#: src/screens/Settings/components/ChangeHandleDialog.tsx:415
msgid "Type:"
msgstr "Type:"
msgstr "Loại:"
#: src/view/screens/ProfileList.tsx:590
msgid "Un-block list"
@@ -7358,7 +7358,7 @@ msgstr "Bỏ chặn bài đăng lại"
#: src/view/com/util/post-ctrls/RepostButton.tsx:68
msgid "Undo repost ({0, plural, one {# repost} other {# reposts}})"
msgstr ""
msgstr "Huỷ đăng lại ({0, plural, one {# đăng lại} other {# đăng lại}})"
#: src/view/com/profile/FollowButton.tsx:60
msgctxt "action"
@@ -7376,11 +7376,11 @@ msgstr "Bỏ theo dõi tài khoản"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:514
msgid "Unlike"
msgstr ""
msgstr "Bỏ thích"
#: src/view/com/util/post-ctrls/PostCtrls.tsx:305
msgid "Unlike ({0, plural, one {# like} other {# likes}})"
msgstr ""
msgstr "Bỏ thích ({0, plural, one {# thích} other {# thích}})"
#: src/view/screens/ProfileFeed.tsx:576
#~ msgid "Unlike this feed"
@@ -7430,7 +7430,7 @@ msgstr "Bỏ ghim"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:519
#: src/screens/Profile/components/ProfileFeedHeader.tsx:526
msgid "Unpin feed"
msgstr ""
msgstr "Bỏ ghim bảng tin"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:309
#: src/screens/Profile/components/ProfileFeedHeader.tsx:311
@@ -7448,7 +7448,7 @@ msgstr "Bỏ ghim khỏi danh sách kiểm duyệt"
#: src/screens/Profile/components/ProfileFeedHeader.tsx:164
msgid "Unpinned {0} from Home"
msgstr ""
msgstr "Đã bỏ ghim {0} khỏi Trang chủ"
#: src/view/screens/ProfileList.tsx:352
msgid "Unpinned from your feeds"
@@ -7854,7 +7854,7 @@ msgstr "Không tìm thấy nội dung nào cho hashtag."
#: src/screens/Topic.tsx:178
msgid "We couldn't find any results for that topic."
msgstr ""
msgstr "Chúng tôi không tìm thấy kết quả nào cho chủ đề đó."
#: src/screens/Messages/Conversation.tsx:103
msgid "We couldn't load this conversation"
@@ -7952,7 +7952,7 @@ msgstr "Bạn muốn gọi gói khởi đầu của mình là gì?"
#: src/screens/Search/components/ExploreTrendingTopics.tsx:79
msgid "What people are posting about."
msgstr ""
msgstr "Chủ đề mà mọi người đang bàn luận."
#: src/view/com/auth/SplashScreen.tsx:38
#: src/view/com/auth/SplashScreen.web.tsx:99
@@ -8107,7 +8107,7 @@ msgstr "Bạn có thể kích hoạt lại tài khoản của mình để tiếp
#: src/screens/Search/components/ExploreTrendingTopics.tsx:136
#: src/view/shell/desktop/SidebarTrendingTopics.tsx:109
msgid "You can update this later from your settings."
msgstr ""
msgstr "Bạn có thể cập nhật lại sau trong phần cài đặt."
#: src/view/com/profile/ProfileFollowers.tsx:94
msgid "You do not have any followers."
@@ -8325,7 +8325,7 @@ msgstr "Bạn đã chọn ẩn một từ hoặc thẻ trong bài đăng này."
#: src/state/shell/progress-guide.tsx:234
msgid "You've found some people to follow"
msgstr ""
msgstr "Bạn đã tìm thấy một số người để theo dõi"
#: src/view/com/posts/FollowingEndOfFeed.tsx:44
msgid "You've reached the end of your feed! Find some more accounts to follow."
@@ -8373,7 +8373,7 @@ msgstr "Lựa chọn của bạn sẽ được lưu, nhưng có thể được t
#: src/screens/Settings/components/ChangeHandleDialog.tsx:496
msgid "Your current handle <0>{0}</0> will automatically remain reserved for you. You can switch back to it at any time from this account."
msgstr ""
msgstr "Tên người dùng hiện tại của bạn <0>{0}</0> sẽ tự động được giữ lại cho bạn. Bạn có thể chuyển lại vào bất kỳ lúc nào từ tài khoản này."
#: src/screens/Login/ForgotPasswordForm.tsx:51
#: src/screens/Signup/state.ts:203
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -13
View File
@@ -1,4 +1,4 @@
import {GestureResponderEvent, Linking} from 'react-native'
import {Linking} from 'react-native'
import {isNative, isWeb} from './detection'
@@ -24,15 +24,3 @@ export function clearHash() {
window.location.hash = ''
}
}
export function shouldClickOpenNewTab(e: GestureResponderEvent) {
/**
* A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
* of @ts-ignore below.
*/
const event = e as any
const isMiddleClick = isWeb && event.button === 1
const isMetaKey =
isWeb && (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
return isMetaKey || isMiddleClick
}
+1
View File
@@ -64,4 +64,5 @@ export const router = new Router({
StarterPack: '/starter-pack/:name/:rkey',
StarterPackShort: '/starter-pack-short/:code',
StarterPackWizard: '/starter-pack/create',
VideoFeed: '/video-feed',
})
+10 -2
View File
@@ -14,6 +14,7 @@ import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
import {useMessagesEventBus} from '#/state/messages/events'
import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {List} from '#/view/com/util/List'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
@@ -94,12 +95,19 @@ export function MessagesScreen({navigation, route}: Props) {
useRefreshOnFocus(refetch)
const leftConvos = useLeftConvos()
const conversations = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.convos)
return (
data.pages
.flatMap(page => page.convos)
// filter out convos that are actively being left
.filter(convo => !leftConvos.includes(convo.id))
)
}
return []
}, [data])
}, [data, leftConvos])
const onRefresh = useCallback(async () => {
setIsPTRing(true)
+7
View File
@@ -12,6 +12,7 @@ import {
BSKY_APP_ACCOUNT_DID,
DISCOVER_SAVED_FEED,
TIMELINE_SAVED_FEED,
VIDEO_SAVED_FEED,
} from '#/lib/constants'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {logEvent, useGate} from '#/lib/statsig/statsig'
@@ -111,6 +112,12 @@ export function StepFinished() {
id: TID.nextStr(),
},
]
if (gate('onboarding_add_video_feed')) {
feedsToSave.push({
...VIDEO_SAVED_FEED,
id: TID.nextStr(),
})
}
// Any starter pack feeds will be pinned _after_ the defaults
if (starterPack && starterPack.feeds?.length) {
+32 -3
View File
@@ -1,12 +1,14 @@
import React, {useCallback, useMemo} from 'react'
import {StyleSheet, View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated'
import {AppBskyFeedDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useIsFocused, useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URIS} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {ComposeIcon2} from '#/lib/icons'
@@ -18,7 +20,7 @@ import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {
usePreferencesQuery,
@@ -46,6 +48,11 @@ type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
export function ProfileFeedScreen(props: Props) {
const {rkey, name: handleOrDid} = props.route.params
const feedParams: FeedParams | undefined = props.route.params.feedCacheKey
? {
feedCacheKey: props.route.params.feedCacheKey,
}
: undefined
const pal = usePalette('default')
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
@@ -96,7 +103,10 @@ export function ProfileFeedScreen(props: Props) {
return resolvedUri ? (
<Layout.Screen>
<ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} />
<ProfileFeedScreenIntermediate
feedUri={resolvedUri.uri}
feedParams={feedParams}
/>
</Layout.Screen>
) : (
<Layout.Screen>
@@ -108,7 +118,13 @@ export function ProfileFeedScreen(props: Props) {
)
}
function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
function ProfileFeedScreenIntermediate({
feedUri,
feedParams,
}: {
feedUri: string
feedParams: FeedParams | undefined
}) {
const {data: preferences} = usePreferencesQuery()
const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
@@ -125,15 +141,18 @@ function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
<ProfileFeedScreenInner
preferences={preferences}
feedInfo={info as FeedSourceFeedInfo}
feedParams={feedParams}
/>
)
}
export function ProfileFeedScreenInner({
feedInfo,
feedParams,
}: {
preferences: UsePreferencesQueryResponse
feedInfo: FeedSourceFeedInfo
feedParams: FeedParams | undefined
}) {
const {_} = useLingui()
const {hasSession} = useSession()
@@ -170,6 +189,14 @@ export function ProfileFeedScreenInner({
return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
}, [_])
const isVideoFeed = React.useMemo(() => {
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
const feedIsVideoMode =
feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO
const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode
return isNative && _isVideoFeed
}, [feedInfo])
return (
<>
<ProfileFeedHeader info={feedInfo} />
@@ -177,12 +204,14 @@ export function ProfileFeedScreenInner({
<FeedFeedbackProvider value={feedFeedback}>
<PostFeed
feed={feed}
feedParams={feedParams}
pollInterval={60e3}
disablePoll={hasNew}
onHasNew={setHasNew}
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
isVideoFeed={isVideoFeed}
/>
</FeedFeedbackProvider>
+3 -1
View File
@@ -45,6 +45,7 @@ export const ProfileFeedSection = React.forwardRef<
const [hasNew, setHasNew] = React.useState(false)
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const shouldUseAdjustedNumToRender = feed.endsWith('posts_and_author_threads')
const isVideoFeed = isNative && feed.endsWith('posts_with_video')
const adjustedInitialNumToRender = useInitialNumToRender({
screenHeightOffset: headerHeight,
})
@@ -84,11 +85,12 @@ export const ProfileFeedSection = React.forwardRef<
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
progressViewOffset={ios(0)}
renderEndOfFeed={ProfileEndOfFeed}
renderEndOfFeed={isVideoFeed ? undefined : ProfileEndOfFeed}
ignoreFilterFor={ignoreFilterFor}
initialNumToRender={
shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined
}
isVideoFeed={isVideoFeed}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
@@ -0,0 +1,271 @@
import React 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 {useFocusEffect} from '@react-navigation/native'
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 {isWeb} from '#/platform/detection'
import {useSavedFeeds} from '#/state/queries/feed'
import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed'
import {useAddSavedFeedsMutation} from '#/state/queries/preferences'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {GradientFill} from '#/components/GradientFill'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
import {Link} from '#/components/Link'
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: 'explore'
} = {
feedCacheKey: 'explore',
}
export function ExploreTrendingVideos() {
const t = useTheme()
const {_} = useLingui()
const gutters = useGutters([0, 'base'])
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
// Refetch on tab change if nothing else is using this query.
const queryClient = useQueryClient()
useFocusEffect(() => {
return () => {
const query = queryClient
.getQueryCache()
.find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
if (query && query.getObserversCount() <= 1) {
query.fetch()
}
}
})
const {data: saved} = useSavedFeeds()
const isSavedAlready = React.useMemo(() => {
return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI)
}, [saved])
const {mutateAsync: addSavedFeeds, isPending: isPinPending} =
useAddSavedFeedsMutation()
const pinFeed = React.useCallback(
(e: any) => {
e.preventDefault()
addSavedFeeds([
{
type: 'feed',
value: VIDEO_FEED_URI,
pinned: true,
},
])
// prevent navigation
return false
},
[addSavedFeeds],
)
if (error) {
return null
}
return (
<View style={[a.pb_xl]}>
<View
style={[
a.flex_row,
isWeb
? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
: [a.p_lg, a.pt_xl, a.gap_md],
a.border_b,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Graph
size="lg"
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
<Trans>Trending Videos</Trans>
</Text>
<View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
<GradientFill gradient={tokens.gradients.primary} />
<Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
<Trans>BETA</Trans>
</Text>
</View>
</View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>Popular videos in your network.</Trans>
</Text>
</View>
</View>
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToInterval={CARD_WIDTH + tokens.space.sm}>
<View
style={[
a.pt_lg,
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>
{!isSavedAlready && (
<View
style={[
gutters,
a.pt_lg,
a.flex_row,
a.align_center,
a.justify_between,
a.gap_xl,
]}>
<Text style={[a.flex_1, a.text_sm, a.leading_snug]}>
<Trans>
Pin the trending videos feed to your home screen for easy access
</Trans>
</Text>
<Button
disabled={isPinPending}
label={_(msg`Pin`)}
size="small"
variant="outline"
color="secondary"
onPress={pinFeed}>
<ButtonText>{_(msg`Pin`)}</ButtonText>
<ButtonIcon icon={Pin} position="right" />
</Button>
</View>
)}
</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, 'explore')
}, [])
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: 'explore',
}}
onInteract={() => {
logEvent('videoCard:click', {
context: 'interstitial:explore',
})
}}
/>
</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_contrast_25,
]}>
{({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>
</>
)
}
@@ -37,8 +37,9 @@ export function ContentAndMediaSettingsScreen({}: Props) {
const inAppBrowserPref = useInAppBrowser()
const setUseInAppBrowser = useSetInAppBrowser()
const {enabled: trendingEnabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
const {setTrendingDisabled} = useTrendingSettingsApi()
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
const {setTrendingDisabled, setTrendingVideoDisabled} =
useTrendingSettingsApi()
return (
<Layout.Screen>
@@ -138,6 +139,27 @@ export function ContentAndMediaSettingsScreen({}: Props) {
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
<Toggle.Item
name="show_trending_videos"
label={_(msg`Enable trending videos in your Discover feed.`)}
value={!trendingVideoDisabled}
onChange={value => {
const hide = Boolean(!value)
if (hide) {
logEvent('trendingVideos:hide', {context: 'settings'})
} else {
logEvent('trendingVideos:show', {context: 'settings'})
}
setTrendingVideoDisabled(hide)
}}>
<SettingsList.Item>
<SettingsList.ItemIcon icon={Graph} />
<SettingsList.ItemText>
<Trans>Enable trending videos in your Discover feed</Trans>
</SettingsList.ItemText>
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
</>
)}
</SettingsList.Container>
+1 -2
View File
@@ -80,9 +80,8 @@ export function StepCaptcha() {
<View
style={[
a.w_full,
a.pb_xl,
a.overflow_hidden,
{minHeight: 500},
{minHeight: 510},
completed && [a.align_center, a.justify_center],
]}>
{!completed ? (
+21 -24
View File
@@ -85,6 +85,21 @@ export function SignupQueued() {
</Button>
)
const logoutBtn = (
<Button
variant="ghost"
size="large"
color="primary"
label={_(msg`Log out`)}
onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText>
<Trans>Log out</Trans>
</ButtonText>
</Button>
)
const webLayout = isWeb && gtMobile
return (
<Modal
visible
@@ -108,7 +123,7 @@ export function SignupQueued() {
<Logo width={120} />
</View>
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
<Text style={[a.text_4xl, a.font_heavy, a.pb_sm]}>
<Trans>You're in line</Trans>
</Text>
<P style={[t.atoms.text_contrast_medium]}>
@@ -153,7 +168,7 @@ export function SignupQueued() {
</P>
</View>
{isWeb && gtMobile && (
{webLayout && (
<View
style={[
a.w_full,
@@ -162,15 +177,7 @@ export function SignupQueued() {
a.pt_5xl,
{paddingBottom: 200},
]}>
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
{logoutBtn}
{checkBtn}
</View>
)}
@@ -178,27 +185,17 @@ export function SignupQueued() {
</View>
</ScrollView>
{(!isWeb || !gtMobile) && (
{!webLayout && (
<View
style={[
a.align_center,
t.atoms.bg,
gtMobile ? a.px_5xl : a.px_xl,
{
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
},
{paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom)},
]}>
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
{checkBtn}
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
{logoutBtn}
</View>
</View>
)}
+263
View File
@@ -0,0 +1,263 @@
import {useMemo, useState} from 'react'
import {Modal, View} from 'react-native'
import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {StatusBar} from 'expo-status-bar'
import {ComAtprotoAdminDefs, ComAtprotoModerationDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import Graphemer from 'graphemer'
import {MAX_REPORT_REASON_GRAPHEME_LENGTH} from '#/lib/constants'
import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
import {cleanError} from '#/lib/strings/errors'
import {isIOS, isWeb} from '#/platform/detection'
import {useAgent, useSession, useSessionApi} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
const COL_WIDTH = 400
export function Takendown() {
const {_} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession()
const {logoutCurrentAccount} = useSessionApi()
const agent = useAgent()
const [isAppealling, setIsAppealling] = useState(false)
const [reason, setReason] = useState('')
const graphemer = useMemo(() => new Graphemer(), [])
const reasonGraphemeLength = useMemo(() => {
return graphemer.countGraphemes(reason)
}, [graphemer, reason])
const {
mutate: submitAppeal,
isPending,
isSuccess,
error,
} = useMutation({
mutationFn: async (appealText: string) => {
if (!currentAccount) throw new Error('No session')
await agent.com.atproto.moderation.createReport({
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: currentAccount.did,
} satisfies ComAtprotoAdminDefs.RepoRef,
reason: appealText,
})
},
onSuccess: () => setReason(''),
})
const primaryBtn =
isAppealling && !isSuccess ? (
<Button
variant="solid"
color="primary"
size="large"
label={_(msg`Submit appeal`)}
onPress={() => submitAppeal(reason)}
disabled={
isPending || reasonGraphemeLength > MAX_REPORT_REASON_GRAPHEME_LENGTH
}>
<ButtonText>
<Trans>Submit Appeal</Trans>
</ButtonText>
{isPending && <ButtonIcon icon={Loader} />}
</Button>
) : (
<Button
variant="solid"
size="large"
color="secondary_inverted"
label={_(msg`Log out`)}
onPress={() => logoutCurrentAccount('Takendown')}>
<ButtonText>
<Trans>Log Out</Trans>
</ButtonText>
</Button>
)
const secondaryBtn = isAppealling ? (
!isSuccess && (
<Button
variant="ghost"
size="large"
color="secondary"
label={_(msg`Cancel`)}
onPress={() => setIsAppealling(false)}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
)
) : (
<Button
variant="ghost"
size="large"
color="secondary"
label={_(msg`Appeal suspension`)}
onPress={() => setIsAppealling(true)}>
<ButtonText>
<Trans>Appeal Suspension</Trans>
</ButtonText>
</Button>
)
const webLayout = isWeb && gtMobile
useEnableKeyboardController(true)
return (
<Modal
visible
animationType={native('slide')}
presentationStyle="formSheet"
style={[web(a.util_screen_outer)]}>
{isIOS && <StatusBar style="light" />}
<KeyboardAwareScrollView style={[a.flex_1, t.atoms.bg]} centerContent>
<View
style={[
a.flex_row,
a.justify_center,
gtMobile ? a.pt_4xl : [a.px_xl, a.pt_4xl],
]}>
<View style={[a.flex_1, {maxWidth: COL_WIDTH, minHeight: COL_WIDTH}]}>
<View style={[a.pb_xl]}>
<Logo width={64} />
</View>
<Text style={[a.text_4xl, a.font_heavy, a.pb_md]}>
{isAppealling ? (
<Trans>Appeal suspension</Trans>
) : (
<Trans>Your account has been suspended</Trans>
)}
</Text>
{isAppealling ? (
<View style={[a.relative, a.w_full, a.mt_xl]}>
{isSuccess ? (
<P style={[t.atoms.text_contrast_medium, a.text_center]}>
<Trans>
Your appeal has been submitted. If your appeal succeeds,
you will receive an email.
</Trans>
</P>
) : (
<>
<TextField.LabelText>
<Trans>Reason for appeal</Trans>
</TextField.LabelText>
<TextField.Root
isInvalid={
reasonGraphemeLength >
MAX_REPORT_REASON_GRAPHEME_LENGTH || !!error
}>
<TextField.Input
label={_(msg`Reason for appeal`)}
defaultValue={reason}
onChangeText={setReason}
placeholder={_(msg`Why are you appealing?`)}
multiline
numberOfLines={5}
autoFocus
style={{paddingBottom: 40, minHeight: 150}}
maxLength={MAX_REPORT_REASON_GRAPHEME_LENGTH * 10}
/>
</TextField.Root>
<View
style={[
a.absolute,
a.flex_row,
a.align_center,
a.pr_md,
a.pb_sm,
{
bottom: 0,
right: 0,
},
]}>
<CharProgress
count={reasonGraphemeLength}
max={MAX_REPORT_REASON_GRAPHEME_LENGTH}
/>
</View>
</>
)}
{error && (
<Text
style={[
a.text_md,
a.leading_normal,
{color: t.palette.negative_500},
a.mt_lg,
]}>
{cleanError(error)}
</Text>
)}
</View>
) : (
<P style={[t.atoms.text_contrast_medium]}>
<Trans>
Your account was found to be in violation of the{' '}
<InlineLinkText
label={_(msg`Bluesky Social Terms of Service`)}
to="https://bsky.social/about/support/tos"
style={[a.text_md, a.leading_normal]}
overridePresentation>
Bluesky Social Terms of Service
</InlineLinkText>
. You have been sent an email outlining the specific violation
and suspension period, if applicable. You can appeal this
decision if you believe it was made in error.
</Trans>
</P>
)}
{webLayout && (
<View
style={[
a.w_full,
a.flex_row,
a.justify_between,
a.pt_5xl,
{paddingBottom: 200},
]}>
{secondaryBtn}
{primaryBtn}
</View>
)}
</View>
</View>
</KeyboardAwareScrollView>
{!webLayout && (
<View
style={[
a.align_center,
t.atoms.bg,
gtMobile ? a.px_5xl : a.px_xl,
{paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom)},
]}>
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
{primaryBtn}
{secondaryBtn}
</View>
</View>
)}
</Modal>
)
}
+180
View File
@@ -0,0 +1,180 @@
import {useCallback} from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants'
import {NavigationProp} from '#/lib/routes/types'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useFeedSourceInfoQuery} from '#/state/queries/feed'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import * as Layout from '#/components/Layout'
import {BUTTON_VISUAL_ALIGNMENT_OFFSET} from '#/components/Layout/const'
import {Text} from '#/components/Typography'
export function HeaderPlaceholder() {
return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<View
style={[
a.rounded_sm,
{
width: 36,
height: 36,
backgroundColor: 'white',
opacity: 0.8,
},
]}
/>
<View style={[a.flex_1, a.gap_xs]}>
<View
style={[
a.w_full,
a.rounded_xs,
{
backgroundColor: 'white',
height: 14,
width: 80,
opacity: 0.8,
},
]}
/>
<View
style={[
a.w_full,
a.rounded_xs,
{
backgroundColor: 'white',
height: 10,
width: 140,
opacity: 0.6,
},
]}
/>
</View>
</View>
)
}
export function Header({
sourceContext,
}: {
sourceContext: VideoFeedSourceContext
}) {
let content = null
switch (sourceContext.type) {
case 'feedgen': {
content = <FeedHeader sourceContext={sourceContext} />
break
}
case 'author':
// TODO
default: {
break
}
}
return (
<Layout.Header.Outer noBottomBorder>
<BackButton />
<Layout.Header.Content align="left">{content}</Layout.Header.Content>
</Layout.Header.Outer>
)
}
export function FeedHeader({
sourceContext,
}: {
sourceContext: Exclude<VideoFeedSourceContext, {type: 'author'}>
}) {
const {gtMobile} = useBreakpoints()
const {
data: info,
isLoading,
error,
} = useFeedSourceInfoQuery({uri: sourceContext.uri})
if (sourceContext.sourceInterstitial !== undefined) {
// For now, don't show the header if coming from an interstitial.
return null
}
if (isLoading) {
return <HeaderPlaceholder />
} else if (error || !info) {
return null
}
return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
{info.avatar && <UserAvatar size={36} type="algo" avatar={info.avatar} />}
<View style={[a.flex_1]}>
<Text
style={[
a.text_md,
a.font_heavy,
a.leading_tight,
gtMobile && a.text_lg,
]}
numberOfLines={2}>
{info.displayName}
</Text>
<View style={[a.flex_row, {gap: 6}]}>
<Text
style={[a.flex_shrink, a.text_sm, a.leading_snug]}
numberOfLines={1}>
{sanitizeHandle(info.creatorHandle, '@')}
</Text>
</View>
</View>
</View>
)
}
// TODO: This customization should be a part of the layout component
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const onPressBack = useCallback(
(evt: GestureResponderEvent) => {
onPress?.(evt)
if (evt.defaultPrevented) return
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
},
[onPress, navigation],
)
return (
<Layout.Header.Slot>
<Button
label={_(msg`Go back`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ArrowLeft size="lg" fill="white" />
</Button>
</Layout.Header.Slot>
)
}
@@ -0,0 +1,265 @@
import {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
Gesture,
GestureDetector,
NativeGesture,
} from 'react-native-gesture-handler'
import Animated, {
interpolate,
runOnJS,
runOnUI,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {useEventListener} from 'expo'
import {VideoPlayer} from 'expo-video'
import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils'
import {tokens} from '#/alf'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
// magic number that is roughly the min height of the write reply button
// we inset the video by this amount
export const VIDEO_PLAYER_BOTTOM_INSET = 57
export function Scrubber({
active,
player,
seekingAnimationSV,
scrollGesture,
children,
}: {
active: boolean
player?: VideoPlayer
seekingAnimationSV: SharedValue<number>
scrollGesture: NativeGesture
children?: React.ReactNode
}) {
const {width: screenWidth} = useSafeAreaFrame()
const insets = useSafeAreaInsets()
const currentTimeSV = useSharedValue(0)
const durationSV = useSharedValue(0)
const [currentSeekTime, setCurrentSeekTime] = useState(0)
const [duration, setDuration] = useState(0)
const updateTime = (currentTime: number, duration: number) => {
'worklet'
currentTimeSV.set(currentTime)
if (duration !== 0) {
durationSV.set(duration)
}
}
const isSeekingSV = useSharedValue(false)
const seekProgressSV = useSharedValue(0)
useAnimatedReaction(
() => Math.round(seekProgressSV.get()),
(progress, prevProgress) => {
if (progress !== prevProgress) {
runOnJS(setCurrentSeekTime)(progress)
}
},
)
const seekBy = useCallback(
(time: number) => {
player?.seekBy(time)
setTimeout(() => {
runOnUI(() => {
'worklet'
isSeekingSV.set(false)
seekingAnimationSV.set(withTiming(0, {duration: 500}))
})()
}, 50)
},
[player, isSeekingSV, seekingAnimationSV],
)
const scrubPanGesture = useMemo(() => {
return Gesture.Pan()
.blocksExternalGesture(scrollGesture)
.activeOffsetX([-10, 10])
.failOffsetY([-10, 10])
.onStart(() => {
'worklet'
seekProgressSV.set(currentTimeSV.get())
isSeekingSV.set(true)
seekingAnimationSV.set(withTiming(1, {duration: 500}))
})
.onUpdate(evt => {
'worklet'
const progress = evt.x / screenWidth
seekProgressSV.set(
clamp(progress * durationSV.get(), 0, durationSV.get()),
)
})
.onEnd(evt => {
'worklet'
isSeekingSV.get()
const progress = evt.x / screenWidth
const newTime = clamp(progress * durationSV.get(), 0, durationSV.get())
// optimisically set the progress bar
seekProgressSV.set(newTime)
// it's seek by, so offset by the current time
// seekBy sets isSeekingSV back to false, so no need to do that here
runOnJS(seekBy)(newTime - currentTimeSV.get())
})
}, [
scrollGesture,
seekingAnimationSV,
seekBy,
screenWidth,
currentTimeSV,
durationSV,
isSeekingSV,
seekProgressSV,
])
const timeStyle = useAnimatedStyle(() => {
return {
display: seekingAnimationSV.get() === 0 ? 'none' : 'flex',
opacity: seekingAnimationSV.get(),
}
})
const barStyle = useAnimatedStyle(() => {
const currentTime = isSeekingSV.get()
? seekProgressSV.get()
: currentTimeSV.get()
const progress = currentTime === 0 ? 0 : currentTime / durationSV.get()
const isSeeking = seekingAnimationSV.get()
return {
height: isSeeking * 3 + 1,
opacity: interpolate(isSeeking, [0, 1], [0.4, 0.6]),
width: `${progress * 100}%`,
}
})
const trackStyle = useAnimatedStyle(() => {
return {
height: seekingAnimationSV.get() * 3 + 1,
}
})
const childrenStyle = useAnimatedStyle(() => {
return {
opacity: 1 - seekingAnimationSV.get(),
}
})
return (
<>
{player && active && (
<PlayerListener
player={player}
setDuration={setDuration}
updateTime={updateTime}
/>
)}
<Animated.View
style={[
a.absolute,
{
left: 0,
right: 0,
bottom: insets.bottom + 80,
},
timeStyle,
]}
pointerEvents="none">
<Text style={[a.text_center, a.font_bold]}>
<Text style={[a.text_5xl, {fontVariant: ['tabular-nums']}]}>
{formatTime(currentSeekTime)}
</Text>
<Text style={[a.text_2xl, {opacity: 0.8}]}>{' / '}</Text>
<Text
style={[
a.text_5xl,
{opacity: 0.8},
{fontVariant: ['tabular-nums']},
]}>
{formatTime(duration)}
</Text>
</Text>
</Animated.View>
<GestureDetector gesture={scrubPanGesture}>
<View
style={[
a.relative,
a.w_full,
a.justify_end,
{
paddingBottom: insets.bottom,
minHeight:
// bottom padding
insets.bottom +
// scrubber height
tokens.space.lg +
// write reply height
VIDEO_PLAYER_BOTTOM_INSET,
},
a.z_10,
]}>
<View style={[a.w_full, a.relative]}>
<Animated.View
style={[
a.w_full,
{backgroundColor: 'white', opacity: 0.2},
trackStyle,
]}
/>
<Animated.View
style={[
a.absolute,
{top: 0, left: 0, backgroundColor: 'white'},
barStyle,
]}
/>
</View>
<Animated.View
style={[{minHeight: VIDEO_PLAYER_BOTTOM_INSET}, childrenStyle]}>
{children}
</Animated.View>
</View>
</GestureDetector>
</>
)
}
function PlayerListener({
player,
setDuration,
updateTime,
}: {
player: VideoPlayer
setDuration: (duration: number) => void
updateTime: (currentTime: number, duration: number) => void
}) {
useEventListener(player, 'timeUpdate', evt => {
const duration = player.duration
if (duration !== 0) {
setDuration(Math.round(duration))
}
runOnUI(updateTime)(evt.currentTime, duration)
})
return null
}
function clamp(num: number, min: number, max: number) {
'worklet'
return Math.min(Math.max(num, min), max)
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
export function VideoScreen() {
return null
}
+18
View File
@@ -0,0 +1,18 @@
import {AuthorFilter} from '#/state/queries/post-feed'
/**
* Kind of like `FeedDescriptor` but not
*/
export type VideoFeedSourceContext =
| {
type: 'feedgen'
uri: string
sourceInterstitial: 'discover' | 'explore' | 'none'
initialPostUri?: string
}
| {
type: 'author'
did: string
filter: AuthorFilter
initialPostUri?: string
}
+14 -11
View File
@@ -3,11 +3,11 @@ import {AppState, AppStateStatus} from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api'
import throttle from 'lodash.throttle'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
import {getFeedPostSlice} from '#/view/com/posts/PostFeed'
import {getItemsForFeedback} from '#/view/com/posts/PostFeed'
import {useAgent} from './session'
type StateContext = {
@@ -25,6 +25,7 @@ const stateContext = React.createContext<StateContext>({
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
const agent = useAgent()
const enabled = isDiscoverFeed(feed) && hasSession
const queue = React.useRef<Set<string>>(new Set())
const history = React.useRef<
// Use a WeakSet so that we don't need to clear it.
@@ -46,6 +47,11 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
const interactions = Array.from(queue.current).map(toInteraction)
queue.current.clear()
let proxyDid = 'did:web:discover.bsky.app'
if (STAGING_FEEDS.includes(feed)) {
proxyDid = 'did:web:algo.pop2.bsky.app'
}
// Send to the feed
agent.app.bsky.feed
.sendInteractions(
@@ -54,7 +60,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
encoding: 'application/json',
headers: {
// TODO when we start sending to other feeds, we need to grab their DID -prf
'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg',
'atproto-proxy': `${proxyDid}#bsky_fg`,
},
},
)
@@ -68,7 +74,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
}
sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions)
throttledFlushAggregatedStats()
}, [agent, throttledFlushAggregatedStats])
}, [agent, throttledFlushAggregatedStats, feed])
const sendToFeed = React.useMemo(
() =>
@@ -96,18 +102,15 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
if (!enabled) {
return
}
const slice = getFeedPostSlice(feedItem)
if (slice === null) {
return
}
for (const postItem of slice.items) {
const items = getItemsForFeedback(feedItem)
for (const {item: postItem, feedContext} of items) {
if (!history.current.has(postItem)) {
history.current.add(postItem)
queue.current.add(
toString({
item: postItem.uri,
event: 'app.bsky.feed.defs#interactionSeen',
feedContext: slice.feedContext,
feedContext,
}),
)
sendToFeed()
@@ -155,7 +158,7 @@ export function useFeedFeedbackContext() {
// place, we're hardcoding it to the discover feed.
// -prf
function isDiscoverFeed(feed: FeedDescriptor) {
return feed === `feedgen|${PROD_DEFAULT_FEED('whats-hot')}`
return FEEDBACK_FEEDS.includes(feed)
}
function toString(interaction: AppBskyFeedDefs.Interaction): string {
+2
View File
@@ -126,6 +126,7 @@ const schema = z.object({
/** @deprecated */
mutedThreads: z.array(z.string()),
trendingDisabled: z.boolean().optional(),
trendingVideoDisabled: z.boolean().optional(),
})
export type Schema = z.infer<typeof schema>
@@ -172,6 +173,7 @@ export const defaults: Schema = {
hasCheckedForStarterPack: false,
subtitlesEnabled: true,
trendingDisabled: false,
trendingVideoDisabled: false,
}
export function tryParse(rawData: string): Schema | undefined {
+17 -3
View File
@@ -4,18 +4,27 @@ import * as persisted from '#/state/persisted'
type StateContext = {
trendingDisabled: Exclude<persisted.Schema['trendingDisabled'], undefined>
trendingVideoDisabled: Exclude<
persisted.Schema['trendingVideoDisabled'],
undefined
>
}
type ApiContext = {
setTrendingDisabled(
hidden: Exclude<persisted.Schema['trendingDisabled'], undefined>,
): void
setTrendingVideoDisabled(
hidden: Exclude<persisted.Schema['trendingVideoDisabled'], undefined>,
): void
}
const StateContext = React.createContext<StateContext>({
trendingDisabled: Boolean(persisted.defaults.trendingDisabled),
trendingVideoDisabled: Boolean(persisted.defaults.trendingVideoDisabled),
})
const ApiContext = React.createContext<ApiContext>({
setTrendingDisabled() {},
setTrendingVideoDisabled() {},
})
function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) {
@@ -43,14 +52,19 @@ function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) {
export function Provider({children}: React.PropsWithChildren<{}>) {
const [trendingDisabled, setTrendingDisabled] =
usePersistedBooleanValue('trendingDisabled')
const [trendingVideoDisabled, setTrendingVideoDisabled] =
usePersistedBooleanValue('trendingVideoDisabled')
/*
* Context
*/
const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled])
const state = React.useMemo(
() => ({trendingDisabled, trendingVideoDisabled}),
[trendingDisabled, trendingVideoDisabled],
)
const api = React.useMemo(
() => ({setTrendingDisabled}),
[setTrendingDisabled],
() => ({setTrendingDisabled, setTrendingVideoDisabled}),
[setTrendingDisabled, setTrendingVideoDisabled],
)
return (
+6
View File
@@ -48,6 +48,7 @@ export type FeedSourceFeedInfo = {
creatorHandle: string
likeCount: number | undefined
likeUri: string | undefined
contentMode: AppBskyFeedDefs.GeneratorView['contentMode']
}
export type FeedSourceListInfo = {
@@ -65,6 +66,7 @@ export type FeedSourceListInfo = {
description: RichText
creatorDid: string
creatorHandle: string
contentMode: undefined
}
export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo
@@ -111,6 +113,7 @@ export function hydrateFeedGenerator(
creatorHandle: view.creator.handle,
likeCount: view.likeCount,
likeUri: view.viewer?.like,
contentMode: view.contentMode,
}
}
@@ -141,6 +144,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
displayName: view.name
? sanitizeDisplayName(view.name)
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`,
contentMode: undefined,
}
}
@@ -399,6 +403,7 @@ const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = {
id: 'pwi-discover',
...DISCOVER_SAVED_FEED,
},
contentMode: undefined,
}
const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
@@ -485,6 +490,7 @@ export function usePinnedFeedsInfos() {
likeCount: 0,
likeUri: '',
savedFeed: pinnedItem,
contentMode: undefined,
})
}
}
@@ -1,17 +1,28 @@
import {ChatBskyConvoLeaveConvo, ChatBskyConvoListConvos} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {
useMutation,
useMutationState,
useQueryClient,
} from '@tanstack/react-query'
import {logger} from '#/logger'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
import {useAgent} from '#/state/session'
import {RQKEY as CONVO_LIST_KEY} from './list-conversations'
const RQKEY_ROOT = 'leave-convo'
export function RQKEY(convoId: string | undefined) {
return [RQKEY_ROOT, convoId]
}
export function useLeaveConvo(
convoId: string | undefined,
{
onSuccess,
onMutate,
onError,
}: {
onMutate?: () => void
onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void
onError?: (error: Error) => void
},
@@ -20,6 +31,7 @@ export function useLeaveConvo(
const agent = useAgent()
return useMutation({
mutationKey: RQKEY(convoId),
mutationFn: async () => {
if (!convoId) throw new Error('No convoId provided')
@@ -51,6 +63,7 @@ export function useLeaveConvo(
}
},
)
onMutate?.()
return {prevPages}
},
onSuccess: data => {
@@ -77,3 +90,20 @@ export function useLeaveConvo(
},
})
}
/**
* Gets currently pending and successful leave convo mutations
*
* @returns Array of `convoId`
*/
export function useLeftConvos() {
const pending = useMutationState({
filters: {mutationKey: [RQKEY_ROOT], status: 'pending'},
select: mutation => mutation.options.mutationKey?.[1] as string | undefined,
})
const success = useMutationState({
filters: {mutationKey: [RQKEY_ROOT], status: 'success'},
select: mutation => mutation.options.mutationKey?.[1] as string | undefined,
})
return [...pending, ...success].filter(id => id !== undefined)
}
+5 -1
View File
@@ -44,11 +44,12 @@ import {
} from './util'
type ActorDid = string
type AuthorFilter =
export type AuthorFilter =
| 'posts_with_replies'
| 'posts_no_replies'
| 'posts_and_author_threads'
| 'posts_with_media'
| 'posts_with_video'
type FeedUri = string
type ListUri = string
@@ -61,6 +62,7 @@ export type FeedDescriptor =
export interface FeedParams {
mergeFeedEnabled?: boolean
mergeFeedSources?: string[]
feedCacheKey?: 'discover' | 'explore' | undefined
}
type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
@@ -88,6 +90,7 @@ export interface FeedPostSlice {
isIncompleteThread: boolean
isFallbackMarker: boolean
feedContext: string | undefined
feedPostUri: string
reason?:
| AppBskyFeedDefs.ReasonRepost
| AppBskyFeedDefs.ReasonPin
@@ -329,6 +332,7 @@ export function usePostFeedQuery(
isFallbackMarker: slice.isFallbackMarker,
feedContext: slice.feedContext,
reason: slice.reason,
feedPostUri: slice.feedPostUri,
items: slice.items.map((item, i) => {
const feedPostSliceItem: FeedPostSliceItem = {
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
+14 -7
View File
@@ -53,8 +53,9 @@ export type ThreadPost = {
uri: string
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
parent?: ThreadNode
replies?: ThreadNode[]
parent: ThreadNode | undefined
replies: ThreadNode[] | undefined
hasOPLike: boolean | undefined
ctx: ThreadCtx
}
@@ -255,8 +256,8 @@ export function sortThread(
if (aFetchedAt !== bFetchedAt) {
return aFetchedAt - bFetchedAt // older fetches first
} else if (opts.sort === 'hotness') {
const aHotness = getHotness(a.post, aFetchedAt)
const bHotness = getHotness(b.post, bFetchedAt /* same as aFetchedAt */)
const aHotness = getHotness(a, aFetchedAt)
const bHotness = getHotness(b, bFetchedAt /* same as aFetchedAt */)
return bHotness - aHotness
} else if (opts.sort === 'oldest') {
return a.post.indexedAt.localeCompare(b.post.indexedAt)
@@ -309,16 +310,18 @@ export function sortThread(
// We want to give recent comments a real chance (and not bury them deep below the fold)
// while also surfacing well-liked comments from the past. In the future, we can explore
// something more sophisticated, but we don't have much data on the client right now.
function getHotness(post: AppBskyFeedDefs.PostView, fetchedAt: number) {
function getHotness(threadPost: ThreadPost, fetchedAt: number) {
const {post, hasOPLike} = threadPost
const hoursAgo = Math.max(
0,
(new Date(fetchedAt).getTime() - new Date(post.indexedAt).getTime()) /
(1000 * 60 * 60),
)
const likeCount = post.likeCount ?? 0
const likeOrder = Math.log(3 + likeCount)
const likeOrder = Math.log(3 + likeCount) * (hasOPLike ? 1.45 : 1.0)
const timePenaltyExponent = 1.5 + 1.5 / (1 + Math.log(1 + likeCount))
const timePenalty = Math.pow(hoursAgo + 2, timePenaltyExponent)
const opLikeBoost = hasOPLike ? 0.8 : 1.0
const timePenalty = Math.pow(hoursAgo + 2, timePenaltyExponent * opLikeBoost)
return likeOrder / timePenalty
}
@@ -356,6 +359,7 @@ function responseToThreadNodes(
// do not show blocked posts in replies
.filter(node => node.type !== 'blocked')
: undefined,
hasOPLike: Boolean(node?.threadContext?.rootAuthorLike),
ctx: {
depth,
isHighlightedPost: depth === 0,
@@ -552,6 +556,7 @@ function threadNodeToPlaceholderThread(
record: node.record,
parent: undefined,
replies: undefined,
hasOPLike: undefined,
ctx: {
depth: 0,
isHighlightedPost: true,
@@ -573,6 +578,7 @@ function postViewToPlaceholderThread(
record: post.record as AppBskyFeedPost.Record, // validated in notifs
parent: undefined,
replies: undefined,
hasOPLike: undefined,
ctx: {
depth: 0,
isHighlightedPost: true,
@@ -594,6 +600,7 @@ function embedViewRecordToPlaceholderThread(
record: record.value as AppBskyFeedPost.Record, // validated in getEmbeddedPost
parent: undefined,
replies: undefined,
hasOPLike: undefined,
ctx: {
depth: 0,
isHighlightedPost: true,
+6 -1
View File
@@ -83,7 +83,12 @@ export async function createAgentAndLogin(
) => void,
) {
const agent = new BskyAppAgent({service})
await agent.login({identifier, password, authFactorToken})
await agent.login({
identifier,
password,
authFactorToken,
allowTakendown: true,
})
const account = agentToSessionAccountOrThrow(agent)
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
+1 -1
View File
@@ -258,7 +258,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
],
)
// @ts-ignore
// @ts-expect-error window type is not declared, debug only
if (__DEV__ && isWeb) window.agent = state.currentAgentState.agent
const agent = state.currentAgentState.agent as BskyAppAgent
+13 -1
View File
@@ -1,11 +1,12 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NavigationProp, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URIS} from '#/lib/constants'
import {ComposeIcon2} from '#/lib/icons'
import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
import {AllNavigatorParams} from '#/lib/routes/types'
@@ -15,6 +16,7 @@ import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {useSetHomeBadge} from '#/state/home-badge'
import {SavedFeedSourceInfo} from '#/state/queries/feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util'
@@ -39,6 +41,7 @@ export function FeedPage({
renderEmptyState,
renderEndOfFeed,
savedFeedConfig,
feedInfo,
}: {
testID?: string
feed: FeedDescriptor
@@ -48,6 +51,7 @@ export function FeedPage({
renderEmptyState: () => JSX.Element
renderEndOfFeed?: () => JSX.Element
savedFeedConfig?: AppBskyActorDefs.SavedFeed
feedInfo: SavedFeedSourceInfo
}) {
const {hasSession} = useSession()
const {_} = useLingui()
@@ -61,6 +65,13 @@ export function FeedPage({
const scrollElRef = React.useRef<ListMethods>(null)
const [hasNew, setHasNew] = React.useState(false)
const setHomeBadge = useSetHomeBadge()
const isVideoFeed = React.useMemo(() => {
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
const feedIsVideoMode =
feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO
const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode
return isNative && _isVideoFeed
}, [feedInfo])
React.useEffect(() => {
if (isPageFocused) {
@@ -134,6 +145,7 @@ export function FeedPage({
renderEndOfFeed={renderEndOfFeed}
headerOffset={headerOffset}
savedFeedConfig={savedFeedConfig}
isVideoFeed={isVideoFeed}
/>
</FeedFeedbackProvider>
</MainScrollProvider>
+1 -1
View File
@@ -17,7 +17,6 @@ import {usePalette} from '#/lib/hooks/usePalette'
import {sanitizeHandle} from '#/lib/strings/handles'
import {s} from '#/lib/styles'
import {logger} from '#/logger'
import {shouldClickOpenNewTab} from '#/platform/urls'
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
import {
useAddSavedFeedsMutation,
@@ -29,6 +28,7 @@ import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import * as Toast from '#/view/com/util/Toast'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {shouldClickOpenNewTab} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {Text} from '../util/text/Text'
-2
View File
@@ -205,9 +205,7 @@ export const ProfileFeedgens = React.forwardRef<
headerOffset={headerOffset}
progressViewOffset={ios(0)}
contentContainerStyle={isMobile && {paddingBottom: headerOffset + 100}}
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true}
// @ts-ignore our .web version only -prf
desktopFixedHeight
onEndReached={onEndReached}
/>
-2
View File
@@ -203,9 +203,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
contentContainerStyle={
isMobile && {paddingBottom: headerOffset + 100}
}
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true}
// @ts-ignore our .web version only -prf
desktopFixedHeight
onEndReached={onEndReached}
/>
+2 -40
View File
@@ -1,4 +1,4 @@
import React, {Children, forwardRef, useCallback, useContext} from 'react'
import React, {forwardRef, useCallback, useContext} from 'react'
import {View} from 'react-native'
import {DrawerGestureContext} from 'react-native-drawer-layout'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
@@ -17,7 +17,6 @@ import Animated, {
} from 'react-native-reanimated'
import {useFocusEffect} from '@react-navigation/native'
import {isAndroid} from '#/platform/detection'
import {useSetDrawerSwipeDisabled} from '#/state/shell'
import {atoms as a, native} from '#/alf'
@@ -149,11 +148,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
style={[a.flex_1]}
initialPage={initialPage}
onPageScroll={handlePageScroll}>
{isAndroid
? Children.map(children, child => (
<CaptureSwipesAndroid>{child}</CaptureSwipesAndroid>
))
: children}
{children}
</AnimatedPagerView>
</GestureDetector>
</View>
@@ -161,39 +156,6 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
},
)
// HACK.
// This works around https://github.com/callstack/react-native-pager-view/issues/960.
// It appears that the Pressables inside the pager get confused if there's enough work
// happening on the JS thread, and mistakingly interpret a pager swipe as a tap.
// We can prevent this by stealing all horizontal movements from the tree inside.
function CaptureSwipesAndroid({children}: {children: React.ReactNode}) {
const lastTouchStart = React.useRef<{x: number; y: number} | null>(null)
return (
<View
onTouchStart={e => {
lastTouchStart.current = {
x: e.nativeEvent.pageX,
y: e.nativeEvent.pageY,
}
}}
onMoveShouldSetResponderCapture={e => {
const coords = lastTouchStart.current
if (!coords) {
return false
}
const dx = Math.abs(e.nativeEvent.pageX - coords.x)
if (dx > 0) {
// This is a horizontal movement and will result in a swipe.
// Prevent pager children from receiving this touch.
return true
}
return false
}}>
{children}
</View>
)
}
function usePagerHandlers(
handlers: {
onPageScroll: (e: PagerViewOnPageScrollEventData) => void

Some files were not shown because too many files have changed in this diff Show More