Merge branch 'main' into app-2067

This commit is contained in:
vineyardbovines
2026-04-17 09:02:19 -04:00
128 changed files with 7775 additions and 2556 deletions
+14
View File
@@ -453,6 +453,7 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://bandcamp.com',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300&mp4=videoSlugMp4&webm=videoSlugWebm',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/other/path.gif?hh=200&ww=300',
@@ -853,6 +854,19 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
// With video slug params — on native (test env), keeps gif filename,
// strips mp4/webm params. On web, would swap to video filename.
{
type: 'klipy_gif',
source: 'klipy',
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z"/></svg>

After

Width:  |  Height:  |  Size: 448 B

+5 -1
View File
@@ -9,7 +9,11 @@ export function applyTheme(theme: 'light' | 'dark') {
document.documentElement.classList.add(theme)
}
export function initSystemColorMode() {
export function initSystemColorMode({additionalBodyClasses = ''} = {}) {
if (additionalBodyClasses) {
document.body.classList.add(additionalBodyClasses)
}
applyTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
+2 -2
View File
@@ -28,7 +28,7 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
const root = document.getElementById('app')
if (!root) throw new Error('No root element')
initSystemColorMode()
initSystemColorMode({additionalBodyClasses: 'dark:bg-dimmedBgDarken'})
const agent = new AtpAgent({
service: 'https://public.api.bsky.app',
@@ -119,7 +119,7 @@ function LandingPage() {
}, [uri])
return (
<main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:bg-dimmedBgDarken dark:text-slate-200">
<main className="w-full min-h-dvh flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:text-slate-200">
<Link
href="https://bsky.social/about"
className="transition-transform hover:scale-110">
+8
View File
@@ -250,6 +250,14 @@ export default defineConfig(
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
'@typescript-eslint/await-thenable': 'warn',
"no-restricted-imports": ["error", {
"paths": [{
"name": "react",
"importNames": ["React", "default"],
"message": "React is already in the global type namespace. Use named imports for runtime modules."
}]
}],
/**
* Turn off rules that we haven't enforced thus far
*/
@@ -1,4 +1,4 @@
import * as React from 'react'
import {Component, createRef} from 'react'
import {
Dimensions,
type LayoutChangeEvent,
@@ -39,14 +39,14 @@ const IS_IOS15 =
const IS_NON_E2E_ANDROID =
Platform.OS === 'android' && Number(Platform.Version) < 35
export class BottomSheetNativeComponent extends React.Component<
export class BottomSheetNativeComponent extends Component<
BottomSheetViewProps,
{
open: boolean
viewHeight?: number
}
> {
ref = React.createRef<any>()
ref = createRef<any>()
static contextType = PortalContext
@@ -129,6 +129,7 @@ export class BottomSheetNativeComponent extends React.Component<
function BottomSheetNativeComponentInner({
children,
backgroundColor,
maxHeight,
onLayout,
onStateChange,
nativeViewRef,
@@ -156,6 +157,7 @@ function BottomSheetNativeComponentInner({
return (
<NativeView
{...rest}
maxHeight={maxHeight}
onStateChange={onStateChange}
ref={nativeViewRef}
style={{
@@ -170,6 +172,7 @@ function BottomSheetNativeComponentInner({
flex: 1,
backgroundColor,
},
maxHeight != null && {maxHeight},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
@@ -177,7 +180,9 @@ function BottomSheetNativeComponentInner({
},
extraStyles,
]}>
<View onLayout={onLayout}>
<View
onLayout={onLayout}
style={maxHeight == null ? undefined : {flex: 1}}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</View>
+4 -4
View File
@@ -81,7 +81,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.19.8",
"@atproto/api": "^0.19.9",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7",
@@ -89,8 +89,8 @@
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.2",
"@bsky.app/tapper": "^0.5.0",
"@bsky.app/sift": "^0.3.3",
"@bsky.app/tapper": "^0.5.1",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
@@ -275,7 +275,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-native": "^5.0.0",
"eslint-plugin-react-native-a11y": "^3.5.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-simple-import-sort": "^13.0.0",
"file-loader": "6.2.0",
"globals": "^17.0.0",
"husky": "^8.0.3",
@@ -0,0 +1,48 @@
diff --git a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
index 24a25ae..2c5ff6d 100644
--- a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
+++ b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
@@ -1,8 +1,6 @@
import { useCallback } from "react";
-import { Platform } from "react-native";
import { scrollTo, useAnimatedReaction } from "react-native-reanimated";
-import { IS_FABRIC } from "../../../architecture";
import { isScrollAtEnd, shouldShiftContent } from "../useChatKeyboard/helpers";
import type { KeyboardLiftBehavior } from "../useChatKeyboard/types";
@@ -52,7 +50,6 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
scroll,
layout,
size,
- contentOffsetY,
inverted,
keyboardLiftBehavior,
freeze,
@@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
(target: number) => {
"worklet";
- if (contentOffsetY && IS_FABRIC) {
- // eslint-disable-next-line react-compiler/react-compiler
- contentOffsetY.value = target;
- } else if (Platform.OS === "android") {
- // Defer scrollTo so the animatedProps inset commit lands first;
- // otherwise the native ScrollView clamps to the old range.
- requestAnimationFrame(() => {
- scrollTo(scrollViewRef, 0, target, false);
- });
- } else {
+ // Always defer scrollTo so the animatedProps inset commit lands first;
+ // otherwise the native ScrollView clamps contentOffset to the old
+ // contentInset range (iOS Fabric) or the old contentInsetBottom (Android).
+ requestAnimationFrame(() => {
scrollTo(scrollViewRef, 0, target, false);
- }
+ });
},
- [scrollViewRef, contentOffsetY],
+ [scrollViewRef],
);
useAnimatedReaction(
+6
View File
@@ -78,6 +78,7 @@ import HashtagScreen from '#/screens/Hashtag'
import {LogScreen} from '#/screens/Log'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
@@ -568,6 +569,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesConversationScreen}
options={{title: title(msg`Chat`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesConversationSettings"
getComponent={() => MessagesConversationSettingsScreen}
options={{title: title(msg`Group chat settings`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesSettings"
getComponent={() => MessagesSettingsScreen}
@@ -20,8 +20,8 @@ import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAp
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
import {Full as Logo} from '#/components/icons/Logo'
+16
View File
@@ -2,6 +2,8 @@ import {useEffect, useRef} from 'react'
import {getCurrentState, onAppStateChange} from '#/lib/appState'
import {useAnalytics} from '#/analytics'
import {Features, features} from '#/analytics/features'
import {IS_DEV, IS_TESTFLIGHT} from '#/env'
/**
* Tracks passive analytics like app foreground/background time.
@@ -24,6 +26,20 @@ export function PassiveAnalytics() {
),
})
}
if (IS_DEV || IS_TESTFLIGHT) {
const feats = Object.values(Features).reduce(
(acc, feat) => {
acc[feat] = features.evalFeature(feat)
return acc
},
{} as Record<Features, any>,
)
ax.logger.info('FEATURES', {
features: feats,
definitions: features.getFeatures(),
})
}
})
return () => sub.remove()
}, [ax])
+9 -1
View File
@@ -2,11 +2,13 @@ import {MMKV} from '@bsky.app/react-native-mmkv'
import {setPolyfills} from '@growthbook/growthbook'
import {GrowthBook} from '@growthbook/growthbook-react'
import {Logger} from '#/logger'
import {getNavigationMetadata, type Metadata} from '#/analytics/metadata'
import * as env from '#/env'
export {Features} from '#/analytics/features/types'
const logger = Logger.create(Logger.Context.Growthbook)
const CACHE = new MMKV({id: 'bsky_features_cache'})
setPolyfills({
@@ -44,7 +46,13 @@ export const features = new GrowthBook({
* initialization completes.
*/
export const init = new Promise<void>(async y => {
await features.init({timeout: TIMEOUT_INIT})
const res = await features.init({timeout: TIMEOUT_INIT})
if (!res.success) {
logger.warn('GrowthBook initialization failed or timed out', {
source: res.source,
safeMessage: res.error?.toString(),
})
}
y()
})
+1
View File
@@ -14,6 +14,7 @@ export enum Features {
GroupChatsEnable = 'group_chats:enable',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
AATest = 'aa-test',
}
+18
View File
@@ -563,6 +563,9 @@ export type Events = {
| 'ChatsList'
| 'SendViaChatDialog'
}
'groupchat:create': {
logContext: 'NewChatDialog'
}
'starterPack:addUser': {
starterPack?: string
}
@@ -1043,4 +1046,19 @@ export type Events = {
'profile:associated:germ:click-self-info': {}
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}
// Gallery carousel events
'post:gallery:swipe': {
fromImage: number
toImage: number
totalImages: number
}
'post:gallery:openLightbox': {
fromImage: number
totalImages: number
}
'post:gallery:impression': {
totalImages: number
postUri: string
}
}
+11 -1
View File
@@ -58,7 +58,17 @@ export function Autocomplete({
data={data}
onSelect={onSelect}
onDismiss={onDismiss}
style={[
outerStyle={[
a.rounded_md,
a.w_full,
t.atoms.shadow_lg,
IS_WEB
? {
maxWidth: 300,
}
: {},
]}
innerStyle={[
a.overflow_hidden,
a.rounded_md,
a.border,
+274
View File
@@ -0,0 +1,274 @@
import {useCallback, useEffect} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import Animated, {
Easing,
interpolate,
useAnimatedStyle,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import type * as bsky from '#/types/bsky'
type Props = {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
size?: 'small' | 'medium' | 'large' | number
}
export function AvatarBubbles({
animate = false,
profiles: allProfiles,
size = 'large',
}: Props) {
const {currentAccount} = useSession()
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
const containerSize =
typeof size === 'number'
? size
: size === 'small'
? 40
: size === 'medium'
? 56
: 120
const scale =
typeof size === 'number'
? size / 120
: size === 'small'
? 40 / 120
: size === 'medium'
? 56 / 120
: 1
const marginOffset = size === 'small' || size === 'medium' ? -2 : 0
const initialValue = animate ? 0 : 1
const p0 = useSharedValue(initialValue)
const p1 = useSharedValue(initialValue)
const p2 = useSharedValue(initialValue)
const p3 = useSharedValue(initialValue)
const animateScale = (p: Animated.SharedValue<number>, index: number) => {
p.set(0)
p.set(() =>
withDelay(
500 + index * 100,
withTiming(1, {
duration: 250,
easing: Easing.out(Easing.back(1.75)),
}),
),
)
}
const playScaleAnimation = useCallback(() => {
animateScale(p0, 0)
animateScale(p1, 1)
animateScale(p2, 2)
animateScale(p3, 3)
}, [p0, p1, p2, p3])
useEffect(() => {
if (!animate) return
playScaleAnimation()
}, [animate, playScaleAnimation])
let avatars = (
<>
<AvatarBubble
profile={profiles[0] ?? allProfiles[0]}
scale={p0}
size={76}
x={-2}
y={-2}
style={[a.z_20]}
includeProfileBorder
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={76}
x={42}
y={42}
style={[a.z_10]}
includeProfileBorder
/>
</>
)
if (profiles.length === 3) {
avatars = (
<>
<AvatarBubble
profile={profiles[0]}
scale={p0}
size={68}
x={-2}
y={-2}
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={56}
x={38}
y={62}
/>
<AvatarBubble
profile={profiles[2]}
scale={p2}
size={46}
x={71}
y={18}
/>
</>
)
}
if (profiles.length >= 4) {
avatars = (
<>
<AvatarBubble
profile={profiles[0]}
scale={p0}
size={68}
x={-2}
y={-2}
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={56}
x={60}
y={49}
/>
<AvatarBubble
profile={profiles[2]}
scale={p2}
size={42}
x={14}
y={74}
/>
<AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} />
</>
)
}
return (
<Animated.View
style={[
a.p_2xs,
{
height: containerSize,
width: containerSize,
},
]}>
<View
style={[
{
marginTop: marginOffset,
marginLeft: marginOffset,
transform: [{scale}],
transformOrigin: 'top left',
},
]}>
{avatars}
</View>
</Animated.View>
)
}
function AvatarBubble({
profile,
scale,
size,
style,
x,
y,
includeProfileBorder,
}: {
profile?: bsky.profile.AnyProfileView
scale: Animated.SharedValue<number>
size: number
style?: StyleProp<ViewStyle>
x: number
y: number
includeProfileBorder?: boolean
}) {
const t = useTheme()
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{translateX: x},
{translateY: y},
{scale: interpolate(scale.get(), [0, 1], [0, 1])},
],
}))
return (
<Animated.View
style={[
a.absolute,
a.rounded_full,
a.flex_grow_0,
{transform: [{translateX: x}, {translateY: y}]},
includeProfileBorder && {
borderColor: t.atoms.text_inverted.color,
borderWidth: 2,
},
style,
animatedStyle,
]}>
{profile ? (
<Avatar profile={profile} size={size} />
) : (
<AvatarPlaceholder size={size} />
)}
</Animated.View>
)
}
function Avatar({
profile,
size = 76,
}: {
profile: bsky.profile.AnyProfileView
size?: number
}) {
return (
<UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
/>
)
}
function AvatarPlaceholder({size = 76}: {size?: number}) {
const t = useTheme()
return (
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_200,
{
width: size,
height: size,
},
]}>
<PersonIcon
width={size * 0.5}
height={size * 0.5}
fill={t.atoms.text_inverted.color}
/>
</View>
)
}
+26 -2
View File
@@ -235,7 +235,13 @@ export function Root({children}: {children: React.ReactNode}) {
return <Context.Provider value={context}>{children}</Context.Provider>
}
export function Trigger({children, label, contentLabel, style}: TriggerProps) {
export function Trigger({
children,
label,
contentLabel,
style,
onTap,
}: TriggerProps) {
const context = useContextMenuContext()
const playHaptic = useHaptics()
const insets = useSafeAreaInsets()
@@ -294,6 +300,17 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
}
}, [context, insets])
const tapGesture = useMemo(() => {
const gesture = Gesture.Tap()
.numberOfTaps(1)
.cancelsTouchesInView(false)
.runOnJS(true)
if (onTap) {
gesture.onEnd(() => void onTap())
}
return gesture
}, [onTap])
const doubleTapGesture = useMemo(() => {
return Gesture.Tap()
.numberOfTaps(2)
@@ -346,8 +363,10 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
})
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
// Order matters here: doubleTapGesture must come before tapGesture.
const composedGestures = Gesture.Exclusive(
doubleTapGesture,
tapGesture,
pressAndHoldGesture,
)
@@ -482,7 +501,11 @@ function TriggerClone({
)
}
export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
export function AuxiliaryView({
children,
align = 'left',
style,
}: AuxiliaryViewProps) {
const context = useContextMenuContext()
const {width: screenWidth} = useWindowDimensions()
const {top: topInset} = useSafeAreaInsets()
@@ -556,6 +579,7 @@ export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
: {right: screenWidth - measurement.x - measurement.width},
animatedStyle,
a.z_20,
style,
]}>
{children}
</Animated.View>
+9
View File
@@ -21,6 +21,7 @@ export type {
export type AuxiliaryViewProps = {
children?: React.ReactNode
align?: 'left' | 'right'
style?: StyleProp<ViewStyle>
}
export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & {
@@ -83,6 +84,14 @@ export type TriggerProps = {
hint?: string
role?: AccessibilityRole
style?: StyleProp<ViewStyle>
/**
* Callback for single taps. Composed with the double-tap and
* press-and-hold gestures via `Gesture.Exclusive`, so a double tap
* does not also fire this handler.
*
* @platform ios, android
*/
onTap?: () => void
}
export type TriggerChildProps =
| {
+1
View File
@@ -23,6 +23,7 @@ export const Context = createContext<DialogContextProps>({
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: false,
isHeightConstrained: false,
})
Context.displayName = 'DialogContext'
+11 -4
View File
@@ -157,6 +157,8 @@ export function Outer({
[open, close],
)
const isHeightConstrained = nativeOptions?.maxHeight != null
const context = useMemo(
() => ({
close,
@@ -165,8 +167,9 @@ export function Outer({
disableDrag,
setDisableDrag,
isWithinDialog: true,
isHeightConstrained,
}),
[close, snapPoint, disableDrag, setDisableDrag],
[close, snapPoint, disableDrag, setDisableDrag, isHeightConstrained],
)
return (
@@ -180,7 +183,9 @@ export function Outer({
onStateChange={onStateChange}
disableDrag={disableDrag}>
<Context.Provider value={context}>
<View testID={testID} style={[a.relative]}>
<View
testID={testID}
style={[a.relative, isHeightConstrained && a.flex_1]}>
{children}
</View>
</Context.Provider>
@@ -213,10 +218,11 @@ export function Inner({children, style, header}: DialogInnerProps) {
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner(
{children, contentContainerStyle, header, ...props},
{children, contentContainerStyle, header, style, ...props},
ref,
) {
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
useDialogContext()
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
const insets = useSafeAreaInsets()
const [keyboardHeight, setKeyboardHeight] = useState(() =>
@@ -243,6 +249,7 @@ export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
return (
<ScrollView
style={[isHeightConstrained && a.flex_1, style]}
contentContainerStyle={[
a.pt_2xl,
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
+2
View File
@@ -111,6 +111,7 @@ export function Outer({
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: true,
isHeightConstrained: false,
}),
[close],
)
@@ -196,6 +197,7 @@ export function Inner({
a.border,
t.atoms.bg,
{
cursor: 'default', // The overlay applies `cursor: 'pointer'` to all children.
maxWidth: 600,
borderColor: t.palette.contrast_200,
shadowColor: t.palette.black,
+1
View File
@@ -45,6 +45,7 @@ export type DialogContextProps = {
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
// in the event that the hook is used outside of a dialog
isWithinDialog: boolean
isHeightConstrained: boolean
}
export type DialogControlOpenOptions = {
+40
View File
@@ -0,0 +1,40 @@
import {type PickerProps, type RootProps, type TriggerProps} from './types'
export * from './types'
/**
* Provides emoji picker context and wraps children in a {@link Menu.Root}.
*
* On emoji select, fires a `textInputWebEmitter` event (for web text inputs
* that listen for emoji insertions) and forwards to the optional
* `onEmojiSelect` callback.
*
* @platform web
*/
export function Root(_props: RootProps): React.ReactNode {
throw new Error('EmojiPopup is not implemented on native')
}
/**
* Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
* pattern.
*
* @platform web
*/
export function Trigger(_props: TriggerProps): React.ReactNode {
throw new Error('EmojiPopup is not implemented on native')
}
/**
* Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
*
* Holding Shift while selecting an emoji keeps the picker open for
* multi-select. Otherwise the menu closes after each selection.
*
* Must be rendered inside a {@link Root}.
*
* @platform web
*/
export function Picker(_props: PickerProps): React.ReactNode {
throw new Error('EmojiPopup is not implemented on native')
}
+150
View File
@@ -0,0 +1,150 @@
import {createContext, useContext, useEffect, useMemo, useRef} from 'react'
import EmojiPicker from '@emoji-mart/react'
import {DropdownMenu} from 'radix-ui'
import {useA11y} from '#/state/a11y'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a, flatten} from '#/alf'
import * as Menu from '../Menu'
import {useWebPreloadEmoji} from './preload'
import {
type Emoji,
type PickerProps,
type RootProps,
type TriggerProps,
} from './types'
export * from './types'
const EmojiPickerContext = createContext<{
onEmojiSelect: (emoji: Emoji) => void
nextFocusRef: RootProps['nextFocusRef']
} | null>(null)
/**
* Provides emoji picker context and wraps children in a {@link Menu.Root}.
*
* On emoji select, fires a `textInputWebEmitter` event (for web text inputs
* that listen for emoji insertions) and forwards to the optional
* `onEmojiSelect` callback.
*
* @platform web
*/
export function Root({
children,
control,
onEmojiSelect,
preloadOnMount = true,
nextFocusRef,
}: RootProps) {
useWebPreloadEmoji({immediate: preloadOnMount})
const value = useMemo(
() => ({
onEmojiSelect: (emoji: Emoji) => {
textInputWebEmitter.emit('emoji-inserted', emoji)
if (onEmojiSelect) onEmojiSelect(emoji)
},
nextFocusRef,
}),
[onEmojiSelect, nextFocusRef],
)
return (
<EmojiPickerContext value={value}>
<Menu.Root control={control}>{children}</Menu.Root>
</EmojiPickerContext>
)
}
/**
* Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
* pattern.
*
* @platform web
*/
export function Trigger(props: TriggerProps) {
return <Menu.Trigger {...props} />
}
/**
* Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
*
* Holding Shift while selecting an emoji keeps the picker open for
* multi-select. Otherwise the menu closes after each selection.
*
* Must be rendered inside a {@link Root}.
*
* @platform web
*/
export function Picker({keepOpenWhenShiftHeld = true}: PickerProps) {
const {onEmojiSelect, nextFocusRef} = useEmojiPickerContext()
const {control} = Menu.useMenuContext()
const {reduceMotionEnabled} = useA11y()
const isShiftDown = useRef(false)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
isShiftDown.current = true
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
isShiftDown.current = false
}
}
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('keyup', onKeyUp, true)
return () => {
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('keyup', onKeyUp, true)
}
}, [])
return (
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={5}
collisionPadding={{left: 5, right: 5, bottom: 5}}
className="dropdown-menu-transform-origin dropdown-menu-constrain-size"
onCloseAutoFocus={evt => {
if (!nextFocusRef) return
let element =
nextFocusRef instanceof Function
? nextFocusRef()
: nextFocusRef.current
if (element) {
evt.preventDefault()
element.focus()
}
}}>
<div
onWheel={evt => evt.stopPropagation()}
style={flatten([!reduceMotionEnabled && a.zoom_fade_in])}>
<EmojiPicker
autoFocus
onEmojiSelect={(emoji: Emoji) => {
onEmojiSelect(emoji)
if (!keepOpenWhenShiftHeld || !isShiftDown.current) {
control.close()
}
}}
/>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
)
}
function useEmojiPickerContext() {
const ctx = useContext(EmojiPickerContext)
if (!ctx)
throw new Error(
'EmojiPicker.Picker must be used within an EmojiPicker.Root component',
)
return ctx
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Native no-op. Emoji data preloading is only needed on web where the picker
* uses `emoji-mart`.
*/
export function useWebPreloadEmoji({}: {immediate?: boolean} = {}) {
return () => Promise.resolve()
}
@@ -7,8 +7,14 @@ import {init} from 'emoji-mart'
let loadRequested = false
/**
* Preload the emoji picker data to prevent flash.
* {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194}
* Preloads emoji-mart data so the picker renders instantly when opened.
*
* Returns a function that can be called manually to trigger preloading (e.g.
* on hover). When `immediate` is `true`, preloading starts on mount.
*
* Data is only fetched once per page load subsequent calls are no-ops.
*
* @see {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194 | emoji-mart preloading docs}
*/
export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) {
const preload = useCallback(async () => {
+65
View File
@@ -0,0 +1,65 @@
import {type DialogControlProps} from '../Dialog'
import {type TriggerProps as MenuTriggerProps} from '../Menu/types'
/**
* Represents an emoji selected from the picker. Sourced from the `emoji-mart`
* library's selection data.
*/
export type Emoji = {
aliases?: string[]
emoticons: string[]
id: string
keywords: string[]
name: string
/** The native unicode character for the emoji, e.g. "😀" */
native: string
shortcodes?: string
/** The unicode codepoint, e.g. "1f600" */
unified: string
/** Skin tone variant (16), if applicable */
skin?: number
}
type FocusableElement = {focus: () => void}
export interface RootProps {
children: React.ReactNode
control?: DialogControlProps
/**
* Called when the user selects an emoji. On web this fires in addition to
* the `textInputWebEmitter` event, so callers that only need the text
* insertion can omit this.
*/
onEmojiSelect?: (emoji: Emoji) => void
/**
* When `true` (default), preloads emoji data as soon as the component
* mounts so the picker opens instantly. Set to `false` to defer loading
* until the picker is actually opened.
*/
preloadOnMount?: boolean
/**
* Element to return focus to when the picker closes. Accepts either a ref
* or a getter function.
*/
nextFocusRef?:
| React.RefObject<FocusableElement | null>
| (() => FocusableElement | null | undefined)
}
/**
* Props for the trigger button that opens the emoji picker. Extends
* {@link MenuTriggerProps} — accepts the same render-prop children pattern.
*/
export interface TriggerProps extends MenuTriggerProps {}
/**
* Props for the picker panel itself.
*/
export interface PickerProps {
/**
* When `true`, the picker will remain open after selecting an emoji when the Shift key is held down.
*
* @default true
*/
keepOpenWhenShiftHeld?: boolean
}
+2 -4
View File
@@ -60,8 +60,7 @@ export function Error({
color="primary"
label={_(msg`Press to retry`)}
onPress={onRetry}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
size="large">
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
@@ -73,8 +72,7 @@ export function Error({
color={onRetry ? 'secondary' : 'primary'}
label={_(msg`Return to previous page`)}
onPress={goBack}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
size="large">
<ButtonText>
<Trans>Go Back</Trans>
</ButtonText>
+28 -30
View File
@@ -1,19 +1,15 @@
import {InteractionManager, View} from 'react-native'
import {
type AnimatedRef,
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
} from 'react-native-reanimated'
import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {useLightboxControls} from '#/state/lightbox'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
@@ -23,8 +19,10 @@ export function ImageEmbed({
}: CommonProps & {
embed: EmbedType<'images'>
}) {
const ax = useAnalytics()
const {openLightbox} = useLightboxControls()
const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
if (images.length > 0) {
const items = images.map(img => ({
@@ -33,34 +31,21 @@ export function ImageEmbed({
alt: img.alt,
dimensions: img.aspectRatio ?? null,
}))
const _openLightbox = (
index: number,
thumbRects: (MeasuredDimensions | null)[],
fetchedDims: (Dimensions | null)[],
) => {
openLightbox({
images: items.map((item, i) => ({
...item,
thumbRect: thumbRects[i] ?? null,
thumbDimensions: fetchedDims[i] ?? null,
type: 'image',
})),
index,
})
}
const onPress = (
index: number,
refs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => {
runOnUI(() => {
'worklet'
const rects: (MeasuredDimensions | null)[] = []
for (const r of refs) {
rects.push(measure(r))
}
runOnJS(_openLightbox)(index, rects, fetchedDims)
})()
openLightbox({
images: items.map((item, i) => ({
...item,
thumbRect: null,
thumbRef: refs[i] ?? null,
thumbDimensions: fetchedDims[i] ?? null,
type: 'image',
})),
index,
})
}
const onPressIn = (_: number) => {
InteractionManager.runAfterInteractions(() => {
@@ -95,6 +80,19 @@ export function ImageEmbed({
)
}
if (galleryEnabled) {
return (
<View style={[a.mt_sm, rest.style]}>
<Gallery
images={images}
onPress={onPress}
onPressIn={onPressIn}
viewContext={rest.viewContext}
/>
</View>
)
}
return (
<View style={[a.mt_sm, rest.style]}>
<ImageLayoutGrid
+42 -38
View File
@@ -19,6 +19,7 @@ import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {GalleryBleed} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText'
@@ -308,6 +309,7 @@ export function QuoteEmbed({
<Embed
embed={quote.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
@@ -319,43 +321,45 @@ export function QuoteEmbed({
)
return (
<View
style={[a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
activeStyle={[a.p_md, a.pt_sm]}
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && !linkDisabled && (
<SubtleHover
native
hover={hover || pressed}
style={[a.rounded_md]}
/>
)}
{linkDisabled ? (
<View style={[!active && a.p_md]} pointerEvents="none">
{contents}
</View>
) : (
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
{contents}
</Link>
)}
</>
)}
</ContentHider>
</View>
<GalleryBleed>
<View
style={[a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
activeStyle={[a.p_md, a.pt_sm]}
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && !linkDisabled && (
<SubtleHover
native
hover={hover || pressed}
style={[a.rounded_md]}
/>
)}
{linkDisabled ? (
<View style={[!active && a.p_md]} pointerEvents="none">
{contents}
</View>
) : (
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
{contents}
</Link>
)}
</>
)}
</ContentHider>
</View>
</GalleryBleed>
)
}
@@ -11,8 +11,8 @@ import {useLingui} from '@lingui/react/macro'
import {type Shadow} from '#/state/cache/post-shadow'
import {EventStopper} from '#/view/com/util/EventStopper'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {useMenuControl} from '#/components/Menu'
import * as Menu from '#/components/Menu'
import {useMenuControl} from '#/components/Menu'
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
import {PostMenuItems} from './PostMenuItems'
@@ -6,21 +6,21 @@ import {Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, tokens, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button} from '#/components/Button'
import {useDialogContext} from '#/components/Dialog'
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
export function RecentChats({
postUri,
@@ -60,23 +60,24 @@ export function RecentChats({
showsHorizontalScrollIndicator={false}
nestedScrollEnabled>
{convos && convos.length > 0 ? (
convos.map(convo => {
const otherMember = convo.members.find(
member => member.did !== currentAccount?.did,
)
convos.map(c => {
const convo = parseConvoView(c, currentAccount?.did)
if (!convo) return null
if (
!otherMember ||
otherMember.handle === 'missing.invalid' ||
convo.muted
)
(convo.kind === 'direct' &&
convo.primaryMember.handle === 'missing.invalid') ||
convo.view.muted
) {
return null
}
return (
<RecentChatItem
key={convo.id}
profile={otherMember}
onPress={() => onSelectChat(convo.id)}
key={convo.view.id}
convo={convo}
onPress={() => onSelectChat(convo.view.id)}
moderationOpts={moderationOpts}
/>
)
@@ -99,26 +100,33 @@ export function RecentChats({
const WIDTH = 80
function RecentChatItem({
profile: profileUnshadowed,
onPress,
moderationOpts,
convo,
}: {
profile: bsky.profile.AnyProfileView
onPress: () => void
moderationOpts: ModerationOpts
convo: ConvoWithDetails
}) {
const {_} = useLingui()
const t = useTheme()
const profile = useProfileShadow(profileUnshadowed)
const primaryProfile = useProfileShadow(convo.primaryMember)
const moderation = moderateProfile(profile, moderationOpts)
const name = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const moderation = moderateProfile(primaryProfile, moderationOpts)
const name =
convo.kind === 'group'
? convo.details.name
: createSanitizedDisplayName(
primaryProfile,
true,
moderation.ui('displayName'),
)
if (isBlockedOrBlocking(profile) || isMuted(profile)) {
if (
convo.kind === 'direct' &&
(isBlockedOrBlocking(primaryProfile) || isMuted(primaryProfile))
) {
return null
}
@@ -133,12 +141,16 @@ function RecentChatItem({
a.justify_start,
a.align_center,
]}>
<UserAvatar
avatar={profile.avatar}
size={WIDTH - 8}
type={profile.associated?.labeler ? 'labeler' : 'user'}
moderation={moderation.ui('avatar')}
/>
{convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size={WIDTH - 8} />
) : (
<UserAvatar
avatar={primaryProfile.avatar}
size={WIDTH - 8}
type={primaryProfile.associated?.labeler ? 'labeler' : 'user'}
moderation={moderation.ui('avatar')}
/>
)}
<View style={[a.flex_row, a.align_center, a.justify_center, a.w_full]}>
<Text
emoji
@@ -146,7 +158,13 @@ function RecentChatItem({
numberOfLines={1}>
{name}
</Text>
<ProfileBadges profile={profile} size="xs" style={[a.pl_2xs]} />
{convo.kind === 'direct' && (
<ProfileBadges
profile={primaryProfile}
size="xs"
style={[a.pl_2xs]}
/>
)}
</View>
</Button>
)
@@ -18,8 +18,8 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {EventStopper} from '#/view/com/util/EventStopper'
import {native} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
import {useMenuControl} from '#/components/Menu'
import * as Menu from '#/components/Menu'
import {useMenuControl} from '#/components/Menu'
import {useAnalytics} from '#/analytics'
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
import {ShareMenuItems} from './ShareMenuItems'
+31 -16
View File
@@ -109,21 +109,32 @@ export function FollowDialogWithoutGuide({
let lastSelectedInterest = ''
let lastSearchText = ''
const FOR_YOU_TAB = 'all'
function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const {t: l} = useLingui()
const ax = useAnalytics()
const interestsDisplayNames = useInterestsDisplayNames()
const rawInterestsDisplayNames = useInterestsDisplayNames()
const {data: preferences} = usePreferencesQuery()
const personalizedInterests = preferences?.interests?.tags
const interests = Object.keys(interestsDisplayNames)
.sort(boostInterests(popularInterests))
.sort(boostInterests(personalizedInterests))
const interests = useMemo(
() => [
FOR_YOU_TAB,
...Object.keys(rawInterestsDisplayNames)
.sort(boostInterests(popularInterests))
.sort(boostInterests(personalizedInterests)),
],
[rawInterestsDisplayNames, personalizedInterests],
)
const interestsDisplayNames = useMemo(
() => ({
[FOR_YOU_TAB]: l`For You`,
...rawInterestsDisplayNames,
}),
[l, rawInterestsDisplayNames],
)
const [selectedInterest, setSelectedInterest] = useState(
() =>
lastSelectedInterest ||
(personalizedInterests && interests.includes(personalizedInterests[0])
? personalizedInterests[0]
: interests[0]),
() => lastSelectedInterest || FOR_YOU_TAB,
)
const [searchText, setSearchText] = useState(lastSearchText)
const moderationOpts = useModerationOpts()
@@ -137,14 +148,15 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
lastSelectedInterest = selectedInterest
}, [searchText, selectedInterest])
const {
data: suggestions,
isFetching: isFetchingSuggestions,
error: suggestionsError,
} = useGetSuggestedUsersForSeeMoreQuery({
category: selectedInterest,
const isForYou = selectedInterest === FOR_YOU_TAB
const seeMoreQuery = useGetSuggestedUsersForSeeMoreQuery({
category: isForYou ? undefined : selectedInterest,
limit: 50,
})
const suggestions = seeMoreQuery.data
const isFetchingSuggestions = seeMoreQuery.isFetching
const suggestionsError = seeMoreQuery.error
const {
data: searchResults,
isFetching: isFetchingSearchResults,
@@ -277,7 +289,10 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
recId: recIdForLogging,
position: position !== -1 ? position : 0,
suggestedDid: item.profile.did,
category: selectedInterestRef.current,
category:
selectedInterestRef.current === FOR_YOU_TAB
? null
: selectedInterestRef.current,
})
}
}
+1 -1
View File
@@ -10,8 +10,8 @@ import {shareUrl} from '#/lib/sharing'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {type DialogControlProps} from '#/components/Dialog'
import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import {QrCode_Stroke2_Corner0_Rounded as QrCodeIcon} from '#/components/icons/QrCode'
+184 -33
View File
@@ -8,11 +8,9 @@ import {
} from 'react'
import {TextInput, View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
@@ -23,7 +21,11 @@ import {type ListMethods} from '#/view/com/util/List'
import {android, atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {canBeMessaged} from '#/components/dms/util'
import {
canBeMessaged,
type ConvoWithDetails,
parseConvoView,
} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -31,6 +33,9 @@ import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky'
import {AvatarBubbles} from '../AvatarBubbles'
import {Error} from '../Error'
import {ProfileBadges} from '../ProfileBadges'
export type ProfileItem = {
type: 'profile'
@@ -38,6 +43,12 @@ export type ProfileItem = {
profile: bsky.profile.AnyProfileView
}
type ExistingChatItem = {
type: 'existingChat'
key: string
convo: ConvoWithDetails
}
type EmptyItem = {
type: 'empty'
key: string
@@ -54,7 +65,12 @@ type ErrorItem = {
key: string
}
type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
type Item =
| ProfileItem
| ExistingChatItem
| EmptyItem
| PlaceholderItem
| ErrorItem
export function SearchablePeopleList({
title,
@@ -72,12 +88,14 @@ export function SearchablePeopleList({
onSelectChat?: undefined
}
| {
onSelectChat: (did: string) => void
onSelectChat: (
chat: {kind: 'user'; did: string} | {kind: 'convo'; id: string},
) => void
renderProfileCard?: undefined
}
)) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const [headerHeight, setHeaderHeight] = useState(0)
@@ -105,7 +123,7 @@ export function SearchablePeopleList({
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
message: l`We're having network issues, try again`,
})
} else if (searchText.length) {
if (results?.length) {
@@ -139,20 +157,27 @@ export function SearchablePeopleList({
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 convoView of page.convos) {
const convo = parseConvoView(convoView, currentAccount?.did)
for (const profile of profiles) {
if (usedDids.has(profile.did)) continue
if (!convo) continue
usedDids.add(profile.did)
if (convo.kind === 'group') {
_items.push({
type: 'existingChat',
key: convo.view.id,
convo,
})
} else {
if (convo.primaryMember.handle === 'missing.invalid') continue
if (usedDids.has(convo.primaryMember.did)) continue
usedDids.add(convo.primaryMember.did)
_items.push({
type: 'profile',
key: profile.did,
profile,
type: 'existingChat',
key: convo.view.id,
convo: convo,
})
}
}
@@ -209,7 +234,7 @@ export function SearchablePeopleList({
return _items
}, [
_,
l,
searchText,
results,
isError,
@@ -221,12 +246,27 @@ export function SearchablePeopleList({
])
if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
items.push({type: 'empty', key: 'empty', message: l`No results`})
}
const renderItems = useCallback(
({item}: {item: Item}) => {
switch (item.type) {
case 'existingChat': {
if (renderProfileCard) {
// should be unreachable
return null
} else {
return (
<ExistingChatCard
key={item.key}
convo={item.convo}
moderationOpts={moderationOpts!}
onPress={id => onSelectChat({kind: 'convo', id})}
/>
)
}
}
case 'profile': {
if (renderProfileCard) {
return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment>
@@ -236,7 +276,7 @@ export function SearchablePeopleList({
key={item.key}
profile={item.profile}
moderationOpts={moderationOpts!}
onPress={onSelectChat}
onPress={did => onSelectChat({kind: 'user', did})}
/>
)
}
@@ -247,11 +287,14 @@ export function SearchablePeopleList({
case 'empty': {
return <Empty key={item.key} message={item.message} />
}
case 'error': {
return <Error key={item.key} message={l`Failed to load profiles`} />
}
default:
return null
}
},
[moderationOpts, onSelectChat, renderProfileCard],
[moderationOpts, onSelectChat, renderProfileCard, l],
)
useLayoutEffect(() => {
@@ -293,7 +336,7 @@ export function SearchablePeopleList({
</Text>
{IS_WEB ? (
<Button
label={_(msg`Close`)}
label={l`Close`}
size="small"
shape="round"
variant={IS_WEB ? 'ghost' : 'solid'}
@@ -328,7 +371,7 @@ export function SearchablePeopleList({
t.atoms.border_contrast_low,
t.atoms.bg,
t.atoms.text_contrast_high,
_,
l,
title,
searchText,
control,
@@ -364,12 +407,13 @@ function DefaultProfileCard({
onPress: (did: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const enabled = canBeMessaged(profile)
const moderation = moderateProfile(profile, moderationOpts)
const handle = sanitizeHandle(profile.handle, '@')
const displayName = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
const displayName = createSanitizedDisplayName(
profile,
true,
moderation.ui('displayName'),
)
@@ -380,7 +424,7 @@ function DefaultProfileCard({
return (
<Button
disabled={!enabled}
label={_(msg`Start chat with ${displayName}`)}
label={l`Start chat with ${displayName}`}
onPress={handleOnPress}>
{({hovered, pressed, focused}) => (
<View
@@ -422,6 +466,113 @@ function DefaultProfileCard({
)
}
function ExistingChatCard({
convo,
moderationOpts,
onPress,
}: {
convo: ConvoWithDetails
moderationOpts: ModerationOpts
onPress: (convoId: string) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const enabled =
convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true
const moderation = moderateProfile(convo.primaryMember, moderationOpts)
const name =
convo.kind === 'group'
? convo.details.name
: createSanitizedDisplayName(
convo.primaryMember,
true,
moderation.ui('displayName'),
)
const handleOnPress = useCallback(() => {
onPress(convo.view.id)
}, [onPress, convo.view.id])
return (
<Button
disabled={!enabled}
label={l`Select chat "${name}"`}
onPress={handleOnPress}>
{({hovered, pressed, focused}) => (
<View
style={[
a.flex_1,
a.py_sm,
a.px_lg,
!enabled
? {opacity: 0.5}
: pressed || focused || hovered
? t.atoms.bg_contrast_25
: t.atoms.bg,
]}>
<ProfileCard.Header>
{convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size="small" />
) : (
<ProfileCard.Avatar
profile={convo.primaryMember}
moderationOpts={moderationOpts}
disabledPreview
/>
)}
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center, a.max_w_full]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.leading_snug,
a.self_start,
a.flex_shrink,
]}
numberOfLines={1}>
{name}
</Text>
{convo.kind === 'direct' && (
<ProfileBadges
profile={convo.primaryMember}
size="md"
style={[a.pl_xs]}
/>
)}
</View>
{convo.kind === 'direct' ? (
<ProfileCard.Handle profile={convo.primaryMember} />
) : (
<>
{enabled ? (
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={2}>
<Plural
value={convo.members.length}
one="# member"
other="# members"
/>
</Text>
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>Group is locked</Trans>
</Text>
)}
</>
)}
</View>
</ProfileCard.Header>
</View>
)}
</Button>
)
}
function ProfileCardSkeleton() {
const t = useTheme()
@@ -488,7 +639,7 @@ function SearchInput({
inputRef: React.RefObject<TextInput | null>
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {
state: hovered,
onIn: onMouseEnter,
@@ -512,7 +663,7 @@ function SearchInput({
<TextInput
// @ts-ignore bottom sheet input types issue — esb
ref={inputRef}
placeholder={_(msg`Search`)}
placeholder={l`Search`}
value={value}
onChangeText={onChangeText}
onFocus={onFocus}
@@ -532,8 +683,8 @@ function SearchInput({
autoComplete="off"
autoCapitalize="none"
autoFocus
accessibilityLabel={_(msg`Search profiles`)}
accessibilityHint={_(msg`Searches for profiles`)}
accessibilityLabel={l`Search profiles`}
accessibilityHint={l`Searches for profiles`}
/>
</View>
)
+7 -5
View File
@@ -1,7 +1,6 @@
import {View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
@@ -10,15 +9,18 @@ export function ActionsWrapper({
message,
isFromSelf,
children,
onTap,
}: {
message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean
isFromSelf: boolean
children: React.ReactNode
onTap?: () => void
}) {
const {_} = useLingui()
const {t: l} = useLingui()
return (
<MessageContextMenu message={message}>
<MessageContextMenu message={message} onTap={onTap}>
{trigger =>
// will always be true, since this file is platform split
trigger.IS_NATIVE && (
@@ -32,7 +34,7 @@ export function ActionsWrapper({
]}
accessible={true}
accessibilityActions={[
{name: 'activate', label: _(msg`Open message options`)},
{name: 'activate', label: l`Open message options`},
]}
onAccessibilityAction={() => trigger.control.open('full')}>
{children}
+15 -8
View File
@@ -1,8 +1,7 @@
import {useCallback, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {useConvoActive} from '#/state/messages/convo'
import {useSession} from '#/state/session'
@@ -16,16 +15,20 @@ import {hasReachedReactionLimit} from './util'
export function ActionsWrapper({
message,
hasReactions,
isFromSelf,
children,
onTap,
}: {
message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean
isFromSelf: boolean
children: React.ReactNode
onTap?: () => void
}) {
const viewRef = useRef(null)
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const convo = useConvoActive()
const {currentAccount} = useSession()
@@ -57,17 +60,17 @@ export function ActionsWrapper({
) {
convo
.removeReaction(message.id, emoji)
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo.addReaction(message.id, emoji).catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), {
Toast.show(l`Failed to add emoji reaction`, {
type: 'error',
}),
)
}
},
[_, convo, message, currentAccount?.did],
[l, convo, message, currentAccount?.did],
)
return (
@@ -87,6 +90,7 @@ export function ActionsWrapper({
isFromSelf
? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse]
: [a.ml_xs, {marginRight: 'auto'}],
hasReactions ? [a.mb_2xl] : undefined,
]}>
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
{({props, state, IS_NATIVE, control}) => {
@@ -133,10 +137,13 @@ export function ActionsWrapper({
}}
</MessageContextMenu>
</View>
<View
<Pressable
accessibilityRole="button"
accessibilityHint={l`Click to view the date and time`}
onPress={onTap}
style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}>
{children}
</View>
</Pressable>
</View>
)
}
+470
View File
@@ -0,0 +1,470 @@
import {
useCallback,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react'
import {LayoutAnimation, type TextInput, View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import {type ListMethods} from '#/view/com/util/List'
import {android, atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {canBeMessaged} from '#/components/dms/util'
import * as Toggle from '#/components/forms/Toggle'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import {IS_NATIVE, IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky'
import {ChatProfileTabs} from './ChatProfileTabs'
import {EmptyMemberList} from './components/EmptyMemberList'
import {GroupChatProfileCard} from './components/GroupChatProfileCard'
import {ProfileCardSkeleton} from './components/ProfileCardSkeleton'
import {UserLabel} from './components/UserLabel'
import {UserSearchInput} from './components/UserSearchInput'
type LabelItem = {
type: 'label'
key: string
message: string
}
type ProfileItem = {
type: 'profile'
key: string
profile: bsky.profile.AnyProfileView
}
type EmptyItem = {
type: 'empty'
key: string
message: string
}
type PlaceholderItem = {
type: 'placeholder'
key: string
}
type ErrorItem = {
type: 'error'
key: string
}
type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
export type State = {
groupChatDids: string[]
groupChatProfiles: bsky.profile.AnyProfileView[]
}
export type Action =
| {
type: 'setDids'
groupChatDids: string[]
groupChatProfiles: bsky.profile.AnyProfileView[]
}
| {
type: 'removeDids'
groupChatDids: string[]
groupChatProfiles: bsky.profile.AnyProfileView[]
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'setDids': {
return {
...state,
groupChatDids: action.groupChatDids,
groupChatProfiles: action.groupChatProfiles,
}
}
case 'removeDids': {
return {
...state,
groupChatDids: action.groupChatDids,
groupChatProfiles: action.groupChatProfiles,
}
}
}
}
export function AddMembersFlow({
title,
onAddMembers,
}: {
title: string
onAddMembers: (dids: string[]) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const [headerHeight, setHeaderHeight] = useState(0)
const [footerHeight, setFooterHeight] = useState(0)
const listRef = useRef<ListMethods>(null)
const {currentAccount} = useSession()
const inputRef = useRef<TextInput>(null)
const [searchText, setSearchText] = useState('')
const {
data: results,
isError,
isFetching,
} = useActorAutocompleteQuery(searchText, true, 12)
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
groupChatDids: [],
groupChatProfiles: [],
})
const onRemoveDid = useCallback(
(did: string) => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
dispatch({
type: 'removeDids',
groupChatDids: groupChatDids.filter(d => d !== did),
groupChatProfiles: groupChatProfiles.filter(
profile => profile.did !== did,
),
})
},
[groupChatDids, groupChatProfiles],
)
const items = useMemo(() => {
let _items: Item[] = []
if (isError) {
_items.push({
type: 'empty',
key: 'empty',
message: l`Were 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,
profile,
})
}
_items = _items.sort(item => {
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
})
}
} else {
const placeholders: Item[] = Array(10)
.fill(0)
.map((__, i) => ({
type: 'placeholder',
key: i + '',
}))
if (follows) {
for (const page of follows.pages) {
for (const profile of page.follows) {
_items.push({
type: 'profile',
key: profile.did,
profile,
})
}
}
_items = _items.sort(item => {
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
})
} else {
_items.push(...placeholders)
}
}
if (searchText === '') {
_items.unshift({
type: 'label',
key: 'suggested',
message: l`Suggested`,
})
}
return _items
}, [isError, searchText, l, results, currentAccount?.did, follows])
if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: l`No results`})
}
const handlePressBack = useCallback(() => {
control.close()
}, [control])
const handlePressAdd = useCallback(() => {
onAddMembers(groupChatDids)
}, [groupChatDids, onAddMembers])
const renderItems = useCallback(
({item}: {item: Item}) => {
switch (item.type) {
case 'label': {
return <UserLabel key={item.key} message={item.message} />
}
case 'profile': {
return (
<GroupChatProfileCard
key={item.key}
profile={item.profile}
moderationOpts={moderationOpts!}
/>
)
}
case 'placeholder': {
return <ProfileCardSkeleton key={item.key} />
}
case 'empty': {
return <EmptyMemberList key={item.key} message={item.message} />
}
default:
return null
}
},
[moderationOpts],
)
useLayoutEffect(() => {
if (IS_WEB) {
setImmediate(() => {
inputRef?.current?.focus()
})
}
}, [])
let buttonLabel = l`Continue to group name`
let buttonText = l`Next`
let showButton = groupChatProfiles.length > 0
let isButtonDisabled = !showButton
const showChatProfileTabs = groupChatProfiles.length > 0
const listHeader = useMemo(
() => (
<View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
<View
style={[
a.relative,
web(a.pt_lg),
native(a.pt_4xl),
android({
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
}),
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
t.atoms.bg,
]}>
<View
style={[
a.flex_row,
a.gap_sm,
a.relative,
a.align_center,
a.justify_between,
web(a.pb_lg),
]}>
{IS_NATIVE ? (
<Button
label={l`Back`}
size="large"
shape="round"
variant="ghost"
color="secondary"
style={[native([a.absolute, a.z_20])]}
onPress={handlePressBack}>
<ButtonIcon icon={ArrowLeftIcon} size="lg" />
</Button>
) : null}
<Text
style={[
a.flex_grow,
a.z_10,
a.text_lg,
a.font_bold,
a.leading_tight,
t.atoms.text_contrast_high,
a.text_center,
a.px_5xl,
]}>
{title}
</Text>
{IS_WEB ? (
<Button
label={l`Close`}
size="small"
shape="round"
variant="ghost"
color="secondary"
style={[a.absolute, a.z_20, {right: -4}]}
onPress={() => control.close()}>
<ButtonIcon icon={XIcon} size="lg" />
</Button>
) : showButton ? (
<Button
label={buttonLabel}
size="small"
color="primary"
style={[
native([
a.absolute,
a.z_20,
{
right: 8,
},
]),
]}
disabled={isButtonDisabled}
onPress={handlePressAdd}>
<ButtonText>
<Trans>Add</Trans>
</ButtonText>
</Button>
) : null}
</View>
<View style={[web(a.pt_xs), native(a.pt_md)]}>
<UserSearchInput
inputRef={inputRef}
value={searchText}
onChangeText={text => {
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onEscape={control.close}
/>
</View>
</View>
{showChatProfileTabs ? (
<View style={[a.pb_sm, a.pt_md, t.atoms.bg]}>
<ChatProfileTabs
testID="newGroupChatMembers"
profiles={groupChatProfiles}
onRemove={onRemoveDid}
/>
</View>
) : null}
</View>
),
[
buttonLabel,
control,
groupChatProfiles,
handlePressAdd,
handlePressBack,
isButtonDisabled,
l,
onRemoveDid,
searchText,
showButton,
showChatProfileTabs,
t.atoms.bg,
t.atoms.border_contrast_low,
t.atoms.text_contrast_high,
title,
],
)
const setGroupChatMembers = (dids: string[]) => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
const added = dids.filter(d => !groupChatDids.includes(d))
const removed = groupChatDids.filter(d => !dids.includes(d))
const newDids = [
...groupChatDids.filter(d => !removed.includes(d)),
...added,
]
const kept = groupChatProfiles.filter(p => dids.includes(p.did))
const keptDids = new Set(kept.map(p => p.did))
const addedProfiles = items
.filter(
(item): item is ProfileItem =>
item.type === 'profile' &&
dids.includes(item.profile.did) &&
!keptDids.has(item.profile.did),
)
.map(item => item.profile)
.sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did))
dispatch({
type: 'setDids',
groupChatDids: newDids,
groupChatProfiles: [...kept, ...addedProfiles],
})
}
return (
<Toggle.Group
values={groupChatDids}
onChange={setGroupChatMembers}
type="checkbox"
label={l`Add group chat members`}
style={web([a.contents])}>
<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%'}),
]}
webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
keyboardDismissMode="on-drag"
footer={
IS_WEB ? (
<Dialog.FlatListFooter
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<Button
label={l`Back`}
size="small"
color="secondary"
onPress={handlePressBack}>
<ButtonIcon icon={ArrowLeftIcon} size="md" />
<ButtonText>
{' '}
<Trans>Back</Trans>
</ButtonText>
</Button>
<Button
label={buttonLabel}
size="small"
color="primary"
disabled={isButtonDisabled}
onPress={handlePressAdd}>
<ButtonText>{buttonText} </ButtonText>
</Button>
</View>
</Dialog.FlatListFooter>
) : null
}
/>
</Toggle.Group>
)
}
+9 -9
View File
@@ -25,9 +25,9 @@ import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
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 {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} 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 {
@@ -95,7 +95,7 @@ let ConvoMenu = ({
shape="round"
variant="ghost"
style={[a.bg_transparent]}>
<ButtonIcon icon={DotsHorizontal} size="md" />
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
</Button>
)}
</Menu.Trigger>
@@ -220,9 +220,9 @@ function MenuContent({
}
if (userBlock) {
queueUnblock()
void queueUnblock()
} else {
queueBlock()
void queueBlock()
}
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
@@ -233,7 +233,7 @@ function MenuContent({
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
) : (
<>
@@ -245,7 +245,7 @@ function MenuContent({
<Menu.ItemText>
<Trans>Mark as read</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Bubble} />
<Menu.ItemIcon icon={BubbleIcon} />
</Menu.Item>
)}
<Menu.Item
@@ -296,7 +296,7 @@ function MenuContent({
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
</Menu.Group>
</>
+6 -12
View File
@@ -1,8 +1,6 @@
import {memo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {subDays} from 'date-fns'
import {atoms as a, useTheme} from '#/alf'
@@ -29,8 +27,8 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
})
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
const {t: l} = useLingui()
let date: string
const time = timeFormatter.format(new Date(dateStr))
@@ -42,9 +40,9 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const oneWeekAgo = subDays(today, 7)
if (localDateString(today) === localDateString(timestamp)) {
date = _(msg`Today`)
date = l`Today`
} else if (localDateString(yesterday) === localDateString(timestamp)) {
date = _(msg`Yesterday`)
date = l`Yesterday`
} else {
if (timestamp < oneWeekAgo) {
if (timestamp.getFullYear() === today.getFullYear()) {
@@ -58,7 +56,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
}
return (
<View style={[a.w_full, a.my_lg]}>
<View style={[a.w_full, a.my_sm]}>
<Text
style={[
a.text_xs,
@@ -68,11 +66,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
a.px_md,
]}>
<Trans>
<Text
style={[a.text_xs, t.atoms.text_contrast_medium, a.font_semi_bold]}>
{date}
</Text>{' '}
at {time}
{date} at {time}
</Trans>
</Text>
</View>
+44
View File
@@ -0,0 +1,44 @@
import {createContext, useCallback, useContext, useState} from 'react'
type DateDividerToggleContextType = {
isDividerToggled: (id: string) => boolean
toggleDivider: (id: string) => void
}
const DateDividerToggleContext = createContext<DateDividerToggleContextType>({
isDividerToggled: () => false,
toggleDivider: () => {},
})
export function DateDividerToggleProvider({
children,
}: {
children: React.ReactNode
}) {
const [toggledIds, setToggledIds] = useState(new Set<string>())
const toggleDivider = useCallback((id: string) => {
setToggledIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const isDividerToggled = useCallback(
(id: string) => toggledIds.has(id),
[toggledIds],
)
return (
<DateDividerToggleContext.Provider
value={{isDividerToggled, toggleDivider}}>
{children}
</DateDividerToggleContext.Provider>
)
}
export function useDateDividerToggle() {
return useContext(DateDividerToggleContext)
}
+10 -29
View File
@@ -1,18 +1,14 @@
import {useState} from 'react'
import {Pressable, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import EmojiPicker from '@emoji-mart/react'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {DropdownMenu} from 'radix-ui'
import {useSession} from '#/state/session'
import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker'
import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji'
import {atoms as a, flatten, useTheme} from '#/alf'
import * as EmojiPicker from '#/components/EmojiPicker'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
import * as Menu from '#/components/Menu'
import {type TriggerProps} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
@@ -22,19 +18,21 @@ export function EmojiReactionPicker({
onEmojiSelect,
}: {
message: ChatBskyConvoDefs.MessageView
children?: TriggerProps['children']
children?: EmojiPicker.TriggerProps['children']
onEmojiSelect: (emoji: string) => void
}) {
if (!children)
throw new Error('EmojiReactionPicker requires the children prop on web')
const {_} = useLingui()
const {t: l} = useLingui()
return (
<Menu.Root>
<Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger>
<EmojiPicker.Root onEmojiSelect={emoji => onEmojiSelect(emoji.native)}>
<EmojiPicker.Trigger label={l`Add emoji reaction`}>
{children}
</EmojiPicker.Trigger>
<MenuInner message={message} onEmojiSelect={onEmojiSelect} />
</Menu.Root>
</EmojiPicker.Root>
)
}
@@ -49,8 +47,6 @@ function MenuInner({
const {control} = Menu.useMenuContext()
const {currentAccount} = useSession()
useWebPreloadEmoji({immediate: true})
const [expanded, setExpanded] = useState(false)
const [prevOpen, setPrevOpen] = useState(control.isOpen)
@@ -62,10 +58,6 @@ function MenuInner({
}
}
const handleEmojiPickerResponse = (emoji: Emoji) => {
handleEmojiSelect(emoji.native)
}
const handleEmojiSelect = (emoji: string) => {
control.close()
onEmojiSelect(emoji)
@@ -74,18 +66,7 @@ function MenuInner({
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
return expanded ? (
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={5}
collisionPadding={{left: 5, right: 5, bottom: 5}}>
<div onWheel={evt => evt.stopPropagation()}>
<EmojiPicker
onEmojiSelect={handleEmojiPickerResponse}
autoFocus={true}
/>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
<EmojiPicker.Picker keepOpenWhenShiftHeld={false} />
) : (
<Menu.Outer style={[a.rounded_full]}>
<View style={[a.flex_row, a.gap_xs]}>
+11 -163
View File
@@ -6,7 +6,7 @@ import {
useRef,
useState,
} from 'react'
import {LayoutAnimation, TextInput, View} from 'react-native'
import {LayoutAnimation, type TextInput, View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
@@ -23,13 +23,11 @@ import * as Dialog from '#/components/Dialog'
import {canBeMessaged} from '#/components/dms/util'
import * as TextField from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {
ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon,
ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon,
} from '#/components/icons/Arrow'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard'
@@ -37,6 +35,11 @@ import {Text} from '#/components/Typography'
import {IS_NATIVE, IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky'
import {ChatProfileTabs} from './ChatProfileTabs'
import {EmptyMemberList} from './components/EmptyMemberList'
import {GroupChatProfileCard} from './components/GroupChatProfileCard'
import {ProfileCardSkeleton} from './components/ProfileCardSkeleton'
import {UserLabel} from './components/UserLabel'
import {UserSearchInput} from './components/UserSearchInput'
type NewGroupChatItem = {
type: 'newGroupChat'
@@ -49,7 +52,7 @@ type LabelItem = {
message: string
}
export type ProfileItem = {
type ProfileItem = {
type: 'profile'
key: string
profile: bsky.profile.AnyProfileView
@@ -184,6 +187,7 @@ function reducer(state: State, action: Action): State {
}
}
}
export function InitiateChatFlow({
title,
onSelectChat,
@@ -382,7 +386,7 @@ export function InitiateChatFlow({
)
}
case 'label': {
return <Label key={item.key} message={item.message} />
return <UserLabel key={item.key} message={item.message} />
}
case 'profile': {
switch (chatState) {
@@ -417,7 +421,7 @@ export function InitiateChatFlow({
return <ProfileCardSkeleton key={item.key} />
}
case 'empty': {
return <Empty key={item.key} message={item.message} />
return <EmptyMemberList key={item.key} message={item.message} />
}
default:
return null
@@ -560,7 +564,7 @@ export function InitiateChatFlow({
</TextField.Root>
</View>
) : (
<SearchInput
<UserSearchInput
inputRef={inputRef}
value={searchText}
onChangeText={text => {
@@ -813,59 +817,6 @@ function DefaultProfileCard({
)
}
function GroupChatProfileCard({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const t = useTheme()
const enabled = canBeMessaged(profile)
const moderation = moderateProfile(profile, moderationOpts)
const handle = sanitizeHandle(profile.handle, '@')
const displayName = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
return (
<Toggle.Item
key={profile.did}
disabled={!enabled}
name={profile.did}
label={displayName}
style={[a.flex_1, a.py_sm, a.px_lg]}>
<View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
<View>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
{enabled ? (
<ProfileCard.Handle profile={profile} />
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>{handle} cant be messaged</Trans>
</Text>
)}
</View>
</ProfileCard.Header>
</View>
{enabled ? <Toggle.Checkbox /> : null}
</Toggle.Item>
)
}
function GroupChatMemberProfileCard({
profile,
moderationOpts,
@@ -902,106 +853,3 @@ function GroupChatMemberProfileCard({
</View>
)
}
function ProfileCardSkeleton() {
return (
<View
style={[
a.flex_1,
a.py_md,
a.px_lg,
a.gap_md,
a.align_center,
a.flex_row,
]}>
<ProfileCard.AvatarPlaceholder size={42} />
<ProfileCard.NameAndHandlePlaceholder />
</View>
)
}
function Label({message}: {message: string}) {
const t = useTheme()
return (
<View style={[a.px_lg, a.py_sm]}>
<Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
{message}
</Text>
</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<TextInput | null>
}) {
const t = useTheme()
const {t: l} = 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]}>
<SearchIcon
size="md"
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
/>
<TextInput
// @ts-ignore bottom sheet input types issue - esb
ref={inputRef}
placeholder={l`Search for people`}
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={l`Search profiles`}
accessibilityHint={l`Searches for profiles`}
/>
</View>
)
}
+42 -42
View File
@@ -2,8 +2,7 @@ import {memo, useCallback} from 'react'
import {LayoutAnimation, Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
@@ -12,13 +11,14 @@ import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {BubbleQuestion_Stroke2_Corner0_Rounded as TranslateIcon} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
@@ -31,11 +31,13 @@ import {hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({
message,
children,
onTap,
}: {
message: ChatBskyConvoDefs.MessageView
children: TriggerProps['children']
onTap?: () => void
}): React.ReactNode => {
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
@@ -47,6 +49,7 @@ export let MessageContextMenu = ({
const translate = useGoogleTranslate()
const isFromSelf = message.sender?.did === currentAccount?.did
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
const onCopyMessage = useCallback(() => {
const str = richTextToString(
@@ -58,10 +61,10 @@ export let MessageContextMenu = ({
)
void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), {
Toast.show(l`Copied to clipboard`, {
type: 'success',
})
}, [_, message.text, message.facets])
}, [l, message.text, message.facets])
const onPressTranslateMessage = useCallback(() => {
void translate(message.text, langPrefs.primaryLanguage)
@@ -79,11 +82,9 @@ export let MessageContextMenu = ({
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
.then(() =>
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
)
.catch(() => Toast.show(_(msg`Failed to delete message`)))
}, [_, convo, message.id])
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
.catch(() => Toast.show(l`Failed to delete message`))
}, [l, convo, message.id])
const onEmojiSelect = useCallback(
(emoji: string) => {
@@ -96,17 +97,17 @@ export let MessageContextMenu = ({
) {
convo
.removeReaction(message.id, emoji)
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo.addReaction(message.id, emoji).catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), {
Toast.show(l`Failed to add emoji reaction`, {
type: 'error',
}),
)
}
},
[_, convo, message, currentAccount?.did],
[l, convo, message, currentAccount?.did],
)
const sender = convo.convo.members.find(
@@ -117,7 +118,9 @@ export let MessageContextMenu = ({
<>
<ContextMenu.Root>
{IS_NATIVE && (
<ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}>
<ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
<EmojiReactionPicker
message={message}
onEmojiSelect={onEmojiSelect}
@@ -126,31 +129,32 @@ export let MessageContextMenu = ({
)}
<ContextMenu.Trigger
label={_(msg`Message options`)}
contentLabel={_(
msg`Message from @${
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`,
)}>
label={l`Message options`}
contentLabel={l`Message from @${
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`}
onTap={onTap}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Outer align={isFromSelf ? 'right' : 'left'}>
<ContextMenu.Outer
align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && (
<>
<ContextMenu.Item
testID="messageDropdownTranslateBtn"
label={_(msg`Translate`)}
label={l`Translate`}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemText>{_(msg`Translate`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Translate} position="right" />
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={TranslateIcon} position="right" />
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={_(msg`Copy message text`)}
label={l`Copy message text`}
onPress={onCopyMessage}>
<ContextMenu.ItemText>
{_(msg`Copy message text`)}
{l`Copy message text`}
</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="right" />
</ContextMenu.Item>
@@ -159,23 +163,22 @@ export let MessageContextMenu = ({
)}
<ContextMenu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
label={l`Delete message for me`}
onPress={() => deleteControl.open()}>
<ContextMenu.ItemText>{_(msg`Delete for me`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Trash} position="right" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={TrashIcon} position="right" />
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
label={l`Report message`}
onPress={() => reportControl.open()}>
<ContextMenu.ItemText>{_(msg`Report`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Warning} position="right" />
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={WarningIcon} position="right" />
</ContextMenu.Item>
)}
</ContextMenu.Outer>
</ContextMenu.Root>
<ReportDialog
control={reportControl}
subject={{
@@ -198,14 +201,11 @@ export let MessageContextMenu = ({
message,
}}
/>
<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 the other participant.`,
)}
confirmButtonCta={_(msg`Delete`)}
title={l`Delete message`}
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
confirmButtonCta={l`Delete`}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
+434 -206
View File
@@ -1,13 +1,21 @@
import {memo, useCallback, useMemo} from 'react'
import {memo, useCallback, useEffect, useMemo, useRef} from 'react'
import {
type GestureResponderEvent,
LayoutAnimation,
Pressable,
type StyleProp,
type TextStyle,
View,
type ViewStyle,
} from 'react-native'
import Animated, {
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
useAnimatedStyle,
useSharedValue,
withTiming,
ZoomIn,
ZoomOut,
} from 'react-native-reanimated'
@@ -16,217 +24,480 @@ import {
ChatBskyConvoDefs,
RichText as RichTextAPI,
} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {atoms as a, native, useTheme} from '#/alf'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {useDialogControl} from '#/components/Dialog'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link'
import {InlineLinkText, Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky'
import {DateDivider} from './DateDivider'
import {useDateDividerToggle} from './DateDividerToggle'
import {MessageItemEmbed} from './MessageItemEmbed'
import {localDateString} from './util'
import {ReactionsDialog} from './ReactionsDialog'
const AVATAR_SIZE = 28
const CLUSTERED_MESSAGE_GAP = 2
const BORDER_RADIUS = 18
const SQUARED_BORDER_RADIUS = 4
const DISPLAY_NAME_INSET = 22
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
function isWithinCluster({
isPending,
adjacentMessage,
isFromSameSender,
currentSentAt,
direction,
}: {
isPending: boolean
adjacentMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
isFromSameSender: boolean
currentSentAt: string
direction: 'prev' | 'next'
}): boolean {
if (!isFromSameSender) return true
if (isPending && adjacentMessage) return false
if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) {
const thisDate = new Date(currentSentAt)
const adjDate = new Date(adjacentMessage.sentAt)
const diff =
direction === 'next'
? adjDate.getTime() - thisDate.getTime()
: thisDate.getTime() - adjDate.getTime()
return diff > CLUSTERED_MESSAGE_THRESHOLD_MS
}
return true
}
let MessageItem = ({
item,
isGroupChat = false,
profile,
}: {
item: ConvoItem & {type: 'message' | 'pending-message'}
isGroupChat?: boolean
profile?: bsky.profile.AnyProfileView
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
const {_} = useLingui()
const {t: l} = useLingui()
const {convo} = useConvoActive()
const moderationOpts = useModerationOpts()
const queryClient = useQueryClient()
const reactionsControl = useDialogControl()
const reactionTapRef = useRef(false)
const {message, nextMessage, prevMessage} = item
const isPending = item.type === 'pending-message'
const displayName = sanitizeDisplayName(
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
)
const isFromSelf = message.sender?.did === currentAccount?.did
const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage)
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
const isNextFromSelf =
nextIsMessage && nextMessage.sender?.did === currentAccount?.did
const isPrevFromSameSender =
prevIsMessage && prevMessage.sender?.did === message.sender?.did
const isNextFromSameSender =
nextIsMessage && nextMessage.sender?.did === message.sender?.did
const isNextFromSameSender = isNextFromSelf === isFromSelf
const isFirstInCluster = useMemo(
() =>
isWithinCluster({
isPending,
adjacentMessage: prevMessage,
isFromSameSender: isPrevFromSameSender,
currentSentAt: message.sentAt,
direction: 'prev',
}),
[isPending, prevMessage, isPrevFromSameSender, message.sentAt],
)
const isNewDay = useMemo(() => {
if (!prevMessage) return true
const isLastInCluster = useMemo(
() =>
isWithinCluster({
isPending,
adjacentMessage: nextMessage,
isFromSameSender: isNextFromSameSender,
currentSentAt: message.sentAt,
direction: 'next',
}),
[isPending, nextMessage, isNextFromSameSender, message.sentAt],
)
const thisDate = new Date(message.sentAt)
const prevDate = new Date(prevMessage.sentAt)
const hasLargeGapFromPrev =
!ChatBskyConvoDefs.isMessageView(prevMessage) ||
new Date(message.sentAt).getTime() -
new Date(prevMessage.sentAt).getTime() >
MESSAGE_GAP_THRESHOLD_MS
return localDateString(thisDate) !== localDateString(prevDate)
}, [message, prevMessage])
const {isDividerToggled, toggleDivider} = useDateDividerToggle()
const isDateDividerToggled = isDividerToggled(message.id)
const isNextDateDividerToggled =
nextMessage != null && isDividerToggled(nextMessage.id)
const showDateDivider = hasLargeGapFromPrev
const isLastMessageOfDay = useMemo(() => {
if (!nextMessage || !nextIsMessage) return true
const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled
const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled
const isInCluster = !(effectiveFirstInCluster && effectiveLastInCluster)
const isInMiddleOfCluster =
isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster
const thisDate = new Date(message.sentAt)
const prevDate = new Date(nextMessage.sentAt)
const hasReactions = message.reactions && message.reactions.length > 0
const squaredBottomCorner =
!hasReactions &&
isInCluster &&
(isInMiddleOfCluster || effectiveFirstInCluster)
const squaredTopCorner =
isInCluster && (isInMiddleOfCluster || effectiveLastInCluster)
return localDateString(thisDate) !== localDateString(prevDate)
}, [message.sentAt, nextIsMessage, nextMessage])
const needsTail = isLastMessageOfDay || !isNextFromSameSender
const isLastInGroup = useMemo(() => {
// if this message is pending, it means the next message is pending too
if (isPending && nextMessage) {
return false
}
// or, if there's a 5 minute gap between this message and the next
if (ChatBskyConvoDefs.isMessageView(nextMessage)) {
const thisDate = new Date(message.sentAt)
const nextDate = new Date(nextMessage.sentAt)
const diff = nextDate.getTime() - thisDate.getTime()
// 5 minutes
return diff > 5 * 60 * 1000
}
return true
}, [message, nextMessage, isPending])
const pendingColor = t.palette.primary_200
const pendingColor = t.palette.primary_300
const rt = useMemo(() => {
return new RichTextAPI({text: message.text, facets: message.facets})
}, [message.text, message.facets])
const hasEmbedAndText =
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
const targetBottomRadius =
squaredBottomCorner || hasEmbedAndText
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS
const targetTopRadius = squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS
const bottomRadiusSV = useSharedValue(targetBottomRadius)
const topRadiusSV = useSharedValue(targetTopRadius)
const showDisplayName =
isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text)
const showAvatar = isGroupChat && !isFromSelf && isLastInCluster
useEffect(() => {
bottomRadiusSV.set(withTiming(targetBottomRadius, {duration: 300}))
}, [targetBottomRadius, bottomRadiusSV])
useEffect(() => {
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
}, [targetTopRadius, topRadiusSV])
const borderRadiusStyle = useAnimatedStyle(() =>
isFromSelf
? {
borderBottomRightRadius: bottomRadiusSV.get(),
borderTopRightRadius: topRadiusSV.get(),
}
: {
borderBottomLeftRadius: bottomRadiusSV.get(),
borderTopLeftRadius: topRadiusSV.get(),
},
)
const avatar = profile ? (
<Link
label={l`${sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)}s avatar`}
accessibilityHint={l`Opens this profile`}
to={makeProfileLink({
did: profile.did,
handle: profile.handle,
})}
onPress={() => unstableCacheProfileView(queryClient, profile)}>
<ProfileCard.Avatar
profile={profile}
size={AVATAR_SIZE}
moderationOpts={moderationOpts!}
disabledPreview
/>
</Link>
) : (
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
)
const groupedReactions = useMemo(() => {
const reactions = message.reactions ?? []
const grouped = new Map<
string,
{
key: string
value: string
senders: ChatBskyConvoDefs.ReactionViewSender[]
count: number
}
>()
for (const reaction of reactions) {
if (!reaction) continue
const existing = grouped.get(reaction.value)
if (existing) {
existing.senders.push(reaction.sender)
existing.count++
} else {
grouped.set(reaction.value, {
key: reaction.value,
value: reaction.value,
senders: [reaction.sender],
count: 1,
})
}
}
return Array.from(grouped.values())
}, [message.reactions])
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
const reactionsLabel = useMemo(() => {
if (reactions.length === 0) return ''
if (reactions.length === 1) {
const reaction = reactions[0]
const sender = reaction.sender
if (sender.did === currentAccount?.did) {
return l`You reacted ${reaction.value}`
} else {
const senderDid = reaction.sender.did
const sender = convo.members.find(member => member.did === senderDid)
if (sender) {
return l`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${reaction.value}`
}
return l`Someone reacted ${reaction.value}`
}
}
return l`${plural(reactions.length, {
one: '# person',
other: '# people',
})} reacted ${groupedReactions.map(g => g.value).join(' ')}`
}, [reactions, groupedReactions, currentAccount?.did, convo.members, l])
const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting>
{message.reactions && message.reactions.length > 0 && (
{hasReactions ? (
<View
style={[isFromSelf ? a.align_end : a.align_start, a.px_sm, a.pb_2xs]}>
<View
style={[
a.relative,
a.bottom_0,
isFromSelf ? [a.align_end] : [a.ml_sm, a.align_start],
a.px_sm,
]}>
<Pressable
accessible={true}
accessibilityLabel={reactionsLabel}
accessibilityHint={
isGroupChat ? l`Tap to view reactions` : undefined
}
style={[
a.flex_row,
a.gap_2xs,
a.py_xs,
a.px_xs,
a.justify_center,
isFromSelf ? a.justify_end : a.justify_start,
a.flex_wrap,
a.pb_xs,
t.atoms.bg_contrast_25,
a.rounded_lg,
a.border,
t.atoms.border_contrast_low,
a.rounded_lg,
t.atoms.bg_contrast_25,
t.atoms.shadow_sm,
{
// vibe coded number
transform: [{translateY: -11}],
paddingTop: platform({android: 2, default: 3}),
paddingBottom: platform({android: 2, default: 3}),
transform: [{translateY: -8}],
},
]}>
{message.reactions.map((reaction, _i, reactions) => {
let label
if (reaction.sender.did === currentAccount?.did) {
label = _(msg`You reacted ${reaction.value}`)
} else {
const senderDid = reaction.sender.did
const sender = convo.members.find(
member => member.did === senderDid,
)
if (sender) {
label = _(
msg`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${reaction.value}`,
)
} else {
label = _(msg`Someone reacted ${reaction.value}`)
]}
onPressIn={() => {
// Don't toggle the date divider when tapping a reaction.
reactionTapRef.current = true
}}
onPressOut={() => {
// Include a delay here to account for tap-and-drag before release.
setTimeout(() => {
reactionTapRef.current = false
}, 100)
}}
onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}>
{groupedReactions.map(group => (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
exiting={
groupedReactions.length > 1 && native(ZoomOut.delay(200))
}
}
return (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
exiting={reactions.length > 1 && native(ZoomOut.delay(200))}
layout={native(LinearTransition.delay(300))}
key={reaction.sender.did + reaction.value}
style={[a.p_2xs]}
accessible={true}
accessibilityLabel={label}
accessibilityHint={_(
msg`Double tap or long press the message to add a reaction`,
)}>
<Text emoji style={[a.text_sm]}>
{reaction.value}
</Text>
</Animated.View>
)
})}
</View>
layout={native(LinearTransition.delay(300))}
key={group.value}
style={[a.py_2xs]}>
<Text
emoji
style={[
a.text_xs,
{textAlignVertical: 'center', includeFontPadding: false},
]}>
{group.value}
</Text>
</Animated.View>
))}
{groupedReactions.length !== reactions.length &&
reactions.length > 1 ? (
<View style={[a.p_2xs, a.pl_0, a.justify_center]}>
<Text
style={[
a.text_xs,
t.atoms.text_contrast_medium,
{textAlignVertical: 'center', includeFontPadding: false},
]}>
{reactions.length}
</Text>
</View>
) : null}
</Pressable>
</View>
)}
) : null}
<ReactionsDialog
control={reactionsControl}
members={convo.members}
message={message}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</LayoutAnimationConfig>
)
const messageInset = platform<ViewStyle | undefined>({
ios: isFromSelf ? a.mr_md : isGroupChat ? a.ml_md : a.ml_sm,
android: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined,
web: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined,
})
return (
<>
{isNewDay && <DateDivider date={message.sentAt} />}
{(showDateDivider || isDateDividerToggled) && (
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
<DateDivider date={message.sentAt} />
</Animated.View>
)}
<View
style={[
isFromSelf ? a.mr_md : a.ml_md,
nextIsMessage && !isNextFromSameSender && a.mb_md,
]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed embed={message.embed} />
)}
{rt.text.length > 0 && (
style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}>
<View style={[a.relative]}>
{showAvatar ? (
<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: needsTail ? 2 : 17}
: {borderBottomLeftRadius: needsTail ? 2 : 17},
]
}>
<RichText
value={rt}
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
shouldProxyLinks={true}
/>
style={[
a.absolute,
a.bottom_0,
a.z_50,
{
transform: [{translateY: hasReactions ? -24 : 0}],
},
]}>
{avatar}
</View>
)}
{IS_NATIVE && appliedReactions}
</ActionsWrapper>
{!IS_NATIVE && appliedReactions}
{isLastInGroup && (
) : null}
<View
style={[
a.flex_grow,
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
]}>
{showDisplayName ? (
<Text
style={[
a.text_xs,
t.atoms.text_contrast_medium,
a.pt_xs,
a.pb_2xs,
{
paddingLeft: DISPLAY_NAME_INSET,
},
]}>
{displayName}
</Text>
) : null}
<ActionsWrapper
hasReactions={hasReactions}
isFromSelf={isFromSelf}
message={message}
onTap={() => {
if (reactionTapRef.current) return
if (!hasLargeGapFromPrev) {
LayoutAnimation.configureNext(
LayoutAnimation.Presets.easeInEaseOut,
)
toggleDivider(message.id)
}
}}>
{rt.text.length > 0 && (
<Animated.View
accessibilityHint={l`Double tap or long press the message to add a reaction`}
style={[
!isFromSelf && a.ml_sm,
...(isOnlyEmoji(message.text)
? []
: [
a.rounded_md,
a.rounded_xl,
a.py_sm,
a.px_md,
{
marginTop: effectiveFirstInCluster
? 0
: CLUSTERED_MESSAGE_GAP,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
},
isFromSelf ? a.self_end : a.self_start,
borderRadiusStyle,
]),
]}>
<RichText
value={rt}
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
shouldProxyLinks={true}
/>
</Animated.View>
)}
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed
embed={message.embed}
isFromSelf={isFromSelf}
squaredBottomCorner={squaredBottomCorner}
squaredTopCorner={squaredTopCorner || hasEmbedAndText}
/>
)}
{appliedReactions}
</ActionsWrapper>
</View>
</View>
{effectiveLastInCluster && (
<MessageItemMetadata
item={item}
style={isFromSelf ? a.text_right : a.text_left}
style={[isFromSelf ? a.text_right : a.text_left]}
/>
)}
</View>
@@ -244,8 +515,7 @@ let MessageItemMetadata = ({
style: StyleProp<TextStyle>
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {message} = item
const {t: l} = useLingui()
const handleRetry = useCallback(
(e: GestureResponderEvent) => {
@@ -258,75 +528,33 @@ let MessageItemMetadata = ({
[item],
)
const relativeTimestamp = useCallback(
(i18n: I18n, timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const errorColor = t.palette.negative_400
const time = i18n.date(date, {
hour: 'numeric',
minute: 'numeric',
})
const diff = now.getTime() - date.getTime()
// if under 30 seconds
if (diff < 1000 * 30) {
return _(msg`Now`)
}
return time
},
[_],
)
return (
<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 && (
<>
{' '}
&middot;{' '}
<Text
style={[
a.text_xs,
{
color: t.palette.negative_400,
},
]}>
{_(msg`Failed to send`)}
switch (item.type) {
case 'pending-message':
return item.failed ? (
<Text style={[a.text_xs, a.my_2xs, {color: errorColor}, style]}>
<Text style={[a.text_xs, {color: errorColor}]}>
<Trans>Message failed to send.</Trans>
</Text>
{item.retry && (
<>
{' '}
&middot;{' '}
<InlineLinkText
label={_(msg`Click to retry failed message`)}
label={l`Click to retry failed message`}
to="#"
onPress={handleRetry}
style={[a.text_xs]}>
{_(msg`Retry`)}
style={[a.text_xs, {color: errorColor}]}>
<Trans>Tap to retry</Trans>
</InlineLinkText>
.
</>
)}
</>
)}
</Text>
)
</Text>
) : null
default:
return null
}
}
MessageItemMetadata = memo(MessageItemMetadata)
export {MessageItemMetadata}
+39 -3
View File
@@ -2,14 +2,24 @@ import {memo} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {MessageContextProvider} from './MessageContext'
const CLUSTERED_MESSAGE_GAP = 2
const BORDER_RADIUS = 20
const SQUARED_BORDER_RADIUS = 4
let MessageItemEmbed = ({
embed,
isFromSelf,
squaredTopCorner,
squaredBottomCorner,
}: {
embed: $Typed<AppBskyEmbedRecord.View>
isFromSelf: boolean
squaredTopCorner: boolean
squaredBottomCorner: boolean
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
@@ -18,7 +28,7 @@ let MessageItemEmbed = ({
<MessageContextProvider>
<View
style={[
a.my_xs,
isFromSelf ? a.mr_sm : a.ml_sm,
t.atoms.bg,
a.rounded_md,
native({
@@ -30,12 +40,38 @@ let MessageItemEmbed = ({
minWidth: 280,
maxWidth: 360,
}),
{
marginTop: CLUSTERED_MESSAGE_GAP,
},
]}>
<View style={{marginTop: tokens.space.sm * -1}}>
<View style={{marginTop: -8}}>
<Embed
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
style={[
a.rounded_xl,
a.border_0,
isFromSelf
? {
backgroundColor: t.palette.primary_50,
borderBottomRightRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopRightRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
}
: {
backgroundColor: t.palette.contrast_50,
borderBottomLeftRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopLeftRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
},
]}
/>
</View>
</View>
+102 -89
View File
@@ -5,24 +5,29 @@ import {
type ModerationCause,
type ModerationDecision,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon} from '#/components/Button'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
@@ -48,7 +53,7 @@ export function MessagesListHeader({
}, [moderation])
return (
<Layout.Header.Outer>
<Layout.Header.Outer noBottomBorder={IS_LIQUID_GLASS}>
<View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}>
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.BackButton />
@@ -72,19 +77,12 @@ export function MessagesListHeader({
<View style={a.gap_xs}>
<View
style={[
{width: 120, height: 16},
{width: 150, 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>
@@ -108,22 +106,27 @@ function HeaderReady({
userBlock?: ModerationCause
}
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const convoState = useConvo()
const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>()
const groupInfo = convoState.getGroupInfo?.()
const isGroupChat = groupInfo != null
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
? _(msg`Deleted Account`)
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
const displayName = isGroupChat
? (groupInfo.name ?? l`${profile.handle}'s group chat`)
: isDeletedAccount
? l`Deleted Account`
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
// @ts-ignore findLast is polyfilled - esb
const latestMessageFromOther = convoState.items.findLast(
(item: ConvoItem) =>
item.type === 'message' && item.message.sender.did === profile.did,
item.type === 'message' &&
item.message.sender.did !== currentAccount?.did,
)
const latestReportableMessage =
@@ -131,85 +134,95 @@ function HeaderReady({
? latestMessageFromOther.message
: undefined
const handleNavigateToSettings = () => {
const convoId = convoState.convo?.id
if (convoId) {
navigation.navigate('MessagesConversationSettings', {
conversation: convoId,
})
} else {
logger.error(`handleNavigateToSettings: missing convo ID`)
}
}
return (
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
<Link
label={_(msg`View ${displayName}'s profile`)}
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}>
{displayName}
</Text>
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
</View>
{!isDeletedAccount && (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_xs,
web([a.leading_normal, {marginTop: -2}]),
]}
numberOfLines={1}>
@{profile.handle}
{isGroupChat ? (
<View
style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}>
<AvatarBubbles
size="small"
profiles={convoState.recipients ?? []}
/>
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
{displayName}
</Text>
</View>
) : (
<Link
label={l`View ${displayName}'s profile`}
style={[a.flex_row, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[a.text_md, a.font_semi_bold, a.self_start]}
numberOfLines={1}>
{displayName}
</Text>
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
{convoState.convo?.muted && (
<>
{' '}
&middot;{' '}
<BellStroke
size="xs"
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
{' '}
&middot;{' '}
</Text>
<BellOffIcon
size="sm"
style={t.atoms.text_contrast_medium}
/>
</>
)}
</Text>
)}
</View>
</Link>
</View>
</View>
</Link>
)}
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.Slot>
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)}
{isConvoActive(convoState) ? (
isGroupChat ? (
<Button
label={l`Open group chat settings`}
size="small"
color="secondary"
shape="round"
variant="ghost"
style={[a.bg_transparent]}
onPress={handleNavigateToSettings}>
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
</Button>
) : (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)
) : null}
</Layout.Header.Slot>
</View>
</View>
<View
style={[
{
paddingLeft: PFP_SIZE + a.gap_md.gap,
},
]}>
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
</View>
</View>
)
}
+391
View File
@@ -0,0 +1,391 @@
import {useRef, useState} from 'react'
import {
LayoutAnimation,
Pressable,
type ScrollView,
useWindowDimensions,
View,
} from 'react-native'
import Animated from 'react-native-reanimated'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeHandle} from '#/lib/strings/handles'
import {type ActiveConvoStates, useConvoActive} from '#/state/messages/convo'
import {useSession} from '#/state/session'
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_NATIVE, IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky'
type Reaction = {
key: string
value: string
senders: ChatBskyConvoDefs.ReactionViewSender[]
count: number
}
export function ReactionsDialog({
control,
members,
message,
reactions,
groupedReactions,
}: {
control: Dialog.DialogControlProps
members: bsky.profile.AnyProfileView[]
message: ChatBskyConvoDefs.MessageView
reactions?: ChatBskyConvoDefs.ReactionView[]
groupedReactions?: Reaction[]
}) {
const {t: l} = useLingui()
const {height: screenHeight} = useWindowDimensions()
const {currentAccount} = useSession()
const convo = useConvoActive()
const [selected, setSelected] = useState('all')
const handleFilter = (value: string) => {
setSelected(value)
}
const filteredReactions = reactions?.filter(
r => selected === 'all' || r.value === selected,
)
const header = (
<>
<View style={[a.px_2xl, IS_WEB ? [a.pt_xl, a.pb_md] : a.pt_3xl]}>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>
<Trans>Reactions</Trans>
</Text>
</View>
<ReactionTabs
groupedReactions={groupedReactions}
selected={selected}
totalReactions={reactions?.length ?? 0}
onFilter={handleFilter}
/>
<Dialog.Close />
</>
)
return (
<Dialog.Outer
control={control}
onClose={() => setSelected('all')}
nativeOptions={{
preventExpansion: true,
minHeight: screenHeight / 2,
maxHeight: screenHeight / 2,
}}>
<Dialog.Handle />
{IS_NATIVE ? header : null}
<Dialog.ScrollableInner
label={l`Reactions`}
contentContainerStyle={[a.pt_0]}
header={IS_WEB ? header : null}
style={[web({maxWidth: 400})]}>
{filteredReactions
?.sort((a, b) => {
if (a.sender.did === currentAccount?.did) return -1
if (b.sender.did === currentAccount?.did) return 1
return 0
})
.map(reaction => {
const sender = members.find(m => m.did === reaction.sender.did)
if (!sender) return null
return (
<ReactionRow
key={reaction.sender.did + '-' + reaction.value}
control={control}
convo={convo}
currentAccount={currentAccount}
message={message}
profile={sender}
reaction={reaction}
allReactions={reactions ?? []}
selected={selected}
setSelected={setSelected}
/>
)
})}
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function ReactionRow({
control,
convo,
currentAccount,
message,
profile,
reaction,
allReactions,
selected,
setSelected,
}: {
control: Dialog.DialogControlProps
convo: ActiveConvoStates
currentAccount?: bsky.profile.AnyProfileView
message: ChatBskyConvoDefs.MessageView
profile: bsky.profile.AnyProfileView
reaction: ChatBskyConvoDefs.ReactionView
allReactions: ChatBskyConvoDefs.ReactionView[]
selected: string
setSelected: React.Dispatch<React.SetStateAction<string>>
}) {
const t = useTheme()
const {t: l} = useLingui()
const isFromSelf = currentAccount?.did === profile.did
const displayName = createSanitizedDisplayName(profile, true)
const handle = sanitizeHandle(profile?.handle ?? '', '@')
const handleOnPress = () => {
const remainingReactions =
allReactions?.filter(
r =>
!(r.value === reaction.value && r.sender.did === currentAccount?.did),
) ?? []
if (remainingReactions.length === 0) {
control.close()
} else if (
selected !== 'all' &&
!remainingReactions.some(r => r.value === reaction.value)
) {
// tab no longer exists
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setSelected('all')
}
convo
.removeReaction(message.id, reaction.value)
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
}
const inner = (
<>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<UserAvatar
avatar={profile.avatar}
size={42}
type="user"
hideLiveBadge
/>
<View>
<Text
numberOfLines={1}
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{displayName}
</Text>
<Text
numberOfLines={1}
style={[a.text_xs, t.atoms.text_contrast_medium, web([a.mt_xs])]}>
{isFromSelf ? l`Tap to remove` : handle}
</Text>
</View>
</View>
<View>
<Text style={[a.text_5xl, {includeFontPadding: false}]} emoji>
{reaction.value}
</Text>
</View>
</>
)
if (isFromSelf) {
return (
<Pressable
accessibilityRole="button"
accessibilityHint={l`Tap to remove your ${reaction.value} reaction`}
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.justify_between,
a.my_sm,
]}
onPress={handleOnPress}>
{inner}
</Pressable>
)
}
return (
<View
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.justify_between,
a.my_sm,
]}>
{inner}
</View>
)
}
function ReactionTabs({
groupedReactions,
selected,
totalReactions,
onFilter,
}: {
groupedReactions?: Reaction[]
selected: string
totalReactions: number
onFilter: (value: string) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const scrollViewRef = useRef<ScrollView>(null)
const scrollState = useRef({x: 0, width: 0})
const tabLayouts = useRef<Map<string, {x: number; width: number}>>(new Map())
const handlePress = (value: string) => {
onFilter(value)
// Scroll a partially-visible tab fully into view.
const layout = tabLayouts.current.get(value)
if (layout && scrollViewRef.current && scrollState.current.width > 0) {
const tabLeft = layout.x
const tabRight = layout.x + layout.width
const viewLeft = scrollState.current.x
const viewRight = viewLeft + scrollState.current.width
if (tabLeft < viewLeft) {
scrollViewRef.current.scrollTo({
x: Math.max(0, tabLeft - 24),
animated: true,
})
} else if (tabRight > viewRight) {
scrollViewRef.current.scrollTo({
x: tabRight - scrollState.current.width + 24,
animated: true,
})
}
}
}
const handleTabLayout = (key: string, layout: {x: number; width: number}) => {
tabLayouts.current.set(key, layout)
}
const tabs = [
{
key: 'all',
value: l`All`,
senders: [],
count: totalReactions,
} as Reaction,
...(groupedReactions ?? []),
]
return (
<View accessibilityRole="list" style={[t.atoms.bg]}>
<DraggableScrollView
ref={scrollViewRef}
horizontal={true}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={false}
onScroll={e => {
scrollState.current = {
x: e.nativeEvent.contentOffset.x,
width: e.nativeEvent.layoutMeasurement.width,
}
}}
onLayout={e => {
scrollState.current.width = e.nativeEvent.layout.width
}}>
<Animated.View
style={[
a.flex_row,
a.flex_grow,
a.gap_sm,
a.align_center,
a.justify_start,
]}>
{tabs?.map((reaction, index) => (
<ReactionTab
key={reaction.value}
index={index}
reaction={reaction}
selected={selected}
total={tabs.length}
onPress={handlePress}
onTabLayout={handleTabLayout}
/>
))}
</Animated.View>
</DraggableScrollView>
</View>
)
}
function ReactionTab({
index,
reaction,
selected,
total,
onPress,
onTabLayout,
}: {
index: number
reaction: Reaction
selected: string
total: number
onPress: (value: string) => void
onTabLayout: (key: string, layout: {x: number; width: number}) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
return (
<Pressable
accessibilityRole="button"
accessibilityHint={
reaction.key === 'all'
? l`Tap to show all reactions `
: l`Tap to show ${reaction.value} reactions`
}
hitSlop={HITSLOP_10}
style={[
a.flex_row,
a.align_center,
a.border,
a.justify_center,
a.rounded_lg,
a.px_md,
a.py_sm,
a.mb_sm,
selected === reaction.key
? t.atoms.border_contrast_low
: {borderColor: t.palette.contrast_50},
selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
]}
onLayout={e => {
onTabLayout(reaction.key, {
x: e.nativeEvent.layout.x,
width: e.nativeEvent.layout.width,
})
}}
onPress={() => onPress(reaction.key)}>
<Text emoji style={[a.text_sm]}>
{l`${reaction.value} ${reaction.count}`}
</Text>
</Pressable>
)
}
@@ -0,0 +1,16 @@
import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
export function EmptyMemberList({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>
)
}
@@ -0,0 +1,65 @@
import {View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {atoms as a, useTheme} from '#/alf'
import {canBeMessaged} from '#/components/dms/util'
import * as Toggle from '#/components/forms/Toggle'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
export function GroupChatProfileCard({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const t = useTheme()
const enabled = canBeMessaged(profile)
const moderation = moderateProfile(profile, moderationOpts)
const handle = sanitizeHandle(profile.handle, '@')
const displayName = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
return (
<Toggle.Item
key={profile.did}
disabled={!enabled}
name={profile.did}
label={displayName}
style={[a.flex_1, a.py_sm, a.px_lg]}>
<View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
<View>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
{enabled ? (
<ProfileCard.Handle profile={profile} />
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>{handle} cant be messaged</Trans>
</Text>
)}
</View>
</ProfileCard.Header>
</View>
{enabled ? <Toggle.Checkbox /> : null}
</Toggle.Item>
)
}
@@ -0,0 +1,21 @@
import {View} from 'react-native'
import {atoms as a} from '#/alf'
import * as ProfileCard from '#/components/ProfileCard'
export function ProfileCardSkeleton() {
return (
<View
style={[
a.flex_1,
a.py_md,
a.px_lg,
a.gap_md,
a.align_center,
a.flex_row,
]}>
<ProfileCard.AvatarPlaceholder size={42} />
<ProfileCard.NameAndHandlePlaceholder />
</View>
)
}
@@ -0,0 +1,15 @@
import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
export function UserLabel({message}: {message: string}) {
const t = useTheme()
return (
<View style={[a.px_lg, a.py_sm]}>
<Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
{message}
</Text>
</View>
)
}
@@ -0,0 +1,68 @@
import {TextInput, View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme, web} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
export function UserSearchInput({
value,
onChangeText,
onEscape,
inputRef,
}: {
value: string
onChangeText: (text: string) => void
onEscape: () => void
inputRef: React.RefObject<TextInput | null>
}) {
const t = useTheme()
const {t: l} = 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]}>
<SearchIcon
size="md"
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
/>
<TextInput
// @ts-ignore bottom sheet input types issue - esb
ref={inputRef}
placeholder={l`Search for people`}
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={l`Search profiles`}
accessibilityHint={l`Searches for profiles`}
/>
</View>
)
}
+41 -7
View File
@@ -3,13 +3,14 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {logger} from '#/logger'
import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {FAB} from '#/view/com/util/fab/FAB'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {MessagePlus_Stroke2_Corner0_Rounded as NewChatIcon} from '#/components/icons/Message'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
@@ -38,12 +39,28 @@ export function NewChat({
},
onError: error => {
logger.error('Failed to create chat', {safeMessage: error})
Toast.show(l`An issue occurred starting the chat`, {
Toast.show(l`An issue occurred starting the chat, please try again`, {
type: 'error',
})
},
})
const {mutate: createGroupChat} = useCreateGroupChat({
onSuccess: data => {
onNewChat(data.convo.id)
ax.metric('groupchat:create', {logContext: 'NewChatDialog'})
},
onError: error => {
logger.error('Failed to create groupchat', {safeMessage: error})
Toast.show(
l`An issue occurred creating the group chat, please try again`,
{
type: 'error',
},
)
},
})
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
@@ -52,10 +69,21 @@ export function NewChat({
)
const onCreateGroupChat = useCallback(
(_dids: string[], _groupName: string) => {
control.close()
(members: string[], name: string) => {
control.close(() => {
createGroupChat({members, name})
})
},
[control],
[control, createGroupChat],
)
const onSelectExistingChat = useCallback(
(chatId: string) => {
control.close(() => {
onNewChat(chatId)
})
},
[control, onNewChat],
)
const onPress = useCallback(() => {
@@ -74,7 +102,7 @@ export function NewChat({
<FAB
testID="newChatFAB"
onPress={wrappedOnPress}
icon={<Plus size="lg" fill={t.palette.white} />}
icon={<NewChatIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={l`New chat`}
accessibilityHint=""
@@ -93,7 +121,13 @@ export function NewChat({
) : (
<SearchablePeopleList
title={l`Start a new chat`}
onSelectChat={onCreateChat}
onSelectChat={chat => {
if (chat.kind === 'user') {
onCreateChat(chat.did)
} else {
onSelectExistingChat(chat.id)
}
}}
sortByMessageDeclaration
/>
)}
@@ -53,6 +53,13 @@ function SendViaChatDialogInner({
},
})
const onSelectExistingChat = useCallback(
(chatId: string) => {
control.close(() => onSelectChat(chatId))
},
[control, onSelectChat],
)
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
@@ -63,7 +70,13 @@ function SendViaChatDialogInner({
return (
<SearchablePeopleList
title={_(msg`Send post to...`)}
onSelectChat={onCreateChat}
onSelectChat={chat => {
if (chat.kind === 'user') {
onCreateChat(chat.did)
} else {
onSelectExistingChat(chat.id)
}
}}
showRecentConvos
sortByMessageDeclaration
/>
-1
View File
@@ -1 +0,0 @@
export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src'
@@ -1 +0,0 @@
export {TextInput} from 'react-native'
+99 -2
View File
@@ -1,7 +1,8 @@
import {type ChatBskyConvoDefs} from '@atproto/api'
import {type $Typed, ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import type * as bsky from '#/types/bsky'
import {logger} from '#/logger'
import * as bsky from '#/types/bsky'
export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
switch (profile.associated?.chat?.allowIncoming) {
@@ -54,3 +55,99 @@ export function hasReachedReactionLimit(
)
return myReactions.length >= EMOJI_REACTION_LIMIT
}
type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
// can be missing if account deleted
kind?: $Typed<ChatBskyActorDefs.GroupConvoMember>
}
type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
kind: $Typed<ChatBskyActorDefs.DirectConvoMember>
}
export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & (
| {
kind: 'group'
details: ChatBskyConvoDefs.GroupConvo
primaryMember: GroupConvoMember // the owner
members: Array<GroupConvoMember>
}
| {
kind: 'direct'
details: ChatBskyConvoDefs.DirectConvo
primaryMember: DirectConvoMember // the other user
members: Array<DirectConvoMember>
}
)
/**
* Converts a raw convoView into something easier to use (i.e. extracts chat owner)
* and enforces the correct type for convo members.
*/
export function parseConvoView(
convoView: ChatBskyConvoDefs.ConvoView,
ownDid: string | undefined,
): ConvoWithDetails | null {
if (
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
convoView.kind,
ChatBskyConvoDefs.isGroupConvo,
)
) {
let owner: GroupConvoMember | undefined = undefined
for (const member of convoView.members) {
if (
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
member.kind,
ChatBskyActorDefs.isGroupConvoMember,
)
) {
if (member.kind.role === 'owner') {
// have to do a type assertion here
// this works: {...member, kind: member.kind}
// however that's creating a new object for no good reason
owner = member as GroupConvoMember
}
} else {
throw new Error(
'Expected a GroupConvoMember, got an unknown kind of member',
)
}
}
if (!owner) {
throw new Error('No owner found in group convo')
}
return {
view: convoView,
kind: 'group',
details: convoView.kind,
primaryMember: owner,
members: convoView.members as Array<GroupConvoMember>,
}
} else if (
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
convoView.kind,
ChatBskyConvoDefs.isDirectConvo,
)
) {
const otherUser = convoView.members.find(m => m.did !== ownDid)
if (!otherUser) {
throw new Error('No other user found in direct convo')
}
return {
view: convoView,
kind: 'direct',
details: convoView.kind,
primaryMember: otherUser as DirectConvoMember,
members: convoView.members as Array<DirectConvoMember>,
}
} else {
logger.warn('Unknown convo kind: ' + JSON.stringify(convoView.kind))
return null
}
}
+4
View File
@@ -15,3 +15,7 @@ export const Message_Stroke2_Corner0_Rounded_Filled = createSinglePathSVG({
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',
})
export const MessagePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z',
})
+3
View File
@@ -0,0 +1,3 @@
export const ITEM_GAP = 8 // tokens.space.sm
export const MIN_ASPECT_RATIO = 2 / 3 // portrait limit
export const MAX_ASPECT_RATIO = 3 / 2 // landscape limit
+531
View File
@@ -0,0 +1,531 @@
import {
cloneElement,
createContext,
isValidElement,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
import Animated, {
type AnimatedRef,
useAnimatedRef,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {utils} from '@bsky.app/alf'
import {Trans, useLingui} from '@lingui/react/macro'
import debounce from 'lodash.debounce'
import {type Dimensions} from '#/lib/media/types'
import {mergeRefs} from '#/lib/merge-refs'
import {useA11y} from '#/state/a11y'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {
ITEM_GAP,
MAX_ASPECT_RATIO,
MIN_ASPECT_RATIO,
} from '#/components/images/Gallery/const'
import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers'
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
import {getAspectRatio} from '#/components/images/Gallery/utils'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
export * from './const'
export * from './maybeApplyGalleryOffsetStyles'
interface GalleryProps {
images: AppBskyEmbedImages.ViewImage[]
onPress?: (
index: number,
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
}
const Context = createContext<{
bleedRef: React.RefObject<View | null>
bleedWidth: number
}>({
bleedRef: {current: null},
bleedWidth: 0,
})
export function GalleryBleed({children}: {children: React.ReactNode}) {
const ref = useRef<View>(null)
const [bleedWidth, setBleedWidth] = useState(0)
if (!isValidElement(children)) {
throw new Error('GalleryBleed children must be a single React element')
}
const node = children as React.ReactElement<any>
return (
<Context.Provider value={{bleedRef: ref, bleedWidth}}>
{cloneElement(node, {
ref: mergeRefs([ref, node?.props?.ref]),
onLayout: (e: {nativeEvent: {layout: {width: number}}}) => {
setBleedWidth(e.nativeEvent.layout.width)
node.props.onLayout?.(e)
},
style: [node.props.style, a.overflow_hidden],
})}
</Context.Provider>
)
}
export function useGalleryBleed() {
return useContext(Context)
}
export function Gallery({
images,
onPress,
onPressIn,
viewContext,
}: GalleryProps) {
const {t: l} = useLingui()
const ax = useAnalytics()
const {screenReaderEnabled} = useA11y()
const largeAltBadge = useLargeAltBadgeEnabled()
const bps = useBreakpoints()
const window = useWindowDimensions()
const contentHeight = useMemo(() => {
if (bps.gtMobile) {
return 300
} else if (bps.gtPhone) {
return 260
} else {
return 200
}
}, [bps])
const isWithinQuote =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const hideBadges = isWithinQuote
/*
* Container overflow styles
*
* Uses measureLayout to get the Gallery's offset relative to the GalleryBleed
* ancestor. This is a layout-relative measurement that doesn't depend on
* scroll position, so it works correctly for off-screen FlatList items.
*/
const {bleedRef, bleedWidth} = useGalleryBleed()
const contentRef = useRef<View>(null)
const [contentDims, setContentDims] = useState<{x: number; width: number}>()
const measure = () => {
if (contentRef.current && bleedRef.current) {
contentRef.current.measureLayout(
bleedRef.current,
(x, _y, w) => {
setContentDims({x, width: w})
},
() => {},
)
}
}
const width = bleedWidth || Math.min(600, window.width)
const insetLeft = contentDims?.x ?? 0
const insetRight =
bleedWidth > 0
? bleedWidth - (contentDims?.x ?? 0) - (contentDims?.width ?? 0)
: 0
/* End container overflow styles */
const flatListRef = useRef<FlatList>(null)
const itemWidthsRef = useRef<Map<number, number>>(new Map())
const itemRefsRef = useRef<Map<number, View>>(new Map())
const containerRefsRef = useRef<Map<number, AnimatedRef<any>>>(new Map())
const thumbDimsRef = useRef<Map<number, Dimensions>>(new Map())
const currentIndexRef = useRef(0)
const emitSwipeMetric = useMemo(
() =>
debounce((fromIndex: number, toIndex: number) => {
ax.metric('post:gallery:swipe', {
fromImage: fromIndex + 1, // convert to 1-based index for easier analysis
toImage: toIndex + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
}, 200),
[ax, images.length],
)
const setCurrentIndex = (index: number) => {
const prev = currentIndexRef.current
if (prev !== index) {
currentIndexRef.current = index
emitSwipeMetric(prev, index)
}
}
const scrollTo = (offset: number) => {
flatListRef.current?.scrollToOffset({offset, animated: false})
}
const onSettle = (index: number) => {
setCurrentIndex(index)
if (!IS_WEB) return
// Update tabIndex: only the active image is tab-focusable
itemRefsRef.current.forEach((node, i) => {
const el = node as unknown as HTMLElement
el.tabIndex = i === index ? 0 : -1
})
const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null
el?.focus({preventScroll: true})
}
useKeyboardHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount: images.length,
})
usePointerHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount: images.length,
})
if (screenReaderEnabled) {
return (
<View style={[a.relative, a.gap_sm]}>
{images.map((image, index) => (
<AutoSizedImage
key={image.thumb + index}
crop={
viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) =>
onPress?.(index, [containerRef], [dims])
}
onPressIn={() => onPressIn?.(index)}
hideBadge={
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
))}
</View>
)
}
return (
<View
ref={contentRef}
style={[
a.w_full,
{
height: contentHeight,
overflow: 'visible',
},
]}
onLayout={measure}>
<BlockDrawerGesture>
<FlatList
ref={flatListRef}
role="group"
aria-roledescription={l`carousel`}
aria-label={l`Image gallery, ${images.length} images`}
horizontal
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
decelerationRate={0.993}
directionalLockEnabled
nestedScrollEnabled
alwaysBounceVertical={false}
scrollEventThrottle={16}
data={images}
keyExtractor={(item, index) => item.thumb + index}
renderItem={({item, index}) => {
return (
<GalleryImage
hideBadges={hideBadges}
largeAltBadge={largeAltBadge}
image={item}
contentHeight={contentHeight}
index={index}
imageCount={images.length}
onWidthChange={(i, w) => {
itemWidthsRef.current.set(i, w)
}}
itemRef={node => {
if (node) {
itemRefsRef.current.set(index, node)
} else {
itemRefsRef.current.delete(index)
}
}}
onContainerRef={(i, ref) => {
containerRefsRef.current.set(i, ref)
}}
onThumbDims={(i, dims) => {
thumbDimsRef.current.set(i, dims)
}}
onPress={
onPress
? () => {
ax.metric('post:gallery:openLightbox', {
fromImage: index + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
const refs: AnimatedRef<any>[] = []
const dims: (Dimensions | null)[] = []
for (let i = 0; i < images.length; i++) {
refs.push(containerRefsRef.current.get(i)!)
dims.push(thumbDimsRef.current.get(i) ?? null)
}
onPress(index, refs, dims)
}
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
/>
)
}}
onScroll={e => {
// web handles via onSettle in the web hooks
if (IS_WEB) return
const offsetX = e.nativeEvent.contentOffset.x
let accumulated = 0
for (let i = 0; i < images.length; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
setCurrentIndex(i)
break
}
accumulated += w
if (i === images.length - 1) {
setCurrentIndex(i)
}
}
}}
style={[
{
height: contentHeight,
marginLeft: -insetLeft,
width,
},
]}
contentContainerStyle={{
gap: ITEM_GAP,
paddingLeft: insetLeft,
paddingRight: insetRight,
}}
/>
</BlockDrawerGesture>
</View>
)
}
function computeDims({
height,
aspectRatio,
}: {
height: number
aspectRatio?: number
}) {
/*
* Old images, or images from other clients can sometimes not have
* aspectRatio populated. In these cases, default to square and we'll
* resize once the image loads.
*
* Clamp between MIN_ASPECT_RATIO (portrait) and MAX_ASPECT_RATIO
* (landscape) so items stay a reasonable size in the carousel.
*/
const raw = aspectRatio ?? 1
const clamped = Math.max(MIN_ASPECT_RATIO, Math.min(raw, MAX_ASPECT_RATIO))
const width = Math.floor(height * clamped)
return {width, height, aspectRatio: clamped, isCropped: raw !== clamped}
}
function GalleryImage({
contentHeight: height,
image,
index,
imageCount,
onWidthChange,
itemRef,
hideBadges,
largeAltBadge,
onContainerRef,
onThumbDims,
onPress,
onPressIn,
}: {
contentHeight: number
image: AppBskyEmbedImages.ViewImage
index: number
imageCount: number
onWidthChange: (index: number, width: number) => void
itemRef: (node: View | null) => void
hideBadges?: boolean
largeAltBadge?: boolean
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
onThumbDims: (index: number, dims: Dimensions) => void
onPress?: () => void
onPressIn?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const [focused, setFocused] = useState(false)
const containerRef = useAnimatedRef()
const [aspectRatio, setAspectRatio] = useState(() =>
getAspectRatio(image.aspectRatio),
)
const {isCropped, ...dims} = computeDims({height, aspectRatio})
const hasAlt = !!image.alt
useEffect(() => {
onWidthChange(index, dims.width)
}, [index, dims.width, onWidthChange])
useEffect(() => {
onContainerRef(index, containerRef)
}, [index, containerRef, onContainerRef])
return (
<Animated.View
ref={containerRef}
collapsable={false}
aria-roledescription={l`slide`}
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
<Pressable
ref={itemRef}
tabIndex={index === 0 ? 0 : -1}
onPress={onPress}
onPressIn={onPressIn}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
accessibilityRole="button"
accessibilityLabel={image.alt || l`Image ${index + 1}`}
accessibilityHint={l`Opens full image`}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
web({
cursor: 'inherit',
outline: 0,
border: 0,
}),
]}>
<Image
source={{uri: image.thumb}}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
loading={index === 0 ? 'eager' : 'lazy'}
style={[dims]}
onLoad={e => {
const ar = getAspectRatio(e.source)
if (ar && ar !== aspectRatio) {
setAspectRatio(ar)
}
onThumbDims(index, {
width: e.source.width,
height: e.source.height,
})
}}
/>
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeAltBadge && {
gap: 4,
},
]}>
{isCropped && (
<View
style={[
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Fullscreen
fill={t.atoms.text_contrast_high.color}
width={largeAltBadge ? 18 : 12}
/>
</View>
)}
{hasAlt && (
<View
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans>ALT</Trans>
</Text>
</View>
)}
</View>
) : null}
<MediaInsetBorder
style={
focused && {
borderWidth: 2,
}
}
/>
</Pressable>
</Animated.View>
)
}
@@ -0,0 +1,102 @@
import {
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
type AppBskyFeedDefs,
AppBskyFeedPost,
type ModerationCause,
type ModerationUI,
} from '@atproto/api'
import {unique} from '#/lib/moderation'
import {type AppModerationCause} from '#/components/Pills'
import {Features, features} from '#/analytics/features'
import * as bsky from '#/types/bsky'
export const POST_META_NO_CONTENT_OFFSET = {paddingTop: 10}
export const POST_EMBED_NO_CONTENT_OFFSET = {paddingTop: 6}
export function maybeApplyGalleryOffsetStyles(
placement: 'meta' | 'embed',
{
post,
modui,
additionalCauses,
}: {
post: AppBskyFeedDefs.PostView
modui: ModerationUI
additionalCauses?: ModerationCause[] | AppModerationCause[]
},
) {
// don't ever check gates like this, except this one time
if (!features.isOn(Features.PostGalleryEmbedEnable)) return
if (
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
return
}
/*
* First check if we even have images
*/
const embed = post.record.embed
const isImageEmbed =
embed &&
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
embed,
AppBskyEmbedImages.isMain,
)
const isRecordWithMedia =
embed &&
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
embed,
AppBskyEmbedRecordWithMedia.isMain,
)
let hasImages = false
if (isImageEmbed) {
// one image, not a gallery
if (embed.images.length === 1) return
hasImages = true
}
if (isRecordWithMedia) {
if (
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
embed.media,
AppBskyEmbedImages.isMain,
)
) {
// one image, not a gallery
if (embed.media.images.length === 1) return
}
hasImages = true
}
if (!hasImages) return
/*
* Then check if we have any text
*/
let hasLabels = false
if (modui.alert) {
hasLabels = modui.alerts.filter(unique).length > 0
}
if (modui.inform) {
hasLabels = hasLabels || modui.informs.filter(unique).length > 0
}
if (additionalCauses?.length) {
hasLabels = true
}
/*
* If no text or labels, then we need a lil bump
*/
const shouldApplyOffset = !post.record.text && !hasLabels
return shouldApplyOffset
? placement === 'meta'
? POST_META_NO_CONTENT_OFFSET
: POST_EMBED_NO_CONTENT_OFFSET
: {}
}
+40
View File
@@ -0,0 +1,40 @@
function ease(t: number, b: number, c: number, d: number) {
return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b
}
/**
* Tween from `start` to `end` over `duration` ms using an exponential ease-out.
* Returns a function that starts the tween. That function returns a stop handle.
*
* Adapted from tinkerbell.
*/
export function tween(start: number, end: number, duration: number) {
return function run(cb: (v: number) => void, done?: () => void) {
let ts: number | undefined
let frame: number
frame = (function tick(last: number) {
return requestAnimationFrame(t => {
if (!ts) ts = t
const te = t - ts
const next = Math.round(ease(te, start, end - start, duration))
if (
(end > start
? next < end && last <= end
: next > end && last >= end) &&
te <= duration
) {
frame = tick(next)
cb(next)
} else {
cb(end)
done?.()
}
})
})(start)
return function stop() {
cancelAnimationFrame(frame)
}
}
}
@@ -0,0 +1,8 @@
export function useKeyboardHandlers(_args: {
flatListRef: any
itemWidthsRef: any
currentIndexRef: any
scrollTo: any
onSettle: any
imageCount: any
}) {}
@@ -0,0 +1,91 @@
import {useEffect} from 'react'
import {type FlatList} from 'react-native'
import {tween} from '#/components/images/Gallery/tween'
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
const SETTLE_DURATION = 700
export function useKeyboardHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
}: {
flatListRef: React.RefObject<FlatList | null>
itemWidthsRef: React.RefObject<Map<number, number>>
currentIndexRef: React.RefObject<number>
scrollTo: (offset: number) => void
onSettle: (index: number) => void
imageCount: number
}) {
useEffect(() => {
if (imageCount <= 1) return
let stopTween: (() => void) | null = null
let pendingIndex: number | null = null
const onKeyDown = (e: KeyboardEvent) => {
const el =
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
if (!el || !el.contains(document.activeElement)) return
const current = pendingIndex ?? currentIndexRef.current
let targetIndex: number | undefined
if (e.key === 'ArrowRight') {
if (current < imageCount - 1) {
targetIndex = current + 1
}
} else if (e.key === 'ArrowLeft') {
if (current > 0) {
targetIndex = current - 1
}
}
if (targetIndex != null) {
e.preventDefault()
if (stopTween) {
stopTween()
stopTween = null
}
pendingIndex = targetIndex
const from = el.scrollLeft
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
const idx = targetIndex
stopTween = tween(
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
pendingIndex = null
onSettle(idx)
},
)
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
if (stopTween) stopTween()
}
}, [
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
])
}
@@ -0,0 +1,8 @@
export function usePointerHandlers(_args: {
flatListRef: any
itemWidthsRef: any
currentIndexRef: any
scrollTo: any
onSettle: any
imageCount: any
}) {}
@@ -0,0 +1,270 @@
import {useEffect} from 'react'
import {type FlatList} from 'react-native'
import {ITEM_GAP} from '#/components/images/Gallery/const'
import {tween} from '#/components/images/Gallery/tween'
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
const DRAG_THRESHOLD = 3
const FLICK_DECAY = 0.85
const FLICK_MIN_VELOCITY = 0.1
const ADVANCE_THRESHOLD = 0.15
const FRAME_MS = 1000 / 60
const SETTLE_DURATION = 700
const OVERSCROLL_RESISTANCE = 0.4
const BOUNCE_DURATION = 700
function whichByDistance(
itemWidths: Map<number, number>,
currentIndex: number,
distance: number,
direction: -1 | 1,
imageCount: number,
): number {
let remaining = distance
let i = currentIndex
while (remaining > 0 && i >= 0 && i < imageCount) {
const w = (itemWidths.get(i) ?? 0) + ITEM_GAP
if (remaining > w) {
remaining -= w
i -= direction
} else if (remaining > w * ADVANCE_THRESHOLD) {
i -= direction
break
} else {
break
}
}
return Math.max(0, Math.min(i, imageCount - 1))
}
export function usePointerHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
}: {
flatListRef: React.RefObject<FlatList | null>
itemWidthsRef: React.RefObject<Map<number, number>>
currentIndexRef: React.RefObject<number>
scrollTo: (offset: number) => void
onSettle: (index: number) => void
imageCount: number
}) {
useEffect(() => {
if (imageCount <= 1) return
const el =
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
if (!el) return
let isDragging = false
let isMouseDown = false
let startX = 0
let dragScrollLeft = 0
let delta = 0
let prevDelta = 0
let velo = 0
let t = 0
let stopTween: (() => void) | null = null
let localIndex = currentIndexRef.current
let overscrollX = 0
el.style.cursor = 'grab'
const clearOverscroll = () => {
overscrollX = 0
el.style.transform = ''
}
const onMouseDown = (e: MouseEvent) => {
e.preventDefault() // prevent native image drag
// Cancel any in-progress tween
if (stopTween) {
stopTween()
stopTween = null
}
clearOverscroll()
isMouseDown = true
isDragging = false
localIndex = currentIndexRef.current
startX = e.pageX
dragScrollLeft = el.scrollLeft
delta = 0
prevDelta = 0
velo = 0
t = e.timeStamp
}
const onMouseMove = (e: MouseEvent) => {
if (!isMouseDown) return
const x = e.pageX - startX
// Require minimum movement before starting drag
if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return
if (!isDragging) {
isDragging = true
el.style.cursor = 'grabbing'
el.style.userSelect = 'none'
// Blur focused element within the gallery
if (el.contains(document.activeElement)) {
;(document.activeElement as HTMLElement)?.blur?.()
}
}
e.preventDefault()
// Track velocity
const elapsed = e.timeStamp - t || 1
prevDelta = delta
delta = x
velo = (delta - prevDelta) / (elapsed * FRAME_MS)
t = e.timeStamp
const desiredScroll = dragScrollLeft - delta
const maxScroll = el.scrollWidth - el.clientWidth
if (desiredScroll < 0) {
// Overscroll at start — rubber band
scrollTo(0)
overscrollX = desiredScroll * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else if (desiredScroll > maxScroll) {
// Overscroll at end — rubber band
scrollTo(maxScroll)
overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else {
// Normal scroll range
scrollTo(desiredScroll)
if (overscrollX !== 0) clearOverscroll()
}
// Update local index from scroll position (only in normal range)
if (overscrollX === 0) {
const offsetX = desiredScroll
let accumulated = 0
for (let i = 0; i < imageCount; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
localIndex = i
break
}
accumulated += w
if (i === imageCount - 1) localIndex = i
}
}
}
const onMouseUp = () => {
if (!isMouseDown) return
const wasDragging = isDragging
isMouseDown = false
isDragging = false
el.style.cursor = 'grab'
el.style.userSelect = ''
if (wasDragging) {
// Suppress the click that follows mouseup after a drag
el.addEventListener('click', e => e.stopPropagation(), {
once: true,
capture: true,
})
if (overscrollX !== 0) {
// Bounce back from overscroll
const targetIndex = overscrollX > 0 ? imageCount - 1 : 0
const fromOverscroll = overscrollX
stopTween = tween(
fromOverscroll,
0,
BOUNCE_DURATION,
)(
v => {
el.style.transform = `translateX(${-v}px)`
},
() => {
stopTween = null
clearOverscroll()
onSettle(targetIndex)
},
)
} else {
// Normal flick settle
let v = Math.abs(velo)
let restingDistance = 0
while (v > FLICK_MIN_VELOCITY) {
v *= FLICK_DECAY
restingDistance += v
}
const direction: -1 | 1 = delta < 0 ? -1 : 1
const totalDistance = Math.abs(delta) + restingDistance
const targetIndex = whichByDistance(
itemWidthsRef.current,
localIndex,
totalDistance,
direction,
imageCount,
)
const from = el.scrollLeft
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
if (from === to) {
onSettle(targetIndex)
return
}
stopTween = tween(
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
onSettle(targetIndex)
},
)
}
}
}
el.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
return () => {
el.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
if (stopTween) stopTween()
clearOverscroll()
el.style.cursor = ''
el.style.userSelect = ''
}
}, [
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
])
}
+22
View File
@@ -0,0 +1,22 @@
import {ITEM_GAP} from '#/components/images/Gallery/const'
export function getOffsetForIndex(
itemWidths: Map<number, number>,
index: number,
): number {
let offset = 0
for (let i = 0; i < index; i++) {
offset += (itemWidths.get(i) ?? 0) + ITEM_GAP
}
return offset
}
export function getAspectRatio({
width,
height,
}: {width?: number; height?: number} = {}) {
if (width && width > 0 && height && height > 0) {
return width / height
}
return undefined
}
+1 -1
View File
@@ -6,7 +6,7 @@ import {type AppBskyEmbedImages} from '@atproto/api'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a, useBreakpoints} from '#/alf'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {GalleryItem} from './Gallery'
import {GalleryItem} from './ImageLayoutGridItem'
interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]
@@ -10,8 +10,8 @@ import {useVerificationCreateMutation} from '#/state/queries/verification/useVer
import {atoms as a, useBreakpoints} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {type DialogControlProps} from '#/components/Dialog'
import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
+1 -1
View File
@@ -8,8 +8,8 @@ import {
} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import * as env from '#/env'
import {IS_WEB} from '#/env'
import {
type LiveEventFeed,
type LiveEventFeedMetricContext,
+31 -3
View File
@@ -1,10 +1,10 @@
import {
type AppBskyFeedDefs,
type AppBskyGraphDefs,
type BskyAgent,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {type BskyAgent} from '@atproto/api'
import {POST_IMG_MAX} from '#/lib/constants'
import {getLinkMeta} from '#/lib/link-meta/link-meta'
@@ -15,18 +15,19 @@ import {
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {
convertBskyAppUrlIfNeeded,
isBskyCustomFeedUrl,
isBskyListUrl,
isBskyPostUrl,
isBskyStarterPackUrl,
isBskyStartUrl,
isShortLink,
makeRecordUri,
} from '#/lib/strings/url-helpers'
import {type ComposerImage} from '#/state/gallery'
import {createComposerImage} from '#/state/gallery'
import {type Gif} from '#/state/queries/tenor'
import {createGIFDescription} from '../gif-alt-text'
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
type ResolvedExternalLink = {
type: 'external'
@@ -190,7 +191,26 @@ export async function resolveGif(
agent: BskyAgent,
gif: Gif,
): Promise<ResolvedExternalLink> {
const uri = `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`
const gifUrl = gif.media_formats.gif.url
const params = new URLSearchParams()
params.set('hh', String(gif.media_formats.gif.dims[1]))
params.set('ww', String(gif.media_formats.gif.dims[0]))
// For Klipy GIFs, embed video format slugs so parseKlipyGif can
// swap to the right format per platform at render time. Klipy uses
// different filename slugs per format (unlike Tenor where format is
// encoded in the URL ID), so this info must travel with the URL.
try {
const url = new URL(gifUrl)
if (url.hostname === 'static.klipy.com') {
const mp4Slug = getFileSlug(gif.media_formats.mp4?.url)
const webmSlug = getFileSlug(gif.media_formats.webm?.url)
if (mp4Slug) params.set('mp4', mp4Slug)
if (webmSlug) params.set('webm', webmSlug)
}
} catch {}
const uri = `${gifUrl}?${params.toString()}`
const altText = gif.content_description || gif.title
return {
type: 'external',
@@ -201,6 +221,14 @@ export async function resolveGif(
}
}
function getFileSlug(url: string | undefined): string | undefined {
if (!url) return undefined
const filename = url.split('/').pop()
if (!filename) return undefined
const dotIndex = filename.lastIndexOf('.')
return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined
}
async function resolveExternal(
agent: BskyAgent,
uri: string,
+3 -3
View File
@@ -1,12 +1,12 @@
import * as React from 'react'
import {useRef} from 'react'
import {Animated} from 'react-native'
export function useAnimatedValue(initialValue: number) {
const lazyRef = React.useRef<Animated.Value>(undefined)
const lazyRef = useRef<Animated.Value>(undefined)
if (lazyRef.current === undefined) {
lazyRef.current = new Animated.Value(initialValue)
}
return lazyRef.current as Animated.Value
return lazyRef.current
}
+5 -5
View File
@@ -1,13 +1,13 @@
import * as React from 'react'
import {useCallback, useEffect, useRef} from 'react'
/**
* Helper hook to run persistent timers on views
*/
export function useTimer(time: number, handler: () => void) {
const timer = React.useRef<undefined | NodeJS.Timeout>(undefined)
const timer = useRef<undefined | NodeJS.Timeout>(undefined)
// function to restart the timer
const reset = React.useCallback(() => {
const reset = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current)
}
@@ -15,7 +15,7 @@ export function useTimer(time: number, handler: () => void) {
}, [time, timer, handler])
// function to cancel the timer
const cancel = React.useCallback(() => {
const cancel = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = undefined
@@ -23,7 +23,7 @@ export function useTimer(time: number, handler: () => void) {
}, [timer])
// start the timer immediately
React.useEffect(() => {
useEffect(() => {
reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
+2 -2
View File
@@ -30,7 +30,7 @@ export async function getServiceAuthToken({
return serviceAuth.token
}
export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) {
export async function getVideoUploadLimits(agent: BskyAgent, i18n: I18n) {
const token = await getServiceAuthToken({
agent,
lxm: 'app.bsky.video.getUploadLimits',
@@ -52,7 +52,7 @@ export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) {
throw new UploadLimitError(limits.message)
} else {
throw new UploadLimitError(
_(
i18n._(
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
),
)
+6 -4
View File
@@ -16,19 +16,19 @@ export async function uploadVideo({
did,
setProgress,
signal,
_,
i18n,
}: {
video: CompressedVideo
agent: BskyAgent
did: string
setProgress: (progress: number) => void
signal: AbortSignal
_: I18n['_']
i18n: I18n
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, _)
await getVideoUploadLimits(agent, i18n)
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
@@ -69,7 +69,9 @@ export async function uploadVideo({
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
if (!responseBody.jobId) {
throw new ServerError(responseBody.error || _(msg`Failed to upload video`))
throw new ServerError(
responseBody.error || i18n._(msg`Failed to upload video`),
)
}
if (signal.aborted) {
+6 -6
View File
@@ -15,19 +15,19 @@ export async function uploadVideo({
did,
setProgress,
signal,
_,
i18n,
}: {
video: CompressedVideo
agent: BskyAgent
did: string
setProgress: (progress: number) => void
signal: AbortSignal
_: I18n['_']
i18n: I18n
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, _)
await getVideoUploadLimits(agent, i18n)
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
@@ -70,11 +70,11 @@ export async function uploadVideo({
) as AppBskyVideoDefs.JobStatus
resolve(uploadRes)
} else {
reject(new ServerError(_(msg`Failed to upload video`)))
reject(new ServerError(i18n._(msg`Failed to upload video`)))
}
}
xhr.onerror = () => {
reject(new ServerError(_(msg`Failed to upload video`)))
reject(new ServerError(i18n._(msg`Failed to upload video`)))
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType)
@@ -84,7 +84,7 @@ export async function uploadVideo({
)
if (!res.jobId) {
throw new ServerError(res.error || _(msg`Failed to upload video`))
throw new ServerError(res.error || i18n._(msg`Failed to upload video`))
}
if (signal.aborted) {
+1
View File
@@ -73,6 +73,7 @@ export type CommonNavigatorParams = {
Hashtag: {tag: string; author?: string}
Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string; accept?: true}
MessagesConversationSettings: {conversation: string}
MessagesSettings: undefined
MessagesInbox: undefined
NotificationsActivityList: {posts: string}
+25 -4
View File
@@ -683,14 +683,35 @@ export function parseKlipyGif(urlp: URL):
return {success: false}
}
// Use the base URL without dimension params as the player URI,
// routed through the bsky KLIPY proxy (k.gifs.bsky.app). Mirrors
// Tenor's t.gifs.bsky.app rewrite, but on a separate hostname so
// the two upstreams can be routed independently.
const playerUrl = new URL(urlp.href)
playerUrl.hostname = 'k.gifs.bsky.app'
// On web, swap the gif filename for a video format so the <video>
// element can play it. Klipy uses different filename slugs per
// format (unlike Tenor's ID-based scheme), so the slugs are
// embedded as query params at composition time by resolveGif().
if (IS_WEB) {
const webmSlug = playerUrl.searchParams.get('webm')
const mp4Slug = playerUrl.searchParams.get('mp4')
const slug = IS_WEB_SAFARI ? mp4Slug : webmSlug
const ext = IS_WEB_SAFARI ? 'mp4' : 'webm'
// Without a slug we can't produce a playable video URL on web,
// so fall back to the link card instead of returning a broken player.
if (!slug) {
return {success: false}
}
const parts = playerUrl.pathname.split('/')
parts[parts.length - 1] = `${slug}.${ext}`
playerUrl.pathname = parts.join('/')
}
// Strip all metadata params — only the path matters for the CDN
playerUrl.searchParams.delete('hh')
playerUrl.searchParams.delete('ww')
playerUrl.searchParams.delete('mp4')
playerUrl.searchParams.delete('webm')
return {
success: true,
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,6 +16,7 @@ export enum LogContext {
PolicyUpdate = 'policy-update',
Geolocation = 'geolocation',
Drafts = 'drafts',
Growthbook = 'growthbook',
/**
* METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
+1
View File
@@ -85,6 +85,7 @@ export const router = new Router<AllNavigatableRoutes>({
MessagesSettings: '/messages/settings',
MessagesInbox: '/messages/inbox',
MessagesConversation: '/messages/:conversation',
MessagesConversationSettings: '/messages/:conversation/settings',
// starter packs
Start: '/start/:name/:rkey',
StarterPackEdit: '/starter-pack/edit/:rkey',
+4 -4
View File
@@ -242,7 +242,7 @@ export function MessagesScreenInner({navigation, route}: Props) {
if (!isScreenFocused) {
return
}
return listenSoftReset(onSoftReset)
return listenSoftReset(() => void onSoftReset())
}, [onSoftReset, isScreenFocused])
// NOTE(APiligrim)
@@ -292,7 +292,7 @@ export function MessagesScreenInner({navigation, route}: Props) {
size="small"
color="secondary_inverted"
variant="solid"
onPress={() => refetch()}>
onPress={() => void refetch()}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
@@ -342,8 +342,8 @@ export function MessagesScreenInner({navigation, route}: Props) {
renderItem={renderItem}
keyExtractor={keyExtractor}
refreshing={isPTRing}
onRefresh={onRefresh}
onEndReached={onEndReached}
onRefresh={() => void onRefresh()}
onEndReached={() => void onEndReached()}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
+45 -14
View File
@@ -1,11 +1,15 @@
import {useCallback, useEffect, useMemo, useState} from 'react'
import {View} from 'react-native'
import {type LayoutChangeEvent, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
type AppBskyActorDefs,
moderateProfile,
type ModerationDecision,
} from '@atproto/api'
import {ScrollEdgeEffectProvider} from '@bsky.app/expo-scroll-edge-effect'
import {
ScrollEdgeEffect,
ScrollEdgeEffectProvider,
} from '@bsky.app/expo-scroll-edge-effect'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -45,7 +49,7 @@ import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
import {Error} from '#/components/Error'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import {IS_WEB} from '#/env'
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
@@ -83,7 +87,10 @@ export function MessagesConversationScreenInner({route}: Props) {
)
return (
<Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}>
<Layout.Screen
testID="convoScreen"
noInsetTop={IS_LIQUID_GLASS}
style={web([{minHeight: 0}, a.flex_1])}>
<ScrollEdgeEffectProvider>
<ConvoProvider key={convoId} convoId={convoId}>
<Inner />
@@ -98,10 +105,11 @@ function Inner() {
const convoState = useConvo()
const {_} = useLingui()
const isFocused = useIsFocused()
const {top: topInset} = useSafeAreaInsets()
const moderationOpts = useModerationOpts()
const {data: recipientUnshadowed} = useProfileQuery({
did: convoState.recipients?.[0].did,
did: convoState.getPrimaryMember?.()?.did,
})
const recipient = useMaybeProfileShadow(recipientUnshadowed)
@@ -133,9 +141,10 @@ function Inner() {
if (convoState.status === ConvoStatus.Error) {
return (
<>
<Layout.Center style={[a.flex_1]}>
<Layout.Center
style={[a.w_full, IS_LIQUID_GLASS && {paddingTop: topInset}]}>
{moderation ? (
<MessagesListHeader moderation={moderation} profile={recipient} />
<MessagesListHeader profile={recipient} moderation={moderation} />
) : (
<MessagesListHeader />
)}
@@ -154,12 +163,15 @@ function Inner() {
<Layout.Center style={[a.flex_1]}>
{/* MessagesList does not use the body scroll */}
{isFocused && IS_WEB && <RemoveScrollBar />}
{!readyToShow &&
(moderation ? (
<MessagesListHeader moderation={moderation} profile={recipient} />
) : (
<MessagesListHeader />
))}
{!readyToShow && (
<View style={IS_LIQUID_GLASS && {paddingTop: topInset}}>
{moderation ? (
<MessagesListHeader profile={recipient} moderation={moderation} />
) : (
<MessagesListHeader />
)}
</View>
)}
<View style={[a.flex_1]}>
{moderation && recipient ? (
<InnerReady
@@ -205,6 +217,11 @@ function InnerReady({
}) {
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
const {top: topInset} = useSafeAreaInsets()
const [headerHeight, setHeaderHeight] = useState(0)
const onHeaderLayout = (e: LayoutChangeEvent) => {
setHeaderHeight(e.nativeEvent.layout.height)
}
const {params} =
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
const {needsEmailVerification} = useEmail()
@@ -248,15 +265,29 @@ function InnerReady({
maybeBlockForEmailVerification()
}, [maybeBlockForEmailVerification])
const header = (
<MessagesListHeader profile={recipient} moderation={moderation} />
)
return (
<>
<MessagesListHeader profile={recipient} moderation={moderation} />
{IS_LIQUID_GLASS ? (
<ScrollEdgeEffect
edge="top"
style={[a.absolute, a.w_full, a.z_10, {paddingTop: topInset}]}
onLayout={onHeaderLayout}>
{header}
</ScrollEdgeEffect>
) : (
header
)}
{isConvoActive(convoState) && (
<MessagesList
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
blocked={moderation?.blocked}
hasAcceptOverride={!!params.accept}
transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0}
footer={
<MessagesListBlockedFooter
recipient={recipient}
File diff suppressed because it is too large Load Diff
+230 -123
View File
@@ -1,39 +1,43 @@
import {memo, useCallback, useMemo, useState} from 'react'
import {useCallback, useMemo, useState} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
AppBskyEmbedRecord,
ChatBskyConvoDefs,
moderateProfile,
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {GestureActionView} from '#/lib/custom-animations/GestureActionView'
import {useHaptics} from '#/lib/haptics'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {decrementBadgeCount} from '#/lib/notifications/notifications'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {
postUriToRelativePath,
toBskyAppUrl,
toShortUrl,
} from '#/lib/strings/url-helpers'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
precacheConvoQuery,
useMarkAsReadMutation,
} from '#/state/queries/messages/conversation'
import {precacheProfile} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import * as tokens from '#/alf/tokens'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {useDialogControl} from '#/components/Dialog'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen'
import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash'
@@ -49,71 +53,199 @@ import type * as bsky from '#/types/bsky'
export const ChatListItemPortal = createPortalGroup()
export let ChatListItem = ({
convo,
/**
* IMPORTANT NOTE: THIS IS CURRENTLY JANKY AF AND PROBABLY BROKEN, JUST WANTED TO ADD GROUPCHAT SUPPPORT
*
* TAKE A SECOND PASS PLEASE -sfn
*/
export function ChatListItem({
convo: convoView,
showMenu = true,
children,
}: {
convo: ChatBskyConvoDefs.ConvoView
showMenu?: boolean
children?: React.ReactNode
}): React.ReactNode => {
}) {
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const otherUser = convo.members.find(
member => member.did !== currentAccount?.did,
)
if (!otherUser || !moderationOpts) {
if (!moderationOpts) {
return null
}
return (
<ChatListItemReady
convo={convo}
profile={otherUser}
moderationOpts={moderationOpts}
showMenu={showMenu}>
{children}
</ChatListItemReady>
)
const convo = parseConvoView(convoView, currentAccount?.did)
switch (convo?.kind) {
case 'direct': {
return (
<DirectChatItem
convo={convo}
moderationOpts={moderationOpts}
showMenu={showMenu}>
{children}
</DirectChatItem>
)
}
case 'group': {
return (
<GroupChatItem
convo={convo}
moderationOpts={moderationOpts}
showMenu={showMenu}
/>
)
}
default: {
return null
}
}
}
ChatListItem = memo(ChatListItem)
function ChatListItemReady({
function DirectChatItem({
convo,
profile: profileUnshadowed,
moderationOpts,
showMenu,
children,
}: {
convo: ChatBskyConvoDefs.ConvoView
profile: bsky.profile.AnyProfileView
convo: Extract<ConvoWithDetails, {kind: 'direct'}>
moderationOpts: ModerationOpts
showMenu?: boolean
children?: React.ReactNode
}) {
const ax = useAnalytics()
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const leaveConvoControl = useDialogControl()
const {gtMobile} = useBreakpoints()
const profile = useProfileShadow(profileUnshadowed)
const {mutate: markAsRead} = useMarkAsReadMutation()
const {t: l} = useLingui()
const profile = useProfileShadow(convo.primaryMember)
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? l`Deleted Account`
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
return (
<BaseChatItem
convo={convo.view}
avatar={
<PreviewableUserAvatar
profile={profile}
size={52}
moderation={moderation.ui('avatar')}
/>
}
primaryProfile={profile}
primaryProfileModeration={moderation}
title={displayName}
subtitle={
isDeletedAccount ? undefined : sanitizeHandle(profile.handle, '@')
}
accessibilityHint={
!isDeletedAccount
? l`Go to conversation with ${profile.handle}`
: l`This conversation is with a deleted or a deactivated account. Press for options`
}
showMenu={showMenu}
isDeletedAccount={isDeletedAccount}
isBlockedAccount={moderation.blocked}
showProfileBadges
postAlerts={
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
}>
{children}
</BaseChatItem>
)
}
function GroupChatItem({
convo,
moderationOpts,
showMenu,
children,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
moderationOpts: ModerationOpts
showMenu?: boolean
children?: React.ReactNode
}) {
const {t: l} = useLingui()
const groupOwner = useProfileShadow(convo.primaryMember)
const moderation = useMemo(
() => moderateProfile(groupOwner, moderationOpts),
[groupOwner, moderationOpts],
)
const chatName = convo.details.name
return (
<BaseChatItem
convo={convo.view}
avatar={<AvatarBubbles profiles={convo.members} size="medium" />}
title={chatName}
accessibilityHint={l`Go to the group chat named "${chatName}"`}
primaryProfile={groupOwner}
primaryProfileModeration={moderation}
isBlockedAccount={false}
isDeletedAccount={false}
showProfileBadges={false}
showMenu={showMenu}>
{children}
</BaseChatItem>
)
}
function BaseChatItem({
convo,
avatar,
title,
subtitle,
accessibilityHint,
isDeletedAccount,
isBlockedAccount,
primaryProfile,
primaryProfileModeration,
showMenu,
showProfileBadges,
postAlerts,
children,
}: {
convo: ChatBskyConvoDefs.ConvoView
avatar: React.ReactNode
title: string
subtitle?: string
accessibilityHint: string
isDeletedAccount: boolean
isBlockedAccount: boolean
primaryProfile: Shadow<bsky.profile.AnyProfileView>
primaryProfileModeration: ModerationDecision
showMenu?: boolean
showProfileBadges: boolean
postAlerts?: React.ReactNode
children?: React.ReactNode
}) {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const leaveConvoControl = useDialogControl()
const {mutate: markAsRead} = useMarkAsReadMutation()
const {gtMobile} = useBreakpoints()
const playHaptic = useHaptics()
const queryClient = useQueryClient()
const isUnread = convo.unreadCount > 0
const blockInfo = useMemo(() => {
const modui = moderation.ui('profileView')
const modui = primaryProfileModeration.ui('profileView')
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
const userBlock = blocks.find(alert => alert.source.type === 'user')
@@ -121,21 +253,13 @@ function ChatListItemReady({
listBlocks,
userBlock,
}
}, [moderation])
}, [primaryProfileModeration])
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? _(msg`Deleted Account`)
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
const isDimStyle = convo.muted || isBlockedAccount || isDeletedAccount
const {lastMessage, lastMessageSentAt, latestReportableMessage} =
useMemo(() => {
let lastMessage = _(msg`No messages yet`)
let lastMessage = l`No messages yet`
let lastMessageSentAt: string | null = null
@@ -150,14 +274,12 @@ function ChatListItemReady({
if (convo.lastMessage.text) {
if (isFromMe) {
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
lastMessage = l`You: ${convo.lastMessage.text}`
} else {
lastMessage = convo.lastMessage.text
}
} else if (convo.lastMessage.embed) {
const defaultEmbeddedContentMessage = _(
msg`(contains embedded content)`,
)
const defaultEmbeddedContentMessage = l`(contains embedded content)`
if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) {
const embed = convo.lastMessage.embed
@@ -172,14 +294,14 @@ function ChatListItemReady({
? toShortUrl(href)
: defaultEmbeddedContentMessage
if (isFromMe) {
lastMessage = _(msg`You: ${short}`)
lastMessage = l`You: ${short}`
} else {
lastMessage = short
}
}
} else {
if (isFromMe) {
lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`)
lastMessage = l`You: ${defaultEmbeddedContentMessage}`
} else {
lastMessage = defaultEmbeddedContentMessage
}
@@ -192,8 +314,8 @@ function ChatListItemReady({
lastMessageSentAt = convo.lastMessage.sentAt
lastMessage = isDeletedAccount
? _(msg`Conversation deleted`)
: _(msg`Message deleted`)
? l`Conversation deleted`
: l`Message deleted`
}
if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
@@ -205,44 +327,36 @@ function ChatListItemReady({
const isFromMe =
convo.lastReaction.reaction.sender.did === currentAccount?.did
const lastMessageText = convo.lastReaction.message.text
const fallbackMessage = _(
msg({
message: 'a message',
comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`,
}),
)
const fallbackMessage = l({
message: 'a message',
comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`,
})
if (isFromMe) {
lastMessage = _(
msg`You reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`,
)
lastMessage = l`You reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
} else {
const senderDid = convo.lastReaction.reaction.sender.did
const sender = convo.members.find(
member => member.did === senderDid,
)
if (sender) {
lastMessage = _(
msg`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`,
)
lastMessage = l`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
} else {
lastMessage = _(
msg`Someone reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`,
)
lastMessage = l`Someone reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
}
}
}
@@ -254,7 +368,7 @@ function ChatListItemReady({
latestReportableMessage,
}
}, [
_,
l,
convo.lastMessage,
convo.lastReaction,
currentAccount?.did,
@@ -279,9 +393,11 @@ function ChatListItemReady({
const onPress = useCallback(
(e: GestureResponderEvent) => {
precacheProfile(queryClient, profile)
for (const member of convo.members) {
unstableCacheProfileView(queryClient, member)
}
precacheConvoQuery(queryClient, convo)
decrementBadgeCount(convo.unreadCount)
void decrementBadgeCount(convo.unreadCount)
if (isDeletedAccount) {
e.preventDefault()
menuControl.open()
@@ -290,7 +406,7 @@ function ChatListItemReady({
ax.metric('chat:open', {logContext: 'ChatsList'})
}
},
[ax, isDeletedAccount, menuControl, queryClient, profile, convo],
[ax, isDeletedAccount, menuControl, queryClient, convo],
)
const onLongPress = useCallback(() => {
@@ -345,40 +461,30 @@ function ChatListItemReady({
a.absolute,
{top: tokens.space.md, left: tokens.space.lg},
]}>
<PreviewableUserAvatar
profile={profile}
size={52}
moderation={moderation.ui('avatar')}
/>
{avatar}
</View>
<Link
to={`/messages/${convo.id}`}
label={displayName}
accessibilityHint={
!isDeletedAccount
? _(msg`Go to conversation with ${profile.handle}`)
: _(
msg`This conversation is with a deleted or a deactivated account. Press for options`,
)
}
label={title}
accessibilityHint={accessibilityHint}
accessibilityActions={
IS_NATIVE
showMenu && IS_NATIVE
? [
{
name: 'magicTap',
label: _(msg`Open conversation options`),
label: l`Open conversation options`,
},
{
name: 'longpress',
label: _(msg`Open conversation options`),
label: l`Open conversation options`,
},
]
: undefined
}
onPress={onPress}
onLongPress={IS_NATIVE ? onLongPress : undefined}
onAccessibilityAction={onLongPress}>
onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined}
onAccessibilityAction={showMenu ? onLongPress : undefined}>
{({hovered, pressed, focused}) => (
<View
style={[
@@ -407,14 +513,18 @@ function ChatListItemReady({
{lineHeight: 21},
isDimStyle && t.atoms.text_contrast_medium,
]}>
{displayName}
{title}
</Text>
</View>
<ProfileBadges
profile={profile}
size="md"
style={[a.pl_xs, a.self_center]}
/>
{showProfileBadges && (
<ProfileBadges
profile={primaryProfile}
size="md"
style={[a.pl_xs, a.self_center]}
/>
)}
{lastMessageSentAt && (
<View style={[a.pl_xs]}>
<TimeElapsed timestamp={lastMessageSentAt}>
@@ -432,7 +542,7 @@ function ChatListItemReady({
</TimeElapsed>
</View>
)}
{(convo.muted || moderation.blocked) && (
{(convo.muted || isBlockedAccount) && (
<Text
style={[
a.text_sm,
@@ -450,7 +560,7 @@ function ChatListItemReady({
)}
</View>
{!isDeletedAccount && (
{subtitle && (
<Text
numberOfLines={1}
style={[
@@ -458,7 +568,7 @@ function ChatListItemReady({
t.atoms.text_contrast_medium,
a.pb_xs,
]}>
@{profile.handle}
{subtitle}
</Text>
)}
@@ -474,11 +584,7 @@ function ChatListItemReady({
{lastMessage}
</Text>
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
{postAlerts}
{children}
</View>
@@ -509,7 +615,7 @@ function ChatListItemReady({
{showMenu && (
<ConvoMenu
convo={convo}
profile={profile}
profile={primaryProfile}
control={menuControl}
currentScreen="list"
showMarkAsRead={convo.unreadCount > 0}
@@ -529,6 +635,7 @@ function ChatListItemReady({
latestReportableMessage={latestReportableMessage}
/>
)}
<LeaveConvoPrompt
control={leaveConvoControl}
convoId={convo.id}
@@ -1,4 +1,4 @@
import {useEffect, useState} from 'react'
import {useState} from 'react'
import {Pressable, View} from 'react-native'
import {
useKeyboardHandler,
@@ -25,14 +25,9 @@ import {
useMessageDraft,
useSaveMessageDraft,
} from '#/state/messages/message-drafts'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
type Emoji,
EmojiPicker,
type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
import {Composer, useComposerInternalApiRef} from '#/components/Composer'
import * as EmojiPicker from '#/components/EmojiPicker'
import {GlassView} from '#/components/GlassView'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
@@ -60,10 +55,6 @@ export function MessageComposer({
const {needsEmailVerification} = useEmail()
const editable = !needsEmailVerification
const {getDraft, clearDraft} = useMessageDraft()
const [emojiPickerState, setEmojiPickerState] = useState<EmojiPickerState>({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})
const composerInternalApiRef = useComposerInternalApiRef()
const [text, setText] = useState(getDraft)
@@ -85,10 +76,6 @@ export function MessageComposer({
const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0)
const openEmojiPicker = (pos: any) => {
setEmojiPickerState({isOpen: true, pos})
}
const onSubmit = () => {
if (!editable) return
if (!hasEmbed && text.trim() === '') return
@@ -112,16 +99,6 @@ export function MessageComposer({
}
}
useEffect(() => {
function onEmojiInserted(emoji: Emoji) {
composerInternalApiRef.current?.insert(emoji.native)
}
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
return () => {
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
}
}, [composerInternalApiRef])
return (
<ComposerContainer>
{children}
@@ -142,54 +119,47 @@ export function MessageComposer({
tintColor={t.palette.contrast_50}
fallbackStyle={[t.atoms.bg_contrast_50]}>
{IS_WEB && (
<Pressable
onPress={e => {
e.currentTarget.measure(
(_fx, _fy, _width, _height, px, py) => {
// TODO: rip this horrible system out
openEmojiPicker?.({
top: py,
left: px - 400,
right: px - 400,
bottom: py,
nextFocusRef: {
current:
composerInternalApiRef.current?.input?.element,
<EmojiPicker.Root
onEmojiSelect={emoji =>
composerInternalApiRef.current?.insert(emoji.native)
}
nextFocusRef={() =>
composerInternalApiRef.current?.input?.element
}>
<EmojiPicker.Trigger label={l`Open emoji picker`}>
{({props, state, control}) => (
<Pressable
{...props}
style={[
a.overflow_hidden,
a.absolute,
a.rounded_full,
a.align_center,
a.justify_center,
a.z_30,
{
height: 20,
width: 20,
top: 10,
right: 10,
},
})
},
)
}}
style={[
a.overflow_hidden,
a.absolute,
a.rounded_full,
a.align_center,
a.justify_center,
a.z_30,
{
height: 20,
width: 20,
top: 10,
right: 10,
},
]}
accessibilityLabel={l`Open emoji picker`}
accessibilityHint="">
{state => (
<EmojiSmileIcon
size="md"
style={
state.hovered ||
state.focused ||
state.pressed ||
emojiPickerState.isOpen
? {color: t.palette.primary_500}
: t.atoms.text_contrast_high
}
/>
)}
</Pressable>
]}>
<EmojiSmileIcon
size="md"
style={
state.hovered ||
state.focused ||
state.pressed ||
control.isOpen
? {color: t.palette.primary_500}
: t.atoms.text_contrast_high
}
/>
</Pressable>
)}
</EmojiPicker.Trigger>
<EmojiPicker.Picker />
</EmojiPicker.Root>
)}
<Composer
@@ -226,14 +196,6 @@ export function MessageComposer({
<SubmitButton onPress={onSubmit} disabled={submitDisabled} />
</GlassContainer>
</View>
{IS_WEB && (
<EmojiPicker
pinToTop
state={emojiPickerState}
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
</ComposerContainer>
)
}
@@ -15,8 +15,7 @@ import Animated, {
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {GlassContainer} from 'expo-glass-effect'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {countGraphemes} from 'unicode-segmenter/grapheme'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
@@ -26,7 +25,6 @@ import {
useMessageDraft,
useSaveMessageDraft,
} from '#/state/messages/message-drafts'
import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker'
import {atoms as a, platform, tokens, useTheme} from '#/alf'
import {GlassView} from '#/components/GlassView'
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
@@ -47,13 +45,12 @@ export function MessageInput({
children,
}: {
textInputId?: string
onSendMessage: (message: string) => void
onSendMessage: (message: string) => Promise<void> | void
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const playHaptic = useHaptics()
const {getDraft, clearDraft} = useMessageDraft()
@@ -82,13 +79,13 @@ export function MessageInput({
return
}
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(_(msg`Message is too long`), {
Toast.show(l`Message is too long`, {
type: 'error',
})
return
}
clearDraft()
onSendMessage(message)
void onSendMessage(message)
playHaptic()
setEmbed(undefined)
setMessage('')
@@ -111,7 +108,7 @@ export function MessageInput({
playHaptic,
setEmbed,
inputRef,
_,
l,
])
useFocusedInputHandler(
@@ -169,9 +166,9 @@ export function MessageInput({
fallbackStyle={[t.atoms.bg_contrast_50]}>
<AnimatedTextInput
nativeID={textInputId}
accessibilityLabel={_(msg`Message input field`)}
accessibilityHint={_(msg`Type your message here`)}
placeholder={_(msg`Message`)}
accessibilityLabel={l`Message input field`}
accessibilityHint={l`Type your message here`}
placeholder={l`Message`}
placeholderTextColor={t.palette.contrast_500}
value={message}
onChange={evt => {
@@ -225,7 +222,7 @@ export function MessageInput({
}}>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Send message`)}
accessibilityLabel={l`Send message`}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
@@ -1,7 +1,6 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {useCallback, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {flushSync} from 'react-dom'
import TextareaAutosize from 'react-textarea-autosize'
import {countGraphemes} from 'unicode-segmenter/grapheme'
@@ -12,13 +11,9 @@ import {
useMessageDraft,
useSaveMessageDraft,
} from '#/state/messages/message-drafts'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
type Emoji,
type EmojiPickerPosition,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {atoms as a, flatten, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import * as EmojiPicker from '#/components/EmojiPicker'
import {useSharedInputStyles} from '#/components/forms/TextField'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
@@ -31,16 +26,14 @@ export function MessageInput({
hasEmbed,
setEmbed,
children,
openEmojiPicker,
}: {
onSendMessage: (message: string) => void
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) {
const {isMobile} = useWebMediaQueries()
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const {getDraft, clearDraft} = useMessageDraft()
const [message, setMessage] = useState(getDraft)
@@ -57,7 +50,7 @@ export function MessageInput({
return
}
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(_(msg`Message is too long`), {
Toast.show(l`Message is too long`, {
type: 'error',
})
return
@@ -66,7 +59,7 @@ export function MessageInput({
onSendMessage(message)
setMessage('')
setEmbed(undefined)
}, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed])
}, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed])
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -105,12 +98,11 @@ export function MessageInput({
}, [])
const onEmojiInserted = useCallback(
(emoji: Emoji) => {
(emoji: EmojiPicker.Emoji) => {
if (!textAreaRef.current) {
return
}
const position = textAreaRef.current.selectionStart ?? 0
textAreaRef.current.focus()
flushSync(() => {
setMessage(
message =>
@@ -122,12 +114,6 @@ export function MessageInput({
},
[setMessage],
)
useEffect(() => {
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
return () => {
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
}
}, [onEmojiInserted])
useSaveMessageDraft(message)
useExtractEmbedFromFacets(message, setEmbed)
@@ -153,49 +139,45 @@ export function MessageInput({
// @ts-expect-error web only
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
<Button
onPress={e => {
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
openEmojiPicker?.({
top: py,
left: px,
right: px,
bottom: py,
nextFocusRef:
textAreaRef as unknown as React.MutableRefObject<HTMLElement>,
})
})
}}
style={[
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
{
marginTop: 5,
height: 30,
width: 30,
},
]}
label={_(msg`Open emoji picker`)}>
{state => (
<View
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
{
backgroundColor:
state.hovered || state.focused || state.pressed
? t.atoms.bg.backgroundColor
: undefined,
},
]}>
<EmojiSmile size="lg" />
</View>
)}
</Button>
<EmojiPicker.Root
onEmojiSelect={onEmojiInserted}
nextFocusRef={textAreaRef}>
<EmojiPicker.Trigger label={l`Open emoji picker`}>
{({props, state}) => (
<Button
style={[
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
{
marginTop: 5,
height: 30,
width: 30,
},
]}
label={props.accessibilityLabel}
{...props}>
<View
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
{
backgroundColor:
state.hovered || state.focused || state.pressed
? t.atoms.bg.backgroundColor
: undefined,
},
]}>
<EmojiSmile size="lg" />
</View>
</Button>
)}
</EmojiPicker.Trigger>
<EmojiPicker.Picker />
</EmojiPicker.Root>
<TextareaAutosize
ref={textAreaRef}
style={flatten([
@@ -210,7 +192,7 @@ export function MessageInput({
},
])}
maxRows={12}
placeholder={_(msg`Write a message`)}
placeholder={l`Message`}
defaultValue=""
value={message}
dirName="ltr"
@@ -231,7 +213,7 @@ export function MessageInput({
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Send message`)}
accessibilityLabel={l`Send message`}
accessibilityHint=""
style={[
a.rounded_full,
@@ -1,34 +1,33 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {type ConvoItem, ConvoItemError} from '#/state/messages/convo/types'
import {atoms as a, useTheme} from '#/alf'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {InlineLinkText} from '#/components/Link'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {description, help, cta} = useMemo(() => {
return {
[ConvoItemError.FirehoseFailed]: {
description: _(msg`This chat was disconnected`),
help: _(msg`Press to attempt reconnection`),
cta: _(msg`Reconnect`),
description: l`This chat was disconnected`,
help: l`Press to attempt reconnection`,
cta: l`Reconnect`,
},
[ConvoItemError.HistoryFailed]: {
description: _(msg`Failed to load past messages`),
help: _(msg`Press to retry`),
cta: _(msg`Retry`),
description: l`Failed to load past messages`,
help: l`Press to retry`,
cta: l`Retry`,
},
}[item.code]
}, [_, item.code])
}, [l, item.code])
return (
<View style={[a.py_md, a.w_full, a.flex_row, a.justify_center]}>
<View style={[a.my_md, a.w_full, a.flex_row, a.justify_center]}>
<View
style={[
a.flex_1,
@@ -41,18 +40,18 @@ export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
<CircleInfo size="sm" fill={t.palette.negative_400} />
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
{description} &middot;{' '}
{description}
{item.retry && (
<InlineLinkText
to="#"
label={help}
onPress={e => {
e.preventDefault()
item.retry?.()
return false
}}>
{cta}
</InlineLinkText>
<>
&middot;{' '}
<InlineLinkText
label={help}
{...createStaticClick(() => {
item.retry?.()
})}>
{cta}
</InlineLinkText>
</>
)}
</Text>
</View>
+107 -64
View File
@@ -1,11 +1,18 @@
import {useCallback, useEffect, useId, useRef, useState} from 'react'
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from 'react'
import {type LayoutChangeEvent, type ScrollViewProps, View} from 'react-native'
import {
KeyboardChatScrollView,
type KeyboardChatScrollViewProps,
KeyboardGestureArea,
} from 'react-native-keyboard-controller'
import Animated, {
import {
runOnJS,
type ScrollEvent,
type SharedValue,
@@ -42,10 +49,6 @@ import {
} from '#/state/messages/convo/types'
import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session'
import {
EmojiPicker,
type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {List, type ListMethods} from '#/view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
@@ -53,6 +56,7 @@ import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {DateDividerToggleProvider} from '#/components/dms/DateDividerToggle'
import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader'
@@ -61,6 +65,7 @@ import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env'
import {ChatStatusInfo} from './ChatStatusInfo'
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
import {MessagesListInfoPanel} from './MessagesListInfoPanel'
import {KeyboardStickyView} from './vendor/KeyboardStickyView'
function MaybeLoader({isLoading}: {isLoading: boolean}) {
@@ -77,18 +82,6 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
)
}
function renderItem({item}: {item: ConvoItem}) {
if (item.type === 'message' || item.type === 'pending-message') {
return <MessageItem item={item} />
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'error') {
return <MessageListError item={item} />
}
return null
}
function keyExtractor(item: ConvoItem) {
return item.key
}
@@ -103,12 +96,14 @@ export function MessagesList({
blocked,
footer,
hasAcceptOverride,
transparentHeaderHeight,
}: {
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
blocked?: boolean
footer?: React.ReactNode
hasAcceptOverride?: boolean
transparentHeaderHeight?: number
}) {
const ax = useAnalytics()
const convoState = useConvoActive()
@@ -125,11 +120,6 @@ export function MessagesList({
startContentOffset: 0,
})
const [emojiPickerState, setEmojiPickerState] = useState<EmojiPickerState>({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})
const inputHeightUI = useSharedValue(0)
const [inputHeightJS, setInputHeightJS] = useState(0)
@@ -155,6 +145,18 @@ export function MessagesList({
const prevContentHeight = useRef(0)
const prevItemCount = useRef(0)
// Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset
// (which causes an early onScroll with negative offset) can't prevent the first scroll.
// Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding).
const hasInitiallyScrolled = useRef(false)
const prevHasScrolled = useRef(hasScrolled)
useLayoutEffect(() => {
if (prevHasScrolled.current && !hasScrolled) {
hasInitiallyScrolled.current = false
}
prevHasScrolled.current = hasScrolled
}, [hasScrolled])
// -- Keep track of background state and positioning for new pill
const layoutHeight = useSharedValue(0)
const didBackground = useRef(false)
@@ -187,8 +189,25 @@ export function MessagesList({
})
}
// This number _must_ be the height of the MaybeLoader component
if (height > 50 && isAtBottom.get()) {
// Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset
// can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here.
if (!hasInitiallyScrolled.current && convoState.items.length > 0) {
hasInitiallyScrolled.current = true
flatListRef.current?.scrollToOffset({offset: height, animated: false})
// If history is already done loading, mark ready after a frame for the scroll to settle.
// Otherwise, the footer sentinel's onLayout will handle it when history finishes.
if (!convoState.isFetchingHistory) {
requestAnimationFrame(() => {
setHasScrolled(true)
})
}
prevContentHeight.current = height
prevItemCount.current = convoState.items.length
return
}
// Subsequent: auto-scroll only if user is at the bottom
if (isAtBottom.get()) {
// If the size of the content is changing by more than the height of the screen, then we don't
// want to scroll further than the start of all the new content. Since we are storing the previous offset,
// we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill
@@ -212,17 +231,6 @@ export function MessagesList({
offset: height,
animated: hasScrolled && height > prevContentHeight.current,
})
// HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
// because otherwise there is too much of a delay between the time the content
// scrolls and the time the screen appears, causing a flicker.
// We cannot actually use a synchronous scroll here, because `onContentSizeChange`
// is actually async itself - all the info has to come across the bridge first.
if (!hasScrolled && !convoState.isFetchingHistory) {
setTimeout(() => {
setHasScrolled(true)
}, 100)
}
}
}
@@ -365,9 +373,39 @@ export function MessagesList({
})
}, [flatListRef])
const onOpenEmojiPicker = useCallback((pos: any) => {
setEmojiPickerState({isOpen: true, pos})
}, [])
const renderItem = ({item}: {item: ConvoItem}) => {
if (item.type === 'message' || item.type === 'pending-message') {
return (
<MessageItem
item={item}
profile={convoState.convo.members.find(
member => member.did === item.message.sender.did,
)}
isGroupChat={convoState.isGroup()}
/>
)
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'error') {
return <MessageListError item={item} />
}
return null
}
// Footer sentinel: when history is still loading during the initial scroll, the footer's onLayout fires each time
// new items are prepended (shifting its position). Once history finishes, this triggers setHasScrolled.
const onFooterLayout = useCallback(() => {
if (
hasInitiallyScrolled.current &&
!hasScrolled &&
!convoState.isFetchingHistory
) {
requestAnimationFrame(() => {
setHasScrolled(true)
})
}
}, [hasScrolled, setHasScrolled, convoState.isFetchingHistory])
const renderScrollComponent = useCallback(
(props: ScrollViewProps) => (
@@ -377,12 +415,13 @@ export function MessagesList({
)
return (
<>
<DateDividerToggleProvider>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
offset={Math.round(inputHeightJS)}
textInputNativeID={textInputId}
// slightly too buggy unfortunately, enable when possible
// textInputNativeID={textInputId}
style={[a.flex_1]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll}>
@@ -407,19 +446,36 @@ export function MessagesList({
showsVerticalScrollIndicator={!IS_ANDROID}
scrollEventThrottle={100}
ListHeaderComponent={
<MaybeLoader isLoading={convoState.isFetchingHistory} />
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.isGroup() && convoState.hasAllHistory ? (
<MessagesListInfoPanel convoState={convoState} />
) : null}
</>
}
// native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent}
// pushes up the content under the input on web (renderScrollComponent handles it on native)
ListFooterComponent={web(
<WebInputSpacer inputHeight={inputHeightJS} />,
)}
contentContainerStyle={{
paddingBottom: platform({
// ios is slightly larger as the input has no top padding
ios: tokens.space.lg,
android: tokens.space.md,
web: 0, // web uses ListFooterComponent instead for scroll reasons
}),
}}
ListFooterComponent={
<View
style={web({height: tokens.space.md + inputHeightJS})}
onLayout={onFooterLayout}
/>
}
style={web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable both-edges',
})}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
<KeyboardStickyView
@@ -444,7 +500,9 @@ export function MessagesList({
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={onSendMessage}
onSendMessage={(message: string) =>
void onSendMessage(message)
}
hasEmbed={!!embedUri}
setEmbed={setEmbed}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
@@ -454,8 +512,7 @@ export function MessagesList({
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
openEmojiPicker={onOpenEmojiPicker}>
setEmbed={setEmbed}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageInput>
)}
@@ -464,16 +521,8 @@ export function MessagesList({
</KeyboardStickyView>
</KeyboardGestureArea>
{IS_WEB && (
<EmojiPicker
pinToTop
state={emojiPickerState}
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
</>
</DateDividerToggleProvider>
)
}
@@ -518,12 +567,6 @@ function ChatScrollComponent({
)
}
function WebInputSpacer({inputHeight}: {inputHeight: number}) {
if (!IS_WEB) return null
return <Animated.View style={{height: inputHeight}} />
}
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
function getFooterState(
@@ -0,0 +1,135 @@
import {View} from 'react-native'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {type ConvoState} from '#/state/messages/convo/types'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import {Text} from '#/components/Typography'
export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
const t = useTheme()
const {t: l} = useLingui()
const addMembersControl = Dialog.useDialogControl()
const {currentAccount} = useSession()
const isOwner =
currentAccount?.did == null
? false
: convoState.getPrimaryMember?.()?.did === currentAccount.did
// TODO Get this from @api/atproto - dsb
const isLinkEnabled = false
const groupName = convoState.getGroupInfo?.()?.name
const members = (convoState?.convo?.members ?? []).filter(
profile => profile.did !== currentAccount?.did,
)
let names: React.ReactNode | null = null
if (members.length === 1) {
names = <Trans>New chat with {members[0].displayName}</Trans>
}
if (members.length === 2) {
names = (
<Trans>
New chat with {members[0].displayName} and {members[1].displayName}
</Trans>
)
}
if (members.length > 2) {
names = (
<Trans>
New chat with {members[0].displayName}, {members[1].displayName}, and{' '}
<Plural
value={members.length - 2}
one={`${members.length - 2} more`}
other={`${members.length - 2} more`}
/>
.
</Trans>
)
}
const showButtons = isOwner || isLinkEnabled
return (
<>
<View style={[a.align_center, a.justify_center]}>
<AvatarBubbles animate={true} profiles={members} />
{groupName ? (
<Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}>
{groupName}
</Text>
) : null}
{names ? (
<Text
style={[
a.text_sm,
a.mt_xs,
t.atoms.text_contrast_high,
showButtons ? null : a.mb_4xl,
]}>
{names}
</Text>
) : null}
{showButtons ? (
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_sm,
a.mt_lg,
a.mb_4xl,
]}>
{isOwner ? (
<Button
color="secondary"
size="small"
label={l`Click here to add people to this group chat`}
onPress={() => addMembersControl.open()}>
<ButtonIcon icon={PersonPlusIcon} />
<ButtonText>
<Trans>Add people</Trans>
</ButtonText>
</Button>
) : null}
{isOwner || isLinkEnabled ? (
<Button
color="secondary"
size="small"
label={l`Click here to view or create an invite link for this group chat`}
onPress={() => {}}>
<ButtonIcon icon={ChainLinkIcon} />
<ButtonText>
<Trans>Invite link</Trans>
</ButtonText>
</Button>
) : null}
</View>
) : null}
</View>
<Dialog.Outer
control={addMembersControl}
testID="addChatMembersDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<AddMembersFlow
title={l`Add people`}
onAddMembers={(_dids: string[]) => {
// TODO Add members here
addMembersControl.close()
}}
/>
</Dialog.Outer>
</>
)
}
@@ -5,31 +5,34 @@ import {Trans} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {atoms as a, tokens} from '#/alf'
import {parseConvoView} from '#/components/dms/util'
import {KnownFollowers} from '#/components/KnownFollowers'
import {Text} from '#/components/Typography'
import {ChatListItem, ChatListItemPortal} from './ChatListItem'
import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
export function RequestListItem({
convo: convoView,
}: {
convo: ChatBskyConvoDefs.ConvoView
}) {
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const otherUser = convo.members.find(
member => member.did !== currentAccount?.did,
)
const convo = parseConvoView(convoView, currentAccount?.did)
if (!otherUser || !moderationOpts) {
if (!convo || !moderationOpts) {
return null
}
const isDeletedAccount = otherUser.handle === 'missing.invalid'
const isDeletedAccount = convo.primaryMember.handle === 'missing.invalid'
return (
<View style={[a.relative, a.flex_1]}>
<ChatListItem convo={convo} showMenu={false}>
<ChatListItem convo={convo.view} showMenu={false}>
<View style={[a.pt_xs, a.pb_2xs]}>
<KnownFollowers
profile={otherUser}
profile={convo.primaryMember}
moderationOpts={moderationOpts}
minimal
showIfEmpty
@@ -59,17 +62,17 @@ export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
]}>
{!isDeletedAccount ? (
<>
<AcceptChatButton convo={convo} currentScreen="list" />
<AcceptChatButton convo={convo.view} currentScreen="list" />
<RejectMenu
convo={convo}
profile={otherUser}
convo={convo.view}
profile={convo.primaryMember}
showDeleteConvo
currentScreen="list"
/>
</>
) : (
<>
<DeleteChatButton convo={convo} currentScreen="list" />
<DeleteChatButton convo={convo.view} currentScreen="list" />
<View style={a.flex_1} />
</>
)}
@@ -39,6 +39,7 @@ import {Button} from '#/components/Button'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {GalleryBleed} from '#/components/images/Gallery'
import {Link} from '#/components/Link'
import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
@@ -308,234 +309,243 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
return (
<>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
<View
testID={`postThreadItem-by-${post.author.handle}`}
style={[
{
paddingHorizontal: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
<View collapsable={false}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
onBeforePress={onOpenAuthor}
/>
</View>
<Link
to={authorHref}
style={[a.flex_1]}
label={sanitizeDisplayName(
post.author.displayName || sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
onPress={onOpenAuthor}>
<View style={[a.flex_1, a.align_start]}>
<ProfileHoverCard did={post.author.did} style={[a.w_full]}>
<View style={[a.flex_row, a.align_center]}>
<GalleryBleed>
<View
testID={`postThreadItem-by-${post.author.handle}`}
style={[
{
paddingHorizontal: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
<View collapsable={false}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
onBeforePress={onOpenAuthor}
/>
</View>
<Link
to={authorHref}
style={[a.flex_1]}
label={sanitizeDisplayName(
post.author.displayName || sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
onPress={onOpenAuthor}>
<View style={[a.flex_1, a.align_start]}>
<ProfileHoverCard did={post.author.did} style={[a.w_full]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.flex_shrink,
a.text_lg,
a.font_semi_bold,
a.leading_snug,
]}
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
</Text>
<View style={[a.pl_xs]}>
<ProfileBadges
profile={authorShadow}
size="md"
interactive
/>
</View>
</View>
<Text
emoji
style={[
a.flex_shrink,
a.text_lg,
a.font_semi_bold,
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
{sanitizeHandle(post.author.handle, '@')}
</Text>
<View style={[a.pl_xs]}>
<ProfileBadges
profile={authorShadow}
size="md"
interactive
/>
</View>
</View>
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
</Text>
</ProfileHoverCard>
</View>
</Link>
<View collapsable={false} style={[a.self_center]}>
<ThreadItemAnchorFollowButton
did={post.author.did}
enabled={showFollowButton}
/>
</View>
</View>
<View style={[a.pb_sm]}>
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
<ContentHider
modui={moderation.ui('contentView')}
ignoreMute
childContainerStyle={[a.pt_sm]}>
<PostAlerts
modui={moderation.ui('contentView')}
size="lg"
includeMute
style={[a.pb_sm]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<RichText
enableTags
selectable
value={richText}
style={[a.flex_1, a.text_lg]}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
) : undefined}
<TranslatedPost post={post} postTextStyle={[a.text_lg]} />
{post.embed && (
<View style={[a.py_xs]}>
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.ThreadHighlighted}
onOpen={onOpenEmbed}
/>
</ProfileHoverCard>
</View>
)}
</ContentHider>
<ExpandedPostDetails
post={item.value.post}
isThreadAuthor={isThreadAuthor}
/>
{post.repostCount !== 0 ||
post.likeCount !== 0 ||
post.quoteCount !== 0 ||
post.bookmarkCount !== 0 ? (
// Show this section unless we're *sure* it has no engagement.
</Link>
<View collapsable={false} style={[a.self_center]}>
<ThreadItemAnchorFollowButton
did={post.author.did}
enabled={showFollowButton}
/>
</View>
</View>
<View style={[a.pb_sm]}>
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
<ContentHider
modui={moderation.ui('contentView')}
ignoreMute
childContainerStyle={[a.pt_sm]}>
<PostAlerts
modui={moderation.ui('contentView')}
size="lg"
includeMute
style={[a.pb_sm]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<RichText
enableTags
selectable
value={richText}
style={[a.flex_1, a.text_lg]}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
) : undefined}
<TranslatedPost post={post} postTextStyle={[a.text_lg]} />
{post.embed && (
<View style={[richText?.text ? a.py_xs : []]}>
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.ThreadHighlighted}
onOpen={onOpenEmbed}
/>
</View>
)}
</ContentHider>
<ExpandedPostDetails
post={item.value.post}
isThreadAuthor={isThreadAuthor}
/>
{post.repostCount !== 0 ||
post.likeCount !== 0 ||
post.quoteCount !== 0 ||
post.bookmarkCount !== 0 ? (
// Show this section unless we're *sure* it has no engagement.
<View
style={[
a.flex_row,
a.flex_wrap,
a.align_center,
{
rowGap: a.gap_sm.gap,
columnGap: a.gap_lg.gap,
},
a.border_t,
a.border_b,
a.mt_md,
a.py_md,
t.atoms.border_contrast_low,
]}>
{post.repostCount != null && post.repostCount !== 0 ? (
<Link to={repostsHref} label={l`Reposts of this post`}>
<Text
testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)">
<Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
</Trans>
</Text>
</Link>
) : null}
{post.quoteCount != null &&
post.quoteCount !== 0 &&
!post.viewer?.embeddingDisabled ? (
<Link to={quotesHref} label={l`Quotes of this post`}>
<Text
testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)">
<Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
</Trans>
</Text>
</Link>
) : null}
{post.likeCount != null && post.likeCount !== 0 ? (
<Link to={likesHref} label={l`Likes on this post`}>
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.likeCount)}
</Text>{' '}
<Plural
value={post.likeCount}
one="like"
other="likes"
/>
</Trans>
</Text>
</Link>
) : null}
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
<Text
testID="bookmarkCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.bookmarkCount)}
</Text>{' '}
<Plural
value={post.bookmarkCount}
one="save"
other="saves"
/>
</Trans>
</Text>
) : null}
</View>
) : null}
<View
style={[
a.flex_row,
a.flex_wrap,
a.align_center,
a.pt_sm,
a.pb_2xs,
{
rowGap: a.gap_sm.gap,
columnGap: a.gap_lg.gap,
marginLeft: -5,
},
a.border_t,
a.border_b,
a.mt_md,
a.py_md,
t.atoms.border_contrast_low,
]}>
{post.repostCount != null && post.repostCount !== 0 ? (
<Link to={repostsHref} label={l`Reposts of this post`}>
<Text
testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
</Trans>
</Text>
</Link>
) : null}
{post.quoteCount != null &&
post.quoteCount !== 0 &&
!post.viewer?.embeddingDisabled ? (
<Link to={quotesHref} label={l`Quotes of this post`}>
<Text
testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
</Trans>
</Text>
</Link>
) : null}
{post.likeCount != null && post.likeCount !== 0 ? (
<Link to={likesHref} label={l`Likes on this post`}>
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.likeCount)}
</Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
</Trans>
</Text>
</Link>
) : null}
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
<Text
testID="bookmarkCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.bookmarkCount)}
</Text>{' '}
<Plural
value={post.bookmarkCount}
one="save"
other="saves"
/>
</Trans>
</Text>
) : null}
<FeedFeedbackProvider value={feedFeedback}>
<PostControls
big
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
feedContext={postSource?.post?.feedContext}
reqId={postSource?.post?.reqId}
viaRepost={viaRepost}
/>
</FeedFeedbackProvider>
</View>
) : null}
<View
style={[
a.pt_sm,
a.pb_2xs,
{
marginLeft: -5,
},
]}>
<FeedFeedbackProvider value={feedFeedback}>
<PostControls
big
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
feedContext={postSource?.post?.feedContext}
reqId={postSource?.post?.reqId}
viaRepost={viaRepost}
/>
</FeedFeedbackProvider>
<DebugFieldDisplay subject={post} />
</View>
<DebugFieldDisplay subject={post} />
</View>
</View>
</GalleryBleed>
</>
)
})
@@ -32,6 +32,10 @@ import {atoms as a, useTheme} from '#/alf'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {
GalleryBleed,
maybeApplyGalleryOffsetStyles,
} from '#/components/images/Gallery'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
@@ -131,18 +135,20 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
!item.ui.showParentReplyLine && overrides?.topBorder !== true
return (
<View
style={[
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
{paddingHorizontal: OUTER_SPACE},
// If there's no next child, add a little padding to bottom
!item.ui.showChildReplyLine &&
!item.ui.precedesChildReadMore && {
paddingBottom: OUTER_SPACE / 2,
},
]}>
{children}
</View>
<GalleryBleed>
<View
style={[
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
{paddingHorizontal: OUTER_SPACE},
// If there's no next child, add a little padding to bottom
!item.ui.showChildReplyLine &&
!item.ui.precedesChildReadMore && {
paddingBottom: OUTER_SPACE / 2,
},
]}>
{children}
</View>
</GalleryBleed>
)
})
@@ -295,7 +301,14 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
moderation={moderation}
timestamp={post.indexedAt}
postHref={postHref}
style={[a.pb_xs]}
style={[
a.pb_xs,
maybeApplyGalleryOffsetStyles('meta', {
post,
modui: moderation.ui('contentList'),
additionalCauses: additionalPostAlerts,
}),
]}
/>
<LabelsOnMyPost post={post} style={[a.pb_xs]} />
<PostAlerts
@@ -323,7 +336,15 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
) : undefined}
<TranslatedPost hideTranslateLink post={post} />
{post.embed && (
<View style={[a.pb_xs]}>
<View
style={[
maybeApplyGalleryOffsetStyles('embed', {
post,
modui: moderation.ui('contentList'),
additionalCauses: additionalPostAlerts,
}),
a.pb_xs,
]}>
<Embed
embed={post.embed}
moderation={moderation}
@@ -32,6 +32,7 @@ import {atoms as a, useTheme} from '#/alf'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {GalleryBleed} from '#/components/images/Gallery'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
@@ -129,33 +130,35 @@ const ThreadItemTreePostOuterWrapper = memo(
const indents = Math.max(0, item.ui.indent - 1)
return (
<View
style={[
a.flex_row,
item.ui.indent === 1 &&
!item.ui.showParentReplyLine && [
a.border_t,
t.atoms.border_contrast_low,
],
]}>
{Array.from(Array(indents)).map((_, n: number) => {
const isSkipped = item.ui.skippedIndentIndices.has(n)
return (
<View
key={`${item.value.post.uri}-padding-${n}`}
style={[
<GalleryBleed>
<View
style={[
a.flex_row,
item.ui.indent === 1 &&
!item.ui.showParentReplyLine && [
a.border_t,
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})}
{children}
</View>
],
]}>
{Array.from(Array(indents)).map((_, n: number) => {
const isSkipped = item.ui.skippedIndentIndices.has(n)
return (
<View
key={`${item.value.post.uri}-padding-${n}`}
style={[
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})}
{children}
</View>
</GalleryBleed>
)
},
)
+6 -16
View File
@@ -1,10 +1,7 @@
import {memo, useCallback, useEffect, useMemo} from 'react'
import {Pressable, View} from 'react-native'
import Animated, {
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
type AnimatedRef,
useAnimatedRef,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
@@ -76,7 +73,7 @@ let ProfileHeaderShell = ({
const _openLightbox = useCallback(
(
uri: string,
thumbRect: MeasuredDimensions | null,
thumbRef: AnimatedRef<any>,
type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi',
) => {
openLightbox({
@@ -84,7 +81,8 @@ let ProfileHeaderShell = ({
{
uri,
thumbUri: uri,
thumbRect,
thumbRect: null,
thumbRef,
dimensions:
type === 'circle-avi' || type === 'rect-avi'
? {
@@ -130,11 +128,7 @@ let ProfileHeaderShell = ({
const avatar = profile.avatar
const type = profile.associated?.labeler ? 'rect-avi' : 'circle-avi'
if (avatar && !(modui.blur && modui.noOverride)) {
runOnUI(() => {
'worklet'
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect, type)
})()
_openLightbox(avatar, aviRef, type)
}
}
}, [
@@ -152,11 +146,7 @@ let ProfileHeaderShell = ({
const modui = moderation.ui('banner')
const banner = profile.banner
if (banner && !(modui.blur && modui.noOverride)) {
runOnUI(() => {
'worklet'
const rect = measure(bannerRef)
runOnJS(_openLightbox)(banner, rect, 'image')
})()
_openLightbox(banner, bannerRef, 'image')
}
}, [profile.banner, moderation, _openLightbox, bannerRef])
+12 -10
View File
@@ -96,7 +96,12 @@ export function SearchScreenShell({
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
// Query terms
const [searchText, setSearchText] = useState<string>(queryParam)
const [searchText, _setSearchText] = useState<string>(queryParam)
const searchTextRef = useRef(searchText)
const setSearchText = (text: string) => {
searchTextRef.current = text
_setSearchText(text)
}
const {data: autocompleteData, isFetching: isAutocompleteFetching} =
useActorAutocompleteQuery(searchText, true)
@@ -227,15 +232,12 @@ export function SearchScreenShell({
}
}, [setShowAutocomplete, setSearchText, navigation, route.params, route.name])
const onSubmit = useCallback(
(source: 'typed' | 'autocomplete') => () => {
ax.metric('search:query', {
source,
})
navigateToItem(searchText)
},
[ax, navigateToItem, searchText],
)
const onSubmit = (source: 'typed' | 'autocomplete') => () => {
ax.metric('search:query', {
source,
})
navigateToItem(searchTextRef.current)
}
const onAutocompleteResultPress = useCallback(() => {
if (IS_WEB) {

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