Merge branch 'main' into Improve-notification-localization
This commit is contained in:
+42
-46
@@ -4,6 +4,7 @@ import 'view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
@@ -22,7 +22,6 @@ import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {useNotificationsListener} from 'lib/notifications/notifications'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {s} from 'lib/styles'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
@@ -49,6 +48,7 @@ import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
|
||||
@@ -96,27 +96,27 @@ function InnerApp() {
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PushNotificationsListener>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</PushNotificationsListener>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</RootSiblingParent>
|
||||
@@ -127,12 +127,6 @@ function InnerApp() {
|
||||
)
|
||||
}
|
||||
|
||||
function PushNotificationsListener({children}: {children: React.ReactNode}) {
|
||||
const queryClient = useQueryClient()
|
||||
useNotificationsListener(queryClient)
|
||||
return children
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
@@ -149,27 +143,29 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
<KeyboardProvider enabled={true} statusBarTranslucent={true}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</KeyboardProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+37
-31
@@ -2,6 +2,7 @@ import 'lib/sentry' // must be near top
|
||||
import 'view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
@@ -39,6 +40,7 @@ import {Shell} from 'view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
|
||||
@@ -77,37 +79,41 @@ function InnerApp() {
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
<ToastContainer />
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
<KeyboardProvider enabled={false}>
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
<ToastContainer />
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
</KeyboardProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+21
-17
@@ -304,7 +304,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
<Stack.Screen
|
||||
name="MessagesSettings"
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
options={{title: title(msg`Messaging settings`), requireAuth: true}}
|
||||
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -353,11 +353,11 @@ function HomeTabNavigator() {
|
||||
return (
|
||||
<HomeTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||
@@ -371,11 +371,11 @@ function SearchTabNavigator() {
|
||||
return (
|
||||
<SearchTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
|
||||
@@ -389,11 +389,11 @@ function FeedsTabNavigator() {
|
||||
return (
|
||||
<FeedsTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
|
||||
@@ -407,11 +407,11 @@ function NotificationsTabNavigator() {
|
||||
return (
|
||||
<NotificationsTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<NotificationsTab.Screen
|
||||
@@ -429,11 +429,11 @@ function MyProfileTabNavigator() {
|
||||
return (
|
||||
<MyProfileTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<MyProfileTab.Screen
|
||||
@@ -454,17 +454,20 @@ function MessagesTabNavigator() {
|
||||
return (
|
||||
<MessagesTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<MessagesTab.Screen
|
||||
name="Messages"
|
||||
getComponent={() => MessagesScreen}
|
||||
options={{requireAuth: true}}
|
||||
options={({route}) => ({
|
||||
requireAuth: true,
|
||||
animationTypeForReplace: route.params?.animation ?? 'push',
|
||||
})}
|
||||
/>
|
||||
{commonScreens(MessagesTab as typeof HomeTab)}
|
||||
</MessagesTab.Navigator>
|
||||
@@ -485,10 +488,11 @@ const FlatNavigator = () => {
|
||||
<Flat.Navigator
|
||||
screenListeners={screenListeners}
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<Flat.Screen
|
||||
@@ -608,7 +612,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
logEvent('router:navigate', {
|
||||
logEvent('router:navigate:sampled', {
|
||||
from: prevLoggedRouteName.current,
|
||||
})
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
@@ -617,7 +621,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
logModuleInitTime()
|
||||
onReady()
|
||||
logEvent('router:navigate', {})
|
||||
logEvent('router:navigate:sampled', {})
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
|
||||
+41
-6
@@ -1,7 +1,8 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {Platform, StyleSheet} from 'react-native'
|
||||
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import {native, web} from '#/alf/util/platform'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export const atoms = {
|
||||
/*
|
||||
@@ -55,6 +56,13 @@ export const atoms = {
|
||||
height: '100vh',
|
||||
}),
|
||||
|
||||
/*
|
||||
* Theme-independent bg colors
|
||||
*/
|
||||
bg_transparent: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
|
||||
/*
|
||||
* Border radius
|
||||
*/
|
||||
@@ -270,19 +278,19 @@ export const atoms = {
|
||||
borderWidth: 0,
|
||||
},
|
||||
border: {
|
||||
borderWidth: 1,
|
||||
borderWidth: hairlineWidth,
|
||||
},
|
||||
border_t: {
|
||||
borderTopWidth: 1,
|
||||
borderTopWidth: hairlineWidth,
|
||||
},
|
||||
border_b: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomWidth: hairlineWidth,
|
||||
},
|
||||
border_l: {
|
||||
borderLeftWidth: 1,
|
||||
borderLeftWidth: hairlineWidth,
|
||||
},
|
||||
border_r: {
|
||||
borderRightWidth: 1,
|
||||
borderRightWidth: hairlineWidth,
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -840,4 +848,31 @@ export const atoms = {
|
||||
mr_auto: {
|
||||
marginRight: 'auto',
|
||||
},
|
||||
/*
|
||||
* Pointer events & user select
|
||||
*/
|
||||
pointer_events_none: {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
pointer_events_auto: {
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
user_select_none: {
|
||||
userSelect: 'none',
|
||||
},
|
||||
user_select_text: {
|
||||
userSelect: 'text',
|
||||
},
|
||||
user_select_all: {
|
||||
userSelect: 'all',
|
||||
},
|
||||
/*
|
||||
* Text decoration
|
||||
*/
|
||||
underline: {
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
strike_through: {
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
} as const
|
||||
|
||||
+5
-3
@@ -1,12 +1,14 @@
|
||||
import React from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
|
||||
import * as themes from '#/alf/themes'
|
||||
|
||||
export * from '#/alf/types'
|
||||
export * as tokens from '#/alf/tokens'
|
||||
export {atoms} from '#/alf/atoms'
|
||||
export * from '#/alf/util/platform'
|
||||
export * as tokens from '#/alf/tokens'
|
||||
export * from '#/alf/types'
|
||||
export * from '#/alf/util/flatten'
|
||||
export * from '#/alf/util/platform'
|
||||
export * from '#/alf/util/themeSelector'
|
||||
|
||||
type BreakpointName = keyof typeof breakpoints
|
||||
|
||||
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
import {atoms} from '#/alf/atoms'
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import type {Mutable} from '#/alf/types'
|
||||
import {atoms} from '#/alf/atoms'
|
||||
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
|
||||
|
||||
export type ThemeName = 'light' | 'dim' | 'dark'
|
||||
export type ReadonlyTheme = typeof light
|
||||
export type Theme = Mutable<ReadonlyTheme>
|
||||
export type Theme = Mutable<ReadonlyTheme> & {name: ThemeName}
|
||||
export type ReadonlyPalette = typeof lightPalette
|
||||
export type Palette = Mutable<ReadonlyPalette>
|
||||
|
||||
@@ -193,7 +193,7 @@ export const dimPalette: Palette = {
|
||||
} as const
|
||||
|
||||
export const light = {
|
||||
name: 'light',
|
||||
name: 'light' as ThemeName,
|
||||
palette: lightPalette,
|
||||
atoms: {
|
||||
text: {
|
||||
@@ -278,7 +278,7 @@ export const light = {
|
||||
}
|
||||
|
||||
export const dark: Theme = {
|
||||
name: 'dark',
|
||||
name: 'dark' as ThemeName,
|
||||
palette: darkPalette,
|
||||
atoms: {
|
||||
text: {
|
||||
@@ -367,7 +367,7 @@ export const dark: Theme = {
|
||||
|
||||
export const dim: Theme = {
|
||||
...dark,
|
||||
name: 'dim',
|
||||
name: 'dim' as ThemeName,
|
||||
palette: dimPalette,
|
||||
atoms: {
|
||||
...dark.atoms,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {ThemeName} from '#/alf/themes'
|
||||
|
||||
export function select<T>(name: ThemeName, options: Record<ThemeName, T>) {
|
||||
switch (name) {
|
||||
case 'light':
|
||||
return options.light
|
||||
case 'dark':
|
||||
return options.dark || options.dim
|
||||
case 'dim':
|
||||
return options.dim || options.dark
|
||||
default:
|
||||
throw new Error(`select(theme, options) received unknown theme ${name}`)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
import React from 'react'
|
||||
import {ColorSchemeName, useColorScheme} from 'react-native'
|
||||
|
||||
import {useThemePrefs} from 'state/shell'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ThemeName, light, dark, dim} from '#/alf/themes'
|
||||
import * as SystemUI from 'expo-system-ui'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useThemePrefs} from 'state/shell'
|
||||
import {dark, dim, light, ThemeName} from '#/alf/themes'
|
||||
|
||||
export function useColorModeTheme(): ThemeName {
|
||||
const theme = useThemeName()
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateDocument(theme)
|
||||
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
|
||||
}, [theme])
|
||||
|
||||
return theme
|
||||
}
|
||||
|
||||
export function useThemeName(): ThemeName {
|
||||
const colorScheme = useColorScheme()
|
||||
const {colorMode, darkTheme} = useThemePrefs()
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const theme = getThemeName(colorScheme, colorMode, darkTheme)
|
||||
updateDocument(theme)
|
||||
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
|
||||
}, [colorMode, colorScheme, darkTheme])
|
||||
|
||||
return React.useMemo(
|
||||
() => getThemeName(colorScheme, colorMode, darkTheme),
|
||||
[colorScheme, colorMode, darkTheme],
|
||||
)
|
||||
return getThemeName(colorScheme, colorMode, darkTheme)
|
||||
}
|
||||
|
||||
function getThemeName(
|
||||
@@ -53,7 +55,7 @@ function updateDocument(theme: ThemeName) {
|
||||
}
|
||||
}
|
||||
|
||||
function getBackgroundColor(theme: ThemeName): string {
|
||||
export function getBackgroundColor(theme: ThemeName): string {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return light.atoms.bg.backgroundColor
|
||||
|
||||
@@ -37,7 +37,7 @@ export function AccountList({
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
{borderWidth: 1},
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{accounts.map(account => (
|
||||
@@ -48,7 +48,7 @@ export function AccountList({
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
isPendingAccount={account.did === pendingDid}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
<View style={[{borderBottomWidth: 1}, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Button
|
||||
|
||||
@@ -28,7 +28,7 @@ export type ButtonColor =
|
||||
| 'gradient_sunset'
|
||||
| 'gradient_nordic'
|
||||
| 'gradient_bonfire'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
|
||||
export type ButtonSize = 'tiny' | 'xsmall' | 'small' | 'medium' | 'large'
|
||||
export type ButtonShape = 'round' | 'square' | 'default'
|
||||
export type VariantProps = {
|
||||
/**
|
||||
@@ -64,13 +64,14 @@ type NonTextElements =
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID' | 'onLongPress'
|
||||
'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop'
|
||||
> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
testID?: string
|
||||
label: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
hoverStyle?: StyleProp<ViewStyle>
|
||||
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
|
||||
}
|
||||
|
||||
@@ -96,6 +97,7 @@ export function Button({
|
||||
label,
|
||||
disabled = false,
|
||||
style,
|
||||
hoverStyle: hoverStyleProp,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const t = useTheme()
|
||||
@@ -281,6 +283,8 @@ export function Button({
|
||||
baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
|
||||
} else if (size === 'xsmall') {
|
||||
baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm)
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
|
||||
}
|
||||
@@ -292,7 +296,9 @@ export function Button({
|
||||
baseStyles.push({height: 50, width: 50})
|
||||
}
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({height: 40, width: 40})
|
||||
baseStyles.push({height: 34, width: 34})
|
||||
} else if (size === 'xsmall') {
|
||||
baseStyles.push({height: 28, width: 28})
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push({height: 20, width: 20})
|
||||
}
|
||||
@@ -374,8 +380,10 @@ export function Button({
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
flattenedBaseStyles,
|
||||
...(state.hovered || state.pressed ? hoverStyles : []),
|
||||
flatten(style),
|
||||
...(state.hovered || state.pressed
|
||||
? [hoverStyles, flatten(hoverStyleProp)]
|
||||
: []),
|
||||
]}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {Dimensions, Pressable, View} from 'react-native'
|
||||
import {
|
||||
Dimensions,
|
||||
Keyboard,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import BottomSheet, {
|
||||
@@ -25,6 +32,7 @@ import {
|
||||
DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
@@ -163,46 +171,49 @@ export function Outer({
|
||||
return (
|
||||
isOpen && (
|
||||
<Portal>
|
||||
<View
|
||||
// iOS
|
||||
accessibilityViewIsModal
|
||||
// Android
|
||||
importantForAccessibility="yes"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
testID={testID}>
|
||||
<BottomSheet
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
topInset={insets.top}
|
||||
{...sheetOptions}
|
||||
snapPoints={sheetOptions.snapPoints || ['100%']}
|
||||
ref={sheet}
|
||||
index={openIndex}
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
backdropComponent={Backdrop}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onClose={onCloseAnimationComplete}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
</View>
|
||||
<FullWindowOverlay>
|
||||
<View
|
||||
// iOS
|
||||
accessibilityViewIsModal
|
||||
// Android
|
||||
importantForAccessibility="yes"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
testID={testID}
|
||||
onTouchMove={() => Keyboard.dismiss()}>
|
||||
<BottomSheet
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
topInset={insets.top}
|
||||
{...sheetOptions}
|
||||
snapPoints={sheetOptions.snapPoints || ['100%']}
|
||||
ref={sheet}
|
||||
index={openIndex}
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
backdropComponent={Backdrop}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onClose={onCloseAnimationComplete}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
</View>
|
||||
</FullWindowOverlay>
|
||||
</Portal>
|
||||
)
|
||||
)
|
||||
@@ -213,7 +224,8 @@ export function Inner({children, style}: DialogInnerProps) {
|
||||
return (
|
||||
<BottomSheetView
|
||||
style={[
|
||||
a.p_xl,
|
||||
a.py_xl,
|
||||
a.px_xl,
|
||||
{
|
||||
paddingTop: 40,
|
||||
borderTopLeftRadius: 40,
|
||||
@@ -256,9 +268,10 @@ export const ScrollableInner = React.forwardRef<
|
||||
|
||||
export const InnerFlatList = React.forwardRef<
|
||||
BottomSheetFlatListMethods,
|
||||
BottomSheetFlatListProps<any>
|
||||
BottomSheetFlatListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
|
||||
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<BottomSheetFlatList
|
||||
keyboardShouldPersistTaps="handled"
|
||||
|
||||
@@ -2,8 +2,10 @@ import React, {useImperativeHandle} from 'react'
|
||||
import {
|
||||
FlatList,
|
||||
FlatListProps,
|
||||
StyleProp,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
@@ -199,18 +201,21 @@ export const ScrollableInner = Inner
|
||||
|
||||
export const InnerFlatList = React.forwardRef<
|
||||
FlatList,
|
||||
FlatListProps<any> & {label: string}
|
||||
>(function InnerFlatList({label, style, ...props}, ref) {
|
||||
FlatListProps<any> & {label: string} & {webInnerStyle?: StyleProp<ViewStyle>}
|
||||
>(function InnerFlatList({label, style, webInnerStyle, ...props}, ref) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<Inner
|
||||
label={label}
|
||||
// @ts-ignore web only -sfn
|
||||
style={{
|
||||
paddingHorizontal: 0,
|
||||
maxHeight: 'calc(-36px + 100vh)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
style={[
|
||||
// @ts-ignore web only -sfn
|
||||
{
|
||||
paddingHorizontal: 0,
|
||||
maxHeight: 'calc(-36px + 100vh)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
webInnerStyle,
|
||||
]}>
|
||||
<FlatList
|
||||
ref={ref}
|
||||
style={[gtMobile ? a.px_2xl : a.px_xl, flatten(style)]}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {FullWindowOverlay} from 'react-native-screens'
|
||||
@@ -0,0 +1 @@
|
||||
export {Fragment as FullWindowOverlay} from 'react'
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import {useKeyboardHandler} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
export function KeyboardPadding({maxHeight}: {maxHeight?: number}) {
|
||||
const keyboardHeight = useSharedValue(0)
|
||||
|
||||
useKeyboardHandler(
|
||||
{
|
||||
onMove: e => {
|
||||
'worklet'
|
||||
|
||||
if (maxHeight && e.height > maxHeight) {
|
||||
keyboardHeight.value = maxHeight
|
||||
} else {
|
||||
keyboardHeight.value = e.height
|
||||
}
|
||||
},
|
||||
},
|
||||
[maxHeight],
|
||||
)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
height: keyboardHeight.value,
|
||||
}))
|
||||
|
||||
return <Animated.View style={animatedStyle} />
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function KeyboardPadding({maxHeight: _}: {maxHeight?: number}) {
|
||||
return null
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useLabelerInfoQuery} from '#/state/queries/labeler'
|
||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useLabelerInfoQuery} from '#/state/queries/labeler'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
|
||||
|
||||
type LabelingServiceProps = {
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
|
||||
@@ -13,16 +13,22 @@ import {Text} from '#/components/Typography'
|
||||
|
||||
export function ListFooter({
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
onRetry,
|
||||
height,
|
||||
style,
|
||||
showEndMessage = false,
|
||||
endMessageText,
|
||||
}: {
|
||||
isFetchingNextPage?: boolean
|
||||
hasNextPage?: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
height?: number
|
||||
style?: StyleProp<ViewStyle>
|
||||
showEndMessage?: boolean
|
||||
endMessageText?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -39,9 +45,13 @@ export function ListFooter({
|
||||
]}>
|
||||
{isFetchingNextPage ? (
|
||||
<Loader size="xl" />
|
||||
) : (
|
||||
) : 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>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -136,6 +146,7 @@ let ListMaybePlaceholder = ({
|
||||
onGoBack,
|
||||
hideBackButton,
|
||||
sideBorders,
|
||||
topBorder = true,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
noEmpty?: boolean
|
||||
@@ -149,6 +160,7 @@ let ListMaybePlaceholder = ({
|
||||
onGoBack?: () => void
|
||||
hideBackButton?: boolean
|
||||
sideBorders?: boolean
|
||||
topBorder?: boolean
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -165,7 +177,7 @@ let ListMaybePlaceholder = ({
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders={sideBorders ?? gtMobile}
|
||||
topBorder={!gtTablet}>
|
||||
topBorder={topBorder && !gtTablet}>
|
||||
<View style={[a.w_full, a.align_center, {top: 100}]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isTouchDevice} from 'lib/browser'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
@@ -43,10 +44,12 @@ const floatingMiddlewares = [
|
||||
}),
|
||||
]
|
||||
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||
|
||||
export function ProfileHoverCard(props: ProfileHoverCardProps) {
|
||||
return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} />
|
||||
if (props.disable || isTouchDevice) {
|
||||
return props.children
|
||||
} else {
|
||||
return <ProfileHoverCardInner {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
type State =
|
||||
@@ -285,14 +288,14 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<View
|
||||
// @ts-ignore View is being used as div
|
||||
ref={refs.setReference}
|
||||
onPointerMove={onPointerMoveTarget}
|
||||
onPointerLeave={onPointerLeaveTarget}
|
||||
// @ts-ignore web only prop
|
||||
onMouseUp={onPress}
|
||||
style={{
|
||||
display: props.inline ? 'inline' : 'block',
|
||||
}}>
|
||||
style={{flexShrink: 1}}>
|
||||
{props.children}
|
||||
{isVisible && (
|
||||
<Portal>
|
||||
@@ -307,7 +310,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ export type ProfileHoverCardProps = {
|
||||
children: React.ReactElement
|
||||
did: string
|
||||
inline?: boolean
|
||||
disable?: boolean
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ export function Basic({
|
||||
confirmButtonCta,
|
||||
onConfirm,
|
||||
confirmButtonColor,
|
||||
showCancel = true,
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
title: string
|
||||
@@ -187,6 +188,7 @@ export function Basic({
|
||||
*/
|
||||
onConfirm: () => void
|
||||
confirmButtonColor?: ButtonColor
|
||||
showCancel?: boolean
|
||||
}>) {
|
||||
return (
|
||||
<Outer control={control} testID="confirmModal">
|
||||
@@ -199,7 +201,7 @@ export function Basic({
|
||||
color={confirmButtonColor}
|
||||
testID="confirmBtn"
|
||||
/>
|
||||
<Cancel cta={cancelButtonCta} />
|
||||
{showCancel && <Cancel cta={cancelButtonCta} />}
|
||||
</Actions>
|
||||
</Outer>
|
||||
)
|
||||
|
||||
@@ -27,7 +27,8 @@ import {ReportDialogProps} from './types'
|
||||
|
||||
export function SelectReportOptionView({
|
||||
...props
|
||||
}: ReportDialogProps & {
|
||||
}: {
|
||||
params: ReportDialogProps['params']
|
||||
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||
onSelectReportOption: (reportOption: ReportOption) => void
|
||||
goBack: () => void
|
||||
@@ -54,6 +55,9 @@ export function SelectReportOptionView({
|
||||
} else if (props.params.type === 'feedgen') {
|
||||
title = _(msg`Report this feed`)
|
||||
description = _(msg`Why should this feed be reviewed?`)
|
||||
} else if (props.params.type === 'convoMessage') {
|
||||
title = _(msg`Report this message`)
|
||||
description = _(msg`Why should this message be reviewed?`)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -15,6 +15,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 {KeyboardPadding} from '#/components/KeyboardPadding'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ReportDialogProps} from './types'
|
||||
@@ -35,7 +36,7 @@ export function SubmitView({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {getAgent} = useAgent()
|
||||
const agent = useAgent()
|
||||
const [details, setDetails] = React.useState<string>('')
|
||||
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
||||
const [selectedServices, setSelectedServices] = React.useState<string[]>([
|
||||
@@ -61,7 +62,7 @@ export function SubmitView({
|
||||
}
|
||||
const results = await Promise.all(
|
||||
selectedServices.map(did =>
|
||||
getAgent()
|
||||
agent
|
||||
.withProxy('atproto_labeler', did)
|
||||
.createModerationReport(report)
|
||||
.then(
|
||||
@@ -91,7 +92,7 @@ export function SubmitView({
|
||||
selectedServices,
|
||||
onSubmitComplete,
|
||||
setError,
|
||||
getAgent,
|
||||
agent,
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -221,6 +222,7 @@ export function SubmitView({
|
||||
{submitting && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
<KeyboardPadding />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ export type ReportDialogProps = {
|
||||
type: 'account'
|
||||
did: string
|
||||
}
|
||||
| {type: 'convoMessage'}
|
||||
}
|
||||
|
||||
+32
-18
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -26,6 +27,8 @@ export function RichText({
|
||||
enableTags = false,
|
||||
authorHandle,
|
||||
onLinkPress,
|
||||
interactiveStyle,
|
||||
emojiMultiplier = 1.85,
|
||||
}: TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
@@ -35,28 +38,35 @@ export function RichText({
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
interactiveStyle?: TextStyle
|
||||
emojiMultiplier?: number
|
||||
}) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
[value],
|
||||
)
|
||||
const styles = [a.leading_snug, flatten(style)]
|
||||
|
||||
const flattenedStyle = flatten(style)
|
||||
const plainStyles = [a.leading_snug, flattenedStyle]
|
||||
const interactiveStyles = [
|
||||
a.leading_snug,
|
||||
a.pointer_events_auto,
|
||||
flatten(interactiveStyle),
|
||||
flattenedStyle,
|
||||
]
|
||||
|
||||
const {text, facets} = richText
|
||||
|
||||
if (!facets?.length) {
|
||||
if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
|
||||
if (isOnlyEmoji(text)) {
|
||||
const fontSize =
|
||||
(flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
|
||||
return (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={[
|
||||
{
|
||||
fontSize: 26,
|
||||
lineHeight: 30,
|
||||
},
|
||||
]}
|
||||
style={[plainStyles, {fontSize}]}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
{text}
|
||||
@@ -67,7 +77,7 @@ export function RichText({
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={styles}
|
||||
style={plainStyles}
|
||||
numberOfLines={numberOfLines}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
@@ -93,7 +103,7 @@ export function RichText({
|
||||
<InlineLinkText
|
||||
selectable={selectable}
|
||||
to={`/profile/${mention.did}`}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
style={interactiveStyles}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
onPress={onLinkPress}>
|
||||
@@ -110,7 +120,7 @@ export function RichText({
|
||||
selectable={selectable}
|
||||
key={key}
|
||||
to={link.uri}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
style={interactiveStyles}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
shareOnLongPress
|
||||
@@ -130,7 +140,7 @@ export function RichText({
|
||||
key={key}
|
||||
text={segment.text}
|
||||
tag={tag.tag}
|
||||
style={styles}
|
||||
style={interactiveStyles}
|
||||
selectable={selectable}
|
||||
authorHandle={authorHandle}
|
||||
/>,
|
||||
@@ -145,7 +155,7 @@ export function RichText({
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={styles}
|
||||
style={plainStyles}
|
||||
numberOfLines={numberOfLines}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
@@ -219,19 +229,16 @@ function RichTextTag({
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
pointerEvents: 'auto',
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
web({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
{color: t.palette.primary_500},
|
||||
(hovered || focused || pressed) && {
|
||||
...web({outline: 0}),
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.palette.primary_500,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{text}
|
||||
</Text>
|
||||
@@ -239,3 +246,10 @@ function RichTextTag({
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export function isOnlyEmoji(text: string) {
|
||||
return (
|
||||
text.length <= 15 &&
|
||||
/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+$/u.test(text)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {Modal, ScrollView, TextInput, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {
|
||||
Gif,
|
||||
useFeaturedGifsQuery,
|
||||
useGifSearchQuery,
|
||||
} from '#/state/queries/tenor'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {FlatList_INTERNAL} from '#/view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Button, ButtonText} from '../Button'
|
||||
import {Handle} from '../Dialog'
|
||||
import {useThrottledValue} from '../hooks/useThrottledValue'
|
||||
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
||||
import {GifPreview} from './GifSelect.shared'
|
||||
|
||||
export function GifSelectDialog({
|
||||
controlRef,
|
||||
onClose,
|
||||
onSelectGif: onSelectGifProp,
|
||||
}: {
|
||||
controlRef: React.RefObject<{open: () => void}>
|
||||
onClose: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useImperativeHandle(controlRef, () => ({
|
||||
open: () => setOpen(true),
|
||||
}))
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false)
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const onSelectGif = useCallback(
|
||||
(gif: Gif) => {
|
||||
onSelectGifProp(gif)
|
||||
close()
|
||||
},
|
||||
[onSelectGifProp, close],
|
||||
)
|
||||
|
||||
const renderErrorBoundary = useCallback(
|
||||
(error: any) => <ModalError details={String(error)} close={close} />,
|
||||
[close],
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
animationType="slide"
|
||||
presentationStyle="formSheet"
|
||||
onRequestClose={close}
|
||||
aria-modal
|
||||
accessibilityViewIsModal>
|
||||
<View style={[a.flex_1, t.atoms.bg]}>
|
||||
<Handle />
|
||||
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||
<GifList onSelectGif={onSelectGif} close={close} />
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function GifList({
|
||||
onSelectGif,
|
||||
}: {
|
||||
close: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
const listRef = useRef<FlatList_INTERNAL>(null)
|
||||
const [undeferredSearch, setSearch] = useState('')
|
||||
const search = useThrottledValue(undeferredSearch, 500)
|
||||
|
||||
const isSearching = search.length > 0
|
||||
|
||||
const trendingQuery = useFeaturedGifsQuery()
|
||||
const searchQuery = useGifSearchQuery(search)
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = isSearching ? searchQuery : trendingQuery
|
||||
|
||||
const flattenedData = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.results) || []
|
||||
}, [data])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item}: {item: Gif}) => {
|
||||
return <GifPreview gif={item} onSelectGif={onSelectGif} />
|
||||
},
|
||||
[onSelectGif],
|
||||
)
|
||||
|
||||
const onEndReached = React.useCallback(() => {
|
||||
if (isFetchingNextPage || !hasNextPage || error) return
|
||||
fetchNextPage()
|
||||
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
|
||||
|
||||
const hasData = flattenedData.length > 0
|
||||
|
||||
const onGoBack = useCallback(() => {
|
||||
if (isSearching) {
|
||||
// clear the input and reset the state
|
||||
textInputRef.current?.clear()
|
||||
setSearch('')
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}, [isSearching])
|
||||
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View style={[a.relative, a.mb_lg, a.pt_4xl, a.flex_row, a.align_center]}>
|
||||
{/* cover top corners */}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
t.atoms.bg,
|
||||
]}
|
||||
/>
|
||||
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Search} />
|
||||
<TextField.Input
|
||||
label={_(msg`Search GIFs`)}
|
||||
placeholder={_(msg`Search Tenor`)}
|
||||
onChangeText={text => {
|
||||
setSearch(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
inputRef={textInputRef}
|
||||
maxLength={50}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
)
|
||||
}, [t.atoms.bg, _])
|
||||
|
||||
return (
|
||||
<FlatList_INTERNAL
|
||||
ref={listRef}
|
||||
key={gtMobile ? '3 cols' : '2 cols'}
|
||||
data={flattenedData}
|
||||
renderItem={renderItem}
|
||||
numColumns={gtMobile ? 3 : 2}
|
||||
columnWrapperStyle={a.gap_sm}
|
||||
contentContainerStyle={a.px_lg}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{!hasData && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
onGoBack={onGoBack}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
topBorder={false}
|
||||
errorTitle={_(msg`Failed to load GIFs`)}
|
||||
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
|
||||
emptyMessage={
|
||||
isSearching
|
||||
? _(msg`No search results found for "${search}".`)
|
||||
: _(
|
||||
msg`No featured GIFs found. There may be an issue with Tenor.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
stickyHeaderIndices={[0]}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
keyExtractor={(item: Gif) => item.id}
|
||||
keyboardDismissMode="on-drag"
|
||||
ListFooterComponent={
|
||||
hasData ? (
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
style={{borderTopWidth: 0}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ModalError({details, close}: {details?: string; close: () => void}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[a.flex_1, a.gap_md]}
|
||||
centerContent
|
||||
contentContainerStyle={a.px_lg}>
|
||||
<ErrorScreen
|
||||
title={_(msg`Oh no!`)}
|
||||
message={_(
|
||||
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
||||
)}
|
||||
details={details}
|
||||
/>
|
||||
<Button
|
||||
label={_(msg`Close dialog`)}
|
||||
onPress={close}
|
||||
color="primary"
|
||||
size="medium"
|
||||
variant="solid">
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button} from '../Button'
|
||||
|
||||
export function GifPreview({
|
||||
gif,
|
||||
onSelectGif,
|
||||
}: {
|
||||
gif: Gif
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
logEvent('composer:gif:select', {})
|
||||
onSelectGif(gif)
|
||||
}, [onSelectGif, gif])
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={_(msg`Select GIF "${gif.title}"`)}
|
||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||
onPress={onPress}>
|
||||
{({pressed}) => (
|
||||
<Image
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_sm,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: gif.media_formats.tinygif.url,
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
accessibilityHint=""
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import React, {
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {
|
||||
@@ -23,16 +27,23 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
||||
import {GifPreview} from './GifSelect.shared'
|
||||
|
||||
export function GifSelectDialog({
|
||||
control,
|
||||
controlRef,
|
||||
onClose,
|
||||
onSelectGif: onSelectGifProp,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
controlRef: React.RefObject<{open: () => void}>
|
||||
onClose: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
useImperativeHandle(controlRef, () => ({
|
||||
open: () => control.open(),
|
||||
}))
|
||||
|
||||
const onSelectGif = useCallback(
|
||||
(gif: Gif) => {
|
||||
control.close(() => onSelectGifProp(gif))
|
||||
@@ -197,6 +208,7 @@ function GifList({
|
||||
onGoBack={onGoBack}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
topBorder={false}
|
||||
errorTitle={_(msg`Failed to load GIFs`)}
|
||||
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
|
||||
emptyMessage={
|
||||
@@ -232,50 +244,6 @@ function GifList({
|
||||
)
|
||||
}
|
||||
|
||||
function GifPreview({
|
||||
gif,
|
||||
onSelectGif,
|
||||
}: {
|
||||
gif: Gif
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
logEvent('composer:gif:select', {})
|
||||
onSelectGif(gif)
|
||||
}, [onSelectGif, gif])
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={_(msg`Select GIF "${gif.title}"`)}
|
||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||
onPress={onPress}>
|
||||
{({pressed}) => (
|
||||
<Image
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_sm,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: gif.media_formats.tinygif.url,
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
accessibilityHint=""
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogError({details}: {details?: string}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
@@ -28,6 +28,7 @@ import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Has
|
||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {KeyboardPadding} from '#/components/KeyboardPadding'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -256,6 +257,7 @@ function MutedWordsInner() {
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
<KeyboardPadding maxHeight={100} />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Keyboard, Pressable, View} from 'react-native'
|
||||
import React from 'react'
|
||||
import {Keyboard} from 'react-native'
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
runOnJS,
|
||||
@@ -7,15 +8,16 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
isFromSelf,
|
||||
@@ -25,59 +27,69 @@ export function ActionsWrapper({
|
||||
isFromSelf: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const playHaptic = useHaptics()
|
||||
const menuControl = useMenuControl()
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
const animationDidComplete = useSharedValue(false)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{scale: scale.value}],
|
||||
}))
|
||||
|
||||
// Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
|
||||
// function
|
||||
const open = useCallback(() => {
|
||||
const open = React.useCallback(() => {
|
||||
playHaptic()
|
||||
Keyboard.dismiss()
|
||||
menuControl.open()
|
||||
}, [menuControl])
|
||||
}, [menuControl, playHaptic])
|
||||
|
||||
const shrink = useCallback(() => {
|
||||
const shrink = React.useCallback(() => {
|
||||
'worklet'
|
||||
cancelAnimation(scale)
|
||||
scale.value = withTiming(1, {duration: 200}, () => {
|
||||
animationDidComplete.value = false
|
||||
})
|
||||
}, [animationDidComplete, scale])
|
||||
scale.value = withTiming(1, {duration: 200})
|
||||
}, [scale])
|
||||
|
||||
const grow = React.useCallback(() => {
|
||||
'worklet'
|
||||
scale.value = withTiming(1.05, {duration: 750}, finished => {
|
||||
if (!finished) return
|
||||
animationDidComplete.value = true
|
||||
runOnJS(playHaptic)()
|
||||
runOnJS(open)()
|
||||
const doubleTapGesture = Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.hitSlop(HITSLOP_10)
|
||||
.onEnd(open)
|
||||
|
||||
shrink()
|
||||
const pressAndHoldGesture = Gesture.LongPress()
|
||||
.onStart(() => {
|
||||
scale.value = withTiming(1.05, {duration: 200}, finished => {
|
||||
if (!finished) return
|
||||
runOnJS(open)()
|
||||
shrink()
|
||||
})
|
||||
})
|
||||
}, [scale, animationDidComplete, playHaptic, shrink, open])
|
||||
.onTouchesUp(shrink)
|
||||
.onTouchesMove(shrink)
|
||||
.cancelsTouchesInView(false)
|
||||
.runOnJS(true)
|
||||
|
||||
const composedGestures = Gesture.Exclusive(
|
||||
doubleTapGesture,
|
||||
pressAndHoldGesture,
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
maxWidth: '65%',
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
]}>
|
||||
<AnimatedPressable
|
||||
style={animatedStyle}
|
||||
unstable_pressDelay={200}
|
||||
onPressIn={grow}
|
||||
onTouchEnd={shrink}>
|
||||
<GestureDetector gesture={composedGestures}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
maxWidth: '80%',
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
animatedStyle,
|
||||
]}
|
||||
accessible={true}
|
||||
accessibilityActions={[
|
||||
{name: 'activate', label: _(msg`Open message options`)},
|
||||
]}
|
||||
onAccessibilityAction={open}>
|
||||
{children}
|
||||
</AnimatedPressable>
|
||||
<MessageMenu message={message} control={menuControl} hideTrigger={true} />
|
||||
</View>
|
||||
<MessageMenu message={message} control={menuControl} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
||||
@@ -57,15 +57,12 @@ export function ActionsWrapper({
|
||||
message={message}
|
||||
control={menuControl}
|
||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
||||
onTriggerPress={onMouseEnter}
|
||||
// @ts-expect-error web only
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
maxWidth: '65%',
|
||||
maxWidth: '80%',
|
||||
}}>
|
||||
{children}
|
||||
</View>
|
||||
@@ -75,9 +72,6 @@ export function ActionsWrapper({
|
||||
message={message}
|
||||
control={menuControl}
|
||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
||||
onTriggerPress={onMouseEnter}
|
||||
// @ts-expect-error web only
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ModerationCause} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {listUriToHref} from 'lib/strings/url-helpers'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function BlockedByListDialog({
|
||||
control,
|
||||
listBlocks,
|
||||
}: {
|
||||
control: DialogControlProps
|
||||
listBlocks: ModerationCause[]
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<Prompt.Outer control={control} testID="blockedByListDialog">
|
||||
<Prompt.TitleText>{_(msg`User blocked by list`)}</Prompt.TitleText>
|
||||
|
||||
<View style={[a.gap_sm, a.pb_lg]}>
|
||||
<Text
|
||||
selectable
|
||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
{_(
|
||||
msg`This account is blocked by one or more of your moderation lists. To unblock, please visit the lists directly and remove this user.`,
|
||||
)}{' '}
|
||||
</Text>
|
||||
|
||||
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
{_(msg`Lists blocking this user:`)}{' '}
|
||||
{listBlocks.map((block, i) =>
|
||||
block.source.type === 'list' ? (
|
||||
<React.Fragment key={block.source.list.uri}>
|
||||
{i === 0 ? null : ', '}
|
||||
<InlineLinkText
|
||||
to={listUriToHref(block.source.list.uri)}
|
||||
style={[a.text_md, a.leading_snug]}>
|
||||
{block.source.list.name}
|
||||
</InlineLinkText>
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action cta={_(msg`I understand`)} onPress={() => {}} />
|
||||
</Prompt.Actions>
|
||||
|
||||
<Dialog.Close />
|
||||
</Prompt.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ScaleAndFadeIn} from 'lib/custom-animations/ScaleAndFade'
|
||||
import {ShrinkAndPop} from 'lib/custom-animations/ShrinkAndPop'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
let lastIndex = 0
|
||||
|
||||
export function ChatEmptyPill() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const playHaptic = useHaptics()
|
||||
const [promptIndex, setPromptIndex] = React.useState(lastIndex)
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
const prompts = React.useMemo(() => {
|
||||
return [
|
||||
_(msg`Say hello!`),
|
||||
_(msg`Share your favorite feed!`),
|
||||
_(msg`Tell a joke!`),
|
||||
_(msg`Share a fun fact!`),
|
||||
_(msg`Share a cool story!`),
|
||||
_(msg`Send a neat website!`),
|
||||
_(msg`Clip 🐴 clop 🐴`),
|
||||
]
|
||||
}, [_])
|
||||
|
||||
const onPressIn = React.useCallback(() => {
|
||||
if (isWeb) return
|
||||
scale.value = withTiming(1.075, {duration: 100})
|
||||
}, [scale])
|
||||
|
||||
const onPressOut = React.useCallback(() => {
|
||||
if (isWeb) return
|
||||
scale.value = withTiming(1, {duration: 100})
|
||||
}, [scale])
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
runOnJS(playHaptic)()
|
||||
let randomPromptIndex = Math.floor(Math.random() * prompts.length)
|
||||
while (randomPromptIndex === lastIndex) {
|
||||
randomPromptIndex = Math.floor(Math.random() * prompts.length)
|
||||
}
|
||||
setPromptIndex(randomPromptIndex)
|
||||
lastIndex = randomPromptIndex
|
||||
}, [playHaptic, prompts.length])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{scale: scale.value}],
|
||||
}))
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.w_full,
|
||||
a.z_10,
|
||||
a.align_center,
|
||||
{
|
||||
top: -50,
|
||||
},
|
||||
]}>
|
||||
<AnimatedPressable
|
||||
style={[
|
||||
a.px_xl,
|
||||
a.py_md,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.align_center,
|
||||
animatedStyle,
|
||||
]}
|
||||
entering={ScaleAndFadeIn}
|
||||
exiting={ShrinkAndPop}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}>
|
||||
<Text style={[a.font_bold, a.pointer_events_none]} selectable={false}>
|
||||
{prompts[promptIndex]}
|
||||
</Text>
|
||||
</AnimatedPressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
+169
-118
@@ -1,159 +1,139 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Keyboard, Pressable} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {Keyboard, Pressable, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
ModerationCause,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {
|
||||
useMuteConvo,
|
||||
useUnmuteConvo,
|
||||
} from '#/state/queries/messages/mute-conversation'
|
||||
useConvoQuery,
|
||||
useMarkAsReadMutation,
|
||||
} from '#/state/queries/messages/conversation'
|
||||
import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
|
||||
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
|
||||
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
|
||||
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
|
||||
import {
|
||||
Person_Stroke2_Corner0_Rounded as Person,
|
||||
PersonCheck_Stroke2_Corner0_Rounded as PersonCheck,
|
||||
PersonX_Stroke2_Corner0_Rounded as PersonX,
|
||||
} from '#/components/icons/Person'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
|
||||
|
||||
let ConvoMenu = ({
|
||||
convo,
|
||||
convo: initialConvo,
|
||||
profile,
|
||||
onUpdateConvo,
|
||||
control,
|
||||
hideTrigger,
|
||||
currentScreen,
|
||||
showMarkAsRead,
|
||||
hideTrigger,
|
||||
blockInfo,
|
||||
style,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
onUpdateConvo?: (convo: ChatBskyConvoDefs.ConvoView) => void
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
control?: Menu.MenuControlProps
|
||||
hideTrigger?: boolean
|
||||
currentScreen: 'list' | 'conversation'
|
||||
showMarkAsRead?: boolean
|
||||
hideTrigger?: boolean
|
||||
blockInfo: {
|
||||
listBlocks: ModerationCause[]
|
||||
userBlock?: ModerationCause
|
||||
}
|
||||
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 {mutate: markAsRead} = useMarkAsReadMutation()
|
||||
|
||||
const {listBlocks, userBlock} = blockInfo
|
||||
const isBlocking = userBlock || !!listBlocks.length
|
||||
const isDeletedAccount = profile.handle === 'missing.invalid'
|
||||
|
||||
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(convo?.id, {
|
||||
onSuccess: data => {
|
||||
onUpdateConvo?.(data.convo)
|
||||
Toast.show(_(msg`Chat muted`))
|
||||
if (data.convo.muted) {
|
||||
Toast.show(_(msg`Chat muted`))
|
||||
} else {
|
||||
Toast.show(_(msg`Chat unmuted`))
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Could not mute chat`))
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: unmuteConvo} = useUnmuteConvo(convo.id, {
|
||||
onSuccess: data => {
|
||||
onUpdateConvo?.(data.convo)
|
||||
Toast.show(_(msg`Chat unmuted`))
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Could not unmute chat`))
|
||||
},
|
||||
})
|
||||
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||
|
||||
const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
|
||||
onSuccess: () => {
|
||||
if (currentScreen === 'conversation') {
|
||||
navigation.replace('Messages')
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Could not leave chat`))
|
||||
},
|
||||
})
|
||||
const toggleBlock = React.useCallback(() => {
|
||||
if (listBlocks.length) {
|
||||
blockedByListControl.open()
|
||||
return
|
||||
}
|
||||
|
||||
if (userBlock) {
|
||||
queueUnblock()
|
||||
} else {
|
||||
queueBlock()
|
||||
}
|
||||
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Root control={control}>
|
||||
{!hideTrigger && (
|
||||
<Menu.Trigger label={_(msg`Chat settings`)}>
|
||||
{({props, state}) => (
|
||||
<Pressable
|
||||
{...props}
|
||||
onPress={() => {
|
||||
Keyboard.dismiss()
|
||||
// eslint-disable-next-line react/prop-types -- eslint is confused by the name `props`
|
||||
props.onPress()
|
||||
}}
|
||||
style={[
|
||||
a.p_sm,
|
||||
a.rounded_sm,
|
||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
||||
// make sure pfp is in the middle
|
||||
{marginLeft: -10},
|
||||
]}>
|
||||
<DotsHorizontal size="lg" style={t.atoms.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<View style={[style]}>
|
||||
<Menu.Trigger label={_(msg`Chat settings`)}>
|
||||
{({props, state}) => (
|
||||
<Pressable
|
||||
{...props}
|
||||
onPress={() => {
|
||||
Keyboard.dismiss()
|
||||
// eslint-disable-next-line react/prop-types -- eslint is confused by the name `props`
|
||||
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>
|
||||
<Menu.Group>
|
||||
<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 notifications`)}
|
||||
onPress={() => (convo?.muted ? unmuteConvo() : muteConvo())}>
|
||||
<Menu.ItemText>
|
||||
{convo?.muted ? (
|
||||
<Trans>Unmute notifications</Trans>
|
||||
) : (
|
||||
<Trans>Mute notifications</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
{/* TODO(samuel): implement these */}
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Block account`)}
|
||||
onPress={() => {}}
|
||||
disabled>
|
||||
<Menu.ItemText>
|
||||
<Trans>Block account</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={profile.viewer?.blocking ? PersonCheck : PersonX}
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Report account`)}
|
||||
onPress={() => {}}
|
||||
disabled>
|
||||
<Menu.ItemText>
|
||||
<Trans>Report account</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Flag} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
|
||||
{isDeletedAccount ? (
|
||||
<Menu.Outer>
|
||||
<Menu.Item
|
||||
label={_(msg`Leave conversation`)}
|
||||
onPress={leaveConvoControl.open}>
|
||||
@@ -162,19 +142,90 @@ let ConvoMenu = ({
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ArrowBoxLeft} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</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>
|
||||
|
||||
<Prompt.Basic
|
||||
<LeaveConvoPrompt
|
||||
control={leaveConvoControl}
|
||||
title={_(msg`Leave conversation`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Leave`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={() => leaveConvo()}
|
||||
convoId={convo.id}
|
||||
currentScreen={currentScreen}
|
||||
/>
|
||||
<ReportConversationPrompt control={reportControl} />
|
||||
<BlockedByListDialog
|
||||
control={blockedByListControl}
|
||||
listBlocks={listBlocks}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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 {isNative} from 'platform/detection'
|
||||
import {useLeaveConvo} from 'state/queries/messages/leave-conversation'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {DialogOuterProps} from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
export function LeaveConvoPrompt({
|
||||
control,
|
||||
convoId,
|
||||
currentScreen,
|
||||
}: {
|
||||
control: DialogOuterProps['control']
|
||||
convoId: string
|
||||
currentScreen: 'list' | 'conversation'
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const {mutate: leaveConvo} = useLeaveConvo(convoId, {
|
||||
onSuccess: () => {
|
||||
if (currentScreen === 'conversation') {
|
||||
navigation.replace(
|
||||
'Messages',
|
||||
isNative
|
||||
? {
|
||||
animation: 'pop',
|
||||
}
|
||||
: {},
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Could not leave chat`))
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Prompt.Basic
|
||||
control={control}
|
||||
title={_(msg`Leave conversation`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Leave`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={leaveConvo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,49 @@
|
||||
import React, {useCallback, useMemo, useRef} from 'react'
|
||||
import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {
|
||||
GestureResponderEvent,
|
||||
LayoutAnimation,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
ChatBskyConvoDefs,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ConvoItem} from '#/state/messages/convo/types'
|
||||
import {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {isOnlyEmoji, RichText} from '../RichText'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
|
||||
export let MessageItem = ({
|
||||
let MessageItem = ({
|
||||
item,
|
||||
next,
|
||||
pending,
|
||||
}: {
|
||||
item: ChatBskyConvoDefs.MessageView
|
||||
next:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null
|
||||
pending?: boolean
|
||||
item: ConvoItem & {type: 'message' | 'pending-message'}
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const isFromSelf = item.sender?.did === currentAccount?.did
|
||||
const {message, nextMessage} = item
|
||||
const isPending = item.type === 'pending-message'
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
|
||||
const isNextFromSelf =
|
||||
ChatBskyConvoDefs.isMessageView(next) &&
|
||||
next.sender?.did === currentAccount?.did
|
||||
ChatBskyConvoDefs.isMessageView(nextMessage) &&
|
||||
nextMessage.sender?.did === currentAccount?.did
|
||||
|
||||
const isLastInGroup = useMemo(() => {
|
||||
// TODO this means it's a placeholder. Let's figure out the right way to do this though!
|
||||
if (item.id.length > 13) {
|
||||
// if this message is pending, it means the next message is pending too
|
||||
if (isPending && nextMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -43,9 +53,9 @@ export let MessageItem = ({
|
||||
}
|
||||
|
||||
// or, if there's a 3 minute gap between this message and the next
|
||||
if (ChatBskyConvoDefs.isMessageView(next)) {
|
||||
const thisDate = new Date(item.sentAt)
|
||||
const nextDate = new Date(next.sentAt)
|
||||
if (ChatBskyConvoDefs.isMessageView(nextMessage)) {
|
||||
const thisDate = new Date(message.sentAt)
|
||||
const nextDate = new Date(nextMessage.sentAt)
|
||||
|
||||
const diff = nextDate.getTime() - thisDate.getTime()
|
||||
|
||||
@@ -54,7 +64,7 @@ export let MessageItem = ({
|
||||
}
|
||||
|
||||
return true
|
||||
}, [item, next, isFromSelf, isNextFromSelf])
|
||||
}, [message, nextMessage, isFromSelf, isNextFromSelf, isPending])
|
||||
|
||||
const lastInGroupRef = useRef(isLastInGroup)
|
||||
if (lastInGroupRef.current !== isLastInGroup) {
|
||||
@@ -65,61 +75,88 @@ export let MessageItem = ({
|
||||
const pendingColor =
|
||||
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
|
||||
|
||||
const rt = useMemo(() => {
|
||||
return new RichTextAPI({text: message.text, facets: message.facets})
|
||||
}, [message.text, message.facets])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={item}>
|
||||
<View
|
||||
style={[
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? pending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
isFromSelf
|
||||
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
|
||||
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
isFromSelf && {color: t.palette.white},
|
||||
pending && t.name !== 'light' && {color: t.palette.primary_300},
|
||||
]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[isFromSelf ? a.mr_md : a.ml_md]}>
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed embed={message.embed} />
|
||||
)}
|
||||
{rt.text.length > 0 && (
|
||||
<View
|
||||
style={
|
||||
!isOnlyEmoji(message.text) && [
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
isFromSelf
|
||||
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
|
||||
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
|
||||
]
|
||||
}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[
|
||||
a.text_md,
|
||||
isFromSelf && {color: t.palette.white},
|
||||
isPending &&
|
||||
t.name !== 'light' && {color: t.palette.primary_300},
|
||||
]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ActionsWrapper>
|
||||
<MessageItemMetadata
|
||||
message={item}
|
||||
isLastInGroup={isLastInGroup}
|
||||
style={isFromSelf ? a.text_right : a.text_left}
|
||||
/>
|
||||
|
||||
{isLastInGroup && (
|
||||
<MessageItemMetadata
|
||||
item={item}
|
||||
style={isFromSelf ? a.text_right : a.text_left}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
MessageItem = React.memo(MessageItem)
|
||||
export {MessageItem}
|
||||
|
||||
let MessageItemMetadata = ({
|
||||
message,
|
||||
isLastInGroup,
|
||||
item,
|
||||
style,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
isLastInGroup: boolean
|
||||
item: ConvoItem & {type: 'message' | 'pending-message'}
|
||||
style: StyleProp<TextStyle>
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {message} = item
|
||||
|
||||
const handleRetry = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
if (item.type === 'pending-message' && item.retry) {
|
||||
e.preventDefault()
|
||||
item.retry()
|
||||
return false
|
||||
}
|
||||
},
|
||||
[item],
|
||||
)
|
||||
|
||||
const relativeTimestamp = useCallback(
|
||||
(timestamp: string) => {
|
||||
@@ -129,7 +166,6 @@ let MessageItemMetadata = ({
|
||||
const time = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
}).format(date)
|
||||
|
||||
const diff = now.getTime() - date.getTime()
|
||||
@@ -155,7 +191,6 @@ let MessageItemMetadata = ({
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric',
|
||||
@@ -164,29 +199,57 @@ let MessageItemMetadata = ({
|
||||
[_],
|
||||
)
|
||||
|
||||
if (!isLastInGroup) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_xs,
|
||||
a.mt_2xs,
|
||||
a.mb_lg,
|
||||
style,
|
||||
]}>
|
||||
{timeElapsed}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.mt_2xs,
|
||||
a.mb_lg,
|
||||
t.atoms.text_contrast_medium,
|
||||
style,
|
||||
]}>
|
||||
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
|
||||
{({timeElapsed}) => (
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
{timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
|
||||
{item.type === 'pending-message' && item.failed && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
{
|
||||
color: t.palette.negative_400,
|
||||
},
|
||||
]}>
|
||||
{_(msg`Failed to send`)}
|
||||
</Text>
|
||||
{item.retry && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`Click to retry failed message`)}
|
||||
to="#"
|
||||
onPress={handleRetry}
|
||||
style={[a.text_xs]}>
|
||||
{_(msg`Retry`)}
|
||||
</InlineLinkText>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
MessageItemMetadata = React.memo(MessageItemMetadata)
|
||||
export {MessageItemMetadata}
|
||||
|
||||
function localDateString(date: Date) {
|
||||
// can't use toISOString because it should be in local time
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||
|
||||
import {PostEmbeds} from '#/view/com/util/post-embeds'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
|
||||
let MessageItemEmbed = ({
|
||||
embed,
|
||||
}: {
|
||||
embed: AppBskyEmbedRecord.View
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
||||
<PostEmbeds embed={embed} allowNestedQuotes />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
MessageItemEmbed = React.memo(MessageItemEmbed)
|
||||
export {MessageItemEmbed}
|
||||
@@ -1,15 +1,21 @@
|
||||
import React from 'react'
|
||||
import {LayoutAnimation, Pressable, View} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {ChatBskyConvoDefs, RichText} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useConvo} from 'state/messages/convo'
|
||||
import {ConvoStatus} from 'state/messages/convo/types'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {getTranslatorLink} from '#/locale/helpers'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useConvoActive} from 'state/messages/convo'
|
||||
import {useSession} from 'state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ReportDialog} from '#/components/dms/ReportDialog'
|
||||
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
@@ -21,50 +27,56 @@ import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipb
|
||||
export let MessageMenu = ({
|
||||
message,
|
||||
control,
|
||||
hideTrigger,
|
||||
triggerOpacity,
|
||||
}: {
|
||||
hideTrigger?: boolean
|
||||
triggerOpacity?: number
|
||||
onTriggerPress?: () => void
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
control: Menu.MenuControlProps
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const convo = useConvo()
|
||||
const convo = useConvoActive()
|
||||
const deleteControl = usePromptControl()
|
||||
const retryDeleteControl = usePromptControl()
|
||||
const reportControl = usePromptControl()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
|
||||
const onCopyPostText = React.useCallback(() => {
|
||||
// use when we have rich text
|
||||
// const str = richTextToString(richText, true)
|
||||
const onCopyMessage = React.useCallback(() => {
|
||||
const str = richTextToString(
|
||||
new RichText({
|
||||
text: message.text,
|
||||
facets: message.facets,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
Clipboard.setStringAsync(message.text)
|
||||
Clipboard.setStringAsync(str)
|
||||
Toast.show(_(msg`Copied to clipboard`))
|
||||
}, [_, message.text])
|
||||
}, [_, message.text, message.facets])
|
||||
|
||||
const onPressTranslateMessage = React.useCallback(() => {
|
||||
const translatorUrl = getTranslatorLink(
|
||||
message.text,
|
||||
langPrefs.primaryLanguage,
|
||||
)
|
||||
openLink(translatorUrl)
|
||||
}, [langPrefs.primaryLanguage, message.text, openLink])
|
||||
|
||||
const onDelete = React.useCallback(() => {
|
||||
if (convo.status !== ConvoStatus.Ready) return
|
||||
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(message.id)
|
||||
.then(() => Toast.show(_(msg`Message deleted`)))
|
||||
.catch(() => retryDeleteControl.open())
|
||||
}, [_, convo, message.id, retryDeleteControl])
|
||||
|
||||
const onReport = React.useCallback(() => {
|
||||
// TODO report the message
|
||||
}, [])
|
||||
.catch(() => Toast.show(_(msg`Failed to delete message`)))
|
||||
}, [_, convo, message.id])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Root control={control}>
|
||||
{!hideTrigger && (
|
||||
{isWeb && (
|
||||
<View style={{opacity: triggerOpacity}}>
|
||||
<Menu.Trigger label={_(msg`Chat settings`)}>
|
||||
{({props, state}) => (
|
||||
@@ -75,7 +87,7 @@ export let MessageMenu = ({
|
||||
a.rounded_full,
|
||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<DotsHorizontal size="sm" style={t.atoms.text} />
|
||||
<DotsHorizontal size="md" style={t.atoms.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
@@ -83,16 +95,27 @@ export let MessageMenu = ({
|
||||
)}
|
||||
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={_(msg`Copy message text`)}
|
||||
onPress={onCopyPostText}>
|
||||
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownTranslateBtn"
|
||||
label={_(msg`Translate`)}
|
||||
onPress={onPressTranslateMessage}>
|
||||
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Translate} position="right" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={_(msg`Copy message text`)}
|
||||
onPress={onCopyMessage}>
|
||||
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownDeleteBtn"
|
||||
@@ -105,7 +128,7 @@ export let MessageMenu = ({
|
||||
<Menu.Item
|
||||
testID="messageDropdownReportBtn"
|
||||
label={_(msg`Report message`)}
|
||||
onPress={onReport}>
|
||||
onPress={reportControl.open}>
|
||||
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Warning} position="right" />
|
||||
</Menu.Item>
|
||||
@@ -114,27 +137,21 @@ export let MessageMenu = ({
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
|
||||
<ReportDialog
|
||||
params={{type: 'convoMessage', convoId: convo.convo.id, message}}
|
||||
control={reportControl}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={deleteControl}
|
||||
title={_(msg`Delete message`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`,
|
||||
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Delete`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={retryDeleteControl}
|
||||
title={_(msg`Failed to delete message`)}
|
||||
description={_(
|
||||
msg`An error occurred while trying to delete the message. Please try again.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Retry`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Message_Stroke2_Corner0_Rounded as Message} from '../icons/Message'
|
||||
import {Link} from '../Link'
|
||||
import {canBeMessaged} from './util'
|
||||
|
||||
export function MessageProfileButton({
|
||||
profile,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const {data: convo, isPending} = useMaybeConvoForUser(profile.did)
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
if (convo && !convo.lastMessage) {
|
||||
logEvent('chat:create', {logContext: 'ProfileHeader'})
|
||||
}
|
||||
logEvent('chat:open', {logContext: 'ProfileHeader'})
|
||||
}, [convo])
|
||||
|
||||
if (isPending) {
|
||||
// show pending state based on declaration
|
||||
if (canBeMessaged(profile)) {
|
||||
return (
|
||||
<View
|
||||
testID="dmBtnLoading"
|
||||
aria-hidden={true}
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.rounded_full,
|
||||
{width: 36, height: 36},
|
||||
]}>
|
||||
<Message
|
||||
style={[
|
||||
t.atoms.text,
|
||||
{marginLeft: 1, marginBottom: 1, opacity: 0.3},
|
||||
]}
|
||||
size="md"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (convo) {
|
||||
return (
|
||||
<Link
|
||||
testID="dmBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
shape="round"
|
||||
label={_(msg`Message ${profile.handle}`)}
|
||||
to={`/messages/${convo.id}`}
|
||||
style={[a.justify_center, {width: 36, height: 36}]}
|
||||
onPress={onPress}>
|
||||
<Message
|
||||
style={[t.atoms.text, {marginLeft: 1, marginBottom: 1}]}
|
||||
size="md"
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, ModerationCause} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {useProfileBlockMutationQueue} from 'state/queries/profile'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MessagesListBlockedFooter({
|
||||
recipient: initialRecipient,
|
||||
convoId,
|
||||
hasMessages,
|
||||
blockInfo,
|
||||
}: {
|
||||
recipient: AppBskyActorDefs.ProfileViewBasic
|
||||
convoId: string
|
||||
hasMessages: boolean
|
||||
blockInfo: {
|
||||
listBlocks: ModerationCause[]
|
||||
userBlock: ModerationCause | undefined
|
||||
}
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const recipient = useProfileShadow(initialRecipient)
|
||||
const [__, queueUnblock] = useProfileBlockMutationQueue(recipient)
|
||||
|
||||
const leaveConvoControl = useDialogControl()
|
||||
const reportControl = useDialogControl()
|
||||
const blockedByListControl = useDialogControl()
|
||||
|
||||
const {listBlocks, userBlock} = blockInfo
|
||||
const isBlocking = !!userBlock || !!listBlocks.length
|
||||
|
||||
const onUnblockPress = React.useCallback(() => {
|
||||
if (listBlocks.length) {
|
||||
blockedByListControl.open()
|
||||
} else {
|
||||
queueUnblock()
|
||||
}
|
||||
}, [blockedByListControl, listBlocks, queueUnblock])
|
||||
|
||||
return (
|
||||
<View style={[hasMessages && a.pt_md, a.pb_xl, a.gap_lg]}>
|
||||
<Divider />
|
||||
<Text style={[a.text_md, a.font_bold, a.text_center]}>
|
||||
{isBlocking ? (
|
||||
<Trans>You have blocked this user</Trans>
|
||||
) : (
|
||||
<Trans>This user has blocked you</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_row, a.justify_between, a.gap_lg, a.px_md]}>
|
||||
<Button
|
||||
label={_(msg`Leave chat`)}
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
size="small"
|
||||
style={[a.flex_1]}
|
||||
onPress={leaveConvoControl.open}>
|
||||
<ButtonText style={{color: t.palette.negative_500}}>
|
||||
<Trans>Leave chat</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Report`)}
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
size="small"
|
||||
style={[a.flex_1]}
|
||||
onPress={reportControl.open}>
|
||||
<ButtonText style={{color: t.palette.negative_500}}>
|
||||
<Trans>Report</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{isBlocking && gtMobile && (
|
||||
<Button
|
||||
label={_(msg`Unblock`)}
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
size="small"
|
||||
style={[a.flex_1]}
|
||||
onPress={onUnblockPress}>
|
||||
<ButtonText style={{color: t.palette.primary_500}}>
|
||||
<Trans>Unblock</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
{isBlocking && !gtMobile && (
|
||||
<View style={[a.flex_row, a.justify_center, a.px_md]}>
|
||||
<Button
|
||||
label={_(msg`Unblock`)}
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
size="small"
|
||||
style={[a.flex_1]}
|
||||
onPress={onUnblockPress}>
|
||||
<ButtonText style={{color: t.palette.primary_500}}>
|
||||
<Trans>Unblock</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<LeaveConvoPrompt
|
||||
control={leaveConvoControl}
|
||||
currentScreen="conversation"
|
||||
convoId={convoId}
|
||||
/>
|
||||
|
||||
<ReportConversationPrompt control={reportControl} />
|
||||
|
||||
<BlockedByListDialog
|
||||
control={blockedByListControl}
|
||||
listBlocks={listBlocks}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {TouchableOpacity, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
ModerationCause,
|
||||
ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {BACK_HITSLOP} from 'lib/constants'
|
||||
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 {isConvoActive, useConvo} from 'state/messages/convo'
|
||||
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
||||
import {Link} from '#/components/Link'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const PFP_SIZE = isWeb ? 40 : 34
|
||||
|
||||
export let MessagesListHeader = ({
|
||||
profile,
|
||||
moderation,
|
||||
blockInfo,
|
||||
}: {
|
||||
profile?: AppBskyActorDefs.ProfileViewBasic
|
||||
moderation?: ModerationDecision
|
||||
blockInfo?: {
|
||||
listBlocks: ModerationCause[]
|
||||
userBlock?: ModerationCause
|
||||
}
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onPressBack = useCallback(() => {
|
||||
if (isWeb) {
|
||||
navigation.replace('Messages', {})
|
||||
} else {
|
||||
navigation.goBack()
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
a.border_b,
|
||||
a.flex_row,
|
||||
a.align_start,
|
||||
a.gap_sm,
|
||||
gtTablet ? a.pl_lg : a.pl_xl,
|
||||
a.pr_lg,
|
||||
a.py_sm,
|
||||
]}>
|
||||
{!gtTablet && (
|
||||
<TouchableOpacity
|
||||
testID="conversationHeaderBackBtn"
|
||||
onPress={onPressBack}
|
||||
hitSlop={BACK_HITSLOP}
|
||||
style={{width: 30, height: 30, marginTop: isWeb ? 6 : 4}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Back`)}
|
||||
accessibilityHint="">
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
icon="angle-left"
|
||||
style={{
|
||||
marginTop: 6,
|
||||
}}
|
||||
color={t.atoms.text.color}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{profile && moderation && blockInfo ? (
|
||||
<HeaderReady
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
blockInfo={blockInfo}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
|
||||
<View
|
||||
style={[
|
||||
{width: PFP_SIZE, height: PFP_SIZE},
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
<View style={a.gap_xs}>
|
||||
<View
|
||||
style={[
|
||||
{width: 120, height: 16},
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.mt_xs,
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
{width: 175, height: 12},
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{width: 30}} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
MessagesListHeader = React.memo(MessagesListHeader)
|
||||
|
||||
function HeaderReady({
|
||||
profile: profileUnshadowed,
|
||||
moderation,
|
||||
blockInfo,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
moderation: ModerationDecision
|
||||
blockInfo: {
|
||||
listBlocks: ModerationCause[]
|
||||
userBlock?: ModerationCause
|
||||
}
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const convoState = useConvo()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
|
||||
const isDeletedAccount = profile?.handle === 'missing.invalid'
|
||||
const displayName = isDeletedAccount
|
||||
? 'Deleted Account'
|
||||
: sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Link
|
||||
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
|
||||
to={makeProfileLink(profile)}>
|
||||
<View style={[a.pt_2xs]}>
|
||||
<PreviewableUserAvatar
|
||||
size={PFP_SIZE}
|
||||
profile={profile}
|
||||
moderation={moderation.ui('avatar')}
|
||||
disableHoverCard={moderation.blocked}
|
||||
/>
|
||||
</View>
|
||||
<View style={a.flex_1}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, web(a.leading_normal)]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{!isDeletedAccount && (
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
web([a.leading_normal, {marginTop: -2}]),
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
@{profile.handle}
|
||||
{convoState.convo?.muted && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<BellStroke
|
||||
size="xs"
|
||||
style={t.atoms.text_contrast_medium}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
{isConvoActive(convoState) && (
|
||||
<ConvoMenu
|
||||
convo={convoState.convo}
|
||||
profile={profile}
|
||||
currentScreen="conversation"
|
||||
blockInfo={blockInfo}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingLeft: PFP_SIZE + a.gap_md.gap,
|
||||
},
|
||||
]}>
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
size="large"
|
||||
style={[a.pt_xs]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, {useCallback, useEffect} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ChatBskyActorDeclaration} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Message_Stroke2_Corner0_Rounded} from '#/components/icons/Message'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MessagesNUX() {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({
|
||||
did: currentAccount!.did,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (profile && typeof profile.associated?.chat === 'undefined') {
|
||||
const timeout = setTimeout(() => {
|
||||
control.open()
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [profile, control])
|
||||
|
||||
if (!profile) return null
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner chatDeclation={profile.associated?.chat} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner({
|
||||
chatDeclation,
|
||||
}: {
|
||||
chatDeclation?: ChatBskyActorDeclaration.Record
|
||||
}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const [initialized, setInitialzed] = React.useState(false)
|
||||
const {mutate: updateDeclaration} = useUpdateActorDeclaration({
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Failed to update settings`))
|
||||
},
|
||||
})
|
||||
|
||||
const onSelectItem = useCallback(
|
||||
(keys: string[]) => {
|
||||
const key = keys[0]
|
||||
if (!key) return
|
||||
updateDeclaration(key as 'all' | 'none' | 'following')
|
||||
},
|
||||
[updateDeclaration],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatDeclation && !initialized) {
|
||||
updateDeclaration('following')
|
||||
setInitialzed(true)
|
||||
}
|
||||
}, [chatDeclation, updateDeclaration, initialized])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Introducing Direct Messages`)}
|
||||
style={web({maxWidth: 440})}>
|
||||
<View style={a.gap_xl}>
|
||||
<View style={[a.align_center, a.pt_sm, a.pb_xs]}>
|
||||
<Message_Stroke2_Corner0_Rounded width={64} />
|
||||
<Text style={[a.text_2xl, a.font_bold, a.text_center, a.mt_md]}>
|
||||
<Trans>Direct messages are here!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.text_center, a.mt_sm]}>
|
||||
<Trans>Privately chat with other users.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.gap_xs,
|
||||
a.border,
|
||||
a.overflow_hidden,
|
||||
a.rounded_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.p_md,
|
||||
a.border_b,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Text style={[a.text_sm, a.font_bold]}>
|
||||
<Trans>Who can message you?</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.mt_xs,
|
||||
a.text_sm,
|
||||
a.italic,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>You can change this at any time.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.px_md, a.py_xs]}>
|
||||
<Toggle.Group
|
||||
label={_(msg`Who can message you?`)}
|
||||
type="radio"
|
||||
values={[chatDeclation?.allowIncoming ?? 'following']}
|
||||
onChange={onSelectItem}>
|
||||
<View>
|
||||
<Toggle.Item
|
||||
name="all"
|
||||
label={_(msg`Everyone`)}
|
||||
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
|
||||
<Toggle.LabelText>
|
||||
<Trans>Everyone</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Radio />
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="following"
|
||||
label={_(msg`Users I follow`)}
|
||||
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
|
||||
<Toggle.LabelText>
|
||||
<Trans>Users I follow</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Radio />
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="none"
|
||||
label={_(msg`No one`)}
|
||||
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
|
||||
<Toggle.LabelText>
|
||||
<Trans>No one</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Radio />
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Start chatting`)}
|
||||
accessibilityHint={_(msg`Close modal`)}
|
||||
size="medium"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Get started</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Button} from '../Button'
|
||||
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
|
||||
import {ListMaybePlaceholder} from '../Lists'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function NewChat({
|
||||
control,
|
||||
onNewChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onNewChat: (chatId: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
onNewChat(data.convo.id)
|
||||
},
|
||||
onError: error => {
|
||||
Toast.show(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
},
|
||||
[control, createChat],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FAB
|
||||
testID="newChatFAB"
|
||||
onPress={control.open}
|
||||
icon={<Plus size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New chat`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<Dialog.Handle />
|
||||
<SearchablePeopleList onCreateChat={onCreateChat} />
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchablePeopleList({
|
||||
onCreateChat,
|
||||
}: {
|
||||
onCreateChat: (did: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const control = Dialog.useDialogContext()
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const {
|
||||
data: actorAutocompleteData,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useActorAutocompleteQuery(searchText, true)
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item: profile}: {item: AppBskyActorDefs.ProfileView}) => {
|
||||
if (!moderationOpts) return null
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
return (
|
||||
<Button
|
||||
label={profile.displayName || sanitizeHandle(profile.handle)}
|
||||
onPress={() => onCreateChat(profile.did)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.flex_row,
|
||||
a.rounded_sm,
|
||||
pressed
|
||||
? t.atoms.bg_contrast_25
|
||||
: hovered
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg,
|
||||
]}>
|
||||
<UserAvatar
|
||||
size={40}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={{flex: 1}}>
|
||||
<Text
|
||||
style={[t.atoms.text, a.font_bold, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
<Text style={t.atoms.text_contrast_high} numberOfLines={1}>
|
||||
{sanitizeHandle(profile.handle, '@')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
[
|
||||
moderationOpts,
|
||||
onCreateChat,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.bg_contrast_50,
|
||||
t.atoms.bg,
|
||||
t.atoms.text,
|
||||
t.atoms.text_contrast_high,
|
||||
],
|
||||
)
|
||||
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View style={[a.relative, a.mb_lg]}>
|
||||
{/* cover top corners */}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
t.atoms.bg,
|
||||
]}
|
||||
/>
|
||||
<Dialog.Close />
|
||||
<Text
|
||||
style={[
|
||||
a.text_2xl,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
a.pb_lg,
|
||||
web(a.pt_lg),
|
||||
]}>
|
||||
<Trans>Start a new chat</Trans>
|
||||
</Text>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Search} />
|
||||
<Dialog.Input
|
||||
label={_(msg`Search profiles`)}
|
||||
placeholder={_(msg`Search`)}
|
||||
value={searchText}
|
||||
onChangeText={text => {
|
||||
setSearchText(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={50}
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
control.close()
|
||||
}
|
||||
}}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
)
|
||||
}, [t.atoms.bg, _, control, searchText])
|
||||
|
||||
return (
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
data={actorAutocompleteData}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{searchText.length === 0 ? (
|
||||
<View style={[a.pt_4xl, a.align_center, a.px_lg]}>
|
||||
<Envelope width={64} fill={t.palette.contrast_200} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.text_center,
|
||||
a.mt_md,
|
||||
t.atoms.text_contrast_low,
|
||||
]}>
|
||||
<Trans>Search for someone to start a conversation with.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
!actorAutocompleteData?.length && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isFetching}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
hideBackButton={true}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
emptyMessage={
|
||||
isError
|
||||
? _(msg`No search results found for "${searchText}".`)
|
||||
: _(msg`Could not load profiles. Please try again later.`)
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
}
|
||||
stickyHeaderIndices={[0]}
|
||||
keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did}
|
||||
// @ts-expect-error web only
|
||||
style={isWeb && {minHeight: '100vh'}}
|
||||
onScrollBeginDrag={() => Keyboard.dismiss()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
ScaleAndFadeIn,
|
||||
ScaleAndFadeOut,
|
||||
} from 'lib/custom-animations/ScaleAndFade'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {isAndroid, isIOS, isWeb} from 'platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export function NewMessagesPill({
|
||||
onPress: onPressInner,
|
||||
}: {
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
const {bottom: bottomInset} = useSafeAreaInsets()
|
||||
const bottomBarHeight = isIOS ? 42 : isAndroid ? 60 : 0
|
||||
const bottomOffset = isWeb ? 0 : bottomInset + bottomBarHeight
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
const onPressIn = React.useCallback(() => {
|
||||
if (isWeb) return
|
||||
scale.value = withTiming(1.075, {duration: 100})
|
||||
}, [scale])
|
||||
|
||||
const onPressOut = React.useCallback(() => {
|
||||
if (isWeb) return
|
||||
scale.value = withTiming(1, {duration: 100})
|
||||
}, [scale])
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
runOnJS(playHaptic)()
|
||||
onPressInner?.()
|
||||
}, [onPressInner, playHaptic])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{scale: scale.value}],
|
||||
}))
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.w_full,
|
||||
a.z_10,
|
||||
a.align_center,
|
||||
{
|
||||
bottom: bottomOffset + 70,
|
||||
// Don't prevent scrolling in this area _except_ for in the pill itself
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
]}>
|
||||
<AnimatedPressable
|
||||
style={[
|
||||
a.py_sm,
|
||||
a.rounded_full,
|
||||
a.shadow_sm,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_50,
|
||||
t.atoms.border_contrast_medium,
|
||||
{
|
||||
width: 160,
|
||||
alignItems: 'center',
|
||||
shadowOpacity: 0.125,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: {width: 0, height: 5},
|
||||
pointerEvents: 'box-only',
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
entering={ScaleAndFadeIn}
|
||||
exiting={ScaleAndFadeOut}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}>
|
||||
<Text style={[a.font_bold]}>
|
||||
<Trans>New messages</Trans>
|
||||
</Text>
|
||||
</AnimatedPressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
export function ReportConversationPrompt({
|
||||
control,
|
||||
}: {
|
||||
control: DialogControlProps
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Prompt.Basic
|
||||
control={control}
|
||||
title={_(msg`Report conversation`)}
|
||||
description={_(
|
||||
msg`To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`I understand`)}
|
||||
onConfirm={() => {}}
|
||||
showCancel={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import React, {memo, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
ChatBskyConvoDefs,
|
||||
ComAtprotoModerationCreateReport,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
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 * 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 {MessageItemMetadata} from './MessageItem'
|
||||
|
||||
type ReportDialogParams = {
|
||||
type: 'convoMessage'
|
||||
convoId: string
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
}
|
||||
|
||||
let ReportDialog = ({
|
||||
control,
|
||||
params,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
params: ReportDialogParams
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner label={_(msg`Report this message`)}>
|
||||
<DialogInner params={params} />
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
ReportDialog = memo(ReportDialog)
|
||||
export {ReportDialog}
|
||||
|
||||
function DialogInner({params}: {params: ReportDialogParams}) {
|
||||
const [reportOption, setReportOption] = useState<ReportOption | null>(null)
|
||||
|
||||
return reportOption ? (
|
||||
<SubmitStep
|
||||
params={params}
|
||||
reportOption={reportOption}
|
||||
goBack={() => setReportOption(null)}
|
||||
/>
|
||||
) : (
|
||||
<ReasonStep params={params} setReportOption={setReportOption} />
|
||||
)
|
||||
}
|
||||
|
||||
function ReasonStep({
|
||||
setReportOption,
|
||||
}: {
|
||||
setReportOption: (reportOption: ReportOption) => void
|
||||
params: ReportDialogParams
|
||||
}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
return (
|
||||
<SelectReportOptionView
|
||||
labelers={[]}
|
||||
goBack={control.close}
|
||||
params={{
|
||||
type: 'convoMessage',
|
||||
}}
|
||||
onSelectReportOption={setReportOption}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SubmitStep({
|
||||
params,
|
||||
reportOption,
|
||||
goBack,
|
||||
}: {
|
||||
params: ReportDialogParams
|
||||
reportOption: ReportOption
|
||||
goBack: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const t = useTheme()
|
||||
const [details, setDetails] = useState('')
|
||||
const control = Dialog.useDialogContext()
|
||||
const agent = useAgent()
|
||||
|
||||
const {
|
||||
mutate: submit,
|
||||
error,
|
||||
isPending: submitting,
|
||||
} = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (params.type === 'convoMessage') {
|
||||
const {convoId, message} = params
|
||||
|
||||
const report = {
|
||||
reasonType: reportOption.reason,
|
||||
subject: {
|
||||
$type: 'chat.bsky.convo.defs#messageRef',
|
||||
messageId: message.id,
|
||||
convoId,
|
||||
did: message.sender.did,
|
||||
} satisfies ChatBskyConvoDefs.MessageRef,
|
||||
reason: details,
|
||||
} satisfies ComAtprotoModerationCreateReport.InputSchema
|
||||
|
||||
await agent.createModerationReport(report)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
control.close(() => {
|
||||
Toast.show(_(msg`Thank you. Your report has been sent.`))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const copy = useMemo(() => {
|
||||
return {
|
||||
convoMessage: {
|
||||
title: _(msg`Report this message`),
|
||||
},
|
||||
}[params.type]
|
||||
}, [_, params])
|
||||
|
||||
return (
|
||||
<View style={a.gap_lg}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
label={_(msg`Go back to previous step`)}
|
||||
onPress={goBack}>
|
||||
<ButtonIcon icon={Chevron} />
|
||||
</Button>
|
||||
|
||||
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>{copy.title}</Text>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Your report will be sent to the Bluesky Moderation Service
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{params.type === 'convoMessage' && (
|
||||
<PreviewMessage message={params.message} />
|
||||
)}
|
||||
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Reason:</Trans>
|
||||
</Text>{' '}
|
||||
<Text style={[a.font_bold, a.text_md]}>{reportOption.title}</Text>
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.gap_md]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Optionally provide additional information below:</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.relative, a.w_full]}>
|
||||
<Dialog.Input
|
||||
multiline
|
||||
value={details}
|
||||
onChangeText={setDetails}
|
||||
label="Text field"
|
||||
style={{paddingRight: 60}}
|
||||
numberOfLines={6}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.pr_md,
|
||||
a.pb_sm,
|
||||
{
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
]}>
|
||||
<CharProgress count={details?.length || 0} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}>
|
||||
{error && (
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
There was an issue sending your report. Please check your internet
|
||||
connection.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
testID="sendReportBtn"
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="negative"
|
||||
label={_(msg`Send report`)}
|
||||
onPress={() => submit()}>
|
||||
<ButtonText>
|
||||
<Trans>Send report</Trans>
|
||||
</ButtonText>
|
||||
{submitting && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewMessage({message}: {message: ChatBskyConvoDefs.MessageView}) {
|
||||
const t = useTheme()
|
||||
const rt = useMemo(() => {
|
||||
return new RichTextAPI({text: message.text, facets: message.facets})
|
||||
}, [message.text, message.facets])
|
||||
|
||||
return (
|
||||
<View style={a.align_start}>
|
||||
<View
|
||||
style={[
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
{borderBottomLeftRadius: 2},
|
||||
]}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md, a.leading_snug]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
/>
|
||||
</View>
|
||||
<MessageItemMetadata
|
||||
item={{
|
||||
type: 'message',
|
||||
message,
|
||||
key: '',
|
||||
nextMessage: null,
|
||||
}}
|
||||
style={[a.text_left, a.mb_0]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {SearchablePeopleList} from './SearchablePeopleList'
|
||||
|
||||
export function NewChat({
|
||||
control,
|
||||
onNewChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onNewChat: (chatId: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
onNewChat(data.convo.id)
|
||||
|
||||
if (!data.convo.lastMessage) {
|
||||
logEvent('chat:create', {logContext: 'NewChatDialog'})
|
||||
}
|
||||
logEvent('chat:open', {logContext: 'NewChatDialog'})
|
||||
},
|
||||
onError: error => {
|
||||
Toast.show(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
},
|
||||
[control, createChat],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FAB
|
||||
testID="newChatFAB"
|
||||
onPress={control.open}
|
||||
icon={<Plus size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New chat`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Start a new chat`)}
|
||||
onSelectChat={onCreateChat}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type {TextInput as TextInputType} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useListConvosQuery} from '#/state/queries/messages/list-converations'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {TextInput} from '#/components/dms/dialogs/TextInput'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type Item =
|
||||
| {
|
||||
type: 'profile'
|
||||
key: string
|
||||
enabled: boolean
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
}
|
||||
| {
|
||||
type: 'empty'
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: 'placeholder'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'error'
|
||||
key: string
|
||||
}
|
||||
|
||||
export function SearchablePeopleList({
|
||||
title,
|
||||
onSelectChat,
|
||||
showRecentConvos,
|
||||
}: {
|
||||
title: string
|
||||
onSelectChat: (did: string) => void
|
||||
showRecentConvos?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const control = Dialog.useDialogContext()
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
const {currentAccount} = useSession()
|
||||
const inputRef = useRef<TextInputType>(null)
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const {
|
||||
data: results,
|
||||
isError,
|
||||
isFetching,
|
||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||
const {data: convos} = useListConvosQuery({enabled: showRecentConvos})
|
||||
|
||||
const items = useMemo(() => {
|
||||
let _items: Item[] = []
|
||||
|
||||
if (isError) {
|
||||
_items.push({
|
||||
type: 'empty',
|
||||
key: 'empty',
|
||||
message: _(msg`We're having network issues, try again`),
|
||||
})
|
||||
} else if (searchText.length) {
|
||||
if (results?.length) {
|
||||
for (const profile of results) {
|
||||
if (profile.did === currentAccount?.did) continue
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
|
||||
_items = _items.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const placeholders: Item[] = Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
type: 'placeholder',
|
||||
key: i + '',
|
||||
}))
|
||||
|
||||
if (showRecentConvos) {
|
||||
if (convos && follows) {
|
||||
const usedDids = new Set()
|
||||
|
||||
for (const page of convos.pages) {
|
||||
for (const convo of page.convos) {
|
||||
const profiles = convo.members.filter(
|
||||
m => m.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
for (const profile of profiles) {
|
||||
if (usedDids.has(profile.did)) continue
|
||||
|
||||
usedDids.add(profile.did)
|
||||
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: true,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let followsItems: typeof _items = []
|
||||
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
if (usedDids.has(profile.did)) continue
|
||||
|
||||
followsItems.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// only sort follows
|
||||
followsItems = followsItems.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
|
||||
// then append
|
||||
_items.push(...followsItems)
|
||||
} else {
|
||||
_items.push(...placeholders)
|
||||
}
|
||||
} else if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_items = _items.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
} else {
|
||||
_items.push(...placeholders)
|
||||
}
|
||||
}
|
||||
|
||||
return _items
|
||||
}, [
|
||||
_,
|
||||
searchText,
|
||||
results,
|
||||
isError,
|
||||
currentAccount?.did,
|
||||
follows,
|
||||
convos,
|
||||
showRecentConvos,
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
}
|
||||
|
||||
const renderItems = useCallback(
|
||||
({item}: {item: Item}) => {
|
||||
switch (item.type) {
|
||||
case 'profile': {
|
||||
return (
|
||||
<ProfileCard
|
||||
key={item.key}
|
||||
enabled={item.enabled}
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts!}
|
||||
onPress={onSelectChat}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'placeholder': {
|
||||
return <ProfileCardSkeleton key={item.key} />
|
||||
}
|
||||
case 'empty': {
|
||||
return <Empty key={item.key} message={item.message} />
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
[moderationOpts, onSelectChat],
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isWeb) {
|
||||
setImmediate(() => {
|
||||
inputRef?.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.pt_md,
|
||||
a.pb_xs,
|
||||
a.px_lg,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
native([a.pt_lg]),
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
native(a.align_center),
|
||||
a.justify_center,
|
||||
{height: 32},
|
||||
]}>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="small"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_20,
|
||||
native({
|
||||
left: -7,
|
||||
}),
|
||||
web({
|
||||
right: -4,
|
||||
}),
|
||||
]}
|
||||
onPress={() => control.close()}>
|
||||
{isWeb ? (
|
||||
<X size="md" fill={t.palette.contrast_500} />
|
||||
) : (
|
||||
<ChevronLeft size="md" fill={t.palette.contrast_500} />
|
||||
)}
|
||||
</Button>
|
||||
<Text
|
||||
style={[
|
||||
a.z_10,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
value={searchText}
|
||||
onChangeText={text => {
|
||||
setSearchText(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
onEscape={control.close}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}, [
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
t.atoms.text_contrast_high,
|
||||
t.palette.contrast_500,
|
||||
_,
|
||||
title,
|
||||
searchText,
|
||||
control,
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={renderItems}
|
||||
ListHeaderComponent={listHeader}
|
||||
stickyHeaderIndices={[0]}
|
||||
keyExtractor={(item: Item) => item.key}
|
||||
style={[
|
||||
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||
native({
|
||||
height: '100%',
|
||||
paddingHorizontal: 0,
|
||||
marginTop: 0,
|
||||
paddingTop: 0,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
}),
|
||||
]}
|
||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||
keyboardDismissMode="on-drag"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCard({
|
||||
enabled,
|
||||
profile,
|
||||
moderationOpts,
|
||||
onPress,
|
||||
}: {
|
||||
enabled: boolean
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
onPress: (did: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
const displayName = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
const handleOnPress = useCallback(() => {
|
||||
onPress(profile.did)
|
||||
}, [onPress, profile.did])
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={!enabled}
|
||||
label={_(msg`Start chat with ${displayName}`)}
|
||||
onPress={handleOnPress}>
|
||||
{({hovered, pressed, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.flex_row,
|
||||
!enabled
|
||||
? {opacity: 0.5}
|
||||
: pressed || focused
|
||||
? t.atoms.bg_contrast_25
|
||||
: hovered
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg,
|
||||
]}>
|
||||
<UserAvatar
|
||||
size={42}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
style={[t.atoms.text, a.font_bold, a.leading_tight]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.leading_tight, t.atoms.text_contrast_high]}
|
||||
numberOfLines={2}>
|
||||
{!enabled ? <Trans>{handle} can't be messaged</Trans> : handle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCardSkeleton() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.flex_row,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
{width: 42, height: 42},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
{width: 80, height: 14},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
{width: 120, height: 10},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty({message}: {message: string}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}>
|
||||
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}>
|
||||
{message}
|
||||
</Text>
|
||||
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
value,
|
||||
onChangeText,
|
||||
onEscape,
|
||||
inputRef,
|
||||
}: {
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
onEscape: () => void
|
||||
inputRef: React.RefObject<TextInputType>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const interacted = hovered || focused
|
||||
|
||||
return (
|
||||
<View
|
||||
{...web({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
})}
|
||||
style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Search
|
||||
size="md"
|
||||
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
// @ts-ignore bottom sheet input types issue — esb
|
||||
ref={inputRef}
|
||||
placeholder={_(msg`Search`)}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={50}
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
onEscape()
|
||||
}
|
||||
}}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
accessibilityLabel={_(msg`Search profiles`)}
|
||||
accessibilityHint={_(msg`Search profiles`)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {SearchablePeopleList} from './SearchablePeopleList'
|
||||
|
||||
export function SendViaChatDialog({
|
||||
control,
|
||||
onSelectChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectChat: (chatId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="sendViaChatChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function SendViaChatDialogInner({
|
||||
control,
|
||||
onSelectChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectChat: (chatId: string) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
onSelectChat(data.convo.id)
|
||||
|
||||
if (!data.convo.lastMessage) {
|
||||
logEvent('chat:create', {logContext: 'SendViaChatDialog'})
|
||||
}
|
||||
logEvent('chat:open', {logContext: 'SendViaChatDialog'})
|
||||
},
|
||||
onError: error => {
|
||||
Toast.show(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
},
|
||||
[control, createChat],
|
||||
)
|
||||
|
||||
return (
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Send post to...`)}
|
||||
onSelectChat={onCreateChat}
|
||||
showRecentConvos
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src'
|
||||
@@ -0,0 +1 @@
|
||||
export {TextInput} from 'react-native'
|
||||
@@ -0,0 +1,18 @@
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
export function canBeMessaged(profile: AppBskyActorDefs.ProfileView) {
|
||||
switch (profile.associated?.chat?.allowIncoming) {
|
||||
case 'none':
|
||||
return false
|
||||
case 'all':
|
||||
return true
|
||||
// if unset, treat as following
|
||||
case 'following':
|
||||
case undefined:
|
||||
return Boolean(profile.viewer?.followedBy)
|
||||
// any other values are invalid according to the lexicon, so
|
||||
// let's treat as false to be safe
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
|
||||
export function useHeaderOffset() {
|
||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||
const {fontScale} = useWindowDimensions()
|
||||
if (isDesktop || isTablet) {
|
||||
return 0
|
||||
}
|
||||
const navBarHeight = 42
|
||||
const tabBarPad = 10 + 10 + 3 // padding + border
|
||||
const normalLineHeight = 1.2
|
||||
const tabBarText = 16 * normalLineHeight * fontScale
|
||||
return navBarHeight + tabBarPad + tabBarText
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
|
||||
const [prevText, setPrevText] = React.useState(text)
|
||||
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
|
||||
const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
|
||||
const {getAgent} = useAgent()
|
||||
const agent = useAgent()
|
||||
if (text !== prevText) {
|
||||
setPrevText(text)
|
||||
setRawRT(new RichTextAPI({text}))
|
||||
@@ -19,7 +19,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
|
||||
async function resolveRTFacets() {
|
||||
// new each time
|
||||
const resolvedRT = new RichTextAPI({text})
|
||||
await resolvedRT.detectFacets(getAgent())
|
||||
await resolvedRT.detectFacets(agent)
|
||||
if (!ignore) {
|
||||
setResolvedRT(resolvedRT)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [text, getAgent])
|
||||
}, [text, agent])
|
||||
const isResolving = resolvedRT === null
|
||||
return [resolvedRT ?? rawRT, isResolving]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Bell_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.216 8.815a7.853 7.853 0 0 1 15.568 0l1.207 9.053A1 1 0 0 1 20 19h-3.354c-.904 1.748-2.607 3-4.646 3-2.039 0-3.742-1.252-4.646-3H4a1 1 0 0 1-.991-1.132l1.207-9.053ZM9.778 19c.61.637 1.399 1 2.222 1s1.613-.363 2.222-1H9.778ZM12 4a5.853 5.853 0 0 0-5.802 5.08L5.142 17h13.716l-1.056-7.92A5.853 5.853 0 0 0 12 4Z',
|
||||
})
|
||||
|
||||
export const Bell_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 2a7.853 7.853 0 0 0-7.784 6.815l-1.207 9.053A1 1 0 0 0 4 19h3.354c.904 1.748 2.607 3 4.646 3 2.039 0 3.742-1.252 4.646-3H20a1 1 0 0 0 .991-1.132l-1.207-9.053A7.853 7.853 0 0 0 12 2Zm2.222 17H9.778c.61.637 1.399 1 2.222 1s1.613-.363 2.222-1Z',
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Bell2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.702 8.943a7.307 7.307 0 0 1 14.596 0l.19 3.798 1.321 2.641A1.809 1.809 0 0 1 19.191 18H16.9a5.002 5.002 0 0 1-9.8 0H4.809a1.809 1.809 0 0 1-1.618-2.618l1.32-2.641.19-3.798ZM9.17 18a3.001 3.001 0 0 0 5.658 0H9.171ZM12 4a5.307 5.307 0 0 0-5.3 5.042l-.19 3.798a2 2 0 0 1-.21.795L5.119 16h13.764l-1.183-2.365a2 2 0 0 1-.208-.795l-.19-3.798A5.308 5.308 0 0 0 12 4Z',
|
||||
})
|
||||
|
||||
export const Bell2_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 2a7.307 7.307 0 0 0-7.298 6.943l-.19 3.798-1.321 2.641A1.809 1.809 0 0 0 4.809 18H7.1a5.002 5.002 0 0 0 9.8 0h2.291a1.809 1.809 0 0 0 1.618-2.618l-1.32-2.641-.19-3.798A7.308 7.308 0 0 0 12 2Zm0 18a3.001 3.001 0 0 1-2.83-2h5.66A3.001 3.001 0 0 1 12 20Z',
|
||||
})
|
||||
|
||||
export const Bell2Off_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M2.293 2.293a1 1 0 0 1 1.414 0l18 18a1 1 0 0 1-1.414 1.414L17.586 19h-.94c-.904 1.748-2.607 3-4.646 3-2.039 0-3.742-1.252-4.646-3H4a1 1 0 0 1-.991-1.132l1.207-9.053c.116-.87.372-1.69.743-2.442L2.293 3.707a1 1 0 0 1 0-1.414Zm4.19 5.604c-.134.376-.23.772-.285 1.183L5.142 17h10.444L6.483 7.897ZM9.778 19c.61.637 1.399 1 2.222 1s1.613-.363 2.222-1H9.778ZM8.834 2.666a7.853 7.853 0 0 1 10.95 6.15l.645 4.832a1 1 0 0 1-1.983.265l-.644-4.833A5.853 5.853 0 0 0 9.64 4.495a1 1 0 0 1-.807-1.83Z',
|
||||
})
|
||||
|
||||
export const Bell2Off_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'm19.785 8.815 1.034 7.761L7.595 3.352a7.853 7.853 0 0 1 12.19 5.463ZM4 19h3.354c.904 1.748 2.607 3 4.646 3 2.038 0 3.742-1.252 4.646-3h.94l2.707 2.707a1 1 0 0 0 1.414-1.414l-18-18a1 1 0 0 0-1.414 1.414l2.666 2.666a7.842 7.842 0 0 0-.743 2.442l-1.207 9.053A1 1 0 0 0 4 19Zm8 1c-.823 0-1.613-.363-2.222-1h4.443c-.608.637-1.398 1-2.221 1Z',
|
||||
})
|
||||
@@ -3,3 +3,11 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const BubbleQuestion_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
|
||||
})
|
||||
|
||||
export const Bubble_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M2.002 6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6Zm3-1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h7a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-14Z',
|
||||
})
|
||||
|
||||
export const Bubble_Stroke2_Corner3_Rounded = createSinglePathSVG({
|
||||
path: 'M2.002 7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2a4 4 0 0 1-4-4V7Zm4-2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h1a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h6a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-12Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const BulletList_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2ZM3 7a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm9 0a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Zm-6 9a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm9 0a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
export const BulletList_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 7a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm0 10a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm10-1a1 1 0 1 0 0 2h7a1 1 0 1 0 0-2h-7Zm-1-9a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z',
|
||||
})
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Globe_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z',
|
||||
})
|
||||
|
||||
export const Earth_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.4 9.493C4.14 10.28 4 11.124 4 12a8 8 0 1 0 10.899-7.459l-.953 3.81a1 1 0 0 1-.726.727l-3.444.866-.772 1.533a1 1 0 0 1-1.493.35L4.4 9.493Zm.883-1.84L7.756 9.51l.44-.874a1 1 0 0 1 .649-.52l3.306-.832.807-3.227a7.993 7.993 0 0 0-7.676 3.597ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8.43.162a1 1 0 0 1 .77-.29l1.89.121a1 1 0 0 1 .494.168l2.869 1.928a1 1 0 0 1 .336 1.277l-.973 1.946a1 1 0 0 1-.894.553h-2.92a1 1 0 0 1-.831-.445L9.225 14.5a1 1 0 0 1 .126-1.262l1.08-1.076Zm.915 1.913.177-.177 1.171.074 1.914 1.286-.303.607h-1.766l-1.194-1.79Z',
|
||||
})
|
||||
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Hashtag_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M9.124 3.008a1 1 0 0 1 .868 1.116L9.632 7h5.985l.39-3.124a1 1 0 0 1 1.985.248L17.632 7H20a1 1 0 1 1 0 2h-2.617l-.75 6H20a1 1 0 1 1 0 2h-3.617l-.39 3.124a1 1 0 1 1-1.985-.248l.36-2.876H8.382l-.39 3.124a1 1 0 1 1-1.985-.248L6.368 17H4a1 1 0 1 1 0-2h2.617l.75-6H4a1 1 0 1 1 0-2h3.617l.39-3.124a1 1 0 0 1 1.117-.868ZM9.383 9l-.75 6h5.984l.75-6H9.383Z',
|
||||
})
|
||||
|
||||
export const Hashtag_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M9.186 2.512a1.5 1.5 0 0 0-1.674 1.302L7.176 6.5H4a1.5 1.5 0 1 0 0 3h2.8l-.624 5H4a1.5 1.5 0 0 0 0 3h1.8l-.288 2.314a1.5 1.5 0 1 0 2.976.372l.336-2.686h4.977l-.29 2.314a1.5 1.5 0 1 0 2.977.372l.336-2.686H20a1.5 1.5 0 0 0 0-3h-2.8l.624-5H20a1.5 1.5 0 0 0 0-3h-1.8l.288-2.314a1.5 1.5 0 1 0-2.976-.372L15.176 6.5h-4.977l.29-2.314a1.5 1.5 0 0 0-1.303-1.674ZM9.2 14.5l.625-5h4.977l-.625 5H9.199Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Home_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11.46 1.362a2 2 0 0 1 1.08 0c.249.07.448.188.611.301.146.102.306.232.467.363l6.421 5.218.046.036c.169.137.38.308.54.53a2 2 0 0 1 .304.64c.073.264.072.536.071.753v9.229c0 .252 0 .498-.017.706a2.023 2.023 0 0 1-.201.77 2 2 0 0 1-.874.874 2.02 2.02 0 0 1-.77.201c-.208.017-.454.017-.706.017H5.568c-.252 0-.498 0-.706-.017a2.02 2.02 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C3 18.93 3 18.684 3 18.432V9.203c0-.217-.002-.49.07-.754a2 2 0 0 1 .304-.638c.16-.223.372-.394.541-.53l.045-.037 6.422-5.218c.161-.13.321-.26.467-.362.163-.114.362-.232.612-.302Zm.532 1.943c-.077.054-.18.136-.37.29l-6.4 5.2a6.315 6.315 0 0 0-.215.18c-.002 0-.003.002-.004.003v.004C5 9.036 5 9.112 5 9.262V18.4a8.18 8.18 0 0 0 .011.588l.014.002c.116.01.278.01.575.01H8v-5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v5h2.4a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V9.262c0-.15 0-.226-.003-.28v-.004l-.003-.003a6.448 6.448 0 0 0-.216-.18l-6.4-5.2a7.373 7.373 0 0 0-.37-.29L12 3.299l-.008.006ZM14 19v-5h-4v5h4Z',
|
||||
})
|
||||
|
||||
export const Home_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M13.261 1.736a2 2 0 0 0-2.522 0l-7 5.687A2 2 0 0 0 3 8.976V19a2 2 0 0 0 2 2h3v-8a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v8h3a2 2 0 0 0 2-2V8.976a2 2 0 0 0-.739-1.553l-7-5.687ZM14 21h-4v-7h4v7Z',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const HomeOpen_Stoke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11.37 1.724a1 1 0 0 1 1.26 0l8 6.5A1 1 0 0 1 21 9v11a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1v-5h-2v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 .37-.776l8-6.5ZM5 9.476V19h4v-5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v5h4V9.476l-7-5.688-7 5.688Z',
|
||||
})
|
||||
|
||||
export const HomeOpen_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12.63 1.724a1 1 0 0 0-1.26 0l-8 6.5A1 1 0 0 0 3 9v11a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1v-6h4v6a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V9a1 1 0 0 0-.37-.776l-8-6.5Z',
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z',
|
||||
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const MagnifyingGlass_Filled_Stroke2_Corner0_Rounded =
|
||||
createSinglePathSVG({
|
||||
path: 'M5 11a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm6-8a8 8 0 1 0 4.906 14.32l3.387 3.387a1 1 0 0 0 1.414-1.414l-3.387-3.387A8 8 0 0 0 11 3Zm4 8a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Message_Stroke2_Corner0_Rounded_Filled = createSinglePathSVG({
|
||||
path: 'M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10a9.968 9.968 0 0 1-4.136-.893l-4.68.876a1 1 0 0 1-1.164-1.184l.931-4.537A9.965 9.965 0 0 1 2 12Zm4.25 0a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Zm4.5 0a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Zm5.75 1.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
|
||||
})
|
||||
|
||||
export const Message_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 12a8 8 0 1 1 4.445 7.169 1 1 0 0 0-.629-.088l-3.537.662.7-3.415a1 1 0 0 0-.09-.66A7.961 7.961 0 0 1 4 12Zm8-10C6.477 2 2 6.477 2 12c0 1.523.341 2.968.951 4.262l-.93 4.537a1 1 0 0 0 1.163 1.184l4.68-.876A9.968 9.968 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2ZM7.5 13.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
|
||||
})
|
||||
@@ -3,3 +3,19 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
|
||||
})
|
||||
|
||||
export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z',
|
||||
})
|
||||
|
||||
export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
|
||||
export const PersonPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const PersonPlus_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H15a3 3 0 1 1 0-6c0-.824.332-1.571.87-2.113C14.739 12.32 13.435 12 12 12Zm6 2a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z',
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const OpenQuote_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M7.574 4.178a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.163 8.163 0 0 1 1.569-1.15 7.029 7.029 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588a5.99 5.99 0 0 0-.316.276C4.842 7.835 4.004 9.085 4.004 11v7h5v-6h-2a1 1 0 0 1-1-1V6.766Zm12.57-2.588a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.166 8.166 0 0 1 1.569-1.15 7.038 7.038 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588c-.105.085-.21.177-.316.276-.846.793-1.684 2.043-1.684 3.958v7h5v-6h-2a1 1 0 0 1-1-1V6.766Z',
|
||||
})
|
||||
|
||||
export const OpenQuote_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M8.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15C3.166 6.665 2.004 8.415 2.004 11v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5ZM19.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15-1.154 1.082-2.316 2.832-2.316 5.417v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5Z',
|
||||
})
|
||||
|
||||
export const CloseQuote_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M2.004 5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936A1 1 0 0 1 5.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234c.104-.085.21-.177.316-.276.846-.793 1.684-2.043 1.684-3.958V6h-5v6h2a1 1 0 0 1 1 1v4.234Zm6-12.234a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234V13a1 1 0 0 0-1-1h-2V6h5v7c0 1.915-.838 3.165-1.684 3.958-.106.1-.212.191-.316.276Z',
|
||||
})
|
||||
|
||||
export const CloseQuote_Stroke2_Corner1_Rounded = createSinglePathSVG({
|
||||
path: 'M2.003 5.999a2 2 0 0 1 2-1.999h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936a1 1 0 0 1-1.351-.935L5 14H4a2 2 0 0 1-2-2.001l.002-6Zm5 11.236L7 12.999A1 1 0 0 0 6 12H4l.003-6h5v7c0 1.915-.837 3.165-1.683 3.958-.106.1-.213.192-.317.277Zm6-11.235a2 2 0 0 1 2-2h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-1a2 2 0 0 1-2-2V6Zm7 0h-5v6h2a1 1 0 0 1 1 1v4.234c.105-.085.211-.177.317-.276.846-.793 1.684-2.043 1.684-3.958V6Z',
|
||||
})
|
||||
|
||||
export const CloseQuote_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7ZM14.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7Z',
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Repost_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M16.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-3 3a1 1 0 0 1-1.414-1.414L17.586 7H5v4a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1h13.586l-1.293-1.293a1 1 0 0 1 0-1.414ZM21 13v5a1 1 0 0 1-1 1H6.414l1.293 1.293a1 1 0 1 1-1.414 1.414l-3-3a1 1 0 0 1 0-1.414l3-3a1 1 0 0 1 1.414 1.414L6.414 17H19v-4a1 1 0 1 1 2 0Z',
|
||||
})
|
||||
|
||||
export const Repost_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const Repost_Stroke2_Corner3_Rounded = createSinglePathSVG({
|
||||
path: 'M16.793 2.293a1 1 0 0 1 1.414 0L20.5 4.586a2 2 0 0 1 0 2.828l-2.293 2.293a1 1 0 0 1-1.414-1.414L18.086 7H7a2 2 0 0 0-2 2v2a1 1 0 1 1-2 0V9a4 4 0 0 1 4-4h11.086l-1.293-1.293a1 1 0 0 1 0-1.414ZM20 12a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H5.914l1.293 1.293a1 1 0 1 1-1.414 1.414L3.5 19.414a2 2 0 0 1 0-2.828l2.293-2.293a1 1 0 0 1 1.414 1.414L5.914 17H17a2 2 0 0 0 2-2v-2a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||
})
|
||||
|
||||
export const SettingsGear2_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M9.996 2.869A1.951 1.951 0 0 1 11.62 2h.76c.653 0 1.262.326 1.624.869l1.141 1.712 1.749-.404a1.951 1.951 0 0 1 1.819.522l.588.589c.476.475.673 1.162.522 1.818l-.404 1.749 1.712 1.141c.543.362.869.971.869 1.624v.76c0 .653-.326 1.262-.869 1.624l-1.712 1.141.404 1.749a1.951 1.951 0 0 1-.522 1.819l-.588.588a1.951 1.951 0 0 1-1.819.522l-1.749-.404-1.141 1.712A1.951 1.951 0 0 1 12.38 22h-.76a1.951 1.951 0 0 1-1.624-.869L8.855 19.42l-1.749.404a1.951 1.951 0 0 1-1.818-.522l-.59-.588a1.951 1.951 0 0 1-.52-1.819l.403-1.749-1.712-1.141A1.951 1.951 0 0 1 2 12.38v-.76c0-.653.326-1.262.869-1.624L4.58 8.855l-.404-1.749A1.951 1.951 0 0 1 4.7 5.288l.589-.59a1.951 1.951 0 0 1 1.818-.52l1.749.403 1.141-1.712ZM8.5 12a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Z',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const UserCircle_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a8 8 0 0 0-5.935 13.365C7.56 15.895 9.612 15 12 15c2.388 0 4.44.894 5.935 2.365A8 8 0 0 0 12 4Zm4.412 14.675C15.298 17.636 13.792 17 12 17c-1.791 0-3.298.636-4.412 1.675A7.96 7.96 0 0 0 12 20a7.96 7.96 0 0 0 4.412-1.325ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10a9.98 9.98 0 0 1-3.462 7.567A9.965 9.965 0 0 1 12 22a9.965 9.965 0 0 1-6.538-2.433A9.98 9.98 0 0 1 2 12Zm10-4a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
|
||||
})
|
||||
|
||||
export const UserCircle_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3-12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm-3 10a7.976 7.976 0 0 1-5.714-2.4C7.618 16.004 9.605 15 12 15c2.396 0 4.383 1.005 5.714 2.6A7.976 7.976 0 0 1 12 20Z',
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import {StyleSheet, TextProps} from 'react-native'
|
||||
import type {SvgProps, PathProps} from 'react-native-svg'
|
||||
import type {PathProps, SvgProps} from 'react-native-svg'
|
||||
|
||||
import {tokens} from '#/alf'
|
||||
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {isJustAMute} from '#/lib/moderation'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
|
||||
import {atoms as a, useTheme, useBreakpoints, web} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ContentHider({
|
||||
testID,
|
||||
@@ -52,7 +51,9 @@ export function ContentHider({
|
||||
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!modui.noOverride) {
|
||||
setOverride(v => !v)
|
||||
} else {
|
||||
@@ -121,7 +122,9 @@ export function ContentHider({
|
||||
|
||||
{desc.source && blur.type === 'label' && !override && (
|
||||
<Button
|
||||
onPress={() => {
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
control.open()
|
||||
}}
|
||||
label={_(
|
||||
|
||||
@@ -3,10 +3,10 @@ import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {msg, Plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button'
|
||||
import {Button, ButtonIcon, ButtonSize, ButtonText} from '#/components/Button'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {
|
||||
LabelsOnMeDialog,
|
||||
@@ -32,9 +32,7 @@ export function LabelsOnMe({
|
||||
if (!labels || !currentAccount) {
|
||||
return null
|
||||
}
|
||||
labels = labels.filter(
|
||||
l => !l.val.startsWith('!') && l.src !== currentAccount.did,
|
||||
)
|
||||
labels = labels.filter(l => !l.val.startsWith('!'))
|
||||
if (!labels.length) {
|
||||
return null
|
||||
}
|
||||
@@ -57,13 +55,13 @@ export function LabelsOnMe({
|
||||
<Plural
|
||||
value={labels.length}
|
||||
one="# label has been placed on this account"
|
||||
other="# labels has been placed on this account"
|
||||
other="# labels have been placed on this account"
|
||||
/>
|
||||
) : (
|
||||
<Plural
|
||||
value={labels.length}
|
||||
one="# label has been placed on this content"
|
||||
other="# labels has been placed on this content"
|
||||
other="# labels have been placed on this content"
|
||||
/>
|
||||
)}
|
||||
</ButtonText>
|
||||
|
||||
@@ -3,19 +3,22 @@ import {View} from 'react-native'
|
||||
import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {KeyboardPadding} from '#/components/KeyboardPadding'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Divider} from '../Divider'
|
||||
|
||||
import {Loader} from '../Loader'
|
||||
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
||||
|
||||
type Subject =
|
||||
@@ -33,13 +36,28 @@ export interface LabelsOnMeDialogProps {
|
||||
labels: ComAtprotoLabelDefs.Label[]
|
||||
}
|
||||
|
||||
export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
||||
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<LabelsOnMeDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const [appealingLabel, setAppealingLabel] = React.useState<
|
||||
ComAtprotoLabelDefs.Label | undefined
|
||||
>(undefined)
|
||||
const {subject, labels} = props
|
||||
const isAccount = 'did' in subject
|
||||
const containsSelfLabel = React.useMemo(
|
||||
() => labels.some(l => l.src === currentAccount?.did),
|
||||
[currentAccount?.did, labels],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
@@ -65,9 +83,17 @@ export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
||||
)}
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
You may appeal these labels if you feel they were placed in error.
|
||||
</Trans>
|
||||
{containsSelfLabel ? (
|
||||
<Trans>
|
||||
You may appeal non-self labels if you feel they were placed in
|
||||
error.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You may appeal these labels if you feel they were placed in
|
||||
error.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<View style={[a.py_lg, a.gap_md]}>
|
||||
@@ -75,35 +101,28 @@ export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
||||
<Label
|
||||
key={`${label.val}-${label.src}`}
|
||||
label={label}
|
||||
isSelfLabel={label.src === currentAccount?.did}
|
||||
control={props.control}
|
||||
onPressAppeal={label => setAppealingLabel(label)}
|
||||
onPressAppeal={setAppealingLabel}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog.Close />
|
||||
<KeyboardPadding />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<LabelsOnMeDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function Label({
|
||||
label,
|
||||
isSelfLabel,
|
||||
control,
|
||||
onPressAppeal,
|
||||
}: {
|
||||
label: ComAtprotoLabelDefs.Label
|
||||
isSelfLabel: boolean
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void
|
||||
}) {
|
||||
@@ -125,32 +144,42 @@ function Label({
|
||||
{strings.description}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label={_(msg`Appeal`)}
|
||||
onPress={() => onPressAppeal(label)}>
|
||||
<ButtonText>
|
||||
<Trans>Appeal</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
{!isSelfLabel && (
|
||||
<View>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label={_(msg`Appeal`)}
|
||||
onPress={() => onPressAppeal(label)}>
|
||||
<ButtonText>
|
||||
<Trans>Appeal</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Source:</Trans>{' '}
|
||||
<InlineLinkText
|
||||
to={makeProfileLink(
|
||||
labeler ? labeler.creator : {did: label.src, handle: ''},
|
||||
)}
|
||||
onPress={() => control.close()}>
|
||||
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
|
||||
</InlineLinkText>
|
||||
{isSelfLabel ? (
|
||||
<Trans>This label was applied by you.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Source:{' '}
|
||||
<InlineLinkText
|
||||
to={makeProfileLink(
|
||||
labeler ? labeler.creator : {did: label.src, handle: ''},
|
||||
)}
|
||||
onPress={() => control.close()}>
|
||||
{labeler
|
||||
? sanitizeHandle(labeler.creator.handle, '@')
|
||||
: label.src}
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -173,14 +202,14 @@ function AppealForm({
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [details, setDetails] = React.useState('')
|
||||
const isAccountReport = 'did' in subject
|
||||
const {getAgent} = useAgent()
|
||||
const agent = useAgent()
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
const {mutate, isPending} = useMutation({
|
||||
mutationFn: async () => {
|
||||
const $type = !isAccountReport
|
||||
? 'com.atproto.repo.strongRef'
|
||||
: 'com.atproto.admin.defs#repoRef'
|
||||
await getAgent()
|
||||
await agent
|
||||
.withProxy('atproto_labeler', label.src)
|
||||
.createModerationReport({
|
||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||
@@ -190,11 +219,18 @@ function AppealForm({
|
||||
},
|
||||
reason: details,
|
||||
})
|
||||
Toast.show(_(msg`Appeal submitted`))
|
||||
} finally {
|
||||
},
|
||||
onError: err => {
|
||||
logger.error('Failed to submit label appeal', {message: err})
|
||||
Toast.show(_(msg`Failed to submit appeal, please try again.`))
|
||||
},
|
||||
onSuccess: () => {
|
||||
control.close()
|
||||
}
|
||||
}
|
||||
Toast.show(_(msg`Appeal submitted`))
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = React.useCallback(() => mutate(), [mutate])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -255,6 +291,7 @@ function AppealForm({
|
||||
onPress={onSubmit}
|
||||
label={_(msg`Submit`)}>
|
||||
<ButtonText>{_(msg`Submit`)}</ButtonText>
|
||||
{isPending && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {ModerationUI, ModerationCause} from '@atproto/api'
|
||||
import {BSKY_LABELER_DID, ModerationCause, ModerationUI} from '@atproto/api'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {getModerationCauseKey} from '#/lib/moderation'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function PostAlerts({
|
||||
modui,
|
||||
size,
|
||||
style,
|
||||
}: {
|
||||
modui: ModerationUI
|
||||
size?: 'medium' | 'large'
|
||||
includeMute?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
@@ -28,36 +31,82 @@ export function PostAlerts({
|
||||
<View style={[a.flex_col, a.gap_xs, style]}>
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
|
||||
{modui.alerts.map(cause => (
|
||||
<PostLabel key={getModerationCauseKey(cause)} cause={cause} />
|
||||
<PostLabel
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
{modui.informs.map(cause => (
|
||||
<PostLabel key={getModerationCauseKey(cause)} cause={cause} />
|
||||
<PostLabel
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PostLabel({cause}: {cause: ModerationCause}) {
|
||||
function PostLabel({
|
||||
cause,
|
||||
size,
|
||||
}: {
|
||||
cause: ModerationCause
|
||||
size?: 'medium' | 'large'
|
||||
}) {
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const desc = useModerationCauseDescription(cause)
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={desc.name}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
shape="default"
|
||||
onPress={() => {
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
control.open()
|
||||
}}
|
||||
style={[a.px_sm, a.py_xs, a.gap_xs]}>
|
||||
<ButtonIcon icon={desc.icon} position="left" />
|
||||
<ButtonText style={[a.text_left, a.leading_snug]}>
|
||||
{desc.name}
|
||||
</ButtonText>
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
a.rounded_sm,
|
||||
hovered || pressed
|
||||
? size === 'large'
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg_contrast_25
|
||||
: size === 'large'
|
||||
? t.atoms.bg_contrast_25
|
||||
: undefined,
|
||||
size === 'large'
|
||||
? {paddingLeft: 4, paddingRight: 6, paddingVertical: 2}
|
||||
: {paddingRight: 4, paddingVertical: 1},
|
||||
]}>
|
||||
{desc.sourceType === 'labeler' &&
|
||||
desc.sourceDid !== BSKY_LABELER_DID ? (
|
||||
<UserAvatar
|
||||
avatar={desc.sourceAvi}
|
||||
size={size === 'large' ? 16 : 12}
|
||||
/>
|
||||
) : (
|
||||
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
a.text_left,
|
||||
a.leading_snug,
|
||||
size === 'large' ? {fontSize: 13} : a.text_xs,
|
||||
size === 'large' ? t.atoms.text : t.atoms.text_contrast_high,
|
||||
]}>
|
||||
{desc.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||
|
||||
@@ -18,21 +18,25 @@ import {
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
interface Props extends ComponentProps<typeof Link> {
|
||||
disabled: boolean
|
||||
iconSize: number
|
||||
iconStyles: StyleProp<ViewStyle>
|
||||
modui: ModerationUI
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
interpretFilterAsBlur?: boolean
|
||||
}
|
||||
|
||||
export function PostHider({
|
||||
testID,
|
||||
href,
|
||||
disabled,
|
||||
modui,
|
||||
style,
|
||||
children,
|
||||
iconSize,
|
||||
iconStyles,
|
||||
profile,
|
||||
interpretFilterAsBlur,
|
||||
...props
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -40,14 +44,15 @@ export function PostHider({
|
||||
const {_} = useLingui()
|
||||
const [override, setOverride] = React.useState(false)
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const blur = modui.blurs[0]
|
||||
const blur =
|
||||
modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined)
|
||||
const desc = useModerationCauseDescription(blur)
|
||||
|
||||
const onBeforePress = React.useCallback(() => {
|
||||
precacheProfile(queryClient, profile)
|
||||
}, [queryClient, profile])
|
||||
|
||||
if (!blur) {
|
||||
if (!blur || (disabled && !modui.noOverride)) {
|
||||
return (
|
||||
<Link
|
||||
testID={testID}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {ModerationCause, ModerationDecision} from '@atproto/api'
|
||||
import {
|
||||
BSKY_LABELER_DID,
|
||||
ModerationCause,
|
||||
ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {getModerationCauseKey} from 'lib/moderation'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
import {getModerationCauseKey} from 'lib/moderation'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ProfileHeaderAlerts({
|
||||
moderation,
|
||||
@@ -39,6 +44,7 @@ export function ProfileHeaderAlerts({
|
||||
}
|
||||
|
||||
function ProfileLabel({cause}: {cause: ModerationCause}) {
|
||||
const t = useTheme()
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const desc = useModerationCauseDescription(cause)
|
||||
|
||||
@@ -46,18 +52,39 @@ function ProfileLabel({cause}: {cause: ModerationCause}) {
|
||||
<>
|
||||
<Button
|
||||
label={desc.name}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
shape="default"
|
||||
onPress={() => {
|
||||
control.open()
|
||||
}}
|
||||
style={[a.px_sm, a.py_xs, a.gap_xs]}>
|
||||
<ButtonIcon icon={desc.icon} position="left" />
|
||||
<ButtonText style={[a.text_left, a.leading_snug]}>
|
||||
{desc.name}
|
||||
</ButtonText>
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{paddingLeft: 6, paddingRight: 8, paddingVertical: 4},
|
||||
a.gap_xs,
|
||||
a.rounded_md,
|
||||
hovered || pressed
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
{desc.sourceType === 'labeler' &&
|
||||
desc.sourceDid !== BSKY_LABELER_DID ? (
|
||||
<UserAvatar avatar={desc.sourceAvi} size={16} />
|
||||
) : (
|
||||
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
a.text_left,
|
||||
a.leading_snug,
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.font_semibold,
|
||||
]}>
|
||||
{desc.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||
|
||||
@@ -150,6 +150,8 @@ export type TrackPropertiesMap = {
|
||||
}
|
||||
'OnboardingV2:StepModeration:Start': {}
|
||||
'OnboardingV2:StepModeration:End': {}
|
||||
'OnboardingV2:StepProfile:Start': {}
|
||||
'OnboardingV2:StepProfile:End': {}
|
||||
'OnboardingV2:StepFinished:Start': {}
|
||||
'OnboardingV2:StepFinished:End': {}
|
||||
'OnboardingV2:Complete': {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {BskyAgent, stringifyLex, jsonToLex} from '@atproto/api'
|
||||
import RNFS from 'react-native-fs'
|
||||
import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api'
|
||||
|
||||
const GET_TIMEOUT = 15e3 // 15s
|
||||
const POST_TIMEOUT = 60e3 // 60s
|
||||
@@ -68,8 +68,10 @@ async function fetchHandler(
|
||||
resBody = jsonToLex(await res.json())
|
||||
} else if (resMimeType.startsWith('text/')) {
|
||||
resBody = await res.text()
|
||||
} else if (resMimeType === 'application/vnd.ipld.car') {
|
||||
resBody = await res.arrayBuffer()
|
||||
} else {
|
||||
throw new Error('TODO: non-textual response body')
|
||||
throw new Error('Non-supported mime type')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,10 @@ export class FeedViewPostsSlice {
|
||||
?.__source as ReasonFeedSource
|
||||
}
|
||||
|
||||
get feedContext() {
|
||||
return this.items.find(item => item.feedContext)?.feedContext
|
||||
}
|
||||
|
||||
containsUri(uri: string) {
|
||||
return !!this.items.find(item => item.post.uri === uri)
|
||||
}
|
||||
|
||||
@@ -7,22 +7,22 @@ import {
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class AuthorFeedAPI implements FeedAPI {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
params: GetAuthorFeed.QueryParams
|
||||
|
||||
constructor({
|
||||
getAgent,
|
||||
agent,
|
||||
feedParams,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
feedParams: GetAuthorFeed.QueryParams
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.agent = agent
|
||||
this.params = feedParams
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.getAgent().getAuthorFeed({
|
||||
const res = await this.agent.getAuthorFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
@@ -36,7 +36,7 @@ export class AuthorFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.getAgent().getAuthorFeed({
|
||||
const res = await this.agent.getAuthorFeed({
|
||||
...this.params,
|
||||
cursor,
|
||||
limit,
|
||||
|
||||
@@ -10,27 +10,27 @@ import {FeedAPI, FeedAPIResponse} from './types'
|
||||
import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'
|
||||
|
||||
export class CustomFeedAPI implements FeedAPI {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
params: GetCustomFeed.QueryParams
|
||||
userInterests?: string
|
||||
|
||||
constructor({
|
||||
getAgent,
|
||||
agent,
|
||||
feedParams,
|
||||
userInterests,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
feedParams: GetCustomFeed.QueryParams
|
||||
userInterests?: string
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.agent = agent
|
||||
this.params = feedParams
|
||||
this.userInterests = userInterests
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const contentLangs = getContentLanguages().join(',')
|
||||
const res = await this.getAgent().app.bsky.feed.getFeed(
|
||||
const res = await this.agent.app.bsky.feed.getFeed(
|
||||
{
|
||||
...this.params,
|
||||
limit: 1,
|
||||
@@ -48,11 +48,11 @@ export class CustomFeedAPI implements FeedAPI {
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const contentLangs = getContentLanguages().join(',')
|
||||
const agent = this.getAgent()
|
||||
const agent = this.agent
|
||||
const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
|
||||
|
||||
const res = agent.session
|
||||
? await this.getAgent().app.bsky.feed.getFeed(
|
||||
? await this.agent.app.bsky.feed.getFeed(
|
||||
{
|
||||
...this.params,
|
||||
cursor,
|
||||
|
||||
@@ -3,14 +3,14 @@ import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class FollowingFeedAPI implements FeedAPI {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
|
||||
constructor({getAgent}: {getAgent: () => BskyAgent}) {
|
||||
this.getAgent = getAgent
|
||||
constructor({agent}: {agent: BskyAgent}) {
|
||||
this.agent = agent
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.getAgent().getTimeline({
|
||||
const res = await this.agent.getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
@@ -23,7 +23,7 @@ export class FollowingFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.getAgent().getTimeline({
|
||||
const res = await this.agent.getTimeline({
|
||||
cursor,
|
||||
limit,
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ export const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = {
|
||||
}
|
||||
|
||||
export class HomeFeedAPI implements FeedAPI {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
following: FollowingFeedAPI
|
||||
discover: CustomFeedAPI
|
||||
usingDiscover = false
|
||||
@@ -36,24 +36,24 @@ export class HomeFeedAPI implements FeedAPI {
|
||||
|
||||
constructor({
|
||||
userInterests,
|
||||
getAgent,
|
||||
agent,
|
||||
}: {
|
||||
userInterests?: string
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.following = new FollowingFeedAPI({getAgent})
|
||||
this.agent = agent
|
||||
this.following = new FollowingFeedAPI({agent})
|
||||
this.discover = new CustomFeedAPI({
|
||||
getAgent,
|
||||
agent,
|
||||
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
|
||||
})
|
||||
this.userInterests = userInterests
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.following = new FollowingFeedAPI({getAgent: this.getAgent})
|
||||
this.following = new FollowingFeedAPI({agent: this.agent})
|
||||
this.discover = new CustomFeedAPI({
|
||||
getAgent: this.getAgent,
|
||||
agent: this.agent,
|
||||
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
|
||||
userInterests: this.userInterests,
|
||||
})
|
||||
|
||||
@@ -7,22 +7,22 @@ import {
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class LikesFeedAPI implements FeedAPI {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
params: GetActorLikes.QueryParams
|
||||
|
||||
constructor({
|
||||
getAgent,
|
||||
agent,
|
||||
feedParams,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
feedParams: GetActorLikes.QueryParams
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.agent = agent
|
||||
this.params = feedParams
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.getAgent().getActorLikes({
|
||||
const res = await this.agent.getActorLikes({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
@@ -36,7 +36,7 @@ export class LikesFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.getAgent().getActorLikes({
|
||||
const res = await this.agent.getActorLikes({
|
||||
...this.params,
|
||||
cursor,
|
||||
limit,
|
||||
|
||||
@@ -7,22 +7,22 @@ import {
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class ListFeedAPI implements FeedAPI {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
params: GetListFeed.QueryParams
|
||||
|
||||
constructor({
|
||||
getAgent,
|
||||
agent,
|
||||
feedParams,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
feedParams: GetListFeed.QueryParams
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.agent = agent
|
||||
this.params = feedParams
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.getAgent().app.bsky.feed.getListFeed({
|
||||
const res = await this.agent.app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
@@ -36,7 +36,7 @@ export class ListFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.getAgent().app.bsky.feed.getListFeed({
|
||||
const res = await this.agent.app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
cursor,
|
||||
limit,
|
||||
|
||||
+19
-19
@@ -16,7 +16,7 @@ const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
|
||||
|
||||
export class MergeFeedAPI implements FeedAPI {
|
||||
userInterests?: string
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
params: FeedParams
|
||||
feedTuners: FeedTunerFn[]
|
||||
following: MergeFeedSource_Following
|
||||
@@ -26,29 +26,29 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
sampleCursor = 0
|
||||
|
||||
constructor({
|
||||
getAgent,
|
||||
agent,
|
||||
feedParams,
|
||||
feedTuners,
|
||||
userInterests,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
feedParams: FeedParams
|
||||
feedTuners: FeedTunerFn[]
|
||||
userInterests?: string
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.agent = agent
|
||||
this.params = feedParams
|
||||
this.feedTuners = feedTuners
|
||||
this.userInterests = userInterests
|
||||
this.following = new MergeFeedSource_Following({
|
||||
getAgent: this.getAgent,
|
||||
agent: this.agent,
|
||||
feedTuners: this.feedTuners,
|
||||
})
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.following = new MergeFeedSource_Following({
|
||||
getAgent: this.getAgent,
|
||||
agent: this.agent,
|
||||
feedTuners: this.feedTuners,
|
||||
})
|
||||
this.customFeeds = []
|
||||
@@ -60,7 +60,7 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
this.params.mergeFeedSources.map(
|
||||
feedUri =>
|
||||
new MergeFeedSource_Custom({
|
||||
getAgent: this.getAgent,
|
||||
agent: this.agent,
|
||||
feedUri,
|
||||
feedTuners: this.feedTuners,
|
||||
userInterests: this.userInterests,
|
||||
@@ -73,7 +73,7 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.getAgent().getTimeline({
|
||||
const res = await this.agent.getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
@@ -167,7 +167,7 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
}
|
||||
|
||||
class MergeFeedSource {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
feedTuners: FeedTunerFn[]
|
||||
sourceInfo: ReasonFeedSource | undefined
|
||||
cursor: string | undefined = undefined
|
||||
@@ -175,13 +175,13 @@ class MergeFeedSource {
|
||||
hasMore = true
|
||||
|
||||
constructor({
|
||||
getAgent,
|
||||
agent,
|
||||
feedTuners,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
feedTuners: FeedTunerFn[]
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.agent = agent
|
||||
this.feedTuners = feedTuners
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||
cursor: string | undefined,
|
||||
limit: number,
|
||||
): Promise<AppBskyFeedGetTimeline.Response> {
|
||||
const res = await this.getAgent().getTimeline({cursor, limit})
|
||||
const res = await this.agent.getTimeline({cursor, limit})
|
||||
// run the tuner pre-emptively to ensure better mixing
|
||||
const slices = this.tuner.tune(res.data.feed, {
|
||||
dryRun: false,
|
||||
@@ -257,27 +257,27 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||
}
|
||||
|
||||
class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
minDate: Date
|
||||
feedUri: string
|
||||
userInterests?: string
|
||||
|
||||
constructor({
|
||||
getAgent,
|
||||
agent,
|
||||
feedUri,
|
||||
feedTuners,
|
||||
userInterests,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
agent: BskyAgent
|
||||
feedUri: string
|
||||
feedTuners: FeedTunerFn[]
|
||||
userInterests?: string
|
||||
}) {
|
||||
super({
|
||||
getAgent,
|
||||
agent,
|
||||
feedTuners,
|
||||
})
|
||||
this.getAgent = getAgent
|
||||
this.agent = agent
|
||||
this.feedUri = feedUri
|
||||
this.userInterests = userInterests
|
||||
this.sourceInfo = {
|
||||
@@ -295,7 +295,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
try {
|
||||
const contentLangs = getContentLanguages().join(',')
|
||||
const isBlueskyOwned = isBlueskyOwnedFeed(this.feedUri)
|
||||
const res = await this.getAgent().app.bsky.feed.getFeed(
|
||||
const res = await this.agent.app.bsky.feed.getFeed(
|
||||
{
|
||||
cursor,
|
||||
limit,
|
||||
|
||||
+2
-13
@@ -4,7 +4,6 @@ import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedThreadgate,
|
||||
AppBskyRichtextFacet,
|
||||
BskyAgent,
|
||||
ComAtprotoLabelDefs,
|
||||
ComAtprotoRepoUploadBlob,
|
||||
@@ -15,7 +14,7 @@ import {AtUri} from '@atproto/api'
|
||||
import {logger} from '#/logger'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
||||
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
@@ -81,17 +80,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
opts.onStateChange?.('Processing...')
|
||||
await rt.detectFacets(agent)
|
||||
rt = shortenLinks(rt)
|
||||
|
||||
// filter out any mention facets that didn't map to a user
|
||||
rt.facets = rt.facets?.filter(facet => {
|
||||
const mention = facet.features.find(feature =>
|
||||
AppBskyRichtextFacet.isMention(feature),
|
||||
)
|
||||
if (mention && !mention.did) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
rt = stripInvalidMentions(rt)
|
||||
|
||||
// add quote embed if present
|
||||
if (opts.quote) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {version} from '../../package.json'
|
||||
|
||||
export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV
|
||||
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
export const IS_TESTFLIGHT = false
|
||||
|
||||
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
|
||||
// along with the other version info. Useful for debugging/reporting.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const isSafari = false
|
||||
export const isFirefox = false
|
||||
export const isTouchDevice = true
|
||||
@@ -0,0 +1,7 @@
|
||||
// https://stackoverflow.com/questions/7944460/detect-safari-browser
|
||||
export const isSafari = /^((?!chrome|android).)*safari/i.test(
|
||||
navigator.userAgent,
|
||||
)
|
||||
export const isFirefox = /firefox|fxios/i.test(navigator.userAgent)
|
||||
export const isTouchDevice =
|
||||
'ontouchstart' in window || navigator.maxTouchPoints > 1
|
||||
+26
-1
@@ -1,4 +1,5 @@
|
||||
import {Insets, Platform} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
export const LOCAL_DEV_SERVICE =
|
||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||
@@ -35,6 +36,8 @@ export const MAX_DESCRIPTION = 256
|
||||
|
||||
export const MAX_GRAPHEME_LENGTH = 300
|
||||
|
||||
export const MAX_DM_GRAPHEME_LENGTH = 1000
|
||||
|
||||
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
|
||||
// but increasing limit per user feedback
|
||||
export const MAX_ALT_TEXT = 1000
|
||||
@@ -44,7 +47,7 @@ export function IS_TEST_USER(handle?: string) {
|
||||
}
|
||||
|
||||
export function IS_PROD_SERVICE(url?: string) {
|
||||
return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
|
||||
return url && url !== STAGING_SERVICE && !url.startsWith(LOCAL_DEV_SERVICE)
|
||||
}
|
||||
|
||||
export const PROD_DEFAULT_FEED = (rkey: string) =>
|
||||
@@ -92,6 +95,28 @@ export const BSKY_FEED_OWNER_DIDS = [
|
||||
'did:plc:q6gjnaw2blty4crticxkmujt',
|
||||
]
|
||||
|
||||
export const DISCOVER_FEED_URI =
|
||||
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
|
||||
export const DISCOVER_SAVED_FEED = {
|
||||
type: 'feed',
|
||||
value: DISCOVER_FEED_URI,
|
||||
pinned: true,
|
||||
}
|
||||
export const TIMELINE_SAVED_FEED = {
|
||||
type: 'timeline',
|
||||
value: 'following',
|
||||
pinned: true,
|
||||
}
|
||||
|
||||
export const RECOMMENDED_SAVED_FEEDS: Pick<
|
||||
AppBskyActorDefs.SavedFeed,
|
||||
'type' | 'value' | 'pinned'
|
||||
>[] = [DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED]
|
||||
|
||||
export const KNOWN_SHUTDOWN_FEEDS = [
|
||||
'at://did:plc:wqowuobffl66jv3kpsvo7ak4/app.bsky.feed.generator/the-algorithm', // for you by skygaze
|
||||
]
|
||||
|
||||
export const GIF_SERVICE = 'https://gifs.bsky.app'
|
||||
|
||||
export const GIF_SEARCH = (params: string) =>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {withTiming} from 'react-native-reanimated'
|
||||
|
||||
export function ScaleAndFadeIn() {
|
||||
'worklet'
|
||||
|
||||
const animations = {
|
||||
opacity: withTiming(1),
|
||||
transform: [{scale: withTiming(1)}],
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
opacity: 0,
|
||||
transform: [{scale: 0.7}],
|
||||
}
|
||||
|
||||
return {
|
||||
animations,
|
||||
initialValues,
|
||||
}
|
||||
}
|
||||
|
||||
export function ScaleAndFadeOut() {
|
||||
'worklet'
|
||||
|
||||
const animations = {
|
||||
opacity: withTiming(0),
|
||||
transform: [{scale: withTiming(0.7)}],
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
opacity: 1,
|
||||
transform: [{scale: 1}],
|
||||
}
|
||||
|
||||
return {
|
||||
animations,
|
||||
initialValues,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {withDelay, withSequence, withTiming} from 'react-native-reanimated'
|
||||
|
||||
export function ShrinkAndPop() {
|
||||
'worklet'
|
||||
|
||||
const animations = {
|
||||
opacity: withDelay(125, withTiming(0, {duration: 125})),
|
||||
transform: [
|
||||
{
|
||||
scale: withSequence(
|
||||
withTiming(0.7, {duration: 75}),
|
||||
withTiming(1.1, {duration: 150}),
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
opacity: 1,
|
||||
transform: [{scale: 1}],
|
||||
}
|
||||
|
||||
return {
|
||||
animations,
|
||||
initialValues,
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,11 @@ export function useAccountSwitcher() {
|
||||
logger.error(`switch account: selectAccount failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
requestSwitchToAccount({requestedAccount: account.did})
|
||||
Toast.show(
|
||||
_(msg`Please sign in as @${account.handle}`),
|
||||
'circle-exclamation',
|
||||
)
|
||||
} finally {
|
||||
setPendingDid(null)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
|
||||
export function useAppState() {
|
||||
const [state, setState] = useState(AppState.currentState)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener('change', nextAppState => {
|
||||
setState(nextAppState)
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import React from 'react'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {CommonActions, useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
import {useCurrentConvoId} from 'state/messages/current-convo-id'
|
||||
import {RQKEY as RQKEY_NOTIFS} from 'state/queries/notifications/feed'
|
||||
import {invalidateCachedUnreadPage} from 'state/queries/notifications/unread'
|
||||
import {truncateAndInvalidate} from 'state/queries/util'
|
||||
import {useSession} from 'state/session'
|
||||
import {useLoggedOutViewControls} from 'state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from 'state/util'
|
||||
import {resetToTab} from '#/Navigation'
|
||||
|
||||
type NotificationReason =
|
||||
| 'like'
|
||||
| 'repost'
|
||||
| 'follow'
|
||||
| 'mention'
|
||||
| 'reply'
|
||||
| 'quote'
|
||||
| 'chat-message'
|
||||
|
||||
type NotificationPayload =
|
||||
| {
|
||||
reason: Exclude<NotificationReason, 'chat-message'>
|
||||
uri: string
|
||||
subject: string
|
||||
}
|
||||
| {
|
||||
reason: 'chat-message'
|
||||
convoId: string
|
||||
messageId: string
|
||||
recipientDid: string
|
||||
}
|
||||
|
||||
const DEFAULT_HANDLER_OPTIONS = {
|
||||
shouldShowAlert: false,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: true,
|
||||
}
|
||||
|
||||
// These need to stay outside the hook to persist between account switches
|
||||
let storedPayload: NotificationPayload | undefined
|
||||
let prevDate = 0
|
||||
|
||||
export function useNotificationsHandler() {
|
||||
const queryClient = useQueryClient()
|
||||
const {currentAccount, accounts} = useSession()
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {currentConvoId} = useCurrentConvoId()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
// On Android, we cannot control which sound is used for a notification on Android
|
||||
// 28 or higher. Instead, we have to configure a notification channel ahead of time
|
||||
// which has the sounds we want in the configuration for that channel. These two
|
||||
// channels allow for the mute/unmute functionality we want for the background
|
||||
// handler.
|
||||
React.useEffect(() => {
|
||||
if (!isAndroid) return
|
||||
Notifications.setNotificationChannelAsync('chat-messages', {
|
||||
name: 'Chat',
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
sound: 'dm.mp3',
|
||||
showBadge: true,
|
||||
vibrationPattern: [250],
|
||||
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
|
||||
})
|
||||
|
||||
Notifications.setNotificationChannelAsync('chat-messages-muted', {
|
||||
name: 'Chat - Muted',
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
sound: null,
|
||||
showBadge: true,
|
||||
vibrationPattern: [250],
|
||||
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
|
||||
})
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleNotification = (payload?: NotificationPayload) => {
|
||||
if (!payload) return
|
||||
|
||||
if (payload.reason === 'chat-message') {
|
||||
if (payload.recipientDid !== currentAccount?.did && !storedPayload) {
|
||||
storedPayload = payload
|
||||
closeAllActiveElements()
|
||||
|
||||
const account = accounts.find(a => a.did === payload.recipientDid)
|
||||
if (account) {
|
||||
onPressSwitchAccount(account, 'Notification')
|
||||
} else {
|
||||
setShowLoggedOut(true)
|
||||
}
|
||||
} else {
|
||||
navigation.dispatch(state => {
|
||||
if (state.routes[0].name === 'Messages') {
|
||||
if (
|
||||
state.routes[state.routes.length - 1].name ===
|
||||
'MessagesConversation'
|
||||
) {
|
||||
return CommonActions.reset({
|
||||
...state,
|
||||
routes: [
|
||||
...state.routes.slice(0, state.routes.length - 1),
|
||||
{
|
||||
name: 'MessagesConversation',
|
||||
params: {
|
||||
conversation: payload.convoId,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
return CommonActions.navigate('MessagesConversation', {
|
||||
conversation: payload.convoId,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
return CommonActions.navigate('MessagesTab', {
|
||||
screen: 'Messages',
|
||||
params: {
|
||||
pushToConversation: payload.convoId,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
switch (payload.reason) {
|
||||
case 'like':
|
||||
case 'repost':
|
||||
case 'follow':
|
||||
case 'mention':
|
||||
case 'quote':
|
||||
case 'reply':
|
||||
resetToTab('NotificationsTab')
|
||||
break
|
||||
// TODO implement these after we have an idea of how to handle each individual case
|
||||
// case 'follow':
|
||||
// const uri = new AtUri(payload.uri)
|
||||
// setTimeout(() => {
|
||||
// // @ts-expect-error types are weird here
|
||||
// navigation.navigate('HomeTab', {
|
||||
// screen: 'Profile',
|
||||
// params: {
|
||||
// name: uri.host,
|
||||
// },
|
||||
// })
|
||||
// }, 500)
|
||||
// break
|
||||
// case 'mention':
|
||||
// case 'reply':
|
||||
// const urip = new AtUri(payload.uri)
|
||||
// setTimeout(() => {
|
||||
// // @ts-expect-error types are weird here
|
||||
// navigation.navigate('HomeTab', {
|
||||
// screen: 'PostThread',
|
||||
// params: {
|
||||
// name: urip.host,
|
||||
// rkey: urip.rkey,
|
||||
// },
|
||||
// })
|
||||
// }, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async e => {
|
||||
if (e.request.trigger.type !== 'push') return DEFAULT_HANDLER_OPTIONS
|
||||
|
||||
logger.debug(
|
||||
'Notifications: received',
|
||||
{e},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
|
||||
const payload = e.request.trigger.payload as NotificationPayload
|
||||
if (
|
||||
payload.reason === 'chat-message' &&
|
||||
payload.recipientDid === currentAccount?.did
|
||||
) {
|
||||
return {
|
||||
shouldShowAlert: payload.convoId !== currentConvoId,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Any notification other than a chat message should invalidate the unread page
|
||||
invalidateCachedUnreadPage()
|
||||
return DEFAULT_HANDLER_OPTIONS
|
||||
},
|
||||
})
|
||||
|
||||
const responseReceivedListener =
|
||||
Notifications.addNotificationResponseReceivedListener(e => {
|
||||
if (e.notification.date === prevDate) {
|
||||
return
|
||||
}
|
||||
prevDate = e.notification.date
|
||||
|
||||
logger.debug(
|
||||
'Notifications: response received',
|
||||
{
|
||||
actionIdentifier: e.actionIdentifier,
|
||||
},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
|
||||
if (
|
||||
e.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER &&
|
||||
e.notification.request.trigger.type === 'push'
|
||||
) {
|
||||
logger.debug(
|
||||
'User pressed a notification, opening notifications tab',
|
||||
{},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
track('Notificatons:OpenApp')
|
||||
logEvent('notifications:openApp', {})
|
||||
invalidateCachedUnreadPage()
|
||||
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
|
||||
logger.debug('Notifications: handleNotification', {
|
||||
content: e.notification.request.content,
|
||||
payload: e.notification.request.trigger.payload,
|
||||
})
|
||||
handleNotification(
|
||||
e.notification.request.trigger.payload as NotificationPayload,
|
||||
)
|
||||
Notifications.dismissAllNotificationsAsync()
|
||||
}
|
||||
})
|
||||
|
||||
// Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
|
||||
// Whenever currentAccount changes, we should try to handle it again.
|
||||
if (
|
||||
storedPayload?.reason === 'chat-message' &&
|
||||
currentAccount?.did === storedPayload.recipientDid
|
||||
) {
|
||||
handleNotification(storedPayload)
|
||||
storedPayload = undefined
|
||||
}
|
||||
|
||||
return () => {
|
||||
responseReceivedListener.remove()
|
||||
}
|
||||
}, [
|
||||
queryClient,
|
||||
currentAccount,
|
||||
currentConvoId,
|
||||
accounts,
|
||||
closeAllActiveElements,
|
||||
currentAccount?.did,
|
||||
navigation,
|
||||
onPressSwitchAccount,
|
||||
setShowLoggedOut,
|
||||
])
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user