Create logged-out view for group chat invites (#10598)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
DS Boyce
2026-06-02 12:02:49 -07:00
committed by GitHub
parent d08ab487d8
commit 56496520d6
32 changed files with 1331 additions and 78 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+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>
+14
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'
@@ -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)
+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 []
+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,
],
)
@@ -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)
@@ -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>
)
}
+1
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'
+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} =
+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
+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>
)
}
@@ -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(
@@ -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,
+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'
+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,
})
}
}
@@ -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>
+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') ||
+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>
)
}