diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 4018638cdc..ddcd835fb7 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -282,19 +282,6 @@ "count": 1 } }, - "src/components/PostControls/ShareMenu/ShareMenuItems.tsx": { - "typescript/no-floating-promises": { - "count": 3 - }, - "typescript/no-misused-promises": { - "count": 1 - } - }, - "src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx": { - "typescript/no-floating-promises": { - "count": 3 - } - }, "src/components/PostControls/ShareMenu/index.tsx": { "typescript/no-floating-promises": { "count": 1 diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index ea68ea01e6..b0445db587 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -605,7 +605,7 @@ export type Events = { // Group chat adoption 'groupchat:create': { - logContext: 'NewChatDialog' + logContext: 'NewChatDialog' | 'SendViaChatDialog' } 'groupchat:landingPage:view': { hasSession: boolean diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx index 627034f5d6..dc66340fd3 100644 --- a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx @@ -1,9 +1,7 @@ import {memo, useMemo} from 'react' import * as ExpoClipboard from 'expo-clipboard' import {AtUri} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' @@ -37,7 +35,7 @@ let ShareMenuItems = ({ }: ShareMenuItemsProps): React.ReactNode => { const ax = useAnalytics() const {hasSession} = useSession() - const {_} = useLingui() + const {t: l} = useLingui() const navigation = useNavigation() const sendViaChatControl = useDialogControl() const [devModeEnabled] = useDevMode() @@ -61,7 +59,7 @@ let ShareMenuItems = ({ const onSharePost = () => { ax.metric('share:press:nativeShare', {}) const url = toShareUrl(href) - shareUrl(url) + void shareUrl(url) onShareProp() } @@ -74,7 +72,7 @@ let ShareMenuItems = ({ } else { await ExpoClipboard.setStringAsync(url) } - Toast.show(_(msg`Copied to clipboard`), { + Toast.show(l`Copied to clipboard`, { type: 'success', }) onShareProp() @@ -93,11 +91,11 @@ let ShareMenuItems = ({ } const onShareATURI = () => { - shareText(postUri) + void shareText(postUri) } const onShareAuthorDID = () => { - shareText(postAuthor.did) + void shareText(postAuthor.did) } return ( @@ -113,13 +111,13 @@ let ShareMenuItems = ({ { ax.metric('share:press:openDmSearch', {}) sendViaChatControl.open() }}> - Send via direct message + Send via chat @@ -129,7 +127,7 @@ let ShareMenuItems = ({ Share via... @@ -139,8 +137,8 @@ let ShareMenuItems = ({ + label={l`Copy link to post`} + onPress={() => void onCopyLink()}> Copy link to post @@ -164,7 +162,7 @@ let ShareMenuItems = ({ Share post at:// URI @@ -173,7 +171,7 @@ let ShareMenuItems = ({ Share author DID @@ -183,7 +181,6 @@ let ShareMenuItems = ({ )} - () const embedPostControl = useDialogControl() const sendViaChatControl = useDialogControl() @@ -60,7 +58,7 @@ let ShareMenuItems = ({ const onCopyLink = () => { ax.metric('share:press:copyLink', {}) const url = toShareUrl(href) - shareUrl(url) + void shareUrl(url) onShareProp() } @@ -75,17 +73,17 @@ let ShareMenuItems = ({ const canEmbed = IS_WEB && gtMobile && !hideInPWI const onShareATURI = () => { - shareText(postUri) + void shareText(postUri) } const onShareAuthorDID = () => { - shareText(postAuthor.did) + void shareText(postAuthor.did) } const copyLinkItem = ( Copy link to post @@ -102,13 +100,13 @@ let ShareMenuItems = ({ {hasSession && aa.state.access === aa.Access.Full && ( { ax.metric('share:press:openDmSearch', {}) sendViaChatControl.open() }}> - Send via direct message + Send via chat @@ -117,12 +115,12 @@ let ShareMenuItems = ({ {canEmbed && ( { ax.metric('share:press:embed', {}) embedPostControl.open() }}> - {_(msg`Embed post`)} + {l`Embed post`} )} @@ -142,7 +140,7 @@ let ShareMenuItems = ({ Copy post at:// URI @@ -151,7 +149,7 @@ let ShareMenuItems = ({ Copy author DID @@ -161,7 +159,6 @@ let ShareMenuItems = ({ )} - {canEmbed && ( )} - void onSelectGroupChat: (dids: string[], groupName: string) => void startInGroupChat?: boolean + showRecentConvos?: boolean + onSelectExistingChat?: (convoId: string) => void + sortByMessageDeclaration?: boolean }) { const t = useTheme() const {t: l} = useLingui() @@ -230,6 +252,12 @@ export function InitiateChatFlow({ const inputRef = useRef(null) const accountTooNewPromptControl = Dialog.useDialogControl() + const {data: convos} = useListConvosQuery({ + enabled: showRecentConvos, + status: 'accepted', + lockStatus: 'unlocked', + }) + const {data: chatStatus} = useChatActorStatusQuery() const canCreateGroups = chatStatus?.canCreateGroups ?? true const groupMemberLimit = chatStatus?.groupMemberLimit @@ -281,6 +309,10 @@ export function InitiateChatFlow({ let _items: Item[] = [] const checker = chatState === ChatState.NEW_GROUP_CHAT ? canBeAddedToGroup : canBeMessaged + const messageDeclarationRank = (item: Item) => + item.type === 'profile' && checker(item.profile) ? 0 : 1 + const compareByMessageDeclaration = (a: Item, b: Item) => + messageDeclarationRank(a) - messageDeclarationRank(b) if (isError) { _items.push({ @@ -310,9 +342,9 @@ export function InitiateChatFlow({ }) } - _items = _items.sort(item => { - return item.type === 'profile' && checker(item.profile) ? -1 : 1 - }) + if (sortByMessageDeclaration) { + _items = _items.sort(compareByMessageDeclaration) + } } } else { const placeholders: Item[] = Array(10) @@ -322,7 +354,57 @@ export function InitiateChatFlow({ key: i + '', })) - if (follows) { + if ( + chatState === ChatState.NEW_CHAT && + showRecentConvos && + convos && + follows + ) { + const usedDids = new Set() + + for (const page of convos.pages) { + for (const convoView of page.convos) { + const convo = parseConvoView(convoView, currentAccount?.did) + + if (!convo) continue + + if (convo.kind === 'group') { + _items.push({ + type: 'existingChat', + key: convo.view.id, + convo, + }) + } else { + if (convo.primaryMember.handle === 'missing.invalid') continue + if (usedDids.has(convo.primaryMember.did)) continue + + usedDids.add(convo.primaryMember.did) + + _items.push({ + type: 'existingChat', + key: convo.view.id, + convo, + }) + } + } + } + + let followsItems: ProfileItem[] = [] + + for (const page of follows.pages) { + for (const profile of page.follows) { + if (usedDids.has(profile.did)) continue + if (!checker(profile)) continue + followsItems.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + } + + _items.push(...followsItems) + } else if (follows) { for (const page of follows.pages) { for (const profile of page.follows) { if (!checker(profile)) continue @@ -359,10 +441,19 @@ export function InitiateChatFlow({ _items.unshift({type: 'newGroupChat', key: 'newGroupChat'}) } - return _items + const profileDids = new Set() + + return _items.filter(item => { + if (item.type !== 'profile') return true + if (profileDids.has(item.profile.did)) return false + + profileDids.add(item.profile.did) + return true + }) }, [ isError, chatState, + convos, searchText, l, groupChatProfiles, @@ -370,6 +461,8 @@ export function InitiateChatFlow({ currentAccount?.did, follows, aa.flags.groupChatDisabled, + showRecentConvos, + sortByMessageDeclaration, ]) if (searchText && !isFetching && !items.length && !isError) { @@ -429,6 +522,16 @@ export function InitiateChatFlow({ case 'label': { return } + case 'existingChat': { + return showRecentConvos && onSelectExistingChat ? ( + + ) : null + } case 'profile': { switch (chatState) { case ChatState.NEW_CHAT: @@ -474,6 +577,8 @@ export function InitiateChatFlow({ handlePressNewGroupChat, moderationOpts, onSelectChat, + onSelectExistingChat, + showRecentConvos, ], ) @@ -845,6 +950,114 @@ function NewGroupChatButton({ ) } +function ExistingChatCard({ + convo, + moderationOpts, + onPress, +}: { + convo: ConvoWithDetails + moderationOpts: ModerationOpts + onPress: (convoId: string) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const enabled = + convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true + const name = + convo.kind === 'group' + ? convo.details.name + : createSanitizedDisplayName( + convo.primaryMember, + true, + moderateProfile(convo.primaryMember, moderationOpts).ui( + 'displayName', + ), + ) + + const handleOnPress = useCallback(() => { + onPress(convo.view.id) + }, [onPress, convo.view.id]) + + return ( + + ) +} + function DefaultProfileCard({ profile, moderationOpts, diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index 74ab6b4c0d..fd9eaa8880 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -89,7 +89,7 @@ export function NewChat({ }, onError: error => { logger.error('Failed to create groupchat', {safeMessage: error}) - let errorMessage = l`An issue occurred creating the group chat, please try again.` + let errorMessage = l`An issue occurred starting the group chat, please try again.` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` } else if ( @@ -184,6 +184,7 @@ export function NewChat({ title={l`New chat`} onSelectChat={onCreateChat} onSelectGroupChat={onCreateGroupChat} + sortByMessageDeclaration startInGroupChat={startInGroupChat} /> ) : ( diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx index 30cd80862d..0463cc694d 100644 --- a/src/components/dms/dialogs/ShareViaChatDialog.tsx +++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx @@ -1,11 +1,17 @@ -import {useCallback} from 'react' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useCallback, useState} from 'react' +import { + ChatBskyConvoGetConvoForMembers, + ChatBskyGroupCreateGroup, +} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' +import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' +import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import * as Dialog from '#/components/Dialog' import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' +import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow' import * as Toast from '#/components/Toast' import {useAnalytics} from '#/analytics' @@ -16,26 +22,39 @@ export function SendViaChatDialog({ control: Dialog.DialogControlProps onSelectChat: (chatId: string) => void }) { + const [flowKey, setFlowKey] = useState(0) + const onClose = useCallback(() => setFlowKey(key => key + 1), []) + return ( + nativeOptions={{fullHeight: true}} + onClose={onClose}> - + ) } function SendViaChatDialogInner({ control, + flowKey, onSelectChat, }: { control: Dialog.DialogControlProps + flowKey: number onSelectChat: (chatId: string) => void }) { - const {_} = useLingui() + const {t: l} = useLingui() const ax = useAnalytics() + + const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable) + const {mutate: createChat} = useGetConvoForMembers({ onSuccess: data => { onSelectChat(data.convo.id) @@ -46,8 +65,74 @@ function SendViaChatDialogInner({ ax.metric('chat:open', {logContext: 'SendViaChatDialog'}) }, onError: error => { - logger.error('Failed to share post to chat', {message: error}) - Toast.show(_(msg`An issue occurred while trying to open the chat`), { + logger.error('Failed to share post to chat', {safeMessage: error}) + let errorMessage = l`An issue occurred starting the chat, please try again.` + if (isNetworkError(error)) { + errorMessage = l`A network error occurred. Please check your internet connection.` + } else if ( + error instanceof ChatBskyConvoGetConvoForMembers.AccountSuspendedError + ) { + errorMessage = l`Suspended accounts cannot participate in chat.` + } else if ( + error instanceof ChatBskyConvoGetConvoForMembers.BlockedActorError + ) { + errorMessage = l`This user has blocked you and cannot be messaged.` + } else if ( + error instanceof ChatBskyConvoGetConvoForMembers.MessagesDisabledError + ) { + errorMessage = l`This user has disabled chat and cannot be messaged.` + } else if ( + error instanceof + ChatBskyConvoGetConvoForMembers.NotFollowedBySenderError + ) { + errorMessage = l`Chat recipient is not followed by the sender.` + } else if ( + error instanceof ChatBskyConvoGetConvoForMembers.RecipientNotFoundError + ) { + errorMessage = l`Unable to find the selected recipient.` + } + Toast.show(errorMessage, { + type: 'error', + }) + }, + }) + + const {mutate: createGroupChat} = useCreateGroupChat({ + onSuccess: data => { + onSelectChat(data.convo.id) + + ax.metric('groupchat:create', {logContext: 'SendViaChatDialog'}) + }, + onError: error => { + logger.error('Failed to share post to group chat', {safeMessage: error}) + let errorMessage = l`An issue occurred starting the group chat, please try again.` + if (isNetworkError(error)) { + errorMessage = l`A network error occurred. Please check your internet connection.` + } else if ( + error instanceof ChatBskyGroupCreateGroup.AccountSuspendedError + ) { + errorMessage = l`Suspended accounts cannot participate in a group chat.` + } else if (error instanceof ChatBskyGroupCreateGroup.BlockedActorError) { + errorMessage = l`One of the selected recipients has blocked you and cannot be messaged.` + } else if ( + error instanceof + ChatBskyGroupCreateGroup.NewAccountCannotCreateGroupError + ) { + errorMessage = l`You cannot create a group chat yet.` + } else if ( + error instanceof ChatBskyGroupCreateGroup.NotFollowedBySenderError + ) { + errorMessage = l`A selected recipient is not followed by the sender.` + } else if ( + error instanceof ChatBskyGroupCreateGroup.RecipientNotFoundError + ) { + errorMessage = l`Unable to find a selected recipient.` + } else if ( + error instanceof ChatBskyGroupCreateGroup.UserForbidsGroupsError + ) { + errorMessage = l`One of the selected recipients does not allow group chats.` + } + Toast.show(errorMessage, { type: 'error', }) }, @@ -67,9 +152,28 @@ function SendViaChatDialogInner({ [control, createChat], ) - return ( + const onCreateGroupChat = useCallback( + (members: string[], name: string) => { + control.close(() => { + createGroupChat({members, name}) + }) + }, + [control, createGroupChat], + ) + + return isGroupChatEnabled ? ( + + ) : ( { if (chat.kind === 'user') { onCreateChat(chat.did)