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

* origin/main: (100 commits)
  Adding webp to regex list. Added function return type for consistency with the rest of the functions (#7649)
  bump version (#7650)
  use did for userID (#7647)
  Upgrade RN to 0.76.6 (#7557)
  Update expo-blur (#7596)
  Fix slow Hosting Provider dialog (#7594)
  make convo menu lazy (#7604)
  Fix convo header loading state (#7603)
  Spring cleaning (#7640)
  Date input improvements (#7639)
  Add example account store (#7641)
  rm refs in render (#7589)
  change search cancel behaviour (#7624)
  reduce padding on video overlay (#7617)
  Typo fix (#7637)
  no follow self (#7606)
  env var tweaks (#7457)
  Test fix
  Add Danish localization (#7220)
  Add Swedish localization (#7232)
  ...
This commit is contained in:
Eric Bailey
2025-02-05 09:37:10 -06:00
205 changed files with 41940 additions and 12720 deletions
+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: {
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" stroke="#000" stroke-linejoin="round" d="M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.5 3.5 0 0 1 5.5 12a3.5 3.5 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.5 3.5 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 0-1 1v4.17a1 1 0 0 0 .667.944 2.001 2.001 0 0 1 0 3.772A1 1 0 0 0 2 14.83V19a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-4.17a1 1 0 0 0-.667-.944 2.001 2.001 0 0 1 0-3.772A1 1 0 0 0 22 9.17V5a1 1 0 0 0-1-1H3Zm1 4.535V6h16v2.535A4 4 0 0 0 18 12c0 1.482.805 2.773 2 3.465V18H4v-2.535A4 4 0 0 0 6 12a4 4 0 0 0-2-3.465ZM15 15a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-1-3a1 1 0 1 1 2 0 1 1 0 0 1-2 0Zm1-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 756 B

After

Width:  |  Height:  |  Size: 561 B

+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',
+9 -6
View File
@@ -1,11 +1,11 @@
{
"name": "bsky.app",
"version": "1.97.0",
"version": "1.98.0",
"private": true,
"engines": {
"node": ">=20"
},
"packageManager": "yarn@1.22.19",
"packageManager": "yarn@1.22.22",
"scripts": {
"prepare": "is-ci || husky install",
"postinstall": "patch-package && yarn intl:compile",
@@ -55,6 +55,7 @@
},
"dependencies": {
"@atproto/api": "0.14.0-next.2",
"@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",
@@ -72,7 +73,7 @@
"@haileyok/bluesky-video": "0.2.6",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/react": "^4.14.1",
"@mattermost/react-native-paste-input": "^0.8.1",
"@mattermost/react-native-paste-input": "^0.7.1",
"@miblanchard/react-native-slider": "^2.3.1",
"@mozzius/expo-dynamic-app-icon": "^1.5.0",
"@radix-ui/react-dismissable-layer": "^1.1.1",
@@ -120,7 +121,7 @@
"eventemitter3": "^5.0.1",
"expo": "~52.0.19",
"expo-application": "^6.0.1",
"expo-blur": "^14.0.1",
"expo-blur": "^14.0.3",
"expo-build-properties": "^0.13.1",
"expo-camera": "~16.0.7",
"expo-clipboard": "^7.0.0",
@@ -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",
@@ -168,7 +170,7 @@
"react-dom": "^18.2.0",
"react-image-crop": "^11.0.7",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.76.3",
"react-native": "0.76.6",
"react-native-compressor": "1.10.3",
"react-native-date-picker": "^5.0.7",
"react-native-drawer-layout": "^4.1.1",
@@ -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",
@@ -1,19 +1,16 @@
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
index e916023..9968f3c 100644
index e916023..5049c33 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
@@ -3,58 +3,87 @@
// PasteInput
@@ -4,6 +4,7 @@
//
// Created by Elias Nahum on 04-11-20.
-// Copyright © 2020 Facebook. All rights reserved.
// Copyright © 2020 Facebook. All rights reserved.
+// Updated to remove parents default text view
//
#import "PasteInputView.h"
#import "PasteInputTextView.h"
-#import <React/RCTUtils.h>
+#import <React/RCTUtils.h> // for RCTDirectEventBlock, etc.
@@ -12,49 +13,78 @@
@implementation PasteInputView
{
@@ -117,7 +114,7 @@ index e916023..9968f3c 100644
}
#pragma mark - UIScrollViewDelegate
@@ -62,7 +91,6 @@
@@ -62,7 +92,6 @@
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
RCTDirectEventBlock onScroll = self.onScroll;
@@ -125,7 +122,7 @@ index e916023..9968f3c 100644
if (onScroll) {
CGPoint contentOffset = scrollView.contentOffset;
CGSize contentSize = scrollView.contentSize;
@@ -71,22 +99,22 @@
@@ -71,22 +100,22 @@
onScroll(@{
@"contentOffset": @{
@@ -0,0 +1,36 @@
diff --git a/node_modules/react-native-gesture-handler/apple/RNGestureHandler.mm b/node_modules/react-native-gesture-handler/apple/RNGestureHandler.mm
index 43d11b4..5bf0d32 100644
--- a/node_modules/react-native-gesture-handler/apple/RNGestureHandler.mm
+++ b/node_modules/react-native-gesture-handler/apple/RNGestureHandler.mm
@@ -461,16 +461,23 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
- if ([_handlersToWaitFor count]) {
- RNGestureHandler *handler = [RNGestureHandler findGestureHandlerByRecognizer:otherGestureRecognizer];
- if (handler != nil) {
- for (NSNumber *handlerTag in _handlersToWaitFor) {
- if ([handler.tag isEqual:handlerTag]) {
- return YES;
- }
- }
+ RNGestureHandler *handler = [RNGestureHandler findGestureHandlerByRecognizer:otherGestureRecognizer];
+ if (handler == nil) {
+ return NO;
+ }
+
+ for (NSNumber *handlerTag in _handlersToWaitFor) {
+ if ([handler.tag isEqual:handlerTag]) {
+ return YES;
}
}
+
+ for (NSNumber *handlerTag in handler->_handlersThatShouldWait) {
+ if ([_tag isEqual:handlerTag]) {
+ return YES;
+ }
+ }
+
return NO;
}
+39 -42
View File
@@ -69,7 +69,6 @@ import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {AppProfiler} from '#/AppProfiler'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -152,9 +151,11 @@ function InnerApp() {
<TrendingConfigProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
<NuxDialogs />
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GestureHandlerRootView>
</TrendingConfigProvider>
</ProgressGuideProvider>
@@ -198,44 +199,40 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<AppProfiler>
<GeolocationProvider>
<A11yProvider>
<KeyboardControllerProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<IntentDialogProvider>
<LightStatusBarProvider>
<InnerApp />
</LightStatusBarProvider>
</IntentDialogProvider>
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</KeyboardControllerProvider>
</A11yProvider>
</GeolocationProvider>
</AppProfiler>
<GeolocationProvider>
<A11yProvider>
<KeyboardControllerProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<LightStatusBarProvider>
<InnerApp />
</LightStatusBarProvider>
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</KeyboardControllerProvider>
</A11yProvider>
</GeolocationProvider>
)
}
+7 -7
View File
@@ -129,8 +129,10 @@ function InnerApp() {
<SafeAreaProvider>
<ProgressGuideProvider>
<TrendingConfigProvider>
<Shell />
<NuxDialogs />
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</TrendingConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
@@ -187,11 +189,9 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<IntentDialogProvider>
<LightStatusBarProvider>
<InnerApp />
</LightStatusBarProvider>
</IntentDialogProvider>
<LightStatusBarProvider>
<InnerApp />
</LightStatusBarProvider>
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
-29
View File
@@ -1,29 +0,0 @@
import React, {Profiler} from 'react'
// Don't let it get stripped out in profiling builds (which apply production Babel preset).
const log = (global as any)['con' + 'sole'].log
function onRender(id: string, phase: string, actualDuration: number) {
if (!__DEV__) {
// This block of code will exist in the production build of the app.
// However, only profiling builds of React call `onRender` so it's dead code in actual production.
const message = `<Profiler> ${id}:${phase} ${
actualDuration > 500
? '(╯°□°)╯ '
: actualDuration > 100
? '[!!] '
: actualDuration > 16
? '[!] '
: ''
}${Math.round(actualDuration)}ms`
log(message)
}
}
export function AppProfiler({children}: {children: React.ReactNode}) {
return (
<Profiler id="app" onRender={onRender}>
{children}
</Profiler>
)
}
+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,
}}
/>
</>
)
}
+3
View File
@@ -961,6 +961,9 @@ export const atoms = {
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_delay_50ms: web({
transitionDelay: '50ms',
}),
/**
* {@link Layout.SCROLLBAR_OFFSET}
+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,
+5 -1
View File
@@ -131,7 +131,11 @@ function AccountItem({
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
]}>
<View style={a.p_md}>
<UserAvatar avatar={profile?.avatar} size={24} />
<UserAvatar
avatar={profile?.avatar}
size={24}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
<Text emoji style={[a.font_bold]}>
+1
View File
@@ -65,6 +65,7 @@ export function AvatarStack({
<UserAvatar
size={size - 2}
avatar={item.profile.avatar}
type={item.profile.associated?.labeler ? 'labeler' : 'user'}
moderation={item.moderation.ui('avatar')}
/>
)}
+30 -16
View File
@@ -3,10 +3,12 @@ import {
AccessibilityProps,
GestureResponderEvent,
MouseEvent,
NativeSyntheticEvent,
Pressable,
PressableProps,
StyleProp,
StyleSheet,
TargetedEvent,
TextProps,
TextStyle,
View,
@@ -76,6 +78,8 @@ export type ButtonProps = Pick<
| 'onHoverOut'
| 'onPressIn'
| 'onPressOut'
| 'onFocus'
| 'onBlur'
> &
AccessibilityProps &
VariantProps & {
@@ -116,6 +120,12 @@ export const Button = React.forwardRef<View, ButtonProps>(
style,
hoverStyle: hoverStyleProp,
PressableComponent = Pressable,
onPressIn: onPressInOuter,
onPressOut: onPressOutOuter,
onHoverIn: onHoverInOuter,
onHoverOut: onHoverOutOuter,
onFocus: onFocusOuter,
onBlur: onBlurOuter,
...rest
},
ref,
@@ -127,7 +137,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
focused: false,
})
const onPressInOuter = rest.onPressIn
const onPressIn = React.useCallback(
(e: GestureResponderEvent) => {
setState(s => ({
@@ -138,7 +147,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onPressInOuter],
)
const onPressOutOuter = rest.onPressOut
const onPressOut = React.useCallback(
(e: GestureResponderEvent) => {
setState(s => ({
@@ -149,7 +157,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onPressOutOuter],
)
const onHoverInOuter = rest.onHoverIn
const onHoverIn = React.useCallback(
(e: MouseEvent) => {
setState(s => ({
@@ -160,7 +167,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onHoverInOuter],
)
const onHoverOutOuter = rest.onHoverOut
const onHoverOut = React.useCallback(
(e: MouseEvent) => {
setState(s => ({
@@ -171,18 +177,26 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onHoverOutOuter],
)
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
const onFocus = React.useCallback(
(e: NativeSyntheticEvent<TargetedEvent>) => {
setState(s => ({
...s,
focused: true,
}))
onFocusOuter?.(e)
},
[setState, onFocusOuter],
)
const onBlur = React.useCallback(
(e: NativeSyntheticEvent<TargetedEvent>) => {
setState(s => ({
...s,
focused: false,
}))
onBlurOuter?.(e)
},
[setState, onBlurOuter],
)
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
+3 -1
View File
@@ -27,6 +27,7 @@ import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {useThemeName} from '#/alf/util/useColorModeTheme'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {
DialogControlProps,
@@ -55,7 +56,8 @@ export function Outer({
nativeOptions,
testID,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const themeName = useThemeName()
const t = useTheme(themeName)
const ref = React.useRef<BottomSheetNativeComponent>(null)
const closeCallbacks = React.useRef<(() => void)[]>([])
const {setDialogIsOpen, setFullyExpandedCount} =
+81 -51
View File
@@ -17,6 +17,7 @@ import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows
import {useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
@@ -197,6 +198,7 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={data?.suggestions ?? []}
recId={data?.recId}
error={error}
viewContext="profile"
/>
@@ -223,10 +225,12 @@ export function ProfileGrid({
isSuggestionsLoading,
error,
profiles,
recId,
viewContext = 'feed',
}: {
isSuggestionsLoading: boolean
profiles: atp.profile.AnyProfileView[]
recId?: number
error: Error | null
viewContext: 'profile' | 'feed'
}) {
@@ -250,12 +254,19 @@ export function ProfileGrid({
))
) : error || !profiles.length ? null : (
<>
{profiles.slice(0, maxLength).map(profile => (
{profiles.slice(0, maxLength).map((profile, index) => (
<ProfileCard.Link
key={profile.did}
profile={profile}
onPress={() => {
logEvent('feed:interstitial:profileCard:press', {})
logEvent('suggestedUser:press', {
logContext:
viewContext === 'feed'
? 'InterstitialDiscover'
: 'InterstitialProfile',
recId,
position: index,
})
}}
style={[
a.flex_1,
@@ -283,6 +294,17 @@ export function ProfileGrid({
logContext="FeedInterstitial"
shape="round"
colorInverted
onFollow={() => {
logEvent('suggestedUser:follow', {
logContext:
viewContext === 'feed'
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
recId,
position: index,
})
}}
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} numberOfLines={2} />
@@ -337,33 +359,37 @@ export function ProfileGrid({
</View>
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
{content}
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more accounts on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on the Explore page</Trans>
</Text>
<Button
label={_(msg`Browse more accounts on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>
Browse more suggestions on the Explore page
</Trans>
</Text>
<Arrow size="xl" />
<Arrow size="xl" />
</View>
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
</CardOuter>
</Button>
</View>
</ScrollView>
</BlockDrawerGesture>
)}
</View>
)
@@ -470,34 +496,38 @@ export function SuggestedFeeds() {
</View>
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more feeds on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}
style={[a.flex_col]}>
<CardOuter style={[a.flex_1]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on the Explore page</Trans>
</Text>
<Button
label={_(msg`Browse more feeds on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}
style={[a.flex_col]}>
<CardOuter style={[a.flex_1]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>
Browse more suggestions on the Explore page
</Trans>
</Text>
<Arrow size="xl" />
<Arrow size="xl" />
</View>
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
</CardOuter>
</Button>
</View>
</ScrollView>
</BlockDrawerGesture>
)}
</View>
)
+59
View File
@@ -0,0 +1,59 @@
import {createContext, useContext, useMemo} from 'react'
import {View} from 'react-native'
import {atoms as a, ViewStyleProp} from '#/alf'
const Context = createContext({
gap: 0,
})
export function Row({
children,
gap = 0,
style,
}: ViewStyleProp & {
children: React.ReactNode
gap?: number
}) {
return (
<Context.Provider value={useMemo(() => ({gap}), [gap])}>
<View
style={[
a.flex_row,
a.flex_1,
{
marginLeft: -gap / 2,
marginRight: -gap / 2,
},
style,
]}>
{children}
</View>
</Context.Provider>
)
}
export function Col({
children,
width = 1,
style,
}: ViewStyleProp & {
children: React.ReactNode
width?: number
}) {
const {gap} = useContext(Context)
return (
<View
style={[
a.flex_col,
{
paddingLeft: gap / 2,
paddingRight: gap / 2,
width: `${width * 100}%`,
},
style,
]}>
{children}
</View>
)
}
+1
View File
@@ -163,6 +163,7 @@ function KnownFollowersInner({
size={SIZE}
avatar={prof.avatar}
moderation={moderation.ui('avatar')}
type={prof.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
))}
+5 -1
View File
@@ -122,7 +122,11 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
shape="square"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, style]}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
+2
View File
@@ -58,6 +58,7 @@ export const Content = React.memo(function Content({
contentContainerStyle,
...props
}: ContentProps) {
const t = useTheme()
const {footerHeight} = useShellLayout()
const animatedProps = useAnimatedProps(() => {
return {
@@ -73,6 +74,7 @@ export const Content = React.memo(function Content({
<Animated.ScrollView
id="content"
automaticallyAdjustsScrollIndicatorInsets={false}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
// sets the scroll inset to the height of the footer
animatedProps={animatedProps}
style={[scrollViewStyles.common, style]}
+10 -4
View File
@@ -6,12 +6,18 @@ import {gradients} from '#/alf/tokens'
export function LinearGradientBackground({
style,
gradient = 'sky',
children,
start,
end,
}: {
style: StyleProp<ViewStyle>
children: React.ReactNode
style?: StyleProp<ViewStyle>
gradient?: keyof typeof gradients
children?: React.ReactNode
start?: [number, number]
end?: [number, number]
}) {
const gradient = gradients.sky.values.map(([_, color]) => {
const colors = gradients[gradient].values.map(([_, color]) => {
return color
}) as [string, string, ...string[]]
@@ -20,7 +26,7 @@ export function LinearGradientBackground({
}
return (
<LinearGradient colors={gradient} style={style}>
<LinearGradient colors={colors} style={style} start={start} end={end}>
{children}
</LinearGradient>
)
+120 -24
View File
@@ -15,7 +15,6 @@ import {
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
import {isNative, isWeb} from '#/platform/detection'
import {shouldClickOpenNewTab} from '#/platform/urls'
import {useModalControls} from '#/state/modals'
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
@@ -55,6 +54,12 @@ type BaseLinkProps = Pick<
*/
onPress?: (e: GestureResponderEvent) => void | false
/**
* Callback for when the link is long pressed (on native). Prevent default
* and return `false` to exit early and prevent default long press hander.
*/
onLongPress?: (e: GestureResponderEvent) => void | false
/**
* Web-only attribute. Sets `download` attr on web.
*/
@@ -72,9 +77,12 @@ export function useLink({
action = 'push',
disableMismatchWarning,
onPress: outerOnPress,
onLongPress: outerOnLongPress,
shareOnLongPress,
overridePresentation,
}: BaseLinkProps & {
displayText: string
overridePresentation?: boolean
}) {
const navigation = useNavigationDeduped()
const {href} = useLinkProps<AllNavigatorParams>({
@@ -110,7 +118,7 @@ export function useLink({
})
} else {
if (isExternal) {
openLink(href)
openLink(href, overridePresentation)
} else {
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
@@ -152,6 +160,7 @@ export function useLink({
closeModal,
action,
navigation,
overridePresentation,
],
)
@@ -175,8 +184,14 @@ export function useLink({
}
}, [disableMismatchWarning, displayText, href, isExternal, openModal])
const onLongPress =
isNative && isExternal && shareOnLongPress ? handleLongPress : undefined
const onLongPress = React.useCallback(
(e: GestureResponderEvent) => {
const exitEarlyIfFalse = outerOnLongPress?.(e)
if (exitEarlyIfFalse === false) return
return isNative && shareOnLongPress ? handleLongPress() : undefined
},
[outerOnLongPress, handleLongPress, shareOnLongPress],
)
return {
isExternal,
@@ -202,14 +217,16 @@ export function Link({
to,
action = 'push',
onPress: outerOnPress,
onLongPress: outerOnLongPress,
download,
...rest
}: LinkProps) {
const {href, isExternal, onPress} = useLink({
const {href, isExternal, onPress, onLongPress} = useLink({
to,
displayText: typeof children === 'string' ? children : '',
action,
onPress: outerOnPress,
onLongPress: outerOnLongPress,
})
return (
@@ -220,6 +237,7 @@ export function Link({
accessibilityRole="link"
href={href}
onPress={download ? undefined : onPress}
onLongPress={onLongPress}
{...web({
hrefAttrs: {
target: download ? undefined : isExternal ? 'blank' : undefined,
@@ -239,12 +257,13 @@ export function Link({
export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps &
TextStyleProp &
Pick<TextProps, 'selectable' | 'numberOfLines'>
> &
Pick<ButtonProps, 'label'> & {
disableUnderline?: boolean
title?: TextProps['title']
}
Pick<TextProps, 'selectable' | 'numberOfLines'> &
Pick<ButtonProps, 'label' | 'accessibilityHint'> & {
disableUnderline?: boolean
title?: TextProps['title']
overridePresentation?: boolean
}
>
export function InlineLinkText({
children,
@@ -253,11 +272,13 @@ export function InlineLinkText({
disableMismatchWarning,
style,
onPress: outerOnPress,
onLongPress: outerOnLongPress,
download,
selectable,
label,
shareOnLongPress,
disableUnderline,
overridePresentation,
...rest
}: InlineLinkProps) {
const t = useTheme()
@@ -268,7 +289,9 @@ export function InlineLinkText({
action,
disableMismatchWarning,
onPress: outerOnPress,
onLongPress: outerOnLongPress,
shareOnLongPress,
overridePresentation,
})
const {
state: hovered,
@@ -319,6 +342,21 @@ export function InlineLinkText({
)
}
export function WebOnlyInlineLinkText({
children,
to,
onPress,
...props
}: Omit<InlineLinkProps, 'onLongPress'>) {
return isWeb ? (
<InlineLinkText {...props} to={to} onPress={onPress}>
{children}
</InlineLinkText>
) : (
<Text {...props}>{children}</Text>
)
}
/**
* Utility to create a static `onPress` handler for a `Link` that would otherwise link to a URI
*
@@ -327,7 +365,10 @@ export function InlineLinkText({
*/
export function createStaticClick(
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
): Pick<BaseLinkProps, 'to' | 'onPress'> {
): {
to: BaseLinkProps['to']
onPress: Exclude<BaseLinkProps['onPress'], undefined>
} {
return {
to: '#',
onPress(e: GestureResponderEvent) {
@@ -338,17 +379,72 @@ export function createStaticClick(
}
}
export function WebOnlyInlineLinkText({
children,
to,
onPress,
...props
}: InlineLinkProps) {
return isWeb ? (
<InlineLinkText {...props} to={to} onPress={onPress}>
{children}
</InlineLinkText>
) : (
<Text {...props}>{children}</Text>
/**
* Utility to create a static `onPress` handler for a `Link`, but only if the
* click was not modified in some way e.g. `Cmd` or a middle click.
*
* On native, this behaves the same as `createStaticClick` because there are no
* options to "modify" the click in this sense.
*
* Example:
* `<Link {...createStaticClick(e => {...})} />`
*/
export function createStaticClickIfUnmodified(
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
): {onPress: Exclude<BaseLinkProps['onPress'], undefined>} {
return {
onPress(e: GestureResponderEvent) {
if (!isWeb || !isModifiedClickEvent(e)) {
e.preventDefault()
onPressHandler(e)
return false
}
},
}
}
/**
* Determines if the click event has a meta key pressed, indicating the user
* intends to deviate from default behavior.
*/
export function isClickEventWithMetaKey(e: GestureResponderEvent) {
if (!isWeb) return false
const event = e as unknown as MouseEvent
return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey
}
/**
* Determines if the web click target is anything other than `_self`
*/
export function isClickTargetExternal(e: GestureResponderEvent) {
if (!isWeb) return false
const event = e as unknown as MouseEvent
const el = event.currentTarget as HTMLAnchorElement
return el && el.target && el.target !== '_self'
}
/**
* Determines if a click event has been modified in some way from its default
* behavior, e.g. `Cmd` or a middle click.
* {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
*/
export function isModifiedClickEvent(e: GestureResponderEvent): boolean {
if (!isWeb) return false
const event = e as unknown as MouseEvent
const isPrimaryButton = event.button === 0
return (
isClickEventWithMetaKey(e) || isClickTargetExternal(e) || !isPrimaryButton
)
}
/**
* Determines if a click event has been modified in a way that should indiciate
* that the user intends to open a new tab.
* {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
*/
export function shouldClickOpenNewTab(e: GestureResponderEvent) {
if (!isWeb) return false
const event = e as unknown as MouseEvent
const isMiddleClick = isWeb && event.button === 1
return isClickEventWithMetaKey(e) || isClickTargetExternal(e) || isMiddleClick
}
+9 -3
View File
@@ -20,6 +20,7 @@ export function ListFooter({
style,
showEndMessage = false,
endMessageText,
renderEndMessage,
}: {
isFetchingNextPage?: boolean
hasNextPage?: boolean
@@ -29,6 +30,7 @@ export function ListFooter({
style?: StyleProp<ViewStyle>
showEndMessage?: boolean
endMessageText?: string
renderEndMessage?: () => React.ReactNode
}) {
const t = useTheme()
@@ -48,9 +50,13 @@ export function ListFooter({
) : error ? (
<ListFooterMaybeError error={error} onRetry={onRetry} />
) : !hasNextPage && showEndMessage ? (
<Text style={[a.text_sm, t.atoms.text_contrast_low]}>
{endMessageText ?? <Trans>You have reached the end</Trans>}
</Text>
renderEndMessage ? (
renderEndMessage()
) : (
<Text style={[a.text_sm, t.atoms.text_contrast_low]}>
{endMessageText ?? <Trans>You have reached the end</Trans>}
</Text>
)
) : null}
</View>
)
+8 -1
View File
@@ -47,7 +47,12 @@ export function Root({
return <Context.Provider value={context}>{children}</Context.Provider>
}
export function Trigger({children, label, role = 'button'}: TriggerProps) {
export function Trigger({
children,
label,
role = 'button',
hint,
}: TriggerProps) {
const context = useMenuContext()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
@@ -65,11 +70,13 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) {
pressed,
},
props: {
ref: null,
onPress: context.control.open,
onFocus,
onBlur,
onPressIn,
onPressOut,
accessibilityHint: hint,
accessibilityLabel: label,
accessibilityRole: role,
},
+9 -2
View File
@@ -110,7 +110,12 @@ const RadixTriggerPassThrough = React.forwardRef(
)
RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough'
export function Trigger({children, label, role = 'button'}: TriggerProps) {
export function Trigger({
children,
label,
role = 'button',
hint,
}: TriggerProps) {
const {control} = useMenuContext()
const {
state: hovered,
@@ -153,6 +158,7 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) {
onBlur: onBlur,
onMouseEnter,
onMouseLeave,
accessibilityHint: hint,
accessibilityLabel: label,
accessibilityRole: role,
},
@@ -202,7 +208,7 @@ export function Outer({
)
}
export function Item({children, label, onPress, ...rest}: ItemProps) {
export function Item({children, label, onPress, style, ...rest}: ItemProps) {
const t = useTheme()
const {control} = useMenuContext()
const {
@@ -248,6 +254,7 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
? t.atoms.bg_contrast_25
: t.atoms.bg_contrast_50,
],
style,
])}
{...web({
onMouseEnter,
+5
View File
@@ -19,6 +19,7 @@ export type ItemContextType = {
}
export type RadixPassThroughTriggerProps = {
ref: React.RefObject<any>
id: string
type: 'button'
disabled: boolean
@@ -37,6 +38,7 @@ export type RadixPassThroughTriggerProps = {
export type TriggerProps = {
children(props: TriggerChildProps): React.ReactNode
label: string
hint?: string
role?: AccessibilityRole
}
export type TriggerChildProps =
@@ -59,11 +61,13 @@ export type TriggerChildProps =
* object is empty.
*/
props: {
ref: null
onPress: () => void
onFocus: () => void
onBlur: () => void
onPressIn: () => void
onPressOut: () => void
accessibilityHint?: string
accessibilityLabel: string
accessibilityRole: AccessibilityRole
}
@@ -85,6 +89,7 @@ export type TriggerChildProps =
onBlur: () => void
onMouseEnter: () => void
onMouseLeave: () => void
accessibilityHint?: string
accessibilityLabel: string
accessibilityRole: AccessibilityRole
}
+1 -1
View File
@@ -126,7 +126,7 @@ export function Label({
fill={t.atoms.text_contrast_medium.color}
/>
) : (
<UserAvatar avatar={desc.sourceAvi} size={avi} />
<UserAvatar avatar={desc.sourceAvi} type="user" size={avi} />
)}
<Text
+3
View File
@@ -285,6 +285,7 @@ export type FollowButtonProps = {
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
colorInverted?: boolean
onFollow?: () => void
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
@@ -298,6 +299,7 @@ export function FollowButtonInner({
moderationOpts,
logContext,
onPress: onPressProp,
onFollow,
colorInverted,
...rest
}: FollowButtonProps) {
@@ -324,6 +326,7 @@ export function FollowButtonInner({
),
)
onPressProp?.(e)
onFollow?.()
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
@@ -25,9 +25,7 @@ import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from
import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types'
export function SelectReportOptionView({
...props
}: {
export function SelectReportOptionView(props: {
params: ReportDialogProps['params']
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
onSelectReportOption: (reportOption: ReportOption) => void
+2 -1
View File
@@ -16,6 +16,7 @@ import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {PaperPlane_Stroke2_Corner0_Rounded as SendIcon} from '#/components/icons/PaperPlane'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types'
@@ -223,7 +224,7 @@ export function SubmitView({
<ButtonText>
<Trans>Send report</Trans>
</ButtonText>
{submitting && <ButtonIcon icon={Loader} />}
<ButtonIcon icon={submitting ? Loader : SendIcon} />
</Button>
</View>
{/* Maybe fix this later -h */}
+13 -91
View File
@@ -1,25 +1,19 @@
import React from 'react'
import {TextStyle} from 'react-native'
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {toShortUrl} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf'
import {atoms as a, flatten, TextStyleProp} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {InlineLinkText, LinkProps} from '#/components/Link'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
import {RichTextTag} from '#/components/RichTextTag'
import {Text, TextProps} from '#/components/Typography'
const WORD_WRAP = {wordWrap: 1}
export type RichTextProps = TextStyleProp &
Pick<TextProps, 'selectable'> & {
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
value: RichTextAPI | string
testID?: string
numberOfLines?: number
@@ -43,6 +37,8 @@ export function RichText({
onLinkPress,
interactiveStyle,
emojiMultiplier = 1.85,
onLayout,
onTextLayout,
}: RichTextProps) {
const richText = React.useMemo(
() =>
@@ -70,6 +66,8 @@ export function RichText({
selectable={selectable}
testID={testID}
style={[plainStyles, {fontSize}]}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{text}
@@ -83,6 +81,8 @@ export function RichText({
testID={testID}
style={plainStyles}
numberOfLines={numberOfLines}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{text}
@@ -143,10 +143,9 @@ export function RichText({
els.push(
<RichTextTag
key={key}
text={segment.text}
display={segment.text}
tag={tag.tag}
style={interactiveStyles}
selectable={selectable}
textStyle={interactiveStyles}
authorHandle={authorHandle}
/>,
)
@@ -163,88 +162,11 @@ export function RichText({
testID={testID}
style={plainStyles}
numberOfLines={numberOfLines}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{els}
</Text>
)
}
function RichTextTag({
text,
tag,
style,
selectable,
authorHandle,
}: {
text: string
tag: string
selectable?: boolean
authorHandle?: string
} & TextStyleProp) {
const t = useTheme()
const {_} = useLingui()
const control = useTagMenuControl()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const navigation = useNavigation<NavigationProp>()
const navigateToPage = React.useCallback(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
}, [navigation, tag])
const openDialog = React.useCallback(() => {
control.open()
}, [control])
/*
* N.B. On web, this is wrapped in another pressable comopnent with a11y
* labels, etc. That's why only some of these props are applied here.
*/
return (
<React.Fragment>
<TagMenu control={control} tag={tag} authorHandle={authorHandle}>
<Text
emoji
selectable={selectable}
{...native({
accessibilityLabel: _(msg`Hashtag: #${tag}`),
accessibilityHint: _(msg`Long press to open tag menu for #${tag}`),
accessibilityRole: isNative ? 'button' : undefined,
onPress: navigateToPage,
onLongPress: openDialog,
})}
{...web({
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
})}
// @ts-ignore
onFocus={onFocus}
onBlur={onBlur}
style={[
web({
cursor: 'pointer',
}),
{color: t.palette.primary_500},
(hovered || focused) && {
...web({
outline: 0,
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
}),
},
style,
]}>
{text}
</Text>
</TagMenu>
</React.Fragment>
)
}
+160
View File
@@ -0,0 +1,160 @@
import React from 'react'
import {StyleProp, Text as RNText, TextStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {isInvalidHandle} from '#/lib/strings/handles'
import {isNative, isWeb} from '#/platform/detection'
import {
usePreferencesQuery,
useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
} from '#/state/queries/preferences'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {
createStaticClick,
createStaticClickIfUnmodified,
InlineLinkText,
} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
export function RichTextTag({
tag,
display,
authorHandle,
textStyle,
}: {
tag: string
display: string
authorHandle?: string
textStyle: StyleProp<TextStyle>
}) {
const {_} = useLingui()
const {isLoading: isPreferencesLoading, data: preferences} =
usePreferencesQuery()
const {
mutateAsync: upsertMutedWord,
variables: optimisticUpsert,
reset: resetUpsert,
} = useUpsertMutedWordsMutation()
const {
mutateAsync: removeMutedWords,
variables: optimisticRemove,
reset: resetRemove,
} = useRemoveMutedWordsMutation()
const navigation = useNavigation<NavigationProp>()
const label = _(msg`Hashtag ${tag}`)
const hint = isNative
? _(msg`Long press to open tag menu for #${tag}`)
: _(msg`Click to open tag menu for ${tag}`)
const isMuted = Boolean(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!optimisticRemove?.find(m => m?.value === tag),
)
/*
* Mute word records that exactly match the tag in question.
*/
const removeableMuteWords = React.useMemo(() => {
return (
preferences?.moderationPrefs.mutedWords?.filter(word => {
return word.value === tag
}) || []
)
}, [tag, preferences?.moderationPrefs?.mutedWords])
return (
<Menu.Root>
<Menu.Trigger label={label} hint={hint}>
{({props: menuProps}) => (
<InlineLinkText
to={{
screen: 'Hashtag',
params: {tag: encodeURIComponent(tag)},
}}
{...menuProps}
onPress={e => {
if (isWeb) {
return createStaticClickIfUnmodified(() => {
if (!isNative) {
menuProps.onPress()
}
}).onPress(e)
}
}}
onLongPress={createStaticClick(menuProps.onPress).onPress}
accessibilityHint={hint}
label={label}
style={textStyle}>
{isNative ? (
display
) : (
<RNText ref={menuProps.ref}>{display}</RNText>
)}
</InlineLinkText>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`See ${tag} posts`)}
onPress={() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
}}>
<Menu.ItemText>
<Trans>See #{tag} posts</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Search} />
</Menu.Item>
{authorHandle && !isInvalidHandle(authorHandle) && (
<Menu.Item
label={_(msg`See ${tag} posts by user`)}
onPress={() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
author: authorHandle,
})
}}>
<Menu.ItemText>
<Trans>See #{tag} posts by user</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
)}
</Menu.Group>
<Menu.Divider />
<Menu.Item
label={isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)}
onPress={() => {
if (isMuted) {
resetUpsert()
removeMutedWords(removeableMuteWords)
} else {
resetRemove()
upsertMutedWord([
{value: tag, targets: ['tag'], actorTarget: 'all'},
])
}
}}>
<Menu.ItemText>
{isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)}
</Menu.ItemText>
<Menu.ItemIcon icon={isPreferencesLoading ? Loader : Mute} />
</Menu.Item>
</Menu.Outer>
</Menu.Root>
)
}
@@ -136,7 +136,6 @@ export const ProfileStarterPacks = React.forwardRef<
headerOffset={headerOffset}
progressViewOffset={ios(0)}
contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}}
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true}
desktopFixedHeight
onEndReached={onEndReached}
-290
View File
@@ -1,290 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {isInvalidHandle} from '#/lib/strings/handles'
import {
usePreferencesQuery,
useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
} from '#/state/queries/preferences'
import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {createStaticClick, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function useTagMenuControl() {
return Dialog.useDialogControl()
}
export function TagMenu({
children,
control,
tag,
authorHandle,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
/**
* This should be the sanitized tag value from the facet itself, not the
* "display" value with a leading `#`.
*/
tag: string
authorHandle?: string
}>) {
const navigation = useNavigation<NavigationProp>()
return (
<>
{children}
<Dialog.Outer control={control}>
<Dialog.Handle />
<TagMenuInner
control={control}
tag={tag}
authorHandle={authorHandle}
navigation={navigation}
/>
</Dialog.Outer>
</>
)
}
function TagMenuInner({
control,
tag,
authorHandle,
navigation,
}: {
control: Dialog.DialogOuterProps['control']
tag: string
authorHandle?: string
// Passed down because on native, we don't use real portals (and context would be wrong).
navigation: NavigationProp
}) {
const {_} = useLingui()
const t = useTheme()
const {isLoading: isPreferencesLoading, data: preferences} =
usePreferencesQuery()
const {
mutateAsync: upsertMutedWord,
variables: optimisticUpsert,
reset: resetUpsert,
} = useUpsertMutedWordsMutation()
const {
mutateAsync: removeMutedWords,
variables: optimisticRemove,
reset: resetRemove,
} = useRemoveMutedWordsMutation()
const displayTag = '#' + tag
const isMuted = Boolean(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!optimisticRemove?.find(m => m?.value === tag),
)
/*
* Mute word records that exactly match the tag in question.
*/
const removeableMuteWords = React.useMemo(() => {
return (
preferences?.moderationPrefs.mutedWords?.filter(word => {
return word.value === tag
}) || []
)
}, [tag, preferences?.moderationPrefs?.mutedWords])
return (
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
{isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}>
<Loader size="lg" />
</View>
) : (
<>
<View
style={[
a.rounded_md,
a.border,
a.mb_md,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}>
<Link
label={_(msg`View all posts with tag ${displayTag}`)}
{...createStaticClick(() => {
control.close(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
})
})}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Search size="lg" style={[t.atoms.text_contrast_medium]} />
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
<Trans>
See{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
posts
</Trans>
</Text>
</View>
</Link>
{authorHandle && !isInvalidHandle(authorHandle) && (
<>
<Divider />
<Link
label={_(
msg`View all posts by @${authorHandle} with tag ${displayTag}`,
)}
{...createStaticClick(() => {
control.close(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
author: authorHandle,
})
})
})}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Person size="lg" style={[t.atoms.text_contrast_medium]} />
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
<Trans>
See{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
posts by this user
</Trans>
</Text>
</View>
</Link>
</>
)}
{preferences ? (
<>
<Divider />
<Button
label={
isMuted
? _(msg`Unmute all ${displayTag} posts`)
: _(msg`Mute all ${displayTag} posts`)
}
onPress={() => {
control.close(() => {
if (isMuted) {
resetUpsert()
removeMutedWords(removeableMuteWords)
} else {
resetRemove()
upsertMutedWord([
{
value: tag,
targets: ['tag'],
actorTarget: 'all',
},
])
}
})
}}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Mute size="lg" style={[t.atoms.text_contrast_medium]} />
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
<Trans>posts</Trans>
</Text>
</View>
</Button>
</>
) : null}
</View>
<Button
label={_(msg`Close this dialog`)}
size="small"
variant="ghost"
color="secondary"
onPress={() => control.close()}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</>
)}
</Dialog.Inner>
)
}
-163
View File
@@ -1,163 +0,0 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {isInvalidHandle} from '#/lib/strings/handles'
import {enforceLen} from '#/lib/strings/helpers'
import {
usePreferencesQuery,
useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
} from '#/state/queries/preferences'
import {EventStopper} from '#/view/com/util/EventStopper'
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
import {web} from '#/alf'
import * as Dialog from '#/components/Dialog'
export function useTagMenuControl(): Dialog.DialogControlProps {
return {
id: '',
// @ts-ignore
ref: null,
open: () => {
throw new Error(`TagMenu controls are only available on native platforms`)
},
close: () => {
throw new Error(`TagMenu controls are only available on native platforms`)
},
}
}
export function TagMenu({
children,
tag,
authorHandle,
}: React.PropsWithChildren<{
/**
* This should be the sanitized tag value from the facet itself, not the
* "display" value with a leading `#`.
*/
tag: string
authorHandle?: string
}>) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {data: preferences} = usePreferencesQuery()
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
useUpsertMutedWordsMutation()
const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
useRemoveMutedWordsMutation()
const isMuted = Boolean(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!optimisticRemove?.find(m => m?.value === tag),
)
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
/*
* Mute word records that exactly match the tag in question.
*/
const removeableMuteWords = React.useMemo(() => {
return (
preferences?.moderationPrefs.mutedWords?.filter(word => {
return word.value === tag
}) || []
)
}, [tag, preferences?.moderationPrefs?.mutedWords])
const dropdownItems = React.useMemo(() => {
return [
{
label: _(msg`See ${truncatedTag} posts`),
onPress() {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
},
testID: 'tagMenuSearch',
icon: {
ios: {
name: 'magnifyingglass',
},
android: '',
web: 'magnifying-glass',
},
},
authorHandle &&
!isInvalidHandle(authorHandle) && {
label: _(msg`See ${truncatedTag} posts by user`),
onPress() {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
author: authorHandle,
})
},
testID: 'tagMenuSearchByUser',
icon: {
ios: {
name: 'magnifyingglass',
},
android: '',
web: ['far', 'user'],
},
},
preferences && {
label: 'separator',
},
preferences && {
label: isMuted
? _(msg`Unmute ${truncatedTag}`)
: _(msg`Mute ${truncatedTag}`),
onPress() {
if (isMuted) {
removeMutedWords(removeableMuteWords)
} else {
upsertMutedWord([
{value: tag, targets: ['tag'], actorTarget: 'all'},
])
}
},
testID: 'tagMenuMute',
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_menu_sort_alphabetically',
web: isMuted ? 'eye' : ['far', 'eye-slash'],
},
},
].filter(Boolean)
}, [
_,
authorHandle,
isMuted,
navigation,
preferences,
tag,
truncatedTag,
upsertMutedWord,
removeMutedWords,
removeableMuteWords,
])
return (
<EventStopper>
<NativeDropdown
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
accessibilityHint=""
// @ts-ignore
items={dropdownItems}
triggerStyle={web({
textAlign: 'left',
})}>
{children}
</NativeDropdown>
</EventStopper>
)
}
+540
View File
@@ -0,0 +1,540 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {
AppBskyActorDefs,
AppBskyEmbedVideo,
AppBskyFeedDefs,
AppBskyFeedPost,
ModerationDecision,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {sanitizeHandle} from '#/lib/strings/handles'
import {formatCount} from '#/view/com/util/numeric/format'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {atoms as a, useTheme} from '#/alf'
import {BLUE_HUE} from '#/alf/util/colorGeneration'
import {select} from '#/alf/util/themeSelector'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash'
import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
import {Link} from '#/components/Link'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography'
function getBlackColor(t: ReturnType<typeof useTheme>) {
return select(t.name, {
light: t.palette.black,
dark: t.atoms.bg_contrast_25.backgroundColor,
dim: `hsl(${BLUE_HUE}, 28%, 6%)`,
})
}
export function VideoPostCard({
post,
sourceContext,
moderation,
onInteract,
}: {
post: AppBskyFeedDefs.PostView
sourceContext: VideoFeedSourceContext
moderation: ModerationDecision
/**
* Callback for metrics etc
*/
onInteract?: () => void
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const embed = post.embed
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const listModUi = moderation.ui('contentList')
const mergedModui = useMemo(() => {
const modui = moderation.ui('contentList')
const mediaModui = moderation.ui('contentMedia')
modui.alerts = [...modui.alerts, ...mediaModui.alerts]
modui.blurs = [...modui.blurs, ...mediaModui.blurs]
modui.filters = [...modui.filters, ...mediaModui.filters]
modui.informs = [...modui.informs, ...mediaModui.informs]
return modui
}, [moderation])
/**
* Filtering should be done at a higher level, such as `PostFeed` or
* `PostFeedVideoGridRow`, but we need to protect here as well.
*/
if (!AppBskyEmbedVideo.isView(embed)) return null
const author = post.author
const text = AppBskyFeedPost.isRecord(post.record) ? post.record?.text : ''
const likeCount = post?.likeCount ?? 0
const repostCount = post?.repostCount ?? 0
const {thumbnail} = embed
const black = getBlackColor(t)
const textAndAuthor = (
<View style={[a.pr_xs, {paddingTop: 6, gap: 4}]}>
{text && (
<Text style={[a.text_md, a.leading_snug]} numberOfLines={2} emoji>
{text}
</Text>
)}
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
<View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
<UserAvatar type="user" size={20} avatar={post.author.avatar} />
<MediaInsetBorder />
</View>
<Text
style={[
a.flex_1,
a.text_sm,
a.leading_tight,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
</Text>
</View>
</View>
)
return (
<Link
accessibilityHint={_(msg`Tap to view video in immersive mode.`)}
label={_(msg`Video from ${author.handle}: ${text}`)}
to={{
screen: 'VideoFeed',
params: {
...sourceContext,
initialPostUri: post.uri,
},
}}
onPress={() => {
onInteract?.()
}}
onPressIn={onPressIn}
onPressOut={onPressOut}
style={[
a.flex_col,
{
alignItems: undefined,
justifyContent: undefined,
},
]}>
<Hider.Outer modui={mergedModui}>
<Hider.Mask>
<View
style={[
a.justify_center,
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<Image
source={{uri: thumbnail}}
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
accessibilityIgnoresInvertColors
blurRadius={100}
/>
<MediaInsetBorder />
<View
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.align_center,
{
backgroundColor: 'black',
opacity: 0.2,
},
]}
/>
<View style={[a.align_center, a.gap_xs]}>
<Eye size="lg" fill="white" />
<Text style={[a.text_sm, {color: 'white'}]}>
{_(msg`Hidden`)}
</Text>
</View>
</View>
</View>
{listModUi.blur ? (
<VideoPostCardTextPlaceholder author={post.author} />
) : (
textAndAuthor
)}
</Hider.Mask>
<Hider.Content>
<View
style={[
a.justify_center,
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<Image
source={{uri: thumbnail}}
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
accessibilityIgnoresInvertColors
/>
<MediaInsetBorder />
<View style={[a.absolute, a.inset_0]}>
<View
style={[
a.absolute,
a.inset_0,
a.pt_2xl,
{
top: 'auto',
},
]}>
<LinearGradient
colors={[black, 'rgba(0, 0, 0, 0)']}
locations={[0.02, 1]}
start={{x: 0, y: 1}}
end={{x: 0, y: 0}}
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
/>
<View
style={[a.relative, a.z_10, a.p_md, a.flex_row, a.gap_md]}>
{likeCount > 0 && (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Heart size="sm" fill="white" />
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
{formatCount(i18n, likeCount)}
</Text>
</View>
)}
{repostCount > 0 && (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Repost size="sm" fill="white" />
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
{formatCount(i18n, repostCount)}
</Text>
</View>
)}
</View>
</View>
</View>
</View>
{textAndAuthor}
</Hider.Content>
</Hider.Outer>
</Link>
)
}
export function VideoPostCardPlaceholder() {
const t = useTheme()
const black = getBlackColor(t)
return (
<View style={[a.flex_1]}>
<View
style={[
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<MediaInsetBorder />
</View>
<VideoPostCardTextPlaceholder />
</View>
)
}
export function VideoPostCardTextPlaceholder({
author,
}: {
author?: AppBskyActorDefs.ProfileViewBasic
}) {
const t = useTheme()
return (
<View style={[a.flex_1]}>
<View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}>
<View
style={[
a.w_full,
a.rounded_xs,
t.atoms.bg_contrast_50,
{
height: 14,
},
]}
/>
<View
style={[
a.w_full,
a.rounded_xs,
t.atoms.bg_contrast_50,
{
height: 14,
width: '70%',
},
]}
/>
{author ? (
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
<View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
<UserAvatar type="user" size={20} avatar={author.avatar} />
<MediaInsetBorder />
</View>
<Text
style={[
a.flex_1,
a.text_sm,
a.leading_tight,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(author.handle, '@')}
</Text>
</View>
) : (
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
<View
style={[
a.rounded_full,
t.atoms.bg_contrast_50,
{
width: 20,
height: 20,
},
]}
/>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
{
height: 12,
width: '75%',
},
]}
/>
</View>
)}
</View>
</View>
)
}
export function CompactVideoPostCard({
post,
sourceContext,
moderation,
onInteract,
}: {
post: AppBskyFeedDefs.PostView
sourceContext: VideoFeedSourceContext
moderation: ModerationDecision
/**
* Callback for metrics etc
*/
onInteract?: () => void
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const embed = post.embed
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const mergedModui = useMemo(() => {
const modui = moderation.ui('contentList')
const mediaModui = moderation.ui('contentMedia')
modui.alerts = [...modui.alerts, ...mediaModui.alerts]
modui.blurs = [...modui.blurs, ...mediaModui.blurs]
modui.filters = [...modui.filters, ...mediaModui.filters]
modui.informs = [...modui.informs, ...mediaModui.informs]
return modui
}, [moderation])
/**
* Filtering should be done at a higher level, such as `PostFeed` or
* `PostFeedVideoGridRow`, but we need to protect here as well.
*/
if (!AppBskyEmbedVideo.isView(embed)) return null
const likeCount = post?.likeCount ?? 0
const {thumbnail} = embed
const black = getBlackColor(t)
return (
<Link
label={_(msg`View video`)}
to={{
screen: 'VideoFeed',
params: {
...sourceContext,
initialPostUri: post.uri,
},
}}
onPress={() => {
onInteract?.()
}}
onPressIn={onPressIn}
onPressOut={onPressOut}
style={[
a.flex_col,
{
alignItems: undefined,
justifyContent: undefined,
},
]}>
<Hider.Outer modui={mergedModui}>
<Hider.Mask>
<View
style={[
a.justify_center,
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<Image
source={{uri: thumbnail}}
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
accessibilityIgnoresInvertColors
blurRadius={100}
/>
<MediaInsetBorder />
<View
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.align_center,
{
backgroundColor: 'black',
opacity: 0.2,
},
]}
/>
<View style={[a.align_center, a.gap_xs]}>
<Eye size="lg" fill="white" />
<Text style={[a.text_sm, {color: 'white'}]}>
{_(msg`Hidden`)}
</Text>
</View>
</View>
</View>
</Hider.Mask>
<Hider.Content>
<View
style={[
a.justify_center,
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<Image
source={{uri: thumbnail}}
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
accessibilityIgnoresInvertColors
/>
<MediaInsetBorder />
<View style={[a.absolute, a.inset_0]}>
<View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
<View
style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
<UserAvatar
type="user"
size={20}
avatar={post.author.avatar}
/>
<MediaInsetBorder />
</View>
</View>
<View
style={[
a.absolute,
a.inset_0,
a.pt_2xl,
{
top: 'auto',
},
]}>
<LinearGradient
colors={[black, 'rgba(0, 0, 0, 0)']}
locations={[0.02, 1]}
start={{x: 0, y: 1}}
end={{x: 0, y: 0}}
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
/>
<View
style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
{likeCount > 0 && (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Heart size="sm" fill="white" />
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
{formatCount(i18n, likeCount)}
</Text>
</View>
)}
</View>
</View>
</View>
</View>
</Hider.Content>
</Hider.Outer>
</Link>
)
}
export function CompactVideoPostCardPlaceholder() {
const t = useTheme()
const black = getBlackColor(t)
return (
<View style={[a.flex_1]}>
<View
style={[
a.rounded_md,
a.overflow_hidden,
{
backgroundColor: black,
aspectRatio: 9 / 16,
},
]}>
<MediaInsetBorder />
</View>
</View>
)
}
+6 -10
View File
@@ -12,12 +12,12 @@ import {
usePreferencesSetBirthDateMutation,
} from '#/state/queries/preferences'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {DateInput} from '#/view/com/util/forms/DateInput'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {DateField} from '#/components/forms/DateField'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {Text} from '../Typography'
export function BirthDateSettingsDialog({
control,
@@ -95,17 +95,13 @@ function BirthdayInner({
return (
<View style={a.gap_lg} testID="birthDateSettingsDialog">
<View style={isIOS && [a.w_full, a.align_center]}>
<DateInput
handleAsUTC
<DateField
testID="birthdayInput"
value={date}
onChange={setDate}
buttonType="default-light"
buttonStyle={[a.rounded_sm]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
onChangeDate={newDate => setDate(new Date(newDate))}
label={_(msg`Birthday`)}
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
maximumDate={new Date()}
/>
</View>
+171 -125
View File
@@ -32,15 +32,17 @@ import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import * as atp from '#/types/atproto'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
import {ReportDialog} from './ReportDialog'
let ConvoMenu = ({
convo: initialConvo,
convo,
profile,
control,
currentScreen,
showMarkAsRead,
hideTrigger,
blockInfo,
latestReportableMessage,
style,
}: {
convo: ChatBskyConvoDefs.ConvoView
@@ -53,27 +55,122 @@ let ConvoMenu = ({
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
latestReportableMessage?: ChatBskyConvoDefs.MessageView
style?: ViewStyleProp['style']
}): React.ReactNode => {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const t = useTheme()
const leaveConvoControl = Prompt.usePromptControl()
const reportControl = Prompt.usePromptControl()
const blockedByListControl = Prompt.usePromptControl()
const {listBlocks} = blockInfo
return (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<View style={[style]}>
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
onPress={() => {
Keyboard.dismiss()
props.onPress()
}}
style={[
a.p_sm,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
// make sure pfp is in the middle
{marginLeft: -10},
]}>
<DotsHorizontal size="md" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
</View>
)}
<Menu.Outer>
<MenuContent
profile={profile}
showMarkAsRead={showMarkAsRead}
blockInfo={blockInfo}
convo={convo}
leaveConvoControl={leaveConvoControl}
reportControl={reportControl}
blockedByListControl={blockedByListControl}
/>
</Menu.Outer>
</Menu.Root>
<LeaveConvoPrompt
control={leaveConvoControl}
convoId={convo.id}
currentScreen={currentScreen}
/>
{latestReportableMessage ? (
<ReportDialog
currentScreen={currentScreen}
params={{
type: 'convoMessage',
convoId: convo.id,
message: latestReportableMessage,
}}
control={reportControl}
/>
) : (
<ReportConversationPrompt control={reportControl} />
)}
<BlockedByListDialog
control={blockedByListControl}
listBlocks={listBlocks}
/>
</>
)
}
ConvoMenu = React.memo(ConvoMenu)
function MenuContent({
convo: initialConvo,
profile,
showMarkAsRead,
blockInfo,
leaveConvoControl,
reportControl,
blockedByListControl,
}: {
convo: ChatBskyConvoDefs.ConvoView
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
showMarkAsRead?: boolean
blockInfo: {
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
leaveConvoControl: Prompt.PromptControlProps
reportControl: Prompt.PromptControlProps
blockedByListControl: Prompt.PromptControlProps
}) {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const {mutate: markAsRead} = useMarkAsReadMutation()
const {listBlocks, userBlock} = blockInfo
const isBlocking = userBlock || !!listBlocks.length
const isDeletedAccount = profile.handle === 'missing.invalid'
const convoId = initialConvo.id
const {data: convo} = useConvoQuery(initialConvo)
const onNavigateToProfile = useCallback(() => {
navigation.navigate('Profile', {name: profile.did})
}, [navigation, profile.did])
const {mutate: muteConvo} = useMuteConvo(convo?.id, {
const {mutate: muteConvo} = useMuteConvo(convoId, {
onSuccess: data => {
if (data.convo.muted) {
Toast.show(_(msg`Chat muted`))
@@ -101,132 +198,81 @@ let ConvoMenu = ({
}
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
return (
return isDeletedAccount ? (
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
) : (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<View style={[style]}>
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
onPress={() => {
Keyboard.dismiss()
props.onPress()
}}
style={[
a.p_sm,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
// make sure pfp is in the middle
{marginLeft: -10},
]}>
<DotsHorizontal size="md" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
</View>
<Menu.Group>
{showMarkAsRead && (
<Menu.Item
label={_(msg`Mark as read`)}
onPress={() => markAsRead({convoId})}>
<Menu.ItemText>
<Trans>Mark as read</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Bubble} />
</Menu.Item>
)}
{isDeletedAccount ? (
<Menu.Outer>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
</Menu.Outer>
) : (
<Menu.Outer>
<Menu.Group>
{showMarkAsRead && (
<Menu.Item
label={_(msg`Mark as read`)}
onPress={() =>
markAsRead({
convoId: convo?.id,
})
}>
<Menu.ItemText>
<Trans>Mark as read</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Bubble} />
</Menu.Item>
)}
<Menu.Item
label={_(msg`Go to user's profile`)}
onPress={onNavigateToProfile}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
<Menu.Item
label={_(msg`Mute conversation`)}
onPress={() => muteConvo({mute: !convo?.muted})}>
<Menu.ItemText>
{convo?.muted ? (
<Trans>Unmute conversation</Trans>
) : (
<Trans>Mute conversation</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={
isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)
}
onPress={toggleBlock}>
<Menu.ItemText>
{isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)}
</Menu.ItemText>
<Menu.ItemIcon icon={isBlocking ? PersonCheck : PersonX} />
</Menu.Item>
<Menu.Item
label={_(msg`Report conversation`)}
onPress={() => reportControl.open()}>
<Menu.ItemText>
<Trans>Report conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
)}
</Menu.Root>
<LeaveConvoPrompt
control={leaveConvoControl}
convoId={convo.id}
currentScreen={currentScreen}
/>
<ReportConversationPrompt control={reportControl} />
<BlockedByListDialog
control={blockedByListControl}
listBlocks={listBlocks}
/>
<Menu.Item
label={_(msg`Go to user's profile`)}
onPress={onNavigateToProfile}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
<Menu.Item
label={_(msg`Mute conversation`)}
onPress={() => muteConvo({mute: !convo?.muted})}>
<Menu.ItemText>
{convo?.muted ? (
<Trans>Unmute conversation</Trans>
) : (
<Trans>Mute conversation</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)}
onPress={toggleBlock}>
<Menu.ItemText>
{isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)}
</Menu.ItemText>
<Menu.ItemIcon icon={isBlocking ? PersonCheck : PersonX} />
</Menu.Item>
<Menu.Item
label={_(msg`Report conversation`)}
onPress={() => reportControl.open()}>
<Menu.ItemText>
<Trans>Report conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
</Menu.Group>
</>
)
}
ConvoMenu = React.memo(ConvoMenu)
export {ConvoMenu}
+4 -9
View File
@@ -1,6 +1,6 @@
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {StackActions, useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {isNative} from '#/platform/detection'
@@ -22,15 +22,10 @@ export function LeaveConvoPrompt({
const navigation = useNavigation<NavigationProp>()
const {mutate: leaveConvo} = useLeaveConvo(convoId, {
onSuccess: () => {
onMutate: () => {
if (currentScreen === 'conversation') {
navigation.replace(
'Messages',
isNative
? {
animation: 'pop',
}
: {},
navigation.dispatch(
StackActions.replace('Messages', isNative ? {animation: 'pop'} : {}),
)
}
},
+1
View File
@@ -138,6 +138,7 @@ export let MessageMenu = ({
</Menu.Root>
<ReportDialog
currentScreen="conversation"
params={{type: 'convoMessage', convoId: convo.convo.id, message}}
control={reportControl}
/>
@@ -1,6 +1,6 @@
import React from 'react'
import {View} from 'react-native'
import {ModerationCause} from '@atproto/api'
import {ModerationDecision} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -20,15 +20,12 @@ export function MessagesListBlockedFooter({
recipient: initialRecipient,
convoId,
hasMessages,
blockInfo,
moderation,
}: {
recipient: atp.profile.AnyProfileView
convoId: string
hasMessages: boolean
blockInfo: {
listBlocks: ModerationCause[]
userBlock: ModerationCause | undefined
}
moderation: ModerationDecision
}) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
@@ -40,7 +37,17 @@ export function MessagesListBlockedFooter({
const reportControl = useDialogControl()
const blockedByListControl = useDialogControl()
const {listBlocks, userBlock} = blockInfo
const {listBlocks, userBlock} = React.useMemo(() => {
const modui = moderation.ui('profileView')
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
const userBlock = blocks.find(alert => alert.source.type === 'user')
return {
listBlocks,
userBlock,
}
}, [moderation])
const isBlocking = !!userBlock || !!listBlocks.length
const onUnblockPress = React.useCallback(() => {
+31 -12
View File
@@ -1,6 +1,10 @@
import React, {useCallback} from 'react'
import {TouchableOpacity, View} from 'react-native'
import {ModerationCause, ModerationDecision} from '@atproto/api'
import {
AppBskyActorDefs,
ModerationCause,
ModerationDecision,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -11,7 +15,7 @@ import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isWeb} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
@@ -20,27 +24,33 @@ import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/
import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Text} from '#/components/Typography'
import * as atp from '#/types/atproto'
const PFP_SIZE = isWeb ? 40 : 34
export let MessagesListHeader = ({
profile,
moderation,
blockInfo,
}: {
profile?: atp.profile.AnyProfileView
profile?: Shadow<AppBskyActorDefs.ProfileViewBasic>
moderation?: ModerationDecision
blockInfo?: {
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {gtTablet} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
const blockInfo = React.useMemo(() => {
if (!moderation) return
const modui = moderation.ui('profileView')
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
const userBlock = blocks.find(alert => alert.source.type === 'user')
return {
listBlocks,
userBlock,
}
}, [moderation])
const onPressBack = useCallback(() => {
if (isWeb) {
navigation.replace('Messages', {})
@@ -124,11 +134,11 @@ export let MessagesListHeader = ({
MessagesListHeader = React.memo(MessagesListHeader)
function HeaderReady({
profile: profileUnshadowed,
profile,
moderation,
blockInfo,
}: {
profile: atp.profile.AnyProfileView
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
moderation: ModerationDecision
blockInfo: {
listBlocks: ModerationCause[]
@@ -138,7 +148,6 @@ function HeaderReady({
const {_} = useLingui()
const t = useTheme()
const convoState = useConvo()
const profile = useProfileShadow(profileUnshadowed)
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
@@ -148,6 +157,15 @@ function HeaderReady({
moderation.ui('displayName'),
)
const latestMessageFromOther = convoState.items.findLast(
item => item.type === 'message' && item.message.sender.did === profile.did,
)
const latestReportableMessage =
latestMessageFromOther?.type === 'message'
? latestMessageFromOther.message
: undefined
return (
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
@@ -205,6 +223,7 @@ function HeaderReady({
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)}
</View>
+170 -22
View File
@@ -2,27 +2,39 @@ import React, {memo, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
$Typed,
AppBskyActorDefs,
ChatBskyConvoDefs,
ComAtprotoModerationCreateReport,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native'
import {useMutation} from '@tanstack/react-query'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {NavigationProp} from '#/lib/routes/types'
import {isNative} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {
useProfileBlockMutationQueue,
useProfileQuery,
} from '#/state/queries/profile'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {Divider} from '../Divider'
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
import {Loader} from '../Loader'
import {SelectReportOptionView} from '../ReportDialog/SelectReportOptionView'
import {RichText} from '../RichText'
import {Text} from '../Typography'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
import {PaperPlane_Stroke2_Corner0_Rounded as SendIcon} from '#/components/icons/PaperPlane'
import {Loader} from '#/components/Loader'
import {SelectReportOptionView} from '#/components/ReportDialog/SelectReportOptionView'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {MessageItemMetadata} from './MessageItem'
type ReportDialogParams = {
@@ -34,16 +46,18 @@ type ReportDialogParams = {
let ReportDialog = ({
control,
params,
currentScreen,
}: {
control: Dialog.DialogControlProps
params: ReportDialogParams
currentScreen: 'list' | 'conversation'
}): React.ReactNode => {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Report this message`)}>
<DialogInner params={params} />
<DialogInner params={params} currentScreen={currentScreen} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
@@ -52,14 +66,44 @@ let ReportDialog = ({
ReportDialog = memo(ReportDialog)
export {ReportDialog}
function DialogInner({params}: {params: ReportDialogParams}) {
function DialogInner({
params,
currentScreen,
}: {
params: ReportDialogParams
currentScreen: 'list' | 'conversation'
}) {
const {data: profile, isError} = useProfileQuery({
did: params.message.sender.did,
})
const [reportOption, setReportOption] = useState<ReportOption | null>(null)
const [done, setDone] = useState(false)
const control = Dialog.useDialogContext()
return reportOption ? (
return done ? (
profile ? (
<DoneStep
convoId={params.convoId}
currentScreen={currentScreen}
profile={profile}
/>
) : (
<View style={[a.w_full, a.py_5xl, a.align_center]}>
<Loader />
</View>
)
) : reportOption ? (
<SubmitStep
params={params}
reportOption={reportOption}
goBack={() => setReportOption(null)}
onComplete={() => {
if (isError) {
control.close()
} else {
setDone(true)
}
}}
/>
) : (
<ReasonStep params={params} setReportOption={setReportOption} />
@@ -90,16 +134,17 @@ function SubmitStep({
params,
reportOption,
goBack,
onComplete,
}: {
params: ReportDialogParams
reportOption: ReportOption
goBack: () => void
onComplete: () => void
}) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const t = useTheme()
const [details, setDetails] = useState('')
const control = Dialog.useDialogContext()
const agent = useAgent()
const {
@@ -126,11 +171,7 @@ function SubmitStep({
await agent.createModerationReport(report)
}
},
onSuccess: () => {
control.close(() => {
Toast.show(_(msg`Thank you. Your report has been sent.`))
})
},
onSuccess: onComplete,
})
const copy = useMemo(() => {
@@ -183,13 +224,12 @@ function SubmitStep({
<View style={[a.relative, a.w_full]}>
<Dialog.Input
multiline
value={details}
defaultValue={details}
onChangeText={setDetails}
label="Text field"
label={_(msg`Text field`)}
style={{paddingRight: 60}}
numberOfLines={6}
numberOfLines={5}
/>
<View
style={[
a.absolute,
@@ -233,7 +273,115 @@ function SubmitStep({
<ButtonText>
<Trans>Send report</Trans>
</ButtonText>
{submitting && <ButtonIcon icon={Loader} />}
<ButtonIcon icon={submitting ? Loader : SendIcon} />
</Button>
</View>
</View>
)
}
function DoneStep({
convoId,
currentScreen,
profile,
}: {
convoId: string
currentScreen: 'list' | 'conversation'
profile: AppBskyActorDefs.ProfileViewBasic
}) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const control = Dialog.useDialogContext()
const {gtMobile} = useBreakpoints()
const t = useTheme()
const [actions, setActions] = useState<string[]>(['block', 'leave'])
const shadow = useProfileShadow(profile)
const [queueBlock] = useProfileBlockMutationQueue(shadow)
const {mutate: leaveConvo} = useLeaveConvo(convoId, {
onMutate: () => {
if (currentScreen === 'conversation') {
navigation.dispatch(
StackActions.replace('Messages', isNative ? {animation: 'pop'} : {}),
)
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`), 'xmark')
},
})
const onPressPrimaryAction = () => {
control.close(() => {
if (actions.includes('block')) {
queueBlock()
}
if (actions.includes('leave')) {
leaveConvo()
}
})
}
let btnText = _(msg`Done`)
if (actions.includes('leave') && actions.includes('block')) {
btnText = _(msg`Block and Delete`)
} else if (actions.includes('leave')) {
btnText = _(msg`Delete Conversation`)
} else if (actions.includes('block')) {
btnText = _(msg`Block User`)
}
return (
<View style={a.gap_2xl}>
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Report submitted</Trans>
</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Our moderation team has received your report.</Trans>
</Text>
</View>
<Toggle.Group
label={_(msg`Block and/or delete this conversation`)}
values={actions}
onChange={setActions}>
<View style={[a.gap_md]}>
<Toggle.Item name="block" label={_(msg`Block user`)}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.text_md]}>
<Trans>Block user</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item name="leave" label={_(msg`Delete conversation`)}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.text_md]}>
<Trans>Delete conversation</Trans>
</Toggle.LabelText>
</Toggle.Item>
</View>
</Toggle.Group>
<View style={[a.gap_md, web([a.flex_row_reverse])]}>
<Button
label={btnText}
onPress={onPressPrimaryAction}
size="large"
variant="solid"
color={actions.length > 0 ? 'negative' : 'primary'}>
<ButtonText>{btnText}</ButtonText>
</Button>
<Button
label={_(msg`Close`)}
onPress={() => control.close()}
size={platform({native: 'small', web: 'large'})}
variant={platform({
native: 'ghost',
web: 'solid',
})}
color="secondary">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
</View>
@@ -0,0 +1,67 @@
import {View} from 'react-native'
import {AppBskyEmbedVideo} from '@atproto/api'
import {logEvent} from '#/lib/statsig/statsig'
import {FeedPostSliceItem} from '#/state/queries/post-feed'
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {atoms as a, useGutters} from '#/alf'
import * as Grid from '#/components/Grid'
import {
VideoPostCard,
VideoPostCardPlaceholder,
} from '#/components/VideoPostCard'
export function PostFeedVideoGridRow({
items: slices,
sourceContext,
}: {
items: FeedPostSliceItem[]
sourceContext: VideoFeedSourceContext
}) {
const gutters = useGutters(['base', 'base', 0, 'base'])
const posts = slices
.filter(slice => AppBskyEmbedVideo.isView(slice.post.embed))
.map(slice => ({
post: slice.post,
moderation: slice.moderation,
}))
/**
* This should not happen because we should be filtering out posts without
* videos within the `PostFeed` component.
*/
if (posts.length !== slices.length) return null
return (
<View style={[gutters]}>
<View style={[a.flex_row, a.gap_sm]}>
<Grid.Row gap={a.gap_sm.gap}>
{posts.map(post => (
<Grid.Col key={post.post.uri} width={1 / 2}>
<VideoPostCard
post={post.post}
sourceContext={sourceContext}
moderation={post.moderation}
onInteract={() => {
logEvent('videoCard:click', {context: 'feed'})
}}
/>
</Grid.Col>
))}
</Grid.Row>
</View>
</View>
)
}
export function PostFeedVideoGridRowPlaceholder() {
const gutters = useGutters(['base', 'base', 0, 'base'])
return (
<View style={[gutters]}>
<View style={[a.flex_row, a.gap_sm]}>
<VideoPostCardPlaceholder />
<VideoPostCardPlaceholder />
</View>
</View>
)
}
@@ -17,6 +17,7 @@ export function DateField({
isInvalid,
testID,
accessibilityHint,
maximumDate,
}: DateFieldProps) {
const t = useTheme()
const [open, setOpen] = React.useState(false)
@@ -67,6 +68,9 @@ export function DateField({
aria-label={label}
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
maximumDate={
maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined
}
/>
)}
</>
@@ -19,7 +19,7 @@ export function DateFieldButton({
accessibilityHint,
}: {
label: string
value: string
value: string | Date
onPress: () => void
isInvalid?: boolean
accessibilityHint?: string
+17 -7
View File
@@ -16,10 +16,11 @@ export * as utils from '#/components/forms/DateField/utils'
export const LabelText = TextField.LabelText
/**
* Date-only input. Accepts a date in the format YYYY-MM-DD, and reports date
* changes in the same format.
* Date-only input. Accepts a string in the format YYYY-MM-DD, or a Date object.
* Date objects are converted to strings in the format YYYY-MM-DD.
* Returns a string in the format YYYY-MM-DD.
*
* For dates of unknown format, convert with the
* To generate a string in the format YYYY-MM-DD from a Date object, use the
* `utils.toSimpleDateString(Date)` export of this file.
*/
export function DateField({
@@ -29,6 +30,7 @@ export function DateField({
label,
isInvalid,
accessibilityHint,
maximumDate,
}: DateFieldProps) {
const {_} = useLingui()
const t = useTheme()
@@ -56,21 +58,29 @@ export function DateField({
isInvalid={isInvalid}
accessibilityHint={accessibilityHint}
/>
<Dialog.Outer control={control} testID={testID}>
<Dialog.Outer
control={control}
testID={testID}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.Inner label={label}>
<Dialog.ScrollableInner label={label}>
<View style={a.gap_lg}>
<View style={[a.relative, a.w_full, a.align_center]}>
<DatePicker
timeZoneOffsetInMinutes={0}
theme={t.name === 'light' ? 'light' : 'dark'}
date={new Date(value)}
date={new Date(toSimpleDateString(value))}
onDateChange={onChangeInternal}
mode="date"
testID={`${testID}-datepicker`}
aria-label={label}
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
maximumDate={
maximumDate
? new Date(toSimpleDateString(maximumDate))
: undefined
}
/>
</View>
<Button
@@ -84,7 +94,7 @@ export function DateField({
</ButtonText>
</Button>
</View>
</Dialog.Inner>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
+5 -2
View File
@@ -1,6 +1,6 @@
import React from 'react'
import {StyleSheet, TextInput, TextInputProps} from 'react-native'
// @ts-ignore
// @ts-expect-error untyped
import {unstable_createElement} from 'react-native-web'
import {DateFieldProps} from '#/components/forms/DateField/types'
@@ -39,6 +39,7 @@ export function DateField({
isInvalid,
testID,
accessibilityHint,
maximumDate,
}: DateFieldProps) {
const handleOnChange = React.useCallback(
(e: any) => {
@@ -56,12 +57,14 @@ export function DateField({
<TextField.Root isInvalid={isInvalid}>
<TextField.Icon icon={CalendarDays} />
<Input
value={value}
value={toSimpleDateString(value)}
label={label}
onChange={handleOnChange}
onChangeText={() => {}}
testID={testID}
accessibilityHint={accessibilityHint}
// @ts-expect-error not typed as <input type="date"> even though it is one
max={maximumDate ? toSimpleDateString(maximumDate) : undefined}
/>
</TextField.Root>
)
+2 -1
View File
@@ -1,8 +1,9 @@
export type DateFieldProps = {
value: string
value: string | Date
onChangeDate: (date: string) => void
label: string
isInvalid?: boolean
testID?: string
accessibilityHint?: string
maximumDate?: string | Date
}
+83 -60
View File
@@ -6,21 +6,23 @@ import {useLingui} from '@lingui/react'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {isAndroid} from '#/platform/detection'
import {ServerInputDialog} from '#/view/com/auth/server-input'
import {atoms as a, useTheme} from '#/alf'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {PencilLine_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
import {Button} from '../Button'
import {useDialogControl} from '../Dialog'
import {Text} from '../Typography'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
import {Text} from '#/components/Typography'
export function HostingProvider({
serviceUrl,
onSelectServiceUrl,
onOpenDialog,
minimal,
}: {
serviceUrl: string
onSelectServiceUrl: (provider: string) => void
onOpenDialog?: () => void
minimal?: boolean
}) {
const serverInputControl = useDialogControl()
const t = useTheme()
@@ -29,9 +31,7 @@ export function HostingProvider({
const onPressSelectService = React.useCallback(() => {
Keyboard.dismiss()
serverInputControl.open()
if (onOpenDialog) {
onOpenDialog()
}
onOpenDialog?.()
}, [onOpenDialog, serverInputControl])
return (
@@ -40,57 +40,80 @@ export function HostingProvider({
control={serverInputControl}
onSelect={onSelectServiceUrl}
/>
<Button
testID="selectServiceButton"
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Press to change hosting provider`)}
variant="solid"
color="secondary"
style={[
a.w_full,
a.flex_row,
a.align_center,
a.rounded_sm,
a.px_md,
a.pr_sm,
a.gap_xs,
{paddingVertical: isAndroid ? 14 : 9},
]}
onPress={onPressSelectService}>
{({hovered, pressed}) => {
const interacted = hovered || pressed
return (
<>
<View style={a.pr_xs}>
<Globe
size="md"
fill={
interacted ? t.palette.contrast_800 : t.palette.contrast_500
}
/>
</View>
<Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text>
<View
style={[
a.rounded_sm,
interacted
? t.atoms.bg_contrast_300
: t.atoms.bg_contrast_100,
{marginLeft: 'auto', padding: 6},
]}>
<Pencil
size="sm"
style={{
color: interacted
? t.palette.contrast_800
: t.palette.contrast_500,
}}
/>
</View>
</>
)
}}
</Button>
{minimal ? (
<View style={[a.flex_row, a.align_center, a.flex_wrap]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
You are creating an account on{' '}
</Text>
<Button
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Press to change hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
color="secondary"
size="tiny"
style={[a.px_xs, {marginLeft: tokens.space.xs * -1}]}>
<ButtonText style={[a.text_sm]}>
{toNiceDomain(serviceUrl)}
</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</View>
) : (
<Button
testID="selectServiceButton"
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Press to change hosting provider`)}
variant="solid"
color="secondary"
style={[
a.w_full,
a.flex_row,
a.align_center,
a.rounded_sm,
a.pl_md,
a.pr_sm,
a.gap_xs,
{paddingVertical: isAndroid ? 14 : 8},
]}
onPress={onPressSelectService}>
{({hovered, pressed}) => {
const interacted = hovered || pressed
return (
<>
<View style={a.pr_xs}>
<GlobeIcon
size="md"
fill={
interacted
? t.palette.contrast_800
: t.palette.contrast_500
}
/>
</View>
<Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text>
<View
style={[
a.rounded_sm,
interacted
? t.atoms.bg_contrast_300
: t.atoms.bg_contrast_100,
{marginLeft: 'auto', padding: 6},
]}>
<PencilIcon
size="sm"
style={{
color: interacted
? t.palette.contrast_800
: t.palette.contrast_500,
}}
/>
</View>
</>
)
}}
</Button>
)}
</>
)
}
+6
View File
@@ -11,10 +11,12 @@ import React from 'react'
import {formatDistance, Locale} from 'date-fns'
import {
ca,
da,
de,
el,
enGB,
es,
eu,
fi,
fr,
gl,
@@ -30,6 +32,7 @@ import {
ptBR,
ro,
ru,
sv,
th,
tr,
uk,
@@ -50,10 +53,12 @@ const locales: Record<AppLanguage, Locale | undefined> = {
an: undefined,
ast: undefined,
ca,
da,
de,
el,
['en-GB']: enGB,
es,
eu,
fi,
fr,
ga: undefined,
@@ -71,6 +76,7 @@ const locales: Record<AppLanguage, Locale | undefined> = {
['pt-BR']: ptBR,
ro,
ru,
sv,
th,
tr,
uk,
+1 -1
View File
@@ -1,5 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Ticket_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z',
path: 'M3 4a1 1 0 0 0-1 1v4.17a1 1 0 0 0 .667.944 2.001 2.001 0 0 1 0 3.772A1 1 0 0 0 2 14.83V19a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-4.17a1 1 0 0 0-.667-.944 2.001 2.001 0 0 1 0-3.772A1 1 0 0 0 22 9.17V5a1 1 0 0 0-1-1H3Zm1 4.535V6h16v2.535A4 4 0 0 0 18 12c0 1.482.805 2.773 2 3.465V18H4v-2.535A4 4 0 0 0 6 12a4 4 0 0 0-2-3.465ZM15 15a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-1-3a1 1 0 1 1 2 0 1 1 0 0 1-2 0Zm1-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z',
})
+82 -76
View File
@@ -1,6 +1,5 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {ScrollView, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -12,6 +11,7 @@ import {
import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
import {useTrendingConfig} from '#/state/trending-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -42,81 +42,87 @@ export function Inner() {
return error || noTopics ? null : (
<View style={[t.atoms.border_contrast_low, a.border_t]}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast">
<View style={[gutters, a.flex_row, a.align_center, a.gap_lg]}>
<View style={{paddingLeft: 4, paddingRight: 2}}>
<Graph size="sm" />
</View>
{isLoading ? (
<View style={[a.py_lg, a.flex_row, a.gap_lg, a.align_center]}>
<LoadingPlaceholder
width={80}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={50}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={120}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={30}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={180}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<Text
style={[t.atoms.text_contrast_medium, a.text_sm, a.font_bold]}>
{' '}
</Text>
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast">
<View style={[gutters, a.flex_row, a.align_center, a.gap_lg]}>
<View style={{paddingLeft: 4, paddingRight: 2}}>
<Graph size="sm" />
</View>
) : !trending?.topics ? null : (
<>
{trending.topics.map(topic => (
<TrendingTopicLink
key={topic.link}
topic={topic}
onPress={() => {
logEvent('trendingTopic:click', {context: 'interstitial'})
}}>
<View style={[a.py_lg]}>
<Text
style={[
t.atoms.text,
a.text_sm,
a.font_bold,
{opacity: 0.7}, // NOTE: we use opacity 0.7 instead of a color to match the color of the home pager tab bar
]}>
{topic.topic}
</Text>
</View>
</TrendingTopicLink>
))}
<Button
label={_(msg`Hide trending topics`)}
size="tiny"
variant="ghost"
color="secondary"
shape="round"
onPress={() => trendingPrompt.open()}>
<ButtonIcon icon={X} />
</Button>
</>
)}
</View>
</ScrollView>
{isLoading ? (
<View style={[a.py_lg, a.flex_row, a.gap_lg, a.align_center]}>
<LoadingPlaceholder
width={80}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={50}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={120}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={30}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<LoadingPlaceholder
width={180}
height={undefined}
style={{alignSelf: 'stretch'}}
/>
<Text
style={[
t.atoms.text_contrast_medium,
a.text_sm,
a.font_bold,
]}>
{' '}
</Text>
</View>
) : !trending?.topics ? null : (
<>
{trending.topics.map(topic => (
<TrendingTopicLink
key={topic.link}
topic={topic}
onPress={() => {
logEvent('trendingTopic:click', {context: 'interstitial'})
}}>
<View style={[a.py_lg]}>
<Text
style={[
t.atoms.text,
a.text_sm,
a.font_bold,
{opacity: 0.7}, // NOTE: we use opacity 0.7 instead of a color to match the color of the home pager tab bar
]}>
{topic.topic}
</Text>
</View>
</TrendingTopicLink>
))}
<Button
label={_(msg`Hide trending topics`)}
size="tiny"
variant="ghost"
color="secondary"
shape="round"
onPress={() => trendingPrompt.open()}>
<ButtonIcon icon={X} />
</Button>
</>
)}
</View>
</ScrollView>
</BlockDrawerGesture>
<Prompt.Basic
control={trendingPrompt}
@@ -0,0 +1,231 @@
import React, {useEffect} from 'react'
import {ScrollView, View} from 'react-native'
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URI} from '#/lib/constants'
import {makeCustomFeedLink} from '#/lib/routes/links'
import {logEvent} from '#/lib/statsig/statsig'
import {useTrendingSettingsApi} from '#/state/preferences/trending'
import {usePostFeedQuery} from '#/state/queries/post-feed'
import {RQKEY} from '#/state/queries/post-feed'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {
CompactVideoPostCard,
CompactVideoPostCardPlaceholder,
} from '#/components/VideoPostCard'
const CARD_WIDTH = 100
const FEED_DESC = `feedgen|${VIDEO_FEED_URI}`
const FEED_PARAMS: {
feedCacheKey: 'discover'
} = {
feedCacheKey: 'discover',
}
export function TrendingVideos() {
const t = useTheme()
const {_} = useLingui()
const gutters = useGutters([0, 'base'])
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
// Refetch on unmount if nothing else is using this query.
const queryClient = useQueryClient()
useEffect(() => {
return () => {
const query = queryClient
.getQueryCache()
.find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
if (query && query.getObserversCount() <= 1) {
query.fetch()
}
}
}, [queryClient])
const {setTrendingVideoDisabled} = useTrendingSettingsApi()
const trendingPrompt = Prompt.usePromptControl()
const onConfirmHide = React.useCallback(() => {
setTrendingVideoDisabled(true)
logEvent('trendingVideos:hide', {context: 'interstitial:discover'})
}, [setTrendingVideoDisabled])
if (error) {
return null
}
return (
<View
style={[
a.pt_lg,
a.pb_lg,
a.border_t,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}>
<View
style={[
gutters,
a.pb_sm,
a.flex_row,
a.align_center,
a.justify_between,
]}>
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_xs]}>
<Graph />
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
<Trans>Trending Videos</Trans>
</Text>
</View>
<Button
label={_(msg`Dismiss this section`)}
size="tiny"
variant="ghost"
color="secondary"
shape="round"
onPress={() => trendingPrompt.open()}>
<ButtonIcon icon={X} />
</Button>
</View>
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToInterval={CARD_WIDTH + a.gap_sm.gap}>
<View
style={[
a.flex_row,
a.gap_sm,
{
paddingLeft: gutters.paddingLeft,
paddingRight: gutters.paddingRight,
},
]}>
{isLoading ? (
Array(10)
.fill(0)
.map((_, i) => (
<View key={i} style={[{width: CARD_WIDTH}]}>
<CompactVideoPostCardPlaceholder />
</View>
))
) : error || !data ? (
<Text>
<Trans>Whoops! Trending videos failed to load.</Trans>
</Text>
) : (
<VideoCards data={data} />
)}
</View>
</ScrollView>
</BlockDrawerGesture>
<Prompt.Basic
control={trendingPrompt}
title={_(msg`Hide trending videos?`)}
description={_(msg`You can update this later from your settings.`)}
confirmButtonCta={_(msg`Hide`)}
onConfirm={onConfirmHide}
/>
</View>
)
}
function VideoCards({
data,
}: {
data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
}) {
const t = useTheme()
const {_} = useLingui()
const items = React.useMemo(() => {
return data.pages
.flatMap(page => page.slices)
.map(slice => slice.items[0])
.filter(Boolean)
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
.slice(0, 8)
}, [data])
const href = React.useMemo(() => {
const urip = new AtUri(VIDEO_FEED_URI)
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover')
}, [])
return (
<>
{items.map(item => (
<View key={item.post.uri} style={[{width: CARD_WIDTH}]}>
<CompactVideoPostCard
post={item.post}
moderation={item.moderation}
sourceContext={{
type: 'feedgen',
uri: VIDEO_FEED_URI,
sourceInterstitial: 'discover',
}}
onInteract={() => {
logEvent('videoCard:click', {
context: 'interstitial:discover',
})
}}
/>
</View>
))}
<View style={[{width: CARD_WIDTH * 2}]}>
<Link
to={href}
label={_(msg`View more`)}
style={[
a.justify_center,
a.align_center,
a.flex_1,
a.rounded_md,
t.atoms.bg,
]}>
{({pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.gap_md,
{
opacity: pressed ? 0.6 : 1,
},
]}>
<Text style={[a.text_md]}>
<Trans>View more</Trans>
</Text>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
{
width: 34,
height: 34,
backgroundColor: t.palette.primary_500,
},
]}>
<ButtonIcon icon={ChevronRight} />
</View>
</View>
)}
</Link>
</View>
</>
)
}
+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
@@ -194,7 +194,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,
+2 -2
View File
@@ -8,8 +8,8 @@ export function getDataUriSize(uri: string): number {
return Math.round((uri.length * 3) / 4)
}
export function isUriImage(uri: string) {
return /\.(jpg|jpeg|png).*$/.test(uri)
export function isUriImage(uri: string): boolean {
return /\.(jpg|jpeg|png|webp).*$/.test(uri)
}
export function blobToDataUri(blob: Blob): Promise<string> {
+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 & {
+41 -6
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,27 @@ export type LogEvents = {
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
}
'suggestedUser:follow': {
logContext:
| 'Explore'
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'Profile'
location: 'Card' | 'Profile'
recId?: number
position: number
}
'suggestedUser:press': {
logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile'
recId?: number
position: number
}
'suggestedUser:seen': {
logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile'
recId?: number
position: number
}
'profile:unfollow': {
logContext:
@@ -179,6 +205,7 @@ export type LogEvents = {
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -218,7 +245,6 @@ export type LogEvents = {
domain: string
}
'feed:interstitial:profileCard:press': {}
'feed:interstitial:feedCard:press': {}
'profile:header:suggestedFollowsCard:press': {}
@@ -249,6 +275,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': {}
+3
View File
@@ -3,5 +3,8 @@ export type Gate =
| 'debug_show_feedcontext'
| 'debug_subscriptions'
| 'new_postonboarding'
| 'onboarding_add_video_feed'
| 'remove_show_latest_button'
| 'test_gate_1'
| 'test_gate_2'
| 'trending_topics_beta'
+3 -10
View File
@@ -1,12 +1,10 @@
import React from 'react'
import {Platform} from 'react-native'
import {AppState, AppStateStatus} from 'react-native'
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 +106,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})
@@ -191,13 +188,9 @@ export function useDangerousSetGate(): (
}
function toStatsigUser(did: string | undefined): StatsigUser {
let userID: string | undefined
if (did) {
userID = sha256(did)
}
const languagePrefs = persisted.get('languagePrefs')
return {
userID,
userID: did,
platform: Platform.OS as 'ios' | 'android' | 'web',
custom: {
refSrc,
-52
View File
@@ -1,52 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
export async function loadString(key: string): Promise<string | null> {
try {
return await AsyncStorage.getItem(key)
} catch {
// not sure why this would fail... even reading the RN docs I'm unclear
return null
}
}
export async function saveString(key: string, value: string): Promise<boolean> {
try {
await AsyncStorage.setItem(key, value)
return true
} catch {
return false
}
}
export async function load(key: string): Promise<any | null> {
try {
const str = await AsyncStorage.getItem(key)
if (typeof str !== 'string') {
return null
}
return JSON.parse(str)
} catch {
return null
}
}
export async function save(key: string, value: any): Promise<boolean> {
try {
await AsyncStorage.setItem(key, JSON.stringify(value))
return true
} catch {
return false
}
}
export async function remove(key: string): Promise<void> {
try {
await AsyncStorage.removeItem(key)
} catch {}
}
export async function clear(): Promise<void> {
try {
await AsyncStorage.clear()
} catch {}
}
+10 -2
View File
@@ -19,6 +19,10 @@ export function createFullHandle(name: string, domain: string): string {
return `${name}.${domain}`
}
export function maxServiceHandleLength(domain: string): number {
return 30 - `.${(domain || '').replace(/^[.]+/, '')}`.length
}
export function isInvalidHandle(handle: string): boolean {
return handle === 'handle.invalid'
}
@@ -38,7 +42,11 @@ export interface IsValidHandle {
}
// More checks from https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/handle/index.ts#L72
export function validateHandle(str: string, userDomain: string): IsValidHandle {
export function validateHandle(
str: string,
userDomain: string,
isServiceHandle?: boolean,
): IsValidHandle {
const fullHandle = createFullHandle(str, userDomain)
const results = {
@@ -46,7 +54,7 @@ export function validateHandle(str: string, userDomain: string): IsValidHandle {
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'),
frontLength: str.length >= 3,
totalLength: fullHandle.length <= 253,
totalLength: fullHandle.length <= (isServiceHandle ? 30 : 253),
}
return {
-3
View File
@@ -1,3 +0,0 @@
export const getKeys = Object.keys as <T extends object>(
obj: T,
) => Array<keyof T>
+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'},
+2 -2
View File
@@ -3497,7 +3497,7 @@ msgstr "Rechistro de publicación invalido u no compatible"
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgid "Incorrect username or password"
msgstr "Nombre d'usuario u clau no validos"
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
@@ -6417,7 +6417,7 @@ msgstr "Salir?"
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgid "Create account"
msgstr "Crear cuenta"
#: src/components/moderation/ScreenHider.tsx:91
+2 -2
View File
@@ -3694,7 +3694,7 @@ msgstr ""
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgid "Incorrect username or password"
msgstr ""
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
@@ -6861,7 +6861,7 @@ msgstr "¿Quies zarrar la sesión?"
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgid "Create account"
msgstr "Rexistrase"
#: src/components/moderation/ScreenHider.tsx:91
+7 -7
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
@@ -4457,7 +4457,7 @@ msgstr "Registre de publicació no vàlid o no admès"
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgid "Incorrect username or password"
msgstr "Nom d'usuari o contrasenya incorrectes"
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
@@ -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"
@@ -8301,7 +8301,7 @@ msgstr "Tancar la sessió?"
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgid "Create account"
msgstr "Registra't"
#: src/view/shell/NavSignupCard.tsx:47
@@ -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
+2 -2
View File
@@ -3931,7 +3931,7 @@ msgstr "Ungültiger oder nicht unterstützter Beitragrekord"
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgid "Incorrect username or password"
msgstr "Ungültiger Benutzername oder Passwort"
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
@@ -7634,7 +7634,7 @@ msgstr ""
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgid "Create account"
msgstr "Registrieren"
#: src/view/shell/NavSignupCard.tsx:47
+2 -2
View File
@@ -3499,7 +3499,7 @@ msgstr "Μη έγκυρη ή μη υποστηριζόμενη εγγραφή α
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgid "Incorrect username or password"
msgstr "Μη έγκυρο όνομα χρήστη ή κωδικός πρόσβασης"
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
@@ -6430,7 +6430,7 @@ msgstr "Αποσύνδεση;"
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgid "Create account"
msgstr "Εγγραφή"
#: src/components/moderation/ScreenHider.tsx:91
+2 -2
View File
@@ -3694,7 +3694,7 @@ msgstr ""
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgid "Incorrect username or password"
msgstr ""
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
@@ -6861,7 +6861,7 @@ msgstr ""
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgid "Create account"
msgstr ""
#: src/components/moderation/ScreenHider.tsx:91
+2 -2
View File
@@ -3694,7 +3694,7 @@ msgstr ""
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgid "Incorrect username or password"
msgstr ""
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
@@ -6861,7 +6861,7 @@ msgstr ""
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgid "Create account"
msgstr ""
#: src/components/moderation/ScreenHider.tsx:91
+117 -117
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"
@@ -4085,7 +4085,7 @@ msgstr "Registro de publicación inválido o no compatible"
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgid "Incorrect username or password"
msgstr "Nombre de usuario o contraseña no válidos"
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
@@ -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"
@@ -7569,8 +7569,8 @@ msgstr "¿Cerrar sesión?"
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgstr "Crear cuenta"
msgid "Create account"
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
+10 -10
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
@@ -3409,8 +3409,8 @@ msgstr "Virheellinen tai ei-tuettu julkaisutietue"
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgstr "Virheellinen käyttäjätunnus tai salasana"
msgid "Incorrect username or password"
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"
@@ -6282,8 +6282,8 @@ msgstr "Kirjaudutaanko ulos?"
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgstr "Rekisteröidy"
msgid "Create account"
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
+2 -2
View File
@@ -4085,7 +4085,7 @@ msgstr "Rexistro de chío inválido ou non compatíbel"
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgid "Incorrect username or password"
msgstr "Alcume ou contrasinal incorrectos"
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
@@ -7574,7 +7574,7 @@ msgstr ""
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgid "Create account"
msgstr "Crear conta"
#: src/view/shell/NavSignupCard.tsx:47
+2 -2
View File
@@ -3965,7 +3965,7 @@ msgstr "अमान्य या असमर्थित पोस्ट र
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgid "Incorrect username or password"
msgstr "अमान्य उपयोगकर्ता नाम या पासवर्ड"
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
@@ -7248,7 +7248,7 @@ msgstr "साइन आउट करें?"
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgid "Create account"
msgstr "साइन अप करें"
#: src/components/moderation/ScreenHider.tsx:91
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4196,7 +4196,7 @@ msgstr "Catatan postingan tidak valid atau tidak didukung"
#: src/screens/Login/LoginForm.tsx:90
#: src/screens/Login/LoginForm.tsx:149
msgid "Invalid username or password"
msgid "Incorrect username or password"
msgstr "Nama pengguna atau kata sandi salah"
#: src/components/intents/VerifyEmailIntentDialog.tsx:91
@@ -7717,7 +7717,7 @@ msgstr ""
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:205
#: src/view/shell/NavSignupCard.tsx:47
#: src/view/shell/NavSignupCard.tsx:52
msgid "Sign up"
msgid "Create account"
msgstr "Daftar"
#: src/view/shell/NavSignupCard.tsx:47
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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