Timeline: Add pills quick access

This commit is contained in:
Thomas Ricouard 2023-12-30 14:54:09 +01:00
parent 631707a798
commit fe66acbd39
5 changed files with 262 additions and 94 deletions

View file

@ -33,11 +33,13 @@ extension View {
ConversationDetailView(conversation: conversation) ConversationDetailView(conversation: conversation)
case let .hashTag(tag, accountId): case let .hashTag(tag, accountId):
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)),
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil), selectedTagGroup: .constant(nil),
scrollToTopSignal: .constant(0), scrollToTopSignal: .constant(0),
canFilterTimeline: false) canFilterTimeline: false)
case let .list(list): case let .list(list):
TimelineView(timeline: .constant(.list(list: list)), TimelineView(timeline: .constant(.list(list: list)),
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil), selectedTagGroup: .constant(nil),
scrollToTopSignal: .constant(0), scrollToTopSignal: .constant(0),
canFilterTimeline: false) canFilterTimeline: false)
@ -53,6 +55,7 @@ extension View {
AccountsListView(mode: .accountsList(accounts: accounts)) AccountsListView(mode: .accountsList(accounts: accounts))
case .trendingTimeline: case .trendingTimeline:
TimelineView(timeline: .constant(.trending), TimelineView(timeline: .constant(.trending),
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil), selectedTagGroup: .constant(nil),
scrollToTopSignal: .constant(0), scrollToTopSignal: .constant(0),
canFilterTimeline: false) canFilterTimeline: false)

View file

