// // 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 #import #import static NSString *const kCommentHeaderIdentifier = @"CommentHeader"; static NSString *const kReplyCellIdentifier = @"ReplyCell"; static NSString *const kCommentFooterIdentifier = @"CommentFooter"; @interface KBAICommentView () @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 *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 *oldReplies = comment.replies ?: @[]; NSMutableArray *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