Files
keyboard/keyBoard/Class/AiTalk/V/Comment/KBAICommentView.m
2026-03-09 17:34:08 +08:00

1150 lines
38 KiB
Objective-C

//
// KBAICommentView.m
// keyBoard
//
// Created by Mac on 2026/1/16.
//
#import "KBAICommentView.h"
#import "KBAICommentFooterView.h"
#import "KBAICommentHeaderView.h"
#import "KBAICommentInputView.h"
#import "KBAICommentModel.h"
#import "KBAIReplyCell.h"
#import "KBAIReplyModel.h"
#import "KBCommentModel.h"
#import "AiVM.h"
#import "KBUserSessionManager.h"
#import "KBUser.h"
#import <MJExtension/MJExtension.h>
#import <Masonry/Masonry.h>
#import <MJRefresh/MJRefresh.h>
static NSString *const kCommentHeaderIdentifier = @"CommentHeader";
static NSString *const kReplyCellIdentifier = @"ReplyCell";
static NSString *const kCommentFooterIdentifier = @"CommentFooter";
@interface KBAICommentView () <UITableViewDataSource, UITableViewDelegate>
@property(nonatomic, strong) UIVisualEffectView *blurBackgroundView;
@property(nonatomic, strong) UIView *headerView;
@property(nonatomic, strong) UILabel *titleLabel;
@property(nonatomic, strong) UIButton *closeButton;
@property(nonatomic, strong) BaseTableView *tableView;
@property(nonatomic, strong) KBAICommentInputView *inputView;
@property(nonatomic, strong) NSMutableArray<KBAICommentModel *> *comments;
/// Pagination params
@property(nonatomic, assign) NSInteger currentPage;
@property(nonatomic, assign) NSInteger pageSize;
@property(nonatomic, assign) BOOL isLoading;
@property(nonatomic, assign) BOOL hasMoreData;
/// Keyboard height
@property(nonatomic, assign) CGFloat keyboardHeight;
/// Bottom constraint for input view
@property(nonatomic, strong) MASConstraint *inputBottomConstraint;
/// Current reply target (top-level comment)
@property(nonatomic, weak) KBAICommentModel *replyToComment;
/// Current reply target (reply)
@property(nonatomic, weak) KBAIReplyModel *replyToReply;
/// AiVM instance
@property(nonatomic, strong) AiVM *aiVM;
@end
@implementation KBAICommentView
#pragma mark - Local Model Builders
- (NSString *)currentUserName {
KBUser *user = [KBUserSessionManager shared].currentUser;
if (user.nickName.length > 0) {
return user.nickName;
}
return KBLocalized(@"Me");
}
- (NSString *)currentUserId {
KBUser *user = [KBUserSessionManager shared].currentUser;
return user.userId ?: @"";
}
- (NSString *)currentUserAvatarUrl {
KBUser *user = [KBUserSessionManager shared].currentUser;
return user.avatarUrl ?: @"";
}
- (NSString *)generateTempIdString {
long long ms = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0);
// Use negative values to avoid colliding with server IDs
long long tmp = -ms;
return [NSString stringWithFormat:@"%lld", tmp];
}
- (KBAICommentModel *)buildLocalNewCommentWithText:(NSString *)text
serverItem:(KBCommentItem *_Nullable)serverItem
tableWidth:(CGFloat)tableWidth {
KBAICommentModel *comment = [[KBAICommentModel alloc] init];
NSString *cid = nil;
if (serverItem && serverItem.commentId > 0) {
cid = [NSString stringWithFormat:@"%ld", (long)serverItem.commentId];
} else {
cid = [self generateTempIdString];
}
comment.commentId = cid;
comment.userId = [self currentUserId];
comment.userName = [self currentUserName];
comment.avatarUrl = [self currentUserAvatarUrl];
comment.content = text ?: @"";
comment.likeCount = 0;
comment.liked = NO;
comment.createTime = [[NSDate date] timeIntervalSince1970];
comment.replies = @[];
comment.cachedHeaderHeight =
[comment calculateHeaderHeightWithMaxWidth:tableWidth];
return comment;
}
- (KBAIReplyModel *)buildLocalNewReplyWithText:(NSString *)text
serverItem:(KBCommentItem *_Nullable)serverItem
replyToUserName:(NSString *)replyToUserName
tableWidth:(CGFloat)tableWidth {
KBAIReplyModel *reply = [[KBAIReplyModel alloc] init];
NSString *rid = nil;
if (serverItem && serverItem.commentId > 0) {
rid = [NSString stringWithFormat:@"%ld", (long)serverItem.commentId];
} else {
rid = [self generateTempIdString];
}
reply.replyId = rid;
reply.userId = [self currentUserId];
reply.userName = [self currentUserName];
reply.avatarUrl = [self currentUserAvatarUrl];
reply.content = text ?: @"";
reply.replyToUserName = replyToUserName ?: @"";
reply.likeCount = 0;
reply.liked = NO;
reply.createTime = [[NSDate date] timeIntervalSince1970];
reply.cachedCellHeight = [reply calculateCellHeightWithMaxWidth:tableWidth];
return reply;
}
#pragma mark - Lifecycle
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
self.comments = [NSMutableArray array];
self.currentPage = 1;
self.pageSize = 20;
self.hasMoreData = YES;
[self setupUI];
[self setupKeyboardObservers];
}
return self;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - UI Setup
- (void)setupUI {
// Make background transparent so blur effect is visible
self.backgroundColor = [UIColor clearColor];
self.layer.cornerRadius = 12;
self.layer.maskedCorners = kCALayerMinXMinYCorner | kCALayerMaxXMinYCorner;
self.clipsToBounds = YES;
// Add blur background (bottom-most layer)
[self addSubview:self.blurBackgroundView];
[self.blurBackgroundView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self);
}];
[self addSubview:self.headerView];
[self.headerView addSubview:self.titleLabel];
[self.headerView addSubview:self.closeButton];
[self addSubview:self.tableView];
[self addSubview:self.inputView];
[self.headerView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.left.right.equalTo(self);
make.height.mas_equalTo(50);
}];
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.headerView);
}];
[self.closeButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.headerView).offset(-16);
make.centerY.equalTo(self.headerView);
make.width.height.mas_equalTo(25);
}];
[self.inputView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self).inset(12);
make.height.mas_equalTo(52);
self.inputBottomConstraint =
make.bottom.equalTo(self).offset(-KB_SafeAreaBottom());
}];
[self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.headerView.mas_bottom);
make.left.right.equalTo(self);
make.bottom.equalTo(self.inputView.mas_top);
}];
// Load more on pull-up
__weak typeof(self) weakSelf = self;
MJRefreshAutoNormalFooter *footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
[strongSelf loadMoreComments];
}];
footer.stateLabel.hidden = YES;
footer.backgroundColor = [UIColor clearColor];
footer.automaticallyHidden = YES;
self.tableView.mj_footer = footer;
}
#pragma mark - Keyboard Observers
- (void)setupKeyboardObservers {
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(keyboardWillShow:)
name:UIKeyboardWillShowNotification
object:nil];
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(keyboardWillHide:)
name:UIKeyboardWillHideNotification
object:nil];
}
- (void)keyboardWillShow:(NSNotification *)notification {
CGRect keyboardFrame =
[notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
NSTimeInterval duration =
[notification.userInfo[UIKeyboardAnimationDurationUserInfoKey]
doubleValue];
self.keyboardHeight = keyboardFrame.size.height;
[self.inputBottomConstraint uninstall];
[self.inputView mas_updateConstraints:^(MASConstraintMaker *make) {
self.inputBottomConstraint =
make.bottom.equalTo(self).offset(-self.keyboardHeight);
}];
[UIView animateWithDuration:duration
animations:^{
[self layoutIfNeeded];
}];
}
- (void)keyboardWillHide:(NSNotification *)notification {
NSTimeInterval duration =
[notification.userInfo[UIKeyboardAnimationDurationUserInfoKey]
doubleValue];
self.keyboardHeight = 0;
[self.inputBottomConstraint uninstall];
[self.inputView mas_updateConstraints:^(MASConstraintMaker *make) {
self.inputBottomConstraint =
make.bottom.equalTo(self).offset(-KB_SafeAreaBottom());
}];
[UIView animateWithDuration:duration
animations:^{
[self layoutIfNeeded];
}];
}
#pragma mark - Data Loading
- (void)loadComments {
if (self.isLoading) {
return;
}
self.currentPage = 1;
self.hasMoreData = YES;
[self.tableView.mj_footer resetNoMoreData];
[self fetchCommentsAtPage:self.currentPage append:NO];
}
- (void)loadMoreComments {
if (self.isLoading) {
[self.tableView.mj_footer endRefreshing];
return;
}
if (!self.hasMoreData) {
[self.tableView.mj_footer endRefreshingWithNoMoreData];
return;
}
NSInteger nextPage = self.currentPage + 1;
[self fetchCommentsAtPage:nextPage append:YES];
}
- (void)fetchCommentsAtPage:(NSInteger)page append:(BOOL)append {
if (self.companionId <= 0) {
NSLog(@"[KBAICommentView] companionId is not set, cannot load comments");
[self showEmptyState];
[self.tableView.mj_footer endRefreshing];
return;
}
self.isLoading = YES;
__weak typeof(self) weakSelf = self;
[self.aiVM fetchCommentsWithCompanionId:self.companionId
pageNum:page
pageSize:self.pageSize
completion:^(KBCommentPageModel *pageModel, NSError *error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
strongSelf.isLoading = NO;
if (error) {
NSLog(@"[KBAICommentView] Failed to load comments: %@", error.localizedDescription);
dispatch_async(dispatch_get_main_queue(), ^{
if (append) {
[strongSelf.tableView.mj_footer endRefreshing];
} else {
[strongSelf showEmptyStateWithError];
}
});
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
[strongSelf updateCommentsWithPageModel:pageModel append:append];
});
}];
}
/// Update comments (convert backend KBCommentPageModel to UI KBAICommentModel)
- (void)updateCommentsWithPageModel:(KBCommentPageModel *)pageModel append:(BOOL)append {
if (!pageModel) {
NSLog(@"[KBAICommentView] pageModel is nil");
// Data is empty, show empty state
[self showEmptyState];
[self.tableView.mj_footer endRefreshing];
return;
}
// self.totalCommentCount = pageModel.total;
if (!append) {
[self.comments removeAllObjects];
}
// Get tableView width for height calculation
CGFloat tableWidth = self.tableView.bounds.size.width;
if (tableWidth <= 0) {
tableWidth = [UIScreen mainScreen].bounds.size.width;
}
NSLog(@"[KBAICommentView] Loaded %ld comments, total %ld, page: %ld/%ld", (long)pageModel.records.count, (long)pageModel.total, (long)pageModel.current, (long)pageModel.pages);
for (KBCommentItem *item in pageModel.records) {
// Convert to KBAICommentModel (via MJExtension)
// Note: KBCommentItem maps backend field id to commentId via MJExtension.
// If we directly use mj_keyValues, the dictionary only has commentId and
// KBAICommentModel/KBAIReplyModel mapping (commentId/replyId -> id) misses it.
// That would make commentId/replyId empty and break parentId/rootId when replying.
NSMutableDictionary *itemKV = [[item mj_keyValues] mutableCopy];
id commentIdVal = itemKV[@"commentId"];
if (commentIdVal) {
itemKV[@"id"] = commentIdVal;
[itemKV removeObjectForKey:@"commentId"];
}
id repliesObj = itemKV[@"replies"];
if ([repliesObj isKindOfClass:[NSArray class]]) {
NSArray *replies = (NSArray *)repliesObj;
NSMutableArray *fixedReplies = [NSMutableArray arrayWithCapacity:replies.count];
for (id obj in replies) {
if (![obj isKindOfClass:[NSDictionary class]]) {
continue;
}
NSMutableDictionary *replyKV = [((NSDictionary *)obj) mutableCopy];
id replyCommentIdVal = replyKV[@"commentId"];
if (replyCommentIdVal) {
replyKV[@"id"] = replyCommentIdVal;
[replyKV removeObjectForKey:@"commentId"];
}
[fixedReplies addObject:[replyKV copy]];
}
itemKV[@"replies"] = [fixedReplies copy];
}
KBAICommentModel *comment = [KBAICommentModel mj_objectWithKeyValues:[itemKV copy]];
// Precompute and cache header height
comment.cachedHeaderHeight = [comment calculateHeaderHeightWithMaxWidth:tableWidth];
// Precompute and cache all reply heights
for (KBAIReplyModel *reply in comment.replies) {
reply.cachedCellHeight = [reply calculateCellHeightWithMaxWidth:tableWidth];
}
[self.comments addObject:comment];
}
[self updateTitle];
[self.tableView reloadData];
// Update pagination state
self.currentPage = pageModel.current > 0 ? pageModel.current : self.currentPage;
if (pageModel.pages > 0) {
self.hasMoreData = pageModel.current < pageModel.pages;
} else {
self.hasMoreData = pageModel.records.count >= self.pageSize;
}
if (self.hasMoreData) {
[self.tableView.mj_footer endRefreshing];
} else {
[self.tableView.mj_footer endRefreshingWithNoMoreData];
}
// Toggle empty state based on data
if (self.comments.count == 0) {
[self showEmptyState];
} else {
[self hideEmptyState];
}
}
/// Show empty state view
- (void)showEmptyState {
self.tableView.useEmptyDataSet = YES;
self.tableView.emptyTitleText = KBLocalized(@"No comments yet");
self.tableView.emptyDescriptionText = KBLocalized(@"Be the first to comment");
self.tableView.emptyImage = nil; // Optional: set empty-state image
self.tableView.emptyVerticalOffset = -50; // Slight upward offset
[self.tableView kb_reloadEmptyDataSet];
}
/// Show error empty state view
- (void)showEmptyStateWithError {
self.tableView.useEmptyDataSet = YES;
self.tableView.emptyTitleText = KBLocalized(@"Load failed");
self.tableView.emptyDescriptionText = KBLocalized(@"Tap to retry");
self.tableView.emptyImage = nil;
self.tableView.emptyVerticalOffset = -50;
// Tap to reload
__weak typeof(self) weakSelf = self;
self.tableView.emptyDidTapView = ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (strongSelf) {
[strongSelf loadComments];
}
};
[self.tableView kb_reloadEmptyDataSet];
}
/// Hide empty state view
- (void)hideEmptyState {
self.tableView.useEmptyDataSet = NO;
[self.tableView kb_reloadEmptyDataSet];
}
- (void)updateTitle {
NSString *countText;
if (self.totalCommentCount >= 10000) {
countText = [NSString
stringWithFormat:KBLocalized(@"%.1fw comments"), self.totalCommentCount / 10000.0];
} else {
countText =
[NSString stringWithFormat:KBLocalized(@"%ld comments"), (long)self.totalCommentCount];
}
self.titleLabel.text = countText;
}
#pragma mark - UITableViewDataSource
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return self.comments.count;
}
- (NSInteger)tableView:(UITableView *)tableView
numberOfRowsInSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
return comment.displayedReplies.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath {
KBAIReplyCell *cell =
[tableView dequeueReusableCellWithIdentifier:kReplyCellIdentifier
forIndexPath:indexPath];
KBAICommentModel *comment = self.comments[indexPath.section];
KBAIReplyModel *reply = comment.displayedReplies[indexPath.row];
[cell configureWithReply:reply];
__weak typeof(self) weakSelf = self;
cell.onLikeAction = ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
// Get comment ID (convert to NSInteger)
NSInteger commentId = [reply.replyId integerValue];
// Call like API
[strongSelf.aiVM likeCommentWithCommentId:commentId completion:^(KBCommentLikeResponse * _Nullable response, NSError * _Nullable error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (error) {
NSLog(@"[KBAICommentView] Failed to like reply: %@", error.localizedDescription);
// TODO: Show error message
return;
}
if (response && response.code == 0) {
// data = true: liked, data = false: unliked
BOOL isNowLiked = response.data;
// Update model state
if (isNowLiked) {
// Like succeeded: like count +1
reply.liked = YES;
reply.likeCount = MAX(0, reply.likeCount + 1);
NSLog(@"[KBAICommentView] Reply liked successfully, ID: %ld", (long)commentId);
} else {
// Unlike succeeded: like count -1
reply.liked = NO;
reply.likeCount = MAX(0, reply.likeCount - 1);
NSLog(@"[KBAICommentView] Reply unliked successfully, ID: %ld", (long)commentId);
}
// Refresh target row
[strongSelf.tableView reloadRowsAtIndexPaths:@[ indexPath ]
withRowAnimation:UITableViewRowAnimationNone];
} else {
NSLog(@"[KBAICommentView] Failed to like reply: %@", response.message ?: @"Unknown error");
// TODO: Show error message
}
});
}];
};
cell.onReplyAction = ^{
[weakSelf setReplyToComment:comment reply:reply];
};
return cell;
}
#pragma mark - UITableViewDelegate
- (UIView *)tableView:(UITableView *)tableView
viewForHeaderInSection:(NSInteger)section {
KBAICommentHeaderView *header = [tableView
dequeueReusableHeaderFooterViewWithIdentifier:kCommentHeaderIdentifier];
KBAICommentModel *comment = self.comments[section];
[header configureWithComment:comment];
__weak typeof(self) weakSelf = self;
header.onLikeAction = ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
// Get comment ID (convert to NSInteger)
NSInteger commentId = [comment.commentId integerValue];
// Call like API
[strongSelf.aiVM likeCommentWithCommentId:commentId completion:^(KBCommentLikeResponse * _Nullable response, NSError * _Nullable error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (error) {
NSLog(@"[KBAICommentView] Failed to like top-level comment: %@", error.localizedDescription);
// TODO: Show error message
return;
}
if (response && response.code == 0) {
// data = true: liked, data = false: unliked
BOOL isNowLiked = response.data;
// Update model state
if (isNowLiked) {
// Like succeeded: like count +1
comment.liked = YES;
comment.likeCount = MAX(0, comment.likeCount + 1);
NSLog(@"[KBAICommentView] Top-level comment liked successfully, ID: %ld", (long)commentId);
} else {
// Unlike succeeded: like count -1
comment.liked = NO;
comment.likeCount = MAX(0, comment.likeCount - 1);
NSLog(@"[KBAICommentView] Top-level comment unliked successfully, ID: %ld", (long)commentId);
}
// Refresh target section
[strongSelf.tableView reloadSections:[NSIndexSet indexSetWithIndex:section]
withRowAnimation:UITableViewRowAnimationNone];
} else {
NSLog(@"[KBAICommentView] Failed to like top-level comment: %@", response.message ?: @"Unknown error");
// TODO: Show error message
}
});
}];
};
header.onReplyAction = ^{
[weakSelf setReplyToComment:comment reply:nil];
};
return header;
}
- (UIView *)tableView:(UITableView *)tableView
viewForFooterInSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
KBAIReplyFooterState state = [comment footerState];
// Return empty view when there are no replies
if (state == KBAIReplyFooterStateHidden) {
return nil;
}
KBAICommentFooterView *footer = [tableView
dequeueReusableHeaderFooterViewWithIdentifier:kCommentFooterIdentifier];
[footer configureWithComment:comment];
__weak typeof(self) weakSelf = self;
footer.onAction = ^{
[weakSelf handleFooterActionForSection:section];
};
return footer;
}
- (CGFloat)tableView:(UITableView *)tableView
heightForHeaderInSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
return comment.cachedHeaderHeight;
}
- (CGFloat)tableView:(UITableView *)tableView
heightForRowAtIndexPath:(NSIndexPath *)indexPath {
KBAICommentModel *comment = self.comments[indexPath.section];
KBAIReplyModel *reply = comment.displayedReplies[indexPath.row];
return reply.cachedCellHeight;
}
- (CGFloat)tableView:(UITableView *)tableView
heightForFooterInSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
KBAIReplyFooterState state = [comment footerState];
if (state == KBAIReplyFooterStateHidden) {
return CGFLOAT_MIN;
}
return 30;
}
#pragma mark - Footer Actions
/// Number of replies loaded each time
static NSInteger const kRepliesLoadCount = 5;
- (void)handleFooterActionForSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
KBAIReplyFooterState state = [comment footerState];
switch (state) {
case KBAIReplyFooterStateExpand:
case KBAIReplyFooterStateLoadMore: {
[self loadMoreRepliesForSection:section];
break;
}
case KBAIReplyFooterStateCollapse: {
[self collapseRepliesForSection:section];
break;
}
default:
break;
}
}
- (void)loadMoreRepliesForSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
NSInteger currentCount = comment.displayedReplies.count;
// Load more replies
[comment loadMoreReplies:kRepliesLoadCount];
// Calculate newly inserted rows
NSInteger newCount = comment.displayedReplies.count;
NSMutableArray *insertIndexPaths = [NSMutableArray array];
for (NSInteger i = currentCount; i < newCount; i++) {
[insertIndexPaths addObject:[NSIndexPath indexPathForRow:i
inSection:section]];
}
// Insert rows (do not refresh header to avoid avatar flicker)
[self.tableView beginUpdates];
if (insertIndexPaths.count > 0) {
[self.tableView insertRowsAtIndexPaths:insertIndexPaths
withRowAnimation:UITableViewRowAnimationAutomatic];
}
[self.tableView endUpdates];
// Manually refresh footer
KBAICommentFooterView *footerView =
(KBAICommentFooterView *)[self.tableView footerViewForSection:section];
if (footerView) {
[footerView configureWithComment:comment];
}
}
- (void)collapseRepliesForSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
NSInteger rowCount = comment.displayedReplies.count;
// Calculate rows to delete
NSMutableArray *deleteIndexPaths = [NSMutableArray array];
for (NSInteger i = 0; i < rowCount; i++) {
[deleteIndexPaths addObject:[NSIndexPath indexPathForRow:i
inSection:section]];
}
// Collapse all replies
[comment collapseReplies];
// Delete rows (do not refresh header to avoid avatar flicker)
[self.tableView beginUpdates];
if (deleteIndexPaths.count > 0) {
[self.tableView deleteRowsAtIndexPaths:deleteIndexPaths
withRowAnimation:UITableViewRowAnimationAutomatic];
}
[self.tableView endUpdates];
// Manually refresh footer
KBAICommentFooterView *footerView =
(KBAICommentFooterView *)[self.tableView footerViewForSection:section];
if (footerView) {
[footerView configureWithComment:comment];
}
}
#pragma mark - Actions
- (void)closeButtonTapped {
[self.popView dismiss];
// Close comment view (handled by outside)
// [[NSNotificationCenter defaultCenter]
// postNotificationName:@"KBAICommentViewCloseNotification"
// object:nil];
}
#pragma mark - Lazy Loading
- (UIVisualEffectView *)blurBackgroundView {
if (!_blurBackgroundView) {
// Create blur effect (43pt blur radius in design)
// iOS UIBlurEffect has no direct blur-radius API; use system dark style
UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleDark];
_blurBackgroundView = [[UIVisualEffectView alloc] initWithEffect:blurEffect];
// Overlay a semi-transparent black layer to tune tone and opacity
// Color: #000000, alpha: 0.31
UIView *darkOverlay = [[UIView alloc] init];
darkOverlay.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.31];
[_blurBackgroundView.contentView addSubview:darkOverlay];
[darkOverlay mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(_blurBackgroundView);
}];
}
return _blurBackgroundView;
}
- (UIView *)headerView {
if (!_headerView) {
_headerView = [[UIView alloc] init];
_headerView.backgroundColor = [UIColor clearColor];
}
return _headerView;
}
- (UILabel *)titleLabel {
if (!_titleLabel) {
_titleLabel = [[UILabel alloc] init];
_titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightMedium];
_titleLabel.textColor = [UIColor whiteColor];
_titleLabel.text = [NSString stringWithFormat:KBLocalized(@"%ld comments"), (long)0];
}
return _titleLabel;
}
- (UIButton *)closeButton {
if (!_closeButton) {
_closeButton = [UIButton buttonWithType:UIButtonTypeCustom];
[_closeButton setImage:[UIImage imageNamed:@"comment_close_icon"]
forState:UIControlStateNormal];
[_closeButton addTarget:self
action:@selector(closeButtonTapped)
forControlEvents:UIControlEventTouchUpInside];
}
return _closeButton;
}
- (BaseTableView *)tableView {
if (!_tableView) {
_tableView = [[BaseTableView alloc] initWithFrame:CGRectZero
style:UITableViewStyleGrouped];
_tableView.dataSource = self;
_tableView.delegate = self;
_tableView.backgroundColor = [UIColor clearColor];
_tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
_tableView.keyboardDismissMode = UIScrollViewKeyboardDismissModeOnDrag;
_tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 0.01)];
// Disable empty placeholder by default to avoid showing "No data" while loading
_tableView.useEmptyDataSet = NO;
// Register Header/Cell/Footer
[_tableView registerClass:[KBAICommentHeaderView class]
forHeaderFooterViewReuseIdentifier:kCommentHeaderIdentifier];
[_tableView registerClass:[KBAIReplyCell class]
forCellReuseIdentifier:kReplyCellIdentifier];
[_tableView registerClass:[KBAICommentFooterView class]
forHeaderFooterViewReuseIdentifier:kCommentFooterIdentifier];
// Remove top padding
if (@available(iOS 15.0, *)) {
_tableView.sectionHeaderTopPadding = 0;
}
}
return _tableView;
}
- (KBAICommentInputView *)inputView {
if (!_inputView) {
_inputView = [[KBAICommentInputView alloc] init];
_inputView.placeholder = KBLocalized(@"Send A Message");
_inputView.layer.cornerRadius = 26;
_inputView.clipsToBounds = true;
__weak typeof(self) weakSelf = self;
_inputView.onSend = ^(NSString *text) {
[weakSelf sendCommentWithText:text];
};
}
return _inputView;
}
#pragma mark - Reply
- (void)setReplyToComment:(KBAICommentModel *)comment
reply:(KBAIReplyModel *)reply {
self.replyToComment = comment;
self.replyToReply = reply;
if (reply) {
// Reply to a second-level comment
self.inputView.placeholder =
[NSString stringWithFormat:KBLocalized(@"Reply to @%@"), reply.userName];
} else if (comment) {
// Reply to a top-level comment
self.inputView.placeholder =
[NSString stringWithFormat:KBLocalized(@"Reply to @%@"), comment.userName];
} else {
// New comment
self.inputView.placeholder = KBLocalized(@"Say something...");
}
// Show keyboard
[self.inputView showKeyboard];
}
- (void)clearReplyTarget {
self.replyToComment = nil;
self.replyToReply = nil;
self.inputView.placeholder = KBLocalized(@"Say something...");
}
#pragma mark - Send Comment
- (void)sendCommentWithText:(NSString *)text {
if (text.length == 0)
return;
CGFloat tableWidth = self.tableView.bounds.size.width;
if (tableWidth <= 0) {
tableWidth = [UIScreen mainScreen].bounds.size.width;
}
if (self.replyToComment) {
// Send reply (add second-level comment)
[self sendReplyWithText:text tableWidth:tableWidth];
} else {
// Send top-level comment
[self sendNewCommentWithText:text tableWidth:tableWidth];
}
// Clear input and reply target
[self.inputView clearText];
[self clearReplyTarget];
}
- (void)sendNewCommentWithText:(NSString *)text tableWidth:(CGFloat)tableWidth {
NSLog(@"[KBAICommentView] Send top-level comment: %@", text);
__weak typeof(self) weakSelf = self;
[self.aiVM addCommentWithCompanionId:self.companionId
content:text
parentId:nil
rootId:nil
completion:^(KBCommentItem * _Nullable newItem, NSInteger code, NSError * _Nullable error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
if (error || code != 0) {
NSLog(@"[KBAICommentView] Failed to send top-level comment: %@", error.localizedDescription ?: @"");
return;
}
// Insert new comment locally at first position; avoid full reload
KBAICommentModel *localComment =
[strongSelf buildLocalNewCommentWithText:text
serverItem:newItem
tableWidth:tableWidth];
[strongSelf.comments insertObject:localComment atIndex:0];
strongSelf.totalCommentCount += 1;
[strongSelf updateTitle];
[strongSelf hideEmptyState];
if ([strongSelf.delegate respondsToSelector:@selector(commentView:didUpdateTotalCommentCount:)]) {
[strongSelf.delegate commentView:strongSelf didUpdateTotalCommentCount:strongSelf.totalCommentCount];
}
[strongSelf.tableView beginUpdates];
[strongSelf.tableView
insertSections:[NSIndexSet indexSetWithIndex:0]
withRowAnimation:UITableViewRowAnimationAutomatic];
[strongSelf.tableView endUpdates];
[strongSelf.tableView setContentOffset:CGPointZero animated:YES];
});
}];
// Example code:
// [self.aiVM sendCommentWithCompanionId:self.companionId
// content:text
// completion:^(KBCommentItem *newItem, NSError *error) {
// if (error) {
// NSLog(@"[KBAICommentView] Failed to send comment: %@", error.localizedDescription);
// return;
// }
//
// // Convert to KBAICommentModel
// KBAICommentModel *comment = [KBAICommentModel mj_objectWithKeyValues:[newItem mj_keyValues]];
// comment.cachedHeaderHeight = [comment calculateHeaderHeightWithMaxWidth:tableWidth];
//
// // Insert into array at index 0
// [self.comments insertObject:comment atIndex:0];
// self.totalCommentCount++;
// [self updateTitle];
//
// // Insert new section
// [self.tableView insertSections:[NSIndexSet indexSetWithIndex:0]
// withRowAnimation:UITableViewRowAnimationAutomatic];
//
// // Scroll to top
// [self.tableView setContentOffset:CGPointZero animated:YES];
// }];
}
- (void)sendReplyWithText:(NSString *)text tableWidth:(CGFloat)tableWidth {
KBAICommentModel *comment = self.replyToComment;
if (!comment)
return;
NSLog(@"[KBAICommentView] Reply to comment %@: %@", comment.commentId, text);
NSInteger root = [comment.commentId integerValue];
NSNumber *rootId = @(root);
NSNumber *parentId = nil;
if (self.replyToReply && self.replyToReply.replyId.length > 0) {
parentId = @([self.replyToReply.replyId integerValue]);
} else {
parentId = @(root);
}
__weak typeof(self) weakSelf = self;
[self.aiVM addCommentWithCompanionId:self.companionId
content:text
parentId:parentId
rootId:rootId
completion:^(KBCommentItem * _Nullable newItem, NSInteger code, NSError * _Nullable error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
if (error || code != 0) {
NSLog(@"[KBAICommentView] Failed to send reply: %@", error.localizedDescription ?: @"");
return;
}
NSInteger section = [strongSelf.comments indexOfObject:comment];
if (section == NSNotFound) {
return;
}
NSInteger oldTotalReplyCount = comment.totalReplyCount;
BOOL wasFooterHidden = (oldTotalReplyCount == 0);
BOOL wasFullyExpanded =
(comment.isRepliesExpanded &&
comment.displayedReplies.count == oldTotalReplyCount);
NSString *replyToUserName = @"";
if (strongSelf.replyToReply && strongSelf.replyToReply.userName.length > 0) {
replyToUserName = strongSelf.replyToReply.userName;
} else if (comment.userName.length > 0) {
replyToUserName = comment.userName;
}
KBAIReplyModel *localReply =
[strongSelf buildLocalNewReplyWithText:text
serverItem:newItem
replyToUserName:replyToUserName
tableWidth:tableWidth];
NSArray<KBAIReplyModel *> *oldReplies = comment.replies ?: @[];
NSMutableArray<KBAIReplyModel *> *newReplies =
[NSMutableArray arrayWithArray:oldReplies];
[newReplies addObject:localReply];
comment.replies = [newReplies copy];
strongSelf.totalCommentCount += 1;
[strongSelf updateTitle];
if ([strongSelf.delegate respondsToSelector:@selector(commentView:didUpdateTotalCommentCount:)]) {
[strongSelf.delegate commentView:strongSelf didUpdateTotalCommentCount:strongSelf.totalCommentCount];
}
// If fully expanded, insert new row directly; otherwise keep displayedReplies as prefix to preserve loadMoreReplies behavior
if (wasFullyExpanded) {
[comment.displayedReplies addObject:localReply];
NSInteger newRowIndex = comment.displayedReplies.count - 1;
NSIndexPath *indexPath =
[NSIndexPath indexPathForRow:newRowIndex inSection:section];
[strongSelf.tableView beginUpdates];
[strongSelf.tableView insertRowsAtIndexPaths:@[ indexPath ]
withRowAnimation:UITableViewRowAnimationAutomatic];
[strongSelf.tableView endUpdates];
KBAICommentFooterView *footerView =
(KBAICommentFooterView *)[strongSelf.tableView footerViewForSection:section];
if (footerView) {
[footerView configureWithComment:comment];
}
} else {
if (wasFooterHidden) {
[strongSelf.tableView reloadSections:[NSIndexSet indexSetWithIndex:section]
withRowAnimation:UITableViewRowAnimationNone];
} else {
KBAICommentFooterView *footerView =
(KBAICommentFooterView *)[strongSelf.tableView footerViewForSection:section];
if (footerView) {
[footerView configureWithComment:comment];
} else {
[strongSelf.tableView reloadSections:[NSIndexSet indexSetWithIndex:section]
withRowAnimation:UITableViewRowAnimationNone];
}
}
}
});
}];
// Example code:
// NSInteger parentId = [comment.commentId integerValue];
// [self.aiVM replyCommentWithParentId:parentId
// content:text
// completion:^(KBCommentItem *newItem, NSError *error) {
// if (error) {
// NSLog(@"[KBAICommentView] Failed to reply comment: %@", error.localizedDescription);
// return;
// }
//
// // Convert to KBAIReplyModel
// KBAIReplyModel *newReply = [KBAIReplyModel mj_objectWithKeyValues:[newItem mj_keyValues]];
// newReply.cachedCellHeight = [newReply calculateCellHeightWithMaxWidth:tableWidth];
//
// // Append to replies array
// NSMutableArray *newReplies = [NSMutableArray arrayWithArray:comment.replies];
// [newReplies addObject:newReply];
// comment.replies = newReplies;
// comment.totalReplyCount = newReplies.count;
//
// // Find section for this comment
// NSInteger section = [self.comments indexOfObject:comment];
// if (section == NSNotFound) return;
//
// // If expanded, append to displayedReplies and insert row
// if (comment.isRepliesExpanded) {
// NSInteger newRowIndex = comment.displayedReplies.count;
// [comment.displayedReplies addObject:newReply];
//
// NSIndexPath *indexPath = [NSIndexPath indexPathForRow:newRowIndex inSection:section];
// [self.tableView insertRowsAtIndexPaths:@[indexPath]
// withRowAnimation:UITableViewRowAnimationAutomatic];
//
// // Refresh footer
// KBAICommentFooterView *footerView = (KBAICommentFooterView *)[self.tableView footerViewForSection:section];
// if (footerView) {
// [footerView configureWithComment:comment];
// }
//
// // Scroll to new reply
// [self.tableView scrollToRowAtIndexPath:indexPath
// atScrollPosition:UITableViewScrollPositionBottom
// animated:YES];
// } else {
// // If not expanded, refresh footer to show updated reply count
// KBAICommentFooterView *footerView = (KBAICommentFooterView *)[self.tableView footerViewForSection:section];
// if (footerView) {
// [footerView configureWithComment:comment];
// }
// }
// }];
}
- (AiVM *)aiVM {
if (!_aiVM) {
_aiVM = [[AiVM alloc] init];
}
return _aiVM;
}
@end