Tag groups (#1506)

* Implemented tag groups

* Cleanup

---------

Co-authored-by: Thomas Ricouard <ricouard77@gmail.com>
This commit is contained in:
Alejandro Martínez 2023-07-19 07:44:35 +02:00 committed by GitHub
parent c4c705aa10
commit 5951bcec38
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 553 additions and 37 deletions

View file

@ -105,6 +105,8 @@
E9DF420129830FEC0003AAD2 /* ActionRequestHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DF420029830FEC0003AAD2 /* ActionRequestHandler.swift */; }; E9DF420129830FEC0003AAD2 /* ActionRequestHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DF420029830FEC0003AAD2 /* ActionRequestHandler.swift */; };
E9DF420329830FEC0003AAD2 /* Action.js in Resources */ = {isa = PBXBuildFile; fileRef = E9DF420229830FEC0003AAD2 /* Action.js */; }; E9DF420329830FEC0003AAD2 /* Action.js in Resources */ = {isa = PBXBuildFile; fileRef = E9DF420229830FEC0003AAD2 /* Action.js */; };
E9DF420729830FEC0003AAD2 /* IceCubesActionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E9DF41FA29830FEC0003AAD2 /* IceCubesActionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E9DF420729830FEC0003AAD2 /* IceCubesActionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E9DF41FA29830FEC0003AAD2 /* IceCubesActionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
FA31A9AB2A66BF7C00D5F662 /* AddTagGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA31A9AA2A66BF7C00D5F662 /* AddTagGroupView.swift */; };
FAD203D02A66D8A80030A7FD /* Symbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD203CF2A66D8A80030A7FD /* Symbols.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -272,6 +274,8 @@
E9DF420229830FEC0003AAD2 /* Action.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Action.js; sourceTree = "<group>"; }; E9DF420229830FEC0003AAD2 /* Action.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Action.js; sourceTree = "<group>"; };
E9DF420429830FEC0003AAD2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; E9DF420429830FEC0003AAD2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
F355EEDA297A8BD500E362C0 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; }; F355EEDA297A8BD500E362C0 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
FA31A9AA2A66BF7C00D5F662 /* AddTagGroupView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddTagGroupView.swift; sourceTree = "<group>"; };
FAD203CF2A66D8A80030A7FD /* Symbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Symbols.swift; sourceTree = "<group>"; };
FF8259FB298E42E000BEAB69 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; }; FF8259FB298E42E000BEAB69 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
FF8259FC298E42E000BEAB69 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = "<group>"; }; FF8259FC298E42E000BEAB69 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -401,6 +405,8 @@
children = ( children = (
9F398AB229360A4C00A889F2 /* TimelineTab.swift */, 9F398AB229360A4C00A889F2 /* TimelineTab.swift */,
9F7335F12967608F00AFF0BA /* AddRemoteTimelineView.swift */, 9F7335F12967608F00AFF0BA /* AddRemoteTimelineView.swift */,
FA31A9AA2A66BF7C00D5F662 /* AddTagGroupView.swift */,
FAD203CF2A66D8A80030A7FD /* Symbols.swift */,
); );
path = Timeline; path = Timeline;
sourceTree = "<group>"; sourceTree = "<group>";
@ -829,6 +835,8 @@
9F7335F92968576500AFF0BA /* DisplaySettingsView.swift in Sources */, 9F7335F92968576500AFF0BA /* DisplaySettingsView.swift in Sources */,
9F2A540729699698009B2D7C /* SupportAppView.swift in Sources */, 9F2A540729699698009B2D7C /* SupportAppView.swift in Sources */,
9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */, 9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */,
FA31A9AB2A66BF7C00D5F662 /* AddTagGroupView.swift in Sources */,
FAD203D02A66D8A80030A7FD /* Symbols.swift in Sources */,
9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */, 9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */,
9F398AA62935FE8A00A889F2 /* AppRouter.swift in Sources */, 9F398AA62935FE8A00A889F2 /* AppRouter.swift in Sources */,
9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */, 9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */,

View file

