diff --git a/FEATURES.md b/FEATURES.md
new file mode 100644
index 00000000..d1db4012
--- /dev/null
+++ b/FEATURES.md
@@ -0,0 +1,76 @@
+# V2er 回复排序和楼中楼功能说明
+
+## 功能概述
+
+本次更新为 V2er 添加了两个重要功能:
+
+### 1. 回复按热门程度排序 (Reply Sorting by Popularity)
+
+用户现在可以在主题页面切换回复的排序方式:
+- **按时间排序** (默认) - 按回复时间顺序显示
+- **按热门排序** - 按感谢次数降序排列,感谢次数相同时按楼层排序
+
+#### 使用方法:
+1. 在主题页面点击右上角菜单
+2. 选择"按时间排序"或"按热门排序"来切换模式
+3. 设置会自动保存,下次打开时保持选择的排序方式
+
+### 2. 楼中楼功能 (Threaded Replies)
+
+系统会自动检测回复中的 @用户名 提及,并以可视化方式显示回复的层级关系:
+
+#### 特性:
+- 自动解析 `@username` 和 `@username` 格式
+- 根据提及关系计算缩进级别(最多3级)
+- 有回复关系的评论会有:
+ - 左侧缩进显示层级关系
+ - 楼层号后显示 💬 图标
+ - 浅色背景区分
+
+## 技术实现
+
+### 核心类修改:
+
+1. **TopicInfo.java**
+ - 添加 `processMentionsAndThreading()` 方法处理提及解析
+ - 添加 `extractMentions()` 方法使用正则表达式提取用户名
+ - 扩展 `getItems()` 方法支持排序模式参数
+
+2. **TopicActivity.java**
+ - 添加回复排序菜单项处理
+ - 实现 `refreshReplySorting()` 方法即时切换排序
+ - 集成排序模式状态管理
+
+3. **TopicReplyItemDelegate.java**
+ - 添加 `applyThreadIndentation()` 方法应用缩进样式
+ - 集成楼层显示和线程指示器
+
+4. **Reply 类增强**
+ - 添加 `mentionedUsers` 列表存储提及的用户
+ - 添加 `indentLevel` 字段控制缩进层级
+ - 提供相关的 getter/setter 方法
+
+### 新增组件:
+
+- **ReplySortMode.java** - 排序模式枚举类
+- **threaded_reply_background.xml** - 楼中楼回复背景样式
+- **ic_sort.xml** - 排序功能图标
+
+## 用户体验改进
+
+1. **直观的可视化** - 通过缩进和背景色清晰显示回复层级
+2. **状态持久化** - 排序偏好自动保存
+3. **性能优化** - 本地排序,无需重新加载网络数据
+4. **无侵入性** - 保持原有界面布局,仅添加新功能
+
+## 示例场景
+
+```
+1楼: 原始问题
+2楼: 直接回复问题
+3楼: @2楼用户 我也有同样问题 💬 [缩进显示]
+4楼: @3楼用户 试试这个方法 💬 [进一步缩进]
+5楼: 新的话题
+```
+
+通过这种方式,用户可以更容易地跟踪对话线程和找到最受欢迎的回复。
\ No newline at end of file
diff --git a/UI_MOCKUP.md b/UI_MOCKUP.md
new file mode 100644
index 00000000..8a75f13a
--- /dev/null
+++ b/UI_MOCKUP.md
@@ -0,0 +1,77 @@
+# UI Changes Mockup
+
+## Before and After Comparison
+
+### 1. Menu Bar Changes
+```
+Before:
+[★] [分享] [逆序浏览] [⋮]
+
+After:
+[★] [分享] [逆序浏览] [≡] [⋮]
+ ↑
+ New sort icon
+```
+
+### 2. Reply List - Time Sorting (Default)
+```
+1楼 ghui ♥ 5
+ This is the main question...
+
+2楼 user1 ♥ 2
+ Here's my answer...
+
+3楼 user2 ♥ 8
+ @user1 I agree with your solution
+```
+
+### 3. Reply List - Popularity Sorting
+```
+3楼 user2 ♥ 8
+ @user1 I agree with your solution
+
+1楼 ghui ♥ 5
+ This is the main question...
+
+2楼 user1 ♥ 2
+ Here's my answer...
+```
+
+### 4. Threaded Replies with Indentation
+```
+1楼 ghui ♥ 5
+ Original question here...
+
+2楼 alice ♥ 3
+ First response...
+
+ 3楼 bob 💬 ♥ 1
+ @alice Good point!
+ [Subtle background, left margin]
+
+ 4楼 charlie 💬 ♥ 0
+ @bob Thanks for clarification
+ [More indented, different background]
+
+5楼 david ♥ 2
+ New topic discussion...
+```
+
+## Visual Indicators
+
+1. **Threading Indicator**: 💬 emoji after floor number
+2. **Indentation**: Progressive left margin for nested replies
+3. **Background**: Subtle gray background for threaded replies
+4. **Sorting**: Real-time toggle without page reload
+
+## User Flow
+
+1. User opens topic page → sees default time-based sorting
+2. User taps sort icon → switches to popularity sorting
+3. Replies reorder immediately showing most-thanked first
+4. Threading relationships shown with visual indentation
+5. User preference saved for future visits
+
+This provides both requested features:
+- **按热门程度显示** (Display by popularity) ✅
+- **楼中楼** (Threaded replies) ✅
\ No newline at end of file
diff --git a/app/src/main/java/me/ghui/v2er/general/Pref.java b/app/src/main/java/me/ghui/v2er/general/Pref.java
index db62ced8..0005ec87 100644
--- a/app/src/main/java/me/ghui/v2er/general/Pref.java
+++ b/app/src/main/java/me/ghui/v2er/general/Pref.java
@@ -30,6 +30,10 @@ public static int readInt(String key, int defaultValue) {
return Prefs.with(app()).readInt(key, defaultValue);
}
+ public static int readInt(@StringRes int key, int defaultValue) {
+ return readInt(app().getString(key), defaultValue);
+ }
+
public static boolean readBool(@StringRes int key) {
return readBool(app().getString(key));
}
@@ -66,6 +70,10 @@ public static void save(String key, int value) {
Prefs.with(app()).writeInt(key, value);
}
+ public static void save(@StringRes int key, int value) {
+ Prefs.with(app()).writeInt(app().getString(key), value);
+ }
+
public static void save(String key, boolean value) {
Prefs.with(app()).writeBoolean(key, value);
}
diff --git a/app/src/main/java/me/ghui/v2er/general/ReplySortMode.java b/app/src/main/java/me/ghui/v2er/general/ReplySortMode.java
new file mode 100644
index 00000000..80ad5ed5
--- /dev/null
+++ b/app/src/main/java/me/ghui/v2er/general/ReplySortMode.java
@@ -0,0 +1,35 @@
+package me.ghui.v2er.general;
+
+/**
+ * Reply sorting modes for topic replies
+ * Created for issue #41 - adding reply sorting by popularity
+ */
+public enum ReplySortMode {
+ BY_TIME(0, "按时间排序"),
+ BY_POPULARITY(1, "按热门排序");
+
+ private final int value;
+ private final String description;
+
+ ReplySortMode(int value, String description) {
+ this.value = value;
+ this.description = description;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public static ReplySortMode fromValue(int value) {
+ for (ReplySortMode mode : values()) {
+ if (mode.value == value) {
+ return mode;
+ }
+ }
+ return BY_TIME; // default
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/me/ghui/v2er/module/topic/TopicActivity.java b/app/src/main/java/me/ghui/v2er/module/topic/TopicActivity.java
index efd0d26e..84cbe3d8 100644
--- a/app/src/main/java/me/ghui/v2er/module/topic/TopicActivity.java
+++ b/app/src/main/java/me/ghui/v2er/module/topic/TopicActivity.java
@@ -130,6 +130,9 @@ public void onMapSharedElements(List names, Map sharedElem
private boolean mIsHideReplyBtn;
private boolean mIsLogin = UserUtils.isLogin();
private boolean mIsScanInOrder = !Pref.readBool(R.string.pref_key_is_scan_in_reverse, false);
+ private me.ghui.v2er.general.ReplySortMode mReplySortMode = me.ghui.v2er.general.ReplySortMode.fromValue(
+ Pref.readInt(R.string.pref_key_reply_sort_mode, me.ghui.v2er.general.ReplySortMode.BY_TIME.getValue()));
+ private MenuItem mReplySortMenuItem;
/**
* @param topicId
@@ -222,6 +225,8 @@ protected void configToolBar(BaseToolBar toolBar) {
replyMenuItem.setVisible(mIsHideReplyBtn);
MenuItem scanOrderMenuItem = menu.findItem(R.id.action_scan_order);
scanOrderMenuItem.setTitle(mIsScanInOrder ? "顺序浏览" : "逆序浏览");
+ mReplySortMenuItem = menu.findItem(R.id.action_reply_sort);
+ mReplySortMenuItem.setTitle(mReplySortMode.getDescription());
mToolbar.setOnMenuItemClickListener(item -> {
if (mTopicInfo == null) {
if (item.getItemId() == R.id.action_open_in_browser) {
@@ -321,6 +326,18 @@ protected void configToolBar(BaseToolBar toolBar) {
loadFromStart();
showLoading();
break;
+ case R.id.action_reply_sort:
+ // Toggle reply sorting mode
+ mReplySortMode = (mReplySortMode == me.ghui.v2er.general.ReplySortMode.BY_TIME)
+ ? me.ghui.v2er.general.ReplySortMode.BY_POPULARITY
+ : me.ghui.v2er.general.ReplySortMode.BY_TIME;
+ mReplySortMenuItem.setTitle(mReplySortMode.getDescription());
+ Pref.save(R.string.pref_key_reply_sort_mode, mReplySortMode.getValue());
+ // Re-apply sorting to current data without reloading from network
+ if (mTopicInfo != null) {
+ refreshReplySorting();
+ }
+ break;
}
return true;
});
@@ -598,7 +615,7 @@ public void fillView(TopicInfo topicInfo, int page) {
}
// TODO: 2019-06-23 save info from adapter
- mAdapter.setData(topicInfo.getItems(isLoadMore, mIsScanInOrder), isLoadMore);
+ mAdapter.setData(topicInfo.getItems(isLoadMore, mIsScanInOrder, mReplySortMode), isLoadMore);
if (!topicInfo.getContentInfo().isValid()) {
onRenderCompleted();
}
@@ -656,6 +673,20 @@ private void autoScroll() {
}
}
+ private void refreshReplySorting() {
+ if (mTopicInfo == null) return;
+
+ // Get current page info to maintain state
+ int currentPage = mPresenter.getPage();
+ boolean isLoadMore = mIsScanInOrder ? currentPage > 1 : currentPage != mTopicInfo.getTotalPage();
+
+ // Re-sort and update adapter with current sorting mode
+ mAdapter.setData(mTopicInfo.getItems(isLoadMore, mIsScanInOrder, mReplySortMode), isLoadMore);
+
+ // Update the @mention list as well
+ fillAtList();
+ }
+
private void fillAtList() {
List datum = mAdapter.getDatas();
if (repliersInfo == null) {
diff --git a/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java b/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java
index e10c6f8b..44960b41 100644
--- a/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java
+++ b/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java
@@ -60,6 +60,9 @@ public void convert(ViewHolder holder, TopicInfo.Item item, int position) {
} else {
holder.getConvertView().setBackgroundColor(Color.TRANSPARENT);
}
+
+ // Apply indentation for threaded replies
+ applyThreadIndentation(holder, replyInfo);
holder.setText(R.id.reply_user_name_tv, replyInfo.getUserName());
if (replyInfo.getLove() == 0) {
holder.getView(R.id.reply_thx_tv).setVisibility(View.GONE);
@@ -88,7 +91,13 @@ public void convert(ViewHolder holder, TopicInfo.Item item, int position) {
} else {
contentView.setVisibility(View.GONE);
}
- holder.setText(R.id.floor_tv, replyInfo.getFloor());
+
+ // Update floor text with threading indicator
+ String floorText = replyInfo.getFloor();
+ if (replyInfo.hasMentions() && replyInfo.getIndentLevel() > 0) {
+ floorText += " 💬"; // Add emoji to indicate threaded reply
+ }
+ holder.setText(R.id.floor_tv, floorText);
}
public interface OnMemberClickListener {
@@ -117,4 +126,33 @@ public boolean onUrlClick(String url) {
}
}
+ /**
+ * Apply indentation for threaded replies based on mention depth
+ */
+ private void applyThreadIndentation(ViewHolder holder, TopicInfo.Reply replyInfo) {
+ View convertView = holder.getConvertView();
+
+ // Calculate indentation based on thread level
+ int indentLevel = replyInfo.getIndentLevel();
+ int baseIndent = (int) mContext.getResources().getDimension(R.dimen.common_padding_size);
+ int indentPixels = baseIndent + (indentLevel * baseIndent / 2); // 12dp base + 6dp per level
+
+ // Apply left margin to indicate thread level
+ if (convertView.getLayoutParams() instanceof android.view.ViewGroup.MarginLayoutParams) {
+ android.view.ViewGroup.MarginLayoutParams params =
+ (android.view.ViewGroup.MarginLayoutParams) convertView.getLayoutParams();
+ params.leftMargin = indentPixels;
+ convertView.setLayoutParams(params);
+ }
+
+ // Visual indicator for threaded replies
+ if (indentLevel > 0 && replyInfo.hasMentions()) {
+ // Add a subtle visual indicator for threaded replies
+ convertView.setBackgroundResource(R.drawable.threaded_reply_background);
+ } else if (!replyInfo.isOwner() || !Pref.readBool(R.string.pref_key_highlight_topic_owner_reply_item)) {
+ // Reset background for non-threaded replies
+ convertView.setBackgroundColor(Color.TRANSPARENT);
+ }
+ }
+
}
diff --git a/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java b/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java
index ebd79b36..e425e968 100644
--- a/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java
+++ b/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java
@@ -50,6 +50,10 @@ public class TopicInfo extends BaseInfo {
private List- items;
+ public TopicInfo() {
+ // Default constructor for testing and general use
+ }
+
public Problem getProblem() {
return problem;
}
@@ -123,6 +127,18 @@ public ContentInfo getContentInfo() {
* @return
*/
public List
- getItems(boolean isLoadMore, boolean isInOrder) {
+ return getItems(isLoadMore, isInOrder, me.ghui.v2er.general.ReplySortMode.BY_TIME);
+ }
+
+ /**
+ * 加载分页后的数据
+ *
+ * @param isLoadMore
+ * @param isInOrder 是否是正序加载
+ * @param sortMode 回复排序模式
+ * @return
+ */
+ public List
- getItems(boolean isLoadMore, boolean isInOrder, me.ghui.v2er.general.ReplySortMode sortMode) {
if (items == null) {
items = new ArrayList<>(Utils.listSize(replies) + 2);
} else {
@@ -145,7 +161,25 @@ public List
- getItems(boolean isLoadMore, boolean isInOrder) {
for (Reply reply : replies) {
reply.setOwner(owner);
}
- items.addAll(replies);
+
+ // Parse mentions and calculate threading levels
+ processMentionsAndThreading(replies);
+
+ // Apply sorting based on mode
+ List sortedReplies = new ArrayList<>(replies);
+ if (sortMode == me.ghui.v2er.general.ReplySortMode.BY_POPULARITY) {
+ // Sort by love count (popularity) in descending order, then by floor for stable sort
+ Collections.sort(sortedReplies, (r1, r2) -> {
+ int loveCompare = Integer.compare(r2.getLove(), r1.getLove());
+ if (loveCompare != 0) {
+ return loveCompare;
+ }
+ // If love count is equal, sort by floor for stable ordering
+ return Integer.compare(r1.floor, r2.floor);
+ });
+ }
+
+ items.addAll(sortedReplies);
}
return items;
}
@@ -166,6 +200,68 @@ public HeaderInfo getHeaderInfo() {
return headerInfo;
}
+ /**
+ * Process @mentions in replies and calculate threading levels
+ * @param replies List of replies to process
+ */
+ private void processMentionsAndThreading(List replies) {
+ if (replies == null || replies.isEmpty()) return;
+
+ // Create a map of username to their latest reply for threading calculation
+ Map userToLatestReply = new HashMap<>();
+
+ for (Reply reply : replies) {
+ // Parse @mentions from reply content
+ List mentions = extractMentions(reply.getReplyContent());
+ reply.setMentionedUsers(mentions);
+
+ // Calculate indent level based on mentions
+ int maxIndentLevel = 0;
+ for (String mentionedUser : mentions) {
+ Reply mentionedReply = userToLatestReply.get(mentionedUser);
+ if (mentionedReply != null) {
+ // This reply is responding to another reply, increase indent
+ maxIndentLevel = Math.max(maxIndentLevel, mentionedReply.getIndentLevel() + 1);
+ }
+ }
+ reply.setIndentLevel(maxIndentLevel);
+
+ // Update the latest reply for this user
+ userToLatestReply.put(reply.getUserName(), reply);
+ }
+ }
+
+ /**
+ * Extract @username mentions from reply content
+ * @param content HTML content of the reply
+ * @return List of mentioned usernames
+ */
+ List extractMentions(String content) {
+ List mentions = new ArrayList<>();
+ if (Check.isEmpty(content)) return mentions;
+
+ // Use regex to find @username patterns
+ // V2EX format: @username or @username
+ java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(
+ "@(?:]*href=\"/member/([^\"]+)\"[^>]*>([^<]+)|([a-zA-Z0-9_\\-]+))"
+ );
+ java.util.regex.Matcher matcher = pattern.matcher(content);
+
+ while (matcher.find()) {
+ String username = matcher.group(1); // From href
+ if (username == null) {
+ username = matcher.group(3); // Direct @username
+ }
+ if (username != null && !mentions.contains(username)) {
+ mentions.add(username);
+ }
+ }
+
+ return mentions;
+ }
+ return headerInfo;
+ }
+
public void setHeaderInfo(HeaderInfo headerInfo) {
this.headerInfo = headerInfo;
}
@@ -496,6 +592,13 @@ public static class Reply implements Item {
@Pick(attr = "id")
private String replyId;
private boolean isOwner = false;
+ // Threading support for 楼中楼 functionality
+ private List mentionedUsers = new ArrayList<>();
+ private int indentLevel = 0;
+
+ public Reply() {
+ // Default constructor for testing and general use
+ }
public boolean isOwner() {
return isOwner;
@@ -610,6 +713,27 @@ public boolean isSelf() {
return false;
}
}
+
+ // Threading support methods
+ public List getMentionedUsers() {
+ return mentionedUsers;
+ }
+
+ public void setMentionedUsers(List mentionedUsers) {
+ this.mentionedUsers = mentionedUsers != null ? mentionedUsers : new ArrayList<>();
+ }
+
+ public int getIndentLevel() {
+ return indentLevel;
+ }
+
+ public void setIndentLevel(int indentLevel) {
+ this.indentLevel = Math.max(0, Math.min(indentLevel, 3)); // Max 3 levels
+ }
+
+ public boolean hasMentions() {
+ return mentionedUsers != null && !mentionedUsers.isEmpty();
+ }
}
}
diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml
new file mode 100644
index 00000000..2606f272
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sort.xml
@@ -0,0 +1,10 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/threaded_reply_background.xml b/app/src/main/res/drawable/threaded_reply_background.xml
new file mode 100644
index 00000000..512e13a3
--- /dev/null
+++ b/app/src/main/res/drawable/threaded_reply_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/topic_info_toolbar_menu.xml b/app/src/main/res/menu/topic_info_toolbar_menu.xml
index db60c61a..28823b8d 100644
--- a/app/src/main/res/menu/topic_info_toolbar_menu.xml
+++ b/app/src/main/res/menu/topic_info_toolbar_menu.xml
@@ -39,6 +39,10 @@
android:id="@id/action_scan_order"
android:icon="@drawable/love_normal_icon"
android:title="逆序浏览" />
+
diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml
index 8c043c84..55c70596 100644
--- a/app/src/main/res/values/ids.xml
+++ b/app/src/main/res/values/ids.xml
@@ -12,6 +12,7 @@
+
diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml
index 36caeaee..6ae98eb1 100644
--- a/app/src/main/res/values/keys.xml
+++ b/app/src/main/res/values/keys.xml
@@ -19,6 +19,7 @@
pref_key_shortcuts_back_to_home
pref_key_auto_checkin
is_scan_in_reverse
+ pref_key_reply_sort_mode
pref_key_auto_dark_mode_settings
pref_key_auto_daynight_switch
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0e588f52..f29bc200 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -49,6 +49,9 @@
搜索输入框
清除搜索输入框
-
+
+ 按时间排序
+ 按热门排序
+ 楼中楼回复
diff --git a/app/src/test/java/me/ghui/v2er/network/bean/TopicInfoTest.java b/app/src/test/java/me/ghui/v2er/network/bean/TopicInfoTest.java
new file mode 100644
index 00000000..199dbdf4
--- /dev/null
+++ b/app/src/test/java/me/ghui/v2er/network/bean/TopicInfoTest.java
@@ -0,0 +1,81 @@
+package me.ghui.v2er.network.bean;
+
+import org.junit.Test;
+import static org.junit.Assert.*;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Unit tests for TopicInfo threading and sorting functionality
+ */
+public class TopicInfoTest {
+
+ @Test
+ public void testExtractMentions_SimpleAtUsername() {
+ TopicInfo topicInfo = new TopicInfo();
+ List mentions = topicInfo.extractMentions("@user1 thanks for the help!");
+ assertEquals(1, mentions.size());
+ assertEquals("user1", mentions.get(0));
+ }
+
+ @Test
+ public void testExtractMentions_LinkFormat() {
+ TopicInfo topicInfo = new TopicInfo();
+ String content = "@user1 what do you think?";
+ List mentions = topicInfo.extractMentions(content);
+ assertEquals(1, mentions.size());
+ assertEquals("user1", mentions.get(0));
+ }
+
+ @Test
+ public void testExtractMentions_MultipleMentions() {
+ TopicInfo topicInfo = new TopicInfo();
+ String content = "@user1 @user2 please check this out";
+ List mentions = topicInfo.extractMentions(content);
+ assertEquals(2, mentions.size());
+ assertTrue(mentions.contains("user1"));
+ assertTrue(mentions.contains("user2"));
+ }
+
+ @Test
+ public void testExtractMentions_NoDuplicates() {
+ TopicInfo topicInfo = new TopicInfo();
+ String content = "@user1 and @user1 again";
+ List mentions = topicInfo.extractMentions(content);
+ assertEquals(1, mentions.size());
+ assertEquals("user1", mentions.get(0));
+ }
+
+ @Test
+ public void testReplyIndentLevel() {
+ TopicInfo.Reply reply = new TopicInfo.Reply();
+
+ // Test default indent level
+ assertEquals(0, reply.getIndentLevel());
+
+ // Test setting indent level
+ reply.setIndentLevel(2);
+ assertEquals(2, reply.getIndentLevel());
+
+ // Test max indent level (should cap at 3)
+ reply.setIndentLevel(5);
+ assertEquals(3, reply.getIndentLevel());
+
+ // Test negative values (should set to 0)
+ reply.setIndentLevel(-1);
+ assertEquals(0, reply.getIndentLevel());
+ }
+
+ @Test
+ public void testReplyMentions() {
+ TopicInfo.Reply reply = new TopicInfo.Reply();
+
+ // Test empty mentions
+ assertFalse(reply.hasMentions());
+
+ // Test with mentions
+ reply.setMentionedUsers(Arrays.asList("user1", "user2"));
+ assertTrue(reply.hasMentions());
+ assertEquals(2, reply.getMentionedUsers().size());
+ }
+}
\ No newline at end of file