添加消息长按弹窗

This commit is contained in:
2026-02-03 15:53:07 +08:00
parent d482cfcb7d
commit a0923c8572
6 changed files with 368 additions and 1 deletions

View File

@@ -229,6 +229,7 @@
04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */; };
04E038EF2F21F0EC002CA5A0 /* AiVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038EE2F21F0EC002CA5A0 /* AiVM.m */; };
04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */; };
0F2A10032F3C0001002CA5A0 /* KBChatMessageActionPopView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0F2A10022F3C0001002CA5A0 /* KBChatMessageActionPopView.m */; };
04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039482F236E75002CA5A0 /* KBChatTimeCell.m */; };
04E0394D2F236E75002CA5A0 /* KBChatAssistantMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039432F236E75002CA5A0 /* KBChatAssistantMessageCell.m */; };
04E0394E2F236E75002CA5A0 /* KBChatTableView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039452F236E75002CA5A0 /* KBChatTableView.m */; };
@@ -720,6 +721,8 @@
04E039482F236E75002CA5A0 /* KBChatTimeCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatTimeCell.m; sourceTree = "<group>"; };
04E039492F236E75002CA5A0 /* KBChatUserMessageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatUserMessageCell.h; sourceTree = "<group>"; };
04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatUserMessageCell.m; sourceTree = "<group>"; };
0F2A10012F3C0001002CA5A0 /* KBChatMessageActionPopView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatMessageActionPopView.h; sourceTree = "<group>"; };
0F2A10022F3C0001002CA5A0 /* KBChatMessageActionPopView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatMessageActionPopView.m; sourceTree = "<group>"; };
04E039502F2387D2002CA5A0 /* KBAiChatMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAiChatMessage.h; sourceTree = "<group>"; };
04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAiChatMessage.m; sourceTree = "<group>"; };
04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceToTextManager.h; sourceTree = "<group>"; };
@@ -1431,6 +1434,8 @@
04E039452F236E75002CA5A0 /* KBChatTableView.m */,
04E039492F236E75002CA5A0 /* KBChatUserMessageCell.h */,
04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */,
0F2A10012F3C0001002CA5A0 /* KBChatMessageActionPopView.h */,
0F2A10022F3C0001002CA5A0 /* KBChatMessageActionPopView.m */,
04E039422F236E75002CA5A0 /* KBChatAssistantMessageCell.h */,
04E039432F236E75002CA5A0 /* KBChatAssistantMessageCell.m */,
04E039472F236E75002CA5A0 /* KBChatTimeCell.h */,
@@ -2413,6 +2418,7 @@
046086D82F1A093400757C95 /* KBAIReplyCell.m in Sources */,
046086D92F1A093400757C95 /* KBAICommentHeaderView.m in Sources */,
04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */,
0F2A10032F3C0001002CA5A0 /* KBChatMessageActionPopView.m in Sources */,
04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */,
04E0394D2F236E75002CA5A0 /* KBChatAssistantMessageCell.m in Sources */,
04E0394E2F236E75002CA5A0 /* KBChatTableView.m in Sources */,

View File

