diff --git a/assets/icons/messagePlus_stroke2_corner0_rounded.svg b/assets/icons/messagePlus_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..bf9e277fb8
--- /dev/null
+++ b/assets/icons/messagePlus_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/bskyembed/src/color-mode.ts b/bskyembed/src/color-mode.ts
index b34624e312..3b9219cb3c 100644
--- a/bskyembed/src/color-mode.ts
+++ b/bskyembed/src/color-mode.ts
@@ -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'
diff --git a/bskyembed/src/screens/landing.tsx b/bskyembed/src/screens/landing.tsx
index b4bb0f7e91..9c7cd68a40 100644
--- a/bskyembed/src/screens/landing.tsx
+++ b/bskyembed/src/screens/landing.tsx
@@ -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 (
-
+
diff --git a/eslint.config.mjs b/eslint.config.mjs
index a0f0db9140..8e7bb941a8 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -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
*/
diff --git a/package.json b/package.json
index 66f6ddd0a6..1623c5c139 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/patches/react-native-keyboard-controller+1.21.5.patch b/patches/react-native-keyboard-controller+1.21.5.patch
new file mode 100644
index 0000000000..d721bbe494
--- /dev/null
+++ b/patches/react-native-keyboard-controller+1.21.5.patch
@@ -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(
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 7ef150e9e5..6f661e03e9 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -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}}
/>
+ MessagesConversationSettingsScreen}
+ options={{title: title(msg`Group chat settings`), requireAuth: true}}
+ />
MessagesSettingsScreen}
diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx
index 600970459a..83b7f474d1 100644
--- a/src/ageAssurance/components/NoAccessScreen.tsx
+++ b/src/ageAssurance/components/NoAccessScreen.tsx
@@ -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'
diff --git a/src/analytics/PassiveAnalytics.tsx b/src/analytics/PassiveAnalytics.tsx
index 25dfea929a..34b5f2d275 100644
--- a/src/analytics/PassiveAnalytics.tsx
+++ b/src/analytics/PassiveAnalytics.tsx
@@ -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,
+ )
+ ax.logger.info('FEATURES', {
+ features: feats,
+ definitions: features.getFeatures(),
+ })
+ }
})
return () => sub.remove()
}, [ax])
diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts
index 7c87c75917..36a354bb49 100644
--- a/src/analytics/features/types.ts
+++ b/src/analytics/features/types.ts
@@ -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',
}
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index 1f6b25278b..1d74e1c5b8 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -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
+ }
}
diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx
new file mode 100644
index 0000000000..2dd2f3b203
--- /dev/null
+++ b/src/components/AvatarBubbles.tsx
@@ -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, 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 = (
+ <>
+
+
+ >
+ )
+
+ if (profiles.length === 3) {
+ avatars = (
+ <>
+
+
+
+ >
+ )
+ }
+
+ if (profiles.length >= 4) {
+ avatars = (
+ <>
+
+
+
+
+ >
+ )
+ }
+
+ return (
+
+
+ {avatars}
+
+
+ )
+}
+
+function AvatarBubble({
+ profile,
+ scale,
+ size,
+ style,
+ x,
+ y,
+ includeProfileBorder,
+}: {
+ profile?: bsky.profile.AnyProfileView
+ scale: Animated.SharedValue
+ size: number
+ style?: StyleProp
+ 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 (
+
+ {profile ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function Avatar({
+ profile,
+ size = 76,
+}: {
+ profile: bsky.profile.AnyProfileView
+ size?: number
+}) {
+ return (
+
+ )
+}
+
+function AvatarPlaceholder({size = 76}: {size?: number}) {
+ const t = useTheme()
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx
index e94eaf7795..cce4332dc4 100644
--- a/src/components/ContextMenu/index.tsx
+++ b/src/components/ContextMenu/index.tsx
@@ -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}
diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts
index 7d4f3019a8..260d95e85c 100644
--- a/src/components/ContextMenu/types.ts
+++ b/src/components/ContextMenu/types.ts
@@ -21,6 +21,7 @@ export type {
export type AuxiliaryViewProps = {
children?: React.ReactNode
align?: 'left' | 'right'
+ style?: StyleProp
}
export type ItemProps = Omit & {
diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx
index a3f0d46377..fba3256d56 100644
--- a/src/components/Post/Embed/ImageEmbed.tsx
+++ b/src/components/Post/Embed/ImageEmbed.tsx
@@ -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 (
+
+
+
+ )
+ }
+
return (
-
- {({active}) => (
- <>
- {!active && !linkDisabled && (
-
- )}
- {linkDisabled ? (
-
- {contents}
-
- ) : (
-
- {contents}
-
- )}
- >
- )}
-
-
+
+
+
+ {({active}) => (
+ <>
+ {!active && !linkDisabled && (
+
+ )}
+ {linkDisabled ? (
+
+ {contents}
+
+ ) : (
+
+ {contents}
+
+ )}
+ >
+ )}
+
+
+
)
}
diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx
index 7ed620bd64..a744efbaa8 100644
--- a/src/components/PostControls/PostMenu/index.tsx
+++ b/src/components/PostControls/PostMenu/index.tsx
@@ -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'
diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx
index 755b60e41f..efac6a3f01 100644
--- a/src/components/PostControls/ShareMenu/index.tsx
+++ b/src/components/PostControls/ShareMenu/index.tsx
@@ -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'
diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx
index 775eedd769..5c81153a10 100644
--- a/src/components/ProgressGuide/FollowDialog.tsx
+++ b/src/components/ProgressGuide/FollowDialog.tsx
@@ -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,
})
}
}
diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx
index 766fdbe9ac..0dd640d3c0 100644
--- a/src/components/StarterPack/ShareDialog.tsx
+++ b/src/components/StarterPack/ShareDialog.tsx
@@ -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'
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
index c1f54e2394..3ed704f99d 100644
--- a/src/components/dms/ActionsWrapper.tsx
+++ b/src/components/dms/ActionsWrapper.tsx
@@ -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 (
@@ -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}
diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx
index ae9a4a3c4a..587a25e95b 100644
--- a/src/components/dms/ConvoMenu.tsx
+++ b/src/components/dms/ConvoMenu.tsx
@@ -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]}>
-
+
)}
@@ -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({
Leave conversation
-
+
) : (
<>
@@ -245,7 +245,7 @@ function MenuContent({
Mark as read
-
+
)}
Leave conversation
-
+
>
diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx
index dfc2d53da5..0a54de39fc 100644
--- a/src/components/dms/DateDivider.tsx
+++ b/src/components/dms/DateDivider.tsx
@@ -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 (
-
+
{
a.px_md,
]}>
-
- {date}
- {' '}
- at {time}
+ {date} at {time}
diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx
index dda99c77e2..2460aa585d 100644
--- a/src/components/dms/MessageContextMenu.tsx
+++ b/src/components/dms/MessageContextMenu.tsx
@@ -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 = ({
<>
{IS_NATIVE && (
-
+
+ label={l`Message options`}
+ contentLabel={l`Message from @${
+ sender?.handle ?? 'unknown' // should always be defined
+ }: ${message.text}`}>
{children}
-
+
{message.text.length > 0 && (
<>
- {_(msg`Translate`)}
-
+ {l`Translate`}
+
- {_(msg`Copy message text`)}
+ {l`Copy message text`}
@@ -159,23 +160,22 @@ export let MessageContextMenu = ({
)}
deleteControl.open()}>
- {_(msg`Delete for me`)}
-
+ {l`Delete for me`}
+
{!isFromSelf && (
reportControl.open()}>
- {_(msg`Report`)}
-
+ {l`Report`}
+
)}
-
-
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index 386b85d7f9..adfc3e67b1 100644
--- a/src/components/dms/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -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 ? (
+
+ ) : (
+
+ )
+
+ 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 = (
- {message.reactions && message.reactions.length > 0 && (
-
+ {hasReactions ? (
+ <>
- {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}`)
- }
+
+ isGroupChat ? reactionsControl.open() : undefined
+ }>
+ {groupedReactions.map(group => (
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]}>
- {reaction.value}
+ {group.value}
- )
- })}
+ ))}
+ {groupedReactions.length !== reactions.length &&
+ reactions.length > 1 ? (
+
+
+ {reactions.length}
+
+
+ ) : null}
+
-
- )}
+
+ >
+ ) : null}
)
return (
<>
- {isNewDay && }
+ {showDateDivider && (
+
+
+
+ )}
-
- {AppBskyEmbedRecord.isView(message.embed) && (
-
- )}
- {rt.text.length > 0 && (
-
-
+
+ {isGroupChat && !isFromSelf && isLastInCluster ? (
+
+ {avatar}
- )}
-
- {IS_NATIVE && appliedReactions}
-
-
- {!IS_NATIVE && appliedReactions}
-
- {isLastInGroup && (
+ ) : null}
+
+ {isGroupChat &&
+ !isFromSelf &&
+ isFirstInCluster &&
+ !isOnlyEmoji(message.text) ? (
+
+ {displayName}
+
+ ) : null}
+
+ {rt.text.length > 0 && (
+
+
+
+ )}
+ {AppBskyEmbedRecord.isView(message.embed) && (
+
+ )}
+ {appliedReactions}
+
+
+
+ {isLastInCluster && (
)}
@@ -244,8 +451,7 @@ let MessageItemMetadata = ({
style: StyleProp
}): 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 (
-
-
- {({timeElapsed}) => (
-
- {timeElapsed}
-
- )}
-
-
- {item.type === 'pending-message' && item.failed && (
- <>
- {' '}
- ·{' '}
-
- {_(msg`Failed to send`)}
+ switch (item.type) {
+ case 'pending-message':
+ return item.failed ? (
+
+
+ Message failed to send.
{item.retry && (
<>
{' '}
- ·{' '}
- {_(msg`Retry`)}
+ style={[a.text_xs, {color: errorColor}]}>
+ Tap to retry
+ .
>
)}
- >
- )}
-
- )
+
+ ) : 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 (
+ setSelected('all')}
+ nativeOptions={{preventExpansion: true, minHeight}}>
+
+
+
+ Reactions
+
+
+
+
+ {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 ? (
+
+
+
+
+
+ {displayName}
+
+
+ {handle}
+
+
+
+
+
+
+
+ ) : null
+ })}
+
+
+ )
+}
+
+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 (
+
+ {
+ scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
+ }}>
+ {
+ contentSize.set(e.nativeEvent.layout.width)
+ }}>
+ {tabs?.map((reaction, index) => (
+
+ ))}
+
+
+
+ )
+}
+
+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 (
+ onPress(reaction.key)}>
+
+ {l`${reaction.value} ${reaction.count}`}
+
+
+ )
+}
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index 67f07dd4fc..ba48b6e123 100644
--- a/src/components/dms/MessageItemEmbed.tsx
+++ b/src/components/dms/MessageItemEmbed.tsx
@@ -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
+ isFromSelf: boolean
+ squaredTopCorner: boolean
+ squaredBottomCorner: boolean
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
@@ -18,7 +28,7 @@ let MessageItemEmbed = ({
-
+
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index 3f7694a342..4d7c2d7e33 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -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 (
-
+
@@ -72,19 +77,12 @@ export function MessagesListHeader({
-
@@ -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()
+
+ 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 (
-
-
-
-
-
- {displayName}
-
-
-
- {!isDeletedAccount && (
-
- @{profile.handle}
+ {isGroupChat ? (
+
+
+
+ {displayName}
+
+
+ ) : (
+
+
+
+
+
+ {displayName}
+
+
{convoState.convo?.muted && (
<>
- {' '}
- ·{' '}
-
+ {' '}
+ ·{' '}
+
+
>
)}
-
- )}
-
-
+
+
+
+ )}
- {isConvoActive(convoState) && (
-
- )}
+ {isConvoActive(convoState) ? (
+ isGroupChat ? (
+
+ ) : (
+
+ )
+ ) : null}
-
-
-
-
)
}
diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx
index 72b417665c..f0861baf45 100644
--- a/src/components/dms/dialogs/NewChatDialog.tsx
+++ b/src/components/dms/dialogs/NewChatDialog.tsx
@@ -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({
}
+ icon={}
accessibilityRole="button"
accessibilityLabel={l`New chat`}
accessibilityHint=""
diff --git a/src/components/icons/Message.tsx b/src/components/icons/Message.tsx
index e3ca70f01b..35d6deb222 100644
--- a/src/components/icons/Message.tsx
+++ b/src/components/icons/Message.tsx
@@ -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',
+})
diff --git a/src/components/images/Gallery/const.ts b/src/components/images/Gallery/const.ts
new file mode 100644
index 0000000000..443b65ecb8
--- /dev/null
+++ b/src/components/images/Gallery/const.ts
@@ -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
diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx
new file mode 100644
index 0000000000..7c3a84e9d0
--- /dev/null
+++ b/src/components/images/Gallery/index.tsx
@@ -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[],
+ fetchedDims: (Dimensions | null)[],
+ ) => void
+ onPressIn?: (index: number) => void
+ viewContext?: PostEmbedViewContext
+}
+
+const Context = createContext<{
+ bleedRef: React.RefObject
+ bleedWidth: number
+}>({
+ bleedRef: {current: null},
+ bleedWidth: 0,
+})
+
+export function GalleryBleed({children}: {children: React.ReactNode}) {
+ const ref = useRef(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
+
+ return (
+
+ {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],
+ })}
+
+ )
+}
+
+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(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(null)
+ const itemWidthsRef = useRef
-
+
+ {showProfileBadges && (
+
+ )}
+
{lastMessageSentAt && (
@@ -432,7 +580,7 @@ function ChatListItemReady({
)}
- {(convo.muted || moderation.blocked) && (
+ {(convo.muted || isBlockedAccount) && (
- {!isDeletedAccount && (
+ {subtitle && (
- @{profile.handle}
+ {subtitle}
)}
@@ -474,11 +622,7 @@ function ChatListItemReady({
{lastMessage}
-
+ {postAlerts}
{children}
@@ -509,7 +653,7 @@ function ChatListItemReady({
{showMenu && (
0}
@@ -529,6 +673,7 @@ function ChatListItemReady({
latestReportableMessage={latestReportableMessage}
/>
)}
+
void
+ onSendMessage: (message: string) => Promise | 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]}>
{
@@ -225,7 +224,7 @@ export function MessageInput({
}}>
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) => {
@@ -177,7 +176,7 @@ export function MessageInput({
width: 30,
},
]}
- label={_(msg`Open emoji picker`)}>
+ label={l`Open emoji picker`}>
{state => (
{
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 (
-
+
- {description} ·{' '}
+ {description}
{item.retry && (
- {
- e.preventDefault()
- item.retry?.()
- return false
- }}>
- {cta}
-
+ <>
+ ·{' '}
+ {
+ item.retry?.()
+ })}>
+ {cta}
+
+ >
)}
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index eda3c593d9..d55a361def 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -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
- } else if (item.type === 'deleted-message') {
- return Deleted message
- } else if (item.type === 'error') {
- return
- }
-
- 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>
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 (
+ member.did === item.message.sender.did,
+ )}
+ isGroupChat={convoState.getGroupInfo?.() != null}
+ />
+ )
+ } else if (item.type === 'deleted-message') {
+ return Deleted message
+ } else if (item.type === 'error') {
+ return
+ }
+
+ 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) => (
@@ -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 */}
@@ -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(
- ,
- )}
+ 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={
+
+ }
style={web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable both-edges',
})}
+ contentInset={{top: transparentHeaderHeight}}
+ scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
+ void onSendMessage(message)
+ }
hasEmbed={!!embedUri}
setEmbed={setEmbed}>
@@ -518,12 +573,6 @@ function ChatScrollComponent({
)
}
-function WebInputSpacer({inputHeight}: {inputHeight: number}) {
- if (!IS_WEB) return null
-
- return
-}
-
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
function getFooterState(
diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx
index 42574c7408..17c8e54be8 100644
--- a/src/screens/PostThread/components/ThreadItemAnchor.tsx
+++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx
@@ -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 (
<>
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ {sanitizeDisplayName(
+ post.author.displayName ||
+ sanitizeHandle(post.author.handle),
+ moderation.ui('displayName'),
+ )}
+
+
+
+
+
+
- {sanitizeDisplayName(
- post.author.displayName ||
- sanitizeHandle(post.author.handle),
- moderation.ui('displayName'),
- )}
+ {sanitizeHandle(post.author.handle, '@')}
-
-
-
-
-
-
- {sanitizeHandle(post.author.handle, '@')}
-
-
-
-
-
-
-
-
-
-
-
-
- {richText?.text ? (
-
- ) : undefined}
-
- {post.embed && (
-
-
+
- )}
-
-
- {post.repostCount !== 0 ||
- post.likeCount !== 0 ||
- post.quoteCount !== 0 ||
- post.bookmarkCount !== 0 ? (
- // Show this section unless we're *sure* it has no engagement.
+
+
+
+
+
+
+
+
+
+ {richText?.text ? (
+
+ ) : undefined}
+
+ {post.embed && (
+
+
+
+ )}
+
+
+ {post.repostCount !== 0 ||
+ post.likeCount !== 0 ||
+ post.quoteCount !== 0 ||
+ post.bookmarkCount !== 0 ? (
+ // Show this section unless we're *sure* it has no engagement.
+
+ {post.repostCount != null && post.repostCount !== 0 ? (
+
+
+
+
+ {formatPostStatCount(post.repostCount)}
+ {' '}
+
+
+
+
+ ) : null}
+ {post.quoteCount != null &&
+ post.quoteCount !== 0 &&
+ !post.viewer?.embeddingDisabled ? (
+
+
+
+
+ {formatPostStatCount(post.quoteCount)}
+ {' '}
+
+
+
+
+ ) : null}
+ {post.likeCount != null && post.likeCount !== 0 ? (
+
+
+
+
+ {formatPostStatCount(post.likeCount)}
+ {' '}
+
+
+
+
+ ) : null}
+ {post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
+
+
+
+ {formatPostStatCount(post.bookmarkCount)}
+ {' '}
+
+
+
+ ) : null}
+
+ ) : null}
- {post.repostCount != null && post.repostCount !== 0 ? (
-
-
-
-
- {formatPostStatCount(post.repostCount)}
- {' '}
-
-
-
-
- ) : null}
- {post.quoteCount != null &&
- post.quoteCount !== 0 &&
- !post.viewer?.embeddingDisabled ? (
-
-
-
-
- {formatPostStatCount(post.quoteCount)}
- {' '}
-
-
-
-
- ) : null}
- {post.likeCount != null && post.likeCount !== 0 ? (
-
-
-
-
- {formatPostStatCount(post.likeCount)}
- {' '}
-
-
-
-
- ) : null}
- {post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
-
-
-
- {formatPostStatCount(post.bookmarkCount)}
- {' '}
-
-
-
- ) : null}
+
+
+
- ) : null}
-
-
-
-
+
-
-
+
>
)
})
diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx
index 87aa551330..841c2af745 100644
--- a/src/screens/PostThread/components/ThreadItemPost.tsx
+++ b/src/screens/PostThread/components/ThreadItemPost.tsx
@@ -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 (
-
- {children}
-
+
+
+ {children}
+
+
)
})
@@ -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,
+ }),
+ ]}
/>
{post.embed && (
-
+
+
)
},
)
diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx
index ac0ad75483..9f6dc67d27 100644
--- a/src/screens/Search/Shell.tsx
+++ b/src/screens/Search/Shell.tsx
@@ -96,7 +96,12 @@ export function SearchScreenShell({
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
// Query terms
- const [searchText, setSearchText] = useState(queryParam)
+ const [searchText, _setSearchText] = useState(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) {
diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx
index 5acb50c4f5..c605a48f0b 100644
--- a/src/screens/Settings/AboutSettings.tsx
+++ b/src/screens/Settings/AboutSettings.tsx
@@ -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'
diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts
index b6c8ee2f16..ef2b251cce 100644
--- a/src/state/messages/convo/agent.ts
+++ b/src/state/messages/convo/agent.ts
@@ -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(
+ 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(
+ 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)
+ }
+ }
}
diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts
index 7053877935..d7adb51c6d 100644
--- a/src/state/messages/convo/types.ts
+++ b/src/state/messages/convo/types.ts
@@ -144,6 +144,9 @@ type FetchMessageHistory = () => Promise
type MarkConvoAccepted = () => void
type AddReaction = (messageId: string, reaction: string) => Promise
type RemoveReaction = (messageId: string, reaction: string) => Promise
+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
diff --git a/src/state/queries/messages/create-group-chat.ts b/src/state/queries/messages/create-group-chat.ts
new file mode 100644
index 0000000000..9f8aadc7d0
--- /dev/null
+++ b/src/state/queries/messages/create-group-chat.ts
@@ -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)
+ },
+ })
+}
diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts
index 08878d7fb5..d90ebb1b55 100644
--- a/src/state/queries/messages/mute-conversation.ts
+++ b/src/state/queries/messages/mute-conversation.ts
@@ -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'},
)
diff --git a/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts b/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts
index efd02a1dce..74c8886693 100644
--- a/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts
+++ b/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts
@@ -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)
diff --git a/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts b/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts
index eec392d4c9..e816f0cedb 100644
--- a/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts
+++ b/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts
@@ -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)
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index ceaa2fc395..f05ff232fb 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -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
}) => {
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 = ({
{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`}
{postUri && (
{
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 ? (
@@ -1264,21 +1266,17 @@ export const ComposePost = ({
{allPostsWithinLimit && (
)}
-
+
)}
@@ -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`}
/>
{canRemovePost && isActive && (
<>