Merge branch 'main' into app-2066

This commit is contained in:
vineyardbovines
2026-04-16 10:43:44 -04:00
75 changed files with 5305 additions and 1609 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z"/></svg>

After

Width:  |  Height:  |  Size: 448 B

+5 -1
View File
@@ -9,7 +9,11 @@ export function applyTheme(theme: 'light' | 'dark') {
document.documentElement.classList.add(theme)
}
export function initSystemColorMode() {
export function initSystemColorMode({additionalBodyClasses = ''} = {}) {
if (additionalBodyClasses) {
document.body.classList.add(additionalBodyClasses)
}
applyTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
+2 -2
View File
@@ -28,7 +28,7 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
const root = document.getElementById('app')
if (!root) throw new Error('No root element')
initSystemColorMode()
initSystemColorMode({additionalBodyClasses: 'dark:bg-dimmedBgDarken'})
const agent = new AtpAgent({
service: 'https://public.api.bsky.app',
@@ -119,7 +119,7 @@ function LandingPage() {
}, [uri])
return (
<main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:bg-dimmedBgDarken dark:text-slate-200">
<main className="w-full min-h-dvh flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:text-slate-200">
<Link
href="https://bsky.social/about"
className="transition-transform hover:scale-110">
+8
View File
@@ -250,6 +250,14 @@ export default defineConfig(
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
'@typescript-eslint/await-thenable': 'warn',
"no-restricted-imports": ["error", {
"paths": [{
"name": "react",
"importNames": ["React", "default"],
"message": "React is already in the global type namespace. Use named imports for runtime modules."
}]
}],
/**
* Turn off rules that we haven't enforced thus far
*/
+2 -2
View File
@@ -81,7 +81,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.19.8",
"@atproto/api": "^0.19.9",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7",
@@ -275,7 +275,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-native": "^5.0.0",
"eslint-plugin-react-native-a11y": "^3.5.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-simple-import-sort": "^13.0.0",
"file-loader": "6.2.0",
"globals": "^17.0.0",
"husky": "^8.0.3",
@@ -0,0 +1,48 @@
diff --git a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
index 24a25ae..2c5ff6d 100644
--- a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
+++ b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
@@ -1,8 +1,6 @@
import { useCallback } from "react";
-import { Platform } from "react-native";
import { scrollTo, useAnimatedReaction } from "react-native-reanimated";
-import { IS_FABRIC } from "../../../architecture";
import { isScrollAtEnd, shouldShiftContent } from "../useChatKeyboard/helpers";
import type { KeyboardLiftBehavior } from "../useChatKeyboard/types";
@@ -52,7 +50,6 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
scroll,
layout,
size,
- contentOffsetY,
inverted,
keyboardLiftBehavior,
freeze,
@@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
(target: number) => {
"worklet";
- if (contentOffsetY && IS_FABRIC) {
- // eslint-disable-next-line react-compiler/react-compiler
- contentOffsetY.value = target;
- } else if (Platform.OS === "android") {
- // Defer scrollTo so the animatedProps inset commit lands first;
- // otherwise the native ScrollView clamps to the old range.
- requestAnimationFrame(() => {
- scrollTo(scrollViewRef, 0, target, false);
- });
- } else {
+ // Always defer scrollTo so the animatedProps inset commit lands first;
+ // otherwise the native ScrollView clamps contentOffset to the old
+ // contentInset range (iOS Fabric) or the old contentInsetBottom (Android).
+ requestAnimationFrame(() => {
scrollTo(scrollViewRef, 0, target, false);
- }
+ });
},
- [scrollViewRef, contentOffsetY],
+ [scrollViewRef],
);
useAnimatedReaction(
+6
View File
@@ -78,6 +78,7 @@ import HashtagScreen from '#/screens/Hashtag'
import {LogScreen} from '#/screens/Log'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
@@ -568,6 +569,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesConversationScreen}
options={{title: title(msg`Chat`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesConversationSettings"
getComponent={() => MessagesConversationSettingsScreen}
options={{title: title(msg`Group chat settings`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesSettings"
getComponent={() => MessagesSettingsScreen}
@@ -20,8 +20,8 @@ import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAp
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
import {Full as Logo} from '#/components/icons/Logo'
+16
View File
@@ -2,6 +2,8 @@ import {useEffect, useRef} from 'react'
import {getCurrentState, onAppStateChange} from '#/lib/appState'
import {useAnalytics} from '#/analytics'
import {Features, features} from '#/analytics/features'
import {IS_DEV, IS_TESTFLIGHT} from '#/env'
/**
* Tracks passive analytics like app foreground/background time.
@@ -24,6 +26,20 @@ export function PassiveAnalytics() {
),
})
}
if (IS_DEV || IS_TESTFLIGHT) {
const feats = Object.values(Features).reduce(
(acc, feat) => {
acc[feat] = features.evalFeature(feat)
return acc
},
{} as Record<Features, any>,
)
ax.logger.info('FEATURES', {
features: feats,
definitions: features.getFeatures(),
})
}
})
return () => sub.remove()
}, [ax])
+1 -1
View File
@@ -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',
}
+18
View File
@@ -563,6 +563,9 @@ export type Events = {
| 'ChatsList'
| 'SendViaChatDialog'
}
'groupchat:create': {
logContext: 'NewChatDialog'
}
'starterPack:addUser': {
starterPack?: string
}
@@ -1043,4 +1046,19 @@ export type Events = {
'profile:associated:germ:click-self-info': {}
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}
// Gallery carousel events
'post:gallery:swipe': {
fromImage: number
toImage: number
totalImages: number
}
'post:gallery:openLightbox': {
fromImage: number
totalImages: number
}
'post:gallery:impression': {
totalImages: number
postUri: string
}
}
+260
View File
@@ -0,0 +1,260 @@
import {useCallback, useEffect} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import Animated, {
Easing,
interpolate,
useAnimatedStyle,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import type * as bsky from '#/types/bsky'
type Props = {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
size?: 'small' | 'medium' | 'large'
}
export function AvatarBubbles({
animate = false,
profiles: allProfiles,
size = 'large',
}: Props) {
const {currentAccount} = useSession()
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120
const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1
const marginOffset = size === 'small' || size === 'medium' ? -2 : 0
const initialValue = animate ? 0 : 1
const p0 = useSharedValue(initialValue)
const p1 = useSharedValue(initialValue)
const p2 = useSharedValue(initialValue)
const p3 = useSharedValue(initialValue)
const animateScale = (p: Animated.SharedValue<number>, index: number) => {
p.set(0)
p.set(() =>
withDelay(
500 + index * 100,
withTiming(1, {
duration: 250,
easing: Easing.out(Easing.back(1.75)),
}),
),
)
}
const playScaleAnimation = useCallback(() => {
animateScale(p0, 0)
animateScale(p1, 1)
animateScale(p2, 2)
animateScale(p3, 3)
}, [p0, p1, p2, p3])
useEffect(() => {
if (!animate) return
playScaleAnimation()
}, [animate, playScaleAnimation])
let avatars = (
<>
<AvatarBubble
profile={profiles[0] ?? allProfiles[0]}
scale={p0}
size={76}
x={-2}
y={-2}
style={[a.z_20]}
includeProfileBorder
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={76}
x={42}
y={42}
style={[a.z_10]}
includeProfileBorder
/>
</>
)
if (profiles.length === 3) {
avatars = (
<>
<AvatarBubble
profile={profiles[0]}
scale={p0}
size={68}
x={-2}
y={-2}
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={56}
x={38}
y={62}
/>
<AvatarBubble
profile={profiles[2]}
scale={p2}
size={46}
x={71}
y={18}
/>
</>
)
}
if (profiles.length >= 4) {
avatars = (
<>
<AvatarBubble
profile={profiles[0]}
scale={p0}
size={68}
x={-2}
y={-2}
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={56}
x={60}
y={49}
/>
<AvatarBubble
profile={profiles[2]}
scale={p2}
size={42}
x={14}
y={74}
/>
<AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} />
</>
)
}
return (
<Animated.View
style={[
a.p_2xs,
{
height: containerSize,
width: containerSize,
},
]}>
<View
style={[
{
marginTop: marginOffset,
marginLeft: marginOffset,
transform: [{scale}],
transformOrigin: 'top left',
},
]}>
{avatars}
</View>
</Animated.View>
)
}
function AvatarBubble({
profile,
scale,
size,
style,
x,
y,
includeProfileBorder,
}: {
profile?: bsky.profile.AnyProfileView
scale: Animated.SharedValue<number>
size: number
style?: StyleProp<ViewStyle>
x: number
y: number
includeProfileBorder?: boolean
}) {
const t = useTheme()
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{translateX: x},
{translateY: y},
{scale: interpolate(scale.get(), [0, 1], [0, 1])},
],
}))
return (
<Animated.View
style={[
a.absolute,
a.rounded_full,
a.flex_grow_0,
{transform: [{translateX: x}, {translateY: y}]},
includeProfileBorder && {
borderColor: t.atoms.text_inverted.color,
borderWidth: 2,
},
style,
animatedStyle,
]}>
{profile ? (
<Avatar profile={profile} size={size} />
) : (
<AvatarPlaceholder size={size} />
)}
</Animated.View>
)
}
function Avatar({
profile,
size = 76,
}: {
profile: bsky.profile.AnyProfileView
size?: number
}) {
return (
<UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
/>
)
}
function AvatarPlaceholder({size = 76}: {size?: number}) {
const t = useTheme()
return (
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_200,
{
width: size,
height: size,
},
]}>
<PersonIcon
width={size * 0.5}
height={size * 0.5}
fill={t.atoms.text_inverted.color}
/>
</View>
)
}
+6 -1
View File
@@ -482,7 +482,11 @@ function TriggerClone({
)
}
export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
export function AuxiliaryView({
children,
align = 'left',
style,
}: AuxiliaryViewProps) {
const context = useContextMenuContext()
const {width: screenWidth} = useWindowDimensions()
const {top: topInset} = useSafeAreaInsets()
@@ -556,6 +560,7 @@ export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
: {right: screenWidth - measurement.x - measurement.width},
animatedStyle,
a.z_20,
style,
]}>
{children}
</Animated.View>
+1
View File
@@ -21,6 +21,7 @@ export type {
export type AuxiliaryViewProps = {
children?: React.ReactNode
align?: 'left' | 'right'
style?: StyleProp<ViewStyle>
}
export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & {
+17
View File
@@ -12,8 +12,10 @@ import {useLightboxControls} from '#/state/lightbox'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
@@ -23,8 +25,10 @@ export function ImageEmbed({
}: CommonProps & {
embed: EmbedType<'images'>
}) {
const ax = useAnalytics()
const {openLightbox} = useLightboxControls()
const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
if (images.length > 0) {
const items = images.map(img => ({
@@ -95,6 +99,19 @@ export function ImageEmbed({
)
}
if (galleryEnabled) {
return (
<View style={[a.mt_sm, rest.style]}>
<Gallery
images={images}
onPress={onPress}
onPressIn={onPressIn}
viewContext={rest.viewContext}
/>
</View>
)
}
return (
<View style={[a.mt_sm, rest.style]}>
<ImageLayoutGrid
+42 -38
View File
@@ -19,6 +19,7 @@ import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {GalleryBleed} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText'
@@ -308,6 +309,7 @@ export function QuoteEmbed({
<Embed
embed={quote.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
@@ -319,43 +321,45 @@ export function QuoteEmbed({
)
return (
<View
style={[a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
activeStyle={[a.p_md, a.pt_sm]}
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && !linkDisabled && (
<SubtleHover
native
hover={hover || pressed}
style={[a.rounded_md]}
/>
)}
{linkDisabled ? (
<View style={[!active && a.p_md]} pointerEvents="none">
{contents}
</View>
) : (
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
{contents}
</Link>
)}
</>
)}
</ContentHider>
</View>
<GalleryBleed>
<View
style={[a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
activeStyle={[a.p_md, a.pt_sm]}
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && !linkDisabled && (
<SubtleHover
native
hover={hover || pressed}
style={[a.rounded_md]}
/>
)}
{linkDisabled ? (
<View style={[!active && a.p_md]} pointerEvents="none">
{contents}
</View>
) : (
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
{contents}
</Link>
)}
</>
)}
</ContentHider>
</View>
</GalleryBleed>
)
}
@@ -11,8 +11,8 @@ import {useLingui} from '@lingui/react/macro'
import {type Shadow} from '#/state/cache/post-shadow'
import {EventStopper} from '#/view/com/util/EventStopper'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {useMenuControl} from '#/components/Menu'
import * as Menu from '#/components/Menu'
import {useMenuControl} from '#/components/Menu'
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
import {PostMenuItems} from './PostMenuItems'
@@ -18,8 +18,8 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {EventStopper} from '#/view/com/util/EventStopper'
import {native} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
import {useMenuControl} from '#/components/Menu'
import * as Menu from '#/components/Menu'
import {useMenuControl} from '#/components/Menu'
import {useAnalytics} from '#/analytics'
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
import {ShareMenuItems} from './ShareMenuItems'
+31 -16
View File
@@ -109,21 +109,32 @@ export function FollowDialogWithoutGuide({
let lastSelectedInterest = ''
let lastSearchText = ''
const FOR_YOU_TAB = 'all'
function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const {t: l} = useLingui()
const ax = useAnalytics()
const interestsDisplayNames = useInterestsDisplayNames()
const rawInterestsDisplayNames = useInterestsDisplayNames()
const {data: preferences} = usePreferencesQuery()
const personalizedInterests = preferences?.interests?.tags
const interests = Object.keys(interestsDisplayNames)
.sort(boostInterests(popularInterests))
.sort(boostInterests(personalizedInterests))
const interests = useMemo(
() => [
FOR_YOU_TAB,
...Object.keys(rawInterestsDisplayNames)
.sort(boostInterests(popularInterests))
.sort(boostInterests(personalizedInterests)),
],
[rawInterestsDisplayNames, personalizedInterests],
)
const interestsDisplayNames = useMemo(
() => ({
[FOR_YOU_TAB]: l`For You`,
...rawInterestsDisplayNames,
}),
[l, rawInterestsDisplayNames],
)
const [selectedInterest, setSelectedInterest] = useState(
() =>
lastSelectedInterest ||
(personalizedInterests && interests.includes(personalizedInterests[0])
? personalizedInterests[0]
: interests[0]),
() => lastSelectedInterest || FOR_YOU_TAB,
)
const [searchText, setSearchText] = useState(lastSearchText)
const moderationOpts = useModerationOpts()
@@ -137,14 +148,15 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
lastSelectedInterest = selectedInterest
}, [searchText, selectedInterest])
const {
data: suggestions,
isFetching: isFetchingSuggestions,
error: suggestionsError,
} = useGetSuggestedUsersForSeeMoreQuery({
category: selectedInterest,
const isForYou = selectedInterest === FOR_YOU_TAB
const seeMoreQuery = useGetSuggestedUsersForSeeMoreQuery({
category: isForYou ? undefined : selectedInterest,
limit: 50,
})
const suggestions = seeMoreQuery.data
const isFetchingSuggestions = seeMoreQuery.isFetching
const suggestionsError = seeMoreQuery.error
const {
data: searchResults,
isFetching: isFetchingSearchResults,
@@ -277,7 +289,10 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
recId: recIdForLogging,
position: position !== -1 ? position : 0,
suggestedDid: item.profile.did,
category: selectedInterestRef.current,
category:
selectedInterestRef.current === FOR_YOU_TAB
? null
: selectedInterestRef.current,
})
}
}
+1 -1
View File
@@ -10,8 +10,8 @@ import {shareUrl} from '#/lib/sharing'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {type DialogControlProps} from '#/components/Dialog'
import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import {QrCode_Stroke2_Corner0_Rounded as QrCodeIcon} from '#/components/icons/QrCode'
+3 -4
View File
@@ -1,7 +1,6 @@
import {View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
@@ -15,7 +14,7 @@ export function ActionsWrapper({
isFromSelf: boolean
children: React.ReactNode
}) {
const {_} = useLingui()
const {t: l} = useLingui()
return (
<MessageContextMenu message={message}>
@@ -32,7 +31,7 @@ export function ActionsWrapper({
]}
accessible={true}
accessibilityActions={[
{name: 'activate', label: _(msg`Open message options`)},
{name: 'activate', label: l`Open message options`},
]}
onAccessibilityAction={() => trigger.control.open('full')}>
{children}
+9 -9
View File
@@ -25,9 +25,9 @@ import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {
@@ -95,7 +95,7 @@ let ConvoMenu = ({
shape="round"
variant="ghost"
style={[a.bg_transparent]}>
<ButtonIcon icon={DotsHorizontal} size="md" />
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
</Button>
)}
</Menu.Trigger>
@@ -220,9 +220,9 @@ function MenuContent({
}
if (userBlock) {
queueUnblock()
void queueUnblock()
} else {
queueBlock()
void queueBlock()
}
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
@@ -233,7 +233,7 @@ function MenuContent({
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
) : (
<>
@@ -245,7 +245,7 @@ function MenuContent({
<Menu.ItemText>
<Trans>Mark as read</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Bubble} />
<Menu.ItemIcon icon={BubbleIcon} />
</Menu.Item>
)}
<Menu.Item
@@ -296,7 +296,7 @@ function MenuContent({
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
</Menu.Group>
</>
+6 -12
View File
@@ -1,8 +1,6 @@
import {memo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {subDays} from 'date-fns'
import {atoms as a, useTheme} from '#/alf'
@@ -29,7 +27,7 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
})
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
let date: string
@@ -42,9 +40,9 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const oneWeekAgo = subDays(today, 7)
if (localDateString(today) === localDateString(timestamp)) {
date = _(msg`Today`)
date = l`Today`
} else if (localDateString(yesterday) === localDateString(timestamp)) {
date = _(msg`Yesterday`)
date = l`Yesterday`
} else {
if (timestamp < oneWeekAgo) {
if (timestamp.getFullYear() === today.getFullYear()) {
@@ -58,7 +56,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
}
return (
<View style={[a.w_full, a.my_lg]}>
<View style={[a.w_full, a.my_sm]}>
<Text
style={[
a.text_xs,
@@ -68,11 +66,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
a.px_md,
]}>
<Trans>
<Text
style={[a.text_xs, t.atoms.text_contrast_medium, a.font_semi_bold]}>
{date}
</Text>{' '}
at {time}
{date} at {time}
</Trans>
</Text>
</View>
+39 -42
View File
@@ -2,8 +2,7 @@ import {memo, useCallback} from 'react'
import {LayoutAnimation, Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
@@ -12,13 +11,14 @@ import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {BubbleQuestion_Stroke2_Corner0_Rounded as TranslateIcon} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
@@ -35,7 +35,7 @@ export let MessageContextMenu = ({
message: ChatBskyConvoDefs.MessageView
children: TriggerProps['children']
}): React.ReactNode => {
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
@@ -47,6 +47,7 @@ export let MessageContextMenu = ({
const translate = useGoogleTranslate()
const isFromSelf = message.sender?.did === currentAccount?.did
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
const onCopyMessage = useCallback(() => {
const str = richTextToString(
@@ -58,10 +59,10 @@ export let MessageContextMenu = ({
)
void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), {
Toast.show(l`Copied to clipboard`, {
type: 'success',
})
}, [_, message.text, message.facets])
}, [l, message.text, message.facets])
const onPressTranslateMessage = useCallback(() => {
void translate(message.text, langPrefs.primaryLanguage)
@@ -79,11 +80,9 @@ export let MessageContextMenu = ({
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
.then(() =>
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
)
.catch(() => Toast.show(_(msg`Failed to delete message`)))
}, [_, convo, message.id])
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
.catch(() => Toast.show(l`Failed to delete message`))
}, [l, convo, message.id])
const onEmojiSelect = useCallback(
(emoji: string) => {
@@ -96,17 +95,17 @@ export let MessageContextMenu = ({
) {
convo
.removeReaction(message.id, emoji)
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo.addReaction(message.id, emoji).catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), {
Toast.show(l`Failed to add emoji reaction`, {
type: 'error',
}),
)
}
},
[_, convo, message, currentAccount?.did],
[l, convo, message, currentAccount?.did],
)
const sender = convo.convo.members.find(
@@ -117,7 +116,9 @@ export let MessageContextMenu = ({
<>
<ContextMenu.Root>
{IS_NATIVE && (
<ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}>
<ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
<EmojiReactionPicker
message={message}
onEmojiSelect={onEmojiSelect}
@@ -126,31 +127,31 @@ export let MessageContextMenu = ({
)}
<ContextMenu.Trigger
label={_(msg`Message options`)}
contentLabel={_(
msg`Message from @${
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`,
)}>
label={l`Message options`}
contentLabel={l`Message from @${
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Outer align={isFromSelf ? 'right' : 'left'}>
<ContextMenu.Outer
align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && (
<>
<ContextMenu.Item
testID="messageDropdownTranslateBtn"
label={_(msg`Translate`)}
label={l`Translate`}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemText>{_(msg`Translate`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Translate} position="right" />
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={TranslateIcon} position="right" />
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={_(msg`Copy message text`)}
label={l`Copy message text`}
onPress={onCopyMessage}>
<ContextMenu.ItemText>
{_(msg`Copy message text`)}
{l`Copy message text`}
</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="right" />
</ContextMenu.Item>
@@ -159,23 +160,22 @@ export let MessageContextMenu = ({
)}
<ContextMenu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
label={l`Delete message for me`}
onPress={() => deleteControl.open()}>
<ContextMenu.ItemText>{_(msg`Delete for me`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Trash} position="right" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={TrashIcon} position="right" />
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
label={l`Report message`}
onPress={() => reportControl.open()}>
<ContextMenu.ItemText>{_(msg`Report`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Warning} position="right" />
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={WarningIcon} position="right" />
</ContextMenu.Item>
)}
</ContextMenu.Outer>
</ContextMenu.Root>
<ReportDialog
control={reportControl}
subject={{
@@ -198,14 +198,11 @@ export let MessageContextMenu = ({
message,
}}
/>
<Prompt.Basic
control={deleteControl}
title={_(msg`Delete message`)}
description={_(
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`,
)}
confirmButtonCta={_(msg`Delete`)}
title={l`Delete message`}
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
confirmButtonCta={l`Delete`}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
+589 -207
View File
@@ -1,13 +1,17 @@
import {memo, useCallback, useMemo} from 'react'
import {memo, useCallback, useMemo, useState} from 'react'
import {
type GestureResponderEvent,
Pressable,
type StyleProp,
type TextStyle,
View,
} from 'react-native'
import Animated, {
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
useSharedValue,
ZoomIn,
ZoomOut,
} from 'react-native-reanimated'
@@ -16,217 +20,420 @@ import {
ChatBskyConvoDefs,
RichText as RichTextAPI,
} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {atoms as a, native, useTheme} from '#/alf'
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky'
import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
import {localDateString} from './util'
const AVATAR_SIZE = 28
const CLUSTERED_MESSAGE_GAP = 2
const BORDER_RADIUS = 18
const SQUARED_BORDER_RADIUS = 4
const DISPLAY_NAME_INSET = 22
// 42px avatar + 2 * 8px my_sm margins
const ROW_HEIGHT = 58
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
type Reaction = {
key: string
value: string
senders: ChatBskyConvoDefs.ReactionViewSender[]
count: number
}
function isWithinCluster({
isPending,
adjacentMessage,
isFromSameSender,
currentSentAt,
direction,
}: {
isPending: boolean
adjacentMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
isFromSameSender: boolean
currentSentAt: string
direction: 'prev' | 'next'
}): boolean {
if (!isFromSameSender) return true
if (isPending && adjacentMessage) return false
if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) {
const thisDate = new Date(currentSentAt)
const adjDate = new Date(adjacentMessage.sentAt)
const diff =
direction === 'next'
? adjDate.getTime() - thisDate.getTime()
: thisDate.getTime() - adjDate.getTime()
return diff > CLUSTERED_MESSAGE_THRESHOLD_MS
}
return true
}
let MessageItem = ({
item,
isGroupChat = false,
profile,
}: {
item: ConvoItem & {type: 'message' | 'pending-message'}
isGroupChat?: boolean
profile?: bsky.profile.AnyProfileView
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
const {_} = useLingui()
const {t: l} = useLingui()
const {convo} = useConvoActive()
const moderationOpts = useModerationOpts()
const reactionsControl = useDialogControl()
const {message, nextMessage, prevMessage} = item
const isPending = item.type === 'pending-message'
const displayName = sanitizeDisplayName(
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
)
const isFromSelf = message.sender?.did === currentAccount?.did
const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage)
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
const isNextFromSelf =
nextIsMessage && nextMessage.sender?.did === currentAccount?.did
const isPrevFromSameSender =
prevIsMessage && prevMessage.sender?.did === message.sender?.did
const isNextFromSameSender =
nextIsMessage && nextMessage.sender?.did === message.sender?.did
const isNextFromSameSender = isNextFromSelf === isFromSelf
const isFirstInCluster = useMemo(
() =>
isWithinCluster({
isPending,
adjacentMessage: prevMessage,
isFromSameSender: isPrevFromSameSender,
currentSentAt: message.sentAt,
direction: 'prev',
}),
[isPending, prevMessage, isPrevFromSameSender, message.sentAt],
)
const isNewDay = useMemo(() => {
if (!prevMessage) return true
const isLastInCluster = useMemo(
() =>
isWithinCluster({
isPending,
adjacentMessage: nextMessage,
isFromSameSender: isNextFromSameSender,
currentSentAt: message.sentAt,
direction: 'next',
}),
[isPending, nextMessage, isNextFromSameSender, message.sentAt],
)
const thisDate = new Date(message.sentAt)
const prevDate = new Date(prevMessage.sentAt)
const hasLargeGapFromPrev =
!ChatBskyConvoDefs.isMessageView(prevMessage) ||
new Date(message.sentAt).getTime() -
new Date(prevMessage.sentAt).getTime() >
MESSAGE_GAP_THRESHOLD_MS
return localDateString(thisDate) !== localDateString(prevDate)
}, [message, prevMessage])
const showDateDivider = hasLargeGapFromPrev
const isLastMessageOfDay = useMemo(() => {
if (!nextMessage || !nextIsMessage) return true
const isInCluster = !(isFirstInCluster && isLastInCluster)
const isInMiddleOfCluster =
isInCluster && !isFirstInCluster && !isLastInCluster
const thisDate = new Date(message.sentAt)
const prevDate = new Date(nextMessage.sentAt)
const hasReactions = message.reactions && message.reactions.length > 0
const squaredBottomCorner =
!hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster)
const squaredTopCorner =
isInCluster && (isInMiddleOfCluster || isLastInCluster)
return localDateString(thisDate) !== localDateString(prevDate)
}, [message.sentAt, nextIsMessage, nextMessage])
const needsTail = isLastMessageOfDay || !isNextFromSameSender
const isLastInGroup = useMemo(() => {
// if this message is pending, it means the next message is pending too
if (isPending && nextMessage) {
return false
}
// or, if there's a 5 minute gap between this message and the next
if (ChatBskyConvoDefs.isMessageView(nextMessage)) {
const thisDate = new Date(message.sentAt)
const nextDate = new Date(nextMessage.sentAt)
const diff = nextDate.getTime() - thisDate.getTime()
// 5 minutes
return diff > 5 * 60 * 1000
}
return true
}, [message, nextMessage, isPending])
const pendingColor = t.palette.primary_200
const pendingColor = t.palette.primary_300
const rt = useMemo(() => {
return new RichTextAPI({text: message.text, facets: message.facets})
}, [message.text, message.facets])
const hasEmbedAndText =
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
const avatar = profile ? (
<ProfileCard.Avatar
profile={profile}
size={AVATAR_SIZE}
moderationOpts={moderationOpts!}
disabledPreview
/>
) : (
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
)
const groupedReactions = useMemo(() => {
const reactions = message.reactions ?? []
const grouped = new Map<
string,
{
key: string
value: string
senders: ChatBskyConvoDefs.ReactionViewSender[]
count: number
}
>()
for (const reaction of reactions) {
if (!reaction) continue
const existing = grouped.get(reaction.value)
if (existing) {
existing.senders.push(reaction.sender)
existing.count++
} else {
grouped.set(reaction.value, {
key: reaction.value,
value: reaction.value,
senders: [reaction.sender],
count: 1,
})
}
}
return Array.from(grouped.values())
}, [message.reactions])
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
const reactionsLabel = useMemo(() => {
if (reactions.length === 0) return ''
if (reactions.length === 1) {
const reaction = reactions[0]
const sender = reaction.sender
if (sender.did === currentAccount?.did) {
return l`You reacted ${reaction.value}`
} else {
const senderDid = reaction.sender.did
const sender = convo.members.find(member => member.did === senderDid)
if (sender) {
return l`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${reaction.value}`
}
return l`Someone reacted ${reaction.value}`
}
}
return l`${plural(reactions.length, {
one: '# person',
other: '# people',
})} reacted ${groupedReactions.map(g => g.value).join(' ')}`
}, [reactions, groupedReactions, currentAccount?.did, convo.members, l])
const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting>
{message.reactions && message.reactions.length > 0 && (
<View
style={[isFromSelf ? a.align_end : a.align_start, a.px_sm, a.pb_2xs]}>
{hasReactions ? (
<>
<View
style={[
a.flex_row,
a.gap_2xs,
a.py_xs,
a.px_xs,
a.justify_center,
isFromSelf ? a.justify_end : a.justify_start,
a.flex_wrap,
a.pb_xs,
t.atoms.bg_contrast_25,
a.border,
t.atoms.border_contrast_low,
a.rounded_lg,
t.atoms.shadow_sm,
{
// vibe coded number
transform: [{translateY: -11}],
},
isFromSelf ? a.align_end : a.align_start,
a.px_sm,
a.pb_2xs,
]}>
{message.reactions.map((reaction, _i, reactions) => {
let label
if (reaction.sender.did === currentAccount?.did) {
label = _(msg`You reacted ${reaction.value}`)
} else {
const senderDid = reaction.sender.did
const sender = convo.members.find(
member => member.did === senderDid,
)
if (sender) {
label = _(
msg`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${reaction.value}`,
)
} else {
label = _(msg`Someone reacted ${reaction.value}`)
}
<Pressable
accessible={true}
accessibilityLabel={reactionsLabel}
accessibilityHint={
isGroupChat ? l`Tap to view reactions` : undefined
}
return (
style={[
a.flex_row,
a.gap_2xs,
a.py_xs,
a.px_xs,
isFromSelf ? a.justify_end : a.justify_start,
a.flex_wrap,
a.rounded_lg,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
t.atoms.shadow_sm,
{
transform: [{translateY: -8}],
},
]}
onPress={() =>
isGroupChat ? reactionsControl.open() : undefined
}>
{groupedReactions.map(group => (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
exiting={reactions.length > 1 && native(ZoomOut.delay(200))}
exiting={
groupedReactions.length > 1 && native(ZoomOut.delay(200))
}
layout={native(LinearTransition.delay(300))}
key={reaction.sender.did + reaction.value}
style={[a.p_2xs]}
accessible={true}
accessibilityLabel={label}
accessibilityHint={_(
msg`Double tap or long press the message to add a reaction`,
)}>
key={group.value}
style={[a.p_2xs]}>
<Text emoji style={[a.text_sm]}>
{reaction.value}
{group.value}
</Text>
</Animated.View>
)
})}
))}
{groupedReactions.length !== reactions.length &&
reactions.length > 1 ? (
<View style={[a.p_2xs, a.justify_center]}>
<Text
style={[
a.text_xs,
t.atoms.text_contrast_medium,
{includeFontPadding: false},
]}>
{reactions.length}
</Text>
</View>
) : null}
</Pressable>
</View>
</View>
)}
<ReactionsDialog
control={reactionsControl}
members={convo.members}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</>
) : null}
</LayoutAnimationConfig>
)
return (
<>
{isNewDay && <DateDivider date={message.sentAt} />}
{showDateDivider && (
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
<DateDivider date={message.sentAt} />
</Animated.View>
)}
<View
style={[
isFromSelf ? a.mr_md : a.ml_md,
nextIsMessage && !isNextFromSameSender && a.mb_md,
isFromSelf ? a.mr_sm : a.ml_sm,
isFirstInCluster && !showDateDivider && a.mt_sm,
]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed embed={message.embed} />
)}
{rt.text.length > 0 && (
<View
style={
!isOnlyEmoji(message.text) && [
a.py_sm,
a.my_2xs,
a.rounded_md,
{
paddingLeft: 14,
paddingRight: 14,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf ? a.self_end : a.self_start,
isFromSelf
? {borderBottomRightRadius: needsTail ? 2 : 17}
: {borderBottomLeftRadius: needsTail ? 2 : 17},
]
}>
<RichText
value={rt}
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
shouldProxyLinks={true}
/>
<View style={[a.relative]}>
{isGroupChat && !isFromSelf && isLastInCluster ? (
<View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}>
{avatar}
</View>
)}
{IS_NATIVE && appliedReactions}
</ActionsWrapper>
{!IS_NATIVE && appliedReactions}
{isLastInGroup && (
) : null}
<View
style={[
a.flex_grow,
!isFromSelf &&
isGroupChat && {
paddingLeft: AVATAR_SIZE,
},
]}>
{isGroupChat &&
!isFromSelf &&
isFirstInCluster &&
!isOnlyEmoji(message.text) ? (
<Text
style={[
a.text_xs,
t.atoms.text_contrast_medium,
a.pt_xs,
a.pb_2xs,
{
paddingLeft: DISPLAY_NAME_INSET,
},
]}>
{displayName}
</Text>
) : null}
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
{rt.text.length > 0 && (
<View
accessibilityHint={l`Double tap or long press the message to add a reaction`}
style={[
!isFromSelf && a.ml_sm,
...(isOnlyEmoji(message.text)
? []
: [
a.rounded_md,
a.rounded_xl,
a.py_sm,
a.px_md,
{
marginTop: isFirstInCluster
? 0
: CLUSTERED_MESSAGE_GAP,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
},
isFromSelf ? a.self_end : a.self_start,
isFromSelf
? {
borderBottomRightRadius:
squaredBottomCorner || hasEmbedAndText
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopRightRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
}
: {
borderBottomLeftRadius:
squaredBottomCorner || hasEmbedAndText
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopLeftRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
},
]),
]}>
<RichText
value={rt}
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
shouldProxyLinks={true}
/>
</View>
)}
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed
embed={message.embed}
isFromSelf={isFromSelf}
squaredBottomCorner={squaredBottomCorner}
squaredTopCorner={squaredTopCorner || hasEmbedAndText}
/>
)}
{appliedReactions}
</ActionsWrapper>
</View>
</View>
{isLastInCluster && (
<MessageItemMetadata
item={item}
style={isFromSelf ? a.text_right : a.text_left}
style={[isFromSelf ? a.text_right : a.text_left]}
/>
)}
</View>
@@ -244,8 +451,7 @@ let MessageItemMetadata = ({
style: StyleProp<TextStyle>
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {message} = item
const {t: l} = useLingui()
const handleRetry = useCallback(
(e: GestureResponderEvent) => {
@@ -258,75 +464,251 @@ let MessageItemMetadata = ({
[item],
)
const relativeTimestamp = useCallback(
(i18n: I18n, timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const errorColor = t.palette.negative_400
const time = i18n.date(date, {
hour: 'numeric',
minute: 'numeric',
})
const diff = now.getTime() - date.getTime()
// if under 30 seconds
if (diff < 1000 * 30) {
return _(msg`Now`)
}
return time
},
[_],
)
return (
<Text
style={[
a.text_xs,
a.mt_2xs,
a.mb_lg,
t.atoms.text_contrast_medium,
style,
]}>
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
{({timeElapsed}) => (
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
{item.type === 'pending-message' && item.failed && (
<>
{' '}
&middot;{' '}
<Text
style={[
a.text_xs,
{
color: t.palette.negative_400,
},
]}>
{_(msg`Failed to send`)}
switch (item.type) {
case 'pending-message':
return item.failed ? (
<Text style={[a.text_xs, a.my_2xs, {color: errorColor}, style]}>
<Text style={[a.text_xs, {color: errorColor}]}>
<Trans>Message failed to send.</Trans>
</Text>
{item.retry && (
<>
{' '}
&middot;{' '}
<InlineLinkText
label={_(msg`Click to retry failed message`)}
label={l`Click to retry failed message`}
to="#"
onPress={handleRetry}
style={[a.text_xs]}>
{_(msg`Retry`)}
style={[a.text_xs, {color: errorColor}]}>
<Trans>Tap to retry</Trans>
</InlineLinkText>
.
</>
)}
</>
)}
</Text>
)
</Text>
) : null
default:
return null
}
}
MessageItemMetadata = memo(MessageItemMetadata)
export {MessageItemMetadata}
function ReactionsDialog({
control,
members,
reactions,
groupedReactions,
}: {
control: Dialog.DialogControlProps
members: bsky.profile.AnyProfileView[]
reactions?: ChatBskyConvoDefs.ReactionView[]
groupedReactions?: Reaction[]
}) {
const t = useTheme()
const {t: l} = useLingui()
const [selected, setSelected] = useState('all')
const handleFilter = (value: string) => {
setSelected(value)
}
const filteredMembers =
selected === 'all'
? members
: members.filter(m =>
reactions?.some(r => r.sender.did === m.did && r.value === selected),
)
const minHeight = members.length * ROW_HEIGHT
return (
<Dialog.Outer
control={control}
onClose={() => setSelected('all')}
nativeOptions={{preventExpansion: true, minHeight}}>
<Dialog.Handle />
<View style={[a.px_2xl, a.pt_3xl, t.atoms.bg]}>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>
<Trans>Reactions</Trans>
</Text>
</View>
<ReactionTabs
groupedReactions={groupedReactions}
selected={selected}
totalReactions={reactions?.length ?? 0}
onFilter={handleFilter}
/>
<Dialog.ScrollableInner
label={l`Reactions`}
contentContainerStyle={[a.pt_0]}
style={[web({maxWidth: 400})]}>
{filteredMembers.map(profile => {
const displayName = sanitizeDisplayName(
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
)
const handle = sanitizeHandle(profile?.handle ?? '', '@')
const reaction = reactions?.find(
({sender}) => sender.did === profile.did,
)
const rt = reaction
? new RichTextAPI({text: reaction.value})
: undefined
return rt ? (
<View
key={profile.did}
style={[
a.flex_row,
a.gap_sm,
a.align_center,
a.justify_between,
a.my_sm,
]}>
<View style={[a.flex_row, a.gap_sm]}>
<UserAvatar
avatar={profile.avatar}
size={42}
type="user"
hideLiveBadge
/>
<View>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{displayName}
</Text>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{handle}
</Text>
</View>
</View>
<View>
<RichText
value={rt}
style={[a.text_md]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={2}
shouldProxyLinks={true}
/>
</View>
</View>
) : null
})}
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function ReactionTabs({
groupedReactions,
selected,
totalReactions,
onFilter,
}: {
groupedReactions?: Reaction[]
selected: string
totalReactions: number
onFilter: (value: string) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const contentSize = useSharedValue(0)
const scrollX = useSharedValue(0)
const handlePress = (value: string) => {
onFilter(value)
}
const tabs = [
{
key: 'all',
value: l`All`,
senders: [],
count: totalReactions,
} as Reaction,
...(groupedReactions ?? []),
]
return (
<View accessibilityRole="list" style={[t.atoms.bg]}>
<DraggableScrollView
horizontal={true}
showsHorizontalScrollIndicator={false}
onScroll={e => {
scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
}}>
<Animated.View
style={[
a.flex_row,
a.flex_grow,
a.gap_sm,
a.align_center,
a.justify_start,
]}
onLayout={e => {
contentSize.set(e.nativeEvent.layout.width)
}}>
{tabs?.map((reaction, index) => (
<ReactionTab
key={reaction.value}
index={index}
reaction={reaction}
selected={selected}
total={tabs.length}
onPress={handlePress}
/>
))}
</Animated.View>
</DraggableScrollView>
</View>
)
}
function ReactionTab({
index,
reaction,
selected,
total,
onPress,
}: {
index: number
reaction: Reaction
selected: string
total: number
onPress: (value: string) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
return (
<Pressable
accessibilityRole="button"
accessibilityHint={
reaction.key === 'all'
? l`Tap to show all reactions `
: l`Tap to show ${reaction.value} reactions`
}
hitSlop={HITSLOP_10}
style={[
a.flex_row,
a.align_center,
a.border,
a.justify_center,
a.rounded_lg,
a.px_md,
a.py_sm,
a.mb_sm,
t.atoms.border_contrast_low,
selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
]}
onPress={() => onPress(reaction.key)}>
<Text emoji style={[a.text_sm]}>
{l`${reaction.value} ${reaction.count}`}
</Text>
</Pressable>
)
}
+39 -3
View File
@@ -2,14 +2,24 @@ import {memo} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {MessageContextProvider} from './MessageContext'
const CLUSTERED_MESSAGE_GAP = 2
const BORDER_RADIUS = 20
const SQUARED_BORDER_RADIUS = 4
let MessageItemEmbed = ({
embed,
isFromSelf,
squaredTopCorner,
squaredBottomCorner,
}: {
embed: $Typed<AppBskyEmbedRecord.View>
isFromSelf: boolean
squaredTopCorner: boolean
squaredBottomCorner: boolean
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
@@ -18,7 +28,7 @@ let MessageItemEmbed = ({
<MessageContextProvider>
<View
style={[
a.my_xs,
isFromSelf ? a.mr_sm : a.ml_sm,
t.atoms.bg,
a.rounded_md,
native({
@@ -30,12 +40,38 @@ let MessageItemEmbed = ({
minWidth: 280,
maxWidth: 360,
}),
{
marginTop: CLUSTERED_MESSAGE_GAP,
},
]}>
<View style={{marginTop: tokens.space.sm * -1}}>
<View style={{marginTop: -8}}>
<Embed
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
style={[
a.rounded_xl,
a.border_0,
isFromSelf
? {
backgroundColor: t.palette.primary_50,
borderBottomRightRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopRightRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
}
: {
backgroundColor: t.palette.contrast_50,
borderBottomLeftRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopLeftRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
},
]}
/>
</View>
</View>
+102 -89
View File
@@ -5,24 +5,29 @@ import {
type ModerationCause,
type ModerationDecision,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon} from '#/components/Button'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
@@ -48,7 +53,7 @@ export function MessagesListHeader({
}, [moderation])
return (
<Layout.Header.Outer>
<Layout.Header.Outer noBottomBorder={IS_LIQUID_GLASS}>
<View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}>
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.BackButton />
@@ -72,19 +77,12 @@ export function MessagesListHeader({
<View style={a.gap_xs}>
<View
style={[
{width: 120, height: 16},
{width: 150, height: 16},
a.rounded_xs,
t.atoms.bg_contrast_25,
a.mt_xs,
]}
/>
<View
style={[
{width: 175, height: 12},
a.rounded_xs,
t.atoms.bg_contrast_25,
]}
/>
</View>
</View>
@@ -108,22 +106,27 @@ function HeaderReady({
userBlock?: ModerationCause
}
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const convoState = useConvo()
const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>()
const groupInfo = convoState.getGroupInfo?.()
const isGroupChat = groupInfo != null
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
? _(msg`Deleted Account`)
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
const displayName = isGroupChat
? (groupInfo.name ?? l`${profile.handle}'s group chat`)
: isDeletedAccount
? l`Deleted Account`
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
// @ts-ignore findLast is polyfilled - esb
const latestMessageFromOther = convoState.items.findLast(
(item: ConvoItem) =>
item.type === 'message' && item.message.sender.did === profile.did,
item.type === 'message' &&
item.message.sender.did !== currentAccount?.did,
)
const latestReportableMessage =
@@ -131,85 +134,95 @@ function HeaderReady({
? latestMessageFromOther.message
: undefined
const handleNavigateToSettings = () => {
const convoId = convoState.convo?.id
if (convoId) {
navigation.navigate('MessagesConversationSettings', {
conversation: convoId,
})
} else {
logger.error(`handleNavigateToSettings: missing convo ID`)
}
}
return (
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
<Link
label={_(msg`View ${displayName}'s profile`)}
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}>
{displayName}
</Text>
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
</View>
{!isDeletedAccount && (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_xs,
web([a.leading_normal, {marginTop: -2}]),
]}
numberOfLines={1}>
@{profile.handle}
{isGroupChat ? (
<View
style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}>
<AvatarBubbles
size="small"
profiles={convoState.recipients ?? []}
/>
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
{displayName}
</Text>
</View>
) : (
<Link
label={l`View ${displayName}'s profile`}
style={[a.flex_row, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[a.text_md, a.font_semi_bold, a.self_start]}
numberOfLines={1}>
{displayName}
</Text>
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
{convoState.convo?.muted && (
<>
{' '}
&middot;{' '}
<BellStroke
size="xs"
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
{' '}
&middot;{' '}
</Text>
<BellOffIcon
size="sm"
style={t.atoms.text_contrast_medium}
/>
</>
)}
</Text>
)}
</View>
</Link>
</View>
</View>
</Link>
)}
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.Slot>
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)}
{isConvoActive(convoState) ? (
isGroupChat ? (
<Button
label={l`Open group chat settings`}
size="small"
color="secondary"
shape="round"
variant="ghost"
style={[a.bg_transparent]}
onPress={handleNavigateToSettings}>
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
</Button>
) : (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)
) : null}
</Layout.Header.Slot>
</View>
</View>
<View
style={[
{
paddingLeft: PFP_SIZE + a.gap_md.gap,
},
]}>
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
</View>
</View>
)
}
+25 -6
View File
@@ -3,13 +3,14 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {logger} from '#/logger'
import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {FAB} from '#/view/com/util/fab/FAB'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {MessagePlus_Stroke2_Corner0_Rounded as NewChatIcon} from '#/components/icons/Message'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
@@ -38,12 +39,28 @@ export function NewChat({
},
onError: error => {
logger.error('Failed to create chat', {safeMessage: error})
Toast.show(l`An issue occurred starting the chat`, {
Toast.show(l`An issue occurred starting the chat, please try again`, {
type: 'error',
})
},
})
const {mutate: createGroupChat} = useCreateGroupChat({
onSuccess: data => {
onNewChat(data.convo.id)
ax.metric('groupchat:create', {logContext: 'NewChatDialog'})
},
onError: error => {
logger.error('Failed to create groupchat', {safeMessage: error})
Toast.show(
l`An issue occurred creating the group chat, please try again`,
{
type: 'error',
},
)
},
})
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
@@ -52,10 +69,12 @@ export function NewChat({
)
const onCreateGroupChat = useCallback(
(_dids: string[], _groupName: string) => {
control.close()
(members: string[], name: string) => {
control.close(() => {
createGroupChat({members, name})
})
},
[control],
[control, createGroupChat],
)
const onPress = useCallback(() => {
@@ -74,7 +93,7 @@ export function NewChat({
<FAB
testID="newChatFAB"
onPress={wrappedOnPress}
icon={<Plus size="lg" fill={t.palette.white} />}
icon={<NewChatIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={l`New chat`}
accessibilityHint=""
+4
View File
@@ -15,3 +15,7 @@ export const Message_Stroke2_Corner0_Rounded_Filled = createSinglePathSVG({
export const Message_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 12a8 8 0 1 1 4.445 7.169 1 1 0 0 0-.629-.088l-3.537.662.7-3.415a1 1 0 0 0-.09-.66A7.961 7.961 0 0 1 4 12Zm8-10C6.477 2 2 6.477 2 12c0 1.523.341 2.968.951 4.262l-.93 4.537a1 1 0 0 0 1.163 1.184l4.68-.876A9.968 9.968 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2ZM7.5 13.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
})
export const MessagePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z',
})
+3
View File
@@ -0,0 +1,3 @@
export const ITEM_GAP = 8 // tokens.space.sm
export const MIN_ASPECT_RATIO = 2 / 3 // portrait limit
export const MAX_ASPECT_RATIO = 3 / 2 // landscape limit
+531
View File
@@ -0,0 +1,531 @@
import {
cloneElement,
createContext,
isValidElement,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
import Animated, {
type AnimatedRef,
useAnimatedRef,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {utils} from '@bsky.app/alf'
import {Trans, useLingui} from '@lingui/react/macro'
import debounce from 'lodash.debounce'
import {type Dimensions} from '#/lib/media/types'
import {mergeRefs} from '#/lib/merge-refs'
import {useA11y} from '#/state/a11y'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {
ITEM_GAP,
MAX_ASPECT_RATIO,
MIN_ASPECT_RATIO,
} from '#/components/images/Gallery/const'
import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers'
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
import {getAspectRatio} from '#/components/images/Gallery/utils'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
export * from './const'
export * from './maybeApplyGalleryOffsetStyles'
interface GalleryProps {
images: AppBskyEmbedImages.ViewImage[]
onPress?: (
index: number,
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
}
const Context = createContext<{
bleedRef: React.RefObject<View | null>
bleedWidth: number
}>({
bleedRef: {current: null},
bleedWidth: 0,
})
export function GalleryBleed({children}: {children: React.ReactNode}) {
const ref = useRef<View>(null)
const [bleedWidth, setBleedWidth] = useState(0)
if (!isValidElement(children)) {
throw new Error('GalleryBleed children must be a single React element')
}
const node = children as React.ReactElement<any>
return (
<Context.Provider value={{bleedRef: ref, bleedWidth}}>
{cloneElement(node, {
ref: mergeRefs([ref, node?.props?.ref]),
onLayout: (e: {nativeEvent: {layout: {width: number}}}) => {
setBleedWidth(e.nativeEvent.layout.width)
node.props.onLayout?.(e)
},
style: [node.props.style, a.overflow_hidden],
})}
</Context.Provider>
)
}
export function useGalleryBleed() {
return useContext(Context)
}
export function Gallery({
images,
onPress,
onPressIn,
viewContext,
}: GalleryProps) {
const {t: l} = useLingui()
const ax = useAnalytics()
const {screenReaderEnabled} = useA11y()
const largeAltBadge = useLargeAltBadgeEnabled()
const bps = useBreakpoints()
const window = useWindowDimensions()
const contentHeight = useMemo(() => {
if (bps.gtMobile) {
return 300
} else if (bps.gtPhone) {
return 260
} else {
return 200
}
}, [bps])
const isWithinQuote =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const hideBadges = isWithinQuote
/*
* Container overflow styles
*
* Uses measureLayout to get the Gallery's offset relative to the GalleryBleed
* ancestor. This is a layout-relative measurement that doesn't depend on
* scroll position, so it works correctly for off-screen FlatList items.
*/
const {bleedRef, bleedWidth} = useGalleryBleed()
const contentRef = useRef<View>(null)
const [contentDims, setContentDims] = useState<{x: number; width: number}>()
const measure = () => {
if (contentRef.current && bleedRef.current) {
contentRef.current.measureLayout(
bleedRef.current,
(x, _y, w) => {
setContentDims({x, width: w})
},
() => {},
)
}
}
const width = bleedWidth || Math.min(600, window.width)
const insetLeft = contentDims?.x ?? 0
const insetRight =
bleedWidth > 0
? bleedWidth - (contentDims?.x ?? 0) - (contentDims?.width ?? 0)
: 0
/* End container overflow styles */
const flatListRef = useRef<FlatList>(null)
const itemWidthsRef = useRef<Map<number, number>>(new Map())
const itemRefsRef = useRef<Map<number, View>>(new Map())
const containerRefsRef = useRef<Map<number, AnimatedRef<any>>>(new Map())
const thumbDimsRef = useRef<Map<number, Dimensions>>(new Map())
const currentIndexRef = useRef(0)
const emitSwipeMetric = useMemo(
() =>
debounce((fromIndex: number, toIndex: number) => {
ax.metric('post:gallery:swipe', {
fromImage: fromIndex + 1, // convert to 1-based index for easier analysis
toImage: toIndex + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
}, 200),
[ax, images.length],
)
const setCurrentIndex = (index: number) => {
const prev = currentIndexRef.current
if (prev !== index) {
currentIndexRef.current = index
emitSwipeMetric(prev, index)
}
}
const scrollTo = (offset: number) => {
flatListRef.current?.scrollToOffset({offset, animated: false})
}
const onSettle = (index: number) => {
setCurrentIndex(index)
if (!IS_WEB) return
// Update tabIndex: only the active image is tab-focusable
itemRefsRef.current.forEach((node, i) => {
const el = node as unknown as HTMLElement
el.tabIndex = i === index ? 0 : -1
})
const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null
el?.focus({preventScroll: true})
}
useKeyboardHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount: images.length,
})
usePointerHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount: images.length,
})
if (screenReaderEnabled) {
return (
<View style={[a.relative, a.gap_sm]}>
{images.map((image, index) => (
<AutoSizedImage
key={image.thumb + index}
crop={
viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) =>
onPress?.(index, [containerRef], [dims])
}
onPressIn={() => onPressIn?.(index)}
hideBadge={
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
))}
</View>
)
}
return (
<View
ref={contentRef}
style={[
a.w_full,
{
height: contentHeight,
overflow: 'visible',
},
]}
onLayout={measure}>
<BlockDrawerGesture>
<FlatList
ref={flatListRef}
role="group"
aria-roledescription={l`carousel`}
aria-label={l`Image gallery, ${images.length} images`}
horizontal
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
decelerationRate={0.993}
directionalLockEnabled
nestedScrollEnabled
alwaysBounceVertical={false}
scrollEventThrottle={16}
data={images}
keyExtractor={(item, index) => item.thumb + index}
renderItem={({item, index}) => {
return (
<GalleryImage
hideBadges={hideBadges}
largeAltBadge={largeAltBadge}
image={item}
contentHeight={contentHeight}
index={index}
imageCount={images.length}
onWidthChange={(i, w) => {
itemWidthsRef.current.set(i, w)
}}
itemRef={node => {
if (node) {
itemRefsRef.current.set(index, node)
} else {
itemRefsRef.current.delete(index)
}
}}
onContainerRef={(i, ref) => {
containerRefsRef.current.set(i, ref)
}}
onThumbDims={(i, dims) => {
thumbDimsRef.current.set(i, dims)
}}
onPress={
onPress
? () => {
ax.metric('post:gallery:openLightbox', {
fromImage: index + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
const refs: AnimatedRef<any>[] = []
const dims: (Dimensions | null)[] = []
for (let i = 0; i < images.length; i++) {
refs.push(containerRefsRef.current.get(i)!)
dims.push(thumbDimsRef.current.get(i) ?? null)
}
onPress(index, refs, dims)
}
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
/>
)
}}
onScroll={e => {
// web handles via onSettle in the web hooks
if (IS_WEB) return
const offsetX = e.nativeEvent.contentOffset.x
let accumulated = 0
for (let i = 0; i < images.length; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
setCurrentIndex(i)
break
}
accumulated += w
if (i === images.length - 1) {
setCurrentIndex(i)
}
}
}}
style={[
{
height: contentHeight,
marginLeft: -insetLeft,
width,
},
]}
contentContainerStyle={{
gap: ITEM_GAP,
paddingLeft: insetLeft,
paddingRight: insetRight,
}}
/>
</BlockDrawerGesture>
</View>
)
}
function computeDims({
height,
aspectRatio,
}: {
height: number
aspectRatio?: number
}) {
/*
* Old images, or images from other clients can sometimes not have
* aspectRatio populated. In these cases, default to square and we'll
* resize once the image loads.
*
* Clamp between MIN_ASPECT_RATIO (portrait) and MAX_ASPECT_RATIO
* (landscape) so items stay a reasonable size in the carousel.
*/
const raw = aspectRatio ?? 1
const clamped = Math.max(MIN_ASPECT_RATIO, Math.min(raw, MAX_ASPECT_RATIO))
const width = Math.floor(height * clamped)
return {width, height, aspectRatio: clamped, isCropped: raw !== clamped}
}
function GalleryImage({
contentHeight: height,
image,
index,
imageCount,
onWidthChange,
itemRef,
hideBadges,
largeAltBadge,
onContainerRef,
onThumbDims,
onPress,
onPressIn,
}: {
contentHeight: number
image: AppBskyEmbedImages.ViewImage
index: number
imageCount: number
onWidthChange: (index: number, width: number) => void
itemRef: (node: View | null) => void
hideBadges?: boolean
largeAltBadge?: boolean
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
onThumbDims: (index: number, dims: Dimensions) => void
onPress?: () => void
onPressIn?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const [focused, setFocused] = useState(false)
const containerRef = useAnimatedRef()
const [aspectRatio, setAspectRatio] = useState(() =>
getAspectRatio(image.aspectRatio),
)
const {isCropped, ...dims} = computeDims({height, aspectRatio})
const hasAlt = !!image.alt
useEffect(() => {
onWidthChange(index, dims.width)
}, [index, dims.width, onWidthChange])
useEffect(() => {
onContainerRef(index, containerRef)
}, [index, containerRef, onContainerRef])
return (
<Animated.View
ref={containerRef}
collapsable={false}
aria-roledescription={l`slide`}
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
<Pressable
ref={itemRef}
tabIndex={index === 0 ? 0 : -1}
onPress={onPress}
onPressIn={onPressIn}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
accessibilityRole="button"
accessibilityLabel={image.alt || l`Image ${index + 1}`}
accessibilityHint={l`Opens full image`}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
web({
cursor: 'inherit',
outline: 0,
border: 0,
}),
]}>
<Image
source={{uri: image.thumb}}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
loading={index === 0 ? 'eager' : 'lazy'}
style={[dims]}
onLoad={e => {
const ar = getAspectRatio(e.source)
if (ar && ar !== aspectRatio) {
setAspectRatio(ar)
}
onThumbDims(index, {
width: e.source.width,
height: e.source.height,
})
}}
/>
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeAltBadge && {
gap: 4,
},
]}>
{isCropped && (
<View
style={[
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Fullscreen
fill={t.atoms.text_contrast_high.color}
width={largeAltBadge ? 18 : 12}
/>
</View>
)}
{hasAlt && (
<View
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans>ALT</Trans>
</Text>
</View>
)}
</View>
) : null}
<MediaInsetBorder
style={
focused && {
borderWidth: 2,
}
}
/>
</Pressable>
</Animated.View>
)
}
@@ -0,0 +1,102 @@
import {
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
type AppBskyFeedDefs,
AppBskyFeedPost,
type ModerationCause,
type ModerationUI,
} from '@atproto/api'
import {unique} from '#/lib/moderation'
import {type AppModerationCause} from '#/components/Pills'
import {Features, features} from '#/analytics/features'
import * as bsky from '#/types/bsky'
export const POST_META_NO_CONTENT_OFFSET = {paddingTop: 10}
export const POST_EMBED_NO_CONTENT_OFFSET = {paddingTop: 6}
export function maybeApplyGalleryOffsetStyles(
placement: 'meta' | 'embed',
{
post,
modui,
additionalCauses,
}: {
post: AppBskyFeedDefs.PostView
modui: ModerationUI
additionalCauses?: ModerationCause[] | AppModerationCause[]
},
) {
// don't ever check gates like this, except this one time
if (!features.isOn(Features.PostGalleryEmbedEnable)) return
if (
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
return
}
/*
* First check if we even have images
*/
const embed = post.record.embed
const isImageEmbed =
embed &&
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
embed,
AppBskyEmbedImages.isMain,
)
const isRecordWithMedia =
embed &&
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
embed,
AppBskyEmbedRecordWithMedia.isMain,
)
let hasImages = false
if (isImageEmbed) {
// one image, not a gallery
if (embed.images.length === 1) return
hasImages = true
}
if (isRecordWithMedia) {
if (
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
embed.media,
AppBskyEmbedImages.isMain,
)
) {
// one image, not a gallery
if (embed.media.images.length === 1) return
}
hasImages = true
}
if (!hasImages) return
/*
* Then check if we have any text
*/
let hasLabels = false
if (modui.alert) {
hasLabels = modui.alerts.filter(unique).length > 0
}
if (modui.inform) {
hasLabels = hasLabels || modui.informs.filter(unique).length > 0
}
if (additionalCauses?.length) {
hasLabels = true
}
/*
* If no text or labels, then we need a lil bump
*/
const shouldApplyOffset = !post.record.text && !hasLabels
return shouldApplyOffset
? placement === 'meta'
? POST_META_NO_CONTENT_OFFSET
: POST_EMBED_NO_CONTENT_OFFSET
: {}
}
+40
View File
@@ -0,0 +1,40 @@
function ease(t: number, b: number, c: number, d: number) {
return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b
}
/**
* Tween from `start` to `end` over `duration` ms using an exponential ease-out.
* Returns a function that starts the tween. That function returns a stop handle.
*
* Adapted from tinkerbell.
*/
export function tween(start: number, end: number, duration: number) {
return function run(cb: (v: number) => void, done?: () => void) {
let ts: number | undefined
let frame: number
frame = (function tick(last: number) {
return requestAnimationFrame(t => {
if (!ts) ts = t
const te = t - ts
const next = Math.round(ease(te, start, end - start, duration))
if (
(end > start
? next < end && last <= end
: next > end && last >= end) &&
te <= duration
) {
frame = tick(next)
cb(next)
} else {
cb(end)
done?.()
}
})
})(start)
return function stop() {
cancelAnimationFrame(frame)
}
}
}
@@ -0,0 +1,8 @@
export function useKeyboardHandlers(_args: {
flatListRef: any
itemWidthsRef: any
currentIndexRef: any
scrollTo: any
onSettle: any
imageCount: any
}) {}
@@ -0,0 +1,91 @@
import {useEffect} from 'react'
import {type FlatList} from 'react-native'
import {tween} from '#/components/images/Gallery/tween'
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
const SETTLE_DURATION = 700
export function useKeyboardHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
}: {
flatListRef: React.RefObject<FlatList | null>
itemWidthsRef: React.RefObject<Map<number, number>>
currentIndexRef: React.RefObject<number>
scrollTo: (offset: number) => void
onSettle: (index: number) => void
imageCount: number
}) {
useEffect(() => {
if (imageCount <= 1) return
let stopTween: (() => void) | null = null
let pendingIndex: number | null = null
const onKeyDown = (e: KeyboardEvent) => {
const el =
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
if (!el || !el.contains(document.activeElement)) return
const current = pendingIndex ?? currentIndexRef.current
let targetIndex: number | undefined
if (e.key === 'ArrowRight') {
if (current < imageCount - 1) {
targetIndex = current + 1
}
} else if (e.key === 'ArrowLeft') {
if (current > 0) {
targetIndex = current - 1
}
}
if (targetIndex != null) {
e.preventDefault()
if (stopTween) {
stopTween()
stopTween = null
}
pendingIndex = targetIndex
const from = el.scrollLeft
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
const idx = targetIndex
stopTween = tween(
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
pendingIndex = null
onSettle(idx)
},
)
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
if (stopTween) stopTween()
}
}, [
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
])
}
@@ -0,0 +1,8 @@
export function usePointerHandlers(_args: {
flatListRef: any
itemWidthsRef: any
currentIndexRef: any
scrollTo: any
onSettle: any
imageCount: any
}) {}
@@ -0,0 +1,270 @@
import {useEffect} from 'react'
import {type FlatList} from 'react-native'
import {ITEM_GAP} from '#/components/images/Gallery/const'
import {tween} from '#/components/images/Gallery/tween'
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
const DRAG_THRESHOLD = 3
const FLICK_DECAY = 0.85
const FLICK_MIN_VELOCITY = 0.1
const ADVANCE_THRESHOLD = 0.15
const FRAME_MS = 1000 / 60
const SETTLE_DURATION = 700
const OVERSCROLL_RESISTANCE = 0.4
const BOUNCE_DURATION = 700
function whichByDistance(
itemWidths: Map<number, number>,
currentIndex: number,
distance: number,
direction: -1 | 1,
imageCount: number,
): number {
let remaining = distance
let i = currentIndex
while (remaining > 0 && i >= 0 && i < imageCount) {
const w = (itemWidths.get(i) ?? 0) + ITEM_GAP
if (remaining > w) {
remaining -= w
i -= direction
} else if (remaining > w * ADVANCE_THRESHOLD) {
i -= direction
break
} else {
break
}
}
return Math.max(0, Math.min(i, imageCount - 1))
}
export function usePointerHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
}: {
flatListRef: React.RefObject<FlatList | null>
itemWidthsRef: React.RefObject<Map<number, number>>
currentIndexRef: React.RefObject<number>
scrollTo: (offset: number) => void
onSettle: (index: number) => void
imageCount: number
}) {
useEffect(() => {
if (imageCount <= 1) return
const el =
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
if (!el) return
let isDragging = false
let isMouseDown = false
let startX = 0
let dragScrollLeft = 0
let delta = 0
let prevDelta = 0
let velo = 0
let t = 0
let stopTween: (() => void) | null = null
let localIndex = currentIndexRef.current
let overscrollX = 0
el.style.cursor = 'grab'
const clearOverscroll = () => {
overscrollX = 0
el.style.transform = ''
}
const onMouseDown = (e: MouseEvent) => {
e.preventDefault() // prevent native image drag
// Cancel any in-progress tween
if (stopTween) {
stopTween()
stopTween = null
}
clearOverscroll()
isMouseDown = true
isDragging = false
localIndex = currentIndexRef.current
startX = e.pageX
dragScrollLeft = el.scrollLeft
delta = 0
prevDelta = 0
velo = 0
t = e.timeStamp
}
const onMouseMove = (e: MouseEvent) => {
if (!isMouseDown) return
const x = e.pageX - startX
// Require minimum movement before starting drag
if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return
if (!isDragging) {
isDragging = true
el.style.cursor = 'grabbing'
el.style.userSelect = 'none'
// Blur focused element within the gallery
if (el.contains(document.activeElement)) {
;(document.activeElement as HTMLElement)?.blur?.()
}
}
e.preventDefault()
// Track velocity
const elapsed = e.timeStamp - t || 1
prevDelta = delta
delta = x
velo = (delta - prevDelta) / (elapsed * FRAME_MS)
t = e.timeStamp
const desiredScroll = dragScrollLeft - delta
const maxScroll = el.scrollWidth - el.clientWidth
if (desiredScroll < 0) {
// Overscroll at start — rubber band
scrollTo(0)
overscrollX = desiredScroll * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else if (desiredScroll > maxScroll) {
// Overscroll at end — rubber band
scrollTo(maxScroll)
overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else {
// Normal scroll range
scrollTo(desiredScroll)
if (overscrollX !== 0) clearOverscroll()
}
// Update local index from scroll position (only in normal range)
if (overscrollX === 0) {
const offsetX = desiredScroll
let accumulated = 0
for (let i = 0; i < imageCount; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
localIndex = i
break
}
accumulated += w
if (i === imageCount - 1) localIndex = i
}
}
}
const onMouseUp = () => {
if (!isMouseDown) return
const wasDragging = isDragging
isMouseDown = false
isDragging = false
el.style.cursor = 'grab'
el.style.userSelect = ''
if (wasDragging) {
// Suppress the click that follows mouseup after a drag
el.addEventListener('click', e => e.stopPropagation(), {
once: true,
capture: true,
})
if (overscrollX !== 0) {
// Bounce back from overscroll
const targetIndex = overscrollX > 0 ? imageCount - 1 : 0
const fromOverscroll = overscrollX
stopTween = tween(
fromOverscroll,
0,
BOUNCE_DURATION,
)(
v => {
el.style.transform = `translateX(${-v}px)`
},
() => {
stopTween = null
clearOverscroll()
onSettle(targetIndex)
},
)
} else {
// Normal flick settle
let v = Math.abs(velo)
let restingDistance = 0
while (v > FLICK_MIN_VELOCITY) {
v *= FLICK_DECAY
restingDistance += v
}
const direction: -1 | 1 = delta < 0 ? -1 : 1
const totalDistance = Math.abs(delta) + restingDistance
const targetIndex = whichByDistance(
itemWidthsRef.current,
localIndex,
totalDistance,
direction,
imageCount,
)
const from = el.scrollLeft
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
if (from === to) {
onSettle(targetIndex)
return
}
stopTween = tween(
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
onSettle(targetIndex)
},
)
}
}
}
el.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
return () => {
el.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
if (stopTween) stopTween()
clearOverscroll()
el.style.cursor = ''
el.style.userSelect = ''
}
}, [
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
])
}
+22
View File
@@ -0,0 +1,22 @@
import {ITEM_GAP} from '#/components/images/Gallery/const'
export function getOffsetForIndex(
itemWidths: Map<number, number>,
index: number,
): number {
let offset = 0
for (let i = 0; i < index; i++) {
offset += (itemWidths.get(i) ?? 0) + ITEM_GAP
}
return offset
}
export function getAspectRatio({
width,
height,
}: {width?: number; height?: number} = {}) {
if (width && width > 0 && height && height > 0) {
return width / height
}
return undefined
}
+1 -1
View File
@@ -6,7 +6,7 @@ import {type AppBskyEmbedImages} from '@atproto/api'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a, useBreakpoints} from '#/alf'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {GalleryItem} from './Gallery'
import {GalleryItem} from './ImageLayoutGridItem'
interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]
@@ -10,8 +10,8 @@ import {useVerificationCreateMutation} from '#/state/queries/verification/useVer
import {atoms as a, useBreakpoints} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {type DialogControlProps} from '#/components/Dialog'
import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
+1 -1
View File
@@ -8,8 +8,8 @@ import {
} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import * as env from '#/env'
import {IS_WEB} from '#/env'
import {
type LiveEventFeed,
type LiveEventFeedMetricContext,
+3 -3
View File
@@ -1,12 +1,12 @@
import * as React from 'react'
import {useRef} from 'react'
import {Animated} from 'react-native'
export function useAnimatedValue(initialValue: number) {
const lazyRef = React.useRef<Animated.Value>(undefined)
const lazyRef = useRef<Animated.Value>(undefined)
if (lazyRef.current === undefined) {
lazyRef.current = new Animated.Value(initialValue)
}
return lazyRef.current as Animated.Value
return lazyRef.current
}
+5 -5
View File
@@ -1,13 +1,13 @@
import * as React from 'react'
import {useCallback, useEffect, useRef} from 'react'
/**
* Helper hook to run persistent timers on views
*/
export function useTimer(time: number, handler: () => void) {
const timer = React.useRef<undefined | NodeJS.Timeout>(undefined)
const timer = useRef<undefined | NodeJS.Timeout>(undefined)
// function to restart the timer
const reset = React.useCallback(() => {
const reset = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current)
}
@@ -15,7 +15,7 @@ export function useTimer(time: number, handler: () => void) {
}, [time, timer, handler])
// function to cancel the timer
const cancel = React.useCallback(() => {
const cancel = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = undefined
@@ -23,7 +23,7 @@ export function useTimer(time: number, handler: () => void) {
}, [timer])
// start the timer immediately
React.useEffect(() => {
useEffect(() => {
reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
+2 -2
View File
@@ -30,7 +30,7 @@ export async function getServiceAuthToken({
return serviceAuth.token
}
export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) {
export async function getVideoUploadLimits(agent: BskyAgent, i18n: I18n) {
const token = await getServiceAuthToken({
agent,
lxm: 'app.bsky.video.getUploadLimits',
@@ -52,7 +52,7 @@ export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) {
throw new UploadLimitError(limits.message)
} else {
throw new UploadLimitError(
_(
i18n._(
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
),
)
+6 -4
View File
@@ -16,19 +16,19 @@ export async function uploadVideo({
did,
setProgress,
signal,
_,
i18n,
}: {
video: CompressedVideo
agent: BskyAgent
did: string
setProgress: (progress: number) => void
signal: AbortSignal
_: I18n['_']
i18n: I18n
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, _)
await getVideoUploadLimits(agent, i18n)
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
@@ -69,7 +69,9 @@ export async function uploadVideo({
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
if (!responseBody.jobId) {
throw new ServerError(responseBody.error || _(msg`Failed to upload video`))
throw new ServerError(
responseBody.error || i18n._(msg`Failed to upload video`),
)
}
if (signal.aborted) {
+6 -6
View File
@@ -15,19 +15,19 @@ export async function uploadVideo({
did,
setProgress,
signal,
_,
i18n,
}: {
video: CompressedVideo
agent: BskyAgent
did: string
setProgress: (progress: number) => void
signal: AbortSignal
_: I18n['_']
i18n: I18n
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, _)
await getVideoUploadLimits(agent, i18n)
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
@@ -70,11 +70,11 @@ export async function uploadVideo({
) as AppBskyVideoDefs.JobStatus
resolve(uploadRes)
} else {
reject(new ServerError(_(msg`Failed to upload video`)))
reject(new ServerError(i18n._(msg`Failed to upload video`)))
}
}
xhr.onerror = () => {
reject(new ServerError(_(msg`Failed to upload video`)))
reject(new ServerError(i18n._(msg`Failed to upload video`)))
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType)
@@ -84,7 +84,7 @@ export async function uploadVideo({
)
if (!res.jobId) {
throw new ServerError(res.error || _(msg`Failed to upload video`))
throw new ServerError(res.error || i18n._(msg`Failed to upload video`))
}
if (signal.aborted) {
+1
View File
@@ -73,6 +73,7 @@ export type CommonNavigatorParams = {
Hashtag: {tag: string; author?: string}
Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string; accept?: true}
MessagesConversationSettings: {conversation: string}
MessagesSettings: undefined
MessagesInbox: undefined
NotificationsActivityList: {posts: string}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -85,6 +85,7 @@ export const router = new Router<AllNavigatableRoutes>({
MessagesSettings: '/messages/settings',
MessagesInbox: '/messages/inbox',
MessagesConversation: '/messages/:conversation',
MessagesConversationSettings: '/messages/:conversation/settings',
// starter packs
Start: '/start/:name/:rkey',
StarterPackEdit: '/starter-pack/edit/:rkey',
+45 -14
View File
@@ -1,11 +1,15 @@
import {useCallback, useEffect, useMemo, useState} from 'react'
import {View} from 'react-native'
import {type LayoutChangeEvent, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
type AppBskyActorDefs,
moderateProfile,
type ModerationDecision,
} from '@atproto/api'
import {ScrollEdgeEffectProvider} from '@bsky.app/expo-scroll-edge-effect'
import {
ScrollEdgeEffect,
ScrollEdgeEffectProvider,
} from '@bsky.app/expo-scroll-edge-effect'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -45,7 +49,7 @@ import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
import {Error} from '#/components/Error'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import {IS_WEB} from '#/env'
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
@@ -83,7 +87,10 @@ export function MessagesConversationScreenInner({route}: Props) {
)
return (
<Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}>
<Layout.Screen
testID="convoScreen"
noInsetTop={IS_LIQUID_GLASS}
style={web([{minHeight: 0}, a.flex_1])}>
<ScrollEdgeEffectProvider>
<ConvoProvider key={convoId} convoId={convoId}>
<Inner />
@@ -98,10 +105,11 @@ function Inner() {
const convoState = useConvo()
const {_} = useLingui()
const isFocused = useIsFocused()
const {top: topInset} = useSafeAreaInsets()
const moderationOpts = useModerationOpts()
const {data: recipientUnshadowed} = useProfileQuery({
did: convoState.recipients?.[0].did,
did: convoState.getPrimaryMember?.()?.did,
})
const recipient = useMaybeProfileShadow(recipientUnshadowed)
@@ -133,9 +141,10 @@ function Inner() {
if (convoState.status === ConvoStatus.Error) {
return (
<>
<Layout.Center style={[a.flex_1]}>
<Layout.Center
style={[a.flex_1, IS_LIQUID_GLASS && {paddingTop: topInset}]}>
{moderation ? (
<MessagesListHeader moderation={moderation} profile={recipient} />
<MessagesListHeader profile={recipient} moderation={moderation} />
) : (
<MessagesListHeader />
)}
@@ -154,12 +163,15 @@ function Inner() {
<Layout.Center style={[a.flex_1]}>
{/* MessagesList does not use the body scroll */}
{isFocused && IS_WEB && <RemoveScrollBar />}
{!readyToShow &&
(moderation ? (
<MessagesListHeader moderation={moderation} profile={recipient} />
) : (
<MessagesListHeader />
))}
{!readyToShow && (
<View style={IS_LIQUID_GLASS && {paddingTop: topInset}}>
{moderation ? (
<MessagesListHeader profile={recipient} moderation={moderation} />
) : (
<MessagesListHeader />
)}
</View>
)}
<View style={[a.flex_1]}>
{moderation && recipient ? (
<InnerReady
@@ -205,6 +217,11 @@ function InnerReady({
}) {
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
const {top: topInset} = useSafeAreaInsets()
const [headerHeight, setHeaderHeight] = useState(0)
const onHeaderLayout = (e: LayoutChangeEvent) => {
setHeaderHeight(e.nativeEvent.layout.height)
}
const {params} =
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
const {needsEmailVerification} = useEmail()
@@ -248,15 +265,29 @@ function InnerReady({
maybeBlockForEmailVerification()
}, [maybeBlockForEmailVerification])
const header = (
<MessagesListHeader profile={recipient} moderation={moderation} />
)
return (
<>
<MessagesListHeader profile={recipient} moderation={moderation} />
{IS_LIQUID_GLASS ? (
<ScrollEdgeEffect
edge="top"
style={[a.absolute, a.w_full, a.z_10, {paddingTop: topInset}]}
onLayout={onHeaderLayout}>
{header}
</ScrollEdgeEffect>
) : (
header
)}
{isConvoActive(convoState) && (
<MessagesList
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
blocked={moderation?.blocked}
hasAcceptOverride={!!params.accept}
transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0}
footer={
<MessagesListBlockedFooter
recipient={recipient}
File diff suppressed because it is too large Load Diff
+261 -116
View File
@@ -1,36 +1,40 @@
import {memo, useCallback, useMemo, useState} from 'react'
import {useCallback, useMemo, useState} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
AppBskyEmbedRecord,
ChatBskyActorDefs,
ChatBskyConvoDefs,
moderateProfile,
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {GestureActionView} from '#/lib/custom-animations/GestureActionView'
import {useHaptics} from '#/lib/haptics'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {decrementBadgeCount} from '#/lib/notifications/notifications'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {
postUriToRelativePath,
toBskyAppUrl,
toShortUrl,
} from '#/lib/strings/url-helpers'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
precacheConvoQuery,
useMarkAsReadMutation,
} from '#/state/queries/messages/conversation'
import {precacheProfile} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import * as tokens from '#/alf/tokens'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {useDialogControl} from '#/components/Dialog'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
@@ -45,11 +49,17 @@ import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky'
import * as bsky from '#/types/bsky'
export const ChatListItemPortal = createPortalGroup()
export let ChatListItem = ({
/**
* IMPORTANT NOTE: THIS IS CURRENTLY JANKY AF AND PROBABLY BROKEN, JUST WANTED TO ADD GROUPCHAT SUPPPORT
*
* TAKE A SECOND PASS PLEASE -sfn
*/
export function ChatListItem({
convo,
showMenu = true,
children,
@@ -57,32 +67,77 @@ export let ChatListItem = ({
convo: ChatBskyConvoDefs.ConvoView
showMenu?: boolean
children?: React.ReactNode
}): React.ReactNode => {
}) {
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const otherUser = convo.members.find(
member => member.did !== currentAccount?.did,
)
if (!otherUser || !moderationOpts) {
if (!moderationOpts) {
return null
}
return (
<ChatListItemReady
convo={convo}
profile={otherUser}
moderationOpts={moderationOpts}
showMenu={showMenu}>
{children}
</ChatListItemReady>
)
if (
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
convo.kind,
ChatBskyConvoDefs.isGroupConvo,
)
) {
const owner = convo.members.find(r => {
if (
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
r.kind,
ChatBskyActorDefs.isGroupConvoMember,
)
) {
return r.kind.role === 'owner'
} else {
throw new Error(
'Expected a GroupConvoMember, got an unknown kind of member',
)
}
})
if (!owner) {
// TODO: Determine if this is the right thing to do here. Throwing here so that
// if it turns out to be wrong it'll be very visible
throw new Error('Could not find the group owner in the group members')
}
return (
<GroupChatItem
convo={convo}
groupOwner={owner}
groupInfo={convo.kind}
moderationOpts={moderationOpts}
showMenu={showMenu}
/>
)
} else if (
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
convo.kind,
ChatBskyConvoDefs.isDirectConvo,
)
) {
const otherMember = convo.members.find(
member => member.did !== currentAccount?.did,
)
if (!otherMember) {
return null
}
return (
<DirectChatItem
convo={convo}
profile={otherMember}
moderationOpts={moderationOpts}
showMenu={showMenu}>
{children}
</DirectChatItem>
)
} else {
return null
}
}
ChatListItem = memo(ChatListItem)
function ChatListItemReady({
function DirectChatItem({
convo,
profile: profileUnshadowed,
moderationOpts,
@@ -95,25 +150,140 @@ function ChatListItemReady({
showMenu?: boolean
children?: React.ReactNode
}) {
const ax = useAnalytics()
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const leaveConvoControl = useDialogControl()
const {gtMobile} = useBreakpoints()
const {t: l} = useLingui()
const profile = useProfileShadow(profileUnshadowed)
const {mutate: markAsRead} = useMarkAsReadMutation()
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? l`Deleted Account`
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
return (
<BaseChatItem
convo={convo}
avatar={
<PreviewableUserAvatar
profile={profile}
size={52}
moderation={moderation.ui('avatar')}
/>
}
primaryProfile={profile}
primaryProfileModeration={moderation}
title={displayName}
subtitle={isDeletedAccount ? undefined : sanitizeHandle(profile.handle)}
accessibilityHint={
!isDeletedAccount
? l`Go to conversation with ${profile.handle}`
: l`This conversation is with a deleted or a deactivated account. Press for options`
}
showMenu={showMenu}
isDeletedAccount={isDeletedAccount}
isBlockedAccount={moderation.blocked}
showProfileBadges
postAlerts={
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
}>
{children}
</BaseChatItem>
)
}
function GroupChatItem({
convo,
groupOwner: groupOwnerUnshadowed,
groupInfo,
moderationOpts,
showMenu,
children,
}: {
convo: ChatBskyConvoDefs.ConvoView
groupOwner: bsky.profile.AnyProfileView
groupInfo: ChatBskyConvoDefs.GroupConvo
moderationOpts: ModerationOpts
showMenu?: boolean
children?: React.ReactNode
}) {
const {t: l} = useLingui()
const groupOwner = useProfileShadow(groupOwnerUnshadowed)
const moderation = useMemo(
() => moderateProfile(groupOwner, moderationOpts),
[groupOwner, moderationOpts],
)
const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat`
return (
<BaseChatItem
convo={convo}
avatar={<AvatarBubbles profiles={convo.members} size="medium" />}
title={chatName}
accessibilityHint={l`Go to the group chat named "${chatName}"`}
primaryProfile={groupOwner}
primaryProfileModeration={moderation}
isBlockedAccount={false}
isDeletedAccount={false}
showProfileBadges={false}
showMenu={showMenu}>
{children}
</BaseChatItem>
)
}
function BaseChatItem({
convo,
avatar,
title,
subtitle,
accessibilityHint,
isDeletedAccount,
isBlockedAccount,
primaryProfile,
primaryProfileModeration,
showMenu,
showProfileBadges,
postAlerts,
children,
}: {
convo: ChatBskyConvoDefs.ConvoView
avatar: React.ReactNode
title: string
subtitle?: string
accessibilityHint: string
isDeletedAccount: boolean
isBlockedAccount: boolean
primaryProfile: Shadow<bsky.profile.AnyProfileView>
primaryProfileModeration: ModerationDecision
showMenu?: boolean
showProfileBadges: boolean
postAlerts?: React.ReactNode
children?: React.ReactNode
}) {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const leaveConvoControl = useDialogControl()
const {mutate: markAsRead} = useMarkAsReadMutation()
const {gtMobile} = useBreakpoints()
const playHaptic = useHaptics()
const queryClient = useQueryClient()
const isUnread = convo.unreadCount > 0
const blockInfo = useMemo(() => {
const modui = moderation.ui('profileView')
const modui = primaryProfileModeration.ui('profileView')
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
const userBlock = blocks.find(alert => alert.source.type === 'user')
@@ -121,21 +291,13 @@ function ChatListItemReady({
listBlocks,
userBlock,
}
}, [moderation])
}, [primaryProfileModeration])
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? _(msg`Deleted Account`)
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
const isDimStyle = convo.muted || isBlockedAccount || isDeletedAccount
const {lastMessage, lastMessageSentAt, latestReportableMessage} =
useMemo(() => {
let lastMessage = _(msg`No messages yet`)
let lastMessage = l`No messages yet`
let lastMessageSentAt: string | null = null
@@ -150,14 +312,12 @@ function ChatListItemReady({
if (convo.lastMessage.text) {
if (isFromMe) {
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
lastMessage = l`You: ${convo.lastMessage.text}`
} else {
lastMessage = convo.lastMessage.text
}
} else if (convo.lastMessage.embed) {
const defaultEmbeddedContentMessage = _(
msg`(contains embedded content)`,
)
const defaultEmbeddedContentMessage = l`(contains embedded content)`
if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) {
const embed = convo.lastMessage.embed
@@ -172,14 +332,14 @@ function ChatListItemReady({
? toShortUrl(href)
: defaultEmbeddedContentMessage
if (isFromMe) {
lastMessage = _(msg`You: ${short}`)
lastMessage = l`You: ${short}`
} else {
lastMessage = short
}
}
} else {
if (isFromMe) {
lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`)
lastMessage = l`You: ${defaultEmbeddedContentMessage}`
} else {
lastMessage = defaultEmbeddedContentMessage
}
@@ -192,8 +352,8 @@ function ChatListItemReady({
lastMessageSentAt = convo.lastMessage.sentAt
lastMessage = isDeletedAccount
? _(msg`Conversation deleted`)
: _(msg`Message deleted`)
? l`Conversation deleted`
: l`Message deleted`
}
if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
@@ -205,44 +365,36 @@ function ChatListItemReady({
const isFromMe =
convo.lastReaction.reaction.sender.did === currentAccount?.did
const lastMessageText = convo.lastReaction.message.text
const fallbackMessage = _(
msg({
message: 'a message',
comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`,
}),
)
const fallbackMessage = l({
message: 'a message',
comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`,
})
if (isFromMe) {
lastMessage = _(
msg`You reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`,
)
lastMessage = l`You reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
} else {
const senderDid = convo.lastReaction.reaction.sender.did
const sender = convo.members.find(
member => member.did === senderDid,
)
if (sender) {
lastMessage = _(
msg`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`,
)
lastMessage = l`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
} else {
lastMessage = _(
msg`Someone reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`,
)
lastMessage = l`Someone reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
}
}
}
@@ -254,7 +406,7 @@ function ChatListItemReady({
latestReportableMessage,
}
}, [
_,
l,
convo.lastMessage,
convo.lastReaction,
currentAccount?.did,
@@ -279,9 +431,11 @@ function ChatListItemReady({
const onPress = useCallback(
(e: GestureResponderEvent) => {
precacheProfile(queryClient, profile)
for (const member of convo.members) {
unstableCacheProfileView(queryClient, member)
}
precacheConvoQuery(queryClient, convo)
decrementBadgeCount(convo.unreadCount)
void decrementBadgeCount(convo.unreadCount)
if (isDeletedAccount) {
e.preventDefault()
menuControl.open()
@@ -290,7 +444,7 @@ function ChatListItemReady({
ax.metric('chat:open', {logContext: 'ChatsList'})
}
},
[ax, isDeletedAccount, menuControl, queryClient, profile, convo],
[ax, isDeletedAccount, menuControl, queryClient, convo],
)
const onLongPress = useCallback(() => {
@@ -345,33 +499,23 @@ function ChatListItemReady({
a.absolute,
{top: tokens.space.md, left: tokens.space.lg},
]}>
<PreviewableUserAvatar
profile={profile}
size={52}
moderation={moderation.ui('avatar')}
/>
{avatar}
</View>
<Link
to={`/messages/${convo.id}`}
label={displayName}
accessibilityHint={
!isDeletedAccount
? _(msg`Go to conversation with ${profile.handle}`)
: _(
msg`This conversation is with a deleted or a deactivated account. Press for options`,
)
}
label={title}
accessibilityHint={accessibilityHint}
accessibilityActions={
IS_NATIVE
? [
{
name: 'magicTap',
label: _(msg`Open conversation options`),
label: l`Open conversation options`,
},
{
name: 'longpress',
label: _(msg`Open conversation options`),
label: l`Open conversation options`,
},
]
: undefined
@@ -407,14 +551,18 @@ function ChatListItemReady({
{lineHeight: 21},
isDimStyle && t.atoms.text_contrast_medium,
]}>
{displayName}
{title}
</Text>
</View>
<ProfileBadges
profile={profile}
size="md"
style={[a.pl_xs, a.self_center]}
/>
{showProfileBadges && (
<ProfileBadges
profile={primaryProfile}
size="md"
style={[a.pl_xs, a.self_center]}
/>
)}
{lastMessageSentAt && (
<View style={[a.pl_xs]}>
<TimeElapsed timestamp={lastMessageSentAt}>
@@ -432,7 +580,7 @@ function ChatListItemReady({
</TimeElapsed>
</View>
)}
{(convo.muted || moderation.blocked) && (
{(convo.muted || isBlockedAccount) && (
<Text
style={[
a.text_sm,
@@ -450,7 +598,7 @@ function ChatListItemReady({
)}
</View>
{!isDeletedAccount && (
{subtitle && (
<Text
numberOfLines={1}
style={[
@@ -458,7 +606,7 @@ function ChatListItemReady({
t.atoms.text_contrast_medium,
a.pb_xs,
]}>
@{profile.handle}
{subtitle}
</Text>
)}
@@ -474,11 +622,7 @@ function ChatListItemReady({
{lastMessage}
</Text>
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
{postAlerts}
{children}
</View>
@@ -509,7 +653,7 @@ function ChatListItemReady({
{showMenu && (
<ConvoMenu
convo={convo}
profile={profile}
profile={primaryProfile}
control={menuControl}
currentScreen="list"
showMarkAsRead={convo.unreadCount > 0}
@@ -529,6 +673,7 @@ function ChatListItemReady({
latestReportableMessage={latestReportableMessage}
/>
)}
<LeaveConvoPrompt
control={leaveConvoControl}
convoId={convo.id}
@@ -15,8 +15,7 @@ import Animated, {
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {GlassContainer} from 'expo-glass-effect'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {countGraphemes} from 'unicode-segmenter/grapheme'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
@@ -47,13 +46,13 @@ export function MessageInput({
children,
}: {
textInputId?: string
onSendMessage: (message: string) => void
onSendMessage: (message: string) => Promise<void> | void
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const playHaptic = useHaptics()
const {getDraft, clearDraft} = useMessageDraft()
@@ -82,13 +81,13 @@ export function MessageInput({
return
}
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(_(msg`Message is too long`), {
Toast.show(l`Message is too long`, {
type: 'error',
})
return
}
clearDraft()
onSendMessage(message)
void onSendMessage(message)
playHaptic()
setEmbed(undefined)
setMessage('')
@@ -111,7 +110,7 @@ export function MessageInput({
playHaptic,
setEmbed,
inputRef,
_,
l,
])
useFocusedInputHandler(
@@ -169,9 +168,9 @@ export function MessageInput({
fallbackStyle={[t.atoms.bg_contrast_50]}>
<AnimatedTextInput
nativeID={textInputId}
accessibilityLabel={_(msg`Message input field`)}
accessibilityHint={_(msg`Type your message here`)}
placeholder={_(msg`Message`)}
accessibilityLabel={l`Message input field`}
accessibilityHint={l`Type your message here`}
placeholder={l`Message`}
placeholderTextColor={t.palette.contrast_500}
value={message}
onChange={evt => {
@@ -225,7 +224,7 @@ export function MessageInput({
}}>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Send message`)}
accessibilityLabel={l`Send message`}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
@@ -1,7 +1,6 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {flushSync} from 'react-dom'
import TextareaAutosize from 'react-textarea-autosize'
import {countGraphemes} from 'unicode-segmenter/grapheme'
@@ -40,7 +39,7 @@ export function MessageInput({
openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) {
const {isMobile} = useWebMediaQueries()
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const {getDraft, clearDraft} = useMessageDraft()
const [message, setMessage] = useState(getDraft)
@@ -57,7 +56,7 @@ export function MessageInput({
return
}
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(_(msg`Message is too long`), {
Toast.show(l`Message is too long`, {
type: 'error',
})
return
@@ -66,7 +65,7 @@ export function MessageInput({
onSendMessage(message)
setMessage('')
setEmbed(undefined)
}, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed])
}, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed])
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -177,7 +176,7 @@ export function MessageInput({
width: 30,
},
]}
label={_(msg`Open emoji picker`)}>
label={l`Open emoji picker`}>
{state => (
<View
style={[
@@ -210,7 +209,7 @@ export function MessageInput({
},
])}
maxRows={12}
placeholder={_(msg`Write a message`)}
placeholder={l`Message`}
defaultValue=""
value={message}
dirName="ltr"
@@ -231,7 +230,7 @@ export function MessageInput({
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Send message`)}
accessibilityLabel={l`Send message`}
accessibilityHint=""
style={[
a.rounded_full,
@@ -1,34 +1,33 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {type ConvoItem, ConvoItemError} from '#/state/messages/convo/types'
import {atoms as a, useTheme} from '#/alf'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {InlineLinkText} from '#/components/Link'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {description, help, cta} = useMemo(() => {
return {
[ConvoItemError.FirehoseFailed]: {
description: _(msg`This chat was disconnected`),
help: _(msg`Press to attempt reconnection`),
cta: _(msg`Reconnect`),
description: l`This chat was disconnected`,
help: l`Press to attempt reconnection`,
cta: l`Reconnect`,
},
[ConvoItemError.HistoryFailed]: {
description: _(msg`Failed to load past messages`),
help: _(msg`Press to retry`),
cta: _(msg`Retry`),
description: l`Failed to load past messages`,
help: l`Press to retry`,
cta: l`Retry`,
},
}[item.code]
}, [_, item.code])
}, [l, item.code])
return (
<View style={[a.py_md, a.w_full, a.flex_row, a.justify_center]}>
<View style={[a.my_md, a.w_full, a.flex_row, a.justify_center]}>
<View
style={[
a.flex_1,
@@ -41,18 +40,18 @@ export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
<CircleInfo size="sm" fill={t.palette.negative_400} />
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
{description} &middot;{' '}
{description}
{item.retry && (
<InlineLinkText
to="#"
label={help}
onPress={e => {
e.preventDefault()
item.retry?.()
return false
}}>
{cta}
</InlineLinkText>
<>
&middot;{' '}
<InlineLinkText
label={help}
{...createStaticClick(() => {
item.retry?.()
})}>
{cta}
</InlineLinkText>
</>
)}
</Text>
</View>
@@ -5,7 +5,7 @@ import {
type KeyboardChatScrollViewProps,
KeyboardGestureArea,
} from 'react-native-keyboard-controller'
import Animated, {
import {
runOnJS,
type ScrollEvent,
type SharedValue,
@@ -77,18 +77,6 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
)
}
function renderItem({item}: {item: ConvoItem}) {
if (item.type === 'message' || item.type === 'pending-message') {
return <MessageItem item={item} />
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'error') {
return <MessageListError item={item} />
}
return null
}
function keyExtractor(item: ConvoItem) {
return item.key
}
@@ -103,12 +91,14 @@ export function MessagesList({
blocked,
footer,
hasAcceptOverride,
transparentHeaderHeight,
}: {
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
blocked?: boolean
footer?: React.ReactNode
hasAcceptOverride?: boolean
transparentHeaderHeight?: number
}) {
const ax = useAnalytics()
const convoState = useConvoActive()
@@ -155,6 +145,16 @@ export function MessagesList({
const prevContentHeight = useRef(0)
const prevItemCount = useRef(0)
// Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset
// (which causes an early onScroll with negative offset) can't prevent the first scroll.
// Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding).
const hasInitiallyScrolled = useRef(false)
const prevHasScrolled = useRef(hasScrolled)
if (prevHasScrolled.current && !hasScrolled) {
hasInitiallyScrolled.current = false
}
prevHasScrolled.current = hasScrolled
// -- Keep track of background state and positioning for new pill
const layoutHeight = useSharedValue(0)
const didBackground = useRef(false)
@@ -187,8 +187,25 @@ export function MessagesList({
})
}
// This number _must_ be the height of the MaybeLoader component
if (height > 50 && isAtBottom.get()) {
// Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset
// can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here.
if (!hasInitiallyScrolled.current && convoState.items.length > 0) {
hasInitiallyScrolled.current = true
flatListRef.current?.scrollToOffset({offset: height, animated: false})
// If history is already done loading, mark ready after a frame for the scroll to settle.
// Otherwise, the footer sentinel's onLayout will handle it when history finishes.
if (!convoState.isFetchingHistory) {
requestAnimationFrame(() => {
setHasScrolled(true)
})
}
prevContentHeight.current = height
prevItemCount.current = convoState.items.length
return
}
// Subsequent: auto-scroll only if user is at the bottom
if (isAtBottom.get()) {
// If the size of the content is changing by more than the height of the screen, then we don't
// want to scroll further than the start of all the new content. Since we are storing the previous offset,
// we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill
@@ -212,17 +229,6 @@ export function MessagesList({
offset: height,
animated: hasScrolled && height > prevContentHeight.current,
})
// HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
// because otherwise there is too much of a delay between the time the content
// scrolls and the time the screen appears, causing a flicker.
// We cannot actually use a synchronous scroll here, because `onContentSizeChange`
// is actually async itself - all the info has to come across the bridge first.
if (!hasScrolled && !convoState.isFetchingHistory) {
setTimeout(() => {
setHasScrolled(true)
}, 100)
}
}
}
@@ -369,6 +375,40 @@ export function MessagesList({
setEmojiPickerState({isOpen: true, pos})
}, [])
const renderItem = ({item}: {item: ConvoItem}) => {
if (item.type === 'message' || item.type === 'pending-message') {
return (
<MessageItem
item={item}
profile={convoState.convo.members.find(
member => member.did === item.message.sender.did,
)}
isGroupChat={convoState.getGroupInfo?.() != null}
/>
)
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'error') {
return <MessageListError item={item} />
}
return null
}
// Footer sentinel: when history is still loading during the initial scroll, the footer's onLayout fires each time
// new items are prepended (shifting its position). Once history finishes, this triggers setHasScrolled.
const onFooterLayout = useCallback(() => {
if (
hasInitiallyScrolled.current &&
!hasScrolled &&
!convoState.isFetchingHistory
) {
requestAnimationFrame(() => {
setHasScrolled(true)
})
}
}, [hasScrolled, setHasScrolled, convoState.isFetchingHistory])
const renderScrollComponent = useCallback(
(props: ScrollViewProps) => (
<ChatScrollComponent {...props} inputHeight={inputHeightUI} />
@@ -382,7 +422,8 @@ export function MessagesList({
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
offset={Math.round(inputHeightJS)}
textInputNativeID={textInputId}
// slightly too buggy unfortunately, enable when possible
// textInputNativeID={textInputId}
style={[a.flex_1]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll}>
@@ -411,15 +452,27 @@ export function MessagesList({
}
// native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent}
// pushes up the content under the input on web (renderScrollComponent handles it on native)
ListFooterComponent={web(
<WebInputSpacer inputHeight={inputHeightJS} />,
)}
contentContainerStyle={{
paddingBottom: platform({
// ios is slightly larger as the input has no top padding
ios: tokens.space.lg,
android: tokens.space.md,
web: 0, // web uses ListFooterComponent instead for scroll reasons
}),
}}
ListFooterComponent={
<View
style={web({height: tokens.space.md + inputHeightJS})}
onLayout={onFooterLayout}
/>
}
style={web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable both-edges',
})}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
<KeyboardStickyView
@@ -444,7 +497,9 @@ export function MessagesList({
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={onSendMessage}
onSendMessage={(message: string) =>
void onSendMessage(message)
}
hasEmbed={!!embedUri}
setEmbed={setEmbed}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
@@ -518,12 +573,6 @@ function ChatScrollComponent({
)
}
function WebInputSpacer({inputHeight}: {inputHeight: number}) {
if (!IS_WEB) return null
return <Animated.View style={{height: inputHeight}} />
}
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
function getFooterState(
@@ -39,6 +39,7 @@ import {Button} from '#/components/Button'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {GalleryBleed} from '#/components/images/Gallery'
import {Link} from '#/components/Link'
import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
@@ -308,234 +309,243 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
return (
<>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
<View
testID={`postThreadItem-by-${post.author.handle}`}
style={[
{
paddingHorizontal: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
<View collapsable={false}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
onBeforePress={onOpenAuthor}
/>
</View>
<Link
to={authorHref}
style={[a.flex_1]}
label={sanitizeDisplayName(
post.author.displayName || sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
onPress={onOpenAuthor}>
<View style={[a.flex_1, a.align_start]}>
<ProfileHoverCard did={post.author.did} style={[a.w_full]}>
<View style={[a.flex_row, a.align_center]}>
<GalleryBleed>
<View
testID={`postThreadItem-by-${post.author.handle}`}
style={[
{
paddingHorizontal: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
<View collapsable={false}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
onBeforePress={onOpenAuthor}
/>
</View>
<Link
to={authorHref}
style={[a.flex_1]}
label={sanitizeDisplayName(
post.author.displayName || sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
onPress={onOpenAuthor}>
<View style={[a.flex_1, a.align_start]}>
<ProfileHoverCard did={post.author.did} style={[a.w_full]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.flex_shrink,
a.text_lg,
a.font_semi_bold,
a.leading_snug,
]}
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
</Text>
<View style={[a.pl_xs]}>
<ProfileBadges
profile={authorShadow}
size="md"
interactive
/>
</View>
</View>
<Text
emoji
style={[
a.flex_shrink,
a.text_lg,
a.font_semi_bold,
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
{sanitizeHandle(post.author.handle, '@')}
</Text>
<View style={[a.pl_xs]}>
<ProfileBadges
profile={authorShadow}
size="md"
interactive
/>
</View>
</View>
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
</Text>
</ProfileHoverCard>
</View>
</Link>
<View collapsable={false} style={[a.self_center]}>
<ThreadItemAnchorFollowButton
did={post.author.did}
enabled={showFollowButton}
/>
</View>
</View>
<View style={[a.pb_sm]}>
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
<ContentHider
modui={moderation.ui('contentView')}
ignoreMute
childContainerStyle={[a.pt_sm]}>
<PostAlerts
modui={moderation.ui('contentView')}
size="lg"
includeMute
style={[a.pb_sm]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<RichText
enableTags
selectable
value={richText}
style={[a.flex_1, a.text_lg]}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
) : undefined}
<TranslatedPost post={post} postTextStyle={[a.text_lg]} />
{post.embed && (
<View style={[a.py_xs]}>
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.ThreadHighlighted}
onOpen={onOpenEmbed}
/>
</ProfileHoverCard>
</View>
)}
</ContentHider>
<ExpandedPostDetails
post={item.value.post}
isThreadAuthor={isThreadAuthor}
/>
{post.repostCount !== 0 ||
post.likeCount !== 0 ||
post.quoteCount !== 0 ||
post.bookmarkCount !== 0 ? (
// Show this section unless we're *sure* it has no engagement.
</Link>
<View collapsable={false} style={[a.self_center]}>
<ThreadItemAnchorFollowButton
did={post.author.did}
enabled={showFollowButton}
/>
</View>
</View>
<View style={[a.pb_sm]}>
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
<ContentHider
modui={moderation.ui('contentView')}
ignoreMute
childContainerStyle={[a.pt_sm]}>
<PostAlerts
modui={moderation.ui('contentView')}
size="lg"
includeMute
style={[a.pb_sm]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<RichText
enableTags
selectable
value={richText}
style={[a.flex_1, a.text_lg]}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
) : undefined}
<TranslatedPost post={post} postTextStyle={[a.text_lg]} />
{post.embed && (
<View style={[richText?.text ? a.py_xs : []]}>
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.ThreadHighlighted}
onOpen={onOpenEmbed}
/>
</View>
)}
</ContentHider>
<ExpandedPostDetails
post={item.value.post}
isThreadAuthor={isThreadAuthor}
/>
{post.repostCount !== 0 ||
post.likeCount !== 0 ||
post.quoteCount !== 0 ||
post.bookmarkCount !== 0 ? (
// Show this section unless we're *sure* it has no engagement.
<View
style={[
a.flex_row,
a.flex_wrap,
a.align_center,
{
rowGap: a.gap_sm.gap,
columnGap: a.gap_lg.gap,
},
a.border_t,
a.border_b,
a.mt_md,
a.py_md,
t.atoms.border_contrast_low,
]}>
{post.repostCount != null && post.repostCount !== 0 ? (
<Link to={repostsHref} label={l`Reposts of this post`}>
<Text
testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)">
<Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
</Trans>
</Text>
</Link>
) : null}
{post.quoteCount != null &&
post.quoteCount !== 0 &&
!post.viewer?.embeddingDisabled ? (
<Link to={quotesHref} label={l`Quotes of this post`}>
<Text
testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)">
<Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
</Trans>
</Text>
</Link>
) : null}
{post.likeCount != null && post.likeCount !== 0 ? (
<Link to={likesHref} label={l`Likes on this post`}>
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.likeCount)}
</Text>{' '}
<Plural
value={post.likeCount}
one="like"
other="likes"
/>
</Trans>
</Text>
</Link>
) : null}
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
<Text
testID="bookmarkCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.bookmarkCount)}
</Text>{' '}
<Plural
value={post.bookmarkCount}
one="save"
other="saves"
/>
</Trans>
</Text>
) : null}
</View>
) : null}
<View
style={[
a.flex_row,
a.flex_wrap,
a.align_center,
a.pt_sm,
a.pb_2xs,
{
rowGap: a.gap_sm.gap,
columnGap: a.gap_lg.gap,
marginLeft: -5,
},
a.border_t,
a.border_b,
a.mt_md,
a.py_md,
t.atoms.border_contrast_low,
]}>
{post.repostCount != null && post.repostCount !== 0 ? (
<Link to={repostsHref} label={l`Reposts of this post`}>
<Text
testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
</Trans>
</Text>
</Link>
) : null}
{post.quoteCount != null &&
post.quoteCount !== 0 &&
!post.viewer?.embeddingDisabled ? (
<Link to={quotesHref} label={l`Quotes of this post`}>
<Text
testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
</Trans>
</Text>
</Link>
) : null}
{post.likeCount != null && post.likeCount !== 0 ? (
<Link to={likesHref} label={l`Likes on this post`}>
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.likeCount)}
</Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
</Trans>
</Text>
</Link>
) : null}
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
<Text
testID="bookmarkCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.bookmarkCount)}
</Text>{' '}
<Plural
value={post.bookmarkCount}
one="save"
other="saves"
/>
</Trans>
</Text>
) : null}
<FeedFeedbackProvider value={feedFeedback}>
<PostControls
big
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
feedContext={postSource?.post?.feedContext}
reqId={postSource?.post?.reqId}
viaRepost={viaRepost}
/>
</FeedFeedbackProvider>
</View>
) : null}
<View
style={[
a.pt_sm,
a.pb_2xs,
{
marginLeft: -5,
},
]}>
<FeedFeedbackProvider value={feedFeedback}>
<PostControls
big
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
feedContext={postSource?.post?.feedContext}
reqId={postSource?.post?.reqId}
viaRepost={viaRepost}
/>
</FeedFeedbackProvider>
<DebugFieldDisplay subject={post} />
</View>
<DebugFieldDisplay subject={post} />
</View>
</View>
</GalleryBleed>
</>
)
})
@@ -32,6 +32,10 @@ import {atoms as a, useTheme} from '#/alf'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {
GalleryBleed,
maybeApplyGalleryOffsetStyles,
} from '#/components/images/Gallery'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
@@ -131,18 +135,20 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
!item.ui.showParentReplyLine && overrides?.topBorder !== true
return (
<View
style={[
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
{paddingHorizontal: OUTER_SPACE},
// If there's no next child, add a little padding to bottom
!item.ui.showChildReplyLine &&
!item.ui.precedesChildReadMore && {
paddingBottom: OUTER_SPACE / 2,
},
]}>
{children}
</View>
<GalleryBleed>
<View
style={[
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
{paddingHorizontal: OUTER_SPACE},
// If there's no next child, add a little padding to bottom
!item.ui.showChildReplyLine &&
!item.ui.precedesChildReadMore && {
paddingBottom: OUTER_SPACE / 2,
},
]}>
{children}
</View>
</GalleryBleed>
)
})
@@ -295,7 +301,14 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
moderation={moderation}
timestamp={post.indexedAt}
postHref={postHref}
style={[a.pb_xs]}
style={[
a.pb_xs,
maybeApplyGalleryOffsetStyles('meta', {
post,
modui: moderation.ui('contentList'),
additionalCauses: additionalPostAlerts,
}),
]}
/>
<LabelsOnMyPost post={post} style={[a.pb_xs]} />
<PostAlerts
@@ -323,7 +336,15 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
) : undefined}
<TranslatedPost hideTranslateLink post={post} />
{post.embed && (
<View style={[a.pb_xs]}>
<View
style={[
maybeApplyGalleryOffsetStyles('embed', {
post,
modui: moderation.ui('contentList'),
additionalCauses: additionalPostAlerts,
}),
a.pb_xs,
]}>
<Embed
embed={post.embed}
moderation={moderation}
@@ -32,6 +32,7 @@ import {atoms as a, useTheme} from '#/alf'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {GalleryBleed} from '#/components/images/Gallery'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
@@ -129,33 +130,35 @@ const ThreadItemTreePostOuterWrapper = memo(
const indents = Math.max(0, item.ui.indent - 1)
return (
<View
style={[
a.flex_row,
item.ui.indent === 1 &&
!item.ui.showParentReplyLine && [
a.border_t,
t.atoms.border_contrast_low,
],
]}>
{Array.from(Array(indents)).map((_, n: number) => {
const isSkipped = item.ui.skippedIndentIndices.has(n)
return (
<View
key={`${item.value.post.uri}-padding-${n}`}
style={[
<GalleryBleed>
<View
style={[
a.flex_row,
item.ui.indent === 1 &&
!item.ui.showParentReplyLine && [
a.border_t,
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})}
{children}
</View>
],
]}>
{Array.from(Array(indents)).map((_, n: number) => {
const isSkipped = item.ui.skippedIndentIndices.has(n)
return (
<View
key={`${item.value.post.uri}-padding-${n}`}
style={[
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})}
{children}
</View>
</GalleryBleed>
)
},
)
+12 -10
View File
@@ -96,7 +96,12 @@ export function SearchScreenShell({
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
// Query terms
const [searchText, setSearchText] = useState<string>(queryParam)
const [searchText, _setSearchText] = useState<string>(queryParam)
const searchTextRef = useRef(searchText)
const setSearchText = (text: string) => {
searchTextRef.current = text
_setSearchText(text)
}
const {data: autocompleteData, isFetching: isAutocompleteFetching} =
useActorAutocompleteQuery(searchText, true)
@@ -227,15 +232,12 @@ export function SearchScreenShell({
}
}, [setShowAutocomplete, setSearchText, navigation, route.params, route.name])
const onSubmit = useCallback(
(source: 'typed' | 'autocomplete') => () => {
ax.metric('search:query', {
source,
})
navigateToItem(searchText)
},
[ax, navigateToItem, searchText],
)
const onSubmit = (source: 'typed' | 'autocomplete') => () => {
ax.metric('search:query', {
source,
})
navigateToItem(searchTextRef.current)
}
const onAutocompleteResultPress = useCallback(() => {
if (IS_WEB) {
+1 -1
View File
@@ -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'
+85 -29
View File
@@ -1,6 +1,6 @@
import {
type AtpAgent,
type ChatBskyActorDefs,
ChatBskyActorDefs,
ChatBskyConvoDefs,
type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage,
@@ -37,6 +37,7 @@ import {
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBusError} from '#/state/messages/events/types'
import {IS_NATIVE} from '#/env'
import * as bsky from '#/types/bsky'
const logger = Logger.create(Logger.Context.ConversationAgent)
@@ -112,6 +113,9 @@ export class Convo {
this.markConvoAccepted = this.markConvoAccepted.bind(this)
this.addReaction = this.addReaction.bind(this)
this.removeReaction = this.removeReaction.bind(this)
this.isGroup = this.isGroup.bind(this)
this.getGroupInfo = this.getGroupInfo.bind(this)
this.getPrimaryMember = this.getPrimaryMember.bind(this)
}
private commit() {
@@ -155,6 +159,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
}
}
case ConvoStatus.Disabled:
@@ -175,6 +182,9 @@ export class Convo {
markConvoAccepted: this.markConvoAccepted,
addReaction: this.addReaction,
removeReaction: this.removeReaction,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
}
}
case ConvoStatus.Error: {
@@ -192,6 +202,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: undefined,
getGroupInfo: undefined,
getPrimaryMember: undefined,
}
}
default: {
@@ -209,6 +222,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
}
}
}
@@ -222,7 +238,7 @@ export class Convo {
switch (action.event) {
case ConvoDispatchEvent.Init: {
this.status = ConvoStatus.Initializing
this.setup()
void this.setup()
this.setupFirehose()
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
break
@@ -234,12 +250,12 @@ export class Convo {
switch (action.event) {
case ConvoDispatchEvent.Ready: {
this.status = ConvoStatus.Ready
this.fetchMessageHistory()
void this.fetchMessageHistory()
break
}
case ConvoDispatchEvent.Background: {
this.status = ConvoStatus.Backgrounded
this.fetchMessageHistory()
void this.fetchMessageHistory()
this.requestPollInterval(BACKGROUND_POLL_INTERVAL)
break
}
@@ -258,7 +274,7 @@ export class Convo {
}
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
this.fetchMessageHistory() // finish init
void this.fetchMessageHistory() // finish init
this.cleanupFirehoseConnection?.()
this.withdrawRequestedPollInterval()
break
@@ -269,7 +285,7 @@ export class Convo {
case ConvoStatus.Ready: {
switch (action.event) {
case ConvoDispatchEvent.Resume: {
this.refreshConvo()
void this.refreshConvo()
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
break
}
@@ -308,11 +324,11 @@ export class Convo {
} else {
if (this.convo) {
this.status = ConvoStatus.Ready
this.refreshConvo()
void this.refreshConvo()
this.maybeRecoverFromNetworkError()
} else {
this.status = ConvoStatus.Initializing
this.setup()
void this.setup()
}
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
}
@@ -435,7 +451,7 @@ export class Convo {
this.firehoseError = undefined
this.commit()
} else {
this.batchRetryPendingMessages()
void this.batchRetryPendingMessages()
}
if (this.fetchMessageHistoryError) {
@@ -487,7 +503,8 @@ export class Convo {
} else {
this.dispatch({event: ConvoDispatchEvent.Ready})
}
} catch (e: any) {
} catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error('setup failed', {
safeMessage: e.message,
@@ -557,11 +574,7 @@ export class Convo {
async fetchConvo() {
if (this.pendingFetchConvo) return this.pendingFetchConvo
this.pendingFetchConvo = new Promise<{
convo: ChatBskyConvoDefs.ConvoView
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[]
}>(async (resolve, reject) => {
this.pendingFetchConvo = (async () => {
try {
const response = await networkRetry(2, () => {
return this.agent.api.chat.bsky.convo.getConvo(
@@ -574,17 +587,15 @@ export class Convo {
const convo = response.data.convo
resolve({
return {
convo,
sender: convo.members.find(m => m.did === this.senderUserDid),
recipients: convo.members.filter(m => m.did !== this.senderUserDid),
})
} catch (e) {
reject(e)
}
} finally {
this.pendingFetchConvo = undefined
}
})
})()
return this.pendingFetchConvo
}
@@ -596,7 +607,8 @@ export class Convo {
this.convo = convo || this.convo
this.sender = sender || this.sender
this.recipients = recipients || this.recipients
} catch (e: any) {
} catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error(`failed to refresh convo`, {
safeMessage: e.message,
@@ -664,7 +676,8 @@ export class Convo {
this.pastMessages.set(message.id, message)
}
}
} catch (e: any) {
} catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error('failed to fetch message history', {
safeMessage: e.message,
@@ -673,7 +686,7 @@ export class Convo {
this.fetchMessageHistoryError = {
retry: () => {
this.fetchMessageHistory()
void this.fetchMessageHistory()
},
}
} finally {
@@ -716,7 +729,7 @@ export class Convo {
onFirehoseConnect() {
this.firehoseError = undefined
this.batchRetryPendingMessages()
void this.batchRetryPendingMessages()
this.commit()
}
@@ -761,8 +774,8 @@ export class Convo {
/**
* If this message is already in new messages, it was added by our
* sending logic, and is based on client-ordering. When we receive
* the "commited" event from the log, we should replace this
* reference and re-insert in order to respect the order we receied
* the "committed" event from the log, we should replace this
* reference and re-insert in order to respect the order we received
* from the log.
*/
if (this.newMessages.has(ev.message.id)) {
@@ -836,7 +849,7 @@ export class Convo {
this.commit()
if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) {
this.processPendingMessages()
void this.processPendingMessages()
}
}
@@ -912,7 +925,7 @@ export class Convo {
}
}
private handleSendMessageFailure(e: any) {
private handleSendMessageFailure(e: Error | XRPCError) {
if (e instanceof XRPCError) {
if (NETWORK_FAILURE_STATUSES.includes(e.status)) {
this.pendingMessageFailure = 'recoverable'
@@ -1026,7 +1039,8 @@ export class Convo {
{encoding: 'application/json', headers: DM_SERVICE_HEADERS},
)
})
} catch (e: any) {
} catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error(`failed to delete message`, {
safeMessage: e.message,
@@ -1334,4 +1348,46 @@ export class Convo {
throw error
}
}
// Group utilities
isGroup(): boolean | undefined {
if (!this.convo) return undefined
const info = this.getGroupInfo()
return !!info
}
getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined {
if (
this.convo &&
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
this.convo.kind,
ChatBskyConvoDefs.isGroupConvo,
)
) {
return this.convo.kind
}
return undefined
}
getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined {
if (this.isGroup()) {
return this.recipients?.find(r => {
if (
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
r.kind,
ChatBskyActorDefs.isGroupConvoMember,
)
) {
return r.kind.role === 'owner'
} else {
throw new Error(
'Expected a GroupConvoMember, got an unknown kind of member',
)
}
})
} else {
return this.recipients?.find(r => r.did !== this.senderUserDid)
}
}
}
+24
View File
@@ -144,6 +144,9 @@ type FetchMessageHistory = () => Promise<void>
type MarkConvoAccepted = () => void
type AddReaction = (messageId: string, reaction: string) => Promise<void>
type RemoveReaction = (messageId: string, reaction: string) => Promise<void>
type IsGroup = () => boolean | undefined
type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvo | undefined
type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined
export type ConvoStateUninitialized = {
status: ConvoStatus.Uninitialized
@@ -159,6 +162,9 @@ export type ConvoStateUninitialized = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateInitializing = {
status: ConvoStatus.Initializing
@@ -174,6 +180,9 @@ export type ConvoStateInitializing = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateReady = {
status: ConvoStatus.Ready
@@ -189,6 +198,9 @@ export type ConvoStateReady = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateBackgrounded = {
status: ConvoStatus.Backgrounded
@@ -204,6 +216,9 @@ export type ConvoStateBackgrounded = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateSuspended = {
status: ConvoStatus.Suspended
@@ -219,6 +234,9 @@ export type ConvoStateSuspended = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateError = {
status: ConvoStatus.Error
@@ -234,6 +252,9 @@ export type ConvoStateError = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: undefined
getGroupInfo: undefined
getPrimaryMember: undefined
}
export type ConvoStateDisabled = {
status: ConvoStatus.Disabled
@@ -249,6 +270,9 @@ export type ConvoStateDisabled = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoState =
| ConvoStateUninitialized
@@ -0,0 +1,37 @@
import {type ChatBskyGroupCreateGroup} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {precacheConvoQuery} from './conversation'
export function useCreateGroupChat({
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupCreateGroup.OutputSchema) => void
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async ({name, members}: {name: string; members: string[]}) => {
const {data} = await agent.chat.bsky.group.createGroup(
{name, members},
{headers: DM_SERVICE_HEADERS},
)
return data
},
onSuccess: data => {
precacheConvoQuery(queryClient, data.convo)
onSuccess?.(data)
},
onError: error => {
logger.error(error)
onError?.(error)
},
})
}
@@ -31,13 +31,13 @@ export function useMuteConvo(
mutationFn: async ({mute}: {mute: boolean}) => {
if (!convoId) throw new Error('No convoId provided')
if (mute) {
const {data} = await agent.api.chat.bsky.convo.muteConvo(
const {data} = await agent.chat.bsky.convo.muteConvo(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
} else {
const {data} = await agent.api.chat.bsky.convo.unmuteConvo(
const {data} = await agent.chat.bsky.convo.unmuteConvo(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
@@ -19,9 +19,9 @@ export type QueryProps = {
export const getSuggestedUsersForDiscoverQueryKeyRoot =
'unspecced-suggested-users-for-explore'
export const createGetSuggestedUsersForDiscoverQueryKey = (
props: QueryProps,
) => [getSuggestedUsersForDiscoverQueryKeyRoot, props.limit]
export const createGetSuggestedUsersForDiscoverQueryKey = (props: {
limit?: number
}) => [getSuggestedUsersForDiscoverQueryKeyRoot, props.limit]
export function useGetSuggestedUsersForDiscoverQuery(props: QueryProps = {}) {
const agent = useAgent()
@@ -29,7 +29,7 @@ export function useGetSuggestedUsersForDiscoverQuery(props: QueryProps = {}) {
return useQuery({
staleTime: STALE.MINUTES.THREE,
queryKey: createGetSuggestedUsersForDiscoverQueryKey(props),
queryKey: createGetSuggestedUsersForDiscoverQueryKey({limit: props.limit}),
queryFn: async () => {
const contentLangs = getContentLanguages().join(',')
const userInterests = aggregateUserInterests(preferences)
@@ -16,21 +16,27 @@ import {useAgent} from '#/state/session'
export type QueryProps = {
category?: string | null
limit?: number
enabled?: boolean
}
export const getSuggestedUsersForSeeMoreQueryKeyRoot =
'unspecced-suggested-users-for-explore'
export const createGetSuggestedUsersForSeeMoreQueryKey = (
props: QueryProps,
) => [getSuggestedUsersForSeeMoreQueryKeyRoot, props.category, props.limit]
export const createGetSuggestedUsersForSeeMoreQueryKey = (props: {
category?: string | null
limit?: number
}) => [getSuggestedUsersForSeeMoreQueryKeyRoot, props.category, props.limit]
export function useGetSuggestedUsersForSeeMoreQuery(props: QueryProps = {}) {
const agent = useAgent()
const {data: preferences} = usePreferencesQuery()
return useQuery({
enabled: props.enabled ?? true,
staleTime: STALE.MINUTES.THREE,
queryKey: createGetSuggestedUsersForSeeMoreQueryKey(props),
queryKey: createGetSuggestedUsersForSeeMoreQueryKey({
category: props.category,
limit: props.limit,
}),
queryFn: async () => {
const contentLangs = getContentLanguages().join(',')
const userInterests = aggregateUserInterests(preferences)
+96 -106
View File
@@ -54,9 +54,8 @@ import {
type BskyAgent,
type RichText,
} from '@atproto/api'
import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -197,12 +196,13 @@ export const ComposePost = ({
cancelRef?: React.RefObject<CancelRef | null>
}) => {
const {currentAccount} = useSession()
const t = useTheme()
const ax = useAnalytics()
const agent = useAgent()
const queryClient = useQueryClient()
const currentDid = currentAccount!.did
const {closeComposer} = useComposerControls()
const {_} = useLingui()
const {t: l, i18n} = useLingui()
const requireAltTextEnabled = useRequireAltTextEnabled()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
@@ -313,7 +313,7 @@ export const ComposePost = ({
abortController,
},
})
processVideo(
void processVideo(
asset,
videoAction => {
composerDispatch({
@@ -328,10 +328,10 @@ export const ComposePost = ({
agent,
currentDid,
abortController.signal,
_,
i18n,
)
},
[_, agent, currentDid, composerDispatch],
[i18n, agent, currentDid, composerDispatch],
)
const onInitVideo = useNonReactiveCallback(() => {
@@ -460,7 +460,7 @@ export const ComposePost = ({
}
// Start video compression and upload
processVideo(
void processVideo(
asset,
videoAction => {
composerDispatch({
@@ -475,7 +475,7 @@ export const ComposePost = ({
agent,
currentDid,
abortController.signal,
_,
i18n,
)
} catch (e) {
logger.error('Failed to restore video from draft', {
@@ -484,7 +484,7 @@ export const ComposePost = ({
})
}
},
[_, agent, currentDid, composerDispatch],
[i18n, agent, currentDid, composerDispatch],
)
const handleSelectDraft = useCallback(
@@ -558,11 +558,11 @@ export const ComposePost = ({
const getDraftSaveError = useCallback(
(e: unknown): string => {
if (e instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) {
return _(msg`You've reached the maximum number of drafts`)
return l`You've reached the maximum number of drafts`
}
return _(msg`Failed to save draft`)
return l`Failed to save draft`
},
[_],
[l],
)
const validateDraftTextOrError = useCallback((): boolean => {
@@ -571,14 +571,12 @@ export const ComposePost = ({
)
if (tooLong) {
setError(
_(
msg`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`,
),
l`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`,
)
return false
}
return true
}, [composerState.thread.posts, _])
}, [composerState.thread.posts, l])
const handleSaveDraft = useCallback(async () => {
setError('')
@@ -768,21 +766,21 @@ export const ComposePost = ({
const media = thread.posts[i].embed.media
if (media) {
if (media.type === 'images' && media.images.some(img => !img.alt)) {
return _(msg`One or more images is missing alt text.`)
return l`One or more images is missing alt text.`
}
if (media.type === 'gif' && !media.alt) {
return _(msg`One or more GIFs is missing alt text.`)
return l`One or more GIFs is missing alt text.`
}
if (
media.type === 'video' &&
media.video.status !== 'error' &&
!media.video.altText
) {
return _(msg`One or more videos is missing alt text.`)
return l`One or more videos is missing alt text.`
}
}
}
}, [thread, requireAltTextEnabled, _])
}, [thread, requireAltTextEnabled, l])
const canPost =
!missingAltError &&
@@ -895,11 +893,9 @@ export const ComposePost = ({
let err = cleanError(e.message)
if (err.includes('not locate record')) {
err = _(
msg`We're sorry! The post you are replying to has been deleted.`,
)
err = l`We're sorry! The post you are replying to has been deleted.`
} else if (e instanceof EmbeddingDisabledError) {
err = _(msg`This post's author has disabled quote posts.`)
err = l`This post's author has disabled quote posts.`
}
setError(err)
setIsPublishing(false)
@@ -979,14 +975,14 @@ export const ComposePost = ({
<Toast.Icon />
<Toast.Text>
{thread.posts.length > 1
? _(msg`Your posts were sent`)
? l`Your posts were sent`
: replyTo
? _(msg`Your reply was sent`)
: _(msg`Your post was sent`)}
? l`Your reply was sent`
: l`Your post was sent`}
</Toast.Text>
{postUri && (
<Toast.Action
label={_(msg`View post`)}
label={l`View post`}
onPress={() => {
const {host: name, rkey} = new AtUri(postUri)
navigation.navigate('PostThread', {name, rkey})
@@ -1001,7 +997,7 @@ export const ComposePost = ({
)
}, 500)
}, [
_,
l,
ax,
agent,
thread,
@@ -1026,7 +1022,7 @@ export const ComposePost = ({
// Preserves the referential identity passed to each post item.
// Avoids re-rendering all posts on each keystroke.
const onComposerPostPublish = useNonReactiveCallback(() => {
onPressPublish()
void onPressPublish()
})
useEffect(() => {
@@ -1047,7 +1043,7 @@ export const ComposePost = ({
setPublishOnUpload(false)
} else if (uploadingVideos === 0) {
setPublishOnUpload(false)
onPressPublish()
void onPressPublish()
}
}
}, [thread.posts, onPressPublish, publishOnUpload])
@@ -1189,7 +1185,13 @@ export const ComposePost = ({
layout={native(LinearTransition)}
onScroll={scrollHandler}
contentContainerStyle={a.flex_grow}
style={a.flex_1}
style={[
a.flex_1,
web({
scrollbarGutter: 'stable',
scrollbarColor: `${t.palette.contrast_200} transparent`,
}),
]}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
@@ -1224,9 +1226,9 @@ export const ComposePost = ({
{replyTo ? (
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
title={l`Discard draft?`}
description=""
confirmButtonCta={_(msg`Discard`)}
confirmButtonCta={l`Discard`}
confirmButtonColor="negative"
onConfirm={handleDiscard}
/>
@@ -1264,21 +1266,17 @@ export const ComposePost = ({
<Prompt.Actions>
{allPostsWithinLimit && (
<Prompt.Action
cta={
composerState.draftId
? _(msg`Save changes`)
: _(msg`Save draft`)
}
cta={composerState.draftId ? l`Save changes` : l`Save draft`}
onPress={handleSaveDraft}
color="primary"
/>
)}
<Prompt.Action
cta={_(msg`Discard`)}
cta={l`Discard`}
onPress={handleDiscard}
color="negative_subtle"
/>
<Prompt.Cancel cta={_(msg`Keep editing`)} />
<Prompt.Cancel cta={l`Keep editing`} />
</Prompt.Actions>
</Prompt.Outer>
)}
@@ -1320,16 +1318,16 @@ let ComposerPost = memo(function ComposerPost({
}) {
const {currentAccount} = useSession()
const currentDid = currentAccount!.did
const {_} = useLingui()
const {t: l} = useLingui()
const {data: currentProfile} = useProfileQuery({did: currentDid})
const richtext = post.richtext
const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media
const forceMinHeight = IS_WEB && isTextOnly && isActive
const selectTextInputPlaceholder = isReply
? isFirstPost
? _(msg`Write your reply`)
: _(msg`Add another post`)
: _(msg`What's up?`)
? l`Write your reply`
: l`Add another post`
: l`What's up?`
const discardPromptControl = Prompt.usePromptControl()
const dispatchPost = useCallback(
@@ -1369,7 +1367,7 @@ let ComposerPost = memo(function ComposerPost({
if (IS_NATIVE) return // web only
const [mimeType] = uri.slice('data:'.length).split(';')
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
Toast.show(_(msg`Unsupported video type: ${mimeType}`), {
Toast.show(l`Unsupported video type: ${mimeType}`, {
type: 'error',
})
return
@@ -1384,7 +1382,7 @@ let ComposerPost = memo(function ComposerPost({
onImageAdd([res])
}
},
[post.id, onSelectVideo, onImageAdd, _],
[post.id, onSelectVideo, onImageAdd, l],
)
useHideKeyboardOnBackground()
@@ -1429,19 +1427,20 @@ let ComposerPost = memo(function ComposerPost({
onError={onError}
onPressPublish={onPublish}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={_(
msg`Compose posts up to ${plural(MAX_GRAPHEME_LENGTH || 0, {
accessibilityLabel={l`Write post`}
accessibilityHint={l`Compose posts up to ${plural(
MAX_GRAPHEME_LENGTH || 0,
{
other: '# characters',
})} in length`,
)}
},
)} in length`}
/>
</View>
{canRemovePost && isActive && (
<>
<Button
label={_(msg`Delete post`)}
label={l`Delete post`}
size="small"
color="secondary"
variant="ghost"
@@ -1466,15 +1465,15 @@ let ComposerPost = memo(function ComposerPost({
</Button>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard post?`)}
description={_(msg`Are you sure you'd like to discard this post?`)}
title={l`Discard post?`}
description={l`Are you sure you'd like to discard this post?`}
onConfirm={() => {
dispatch({
type: 'remove_post',
postId: post.id,
})
}}
confirmButtonCta={_(msg`Discard`)}
confirmButtonCta={l`Discard`}
confirmButtonColor="negative"
/>
</>
@@ -1531,7 +1530,8 @@ function ComposerTopBar({
children?: React.ReactNode
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
return (
<Animated.View
style={topBarAnimatedStyle}
@@ -1544,7 +1544,7 @@ function ComposerTopBar({
IS_LIQUID_GLASS ? [a.px_lg, a.pt_lg, a.pb_md] : [a.p_sm],
]}>
<Button
label={_(msg`Cancel`)}
label={l`Cancel`}
variant="ghost"
color="primary"
shape="default"
@@ -1552,9 +1552,7 @@ function ComposerTopBar({
style={[{paddingLeft: 7, paddingRight: 7}]}
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
onPress={onCancel}
accessibilityHint={_(
msg`Closes post composer and discards post draft`,
)}>
accessibilityHint={l`Closes post composer and discards post draft`}>
<ButtonText style={[a.text_md]} maxFontSizeMultiplier={2}>
<Trans>Cancel</Trans>
</ButtonText>
@@ -1588,35 +1586,27 @@ function ComposerTopBar({
label={
isReply
? isThread
? _(
msg({
message: 'Publish replies',
comment:
'Accessibility label for button to publish multiple replies in a thread',
}),
)
: _(
msg({
message: 'Publish reply',
comment:
'Accessibility label for button to publish a single reply',
}),
)
? l({
message: 'Publish replies',
comment:
'Accessibility label for button to publish multiple replies in a thread',
})
: l({
message: 'Publish reply',
comment:
'Accessibility label for button to publish a single reply',
})
: isThread
? _(
msg({
message: 'Publish posts',
comment:
'Accessibility label for button to publish multiple posts in a thread',
}),
)
: _(
msg({
message: 'Publish post',
comment:
'Accessibility label for button to publish a single post',
}),
)
? l({
message: 'Publish posts',
comment:
'Accessibility label for button to publish multiple posts in a thread',
})
: l({
message: 'Publish post',
comment:
'Accessibility label for button to publish a single post',
})
}
color="primary"
size="small"
@@ -1851,7 +1841,7 @@ function ComposerFooter({
openGallery?: boolean
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {isMobile} = useWebMediaQueries()
/*
* Once we've allowed a certain type of asset to be selected, we don't allow
@@ -1979,8 +1969,8 @@ function ComposerFooter({
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Opens emoji picker`)}
label={l`Open emoji picker`}
accessibilityHint={l`Opens emoji picker`}
variant="ghost"
shape="round"
color="primary">
@@ -1994,7 +1984,7 @@ function ComposerFooter({
<View style={[a.flex_row, a.align_center, a.justify_between]}>
{showAddButton && (
<Button
label={_(msg`Add another post to thread`)}
label={l`Add another post to thread`}
onPress={onAddPost}
style={[a.p_sm]}
variant="ghost"
@@ -2276,7 +2266,7 @@ function ErrorBanner({
clearVideo: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const videoError =
videoState.status === 'error' ? videoState.error : undefined
@@ -2311,7 +2301,7 @@ function ErrorBanner({
{error}
</Text>
<Button
label={_(msg`Dismiss error`)}
label={l`Dismiss error`}
size="tiny"
color="secondary"
variant="ghost"
@@ -2358,7 +2348,7 @@ function ToolbarWrapper({
function VideoUploadToolbar({state}: {state: VideoState}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const progress = state.progress
const shouldRotate =
state.status === 'processing' && (progress === 0 || progress === 1)
@@ -2390,34 +2380,34 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
switch (state.status) {
case 'compressing':
if (isGif) {
text = _(msg`Compressing GIF...`)
text = l`Compressing GIF...`
} else {
text = _(msg`Compressing video...`)
text = l`Compressing video...`
}
break
case 'uploading':
if (isGif) {
text = _(msg`Uploading GIF...`)
text = l`Uploading GIF...`
} else {
text = _(msg`Uploading video...`)
text = l`Uploading video...`
}
break
case 'processing':
if (isGif) {
text = _(msg`Processing GIF...`)
text = l`Processing GIF...`
} else {
text = _(msg`Processing video...`)
text = l`Processing video...`
}
break
case 'error':
text = _(msg`Error`)
text = l`Error`
wheelProgress = 100
break
case 'done':
if (isGif) {
text = _(msg`GIF uploaded`)
text = l`GIF uploaded`
} else {
text = _(msg`Video uploaded`)
text = l`Video uploaded`
}
break
}
+19 -19
View File
@@ -263,7 +263,7 @@ export async function processVideo(
agent: BskyAgent,
did: string,
signal: AbortSignal,
_: I18n['_'],
i18n: I18n,
) {
let video: CompressedVideo | undefined
try {
@@ -274,7 +274,7 @@ export async function processVideo(
signal,
})
} catch (e) {
const message = getCompressErrorMessage(e, _)
const message = getCompressErrorMessage(e, i18n)
if (message !== null) {
dispatch({
type: 'to_error',
@@ -297,13 +297,13 @@ export async function processVideo(
agent,
did,
signal,
_,
i18n,
setProgress: p => {
dispatch({type: 'update_progress', progress: p, signal})
},
})
} catch (e) {
const message = getUploadErrorMessage(e, _)
const message = getUploadErrorMessage(e, i18n)
if (message !== null) {
dispatch({
type: 'to_error',
@@ -355,7 +355,7 @@ export async function processVideo(
logger.error('Error processing video', {safeMessage: e})
dispatch({
type: 'to_error',
error: _(msg`Video failed to process`),
error: i18n._(msg`Video failed to process`),
signal,
})
return // Exit async loop
@@ -387,20 +387,20 @@ export async function processVideo(
}
}
function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null {
function getCompressErrorMessage(e: unknown, i18n: I18n): string | null {
if (e instanceof AbortError) {
return null
}
if (e instanceof VideoTooLargeError) {
return _(
return i18n._(
msg`The selected video is larger than 100 MB. Please try again with a smaller file.`,
)
}
logger.error('Error compressing video', {safeMessage: e})
return _(msg`An error occurred while compressing the video.`)
return i18n._(msg`An error occurred while compressing the video.`)
}
function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
function getUploadErrorMessage(e: unknown, i18n: I18n): string | null {
if (e instanceof AbortError) {
return null
}
@@ -408,38 +408,38 @@ function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
switch (e.message) {
case 'User is not allowed to upload videos':
return _(msg`You are not allowed to upload videos.`)
return i18n._(msg`You are not allowed to upload videos.`)
case 'Uploading is disabled at the moment':
return _(
return i18n._(
msg`Hold up! Were gradually giving access to video, and youre still waiting in line. Check back soon!`,
)
case "Failed to get user's upload stats":
return _(
return i18n._(
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
)
case 'User has exceeded daily upload bytes limit':
return _(
return i18n._(
msg`You've reached your daily limit for video uploads (too many bytes)`,
)
case 'User has exceeded daily upload videos limit':
return _(
return i18n._(
msg`You've reached your daily limit for video uploads (too many videos)`,
)
case 'Account is not old enough to upload videos':
return _(
return i18n._(
msg`Your account is not yet old enough to upload videos. Please try again later.`,
)
case 'file size (100000001 bytes) is larger than the maximum allowed size (100000000 bytes)':
return _(
return i18n._(
msg`The selected video is larger than 100 MB. Please try again with a smaller file.`,
)
case 'Confirm your email address to upload videos':
return _(msg`Please confirm your email address to upload videos.`)
return i18n._(msg`Please confirm your email address to upload videos.`)
}
}
if (isNetworkError(e)) {
return _(
return i18n._(
msg`An error occurred while uploading the video. Please check your internet connection and try again.`,
)
} else {
@@ -448,5 +448,5 @@ function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
}
const message = e instanceof Error ? e.message : ''
return _(msg`An error occurred while uploading the video. ${message}`)
return i18n._(msg`An error occurred while uploading the video. ${message}`)
}
+6 -6
View File
@@ -1,4 +1,4 @@
import * as React from 'react'
import {forwardRef, memo, useCallback, useState} from 'react'
import {type JSX} from 'react'
import {type ScrollView, View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated'
@@ -35,7 +35,7 @@ export interface PagerWithHeaderProps {
onPageSelected?: (index: number) => void
onCurrentPageSelected?: (index: number) => void
}
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
export const PagerWithHeader = forwardRef<PagerRef, PagerWithHeaderProps>(
function PageWithHeaderImpl(
{
children,
@@ -49,9 +49,9 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
}: PagerWithHeaderProps,
ref,
) {
const [currentPage, setCurrentPage] = React.useState(0)
const [currentPage, setCurrentPage] = useState(0)
const renderTabBar = React.useCallback(
const renderTabBar = useCallback(
(props: RenderTabBarFnProps) => {
return (
<PagerTabBar
@@ -76,7 +76,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
],
)
const onPageSelectedInner = React.useCallback(
const onPageSelectedInner = useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
@@ -162,7 +162,7 @@ let PagerTabBar = ({
</>
)
}
PagerTabBar = React.memo(PagerTabBar)
PagerTabBar = memo(PagerTabBar)
function PagerItem({
isFocused,
+101 -80
View File
@@ -27,6 +27,10 @@ import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a} from '#/alf'
import {
GalleryBleed,
maybeApplyGalleryOffsetStyles,
} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -155,89 +159,106 @@ function PostInner({
const [hover, setHover] = useState(false)
return (
<Link
href={itemHref}
style={[
styles.outer,
pal.border,
!hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth},
style,
]}
onBeforePress={onBeforePress}
onPointerEnter={() => {
setHover(true)
}}
onPointerLeave={() => {
setHover(false)
}}>
<SubtleHover hover={hover} />
{showReplyLine && <View style={styles.replyLine} />}
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
<View style={styles.layoutContent}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={itemHref}
/>
{replyAuthorDid !== '' && (
<PostRepliedTo parentAuthor={replyAuthorDid} />
)}
<LabelsOnMyPost post={post} />
<ContentHider
modui={moderation.ui('contentView')}
style={styles.contentHider}
childContainerStyle={styles.contentHiderChild}>
<PostAlerts
modui={moderation.ui('contentView')}
style={[a.pb_xs]}
<GalleryBleed>
<Link
href={itemHref}
style={[
styles.outer,
pal.border,
!hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth},
style,
]}
onBeforePress={onBeforePress}
onPointerEnter={() => {
setHover(true)
}}
onPointerLeave={() => {
setHover(false)
}}>
<SubtleHover hover={hover} />
{showReplyLine && <View style={styles.replyLine} />}
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
/>
{richText.text ? (
<View style={[a.mb_2xs]}>
<RichText
enableTags
testID="postText"
value={richText}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
style={[a.flex_1, a.text_md]}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
{limitLines && (
<ShowMoreTextButton
style={[a.text_md]}
onPress={onPressShowMore}
/>
)}
</View>
) : undefined}
<TranslatedPost hideTranslateLink post={post} />
{post.embed ? (
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
</View>
<View
style={[
styles.layoutContent,
maybeApplyGalleryOffsetStyles('meta', {
post,
modui: moderation.ui('contentList'),
additionalCauses: [],
}),
]}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={itemHref}
/>
{replyAuthorDid !== '' && (
<PostRepliedTo parentAuthor={replyAuthorDid} />
)}
<LabelsOnMyPost post={post} />
<ContentHider
modui={moderation.ui('contentView')}
style={styles.contentHider}
childContainerStyle={styles.contentHiderChild}>
<PostAlerts
modui={moderation.ui('contentView')}
style={[a.pb_xs]}
/>
) : null}
</ContentHider>
<PostControls
post={post}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="Post"
/>
{richText.text ? (
<View style={[a.mb_2xs]}>
<RichText
enableTags
testID="postText"
value={richText}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
style={[a.flex_1, a.text_md]}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
{limitLines && (
<ShowMoreTextButton
style={[a.text_md]}
onPress={onPressShowMore}
/>
)}
</View>
) : undefined}
<TranslatedPost hideTranslateLink post={post} />
{post.embed ? (
<View
style={maybeApplyGalleryOffsetStyles('embed', {
post,
modui: moderation.ui('contentList'),
additionalCauses: [],
})}>
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/>
</View>
) : null}
</ContentHider>
<PostControls
post={post}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="Post"
/>
</View>
</View>
</View>
</Link>
</Link>
</GalleryBleed>
)
}
+158 -139
View File
@@ -34,6 +34,10 @@ import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a} from '#/alf'
import {
GalleryBleed,
maybeApplyGalleryOffsetStyles,
} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -163,6 +167,7 @@ let FeedItemInner = ({
const queryClient = useQueryClient()
const {openComposer} = useOpenComposer()
const pal = usePalette('default')
const {currentAccount} = useSession()
const [hover, setHover] = useState(false)
@@ -293,140 +298,6 @@ let FeedItemInner = ({
}
}, [reason])
return (
<Link
testID={`feedItem-by-${post.author.handle}`}
style={outerStyles}
href={href}
noFeedback
accessible={false}
onBeforePress={onBeforePress}
dataSet={{feedContext}}
onPointerEnter={() => {
setHover(true)
}}
onPointerLeave={() => {
setHover(false)
}}>
<SubtleHover hover={hover} />
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
<View style={{width: 42}}>
{isThreadChild && (
<View
style={[
styles.replyLine,
{
flexGrow: 1,
backgroundColor: pal.colors.replyLine,
marginBottom: 4,
},
]}
/>
)}
</View>
<View style={[a.pt_sm, a.flex_shrink]}>
{reason && (
<PostFeedReason
reason={reason}
moderation={moderation}
onOpenReposter={onOpenReposter}
/>
)}
</View>
</View>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
onBeforePress={onOpenAuthor}
live={live}
/>
{isThreadParent && (
<View
style={[
styles.replyLine,
{
flexGrow: 1,
backgroundColor: pal.colors.replyLine,
marginTop: live ? 8 : 4,
},
]}
/>
)}
</View>
<View style={styles.layoutContent}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={href}
onOpenAuthor={onOpenAuthor}
/>
{showReplyTo &&
(parentAuthor || isParentBlocked || isParentNotFound) && (
<PostRepliedTo
parentAuthor={parentAuthor}
isParentBlocked={isParentBlocked}
isParentNotFound={isParentNotFound}
/>
)}
<LabelsOnMyPost post={post} />
<PostContent
moderation={moderation}
richText={richText}
postEmbed={post.embed}
postAuthor={post.author}
onOpenEmbed={onOpenEmbed}
post={post}
threadgateRecord={threadgateRecord}
/>
<PostControls
post={post}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="FeedItem"
feedContext={feedContext}
reqId={reqId}
threadgateRecord={threadgateRecord}
onShowLess={onShowLess}
viaRepost={viaRepost}
/>
</View>
<DiscoverDebug feedContext={feedContext} />
</View>
</Link>
)
}
FeedItemInner = memo(FeedItemInner)
let PostContent = ({
post,
moderation,
richText,
postEmbed,
postAuthor,
onOpenEmbed,
threadgateRecord,
}: {
moderation: ModerationDecision
richText: RichTextAPI
postEmbed: AppBskyFeedDefs.PostView['embed']
postAuthor: AppBskyFeedDefs.PostView['author']
onOpenEmbed: () => void
post: AppBskyFeedDefs.PostView
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const {currentAccount} = useSession()
const [limitLines, setLimitLines] = useState(
() => countLines(richText.text) >= MAX_POST_LINES,
)
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
@@ -451,6 +322,150 @@ let PostContent = ({
: []
}, [post, currentAccount?.did, threadgateHiddenReplies])
return (
<GalleryBleed>
<Link
testID={`feedItem-by-${post.author.handle}`}
style={outerStyles}
href={href}
noFeedback
accessible={false}
onBeforePress={onBeforePress}
dataSet={{feedContext}}
onPointerEnter={() => {
setHover(true)
}}
onPointerLeave={() => {
setHover(false)
}}>
<SubtleHover hover={hover} />
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
<View style={{width: 42}}>
{isThreadChild && (
<View
style={[
styles.replyLine,
{
flexGrow: 1,
backgroundColor: pal.colors.replyLine,
marginBottom: 4,
},
]}
/>
)}
</View>
<View style={[a.pt_sm, a.flex_shrink]}>
{reason && (
<PostFeedReason
reason={reason}
moderation={moderation}
onOpenReposter={onOpenReposter}
/>
)}
</View>
</View>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
onBeforePress={onOpenAuthor}
live={live}
/>
{isThreadParent && (
<View
style={[
styles.replyLine,
{
flexGrow: 1,
backgroundColor: pal.colors.replyLine,
marginTop: live ? 8 : 4,
},
]}
/>
)}
</View>
<View
style={[
styles.layoutContent,
maybeApplyGalleryOffsetStyles('meta', {
post,
modui: moderation.ui('contentList'),
additionalCauses: additionalPostAlerts,
}),
]}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={href}
onOpenAuthor={onOpenAuthor}
/>
{showReplyTo &&
(parentAuthor || isParentBlocked || isParentNotFound) && (
<PostRepliedTo
parentAuthor={parentAuthor}
isParentBlocked={isParentBlocked}
isParentNotFound={isParentNotFound}
/>
)}
<LabelsOnMyPost post={post} />
<PostContent
moderation={moderation}
richText={richText}
postEmbed={post.embed}
postAuthor={post.author}
onOpenEmbed={onOpenEmbed}
post={post}
additionalPostAlerts={additionalPostAlerts}
/>
<PostControls
post={post}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="FeedItem"
feedContext={feedContext}
reqId={reqId}
threadgateRecord={threadgateRecord}
onShowLess={onShowLess}
viaRepost={viaRepost}
/>
</View>
<DiscoverDebug feedContext={feedContext} />
</View>
</Link>
</GalleryBleed>
)
}
FeedItemInner = memo(FeedItemInner)
let PostContent = ({
post,
moderation,
richText,
postEmbed,
postAuthor,
onOpenEmbed,
additionalPostAlerts,
}: {
moderation: ModerationDecision
richText: RichTextAPI
postEmbed: AppBskyFeedDefs.PostView['embed']
postAuthor: AppBskyFeedDefs.PostView['author']
onOpenEmbed: () => void
post: AppBskyFeedDefs.PostView
additionalPostAlerts?: AppModerationCause[]
}): React.ReactNode => {
const [limitLines, setLimitLines] = useState(
() => countLines(richText.text) >= MAX_POST_LINES,
)
const record = useMemo<AppBskyFeedPost.Record | undefined>(
() =>
bsky.validate(post.record, AppBskyFeedPost.validateRecord)
@@ -492,7 +507,15 @@ let PostContent = ({
) : undefined}
{record && <TranslatedPost hideTranslateLink post={post} />}
{postEmbed ? (
<View style={[a.pb_xs]}>
<View
style={[
a.pb_xs,
maybeApplyGalleryOffsetStyles('embed', {
post,
modui: moderation.ui('contentList'),
additionalCauses: additionalPostAlerts,
}),
]}>
<Embed
embed={postEmbed}
moderation={moderation}
@@ -524,13 +547,9 @@ const styles = StyleSheet.create({
layoutAvi: {
paddingLeft: 8,
paddingRight: 10,
position: 'relative',
zIndex: 999,
},
layoutContent: {
position: 'relative',
flex: 1,
zIndex: 0,
},
alert: {
marginTop: 6,
@@ -1,4 +1,4 @@
import * as React from 'react'
import {useEffect, useRef} from 'react'
import {View} from 'react-native'
// Based on @react-navigation/native-stack/src/navigators/createNativeStackNavigator.ts
// MIT License
@@ -82,7 +82,7 @@ function NativeStackNavigator({
UNSTABLE_router,
})
React.useEffect(
useEffect(
() =>
// @ts-expect-error: there may not be a tab navigator in parent
navigation?.addListener?.('tabPress', (e: any) => {
@@ -110,7 +110,7 @@ function NativeStackNavigator({
// --- our custom logic starts here ---
// Web LRU: tracks route keys in most-recently-focused order
const lruKeysRef = React.useRef<string[]>([])
const lruKeysRef = useRef<string[]>([])
const {hasSession, currentAccount} = useSession()
const activeRoute = state.routes[state.index]
const activeDescriptor = descriptors[activeRoute.key]
+24 -24
View File
@@ -20,14 +20,14 @@
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@atproto/api@^0.19.8":
version "0.19.8"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.8.tgz#ae847abece43f0108535c6305780079e8782ab29"
integrity sha512-b79kuI3AzEmpLLi9afRNq6T0KFEEVL4d+vHFAtWxeDwS7lfwUOIIngMjAVvwmwC5nJRZIrK8L9d4y7LD8zdvsg==
"@atproto/api@^0.19.9":
version "0.19.9"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.9.tgz#f09ed8412159d6878eeaf25a0a8b4445c62fa9eb"
integrity sha512-+sUYNuiA1Rv8HemMCURHwRkMp2D7cq6nNquefjosu6UB54IzkD0MLK3YY383poLRShiApouOxRse2OKK25dbQw==
dependencies:
"@atproto/common-web" "^0.4.20"
"@atproto/common-web" "^0.4.21"
"@atproto/lexicon" "^0.6.2"
"@atproto/syntax" "^0.5.3"
"@atproto/syntax" "^0.5.4"
"@atproto/xrpc" "^0.7.7"
await-lock "^2.2.2"
multiformats "^9.9.0"
@@ -44,14 +44,14 @@
"@atproto/syntax" "^0.5.1"
zod "^3.23.8"
"@atproto/common-web@^0.4.20":
version "0.4.20"
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.20.tgz#bb455868e674d45ed1044c68ccccae3c08168d47"
integrity sha512-RcsYT28yQgVi/Glb/hHPGpqpzIlKrbMLeldEd7PmmMLWDaJL2j3lb92qytvxjl1yhi2Ssq2TEuMZ2NlWaAbpow==
"@atproto/common-web@^0.4.21":
version "0.4.21"
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.21.tgz#2198583f842a000f495f1caec6f7e4eda207191b"
integrity sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==
dependencies:
"@atproto/lex-data" "^0.0.15"
"@atproto/lex-json" "^0.0.15"
"@atproto/syntax" "^0.5.3"
"@atproto/lex-json" "^0.0.16"
"@atproto/syntax" "^0.5.4"
zod "^3.23.8"
"@atproto/lex-data@^0.0.14":
@@ -82,10 +82,10 @@
"@atproto/lex-data" "^0.0.14"
tslib "^2.8.1"
"@atproto/lex-json@^0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.15.tgz#34d300e5dfd8a0ec76ca7363f264a488e17c1bd9"
integrity sha512-kCLdP629H6GhgPjBTpZibUoqlpmW0hnVfZVwcD4s4Jch1KAqY/QcfL24Ih8wrW0Ok1YvtMIhjk98evdTA2OJcw==
"@atproto/lex-json@^0.0.16":
version "0.0.16"
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.16.tgz#c99b5147560310f9f7f74405c57858a12c3e365a"
integrity sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==
dependencies:
"@atproto/lex-data" "^0.0.15"
tslib "^2.8.1"
@@ -108,10 +108,10 @@
dependencies:
tslib "^2.8.1"
"@atproto/syntax@^0.5.3":
version "0.5.3"
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.3.tgz#4331d01f63fe56c374dcf95d4432a22b62271a17"
integrity sha512-gzhlHOJHm5KXdCc17fXi1fXM81ccs5jJfNgCui84ay9JGvczxegpYHNqdMlv+iBuhtBzFIjgx6ChjRxN/kO8kQ==
"@atproto/syntax@^0.5.4":
version "0.5.4"
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.4.tgz#89842eb8b8ab181752b04ed840cc6b100e296b00"
integrity sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==
dependencies:
tslib "^2.8.1"
@@ -8700,10 +8700,10 @@ eslint-plugin-react@^7.37.5:
string.prototype.matchall "^4.0.12"
string.prototype.repeat "^1.0.0"
eslint-plugin-simple-import-sort@^12.1.1:
version "12.1.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-12.1.1.tgz#e64bfdaf91c5b98a298619aa634a9f7aa43b709e"
integrity sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==
eslint-plugin-simple-import-sort@^13.0.0:
version "13.0.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-13.0.0.tgz#93936354367d8bb42c1b9b4c13c92eb29fffd2a5"
integrity sha512-McAc+/Nlvcg4byY/CABGH8kqnefWBj8s3JA2okEtz8ixbECQgU46p0HkTUKa4YS7wvgGceimlc34p1nXqbWqtA==
eslint-scope@5.1.1:
version "5.1.1"