@ -29,6 +29,7 @@ struct TimelineTab: View {
@Query(sort: \TagGroup.creationDate, order: .reverse) var tagGroups: [TagGroup] @Query(sort: \TagGroup.creationDate, order: .reverse) var tagGroups: [TagGroup]
@AppStorage("last_timeline_filter") var lastTimelineFilter: TimelineFilter = .home @AppStorage("last_timeline_filter") var lastTimelineFilter: TimelineFilter = .home
@AppStorage("timeline_pinned_filters") private var pinnedFilters: [TimelineFilter] = []
private let canFilterTimeline: Bool private let canFilterTimeline: Bool
@ -41,6 +42,7 @@ struct TimelineTab: View {
var body: some View { var body: some View {
NavigationStack(path: $routerPath.path) { NavigationStack(path: $routerPath.path) {
TimelineView(timeline: $timeline, TimelineView(timeline: $timeline,
pinnedFilters: $pinnedFilters,
selectedTagGroup: $selectedTagGroup, selectedTagGroup: $selectedTagGroup,
scrollToTopSignal: $scrollToTopSignal, scrollToTopSignal: $scrollToTopSignal,
canFilterTimeline: canFilterTimeline) canFilterTimeline: canFilterTimeline)
@ -119,96 +121,13 @@ struct TimelineTab: View {
@ViewBuilder @ViewBuilder
private var timelineFilterButton: some View { private var timelineFilterButton: some View {
if timeline.supportNewestPagination { latestOrResumeButtons
Button { pinMenuButton
timeline = .latest timelineFiltersButtons
} label: { listsFiltersButons
Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName() ?? "") tagsFiltersButtons
} localTimelinesFiltersButtons
if timeline == .home { tagGroupsFiltersButtons
Button {
timeline = .resume
} label: {
VStack {
Label(TimelineFilter.resume.localizedTitle(),
systemImage: TimelineFilter.resume.iconName() ?? "")
}
}
}
Divider()
}
ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in
Button {
self.timeline = timeline
} label: {
Label(timeline.localizedTitle(), systemImage: timeline.iconName() ?? "")
}
}
if !currentAccount.lists.isEmpty {
Menu("timeline.filter.lists") {
ForEach(currentAccount.sortedLists) { list in
Button {
timeline = .list(list: list)
} label: {
Label(list.title, systemImage: "list.bullet")
}
}
Button {
routerPath.presentedSheet = .listCreate
} label: {
Label("account.list.create", systemImage: "plus")
}
}
}
if !currentAccount.tags.isEmpty {
Menu("timeline.filter.tags") {
ForEach(currentAccount.sortedTags) { tag in
Button {
timeline = .hashtag(tag: tag.name, accountId: nil)
} label: {
Label("#\(tag.name)", systemImage: "number")
}
}
}
}
Menu("timeline.filter.local") {
ForEach(localTimelines) { remoteLocal in
Button {
timeline = .remoteLocal(server: remoteLocal.instance, filter: .local)
} label: {
VStack {
Label(remoteLocal.instance, systemImage: "dot.radiowaves.right")
}
}
}
Button {
routerPath.presentedSheet = .addRemoteLocalTimeline
} label: {
Label("timeline.filter.add-local", systemImage: "badge.plus.radiowaves.right")
}
}
Menu("timeline.filter.tag-groups") {
ForEach(tagGroups) { group in
Button {
selectedTagGroup = group
timeline = .tagGroup(title: group.title, tags: group.tags)
} label: {
VStack {
let icon = group.symbolName.isEmpty ? "number" : group.symbolName
Label(group.title, systemImage: icon)
}
}
}
Button {
routerPath.presentedSheet = .addTagGroup
} label: {
Label("timeline.filter.add-tag-groups", systemImage: "plus")
}
}
} }
private var addAccountButton: some View { private var addAccountButton: some View {
@ -264,6 +183,136 @@ struct TimelineTab: View {
} }
} }
@ViewBuilder
private var latestOrResumeButtons: some View {
if timeline.supportNewestPagination {
Button {
timeline = .latest
} label: {
Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName() ?? "")
}
if timeline == .home {
Button {
timeline = .resume
} label: {
VStack {
Label(TimelineFilter.resume.localizedTitle(),
systemImage: TimelineFilter.resume.iconName() ?? "")
}
}
}
Divider()
}
}
@ViewBuilder
private var pinMenuButton: some View {
let index = pinnedFilters.firstIndex(where: { $0.id == timeline.id})
Button {
withAnimation {
if let index {
pinnedFilters.remove(at: index)
} else {
pinnedFilters.append(timeline)
}
}
} label: {
if index != nil {
Label("status.action.unpin", systemImage: "pin.slash")
} else {
Label("status.action.pin", systemImage: "pin")
}
}
Divider()
}
private var timelineFiltersButtons: some View {
ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in
Button {
self.timeline = timeline
} label: {
Label(timeline.localizedTitle(), systemImage: timeline.iconName() ?? "")
}
}
}
@ViewBuilder
private var listsFiltersButons: some View {
if !currentAccount.lists.isEmpty {
Menu("timeline.filter.lists") {
ForEach(currentAccount.sortedLists) { list in
Button {
timeline = .list(list: list)
} label: {
Label(list.title, systemImage: "list.bullet")
}
}
Button {
routerPath.presentedSheet = .listCreate
} label: {
Label("account.list.create", systemImage: "plus")
}
}
}
}
@ViewBuilder
private var tagsFiltersButtons: some View {
if !currentAccount.tags.isEmpty {
Menu("timeline.filter.tags") {
ForEach(currentAccount.sortedTags) { tag in
Button {
timeline = .hashtag(tag: tag.name, accountId: nil)
} label: {
Label("#\(tag.name)", systemImage: "number")
}
}
}
}
}
private var localTimelinesFiltersButtons: some View {
Menu("timeline.filter.local") {
ForEach(localTimelines) { remoteLocal in
Button {
timeline = .remoteLocal(server: remoteLocal.instance, filter: .local)
} label: {
VStack {
Label(remoteLocal.instance, systemImage: "dot.radiowaves.right")
}
}
}
Button {
routerPath.presentedSheet = .addRemoteLocalTimeline
} label: {
Label("timeline.filter.add-local", systemImage: "badge.plus.radiowaves.right")
}
}
}
private var tagGroupsFiltersButtons: some View {
Menu("timeline.filter.tag-groups") {
ForEach(tagGroups) { group in
Button {
selectedTagGroup = group
timeline = .tagGroup(title: group.title, tags: group.tags)
} label: {
VStack {
let icon = group.symbolName.isEmpty ? "number" : group.symbolName
Label(group.title, systemImage: icon)
}
}
}
Button {
routerPath.presentedSheet = .addTagGroup
} label: {
Label("timeline.filter.add-tag-groups", systemImage: "plus")
}
}
}
private func resetTimelineFilter() { private func resetTimelineFilter() {
if client.isAuth, canFilterTimeline { if client.isAuth, canFilterTimeline {
timeline = lastTimelineFilter timeline = lastTimelineFilter

View file

@ -29,7 +29,8 @@ public enum RemoteTimelineFilter: String, CaseIterable, Hashable, Equatable {
} }
} }
public enum TimelineFilter: Hashable, Equatable { public enum TimelineFilter: Hashable, Equatable, Identifiable {
case home, local, federated, trending case home, local, federated, trending
case hashtag(tag: String, accountId: String?) case hashtag(tag: String, accountId: String?)
case tagGroup(title: String, tags: [String]) case tagGroup(title: String, tags: [String])
@ -38,6 +39,10 @@ public enum TimelineFilter: Hashable, Equatable {
case latest case latest
case resume case resume
public var id: String {
title
}
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(title) hasher.combine(title)
} }
@ -204,7 +209,7 @@ extension TimelineFilter: Codable {
accountId: accountId accountId: accountId
) )
case .tagGroup: case .tagGroup:
var nestedContainer = try container.nestedUnkeyedContainer(forKey: .hashtag) var nestedContainer = try container.nestedUnkeyedContainer(forKey: .tagGroup)
let title = try nestedContainer.decode(String.self) let title = try nestedContainer.decode(String.self)
let tags = try nestedContainer.decode([String].self) let tags = try nestedContainer.decode([String].self)
self = .tagGroup( self = .tagGroup(
@ -259,7 +264,7 @@ extension TimelineFilter: Codable {
case let .list(list): case let .list(list):
try container.encode(list, forKey: .list) try container.encode(list, forKey: .list)
case let .remoteLocal(server, filter): case let .remoteLocal(server, filter):
var nestedContainer = container.nestedUnkeyedContainer(forKey: .hashtag) var nestedContainer = container.nestedUnkeyedContainer(forKey: .remoteLocal)
try nestedContainer.encode(server) try nestedContainer.encode(server)
try nestedContainer.encode(filter) try nestedContainer.encode(filter)
case .latest: case .latest:

View file

@ -0,0 +1,100 @@
import SwiftUI
import Env
import Models
import DesignSystem
@MainActor
struct TimelineQuickAccessPills: View {
@Environment(Theme.self) private var theme
@Environment(CurrentAccount.self) private var currentAccount
@Binding var pinnedFilters: [TimelineFilter]
@Binding var timeline: TimelineFilter
@State private var draggedFilter: TimelineFilter?
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(pinnedFilters) { filter in
makePill(filter)
}
}
}
.scrollClipDisabled()
.scrollIndicators(.never)
.listRowInsets(EdgeInsets(top: 8, leading: .layoutPadding, bottom: 8, trailing: .layoutPadding))
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
.listRowSeparator(.hidden)
}
@ViewBuilder
private func makePill(_ filter: TimelineFilter) -> some View {
if !isFilterSupport(filter) {
EmptyView()
} else if filter == timeline {
makeButton(filter)
.buttonStyle(.borderedProminent)
} else {
makeButton(filter)
.buttonStyle(.bordered)
}
}
private func makeButton(_ filter: TimelineFilter) -> some View {
Button {
timeline = filter
} label: {
Label(filter.localizedTitle(), systemImage: filter.iconName() ?? "")
.font(.callout)
}
.transition(.push(from: .leading).combined(with: .opacity))
.onDrag {
draggedFilter = filter
return NSItemProvider()
}
.onDrop(of: [.text], delegate: PillDropDelegate(destinationItem: filter,
items: $pinnedFilters,
draggedItem: $draggedFilter))
}
private func isFilterSupport(_ filter: TimelineFilter) -> Bool {
switch filter {
case .list(let list):
return currentAccount.lists.contains(where: { $0.id == list.id })
default:
return true
}
}
}
struct PillDropDelegate: DropDelegate {
let destinationItem: TimelineFilter
@Binding var items: [TimelineFilter]
@Binding var draggedItem: TimelineFilter?
func dropUpdated(info: DropInfo) -> DropProposal? {
return DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
draggedItem = nil
return true
}
func dropEntered(info: DropInfo) {
if let draggedItem {
let fromIndex = items.firstIndex(of: draggedItem)
if let fromIndex {
let toIndex = items.firstIndex(of: destinationItem)
if let toIndex, fromIndex != toIndex {
withAnimation {
self.items.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: (toIndex > fromIndex ? (toIndex + 1) : toIndex))
}
}
}
}
}
}

View file

@ -25,6 +25,7 @@ public struct TimelineView: View {
@State private var collectionView: UICollectionView? @State private var collectionView: UICollectionView?
@Binding var timeline: TimelineFilter @Binding var timeline: TimelineFilter
@Binding var pinnedFilters: [TimelineFilter]
@Binding var selectedTagGroup: TagGroup? @Binding var selectedTagGroup: TagGroup?
@Binding var scrollToTopSignal: Int @Binding var scrollToTopSignal: Int
@ -33,11 +34,13 @@ public struct TimelineView: View {
private let canFilterTimeline: Bool private let canFilterTimeline: Bool
public init(timeline: Binding<TimelineFilter>, public init(timeline: Binding<TimelineFilter>,
pinnedFilters: Binding<[TimelineFilter]>,
selectedTagGroup: Binding<TagGroup?>, selectedTagGroup: Binding<TagGroup?>,
scrollToTopSignal: Binding<Int>, scrollToTopSignal: Binding<Int>,
canFilterTimeline: Bool) canFilterTimeline: Bool)
{ {
_timeline = timeline _timeline = timeline
_pinnedFilters = pinnedFilters
_selectedTagGroup = selectedTagGroup _selectedTagGroup = selectedTagGroup
_scrollToTopSignal = scrollToTopSignal _scrollToTopSignal = scrollToTopSignal
self.canFilterTimeline = canFilterTimeline self.canFilterTimeline = canFilterTimeline
@ -48,7 +51,7 @@ public struct TimelineView: View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
List { List {
scrollToTopView scrollToTopView
TimelineTagGroupheaderView(group: $selectedTagGroup, timeline: $viewModel.timeline) TimelineTagGroupheaderView(group: $selectedTagGroup, timeline: $timeline)
TimelineTagHeaderView(tag: $viewModel.tag) TimelineTagHeaderView(tag: $viewModel.tag)
switch viewModel.timeline { switch viewModel.timeline {
case .remoteLocal: case .remoteLocal:
@ -77,6 +80,14 @@ public struct TimelineView: View {
PendingStatusesObserverView(observer: viewModel.pendingStatusesObserver) PendingStatusesObserverView(observer: viewModel.pendingStatusesObserver)
} }
} }
.safeAreaInset(edge: .top) {
if canFilterTimeline, !pinnedFilters.isEmpty {
TimelineQuickAccessPills(pinnedFilters: $pinnedFilters, timeline: $timeline)
.padding(.vertical, 8)
.padding(.horizontal, .layoutPadding)
.background(theme.primaryBackgroundColor.opacity(0.50).background(Material.ultraThin))
}
}
.onChange(of: viewModel.scrollToIndex) { _, newValue in .onChange(of: viewModel.scrollToIndex) { _, newValue in
if let collectionView, if let collectionView,
let newValue, let newValue,