Merge branch 'main' into app-2066
This commit is contained in:
@@ -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 |
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
+2
-2
@@ -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",
|
||||
@@ -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(
|
||||
@@ -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'
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -14,6 +14,6 @@ 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',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
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'
|
||||
}
|
||||
|
||||
export function AvatarBubbles({
|
||||
animate = false,
|
||||
profiles: allProfiles,
|
||||
size = 'large',
|
||||
}: Props) {
|
||||
const {currentAccount} = useSession()
|
||||
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||
const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120
|
||||
const scale = 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>
|
||||
)
|
||||
}
|
||||
@@ -482,7 +482,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 +560,7 @@ export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
|
||||
: {right: screenWidth - measurement.x - measurement.width},
|
||||
animatedStyle,
|
||||
a.z_20,
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
|
||||
@@ -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'> & {
|
||||
|
||||
@@ -12,8 +12,10 @@ 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 +25,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 => ({
|
||||
@@ -95,6 +99,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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -15,7 +14,7 @@ export function ActionsWrapper({
|
||||
isFromSelf: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<MessageContextMenu message={message}>
|
||||
@@ -32,7 +31,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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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,7 +27,7 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
|
||||
})
|
||||
|
||||
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
let date: string
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
@@ -35,7 +35,7 @@ export let MessageContextMenu = ({
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
children: TriggerProps['children']
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -47,6 +47,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 +59,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 +80,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 +95,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 +116,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 +127,31 @@ 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}`}>
|
||||
{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 +160,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 +198,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}
|
||||
/>
|
||||
|
||||
+589
-207
@@ -1,13 +1,17 @@
|
||||
import {memo, useCallback, useMemo} from 'react'
|
||||
import {memo, useCallback, useMemo, useState} from 'react'
|
||||
import {
|
||||
type GestureResponderEvent,
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
useSharedValue,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from 'react-native-reanimated'
|
||||
@@ -16,217 +20,420 @@ 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 {HITSLOP_10} from '#/lib/constants'
|
||||
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 {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {isOnlyEmoji} from '#/alf/typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {InlineLinkText} 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 {MessageItemEmbed} from './MessageItemEmbed'
|
||||
import {localDateString} from './util'
|
||||
|
||||
const AVATAR_SIZE = 28
|
||||
const CLUSTERED_MESSAGE_GAP = 2
|
||||
const BORDER_RADIUS = 18
|
||||
const SQUARED_BORDER_RADIUS = 4
|
||||
const DISPLAY_NAME_INSET = 22
|
||||
|
||||
// 42px avatar + 2 * 8px my_sm margins
|
||||
const ROW_HEIGHT = 58
|
||||
|
||||
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
|
||||
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
|
||||
|
||||
type Reaction = {
|
||||
key: string
|
||||
value: string
|
||||
senders: ChatBskyConvoDefs.ReactionViewSender[]
|
||||
count: number
|
||||
}
|
||||
|
||||
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 reactionsControl = useDialogControl()
|
||||
|
||||
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 showDateDivider = hasLargeGapFromPrev
|
||||
|
||||
const isLastMessageOfDay = useMemo(() => {
|
||||
if (!nextMessage || !nextIsMessage) return true
|
||||
const isInCluster = !(isFirstInCluster && isLastInCluster)
|
||||
const isInMiddleOfCluster =
|
||||
isInCluster && !isFirstInCluster && !isLastInCluster
|
||||
|
||||
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 || isFirstInCluster)
|
||||
const squaredTopCorner =
|
||||
isInCluster && (isInMiddleOfCluster || isLastInCluster)
|
||||
|
||||
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 avatar = profile ? (
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
size={AVATAR_SIZE}
|
||||
moderationOpts={moderationOpts!}
|
||||
disabledPreview
|
||||
/>
|
||||
) : (
|
||||
<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 && (
|
||||
<View
|
||||
style={[isFromSelf ? a.align_end : a.align_start, a.px_sm, a.pb_2xs]}>
|
||||
{hasReactions ? (
|
||||
<>
|
||||
<View
|
||||
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.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_lg,
|
||||
t.atoms.shadow_sm,
|
||||
{
|
||||
// vibe coded number
|
||||
transform: [{translateY: -11}],
|
||||
},
|
||||
isFromSelf ? a.align_end : a.align_start,
|
||||
a.px_sm,
|
||||
a.pb_2xs,
|
||||
]}>
|
||||
{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}`)
|
||||
}
|
||||
<Pressable
|
||||
accessible={true}
|
||||
accessibilityLabel={reactionsLabel}
|
||||
accessibilityHint={
|
||||
isGroupChat ? l`Tap to view reactions` : undefined
|
||||
}
|
||||
return (
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_2xs,
|
||||
a.py_xs,
|
||||
a.px_xs,
|
||||
isFromSelf ? a.justify_end : a.justify_start,
|
||||
a.flex_wrap,
|
||||
a.rounded_lg,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.shadow_sm,
|
||||
{
|
||||
transform: [{translateY: -8}],
|
||||
},
|
||||
]}
|
||||
onPress={() =>
|
||||
isGroupChat ? reactionsControl.open() : undefined
|
||||
}>
|
||||
{groupedReactions.map(group => (
|
||||
<Animated.View
|
||||
entering={native(ZoomIn.springify(200).delay(400))}
|
||||
exiting={reactions.length > 1 && native(ZoomOut.delay(200))}
|
||||
exiting={
|
||||
groupedReactions.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`,
|
||||
)}>
|
||||
key={group.value}
|
||||
style={[a.p_2xs]}>
|
||||
<Text emoji style={[a.text_sm]}>
|
||||
{reaction.value}
|
||||
{group.value}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
{groupedReactions.length !== reactions.length &&
|
||||
reactions.length > 1 ? (
|
||||
<View style={[a.p_2xs, a.justify_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
t.atoms.text_contrast_medium,
|
||||
{includeFontPadding: false},
|
||||
]}>
|
||||
{reactions.length}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<ReactionsDialog
|
||||
control={reactionsControl}
|
||||
members={convo.members}
|
||||
reactions={message.reactions}
|
||||
groupedReactions={groupedReactions}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</LayoutAnimationConfig>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isNewDay && <DateDivider date={message.sentAt} />}
|
||||
{showDateDivider && (
|
||||
<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,
|
||||
isFromSelf ? a.mr_sm : a.ml_sm,
|
||||
isFirstInCluster && !showDateDivider && a.mt_sm,
|
||||
]}>
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed embed={message.embed} />
|
||||
)}
|
||||
{rt.text.length > 0 && (
|
||||
<View
|
||||
style={
|
||||
!isOnlyEmoji(message.text) && [
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
isFromSelf
|
||||
? {borderBottomRightRadius: 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}
|
||||
/>
|
||||
<View style={[a.relative]}>
|
||||
{isGroupChat && !isFromSelf && isLastInCluster ? (
|
||||
<View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}>
|
||||
{avatar}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{IS_NATIVE && appliedReactions}
|
||||
</ActionsWrapper>
|
||||
|
||||
{!IS_NATIVE && appliedReactions}
|
||||
|
||||
{isLastInGroup && (
|
||||
) : null}
|
||||
<View
|
||||
style={[
|
||||
a.flex_grow,
|
||||
!isFromSelf &&
|
||||
isGroupChat && {
|
||||
paddingLeft: AVATAR_SIZE,
|
||||
},
|
||||
]}>
|
||||
{isGroupChat &&
|
||||
!isFromSelf &&
|
||||
isFirstInCluster &&
|
||||
!isOnlyEmoji(message.text) ? (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pt_xs,
|
||||
a.pb_2xs,
|
||||
{
|
||||
paddingLeft: DISPLAY_NAME_INSET,
|
||||
},
|
||||
]}>
|
||||
{displayName}
|
||||
</Text>
|
||||
) : null}
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
||||
{rt.text.length > 0 && (
|
||||
<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: isFirstInCluster
|
||||
? 0
|
||||
: CLUSTERED_MESSAGE_GAP,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
isFromSelf
|
||||
? {
|
||||
borderBottomRightRadius:
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopRightRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
}
|
||||
: {
|
||||
borderBottomLeftRadius:
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopLeftRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
},
|
||||
]),
|
||||
]}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed
|
||||
embed={message.embed}
|
||||
isFromSelf={isFromSelf}
|
||||
squaredBottomCorner={squaredBottomCorner}
|
||||
squaredTopCorner={squaredTopCorner || hasEmbedAndText}
|
||||
/>
|
||||
)}
|
||||
{appliedReactions}
|
||||
</ActionsWrapper>
|
||||
</View>
|
||||
</View>
|
||||
{isLastInCluster && (
|
||||
<MessageItemMetadata
|
||||
item={item}
|
||||
style={isFromSelf ? a.text_right : a.text_left}
|
||||
style={[isFromSelf ? a.text_right : a.text_left]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -244,8 +451,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 +464,251 @@ 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 && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<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 && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<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}
|
||||
|
||||
function ReactionsDialog({
|
||||
control,
|
||||
members,
|
||||
reactions,
|
||||
groupedReactions,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
members: bsky.profile.AnyProfileView[]
|
||||
reactions?: ChatBskyConvoDefs.ReactionView[]
|
||||
groupedReactions?: Reaction[]
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const [selected, setSelected] = useState('all')
|
||||
|
||||
const handleFilter = (value: string) => {
|
||||
setSelected(value)
|
||||
}
|
||||
|
||||
const filteredMembers =
|
||||
selected === 'all'
|
||||
? members
|
||||
: members.filter(m =>
|
||||
reactions?.some(r => r.sender.did === m.did && r.value === selected),
|
||||
)
|
||||
|
||||
const minHeight = members.length * ROW_HEIGHT
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={() => setSelected('all')}
|
||||
nativeOptions={{preventExpansion: true, minHeight}}>
|
||||
<Dialog.Handle />
|
||||
<View style={[a.px_2xl, a.pt_3xl, t.atoms.bg]}>
|
||||
<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.ScrollableInner
|
||||
label={l`Reactions`}
|
||||
contentContainerStyle={[a.pt_0]}
|
||||
style={[web({maxWidth: 400})]}>
|
||||
{filteredMembers.map(profile => {
|
||||
const displayName = sanitizeDisplayName(
|
||||
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
|
||||
)
|
||||
const handle = sanitizeHandle(profile?.handle ?? '', '@')
|
||||
const reaction = reactions?.find(
|
||||
({sender}) => sender.did === profile.did,
|
||||
)
|
||||
const rt = reaction
|
||||
? new RichTextAPI({text: reaction.value})
|
||||
: undefined
|
||||
|
||||
return rt ? (
|
||||
<View
|
||||
key={profile.did}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.my_sm,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={42}
|
||||
type="user"
|
||||
hideLiveBadge
|
||||
/>
|
||||
<View>
|
||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
{handle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={2}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null
|
||||
})}
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function ReactionTabs({
|
||||
groupedReactions,
|
||||
selected,
|
||||
totalReactions,
|
||||
onFilter,
|
||||
}: {
|
||||
groupedReactions?: Reaction[]
|
||||
selected: string
|
||||
totalReactions: number
|
||||
onFilter: (value: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const contentSize = useSharedValue(0)
|
||||
const scrollX = useSharedValue(0)
|
||||
|
||||
const handlePress = (value: string) => {
|
||||
onFilter(value)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'all',
|
||||
value: l`All`,
|
||||
senders: [],
|
||||
count: totalReactions,
|
||||
} as Reaction,
|
||||
...(groupedReactions ?? []),
|
||||
]
|
||||
|
||||
return (
|
||||
<View accessibilityRole="list" style={[t.atoms.bg]}>
|
||||
<DraggableScrollView
|
||||
horizontal={true}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={e => {
|
||||
scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
|
||||
}}>
|
||||
<Animated.View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_grow,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
]}
|
||||
onLayout={e => {
|
||||
contentSize.set(e.nativeEvent.layout.width)
|
||||
}}>
|
||||
{tabs?.map((reaction, index) => (
|
||||
<ReactionTab
|
||||
key={reaction.value}
|
||||
index={index}
|
||||
reaction={reaction}
|
||||
selected={selected}
|
||||
total={tabs.length}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
</DraggableScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ReactionTab({
|
||||
index,
|
||||
reaction,
|
||||
selected,
|
||||
total,
|
||||
onPress,
|
||||
}: {
|
||||
index: number
|
||||
reaction: Reaction
|
||||
selected: string
|
||||
total: number
|
||||
onPress: (value: string) => 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,
|
||||
t.atoms.border_contrast_low,
|
||||
selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
|
||||
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
|
||||
]}
|
||||
onPress={() => onPress(reaction.key)}>
|
||||
<Text emoji style={[a.text_sm]}>
|
||||
{l`${reaction.value} ${reaction.count}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<BellStroke
|
||||
size="xs"
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
{' '}
|
||||
·{' '}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,12 @@ 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 onPress = useCallback(() => {
|
||||
@@ -74,7 +93,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=""
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
: {}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}, [])
|
||||
|
||||
@@ -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.`,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
+536
-259
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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.flex_1, 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
@@ -1,36 +1,40 @@
|
||||
import {memo, useCallback, useMemo, useState} from 'react'
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
ChatBskyActorDefs,
|
||||
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'
|
||||
@@ -45,11 +49,17 @@ import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export const ChatListItemPortal = createPortalGroup()
|
||||
|
||||
export let ChatListItem = ({
|
||||
/**
|
||||
* 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,
|
||||
showMenu = true,
|
||||
children,
|
||||
@@ -57,32 +67,77 @@ export let ChatListItem = ({
|
||||
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>
|
||||
)
|
||||
if (
|
||||
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
|
||||
convo.kind,
|
||||
ChatBskyConvoDefs.isGroupConvo,
|
||||
)
|
||||
) {
|
||||
const owner = convo.members.find(r => {
|
||||
if (
|
||||
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
|
||||
r.kind,
|
||||
ChatBskyActorDefs.isGroupConvoMember,
|
||||
)
|
||||
) {
|
||||
return r.kind.role === 'owner'
|
||||
} else {
|
||||
throw new Error(
|
||||
'Expected a GroupConvoMember, got an unknown kind of member',
|
||||
)
|
||||
}
|
||||
})
|
||||
if (!owner) {
|
||||
// TODO: Determine if this is the right thing to do here. Throwing here so that
|
||||
// if it turns out to be wrong it'll be very visible
|
||||
throw new Error('Could not find the group owner in the group members')
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupChatItem
|
||||
convo={convo}
|
||||
groupOwner={owner}
|
||||
groupInfo={convo.kind}
|
||||
moderationOpts={moderationOpts}
|
||||
showMenu={showMenu}
|
||||
/>
|
||||
)
|
||||
} else if (
|
||||
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
|
||||
convo.kind,
|
||||
ChatBskyConvoDefs.isDirectConvo,
|
||||
)
|
||||
) {
|
||||
const otherMember = convo.members.find(
|
||||
member => member.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
if (!otherMember) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<DirectChatItem
|
||||
convo={convo}
|
||||
profile={otherMember}
|
||||
moderationOpts={moderationOpts}
|
||||
showMenu={showMenu}>
|
||||
{children}
|
||||
</DirectChatItem>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ChatListItem = memo(ChatListItem)
|
||||
|
||||
function ChatListItemReady({
|
||||
function DirectChatItem({
|
||||
convo,
|
||||
profile: profileUnshadowed,
|
||||
moderationOpts,
|
||||
@@ -95,25 +150,140 @@ function ChatListItemReady({
|
||||
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 {t: l} = useLingui()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {mutate: markAsRead} = useMarkAsReadMutation()
|
||||
|
||||
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}
|
||||
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,
|
||||
groupOwner: groupOwnerUnshadowed,
|
||||
groupInfo,
|
||||
moderationOpts,
|
||||
showMenu,
|
||||
children,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
groupOwner: bsky.profile.AnyProfileView
|
||||
groupInfo: ChatBskyConvoDefs.GroupConvo
|
||||
moderationOpts: ModerationOpts
|
||||
showMenu?: boolean
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const groupOwner = useProfileShadow(groupOwnerUnshadowed)
|
||||
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(groupOwner, moderationOpts),
|
||||
[groupOwner, moderationOpts],
|
||||
)
|
||||
|
||||
const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat`
|
||||
|
||||
return (
|
||||
<BaseChatItem
|
||||
convo={convo}
|
||||
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 +291,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 +312,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 +332,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 +352,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 +365,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 +406,7 @@ function ChatListItemReady({
|
||||
latestReportableMessage,
|
||||
}
|
||||
}, [
|
||||
_,
|
||||
l,
|
||||
convo.lastMessage,
|
||||
convo.lastReaction,
|
||||
currentAccount?.did,
|
||||
@@ -279,9 +431,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 +444,7 @@ function ChatListItemReady({
|
||||
ax.metric('chat:open', {logContext: 'ChatsList'})
|
||||
}
|
||||
},
|
||||
[ax, isDeletedAccount, menuControl, queryClient, profile, convo],
|
||||
[ax, isDeletedAccount, menuControl, queryClient, convo],
|
||||
)
|
||||
|
||||
const onLongPress = useCallback(() => {
|
||||
@@ -345,33 +499,23 @@ 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
|
||||
? [
|
||||
{
|
||||
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
|
||||
@@ -407,14 +551,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 +580,7 @@ function ChatListItemReady({
|
||||
</TimeElapsed>
|
||||
</View>
|
||||
)}
|
||||
{(convo.muted || moderation.blocked) && (
|
||||
{(convo.muted || isBlockedAccount) && (
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
@@ -450,7 +598,7 @@ function ChatListItemReady({
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!isDeletedAccount && (
|
||||
{subtitle && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
@@ -458,7 +606,7 @@ function ChatListItemReady({
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pb_xs,
|
||||
]}>
|
||||
@{profile.handle}
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -474,11 +622,7 @@ function ChatListItemReady({
|
||||
{lastMessage}
|
||||
</Text>
|
||||
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
size="lg"
|
||||
style={[a.pt_xs]}
|
||||
/>
|
||||
{postAlerts}
|
||||
|
||||
{children}
|
||||
</View>
|
||||
@@ -509,7 +653,7 @@ function ChatListItemReady({
|
||||
{showMenu && (
|
||||
<ConvoMenu
|
||||
convo={convo}
|
||||
profile={profile}
|
||||
profile={primaryProfile}
|
||||
control={menuControl}
|
||||
currentScreen="list"
|
||||
showMarkAsRead={convo.unreadCount > 0}
|
||||
@@ -529,6 +673,7 @@ function ChatListItemReady({
|
||||
latestReportableMessage={latestReportableMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LeaveConvoPrompt
|
||||
control={leaveConvoControl}
|
||||
convoId={convo.id}
|
||||
|
||||
@@ -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'
|
||||
@@ -47,13 +46,13 @@ 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 +81,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 +110,7 @@ export function MessageInput({
|
||||
playHaptic,
|
||||
setEmbed,
|
||||
inputRef,
|
||||
_,
|
||||
l,
|
||||
])
|
||||
|
||||
useFocusedInputHandler(
|
||||
@@ -169,9 +168,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 +224,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 {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'
|
||||
@@ -40,7 +39,7 @@ export function MessageInput({
|
||||
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 +56,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 +65,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>) => {
|
||||
@@ -177,7 +176,7 @@ export function MessageInput({
|
||||
width: 30,
|
||||
},
|
||||
]}
|
||||
label={_(msg`Open emoji picker`)}>
|
||||
label={l`Open emoji picker`}>
|
||||
{state => (
|
||||
<View
|
||||
style={[
|
||||
@@ -210,7 +209,7 @@ export function MessageInput({
|
||||
},
|
||||
])}
|
||||
maxRows={12}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
placeholder={l`Message`}
|
||||
defaultValue=""
|
||||
value={message}
|
||||
dirName="ltr"
|
||||
@@ -231,7 +230,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} ·{' '}
|
||||
{description}
|
||||
{item.retry && (
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label={help}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
item.retry?.()
|
||||
return false
|
||||
}}>
|
||||
{cta}
|
||||
</InlineLinkText>
|
||||
<>
|
||||
·{' '}
|
||||
<InlineLinkText
|
||||
label={help}
|
||||
{...createStaticClick(() => {
|
||||
item.retry?.()
|
||||
})}>
|
||||
{cta}
|
||||
</InlineLinkText>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type KeyboardChatScrollViewProps,
|
||||
KeyboardGestureArea,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
import {
|
||||
runOnJS,
|
||||
type ScrollEvent,
|
||||
type SharedValue,
|
||||
@@ -77,18 +77,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 +91,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()
|
||||
@@ -155,6 +145,16 @@ 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)
|
||||
if (prevHasScrolled.current && !hasScrolled) {
|
||||
hasInitiallyScrolled.current = false
|
||||
}
|
||||
prevHasScrolled.current = hasScrolled
|
||||
|
||||
// -- Keep track of background state and positioning for new pill
|
||||
const layoutHeight = useSharedValue(0)
|
||||
const didBackground = useRef(false)
|
||||
@@ -187,8 +187,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 +229,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +375,40 @@ export function MessagesList({
|
||||
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.getGroupInfo?.() != null}
|
||||
/>
|
||||
)
|
||||
} 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) => (
|
||||
<ChatScrollComponent {...props} inputHeight={inputHeightUI} />
|
||||
@@ -382,7 +422,8 @@ export function MessagesList({
|
||||
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}>
|
||||
@@ -411,15 +452,27 @@ export function MessagesList({
|
||||
}
|
||||
// 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 +497,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} />
|
||||
@@ -518,12 +573,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(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,8 +21,8 @@ import * as Layout from '#/components/Layout'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {getDeviceId} from '#/analytics/identifiers'
|
||||
import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env'
|
||||
import * as env from '#/env'
|
||||
import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env'
|
||||
import {useDemoMode} from '#/storage/hooks/demo-mode'
|
||||
import {useDevMode} from '#/storage/hooks/dev-mode'
|
||||
import {OTAInfo} from './components/OTAInfo'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
type AtpAgent,
|
||||
type ChatBskyActorDefs,
|
||||
ChatBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
type ChatBskyConvoGetLog,
|
||||
type ChatBskyConvoSendMessage,
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import {type MessagesEventBus} from '#/state/messages/events/agent'
|
||||
import {type MessagesEventBusError} from '#/state/messages/events/types'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
const logger = Logger.create(Logger.Context.ConversationAgent)
|
||||
|
||||
@@ -112,6 +113,9 @@ export class Convo {
|
||||
this.markConvoAccepted = this.markConvoAccepted.bind(this)
|
||||
this.addReaction = this.addReaction.bind(this)
|
||||
this.removeReaction = this.removeReaction.bind(this)
|
||||
this.isGroup = this.isGroup.bind(this)
|
||||
this.getGroupInfo = this.getGroupInfo.bind(this)
|
||||
this.getPrimaryMember = this.getPrimaryMember.bind(this)
|
||||
}
|
||||
|
||||
private commit() {
|
||||
@@ -155,6 +159,9 @@ export class Convo {
|
||||
markConvoAccepted: undefined,
|
||||
addReaction: undefined,
|
||||
removeReaction: undefined,
|
||||
isGroup: this.isGroup,
|
||||
getGroupInfo: this.getGroupInfo,
|
||||
getPrimaryMember: this.getPrimaryMember,
|
||||
}
|
||||
}
|
||||
case ConvoStatus.Disabled:
|
||||
@@ -175,6 +182,9 @@ export class Convo {
|
||||
markConvoAccepted: this.markConvoAccepted,
|
||||
addReaction: this.addReaction,
|
||||
removeReaction: this.removeReaction,
|
||||
isGroup: this.isGroup,
|
||||
getGroupInfo: this.getGroupInfo,
|
||||
getPrimaryMember: this.getPrimaryMember,
|
||||
}
|
||||
}
|
||||
case ConvoStatus.Error: {
|
||||
@@ -192,6 +202,9 @@ export class Convo {
|
||||
markConvoAccepted: undefined,
|
||||
addReaction: undefined,
|
||||
removeReaction: undefined,
|
||||
isGroup: undefined,
|
||||
getGroupInfo: undefined,
|
||||
getPrimaryMember: undefined,
|
||||
}
|
||||
}
|
||||
default: {
|
||||
@@ -209,6 +222,9 @@ export class Convo {
|
||||
markConvoAccepted: undefined,
|
||||
addReaction: undefined,
|
||||
removeReaction: undefined,
|
||||
isGroup: this.isGroup,
|
||||
getGroupInfo: this.getGroupInfo,
|
||||
getPrimaryMember: this.getPrimaryMember,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,7 +238,7 @@ export class Convo {
|
||||
switch (action.event) {
|
||||
case ConvoDispatchEvent.Init: {
|
||||
this.status = ConvoStatus.Initializing
|
||||
this.setup()
|
||||
void this.setup()
|
||||
this.setupFirehose()
|
||||
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
||||
break
|
||||
@@ -234,12 +250,12 @@ export class Convo {
|
||||
switch (action.event) {
|
||||
case ConvoDispatchEvent.Ready: {
|
||||
this.status = ConvoStatus.Ready
|
||||
this.fetchMessageHistory()
|
||||
void this.fetchMessageHistory()
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Background: {
|
||||
this.status = ConvoStatus.Backgrounded
|
||||
this.fetchMessageHistory()
|
||||
void this.fetchMessageHistory()
|
||||
this.requestPollInterval(BACKGROUND_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
@@ -258,7 +274,7 @@ export class Convo {
|
||||
}
|
||||
case ConvoDispatchEvent.Disable: {
|
||||
this.status = ConvoStatus.Disabled
|
||||
this.fetchMessageHistory() // finish init
|
||||
void this.fetchMessageHistory() // finish init
|
||||
this.cleanupFirehoseConnection?.()
|
||||
this.withdrawRequestedPollInterval()
|
||||
break
|
||||
@@ -269,7 +285,7 @@ export class Convo {
|
||||
case ConvoStatus.Ready: {
|
||||
switch (action.event) {
|
||||
case ConvoDispatchEvent.Resume: {
|
||||
this.refreshConvo()
|
||||
void this.refreshConvo()
|
||||
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
@@ -308,11 +324,11 @@ export class Convo {
|
||||
} else {
|
||||
if (this.convo) {
|
||||
this.status = ConvoStatus.Ready
|
||||
this.refreshConvo()
|
||||
void this.refreshConvo()
|
||||
this.maybeRecoverFromNetworkError()
|
||||
} else {
|
||||
this.status = ConvoStatus.Initializing
|
||||
this.setup()
|
||||
void this.setup()
|
||||
}
|
||||
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
||||
}
|
||||
@@ -435,7 +451,7 @@ export class Convo {
|
||||
this.firehoseError = undefined
|
||||
this.commit()
|
||||
} else {
|
||||
this.batchRetryPendingMessages()
|
||||
void this.batchRetryPendingMessages()
|
||||
}
|
||||
|
||||
if (this.fetchMessageHistoryError) {
|
||||
@@ -487,7 +503,8 @@ export class Convo {
|
||||
} else {
|
||||
this.dispatch({event: ConvoDispatchEvent.Ready})
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
|
||||
logger.error('setup failed', {
|
||||
safeMessage: e.message,
|
||||
@@ -557,11 +574,7 @@ export class Convo {
|
||||
async fetchConvo() {
|
||||
if (this.pendingFetchConvo) return this.pendingFetchConvo
|
||||
|
||||
this.pendingFetchConvo = new Promise<{
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
|
||||
recipients: ChatBskyActorDefs.ProfileViewBasic[]
|
||||
}>(async (resolve, reject) => {
|
||||
this.pendingFetchConvo = (async () => {
|
||||
try {
|
||||
const response = await networkRetry(2, () => {
|
||||
return this.agent.api.chat.bsky.convo.getConvo(
|
||||
@@ -574,17 +587,15 @@ export class Convo {
|
||||
|
||||
const convo = response.data.convo
|
||||
|
||||
resolve({
|
||||
return {
|
||||
convo,
|
||||
sender: convo.members.find(m => m.did === this.senderUserDid),
|
||||
recipients: convo.members.filter(m => m.did !== this.senderUserDid),
|
||||
})
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
} finally {
|
||||
this.pendingFetchConvo = undefined
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
return this.pendingFetchConvo
|
||||
}
|
||||
@@ -596,7 +607,8 @@ export class Convo {
|
||||
this.convo = convo || this.convo
|
||||
this.sender = sender || this.sender
|
||||
this.recipients = recipients || this.recipients
|
||||
} catch (e: any) {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
|
||||
logger.error(`failed to refresh convo`, {
|
||||
safeMessage: e.message,
|
||||
@@ -664,7 +676,8 @@ export class Convo {
|
||||
this.pastMessages.set(message.id, message)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
|
||||
logger.error('failed to fetch message history', {
|
||||
safeMessage: e.message,
|
||||
@@ -673,7 +686,7 @@ export class Convo {
|
||||
|
||||
this.fetchMessageHistoryError = {
|
||||
retry: () => {
|
||||
this.fetchMessageHistory()
|
||||
void this.fetchMessageHistory()
|
||||
},
|
||||
}
|
||||
} finally {
|
||||
@@ -716,7 +729,7 @@ export class Convo {
|
||||
|
||||
onFirehoseConnect() {
|
||||
this.firehoseError = undefined
|
||||
this.batchRetryPendingMessages()
|
||||
void this.batchRetryPendingMessages()
|
||||
this.commit()
|
||||
}
|
||||
|
||||
@@ -761,8 +774,8 @@ export class Convo {
|
||||
/**
|
||||
* If this message is already in new messages, it was added by our
|
||||
* sending logic, and is based on client-ordering. When we receive
|
||||
* the "commited" event from the log, we should replace this
|
||||
* reference and re-insert in order to respect the order we receied
|
||||
* the "committed" event from the log, we should replace this
|
||||
* reference and re-insert in order to respect the order we received
|
||||
* from the log.
|
||||
*/
|
||||
if (this.newMessages.has(ev.message.id)) {
|
||||
@@ -836,7 +849,7 @@ export class Convo {
|
||||
this.commit()
|
||||
|
||||
if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) {
|
||||
this.processPendingMessages()
|
||||
void this.processPendingMessages()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,7 +925,7 @@ export class Convo {
|
||||
}
|
||||
}
|
||||
|
||||
private handleSendMessageFailure(e: any) {
|
||||
private handleSendMessageFailure(e: Error | XRPCError) {
|
||||
if (e instanceof XRPCError) {
|
||||
if (NETWORK_FAILURE_STATUSES.includes(e.status)) {
|
||||
this.pendingMessageFailure = 'recoverable'
|
||||
@@ -1026,7 +1039,8 @@ export class Convo {
|
||||
{encoding: 'application/json', headers: DM_SERVICE_HEADERS},
|
||||
)
|
||||
})
|
||||
} catch (e: any) {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
|
||||
logger.error(`failed to delete message`, {
|
||||
safeMessage: e.message,
|
||||
@@ -1334,4 +1348,46 @@ export class Convo {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Group utilities
|
||||
|
||||
isGroup(): boolean | undefined {
|
||||
if (!this.convo) return undefined
|
||||
const info = this.getGroupInfo()
|
||||
return !!info
|
||||
}
|
||||
|
||||
getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined {
|
||||
if (
|
||||
this.convo &&
|
||||
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
|
||||
this.convo.kind,
|
||||
ChatBskyConvoDefs.isGroupConvo,
|
||||
)
|
||||
) {
|
||||
return this.convo.kind
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined {
|
||||
if (this.isGroup()) {
|
||||
return this.recipients?.find(r => {
|
||||
if (
|
||||
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
|
||||
r.kind,
|
||||
ChatBskyActorDefs.isGroupConvoMember,
|
||||
)
|
||||
) {
|
||||
return r.kind.role === 'owner'
|
||||
} else {
|
||||
throw new Error(
|
||||
'Expected a GroupConvoMember, got an unknown kind of member',
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return this.recipients?.find(r => r.did !== this.senderUserDid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ type FetchMessageHistory = () => Promise<void>
|
||||
type MarkConvoAccepted = () => void
|
||||
type AddReaction = (messageId: string, reaction: string) => Promise<void>
|
||||
type RemoveReaction = (messageId: string, reaction: string) => Promise<void>
|
||||
type IsGroup = () => boolean | undefined
|
||||
type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvo | undefined
|
||||
type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined
|
||||
|
||||
export type ConvoStateUninitialized = {
|
||||
status: ConvoStatus.Uninitialized
|
||||
@@ -159,6 +162,9 @@ export type ConvoStateUninitialized = {
|
||||
markConvoAccepted: undefined
|
||||
addReaction: undefined
|
||||
removeReaction: undefined
|
||||
isGroup: IsGroup
|
||||
getGroupInfo: GetGroupInfo
|
||||
getPrimaryMember: GetPrimaryMember
|
||||
}
|
||||
export type ConvoStateInitializing = {
|
||||
status: ConvoStatus.Initializing
|
||||
@@ -174,6 +180,9 @@ export type ConvoStateInitializing = {
|
||||
markConvoAccepted: undefined
|
||||
addReaction: undefined
|
||||
removeReaction: undefined
|
||||
isGroup: IsGroup
|
||||
getGroupInfo: GetGroupInfo
|
||||
getPrimaryMember: GetPrimaryMember
|
||||
}
|
||||
export type ConvoStateReady = {
|
||||
status: ConvoStatus.Ready
|
||||
@@ -189,6 +198,9 @@ export type ConvoStateReady = {
|
||||
markConvoAccepted: MarkConvoAccepted
|
||||
addReaction: AddReaction
|
||||
removeReaction: RemoveReaction
|
||||
isGroup: IsGroup
|
||||
getGroupInfo: GetGroupInfo
|
||||
getPrimaryMember: GetPrimaryMember
|
||||
}
|
||||
export type ConvoStateBackgrounded = {
|
||||
status: ConvoStatus.Backgrounded
|
||||
@@ -204,6 +216,9 @@ export type ConvoStateBackgrounded = {
|
||||
markConvoAccepted: MarkConvoAccepted
|
||||
addReaction: AddReaction
|
||||
removeReaction: RemoveReaction
|
||||
isGroup: IsGroup
|
||||
getGroupInfo: GetGroupInfo
|
||||
getPrimaryMember: GetPrimaryMember
|
||||
}
|
||||
export type ConvoStateSuspended = {
|
||||
status: ConvoStatus.Suspended
|
||||
@@ -219,6 +234,9 @@ export type ConvoStateSuspended = {
|
||||
markConvoAccepted: MarkConvoAccepted
|
||||
addReaction: AddReaction
|
||||
removeReaction: RemoveReaction
|
||||
isGroup: IsGroup
|
||||
getGroupInfo: GetGroupInfo
|
||||
getPrimaryMember: GetPrimaryMember
|
||||
}
|
||||
export type ConvoStateError = {
|
||||
status: ConvoStatus.Error
|
||||
@@ -234,6 +252,9 @@ export type ConvoStateError = {
|
||||
markConvoAccepted: undefined
|
||||
addReaction: undefined
|
||||
removeReaction: undefined
|
||||
isGroup: undefined
|
||||
getGroupInfo: undefined
|
||||
getPrimaryMember: undefined
|
||||
}
|
||||
export type ConvoStateDisabled = {
|
||||
status: ConvoStatus.Disabled
|
||||
@@ -249,6 +270,9 @@ export type ConvoStateDisabled = {
|
||||
markConvoAccepted: MarkConvoAccepted
|
||||
addReaction: AddReaction
|
||||
removeReaction: RemoveReaction
|
||||
isGroup: IsGroup
|
||||
getGroupInfo: GetGroupInfo
|
||||
getPrimaryMember: GetPrimaryMember
|
||||
}
|
||||
export type ConvoState =
|
||||
| ConvoStateUninitialized
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {type ChatBskyGroupCreateGroup} from '@atproto/api'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {precacheConvoQuery} from './conversation'
|
||||
|
||||
export function useCreateGroupChat({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess?: (data: ChatBskyGroupCreateGroup.OutputSchema) => void
|
||||
onError?: (error: Error) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({name, members}: {name: string; members: string[]}) => {
|
||||
const {data} = await agent.chat.bsky.group.createGroup(
|
||||
{name, members},
|
||||
{headers: DM_SERVICE_HEADERS},
|
||||
)
|
||||
|
||||
return data
|
||||
},
|
||||
onSuccess: data => {
|
||||
precacheConvoQuery(queryClient, data.convo)
|
||||
onSuccess?.(data)
|
||||
},
|
||||
onError: error => {
|
||||
logger.error(error)
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -31,13 +31,13 @@ export function useMuteConvo(
|
||||
mutationFn: async ({mute}: {mute: boolean}) => {
|
||||
if (!convoId) throw new Error('No convoId provided')
|
||||
if (mute) {
|
||||
const {data} = await agent.api.chat.bsky.convo.muteConvo(
|
||||
const {data} = await agent.chat.bsky.convo.muteConvo(
|
||||
{convoId},
|
||||
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||
)
|
||||
return data
|
||||
} else {
|
||||
const {data} = await agent.api.chat.bsky.convo.unmuteConvo(
|
||||
const {data} = await agent.chat.bsky.convo.unmuteConvo(
|
||||
{convoId},
|
||||
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||
)
|
||||
|
||||
@@ -19,9 +19,9 @@ export type QueryProps = {
|
||||
|
||||
export const getSuggestedUsersForDiscoverQueryKeyRoot =
|
||||
'unspecced-suggested-users-for-explore'
|
||||
export const createGetSuggestedUsersForDiscoverQueryKey = (
|
||||
props: QueryProps,
|
||||
) => [getSuggestedUsersForDiscoverQueryKeyRoot, props.limit]
|
||||
export const createGetSuggestedUsersForDiscoverQueryKey = (props: {
|
||||
limit?: number
|
||||
}) => [getSuggestedUsersForDiscoverQueryKeyRoot, props.limit]
|
||||
|
||||
export function useGetSuggestedUsersForDiscoverQuery(props: QueryProps = {}) {
|
||||
const agent = useAgent()
|
||||
@@ -29,7 +29,7 @@ export function useGetSuggestedUsersForDiscoverQuery(props: QueryProps = {}) {
|
||||
|
||||
return useQuery({
|
||||
staleTime: STALE.MINUTES.THREE,
|
||||
queryKey: createGetSuggestedUsersForDiscoverQueryKey(props),
|
||||
queryKey: createGetSuggestedUsersForDiscoverQueryKey({limit: props.limit}),
|
||||
queryFn: async () => {
|
||||
const contentLangs = getContentLanguages().join(',')
|
||||
const userInterests = aggregateUserInterests(preferences)
|
||||
|
||||
@@ -16,21 +16,27 @@ import {useAgent} from '#/state/session'
|
||||
export type QueryProps = {
|
||||
category?: string | null
|
||||
limit?: number
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const getSuggestedUsersForSeeMoreQueryKeyRoot =
|
||||
'unspecced-suggested-users-for-explore'
|
||||
export const createGetSuggestedUsersForSeeMoreQueryKey = (
|
||||
props: QueryProps,
|
||||
) => [getSuggestedUsersForSeeMoreQueryKeyRoot, props.category, props.limit]
|
||||
export const createGetSuggestedUsersForSeeMoreQueryKey = (props: {
|
||||
category?: string | null
|
||||
limit?: number
|
||||
}) => [getSuggestedUsersForSeeMoreQueryKeyRoot, props.category, props.limit]
|
||||
|
||||
export function useGetSuggestedUsersForSeeMoreQuery(props: QueryProps = {}) {
|
||||
const agent = useAgent()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
|
||||
return useQuery({
|
||||
enabled: props.enabled ?? true,
|
||||
staleTime: STALE.MINUTES.THREE,
|
||||
queryKey: createGetSuggestedUsersForSeeMoreQueryKey(props),
|
||||
queryKey: createGetSuggestedUsersForSeeMoreQueryKey({
|
||||
category: props.category,
|
||||
limit: props.limit,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
const contentLangs = getContentLanguages().join(',')
|
||||
const userInterests = aggregateUserInterests(preferences)
|
||||
|
||||
@@ -54,9 +54,8 @@ import {
|
||||
type BskyAgent,
|
||||
type RichText,
|
||||
} from '@atproto/api'
|
||||
import {msg, plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
@@ -197,12 +196,13 @@ export const ComposePost = ({
|
||||
cancelRef?: React.RefObject<CancelRef | null>
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const currentDid = currentAccount!.did
|
||||
const {closeComposer} = useComposerControls()
|
||||
const {_} = useLingui()
|
||||
const {t: l, i18n} = useLingui()
|
||||
const requireAltTextEnabled = useRequireAltTextEnabled()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
@@ -313,7 +313,7 @@ export const ComposePost = ({
|
||||
abortController,
|
||||
},
|
||||
})
|
||||
processVideo(
|
||||
void processVideo(
|
||||
asset,
|
||||
videoAction => {
|
||||
composerDispatch({
|
||||
@@ -328,10 +328,10 @@ export const ComposePost = ({
|
||||
agent,
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
_,
|
||||
i18n,
|
||||
)
|
||||
},
|
||||
[_, agent, currentDid, composerDispatch],
|
||||
[i18n, agent, currentDid, composerDispatch],
|
||||
)
|
||||
|
||||
const onInitVideo = useNonReactiveCallback(() => {
|
||||
@@ -460,7 +460,7 @@ export const ComposePost = ({
|
||||
}
|
||||
|
||||
// Start video compression and upload
|
||||
processVideo(
|
||||
void processVideo(
|
||||
asset,
|
||||
videoAction => {
|
||||
composerDispatch({
|
||||
@@ -475,7 +475,7 @@ export const ComposePost = ({
|
||||
agent,
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
_,
|
||||
i18n,
|
||||
)
|
||||
} catch (e) {
|
||||
logger.error('Failed to restore video from draft', {
|
||||
@@ -484,7 +484,7 @@ export const ComposePost = ({
|
||||
})
|
||||
}
|
||||
},
|
||||
[_, agent, currentDid, composerDispatch],
|
||||
[i18n, agent, currentDid, composerDispatch],
|
||||
)
|
||||
|
||||
const handleSelectDraft = useCallback(
|
||||
@@ -558,11 +558,11 @@ export const ComposePost = ({
|
||||
const getDraftSaveError = useCallback(
|
||||
(e: unknown): string => {
|
||||
if (e instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) {
|
||||
return _(msg`You've reached the maximum number of drafts`)
|
||||
return l`You've reached the maximum number of drafts`
|
||||
}
|
||||
return _(msg`Failed to save draft`)
|
||||
return l`Failed to save draft`
|
||||
},
|
||||
[_],
|
||||
[l],
|
||||
)
|
||||
|
||||
const validateDraftTextOrError = useCallback((): boolean => {
|
||||
@@ -571,14 +571,12 @@ export const ComposePost = ({
|
||||
)
|
||||
if (tooLong) {
|
||||
setError(
|
||||
_(
|
||||
msg`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`,
|
||||
),
|
||||
l`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [composerState.thread.posts, _])
|
||||
}, [composerState.thread.posts, l])
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
setError('')
|
||||
@@ -768,21 +766,21 @@ export const ComposePost = ({
|
||||
const media = thread.posts[i].embed.media
|
||||
if (media) {
|
||||
if (media.type === 'images' && media.images.some(img => !img.alt)) {
|
||||
return _(msg`One or more images is missing alt text.`)
|
||||
return l`One or more images is missing alt text.`
|
||||
}
|
||||
if (media.type === 'gif' && !media.alt) {
|
||||
return _(msg`One or more GIFs is missing alt text.`)
|
||||
return l`One or more GIFs is missing alt text.`
|
||||
}
|
||||
if (
|
||||
media.type === 'video' &&
|
||||
media.video.status !== 'error' &&
|
||||
!media.video.altText
|
||||
) {
|
||||
return _(msg`One or more videos is missing alt text.`)
|
||||
return l`One or more videos is missing alt text.`
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [thread, requireAltTextEnabled, _])
|
||||
}, [thread, requireAltTextEnabled, l])
|
||||
|
||||
const canPost =
|
||||
!missingAltError &&
|
||||
@@ -895,11 +893,9 @@ export const ComposePost = ({
|
||||
|
||||
let err = cleanError(e.message)
|
||||
if (err.includes('not locate record')) {
|
||||
err = _(
|
||||
msg`We're sorry! The post you are replying to has been deleted.`,
|
||||
)
|
||||
err = l`We're sorry! The post you are replying to has been deleted.`
|
||||
} else if (e instanceof EmbeddingDisabledError) {
|
||||
err = _(msg`This post's author has disabled quote posts.`)
|
||||
err = l`This post's author has disabled quote posts.`
|
||||
}
|
||||
setError(err)
|
||||
setIsPublishing(false)
|
||||
@@ -979,14 +975,14 @@ export const ComposePost = ({
|
||||
<Toast.Icon />
|
||||
<Toast.Text>
|
||||
{thread.posts.length > 1
|
||||
? _(msg`Your posts were sent`)
|
||||
? l`Your posts were sent`
|
||||
: replyTo
|
||||
? _(msg`Your reply was sent`)
|
||||
: _(msg`Your post was sent`)}
|
||||
? l`Your reply was sent`
|
||||
: l`Your post was sent`}
|
||||
</Toast.Text>
|
||||
{postUri && (
|
||||
<Toast.Action
|
||||
label={_(msg`View post`)}
|
||||
label={l`View post`}
|
||||
onPress={() => {
|
||||
const {host: name, rkey} = new AtUri(postUri)
|
||||
navigation.navigate('PostThread', {name, rkey})
|
||||
@@ -1001,7 +997,7 @@ export const ComposePost = ({
|
||||
)
|
||||
}, 500)
|
||||
}, [
|
||||
_,
|
||||
l,
|
||||
ax,
|
||||
agent,
|
||||
thread,
|
||||
@@ -1026,7 +1022,7 @@ export const ComposePost = ({
|
||||
// Preserves the referential identity passed to each post item.
|
||||
// Avoids re-rendering all posts on each keystroke.
|
||||
const onComposerPostPublish = useNonReactiveCallback(() => {
|
||||
onPressPublish()
|
||||
void onPressPublish()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1047,7 +1043,7 @@ export const ComposePost = ({
|
||||
setPublishOnUpload(false)
|
||||
} else if (uploadingVideos === 0) {
|
||||
setPublishOnUpload(false)
|
||||
onPressPublish()
|
||||
void onPressPublish()
|
||||
}
|
||||
}
|
||||
}, [thread.posts, onPressPublish, publishOnUpload])
|
||||
@@ -1189,7 +1185,13 @@ export const ComposePost = ({
|
||||
layout={native(LinearTransition)}
|
||||
onScroll={scrollHandler}
|
||||
contentContainerStyle={a.flex_grow}
|
||||
style={a.flex_1}
|
||||
style={[
|
||||
a.flex_1,
|
||||
web({
|
||||
scrollbarGutter: 'stable',
|
||||
scrollbarColor: `${t.palette.contrast_200} transparent`,
|
||||
}),
|
||||
]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
onContentSizeChange={onScrollViewContentSizeChange}
|
||||
onLayout={onScrollViewLayout}>
|
||||
@@ -1224,9 +1226,9 @@ export const ComposePost = ({
|
||||
{replyTo ? (
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
title={_(msg`Discard draft?`)}
|
||||
title={l`Discard draft?`}
|
||||
description=""
|
||||
confirmButtonCta={_(msg`Discard`)}
|
||||
confirmButtonCta={l`Discard`}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={handleDiscard}
|
||||
/>
|
||||
@@ -1264,21 +1266,17 @@ export const ComposePost = ({
|
||||
<Prompt.Actions>
|
||||
{allPostsWithinLimit && (
|
||||
<Prompt.Action
|
||||
cta={
|
||||
composerState.draftId
|
||||
? _(msg`Save changes`)
|
||||
: _(msg`Save draft`)
|
||||
}
|
||||
cta={composerState.draftId ? l`Save changes` : l`Save draft`}
|
||||
onPress={handleSaveDraft}
|
||||
color="primary"
|
||||
/>
|
||||
)}
|
||||
<Prompt.Action
|
||||
cta={_(msg`Discard`)}
|
||||
cta={l`Discard`}
|
||||
onPress={handleDiscard}
|
||||
color="negative_subtle"
|
||||
/>
|
||||
<Prompt.Cancel cta={_(msg`Keep editing`)} />
|
||||
<Prompt.Cancel cta={l`Keep editing`} />
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
)}
|
||||
@@ -1320,16 +1318,16 @@ let ComposerPost = memo(function ComposerPost({
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const currentDid = currentAccount!.did
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {data: currentProfile} = useProfileQuery({did: currentDid})
|
||||
const richtext = post.richtext
|
||||
const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media
|
||||
const forceMinHeight = IS_WEB && isTextOnly && isActive
|
||||
const selectTextInputPlaceholder = isReply
|
||||
? isFirstPost
|
||||
? _(msg`Write your reply`)
|
||||
: _(msg`Add another post`)
|
||||
: _(msg`What's up?`)
|
||||
? l`Write your reply`
|
||||
: l`Add another post`
|
||||
: l`What's up?`
|
||||
const discardPromptControl = Prompt.usePromptControl()
|
||||
|
||||
const dispatchPost = useCallback(
|
||||
@@ -1369,7 +1367,7 @@ let ComposerPost = memo(function ComposerPost({
|
||||
if (IS_NATIVE) return // web only
|
||||
const [mimeType] = uri.slice('data:'.length).split(';')
|
||||
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
|
||||
Toast.show(_(msg`Unsupported video type: ${mimeType}`), {
|
||||
Toast.show(l`Unsupported video type: ${mimeType}`, {
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
@@ -1384,7 +1382,7 @@ let ComposerPost = memo(function ComposerPost({
|
||||
onImageAdd([res])
|
||||
}
|
||||
},
|
||||
[post.id, onSelectVideo, onImageAdd, _],
|
||||
[post.id, onSelectVideo, onImageAdd, l],
|
||||
)
|
||||
|
||||
useHideKeyboardOnBackground()
|
||||
@@ -1429,19 +1427,20 @@ let ComposerPost = memo(function ComposerPost({
|
||||
onError={onError}
|
||||
onPressPublish={onPublish}
|
||||
accessible={true}
|
||||
accessibilityLabel={_(msg`Write post`)}
|
||||
accessibilityHint={_(
|
||||
msg`Compose posts up to ${plural(MAX_GRAPHEME_LENGTH || 0, {
|
||||
accessibilityLabel={l`Write post`}
|
||||
accessibilityHint={l`Compose posts up to ${plural(
|
||||
MAX_GRAPHEME_LENGTH || 0,
|
||||
{
|
||||
other: '# characters',
|
||||
})} in length`,
|
||||
)}
|
||||
},
|
||||
)} in length`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{canRemovePost && isActive && (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Delete post`)}
|
||||
label={l`Delete post`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
@@ -1466,15 +1465,15 @@ let ComposerPost = memo(function ComposerPost({
|
||||
</Button>
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
title={_(msg`Discard post?`)}
|
||||
description={_(msg`Are you sure you'd like to discard this post?`)}
|
||||
title={l`Discard post?`}
|
||||
description={l`Are you sure you'd like to discard this post?`}
|
||||
onConfirm={() => {
|
||||
dispatch({
|
||||
type: 'remove_post',
|
||||
postId: post.id,
|
||||
})
|
||||
}}
|
||||
confirmButtonCta={_(msg`Discard`)}
|
||||
confirmButtonCta={l`Discard`}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
@@ -1531,7 +1530,8 @@ function ComposerTopBar({
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={topBarAnimatedStyle}
|
||||
@@ -1544,7 +1544,7 @@ function ComposerTopBar({
|
||||
IS_LIQUID_GLASS ? [a.px_lg, a.pt_lg, a.pb_md] : [a.p_sm],
|
||||
]}>
|
||||
<Button
|
||||
label={_(msg`Cancel`)}
|
||||
label={l`Cancel`}
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
shape="default"
|
||||
@@ -1552,9 +1552,7 @@ function ComposerTopBar({
|
||||
style={[{paddingLeft: 7, paddingRight: 7}]}
|
||||
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
|
||||
onPress={onCancel}
|
||||
accessibilityHint={_(
|
||||
msg`Closes post composer and discards post draft`,
|
||||
)}>
|
||||
accessibilityHint={l`Closes post composer and discards post draft`}>
|
||||
<ButtonText style={[a.text_md]} maxFontSizeMultiplier={2}>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
@@ -1588,35 +1586,27 @@ function ComposerTopBar({
|
||||
label={
|
||||
isReply
|
||||
? isThread
|
||||
? _(
|
||||
msg({
|
||||
message: 'Publish replies',
|
||||
comment:
|
||||
'Accessibility label for button to publish multiple replies in a thread',
|
||||
}),
|
||||
)
|
||||
: _(
|
||||
msg({
|
||||
message: 'Publish reply',
|
||||
comment:
|
||||
'Accessibility label for button to publish a single reply',
|
||||
}),
|
||||
)
|
||||
? l({
|
||||
message: 'Publish replies',
|
||||
comment:
|
||||
'Accessibility label for button to publish multiple replies in a thread',
|
||||
})
|
||||
: l({
|
||||
message: 'Publish reply',
|
||||
comment:
|
||||
'Accessibility label for button to publish a single reply',
|
||||
})
|
||||
: isThread
|
||||
? _(
|
||||
msg({
|
||||
message: 'Publish posts',
|
||||
comment:
|
||||
'Accessibility label for button to publish multiple posts in a thread',
|
||||
}),
|
||||
)
|
||||
: _(
|
||||
msg({
|
||||
message: 'Publish post',
|
||||
comment:
|
||||
'Accessibility label for button to publish a single post',
|
||||
}),
|
||||
)
|
||||
? l({
|
||||
message: 'Publish posts',
|
||||
comment:
|
||||
'Accessibility label for button to publish multiple posts in a thread',
|
||||
})
|
||||
: l({
|
||||
message: 'Publish post',
|
||||
comment:
|
||||
'Accessibility label for button to publish a single post',
|
||||
})
|
||||
}
|
||||
color="primary"
|
||||
size="small"
|
||||
@@ -1851,7 +1841,7 @@ function ComposerFooter({
|
||||
openGallery?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
/*
|
||||
* Once we've allowed a certain type of asset to be selected, we don't allow
|
||||
@@ -1979,8 +1969,8 @@ function ComposerFooter({
|
||||
<Button
|
||||
onPress={onEmojiButtonPress}
|
||||
style={a.p_sm}
|
||||
label={_(msg`Open emoji picker`)}
|
||||
accessibilityHint={_(msg`Opens emoji picker`)}
|
||||
label={l`Open emoji picker`}
|
||||
accessibilityHint={l`Opens emoji picker`}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="primary">
|
||||
@@ -1994,7 +1984,7 @@ function ComposerFooter({
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
{showAddButton && (
|
||||
<Button
|
||||
label={_(msg`Add another post to thread`)}
|
||||
label={l`Add another post to thread`}
|
||||
onPress={onAddPost}
|
||||
style={[a.p_sm]}
|
||||
variant="ghost"
|
||||
@@ -2276,7 +2266,7 @@ function ErrorBanner({
|
||||
clearVideo: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const videoError =
|
||||
videoState.status === 'error' ? videoState.error : undefined
|
||||
@@ -2311,7 +2301,7 @@ function ErrorBanner({
|
||||
{error}
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Dismiss error`)}
|
||||
label={l`Dismiss error`}
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
@@ -2358,7 +2348,7 @@ function ToolbarWrapper({
|
||||
|
||||
function VideoUploadToolbar({state}: {state: VideoState}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const progress = state.progress
|
||||
const shouldRotate =
|
||||
state.status === 'processing' && (progress === 0 || progress === 1)
|
||||
@@ -2390,34 +2380,34 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
|
||||
switch (state.status) {
|
||||
case 'compressing':
|
||||
if (isGif) {
|
||||
text = _(msg`Compressing GIF...`)
|
||||
text = l`Compressing GIF...`
|
||||
} else {
|
||||
text = _(msg`Compressing video...`)
|
||||
text = l`Compressing video...`
|
||||
}
|
||||
break
|
||||
case 'uploading':
|
||||
if (isGif) {
|
||||
text = _(msg`Uploading GIF...`)
|
||||
text = l`Uploading GIF...`
|
||||
} else {
|
||||
text = _(msg`Uploading video...`)
|
||||
text = l`Uploading video...`
|
||||
}
|
||||
break
|
||||
case 'processing':
|
||||
if (isGif) {
|
||||
text = _(msg`Processing GIF...`)
|
||||
text = l`Processing GIF...`
|
||||
} else {
|
||||
text = _(msg`Processing video...`)
|
||||
text = l`Processing video...`
|
||||
}
|
||||
break
|
||||
case 'error':
|
||||
text = _(msg`Error`)
|
||||
text = l`Error`
|
||||
wheelProgress = 100
|
||||
break
|
||||
case 'done':
|
||||
if (isGif) {
|
||||
text = _(msg`GIF uploaded`)
|
||||
text = l`GIF uploaded`
|
||||
} else {
|
||||
text = _(msg`Video uploaded`)
|
||||
text = l`Video uploaded`
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ export async function processVideo(
|
||||
agent: BskyAgent,
|
||||
did: string,
|
||||
signal: AbortSignal,
|
||||
_: I18n['_'],
|
||||
i18n: I18n,
|
||||
) {
|
||||
let video: CompressedVideo | undefined
|
||||
try {
|
||||
@@ -274,7 +274,7 @@ export async function processVideo(
|
||||
signal,
|
||||
})
|
||||
} catch (e) {
|
||||
const message = getCompressErrorMessage(e, _)
|
||||
const message = getCompressErrorMessage(e, i18n)
|
||||
if (message !== null) {
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
@@ -297,13 +297,13 @@ export async function processVideo(
|
||||
agent,
|
||||
did,
|
||||
signal,
|
||||
_,
|
||||
i18n,
|
||||
setProgress: p => {
|
||||
dispatch({type: 'update_progress', progress: p, signal})
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
const message = getUploadErrorMessage(e, _)
|
||||
const message = getUploadErrorMessage(e, i18n)
|
||||
if (message !== null) {
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
@@ -355,7 +355,7 @@ export async function processVideo(
|
||||
logger.error('Error processing video', {safeMessage: e})
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
error: _(msg`Video failed to process`),
|
||||
error: i18n._(msg`Video failed to process`),
|
||||
signal,
|
||||
})
|
||||
return // Exit async loop
|
||||
@@ -387,20 +387,20 @@ export async function processVideo(
|
||||
}
|
||||
}
|
||||
|
||||
function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null {
|
||||
function getCompressErrorMessage(e: unknown, i18n: I18n): string | null {
|
||||
if (e instanceof AbortError) {
|
||||
return null
|
||||
}
|
||||
if (e instanceof VideoTooLargeError) {
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`The selected video is larger than 100 MB. Please try again with a smaller file.`,
|
||||
)
|
||||
}
|
||||
logger.error('Error compressing video', {safeMessage: e})
|
||||
return _(msg`An error occurred while compressing the video.`)
|
||||
return i18n._(msg`An error occurred while compressing the video.`)
|
||||
}
|
||||
|
||||
function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
|
||||
function getUploadErrorMessage(e: unknown, i18n: I18n): string | null {
|
||||
if (e instanceof AbortError) {
|
||||
return null
|
||||
}
|
||||
@@ -408,38 +408,38 @@ function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
|
||||
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
|
||||
switch (e.message) {
|
||||
case 'User is not allowed to upload videos':
|
||||
return _(msg`You are not allowed to upload videos.`)
|
||||
return i18n._(msg`You are not allowed to upload videos.`)
|
||||
case 'Uploading is disabled at the moment':
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`,
|
||||
)
|
||||
case "Failed to get user's upload stats":
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
|
||||
)
|
||||
case 'User has exceeded daily upload bytes limit':
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`You've reached your daily limit for video uploads (too many bytes)`,
|
||||
)
|
||||
case 'User has exceeded daily upload videos limit':
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`You've reached your daily limit for video uploads (too many videos)`,
|
||||
)
|
||||
case 'Account is not old enough to upload videos':
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`Your account is not yet old enough to upload videos. Please try again later.`,
|
||||
)
|
||||
case 'file size (100000001 bytes) is larger than the maximum allowed size (100000000 bytes)':
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`The selected video is larger than 100 MB. Please try again with a smaller file.`,
|
||||
)
|
||||
case 'Confirm your email address to upload videos':
|
||||
return _(msg`Please confirm your email address to upload videos.`)
|
||||
return i18n._(msg`Please confirm your email address to upload videos.`)
|
||||
}
|
||||
}
|
||||
|
||||
if (isNetworkError(e)) {
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`An error occurred while uploading the video. Please check your internet connection and try again.`,
|
||||
)
|
||||
} else {
|
||||
@@ -448,5 +448,5 @@ function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
|
||||
}
|
||||
|
||||
const message = e instanceof Error ? e.message : ''
|
||||
return _(msg`An error occurred while uploading the video. ${message}`)
|
||||
return i18n._(msg`An error occurred while uploading the video. ${message}`)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import {forwardRef, memo, useCallback, useState} from 'react'
|
||||
import {type JSX} from 'react'
|
||||
import {type ScrollView, View} from 'react-native'
|
||||
import {useAnimatedRef} from 'react-native-reanimated'
|
||||
@@ -35,7 +35,7 @@ export interface PagerWithHeaderProps {
|
||||
onPageSelected?: (index: number) => void
|
||||
onCurrentPageSelected?: (index: number) => void
|
||||
}
|
||||
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
export const PagerWithHeader = forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
function PageWithHeaderImpl(
|
||||
{
|
||||
children,
|
||||
@@ -49,9 +49,9 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
}: PagerWithHeaderProps,
|
||||
ref,
|
||||
) {
|
||||
const [currentPage, setCurrentPage] = React.useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
|
||||
const renderTabBar = React.useCallback(
|
||||
const renderTabBar = useCallback(
|
||||
(props: RenderTabBarFnProps) => {
|
||||
return (
|
||||
<PagerTabBar
|
||||
@@ -76,7 +76,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
],
|
||||
)
|
||||
|
||||
const onPageSelectedInner = React.useCallback(
|
||||
const onPageSelectedInner = useCallback(
|
||||
(index: number) => {
|
||||
setCurrentPage(index)
|
||||
onPageSelected?.(index)
|
||||
@@ -162,7 +162,7 @@ let PagerTabBar = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
PagerTabBar = React.memo(PagerTabBar)
|
||||
PagerTabBar = memo(PagerTabBar)
|
||||
|
||||
function PagerItem({
|
||||
isFocused,
|
||||
|
||||
+101
-80
@@ -27,6 +27,10 @@ import {Link} from '#/view/com/util/Link'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {
|
||||
GalleryBleed,
|
||||
maybeApplyGalleryOffsetStyles,
|
||||
} from '#/components/images/Gallery'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
@@ -155,89 +159,106 @@ function PostInner({
|
||||
const [hover, setHover] = useState(false)
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={itemHref}
|
||||
style={[
|
||||
styles.outer,
|
||||
pal.border,
|
||||
!hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth},
|
||||
style,
|
||||
]}
|
||||
onBeforePress={onBeforePress}
|
||||
onPointerEnter={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(false)
|
||||
}}>
|
||||
<SubtleHover hover={hover} />
|
||||
{showReplyLine && <View style={styles.replyLine} />}
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={itemHref}
|
||||
/>
|
||||
{replyAuthorDid !== '' && (
|
||||
<PostRepliedTo parentAuthor={replyAuthorDid} />
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<ContentHider
|
||||
modui={moderation.ui('contentView')}
|
||||
style={styles.contentHider}
|
||||
childContainerStyle={styles.contentHiderChild}>
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentView')}
|
||||
style={[a.pb_xs]}
|
||||
<GalleryBleed>
|
||||
<Link
|
||||
href={itemHref}
|
||||
style={[
|
||||
styles.outer,
|
||||
pal.border,
|
||||
!hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth},
|
||||
style,
|
||||
]}
|
||||
onBeforePress={onBeforePress}
|
||||
onPointerEnter={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(false)
|
||||
}}>
|
||||
<SubtleHover hover={hover} />
|
||||
{showReplyLine && <View style={styles.replyLine} />}
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
{richText.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
value={richText}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
authorHandle={post.author.handle}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
{limitLines && (
|
||||
<ShowMoreTextButton
|
||||
style={[a.text_md]}
|
||||
onPress={onPressShowMore}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : undefined}
|
||||
<TranslatedPost hideTranslateLink post={post} />
|
||||
{post.embed ? (
|
||||
<Embed
|
||||
embed={post.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.layoutContent,
|
||||
maybeApplyGalleryOffsetStyles('meta', {
|
||||
post,
|
||||
modui: moderation.ui('contentList'),
|
||||
additionalCauses: [],
|
||||
}),
|
||||
]}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={itemHref}
|
||||
/>
|
||||
{replyAuthorDid !== '' && (
|
||||
<PostRepliedTo parentAuthor={replyAuthorDid} />
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<ContentHider
|
||||
modui={moderation.ui('contentView')}
|
||||
style={styles.contentHider}
|
||||
childContainerStyle={styles.contentHiderChild}>
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentView')}
|
||||
style={[a.pb_xs]}
|
||||
/>
|
||||
) : null}
|
||||
</ContentHider>
|
||||
<PostControls
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="Post"
|
||||
/>
|
||||
{richText.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
value={richText}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
authorHandle={post.author.handle}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
{limitLines && (
|
||||
<ShowMoreTextButton
|
||||
style={[a.text_md]}
|
||||
onPress={onPressShowMore}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : undefined}
|
||||
<TranslatedPost hideTranslateLink post={post} />
|
||||
{post.embed ? (
|
||||
<View
|
||||
style={maybeApplyGalleryOffsetStyles('embed', {
|
||||
post,
|
||||
modui: moderation.ui('contentList'),
|
||||
additionalCauses: [],
|
||||
})}>
|
||||
<Embed
|
||||
embed={post.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</ContentHider>
|
||||
<PostControls
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="Post"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
</Link>
|
||||
</GalleryBleed>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+158
-139
@@ -34,6 +34,10 @@ import {Link} from '#/view/com/util/Link'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {
|
||||
GalleryBleed,
|
||||
maybeApplyGalleryOffsetStyles,
|
||||
} from '#/components/images/Gallery'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
@@ -163,6 +167,7 @@ let FeedItemInner = ({
|
||||
const queryClient = useQueryClient()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const pal = usePalette('default')
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const [hover, setHover] = useState(false)
|
||||
|
||||
@@ -293,140 +298,6 @@ let FeedItemInner = ({
|
||||
}
|
||||
}, [reason])
|
||||
|
||||
return (
|
||||
<Link
|
||||
testID={`feedItem-by-${post.author.handle}`}
|
||||
style={outerStyles}
|
||||
href={href}
|
||||
noFeedback
|
||||
accessible={false}
|
||||
onBeforePress={onBeforePress}
|
||||
dataSet={{feedContext}}
|
||||
onPointerEnter={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(false)
|
||||
}}>
|
||||
<SubtleHover hover={hover} />
|
||||
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
|
||||
<View style={{width: 42}}>
|
||||
{isThreadChild && (
|
||||
<View
|
||||
style={[
|
||||
styles.replyLine,
|
||||
{
|
||||
flexGrow: 1,
|
||||
backgroundColor: pal.colors.replyLine,
|
||||
marginBottom: 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_sm, a.flex_shrink]}>
|
||||
{reason && (
|
||||
<PostFeedReason
|
||||
reason={reason}
|
||||
moderation={moderation}
|
||||
onOpenReposter={onOpenReposter}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
onBeforePress={onOpenAuthor}
|
||||
live={live}
|
||||
/>
|
||||
{isThreadParent && (
|
||||
<View
|
||||
style={[
|
||||
styles.replyLine,
|
||||
{
|
||||
flexGrow: 1,
|
||||
backgroundColor: pal.colors.replyLine,
|
||||
marginTop: live ? 8 : 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={href}
|
||||
onOpenAuthor={onOpenAuthor}
|
||||
/>
|
||||
{showReplyTo &&
|
||||
(parentAuthor || isParentBlocked || isParentNotFound) && (
|
||||
<PostRepliedTo
|
||||
parentAuthor={parentAuthor}
|
||||
isParentBlocked={isParentBlocked}
|
||||
isParentNotFound={isParentNotFound}
|
||||
/>
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<PostContent
|
||||
moderation={moderation}
|
||||
richText={richText}
|
||||
postEmbed={post.embed}
|
||||
postAuthor={post.author}
|
||||
onOpenEmbed={onOpenEmbed}
|
||||
post={post}
|
||||
threadgateRecord={threadgateRecord}
|
||||
/>
|
||||
<PostControls
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="FeedItem"
|
||||
feedContext={feedContext}
|
||||
reqId={reqId}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
viaRepost={viaRepost}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<DiscoverDebug feedContext={feedContext} />
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
FeedItemInner = memo(FeedItemInner)
|
||||
|
||||
let PostContent = ({
|
||||
post,
|
||||
moderation,
|
||||
richText,
|
||||
postEmbed,
|
||||
postAuthor,
|
||||
onOpenEmbed,
|
||||
threadgateRecord,
|
||||
}: {
|
||||
moderation: ModerationDecision
|
||||
richText: RichTextAPI
|
||||
postEmbed: AppBskyFeedDefs.PostView['embed']
|
||||
postAuthor: AppBskyFeedDefs.PostView['author']
|
||||
onOpenEmbed: () => void
|
||||
post: AppBskyFeedDefs.PostView
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
}): React.ReactNode => {
|
||||
const {currentAccount} = useSession()
|
||||
const [limitLines, setLimitLines] = useState(
|
||||
() => countLines(richText.text) >= MAX_POST_LINES,
|
||||
)
|
||||
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
|
||||
threadgateRecord,
|
||||
})
|
||||
@@ -451,6 +322,150 @@ let PostContent = ({
|
||||
: []
|
||||
}, [post, currentAccount?.did, threadgateHiddenReplies])
|
||||
|
||||
return (
|
||||
<GalleryBleed>
|
||||
<Link
|
||||
testID={`feedItem-by-${post.author.handle}`}
|
||||
style={outerStyles}
|
||||
href={href}
|
||||
noFeedback
|
||||
accessible={false}
|
||||
onBeforePress={onBeforePress}
|
||||
dataSet={{feedContext}}
|
||||
onPointerEnter={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(false)
|
||||
}}>
|
||||
<SubtleHover hover={hover} />
|
||||
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
|
||||
<View style={{width: 42}}>
|
||||
{isThreadChild && (
|
||||
<View
|
||||
style={[
|
||||
styles.replyLine,
|
||||
{
|
||||
flexGrow: 1,
|
||||
backgroundColor: pal.colors.replyLine,
|
||||
marginBottom: 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_sm, a.flex_shrink]}>
|
||||
{reason && (
|
||||
<PostFeedReason
|
||||
reason={reason}
|
||||
moderation={moderation}
|
||||
onOpenReposter={onOpenReposter}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
onBeforePress={onOpenAuthor}
|
||||
live={live}
|
||||
/>
|
||||
{isThreadParent && (
|
||||
<View
|
||||
style={[
|
||||
styles.replyLine,
|
||||
{
|
||||
flexGrow: 1,
|
||||
backgroundColor: pal.colors.replyLine,
|
||||
marginTop: live ? 8 : 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.layoutContent,
|
||||
maybeApplyGalleryOffsetStyles('meta', {
|
||||
post,
|
||||
modui: moderation.ui('contentList'),
|
||||
additionalCauses: additionalPostAlerts,
|
||||
}),
|
||||
]}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={href}
|
||||
onOpenAuthor={onOpenAuthor}
|
||||
/>
|
||||
{showReplyTo &&
|
||||
(parentAuthor || isParentBlocked || isParentNotFound) && (
|
||||
<PostRepliedTo
|
||||
parentAuthor={parentAuthor}
|
||||
isParentBlocked={isParentBlocked}
|
||||
isParentNotFound={isParentNotFound}
|
||||
/>
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<PostContent
|
||||
moderation={moderation}
|
||||
richText={richText}
|
||||
postEmbed={post.embed}
|
||||
postAuthor={post.author}
|
||||
onOpenEmbed={onOpenEmbed}
|
||||
post={post}
|
||||
additionalPostAlerts={additionalPostAlerts}
|
||||
/>
|
||||
<PostControls
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="FeedItem"
|
||||
feedContext={feedContext}
|
||||
reqId={reqId}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
viaRepost={viaRepost}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<DiscoverDebug feedContext={feedContext} />
|
||||
</View>
|
||||
</Link>
|
||||
</GalleryBleed>
|
||||
)
|
||||
}
|
||||
FeedItemInner = memo(FeedItemInner)
|
||||
|
||||
let PostContent = ({
|
||||
post,
|
||||
moderation,
|
||||
richText,
|
||||
postEmbed,
|
||||
postAuthor,
|
||||
onOpenEmbed,
|
||||
additionalPostAlerts,
|
||||
}: {
|
||||
moderation: ModerationDecision
|
||||
richText: RichTextAPI
|
||||
postEmbed: AppBskyFeedDefs.PostView['embed']
|
||||
postAuthor: AppBskyFeedDefs.PostView['author']
|
||||
onOpenEmbed: () => void
|
||||
post: AppBskyFeedDefs.PostView
|
||||
additionalPostAlerts?: AppModerationCause[]
|
||||
}): React.ReactNode => {
|
||||
const [limitLines, setLimitLines] = useState(
|
||||
() => countLines(richText.text) >= MAX_POST_LINES,
|
||||
)
|
||||
|
||||
const record = useMemo<AppBskyFeedPost.Record | undefined>(
|
||||
() =>
|
||||
bsky.validate(post.record, AppBskyFeedPost.validateRecord)
|
||||
@@ -492,7 +507,15 @@ let PostContent = ({
|
||||
) : undefined}
|
||||
{record && <TranslatedPost hideTranslateLink post={post} />}
|
||||
{postEmbed ? (
|
||||
<View style={[a.pb_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.pb_xs,
|
||||
maybeApplyGalleryOffsetStyles('embed', {
|
||||
post,
|
||||
modui: moderation.ui('contentList'),
|
||||
additionalCauses: additionalPostAlerts,
|
||||
}),
|
||||
]}>
|
||||
<Embed
|
||||
embed={postEmbed}
|
||||
moderation={moderation}
|
||||
@@ -524,13 +547,9 @@ const styles = StyleSheet.create({
|
||||
layoutAvi: {
|
||||
paddingLeft: 8,
|
||||
paddingRight: 10,
|
||||
position: 'relative',
|
||||
zIndex: 999,
|
||||
},
|
||||
layoutContent: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
zIndex: 0,
|
||||
},
|
||||
alert: {
|
||||
marginTop: 6,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import {useEffect, useRef} from 'react'
|
||||
import {View} from 'react-native'
|
||||
// Based on @react-navigation/native-stack/src/navigators/createNativeStackNavigator.ts
|
||||
// MIT License
|
||||
@@ -82,7 +82,7 @@ function NativeStackNavigator({
|
||||
UNSTABLE_router,
|
||||
})
|
||||
|
||||
React.useEffect(
|
||||
useEffect(
|
||||
() =>
|
||||
// @ts-expect-error: there may not be a tab navigator in parent
|
||||
navigation?.addListener?.('tabPress', (e: any) => {
|
||||
@@ -110,7 +110,7 @@ function NativeStackNavigator({
|
||||
|
||||
// --- our custom logic starts here ---
|
||||
// Web LRU: tracks route keys in most-recently-focused order
|
||||
const lruKeysRef = React.useRef<string[]>([])
|
||||
const lruKeysRef = useRef<string[]>([])
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const activeRoute = state.routes[state.index]
|
||||
const activeDescriptor = descriptors[activeRoute.key]
|
||||
|
||||
@@ -20,14 +20,14 @@
|
||||
"@jridgewell/gen-mapping" "^0.3.0"
|
||||
"@jridgewell/trace-mapping" "^0.3.9"
|
||||
|
||||
"@atproto/api@^0.19.8":
|
||||
version "0.19.8"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.8.tgz#ae847abece43f0108535c6305780079e8782ab29"
|
||||
integrity sha512-b79kuI3AzEmpLLi9afRNq6T0KFEEVL4d+vHFAtWxeDwS7lfwUOIIngMjAVvwmwC5nJRZIrK8L9d4y7LD8zdvsg==
|
||||
"@atproto/api@^0.19.9":
|
||||
version "0.19.9"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.9.tgz#f09ed8412159d6878eeaf25a0a8b4445c62fa9eb"
|
||||
integrity sha512-+sUYNuiA1Rv8HemMCURHwRkMp2D7cq6nNquefjosu6UB54IzkD0MLK3YY383poLRShiApouOxRse2OKK25dbQw==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.20"
|
||||
"@atproto/common-web" "^0.4.21"
|
||||
"@atproto/lexicon" "^0.6.2"
|
||||
"@atproto/syntax" "^0.5.3"
|
||||
"@atproto/syntax" "^0.5.4"
|
||||
"@atproto/xrpc" "^0.7.7"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
@@ -44,14 +44,14 @@
|
||||
"@atproto/syntax" "^0.5.1"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/common-web@^0.4.20":
|
||||
version "0.4.20"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.20.tgz#bb455868e674d45ed1044c68ccccae3c08168d47"
|
||||
integrity sha512-RcsYT28yQgVi/Glb/hHPGpqpzIlKrbMLeldEd7PmmMLWDaJL2j3lb92qytvxjl1yhi2Ssq2TEuMZ2NlWaAbpow==
|
||||
"@atproto/common-web@^0.4.21":
|
||||
version "0.4.21"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.21.tgz#2198583f842a000f495f1caec6f7e4eda207191b"
|
||||
integrity sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==
|
||||
dependencies:
|
||||
"@atproto/lex-data" "^0.0.15"
|
||||
"@atproto/lex-json" "^0.0.15"
|
||||
"@atproto/syntax" "^0.5.3"
|
||||
"@atproto/lex-json" "^0.0.16"
|
||||
"@atproto/syntax" "^0.5.4"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/lex-data@^0.0.14":
|
||||
@@ -82,10 +82,10 @@
|
||||
"@atproto/lex-data" "^0.0.14"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lex-json@^0.0.15":
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.15.tgz#34d300e5dfd8a0ec76ca7363f264a488e17c1bd9"
|
||||
integrity sha512-kCLdP629H6GhgPjBTpZibUoqlpmW0hnVfZVwcD4s4Jch1KAqY/QcfL24Ih8wrW0Ok1YvtMIhjk98evdTA2OJcw==
|
||||
"@atproto/lex-json@^0.0.16":
|
||||
version "0.0.16"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.16.tgz#c99b5147560310f9f7f74405c57858a12c3e365a"
|
||||
integrity sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==
|
||||
dependencies:
|
||||
"@atproto/lex-data" "^0.0.15"
|
||||
tslib "^2.8.1"
|
||||
@@ -108,10 +108,10 @@
|
||||
dependencies:
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/syntax@^0.5.3":
|
||||
version "0.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.3.tgz#4331d01f63fe56c374dcf95d4432a22b62271a17"
|
||||
integrity sha512-gzhlHOJHm5KXdCc17fXi1fXM81ccs5jJfNgCui84ay9JGvczxegpYHNqdMlv+iBuhtBzFIjgx6ChjRxN/kO8kQ==
|
||||
"@atproto/syntax@^0.5.4":
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.4.tgz#89842eb8b8ab181752b04ed840cc6b100e296b00"
|
||||
integrity sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==
|
||||
dependencies:
|
||||
tslib "^2.8.1"
|
||||
|
||||
@@ -8700,10 +8700,10 @@ eslint-plugin-react@^7.37.5:
|
||||
string.prototype.matchall "^4.0.12"
|
||||
string.prototype.repeat "^1.0.0"
|
||||
|
||||
eslint-plugin-simple-import-sort@^12.1.1:
|
||||
version "12.1.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-12.1.1.tgz#e64bfdaf91c5b98a298619aa634a9f7aa43b709e"
|
||||
integrity sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==
|
||||
eslint-plugin-simple-import-sort@^13.0.0:
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-13.0.0.tgz#93936354367d8bb42c1b9b4c13c92eb29fffd2a5"
|
||||
integrity sha512-McAc+/Nlvcg4byY/CABGH8kqnefWBj8s3JA2okEtz8ixbECQgU46p0HkTUKa4YS7wvgGceimlc34p1nXqbWqtA==
|
||||
|
||||
eslint-scope@5.1.1:
|
||||
version "5.1.1"
|
||||
|
||||
Reference in New Issue
Block a user