From b5cd3985cf7d9bee1b9fafef0a46d9f11d083102 Mon Sep 17 00:00:00 2001 From: jiangdailin Date: Fri, 7 Aug 2026 14:36:32 +0800 Subject: [PATCH] feat: add followed users and activity --- .../zh-Hans.lproj/Localizable.strings | 8 + app/Shared/Views/FollowListView.swift | 101 ++++++++ app/Shared/Views/UserMenuView.swift | 6 + app/Shared/Views/UserProfileView.swift | 28 +++ logic/service/src/dispatch/handlers_async.rs | 8 +- logic/service/src/dispatch/mod.rs | 3 + logic/service/src/user.rs | 235 +++++++++++++++++- protos/DataModel.proto | 18 ++ protos/Service.proto | 28 +++ 9 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 app/Shared/Views/FollowListView.swift diff --git a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings index 51973861..2d9421ab 100644 --- a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings @@ -131,6 +131,14 @@ "%@'s Posts" = "%@ 的回复"; "Posts" = "回复"; "My Profile" = "我的档案"; +"Following" = "关注用户"; +"Following Activity" = "关注动态"; +"No Followed Users" = "还没有关注用户"; +"No Following Activity" = "还没有关注动态"; +"Follow" = "关注"; +"Unfollow" = "取消关注"; +"Posted a Reply" = "发表了回复"; +"Published a Topic" = "发布了主题"; "Edit Favorites" = "编辑收藏"; "Posting" = "发表"; "Device Identity" = "设备身份"; diff --git a/app/Shared/Views/FollowListView.swift b/app/Shared/Views/FollowListView.swift new file mode 100644 index 00000000..ea683462 --- /dev/null +++ b/app/Shared/Views/FollowListView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct FollowUserListView: View { + typealias DataSource = PagingDataSource + + @StateObject private var dataSource: DataSource + + init() { + _dataSource = StateObject(wrappedValue: DataSource( + buildRequest: { page in + .followUserList(.with { $0.page = UInt32(page) }) + }, + onResponse: { ($0.users, Int($0.pages)) }, + id: \.id, + )) + } + + var body: some View { + List { + if dataSource.notLoaded { + LoadingRowView().onAppear { dataSource.initialLoad() } + } else if dataSource.items.isEmpty { + EmptyRowView(title: "No Followed Users") + } else { + ForEach(dataSource.items, id: \.id) { user in + NavigationLink(destination: UserProfileView.build(user: user)) { + UserView(user: user, style: .normal) + } + .onAppear { dataSource.loadMoreIfNeeded(currentItem: user) } + } + } + } + .refreshable { await dataSource.refreshAsync(animated: true) } + .mayGroupedListStyle() + .navigationTitle("Following") + } +} + +struct FollowActivityRowView: View { + let activity: FollowActivity + + var destination: some View { + let postID: PostId? = activity.hasPost ? activity.post.id : nil + return TopicDetailsView.build(topicBinding: .constant(activity.topic), onlyPost: (postID, nil)) + } + + var body: some View { + CrossStackNavigationLinkHack(destination: destination, id: activity.id) { + VStack(alignment: .leading, spacing: 8) { + HStack { + UserView(user: activity.user, style: .compact) + Spacer() + Text(activity.type == .reply ? "Posted a Reply" : "Published a Topic") + .font(.caption) + .foregroundStyle(.secondary) + } + + if activity.hasPost { + TopicPostRowView(topic: activity.topic, post: activity.post) + } else { + TopicRowView(topic: activity.topic, useTopicPostDate: true, dimmedSubject: false) + } + } + .padding(.vertical, 2) + } + } +} + +struct FollowActivityListView: View { + typealias DataSource = PagingDataSource + + @StateObject private var dataSource: DataSource + + init() { + _dataSource = StateObject(wrappedValue: DataSource( + buildRequest: { page in + .followActivityList(.with { $0.page = UInt32(page) }) + }, + onResponse: { ($0.activities, Int($0.pages)) }, + id: \.id, + )) + } + + var body: some View { + List { + if dataSource.notLoaded { + LoadingRowView().onAppear { dataSource.initialLoad() } + } else if dataSource.items.isEmpty { + EmptyRowView(title: "No Following Activity") + } else { + ForEach(dataSource.items, id: \.id) { activity in + FollowActivityRowView(activity: activity) + .onAppear { dataSource.loadMoreIfNeeded(currentItem: activity) } + } + } + } + .refreshable { await dataSource.refreshAsync(animated: true) } + .mayGroupedListStyle() + .navigationTitle("Following Activity") + } +} diff --git a/app/Shared/Views/UserMenuView.swift b/app/Shared/Views/UserMenuView.swift index 0807b247..7ad10c61 100644 --- a/app/Shared/Views/UserMenuView.swift +++ b/app/Shared/Views/UserMenuView.swift @@ -114,6 +114,12 @@ struct UserMenuView: View { NavigationLink(destination: FavoriteTopicListView()) { Label("Favorite Topics", systemImage: "bookmark") } + NavigationLink(destination: FollowActivityListView()) { + Label("Following Activity", systemImage: "person.line.dotted.person.fill") + } + NavigationLink(destination: FollowUserListView()) { + Label("Following", systemImage: "person.2") + } } PlusCheckNavigationLink(destination: TopicHistoryListView.build(), feature: .topicHistory) { Label("History", systemImage: "clock") diff --git a/app/Shared/Views/UserProfileView.swift b/app/Shared/Views/UserProfileView.swift index 747ec149..a5c2d6de 100644 --- a/app/Shared/Views/UserProfileView.swift +++ b/app/Shared/Views/UserProfileView.swift @@ -32,6 +32,7 @@ struct UserProfileView: View { @StateObject var users = UsersModel.shared @State var tab = Tab.topics + @State var isModifyingFollow = false var isMyself: Bool { user.id == currentUser.user?.id @@ -135,6 +136,15 @@ struct UserProfileView: View { } } + if !isMyself, !user.isAnonymous { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { modifyFollow() }) { + Label(user.followed ? "Unfollow" : "Follow", systemImage: user.followed ? "person.fill.checkmark" : "person.badge.plus") + } + .disabled(isModifyingFollow) + } + } + ToolbarItem(placement: .navigationBarTrailing) { Menu { if isMyself { @@ -221,6 +231,24 @@ struct UserProfileView: View { signaturePostModel.show(action: .init(userID: user.id, initialSignature: initial)) } + func modifyFollow() { + guard !isMyself, !user.isAnonymous, !isModifyingFollow else { return } + isModifyingFollow = true + Task { + let response: Result = await logicCallAsync(.followUserModify(.with { + $0.userID = user.id + $0.operation = user.followed ? .delete : .add + })) + switch response { + case let .success(response): + withAnimation { user.followed = response.followed } + case let .failure(error): + ToastModel.showAuto(.error(error.error)) + } + isModifyingFollow = false + } + } + func reloadUser() async { if let refreshed = await users.remoteUser(id: user.id, ignoreCache: true) { withAnimation { user = refreshed } diff --git a/logic/service/src/dispatch/handlers_async.rs b/logic/service/src/dispatch/handlers_async.rs index f8195a1c..ac892685 100644 --- a/logic/service/src/dispatch/handlers_async.rs +++ b/logic/service/src/dispatch/handlers_async.rs @@ -17,7 +17,10 @@ use crate::{ get_hot_topic_list, get_topic_details, get_topic_list, get_user_topic_list, modify_favorite_folder, search_topic, topic_favor, }, - user::{get_remote_user, update_signature}, + user::{ + get_follow_activity_list, get_follow_user_list, get_remote_user, modify_follow_user, + update_signature, + }, }; use paste::paste; use protos::Service::*; @@ -61,3 +64,6 @@ handle!(topic_search, search_topic); handle!(clock_in, clock_in); handle!(cache, manipulate_cache); handle!(user_signature_update, update_signature); +handle!(follow_user_modify, modify_follow_user); +handle!(follow_user_list, get_follow_user_list); +handle!(follow_activity_list, get_follow_activity_list); diff --git a/logic/service/src/dispatch/mod.rs b/logic/service/src/dispatch/mod.rs index b5082b7a..ef6fe7bc 100644 --- a/logic/service/src/dispatch/mod.rs +++ b/logic/service/src/dispatch/mod.rs @@ -51,6 +51,9 @@ mod dispatch_async { clock_in(r) => r!(handle_clock_in(r)), cache(r) => r!(handle_cache(r)), user_signature_update(r) => r!(handle_user_signature_update(r)), + follow_user_modify(r) => r!(handle_follow_user_modify(r)), + follow_user_list(r) => r!(handle_follow_user_list(r)), + follow_activity_list(r) => r!(handle_follow_activity_list(r)), } } } diff --git a/logic/service/src/user.rs b/logic/service/src/user.rs index 8370aac4..e851c971 100644 --- a/logic/service/src/user.rs +++ b/logic/service/src/user.rs @@ -9,10 +9,15 @@ use crate::{ use dashmap::DashMap; use lazy_static::lazy_static; use protos::{ - DataModel::{User, UserName}, + DataModel::{ + FollowActivity, FollowActivity_Type, FollowActivity_oneof__post, LightPost, PostId, Topic, + User, UserName, + }, Service::{ - RemoteUserRequest, RemoteUserResponse, RemoteUserResponse_oneof__user, - UserSignatureUpdateRequest, UserSignatureUpdateResponse, + FollowActivityListRequest, FollowActivityListResponse, FollowUserListRequest, + FollowUserListResponse, FollowUserModifyRequest, FollowUserModifyRequest_Operation, + FollowUserModifyResponse, RemoteUserRequest, RemoteUserResponse, + RemoteUserResponse_oneof__user, UserSignatureUpdateRequest, UserSignatureUpdateResponse, }, }; use serde_json::Value; @@ -177,6 +182,211 @@ pub(crate) fn extract_user_json(value: &Value, remote: bool) -> Option { mute, ip_location: json_string(value, "ipLoc").unwrap_or_default(), remote, + followed: json_i64(value, "follow").unwrap_or_default() == 1, + following_count: json_u32(value, "follow_num").unwrap_or_default(), + follower_count: json_u32(value, "follow_by_num").unwrap_or_default(), + ..Default::default() + }) +} + +fn scalar_string(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.to_owned()), + Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +fn scalar_u32(value: &Value) -> Option { + value + .as_u64() + .and_then(|value| value.try_into().ok()) + .or_else(|| value.as_str()?.parse().ok()) +} + +fn indexed(value: &Value, index: usize) -> Option<&Value> { + value + .as_array() + .and_then(|values| values.get(index)) + .or_else(|| value.get(index.to_string())) +} + +fn records(value: &Value) -> Vec<&Value> { + match value { + Value::Array(values) => values.iter().collect(), + Value::Object(values) => { + let mut values = values.iter().collect::>(); + values.sort_by_key(|(key, _)| key.parse::().unwrap_or(u64::MAX)); + values.into_iter().map(|(_, value)| value).collect() + } + _ => Vec::new(), + } +} + +fn extract_follow_user(value: &Value) -> Option { + let id = json_string(value, "uid")?; + Some(User { + id, + name: Some(extract_user_name( + json_string(value, "username").unwrap_or_default(), + )) + .into(), + avatar_url: json_string(value, "avatar").unwrap_or_default(), + remote: false, + followed: true, + ..Default::default() + }) +} + +fn extract_follow_users(value: &Value) -> Vec { + indexed(value, 0) + .map(records) + .unwrap_or_default() + .into_iter() + .filter_map(extract_follow_user) + .collect() +} + +fn extract_follow_topic(value: &Value, user: &User) -> Topic { + let fid = json_string(value, "fid").unwrap_or_default(); + Topic { + id: json_string(value, "tid").unwrap_or_default(), + subject: Some(text::parse_subject( + &json_string(value, "subject").unwrap_or_default(), + )) + .into(), + author_id: user.id.to_owned(), + author_name: Some(user.name.clone().unwrap_or_default()).into(), + post_date: json_u64(value, "postdate").unwrap_or_default(), + last_post_date: json_u64(value, "lastpost").unwrap_or_default(), + replies_num: json_u32(value, "replies").unwrap_or_default(), + fid, + ..Default::default() + } +} + +pub async fn modify_follow_user( + request: FollowUserModifyRequest, +) -> ServiceResult { + let followed = request.get_operation() == FollowUserModifyRequest_Operation::ADD; + let follow_type = if followed { "1" } else { "8" }; + + let _value = fetch_json_value( + "nuke.php", + vec![("__lib", "follow_v2"), ("__act", "follow")], + vec![("id", request.get_user_id()), ("type", follow_type)], + ) + .await?; + + UserController::get().invalidate_user(request.get_user_id()); + Ok(FollowUserModifyResponse { + followed, + ..Default::default() + }) +} + +pub async fn get_follow_user_list( + request: FollowUserListRequest, +) -> ServiceResult { + let page = request.page.max(1); + let value = fetch_json_value( + "nuke.php", + vec![("__lib", "follow_v2"), ("__act", "get_follow")], + vec![("page", &page.to_string())], + ) + .await?; + + let users = extract_follow_users(&value) + .into_iter() + .inspect(|user| UserController::get().update_user(user.to_owned())) + .collect::>(); + let pages = if users.is_empty() { page } else { page + 1 }; + + Ok(FollowUserListResponse { + users: users.into(), + pages, + ..Default::default() + }) +} + +pub async fn get_follow_activity_list( + request: FollowActivityListRequest, +) -> ServiceResult { + let page = request.page.max(1); + let value = fetch_json_value( + "nuke.php", + vec![("__lib", "follow_v2"), ("__act", "get_push_list")], + vec![("page", &page.to_string())], + ) + .await?; + + let events = indexed(&value, 0).map(records).unwrap_or_default(); + let users = indexed(&value, 1).unwrap_or(&Value::Null); + let topics = indexed(&value, 4).unwrap_or(&Value::Null); + let pages = indexed(&value, 2) + .and_then(scalar_u32) + .unwrap_or_else(|| if events.is_empty() { page } else { page + 1 }); + + let activities = events + .into_iter() + .filter_map(|event| { + let id = indexed(event, 0).and_then(scalar_string)?; + let activity_type = indexed(event, 1).and_then(scalar_u32).unwrap_or_default(); + let user_id = indexed(event, 2).and_then(scalar_string)?; + let topic_id = indexed(event, 3).and_then(scalar_string)?; + let post_id = indexed(event, 4) + .and_then(scalar_string) + .unwrap_or_default(); + + let user_value = users.get(&user_id)?; + let user = + extract_follow_user(user_value).or_else(|| extract_user_json(user_value, false))?; + UserController::get().update_user(user.to_owned()); + + let topic_value = topics.get(&topic_id)?; + let mut topic = extract_follow_topic(topic_value, &user); + if topic.id.is_empty() { + topic.id.clone_from(&topic_id); + } + + let mut activity = FollowActivity { + id, + field_type: if activity_type == 2 { + FollowActivity_Type::REPLY + } else { + FollowActivity_Type::TOPIC + }, + user: Some(user.clone()).into(), + topic: Some(topic).into(), + ..Default::default() + }; + + if activity.field_type == FollowActivity_Type::REPLY { + let reply_key = format!("{topic_id}_{post_id}"); + let reply_value = topics.get(&reply_key).unwrap_or(topic_value); + activity._post = Some(FollowActivity_oneof__post::post(LightPost { + id: Some(PostId { + pid: post_id, + tid: topic_id, + ..Default::default() + }) + .into(), + author_id: user.id, + content: Some(text::parse_content( + &json_string(reply_value, "content").unwrap_or_default(), + )) + .into(), + post_date: json_u64(reply_value, "postdate").unwrap_or_default(), + ..Default::default() + })); + } + Some(activity) + }) + .collect::>(); + + Ok(FollowActivityListResponse { + activities: activities.into(), + pages, ..Default::default() }) } @@ -363,6 +573,25 @@ mod test { assert_eq!(user.get_name().get_anonymous(), "壬宫窦丁钱甄"); } + #[test] + fn test_extract_follow_users_from_wrapped_list() { + let value = serde_json::json!([ + { + "0": { "uid": "100", "username": "Alice", "avatar": "alice.png" }, + "1": { "uid": 200, "username": "Bob" } + } + ]); + + let users = extract_follow_users(&value); + + assert_eq!(users.len(), 2); + assert_eq!(users[0].get_id(), "100"); + assert_eq!(users[0].get_name().get_normal(), "Alice"); + assert_eq!(users[1].get_id(), "200"); + assert_eq!(users[1].get_name().get_normal(), "Bob"); + assert!(users.iter().all(User::get_followed)); + } + #[ignore = "manual: requires network or mutable external state"] #[tokio::test] async fn test_update_signature() -> ServiceResult<()> { diff --git a/protos/DataModel.proto b/protos/DataModel.proto index 39999ee9..4fbfb68c 100755 --- a/protos/DataModel.proto +++ b/protos/DataModel.proto @@ -63,6 +63,24 @@ message User { bool mute = 9; // Whether the user is temporarily banned by NGA. string ip_location = 10; bool remote = 11; // Whether this user info is fetched with a separate `remoteUser` call. + bool followed = 12; // Whether the current user follows this user on NGA. + uint32 following_count = 13; + uint32 follower_count = 14; +} + +// A topic or reply published by a followed user. +message FollowActivity { + enum Type { + UNKNOWN = 0; + TOPIC = 1; + REPLY = 2; + } + + string id = 1; // The server-side activity ID. + Type type = 2; + User user = 3; + Topic topic = 4; + optional LightPost post = 5; // Present for reply activities. } message PostId { diff --git a/protos/Service.proto b/protos/Service.proto index 73d2dd40..86d750fa 100644 --- a/protos/Service.proto +++ b/protos/Service.proto @@ -126,6 +126,12 @@ message AsyncRequest { FavoriteForumListRequest favorite_forum_list = 28; // Add or remove a favorite forum (forum_favor2). FavoriteForumModifyRequest favorite_forum_modify = 29; + // Add or remove a followed user. + FollowUserModifyRequest follow_user_modify = 30; + // Get users followed by the current user. + FollowUserListRequest follow_user_list = 31; + // Get topics and replies published by followed users. + FollowActivityListRequest follow_activity_list = 32; } } @@ -198,6 +204,28 @@ message RemoteUserRequest { } message RemoteUserResponse { optional User user = 1; } +message FollowUserModifyRequest { + enum Operation { + ADD = 0; + DELETE = 1; + } + string user_id = 1; + Operation operation = 2; +} +message FollowUserModifyResponse { bool followed = 1; } + +message FollowUserListRequest { uint32 page = 1; } +message FollowUserListResponse { + repeated User users = 1; + uint32 pages = 2; +} + +message FollowActivityListRequest { uint32 page = 1; } +message FollowActivityListResponse { + repeated FollowActivity activities = 1; + uint32 pages = 2; +} + message PostVoteRequest { enum Operation { UPVOTE = 0;