@@ -0,0 +1,33 @@
//
// KBChatMessageActionPopView.h
// keyBoard
//
// Created by Codex on 2026/2/3.
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, KBChatMessageActionType) {
KBChatMessageActionTypeCopy = 0,
KBChatMessageActionTypeDelete = 1,
KBChatMessageActionTypeReport = 2,
};
@class KBChatMessageActionPopView;
@protocol KBChatMessageActionPopViewDelegate <NSObject>
@optional
- (void)chatMessageActionPopView:(KBChatMessageActionPopView *)view
didSelectAction:(KBChatMessageActionType)action;
@end
/// 聊天消息长按操作弹窗Copy / Delete / Report
@interface KBChatMessageActionPopView : UIView
@property (nonatomic, weak) id<KBChatMessageActionPopViewDelegate> delegate;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,164 @@
//
// KBChatMessageActionPopView.m
// keyBoard
//
// Created by Codex on 2026/2/3.
//
#import "KBChatMessageActionPopView.h"
#import <Masonry/Masonry.h>
static CGFloat const kKBChatActionRowHeight = 52.0;
@interface KBChatMessageActionPopView ()
@property (nonatomic, strong) UIControl *copyRow;
@property (nonatomic, strong) UIControl *deleteRow;
@property (nonatomic, strong) UIControl *reportRow;
@property (nonatomic, strong) UIView *line1;
@property (nonatomic, strong) UIView *line2;
@end
@implementation KBChatMessageActionPopView
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self setupUI];
}
return self;
}
#pragma mark - UI
- (void)setupUI {
self.backgroundColor = [UIColor colorWithWhite:0.1 alpha:0.92];
self.layer.cornerRadius = 16.0;
self.layer.masksToBounds = YES;
[self addSubview:self.copyRow];
[self addSubview:self.line1];
[self addSubview:self.deleteRow];
[self addSubview:self.line2];
[self addSubview:self.reportRow];
[self.copyRow mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.left.right.equalTo(self);
make.height.mas_equalTo(kKBChatActionRowHeight);
}];
[self.line1 mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.copyRow.mas_bottom);
make.left.right.equalTo(self);
make.height.mas_equalTo(0.5);
}];
[self.deleteRow mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.line1.mas_bottom);
make.left.right.equalTo(self);
make.height.mas_equalTo(kKBChatActionRowHeight);
}];
[self.line2 mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.deleteRow.mas_bottom);
make.left.right.equalTo(self);
make.height.mas_equalTo(0.5);
}];
[self.reportRow mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.line2.mas_bottom);
make.left.right.bottom.equalTo(self);
make.height.mas_equalTo(kKBChatActionRowHeight);
}];
}
- (UIControl *)buildRowWithTitle:(NSString *)title
iconName:(NSString *)iconName
action:(KBChatMessageActionType)action {
UIControl *row = [[UIControl alloc] init];
row.tag = action;
[row addTarget:self action:@selector(actionRowTapped:) forControlEvents:UIControlEventTouchUpInside];
UILabel *label = [[UILabel alloc] init];
label.text = title;
label.textColor = [UIColor whiteColor];
label.font = [UIFont systemFontOfSize:18 weight:UIFontWeightRegular];
UIImageView *iconView = [[UIImageView alloc] init];
UIImage *icon = [UIImage systemImageNamed:iconName];
iconView.image = icon;
iconView.tintColor = [UIColor whiteColor];
iconView.contentMode = UIViewContentModeScaleAspectFit;
[row addSubview:label];
[row addSubview:iconView];
[label mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(row).offset(16);
make.centerY.equalTo(row);
}];
[iconView mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(row).offset(-16);
make.centerY.equalTo(row);
make.width.height.mas_equalTo(18);
}];
return row;
}
#pragma mark - Actions
- (void)actionRowTapped:(UIControl *)sender {
if ([self.delegate respondsToSelector:@selector(chatMessageActionPopView:didSelectAction:)]) {
[self.delegate chatMessageActionPopView:self didSelectAction:(KBChatMessageActionType)sender.tag];
}
}
#pragma mark - Lazy
- (UIControl *)copyRow {
if (!_copyRow) {
_copyRow = [self buildRowWithTitle:KBLocalized(@"Copy")
iconName:@"doc.on.doc"
action:KBChatMessageActionTypeCopy];
}
return _copyRow;
}
- (UIControl *)deleteRow {
if (!_deleteRow) {
_deleteRow = [self buildRowWithTitle:KBLocalized(@"Delete")
iconName:@"trash"
action:KBChatMessageActionTypeDelete];
}
return _deleteRow;
}
- (UIControl *)reportRow {
if (!_reportRow) {
_reportRow = [self buildRowWithTitle:KBLocalized(@"Report")
iconName:@"exclamationmark.circle"
action:KBChatMessageActionTypeReport];
}
return _reportRow;
}
- (UIView *)line1 {
if (!_line1) {
_line1 = [[UIView alloc] init];
_line1.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.12];
}
return _line1;
}
- (UIView *)line2 {
if (!_line2) {
_line2 = [[UIView alloc] init];
_line2.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.12];
}
return _line2;
}
@end

