This commit is contained in:
roxy 2026-06-03 20:34:50 +08:00
parent 7bce08a3b4
commit e9ee3e9c02
6 changed files with 314 additions and 99 deletions

View File

@ -581,6 +581,14 @@ class _SCMessageChatPageState extends State<SCMessageChatPage> {
//
onRecvMessageExtensionsChanged: (msgID, extensions) {
for (var ext in extensions) {
if (ext.extensionKey == scChatBubbleMessageExtensionKey) {
_MessageItem.updateChatBubbleExtensionCache(
msgID,
ext.extensionValue,
);
setState(() {});
continue;
}
if (ext.extensionKey == "packetID") {
String packetID = ext.extensionValue;
SCMessageUtils.redPacketFutureCache[packetID] =
@ -985,6 +993,9 @@ class _SCMessageChatPageState extends State<SCMessageChatPage> {
class _MessageItem extends StatelessWidget {
static bool _isOpeningCpInviteDialog = false;
static final Map<String, Future<PropsResources?>>
_chatBubbleExtensionFutures = {};
static final Map<String, PropsResources?> _chatBubbleExtensionCache = {};
final V2TimMessage message;
final V2TimMessage? preMessage;
@ -2630,7 +2641,74 @@ class _MessageItem extends StatelessWidget {
if (chatBubbleUrl.isNotEmpty) {
return _buildVipTextBubble(content: content, imageUrl: chatBubbleUrl);
}
return _buildDefaultTextBubble(content);
final msgID = message.msgID?.trim();
if (msgID == null ||
msgID.isEmpty ||
message.elemType != MessageElemType.V2TIM_ELEM_TYPE_TEXT) {
return _buildDefaultTextBubble(content);
}
return FutureBuilder<PropsResources?>(
future: _chatBubbleFromMessageExtension(message),
builder: (_, snapshot) {
final extensionBubbleUrl = _resolveChatBubbleImageUrl(snapshot.data);
if (extensionBubbleUrl.isNotEmpty) {
return _buildVipTextBubble(
content: content,
imageUrl: extensionBubbleUrl,
);
}
return _buildDefaultTextBubble(content);
},
);
}
static void updateChatBubbleExtensionCache(
String msgID,
String extensionValue,
) {
final id = msgID.trim();
if (id.isEmpty) {
return;
}
_chatBubbleExtensionCache[id] = scDecodeChatBubbleSnapshotValue(
extensionValue,
);
_chatBubbleExtensionFutures.remove(id);
}
Future<PropsResources?> _chatBubbleFromMessageExtension(
V2TimMessage message,
) {
final msgID = message.msgID?.trim();
if (msgID == null || msgID.isEmpty) {
return Future.value(null);
}
if (_chatBubbleExtensionCache.containsKey(msgID)) {
return Future.value(_chatBubbleExtensionCache[msgID]);
}
return _chatBubbleExtensionFutures.putIfAbsent(msgID, () async {
if (message.isSupportMessageExtension == false) {
_chatBubbleExtensionCache[msgID] = null;
return null;
}
try {
final result = await TencentImSDKPlugin.v2TIMManager
.getMessageManager()
.getMessageExtensions(msgID: msgID);
final extensions = result.data ?? const <V2TimMessageExtension>[];
for (final extension in extensions) {
if (extension.extensionKey == scChatBubbleMessageExtensionKey) {
final bubble = scDecodeChatBubbleSnapshotValue(
extension.extensionValue,
);
_chatBubbleExtensionCache[msgID] = bubble;
return bubble;
}
}
} catch (_) {}
_chatBubbleExtensionCache[msgID] = null;
return null;
});
}
Widget _buildDefaultTextBubble(String content) {

View File

@ -776,14 +776,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
targetGroupKey,
targetUserIds,
);
return;
}
_handleStandardGiftComboVisuals(
msg,
giftPhoto,
targetGroupKey,
targetUserIds,
);
}
void _enqueueCpGiftFloatingIfNeeded(Msg msg) {
@ -946,45 +939,6 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
}
}
void _handleStandardGiftComboVisuals(
Msg msg,
String giftPhoto,
String? targetGroupKey,
List<String> targetUserIds,
) {
if ((msg.number ?? 0) <= 0) {
return;
}
if (!_shouldPlaySeatFlightGiftAnimation(msg) ||
targetGroupKey == null ||
targetUserIds.isEmpty ||
giftPhoto.isEmpty) {
return;
}
final flightBatchKey = _buildGiftFlightBatchKey(msg, targetUserIds);
if (targetUserIds.length > 1 &&
!_markGiftFlightBatchForPlayback(flightBatchKey)) {
return;
}
final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetGroupKey);
final session = _luckyGiftComboSessions.putIfAbsent(
sessionKey,
() => _LuckyGiftComboSession(),
);
session.endTimer?.cancel();
session.clearQueueTimer?.cancel();
_enqueueTrackedSeatFlightAnimations(
sessionKey: sessionKey,
giftPhoto: giftPhoto,
targetUserIds: targetUserIds,
animationCount: _resolveTrackedAnimationCount(msg),
batchKey: flightBatchKey,
);
_scheduleGiftAnimationSessionEnd(sessionKey);
}
void _handleLuckyGiftComboVisuals(
Msg msg,
String giftPhoto,
@ -1357,26 +1311,6 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
_giftVisualBatchDedupeTimers.clear();
}
bool _shouldPlaySeatFlightGiftAnimation(Msg msg) {
final gift = msg.gift;
if (gift == null) {
return false;
}
final giftPhoto = (gift.giftPhoto ?? "").trim();
if (giftPhoto.isEmpty) {
return false;
}
final giftPhotoExt = _normalizedGiftResourceExtension(giftPhoto);
if (_isAnimatedGiftResource(giftPhotoExt)) {
return false;
}
final giftSourceUrl = (gift.giftSourceUrl ?? "").trim();
final sourceExt = _normalizedGiftResourceExtension(giftSourceUrl);
return !_isAnimatedGiftResource(sourceExt);
}
bool _isAnimatedGiftResource(String extension) {
return extension == ".svga" || extension == ".mp4" || extension == ".vap";
}

View File

@ -41,6 +41,7 @@ import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_result.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_info.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_image_elem.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_extension.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_receipt.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_msg_create_info_result.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart';
@ -2644,14 +2645,18 @@ class RealTimeMessagingManager extends ChangeNotifier {
if (createTextMessageRes.code == 0) {
//
String id = createTextMessageRes.data!.id!;
final chatBubble =
AccountStorage().getCurrentUser()?.userProfile?.getChatBox();
final chatBubbleCloudCustomData = scEncodeChatBubbleCloudCustomData(
AccountStorage().getCurrentUser()?.userProfile?.getChatBox(),
chatBubble,
);
final chatBubbleSnapshotValue = scEncodeChatBubbleSnapshotValue(
chatBubble,
);
//
// sendMessage时receiver则发个人用户单聊消息
// groupID则发群组消息
// receiver与groupID则发群内的个人用户receiver能看见
String receiveId = toConversation.userID!;
V2TimValueCallback<V2TimMessage> sendMessageRes = await TencentImSDKPlugin
.v2TIMManager
.getMessageManager()
@ -2660,11 +2665,20 @@ class RealTimeMessagingManager extends ChangeNotifier {
receiver: toConversation.userID!, // id
needReadReceipt: true,
groupID: '', //
isSupportMessageExtension: chatBubbleSnapshotValue != null,
cloudCustomData: chatBubbleCloudCustomData,
);
if (sendMessageRes.code == 0) {
//
_onNew1v1Message(sendMessageRes.data);
if (chatBubbleSnapshotValue != null) {
unawaited(
_setC2CChatBubbleMessageExtension(
sendMessageRes.data?.msgID,
chatBubbleSnapshotValue,
),
);
}
} else {
SCTts.show(
'create fail,code:${sendMessageRes.code},${sendMessageRes.desc}',
@ -2673,6 +2687,33 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
}
Future<void> _setC2CChatBubbleMessageExtension(
String? msgID,
String snapshotValue,
) async {
final id = msgID?.trim();
if (id == null || id.isEmpty) {
return;
}
try {
await TencentImSDKPlugin.v2TIMManager
.getMessageManager()
.setMessageExtensions(
msgID: id,
extensions: [
V2TimMessageExtension(
extensionKey: scChatBubbleMessageExtensionKey,
extensionValue: snapshotValue,
),
],
);
} catch (error) {
if (kDebugMode) {
debugPrint('[chat_bubble] set extension failed msgID=$id error=$error');
}
}
}
///
Future<void> sendC2CCustomMsg(
String msg,

View File

@ -322,37 +322,82 @@ class SCConfigRepositoryImp implements SocialChatConfigRepository {
///sys/version/manage/release/latest
@override
Future<SCVersionManageLatestRes> versionManageLatest() async {
final result = await http.get<SCVersionManageLatestRes>(
"ee9584f714ded864780e47dab2cf4a2e84ac21c90fcd0966a13d2ce9e8845eb8e580afbe66f9f0fef79429cd5c1e0687",
fromJson: (json) {
_debugVersionResponse('releaseLatest', json);
return SCVersionManageLatestRes.fromJson(json);
},
);
return result;
const apiName = 'releaseLatest';
const path = '/sys/version/manage/release/latest';
const queryParams = <String, dynamic>{};
_debugVersionRequest(apiName, path, queryParams);
try {
final result = await http.get<SCVersionManageLatestRes>(
"ee9584f714ded864780e47dab2cf4a2e84ac21c90fcd0966a13d2ce9e8845eb8e580afbe66f9f0fef79429cd5c1e0687",
queryParams: queryParams,
fromJson: (json) {
_debugVersionResponse(apiName, path, json);
return SCVersionManageLatestRes.fromJson(json);
},
);
return result;
} catch (error) {
_debugVersionError(apiName, path, queryParams, error);
rethrow;
}
}
///sys/version/manage/latest/review
@override
Future<VersionManageLatesReviewRes> versionManageLatestReview() async {
final result = await http.get<VersionManageLatesReviewRes>(
"ee9584f714ded864780e47dab2cf4a2e11ce42bdd061186d4efe3305b73f10fe574aff257ce7e668d08f4caccd1c6232",
fromJson: (json) {
_debugVersionResponse('latestReview', json);
return VersionManageLatesReviewRes.fromJson(json);
},
);
return result;
const apiName = 'latestReview';
const path = '/sys/version/manage/latest/review';
const queryParams = <String, dynamic>{};
_debugVersionRequest(apiName, path, queryParams);
try {
final result = await http.get<VersionManageLatesReviewRes>(
"ee9584f714ded864780e47dab2cf4a2e11ce42bdd061186d4efe3305b73f10fe574aff257ce7e668d08f4caccd1c6232",
queryParams: queryParams,
fromJson: (json) {
_debugVersionResponse(apiName, path, json);
return VersionManageLatesReviewRes.fromJson(json);
},
);
return result;
} catch (error) {
_debugVersionError(apiName, path, queryParams, error);
rethrow;
}
}
void _debugVersionResponse(String name, dynamic json) {
void _debugVersionRequest(
String name,
String path,
Map<String, dynamic> queryParams,
) {
debugPrint(
'[SCVersionUtils][API] $name request path=$path params=$queryParams',
);
}
void _debugVersionResponse(String name, String path, dynamic json) {
try {
debugPrint('[SCVersionUtils] $name rawBody=${jsonEncode(json)}');
debugPrint(
'[SCVersionUtils][API] $name response path=$path rawBody=${jsonEncode(json)}',
);
} catch (e) {
debugPrint('[SCVersionUtils] $name rawBody=$json encodeError=$e');
debugPrint(
'[SCVersionUtils][API] $name response path=$path rawBody=$json encodeError=$e',
);
}
}
void _debugVersionError(
String name,
String path,
Map<String, dynamic> queryParams,
Object error,
) {
debugPrint(
'[SCVersionUtils][API] $name failed path=$path params=$queryParams error=$error',
);
}
///sys/config/customer-service
@override
Future<SocialChatUserProfile> customerService() async {

View File

@ -3,14 +3,19 @@ import 'dart:convert';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
const String scChatBubbleCloudDataKey = 'scChatBubble';
const String scChatBubbleMessageExtensionKey = scChatBubbleCloudDataKey;
String? scEncodeChatBubbleCloudCustomData(PropsResources? chatBubble) {
if (!_hasChatBubbleImage(chatBubble)) {
final snapshot = scChatBubbleSnapshotJson(chatBubble);
if (snapshot == null) {
return null;
}
return jsonEncode(<String, dynamic>{
scChatBubbleCloudDataKey: chatBubble!.toJson(),
});
return jsonEncode(<String, dynamic>{scChatBubbleCloudDataKey: snapshot});
}
String? scEncodeChatBubbleSnapshotValue(PropsResources? chatBubble) {
final snapshot = scChatBubbleSnapshotJson(chatBubble);
return snapshot == null ? null : jsonEncode(snapshot);
}
PropsResources? scDecodeChatBubbleFromCloudCustomData(String? cloudCustomData) {
@ -31,6 +36,48 @@ PropsResources? scDecodeChatBubbleFromCloudCustomData(String? cloudCustomData) {
return null;
}
PropsResources? scDecodeChatBubbleSnapshotValue(String? snapshotValue) {
final raw = snapshotValue?.trim();
if (raw == null || raw.isEmpty) {
return null;
}
try {
final decoded = jsonDecode(raw);
if (decoded is Map) {
return PropsResources.fromJson(decoded);
}
} catch (_) {}
return null;
}
Map<String, dynamic>? scChatBubbleSnapshotJson(PropsResources? resource) {
if (!_hasChatBubbleImage(resource)) {
return null;
}
final map = <String, dynamic>{};
void put(String key, String? value) {
final text = value?.trim();
if (text != null && text.isNotEmpty) {
map[key] = text;
}
}
put('id', resource?.id);
put('name', resource?.name);
put('type', resource?.type);
put('cover', resource?.cover);
put('expand', resource?.expand);
put('sourceUrl', resource?.sourceUrl);
put('imSendCoverUrl', resource?.imSendCoverUrl);
put('imOpenedUrl', resource?.imOpenedUrl);
put('imOpenedUrlTwo', resource?.imOpenedUrlTwo);
put('imNotOpenedUrl', resource?.imNotOpenedUrl);
put('roomSendCoverUrl', resource?.roomSendCoverUrl);
put('roomOpenedUrl', resource?.roomOpenedUrl);
put('roomNotOpenedUrl', resource?.roomNotOpenedUrl);
return map;
}
bool _hasChatBubbleImage(PropsResources? resource) {
if (resource == null) {
return false;

View File

@ -29,10 +29,17 @@ class SCVersionUtils {
'review=${_versionInfoDebug(versionInfo.review)} '
'latest=${_versionInfoDebug(versionInfo.latest)}',
);
final review = _selectCurrentPlatformVersion(
versionInfo.review,
source: 'latestReview.review',
);
if (versionInfo.review != null) {
SCGlobalConfig.isReview =
"${versionInfo.review?.version}" == SCGlobalConfig.version;
_debugLog('checkReview isReview=${SCGlobalConfig.isReview}');
review != null && "${review.version}" == SCGlobalConfig.version;
_debugLog(
'checkReview isReview=${SCGlobalConfig.isReview} '
'selectedReview=${_versionInfoDebug(review)}',
);
}
final latest = await _resolveLatestVersion(versionInfo.latest);
final shouldForceUpdate = latest != null && _shouldForceUpdate(latest);
@ -96,20 +103,56 @@ class SCVersionUtils {
static Future<SCVersionManageLatestRes?> _resolveLatestVersion(
SCVersionManageLatestRes? latest,
) async {
if (!_isVersionInfoEmpty(latest)) {
return latest;
final selectedLatest = _selectCurrentPlatformVersion(
latest,
source: 'latestReview.latest',
);
if (selectedLatest != null) {
return selectedLatest;
}
_debugLog('checkReview latestReview latest empty, fallback releaseLatest');
_debugLog(
'checkReview latestReview latest unavailable for current platform, '
'fallback releaseLatest',
);
try {
final fallback = await SCConfigRepositoryImp().versionManageLatest();
_debugLog('checkReview fallbackLatest=${_versionInfoDebug(fallback)}');
return _isVersionInfoEmpty(fallback) ? latest : fallback;
return _selectCurrentPlatformVersion(fallback, source: 'releaseLatest');
} catch (e) {
_debugLog('checkReview fallback releaseLatest failed error=$e');
return latest;
return null;
}
}
static SCVersionManageLatestRes? _selectCurrentPlatformVersion(
SCVersionManageLatestRes? version, {
required String source,
}) {
if (_isVersionInfoEmpty(version)) {
_debugLog('checkReview ignore $source reason=empty');
return null;
}
final currentVersion = version!;
if (_isCurrentPlatformVersion(currentVersion)) {
_debugLog(
'checkReview select $source currentPlatform=${_currentPlatformName()} '
'version=${currentVersion.version} '
'buildVersion=${currentVersion.buildVersion} '
'downloadUrl=${currentVersion.downloadUrl}',
);
return currentVersion;
}
_debugLog(
'checkReview ignore $source reason=platform_mismatch '
'currentPlatform=${_currentPlatformName()} '
'responsePlatform=${currentVersion.platform} '
'version=${currentVersion.version} '
'buildVersion=${currentVersion.buildVersion} '
'downloadUrl=${currentVersion.downloadUrl}',
);
return null;
}
static bool _isVersionInfoEmpty(SCVersionManageLatestRes? version) {
if (version == null) {
return true;
@ -145,6 +188,25 @@ class SCVersionUtils {
return 0;
}
static bool _isCurrentPlatformVersion(SCVersionManageLatestRes version) {
return _normalizePlatform(version.platform) == _currentPlatformKey();
}
static String _currentPlatformName() {
return Platform.isIOS ? 'iOS' : 'Android';
}
static String _currentPlatformKey() {
return Platform.isIOS ? 'ios' : 'android';
}
static String _normalizePlatform(String? platform) {
return (platform ?? '').trim().toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]'),
'',
);
}
static List<int> _versionParts(String? version) {
final normalized = version?.trim() ?? "";
if (normalized.isEmpty) {
@ -277,6 +339,14 @@ class SCVersionUtils {
static Future<void> _openUpdateUrl(SCVersionManageLatestRes latest) async {
final url = _resolveDownloadUrl(latest);
final urlSource =
(latest.downloadUrl ?? "").trim().isNotEmpty
? 'api_downloadUrl'
: (Platform.isIOS ? 'local_appStore' : 'local_googlePlay');
_debugLog(
'openUpdateUrl currentPlatform=${_currentPlatformName()} '
'responsePlatform=${latest.platform} source=$urlSource url=$url',
);
if (url.isEmpty) {
return;
}