@ -82,6 +82,9 @@ extension View {
case .addRemoteLocalTimeline: case .addRemoteLocalTimeline:
AddRemoteTimelineView() AddRemoteTimelineView()
.withEnvironments() .withEnvironments()
case .addTagGroup:
AddTagGroupView()
.withEnvironments()
case let .statusEditHistory(status): case let .statusEditHistory(status):
StatusEditHistoryView(statusId: status) StatusEditHistoryView(statusId: status)
.withEnvironments() .withEnvironments()

View file

@ -139,6 +139,9 @@ struct SettingsTabs: View {
NavigationLink(destination: remoteLocalTimelinesView) { NavigationLink(destination: remoteLocalTimelinesView) {
Label("settings.general.remote-timelines", systemImage: "dot.radiowaves.right") Label("settings.general.remote-timelines", systemImage: "dot.radiowaves.right")
} }
NavigationLink(destination: tagGroupsView) {
Label("timeline.filter.tag-groups", systemImage: "number")
}
NavigationLink(destination: ContentSettingsView()) { NavigationLink(destination: ContentSettingsView()) {
Label("settings.general.content", systemImage: "rectangle.stack") Label("settings.general.content", systemImage: "rectangle.stack")
} }
@ -261,6 +264,36 @@ struct SettingsTabs: View {
} }
} }
} }
private var tagGroupsView: some View {
Form {
ForEach(preferences.tagGroups, id: \.self) { group in
Text(group.title)
}
.onDelete { indexes in
if let index = indexes.first {
_ = preferences.tagGroups.remove(at: index)
}
}
.onMove { source, destination in
preferences.tagGroups.move(fromOffsets: source, toOffset: destination)
}
.listRowBackground(theme.primaryBackgroundColor)
Button {
routerPath.presentedSheet = .addTagGroup
} label: {
Label("timeline.filter.add-tag-groups", systemImage: "plus")
}
.listRowBackground(theme.primaryBackgroundColor)
}
.navigationTitle("timeline.filter.tag-groups")
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.toolbar {
EditButton()
}
}
private var remoteLocalTimelinesView: some View { private var remoteLocalTimelinesView: some View {
Form { Form {

View file

@ -0,0 +1,197 @@
import Combine
import DesignSystem
import Env
import Models
import Network
import NukeUI
import Shimmer
import SwiftUI
struct AddTagGroupView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme
@State private var title: String = ""
@State private var sfSymbolName: String = ""
@State private var tags: [String] = []
@State private var newTag: String = ""
private var canSave: Bool {
!title.isEmpty &&
// At least have 2 tags, one main and one additional.
tags.count >= 2
}
@FocusState private var focusedField: Focus?
enum Focus {
case title
case symbol
case new
}
var body: some View {
NavigationStack {
Form {
metadataSection
keywordsSection
}
.formStyle(.grouped)
.navigationTitle("timeline.filter.add-tag-groups")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.scrollDismissesKeyboard(.immediately)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel", action: { dismiss() })
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("action.save", action: { save() })
.disabled(!canSave)
}
}
.onAppear {
focusedField = .title
}
.overlay(alignment: .bottom) {
symbolsSuggestionView
}
}
}
@ViewBuilder
private var metadataSection: some View {
Section {
TextField("add-tag-groups.edit.title.field", text: $title, axis: .horizontal)
.focused($focusedField, equals: Focus.title)
.onSubmit {
focusedField = Focus.symbol
}
HStack {
TextField("add-tag-groups.edit.icon.field", text: $sfSymbolName, axis: .horizontal)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: Focus.symbol)
.onSubmit {
focusedField = Focus.new
}
.onChange(of: sfSymbolName) { name in
popupTagsPresented = true
}
Image(systemName: sfSymbolName)
}
}
}
@State private var popupTagsPresented = false
private var keywordsSection: some View {
Section("add-tag-groups.edit.tags") {
ForEach(tags, id: \.self) { tag in
HStack {
Text(tag)
Spacer()
Button {
deleteTag(tag)
} label: {
Image(systemName: "trash")
.tint(.red)
}
}
}
.onDelete { indexes in
if let index = indexes.first {
let tag = tags[index]
deleteTag(tag)
}
}
HStack {
TextField("add-tag-groups.edit.tags.add", text: $newTag, axis: .horizontal)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit {
addNewTag()
}
.focused($focusedField, equals: Focus.new)
Spacer()
if !newTag.isEmpty {
Button {
addNewTag()
} label: {
Image(systemName: "checkmark.circle.fill")
.tint(.green)
}
}
}
}
.listRowBackground(theme.primaryBackgroundColor)
}
private func addNewTag() {
addTag(newTag.trimmingCharacters(in: .whitespaces))
newTag = ""
focusedField = Focus.new
}
private func addTag(_ tag: String) {
guard !tag.isEmpty else { return }
tags.append(tag)
}
private func deleteTag(_ tag: String) {
tags.removeAll(where: { $0 == tag })
}
private func save() {
var toSave = tags
let main = toSave.removeFirst()
preferences.tagGroups.append(.init(
title: title.trimmingCharacters(in: .whitespaces),
sfSymbolName: sfSymbolName,
main: main,
additional: toSave
))
dismiss()
}
@ViewBuilder
private var symbolsSuggestionView: some View {
if focusedField == .symbol && !sfSymbolName.isEmpty {
let filteredMatches = allSymbols
.filter { $0.contains(sfSymbolName) }
if !filteredMatches.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(filteredMatches, id: \.self) { symbolName in
Button {
sfSymbolName = symbolName
} label: {
Image(systemName: symbolName)
}
}
}
.padding(.horizontal, .layoutPadding)
}
.frame(height: 40)
.background(.ultraThinMaterial)
}
} else {
EmptyView()
}
}
}
struct AddTagGroupView_Previews: PreviewProvider {
static var previews: some View {
AddTagGroupView()
.withEnvironments()
}
}

