Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# V2er 回复排序和楼中楼功能说明

## 功能概述

本次更新为 V2er 添加了两个重要功能:

### 1. 回复按热门程度排序 (Reply Sorting by Popularity)

用户现在可以在主题页面切换回复的排序方式:
- **按时间排序** (默认) - 按回复时间顺序显示
- **按热门排序** - 按感谢次数降序排列,感谢次数相同时按楼层排序

#### 使用方法:
1. 在主题页面点击右上角菜单
2. 选择"按时间排序"或"按热门排序"来切换模式
3. 设置会自动保存,下次打开时保持选择的排序方式

### 2. 楼中楼功能 (Threaded Replies)

系统会自动检测回复中的 @用户名 提及,并以可视化方式显示回复的层级关系:

#### 特性:
- 自动解析 `@username` 和 `@<a href="/member/username">username</a>` 格式
- 根据提及关系计算缩进级别(最多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楼: 新的话题
```

通过这种方式,用户可以更容易地跟踪对话线程和找到最受欢迎的回复。
77 changes: 77 additions & 0 deletions UI_MOCKUP.md
Original file line number Diff line number Diff line change
@@ -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) ✅
8 changes: 8 additions & 0 deletions app/src/main/java/me/ghui/v2er/general/Pref.java
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down Expand Up @@ -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);
}
Expand Down
35 changes: 35 additions & 0 deletions app/src/main/java/me/ghui/v2er/general/ReplySortMode.java
Original file line number Diff line number Diff line change
@@ -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
}
}
33 changes: 32 additions & 1 deletion app/src/main/java/me/ghui/v2er/module/topic/TopicActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ public void onMapSharedElements(List<String> names, Map<String, View> 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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
});
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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<TopicInfo.Item> datum = mAdapter.getDatas();
if (repliersInfo == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}

}
Loading