This commit is contained in:
2025-06-30 16:24:20 +08:00
commit 68de5eb048
454 changed files with 56921 additions and 0 deletions
@@ -0,0 +1,8 @@
/// Extensions on [Uri]
extension UriX on Uri {
/// Return the URI adding the http scheme if it is missing
Uri get withScheme {
if (hasScheme) return this;
return Uri.parse('http://${toString()}');
}
}
@@ -0,0 +1,90 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:link_preview_generator_for_us/link_preview_generator.dart';
import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart';
import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart';
import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/extensions.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/link_preview_entry.dart';
import 'package:url_launcher/url_launcher.dart';
class LinkUtils {
static RegExp urlReg = RegExp(
r"([hH][tT]{2}[pP]:\/\/|[hH][tT]{2}[pP][sS]:\/\/|[wW]{3}.|[wW][aA][pP].|[fF][tT][pP].|[fF][iI][lL][eE].)[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]");
/// Get all the URL from a text message
static List<String> getURLMatches(String textMessage) {
final matches = urlReg.allMatches(textMessage).toList();
List<String> urlMatches = [];
for (Match m in matches) {
String match = m.group(0) ?? "";
urlMatches.add(match);
}
return urlMatches;
}
/// Launch URL
static Future<void> launchURL(BuildContext context, String url) async {
try {
await launchUrl(
Uri.parse(url).withScheme,
mode: LaunchMode.externalApplication,
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(TIM_t("无法打开URL"))), // Cannot launch the url
);
}
}
/// Get color
static Color hexToColor(String hexString) {
return Color(int.parse(hexString, radix: 16)).withAlpha(255);
}
/// Get the URL preview information
static Future<List<LocalCustomDataModel>> getURLPreview(List<String> urlMatches) async {
// Request for preview information for all URL links synchronously
final List<LocalCustomDataModel> urlPreview = await Future.wait(urlMatches.map((e) async {
String url = e;
if (!e.contains("http")) {
url = 'http://$e';
}
final WebInfo info = await LinkPreviewForUs.scrapeFromURL(url);
return LocalCustomDataModel(url: e, title: info.title, image: info.image, description: info.description);
}));
return urlPreview;
}
/// save the link info to local and call updating the message on UI, only works with [onUpdateMessage]
static Future<void> saveToLocalAndUpdate(
V2TimMessage message, LocalCustomDataModel previewItem, ValueChanged<V2TimMessage> onUpdateMessage) async {
if (message.msgID != null) {
String saveInfo = LinkPreviewEntry.linkInfoToString(previewItem);
final currentInfo = message.localCustomData;
if (currentInfo != null && currentInfo.isNotEmpty) {
final Map<String, dynamic> data = json.decode(currentInfo);
data['url'] = previewItem.url;
data['image'] = previewItem.image;
data['title'] = previewItem.title;
data['description'] = previewItem.description;
saveInfo = json.encode(data);
}
message.localCustomData = saveInfo;
if (saveInfo != currentInfo) {
final result = await TencentImSDKPlugin.v2TIMManager.v2TIMMessageManager
.setLocalCustomData(msgID: message.msgID!, localCustomData: saveInfo);
if (result.code == 0) {
onUpdateMessage(message);
}
}
}
}
}
@@ -0,0 +1,31 @@
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/emoji_text.dart';
import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart';
RegExp emojiExp = RegExp(r'\[([\u4e00-\u9fa5A-Za-z0-9]+)\]');
String mdTextCompiler(String originalText, {
bool isUseQQPackage = false,
bool isUseTencentCloudChatPackage = false,
bool isUseTencentCloudChatPackageOldKeys = false,
List<CustomEmojiFaceData> customEmojiStickerList = const [],
}) {
String text = originalText;
final EmojiUtil emojiUtil = EmojiUtil(
isUseQQPackage: isUseQQPackage,
isUseTencentCloudChatPackage: isUseTencentCloudChatPackage,
isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys,
customEmojiStickerList: customEmojiStickerList);
text = text.replaceAllMapped(emojiExp, (match) {
String key = match.group(0)!;
// Check if the emoji exists in the emoji map
if (emojiUtil.emojiMap.containsKey(key)) {
String assetPath = emojiUtil.emojiMap[key]!;
return '![sticker](resource:$assetPath#22x22)';
}
return key;
});
return text;
}
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/widgets/link_preview.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/widgets/link_text.dart';
import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart';
import 'models/link_preview_content.dart';
class LinkPreviewEntry {
/// get the text message with hyperlinks
static LinkPreviewText? getHyperlinksText(String messageText, bool isMarkdown,
{Function(String)? onLinkTap,
bool isEnableTextSelection = false,
bool isUseQQPackage = false,
bool isUseTencentCloudChatPackage = false,
bool isUseTencentCloudChatPackageOldKeys = false,
List<CustomEmojiFaceData> customEmojiStickerList = const []}) {
return ({TextStyle? style}) {
return isMarkdown
? LinkTextMarkdown(
isUseQQPackage: isUseQQPackage,
isUseTencentCloudChatPackage: isUseTencentCloudChatPackage,
isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys,
customEmojiStickerList: customEmojiStickerList,
isEnableTextSelection: isEnableTextSelection,
messageText: addSpaceAfterLeftBracket(addSpaceBeforeHttp(replaceSingleNewlineWithTwo(messageText))),
style: style,
onLinkTap: onLinkTap)
: LinkText(
isEnableTextSelection: isEnableTextSelection,
messageText: messageText,
style: style,
onLinkTap: onLinkTap,
isUseQQPackage: isUseQQPackage,
isUseTencentCloudChatPackage: isUseTencentCloudChatPackage,
isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys,
customEmojiStickerList: customEmojiStickerList);
};
}
static String addSpaceAfterLeftBracket(String inputText) {
return inputText.splitMapJoin(
RegExp(r'<\w+[^<>]*>'),
onMatch: (match) {
return match.group(0)!.replaceFirst('<', '< ');
},
onNonMatch: (text) => text,
);
}
static String replaceSingleNewlineWithTwo(String inputText) {
return inputText.split('\n').join('\n\n');
}
static String addSpaceBeforeHttp(String inputText) {
return inputText.splitMapJoin(
RegExp(r'http'),
onMatch: (match) {
return ' http';
},
onNonMatch: (text) => text,
);
}
/// get the [LinkPreviewContent] with preview widget and website information for the first link.
/// If you provide `onUpdateMessage(String linkInfoJson)`, it can save the link info to local custom data than call updating the message on UI automatically.
static Future<LinkPreviewContent?> getFirstLinkPreviewContent(
{required V2TimMessage message, ValueChanged<V2TimMessage>? onUpdateMessage}) async {
final String? messageText = message.textElem?.text;
if (messageText == null) {
return null;
}
final List<String> urlMatches = LinkUtils.getURLMatches(messageText);
if (urlMatches.isEmpty) {
return null;
}
final List<LocalCustomDataModel?> previewItemList = await LinkUtils.getURLPreview([urlMatches[0]]);
if (previewItemList.isNotEmpty) {
final LocalCustomDataModel previewItem = previewItemList.first!;
if (onUpdateMessage != null) {
LinkUtils.saveToLocalAndUpdate(message, previewItem, onUpdateMessage);
}
return LinkPreviewContent(
linkInfo: previewItem,
linkPreviewWidget: LinkPreviewWidget(linkPreview: previewItem),
);
} else {
return null;
}
}
/// get the [LinkPreviewContent] with preview widget and website information for all the links
static Future<List<LinkPreviewContent?>?> getAllLinkPreviewContent(V2TimMessage message) async {
final String? messageText = message.textElem?.text;
if (messageText == null) {
return null;
}
final List<String> urlMatches = LinkUtils.getURLMatches(messageText);
if (urlMatches.isEmpty) {
return [];
}
final List<LocalCustomDataModel> previewItemList = await LinkUtils.getURLPreview([urlMatches[0]]);
if (previewItemList.isNotEmpty) {
final List<LinkPreviewContent?> resultList = previewItemList
.map((e) => LinkPreviewContent(
linkInfo: e,
linkPreviewWidget: LinkPreviewWidget(linkPreview: e),
))
.toList();
return resultList;
} else {
return [];
}
}
static String linkInfoToString(LocalCustomDataModel linkInfo) {
return linkInfo.toString();
}
}
@@ -0,0 +1,57 @@
import 'dart:convert';
import 'package:flutter/cupertino.dart';
typedef LinkPreviewText = Widget Function({TextStyle? style});
class LocalCustomDataModel {
final String? description;
final String? image;
final String? url;
final String? title;
String? translatedText;
LocalCustomDataModel(
{this.description, this.image, this.url, this.title, this.translatedText});
Map<String, String?> toMap() {
final Map<String, String?> data = {};
data['url'] = url;
data['image'] = image;
data['title'] = title;
data['description'] = description;
data['translatedText'] = translatedText;
return data;
}
LocalCustomDataModel.fromMap(Map map)
: description = map['description'],
image = map['image'],
url = map['url'],
translatedText = map['translatedText'],
title = map['title'];
@override
String toString() {
return json.encode(toMap());
}
bool isLinkPreviewEmpty() {
if ((image == null || image!.isEmpty) &&
(title == null || title!.isEmpty) &&
(description == null || description!.isEmpty)) {
return true;
}
return false;
}
}
class LinkPreviewContent {
const LinkPreviewContent({
this.linkInfo,
this.linkPreviewWidget,
});
final LocalCustomDataModel? linkInfo;
final Widget? linkPreviewWidget;
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_stateless_widget.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/models/link_preview_content.dart';
class LinkPreviewWidget extends TIMStatelessWidget {
final LocalCustomDataModel linkPreview;
const LinkPreviewWidget({Key? key, required this.linkPreview})
: super(key: key);
@override
Widget timBuild(BuildContext context) {
if (linkPreview.isLinkPreviewEmpty()) {
return Container();
}
return GestureDetector(
onTap: () {
if(linkPreview.url != null){
LinkUtils.launchURL(context, linkPreview.url!);
}
},
child: Container(
padding: const EdgeInsets.only(top: 8, bottom: 8, left: 8, right: 8),
decoration: const BoxDecoration(
color: Color(0x19696969),
borderRadius: BorderRadius.all(Radius.circular(6)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (linkPreview.title != null && linkPreview.title!.isNotEmpty)
Text(
linkPreview.title!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14.0,
color: Color(0xFF444444),
fontWeight: FontWeight.w400),
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
if (linkPreview.image != null && linkPreview.image!.isNotEmpty)
Container(
margin: const EdgeInsets.only(right: 8),
child: SizedBox(
height: 40,
width: 40,
child: Image.network(linkPreview.image!),
),
),
if (linkPreview.description != null &&
linkPreview.description!.isNotEmpty)
Expanded(
child: Text(
linkPreview.description!,
style: const TextStyle(
fontSize: 12.0, color: Color(0xFF999999)),
)),
if ((linkPreview.description == null ||
linkPreview.description!.isEmpty) &&
linkPreview.title != null &&
linkPreview.title!.isNotEmpty)
Expanded(
child: Text(
linkPreview.title!,
style: const TextStyle(
fontSize: 12.0, color: Color(0xFF999999)),
)),
],
)
],
),
),
);
}
}
@@ -0,0 +1,187 @@
// ignore_for_file: deprecated_member_use
import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart';
import 'package:extended_text/extended_text.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_stateless_widget.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/http_text.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/compiler/md_text.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/utils.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart';
typedef ImageBuilder = Widget Function(
Uri uri, String? imageDirectory, double? width, double? height);
class LinkTextMarkdown extends TIMStatelessWidget {
/// Callback for when link is tapped
final void Function(String)? onLinkTap;
/// message text
final String messageText;
/// text style for default words
final TextStyle? style;
final bool? isEnableTextSelection;
final bool isUseQQPackage;
final bool isUseTencentCloudChatPackage;
final bool isUseTencentCloudChatPackageOldKeys;
final List<CustomEmojiFaceData> customEmojiStickerList;
const LinkTextMarkdown(
{Key? key,
required this.messageText,
this.isUseQQPackage = false,
this.isUseTencentCloudChatPackage = false,
this.isUseTencentCloudChatPackageOldKeys = false,
this.customEmojiStickerList = const [],
this.isEnableTextSelection,
this.onLinkTap,
this.style})
: super(key: key);
@override
Widget timBuild(BuildContext context) {
return MarkdownBody(
data: mdTextCompiler(messageText,
isUseTencentCloudChatPackage: isUseTencentCloudChatPackage,
customEmojiStickerList: customEmojiStickerList),
selectable: isEnableTextSelection ?? false,
styleSheet: MarkdownStyleSheet.fromTheme(ThemeData(
textTheme: TextTheme(
bodyMedium: style ?? const TextStyle(fontSize: 16.0))))
.copyWith(
a: TextStyle(color: LinkUtils.hexToColor("015fff")),
),
extensionSet: md.ExtensionSet.gitHubWeb,
onTapLink: (
String link,
String? href,
String title,
) {
if (onLinkTap != null) {
onLinkTap!(href ?? "");
} else {
LinkUtils.launchURL(context, href ?? "");
}
},
);
}
}
class LinkText extends TIMStatelessWidget {
/// Callback for when link is tapped
final void Function(String)? onLinkTap;
/// message text
final String messageText;
/// text style for default words
final TextStyle? style;
final bool isUseQQPackage;
final bool isUseTencentCloudChatPackage;
final bool isUseTencentCloudChatPackageOldKeys;
final List<CustomEmojiFaceData> customEmojiStickerList;
final bool? isEnableTextSelection;
const LinkText(
{Key? key,
required this.messageText,
this.onLinkTap,
this.isEnableTextSelection,
this.style,
this.isUseQQPackage = false,
this.isUseTencentCloudChatPackage = false,
this.isUseTencentCloudChatPackageOldKeys = false,
this.customEmojiStickerList = const []})
: super(key: key);
String _getContentSpan(String text, BuildContext context) {
List<InlineSpan> _contentList = [];
String contentData = PlatformUtils().isWeb ? '\u200B' : "";
Iterable<RegExpMatch> matches = LinkUtils.urlReg.allMatches(text);
int index = 0;
for (RegExpMatch match in matches) {
String c = text.substring(match.start, match.end);
if (match.start == index) {
index = match.end;
}
if (index < match.start) {
String a = text.substring(index, match.start);
index = match.end;
contentData += a;
_contentList.add(
TextSpan(text: a),
);
}
if (LinkUtils.urlReg.hasMatch(c)) {
contentData += HttpText.flag + c + HttpText.flag;
_contentList.add(TextSpan(
text: c,
style: TextStyle(color: LinkUtils.hexToColor("015fff")),
recognizer: TapGestureRecognizer()
..onTap = () {
if (onLinkTap != null) {
onLinkTap!(text.substring(match.start, match.end));
} else {
LinkUtils.launchURL(
context, text.substring(match.start, match.end));
}
}));
} else {
contentData += c;
_contentList.add(
TextSpan(text: c, style: style ?? const TextStyle(fontSize: 16.0)),
);
}
}
if (index < text.length) {
String a = text.substring(index, text.length);
contentData += a;
_contentList.add(
TextSpan(text: a, style: style ?? const TextStyle(fontSize: 16.0)),
);
}
return contentData;
}
@override
Widget timBuild(BuildContext context) {
return ExtendedText(_getContentSpan(messageText, context), softWrap: true,
onSpecialTextTap: (dynamic parameter) {
if (parameter.toString().startsWith(HttpText.flag)) {
if (onLinkTap != null) {
onLinkTap!((parameter.toString()).replaceAll(HttpText.flag, ''));
} else {
LinkUtils.launchURL(
context, (parameter.toString()).replaceAll(HttpText.flag, ''));
}
}
},
style: style ?? const TextStyle(fontSize: 16.0),
specialTextSpanBuilder: DefaultSpecialTextSpanBuilder(
isUseQQPackage: isUseQQPackage,
isUseTencentCloudChatPackage: isUseTencentCloudChatPackage,
isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys,
customEmojiStickerList: customEmojiStickerList,
showAtBackground: true,
));
}
}