opt reply item

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-06-26 13:57:09 +08:00
parent d2e5e71729
commit 81f72e2c4a
3 changed files with 150 additions and 216 deletions

View File

@@ -37,8 +37,8 @@ class Constants {
static const String statisticsApp = static const String statisticsApp =
'{"appId":1,"platform":3,"version":"8.43.0","abtest":""}'; '{"appId":1,"platform":3,"version":"8.43.0","abtest":""}';
static const urlPattern = static final urlRegex =
r'https?://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]'; RegExp(r'https?://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]');
static const goodsUrlPrefix = "https://gaoneng.bilibili.com/tetris"; static const goodsUrlPrefix = "https://gaoneng.bilibili.com/tetris";

View File

@@ -643,7 +643,7 @@ class _VideoInfoState extends State<VideoInfo> {
case 1: case 1:
final List<InlineSpan> spanChildren = <InlineSpan>[]; final List<InlineSpan> spanChildren = <InlineSpan>[];
final RegExp urlRegExp = RegExp( final RegExp urlRegExp = RegExp(
'${Constants.urlPattern}|av\\d+|bv[a-z\\d]{10}', '${Constants.urlRegex.pattern}|av\\d+|bv[a-z\\d]{10}',
caseSensitive: false, caseSensitive: false,
); );

View File

@@ -8,7 +8,7 @@ import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; import 'package:PiliPlus/common/widgets/pendant_avatar.dart';
import 'package:PiliPlus/common/widgets/text/text.dart' as custom_text; import 'package:PiliPlus/common/widgets/text/text.dart' as custom_text;
import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart'
show ReplyInfo, ReplyControl, Content; show ReplyInfo, ReplyControl, Content, Url;
import 'package:PiliPlus/http/init.dart'; import 'package:PiliPlus/http/init.dart';
import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/models/common/badge_type.dart'; import 'package:PiliPlus/models/common/badge_type.dart';
@@ -170,7 +170,7 @@ class ReplyItemGrpc extends StatelessWidget {
); );
} }
Widget lfAvtar() => PendantAvatar( Widget _buildAvatar() => PendantAvatar(
avatar: replyItem.member.face, avatar: replyItem.member.face,
size: 34, size: 34,
badgeSize: 14, badgeSize: 14,
@@ -201,7 +201,7 @@ class ReplyItemGrpc extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
spacing: 12, spacing: 12,
children: [ children: [
lfAvtar(), _buildAvatar(),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -245,7 +245,7 @@ class ReplyItemGrpc extends StatelessWidget {
color: theme.colorScheme.outline, color: theme.colorScheme.outline,
), ),
), ),
if (replyItem.replyControl.location.isNotEmpty) if (replyItem.replyControl.hasLocation())
Text( Text(
'${replyItem.replyControl.location}', '${replyItem.replyControl.location}',
style: TextStyle( style: TextStyle(
@@ -297,13 +297,11 @@ class ReplyItemGrpc extends StatelessWidget {
builder: (context, constraints) => imageView( builder: (context, constraints) => imageView(
constraints.maxWidth, constraints.maxWidth,
replyItem.content.pictures replyItem.content.pictures
.map( .map((item) => ImageModel(
(item) => ImageModel( width: item.imgWidth,
width: item.imgWidth, height: item.imgHeight,
height: item.imgHeight, url: item.imgSrc,
url: item.imgSrc, ))
),
)
.toList(), .toList(),
onViewImage: onViewImage, onViewImage: onViewImage,
onDismissed: onDismissed, onDismissed: onDismissed,
@@ -312,12 +310,10 @@ class ReplyItemGrpc extends StatelessWidget {
), ),
), ),
], ],
// 操作区域
if (replyLevel != 0) ...[ if (replyLevel != 0) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
buttonAction(context, theme, replyItem.replyControl), buttonAction(context, theme, replyItem.replyControl),
], ],
// 一楼的评论
if (replyLevel == 1 && replyItem.count > Int64.ZERO) ...[ if (replyLevel == 1 && replyItem.count > Int64.ZERO) ...[
Padding( Padding(
padding: const EdgeInsets.only(top: 5, bottom: 12), padding: const EdgeInsets.only(top: 5, bottom: 12),
@@ -328,22 +324,20 @@ class ReplyItemGrpc extends StatelessWidget {
); );
} }
ButtonStyle get _style => TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
);
// 感谢、回复、复制
Widget buttonAction( Widget buttonAction(
BuildContext context, ThemeData theme, ReplyControl replyControl) { BuildContext context, ThemeData theme, ReplyControl replyControl) {
final ButtonStyle style = TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
);
return Row( return Row(
children: <Widget>[ children: <Widget>[
const SizedBox(width: 36), const SizedBox(width: 36),
SizedBox( SizedBox(
height: 32, height: 32,
child: TextButton( child: TextButton(
style: _style, style: style,
onPressed: () { onPressed: () {
feedBack(); feedBack();
onReply?.call(replyItem); onReply?.call(replyItem);
@@ -371,7 +365,7 @@ class ReplyItemGrpc extends StatelessWidget {
height: 32, height: 32,
child: TextButton( child: TextButton(
onPressed: null, onPressed: null,
style: _style, style: style,
child: Text( child: Text(
'UP主觉得很赞', 'UP主觉得很赞',
style: TextStyle( style: TextStyle(
@@ -399,7 +393,7 @@ class ReplyItemGrpc extends StatelessWidget {
height: 32, height: 32,
child: TextButton( child: TextButton(
onPressed: showDialogue, onPressed: showDialogue,
style: _style, style: style,
child: Text( child: Text(
'查看对话', '查看对话',
style: TextStyle( style: TextStyle(
@@ -524,7 +518,6 @@ class ReplyItemGrpc extends StatelessWidget {
}), }),
if (extraRow) if (extraRow)
InkWell( InkWell(
// 一楼点击【共xx条回复】展开评论详情
onTap: () => replyReply?.call(replyItem, null), onTap: () => replyReply?.call(replyItem, null),
child: Container( child: Container(
width: double.infinity, width: double.infinity,
@@ -570,7 +563,7 @@ class ReplyItemGrpc extends StatelessWidget {
final Content content = replyItem.content; final Content content = replyItem.content;
final List<InlineSpan> spanChildren = <InlineSpan>[]; final List<InlineSpan> spanChildren = <InlineSpan>[];
if (content.hasRichText()) { if (content.richText.hasNote()) {
spanChildren.add( spanChildren.add(
TextSpan( TextSpan(
text: '[笔记] ', text: '[笔记] ',
@@ -584,41 +577,114 @@ class ReplyItemGrpc extends StatelessWidget {
); );
} }
final urlKeys = content.urls.keys;
// 构建正则表达式 // 构建正则表达式
final List<String> specialTokens = [ final List<String> specialTokens = [
...content.emotes.keys, ...content.emotes.keys,
...content.topics.keys.map((e) => '#$e#'), ...content.topics.keys.map((e) => '#$e#'),
...content.atNameToMid.keys.map((e) => '@$e'), ...content.atNameToMid.keys.map((e) => '@$e'),
...content.urls.keys, ...urlKeys,
]; ];
String patternStr = [ String patternStr = [
...specialTokens.map(RegExp.escape), ...specialTokens.map(RegExp.escape),
r'(\b(?:\d+[:])?\d+[:]\d+\b)', r'(\b(?:\d+[:])?\d+[:]\d+\b)',
r'(\{vote:\d+?\})', r'(\{vote:\d+?\})',
Constants.urlPattern, Constants.urlRegex.pattern,
].join('|'); ].join('|');
final RegExp pattern = RegExp(patternStr); final RegExp pattern = RegExp(patternStr);
late List<String> matchedStrs = []; late List<String> matchedUrls = [];
void addPlainTextSpan(str) { void addPlainTextSpan(str) {
spanChildren.add(TextSpan(text: str)); spanChildren.add(TextSpan(text: str));
} }
void addUrl(String matchStr, Url url, {bool addPlainText = false}) {
if (url.extra.isWordSearch && !enableWordRe) {
if (addPlainText) {
addPlainTextSpan(matchStr);
}
return;
}
final isCv = url.clickReport.startsWith('{"cvid');
final children = [
if (!isCv && url.hasPrefixIcon())
WidgetSpan(
child: CachedNetworkImage(
imageUrl: ImageUtil.thumbnailUrl(url.prefixIcon),
height: 19,
color: theme.colorScheme.primary,
placeholder: (context, url) {
return const SizedBox.shrink();
},
),
),
TextSpan(
text: isCv ? '[笔记] ' : url.title,
style: TextStyle(
color: theme.colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () {
if (url.appUrlSchema.isEmpty) {
if (RegExp(r'^(av|bv)', caseSensitive: false)
.hasMatch(matchStr)) {
UrlUtils.matchUrlPush(matchStr, '');
} else {
RegExpMatch? firstMatch = RegExp(
r'^cv(\d+)$|/read/cv(\d+)|note-app/view\?cvid=(\d+)',
caseSensitive: false,
).firstMatch(matchStr);
String? cvid = firstMatch?.group(1) ??
firstMatch?.group(2) ??
firstMatch?.group(3);
if (cvid != null) {
Get.toNamed(
'/articlePage',
parameters: {
'id': cvid,
'type': 'read',
},
);
return;
}
PageUtils.handleWebview(matchStr);
}
} else {
if (url.extra.isWordSearch) {
Get.toNamed(
'/searchResult',
parameters: {'keyword': url.title},
);
} else {
PageUtils.handleWebview(matchStr);
}
}
},
),
];
if (isCv) {
spanChildren.insertAll(0, children);
} else {
spanChildren.addAll(children);
}
}
// 分割文本并处理每个部分 // 分割文本并处理每个部分
content.message.splitMapJoin( content.message.splitMapJoin(
pattern, pattern,
onMatch: (Match match) { onMatch: (Match match) {
String matchStr = match[0]!; String matchStr = match[0]!;
late final name = matchStr.substring(1);
late final topic = matchStr.substring(1, matchStr.length - 1);
if (content.emotes.containsKey(matchStr)) { if (content.emotes.containsKey(matchStr)) {
// 处理表情 // 处理表情
final size = content.emotes[matchStr]!.size.toInt() * 20.0; final emote = content.emotes[matchStr]!;
final size = emote.size.toInt() * 20.0;
spanChildren.add( spanChildren.add(
WidgetSpan( WidgetSpan(
child: NetworkImgLayer( child: NetworkImgLayer(
src: content.emotes[matchStr]?.hasGifUrl() == true src: emote.hasGifUrl() == true ? emote.gifUrl : emote.url,
? content.emotes[matchStr]?.gifUrl
: content.emotes[matchStr]?.url,
type: ImageType.emote, type: ImageType.emote,
width: size, width: size,
height: size, height: size,
@@ -626,27 +692,22 @@ class ReplyItemGrpc extends StatelessWidget {
), ),
); );
} else if (matchStr.startsWith("@") && } else if (matchStr.startsWith("@") &&
content.atNameToMid.containsKey(matchStr.substring(1))) { content.atNameToMid.containsKey(name)) {
// 处理@用户 // 处理@用户
final String userName = matchStr.substring(1);
final userId = content.atNameToMid[userName]!.toString();
spanChildren.add( spanChildren.add(
TextSpan( TextSpan(
text: matchStr, text: matchStr,
style: TextStyle( style: TextStyle(color: theme.colorScheme.primary),
color: theme.colorScheme.primary,
),
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () => Get.toNamed('/member?mid=$userId'), ..onTap = () =>
Get.toNamed('/member?mid=${content.atNameToMid[name]}'),
), ),
); );
} else if (_voteRegExp.hasMatch(matchStr)) { } else if (_voteRegExp.hasMatch(matchStr)) {
spanChildren.add( spanChildren.add(
TextSpan( TextSpan(
text: '投票: ${content.vote.title}', text: '投票: ${content.vote.title}',
style: TextStyle( style: TextStyle(color: theme.colorScheme.primary),
color: theme.colorScheme.primary,
),
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = ..onTap =
() => showVoteDialog(context, content.vote.id.toInt()), () => showVoteDialog(context, content.vote.id.toInt()),
@@ -655,142 +716,64 @@ class ReplyItemGrpc extends StatelessWidget {
} else if (_timeRegExp.hasMatch(matchStr)) { } else if (_timeRegExp.hasMatch(matchStr)) {
matchStr = matchStr.replaceAll('', ':'); matchStr = matchStr.replaceAll('', ':');
bool isValid = false; bool isValid = false;
try { if (Get.currentRoute.startsWith('/video')) {
List<int> split = matchStr try {
.split(':') final ctr = Get.find<VideoDetailController>(
.map((item) => int.parse(item)) tag: getTag?.call() ?? Get.arguments['heroTag']);
.toList() int duration = ctr.data.timeLength!;
.reversed List<int> split = matchStr
.toList(); .split(':')
int seek = 0; .reversed
for (int i = 0; i < split.length; i++) { .map((item) => int.parse(item))
seek += split[i] * pow(60, i).toInt(); .toList();
int seek = 0;
for (int i = 0; i < split.length; i++) {
seek += split[i] * pow(60, i).toInt();
}
isValid = seek * 1000 <= duration;
} catch (e) {
if (kDebugMode) debugPrint('failed to validate: $e');
} }
int duration = Get.find<VideoDetailController>(
tag: getTag?.call() ?? Get.arguments['heroTag'],
).data.timeLength ??
0;
isValid = seek * 1000 <= duration;
} catch (e) {
if (kDebugMode) debugPrint('failed to validate: $e');
} }
bool isVideoPage = Get.currentRoute.startsWith('/video');
spanChildren.add( spanChildren.add(
TextSpan( TextSpan(
text: isValid ? ' $matchStr ' : matchStr, text: isValid ? ' $matchStr ' : matchStr,
style: isValid && isVideoPage style:
? TextStyle( isValid ? TextStyle(color: theme.colorScheme.primary) : null,
color: theme.colorScheme.primary,
)
: null,
recognizer: isValid recognizer: isValid
? (TapGestureRecognizer() ? (TapGestureRecognizer()
..onTap = () { ..onTap = () {
// 跳转到指定位置 // 跳转到指定位置
if (isVideoPage) { try {
try { SmartDialog.showToast('跳转至:$matchStr');
SmartDialog.showToast('跳转至:$matchStr'); Get.find<VideoDetailController>(
Get.find<VideoDetailController>( tag: Get.arguments['heroTag'])
tag: Get.arguments['heroTag']) .plPlayerController
.plPlayerController .seekTo(
.seekTo( Duration(
Duration( seconds:
seconds: DurationUtil.parseDuration(matchStr)),
DurationUtil.parseDuration(matchStr)), type: 'slider');
type: 'slider'); } catch (e) {
} catch (e) { SmartDialog.showToast('跳转失败: $e');
SmartDialog.showToast('跳转失败: $e');
}
} }
}) })
: null, : null,
), ),
); );
} else { } else {
String appUrlSchema = ''; final url = content.urls[matchStr];
if (content.urls[matchStr] != null && if (url != null && !matchedUrls.contains(matchStr)) {
!matchedStrs.contains(matchStr)) { addUrl(matchStr, url, addPlainText: true);
appUrlSchema = content.urls[matchStr]!.appUrlSchema;
if (appUrlSchema.startsWith('bilibili://search') && !enableWordRe) {
addPlainTextSpan(matchStr);
return "";
}
spanChildren.addAll(
[
if (content.urls[matchStr]?.hasPrefixIcon() == true) ...[
WidgetSpan(
child: CachedNetworkImage(
imageUrl: ImageUtil.thumbnailUrl(
content.urls[matchStr]!.prefixIcon),
height: 19,
color: theme.colorScheme.primary,
placeholder: (context, url) {
return const SizedBox.shrink();
},
),
)
],
TextSpan(
text: content.urls[matchStr]!.title,
style: TextStyle(
color: theme.colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () {
late final String title = content.urls[matchStr]!.title;
if (appUrlSchema == '') {
if (RegExp(r'^(av|bv)', caseSensitive: false)
.hasMatch(matchStr)) {
UrlUtils.matchUrlPush(matchStr, '');
} else {
RegExpMatch? firstMatch = RegExp(
r'^cv(\d+)$|/read/cv(\d+)|note-app/view\?cvid=(\d+)',
caseSensitive: false,
).firstMatch(matchStr);
String? cvid = firstMatch?.group(1) ??
firstMatch?.group(2) ??
firstMatch?.group(3);
if (cvid != null) {
Get.toNamed(
'/articlePage',
parameters: {
'id': cvid,
'type': 'read',
},
);
return;
}
PageUtils.handleWebview(matchStr);
}
} else {
if (appUrlSchema.startsWith('bilibili://search')) {
Get.toNamed(
'/searchResult',
parameters: {'keyword': title},
);
} else {
PageUtils.handleWebview(matchStr);
}
}
},
)
],
);
// 只显示一次 // 只显示一次
matchedStrs.add(matchStr); matchedUrls.add(matchStr);
} else if (matchStr.length > 1 && } else if (matchStr.length > 1 && content.topics[topic] != null) {
content.topics[matchStr.substring(1, matchStr.length - 1)] !=
null) {
spanChildren.add( spanChildren.add(
TextSpan( TextSpan(
text: matchStr, text: matchStr,
style: TextStyle( style: TextStyle(color: theme.colorScheme.primary),
color: theme.colorScheme.primary,
),
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () { ..onTap = () {
final String topic =
matchStr.substring(1, matchStr.length - 1);
Get.toNamed( Get.toNamed(
'/searchResult', '/searchResult',
parameters: {'keyword': topic}, parameters: {'keyword': topic},
@@ -798,13 +781,11 @@ class ReplyItemGrpc extends StatelessWidget {
}, },
), ),
); );
} else if (RegExp(Constants.urlPattern).hasMatch(matchStr)) { } else if (Constants.urlRegex.hasMatch(matchStr)) {
spanChildren.add( spanChildren.add(
TextSpan( TextSpan(
text: matchStr, text: matchStr,
style: TextStyle( style: TextStyle(color: theme.colorScheme.primary),
color: theme.colorScheme.primary,
),
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () => PageUtils.handleWebview(matchStr), ..onTap = () => PageUtils.handleWebview(matchStr),
), ),
@@ -821,59 +802,12 @@ class ReplyItemGrpc extends StatelessWidget {
}, },
); );
if (content.urls.keys.isNotEmpty) { if (urlKeys.isNotEmpty) {
List<String> unmatchedItems = content.urls.keys List<String> unmatchedItems =
.toList() urlKeys.where((url) => !matchedUrls.contains(url)).toList();
.where((item) => !content.message.contains(item))
.toList();
if (unmatchedItems.isNotEmpty) { if (unmatchedItems.isNotEmpty) {
for (int i = 0; i < unmatchedItems.length; i++) { for (var patternStr in unmatchedItems) {
String patternStr = unmatchedItems[i]; addUrl(patternStr, content.urls[patternStr]!);
if (content.urls[patternStr]?.extra.isWordSearch == true &&
!enableWordRe) {
continue;
}
spanChildren.addAll(
[
if (content.urls[patternStr]?.hasPrefixIcon() == true) ...[
WidgetSpan(
child: CachedNetworkImage(
imageUrl: ImageUtil.thumbnailUrl(
content.urls[patternStr]!.prefixIcon),
height: 19,
color: theme.colorScheme.primary,
placeholder: (context, url) {
return const SizedBox.shrink();
},
),
)
],
TextSpan(
text: content.urls[patternStr]!.title,
style: TextStyle(
color: theme.colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () {
String? cvid = RegExp(r'note-app/view\?cvid=(\d+)')
.firstMatch(patternStr)
?.group(1);
if (cvid != null) {
Get.toNamed(
'/articlePage',
parameters: {
'id': cvid,
'type': 'read',
},
);
return;
}
PageUtils.handleWebview(patternStr);
},
)
],
);
} }
} }
} }