diff --git a/lib/modules/chat/message_chat_page.dart b/lib/modules/chat/message_chat_page.dart index 2b37e0c..b588a49 100644 --- a/lib/modules/chat/message_chat_page.dart +++ b/lib/modules/chat/message_chat_page.dart @@ -1224,6 +1224,11 @@ class _MessageItem extends StatelessWidget { ), ); } + final customData = _customDataFromMessage(message); + if (customData != null && + _isDuplicateCpResultMessageForConversation(customData)) { + return const SizedBox.shrink(); + } final cpRelationFormedCard = _cpRelationFormedMessageCard(); final cpStatusLineText = _cpStatusLineTextForMessage(); return Container( @@ -1381,7 +1386,7 @@ class _MessageItem extends StatelessWidget { return GestureDetector( onLongPress: () => _showMsgItemMenu(ct, ""), onTap: - () => _showCpInviteDialogFromMessage(data, cpInvite, actionState), + () => _openCpInviteDialogFromMessage(data, cpInvite, actionState), child: Container( width: 251.w, height: 64.w, @@ -1451,6 +1456,30 @@ class _MessageItem extends StatelessWidget { ); } + void _openCpInviteDialogFromMessage( + Map data, + SCSystemInvitMessageRes? cpInvite, + RoomCpInviteActionState actionState, + ) { + unawaited(_openCpInviteDialogFromMessageAsync(data, cpInvite, actionState)); + } + + Future _openCpInviteDialogFromMessageAsync( + Map data, + SCSystemInvitMessageRes? cpInvite, + RoomCpInviteActionState actionState, + ) async { + final latestState = await _latestCpInviteStateForMessage(data); + if (!context.mounted) { + return; + } + final resolvedActionState = + latestState == _CpInviteMessageState.pending + ? actionState + : _cpInviteActionState(latestState); + _showCpInviteDialogFromMessage(data, cpInvite, resolvedActionState); + } + Widget _buildCpInviteGiftThumb(String giftCover) { final child = giftCover.isNotEmpty @@ -1623,12 +1652,22 @@ class _MessageItem extends StatelessWidget { return null; } final localizations = SCAppLocalizations.of(context)!; + final relationType = _cpRelationTypeFromMessageData(data); if (state == _CpInviteMessageState.expired) { + if (relationType != "CP") { + return "The ${scCpRelationPossessiveLabel(relationType)} invitation timed out."; + } return localizations.closeFriendInviteTimedOut; } + if (relationType == "CP") { + return _isCurrentUserCpInviter(data) + ? localizations.otherDeclinedCloseFriendInvite + : localizations.youDeclinedCloseFriendInvite; + } + final relationLabel = scCpRelationPossessiveLabel(relationType); return _isCurrentUserCpInviter(data) - ? localizations.otherDeclinedCloseFriendInvite - : localizations.youDeclinedCloseFriendInvite; + ? "The other party declined your $relationLabel invitation." + : "You declined the other party's $relationLabel invitation."; } void _showCpInviteDialogFromMessage( @@ -1782,14 +1821,15 @@ class _MessageItem extends StatelessWidget { cpInvite != null && actionState == RoomCpInviteActionState.pending && applyId.isNotEmpty - ? () => _processCpInviteFromMessage(applyId, cpInvite, true) + ? () => _processCpInviteFromMessage(applyId, cpInvite, true, data) : null, onReject: !isInviter && cpInvite != null && actionState == RoomCpInviteActionState.pending && applyId.isNotEmpty - ? () => _processCpInviteFromMessage(applyId, cpInvite, false) + ? () => + _processCpInviteFromMessage(applyId, cpInvite, false, data) : null, ); } @@ -1798,16 +1838,37 @@ class _MessageItem extends StatelessWidget { String applyId, SCSystemInvitMessageRes invite, bool agree, + Map data, ) { - unawaited(_processCpInviteFromMessageAsync(applyId, invite, agree)); + unawaited(_processCpInviteFromMessageAsync(applyId, invite, agree, data)); } Future _processCpInviteFromMessageAsync( String applyId, SCSystemInvitMessageRes invite, bool agree, + Map data, ) async { _cpMessageLog('process applyId=$applyId agree=$agree'); + final latestState = await _latestCpInviteStateForMessage(data); + if (latestState != _CpInviteMessageState.pending) { + _cpMessageLog( + 'skip process applyId=$applyId agree=$agree latestState=$latestState', + ); + if (!context.mounted) { + return; + } + _showCpInviteDialogFromMessage( + data, + invite, + _cpInviteActionState(latestState), + ); + updateCall(); + return; + } + if (!context.mounted) { + return; + } final profileManager = Provider.of( context, listen: false, @@ -1821,6 +1882,9 @@ class _MessageItem extends StatelessWidget { _cpMessageLog('process failed applyId=$applyId agree=$agree'); return; } + if (!context.mounted) { + return; + } SmartDialog.dismiss(tag: RoomCpInviteDialog.dialogTag); _cpMessageLog( 'process submitted applyId=$applyId agree=$agree ' @@ -1830,6 +1894,58 @@ class _MessageItem extends StatelessWidget { updateCall(); } + Future<_CpInviteMessageState> _latestCpInviteStateForMessage( + Map data, + ) async { + final localState = _cpInviteStateForMessage(data); + if (localState != _CpInviteMessageState.pending) { + return localState; + } + final applyId = _cpApplyIdFromData(data); + if (applyId.isEmpty) { + return localState; + } + try { + final status = await SCAccountRepository().cpRelationshipApplyStatus( + applyId, + ); + final backendState = _cpInviteStateFromBackendStatus(status); + if (backendState != null) { + return backendState; + } + } catch (error) { + _cpMessageLog('query apply status failed applyId=$applyId error=$error'); + } + return _cpInviteSecondsLeft(data) <= 0 + ? _CpInviteMessageState.expired + : localState; + } + + _CpInviteMessageState? _cpInviteStateFromBackendStatus(String? status) { + final text = status?.trim().toUpperCase() ?? ""; + if (text.isEmpty || text == "WAIT" || text == "WAITING") { + return text.isEmpty ? null : _CpInviteMessageState.pending; + } + if (text.contains("EXPIRE") || + text.contains("TIMEOUT") || + text.contains("OVERDUE")) { + return _CpInviteMessageState.expired; + } + if (text.contains("REFUSE") || + text.contains("REJECT") || + text.contains("DECLIN") || + text.contains("DENY")) { + return _CpInviteMessageState.rejected; + } + if (text.contains("AGREE") || + text.contains("ACCEPT") || + text.contains("APPROVE") || + text.contains("ESTABLISH")) { + return _CpInviteMessageState.accepted; + } + return null; + } + Map? _customDataFromMessage(V2TimMessage msg) { if (msg.elemType != MessageElemType.V2TIM_ELEM_TYPE_CUSTOM) { return null; @@ -1891,6 +2007,60 @@ class _MessageItem extends StatelessWidget { return _CpInviteMessageState.pending; } + bool _isDuplicateCpResultMessageForConversation(Map data) { + final currentKey = _cpResultMessageDedupKey(data); + if (currentKey.isEmpty) { + return false; + } + final currentIndex = currentConversationMessageList.indexWhere( + _isCurrentConversationMessage, + ); + if (currentIndex <= 0) { + return false; + } + for (var i = 0; i < currentIndex; i++) { + final otherData = _customDataFromMessage( + currentConversationMessageList[i], + ); + if (otherData == null) { + continue; + } + if (_cpResultMessageDedupKey(otherData) == currentKey) { + return true; + } + } + return false; + } + + bool _isCurrentConversationMessage(V2TimMessage item) { + final currentMsgId = message.msgID?.trim() ?? ""; + final itemMsgId = item.msgID?.trim() ?? ""; + if (currentMsgId.isNotEmpty && itemMsgId.isNotEmpty) { + return currentMsgId == itemMsgId; + } + final currentSeq = message.seq?.trim() ?? ""; + final itemSeq = item.seq?.trim() ?? ""; + if (currentSeq.isNotEmpty && itemSeq.isNotEmpty) { + return currentSeq == itemSeq && item.timestamp == message.timestamp; + } + return identical(item, message); + } + + String _cpResultMessageDedupKey(Map data) { + if (!_isCpInvitePayload(data)) { + return ""; + } + final state = _cpInviteStateFromData(data); + if (state == _CpInviteMessageState.pending) { + return ""; + } + final applyId = _cpApplyIdFromData(data); + if (applyId.isEmpty) { + return ""; + } + return "$applyId|${state.name}"; + } + _CpInviteMessageState _cpInviteStateFromData(Map data) { final content = _cpContentMap(data); final values = [ @@ -2145,6 +2315,20 @@ class _MessageItem extends StatelessWidget { ], fallback: ""); } + String _cpRelationTypeFromMessageData(Map data) { + final invite = _cpInviteFromMessageData(data); + final content = _cpContentMap(data); + return scNormalizeCpRelationType( + _firstNonBlankString([ + invite?.relationType, + data["relationType"]?.toString(), + data["relation_type"]?.toString(), + content["relationType"]?.toString(), + content["relation_type"]?.toString(), + ], fallback: "CP"), + ); + } + Map _cpContentMap(Map data) { final content = data["content"]; try { diff --git a/lib/modules/chat/system/message_system_page.dart b/lib/modules/chat/system/message_system_page.dart index bc76f77..315197d 100644 --- a/lib/modules/chat/system/message_system_page.dart +++ b/lib/modules/chat/system/message_system_page.dart @@ -458,7 +458,7 @@ class _MessageItem extends StatelessWidget { showMsgItemMenu(ct, ""); }, onTap: () { - _showCpInviteDialogFromMessage(data, cpInvite, actionState); + _openCpInviteDialogFromMessage(data, cpInvite, actionState); }, child: Container( width: 251.w, @@ -529,6 +529,30 @@ class _MessageItem extends StatelessWidget { ); } + void _openCpInviteDialogFromMessage( + Map data, + SCSystemInvitMessageRes? cpInvite, + RoomCpInviteActionState actionState, + ) { + unawaited(_openCpInviteDialogFromMessageAsync(data, cpInvite, actionState)); + } + + Future _openCpInviteDialogFromMessageAsync( + Map data, + SCSystemInvitMessageRes? cpInvite, + RoomCpInviteActionState actionState, + ) async { + final latestState = await _latestCpInviteStateForMessage(data); + if (!context.mounted) { + return; + } + final resolvedActionState = + latestState == _CpInviteMessageState.pending + ? actionState + : _cpInviteActionState(latestState); + _showCpInviteDialogFromMessage(data, cpInvite, resolvedActionState); + } + Widget _buildCpInviteGiftThumb(String giftCover) { final child = giftCover.isNotEmpty @@ -788,6 +812,7 @@ class _MessageItem extends StatelessWidget { applyId, SCSysytemMessageType.CP_BUILD.name, cpInvite: cpInvite, + cpData: data, ) : null, onReject: @@ -798,6 +823,7 @@ class _MessageItem extends StatelessWidget { applyId, SCSysytemMessageType.CP_BUILD.name, cpInvite: cpInvite, + cpData: data, ) : null, ); @@ -856,6 +882,56 @@ class _MessageItem extends StatelessWidget { : _CpInviteMessageState.pending; } + Future<_CpInviteMessageState> _latestCpInviteStateForMessage( + Map data, + ) async { + final localState = _cpInviteStateForMessage(data); + if (localState != _CpInviteMessageState.pending) { + return localState; + } + final applyId = _cpApplyIdFromData(data); + if (applyId.isEmpty) { + return localState; + } + try { + final status = await SCAccountRepository().cpRelationshipApplyStatus( + applyId, + ); + final backendState = _cpInviteStateFromBackendStatus(status); + if (backendState != null) { + return backendState; + } + } catch (_) {} + return _cpInviteSecondsLeft(data) <= 0 + ? _CpInviteMessageState.expired + : localState; + } + + _CpInviteMessageState? _cpInviteStateFromBackendStatus(String? status) { + final text = status?.trim().toUpperCase() ?? ""; + if (text.isEmpty || text == "WAIT" || text == "WAITING") { + return text.isEmpty ? null : _CpInviteMessageState.pending; + } + if (text.contains("EXPIRE") || + text.contains("TIMEOUT") || + text.contains("OVERDUE")) { + return _CpInviteMessageState.expired; + } + if (text.contains("REFUSE") || + text.contains("REJECT") || + text.contains("DECLIN") || + text.contains("DENY")) { + return _CpInviteMessageState.rejected; + } + if (text.contains("AGREE") || + text.contains("ACCEPT") || + text.contains("APPROVE") || + text.contains("ESTABLISH")) { + return _CpInviteMessageState.accepted; + } + return null; + } + _CpInviteMessageState _cpInviteStateFromConversation( Map currentData, ) { @@ -1863,7 +1939,14 @@ class _MessageItem extends StatelessWidget { Future _acceptCpInviteDirect( String id, SCSystemInvitMessageRes? cpInvite, + Map? cpData, ) async { + if (!await _shouldProcessCpInvite(id, cpInvite, cpData)) { + return; + } + if (!context.mounted) { + return; + } final profileManager = Provider.of( context, listen: false, @@ -1882,7 +1965,14 @@ class _MessageItem extends StatelessWidget { Future _rejectCpInviteDirect( String id, SCSystemInvitMessageRes? cpInvite, + Map? cpData, ) async { + if (!await _shouldProcessCpInvite(id, cpInvite, cpData)) { + return; + } + if (!context.mounted) { + return; + } final profileManager = Provider.of( context, listen: false, @@ -1898,9 +1988,62 @@ class _MessageItem extends StatelessWidget { } } - void acceptOpt(String id, String type, {SCSystemInvitMessageRes? cpInvite}) { + Future _shouldProcessCpInvite( + String applyId, + SCSystemInvitMessageRes? cpInvite, + Map? data, + ) async { + final latestState = + data == null + ? await _latestCpInviteStateForApplyId(applyId) + : await _latestCpInviteStateForMessage(data); + if (latestState == _CpInviteMessageState.pending) { + return true; + } + if (!context.mounted) { + return false; + } + if (data != null) { + _showCpInviteDialogFromMessage( + data, + cpInvite, + _cpInviteActionState(latestState), + ); + } else { + SmartDialog.dismiss(tag: RoomCpInviteDialog.dialogTag); + if (latestState == _CpInviteMessageState.expired) { + SCTts.show(SCAppLocalizations.of(context)!.closeFriendInviteTimedOut); + } + } + updateCall(); + return false; + } + + Future<_CpInviteMessageState> _latestCpInviteStateForApplyId( + String applyId, + ) async { + if (applyId.trim().isEmpty) { + return _CpInviteMessageState.pending; + } + try { + final status = await SCAccountRepository().cpRelationshipApplyStatus( + applyId, + ); + return _cpInviteStateFromBackendStatus(status) ?? + _CpInviteMessageState.pending; + } catch (_) { + return _CpInviteMessageState.pending; + } + } + + void acceptOpt( + String id, + String type, { + SCSystemInvitMessageRes? cpInvite, + Map? cpData, + }) { if (type == SCSysytemMessageType.CP_BUILD.name) { - unawaited(_acceptCpInviteDirect(id, cpInvite)); + unawaited(_acceptCpInviteDirect(id, cpInvite, cpData)); return; } SmartDialog.show( @@ -1948,9 +2091,14 @@ class _MessageItem extends StatelessWidget { ); } - void rejectOpt(String id, String type, {SCSystemInvitMessageRes? cpInvite}) { + void rejectOpt( + String id, + String type, { + SCSystemInvitMessageRes? cpInvite, + Map? cpData, + }) { if (type == SCSysytemMessageType.CP_BUILD.name) { - unawaited(_rejectCpInviteDirect(id, cpInvite)); + unawaited(_rejectCpInviteDirect(id, cpInvite, cpData)); return; } SmartDialog.show( diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 41ae98a..c1cb34a 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -434,8 +434,10 @@ class RealTimeMessagingManager extends ChangeNotifier { .addConversationListener( listener: V2TimConversationListener( onNewConversation: (conversationList) { - // _onRefreshConversation(conversationList); - initConversation(); + _onRefreshConversation(conversationList); + }, + onConversationChanged: (conversationList) { + _onRefreshConversation(conversationList); }, onTotalUnreadMessageCountChanged: (int totalUnreadCount) { messageUnReadCount = totalUnreadCount; @@ -605,7 +607,11 @@ class RealTimeMessagingManager extends ChangeNotifier { conversationMap.clear(); if (conversationList != null) { for (V2TimConversation? conversation in conversationList) { - conversationMap[conversation!.conversationID] = conversation; + if (conversation == null) { + continue; + } + conversationMap[conversation.conversationID] = conversation; + _handleCpBuildFromConversation(conversation); } } getConversationList(); @@ -853,6 +859,13 @@ class RealTimeMessagingManager extends ChangeNotifier { if (data == null) { return null; } + final relationNoticePeerId = _cpRelationNoticePeerIdFromData( + data, + message: message, + ); + if (relationNoticePeerId != null) { + return relationNoticePeerId; + } final invite = _cpInviteFromMessageData(data); final currentProfile = AccountStorage().getCurrentUser()?.userProfile; if (invite == null || currentProfile == null) { @@ -930,6 +943,9 @@ class RealTimeMessagingManager extends ChangeNotifier { if (_handleCpInviteResultMessage(data, msgId: message?.msgID)) { return; } + if (_handleCpRelationRefreshNoticeMessage(data, message: message)) { + return; + } if (noticeType != SCSysytemMessageType.CP_BUILD.name) { return; } @@ -1241,6 +1257,47 @@ class RealTimeMessagingManager extends ChangeNotifier { return true; } + bool _handleCpRelationRefreshNoticeMessage( + Map data, { + V2TimMessage? message, + }) { + final noticeType = _cpRelationNoticeTypeFromData(data); + if (!_isCpRelationRefreshNoticeType(noticeType)) { + return false; + } + _cpInviteLog( + 'handle CP relation refresh notice noticeType=$noticeType raw=$data', + ); + final context = navigatorKey.currentState?.context; + if (context != null) { + unawaited( + Provider.of( + context, + listen: false, + ).refreshLoadedCpProfiles(), + ); + if (noticeType == SCSysytemMessageType.CP_DISMISS.name) { + final currentUserId = + AccountStorage().getCurrentUser()?.userProfile?.id?.trim() ?? ""; + final peerUserId = _cpRelationNoticePeerIdFromData( + data, + message: message, + ); + if (currentUserId.isNotEmpty && peerUserId != null) { + Provider.of( + context, + listen: false, + )?.invalidateRoomMicRelationBetween( + userId: currentUserId, + peerUserId: peerUserId, + relationType: _cpRelationTypeFromNoticeData(data), + ); + } + } + } + return true; + } + Future _showCpInviteResultDialog({ required BuildContext context, required Map data, @@ -2039,13 +2096,88 @@ class RealTimeMessagingManager extends ChangeNotifier { content["type"]?.toString(), ])?.toUpperCase() ?? ""; - if (noticeType == SCSysytemMessageType.CP_BUILD.name) { + if (noticeType == SCSysytemMessageType.CP_BUILD.name || + _isCpRelationRefreshNoticeType(noticeType)) { return true; } return _cpInviteApplyIdFromData(data).isNotEmpty && _cpInviteResultFromMessageData(data) != null; } + String _cpRelationNoticeTypeFromData(Map data) { + final content = _cpInviteContentMap(data); + return (_firstCpInviteValue([ + data["noticeType"]?.toString(), + data["type"]?.toString(), + content["noticeType"]?.toString(), + content["type"]?.toString(), + ]) ?? + "") + .toUpperCase(); + } + + bool _isCpRelationRefreshNoticeType(String noticeType) { + return noticeType == SCSysytemMessageType.CP_DISMISS.name || + noticeType == SCSysytemMessageType.CP_BLESS.name; + } + + String _cpRelationTypeFromNoticeData(Map data) { + final content = _cpInviteContentMap(data); + return scNormalizeCpRelationType( + _firstCpInviteValue([ + data["relationType"]?.toString(), + data["relation_type"]?.toString(), + content["relationType"]?.toString(), + content["relation_type"]?.toString(), + ]) ?? + "CP", + ); + } + + String? _cpRelationNoticePeerIdFromData( + Map data, { + V2TimMessage? message, + }) { + if (!_isCpRelationRefreshNoticeType(_cpRelationNoticeTypeFromData(data))) { + return null; + } + final content = _cpInviteContentMap(data); + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + final currentIds = + [currentProfile?.id, currentProfile?.account] + .map((value) => value?.trim() ?? "") + .where((value) => value.isNotEmpty) + .toSet(); + final candidates = [ + data["expand"]?.toString(), + content["expand"]?.toString(), + data["peerUserId"]?.toString(), + data["cpUserId"]?.toString(), + data["targetUserId"]?.toString(), + data["senderUserId"]?.toString(), + data["fromUserId"]?.toString(), + data["userId"]?.toString(), + content["peerUserId"]?.toString(), + content["cpUserId"]?.toString(), + content["targetUserId"]?.toString(), + content["senderUserId"]?.toString(), + content["fromUserId"]?.toString(), + content["userId"]?.toString(), + message?.sender, + message?.userID, + ]; + for (final candidate in candidates) { + final userId = candidate?.trim() ?? ""; + if (userId.isEmpty || + currentIds.contains(userId) || + !scLooksLikeBackendUserId(userId)) { + continue; + } + return userId; + } + return null; + } + void _cpInviteLog(String message) { debugPrint('[CP][Invite] $message'); } @@ -2053,10 +2185,23 @@ class RealTimeMessagingManager extends ChangeNotifier { void _onRefreshConversation(List conversations) { for (V2TimConversation conversation in conversations) { conversationMap[conversation.conversationID] = conversation; + _handleCpBuildFromConversation(conversation); } getConversationList(); } + void _handleCpBuildFromConversation(V2TimConversation conversation) { + final message = conversation.lastMessage; + if (message == null || message.groupID != null) { + return; + } + final data = _decodeCpSystemMessageData(message); + if (!_isCpRelationInviteNoticeData(data)) { + return; + } + unawaited(_handleIncomingCpBuildMessage(message)); + } + ///创建房间im群聊 Future> createRoomGroup( String groupID, diff --git a/lib/services/auth/user_profile_manager.dart b/lib/services/auth/user_profile_manager.dart index a42682a..7b4a980 100644 --- a/lib/services/auth/user_profile_manager.dart +++ b/lib/services/auth/user_profile_manager.dart @@ -178,22 +178,28 @@ class SocialChatUserProfileManager extends ChangeNotifier { if (userId.trim().isEmpty) { return profile; } - final cpPairs = []; - final closeFriendPairs = []; + final fallbackCpPairs = []; + final fallbackCloseFriendPairs = []; _addCpRelationPairs( - cpPairs: cpPairs, - closeFriendPairs: closeFriendPairs, + cpPairs: fallbackCpPairs, + closeFriendPairs: fallbackCloseFriendPairs, relations: profile.cpList, fallbackRelationType: 'CP', ); _addCpRelationPairs( - cpPairs: cpPairs, - closeFriendPairs: closeFriendPairs, + cpPairs: fallbackCpPairs, + closeFriendPairs: fallbackCloseFriendPairs, relations: profile.closeFriendList, ); + final cpPairs = []; + final closeFriendPairs = []; + var loadedAnyCabin = false; await Future.wait( _cpProfileRelationTypes.map((relationType) async { final cabin = await _safeLoadCpCabin(userId, relationType); + if (cabin != null) { + loadedAnyCabin = true; + } final cpPair = cabin?.cpPairUserProfileCO; if (cpPair == null || !_hasCpPair(cpPair)) { return; @@ -231,6 +237,12 @@ class SocialChatUserProfileManager extends ChangeNotifier { 'closeFriends=${closeFriendPairs.length}', ); } + if (!loadedAnyCabin) { + return profile.copyWith( + cpList: fallbackCpPairs, + closeFriendList: fallbackCloseFriendPairs, + ); + } return profile.copyWith( cpList: cpPairs, closeFriendList: closeFriendPairs,