diff --git a/assets/images/chat-desktop-bg-dark.webp b/assets/images/chat-desktop-bg-dark.webp new file mode 100644 index 0000000000..9a3929f2f2 Binary files /dev/null and b/assets/images/chat-desktop-bg-dark.webp differ diff --git a/assets/images/chat-desktop-bg-light.webp b/assets/images/chat-desktop-bg-light.webp new file mode 100644 index 0000000000..099a1045a3 Binary files /dev/null and b/assets/images/chat-desktop-bg-light.webp differ diff --git a/assets/images/chat-mobile-bg-dark.webp b/assets/images/chat-mobile-bg-dark.webp new file mode 100644 index 0000000000..1ed3c11051 Binary files /dev/null and b/assets/images/chat-mobile-bg-dark.webp differ diff --git a/assets/images/chat-mobile-bg-light.webp b/assets/images/chat-mobile-bg-light.webp new file mode 100644 index 0000000000..f14a816c70 Binary files /dev/null and b/assets/images/chat-mobile-bg-light.webp differ diff --git a/src/App.native.tsx b/src/App.native.tsx index 2654c6ed83..22dd39f9d7 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -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() { - + - + - + diff --git a/src/App.web.tsx b/src/App.web.tsx index 5c22b3c921..6faf6f2218 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -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() { - + - + - + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 6284aff34b..1348a975ea 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -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' @@ -797,6 +798,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) diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index 880a5284f0..c49dada16f 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -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 [] diff --git a/src/components/Link.tsx b/src/components/Link.tsx index fcb2e8a87f..8795c8e899 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -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, ], ) diff --git a/src/components/hooks/useStarterPackEntry.native.ts b/src/components/hooks/useLandingEntry.native.ts similarity index 64% rename from src/components/hooks/useStarterPackEntry.native.ts rename to src/components/hooks/useLandingEntry.native.ts index 31221d599d..c5950a6469 100644 --- a/src/components/hooks/useStarterPackEntry.native.ts +++ b/src/components/hooks/useLandingEntry.native.ts @@ -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 } diff --git a/src/components/hooks/useStarterPackEntry.ts b/src/components/hooks/useLandingEntry.ts similarity index 76% rename from src/components/hooks/useStarterPackEntry.ts rename to src/components/hooks/useLandingEntry.ts index ccbd991574..e3b728a5a0 100644 --- a/src/components/hooks/useStarterPackEntry.ts +++ b/src/components/hooks/useLandingEntry.ts @@ -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) diff --git a/src/components/intents/GroupChatJoinDialog.tsx b/src/components/intents/GroupChatJoinDialog.tsx new file mode 100644 index 0000000000..f787a89c3a --- /dev/null +++ b/src/components/intents/GroupChatJoinDialog.tsx @@ -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 ( + + + + + ) +} + +function GroupChatJoinDialogInner({code}: {code?: string}) { + const {t: l} = useLingui() + + return ( + + + + + + + ) +} + +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() + + 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 ( + + + + ) + } + + if (error) { + return ( + <> + + + This invite link is invalid + + + + ) + } + + const joinLinkPreview = data.joinLinkPreviews[0] + + if (!joinLinkPreview) { + return ( + <> + + + + + Chat invite link no longer available + + + + + + ) + } + + 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 ( + <> + + + + + + Group chat + + + {joinLinkPreview.name} + + + + + + {joinLinkPreview.memberCount}/{joinLinkPreview.memberLimit}{' '} + members + + + + + + + {joinLinkPreview.joinRule === 'followedByOwner' + ? l`Followers can join` + : l`Anyone can join`} + + + + + + By{' '} + { + onInteract() + }, + onMouseLeave: () => { + onInteractOut() + }, + })}> + {createSanitizedDisplayName( + joinLinkPreview.owner, + true, + moderateProfile(joinLinkPreview.owner, moderationOpts).ui( + 'displayName', + ), + )} + + + + + + {sanitizeHandle(joinLinkPreview.owner.handle, '@')} + + + + + {convoId ? ( + + ) : ( + + )} + + ) +} diff --git a/src/components/intents/IntentDialogs.tsx b/src/components/intents/IntentDialogs.tsx index ea72f8c099..76809b366a 100644 --- a/src/components/intents/IntentDialogs.tsx +++ b/src/components/intents/IntentDialogs.tsx @@ -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 ( {children} + ) } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 95eb57fd54..a22700f880 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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' diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index c35ca49b16..5a9d0b39fe 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -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} = diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index cd2ae29afe..b5c278dacf 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -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 diff --git a/src/screens/Messages/JoinRequest.tsx b/src/screens/Messages/JoinRequest.tsx new file mode 100644 index 0000000000..c6da1f91ec --- /dev/null +++ b/src/screens/Messages/JoinRequest.tsx @@ -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 ( + + + + {error ? ( + + + + {l`This invite link has expired`} + + + + ) : data && moderationOpts ? ( + + + + + + Group chat + + + + + + {data.joinLinkPreviews[0].memberCount}/ + {data.joinLinkPreviews[0].memberLimit} + + + + + + {data.joinLinkPreviews[0].name} + + + + + + + By{' '} + {createSanitizedDisplayName( + data.joinLinkPreviews[0].owner, + true, + moderateProfile( + data.joinLinkPreviews[0].owner, + moderationOpts, + ).ui('displayName'), + )} + + + + + + {sanitizeHandle(data.joinLinkPreviews[0].owner.handle, '@')} + + + + {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.`} + + + + ) : null} + + + + ) +} + +function Wrapper({children}: React.PropsWithChildren) { + const t = useTheme() + + const isDarkMode = t.name !== 'light' + + return ( + <> + + + {children} + + + ) +} + +function ActionButtons({ + setScreenState, +}: { + setScreenState: (state: LoggedOutScreenState) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const isDarkMode = t.name !== 'light' + + return ( + + + + + ) +} diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx index 755eec2108..ad6d4a08e5 100644 --- a/src/screens/Messages/components/InviteLinkDialog.tsx +++ b/src/screens/Messages/components/InviteLinkDialog.tsx @@ -297,8 +297,8 @@ export function InviteLinkDialog({ 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 currentOption = whoCanJoinOptions.find( diff --git a/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx b/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx index cb76485af6..72d92a24ea 100644 --- a/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx +++ b/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx @@ -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, ) diff --git a/src/screens/Onboarding/StepFinished/index.tsx b/src/screens/Onboarding/StepFinished/index.tsx index e477528f1b..11dfd35ceb 100644 --- a/src/screens/Onboarding/StepFinished/index.tsx +++ b/src/screens/Onboarding/StepFinished/index.tsx @@ -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, diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index fe67773a40..d98b6b4fea 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -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, diff --git a/src/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx index 2b08212794..f15d259e1f 100644 --- a/src/screens/StarterPack/StarterPackLandingScreen.tsx +++ b/src/screens/StarterPack/StarterPackLandingScreen.tsx @@ -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' diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx index adb27ef2ec..ddbd5cd840 100644 --- a/src/screens/StarterPack/StarterPackScreen.tsx +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -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' diff --git a/src/state/preferences/moderation-opts.tsx b/src/state/preferences/moderation-opts.tsx index a6e36b7d2b..b8592d77e0 100644 --- a/src/state/preferences/moderation-opts.tsx +++ b/src/state/preferences/moderation-opts.tsx @@ -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, })), diff --git a/src/state/queries/join-links.ts b/src/state/queries/join-links.ts new file mode 100644 index 0000000000..6aa10331ef --- /dev/null +++ b/src/state/queries/join-links.ts @@ -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, + }) + } +} diff --git a/src/state/queries/messages/request-join-group-chat.ts b/src/state/queries/messages/request-join-group-chat.ts new file mode 100644 index 0000000000..04e63395f7 --- /dev/null +++ b/src/state/queries/messages/request-join-group-chat.ts @@ -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) + }, + }) +} diff --git a/src/state/queries/messages/withdraw-join-group-chat.ts b/src/state/queries/messages/withdraw-join-group-chat.ts new file mode 100644 index 0000000000..92025cc2be --- /dev/null +++ b/src/state/queries/messages/withdraw-join-group-chat.ts @@ -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) + }, + }) +} diff --git a/src/state/shell/landing.tsx b/src/state/shell/landing.tsx new file mode 100644 index 0000000000..f071c79e39 --- /dev/null +++ b/src/state/shell/landing.tsx @@ -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(undefined) +stateContext.displayName = 'ActiveLandingStateContext' +const setContext = createContext((_: LandingType) => {}) +setContext.displayName = 'ActiveLandingSetContext' + +export function Provider({children}: {children: React.ReactNode}) { + const [state, setState] = useState() + + return ( + + {children} + + ) +} + +// 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 +} diff --git a/src/state/shell/logged-out.tsx b/src/state/shell/logged-out.tsx index fdc588dcce..fcb76818c4 100644 --- a/src/state/shell/logged-out.tsx +++ b/src/state/shell/logged-out.tsx @@ -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({ }) ControlsContext.displayName = 'LoggedOutControlsContext' +function getRequestedAccountFromLanding( + landing: ReturnType, + 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({ - showLoggedOut: shouldShowStarterPack, - requestedAccountSwitchTo: shouldShowStarterPack - ? IS_WEB - ? 'starterpack' - : 'new' - : undefined, + showLoggedOut: Boolean(requestedAccount), + requestedAccountSwitchTo: requestedAccount, }) const controls = useMemo( diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 0cfea7cf16..91c0b9afca 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -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(() => { - 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(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 ( void}) { {screenState === ScreenState.S_StarterPack ? ( + ) : screenState === ScreenState.S_GroupChatJoinRequest ? ( + ) : screenState === ScreenState.S_LoginOrCreateAccount ? ( { @@ -122,17 +138,12 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { {screenState === ScreenState.S_Login ? ( { - setScreenState(ScreenState.S_LoginOrCreateAccount) - clearRequestedAccount() + setScreenState(initialScreenState) }} /> ) : undefined} {screenState === ScreenState.S_CreateAccount ? ( - - setScreenState(ScreenState.S_LoginOrCreateAccount) - } - /> + setScreenState(initialScreenState)} /> ) : undefined} diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 7e58218852..658a3ca3b3 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -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') || diff --git a/src/view/icons/LogomarkWithType.tsx b/src/view/icons/LogomarkWithType.tsx new file mode 100644 index 0000000000..7437a5db14 --- /dev/null +++ b/src/view/icons/LogomarkWithType.tsx @@ -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 ( + + + + + + + + + + + ) +}