Merge remote-tracking branch 'origin/main' into app-2273

* origin/main: (38 commits)
  Mount message dialogs once at the list level (#10435)
  Fix alt text selection in lightbox on Android (and iOS) (#10698)
  Mention default `<Text>` style in CLAUDE.md (#10705)
  Use square icons for labellers in Automation Label preview card (#10701)
  [ALF] Set text to `leading_snug` by default (#10627)
  Nightly source-language update
  Transpile `@atproto/api` package for older browser support (#10700)
  Add iOS peek long-press context menu for image embeds (#10300)
  Revert "[ALG-96] Don't fall back to search for onboarding user suggestions (#10680)" (#10699)
  fix blur on image alt text background (#10658)
  Create screen for managing chat requests (#10635)
  Don't show Suggested heading if there are no suggestions (#10695)
  Prefer opengraph image, fall back to Standard Site coverImage (#10694)
  Create logged-out view for group chat invites (#10598)
  Bump actions/create-github-app-token from 1 to 3 (#10691)
  Bump marocchino/sticky-pull-request-comment from 2 to 3 (#10692)
  Bump slackapi/slack-github-action from 2.1.1 to 3.0.3 (#10693)
  ci: add dependabot config with 7-day cooldown (#10475)
  Remove image upload flags (#10687)
  [ALG-96] Don't fall back to search for onboarding user suggestions (#10680)
  ...

# Conflicts:
#	src/components/Lightbox/chrome/Footer.tsx
This commit is contained in:
Eric Bailey
2026-06-03 11:30:59 -05:00
111 changed files with 4500 additions and 1667 deletions
+6 -6
View File
@@ -47,18 +47,18 @@ import {
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LandingProvider} from '#/state/shell/landing'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Shell} from '#/view/shell'
import {atoms as a, ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {useLandingEntry} from '#/components/hooks/useLandingEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
@@ -113,7 +113,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {t: l} = useLingui()
const hasCheckedReferrer = useStarterPackEntry()
const hasCheckedLanding = useLandingEntry()
// init
useEffect(() => {
@@ -146,7 +146,7 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<ContextMenuProvider>
<Splash isReady={isReady && hasCheckedReferrer}>
<Splash isReady={isReady && hasCheckedLanding}>
<VideoVolumeProvider>
<Fragment
// Resets the entire tree below when it changes:
@@ -246,12 +246,12 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<LandingProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</LandingProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
+6 -6
View File
@@ -40,17 +40,17 @@ import {
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LandingProvider} from '#/state/shell/landing'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {useLandingEntry} from '#/components/hooks/useLandingEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
@@ -92,7 +92,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {t: l} = useLingui()
const hasCheckedReferrer = useStarterPackEntry()
const hasCheckedLanding = useLandingEntry()
// init
useEffect(() => {
@@ -125,7 +125,7 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<ContextMenuProvider>
<Splash isReady={isReady && hasCheckedReferrer}>
<Splash isReady={isReady && hasCheckedLanding}>
<VideoVolumeProvider>
<ActiveVideoProvider>
<Fragment
@@ -224,9 +224,9 @@ function App() {
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<LandingProvider>
<InnerApp />
</StarterPackProvider>
</LandingProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
+20
View File
@@ -44,6 +44,7 @@ import {
type State,
} from '#/lib/routes/types'
import {bskyTitle} from '#/lib/strings/headings'
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
@@ -81,6 +82,7 @@ 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 {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
@@ -487,6 +489,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesConversationSettingsScreen}
options={{title: title(msg`Group chat settings`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesJoinRequests"
getComponent={() => MessagesJoinRequestsScreen}
options={{title: title(msg`Requests to join`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesSettings"
getComponent={() => MessagesSettingsScreen}
@@ -797,6 +804,19 @@ const LINKING = {
return buildStateObject('Flat', 'Home', params)
}
// Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
// path to Home so the dialog overlays Home instead of NotFound. On native,
// react-navigation strips the `bluesky://` prefix and passes the path
// without a leading slash, so normalize before matching.
const normalizedPath = path.startsWith('/') ? path : `/${path}`
if (CHAT_INVITE_CODE_REGEX.test(normalizedPath.split('?')[0])) {
if (IS_NATIVE) {
return buildStateObject('HomeTab', 'Home', params)
}
return buildStateObject('Flat', 'Home', params)
}
if (IS_NATIVE) {
if (name === 'Search') {
return buildStateObject('SearchTab', 'Search', params)
-3
View File
@@ -9,14 +9,11 @@ export enum Features {
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
ImageUploadsHighResolution = 'image_uploads:high_resolution',
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
GroupChatsEnable = 'group_chats:enable',
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
LargeVideoUploads = 'large_video_uploads:enable',
AATest = 'aa-test',
}
+16 -10
View File
@@ -28,23 +28,29 @@ type Layout = {
border?: boolean
}
type Props = {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
size?: number
moderationOpts?: ModerationOpts
}
export function AvatarBubbles({
animate = false,
profiles: allProfiles,
self = false,
size = 120,
moderationOpts,
}: Props) {
}: {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
/**
* By default, when there are more than 2 profiles, the current user is
* filtered out (so you don't see yourself among your own group's members).
* Set this to `true` for cases where every passed profile should appear,
* e.g. an invite preview where the owner is meaningful regardless of viewer.
*/
self?: boolean
size?: number
moderationOpts?: ModerationOpts
}) {
const {currentAccount} = useSession()
const profiles =
allProfiles.length > 2
? allProfiles.filter(p => p.did !== currentAccount?.did)
!self && allProfiles.length > 2
? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did)
: allProfiles
const moderations = useMemo(() => {
if (!moderationOpts) return []
+3 -3
View File
@@ -757,11 +757,11 @@ export function useSharedButtonTextStyles() {
}
if (size === 'large') {
baseStyles.push(a.text_md, a.leading_snug, a.font_medium)
baseStyles.push(a.text_md, a.font_medium)
} else if (size === 'small') {
baseStyles.push(a.text_sm, a.leading_snug, a.font_medium)
baseStyles.push(a.text_sm, a.font_medium)
} else if (size === 'tiny') {
baseStyles.push(a.text_xs, a.leading_snug, a.font_semi_bold)
baseStyles.push(a.text_xs, a.font_semi_bold)
}
return flatten(baseStyles)
+5 -1
View File
@@ -230,7 +230,11 @@ function LightboxGallery({
style={[
a.px_4xl,
a.py_2xl,
{backgroundColor: 'rgba(0, 0, 0, 0.45)'},
{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
// @ts-expect-error web only
backdropFilter: 'blur(16px)',
},
delayedFadeInAnim,
]}>
<Pressable
+63 -40
View File
@@ -1,18 +1,13 @@
import {useRef} from 'react'
import {
LayoutAnimation,
Pressable,
ScrollView,
StyleSheet,
View,
} from 'react-native'
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {BlurView} from 'expo-blur'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, platform, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
type Props = {
@@ -46,37 +41,66 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
{paddingBottom: insets.bottom + 8},
]}>
<View style={[a.mx_md, styles.altWrap]}>
<ScrollView
style={{maxHeight}}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={[a.px_md, a.py_sm]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Expand alt text`}
accessibilityHint=""
onPress={() => {
if (isMomentumScrolling.current) return
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
onToggleAltExpanded()
}}>
<Text
emoji
selectable
style={[a.text_sm, {color: t.palette.white}]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</Pressable>
</ScrollView>
<BlurView
intensity={16}
tint="dark"
style={[
// Tint kept over the blur so dense, text-heavy images stay
// readable. On Android the blur falls back to a flat overlay, so
// bump the opacity to keep the contrast the real blur provides
// elsewhere.
platform({
ios: {backgroundColor: 'rgba(0, 0, 0, 0.5)'},
android: {backgroundColor: 'rgba(0, 0, 0, 0.7)'},
}),
]}>
<ScrollView
style={{maxHeight}}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={[a.px_md, a.py_sm]}>
<View
accessibilityRole="button"
accessibilityLabel={l`Expand alt text`}
accessibilityHint="">
{/*
* The press handlers must live on the Text itself, not on a
* wrapping Pressable. Text selection is driven by the platform's
* native text view long-press (RN Text on Android, UITextView on
* iOS). A parent touchable consumes that long-press before the
* selectable Text can begin a selection - on Android this prevents
* selection entirely. Keeping onPress/onLongPress on the Text lets
* the same native node own both the tap-to-expand and the
* long-press-to-select. The empty onLongPress is intentional: it
* reserves the long-press for the OS selection gesture instead of
* firing the expand toggle. RN exposes no API to arbitrate tap vs
* native selection on a single node, so this is the supported
* workaround, and it behaves consistently on both platforms.
*/}
<Text
emoji
selectable
style={[a.text_sm, {color: t.palette.white}]}
numberOfLines={isAltExpanded ? undefined : 3}
onPress={() => {
if (isMomentumScrolling.current) return
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
onToggleAltExpanded()
}}
onLongPress={() => {}}>
{altText}
</Text>
</View>
</ScrollView>
</BlurView>
</View>
</View>
)
@@ -84,7 +108,6 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
const styles = StyleSheet.create({
altWrap: {
backgroundColor: 'rgba(0, 0, 0, 0.45)',
borderRadius: 12,
overflow: 'hidden',
},
+10
View File
@@ -12,6 +12,7 @@ import {
} from '@react-navigation/native'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
@@ -19,6 +20,7 @@ import {shareUrl} from '#/lib/sharing'
import {
convertBskyAppUrlIfNeeded,
createProxiedUrl,
getChatInviteCodeFromUrl,
isBskyDownloadUrl,
isExternalUrl,
linkRequiresWarning,
@@ -130,6 +132,7 @@ export function useLink({
const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const openLink = useOpenLink()
const groupChatJoinIntent = useGroupChatJoinIntent()
const onPress = useCallback(
(e: GestureResponderEvent) => {
@@ -148,6 +151,12 @@ export function useLink({
e.preventDefault()
}
const chatInviteCode = getChatInviteCodeFromUrl(href)
if (chatInviteCode) {
groupChatJoinIntent(chatInviteCode, href)
return
}
if (requiresWarning) {
linkWarningDialogControl.open({
displayText,
@@ -228,6 +237,7 @@ export function useLink({
overridePresentation,
shouldProxy,
linkWarningDialogControl,
groupChatJoinIntent,
],
)
+60 -10
View File
@@ -1,11 +1,16 @@
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyFeedDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {shareImageModal} from '#/lib/media/manip'
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
import {isGifEmbed} from '#/lib/strings/embed-player'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, tokens, useTheme} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as PeekMenu from '#/components/PeekMenu'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import * as bsky from '#/types/bsky'
@@ -16,9 +21,11 @@ import * as bsky from '#/types/bsky'
export function Embed({
embed,
style,
peekable = false,
}: {
embed: AppBskyFeedDefs.PostView['embed']
style?: StyleProp<ViewStyle>
peekable?: boolean
}) {
const e = bsky.post.parseEmbed(embed)
@@ -27,13 +34,17 @@ export function Embed({
if (e.type === 'images') {
return (
<Outer style={style}>
{e.view.images.map(image => (
<ImageItem
key={image.thumb}
thumbnail={image.thumb}
alt={image.alt}
/>
))}
{e.view.images.map(image =>
peekable ? (
<PeekableImageItem key={image.thumb} image={image} />
) : (
<ImageItem
key={image.thumb}
thumbnail={image.thumb}
alt={image.alt}
/>
),
)}
</Outer>
)
} else if (e.type === 'link') {
@@ -156,6 +167,45 @@ export function VideoItem({
)
}
function PeekableImageItem({image}: {image: AppBskyEmbedImages.ViewImage}) {
const {t: l} = useLingui()
const saveImage = useSaveImageToMediaLibrary()
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
? image.aspectRatio.width / image.aspectRatio.height
: undefined
return (
<PeekMenu.Root style={[a.flex_1, {maxWidth: 100}]}>
<PeekMenu.Trigger
preview={{
type: 'image',
uri: image.fullsize,
thumbUri: image.thumb,
aspectRatio: aspect && aspect > 0 ? aspect : 1,
}}
borderRadius={tokens.borderRadius.xs}>
<ImageItem thumbnail={image.thumb} alt={image.alt} />
</PeekMenu.Trigger>
<PeekMenu.Menu>
<PeekMenu.MenuItem
id="save"
onSelect={() => void saveImage(image.fullsize)}>
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
<PeekMenu.MenuItem
id="share"
onSelect={() => void shareImageModal({uri: image.fullsize})}>
<PeekMenu.MenuItemIcon icon={ShareIcon} />
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
</PeekMenu.Menu>
</PeekMenu.Root>
)
}
const styles = StyleSheet.create({
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
+1
View File
@@ -0,0 +1 @@
export * from '@bsky.app/peek-menu'
@@ -0,0 +1,79 @@
import {type ReactNode} from 'react'
import {type StyleProp, type ViewStyle} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {shareImageModal} from '#/lib/media/manip'
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import * as PeekMenu from '#/components/PeekMenu'
import {IS_IOS} from '#/env'
/**
* Wraps an image embed with the iOS peek-and-menu interaction. On non-iOS
* platforms this renders children unchanged.
*
* The aspect ratio is consumed by the native side to size the preview
* viewController correctly — which is what makes the lift animation clean
* for portrait/panorama images.
*/
export function ImageContextMenu({
fullsizeUri,
thumbUri,
aspectRatio,
borderRadius,
onPreviewPress,
style,
children,
}: {
fullsizeUri: string
/** Thumbnail URL. Used as an instant placeholder in the native preview
* while the fullsize loads, so there's no black flash on first peek. */
thumbUri?: string
/** width / height; defaults to 1 if missing. */
aspectRatio: number | undefined
borderRadius?: number
onPreviewPress?: () => void
style?: StyleProp<ViewStyle>
children: ReactNode
}) {
const {t: l} = useLingui()
const saveImage = useSaveImageToMediaLibrary()
if (!IS_IOS) {
return children
}
const handleSave = () => {
void saveImage(fullsizeUri)
}
const handleShare = () => {
void shareImageModal({uri: fullsizeUri})
}
return (
<PeekMenu.Root style={style}>
<PeekMenu.Trigger
preview={{
type: 'image',
uri: fullsizeUri,
thumbUri,
aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1,
}}
borderRadius={borderRadius}
onPreviewPress={onPreviewPress}>
{children}
</PeekMenu.Trigger>
<PeekMenu.Menu>
<PeekMenu.MenuItem id="save" onSelect={handleSave}>
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
<PeekMenu.MenuItem id="share" onSelect={handleShare}>
<PeekMenu.MenuItemIcon icon={ShareIcon} />
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
</PeekMenu.Menu>
</PeekMenu.Root>
)
}
+48 -16
View File
@@ -1,3 +1,4 @@
import {useRef} from 'react'
import {InteractionManager, View} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image'
@@ -8,6 +9,7 @@ import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {useLightboxControls} from '#/components/Lightbox/state'
import {type Dimensions} from '#/components/Lightbox/types'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post'
@@ -24,6 +26,11 @@ export function ImageEmbed({
const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
// ref + dims that a tap would — keeps the lightbox's return animation intact.
const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
const singleDimsRef = useRef<Dimensions | null>(null)
if (images.length > 0) {
const items = images.map(img => ({
uri: img.fullsize,
@@ -59,24 +66,49 @@ export function ImageEmbed({
if (images.length === 1) {
const image = images[0]
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
? image.aspectRatio.width / image.aspectRatio.height
: undefined
const openFromSingle = () => {
if (singleContainerRef.current) {
onPress(0, [singleContainerRef.current], [singleDimsRef.current])
}
}
return (
<View style={[a.mt_sm, rest.style]}>
<AutoSizedImage
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
onPressIn={() => onPressIn(0)}
hideBadge={
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
<ImageContextMenu
fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspect}
borderRadius={tokens.borderRadius.md}
onPreviewPress={openFromSingle}>
<AutoSizedImage
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onContainerRef={ref => {
singleContainerRef.current = ref
}}
onDimsChange={dims => {
singleDimsRef.current = dims
}}
onPress={(containerRef, dims) =>
onPress(0, [containerRef], [dims])
}
onPressIn={() => onPressIn(0)}
hideBadge={
rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
</ImageContextMenu>
</View>
)
}
-3
View File
@@ -11,7 +11,6 @@ import {useLingui} from '@lingui/react/macro'
import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
import {useHaptics} from '#/lib/haptics'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {type Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
@@ -93,7 +92,6 @@ let PostControls = ({
const requireAuth = useRequireAuth()
const {sendInteraction} = useFeedFeedbackContext()
const {captureAction} = useProgressGuideControls()
const playHaptic = useHaptics()
const isBlocked = Boolean(
post.author.viewer?.blocking ||
post.author.viewer?.blockedBy ||
@@ -116,7 +114,6 @@ let PostControls = ({
try {
setHasLikeIconBeenToggled(true)
if (!post.viewer?.like) {
playHaptic('Light')
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionLike',
+3 -1
View File
@@ -224,6 +224,7 @@ export function Basic({
cancelButtonCta,
confirmButtonCta,
onConfirm,
onClose,
confirmButtonColor,
showCancel = true,
}: React.PropsWithChildren<{
@@ -240,11 +241,12 @@ export function Basic({
* should NOT close the dialog as a side effect of this method.
*/
onConfirm: (e: GestureResponderEvent) => void
onClose?: () => void
confirmButtonColor?: ButtonColor
showCancel?: boolean
}>) {
return (
<Outer control={control} testID="confirmModal">
<Outer control={control} testID="confirmModal" onClose={onClose}>
<Content>
<TitleText>{title}</TitleText>
{description && <DescriptionText>{description}</DescriptionText>}
+1 -1
View File
@@ -66,7 +66,7 @@ export function RichText({
}
}, [value])
const plainStyles = [a.leading_snug, style]
const plainStyles = style
const interactiveStyles = [plainStyles, interactiveStyle]
const {text, facets} = richText
+1
View File
@@ -31,6 +31,7 @@ export function Text({
[
a.text_sm,
t.atoms.text,
a.leading_snug,
web(numberOfLines === 1 && numberOfLinesClippingFix),
style,
],
+1 -1
View File
@@ -219,7 +219,7 @@ export function AddMembersFlow({
}
}
if (searchText === '') {
if (searchText === '' && _items.length > 0) {
_items.unshift({
type: 'label',
key: 'suggested',
+6 -1
View File
@@ -33,14 +33,19 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({
control,
params,
currentScreen,
onClose,
}: {
control: Dialog.DialogControlProps
params: ReportDialogParams
currentScreen: 'list' | 'conversation'
onClose?: () => void
}): React.ReactNode {
const {t: l} = useLingui()
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={l`Would you like to block this user and/or delete this conversation?`}
+1
View File
@@ -316,6 +316,7 @@ export function InitiateChatFlow({
if (
searchText === '' &&
_items.length > 0 &&
(chatState === ChatState.NEW_CHAT ||
chatState === ChatState.NEW_GROUP_CHAT)
) {
+64 -112
View File
@@ -1,5 +1,5 @@
import {memo, useCallback} from 'react'
import {LayoutAnimation, Platform} from 'react-native'
import {Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
type ChatBskyConvoDefs,
@@ -7,26 +7,21 @@ import {
RichText,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
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 {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
@@ -48,11 +43,8 @@ export let MessageContextMenu = ({
const {t: l, i18n} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const blockOrDeleteControl = usePromptControl()
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
const langPrefs = useLanguagePrefs()
const translate = useGoogleTranslate()
@@ -93,14 +85,6 @@ export let MessageContextMenu = ({
})
}, [ax, langPrefs.primaryLanguage, message.text, translate])
const onDelete = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(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) => {
if (
@@ -128,104 +112,72 @@ export let MessageContextMenu = ({
const sender = senderProfile
return (
<>
<ContextMenu.Root>
{IS_NATIVE && reactionsAvailable && (
<ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
<EmojiReactionPicker
message={message}
onEmojiSelect={onEmojiSelect}
/>
</ContextMenu.AuxiliaryView>
)}
<ContextMenu.Trigger
label={l`Message options`}
contentLabel={l`Message from @${
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Outer
<ContextMenu.Root>
{IS_NATIVE && reactionsAvailable && (
<ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'}
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short',
})}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && (
<>
<ContextMenu.Item
testID="messageDropdownTranslateBtn"
label={l`Translate`}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={l`Copy message text`}
onPress={onCopyMessage}>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
<ContextMenu.ItemText>
{l`Copy message text`}
</ContextMenu.ItemText>
</ContextMenu.Item>
</>
)}
<EmojiReactionPicker
message={message}
onEmojiSelect={onEmojiSelect}
/>
</ContextMenu.AuxiliaryView>
)}
<ContextMenu.Trigger
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'}
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short',
})}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && (
<>
<ContextMenu.Item
testID="messageDropdownTranslateBtn"
label={l`Translate`}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={l`Copy message text`}
onPress={onCopyMessage}>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
<ContextMenu.ItemText>
{l`Copy message text`}
</ContextMenu.ItemText>
</ContextMenu.Item>
</>
)}
<ContextMenu.Item
destructive
testID="messageDropdownDeleteBtn"
label={l`Delete message for me`}
onPress={() => openDeleteMessage(message)}>
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
destructive
testID="messageDropdownDeleteBtn"
label={l`Delete message for me`}
onPress={() => deleteControl.open()}>
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
testID="messageDropdownReportBtn"
label={l`Report message`}
onPress={() => openReportMessage(message, senderProfile)}>
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
destructive
testID="messageDropdownReportBtn"
label={l`Report message`}
onPress={() => reportControl.open()}>
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
</ContextMenu.Item>
)}
</ContextMenu.Outer>
</ContextMenu.Root>
<ReportDialog
control={reportControl}
subject={{
view: 'message',
convoId: convo.convo.view.id,
message,
}}
onAfterSubmit={() => {
if (sender) {
unstableCacheProfileView(queryClient, sender)
}
blockOrDeleteControl.open()
}}
/>
<AfterReportDialog
control={blockOrDeleteControl}
currentScreen="conversation"
params={{
convoId: convo.convo.view.id,
did: message.sender.did,
}}
/>
<Prompt.Basic
control={deleteControl}
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}
/>
</>
)}
</ContextMenu.Outer>
</ContextMenu.Root>
)
}
MessageContextMenu = memo(MessageContextMenu)
+8 -39
View File
@@ -40,8 +40,8 @@ import {useSession} from '#/state/session'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {InlineLinkText, Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
@@ -49,7 +49,7 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
import {ReactionsDialog} from './ReactionsDialog'
import {groupReactions} from './ReactionsDialog'
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
const AVATAR_SIZE = 28
@@ -118,7 +118,7 @@ let MessageItem = ({
const {message} = item
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
const reactionsControl = useDialogControl()
const {openReactions} = useMessageDialogs()
const isPending = item.type === 'pending-message'
@@ -243,34 +243,10 @@ let MessageItem = ({
<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 groupedReactions = useMemo(
() => groupReactions(message.reactions),
[message.reactions],
)
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
@@ -336,7 +312,7 @@ let MessageItem = ({
transform: [{translateY: -8}],
},
]}
onPress={isGroupChat ? reactionsControl.open : undefined}>
onPress={isGroupChat ? () => openReactions(message) : undefined}>
{groupedReactions.map(group => (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
@@ -377,13 +353,6 @@ let MessageItem = ({
</Pressable>
</View>
) : null}
<ReactionsDialog
control={reactionsControl}
relatedProfiles={relatedProfiles}
message={message}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</LayoutAnimationConfig>
)
+175
View File
@@ -0,0 +1,175 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import {LayoutAnimation} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useConvoActive} from '#/state/messages/convo'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useDialogControl} from '#/components/Dialog'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {ReactionsDialog} from '#/components/dms/ReactionsDialog'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
type MessageDialogsContextType = {
openDeleteMessage: (message: ChatBskyConvoDefs.MessageView) => void
openReportMessage: (
message: ChatBskyConvoDefs.MessageView,
senderProfile: bsky.profile.AnyProfileView | undefined,
) => void
openReactions: (message: ChatBskyConvoDefs.MessageView) => void
}
const Context = createContext<MessageDialogsContextType | null>(null)
export function useMessageDialogs() {
const ctx = useContext(Context)
if (!ctx) {
throw new Error('useMessageDialogs must be used within a MessageOverlays')
}
return ctx
}
export function MessageOverlays({children}: {children: React.ReactNode}) {
const {t: l} = useLingui()
const queryClient = useQueryClient()
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const afterReportControl = usePromptControl()
const reactionsControl = useDialogControl()
const [deleteTarget, setDeleteTarget] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const [reportTarget, setReportTarget] = useState<{
message: ChatBskyConvoDefs.MessageView
senderProfile: bsky.profile.AnyProfileView | undefined
} | null>(null)
const [afterReportTarget, setAfterReportTarget] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const [reactionsTarget, setReactionsTarget] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const openDeleteMessage = useCallback(
(message: ChatBskyConvoDefs.MessageView) => {
setDeleteTarget(message)
deleteControl.open()
},
[deleteControl],
)
const openReportMessage = useCallback(
(
message: ChatBskyConvoDefs.MessageView,
senderProfile: bsky.profile.AnyProfileView | undefined,
) => {
setReportTarget({message, senderProfile})
reportControl.open()
},
[reportControl],
)
const openReactions = useCallback(
(message: ChatBskyConvoDefs.MessageView) => {
setReactionsTarget(message)
},
[],
)
// These dialogs are conditionally mounted, so we can't open them in the same
// tick that we set their targets - the control refs aren't attached yet. Open
// in an effect after the dialog has mounted.
useEffect(() => {
if (reactionsTarget) {
reactionsControl.open()
}
}, [reactionsTarget, reactionsControl])
useEffect(() => {
if (afterReportTarget) {
afterReportControl.open()
}
}, [afterReportTarget, afterReportControl])
const onConfirmDelete = useCallback(() => {
if (!deleteTarget) return
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(deleteTarget.id)
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
.catch(() => Toast.show(l`Failed to delete message`))
}, [l, convo, deleteTarget])
const onAfterReportSubmit = useCallback(() => {
if (!reportTarget) return
if (reportTarget.senderProfile) {
unstableCacheProfileView(queryClient, reportTarget.senderProfile)
}
setAfterReportTarget(reportTarget.message)
}, [queryClient, reportTarget])
const ctx = useMemo<MessageDialogsContextType>(
() => ({openDeleteMessage, openReportMessage, openReactions}),
[openDeleteMessage, openReportMessage, openReactions],
)
const reportSubject = reportTarget
? ({
view: 'message',
convoId: convo.convo.view.id,
message: reportTarget.message,
} as const)
: undefined
return (
<Context.Provider value={ctx}>
{children}
<ReportDialog
control={reportControl}
subject={reportSubject}
onAfterSubmit={onAfterReportSubmit}
onClose={() => setReportTarget(null)}
/>
{afterReportTarget && (
<AfterReportDialog
control={afterReportControl}
currentScreen="conversation"
params={{
convoId: convo.convo.view.id,
did: afterReportTarget.sender.did,
}}
onClose={() => setAfterReportTarget(null)}
/>
)}
{reactionsTarget && (
<ReactionsDialog
control={reactionsControl}
relatedProfiles={convo.relatedProfiles}
message={reactionsTarget}
onClose={() => setReactionsTarget(null)}
/>
)}
<Prompt.Basic
control={deleteControl}
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={onConfirmDelete}
onClose={() => setDeleteTarget(null)}
/>
</Context.Provider>
)
}
+32 -6
View File
@@ -1,4 +1,4 @@
import {useRef, useState} from 'react'
import {useMemo, useRef, useState} from 'react'
import {
LayoutAnimation,
Pressable,
@@ -37,14 +37,12 @@ export function ReactionsDialog({
control,
relatedProfiles,
message,
reactions,
groupedReactions,
onClose,
}: {
control: Dialog.DialogControlProps
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
message: ChatBskyConvoDefs.MessageView
reactions?: ChatBskyConvoDefs.ReactionView[]
groupedReactions?: Reaction[]
onClose?: () => void
}) {
const {t: l} = useLingui()
@@ -54,6 +52,9 @@ export function ReactionsDialog({
const [selected, setSelected] = useState('all')
const reactions = message.reactions
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
const filteredReactions = reactions?.filter(
r => selected === 'all' || r.value === selected,
)
@@ -78,7 +79,10 @@ export function ReactionsDialog({
return (
<Dialog.Outer
control={control}
onClose={() => setSelected('all')}
onClose={() => {
setSelected('all')
onClose?.()
}}
nativeOptions={{
preventExpansion: true,
minHeight: screenHeight / 2,
@@ -388,3 +392,25 @@ function ReactionTab({
</Pressable>
)
}
export function groupReactions(
reactions: ChatBskyConvoDefs.ReactionView[] | undefined,
): Reaction[] {
const grouped = new Map<string, Reaction>()
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())
}
+7 -3
View File
@@ -212,9 +212,12 @@ export function Item({
)
const highlightStyle = highlightRow
? selected
? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}]
: [a.rounded_full, a.p_md]
? [
a.rounded_full,
a.p_md,
hovered && t.atoms.bg_contrast_25,
selected && {backgroundColor: t.palette.primary_50},
]
: null
return (
@@ -588,6 +591,7 @@ export function RadioWithLabel({
style={[
a.font_medium,
a.flex_1,
a.text_md,
a.leading_tight,
selected ? t.atoms.text : t.atoms.text_contrast_high,
]}>
@@ -1,22 +1,45 @@
import {useEffect, useState} from 'react'
import * as Linking from 'expo-linking'
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import {
createStarterPackLinkFromAndroidReferrer,
httpStarterPackUriToAtUri,
} from '#/lib/strings/starter-pack'
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
import {useHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
import {
useSetActiveLanding,
useSetActiveStarterPack,
} from '#/state/shell/landing'
import {IS_ANDROID} from '#/env'
import {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
export function useStarterPackEntry() {
export function useLandingEntry() {
const [ready, setReady] = useState(false)
const setActiveStarterPack = useSetActiveStarterPack()
const setActiveLanding = useSetActiveLanding()
const hasCheckedForStarterPack = useHasCheckedForStarterPack()
useEffect(() => {
if (ready) return
// Check for group chat invite link from the initial deep link URL
const linkingUrl = Linking.getLinkingURL()
if (linkingUrl) {
const urlp = parseLinkingUrl(linkingUrl)
const chatInviteMatch = urlp.pathname.match(CHAT_INVITE_CODE_REGEX)
if (chatInviteMatch) {
setActiveLanding({
type: 'groupchat',
uri: linkingUrl,
code: chatInviteMatch[1],
})
setReady(true)
return
}
}
// On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So,
// let's just ensure we never check again after the first time.
if (hasCheckedForStarterPack) {
@@ -29,7 +52,8 @@ export function useStarterPackEntry() {
setReady(true)
}, 500)
;(async () => {
void (async () => {
// Check for starter pack
let uri: string | null | undefined
if (IS_ANDROID) {
@@ -58,7 +82,7 @@ export function useStarterPackEntry() {
return () => {
clearTimeout(timeout)
}
}, [ready, setActiveStarterPack, hasCheckedForStarterPack])
}, [ready, setActiveStarterPack, setActiveLanding, hasCheckedForStarterPack])
return ready
}
@@ -1,25 +1,27 @@
import {useEffect, useState} from 'react'
import {httpStarterPackUriToAtUri} from '#/lib/strings/starter-pack'
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
import {useSetActiveStarterPack} from '#/state/shell/landing'
export function useStarterPackEntry() {
export function useLandingEntry() {
const [ready, setReady] = useState(false)
const setActiveStarterPack = useSetActiveStarterPack()
useEffect(() => {
const href = window.location.href
const atUri = httpStarterPackUriToAtUri(href)
const url = new URL(href)
// Check for starter pack
const atUri = httpStarterPackUriToAtUri(href)
if (atUri) {
const url = new URL(href)
// Determines if an App Clip is loading this landing page
const isClip = url.searchParams.get('clip') === 'true'
setActiveStarterPack({
uri: atUri,
isClip,
})
setReady(true)
return
}
setReady(true)
+25 -5
View File
@@ -1,8 +1,20 @@
import {forwardRef} from 'react'
import {
forwardRef,
type ForwardRefExoticComponent,
type RefAttributes,
} from 'react'
import Svg, {Path} from 'react-native-svg'
import {type Props, useCommonSVGProps} from '#/components/icons/common'
export type IconWithSvgMeta = ForwardRefExoticComponent<
Props & RefAttributes<Svg>
> & {
svgPaths: string[]
svgViewBox: string
svgStrokeWidth: number
}
export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef(
function LogoImpl(props: Props, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
@@ -41,7 +53,7 @@ export function createSinglePathSVG({
strokeLinecap?: 'butt' | 'round' | 'square'
strokeLinejoin?: 'miter' | 'round' | 'bevel'
}) {
return forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
const hasStroke = strokeWidth > 0
@@ -68,7 +80,11 @@ export function createSinglePathSVG({
/>
</Svg>
)
})
}) as IconWithSvgMeta
Icon.svgPaths = [path]
Icon.svgViewBox = viewBox || '0 0 24 24'
Icon.svgStrokeWidth = strokeWidth
return Icon
}
export function createMultiPathSVG({
@@ -78,7 +94,7 @@ export function createMultiPathSVG({
paths: string[]
viewBox?: string
}) {
return forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
return (
@@ -102,5 +118,9 @@ export function createMultiPathSVG({
))}
</Svg>
)
})
}) as IconWithSvgMeta
Icon.svgPaths = paths
Icon.svgViewBox = viewBox || '0 0 24 24'
Icon.svgStrokeWidth = 0
return Icon
}
+15 -2
View File
@@ -1,4 +1,4 @@
import {useMemo, useRef} from 'react'
import {useEffect, useMemo, useRef} from 'react'
import {type DimensionValue, Pressable, View} from 'react-native'
import Animated, {
type AnimatedRef,
@@ -69,6 +69,8 @@ export function AutoSizedImage({
onPress,
onLongPress,
onPressIn,
onContainerRef,
onDimsChange,
}: {
image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained'
@@ -79,6 +81,11 @@ export function AutoSizedImage({
) => void
onLongPress?: () => void
onPressIn?: () => void
/** Fires once with the internal container ref so a parent can drive its
* own lightbox-return animation without waiting for an `onPress`. */
onContainerRef?: (ref: AnimatedRef<any>) => void
/** Fires when the underlying image reports its natural dimensions. */
onDimsChange?: (dims: Dimensions) => void
}) {
const t = useTheme()
const {_} = useLingui()
@@ -86,6 +93,10 @@ export function AutoSizedImage({
const containerRef = useAnimatedRef()
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
useEffect(() => {
onContainerRef?.(containerRef)
}, [containerRef, onContainerRef])
let aspectRatio: number | undefined
const dims = image.aspectRatio
if (dims) {
@@ -122,10 +133,12 @@ export function AutoSizedImage({
accessibilityHint=""
onLoad={e => {
if (!isContain) {
fetchedDimsRef.current = {
const dims = {
width: e.source.width,
height: e.source.height,
}
fetchedDimsRef.current = dims
onDimsChange?.(dims)
}
}}
loading="lazy"
+141 -131
View File
@@ -24,7 +24,7 @@ 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 {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {
@@ -36,6 +36,7 @@ import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandle
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
import {getAspectRatio} from '#/components/images/Gallery/utils'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
@@ -264,6 +265,21 @@ export function Gallery({
data={images}
keyExtractor={(item, index) => item.thumb + index}
renderItem={({item, index}) => {
const openLightboxAtIndex = 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
return (
<GalleryImage
hideBadges={hideBadges}
@@ -288,24 +304,9 @@ export function Gallery({
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
}
onPress={openLightboxAtIndex}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onPreviewPress={openLightboxAtIndex}
/>
)
}}
@@ -378,6 +379,7 @@ function GalleryImage({
onThumbDims,
onPress,
onPressIn,
onPreviewPress,
}: {
contentHeight: number
image: AppBskyEmbedImages.ViewImage
@@ -391,6 +393,7 @@ function GalleryImage({
onThumbDims: (index: number, dims: Dimensions) => void
onPress?: () => void
onPressIn?: () => void
onPreviewPress?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -416,124 +419,131 @@ function GalleryImage({
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={({pressed}) => [
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
web([
{
cursor: 'inherit',
outline: 0,
border: 0,
},
a.transition_transform,
{transitionDuration: '200ms'},
pressed && {transform: [{scale: 0.99}]},
]),
]}>
<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,
})
<ImageContextMenu
fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspectRatio}
borderRadius={tokens.borderRadius.md}
onPreviewPress={onPreviewPress}>
<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,
}}
/>
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
style={({pressed}) => [
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
web([
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
cursor: 'inherit',
outline: 0,
border: 0,
},
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}
a.transition_transform,
{transitionDuration: '200ms'},
pressed && {transform: [{scale: 0.99}]},
]),
]}>
<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,
})
}}
/>
<MediaInsetBorder
style={
focused && {
borderWidth: 2,
{(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>
/>
</Pressable>
</ImageContextMenu>
</Animated.View>
)
}
+56 -38
View File
@@ -9,8 +9,9 @@ import {Trans} from '@lingui/react/macro'
import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, tokens, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
@@ -52,46 +53,63 @@ export function GalleryItem({
const hasAlt = !!image.alt
const hideBadges =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
? image.aspectRatio.width / image.aspectRatio.height
: undefined
// The tap handler and the peek-commit handler do the same thing: open the
// lightbox with this cell's ref + dims so the lightbox's return animation
// can target the original thumbnail.
const openLightboxAtIndex = onPress
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
: undefined
return (
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
<Pressable
onPress={
onPress
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[
a.flex_1,
a.overflow_hidden,
t.atoms.bg_contrast_25,
imageStyle,
]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
<ImageContextMenu
fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspect}
borderRadius={tokens.borderRadius.md}
onPreviewPress={openLightboxAtIndex}
style={a.flex_1}>
<Pressable
onPress={openLightboxAtIndex}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
loading="lazy"
/>
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
style={[
a.flex_1,
a.overflow_hidden,
t.atoms.bg_contrast_25,
imageStyle,
]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
}}
loading="lazy"
/>
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
</ImageContextMenu>
{hasAlt && !hideBadges ? (
<View
accessible={false}
@@ -0,0 +1,432 @@
import {View} from 'react-native'
import {
ChatBskyGroupRequestJoin,
ChatBskyGroupWithdrawJoinRequest,
moderateProfile,
} from '@atproto/api'
import {Trans, 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 {type NavigationProp} from '#/lib/routes/types'
import {isNetworkError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
import {useRequestJoinGroupChat} from '#/state/queries/messages/request-join-group-chat'
import {useWithdrawJoinGroupChatRequest} from '#/state/queries/messages/withdraw-join-group-chat'
import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {
Button,
type ButtonColor,
ButtonIcon,
ButtonText,
} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight'
import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {ProfileBadges} from '../ProfileBadges'
export function GroupChatJoinDialog() {
const {groupChatJoinDialogControl, groupChatJoinState} = useIntentDialogs()
return (
<Dialog.Outer
control={groupChatJoinDialogControl}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<GroupChatJoinDialogInner code={groupChatJoinState?.code} />
</Dialog.Outer>
)
}
function GroupChatJoinDialogInner({code}: {code?: string}) {
const {t: l} = useLingui()
return (
<Dialog.ScrollableInner
label={l`Join group chat`}
style={[web({maxWidth: 400, borderRadius: 36})]}>
<View style={[a.gap_2xl, a.align_center]}>
<GroupChatJoinDialogContent code={code} />
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function GroupChatJoinDialogContent({code}: {code?: string}) {
const t = useTheme()
const {t: l} = useLingui()
const {groupChatJoinDialogControl: control} = useIntentDialogs()
const {hasSession} = useSession()
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {data, error, isLoading} = useJoinLinkPreviewsQuery({
codes: code ? [code] : undefined,
hasSession,
})
const {mutate: joinGroupChat, isPending: isJoinPending} =
useRequestJoinGroupChat({
onSuccess: data => {
switch (data.status) {
case 'pending':
control.close(() => {
Toast.show(
l`Access requested! The group owner will review your request.`,
)
})
break
case 'joined': {
if (data.convo && data.convo.id) {
control.close(() => {
Toast.show(l`Successfully joined the group chat!`)
navigation.navigate('MessagesConversation', {
conversation: data.convo!.id,
})
})
} else {
logger.warn('Request to join group chat returned no convo ID', {
status: data.status,
convoId: data.convo?.id,
})
}
break
}
}
},
onError: error => {
let errorMessage = l`Failed to join the group chat. Please try again.`
if (isNetworkError(error)) {
errorMessage = l`There was a problem with your internet connection, please try again`
} else if (error instanceof ChatBskyGroupRequestJoin.ConvoLockedError) {
errorMessage = l`This conversation is locked.`
} else if (
error instanceof ChatBskyGroupRequestJoin.FollowRequiredError
) {
errorMessage = l`Only followers can join this group chat.`
} else if (error instanceof ChatBskyGroupRequestJoin.InvalidCodeError) {
errorMessage = l`Invalid group chat code.`
} else if (
error instanceof ChatBskyGroupRequestJoin.LinkDisabledError
) {
errorMessage = l`This invite link has been disabled.`
} else if (
error instanceof ChatBskyGroupRequestJoin.MemberLimitReachedError
) {
errorMessage = l`The member limit has been reached.`
} else if (error instanceof ChatBskyGroupRequestJoin.UserKickedError) {
errorMessage = l`You have been removed from this group.`
}
Toast.show(errorMessage)
},
})
const {mutate: withdrawRequest, isPending: isWithdrawPending} =
useWithdrawJoinGroupChatRequest({
onSuccess: () => {
control.close(() => {
Toast.show(l`Join request rescinded.`)
})
},
onError: error => {
let errorMessage = l`Failed to rescind your request. Please try again.`
if (isNetworkError(error)) {
errorMessage = l`There was a problem with your internet connection, please try again`
} else if (
error instanceof
ChatBskyGroupWithdrawJoinRequest.InvalidJoinRequestError
) {
errorMessage = l`Invalid rescind request.`
}
Toast.show(errorMessage)
},
})
const {
state: interacted,
onIn: onInteract,
onOut: onInteractOut,
} = useInteractionState()
const handleJoin = () => {
if (!code) return
joinGroupChat({code})
}
const handleWithdraw = () => {
if (!convoId) return
withdrawRequest({convoId})
}
// Fallback if the prefetch exceeds the timeout
if (isLoading || !data || !moderationOpts) {
return (
<View style={[a.p_2xl]}>
<Loader size="xl" />
</View>
)
}
if (error) {
return (
<>
<ChainLinkBrokenIcon fill={t.palette.primary_500} size="3xl" />
<Text
style={[a.text_center, a.text_lg, a.font_semi_bold, t.atoms.text]}>
<Trans>This invite link is invalid</Trans>
</Text>
<Button
label={l`Close this dialog`}
accessibilityHint={l`Close this dialog`}
onPress={() => control.close()}
color="primary"
size="large"
style={[a.w_full]}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</>
)
}
const joinLinkPreview = data.joinLinkPreviews[0]
if (!joinLinkPreview) {
return (
<>
<View style={[a.py_lg, a.align_center]}>
<View style={[a.gap_sm, a.align_center, a.mt_lg]}>
<WarningIcon size="3xl" fill={t.atoms.text_contrast_high.color} />
<Text
style={[
a.mb_2xs,
a.text_center,
a.text_sm,
a.font_medium,
t.atoms.text_contrast_high,
]}>
<Trans>Chat invite link no longer available</Trans>
</Text>
</View>
</View>
<Button
testID="joinButton"
onPress={() => control.close()}
label={l`Close this dialog`}
accessibilityHint={l`Close this dialog`}
size="large"
color="secondary"
style={[a.w_full]}>
<ButtonText>{l`Close`}</ButtonText>
</Button>
</>
)
}
const convoId = joinLinkPreview.convo?.id
const isFollowing = joinLinkPreview.owner.viewer?.following ?? false
const hasRequested = !convoId && joinLinkPreview.viewer?.requestedAt != null
let canJoin = true
let ButtonIconImage = isJoinPending || isWithdrawPending ? Loader : JoinIcon
let buttonText = joinLinkPreview.requireApproval
? l`Request to join`
: l`Join`
let buttonColor: ButtonColor = 'primary'
if (joinLinkPreview.enabledStatus !== 'enabled') {
canJoin = false
ButtonIconImage = WarningIcon
buttonText = l`Chat invite link no longer available`
buttonColor = 'secondary'
} else if (joinLinkPreview.memberCount >= joinLinkPreview.memberLimit) {
canJoin = false
ButtonIconImage = HandIcon
buttonText = l`This chat is full`
buttonColor = 'secondary'
} else if (joinLinkPreview.joinRule === 'followedByOwner' && !isFollowing) {
canJoin = false
ButtonIconImage = HandIcon
buttonText = l`Only people the chat owner follows can join`
buttonColor = 'secondary'
} else if (hasRequested) {
ButtonIconImage = XIcon
buttonText = l`Rescind request`
buttonColor = 'secondary'
}
return (
<>
<View style={[a.py_lg, a.align_center]}>
<AvatarBubbles
profiles={[
joinLinkPreview.owner,
...Array(joinLinkPreview.memberCount - 1).fill(undefined),
]}
self
size={135}
/>
<View style={[a.gap_sm, a.align_center, a.mt_lg]}>
<View>
<Text
style={[
a.mb_2xs,
a.text_center,
a.text_sm,
a.font_medium,
t.atoms.text_contrast_high,
]}>
<Trans>Group chat</Trans>
</Text>
<Text
style={[a.text_center, a.text_3xl, a.font_bold, t.atoms.text]}>
{joinLinkPreview.name}
</Text>
</View>
<View style={[a.flex_row, a.align_center]}>
<Text
style={[a.text_center, a.text_xs, a.leading_snug, t.atoms.text]}>
<Trans comment="The number of active group chat members out of the total number allowed.">
{joinLinkPreview.memberCount}/{joinLinkPreview.memberLimit}{' '}
members
</Trans>
</Text>
<View style={[a.flex_row, a.ml_md]}>
<PersonGroupIcon
size="xs"
style={[a.mr_xs, t.atoms.text, {marginTop: -2}]}
/>
</View>
<Text
style={[a.text_center, a.text_xs, a.leading_snug, t.atoms.text]}>
{joinLinkPreview.joinRule === 'followedByOwner'
? l`Followers can join`
: l`Anyone can join`}
</Text>
</View>
<View>
<View
style={[a.flex_row, a.gap_xs, a.align_center, a.justify_center]}>
<Text
style={[
a.mb_2xs,
a.text_center,
a.text_sm,
a.leading_snug,
a.font_semi_bold,
t.atoms.text,
]}>
By{' '}
<InlineLinkText
label={`@${joinLinkPreview.owner.handle}`}
to={makeProfileLink(joinLinkPreview.owner)}
style={[
a.mb_2xs,
a.text_sm,
a.font_semi_bold,
t.atoms.text,
interacted && {
...web({
outline: 0,
textDecorationLine: 'underline',
textDecorationColor: t.palette.contrast_1000,
}),
},
]}
{...web({
onMouseEnter: () => {
onInteract()
},
onMouseLeave: () => {
onInteractOut()
},
})}>
{createSanitizedDisplayName(
joinLinkPreview.owner,
true,
moderateProfile(joinLinkPreview.owner, moderationOpts).ui(
'displayName',
),
)}
</InlineLinkText>
</Text>
<ProfileBadges
profile={data.joinLinkPreviews[0].owner}
size="sm"
style={{marginTop: -3}}
/>
</View>
<Text
style={[
a.text_center,
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_high,
]}>
{sanitizeHandle(joinLinkPreview.owner.handle, '@')}
</Text>
</View>
</View>
</View>
{convoId ? (
<Button
testID="openButton"
onPress={() => {
control.close(() => {
navigation.navigate('MessagesConversation', {
conversation: convoId,
})
})
}}
label={l`Open group chat`}
accessibilityHint={l`Open this group chat`}
size="large"
color="primary"
disabled={!code}
style={[a.w_full]}>
<ButtonText>Open chat</ButtonText>
<ButtonIcon icon={ArrowRightIcon} />
</Button>
) : (
<Button
testID="joinButton"
onPress={hasRequested ? handleWithdraw : handleJoin}
label={
joinLinkPreview.requireApproval
? l`Request access to group chat`
: l`Join group chat`
}
accessibilityHint={
joinLinkPreview.requireApproval
? l`Request access to join this group chat`
: l`Join this group chat`
}
size="large"
color={buttonColor}
disabled={isJoinPending || isWithdrawPending || !code || !canJoin}
style={[a.w_full]}>
<ButtonIcon icon={ButtonIconImage} />
<ButtonText>{buttonText}</ButtonText>
</Button>
)}
</>
)
}
+69 -2
View File
@@ -1,13 +1,30 @@
import {createContext, useContext, useMemo, useState} from 'react'
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {usePrefetchJoinLinkPreviews} from '#/state/queries/join-links'
import {useSession} from '#/state/session'
import {
useActiveGroupChatJoinRequest,
useSetActiveLanding,
} from '#/state/shell/landing'
import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
import {GroupChatJoinDialog} from '#/components/intents/GroupChatJoinDialog'
import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
interface Context {
verifyEmailDialogControl: DialogControlProps
verifyEmailState: {code: string} | undefined
setVerifyEmailState: (state: {code: string} | undefined) => void
groupChatJoinDialogControl: DialogControlProps
groupChatJoinState: {code: string} | undefined
setGroupChatJoinState: (state: {code: string} | undefined) => void
}
const Context = createContext({} as Context)
@@ -19,20 +36,70 @@ export function Provider({children}: {children: React.ReactNode}) {
const [verifyEmailState, setVerifyEmailState] = useState<
{code: string} | undefined
>()
const groupChatJoinDialogControl = Dialog.useDialogControl()
const [groupChatJoinState, setGroupChatJoinState] = useState<
{code: string} | undefined
>()
const {hasSession} = useSession()
const groupChatLanding = useActiveGroupChatJoinRequest()
const setActiveLanding = useSetActiveLanding()
const prefetchJoinLinkPreviews = usePrefetchJoinLinkPreviews()
const landingHandledRef = useRef(false)
useEffect(() => {
if (hasSession && groupChatLanding && !landingHandledRef.current) {
landingHandledRef.current = true
const code = groupChatLanding.code
setActiveLanding(undefined)
const prefetch = prefetchJoinLinkPreviews({
codes: [code],
hasSession: true,
})
void Promise.race([
prefetch,
new Promise(res => setTimeout(res, 200)),
]).finally(() => {
setGroupChatJoinState({code})
groupChatJoinDialogControl.open()
})
}
if (!groupChatLanding) {
landingHandledRef.current = false
}
}, [
hasSession,
groupChatLanding,
setActiveLanding,
setGroupChatJoinState,
prefetchJoinLinkPreviews,
groupChatJoinDialogControl,
])
const value = useMemo(
() => ({
verifyEmailDialogControl,
verifyEmailState,
setVerifyEmailState,
groupChatJoinDialogControl,
groupChatJoinState,
setGroupChatJoinState,
}),
[verifyEmailDialogControl, verifyEmailState, setVerifyEmailState],
[
verifyEmailDialogControl,
verifyEmailState,
setVerifyEmailState,
groupChatJoinDialogControl,
groupChatJoinState,
setGroupChatJoinState,
],
)
return (
<Context.Provider value={value}>
{children}
<VerifyEmailIntentDialog />
<GroupChatJoinDialog />
</Context.Provider>
)
}
@@ -75,9 +75,11 @@ export function ReportDialog(
() => (props.subject ? parseReportSubject(props.subject) : undefined),
[props.subject],
)
const propsOnClose = props.onClose
const onClose = useCallback(() => {
ax.metric('reportDialog:close', {})
}, [ax])
propsOnClose?.()
}, [ax, propsOnClose])
return (
<Dialog.Outer control={props.control} onClose={onClose}>
<Dialog.Handle />
@@ -88,4 +88,8 @@ export type ReportDialogProps = {
* Called if the report was successfully submitted.
*/
onAfterSubmit?: () => void
/**
* Called after the dialog finishes closing.
*/
onClose?: () => void
}
+2 -21
View File
@@ -51,16 +51,10 @@ interface PostOpts {
langs?: string[]
}
type FeatureFlags = {
highResolutionImages?: boolean
increasedBlobSizeLimit?: boolean
}
export async function post(
agent: BskyAgent,
queryClient: QueryClient,
opts: PostOpts,
featureFlags?: FeatureFlags,
) {
const thread = opts.thread
opts.onStateChange?.(t`Processing...`)
@@ -97,7 +91,6 @@ export async function post(
queryClient,
draft,
opts.onStateChange,
featureFlags,
)
let labels: $Typed<ComAtprotoLabelDefs.SelfLabels> | undefined
if (draft.labels.length) {
@@ -257,7 +250,6 @@ async function resolveEmbed(
queryClient: QueryClient,
draft: PostDraft,
onStateChange: ((state: string) => void) | undefined,
featureFlags?: FeatureFlags,
): Promise<
| $Typed<AppBskyEmbedImages.Main>
| $Typed<AppBskyEmbedVideo.Main>
@@ -268,13 +260,7 @@ async function resolveEmbed(
> {
if (draft.embed.quote) {
const [resolvedMedia, resolvedQuote] = await Promise.all([
resolveMedia(
agent,
queryClient,
draft.embed,
onStateChange,
featureFlags,
),
resolveMedia(agent, queryClient, draft.embed, onStateChange),
resolveRecord(agent, queryClient, draft.embed.quote.uri),
])
if (resolvedMedia) {
@@ -297,7 +283,6 @@ async function resolveEmbed(
queryClient,
draft.embed,
onStateChange,
featureFlags,
)
if (resolvedMedia) {
return resolvedMedia
@@ -323,7 +308,6 @@ async function resolveMedia(
queryClient: QueryClient,
embedDraft: EmbedDraft,
onStateChange: ((state: string) => void) | undefined,
featureFlags?: FeatureFlags,
): Promise<
| $Typed<AppBskyEmbedExternal.Main>
| $Typed<AppBskyEmbedImages.Main>
@@ -339,10 +323,7 @@ async function resolveMedia(
const images: AppBskyEmbedImages.Image[] = await Promise.all(
imagesDraft.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(image, {
highResolution: featureFlags?.highResolutionImages,
increasedBlobSizeLimit: featureFlags?.increasedBlobSizeLimit,
})
const {path, width, height, mime} = await compressImage(image)
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(agent, path, mime)
return {
+4
View File
@@ -246,6 +246,10 @@ async function resolveExternal(
title: result.title ?? '',
description: result.description ?? '',
thumb: result.image ? await imageToThumb(result.image) : undefined,
/*
* New fields from Standard Site integration. Other fields are derived from
* opengraph/oembed as before.
*/
associatedRefs: result.associatedRefs,
view: result.view,
}
+3 -2
View File
@@ -13,6 +13,7 @@ export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
export const DEFAULT_SERVICE = BSKY_SERVICE
const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
export const CHAT_SERVICE = 'https://api.bsky.chat'
export const EMBED_SERVICE = 'https://embed.bsky.app'
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
@@ -188,8 +189,8 @@ export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds
* Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
* ISO megabytes.
*/
export const VIDEO_MAX_SIZE_REDUCED = 1000 * 1000 * 100 // 100mb
export const VIDEO_MAX_SIZE = 3000 * 1000 * 100 // 300mb
export const VIDEO_MAX_SIZE_MB = 300
export const VIDEO_MAX_SIZE = VIDEO_MAX_SIZE_MB * 1000 * 1000 // 300mb
export const SUPPORTED_MIME_TYPES = [
'video/mp4',
+51
View File
@@ -5,7 +5,11 @@ import * as WebBrowser from 'expo-web-browser'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
import {usePrefetchJoinLinkPreviews} from '#/state/queries/join-links'
import {useSession} from '#/state/session'
import {useSetActiveLanding} from '#/state/shell/landing'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {useAnalytics} from '#/analytics'
@@ -25,6 +29,7 @@ export function useIntentHandler() {
const ax = useAnalytics()
const composeIntent = useComposeIntent()
const verifyEmailIntent = useVerifyEmailIntent()
const groupChatJoinIntent = useGroupChatJoinIntent()
const {currentAccount} = useSession()
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
@@ -44,6 +49,11 @@ export function useIntentHandler() {
})
}
const urlp = parseLinkingUrl(url)
const chatInviteMatch = urlp.pathname.match(CHAT_INVITE_CODE_REGEX)
if (chatInviteMatch) {
groupChatJoinIntent(chatInviteMatch[1], url)
return
}
const [, intent, intentType] = urlp.pathname.split('/')
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
@@ -99,6 +109,7 @@ export function useIntentHandler() {
ax,
composeIntent,
verifyEmailIntent,
groupChatJoinIntent,
currentAccount,
tryApplyUpdate,
])
@@ -162,6 +173,46 @@ export function useComposeIntent() {
)
}
export function useGroupChatJoinIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {hasSession} = useSession()
const {groupChatJoinDialogControl: control, setGroupChatJoinState: setState} =
useIntentDialogs()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const setActiveLanding = useSetActiveLanding()
const prefetchJoinLinkPreviews = usePrefetchJoinLinkPreviews()
return useCallback(
(code: string, uri?: string) => {
closeAllActiveElements()
if (hasSession) {
setState({code})
const prefetch = prefetchJoinLinkPreviews({
codes: [code],
hasSession: true,
})
void Promise.race([
prefetch,
new Promise(res => setTimeout(res, 200)),
]).finally(() => {
control.open()
})
} else {
setActiveLanding({type: 'groupchat', uri: uri ?? '', code})
requestSwitchToAccount({requestedAccount: 'groupchat'})
}
},
[
closeAllActiveElements,
hasSession,
control,
setState,
prefetchJoinLinkPreviews,
requestSwitchToAccount,
setActiveLanding,
],
)
}
function useVerifyEmailIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
-1
View File
@@ -12,7 +12,6 @@ export async function compressVideo(
opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
TEMP_enableLargeVideoUploads?: boolean
},
): Promise<CompressedVideo> {
const {onProgress, signal} = opts || {}
+3 -9
View File
@@ -1,28 +1,22 @@
import {type ImagePickerAsset} from 'expo-image-picker'
import {VIDEO_MAX_SIZE, VIDEO_MAX_SIZE_REDUCED} from '#/lib/constants'
import {VIDEO_MAX_SIZE} from '#/lib/constants'
import {VideoTooLargeError} from '#/lib/media/video/errors'
import {type CompressedVideo} from './types'
// doesn't actually compress, converts to ArrayBuffer
export async function compressVideo(
asset: ImagePickerAsset,
opts?: {
_opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
TEMP_enableLargeVideoUploads?: number
},
): Promise<CompressedVideo> {
const {mimeType, base64} = parseDataUrl(asset.uri)
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
if (
blob.size >
(opts?.TEMP_enableLargeVideoUploads
? VIDEO_MAX_SIZE
: VIDEO_MAX_SIZE_REDUCED)
) {
if (blob.size > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError()
}
+1
View File
@@ -64,6 +64,7 @@ export type CommonNavigatorParams = {
Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string; accept?: true}
MessagesConversationSettings: {conversation: string}
MessagesJoinRequests: {conversation: string}
MessagesSettings: undefined
MessagesInbox: undefined
NotificationsActivityList: {posts: string}
+22
View File
@@ -178,6 +178,28 @@ export function isBskyStarterPackUrl(url: string): boolean {
return false
}
export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
export function getChatInviteCodeFromUrl(url: string): string | undefined {
let pathname: string
if (isBskyAppUrl(url)) {
try {
pathname = new URL(url).pathname
} catch {
return undefined
}
} else if (url.startsWith('/')) {
pathname = url.split('?')[0].split('#')[0]
} else {
return undefined
}
return pathname.match(CHAT_INVITE_CODE_REGEX)?.[1]
}
export function isBskyChatInviteUrl(url: string): boolean {
return getChatInviteCodeFromUrl(url) !== undefined
}
export function isBskyDownloadUrl(url: string): boolean {
if (isExternalUrl(url)) {
return false
+207 -206
View File
@@ -1,16 +1,16 @@
// Don't remove -force from these because detection is VERY slow on low-end Android.
// https://github.com/formatjs/formatjs/issues/4463#issuecomment-2176070577
import '@formatjs/intl-locale/polyfill-force'
import '@formatjs/intl-pluralrules/polyfill-force'
import '@formatjs/intl-numberformat/polyfill-force'
import '@formatjs/intl-displaynames/polyfill-force'
import '@formatjs/intl-pluralrules/locale-data/en'
import '@formatjs/intl-numberformat/locale-data/en'
import '@formatjs/intl-displaynames/locale-data/en'
import '@formatjs/intl-locale/polyfill-force.js'
import '@formatjs/intl-pluralrules/polyfill-force.js'
import '@formatjs/intl-numberformat/polyfill-force.js'
import '@formatjs/intl-displaynames/polyfill-force.js'
import '@formatjs/intl-pluralrules/locale-data/en.js'
import '@formatjs/intl-numberformat/locale-data/en.js'
import '@formatjs/intl-displaynames/locale-data/en.js'
import {useEffect, useState} from 'react'
import {i18n} from '@lingui/core'
import defaultLocale from 'date-fns/locale/en-US'
import {enUS as defaultLocale} from 'date-fns/locale/en-US'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages'
@@ -64,399 +64,398 @@ export async function dynamicActivate(locale: AppLanguage) {
switch (locale) {
case AppLanguage.an: {
i18n.loadAndActivate({locale, messages: messagesAn})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/an'),
import('@formatjs/intl-numberformat/locale-data/es'),
import('@formatjs/intl-displaynames/locale-data/es'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/es').then(m => m.es),
import('@formatjs/intl-pluralrules/locale-data/an.js'),
import('@formatjs/intl-numberformat/locale-data/an.js'),
import('@formatjs/intl-displaynames/locale-data/an.js'),
])
return dateLocale
}
case AppLanguage.ast: {
i18n.loadAndActivate({locale, messages: messagesAst})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/ast'),
import('@formatjs/intl-numberformat/locale-data/ast'),
import('@formatjs/intl-displaynames/locale-data/ast'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/es').then(m => m.es),
import('@formatjs/intl-pluralrules/locale-data/ast.js'),
import('@formatjs/intl-numberformat/locale-data/ast.js'),
import('@formatjs/intl-displaynames/locale-data/ast.js'),
])
return dateLocale
}
case AppLanguage.ca: {
i18n.loadAndActivate({locale, messages: messagesCa})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ca'),
import('@formatjs/intl-pluralrules/locale-data/ca'),
import('@formatjs/intl-numberformat/locale-data/ca'),
import('@formatjs/intl-displaynames/locale-data/ca'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ca').then(m => m.ca),
import('@formatjs/intl-pluralrules/locale-data/ca.js'),
import('@formatjs/intl-numberformat/locale-data/ca.js'),
import('@formatjs/intl-displaynames/locale-data/ca.js'),
])
return dateLocale
}
case AppLanguage.cy: {
i18n.loadAndActivate({locale, messages: messagesCy})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/cy'),
import('@formatjs/intl-pluralrules/locale-data/cy'),
import('@formatjs/intl-numberformat/locale-data/cy'),
import('@formatjs/intl-displaynames/locale-data/cy'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/cy').then(m => m.cy),
import('@formatjs/intl-pluralrules/locale-data/cy.js'),
import('@formatjs/intl-numberformat/locale-data/cy.js'),
import('@formatjs/intl-displaynames/locale-data/cy.js'),
])
return dateLocale
}
case AppLanguage.da: {
i18n.loadAndActivate({locale, messages: messagesDa})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/da'),
import('@formatjs/intl-pluralrules/locale-data/da'),
import('@formatjs/intl-numberformat/locale-data/da'),
import('@formatjs/intl-displaynames/locale-data/da'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/da').then(m => m.da),
import('@formatjs/intl-pluralrules/locale-data/da.js'),
import('@formatjs/intl-numberformat/locale-data/da.js'),
import('@formatjs/intl-displaynames/locale-data/da.js'),
])
return dateLocale
}
case AppLanguage.de: {
i18n.loadAndActivate({locale, messages: messagesDe})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/de'),
import('@formatjs/intl-pluralrules/locale-data/de'),
import('@formatjs/intl-numberformat/locale-data/de'),
import('@formatjs/intl-displaynames/locale-data/de'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/de').then(m => m.de),
import('@formatjs/intl-pluralrules/locale-data/de.js'),
import('@formatjs/intl-numberformat/locale-data/de.js'),
import('@formatjs/intl-displaynames/locale-data/de.js'),
])
return dateLocale
}
case AppLanguage.el: {
i18n.loadAndActivate({locale, messages: messagesEl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/el'),
import('@formatjs/intl-pluralrules/locale-data/el'),
import('@formatjs/intl-numberformat/locale-data/el'),
import('@formatjs/intl-displaynames/locale-data/el'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/el').then(m => m.el),
import('@formatjs/intl-pluralrules/locale-data/el.js'),
import('@formatjs/intl-numberformat/locale-data/el.js'),
import('@formatjs/intl-displaynames/locale-data/el.js'),
])
return dateLocale
}
case AppLanguage.en_GB: {
i18n.loadAndActivate({locale, messages: messagesEn_GB})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/en-GB'),
import('@formatjs/intl-pluralrules/locale-data/en'),
import('@formatjs/intl-numberformat/locale-data/en-GB'),
import('@formatjs/intl-displaynames/locale-data/en-GB'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/en-GB').then(m => m.enGB),
import('@formatjs/intl-pluralrules/locale-data/en.js'),
import('@formatjs/intl-numberformat/locale-data/en-GB.js'),
import('@formatjs/intl-displaynames/locale-data/en-GB.js'),
])
return dateLocale
}
case AppLanguage.eo: {
i18n.loadAndActivate({locale, messages: messagesEo})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/eo'),
import('@formatjs/intl-pluralrules/locale-data/eo'),
import('@formatjs/intl-numberformat/locale-data/eo'),
// borked, see https://github.com/bluesky-social/social-app/pull/9574
// import('@formatjs/intl-displaynames/locale-data/eo'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/eo').then(m => m.eo),
import('@formatjs/intl-pluralrules/locale-data/eo.js'),
import('@formatjs/intl-numberformat/locale-data/eo.js'),
import('@formatjs/intl-displaynames/locale-data/eo.js'),
])
return dateLocale
}
case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/es'),
import('@formatjs/intl-numberformat/locale-data/es'),
import('@formatjs/intl-displaynames/locale-data/es'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/es').then(m => m.es),
import('@formatjs/intl-pluralrules/locale-data/es.js'),
import('@formatjs/intl-numberformat/locale-data/es.js'),
import('@formatjs/intl-displaynames/locale-data/es.js'),
])
return dateLocale
}
case AppLanguage.eu: {
i18n.loadAndActivate({locale, messages: messagesEu})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/eu'),
import('@formatjs/intl-pluralrules/locale-data/eu'),
import('@formatjs/intl-numberformat/locale-data/eu'),
import('@formatjs/intl-displaynames/locale-data/eu'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/eu').then(m => m.eu),
import('@formatjs/intl-pluralrules/locale-data/eu.js'),
import('@formatjs/intl-numberformat/locale-data/eu.js'),
import('@formatjs/intl-displaynames/locale-data/eu.js'),
])
return dateLocale
}
case AppLanguage.fi: {
i18n.loadAndActivate({locale, messages: messagesFi})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fi'),
import('@formatjs/intl-pluralrules/locale-data/fi'),
import('@formatjs/intl-numberformat/locale-data/fi'),
import('@formatjs/intl-displaynames/locale-data/fi'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/fi').then(m => m.fi),
import('@formatjs/intl-pluralrules/locale-data/fi.js'),
import('@formatjs/intl-numberformat/locale-data/fi.js'),
import('@formatjs/intl-displaynames/locale-data/fi.js'),
])
return dateLocale
}
case AppLanguage.fr: {
i18n.loadAndActivate({locale, messages: messagesFr})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fr'),
import('@formatjs/intl-pluralrules/locale-data/fr'),
import('@formatjs/intl-numberformat/locale-data/fr'),
import('@formatjs/intl-displaynames/locale-data/fr'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/fr').then(m => m.fr),
import('@formatjs/intl-pluralrules/locale-data/fr.js'),
import('@formatjs/intl-numberformat/locale-data/fr.js'),
import('@formatjs/intl-displaynames/locale-data/fr.js'),
])
return dateLocale
}
case AppLanguage.fy: {
i18n.loadAndActivate({locale, messages: messagesFy})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fy'),
import('@formatjs/intl-pluralrules/locale-data/fy'),
import('@formatjs/intl-numberformat/locale-data/fy'),
import('@formatjs/intl-displaynames/locale-data/fy'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/fy').then(m => m.fy),
import('@formatjs/intl-pluralrules/locale-data/fy.js'),
import('@formatjs/intl-numberformat/locale-data/fy.js'),
import('@formatjs/intl-displaynames/locale-data/fy.js'),
])
return dateLocale
}
case AppLanguage.ga: {
i18n.loadAndActivate({locale, messages: messagesGa})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ga'),
import('@formatjs/intl-numberformat/locale-data/ga'),
import('@formatjs/intl-displaynames/locale-data/ga'),
import('@formatjs/intl-pluralrules/locale-data/ga.js'),
import('@formatjs/intl-numberformat/locale-data/ga.js'),
import('@formatjs/intl-displaynames/locale-data/ga.js'),
])
return undefined
}
case AppLanguage.gd: {
i18n.loadAndActivate({locale, messages: messagesGd})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/gd'),
import('@formatjs/intl-pluralrules/locale-data/gd'),
import('@formatjs/intl-numberformat/locale-data/gd'),
import('@formatjs/intl-displaynames/locale-data/gd'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/gd').then(m => m.gd),
import('@formatjs/intl-pluralrules/locale-data/gd.js'),
import('@formatjs/intl-numberformat/locale-data/gd.js'),
import('@formatjs/intl-displaynames/locale-data/gd.js'),
])
return dateLocale
}
case AppLanguage.gl: {
i18n.loadAndActivate({locale, messages: messagesGl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/gl'),
import('@formatjs/intl-pluralrules/locale-data/gl'),
import('@formatjs/intl-numberformat/locale-data/gl'),
import('@formatjs/intl-displaynames/locale-data/gl'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/gl').then(m => m.gl),
import('@formatjs/intl-pluralrules/locale-data/gl.js'),
import('@formatjs/intl-numberformat/locale-data/gl.js'),
import('@formatjs/intl-displaynames/locale-data/gl.js'),
])
return dateLocale
}
case AppLanguage.hi: {
i18n.loadAndActivate({locale, messages: messagesHi})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/hi'),
import('@formatjs/intl-pluralrules/locale-data/hi'),
import('@formatjs/intl-numberformat/locale-data/hi'),
import('@formatjs/intl-displaynames/locale-data/hi'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/hi').then(m => m.hi),
import('@formatjs/intl-pluralrules/locale-data/hi.js'),
import('@formatjs/intl-numberformat/locale-data/hi.js'),
import('@formatjs/intl-displaynames/locale-data/hi.js'),
])
return dateLocale
}
case AppLanguage.hu: {
i18n.loadAndActivate({locale, messages: messagesHu})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/hu'),
import('@formatjs/intl-pluralrules/locale-data/hu'),
import('@formatjs/intl-numberformat/locale-data/hu'),
import('@formatjs/intl-displaynames/locale-data/hu'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/hu').then(m => m.hu),
import('@formatjs/intl-pluralrules/locale-data/hu.js'),
import('@formatjs/intl-numberformat/locale-data/hu.js'),
import('@formatjs/intl-displaynames/locale-data/hu.js'),
])
return dateLocale
}
case AppLanguage.ia: {
i18n.loadAndActivate({locale, messages: messagesIa})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ia'),
import('@formatjs/intl-numberformat/locale-data/ia'),
import('@formatjs/intl-displaynames/locale-data/ia'),
import('@formatjs/intl-pluralrules/locale-data/ia.js'),
import('@formatjs/intl-numberformat/locale-data/ia.js'),
import('@formatjs/intl-displaynames/locale-data/ia.js'),
])
return undefined
}
case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesId})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/id'),
import('@formatjs/intl-pluralrules/locale-data/id'),
import('@formatjs/intl-numberformat/locale-data/id'),
import('@formatjs/intl-displaynames/locale-data/id'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/id').then(m => m.id),
import('@formatjs/intl-pluralrules/locale-data/id.js'),
import('@formatjs/intl-numberformat/locale-data/id.js'),
import('@formatjs/intl-displaynames/locale-data/id.js'),
])
return dateLocale
}
case AppLanguage.it: {
i18n.loadAndActivate({locale, messages: messagesIt})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/it'),
import('@formatjs/intl-pluralrules/locale-data/it'),
import('@formatjs/intl-numberformat/locale-data/it'),
import('@formatjs/intl-displaynames/locale-data/it'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/it').then(m => m.it),
import('@formatjs/intl-pluralrules/locale-data/it.js'),
import('@formatjs/intl-numberformat/locale-data/it.js'),
import('@formatjs/intl-displaynames/locale-data/it.js'),
])
return dateLocale
}
case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ja'),
import('@formatjs/intl-pluralrules/locale-data/ja'),
import('@formatjs/intl-numberformat/locale-data/ja'),
import('@formatjs/intl-displaynames/locale-data/ja'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ja').then(m => m.ja),
import('@formatjs/intl-pluralrules/locale-data/ja.js'),
import('@formatjs/intl-numberformat/locale-data/ja.js'),
import('@formatjs/intl-displaynames/locale-data/ja.js'),
])
return dateLocale
}
case AppLanguage.km: {
i18n.loadAndActivate({locale, messages: messagesKm})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/km'),
import('@formatjs/intl-pluralrules/locale-data/km'),
import('@formatjs/intl-numberformat/locale-data/km'),
import('@formatjs/intl-displaynames/locale-data/km'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/km').then(m => m.km),
import('@formatjs/intl-pluralrules/locale-data/km.js'),
import('@formatjs/intl-numberformat/locale-data/km.js'),
import('@formatjs/intl-displaynames/locale-data/km.js'),
])
return dateLocale
}
case AppLanguage.ko: {
i18n.loadAndActivate({locale, messages: messagesKo})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ko'),
import('@formatjs/intl-pluralrules/locale-data/ko'),
import('@formatjs/intl-numberformat/locale-data/ko'),
import('@formatjs/intl-displaynames/locale-data/ko'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ko').then(m => m.ko),
import('@formatjs/intl-pluralrules/locale-data/ko.js'),
import('@formatjs/intl-numberformat/locale-data/ko.js'),
import('@formatjs/intl-displaynames/locale-data/ko.js'),
])
return dateLocale
}
case AppLanguage.ne: {
i18n.loadAndActivate({locale, messages: messagesNe})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ne'),
import('@formatjs/intl-numberformat/locale-data/ne'),
import('@formatjs/intl-displaynames/locale-data/ne'),
import('@formatjs/intl-pluralrules/locale-data/ne.js'),
import('@formatjs/intl-numberformat/locale-data/ne.js'),
import('@formatjs/intl-displaynames/locale-data/ne.js'),
])
return undefined
}
case AppLanguage.nl: {
i18n.loadAndActivate({locale, messages: messagesNl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/nl'),
import('@formatjs/intl-pluralrules/locale-data/nl'),
import('@formatjs/intl-numberformat/locale-data/nl'),
import('@formatjs/intl-displaynames/locale-data/nl'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/nl').then(m => m.nl),
import('@formatjs/intl-pluralrules/locale-data/nl.js'),
import('@formatjs/intl-numberformat/locale-data/nl.js'),
import('@formatjs/intl-displaynames/locale-data/nl.js'),
])
return dateLocale
}
case AppLanguage.pl: {
i18n.loadAndActivate({locale, messages: messagesPl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pl'),
import('@formatjs/intl-pluralrules/locale-data/pl'),
import('@formatjs/intl-numberformat/locale-data/pl'),
import('@formatjs/intl-displaynames/locale-data/pl'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/pl').then(m => m.pl),
import('@formatjs/intl-pluralrules/locale-data/pl.js'),
import('@formatjs/intl-numberformat/locale-data/pl.js'),
import('@formatjs/intl-displaynames/locale-data/pl.js'),
])
return dateLocale
}
case AppLanguage.pt_BR: {
i18n.loadAndActivate({locale, messages: messagesPt_BR})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pt-BR'),
import('@formatjs/intl-pluralrules/locale-data/pt'),
import('@formatjs/intl-numberformat/locale-data/pt'),
import('@formatjs/intl-displaynames/locale-data/pt'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/pt-BR').then(m => m.ptBR),
import('@formatjs/intl-pluralrules/locale-data/pt.js'),
import('@formatjs/intl-numberformat/locale-data/pt.js'),
import('@formatjs/intl-displaynames/locale-data/pt.js'),
])
return dateLocale
}
case AppLanguage.pt_PT: {
i18n.loadAndActivate({locale, messages: messagesPt_PT})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pt'),
import('@formatjs/intl-pluralrules/locale-data/pt-PT'),
import('@formatjs/intl-numberformat/locale-data/pt-PT'),
import('@formatjs/intl-displaynames/locale-data/pt-PT'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/pt').then(m => m.pt),
import('@formatjs/intl-pluralrules/locale-data/pt-PT.js'),
import('@formatjs/intl-numberformat/locale-data/pt-PT.js'),
import('@formatjs/intl-displaynames/locale-data/pt-PT.js'),
])
return dateLocale
}
case AppLanguage.ro: {
i18n.loadAndActivate({locale, messages: messagesRo})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ro'),
import('@formatjs/intl-pluralrules/locale-data/ro'),
import('@formatjs/intl-numberformat/locale-data/ro'),
import('@formatjs/intl-displaynames/locale-data/ro'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ro').then(m => m.ro),
import('@formatjs/intl-pluralrules/locale-data/ro.js'),
import('@formatjs/intl-numberformat/locale-data/ro.js'),
import('@formatjs/intl-displaynames/locale-data/ro.js'),
])
return dateLocale
}
case AppLanguage.ru: {
i18n.loadAndActivate({locale, messages: messagesRu})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ru'),
import('@formatjs/intl-pluralrules/locale-data/ru'),
import('@formatjs/intl-numberformat/locale-data/ru'),
import('@formatjs/intl-displaynames/locale-data/ru'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ru').then(m => m.ru),
import('@formatjs/intl-pluralrules/locale-data/ru.js'),
import('@formatjs/intl-numberformat/locale-data/ru.js'),
import('@formatjs/intl-displaynames/locale-data/ru.js'),
])
return dateLocale
}
case AppLanguage.sv: {
i18n.loadAndActivate({locale, messages: messagesSv})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/sv'),
import('@formatjs/intl-pluralrules/locale-data/sv'),
import('@formatjs/intl-numberformat/locale-data/sv'),
import('@formatjs/intl-displaynames/locale-data/sv'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/sv').then(m => m.sv),
import('@formatjs/intl-pluralrules/locale-data/sv.js'),
import('@formatjs/intl-numberformat/locale-data/sv.js'),
import('@formatjs/intl-displaynames/locale-data/sv.js'),
])
return dateLocale
}
case AppLanguage.th: {
i18n.loadAndActivate({locale, messages: messagesTh})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/th'),
import('@formatjs/intl-pluralrules/locale-data/th'),
import('@formatjs/intl-numberformat/locale-data/th'),
import('@formatjs/intl-displaynames/locale-data/th'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/th').then(m => m.th),
import('@formatjs/intl-pluralrules/locale-data/th.js'),
import('@formatjs/intl-numberformat/locale-data/th.js'),
import('@formatjs/intl-displaynames/locale-data/th.js'),
])
return dateLocale
}
case AppLanguage.tr: {
i18n.loadAndActivate({locale, messages: messagesTr})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/tr'),
import('@formatjs/intl-pluralrules/locale-data/tr'),
import('@formatjs/intl-numberformat/locale-data/tr'),
import('@formatjs/intl-displaynames/locale-data/tr'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/tr').then(m => m.tr),
import('@formatjs/intl-pluralrules/locale-data/tr.js'),
import('@formatjs/intl-numberformat/locale-data/tr.js'),
import('@formatjs/intl-displaynames/locale-data/tr.js'),
])
return dateLocale
}
case AppLanguage.uk: {
i18n.loadAndActivate({locale, messages: messagesUk})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/uk'),
import('@formatjs/intl-pluralrules/locale-data/uk'),
import('@formatjs/intl-numberformat/locale-data/uk'),
import('@formatjs/intl-displaynames/locale-data/uk'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/uk').then(m => m.uk),
import('@formatjs/intl-pluralrules/locale-data/uk.js'),
import('@formatjs/intl-numberformat/locale-data/uk.js'),
import('@formatjs/intl-displaynames/locale-data/uk.js'),
])
return dateLocale
}
case AppLanguage.vi: {
i18n.loadAndActivate({locale, messages: messagesVi})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/vi'),
import('@formatjs/intl-pluralrules/locale-data/vi'),
import('@formatjs/intl-numberformat/locale-data/vi'),
import('@formatjs/intl-displaynames/locale-data/vi'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/vi').then(m => m.vi),
import('@formatjs/intl-pluralrules/locale-data/vi.js'),
import('@formatjs/intl-numberformat/locale-data/vi.js'),
import('@formatjs/intl-displaynames/locale-data/vi.js'),
])
return dateLocale
}
case AppLanguage.zh_CN: {
i18n.loadAndActivate({locale, messages: messagesZh_CN})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-CN'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/zh-CN').then(m => m.zhCN),
import('@formatjs/intl-pluralrules/locale-data/zh.js'),
import('@formatjs/intl-numberformat/locale-data/zh.js'),
import('@formatjs/intl-displaynames/locale-data/zh.js'),
])
return dateLocale
}
case AppLanguage.zh_HK: {
i18n.loadAndActivate({locale, messages: messagesZh_HK})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-HK'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/zh-HK').then(m => m.zhHK),
import('@formatjs/intl-pluralrules/locale-data/zh.js'),
import('@formatjs/intl-numberformat/locale-data/yue-Hant.js'),
import('@formatjs/intl-displaynames/locale-data/yue-Hant.js'),
])
return dateLocale
}
case AppLanguage.zh_TW: {
i18n.loadAndActivate({locale, messages: messagesZh_TW})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-TW'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/zh-TW').then(m => m.zhTW),
import('@formatjs/intl-pluralrules/locale-data/zh.js'),
import('@formatjs/intl-numberformat/locale-data/zh-Hant.js'),
import('@formatjs/intl-displaynames/locale-data/zh-Hant.js'),
])
return dateLocale
}
@@ -472,9 +471,11 @@ export function useLocaleLanguage() {
const [dateLocale, setDateLocale] = useState(defaultLocale)
useEffect(() => {
dynamicActivate(sanitizeAppLanguageSetting(appLanguage)).then(locale => {
setDateLocale(locale ?? defaultLocale)
})
void dynamicActivate(sanitizeAppLanguageSetting(appLanguage)).then(
locale => {
setDateLocale(locale ?? defaultLocale)
},
)
}, [appLanguage])
return dateLocale
+121 -120
View File
@@ -1,6 +1,7 @@
import {useEffect, useState} from 'react'
import {i18n} from '@lingui/core'
import defaultLocale from 'date-fns/locale/en-US'
import {i18n, type Messages} from '@lingui/core'
import {type Locale} from 'date-fns/locale'
import {enUS as defaultLocale} from 'date-fns/locale/en-US'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages'
@@ -10,288 +11,288 @@ import {useLanguagePrefs} from '#/state/preferences'
* We do a dynamic import of just the catalog that we need
*/
export async function dynamicActivate(locale: AppLanguage) {
let mod: any
let messages: Messages
let dateLocale: Locale = defaultLocale
switch (locale) {
case AppLanguage.an: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/an/messages`),
import('date-fns/locale/es'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/an/messages`).then(m => m.messages),
import('date-fns/locale/es').then(m => m.es),
])
break
}
case AppLanguage.ast: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ast/messages`),
import('date-fns/locale/es'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ast/messages`).then(m => m.messages),
import('date-fns/locale/es').then(m => m.es),
])
break
}
case AppLanguage.ca: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ca/messages`),
import('date-fns/locale/ca'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ca/messages`).then(m => m.messages),
import('date-fns/locale/ca').then(m => m.ca),
])
break
}
case AppLanguage.cy: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/cy/messages`),
import('date-fns/locale/cy'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/cy/messages`).then(m => m.messages),
import('date-fns/locale/cy').then(m => m.cy),
])
break
}
case AppLanguage.da: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/da/messages`),
import('date-fns/locale/da'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/da/messages`).then(m => m.messages),
import('date-fns/locale/da').then(m => m.da),
])
break
}
case AppLanguage.de: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/de/messages`),
import('date-fns/locale/de'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/de/messages`).then(m => m.messages),
import('date-fns/locale/de').then(m => m.de),
])
break
}
case AppLanguage.el: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/el/messages`),
import('date-fns/locale/el'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/el/messages`).then(m => m.messages),
import('date-fns/locale/el').then(m => m.el),
])
break
}
case AppLanguage.en_GB: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/en-GB/messages`),
import('date-fns/locale/en-GB'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/en-GB/messages`).then(m => m.messages),
import('date-fns/locale/en-GB').then(m => m.enGB),
])
break
}
case AppLanguage.eo: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/eo/messages`),
import('date-fns/locale/eo'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/eo/messages`).then(m => m.messages),
import('date-fns/locale/eo').then(m => m.eo),
])
break
}
case AppLanguage.es: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/es/messages`),
import('date-fns/locale/es'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/es/messages`).then(m => m.messages),
import('date-fns/locale/es').then(m => m.es),
])
break
}
case AppLanguage.eu: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/eu/messages`),
import('date-fns/locale/eu'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/eu/messages`).then(m => m.messages),
import('date-fns/locale/eu').then(m => m.eu),
])
break
}
case AppLanguage.fi: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fi/messages`),
import('date-fns/locale/fi'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/fi/messages`).then(m => m.messages),
import('date-fns/locale/fi').then(m => m.fi),
])
break
}
case AppLanguage.fr: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fr/messages`),
import('date-fns/locale/fr'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/fr/messages`).then(m => m.messages),
import('date-fns/locale/fr').then(m => m.fr),
])
break
}
case AppLanguage.fy: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fy/messages`),
import('date-fns/locale/fy'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/fy/messages`).then(m => m.messages),
import('date-fns/locale/fy').then(m => m.fy),
])
break
}
case AppLanguage.ga: {
mod = await import(`./locales/ga/messages`)
messages = await import(`./locales/ga/messages`).then(m => m.messages)
break
}
case AppLanguage.gd: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/gd/messages`),
import('date-fns/locale/gd'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/gd/messages`).then(m => m.messages),
import('date-fns/locale/gd').then(m => m.gd),
])
break
}
case AppLanguage.gl: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/gl/messages`),
import('date-fns/locale/gl'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/gl/messages`).then(m => m.messages),
import('date-fns/locale/gl').then(m => m.gl),
])
break
}
case AppLanguage.hi: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/hi/messages`),
import('date-fns/locale/hi'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/hi/messages`).then(m => m.messages),
import('date-fns/locale/hi').then(m => m.hi),
])
break
}
case AppLanguage.hu: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/hu/messages`),
import('date-fns/locale/hu'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/hu/messages`).then(m => m.messages),
import('date-fns/locale/hu').then(m => m.hu),
])
break
}
case AppLanguage.ia: {
mod = await import(`./locales/ia/messages`)
messages = await import(`./locales/ia/messages`).then(m => m.messages)
break
}
case AppLanguage.id: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/id/messages`),
import('date-fns/locale/id'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/id/messages`).then(m => m.messages),
import('date-fns/locale/id').then(m => m.id),
])
break
}
case AppLanguage.it: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/it/messages`),
import('date-fns/locale/it'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/it/messages`).then(m => m.messages),
import('date-fns/locale/it').then(m => m.it),
])
break
}
case AppLanguage.ja: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ja/messages`),
import('date-fns/locale/ja'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ja/messages`).then(m => m.messages),
import('date-fns/locale/ja').then(m => m.ja),
])
break
}
case AppLanguage.km: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/km/messages`),
import('date-fns/locale/km'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/km/messages`).then(m => m.messages),
import('date-fns/locale/km').then(m => m.km),
])
break
}
case AppLanguage.ko: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ko/messages`),
import('date-fns/locale/ko'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ko/messages`).then(m => m.messages),
import('date-fns/locale/ko').then(m => m.ko),
])
break
}
case AppLanguage.ne: {
mod = await import(`./locales/ne/messages`)
messages = await import(`./locales/ne/messages`).then(m => m.messages)
break
}
case AppLanguage.nl: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/nl/messages`),
import('date-fns/locale/nl'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/nl/messages`).then(m => m.messages),
import('date-fns/locale/nl').then(m => m.nl),
])
break
}
case AppLanguage.pl: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pl/messages`),
import('date-fns/locale/pl'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/pl/messages`).then(m => m.messages),
import('date-fns/locale/pl').then(m => m.pl),
])
break
}
case AppLanguage.pt_BR: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pt-BR/messages`),
import('date-fns/locale/pt-BR'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/pt-BR/messages`).then(m => m.messages),
import('date-fns/locale/pt-BR').then(m => m.ptBR),
])
break
}
case AppLanguage.pt_PT: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pt-PT/messages`),
import('date-fns/locale/pt'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/pt-PT/messages`).then(m => m.messages),
import('date-fns/locale/pt').then(m => m.pt),
])
break
}
case AppLanguage.ro: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ro/messages`),
import('date-fns/locale/ro'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ro/messages`).then(m => m.messages),
import('date-fns/locale/ro').then(m => m.ro),
])
break
}
case AppLanguage.ru: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ru/messages`),
import('date-fns/locale/ru'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ru/messages`).then(m => m.messages),
import('date-fns/locale/ru').then(m => m.ru),
])
break
}
case AppLanguage.sv: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/sv/messages`),
import('date-fns/locale/sv'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/sv/messages`).then(m => m.messages),
import('date-fns/locale/sv').then(m => m.sv),
])
break
}
case AppLanguage.th: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/th/messages`),
import('date-fns/locale/th'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/th/messages`).then(m => m.messages),
import('date-fns/locale/th').then(m => m.th),
])
break
}
case AppLanguage.tr: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/tr/messages`),
import('date-fns/locale/tr'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/tr/messages`).then(m => m.messages),
import('date-fns/locale/tr').then(m => m.tr),
])
break
}
case AppLanguage.uk: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/uk/messages`),
import('date-fns/locale/uk'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/uk/messages`).then(m => m.messages),
import('date-fns/locale/uk').then(m => m.uk),
])
break
}
case AppLanguage.vi: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/vi/messages`),
import('date-fns/locale/vi'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/vi/messages`).then(m => m.messages),
import('date-fns/locale/vi').then(m => m.vi),
])
break
}
case AppLanguage.zh_CN: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-CN/messages`),
import('date-fns/locale/zh-CN'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/zh-CN/messages`).then(m => m.messages),
import('date-fns/locale/zh-CN').then(m => m.zhCN),
])
break
}
case AppLanguage.zh_HK: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-HK/messages`),
import('date-fns/locale/zh-HK'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/zh-HK/messages`).then(m => m.messages),
import('date-fns/locale/zh-HK').then(m => m.zhHK),
])
break
}
case AppLanguage.zh_TW: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-TW/messages`),
import('date-fns/locale/zh-TW'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/zh-TW/messages`).then(m => m.messages),
import('date-fns/locale/zh-TW').then(m => m.zhTW),
])
break
}
default: {
mod = await import(`./locales/en/messages`)
messages = await import(`./locales/en/messages`).then(m => m.messages)
break
}
}
i18n.load(locale, mod.messages)
i18n.load(locale, messages)
i18n.activate(locale)
return dateLocale
@@ -305,7 +306,7 @@ export function useLocaleLanguage() {
const sanitizedLanguage = sanitizeAppLanguageSetting(appLanguage)
document.documentElement.lang = sanitizedLanguage
dynamicActivate(sanitizedLanguage).then(locale => {
void dynamicActivate(sanitizedLanguage).then(locale => {
setDateLocale(locale)
})
}, [appLanguage])
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,4 +1,4 @@
import format from 'date-fns/format'
import {format} from 'date-fns/format'
import {LogLevel, type Transport} from '#/logger/types'
import {prepareMetadata} from '#/logger/util'
+2 -1
View File
@@ -25,7 +25,8 @@ if (process.env.NODE_ENV !== 'production') {
thrownErrors.add(err)
throw err
} else if (!thrownErrors.has(msgOrError)) {
return realConsoleError.apply(this, arguments as any)
// @ts-expect-error
return realConsoleError.apply(this, arguments)
}
}
}
+1
View File
@@ -74,6 +74,7 @@ export const router = new Router<AllNavigatableRoutes>({
MessagesInbox: '/messages/inbox',
MessagesConversation: '/messages/:conversation',
MessagesConversationSettings: '/messages/:conversation/settings',
MessagesJoinRequests: '/messages/:conversation/requests',
// starter packs
Start: '/start/:name/:rkey',
StarterPackEdit: '/starter-pack/edit/:rkey',
@@ -1,26 +1,31 @@
import {View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {atoms as a, useTheme} from '#/alf'
import {InlineLinkText} from '#/components/Link'
import {type ConvoWithDetails} from '#/components/dms/util'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function MembersAndRequests({
memberCount,
memberLimit,
convo,
requestCount,
hasMoreRequests,
isOwner,
}: {
memberCount: number
memberLimit: number
convo: Extract<ConvoWithDetails, {kind: 'group'}>
requestCount: number
hasMoreRequests: boolean
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const memberCount = convo.details.memberCount
const memberLimit = convo.details.memberLimit
return (
<View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}>
@@ -42,13 +47,16 @@ export function MembersAndRequests({
<InlineLinkText
label={l`View incoming group chat requests`}
style={[a.text_sm, a.text_right, a.font_semi_bold]}
// TODO Need to implement this. -dsb
to="#">
{...createStaticClick(() => {
navigation.navigate('MessagesJoinRequests', {
conversation: convo.view.id,
})
})}>
{hasMoreRequests
? l({
message: `${requestCount}+ requests`,
comment:
'Displayed when there are more than 50 requests to join a group chat',
'Displayed when there are more than 20 requests to join a group chat',
})
: l({
message: plural(requestCount, {
@@ -247,8 +247,7 @@ function GroupSettings({
case 'MEMBERS_AND_REQUESTS':
return (
<MembersAndRequests
memberCount={convo.details.memberCount}
memberLimit={convo.details.memberLimit}
convo={convo}
requestCount={requestCount}
hasMoreRequests={!!hasMoreRequests}
isOwner={isOwner}
+305
View File
@@ -0,0 +1,305 @@
import {View} from 'react-native'
import {ImageBackground} from 'expo-image'
import {moderateProfile} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
import {useActiveGroupChatJoinRequest} from '#/state/shell/landing'
import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut'
import {LogomarkWithType} from '#/view/icons/LogomarkWithType'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonText} from '#/components/Button'
import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
const desktopDarkBg = require('../../../assets/images/chat-desktop-bg-dark.webp')
const desktopLightBg = require('../../../assets/images/chat-desktop-bg-light.webp')
const mobileDarkBg = require('../../../assets/images/chat-mobile-bg-dark.webp')
const mobileLightBg = require('../../../assets/images/chat-mobile-bg-light.webp')
type Props = {
setScreenState: (state: LoggedOutScreenState) => void
}
export function JoinRequest({setScreenState}: Props) {
const t = useTheme()
const {t: l} = useLingui()
const {gtMobile, gtTablet} = useBreakpoints()
const moderationOpts = useModerationOpts()
// Get code from context (logged-out only)
const contextJoinRequest = useActiveGroupChatJoinRequest()
const code = contextJoinRequest?.code
const {data, error} = useJoinLinkPreviewsQuery({
codes: code ? [code] : undefined,
hasSession: false,
})
const isDarkMode = t.name !== 'light'
const background = gtMobile
? isDarkMode
? desktopDarkBg
: desktopLightBg
: isDarkMode
? mobileDarkBg
: mobileLightBg
const requiresApproval = data?.joinLinkPreviews[0]?.requireApproval
const requiresFollow =
data?.joinLinkPreviews[0]?.joinRule === 'followedByOwner'
return (
<View style={[a.util_screen_outer, a.w_full, t.atoms.bg_contrast_25]}>
<ImageBackground
source={background}
style={[a.util_screen_outer, a.w_full, a.flex_1, a.justify_center]}
contentFit={gtTablet ? 'contain' : 'cover'}>
<View
style={[
a.util_screen_outer,
a.w_full,
a.justify_center,
a.align_center,
]}>
{error ? (
<Wrapper>
<ChainLinkBrokenIcon fill={t.palette.primary_500} size="3xl" />
<Text
style={[
a.mb_sm,
a.text_center,
a.text_lg,
a.font_semi_bold,
t.atoms.text,
]}>
{l`This invite link has expired`}
</Text>
<ActionButtons setScreenState={setScreenState} />
</Wrapper>
) : data && moderationOpts ? (
<Wrapper>
<AvatarBubbles
profiles={[
data.joinLinkPreviews[0].owner,
...Array(
Math.min(
3,
Math.max(0, data.joinLinkPreviews[0].memberCount - 1),
),
).fill(undefined),
]}
size={135}
/>
<View style={[a.gap_2xs]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_sm,
]}>
<Text
style={[
a.text_center,
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>Group chat</Trans>
</Text>
<View style={[a.flex_row, a.align_center]}>
<PersonGroupIcon
size="xs"
style={[a.mr_2xs, t.atoms.text_contrast_medium]}
/>
<Text
style={[
a.text_center,
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans comment="The number of active group chat members out of the total number allowed.">
{data.joinLinkPreviews[0].memberCount}/
{data.joinLinkPreviews[0].memberLimit}
</Trans>
</Text>
</View>
</View>
<Text
style={[
a.text_center,
a.text_4xl,
a.leading_tight,
a.font_bold,
t.atoms.text,
]}>
{data.joinLinkPreviews[0].name}
</Text>
</View>
<View style={[a.w_full]}>
<View
style={[
a.flex_row,
a.gap_xs,
a.align_center,
a.justify_center,
]}>
<Text
style={[
a.mb_xs,
a.text_center,
a.text_sm,
a.leading_snug,
a.font_semi_bold,
t.atoms.text,
a.max_w_full,
]}>
<Trans comment="The owner (creator) of a group chat.">
By{' '}
{createSanitizedDisplayName(
data.joinLinkPreviews[0].owner,
true,
moderateProfile(
data.joinLinkPreviews[0].owner,
moderationOpts,
).ui('displayName'),
)}
</Trans>
</Text>
<ProfileBadges
profile={data.joinLinkPreviews[0].owner}
size="sm"
style={{marginTop: -4}}
/>
</View>
<Text
style={[
a.text_center,
a.text_xs,
a.leading_snug,
a.font_medium,
t.atoms.text_contrast_medium,
a.max_w_full,
]}>
{sanitizeHandle(data.joinLinkPreviews[0].owner.handle, '@')}
</Text>
</View>
<Text
style={[
a.text_center,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_high,
]}>
{requiresApproval
? l`Sign in to request access to this group chat.`
: l`Sign in to accept invite.`}{' '}
{requiresFollow &&
l`Only people ${createSanitizedDisplayName(
data.joinLinkPreviews[0].owner,
true,
moderateProfile(
data.joinLinkPreviews[0].owner,
moderationOpts,
).ui('displayName'),
)} follows can join.`}
</Text>
<ActionButtons setScreenState={setScreenState} />
</Wrapper>
) : null}
</View>
</ImageBackground>
</View>
)
}
function Wrapper({children}: React.PropsWithChildren<unknown>) {
const t = useTheme()
const isDarkMode = t.name !== 'light'
return (
<>
<LogomarkWithType
width={136}
fill={t.palette.primary_500}
style={[
a.absolute,
{
top: 40,
},
]}
/>
<View
style={[
a.zoom_fade_in,
a.align_center,
a.gap_lg,
a.p_2xl,
a.pt_4xl,
t.atoms.bg,
t.atoms.shadow_xl,
isDarkMode ? [a.border, t.atoms.border_contrast_low] : null,
{
borderRadius: 48,
maxWidth: 320,
width: '90%',
},
]}>
{children}
</View>
</>
)
}
function ActionButtons({
setScreenState,
}: {
setScreenState: (state: LoggedOutScreenState) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const isDarkMode = t.name !== 'light'
return (
<View style={[a.w_full, a.gap_md]}>
<Button
testID="signInButton"
onPress={() => {
setScreenState(LoggedOutScreenState.S_Login)
}}
label={l`Sign in`}
accessibilityHint={l`Opens flow to sign in to your existing Bluesky account`}
size="large"
color="primary"
style={[a.w_full]}>
<ButtonText>
<Trans>Sign in</Trans>
</ButtonText>
</Button>
<Button
testID="createAccountButton"
onPress={() => {
setScreenState(LoggedOutScreenState.S_CreateAccount)
}}
label={l`Create new account`}
accessibilityHint={l`Opens flow to create a new Bluesky account`}
size="large"
color={isDarkMode ? 'secondary_inverted' : 'secondary'}
style={[a.w_full]}>
<ButtonText>
<Trans>Create account</Trans>
</ButtonText>
</Button>
</View>
)
}
+491
View File
@@ -0,0 +1,491 @@
import {useState} from 'react'
import {View} from 'react-native'
import {
ChatBskyGroupApproveJoinRequest,
type ChatBskyGroupListJoinRequests,
ChatBskyGroupRejectJoinRequest,
} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type InfiniteData, useQueryClient} from '@tanstack/react-query'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {isNetworkError} from '#/lib/hooks/useCleanError'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
type NavigationProp,
} from '#/lib/routes/types'
import {logger} from '#/logger'
import {ConvoProvider, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useJoinRequestMutation} from '#/state/queries/messages/join-requests'
import {
createListJoinRequestsQueryKey,
useListJoinRequestsQuery,
} from '#/state/queries/messages/list-join-requests'
import {useSession} from '#/state/session'
import {List} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type ConvoWithDetails} from '#/components/dms/util'
import {Error} from '#/components/Error'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {KnownFollowers} from '#/components/KnownFollowers'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {InviteLinkDialog} from './components/InviteLinkDialog'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesJoinRequests'
>
export function MessagesJoinRequestsScreen(props: Props) {
const {t: l} = useLingui()
const aaCopy = useAgeAssuranceCopy()
return (
<AgeRestrictedScreen
screenTitle={l`Requests to join`}
infoText={aaCopy.chatsInfoText}>
<MessagesJoinRequestsScreenInner {...props} />
</AgeRestrictedScreen>
)
}
function MessagesJoinRequestsScreenInner({route}: Props) {
const convoId = route.params.conversation
return (
<Layout.Screen>
<ConvoProvider key={convoId} convoId={convoId}>
<JoinRequestsInner />
</ConvoProvider>
</Layout.Screen>
)
}
function JoinRequestsInner() {
const {t: l} = useLingui()
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
if (convoState.status === ConvoStatus.Error) {
return (
<>
<Header />
<Error
title={l`Something went wrong`}
message={l`We couldn’t load this conversation’s join requests`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
</>
)
}
if (!convoState.convo) {
return (
<>
<Header />
<View style={[a.flex_1, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
</>
)
}
if (convoState.convo.kind !== 'group') {
return (
<Error
title={l`Wrong kind of conversation`}
message={l`This screen is only available for group conversations.`}
onGoBack={() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.replace('Messages', {animation: 'pop'})
}
}}
/>
)
}
return <JoinRequestsList convo={convoState.convo} />
}
function JoinRequestsList({
convo,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
}) {
const t = useTheme()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const bottomBarOffset = useBottomBarOffset()
const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>()
const queryClient = useQueryClient()
const inviteLinkControl = Dialog.useDialogControl()
const getRemainingRequestCount = () => {
const data = queryClient.getQueryData<
InfiniteData<ChatBskyGroupListJoinRequests.OutputSchema>
>(createListJoinRequestsQueryKey({convoId: convo.view.id}))
return data?.pages.reduce((sum, page) => sum + page.requests.length, 0) ?? 0
}
const [isPTRing, setIsPTRing] = useState(false)
const [footerHeight, setFooterHeight] = useState(0)
const owner = convo.primaryMember
const isOwner = !!owner && owner.did === currentAccount?.did
const {
data: joinRequestsData,
isPending,
isError,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
refetch,
} = useListJoinRequestsQuery({
convoId: convo.view.id,
})
const items =
joinRequestsData?.pages.flatMap(page =>
page.requests.map(request => request.requestedBy),
) ?? []
const requestCount =
joinRequestsData?.pages.reduce(
(sum, page) => sum + page.requests.length,
0,
) ?? 0
const {mutate: approveJoinRequest, isPending: isApprovePending} =
useJoinRequestMutation('approve', convo.view.id, {
onSuccess: () => {
Toast.show(l`Request approved.`)
if (getRemainingRequestCount() < 1) {
navigation.replace('MessagesConversationSettings', {
conversation: convo.view.id,
})
}
},
onError: error => {
let errorMessage = l`Failed to accept join request`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyGroupApproveJoinRequest.InvalidConvoError
) {
errorMessage = l`Conversation not found.`
} else if (
error instanceof ChatBskyGroupApproveJoinRequest.InsufficientRoleError
) {
errorMessage = l`Only admins can accept join requests.`
} else if (
error instanceof
ChatBskyGroupApproveJoinRequest.MemberLimitReachedError
) {
errorMessage = l`The member limit has been reached.`
}
Toast.show(errorMessage, {type: 'error'})
},
})
const {mutate: rejectJoinRequest, isPending: isRejectPending} =
useJoinRequestMutation('reject', convo.view.id, {
onSuccess: () => {
Toast.show(l`Request ignored.`)
if (getRemainingRequestCount() < 1) {
navigation.replace('MessagesConversationSettings', {
conversation: convo.view.id,
})
}
},
onError: error => {
let errorMessage = l`Failed to ignore join request`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyGroupRejectJoinRequest.InvalidConvoError
) {
errorMessage = l`Conversation not found.`
} else if (
error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError
) {
errorMessage = l`Only admins can ignore join requests.`
}
Toast.show(errorMessage, {type: 'error'})
},
})
const isMutating = isApprovePending || isRejectPending
const renderItem = ({item}: {item: bsky.profile.AnyProfileView}) => {
if (!moderationOpts) return null
return (
<View style={[a.relative, a.flex_1, a.p_lg]}>
<View style={[a.flex_row, a.align_start, a.gap_md]}>
<ProfileCard.Link profile={item}>
<ProfileCard.Avatar
profile={item}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
</ProfileCard.Link>
<View>
<ProfileCard.Name profile={item} moderationOpts={moderationOpts} />
<ProfileCard.Handle profile={item} />
<View style={[a.mt_xs]}>
<KnownFollowers
profile={item}
moderationOpts={moderationOpts}
minimal
showIfEmpty
/>
</View>
<View style={[a.flex_row, a.align_center, a.gap_sm, a.mt_md]}>
<AcceptButton
disabled={isMutating}
onPress={() => approveJoinRequest({member: item.did})}
/>
<RejectButton
disabled={isMutating}
onPress={() => rejectJoinRequest({member: item.did})}
/>
</View>
</View>
</View>
</View>
)
}
const footer = (
<View
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
style={[
a.absolute,
a.left_0,
a.right_0,
{bottom: 0},
a.px_xl,
a.border_t,
t.atoms.bg,
t.atoms.border_contrast_low,
{
paddingTop: a.py_lg.paddingTop,
paddingBottom: a.py_lg.paddingBottom + bottomBarOffset,
},
]}>
<Button
label={l`Edit invite link`}
size="large"
color="primary"
onPress={() => inviteLinkControl.open()}
style={[a.w_full]}>
<ButtonText>
<Trans context="button">Edit invite link</Trans>
</ButtonText>
</Button>
</View>
)
const onEndReached = async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more join requests', {message: err})
}
}
const onRefresh = async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh group chat requests', {message: err})
}
setIsPTRing(false)
}
if (isError) {
return (
<>
<Header count={requestCount} hasMoreRequests={hasNextPage} />
<View
style={[
a.flex_1,
a.align_center,
a.justify_center,
a.gap_sm,
a.p_lg,
]}>
<ErrorIcon size="3xl" fill={t.atoms.text_contrast_high.color} />
<Text
style={[
a.leading_snug,
a.text_center,
a.px_lg,
a.text_md,
t.atoms.text_contrast_high,
]}>
<Trans>Unable to fetch join requests.</Trans>
</Text>
<Button
color="primary"
label={l`Press to retry`}
onPress={() => void onRefresh()}
disabled={isPTRing}
size="large"
style={[a.mt_md]}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={isPTRing ? Loader : RetryIcon} />
</Button>
</View>
</>
)
}
const showFooter = isOwner
return (
<>
<Header count={requestCount} hasMoreRequests={hasNextPage} />
<List
data={items}
keyExtractor={(item: bsky.profile.AnyProfileView) => item.did}
renderItem={renderItem}
ListEmptyComponent={
isPending ? (
<View
style={[a.flex_1, a.align_center, a.justify_center, a.py_4xl]}>
<Loader size="xl" />
</View>
) : null
}
contentContainerStyle={
showFooter ? {paddingBottom: footerHeight} : undefined
}
scrollIndicatorInsets={showFooter ? {bottom: footerHeight} : undefined}
refreshing={isPTRing}
onEndReached={() => void onEndReached()}
onRefresh={() => void onRefresh()}
keyboardDismissMode="on-drag"
sideBorders={false}
desktopFixedHeight
/>
{showFooter ? footer : null}
{owner && moderationOpts && (
<InviteLinkDialog
convo={convo}
control={inviteLinkControl}
owner={owner}
isOwner={isOwner}
moderationOpts={moderationOpts}
/>
)}
</>
)
}
function Header({
count,
hasMoreRequests,
}: {
count?: number
hasMoreRequests?: boolean
}) {
const {t: l} = useLingui()
return (
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
{count === undefined ? (
<Trans>Requests to join</Trans>
) : hasMoreRequests ? (
l({
message: `${count}+ requests to join`,
comment:
'Displayed when there are more requests to join a group chat than have been loaded',
})
) : (
<Plural
value={count}
zero="No requests to join"
one="# request to join"
other="# requests to join"
/>
)}
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
)
}
function AcceptButton({
disabled,
onPress,
}: {
disabled?: boolean
onPress: () => void
}) {
const {t: l} = useLingui()
return (
<Button
label={l`Accept join request`}
size="small"
color="primary"
disabled={disabled}
onPress={onPress}>
<ButtonText>
<Trans comment="Accept a request to join a chat" context="button">
Accept
</Trans>
</ButtonText>
</Button>
)
}
function RejectButton({
disabled,
onPress,
}: {
disabled?: boolean
onPress: () => void
}) {
const {t: l} = useLingui()
return (
<Button
label={l`Ignore join request`}
size="small"
color="secondary"
disabled={disabled}
onPress={onPress}>
<ButtonText>
<Trans comment="Ignore a request to join a chat" context="button">
Ignore
</Trans>
</ButtonText>
</Button>
)
}
@@ -6,6 +6,7 @@ import {
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
@@ -213,6 +214,20 @@ function GroupChatItem({
primaryProfileModeration={moderation}
isBlockedAccount={false}
isDeletedAccount={false}
subtitle={
convo.details.joinRequestCount
? convo.details.joinRequestCount > 20
? l({
message: '20+ new join requests',
context:
'Displayed when there are more than 20 requests to join a group chat',
})
: plural(convo.details.joinRequestCount, {
one: '# new join request',
other: '# new join requests',
})
: undefined
}
showProfileBadges={false}
selected={selected}
showMenu={showMenu}>
@@ -35,13 +35,14 @@ export function EditTextButton({
a.flex_row,
a.align_center,
a.justify_between,
a.px_md,
a.pl_lg,
a.pr_sm,
a.py_sm,
a.gap_sm,
]}>
{typeof children === 'function' ? children(context) : children}
<View
style={[
a.ml_sm,
a.rounded_full,
t.atoms.bg_contrast_50,
{paddingHorizontal: 10, paddingVertical: 8},
@@ -1,6 +1,11 @@
import {useEffect, useState} from 'react'
import {useState} from 'react'
import {View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {
type ChatBskyGroupDefs,
moderateProfile,
type ModerationOpts,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
@@ -64,14 +69,9 @@ export function InviteLinkDialog({
)
const {joinLink} = convo.details
const enabledStatus = joinLink?.enabledStatus
const defaultStep = joinLink ? Step.MANAGE : Step.INFO
const defaultWhoCanJoin = joinLink
? [
`${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`,
]
: ['anyone']
const defaultWhoCanJoin = joinLink ? joinLinkToKey(joinLink) : 'anyone'
const [step, setStep] = useState(defaultStep)
const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin)
@@ -79,13 +79,13 @@ export function InviteLinkDialog({
// Resync local state when the server-side join link rules change (mutation
// success, refetch, or change from another client). Keyed on the rule string
// so identity-only refetches don't bump a user mid-edit.
const joinLinkRuleKey = joinLink
? `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`
: null
useEffect(() => {
const joinLinkRuleKey = joinLink ? joinLinkToKey(joinLink) : null
const [prevKey, setPrevKey] = useState(joinLinkRuleKey)
if (joinLinkRuleKey !== prevKey) {
setStep(joinLinkRuleKey ? Step.MANAGE : Step.INFO)
setWhoCanJoin([joinLinkRuleKey ?? 'anyone'])
}, [joinLinkRuleKey])
setWhoCanJoin(joinLinkRuleKey ?? 'anyone')
setPrevKey(joinLinkRuleKey)
}
const {openComposer} = useOpenComposer()
@@ -163,22 +163,32 @@ export function InviteLinkDialog({
let content: React.ReactNode = null
let header: string | null = null
switch (step) {
case Step.INFO:
case Step.INFO: {
header = l`Invite link`
content = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<View style={[a.gap_lg]}>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
An invite link lets people join this group chat without being
added directly. You control who can use the link and whether
they need your approval. You can disable the link at any time.
added directly. You control who can join the chat. You can
disable the link at any time.
</Trans>
</Text>
<Text style={[a.mt_lg, a.text_md, t.atoms.text]}>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Your name, avatar, and the name of the group chat will be
visible to everyone.
Group chats can only have a maximum of{' '}
{plural(convo.details.memberLimit, {
one: '# person',
other: '# people',
})}
.
</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Your name, avatar, the name of the group chat, and the number of
members will be visible to everyone.
</Trans>
</Text>
</View>
@@ -199,15 +209,16 @@ export function InviteLinkDialog({
</>
)
break
case Step.GENERATE:
header =
joinLink && enabledStatus === 'enabled'
? l`Update invite link`
: l`Generate invite link`
}
case Step.GENERATE: {
const linkEnabled = joinLink?.enabledStatus === 'enabled'
const linkHasChanged = linkEnabled && joinLinkRuleKey !== whoCanJoin
header = linkEnabled ? l`Update invite link` : l`Generate invite link`
content = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<Text style={[a.text_md]}>
<Trans>Choose who can join this group chat and how.</Trans>
</Text>
</View>
@@ -215,9 +226,9 @@ export function InviteLinkDialog({
<Toggle.Group
label={l`Who can join this group chat and how`}
type="radio"
values={whoCanJoin}
onChange={setWhoCanJoin}>
<View style={[a.gap_sm]}>
values={[whoCanJoin]}
onChange={([value]) => setWhoCanJoin(value)}>
<View style={[a.gap_xs]}>
{whoCanJoinOptions.map(option => (
<Toggle.Item
key={option.name}
@@ -239,18 +250,22 @@ export function InviteLinkDialog({
<View style={[a.mt_4xl]}>
<Button
label={
joinLink && enabledStatus === 'enabled'
? l`Update invite link`
linkEnabled
? linkHasChanged
? l`Update invite link`
: l`Back`
: l`Generate invite link`
}
color="primary"
color={linkEnabled && !linkHasChanged ? 'secondary' : 'primary'}
size="large"
disabled={isSaving}
onPress={() => {
const parts = whoCanJoin[0].split(':')
const joinRule = parts[0]
const requireApproval = parts[1] === 'requireApproval'
if (joinLink && enabledStatus === 'enabled') {
const {joinRule, requireApproval} = keyToJoinLink(whoCanJoin)
if (linkEnabled) {
if (!linkHasChanged) {
setStep(Step.MANAGE)
return
}
editJoinLink({
joinRule,
requireApproval,
@@ -263,36 +278,40 @@ export function InviteLinkDialog({
}
}}>
<ButtonText>
{joinLink && enabledStatus === 'enabled'
? l`Update invite link`
{linkEnabled
? linkHasChanged
? l`Update invite link`
: l`Back`
: l`Generate invite link`}
</ButtonText>
<ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} />
{linkHasChanged && (
<ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} />
)}
</Button>
</View>
</>
)
break
}
case Step.MANAGE: {
const linkEnabled = joinLink?.enabledStatus === 'enabled'
const linkDisabled = joinLink?.enabledStatus === 'disabled'
const joinLinkURI = joinLink?.code
? `https://bsky.app/chat/${joinLink.code}`
: 'https://bsky.app/chat'
? `https://bsky.app/c/${joinLink.code}`
: 'https://bsky.app/'
const createdAt = joinLink ? new Date(joinLink.createdAt) : null
const currentOptionName = joinLink
? `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`
: whoCanJoinOptions[0].name
const currentOption = whoCanJoinOptions.find(
o => o.name === currentOptionName,
)
const currentOption =
whoCanJoinOptions.find(
o => o.name === (joinLink ? joinLinkToKey(joinLink) : null),
) ?? whoCanJoinOptions[0]
const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner
const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member
header =
enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled`
header = linkEnabled ? l`Invite link` : l`Invite link disabled`
content = (
<>
<View style={[a.mt_lg]}>
<CopyTextButton
disabled={enabledStatus === 'disabled' || !joinLink?.code}
disabled={linkDisabled || !joinLink?.code}
label={l`Invite link`}
value={joinLinkURI}>
<Text
@@ -300,9 +319,7 @@ export function InviteLinkDialog({
style={[
a.mr_xs,
a.text_md,
enabledStatus === 'disabled'
? t.atoms.text_contrast_low
: t.atoms.text,
linkDisabled ? t.atoms.text_contrast_low : t.atoms.text,
]}>
{joinLinkURI}
</Text>
@@ -319,25 +336,25 @@ export function InviteLinkDialog({
</Text>
) : null}
</View>
{enabledStatus === 'enabled' ? (
{linkEnabled ? (
<View style={[a.mt_lg]}>
{isOwner ? (
<EditTextButton
label={l`Edit link settings`}
value={ownerValue}
onPress={() => setStep(Step.GENERATE)}>
<View style={[a.flex_1, a.mr_xs]}>
<Text numberOfLines={1} style={[a.text_md, t.atoms.text]}>
<View style={[a.flex_1]}>
<Text numberOfLines={1} style={[a.text_sm]}>
{ownerValue}
</Text>
</View>
</EditTextButton>
) : (
<Text style={[a.text_sm, t.atoms.text]}>{memberValue}</Text>
<Text style={[a.text_sm]}>{memberValue}</Text>
)}
</View>
) : null}
{enabledStatus === 'enabled' ? (
{linkEnabled ? (
<View style={[a.flex_row, a.justify_between, a.gap_sm, a.mt_lg]}>
{isOwner ? (
<StackedButton
@@ -350,7 +367,7 @@ export function InviteLinkDialog({
</StackedButton>
) : null}
<StackedButton
disabled={enabledStatus === 'disabled'}
disabled={linkDisabled}
label={l`Post link`}
icon={EditIcon}
color="primary_subtle"
@@ -366,7 +383,7 @@ export function InviteLinkDialog({
<Trans>Post link</Trans>
</StackedButton>
<StackedButton
disabled={enabledStatus === 'disabled'}
disabled={linkDisabled}
label={l`Share`}
icon={ArrowShareRightIcon}
color="primary_subtle"
@@ -408,7 +425,7 @@ export function InviteLinkDialog({
)
break
}
case Step.CONFIRM_DISABLE:
case Step.CONFIRM_DISABLE: {
content = (
<>
<View style={[a.align_center, a.justify_center, a.mb_lg]}>
@@ -425,14 +442,7 @@ export function InviteLinkDialog({
]}>
<Trans>Disable this invite link?</Trans>
</Text>
<Text
style={[
a.pb_2xl,
a.text_center,
a.text_sm,
a.leading_snug,
t.atoms.text,
]}>
<Text style={[a.pb_2xl, a.text_center, a.text_sm, a.leading_snug]}>
<Trans>
Anyone who has it will no longer be able to join or request to
join. You can always create a new one.
@@ -465,6 +475,7 @@ export function InviteLinkDialog({
</>
)
break
}
}
if (!isOwner && (!joinLink || joinLink.enabledStatus === 'disabled')) {
@@ -472,7 +483,7 @@ export function InviteLinkDialog({
content = (
<>
<View style={[a.mt_lg]}>
<Text style={[a.text_sm, t.atoms.text]}>
<Text style={[a.text_sm]}>
<Trans>There is no invite link for this group chat.</Trans>
</Text>
</View>
@@ -503,9 +514,7 @@ export function InviteLinkDialog({
header={
<View>
<View style={[IS_WEB ? [a.px_2xl, a.pt_xl] : {paddingTop: 10}]}>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm, t.atoms.text]}>
{header}
</Text>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>{header}</Text>
</View>
<Dialog.Close />
</View>
@@ -517,3 +526,17 @@ export function InviteLinkDialog({
</Dialog.Outer>
)
}
function joinLinkToKey(joinLink: ChatBskyGroupDefs.JoinLinkView): string {
return `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`
}
function keyToJoinLink(
key: string,
): Pick<ChatBskyGroupDefs.JoinLinkView, 'joinRule' | 'requireApproval'> {
const [joinRule, requireApproval] = key.split(':')
return {
joinRule,
requireApproval: requireApproval === 'requireApproval',
}
}
+130 -119
View File
@@ -56,6 +56,7 @@ import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {DateDivider} from '#/components/dms/DateDivider'
import {MessageItem} from '#/components/dms/MessageItem'
import {MessageOverlays} from '#/components/dms/MessageOverlays'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
@@ -240,7 +241,11 @@ export function MessagesList({
// 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 && renderItems.length > 0) {
// Empty convos take this path too (once history is done) so hasScrolled gets set without an animated scroll.
if (
!hasInitiallyScrolled.current &&
(renderItems.length > 0 || !convoState.isFetchingHistory)
) {
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.
@@ -494,127 +499,133 @@ export function MessagesList({
return (
<InviteLinkDialogProvider convo={convoState.convo}>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
offset={Math.round(inputHeightJS)}
// 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 */}
<Animated.View style={[a.flex_1, animatedListStyle]}>
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={renderItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
disableVirtualization={true}
// The extra two items account for the header and the footer components
initialNumToRender={IS_NATIVE ? 32 : 62}
maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{minIndexForVisible: 0}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
showsVerticalScrollIndicator={!IS_ANDROID}
scrollEventThrottle={100}
ListHeaderComponent={
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.hasAllHistory ? (
convoState.convo?.kind === 'group' ? (
<MessagesListGroupInfoPanel convo={convoState.convo} />
) : (
<MessagesListInfoPanel convo={convoState.convo} />
)
) : null}
</>
}
// native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent}
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',
}),
]}
pointerEvents={!hasScrolled ? 'none' : 'auto'}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
</Animated.View>
<KeyboardStickyView
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
onLayout={onInputLayout}
minimumOffset={bottomInset}
offset={{
closed: platform({
ios: tokens.space.lg, // hide bottom padding when closed
default: 0,
}),
opened: 0,
}}>
{footer ?? (
<ConversationFooter
convoState={convoState}
hasAcceptOverride={hasAcceptOverride}>
{({loading}) =>
ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={(message: string) =>
void onSendMessage(message)
}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
<MessageOverlays>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
offset={Math.round(inputHeightJS)}
// 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 */}
<Animated.View style={[a.flex_1, animatedListStyle]}>
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={renderItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
disableVirtualization={true}
// The extra two items account for the header and the footer components
initialNumToRender={IS_NATIVE ? 32 : 62}
maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{minIndexForVisible: 0}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
showsVerticalScrollIndicator={!IS_ANDROID}
scrollEventThrottle={100}
ListHeaderComponent={
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.hasAllHistory ? (
convoState.convo?.kind === 'group' ? (
<MessagesListGroupInfoPanel convo={convoState.convo} />
) : (
<MessagesListInfoPanel convo={convoState.convo} />
)
) : null}
</>
}
// native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent}
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',
}),
]}
pointerEvents={!hasScrolled ? 'none' : 'auto'}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
</Animated.View>
<KeyboardStickyView
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
onLayout={onInputLayout}
minimumOffset={bottomInset}
offset={{
closed: platform({
ios: tokens.space.lg, // hide bottom padding when closed
default: 0,
}),
opened: 0,
}}>
{footer ?? (
<ConversationFooter
convoState={convoState}
hasAcceptOverride={hasAcceptOverride}>
{({loading}) =>
ax.features.enabled(
ax.features.DmsNewMessageComposerEnable,
) ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={(message: string) =>
void onSendMessage(message)
}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
/>
</MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
setEmbed={setEmbed}
/>
</MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
/>
</MessageInput>
)
}
</ConversationFooter>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
setEmbed={setEmbed}
/>
</MessageInput>
)
}
</ConversationFooter>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
{newMessagesPill.show && (
<NewMessagesPill onPress={scrollToEndOnPress} />
)}
</MessageOverlays>
</InviteLinkDialogProvider>
)
}
@@ -43,14 +43,11 @@ export function MessagesListGroupInfoPanel({
},
})
// TODO Enable this once the feature is working end-to-end. -dsb
// const joinLink = groupConvo?.details.joinLink
const isJoinLinkEnabled = false
// (isOwner && groupConvo) ||
// (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled')
const isOwner = convo.primaryMember?.did === currentAccount?.did
const isJoinLinkEnabled =
isOwner || convo.details.joinLink?.enabledStatus === 'enabled'
const members = (convo.members ?? []).filter(
profile => profile.did !== currentAccount?.did,
)
@@ -28,11 +28,11 @@ import {preferencesQueryKey} from '#/state/queries/preferences'
import {RQKEY as profileRQKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {useProgressGuideControls} from '#/state/shell/progress-guide'
import {
useActiveStarterPack,
useSetActiveStarterPack,
} from '#/state/shell/starter-pack'
} from '#/state/shell/landing'
import {useProgressGuideControls} from '#/state/shell/progress-guide'
import {
OnboardingControls,
OnboardingHeaderSlot,
@@ -123,7 +123,11 @@ export function AutomationLabelSettingsScreen({}: Props) {
paddingRight: 20, // helps visually center
},
]}>
<UserAvatar size={42} avatar={profile.avatar} type="user" />
<UserAvatar
size={42}
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View>
<View style={[a.flex_row, a.align_baseline]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
+1 -1
View File
@@ -9,7 +9,7 @@ import {FEEDBACK_FORM_URL} from '#/lib/constants'
import {logger} from '#/logger'
import {useServiceQuery} from '#/state/queries/service'
import {useStarterPackQuery} from '#/state/queries/starter-packs'
import {useActiveStarterPack} from '#/state/shell/starter-pack'
import {useActiveStarterPack} from '#/state/shell/landing'
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
import {
initialState,
@@ -19,7 +19,7 @@ import {useStarterPackQuery} from '#/state/queries/starter-packs'
import {
useActiveStarterPack,
useSetActiveStarterPack,
} from '#/state/shell/starter-pack'
} from '#/state/shell/landing'
import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut'
import {formatCount} from '#/view/com/util/numeric/format'
import {Logo} from '#/view/icons/Logo'
@@ -37,12 +37,12 @@ import {
useStarterPackQuery,
} from '#/state/queries/starter-packs'
import {useAgent, useSession} from '#/state/session'
import {useSetActiveStarterPack} from '#/state/shell/landing'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
+3 -11
View File
@@ -13,7 +13,6 @@ import {
} from 'expo-image-manipulator'
import {nanoid} from 'nanoid/non-secure'
import {POST_IMG_MAX} from '#/lib/constants'
import {getImageDim} from '#/lib/media/manip'
import {openCropper} from '#/lib/media/picker'
import {type PickerImage} from '#/lib/media/picker.shared'
@@ -202,19 +201,12 @@ export function resetImageManipulation(
return img
}
export async function compressImage(
img: ComposerImage,
options?: {
highResolution?: boolean
increasedBlobSizeLimit?: boolean
},
): Promise<PickerImage> {
export async function compressImage(img: ComposerImage): Promise<PickerImage> {
const source = img.transformed || img.source
const highResolution = options?.highResolution ?? false
let attempts = 0
let maxDimension = highResolution ? 4000 : POST_IMG_MAX.width
let maxBytes = options?.increasedBlobSizeLimit ? 2000000 : POST_IMG_MAX.size
let maxDimension = 4000
let maxBytes = 2000000
let minQualityPercentage = 0
let maxQualityPercentage = 101 // exclusive
+2 -2
View File
@@ -1,5 +1,5 @@
import {createContext, useContext, useMemo} from 'react'
import {BskyAgent, type ModerationOpts} from '@atproto/api'
import {AtpAgent, type ModerationOpts} from '@atproto/api'
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
@@ -43,7 +43,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
...moderationPrefs,
labelers: moderationPrefs.labelers.length
? moderationPrefs.labelers
: BskyAgent.appLabelers.map(did => ({
: AtpAgent.appLabelers.map(did => ({
did,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
})),
+72
View File
@@ -0,0 +1,72 @@
import {AtpAgent} from '@atproto/api'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {STALE} from '#/state/queries/index'
import {createQueryKey} from '#/state/queries/util'
import {useAgent} from '#/state/session'
const joinLinkPreviewQueryKeyRoot = 'join-link-preview'
export const createJoinLinkPreviewQueryKey = (args: {
codes: string[]
hasSession: boolean
}) =>
createQueryKey(joinLinkPreviewQueryKeyRoot, args, {
persistedVersion: 1,
})
export function useJoinLinkPreviewsQuery({
codes,
hasSession,
}: {
codes?: string[]
hasSession: boolean
}) {
const agent = useAgent()
return useQuery({
queryKey: createJoinLinkPreviewQueryKey({codes: codes ?? [], hasSession}),
queryFn: async () => {
if (!codes) throw new Error('No invite code')
try {
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
const res = hasSession
? await agent.chat.bsky.group.getJoinLinkPreviews(
{codes},
{headers: DM_SERVICE_HEADERS},
)
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
return res.data
} catch (error) {
logger.error('Failed to fetch join link preview', {safeMessage: error})
throw error
}
},
enabled: codes != null && codes.length > 0,
staleTime: STALE.SECONDS.FIFTEEN,
})
}
export function usePrefetchJoinLinkPreviews() {
const agent = useAgent()
const queryClient = useQueryClient()
return ({codes, hasSession}: {codes: string[]; hasSession: boolean}) => {
return queryClient.prefetchQuery({
queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}),
queryFn: async () => {
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
const res = hasSession
? await agent.chat.bsky.group.getJoinLinkPreviews(
{codes},
{headers: DM_SERVICE_HEADERS},
)
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
return res.data
},
staleTime: STALE.SECONDS.FIFTEEN,
})
}
}
+129
View File
@@ -0,0 +1,129 @@
import {
type ChatBskyActorDefs,
type ChatBskyGroupApproveJoinRequest,
type ChatBskyGroupListJoinRequests,
type ChatBskyGroupRejectJoinRequest,
} from '@atproto/api'
import {
type InfiniteData,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {listConvoMembersQueryKey} from './list-convo-members'
import {createListJoinRequestsQueryKey} from './list-join-requests'
type JoinRequestAction = 'approve' | 'reject'
type JoinRequestOutput<A extends JoinRequestAction> = A extends 'approve'
? ChatBskyGroupApproveJoinRequest.OutputSchema
: ChatBskyGroupRejectJoinRequest.OutputSchema
export function useJoinRequestMutation<A extends JoinRequestAction>(
action: A,
convoId: string | undefined,
{
onSuccess,
onError,
}: {
onSuccess?: (data: JoinRequestOutput<A>) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async ({member}: {member: string}) => {
if (!convoId) throw new Error('No convoId provided')
const endpoint =
action === 'approve'
? agent.chat.bsky.group.approveJoinRequest
: agent.chat.bsky.group.rejectJoinRequest
const {data} = await endpoint(
{convoId, member},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data as JoinRequestOutput<A>
},
onMutate: ({member}) => {
if (!convoId) return
const requestsKey = createListJoinRequestsQueryKey({convoId})
const prevRequests =
queryClient.getQueryData<
InfiniteData<ChatBskyGroupListJoinRequests.OutputSchema>
>(requestsKey)
const requestedByProfile = prevRequests?.pages
.flatMap(page => page.requests)
.find(request => request.requestedBy.did === member)?.requestedBy
queryClient.setQueryData<
InfiniteData<ChatBskyGroupListJoinRequests.OutputSchema>
>(requestsKey, prev => {
if (!prev?.pages) return prev
return {
...prev,
pages: prev.pages.map(page => ({
...page,
requests: page.requests.filter(
request => request.requestedBy.did !== member,
),
})),
}
})
let prevMembers: ChatBskyActorDefs.ProfileViewBasic[] | undefined
if (action === 'approve' && requestedByProfile) {
const membersKey = listConvoMembersQueryKey(convoId)
prevMembers =
queryClient.getQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
membersKey,
)
queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
membersKey,
prev => {
if (!prev) return prev
if (prev.some(m => m.did === member)) return prev
return [...prev, requestedByProfile]
},
)
}
return {prevRequests, prevMembers}
},
onSuccess: data => {
if (convoId) {
void queryClient.invalidateQueries({
queryKey: createListJoinRequestsQueryKey({convoId}),
})
if (action === 'approve') {
void queryClient.invalidateQueries({
queryKey: listConvoMembersQueryKey(convoId),
})
}
}
onSuccess?.(data)
},
onError: (error, _variables, context) => {
logger.error(error)
if (convoId && context?.prevRequests) {
queryClient.setQueryData(
createListJoinRequestsQueryKey({convoId}),
context.prevRequests,
)
}
if (convoId && action === 'approve' && context?.prevMembers) {
queryClient.setQueryData(
listConvoMembersQueryKey(convoId),
context.prevMembers,
)
}
onError?.(error)
},
})
}
@@ -489,12 +489,22 @@ export function ListConvosProviderInner({
// Join link data not included in the log event, trigger refetch to get it
debouncedRefetch()
} else if (
ChatBskyConvoDefs.isLogIncomingJoinRequest(log) ||
ChatBskyConvoDefs.isLogApproveJoinRequest(log) ||
ChatBskyConvoDefs.isLogRejectJoinRequest(log) ||
ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)
ChatBskyConvoDefs.isLogRejectJoinRequest(log)
) {
// TODO Update join request count here when available. -dsb
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
updateGroupConvoJoinRequestCount(log, old, -1),
)
} else if (ChatBskyConvoDefs.isLogIncomingJoinRequest(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
updateGroupConvoJoinRequestCount(log, old, 1),
)
} else if (ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)) {
// Viewer isn't in the chat yet, no need to do anything
} else if (ChatBskyConvoDefs.isLogAddReaction(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
@@ -762,6 +772,29 @@ function optimisticUpdate(
}
}
function updateGroupConvoJoinRequestCount(
log: {convoId: string; rev: string},
old: ConvoListQueryData | undefined,
delta: 1 | -1,
) {
return optimisticUpdate(log.convoId, old, convo => {
// Join requests are only meaningful for group convos.
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {...convo, rev: log.rev}
}
const current = convo.kind.joinRequestCount ?? 0
const next = Math.max(0, current + delta)
return {
...convo,
kind: {
...convo.kind,
joinRequestCount: next === 0 ? undefined : next,
},
rev: log.rev,
}
})
}
function removeMemberFromConvoView(
convo: ChatBskyConvoDefs.ConvoView,
did: string,
@@ -0,0 +1,37 @@
import {type ChatBskyGroupRequestJoin} from '@atproto/api'
import {useMutation} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
export function useRequestJoinGroupChat({
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupRequestJoin.OutputSchema) => void
onError?: (error: Error) => void
} = {}) {
const agent = useAgent()
const {hasSession} = useSession()
return useMutation({
mutationFn: async ({code}: {code: string}) => {
if (!hasSession) throw new Error('Must be logged in to join')
if (!code) throw new Error('No invite code')
const res = await agent.chat.bsky.group.requestJoin(
{code},
{headers: DM_SERVICE_HEADERS},
)
return res.data
},
onSuccess: data => {
onSuccess?.(data)
},
onError: error => {
logger.error('Failed to join group chat', {safeMessage: error})
onError?.(error)
},
})
}
@@ -0,0 +1,38 @@
import {type ChatBskyGroupWithdrawJoinRequest} from '@atproto/api'
import {useMutation} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
export function useWithdrawJoinGroupChatRequest({
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupWithdrawJoinRequest.OutputSchema) => void
onError?: (error: Error) => void
} = {}) {
const agent = useAgent()
const {hasSession} = useSession()
return useMutation({
mutationFn: async ({convoId}: {convoId: string}) => {
if (!hasSession)
throw new Error('Must be logged in to withdraw a join request')
if (!convoId) throw new Error('No convoId provided')
const res = await agent.chat.bsky.group.withdrawJoinRequest(
{convoId},
{headers: DM_SERVICE_HEADERS},
)
return res.data
},
onSuccess: data => {
onSuccess?.(data)
},
onError: error => {
logger.error('Failed to withdraw join request', {safeMessage: error})
onError?.(error)
},
})
}
+66
View File
@@ -0,0 +1,66 @@
import {createContext, useContext, useState} from 'react'
import {logger} from '#/logger'
type StarterPackLanding = {
type: 'starterpack'
uri: string
isClip?: boolean
}
type GroupChatJoinRequestLanding = {
type: 'groupchat'
uri: string
code: string
}
type LandingType = StarterPackLanding | GroupChatJoinRequestLanding | undefined
type SetContext = (v: LandingType) => void
const stateContext = createContext<LandingType>(undefined)
stateContext.displayName = 'ActiveLandingStateContext'
const setContext = createContext<SetContext>((_: LandingType) => {})
setContext.displayName = 'ActiveLandingSetContext'
export function Provider({children}: {children: React.ReactNode}) {
const [state, setState] = useState<LandingType>()
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setState}>{children}</setContext.Provider>
</stateContext.Provider>
)
}
// Core hooks
export const useActiveLanding = () => useContext(stateContext)
export const useSetActiveLanding = () => useContext(setContext)
// Filtered hooks for convenience
export const useActiveStarterPack = () => {
const landing = useActiveLanding()
return landing?.type === 'starterpack' ? landing : undefined
}
export const useSetActiveStarterPack = () => {
const setLanding = useSetActiveLanding()
const currentLanding = useActiveLanding()
return (pack: {uri: string; isClip?: boolean} | undefined) => {
if (!pack) {
setLanding(undefined)
} else {
if (currentLanding && currentLanding.type !== 'starterpack') {
logger.debug(
`[landing] Replacing ${currentLanding.type} landing with starterpack`,
)
}
setLanding({type: 'starterpack', ...pack})
}
}
}
export const useActiveGroupChatJoinRequest = () => {
const landing = useActiveLanding()
return landing?.type === 'groupchat' ? landing : undefined
}
+32 -10
View File
@@ -1,7 +1,7 @@
import {createContext, useContext, useMemo, useState} from 'react'
import {useSession} from '#/state/session'
import {useActiveStarterPack} from '#/state/shell/starter-pack'
import {useActiveLanding} from '#/state/shell/landing'
import {IS_WEB} from '#/env'
type State = {
@@ -26,7 +26,12 @@ type Controls = {
/**
* The did of the account to populate the login form with.
*/
requestedAccount?: (string & {}) | 'none' | 'new' | 'starterpack'
requestedAccount?:
| (string & {})
| 'none'
| 'new'
| 'starterpack'
| 'groupchat'
}) => void
/**
* Clears the requested account so that next time the logged out view is
@@ -48,17 +53,34 @@ const ControlsContext = createContext<Controls>({
})
ControlsContext.displayName = 'LoggedOutControlsContext'
function getRequestedAccountFromLanding(
landing: ReturnType<typeof useActiveLanding>,
hasSession: boolean,
): string | undefined {
if (hasSession || !landing) return undefined
switch (landing.type) {
case 'starterpack':
return IS_WEB ? 'starterpack' : 'new'
case 'groupchat':
return 'groupchat'
default:
return undefined
}
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const activeStarterPack = useActiveStarterPack()
const activeLanding = useActiveLanding()
const {hasSession} = useSession()
const shouldShowStarterPack = Boolean(activeStarterPack?.uri) && !hasSession
const requestedAccount = getRequestedAccountFromLanding(
activeLanding,
hasSession,
)
const [state, setState] = useState<State>({
showLoggedOut: shouldShowStarterPack,
requestedAccountSwitchTo: shouldShowStarterPack
? IS_WEB
? 'starterpack'
: 'new'
: undefined,
showLoggedOut: Boolean(requestedAccount),
requestedAccountSwitchTo: requestedAccount,
})
const controls = useMemo<Controls>(
+30 -19
View File
@@ -9,6 +9,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {STALE} from '#/state/queries'
import {profilesQueryKey} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session'
import {useSetActiveLanding} from '#/state/shell/landing'
import {
useLoggedOutView,
useLoggedOutViewControls,
@@ -16,6 +17,7 @@ import {
import {useEnableMinimalShellMode} from '#/state/shell/minimal-mode'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {Login} from '#/screens/Login'
import {JoinRequest} from '#/screens/Messages/JoinRequest'
import {Signup} from '#/screens/Signup'
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
import {atoms as a, native, tokens, useTheme} from '#/alf'
@@ -29,9 +31,26 @@ enum ScreenState {
S_Login,
S_CreateAccount,
S_StarterPack,
S_GroupChatJoinRequest,
}
export {ScreenState as LoggedOutScreenState}
function getInitialScreenState(requestedAccountSwitchTo?: string): ScreenState {
switch (requestedAccountSwitchTo) {
case 'new':
return ScreenState.S_CreateAccount
case 'starterpack':
return ScreenState.S_StarterPack
case 'groupchat':
return ScreenState.S_GroupChatJoinRequest
case undefined:
return ScreenState.S_LoginOrCreateAccount
default:
// DID or other string = login with that account
return ScreenState.S_Login
}
}
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
const {_} = useLingui()
const ax = useAnalytics()
@@ -39,18 +58,11 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
const insets = useSafeAreaInsets()
useEnableMinimalShellMode()
const {requestedAccountSwitchTo} = useLoggedOutView()
const [screenState, setScreenState] = useState<ScreenState>(() => {
if (requestedAccountSwitchTo === 'new') {
return ScreenState.S_CreateAccount
} else if (requestedAccountSwitchTo === 'starterpack') {
return ScreenState.S_StarterPack
} else if (requestedAccountSwitchTo != null) {
return ScreenState.S_Login
} else {
return ScreenState.S_LoginOrCreateAccount
}
})
const initialScreenState = getInitialScreenState(requestedAccountSwitchTo)
const [screenState, setScreenState] =
useState<ScreenState>(initialScreenState)
const {clearRequestedAccount} = useLoggedOutViewControls()
const setActiveLanding = useSetActiveLanding()
const queryClient = useQueryClient()
const {accounts} = useSession()
@@ -73,7 +85,9 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
onDismiss()
}
clearRequestedAccount()
}, [clearRequestedAccount, onDismiss])
// Clear landing context when user dismisses the modal
setActiveLanding(undefined)
}, [clearRequestedAccount, onDismiss, setActiveLanding])
return (
<View
@@ -107,6 +121,8 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
{screenState === ScreenState.S_StarterPack ? (
<LandingScreen setScreenState={setScreenState} />
) : screenState === ScreenState.S_GroupChatJoinRequest ? (
<JoinRequest setScreenState={setScreenState} />
) : screenState === ScreenState.S_LoginOrCreateAccount ? (
<SplashScreen
onPressSignin={() => {
@@ -122,17 +138,12 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
{screenState === ScreenState.S_Login ? (
<Login
onPressBack={() => {
setScreenState(ScreenState.S_LoginOrCreateAccount)
clearRequestedAccount()
setScreenState(initialScreenState)
}}
/>
) : undefined}
{screenState === ScreenState.S_CreateAccount ? (
<Signup
onPressBack={() =>
setScreenState(ScreenState.S_LoginOrCreateAccount)
}
/>
<Signup onPressBack={() => setScreenState(initialScreenState)} />
) : undefined}
</ErrorBoundary>
</View>
+8 -26
View File
@@ -222,10 +222,6 @@ export const ComposePost = ({
const [publishingStage, setPublishingStage] = useState('')
const [error, setError] = useState('')
const enableLargeVideoUploads = ax.features.enabled(
ax.features.LargeVideoUploads,
)
/**
* Track when a draft was created so we can measure draft age in metrics.
* Set when a draft is loaded via handleSelectDraft.
@@ -348,10 +344,9 @@ export const ComposePost = ({
currentDid,
abortController.signal,
i18n,
enableLargeVideoUploads,
)
},
[i18n, agent, currentDid, composerDispatch, enableLargeVideoUploads],
[i18n, agent, currentDid, composerDispatch],
)
const onInitVideo = useNonReactiveCallback(() => {
@@ -496,7 +491,6 @@ export const ComposePost = ({
currentDid,
abortController.signal,
i18n,
enableLargeVideoUploads,
)
} catch (e) {
logger.error('Failed to restore video from draft', {
@@ -505,7 +499,7 @@ export const ComposePost = ({
})
}
},
[i18n, agent, currentDid, composerDispatch, enableLargeVideoUploads],
[i18n, agent, currentDid, composerDispatch],
)
const handleSelectDraft = useCallback(
@@ -883,24 +877,12 @@ export const ComposePost = ({
try {
logger.info(`composer: posting...`)
postUri = (
await apilib.post(
agent,
queryClient,
{
thread: filteredThread,
replyTo: replyTo?.uri,
onStateChange: setPublishingStage,
langs: currentLanguages,
},
{
highResolutionImages: ax.features.enabled(
ax.features.ImageUploadsHighResolution,
),
increasedBlobSizeLimit: ax.features.enabled(
ax.features.ImageUploadsBlobSize2mbEnabled,
),
},
)
await apilib.post(agent, queryClient, {
thread: filteredThread,
replyTo: replyTo?.uri,
onStateChange: setPublishingStage,
langs: currentLanguages,
})
).uris[0]
/*
+2 -1
View File
@@ -98,7 +98,8 @@ export const ExternalEmbedLink = ({
uri,
description:
data.view?.external?.description || data.description,
thumb: data.view?.external?.thumb || data.thumb?.source.path,
// prefer opengraph data to atproto record-derived image
thumb: data.thumb?.source.path || data.view?.external?.thumb,
}}
/>
)
+6 -2
View File
@@ -5,7 +5,11 @@ import {type ImagePickerAsset} from 'expo-image-picker'
import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE} from '#/lib/constants'
import {
VIDEO_MAX_DURATION_MS,
VIDEO_MAX_SIZE,
VIDEO_MAX_SIZE_MB,
} from '#/lib/constants'
import {
usePhotoLibraryPermission,
useVideoLibraryPermission,
@@ -431,7 +435,7 @@ export function SelectMediaButton({
msg`You can only select one GIF at a time.`,
),
[SelectedAssetError.FileTooBig]: _(
msg`One or more of your selected files are too large. Maximum size is 100 MB.`,
msg`One or more of your selected files are too large. Maximum size is ${VIDEO_MAX_SIZE_MB} MB.`,
),
}[error]
})
+7 -22
View File
@@ -4,6 +4,7 @@ import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {AbortError} from '#/lib/async/cancelable'
import {VIDEO_MAX_SIZE_MB} from '#/lib/constants'
import {compressVideo} from '#/lib/media/video/compress'
import {
ServerError,
@@ -264,7 +265,6 @@ export async function processVideo(
did: string,
signal: AbortSignal,
i18n: I18n,
TEMP_enableLargeVideoUploads: boolean,
) {
let video: CompressedVideo | undefined
try {
@@ -273,14 +273,9 @@ export async function processVideo(
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
},
signal,
TEMP_enableLargeVideoUploads,
})
} catch (e) {
const message = getCompressErrorMessage(
e,
i18n,
TEMP_enableLargeVideoUploads,
)
const message = getCompressErrorMessage(e, i18n)
if (message !== null) {
dispatch({
type: 'to_error',
@@ -309,7 +304,7 @@ export async function processVideo(
},
})
} catch (e) {
const message = getUploadErrorMessage(e, i18n, TEMP_enableLargeVideoUploads)
const message = getUploadErrorMessage(e, i18n)
if (message !== null) {
dispatch({
type: 'to_error',
@@ -393,30 +388,20 @@ export async function processVideo(
}
}
function getCompressErrorMessage(
e: unknown,
i18n: I18n,
TEMP_enableLargeVideoUploads: boolean,
): string | null {
const videoSize = TEMP_enableLargeVideoUploads ? 300 : 100
function getCompressErrorMessage(e: unknown, i18n: I18n): string | null {
if (e instanceof AbortError) {
return null
}
if (e instanceof VideoTooLargeError) {
return i18n._(
msg`The selected video is larger than ${videoSize} MB. Please try again with a smaller file.`,
msg`The selected video is larger than ${VIDEO_MAX_SIZE_MB} MB. Please try again with a smaller file.`,
)
}
logger.error('Error compressing video', {safeMessage: e})
return i18n._(msg`An error occurred while compressing the video.`)
}
function getUploadErrorMessage(
e: unknown,
i18n: I18n,
TEMP_enableLargeVideoUploads: boolean,
): string | null {
const videoSize = TEMP_enableLargeVideoUploads ? 300 : 100
function getUploadErrorMessage(e: unknown, i18n: I18n): string | null {
if (e instanceof AbortError) {
return null
}
@@ -448,7 +433,7 @@ function getUploadErrorMessage(
case 'file size (100000001 bytes) is larger than the maximum allowed size (100000000 bytes)':
case 'file size (300000001 bytes) is larger than the maximum allowed size (300000000 bytes)':
return i18n._(
msg`The selected video is larger than ${videoSize} MB. Please try again with a smaller file.`,
msg`The selected video is larger than ${VIDEO_MAX_SIZE_MB} MB. Please try again with a smaller file.`,
)
case 'Confirm your email address to upload videos':
return i18n._(msg`Please confirm your email address to upload videos.`)
@@ -1093,6 +1093,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
<MediaPreview.Embed
embed={post.embed}
style={styles.additionalPostImages}
peekable
/>
</>
)
+22 -1
View File
@@ -13,6 +13,7 @@ import {
import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions} from '@react-navigation/native'
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
import {
type DebouncedNavigationProp,
useNavigationDeduped,
@@ -21,6 +22,7 @@ import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {getTabState, TabState} from '#/lib/routes/helpers'
import {
convertBskyAppUrlIfNeeded,
getChatInviteCodeFromUrl,
isExternalUrl,
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
@@ -81,6 +83,7 @@ export const Link = memo(function Link({
const navigation = useNavigationDeduped()
const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
const openLink = useOpenLink()
const groupChatJoinIntent = useGroupChatJoinIntent()
const onPress = useCallback(
(e?: Event) => {
@@ -92,11 +95,20 @@ export const Link = memo(function Link({
sanitizeUrl(href),
navigationAction,
openLink,
groupChatJoinIntent,
e,
)
}
},
[closeModal, navigation, navigationAction, href, openLink, onBeforePress],
[
closeModal,
navigation,
navigationAction,
href,
openLink,
onBeforePress,
groupChatJoinIntent,
],
)
const accessibilityActionsWithActivate = [
@@ -196,6 +208,7 @@ export const TextLink = memo(function TextLink({
const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const openLink = useOpenLink()
const groupChatJoinIntent = useGroupChatJoinIntent()
if (!disableMismatchWarning && typeof text !== 'string') {
console.error('Unable to detect mismatching label')
@@ -238,6 +251,7 @@ export const TextLink = memo(function TextLink({
sanitizeUrl(href),
navigationAction,
openLink,
groupChatJoinIntent,
e,
)
},
@@ -252,6 +266,7 @@ export const TextLink = memo(function TextLink({
navigationAction,
openLink,
linkWarningDialogControl,
groupChatJoinIntent,
],
)
const hrefAttrs = useMemo(() => {
@@ -373,6 +388,7 @@ function onPressInner(
href: string,
navigationAction: 'push' | 'replace' | 'navigate' = 'push',
openLink: (href: string) => void,
groupChatJoinIntent: (code: string, uri?: string) => void,
e?: Event,
) {
let shouldHandle = false
@@ -400,6 +416,11 @@ function onPressInner(
if (shouldHandle) {
href = convertBskyAppUrlIfNeeded(href)
const chatInviteCode = getChatInviteCodeFromUrl(href)
if (chatInviteCode) {
groupChatJoinIntent(chatInviteCode, href)
return
}
if (
newTab ||
href.startsWith('http') ||
+1 -1
View File
@@ -345,7 +345,7 @@ function ListImpl<ItemT>(
style,
disableFullWindowScroll && {
flex: 1,
overflowY: 'scroll',
overflowY: isWithinSplitView ? 'auto' : 'scroll',
},
]}
ref={nativeRef as unknown as React.RefObject<View>}>
+4 -1
View File
@@ -22,8 +22,11 @@ export function TimeElapsed({
)
const [prevTick, setPrevTick] = useState(tick)
if (prevTick !== tick) {
const [prevTimestamp, setPrevTimestamp] = useState(timestamp)
if (prevTick !== tick || prevTimestamp !== timestamp) {
setPrevTick(tick)
setPrevTimestamp(timestamp)
setTimeAgo(
timeToString ? timeToString(i18n, timestamp) : ago(timestamp, tick),
)
+55
View File
@@ -0,0 +1,55 @@
import Svg, {Path, type PathProps, type SvgProps} from 'react-native-svg'
import {useTheme} from '#/alf'
const ratio = 17 / 64
export function LogomarkWithType({
fill,
...rest
}: {fill?: PathProps['fill']} & SvgProps) {
const t = useTheme()
const size = parseInt(`${rest.width || 32}`)
return (
<Svg
fill="none"
viewBox="0 0 136 31"
{...rest}
width={size}
height={Number(size) * ratio}>
<Path
d="M7.06102 1.95468C10.7957 4.79341 14.8125 10.5493 16.2874 13.6379C17.7624 10.5493 21.7792 4.79341 25.5138 1.95468C28.2085 -0.0935907 32.5749 -1.67844 32.5749 3.36465C32.5749 4.3718 32.0045 11.8254 31.67 13.0356C30.5072 17.2426 26.2702 18.3156 22.5011 17.6661C29.0892 18.8014 30.7651 22.5617 27.1457 26.3219C19.002 34.7826 16.2874 21.7959 16.2874 21.7959C16.2874 21.7959 13.5729 34.7826 5.42914 26.3219C1.80971 22.5617 3.48561 18.8014 10.0737 17.6661C6.30464 18.3156 2.06767 17.2426 0.904857 13.0356C0.570361 11.8254 0 4.3718 0 3.36465C0 -1.67844 4.36632 -0.0935907 7.06102 1.95468Z"
fill={fill || t.atoms.text.color}
/>
<Path
d="M52.8842 14.3087C55.1269 15.1125 56.3176 16.9695 56.3176 19.0483C56.3176 22.5684 53.964 24.7027 49.4508 24.7027H40.2305V4.9679H49.1462C53.4379 4.9679 55.5146 7.15757 55.5146 10.0679C55.5146 12.0081 54.6286 13.4217 52.8842 14.3087ZM48.8693 8.04453H43.9684V13.2H48.8693C50.7798 13.2 51.8043 12.2021 51.8043 10.5391C51.8043 9.01463 50.7521 8.04453 48.8693 8.04453ZM43.9684 21.5983H49.257C51.3613 21.5983 52.4965 20.6282 52.4965 18.882C52.4965 17.0527 51.4167 16.138 49.257 16.138H43.9684V21.5983Z"
fill={fill || t.atoms.text.color}
/>
<Path
d="M61.5003 24.7027H58.0115V4.9679H61.5003V24.7027Z"
fill={fill || t.atoms.text.color}
/>
<Path
d="M73.0679 18.4108V10.4005H76.5567V24.7027H73.1787V22.6239C72.0988 24.2315 70.6036 25.0353 68.6931 25.0353C65.675 25.0353 63.7091 23.2059 63.7091 19.8799V10.4005H67.1979V19.2978C67.1979 21.0994 68.084 22.0141 69.8837 22.0141C71.5727 22.0141 73.0679 20.7668 73.0679 18.4108Z"
fill={fill || t.atoms.text.color}
/>
<Path
d="M92.4368 17.801V18.6326H81.7489C81.9981 21.0994 83.3549 22.319 85.4592 22.319C87.0652 22.319 88.145 21.626 88.7265 20.2679H92.0768C91.3292 23.1782 88.8372 25.0353 85.4315 25.0353C83.2995 25.0353 81.5828 24.3424 80.2814 22.9842C78.9801 21.626 78.3156 19.8244 78.3156 17.5516C78.3156 15.3065 78.9524 13.5049 80.2538 12.119C81.5551 10.7608 83.2441 10.0679 85.3762 10.0679C87.5359 10.0679 89.2526 10.7885 90.5262 12.2021C91.7999 13.6157 92.4368 15.5005 92.4368 17.801ZM85.3485 12.7842C83.438 12.7842 82.1089 13.8929 81.7766 16.1934H88.948C88.6434 14.1146 87.3697 12.7842 85.3485 12.7842Z"
fill={fill || t.atoms.text.color}
/>
<Path
d="M100.169 25.0907C96.0159 25.0907 93.8285 23.4554 93.6347 20.157H97.0404C97.2342 21.9309 98.1203 22.5962 100.225 22.5962C102.107 22.5962 103.049 22.0141 103.049 20.8777C103.049 19.8521 102.384 19.3532 100.252 18.9929L98.6187 18.7157C95.4898 18.1891 93.9393 16.7478 93.9393 14.3918C93.9393 11.7032 96.0713 10.0679 99.8647 10.0679C103.935 10.0679 106.039 11.6755 106.178 14.9184H102.883C102.8 13.1722 101.775 12.5625 99.8647 12.5625C98.2033 12.5625 97.3727 13.1168 97.3727 14.2255C97.3727 15.2233 98.0926 15.6668 99.7539 15.9717L101.554 16.2489C105.015 16.8864 106.51 18.1614 106.51 20.6005C106.51 23.4831 104.184 25.0907 100.169 25.0907Z"
fill={fill || t.atoms.text.color}
/>
<Path
d="M121.822 24.7027H117.835L113.682 18.0505L111.522 20.2125V24.7027H108.089V4.9679H111.522V16.332L117.337 10.4005H121.49L116.091 15.8054L121.822 24.7027Z"
fill={fill || t.atoms.text.color}
/>
<Path
d="M130.934 14.0038L132.097 10.4005H135.751L130.241 25.8668C129.66 27.4467 128.94 28.5831 128.026 29.2206C127.113 29.8581 125.811 30.163 124.095 30.163C123.513 30.163 123.015 30.1353 122.572 30.0799V27.3358H123.901C125.479 27.3358 126.254 26.3657 126.254 25.0353C126.254 24.3701 126.033 23.4 125.59 22.1527L121.436 10.4005H125.202L126.365 13.976C127.223 16.6646 127.971 19.3255 128.635 21.9587C129.245 19.6858 130.02 17.025 130.934 14.0038Z"
fill={fill || t.atoms.text.color}
/>
</Svg>
)
}