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:
commit
27c6c58d96
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -127,6 +127,7 @@ extension Mastodon.API {
|
|||
public enum Subscriptions { }
|
||||
public enum Reports { }
|
||||
public enum DomainBlock { }
|
||||
public enum Lists { }
|
||||
}
|
||||
|
||||
extension Mastodon.API.V2 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Reference in New Issue