Skip to content

Commit 28acb82

Browse files
Implement hashtag timeline with new TimelineListViewController
Fixes IOS-553
1 parent b42141e commit 28acb82

File tree

17 files changed

+262
-58
lines changed

17 files changed

+262
-58
lines changed

Mastodon.xcodeproj/project.pbxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,6 +1262,7 @@
12621262
"Common Components/Views/TimelineRowViews/AccountRowView.swift",
12631263
"Common Components/Views/TimelineRowViews/AccountRowViewModel.swift",
12641264
"Common Components/Views/TimelineRowViews/HashtagRowView.swift",
1265+
"Common Components/Views/TimelineRowViews/HashtagRowViewModel.swift",
12651266
"Common Components/Views/TimelineRowViews/MastodonPostRowView.swift",
12661267
"Common Components/Views/TimelineRowViews/MastodonPostViewModel.swift",
12671268
"Common Components/Views/TimelineRowViews/Molecules/AuthorHeaderView.swift",

Mastodon/Coordinator/SceneCoordinator.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ extension SceneCoordinator {
180180
case editHistory(viewModel: StatusEditHistoryViewModel)
181181

182182
// Hashtag Timeline
183-
case hashtagTimeline(viewModel: HashtagTimelineViewModel)
183+
case hashtagTimeline(Mastodon.Entity.Tag)
184184

185185
// profile
186186
case accountList(viewModel: AccountListViewModel)
@@ -436,9 +436,8 @@ private extension SceneCoordinator {
436436
case .editHistory(let viewModel):
437437
let editHistoryViewController = StatusEditHistoryViewController(viewModel: viewModel)
438438
viewController = editHistoryViewController
439-
case .hashtagTimeline(let viewModel):
440-
let _viewController = HashtagTimelineViewController()
441-
_viewController.viewModel = viewModel
439+
case .hashtagTimeline(let tag):
440+
let _viewController = TimelineListViewController(.hashtag(tag))
442441
viewController = _viewController
443442
case .accountList(let viewModel):
444443
let accountListViewController = AccountListViewController()

Mastodon/In Progress New Layout and Datamodel/Common Components/Views/TimelineRowViews/AccountRowView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ struct AccountRowView: View {
6969
.fontWeight(.semibold)
7070
Text(stat.label)
7171
.font(.footnote)
72+
.lineLimit(1)
7273
}
7374
}
7475

Mastodon/In Progress New Layout and Datamodel/Common Components/Views/TimelineRowViews/AccountRowViewModel.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import SwiftUI
2929
}
3030
}
3131

