diff --git a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings index 8a6b53ba..5fc26322 100644 --- a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings @@ -66,6 +66,9 @@ "Share" = "分享"; "Notifications" = "通知"; "Notifications (%lld)" = "通知 (%lld)"; +"Followed Activity" = "关注动态"; +"No Activity" = "没有动态"; +"View activity from the users you follow." = "查看你关注的用户的动态。"; "%lld Unread" = "%lld个未读"; "All Read" = "全部已读"; "Read" = "已读"; @@ -73,8 +76,11 @@ "Mark All as Read" = "全部标记为已读"; "replied to your post" = "回复了你的回复"; "replied to your topic" = "回复了你的话题"; +"posted a topic" = "发表了话题"; +"posted a reply" = "发表了回复"; +"favorited" = "收藏了内容"; "Your post" = "你的回复"; -"received 10 more votes" = "收获了 10 个评价"; +"received 10 more votes" = "获得了 10 个评价"; "Discard" = "舍弃"; "Discard the draft?" = "舍弃草稿?"; "Save the draft by swiping down to dismiss the editor." = "你可以通过下滑关闭编辑器来保存草稿。"; diff --git a/app/Shared/Protos/Extensions.swift b/app/Shared/Protos/Extensions.swift index f974e511..add5c033 100644 --- a/app/Shared/Protos/Extensions.swift +++ b/app/Shared/Protos/Extensions.swift @@ -170,6 +170,34 @@ extension Notification.TypeEnum { } } +extension Activity.TypeEnum { + var icon: String { + switch self { + case .postTopic: + "square.and.pencil" + case .postReply: + "arrowshape.turn.up.left" + case .favor: + "bookmark" + case .unknown, .UNRECOGNIZED: + "" + } + } + + var description: LocalizedStringKey { + switch self { + case .postTopic: + "posted a topic" + case .postReply: + "posted a reply" + case .favor: + "favorited" + case .unknown, .UNRECOGNIZED: + "" + } + } +} + extension Post { var idWithAlterInfo: String { id.debugDescription + alterInfo @@ -256,6 +284,10 @@ extension UserName { } extension Topic { + var read: Bool { + hasRepliesNumLastVisit + } + var authorNameDisplay: String { let new = authorName.display diff --git a/app/Shared/Utilities/PlusFeature.swift b/app/Shared/Utilities/PlusFeature.swift index 8fcec02e..ee6c4ba9 100644 --- a/app/Shared/Utilities/PlusFeature.swift +++ b/app/Shared/Utilities/PlusFeature.swift @@ -10,6 +10,7 @@ enum PlusFeature: CaseIterable { case customAppearance case multiAccount case topicHistory + case followedActivity case multiFavorite case authorOnly case jump @@ -33,6 +34,8 @@ enum PlusFeature: CaseIterable { "Short Messages" case .topicHistory: "History" + case .followedActivity: + "Followed Activity" case .authorOnly: "Author Only" case .jump: @@ -64,6 +67,8 @@ enum PlusFeature: CaseIterable { "Send and receive short messages with other users." case .topicHistory: "View your footprint of topics you have explored." + case .followedActivity: + "View activity from the users you follow." case .authorOnly: "Check posts and replies from a specific author in a topic." case .jump: @@ -95,6 +100,8 @@ enum PlusFeature: CaseIterable { "message" case .topicHistory: "clock" + case .followedActivity: + "sparkles" case .authorOnly: "person.fill" case .jump: diff --git a/app/Shared/Views/FollowedActivityListView.swift b/app/Shared/Views/FollowedActivityListView.swift new file mode 100644 index 00000000..7e0c8f4a --- /dev/null +++ b/app/Shared/Views/FollowedActivityListView.swift @@ -0,0 +1,63 @@ +// +// FollowedActivityListView.swift +// MNGA +// +// Created by Bugen Zhao on 2025/12/18. +// + +import Foundation +import SwiftUI +import SwiftUIX + +struct FollowedActivityListView: View { + typealias DataSource = PagingDataSource + + @StateObject var dataSource: DataSource + + static func build() -> Self { + let dataSource = DataSource( + buildRequest: { page in + .activityList(.with { $0.page = UInt32(page) }) + }, + onResponse: { response in + (response.activities, Int(response.pages)) + }, + id: \.id + ) + + return Self(dataSource: dataSource) + } + + @ViewBuilder + func destination(for activity: Activity) -> some View { + if activity.postID.pid == "0" || activity.postID.pid.isEmpty { + TopicDetailsView.build(topic: activity.topic) + } else { + TopicDetailsView.build(topic: activity.topic, onlyPost: (id: activity.postID, atPage: nil)) + } + } + + var body: some View { + Group { + if dataSource.notLoaded { + ProgressView() + .onAppear { dataSource.initialLoad() } + } else if dataSource.items.isEmpty { + ContentUnavailableView("No Activity", systemImage: "dot.radiowaves.left.and.right") + } else { + List { + ForEach(dataSource.items, id: \.id) { activity in + CrossStackNavigationLinkHack(id: activity.id, destination: { + destination(for: activity) + }) { + FollowedActivityRowView(activity: activity) + }.onAppear { dataSource.loadMoreIfNeeded(currentItem: activity) } + } + } + } + } + .navigationTitle("Followed Activity") + .mayGroupedListStyle() + .refreshable(dataSource: dataSource) + } +} diff --git a/app/Shared/Views/FollowedActivityRowView.swift b/app/Shared/Views/FollowedActivityRowView.swift new file mode 100644 index 00000000..3a619bd5 --- /dev/null +++ b/app/Shared/Views/FollowedActivityRowView.swift @@ -0,0 +1,58 @@ +// +// FollowedActivityRowView.swift +// MNGA +// +// Created by Bugen Zhao on 2025/12/18. +// + +import Foundation +import SwiftUI + +struct FollowedActivityRowView: View { + let activity: Activity + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: activity.type.icon) + TopicSubjectView(topic: activity.topic) + } + .topicSubjectDimmed(activity.topic.read) + .foregroundColor(activity.topic.read ? .secondary : .primary) + + HStack { + HStack(alignment: .center) { + Image(systemName: "person") + Text(activity.actor.nameDisplayCompat) + } + Text(activity.type.description) + Spacer() + DateTimeTextView.build(timestamp: activity.timestamp, switchable: false) + } + .foregroundColor(.secondary) + .font(.footnote) + } + .padding(.vertical, 2) + } +} + +struct FollowedActivityRowView_Previews: PreviewProvider { + static var previews: some View { + FollowedActivityRowView(activity: .with { + $0.id = "1" + $0.type = .postTopic + $0.actor = .with { $0.name.normal = "Bugen" } + $0.topic = .with { + $0.id = "123" + $0.subject = .with { $0.content = "Test Topic" } + } + $0.postID = .with { + $0.tid = "123" + $0.pid = "0" + } + $0.timestamp = UInt64(Date().timeIntervalSince1970 - 60) + }) + .background(.primary.opacity(0.1)) + .padding() + } +} diff --git a/app/Shared/Views/NotificationRowView.swift b/app/Shared/Views/NotificationRowView.swift index 75fc297e..a3bd92f1 100644 --- a/app/Shared/Views/NotificationRowView.swift +++ b/app/Shared/Views/NotificationRowView.swift @@ -21,7 +21,9 @@ struct NotificationRowView: View { default: TopicSubjectView(topic: noti.asTopic, showIndicators: false) } - }.foregroundColor(noti.read ? .secondary : .primary) + } + .topicSubjectDimmed(noti.read) + .foregroundColor(noti.read ? .secondary : .primary) DateTimeFooterView(timestamp: noti.timestamp, switchable: false) { switch noti.type { diff --git a/app/Shared/Views/TopicPostRowView.swift b/app/Shared/Views/TopicPostRowView.swift index 8c36b495..cc39527d 100644 --- a/app/Shared/Views/TopicPostRowView.swift +++ b/app/Shared/Views/TopicPostRowView.swift @@ -37,7 +37,7 @@ struct TopicPostRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - TopicSubjectView(topic: topic, lineLimit: 2) + TopicSubjectView(topic: topic) QuoteView(fullWidth: false) { PostContentView(lightPost: cleanPost, initialInQuote: true) diff --git a/app/Shared/Views/TopicRowView.swift b/app/Shared/Views/TopicRowView.swift index c7b706d8..f85341d1 100644 --- a/app/Shared/Views/TopicRowView.swift +++ b/app/Shared/Views/TopicRowView.swift @@ -56,13 +56,13 @@ struct TopicRowView: View { } var shouldDim: Bool { - dimmedSubject && !topic.id.isMNGAMockID && topic.hasRepliesNumLastVisit + dimmedSubject && !topic.id.isMNGAMockID && topic.read } @ViewBuilder var subject: some View { BlockedView(content: BlockWordsStorage.content(for: topic), revealOnTap: false) { - TopicSubjectView(topic: topic, lineLimit: 2, showIndicators: showIndicators) + TopicSubjectView(topic: topic, showIndicators: showIndicators) .topicSubjectDimmed(shouldDim) } } diff --git a/app/Shared/Views/TopicSubjectView.swift b/app/Shared/Views/TopicSubjectView.swift index 752f6210..f4b4d40f 100644 --- a/app/Shared/Views/TopicSubjectView.swift +++ b/app/Shared/Views/TopicSubjectView.swift @@ -93,7 +93,7 @@ struct TopicSubjectView: View { let lineLimit: Int? let showIndicators: Bool - init(topic: Topic, lineLimit: Int? = nil, showIndicators: Bool = false) { + init(topic: Topic, lineLimit: Int? = 2, showIndicators: Bool = false) { self.topic = topic self.lineLimit = lineLimit self.showIndicators = showIndicators diff --git a/app/Shared/Views/UserMenuView.swift b/app/Shared/Views/UserMenuView.swift index 0427f10c..ee307553 100644 --- a/app/Shared/Views/UserMenuView.swift +++ b/app/Shared/Views/UserMenuView.swift @@ -113,6 +113,9 @@ struct UserMenuView: View { NavigationLink(destination: FavoriteTopicListView()) { Label("Favorite Topics", systemImage: "bookmark") } + PlusCheckNavigationLink(destination: FollowedActivityListView.build(), feature: .followedActivity) { + Label("Followed Activity", systemImage: "dot.radiowaves.left.and.right") + } } PlusCheckNavigationLink(destination: TopicHistoryListView.build(), feature: .topicHistory) { Label("History", systemImage: "clock") diff --git a/docs/nga-follow_v2-get_push_list.md b/docs/nga-follow_v2-get_push_list.md new file mode 100644 index 00000000..646690e7 --- /dev/null +++ b/docs/nga-follow_v2-get_push_list.md @@ -0,0 +1,166 @@ +# NGA `follow_v2.get_push_list` (`lite=xml`) API Notes + +This document describes the endpoint behind **My → Followed Activity** (`关注动态`) on `nga.178.com`, focusing on the `lite=xml` output format. + +## Endpoint + +- Method: `POST` +- URL: `https://nga.178.com/nuke.php?__lib=follow_v2&__act=get_push_list&page={page}` +- Body (form): `__output={output}` +- Authentication: requires valid NGA login cookies (same-origin request). + +### Parameters + +- `page` (query, integer, 1-based): page index. +- `lite=xml` (query, flag): forces an XML response (`text/xml; charset=GB18030`) regardless of `__output`. +- `__output` (form, integer/string): still required by the endpoint, but when `lite=xml` is present it no longer changes the response format. + +## Character encoding + +With `lite=xml`, the response uses `charset=GB18030`. + +## Response data model (XML) + +`lite=xml` returns a root node containing `data` and `time`: + +```xml + + + ... + + +``` + +### `data[0]`: activity list (index-based fields) + +Each activity entry is a small object with numeric keys plus a `summary` string. + +Observed field mapping (as used by the site UI): + +- `v[0]`: activity id +- `v[1]`: activity type + - `1`: followed user posted a topic + - `2`: followed user posted a reply + - `3`: followed user favorited a topic/reply +- `v[2]`: actor uid (poster or favoriter) +- `v[3]`: `tid` +- `v[4]`: `pid` (0 means topic) +- `v[5]`: reply-to pid (only meaningful for reply flows) +- `v[6]`: timestamp (unix seconds) +- `v[7]`: favorite table id (only for type `3`) +- `summary`: UBB-like human-readable summary + +### `data[1]`: users map (keyed by uid) + +Users are keyed by UID (string). Each entry contains fields like: + +- `uid`, `username`, `groupid`, `memberid`, `medal`, `reputation`, `postnum`, `money`, `thisvisit`, `bit_data`, ... + +### `data[4]`: topics map (keyed by tid) + +Topics are keyed by TID (string). Each entry contains fields like: + +- `tid`, `fid`, `author`, `authorid`, `subject`, `postdate`, `lastpost`, `lastposter`, `replies`, `content`, `tpcurl`, `parent`, ... + +### Pagination + +- `data[2]`: max page (number) +- `data[3]`: current page (number) +- `time`: server time (unix seconds) + +## UI rendering behavior (where the table comes from) + +The **table rows** are driven by `data[0]` (activity list), but the **display text** is resolved through `data[1]` and `data[4]`: + +- Username/link: `users[uid].username` (from `data[1]`) +- Topic subject/link: `topics[tid].subject` and `/read.php?tid=...` (from `data[4]`) + +This is implemented in the frontend function `commonui.myfollow.get_push_list`, which reads: + +- `da = d.data[0]` (activities) +- `users = d.data[1]` +- `posts = d.data[4]` + +and renders a `table.forumbox` where each row uses `users[uid]` + `posts[tid]` for display. + +## Example (`lite=xml`) + +Request: + +``` +POST /nuke.php?__lib=follow_v2&__act=get_push_list&page=1&lite=xml +Content-Type: application/x-www-form-urlencoded + +__output=3 +``` + +Response (structure): + +```xml + + + + + + + 12070836 + 1 + 465855 + 45727480 + 0 + 0 + 1764909093 + 0 + 0 + [summary omitted] + + + + + + + + 465855 + [username omitted] + 169083394 + ... + ... + -1 + 39 + 3531 + 31004 + 1765853518 + 169083394 + + + + + + 1 + + + 1 + + + + + 45727480 + 685 + [username omitted] + 465855 + [subject omitted] + 1764909093 + 0 + [content omitted] + /read.php?tid=45727480 + + <_0>685 + <_2>[forum title omitted] + + + + + + + +``` diff --git a/logic/service/src/activity.rs b/logic/service/src/activity.rs new file mode 100644 index 00000000..68fe803e --- /dev/null +++ b/logic/service/src/activity.rs @@ -0,0 +1,205 @@ +use std::collections::HashMap; + +use protos::{ + DataModel::{Activity, Activity_Type, PostId, Topic, User}, + ProtobufEnum, + Service::{ActivityListRequest, ActivityListResponse}, +}; +use sxd_document::Package; +use sxd_xpath::nodeset::Node; + +use crate::{ + error::ServiceResult, + fetch_package, + topic::extract_topic, + user::extract_local_user_and_cache, + utils::{extract_nodes, extract_nodes_rel, extract_string, extract_string_rel}, +}; + +fn parse_activity_list(package: &Package) -> ServiceResult { + let users = extract_nodes(package, "/root/data/item[2]/item", |ns| { + ns.into_iter() + .filter_map(|n| extract_local_user_and_cache(n, None)) + .collect::>() + })? + .into_iter() + .map(|u| (u.get_id().to_owned(), u)) + .collect::>(); + + let topics = extract_nodes(package, "/root/data/item[5]/item", |ns| { + ns.into_iter().filter_map(extract_topic).collect::>() + })? + .into_iter() + .map(|t| (t.get_id().to_owned(), t)) + .collect::>(); + + let activities = extract_nodes(package, "/root/data/item[1]/item", |ns| { + ns.into_iter() + .filter_map(|n| extract_activity(n, &users, &topics).ok().flatten()) + .collect::>() + })?; + + let pages = extract_string(package, "/root/data/item[3]")? + .parse::() + .unwrap_or(1); + + Ok(ActivityListResponse { + activities: activities.into(), + pages, + ..Default::default() + }) +} + +fn extract_activity( + node: Node, + users: &HashMap, + topics: &HashMap, +) -> ServiceResult> { + let values = extract_nodes_rel(node, "./item", |ns| { + ns.into_iter().map(|n| n.string_value()).collect() + })?; + + let id = values.first().cloned().unwrap_or_default(); + if id.is_empty() { + return Ok(None); + } + + let type_raw = values.get(1).cloned().unwrap_or_default(); + let field_type = type_raw + .parse::() + .ok() + .and_then(Activity_Type::from_i32) + .unwrap_or(Activity_Type::UNKNOWN); + + let actor_id = values.get(2).cloned().unwrap_or_default(); + let topic_id = values.get(3).cloned().unwrap_or_default(); + let pid = values.get(4).cloned().unwrap_or_else(|| "0".to_owned()); + let timestamp = values + .get(6) + .and_then(|s| s.parse::().ok()) + .unwrap_or_default(); + + let summary = extract_string_rel(node, "./summary").unwrap_or_default(); + let summary = text::unescape(&summary); + + let actor = users.get(&actor_id).cloned().unwrap_or(User { + id: actor_id, + ..Default::default() + }); + + let topic = topics.get(&topic_id).cloned().unwrap_or(Topic { + id: topic_id.clone(), + ..Default::default() + }); + + let post_id = PostId { + tid: topic_id, + pid, + ..Default::default() + }; + + Ok(Some(Activity { + id, + field_type, + actor: Some(actor).into(), + topic: Some(topic).into(), + post_id: Some(post_id).into(), + timestamp, + summary, + ..Default::default() + })) +} + +pub async fn get_activity_list( + request: ActivityListRequest, +) -> ServiceResult { + let package = fetch_package( + "nuke.php", + vec![ + ("__lib", "follow_v2"), + ("__act", "get_push_list"), + ("page", &request.get_page().to_string()), + ], + vec![], + ) + .await?; + + parse_activity_list(&package) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_parse_activity_list_minimal() -> ServiceResult<()> { + let xml = r#" + + + + + 123 + 1 + 42 + 999 + 0 + 0 + 1700000000 + 0 + hello & world + + + + + 42 + tester + + + 3 + 1 + + + 999 + 1 + tester + 42 + test + 1700000000 + 1700000000 + 0 + + + + + +"#; + + let package = sxd_document::parser::parse(xml)?; + let response = parse_activity_list(&package)?; + + assert_eq!(response.get_pages(), 3); + assert_eq!(response.get_activities().len(), 1); + + let a = &response.get_activities()[0]; + assert_eq!(a.get_id(), "123"); + assert_eq!(a.get_field_type(), Activity_Type::POST_TOPIC); + assert_eq!(a.get_actor().get_id(), "42"); + assert_eq!(a.get_topic().get_id(), "999"); + assert_eq!(a.get_post_id().get_pid(), "0"); + assert_eq!(a.get_timestamp(), 1700000000); + assert_eq!(a.get_summary(), "hello & world"); + + Ok(()) + } + + #[tokio::test] + async fn test_activity_list() -> ServiceResult<()> { + let response = get_activity_list(ActivityListRequest { + page: 1, + ..Default::default() + }) + .await?; + assert!(!response.get_activities().is_empty()); + Ok(()) + } +} diff --git a/logic/service/src/dispatch/handlers_async.rs b/logic/service/src/dispatch/handlers_async.rs index f8195a1c..e8c374d8 100644 --- a/logic/service/src/dispatch/handlers_async.rs +++ b/logic/service/src/dispatch/handlers_async.rs @@ -1,4 +1,5 @@ use crate::{ + activity::get_activity_list, cache::manipulate_cache, clock_in::clock_in, error::ServiceResult, @@ -61,3 +62,4 @@ handle!(topic_search, search_topic); handle!(clock_in, clock_in); handle!(cache, manipulate_cache); handle!(user_signature_update, update_signature); +handle!(activity_list, get_activity_list); diff --git a/logic/service/src/dispatch/mod.rs b/logic/service/src/dispatch/mod.rs index b5082b7a..5b8884b5 100644 --- a/logic/service/src/dispatch/mod.rs +++ b/logic/service/src/dispatch/mod.rs @@ -51,6 +51,7 @@ 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)), + activity_list(r) => r!(handle_activity_list(r)), } } } diff --git a/logic/service/src/lib.rs b/logic/service/src/lib.rs index 6bba7ca9..31956d6b 100644 --- a/logic/service/src/lib.rs +++ b/logic/service/src/lib.rs @@ -1,3 +1,4 @@ +mod activity; mod attachment; mod auth; mod cache; diff --git a/protos/DataModel.proto b/protos/DataModel.proto index 39999ee9..282e55db 100755 --- a/protos/DataModel.proto +++ b/protos/DataModel.proto @@ -262,6 +262,23 @@ message Notification { bool read = 8; // Whether this notification has been read. } +message Activity { + enum Type { + UNKNOWN = 0; + POST_TOPIC = 1; // Followed user posted a topic. + POST_REPLY = 2; // Followed user posted a reply. + FAVOR = 3; // Followed user favored a topic or reply. + } + + string id = 1; // Activity id. + Type type = 2; + User actor = 3; + Topic topic = 4; + PostId post_id = 5; // pid=0 indicates topic. + uint64 timestamp = 7; + string summary = 8; // UBB-like summary from the server. +} + message BlockWord { string word = 1; } // Attachments that have been already uploaded, but not posted yet. diff --git a/protos/Service.proto b/protos/Service.proto index 73d2dd40..18b307a0 100644 --- a/protos/Service.proto +++ b/protos/Service.proto @@ -126,6 +126,8 @@ message AsyncRequest { FavoriteForumListRequest favorite_forum_list = 28; // Add or remove a favorite forum (forum_favor2). FavoriteForumModifyRequest favorite_forum_modify = 29; + // Get followed activity list (follow_v2.get_push_list). + ActivityListRequest activity_list = 30; } } @@ -308,6 +310,12 @@ message PostReplyResponse { string message = 1; } message FetchNotificationRequest {} message FetchNotificationResponse { repeated Notification notis = 1; } +message ActivityListRequest { uint32 page = 1; } +message ActivityListResponse { + repeated Activity activities = 1; + uint32 pages = 2; +} + message UploadAttachmentRequest { PostReplyAction action = 1; bytes file = 2; // Data of the file.