View File

@@ -17,6 +17,10 @@ NS_ASSUME_NONNULL_BEGIN
- (void)chatTableViewDidScroll:(KBChatTableView *)chatView
scrollView:(UIScrollView *)scrollView;
- (void)chatTableViewDidTriggerLoadMore:(KBChatTableView *)chatView;
/// 长按消息(用户/AI
- (void)chatTableView:(KBChatTableView *)chatView
didLongPressMessage:(KBAiChatMessage *)message
sourceRect:(CGRect)sourceRect;
@end
/// 聊天列表视图支持用户消息、AI 消息、时间戳、语音播放)

View File

@@ -42,6 +42,7 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
@property (nonatomic, strong) UILabel *topStatusLabel;
@property (nonatomic, assign) BOOL isTopLoading;
@property (nonatomic, assign) BOOL isTopNoMore;
@property (nonatomic, strong) UILongPressGestureRecognizer *messageLongPressGesture;
@end
@@ -101,6 +102,13 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
make.edges.equalTo(self);
}];
//
self.messageLongPressGesture = [[UILongPressGestureRecognizer alloc] initWithTarget:self
action:@selector(handleMessageLongPress:)];
self.messageLongPressGesture.minimumPressDuration = 0.4;
self.messageLongPressGesture.cancelsTouchesInView = YES;
[self.tableView addGestureRecognizer:self.messageLongPressGesture];
// contentInset
self.contentBottomInset = 0;
[self updateContentBottomInset:self.contentBottomInset];
@@ -136,6 +144,32 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
*/
}
#pragma mark - Long Press
- (void)handleMessageLongPress:(UILongPressGestureRecognizer *)gesture {
if (gesture.state != UIGestureRecognizerStateBegan) {
return;
}
CGPoint point = [gesture locationInView:self.tableView];
NSIndexPath *indexPath = [self.tableView indexPathForRowAtPoint:point];
if (!indexPath || indexPath.row >= self.messages.count) {
return;
}
KBAiChatMessage *message = self.messages[indexPath.row];
if (!message || message.isLoading || message.type == KBAiChatMessageTypeTime) {
return;
}
UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
CGRect cellRect = cell ? [cell convertRect:cell.bounds toView:nil] : CGRectZero;
if ([self.delegate respondsToSelector:@selector(chatTableView:didLongPressMessage:sourceRect:)]) {
[self.delegate chatTableView:self didLongPressMessage:message sourceRect:cellRect];
}
}
#pragma mark - Public Methods
- (void)setInverted:(BOOL)inverted {

View File

@@ -12,6 +12,9 @@
#import "KBImagePositionButton.h"
#import "KBAICommentView.h"
#import "KBAIChatMessageCacheManager.h"
#import "KBChatMessageActionPopView.h"
#import "AIReportVC.h"
#import "KBHUD.h"
#import <Masonry/Masonry.h>
#import <SDWebImage/SDWebImage.h>
#import <LSTPopView/LSTPopView.h>
@@ -19,7 +22,7 @@
///
static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidResetNotification";
@interface KBPersonaChatCell () <KBChatTableViewDelegate>
@interface KBPersonaChatCell () <KBChatTableViewDelegate, KBChatMessageActionPopViewDelegate>
///
@property (nonatomic, strong) UIImageView *backgroundImageView;
@@ -65,6 +68,9 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
@property (nonatomic, assign) BOOL shouldAutoPlayPrologueAudio;
@property (nonatomic, assign) BOOL hasPlayedPrologueAudio;
@property (nonatomic, assign) BOOL shouldShowOpeningMessage;
@property (nonatomic, weak) LSTPopView *messageActionPopView;
@property (nonatomic, strong) KBAiChatMessage *selectedActionMessage;
@property (nonatomic, strong) UIControl *messageActionMaskView;
@end
@@ -898,6 +904,126 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
[self loadMoreHistory];
}
- (void)chatTableView:(KBChatTableView *)chatView
didLongPressMessage:(KBAiChatMessage *)message
sourceRect:(CGRect)sourceRect {
[self showMessageActionPopForMessage:message sourceRect:sourceRect];
}
#pragma mark - KBChatMessageActionPopViewDelegate
- (void)chatMessageActionPopView:(KBChatMessageActionPopView *)view
didSelectAction:(KBChatMessageActionType)action {
[self dismissMessageActionPop];
KBAiChatMessage *message = self.selectedActionMessage;
self.selectedActionMessage = nil;
if (!message) {
return;
}
switch (action) {
case KBChatMessageActionTypeCopy: {
if (message.text.length > 0) {
[UIPasteboard generalPasteboard].string = message.text;
[KBHUD showSuccess:KBLocalized(@"复制成功")];
}
} break;
case KBChatMessageActionTypeDelete: {
NSInteger idx = [self.messages indexOfObjectIdenticalTo:message];
if (idx != NSNotFound) {
[self.messages removeObjectAtIndex:idx];
[self.chatView reloadWithMessages:self.messages
keepOffset:YES
scrollToBottom:NO];
if (self.persona.personaId > 0) {
if (self.messages.count > 0) {
[[KBAIChatMessageCacheManager shared] saveMessages:self.messages
forCompanionId:self.persona.personaId];
} else {
[[KBAIChatMessageCacheManager shared] clearMessagesForCompanionId:self.persona.personaId];
}
}
}
} break;
case KBChatMessageActionTypeReport: {
if (self.persona.personaId <= 0) {
return;
}
AIReportVC *vc = [[AIReportVC alloc] init];
vc.personaId = self.persona.personaId;
[KB_CURRENT_NAV pushViewController:vc animated:YES];
} break;
default:
break;
}
}
#pragma mark - Message Action Pop
- (void)showMessageActionPopForMessage:(KBAiChatMessage *)message
sourceRect:(CGRect)sourceRect {
if (!message) {
return;
}
[self dismissMessageActionPop];
self.selectedActionMessage = message;
CGFloat width = 240;
CGFloat height = 156;
KBChatMessageActionPopView *content = [[KBChatMessageActionPopView alloc]
initWithFrame:CGRectMake(0, 0, width, height)];
content.delegate = self;
UIWindow *window = [UIApplication sharedApplication].keyWindow;
if (!window) {
window = [UIApplication sharedApplication].windows.firstObject;
}
if (!window) {
return;
}
UIControl *mask = [[UIControl alloc] initWithFrame:window.bounds];
[mask addTarget:self action:@selector(dismissMessageActionPop) forControlEvents:UIControlEventTouchUpInside];
[window addSubview:mask];
self.messageActionMaskView = mask;
BOOL isUserMessage = (message.type == KBAiChatMessageTypeUser);
CGFloat margin = 12.0;
CGFloat spacing = 8.0;
CGFloat topSafe = 0.0;
CGFloat bottomSafe = 0.0;
if (@available(iOS 11.0, *)) {
topSafe = window.safeAreaInsets.top;
bottomSafe = window.safeAreaInsets.bottom;
}
CGFloat x = isUserMessage ? CGRectGetMaxX(sourceRect) - width : CGRectGetMinX(sourceRect);
x = MAX(margin, MIN(x, CGRectGetWidth(window.bounds) - width - margin));
CGFloat y = CGRectGetMinY(sourceRect) - height - spacing;
if (y < topSafe + margin) {
y = CGRectGetMaxY(sourceRect) + spacing;
}
if (y + height > CGRectGetHeight(window.bounds) - bottomSafe - margin) {
y = MAX(topSafe + margin, CGRectGetHeight(window.bounds) - bottomSafe - margin - height);
}
content.frame = CGRectMake(x, y, width, height);
[mask addSubview:content];
}
- (void)dismissMessageActionPop {
if (self.messageActionPopView) {
[self.messageActionPopView dismiss];
self.messageActionPopView = nil;
}
if (self.messageActionMaskView) {
[self.messageActionMaskView removeFromSuperview];
self.messageActionMaskView = nil;
}
}
#pragma mark - Lazy Load
- (UIImageView *)backgroundImageView {