diff --git a/.gitignore b/.gitignore index 27b7ac9ded..6dc2fe592b 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,9 @@ stats.json # VSCode .vscode +# Zed +.zed + # gitignore and github actions !.gitignore !.github diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx index 5e3cecf077..9fbff9d092 100644 --- a/src/components/PostControls/ShareMenu/RecentChats.tsx +++ b/src/components/PostControls/ShareMenu/RecentChats.tsx @@ -1,5 +1,9 @@ import {ScrollView, View} from 'react-native' -import {moderateProfile, type ModerationOpts} from '@atproto/api' +import { + type ChatBskyActorDefs, + moderateProfile, + type ModerationOpts, +} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -66,8 +70,8 @@ export function RecentChats({ if (!convo) return null if ( - (convo.kind === 'direct' && - convo.primaryMember.handle === 'missing.invalid') || + !convo.primaryMember || + convo.primaryMember.handle === 'missing.invalid' || convo.view.muted ) { return null @@ -77,6 +81,7 @@ export function RecentChats({ onSelectChat(convo.view.id)} moderationOpts={moderationOpts} /> @@ -103,15 +108,17 @@ function RecentChatItem({ onPress, moderationOpts, convo, + primaryMember, }: { onPress: () => void moderationOpts: ModerationOpts convo: ConvoWithDetails + primaryMember: ChatBskyActorDefs.ProfileViewBasic }) { const {_} = useLingui() const t = useTheme() - const primaryProfile = useProfileShadow(convo.primaryMember) + const primaryProfile = useProfileShadow(primaryMember) const moderation = moderateProfile(primaryProfile, moderationOpts) const name = diff --git a/src/components/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx index e5675a8339..31e48cc799 100644 --- a/src/components/dialogs/SearchablePeopleList.tsx +++ b/src/components/dialogs/SearchablePeopleList.tsx @@ -479,14 +479,15 @@ function ExistingChatCard({ const {t: l} = useLingui() const enabled = convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true - const moderation = moderateProfile(convo.primaryMember, moderationOpts) const name = convo.kind === 'group' ? convo.details.name : createSanitizedDisplayName( convo.primaryMember, true, - moderation.ui('displayName'), + moderateProfile(convo.primaryMember, moderationOpts).ui( + 'displayName', + ), ) const handleOnPress = useCallback(() => { diff --git a/src/components/dms/getSystemMessageInfo.ts b/src/components/dms/getSystemMessageInfo.ts index a0a615f793..c8eb54764a 100644 --- a/src/components/dms/getSystemMessageInfo.ts +++ b/src/components/dms/getSystemMessageInfo.ts @@ -15,6 +15,7 @@ import { Unlock_Stroke2_Corner2_Rounded as UnlockIcon, } from '#/components/icons/Lock' import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' export type SystemMessageInfo = { message: MessageDescriptor @@ -68,7 +69,13 @@ export function getSystemMessageInfo( } else if (ChatBskyConvoDefs.isSystemMessageDataUnlockConvo(data)) { return {Icon: UnlockIcon, message: msg`Chat unlocked`} } else if (ChatBskyConvoDefs.isSystemMessageDataLockConvoPermanently(data)) { - return {Icon: LockIcon, message: msg`Chat locked permanently`} + const name = getReferredDisplayName(data.lockedBy, relatedProfiles) + return { + Icon: XIcon, + message: name + ? msg`${name} ended the group chat` + : msg`This group chat was ended`, + } } else if (ChatBskyConvoDefs.isSystemMessageDataEditGroup(data)) { return { Icon: PencilIcon, diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 5140ba64ca..490db31c6b 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -69,7 +69,7 @@ export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & ( | { kind: 'group' details: $Typed - primaryMember: GroupConvoMember // the owner + primaryMember?: GroupConvoMember // the owner - may have left, thus optional members: Array } | { @@ -117,11 +117,6 @@ export function parseConvoView( } } - if (!owner) { - logger.warn('No owner found in group convo') - return null - } - return { view: convoView, kind: 'group', diff --git a/src/screens/Messages/ConversationSettings/Member.tsx b/src/screens/Messages/ConversationSettings/Member.tsx index a7703f4dc0..702aad058e 100644 --- a/src/screens/Messages/ConversationSettings/Member.tsx +++ b/src/screens/Messages/ConversationSettings/Member.tsx @@ -75,7 +75,7 @@ export function Member({ const displayName = isDeletedAccount ? l`Deleted Account` : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) - const isProfileOwner = profile.did === convo.primaryMember.did + const isProfileOwner = profile.did === convo.primaryMember?.did const isSelf = currentAccount?.did === profile.did let statusBadge: React.ReactNode | null = null if (isSelf) { diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx index c0be0b1864..29df1f5ba0 100644 --- a/src/screens/Messages/ConversationSettings/index.tsx +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -1,6 +1,10 @@ import {useState} from 'react' import {View} from 'react-native' -import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' +import { + ChatBskyActorDefs, + ChatBskyConvoDefs, + ModerationOpts, +} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' @@ -14,6 +18,7 @@ import { import {logger} from '#/logger' import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' import {ConvoStatus} from '#/state/messages/convo/types' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name' import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members' @@ -99,6 +104,7 @@ function SettingsInner() { const {t: l} = useLingui() const convoState = useConvo() const navigation = useNavigation() + const moderationOpts = useModerationOpts() if (convoState.status === ConvoStatus.Error) { return ( @@ -111,7 +117,7 @@ function SettingsInner() { ) } - if (!isConvoActive(convoState)) { + if (!isConvoActive(convoState) || !moderationOpts) { return ( @@ -135,7 +141,9 @@ function SettingsInner() { ) } - return + return ( + + ) } function keyExtractor(item: Item) { @@ -157,8 +165,10 @@ function isGroupMember( function GroupSettings({ convo, + moderationOpts, }: { convo: Extract + moderationOpts: ModerationOpts }) { const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) const bottomBarOffset = useBottomBarOffset() @@ -166,7 +176,7 @@ function GroupSettings({ const {currentAccount} = useSession() const primaryMember = convo.primaryMember - const isOwner = primaryMember.did === currentAccount?.did + const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did const {data: memberListData = [], isPending} = useListConvoMembersQuery({ convoId: convo.view.id, @@ -209,8 +219,8 @@ function GroupSettings({ ...memberListData .filter(isGroupMember) .sort((a, b) => { - const aIsOwner = a.did === primaryMember.did - const bIsOwner = b.did === primaryMember.did + const aIsOwner = a.did === primaryMember?.did + const bIsOwner = b.did === primaryMember?.did const aIsSelf = a.did === currentAccount?.did const bIsSelf = b.did === currentAccount?.did if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1 @@ -223,7 +233,7 @@ function GroupSettings({ key: profile.did, profile, status: - primaryMember.did === profile.did + primaryMember?.did === profile.did ? 'owner' : invites.includes(profile.did) ? 'invited' @@ -271,7 +281,13 @@ function GroupSettings({ desktopFixedHeight initialNumToRender={initialNumToRender} keyExtractor={keyExtractor} - ListHeaderComponent={} + ListHeaderComponent={ + + } renderItem={renderItem} sideBorders={false} windowSize={11} @@ -282,9 +298,11 @@ function GroupSettings({ function SettingsHeader({ convo, isOwner, + moderationOpts, }: { convo: Extract isOwner: boolean + moderationOpts: ModerationOpts }) { const t = useTheme() const {i18n, t: l} = useLingui() @@ -517,11 +535,15 @@ function SettingsHeader({ onChangeText={setNewGroupName} onConfirm={handleEditName} /> - + {convo.primaryMember && ( + + )} moderateProfile(groupOwner, moderationOpts), + () => + groupOwner ? moderateProfile(groupOwner, moderationOpts) : undefined, [groupOwner, moderationOpts], ) @@ -178,7 +183,7 @@ function GroupChatItem({ return ( } title={chatName} accessibilityHint={l`Go to the group chat named "${chatName}"`} @@ -208,15 +213,15 @@ function BaseChatItem({ postAlerts, children, }: { - convo: ChatBskyConvoDefs.ConvoView + convo: ConvoWithDetails avatar: React.ReactNode title: string subtitle?: string accessibilityHint: string isDeletedAccount: boolean isBlockedAccount: boolean - primaryProfile: Shadow - primaryProfileModeration: ModerationDecision + primaryProfile?: Shadow + primaryProfileModeration?: ModerationDecision showMenu?: boolean showProfileBadges: boolean postAlerts?: React.ReactNode @@ -233,9 +238,16 @@ function BaseChatItem({ const playHaptic = useHaptics() const queryClient = useQueryClient() - const hasUnread = convo.unreadCount > 0 && !isDeletedAccount + const hasUnread = + convo.view.unreadCount > 0 && + !isDeletedAccount && + !( + convo.kind === 'group' && + convo.details.lockStatus === 'locked-permanently' + ) const blockInfo = useMemo(() => { + if (!primaryProfileModeration) return {listBlocks: [], userBlock: undefined} const modui = primaryProfileModeration.ui('profileView') const blocks = modui.alerts.filter(alert => alert.type === 'blocking') const listBlocks = blocks.filter(alert => alert.source.type === 'list') @@ -246,7 +258,11 @@ function BaseChatItem({ } }, [primaryProfileModeration]) - const isDimStyle = convo.muted || isBlockedAccount || isDeletedAccount + const isDimStyle = + convo.view.muted || + isBlockedAccount || + isDeletedAccount || + (convo.kind === 'group' && convo.details.lockStatus !== 'unlocked') const {lastMessage, lastMessageSentAt, latestReportableMessage} = useMemo(() => { @@ -257,8 +273,8 @@ function BaseChatItem({ let latestReportableMessage: ChatBskyConvoDefs.MessageView | undefined // Deleted message - if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { - lastMessageSentAt = convo.lastMessage.sentAt + if (ChatBskyConvoDefs.isDeletedMessageView(convo.view.lastMessage)) { + lastMessageSentAt = convo.view.lastMessage.sentAt lastMessage = isDeletedAccount ? l`Conversation deleted` @@ -266,9 +282,9 @@ function BaseChatItem({ } // Message - if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { + if (ChatBskyConvoDefs.isMessageView(convo.view.lastMessage)) { const info = getMessageInfo({ - convo, + convo: convo.view, currentAccountDid: currentAccount?.did, i18n, }) @@ -280,9 +296,9 @@ function BaseChatItem({ } // Reaction - if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) { + if (ChatBskyConvoDefs.isMessageAndReactionView(convo.view.lastReaction)) { const info = getReactionInfo({ - convo, + convo: convo.view, currentAccountDid: currentAccount?.did, i18n, }) @@ -297,14 +313,14 @@ function BaseChatItem({ } // System message - if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) { + if (ChatBskyConvoDefs.isSystemMessageView(convo.view.lastMessage)) { const info = getSystemMessageInfo( - convo.lastMessage.data, - new Map(convo.members.map(m => [m.did, m])), + convo.view.lastMessage.data, + new Map(convo.view.members.map(m => [m.did, m])), ) if (info) { lastMessage = i18n._(info.message) - lastMessageSentAt = convo.lastMessage.sentAt + lastMessageSentAt = convo.view.lastMessage.sentAt } } @@ -332,11 +348,11 @@ function BaseChatItem({ const onPress = useCallback( (e: GestureResponderEvent) => { - for (const member of convo.members) { + for (const member of convo.view.members) { unstableCacheProfileView(queryClient, member) } - precacheConvoQuery(queryClient, convo) - void decrementBadgeCount(convo.unreadCount) + precacheConvoQuery(queryClient, convo.view) + void decrementBadgeCount(convo.view.unreadCount) if (isDeletedAccount) { e.preventDefault() menuControl.open() @@ -359,7 +375,7 @@ function BaseChatItem({ icon: EnvelopeOpen, action: () => { markAsRead({ - convoId: convo.id, + convoId: convo.view.id, }) }, } @@ -402,7 +418,7 @@ function BaseChatItem({ precacheConvoQuery(queryClient, convo)} + onPressIn={() => precacheConvoQuery(queryClient, convo.view)} onPress={onPress} onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined} onAccessibilityAction={showMenu ? onLongPress : undefined}> @@ -455,7 +471,7 @@ function BaseChatItem({ - {showProfileBadges && ( + {showProfileBadges && primaryProfile && ( )} - {(convo.muted || isBlockedAccount) && ( + {(convo.view.muted || isBlockedAccount) && ( - {showMenu && ( + {/* TODO: Allow showing menu for groups where the owner has left! */} + {showMenu && primaryProfile && ( 0} + showMarkAsRead={convo.view.unreadCount > 0} hideTrigger={IS_NATIVE} blockInfo={blockInfo} style={[ @@ -576,7 +593,7 @@ function BaseChatItem({ diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx index 6071562d86..103ee9c4ad 100644 --- a/src/screens/Messages/components/InviteLinkDialog.tsx +++ b/src/screens/Messages/components/InviteLinkDialog.tsx @@ -1,5 +1,6 @@ import {useEffect, useState} from 'react' import {View} from 'react-native' +import {moderateProfile, type ModerationOpts} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' @@ -17,7 +18,10 @@ import { StackedButton, } from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {type ConvoWithDetails} from '#/components/dms/util' +import { + type ConvoWithDetails, + type GroupConvoMember, +} from '#/components/dms/util' import * as Toggle from '#/components/forms/Toggle' import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' @@ -39,16 +43,24 @@ enum Step { export function InviteLinkDialog({ convo, control, + owner, isOwner, + moderationOpts, }: { convo: Extract control: Dialog.DialogOuterProps['control'] + owner: GroupConvoMember isOwner: boolean + moderationOpts: ModerationOpts }) { const t = useTheme() const {t: l, i18n} = useLingui() - const ownerName = createSanitizedDisplayName(convo.primaryMember) + const ownerName = createSanitizedDisplayName( + owner, + false, + moderateProfile(owner, moderationOpts).ui('displayName'), + ) const {joinLink} = convo.details const enabledStatus = joinLink?.enabledStatus diff --git a/src/screens/Messages/components/MessagesListInfoPanel.tsx b/src/screens/Messages/components/MessagesListInfoPanel.tsx index 1b1621cac7..704ea9385b 100644 --- a/src/screens/Messages/components/MessagesListInfoPanel.tsx +++ b/src/screens/Messages/components/MessagesListInfoPanel.tsx @@ -2,6 +2,7 @@ import {View} from 'react-native' import {Plural, Trans, useLingui} from '@lingui/react/macro' import {logger} from '#/logger' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useAddGroupMembers} from '#/state/queries/messages/add-group-members' import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' @@ -23,13 +24,14 @@ export function MessagesListInfoPanel({ }) { const t = useTheme() const {t: l} = useLingui() + const moderationOpts = useModerationOpts() + const convoId = convo.view.id const addMembersControl = Dialog.useDialogControl() const inviteLinkControl = Dialog.useDialogControl() const {currentAccount} = useSession() - const convoId = convo.view.id const {mutate: addGroupMembers} = useAddGroupMembers(convoId, { onSuccess: () => { addMembersControl.close() @@ -46,9 +48,9 @@ export function MessagesListInfoPanel({ // (isOwner && groupConvo) || // (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled') - const isOwner = convo?.primaryMember.did === currentAccount?.did + const isOwner = convo.primaryMember?.did === currentAccount?.did - const members = (convo?.members ?? []).filter( + const members = (convo.members ?? []).filter( profile => profile.did !== currentAccount?.did, ) @@ -81,7 +83,7 @@ export function MessagesListInfoPanel({ return ( <> - + {convo.details.name ? ( {convo.details.name} @@ -139,11 +141,15 @@ export function MessagesListInfoPanel({ ) : null} - + {convo.primaryMember && moderationOpts && ( + + )} - - - + {convo.primaryMember && ( + + + + )} {/* spacer, since you can't nest pressables */} {/* Placeholder text so that it responds to the font height */} @@ -60,7 +63,7 @@ export function RequestListItem({ paddingLeft: tokens.space.lg + 52 + tokens.space.md, }, ]}> - {!isDeletedAccount ? ( + {convo.primaryMember && !isDeletedAccount ? ( <> convo.id !== currentConvoId) - .reduce((acc, convo) => { - const otherMember = convo.members.find( - member => member.did !== currentAccountDid, - ) + .reduce((acc, convoView) => { + const convo = parseConvoView(convoView, currentAccountDid) - if (!otherMember || !moderationOpts) return acc + if (!convo || !moderationOpts) return acc - const moderation = moderateProfile(otherMember, moderationOpts) const shouldIgnore = - convo.muted || - moderation.blocked || - otherMember.handle === 'missing.invalid' - const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0 + convo.view.muted || + !convo.primaryMember || + moderateProfile(convo.primaryMember, moderationOpts).blocked || + convo.primaryMember.handle === 'missing.invalid' || + (convo.kind === 'group' && convo.details.lockStatus !== 'unlocked') + const unreadCount = !shouldIgnore && convo.view.unreadCount > 0 ? 1 : 0 return acc + unreadCount }, 0) ?? 0