View file

@ -0,0 +1,19 @@
//
// Symbols.swift
// IceCubesApp
//
// Created by Alejandro Martinez on 18/7/23.
//
import Foundation
let allSymbols: [String] = {
if let bundle = Bundle(identifier: "com.apple.CoreGlyphs"),
let resourcePath = bundle.path(forResource: "symbol_search", ofType: "plist"),
let plist = NSDictionary(contentsOfFile: resourcePath) {
return plist.allKeys as? [String] ?? []
}
return []
}()

View file

@ -151,6 +151,25 @@ struct TimelineTab: View {
Label("timeline.filter.add-local", systemImage: "badge.plus.radiowaves.right") Label("timeline.filter.add-local", systemImage: "badge.plus.radiowaves.right")
} }
} }
Menu("timeline.filter.tag-groups") {
ForEach(preferences.tagGroups, id: \.self) { group in
Button {
timeline = .tagGroup(group)
} label: {
VStack {
let icon = group.sfSymbolName.isEmpty ? "number" : group.sfSymbolName
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 {

View file

@ -245,6 +245,8 @@
"timeline.filter.lists" = "Спісы"; "timeline.filter.lists" = "Спісы";
"timeline.filter.local" = "Мясцовыя шкалы часу"; "timeline.filter.local" = "Мясцовыя шкалы часу";
"timeline.filter.tags" = "Адсочваемыя тэгі"; "timeline.filter.tags" = "Адсочваемыя тэгі";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Дадаць уліковы запіс"; "app-account.button.add" = "Дадаць уліковы запіс";
@ -603,3 +605,9 @@
"tag.suggested.mentions-%lld" = "%lld mentions"; "tag.suggested.mentions-%lld" = "%lld mentions";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -239,6 +239,8 @@
"timeline.filter.lists" = "Llistes"; "timeline.filter.lists" = "Llistes";
"timeline.filter.local" = "Línies de temps locals"; "timeline.filter.local" = "Línies de temps locals";
"timeline.filter.tags" = "Etiquetes seguides"; "timeline.filter.tags" = "Etiquetes seguides";
"timeline.filter.tag-groups" = "Grups d'Etiquetes";
"timeline.filter.add-tag-groups" = "Afegeix grup";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Afegeix un compte"; "app-account.button.add" = "Afegeix un compte";
@ -596,3 +598,15 @@
"status.action.report" = "Report Post"; "status.action.report" = "Report Post";
"tag.suggested.mentions-%lld" = "%lld mentions"; "tag.suggested.mentions-%lld" = "%lld mentions";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Títol del Grup d'Etiquetes";
"add-tag-groups.edit.icon.field" = "Icona del Grup d'Etiquetes (SFSymbol)";
"add-tag-groups.edit.tags" = "Afegeix etiquetes al grup";
"add-tag-groups.edit.tags.add" = "Etiqueta";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -242,6 +242,8 @@
"timeline.filter.lists" = "Listen"; "timeline.filter.lists" = "Listen";
"timeline.filter.local" = "Lokale Timelines"; "timeline.filter.local" = "Lokale Timelines";
"timeline.filter.tags" = "Gefolgte Hashtags"; "timeline.filter.tags" = "Gefolgte Hashtags";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Konto hinzufügen"; "app-account.button.add" = "Konto hinzufügen";
@ -583,3 +585,9 @@
"report.title" = "Beitrag melden"; "report.title" = "Beitrag melden";
"report.action.send" = "Absenden"; "report.action.send" = "Absenden";
"status.action.report" = "Beitrag melden"; "status.action.report" = "Beitrag melden";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -243,6 +243,8 @@
"timeline.filter.lists" = "Lists"; "timeline.filter.lists" = "Lists";
"timeline.filter.local" = "Local Timelines"; "timeline.filter.local" = "Local Timelines";
"timeline.filter.tags" = "Followed Tags"; "timeline.filter.tags" = "Followed Tags";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Add Account"; "app-account.button.add" = "Add Account";
@ -597,3 +599,9 @@
"status.action.report" = "Report Post"; "status.action.report" = "Report Post";
"tag.suggested.mentions-%lld" = "%lld mentions"; "tag.suggested.mentions-%lld" = "%lld mentions";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -241,6 +241,8 @@
"timeline.filter.lists" = "Lists"; "timeline.filter.lists" = "Lists";
"timeline.filter.local" = "Local Timelines"; "timeline.filter.local" = "Local Timelines";
"timeline.filter.tags" = "Followed Tags"; "timeline.filter.tags" = "Followed Tags";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Add Account"; "app-account.button.add" = "Add Account";
@ -599,3 +601,9 @@
"status.action.report" = "Report Post"; "status.action.report" = "Report Post";
"tag.suggested.mentions-%lld" = "%lld mentions"; "tag.suggested.mentions-%lld" = "%lld mentions";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -241,6 +241,8 @@
"timeline.filter.lists" = "Listas"; "timeline.filter.lists" = "Listas";
"timeline.filter.local" = "Cronologías locales"; "timeline.filter.local" = "Cronologías locales";
"timeline.filter.tags" = "Etiquetas que sigues"; "timeline.filter.tags" = "Etiquetas que sigues";
"timeline.filter.tag-groups" = "Grupos de Etiquetas";
"timeline.filter.add-tag-groups" = "Añadir grupo";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Añadir cuenta"; "app-account.button.add" = "Añadir cuenta";
@ -598,3 +600,9 @@
"status.action.report" = "Denunciar publicación"; "status.action.report" = "Denunciar publicación";
"tag.suggested.mentions-%lld" = "%lld menciones"; "tag.suggested.mentions-%lld" = "%lld menciones";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Título del Grupo de Etiquetas";
"add-tag-groups.edit.icon.field" = "Icono del Grupo de Etiquetas (SFSymbol)";
"add-tag-groups.edit.tags" = "Añade etiquetas al grupo";
"add-tag-groups.edit.tags.add" = "Etiqueta";

View file

@ -242,6 +242,8 @@
"timeline.filter.lists" = "Zerrendak"; "timeline.filter.lists" = "Zerrendak";
"timeline.filter.local" = "Denbora-lerro lokalak"; "timeline.filter.local" = "Denbora-lerro lokalak";
"timeline.filter.tags" = "Jarraitutako traolak"; "timeline.filter.tags" = "Jarraitutako traolak";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Gehitu kontua"; "app-account.button.add" = "Gehitu kontua";
@ -584,3 +586,9 @@
"report.title" = "Salaketa"; "report.title" = "Salaketa";
"report.action.send" = "Bidali"; "report.action.send" = "Bidali";
"status.action.report" = "Salatu edukia"; "status.action.report" = "Salatu edukia";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -240,6 +240,8 @@
"timeline.filter.lists" = "Listes"; "timeline.filter.lists" = "Listes";
"timeline.filter.local" = "Chronologies locales"; "timeline.filter.local" = "Chronologies locales";
"timeline.filter.tags" = "Tags suivis"; "timeline.filter.tags" = "Tags suivis";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Ajouter un compte"; "app-account.button.add" = "Ajouter un compte";
@ -593,3 +595,9 @@
"status.action.report" = "Signaler la publication"; "status.action.report" = "Signaler la publication";
"tag.suggested.mentions-%lld" = "%lld mentions"; "tag.suggested.mentions-%lld" = "%lld mentions";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -239,6 +239,8 @@
"timeline.filter.lists" = "Liste"; "timeline.filter.lists" = "Liste";
"timeline.filter.local" = "Timeline locali"; "timeline.filter.local" = "Timeline locali";
"timeline.filter.tags" = "Tag seguiti"; "timeline.filter.tags" = "Tag seguiti";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Aggiungi account"; "app-account.button.add" = "Aggiungi account";
@ -597,3 +599,9 @@
"status.action.report" = "Segnala il messaggio"; "status.action.report" = "Segnala il messaggio";
"tag.suggested.mentions-%lld" = "%lld mentions"; "tag.suggested.mentions-%lld" = "%lld mentions";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -240,6 +240,8 @@
"timeline.filter.lists" = "リスト"; "timeline.filter.lists" = "リスト";
"timeline.filter.local" = "ローカルタイムライン"; "timeline.filter.local" = "ローカルタイムライン";
"timeline.filter.tags" = "フォローしたタグ"; "timeline.filter.tags" = "フォローしたタグ";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "アカウントの追加"; "app-account.button.add" = "アカウントの追加";
@ -596,3 +598,9 @@
"status.action.report" = "投稿を報告"; "status.action.report" = "投稿を報告";
"tag.suggested.mentions-%lld" = "返信:%lld"; "tag.suggested.mentions-%lld" = "返信:%lld";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -241,6 +241,8 @@
"timeline.filter.local" = "원격 로컬 타임라인"; "timeline.filter.local" = "원격 로컬 타임라인";
"timeline.filter.tags" = "팔로우한 태그"; "timeline.filter.tags" = "팔로우한 태그";
"timeline-new-posts %lld" = "%lld개 새 글"; "timeline-new-posts %lld" = "%lld개 새 글";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "계정 추가"; "app-account.button.add" = "계정 추가";
@ -600,3 +602,9 @@
"status.action.report" = "글 신고"; "status.action.report" = "글 신고";
"tag.suggested.mentions-%lld" = "%lld개 글"; "tag.suggested.mentions-%lld" = "%lld개 글";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -240,6 +240,8 @@
"timeline.filter.lists" = "Lister"; "timeline.filter.lists" = "Lister";
"timeline.filter.local" = "Lokale tidslinjer"; "timeline.filter.local" = "Lokale tidslinjer";
"timeline.filter.tags" = "Fulgte tagger"; "timeline.filter.tags" = "Fulgte tagger";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Legg til konto"; "app-account.button.add" = "Legg til konto";
@ -597,3 +599,9 @@
"status.action.report" = "Rapporter innlegg"; "status.action.report" = "Rapporter innlegg";
"tag.suggested.mentions-%lld" = "%lld omtaler"; "tag.suggested.mentions-%lld" = "%lld omtaler";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -237,6 +237,8 @@
"timeline.filter.lists" = "Lijsten"; "timeline.filter.lists" = "Lijsten";
"timeline.filter.local" = "Lokale tijdlijnen"; "timeline.filter.local" = "Lokale tijdlijnen";
"timeline.filter.tags" = "Gevolgde hashtags"; "timeline.filter.tags" = "Gevolgde hashtags";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Account toevoegen"; "app-account.button.add" = "Account toevoegen";
@ -594,3 +596,9 @@
"status.action.report" = "Meld post"; "status.action.report" = "Meld post";
"tag.suggested.mentions-%lld" = "%lld vermeldingen"; "tag.suggested.mentions-%lld" = "%lld vermeldingen";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -240,6 +240,8 @@
"timeline.filter.lists" = "Listy"; "timeline.filter.lists" = "Listy";
"timeline.filter.local" = "Strumienie lokalne"; "timeline.filter.local" = "Strumienie lokalne";
"timeline.filter.tags" = "Obserwowane hasztagi"; "timeline.filter.tags" = "Obserwowane hasztagi";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Dodaj konto"; "app-account.button.add" = "Dodaj konto";
@ -587,3 +589,9 @@
"report.action.send" = "Wyślij"; "report.action.send" = "Wyślij";
"status.action.report" = "Zgłoś post"; "status.action.report" = "Zgłoś post";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -240,6 +240,8 @@
"timeline.filter.lists" = "Listas"; "timeline.filter.lists" = "Listas";
"timeline.filter.local" = "Timelines Locais"; "timeline.filter.local" = "Timelines Locais";
"timeline.filter.tags" = "Hashtags Seguidas"; "timeline.filter.tags" = "Hashtags Seguidas";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Adicionar Conta"; "app-account.button.add" = "Adicionar Conta";
@ -597,3 +599,9 @@
"status.action.report" = "Denunciar"; "status.action.report" = "Denunciar";
"tag.suggested.mentions-%lld" = "%lld menções"; "tag.suggested.mentions-%lld" = "%lld menções";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -240,6 +240,8 @@
"timeline.filter.lists" = "Listeler"; "timeline.filter.lists" = "Listeler";
"timeline.filter.local" = "Yerel Zaman Dilimleri"; "timeline.filter.local" = "Yerel Zaman Dilimleri";
"timeline.filter.tags" = "Takip Edilen Etiketler"; "timeline.filter.tags" = "Takip Edilen Etiketler";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Hesap Ekle"; "app-account.button.add" = "Hesap Ekle";
@ -597,3 +599,9 @@
"status.action.report" = "Report Post"; "status.action.report" = "Report Post";
"tag.suggested.mentions-%lld" = "%lld mentions"; "tag.suggested.mentions-%lld" = "%lld mentions";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -241,6 +241,8 @@
"timeline.filter.lists" = "Списки"; "timeline.filter.lists" = "Списки";
"timeline.filter.local" = "Локальна стрічка"; "timeline.filter.local" = "Локальна стрічка";
"timeline.filter.tags" = "Хештеґи"; "timeline.filter.tags" = "Хештеґи";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "Додати обліковий запис"; "app-account.button.add" = "Додати обліковий запис";
@ -599,3 +601,9 @@
"tag.suggested.mentions-%lld" = "%lld згадок"; "tag.suggested.mentions-%lld" = "%lld згадок";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -238,6 +238,8 @@
"timeline.filter.lists" = "列表"; "timeline.filter.lists" = "列表";
"timeline.filter.local" = "远程时间线"; "timeline.filter.local" = "远程时间线";
"timeline.filter.tags" = "关注的标签"; "timeline.filter.tags" = "关注的标签";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "添加账户"; "app-account.button.add" = "添加账户";
@ -597,3 +599,9 @@
"status.action.report" = "举报嘟文"; "status.action.report" = "举报嘟文";
"tag.suggested.mentions-%lld" = "%lld 个提及"; "tag.suggested.mentions-%lld" = "%lld 个提及";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -242,6 +242,8 @@
"timeline.filter.lists" = "列表"; "timeline.filter.lists" = "列表";
"timeline.filter.local" = "本站時間軸"; "timeline.filter.local" = "本站時間軸";
"timeline.filter.tags" = "跟隨標籤"; "timeline.filter.tags" = "跟隨標籤";
"timeline.filter.tag-groups" = "Tag Groups";
"timeline.filter.add-tag-groups" = "Add tag group";
// MARK: Package: AppAccount // MARK: Package: AppAccount
"app-account.button.add" = "新增帳號"; "app-account.button.add" = "新增帳號";
@ -599,3 +601,9 @@
"status.action.report" = "檢舉嘟文"; "status.action.report" = "檢舉嘟文";
"tag.suggested.mentions-%lld" = "%lld 提及"; "tag.suggested.mentions-%lld" = "%lld 提及";
// MARK: Tag Groups
"add-tag-groups.edit.title.field" = "Tag Group Title";
"add-tag-groups.edit.icon.field" = "Tag Group Icon (SFSymbol name)";
"add-tag-groups.edit.tags" = "Add tags to the group";
"add-tag-groups.edit.tags.add" = "Tag";

View file

@ -33,6 +33,7 @@ public enum SheetDestination: Identifiable {
case listAddAccount(account: Account) case listAddAccount(account: Account)
case addAccount case addAccount
case addRemoteLocalTimeline case addRemoteLocalTimeline
case addTagGroup
case statusEditHistory(status: String) case statusEditHistory(status: String)
case settings case settings
case accountPushNotficationsSettings case accountPushNotficationsSettings
@ -50,6 +51,8 @@ public enum SheetDestination: Identifiable {
return "listAddAccount" return "listAddAccount"
case .addAccount: case .addAccount:
return "addAccount" return "addAccount"
case .addTagGroup:
return "addTagGroup"
case .addRemoteLocalTimeline: case .addRemoteLocalTimeline:
return "addRemoteLocalTimeline" return "addRemoteLocalTimeline"
case .statusEditHistory: case .statusEditHistory:

View file

@ -12,6 +12,7 @@ public class UserPreferences: ObservableObject {
private var client: Client? private var client: Client?
@AppStorage("remote_local_timeline") public var remoteLocalTimelines: [String] = [] @AppStorage("remote_local_timeline") public var remoteLocalTimelines: [String] = []
@AppStorage("tag_groups") public var tagGroups: [TagGroup] = []
@AppStorage("preferred_browser") public var preferredBrowser: PreferredBrowser = .inAppSafari @AppStorage("preferred_browser") public var preferredBrowser: PreferredBrowser = .inAppSafari
@AppStorage("draft_posts") public var draftsPosts: [String] = [] @AppStorage("draft_posts") public var draftsPosts: [String] = []
@AppStorage("show_translate_button_inline") public var showTranslateButton: Bool = true @AppStorage("show_translate_button_inline") public var showTranslateButton: Bool = true

View file

@ -62,3 +62,29 @@ public struct FeaturedTag: Codable, Identifiable {
extension Tag: Sendable {} extension Tag: Sendable {}
extension Tag.History: Sendable {} extension Tag.History: Sendable {}
extension FeaturedTag: Sendable {} extension FeaturedTag: Sendable {}
public struct TagGroup: Codable, Equatable, Hashable {
public init(title: String, sfSymbolName: String, main: String, additional: [String]) {
self.title = title
self.sfSymbolName = sfSymbolName
self.main = main
self.additional = additional
}
public let title: String
public let sfSymbolName: String
public let main: String
public let additional: [String]
public var tags: [String] {
[main] + additional
}
public var description: String {
tags
.map { "#\($0)" }
.joined(separator: " ")
}
}

View file

@ -4,7 +4,7 @@ public enum Timelines: Endpoint {
case pub(sinceId: String?, maxId: String?, minId: String?, local: Bool) case pub(sinceId: String?, maxId: String?, minId: String?, local: Bool)
case home(sinceId: String?, maxId: String?, minId: String?) case home(sinceId: String?, maxId: String?, minId: String?)
case list(listId: String, sinceId: String?, maxId: String?, minId: String?) case list(listId: String, sinceId: String?, maxId: String?, minId: String?)
case hashtag(tag: String, maxId: String?) case hashtag(tag: String, additional: [String]?, maxId: String?)
public func path() -> String { public func path() -> String {
switch self { switch self {
@ -14,11 +14,11 @@ public enum Timelines: Endpoint {
return "timelines/home" return "timelines/home"
case let .list(listId, _, _, _): case let .list(listId, _, _, _):
return "timelines/list/\(listId)" return "timelines/list/\(listId)"
case let .hashtag(tag, _): case let .hashtag(tag, _, _):
return "timelines/tag/\(tag)" return "timelines/tag/\(tag)"
} }
} }
public func queryItems() -> [URLQueryItem]? { public func queryItems() -> [URLQueryItem]? {
switch self { switch self {
case let .pub(sinceId, maxId, minId, local): case let .pub(sinceId, maxId, minId, local):
@ -29,8 +29,11 @@ public enum Timelines: Endpoint {
return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId) return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId)
case let .list(_, sinceId, maxId, mindId): case let .list(_, sinceId, maxId, mindId):
return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId) return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId)
case let .hashtag(_, maxId): case let .hashtag(_, additional, maxId):
return makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil) var params = makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil) ?? []
params.append(contentsOf: (additional ?? [])
.map { URLQueryItem(name: "any[]", value: $0) })
return params
} }
} }
} }

View file

@ -31,7 +31,8 @@ public enum RemoteTimelineFilter: String, CaseIterable, Hashable, Equatable {
public enum TimelineFilter: Hashable, Equatable { public enum TimelineFilter: Hashable, Equatable {
case home, local, federated, trending case home, local, federated, trending
case hashtag(tag: String, accountId: String?) case hashtag(tag: String, accountId: String?)
case tagGroup(TagGroup)
case list(list: Models.List) case list(list: Models.List)
case remoteLocal(server: String, filter: RemoteTimelineFilter) case remoteLocal(server: String, filter: RemoteTimelineFilter)
case latest case latest
@ -72,6 +73,8 @@ public enum TimelineFilter: Hashable, Equatable {
return "Home" return "Home"
case let .hashtag(tag, _): case let .hashtag(tag, _):
return "#\(tag)" return "#\(tag)"
case let .tagGroup(group):
return group.title
case let .list(list): case let .list(list):
return list.title return list.title
case let .remoteLocal(server, _): case let .remoteLocal(server, _):
@ -93,6 +96,8 @@ public enum TimelineFilter: Hashable, Equatable {
return "timeline.home" return "timeline.home"
case let .hashtag(tag, _): case let .hashtag(tag, _):
return "#\(tag)" return "#\(tag)"
case let .tagGroup(group):
return LocalizedStringKey(group.title) // ?? not sure since this can't be localized.
case let .list(list): case let .list(list):
return LocalizedStringKey(list.title) return LocalizedStringKey(list.title)
case let .remoteLocal(server, _): case let .remoteLocal(server, _):
@ -142,8 +147,10 @@ public enum TimelineFilter: Hashable, Equatable {
if let accountId { if let accountId {
return Accounts.statuses(id: accountId, sinceId: nil, tag: tag, onlyMedia: nil, excludeReplies: nil, pinned: nil) return Accounts.statuses(id: accountId, sinceId: nil, tag: tag, onlyMedia: nil, excludeReplies: nil, pinned: nil)
} else { } else {
return Timelines.hashtag(tag: tag, maxId: maxId) return Timelines.hashtag(tag: tag, additional: nil, maxId: maxId)
} }
case let .tagGroup(group):
return Timelines.hashtag(tag: group.main, additional: group.additional, maxId: maxId)
} }
} }
} }
@ -155,6 +162,7 @@ extension TimelineFilter: Codable {
case federated case federated
case trending case trending
case hashtag case hashtag
case tagGroup
case list case list
case remoteLocal case remoteLocal
case latest case latest
@ -180,6 +188,9 @@ extension TimelineFilter: Codable {
tag: tag, tag: tag,
accountId: accountId accountId: accountId
) )
case .tagGroup:
let group = try container.decode(TagGroup.self, forKey: .tagGroup)
self = .tagGroup(group)
case .list: case .list:
let list = try container.decode( let list = try container.decode(
Models.List.self, Models.List.self,
@ -221,6 +232,8 @@ extension TimelineFilter: Codable {
var nestedContainer = container.nestedUnkeyedContainer(forKey: .hashtag) var nestedContainer = container.nestedUnkeyedContainer(forKey: .hashtag)
try nestedContainer.encode(tag) try nestedContainer.encode(tag)
try nestedContainer.encode(accountId) try nestedContainer.encode(accountId)
case let .tagGroup(group):
try container.encode(group, forKey: .tagGroup)
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):

View file

@ -39,7 +39,9 @@ public struct TimelineView: View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ZStack(alignment: .top) { ZStack(alignment: .top) {
List { List {
if viewModel.tag == nil { if viewModel.tagGroup != nil {
tagGroupHeaderView
} else if viewModel.tag == nil {
scrollToTopView scrollToTopView
} else { } else {
tagHeaderView tagHeaderView
@ -180,40 +182,64 @@ public struct TimelineView: View {
@ViewBuilder @ViewBuilder
private var tagHeaderView: some View { private var tagHeaderView: some View {
if let tag = viewModel.tag { if let tag = viewModel.tag {
VStack(alignment: .leading) { headerView {
Spacer() HStack {
HStack { VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 4) { Text("#\(tag.name)")
Text("#\(tag.name)") .font(.scaledHeadline)
.font(.scaledHeadline) Text("timeline.n-recent-from-n-participants \(tag.totalUses) \(tag.totalAccounts)")
Text("timeline.n-recent-from-n-participants \(tag.totalUses) \(tag.totalAccounts)") .font(.scaledFootnote)
.font(.scaledFootnote) .foregroundColor(.gray)
.foregroundColor(.gray)
}
.accessibilityElement(children: .combine)
Spacer()
Button {
Task {
if tag.following {
viewModel.tag = await account.unfollowTag(id: tag.name)
} else {
viewModel.tag = await account.followTag(id: tag.name)
} }
.accessibilityElement(children: .combine)
Spacer()
Button {
Task {
if tag.following {
viewModel.tag = await account.unfollowTag(id: tag.name)
} else {
viewModel.tag = await account.followTag(id: tag.name)
}
}
} label: {
Text(tag.following ? "account.follow.following" : "account.follow.follow")
}.buttonStyle(.bordered)
} }
} label: {
Text(tag.following ? "account.follow.following" : "account.follow.follow")
}.buttonStyle(.bordered)
} }
Spacer()
}
.listRowBackground(theme.secondaryBackgroundColor)
.listRowSeparator(.hidden)
.listRowInsets(.init(top: 8,
leading: .layoutPadding,
bottom: 8,
trailing: .layoutPadding))
} }
} }
@ViewBuilder
private var tagGroupHeaderView: some View {
if let group = viewModel.tagGroup {
headerView {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(group.description)
.font(.scaledHeadline)
}
.accessibilityElement(children: .combine)
}
}
}
}
@ViewBuilder
private func headerView(
@ViewBuilder content: () -> some View
) -> some View {
VStack(alignment: .leading) {
Spacer()
content()
Spacer()
}
.listRowBackground(theme.secondaryBackgroundColor)
.listRowSeparator(.hidden)
.listRowInsets(.init(top: 8,
leading: .layoutPadding,
bottom: 8,
trailing: .layoutPadding))
}
private var scrollToTopView: some View { private var scrollToTopView: some View {
HStack { EmptyView() } HStack { EmptyView() }

View file

@ -40,6 +40,13 @@ class TimelineViewModel: ObservableObject {
private var timelineTask: Task<Void, Never>? private var timelineTask: Task<Void, Never>?
@Published var tag: Tag? @Published var tag: Tag?
var tagGroup: TagGroup? {
if case let .tagGroup(group) = timeline {
return group
}
return nil
}
// Internal source of truth for a timeline. // Internal source of truth for a timeline.
private var datasource = TimelineDatasource() private var datasource = TimelineDatasource()