Implement Lists and Hashtags Menu (and timeline) (IOS-102) (#1325)

# Implement Lists and Hashtags Viewing Capabilities

This PR adds **List** and **Hashtag** viewing capabilities.

| Menu | Lists | Hashtags |
|---|---|---|
|![Simulator Screenshot - iPhone 15 Pro Max - 2024-07-16 at 13 41
07](https://github.com/user-attachments/assets/7ff8dc5c-fe9f-433a-b947-b8ac602e453f)
|![Simulator Screenshot - iPhone 15 Pro Max - 2024-07-16 at 13 40
49](https://github.com/user-attachments/assets/18d7444f-c542-4bce-943a-911a328399c0)|
![Simulator Screenshot - iPhone 15 Pro Max - 2024-07-16 at 13 40
44](https://github.com/user-attachments/assets/8a2b5d04-f51c-4c68-a91d-3c9bd6208eee)|
This commit is contained in:
Marcus Kida 2024-07-17 13:45:28 +02:00 committed by GitHub
commit 27c6c58d96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 279 additions and 16 deletions

View File

@ -477,7 +477,15 @@
"title": "Home",
"timeline_menu": {
"following": "Following",
"local_community": "Local"
"local_community": "Local",
"lists": {
"title": "Lists",
"empty_message": "You don't have any Lists"
},
"hashtags": {
"title": "Followed Hashtags",
"empty_message": "You don't follow any Hashtags"
}
},
"timeline_pill": {
"offline": "Offline",

View File

@ -127,7 +127,6 @@ extension HashtagTimelineViewModel.State {
Task {
do {
let response = try await viewModel.context.apiService.hashtagTimeline(
domain: viewModel.authContext.mastodonAuthenticationBox.domain,
maxID: maxID,
hashtag: viewModel.hashtag,
authenticationBox: viewModel.authContext.mastodonAuthenticationBox

View File

@ -140,10 +140,101 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media
case .home:
showLocalTimelineAction.state = .off
showFollowingAction.state = .on
case .list:
showLocalTimelineAction.state = .off
showFollowingAction.state = .off
case .hashtag:
showLocalTimelineAction.state = .off
showFollowingAction.state = .off
}
}
let listsSubmenu = UIDeferredMenuElement.uncached { [weak self] callback in
guard let self else { return callback([]) }
Task { @MainActor in
let lists = (try? await Mastodon.API.Lists.getLists(
session: .shared,
domain: self.authContext.mastodonAuthenticationBox.domain,
authorization: self.authContext.mastodonAuthenticationBox.userAuthorization
).singleOutput().value) ?? []
var listEntries = lists.map { entry in
return LabeledAction(title: entry.title, image: nil, handler: { [weak self] in
guard let self, let viewModel = self.viewModel else { return }
viewModel.timelineContext = .list(entry.id)
viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.ContextSwitch.self)
timelineSelectorButton.setAttributedTitle(
.init(string: entry.title, attributes: [
.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
]),
for: .normal)
timelineSelectorButton.sizeToFit()
timelineSelectorButton.menu = generateTimelineSelectorMenu()
}).menuElement
}
if listEntries.isEmpty {
listEntries = [
UIAction(title: L10n.Scene.HomeTimeline.TimelineMenu.Lists.emptyMessage, attributes: [.disabled], handler: {_ in })
]
}
callback(listEntries)
}
}
let listsMenu = UIMenu(
title: L10n.Scene.HomeTimeline.TimelineMenu.Lists.title,
image: UIImage(systemName: "list.bullet.rectangle.portrait"),
children: [listsSubmenu]
)
let hashtagsSubmenu = UIDeferredMenuElement.uncached { [weak self] callback in
guard let self else { return callback([]) }
Task { @MainActor in
let lists = (try? await Mastodon.API.Account.followedTags(
session: .shared,
domain: self.authContext.mastodonAuthenticationBox.domain,
query: .init(limit: nil),
authorization: self.authContext.mastodonAuthenticationBox.userAuthorization
).singleOutput().value) ?? []
var listEntries = lists.map { entry in
return LabeledAction(title: entry.name, image: nil, handler: { [weak self] in
guard let self, let viewModel = self.viewModel else { return }
viewModel.timelineContext = .hashtag(entry.name)
viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.ContextSwitch.self)
timelineSelectorButton.setAttributedTitle(
.init(string: entry.name, attributes: [
.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
]),
for: .normal)
timelineSelectorButton.sizeToFit()
timelineSelectorButton.menu = generateTimelineSelectorMenu()
}).menuElement
}
if listEntries.isEmpty {
listEntries = [
UIAction(title: L10n.Scene.HomeTimeline.TimelineMenu.Hashtags.emptyMessage, attributes: [.disabled], handler: {_ in })
]
}
callback(listEntries)
}
}
return UIMenu(children: [showFollowingAction, showLocalTimelineAction])
let hashtagsMenu = UIMenu(
title: L10n.Scene.HomeTimeline.TimelineMenu.Hashtags.title,
image: UIImage(systemName: "number"),
children: [hashtagsSubmenu]
)
let listsDivider = UIMenu(title: "", options: .displayInline, children: [listsMenu, hashtagsMenu])
return UIMenu(children: [showFollowingAction, showLocalTimelineAction, listsDivider])
}
}

View File

@ -132,6 +132,17 @@ extension HomeTimelineViewModel.LoadLatestState {
query: .init(local: true, sinceID: sinceID),
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
case let .list(id):
response = try await viewModel.context.apiService.listTimeline(
id: id,
query: .init(sinceID: sinceID),
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
case let .hashtag(tag):
response = try await viewModel.context.apiService.hashtagTimeline(
hashtag: tag,
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
}
enter(state: Idle.self)

View File

@ -74,6 +74,17 @@ extension HomeTimelineViewModel.LoadOldestState {
query: .init(local: true, maxID: maxID),
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
case let .list(id):
response = try await viewModel.context.apiService.listTimeline(
id: id,
query: .init(local: true, maxID: maxID),
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
case let .hashtag(tag):
response = try await viewModel.context.apiService.hashtagTimeline(
hashtag: tag,
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
}
let statuses = response.value

View File

@ -38,7 +38,11 @@ final class HomeTimelineViewModel: NSObject {
let isOffline = CurrentValueSubject<Bool, Never>(false)
var networkErrorCount = CurrentValueSubject<Int, Never>(0)
var timelineContext: MastodonFeed.Kind.TimelineContext = .home
var timelineContext: MastodonFeed.Kind.TimelineContext = .home {
didSet {
hasNewPosts.send(false)
}
}
weak var tableView: UITableView?
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
@ -170,6 +174,17 @@ extension HomeTimelineViewModel {
query: .init(local: true, maxID: status.id),
authenticationBox: authContext.mastodonAuthenticationBox
)
case let .list(id):
response = try? await context.apiService.listTimeline(
id: id,
query: .init(local: true, maxID: status.id),
authenticationBox: authContext.mastodonAuthenticationBox
)
case let .hashtag(tag):
response = try? await context.apiService.hashtagTimeline(
hashtag: tag,
authenticationBox: authContext.mastodonAuthenticationBox
)
}
// insert missing items

View File

@ -184,6 +184,17 @@ private extension FeedDataController {
query: .init(local: true, maxID: maxID),
authenticationBox: authContext.mastodonAuthenticationBox
)
case let .list(id):
response = try await context.apiService.listTimeline(
id: id,
query: .init(maxID: maxID),
authenticationBox: authContext.mastodonAuthenticationBox
)
case let .hashtag(tag):
response = try await context.apiService.hashtagTimeline(
hashtag: tag,
authenticationBox: authContext.mastodonAuthenticationBox
)
}
return response.value.map { .fromStatus(.fromEntity($0), kind: .home) }

View File

@ -14,7 +14,6 @@ import MastodonSDK
extension APIService {
public func hashtagTimeline(
domain: String,
sinceID: Mastodon.Entity.Status.ID? = nil,
maxID: Mastodon.Entity.Status.ID? = nil,
limit: Int = onceRequestStatusMaxCount,
@ -40,7 +39,7 @@ extension APIService {
query: query,
hashtag: hashtag,
authorization: authorization
).singleOutput()
)
return response
}

View File

@ -0,0 +1,26 @@
import Foundation
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
extension APIService {
public func listTimeline(
id: String,
query: Mastodon.API.Timeline.PublicTimelineQuery,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let response = try await Mastodon.API.Timeline.list(
session: session,
domain: domain,
query: query,
id: id,
authorization: authorization
)
return response
}
}

View File

@ -862,6 +862,18 @@ public enum L10n {
public static let following = L10n.tr("Localizable", "Scene.HomeTimeline.TimelineMenu.Following", fallback: "Following")
/// Local
public static let localCommunity = L10n.tr("Localizable", "Scene.HomeTimeline.TimelineMenu.LocalCommunity", fallback: "Local")
public enum Hashtags {
/// You don't follow any Hashtags
public static let emptyMessage = L10n.tr("Localizable", "Scene.HomeTimeline.TimelineMenu.Hashtags.EmptyMessage", fallback: "You don't follow any Hashtags")
/// Followed Hashtags
public static let title = L10n.tr("Localizable", "Scene.HomeTimeline.TimelineMenu.Hashtags.Title", fallback: "Followed Hashtags")
}
public enum Lists {
/// You don't have any Lists
public static let emptyMessage = L10n.tr("Localizable", "Scene.HomeTimeline.TimelineMenu.Lists.EmptyMessage", fallback: "You don't have any Lists")
/// Lists
public static let title = L10n.tr("Localizable", "Scene.HomeTimeline.TimelineMenu.Lists.Title", fallback: "Lists")
}
}
public enum TimelinePill {
/// New Posts

View File

@ -305,6 +305,10 @@ uploaded to Mastodon.";
"Scene.Following.Title" = "following";
"Scene.HomeTimeline.TimelineMenu.Following" = "Following";
"Scene.HomeTimeline.TimelineMenu.LocalCommunity" = "Local";
"Scene.HomeTimeline.TimelineMenu.Lists.Title" = "Lists";
"Scene.HomeTimeline.TimelineMenu.Lists.EmptyMessage" = "You don't have any Lists";
"Scene.HomeTimeline.TimelineMenu.Hashtags.Title" = "Followed Hashtags";
"Scene.HomeTimeline.TimelineMenu.Hashtags.EmptyMessage" = "You don't follow any Hashtags";
"Scene.HomeTimeline.TimelinePill.NewPosts" = "New Posts";
"Scene.HomeTimeline.TimelinePill.Offline" = "Offline";
"Scene.HomeTimeline.TimelinePill.PostSent" = "Post Sent";
@ -617,4 +621,4 @@ If you disagree with the policy for **%@**, you can go back and pick a different
"Widget.MultipleFollowers.ConfigurationDescription" = "Show number of followers for multiple accounts.";
"Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers";
"Widget.MultipleFollowers.MockUser.AccountName" = "another@follower.social";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";

View File

@ -0,0 +1,53 @@
import Combine
import Foundation
extension Mastodon.API.Lists {
static func listsEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain) .appendingPathComponent("lists")
}
static func listsEndpointURL(domain: String, id: String) -> URL {
return Mastodon.API.endpointURL(domain: domain) .appendingPathComponent("lists/\(id)")
}
static func listAccountsEndpointURL(domain: String, id: String) -> URL {
return Mastodon.API.endpointURL(domain: domain) .appendingPathComponent("lists/\(id)/accounts")
}
public static func getLists(
session: URLSession,
domain: String,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.List]>, Error> {
let request = Mastodon.API.get(
url: listsEndpointURL(domain: domain),
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.List].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
public static func getList(
session: URLSession,
domain: String,
id: String,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.List>, Error> {
let request = Mastodon.API.get(
url: listAccountsEndpointURL(domain: domain, id: id),
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.List.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}

View File

@ -20,6 +20,10 @@ extension Mastodon.API.Timeline {
return Mastodon.API.endpointURL(domain: domain)
.appendingPathComponent("timelines/tag/\(hashtag)")
}
static func listTimelineEndpointURL(domain: String, id: String) -> URL {
return Mastodon.API.endpointURL(domain: domain)
.appendingPathComponent("timelines/list/\(id)")
}
/// View public timeline statuses
///
@ -108,18 +112,34 @@ extension Mastodon.API.Timeline {
query: HashtagTimelineQuery,
hashtag: String,
authorization: Mastodon.API.OAuth.Authorization?
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> {
let request = Mastodon.API.get(
url: hashtagTimelineEndpointURL(domain: domain, hashtag: hashtag),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
let (data, response) = try await session.data(for: request)
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
public static func list(
session: URLSession,
domain: String,
query: PublicTimelineQuery,
id: String,
authorization: Mastodon.API.OAuth.Authorization?
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> {
let request = Mastodon.API.get(
url: listTimelineEndpointURL(domain: domain, id: id),
query: query,
authorization: authorization
)
let (data, response) = try await session.data(for: request)
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
}

View File

@ -127,6 +127,7 @@ extension Mastodon.API {
public enum Subscriptions { }
public enum Reports { }
public enum DomainBlock { }
public enum Lists { }
}
extension Mastodon.API.V2 {

View File

@ -11,9 +11,11 @@ public final class MastodonFeed {
case notificationAll
case notificationMentions
public enum TimelineContext {
public enum TimelineContext: Equatable {
case home
case `public`
case list(String)
case hashtag(String)
}
}

View File

@ -53,7 +53,7 @@ extension HashtagWidgetProvider {
do {
let mostRecentStatuses = try await WidgetExtension.appContext
.apiService
.hashtagTimeline(domain: authBox.domain, limit: 40, hashtag: desiredHashtag, authenticationBox: authBox)
.hashtagTimeline(limit: 40, hashtag: desiredHashtag, authenticationBox: authBox)
.value
let filteredStatuses: [Mastodon.Entity.Status]