32+
func updateAccount(_ updated: MastodonAccount) {
33+
account = updated
34+
}
35+
3236
func doRelationshipButtonAction() async throws {
3337
if let action = relationshipViewModel.button.buttonAction.mastodonPostMenuAction {
3438
try await actionHandler?.doAction(action, forAccount: account)

Mastodon/In Progress New Layout and Datamodel/Common Components/Views/TimelineRowViews/HashtagRowView.swift

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,147 @@ import SwiftUI
44
import MastodonLocalization
55
import MastodonSDK
66
import MastodonUI
7+
import AuthenticationServices
8+
import MastodonCore
79

810
struct HashtagRowView: View {
911

10-
let tag: Mastodon.Entity.Tag
12+
@Environment(HashtagRowViewModel.self) var viewModel
1113

1214
var body: some View {
1315
HStack {
1416
VStack(alignment: .leading, spacing: tinySpacing) {
15-
Text("#\(tag.name)")
17+
Text("#\(viewModel.entity.name)")
1618
.foregroundStyle(.primary)
1719

18-
Text(L10n.Plural.peopleTalking(tag.talkingPeopleCount ?? 0))
20+
Text(L10n.Plural.peopleTalking(viewModel.entity.talkingPeopleCount ?? 0))
1921
.font(.subheadline)
2022
.foregroundStyle(.secondary)
2123
}
2224
Spacer()
2325
.frame(maxWidth: .infinity)
24-
WrappedLineChartView(tag: tag)
26+
WrappedLineChartView(tag: viewModel.entity)
2527
.frame(width: 50, height: 26)
2628
.accessibilityHidden(true)
2729
}
2830
.contentShape(Rectangle())
2931
.accessibilityElement(children: .combine)
3032
}
3133
}
34+
35+
struct HashtagHeaderView: View {
36+
37+
@Environment(HashtagRowViewModel.self) var viewModel
38+
@State var isUpdating: Bool = false
39+
40+
var body: some View {
41+
HStack {
42+
// HASHTAG AND STATS
43+
VStack(alignment: .leading, spacing: tinySpacing) {
44+
Text("#\(viewModel.entity.name)")
45+
.font(.headline)
46+
.foregroundStyle(.primary)
47+
.lineLimit(1)
48+
.fixedSize()
49+
50+
HStack(alignment: .bottom, spacing: doublePadding) {
51+
ForEach(StatType.allCases, id: \.self) { stat in
52+
statsView(stat)
53+
}
54+
}
55+
}
56+
57+
Spacer()
58+
59+
// GRAPH AND FOLLOW BUTTON
60+
VStack(alignment: .trailing) {
61+
if viewModel.entity.history != nil {
62+
WrappedLineChartView(tag: viewModel.entity)
63+
.frame(width: 100, height: 26)
64+
.accessibilityHidden(true)
65+
}
66+
Spacer()
67+
if let isFollowing = viewModel.entity.following {
68+
buttonType.button {
69+
guard let user = AuthenticationServiceProvider.shared.currentActiveUser.value else { return }
70+
isUpdating = true
71+
Task {
72+
if isFollowing {
73+
if let updated = try? await APIService.shared.unfollowTag(
74+
for: viewModel.entity.name,
75+
authenticationBox: user
76+
).value {
77+
viewModel.entity = updated
78+
}
79+
} else {
80+
if let updated = try? await APIService.shared.followTag(
81+
for: viewModel.entity.name,
82+
authenticationBox: user
83+
).value {
84+
viewModel.entity = updated
85+
}
86+
}
87+
isUpdating = false
88+
}
89+
}
90+
}
91+
}
92+
}
93+
.contentShape(Rectangle())
94+
.accessibilityElement(children: .combine)
95+
}
96+
97+
var buttonType: RelationshipButtonType {
98+
guard !isUpdating else { return .updating }
99+
guard let isFollowing = viewModel.entity.following else { return .updating }
100+
return isFollowing ? .iFollowThem(theyFollowMe: false) : .iDoNotFollowThem(theyFollowMe: false, theirAccountIsLocked: false)
101+
}
102+
103+
@ViewBuilder func statsView(_ stat: StatType) -> some View {
104+
VStack(spacing: 0) {
105+
Text(MastodonMetricFormatter().string(from: statCount(stat)) ?? "-")
106+
.font(.subheadline)
107+
.fontWeight(.semibold)
108+
Text(stat.label)
109+
.font(.footnote)
110+
.lineLimit(1)
111+
.fixedSize()
112+
}
113+
}
114+
115+
func statCount(_ stat: StatType) -> Int {
116+
switch stat {
117+
case .postCount:
118+
return (viewModel.entity.history ?? []).reduce(0) { res, acc in
119+
res + (Int(acc.uses) ?? 0)
120+
}
121+
case .participantCount:
122+
return (viewModel.entity.history ?? []).reduce(0) { res, acc in
123+
res + (Int(acc.accounts) ?? 0)
124+
}
125+
case .postsToday:
126+
return Int(viewModel.entity.history?.first?.uses ?? "0") ?? 0
127+
}
128+
}
129+
130+
enum StatType: CaseIterable {
131+
case postCount
132+
case participantCount
133+
case postsToday
134+
135+
var label: String {
136+
switch self {
137+
case .postCount:
138+
L10n.Scene.FollowedTags.Header.posts
139+
case .participantCount:
140+
L10n.Scene.FollowedTags.Header.participants
141+
case .postsToday:
142+
L10n.Scene.FollowedTags.Header.postsToday
143+
}
144+
}
145+
}
146+
}
147+
32148
struct WrappedLineChartView: UIViewRepresentable {
33149
typealias UIViewType = LineChartView
34150
let tag: Mastodon.Entity.Tag
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright © 2025 Mastodon gGmbH. All rights reserved.
2+
3+
import SwiftUI
4+
import MastodonSDK
5+
6+
@MainActor
7+
@Observable class HashtagRowViewModel {
8+
9+
var entity: Mastodon.Entity.Tag
10+
let id: String
11+
12+
init(entity: Mastodon.Entity.Tag) {
13+
self.entity = entity
14+
id = entity.uniqueID
15+
}
16+
}
17+
18+
extension Mastodon.Entity.Tag {
19+
var uniqueID: String {
20+
return name + "-" + url
21+
}
22+
}

Mastodon/In Progress New Layout and Datamodel/Common Components/Views/TimelineRowViews/MastodonPostViewModel.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,7 @@ import MastodonLocalization
155155
return true
156156
} else if let hashtag = fullPost?.actionablePost?.content.htmlWithEntities?.tags.first(where: { $0.name.lowercased() == url.lastPathComponent.lowercased() && url.pathComponents.contains("tags") }) {
157157
guard let currentUser = AuthenticationServiceProvider.shared.currentActiveUser.value else { return false }
158-
let hashtagTimelineViewModel = HashtagTimelineViewModel(authenticationBox: currentUser, hashtag: hashtag.name)
159-
actionHandler?.presentScene(.hashtagTimeline(viewModel: hashtagTimelineViewModel), fromPost: initialDisplayInfo.id, transition: .show)
158+
actionHandler?.presentScene(.hashtagTimeline(hashtag), fromPost: initialDisplayInfo.id, transition: .show)
160159
return true
161160
} else {
162161
// fix non-ascii character URL link can not open issue

Mastodon/In Progress New Layout and Datamodel/Common Components/Views/TimelineRowViews/Molecules/RelationshipButtonType.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,15 @@ enum RelationshipButtonType {
8383

8484
var buttonText: String? {
8585
switch self {
86-
case .iDoNotFollowThem(_, let theirAccountIsLocked):
86+
case .iDoNotFollowThem(let theyFollowMe, let theirAccountIsLocked):
8787
if theirAccountIsLocked {
8888
return L10n.Common.Controls.Friendship.request
8989
} else {
90-
return L10n.Common.Controls.Friendship.followBack
90+
if theyFollowMe {
91+
return L10n.Common.Controls.Friendship.followBack
92+
} else {
93+
return L10n.Common.Controls.Friendship.follow
94+
}
9195
}
9296
case .iFollowThem(let theyFollowMe):
9397
if theyFollowMe {

Mastodon/In Progress New Layout and Datamodel/Common Components/Views/TimelineRowViews/NotificationRowView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,7 @@ struct RelationshipButtonStyle: ButtonStyle {
862862
func makeBody(configuration: Configuration) -> some View {
863863
configuration.label
864864
.lineLimit(1)
865-
.minimumScaleFactor(0.6)
865+
.minimumScaleFactor(0.4)
866866
.padding([.horizontal], 12)
867867
.padding([.vertical], 4)
868868
.background(backgroundColor)

Mastodon/In Progress New Layout and Datamodel/Timeline/TimelineFeedLoader.swift

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public enum MastodonTimelineType: Equatable {
3939
case myFavorites
4040
case local
4141
case list(String)
42-
case hashtag(String)
42+
case hashtag(Mastodon.Entity.Tag, includeHeader: Bool)
4343
case discovery
4444
case search(String, SearchScope)
4545
case userPosts(userID: String, queryFilter: TimelineQueryFilter)
@@ -52,7 +52,7 @@ public enum MastodonTimelineType: Equatable {
5252
case (.following, .following): return true
5353
case (.local, .local): return true
5454
case (.list(let first), .list(let second)): return first == second
55-
case (.hashtag(let first), .hashtag(let second)): return first == second
55+
case (.hashtag(let firstTag, let firstHeader), .hashtag(let secondTag, let secondHeader)): return firstTag == secondTag && firstHeader == secondHeader
5656
case (.discovery, .discovery): return true
5757
case (.search(let firstText, let firstScope), .search(let secondText, let secondScope)): return firstText == secondText && firstScope == secondScope
5858
case (.userPosts(let firstID, let firstFilter), .userPosts(let secondID, let secondFilter)): return firstID == secondID && firstFilter == secondFilter
@@ -123,7 +123,7 @@ extension GenericMastodonPost {
123123
enum TimelineItem: Identifiable {
124124
case post(MastodonPostViewModel)
125125
case notification(NotificationRowViewModel)
126-
case hashtag(Mastodon.Entity.Tag)
126+
case hashtag(HashtagRowViewModel)
127127
case account(AccountRowViewModel)
128128
case filteredNotificationsInfo(
129129
Mastodon.Entity.NotificationPolicy?,
@@ -136,8 +136,8 @@ enum TimelineItem: Identifiable {
136136
return postViewModel.initialDisplayInfo.id
137137
case .notification(let groupedNotificationInfo):
138138
return groupedNotificationInfo.id
139-
case .hashtag(let tag):
140-
return tag.name + tag.url
139+
case .hashtag(let tagViewModel):
140+
return tagViewModel.id
141141
case .account(let accountViewModel):
142142
return accountViewModel.id
143143
case .filteredNotificationsInfo:
@@ -190,6 +190,7 @@ final class TimelineFeedLoader: MastodonFeedLoader<TimelineItem, CacheableTimeli
190190
private var postViewModels = [Mastodon.Entity.Status.ID : MastodonPostViewModel]()
191191
private var notificationViewModels = [Mastodon.Entity.NotificationGroup.ID : NotificationRowViewModel]()
192192
private var accountViewModels = [Mastodon.Entity.Account.ID : AccountRowViewModel]()
193+
private var hashtagViewModels = [String : HashtagRowViewModel]()
193194

194195
private let myAccountID: Mastodon.Entity.Account.ID?
195196

@@ -288,6 +289,7 @@ final class TimelineFeedLoader: MastodonFeedLoader<TimelineItem, CacheableTimeli
288289
var newPostModels = [Mastodon.Entity.Status.ID : MastodonPostViewModel]()
289290
var newNotificationModels = [Mastodon.Entity.NotificationGroup.ID : NotificationRowViewModel]()
290291
var newAccountModels = [Mastodon.Entity.Account.ID : AccountRowViewModel]()
292+
var newHashtagModels = [String : HashtagRowViewModel]()
291293

292294
func timelineItem(fromStatus status: Mastodon.Entity.Status) -> TimelineItem {
293295
let post = GenericMastodonPost.fromStatus(status)
@@ -300,11 +302,19 @@ final class TimelineFeedLoader: MastodonFeedLoader<TimelineItem, CacheableTimeli
300302
viewModel.setFullPost(post)
301303
return TimelineItem.post(viewModel)
302304
}
303-
func timelineItem(fromAccount account: Mastodon.Entity.Account) -> TimelineItem {
304-
let viewModel = accountViewModels[account.id] ?? AccountRowViewModel(account: MastodonAccount.fromEntity(account))
305+
func timelineItem(fromAccount accountEntity: Mastodon.Entity.Account) -> TimelineItem {
306+
let account = MastodonAccount.fromEntity(accountEntity)
307+
let viewModel = accountViewModels[account.id] ?? AccountRowViewModel(account: account)
308+
viewModel.updateAccount(account)
305309
newAccountModels[account.id] = viewModel
306310
return TimelineItem.account(viewModel)
307311
}
312+
func timelineItem(fromHashtag hashtag: Mastodon.Entity.Tag) -> TimelineItem {
313+
let viewModel = hashtagViewModels[hashtag.uniqueID] ?? HashtagRowViewModel(entity: hashtag)
314+
viewModel.entity = hashtag
315+
newHashtagModels[hashtag.uniqueID] = viewModel
316+
return TimelineItem.hashtag(viewModel)
317+
}
308318

309319
let newBatch: [TimelineItem]
310320
switch timeline {
@@ -321,13 +331,28 @@ final class TimelineFeedLoader: MastodonFeedLoader<TimelineItem, CacheableTimeli
321331
query: .init(local: true, maxID: itemsImmediatelyBefore, sinceID: itemsNoOlderThan, minID: itemsImmediatelyAfter),
322332
authenticationBox: authenticatedUser
323333
).value.map { timelineItem(fromStatus: $0) }
324-
case .hashtag(let hashtag):
325-
newBatch = try await APIService.shared.hashtagTimeline(
334+
case .hashtag(let hashtag, let includeHeader):
335+
let statuses = try await APIService.shared.hashtagTimeline(
326336
sinceID: itemsNoOlderThan,
327337
maxID: itemsImmediatelyBefore,
328-
hashtag: hashtag,
338+
hashtag: hashtag.name,
329339
authenticationBox: authenticatedUser
330340
).value.map { timelineItem(fromStatus: $0) }
341+
if includeHeader {
342+
let header: TimelineItem
343+
if request == .reload || request == .newer,
344+
let updated = try? await APIService.shared.getTagInformation(
345+
for: hashtag.name,
346+
authenticationBox: authenticatedUser
347+
).value {
348+
header = timelineItem(fromHashtag: updated)
349+
} else {
350+
header = timelineItem(fromHashtag: hashtag)
351+
}
352+
newBatch = [header] + statuses
353+
} else {
354+
newBatch = statuses
355+
}
331356
case .discovery:
332357
newBatch = try await APIService.shared.trendStatuses(
333358
domain: authenticatedUser.domain,
@@ -355,7 +380,7 @@ final class TimelineFeedLoader: MastodonFeedLoader<TimelineItem, CacheableTimeli
355380
authenticationBox: authenticatedUser
356381
).value
357382
let statuses = results.statuses.map { timelineItem(fromStatus: $0) }
358-
let hashtags = results.hashtags.map { TimelineItem.hashtag($0) } // TODO: manage these, too, because they can be followed and unfollowed? or can they never be from the row?
383+
let hashtags = results.hashtags.map { timelineItem(fromHashtag: $0) }
359384
let accounts = results.accounts.map { timelineItem(fromAccount: $0) }
360385
newBatch = accounts + hashtags + statuses
361386
case .userPosts(let userID, let queryFilter):
@@ -450,6 +475,7 @@ final class TimelineFeedLoader: MastodonFeedLoader<TimelineItem, CacheableTimeli
450475
postViewModels = newPostModels
451476
notificationViewModels = newNotificationModels
452477
accountViewModels = newAccountModels
478+
hashtagViewModels = newHashtagModels
453479
createContentConcealViewModels(newCache)
454480
try? await fetchReplyTos(newCache)
455481

0 commit comments

Comments
 (0)