From b89df3f27ba30f5231d9e8e003e32183b25c92e7 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 18 Apr 2024 00:00:00 -0400 Subject: [PATCH] Add instance announcements Closes #356 --- .../InstanceFeatures/InstanceFeatures.swift | 4 + .../Pachyderm/Model/Announcement.swift | 99 +++++++ .../Sources/Pachyderm/Model/Emoji.swift | 7 +- Tusker.xcodeproj/project.pbxproj | 32 +++ .../Contents.json | 12 + .../face.smiling.badge.plus.svg | 125 +++++++++ .../Announcements/AddReactionView.swift | 191 ++++++++++++++ .../AnnouncementContentTextView.swift | 45 ++++ .../Announcements/AnnouncementListRow.swift | 246 ++++++++++++++++++ .../AnnouncementsCollection.swift | 18 ++ .../AnnouncementsHostingController.swift | 32 +++ .../Announcements/AnnouncementsView.swift | 39 +++ .../NotificationsPageViewController.swift | 81 ++++++ 13 files changed, 930 insertions(+), 1 deletion(-) create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift create mode 100644 Tusker/Assets.xcassets/face.smiling.badge.plus.symbolset/Contents.json create mode 100644 Tusker/Assets.xcassets/face.smiling.badge.plus.symbolset/face.smiling.badge.plus.svg create mode 100644 Tusker/Screens/Announcements/AddReactionView.swift create mode 100644 Tusker/Screens/Announcements/AnnouncementContentTextView.swift create mode 100644 Tusker/Screens/Announcements/AnnouncementListRow.swift create mode 100644 Tusker/Screens/Announcements/AnnouncementsCollection.swift create mode 100644 Tusker/Screens/Announcements/AnnouncementsHostingController.swift create mode 100644 Tusker/Screens/Announcements/AnnouncementsView.swift diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index 86fc5520..1d795a55 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -209,6 +209,10 @@ public final class InstanceFeatures: ObservableObject { } } + public var instanceAnnouncements: Bool { + hasMastodonVersion(3, 1, 0) + } + public init() { } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift new file mode 100644 index 00000000..b4735994 --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift @@ -0,0 +1,99 @@ +// +// Announcement.swift +// Pachyderm +// +// Created by Shadowfacts on 4/16/24. +// + +import Foundation +import WebURL + +public struct Announcement: Decodable, Sendable, Hashable, Identifiable { + public let id: String + public let content: String + public let startsAt: Date? + public let endsAt: Date? + public let allDay: Bool + public let publishedAt: Date + public let updatedAt: Date + public let read: Bool? + public let mentions: [Account] + public let statuses: [Status] + public let tags: [Hashtag] + public let emojis: [Emoji] + public var reactions: [Reaction] + + public static func all() -> Request<[Announcement]> { + return Request(method: .get, path: "/api/v1/announcements") + } + + public static func dismiss(id: String) -> Request { + return Request(method: .post, path: "/api/v1/announcements/\(id)/dismiss") + } + + public static func react(id: String, name: String) -> Request { + return Request(method: .put, path: "/api/v1/announcements/\(id)/reactions/\(name)") + } + + public static func unreact(id: String, name: String) -> Request { + return Request(method: .delete, path: "/api/v1/announcements/\(id)/reactions/\(name)") + } + + enum CodingKeys: String, CodingKey { + case id + case content + case startsAt = "starts_at" + case endsAt = "ends_at" + case allDay = "all_day" + case publishedAt = "published_at" + case updatedAt = "updated_at" + case read + case mentions + case statuses + case tags + case emojis + case reactions + } +} + +extension Announcement { + public struct Account: Decodable, Sendable, Hashable { + public let id: String + public let username: String + public let url: WebURL + public let acct: String + } +} + +extension Announcement { + public struct Status: Decodable, Sendable, Hashable { + public let id: String + public let url: WebURL + } +} + +extension Announcement { + public struct Reaction: Decodable, Sendable, Hashable { + public let name: String + public var count: Int + public var me: Bool? + public let url: URL? + public let staticURL: URL? + + public init(name: String, count: Int, me: Bool?, url: URL?, staticURL: URL?) { + self.name = name + self.count = count + self.me = me + self.url = url + self.staticURL = staticURL + } + + enum CodingKeys: String, CodingKey { + case name + case count + case me + case url + case staticURL = "static_url" + } + } +} diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift index 31163451..da263257 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift @@ -43,8 +43,13 @@ extension Emoji: CustomDebugStringConvertible { } } -extension Emoji: Equatable { +extension Emoji: Equatable, Hashable { public static func ==(lhs: Emoji, rhs: Emoji) -> Bool { return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url } + + public func hash(into hasher: inout Hasher) { + hasher.combine(shortcode) + hasher.combine(url) + } } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 1bdc24fb..0e836ad2 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -227,6 +227,12 @@ D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */; }; D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */; }; D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9F240C8384002843CE /* EmojiLabel.swift */; }; + D698F4672BD079800054DB14 /* AnnouncementsHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */; }; + D698F4692BD0799F0054DB14 /* AnnouncementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */; }; + D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */; }; + D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */; }; + D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */; }; + D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */; }; D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; }; D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; }; D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; }; @@ -650,6 +656,12 @@ D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeSceneDelegate.swift; sourceTree = ""; }; D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTextAttachment+Emoji.swift"; sourceTree = ""; }; D6969E9F240C8384002843CE /* EmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiLabel.swift; sourceTree = ""; }; + D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsHostingController.swift; sourceTree = ""; }; + D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsView.swift; sourceTree = ""; }; + D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementListRow.swift; sourceTree = ""; }; + D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsCollection.swift; sourceTree = ""; }; + D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddReactionView.swift; sourceTree = ""; }; + D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnouncementContentTextView.swift; sourceTree = ""; }; D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = ""; }; D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = ""; }; D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = ""; }; @@ -1055,6 +1067,7 @@ children = ( D65B4B89297879DE00DABDFB /* Account Follows */, D6A3BC822321F69400FD64D5 /* Account List */, + D698F4472BCEE2320054DB14 /* Announcements */, D641C787213DD862004B4513 /* Compose */, D641C785213DD83B004B4513 /* Conversation */, D6F2E960249E772F005846BB /* Crash Reporter */, @@ -1350,6 +1363,19 @@ path = About; sourceTree = ""; }; + D698F4472BCEE2320054DB14 /* Announcements */ = { + isa = PBXGroup; + children = ( + D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */, + D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */, + D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */, + D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */, + D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */, + D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */, + ); + path = Announcements; + sourceTree = ""; + }; D6A3BC822321F69400FD64D5 /* Account List */ = { isa = PBXGroup; children = ( @@ -2131,6 +2157,7 @@ D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */, D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */, D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */, + D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */, D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */, D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */, D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */, @@ -2161,8 +2188,10 @@ D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */, D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */, + D698F4692BD0799F0054DB14 /* AnnouncementsView.swift in Sources */, D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */, D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */, + D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */, D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */, D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */, D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */, @@ -2190,6 +2219,7 @@ D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */, D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, + D698F4672BD079800054DB14 /* AnnouncementsHostingController.swift in Sources */, D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */, D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */, D61DC84628F498F200B82C6E /* Logging.swift in Sources */, @@ -2325,6 +2355,7 @@ D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */, D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */, D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */, + D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */, D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */, D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */, D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */, @@ -2369,6 +2400,7 @@ D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */, D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */, D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */, + D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */, D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */, D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */, D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */, diff --git a/Tusker/Assets.xcassets/face.smiling.badge.plus.symbolset/Contents.json b/Tusker/Assets.xcassets/face.smiling.badge.plus.symbolset/Contents.json new file mode 100644 index 00000000..80f9fe2b --- /dev/null +++ b/Tusker/Assets.xcassets/face.smiling.badge.plus.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "face.smiling.badge.plus.svg", + "idiom" : "universal" + } + ] +} diff --git a/Tusker/Assets.xcassets/face.smiling.badge.plus.symbolset/face.smiling.badge.plus.svg b/Tusker/Assets.xcassets/face.smiling.badge.plus.symbolset/face.smiling.badge.plus.svg new file mode 100644 index 00000000..09e8492f --- /dev/null +++ b/Tusker/Assets.xcassets/face.smiling.badge.plus.symbolset/face.smiling.badge.plus.svg @@ -0,0 +1,125 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.5.0 + Requires Xcode 15 or greater + Generated from + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tusker/Screens/Announcements/AddReactionView.swift b/Tusker/Screens/Announcements/AddReactionView.swift new file mode 100644 index 00000000..5bb8abff --- /dev/null +++ b/Tusker/Screens/Announcements/AddReactionView.swift @@ -0,0 +1,191 @@ +// +// AddReactionView.swift +// Tusker +// +// Created by Shadowfacts on 4/17/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm +import TuskerComponents + +struct AddReactionView: View { + let mastodonController: MastodonController + let addReaction: (Reaction) async throws -> Void + @Environment(\.dismiss) private var dismiss + @ScaledMetric private var emojiSize = 30 + @State private var allEmojis: [Emoji] = [] + @State private var emojisBySection: [String: [Emoji]] = [:] + @State private var query = "" + @State private var error: (any Error)? + + var body: some View { + NavigationView { + ScrollView(.vertical) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) { + if query.count == 1 { + Section { + AddReactionButton { + await doAddReaction(.emoji(query)) + } label: { + Text(query) + .font(.system(size: 25)) + } + .buttonStyle(.plain) + } + } + + ForEach(emojisBySection.keys.sorted(), id: \.self) { section in + Section { + ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in + AddReactionButton { + await doAddReaction(.custom(emoji)) + } label: { + CustomEmojiImageView(emoji: emoji) + .frame(height: emojiSize) + .accessibilityLabel(emoji.shortcode) + } + } + } header: { + if !section.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text(section) + .font(.caption) + + Divider() + } + .padding(.top, 4) + } + } + } + } + .padding(.horizontal) + } + .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always)) + .searchPresentationToolbarBehaviorIfAvailable() + .onChange(of: query) { _ in + updateFilteredEmojis() + } + .navigationTitle("Add Reaction") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(role: .cancel) { + dismiss() + } label: { + Text("Cancel") + } + } + } + } + .navigationViewStyle(.stack) + .mediumPresentationDetentIfAvailable() + .alertWithData("Error Adding Reaction", data: $error, actions: { _ in + Button("OK") {} + }, message: { error in + Text(error.localizedDescription) + }) + .task { + allEmojis = await mastodonController.getCustomEmojis() + updateFilteredEmojis() + } + } + + private func updateFilteredEmojis() { + let filteredEmojis = if !query.isEmpty { + allEmojis.map { emoji -> (Emoji, (matched: Bool, score: Int)) in + (emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode)) + } + .filter(\.1.matched) + .sorted { $0.1.score > $1.1.score } + .map(\.0) + } else { + allEmojis + } + + var shortcodes = Set() + var newEmojis = [Emoji]() + var newEmojisBySection = [String: [Emoji]]() + for emoji in filteredEmojis where !shortcodes.contains(emoji.shortcode) { + newEmojis.append(emoji) + shortcodes.insert(emoji.shortcode) + + let category = emoji.category ?? "" + if newEmojisBySection.keys.contains(category) { + newEmojisBySection[category]!.append(emoji) + } else { + newEmojisBySection[category] = [emoji] + } + } + emojisBySection = newEmojisBySection + } + + private func doAddReaction(_ reaction: Reaction) async { + try! await Task.sleep(nanoseconds: NSEC_PER_SEC) + do { + try await addReaction(reaction) + dismiss() + } catch { + self.error = error + } + } + + enum Reaction { + case emoji(String) + case custom(Emoji) + } +} + +private struct AddReactionButton: View { + let addReaction: () async -> Void + @ViewBuilder let label: Label + @State private var isLoading = false + + var body: some View { + Button { + isLoading = true + Task { + await addReaction() + isLoading = false + } + } label: { + ZStack { + label + .opacity(isLoading ? 0 : 1) + + if isLoading { + ProgressView() + } + } + } + .padding(2) + .hoverEffect() + } +} + +private extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func mediumPresentationDetentIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self.presentationDetents([.medium, .large]) + } else { + self + } + } + + @available(iOS, obsoleted: 17.1) + @ViewBuilder + func searchPresentationToolbarBehaviorIfAvailable() -> some View { + if #available(iOS 17.1, *) { + self.searchPresentationToolbarBehavior(.avoidHidingContent) + } else { + self + } + } +} + +//#Preview { +// AddReactionView() +//} diff --git a/Tusker/Screens/Announcements/AnnouncementContentTextView.swift b/Tusker/Screens/Announcements/AnnouncementContentTextView.swift new file mode 100644 index 00000000..cc9d15db --- /dev/null +++ b/Tusker/Screens/Announcements/AnnouncementContentTextView.swift @@ -0,0 +1,45 @@ +// +// AnnouncementContentTextView.swift +// Tusker +// +// Created by Shadowfacts on 4/16/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm +import WebURL + +class AnnouncementContentTextView: ContentTextView { + + var heightChanged: ((CGFloat) -> Void)? + + private var announcement: Announcement? + + override func layoutSubviews() { + super.layoutSubviews() + + heightChanged?(contentSize.height) + } + + func setTextFrom(announcement: Announcement, content: NSAttributedString) { + self.announcement = announcement + self.attributedText = content + setEmojis(announcement.emojis, identifier: announcement.id) + } + + override func getMention(for url: URL, text: String) -> Mention? { + announcement?.mentions.first { + URL($0.url) == url + }.map { + Mention(url: $0.url, username: $0.username, acct: $0.acct, id: $0.id) + } + } + + override func getHashtag(for url: URL, text: String) -> Hashtag? { + announcement?.tags.first { + URL($0.url) == url + } + } + +} diff --git a/Tusker/Screens/Announcements/AnnouncementListRow.swift b/Tusker/Screens/Announcements/AnnouncementListRow.swift new file mode 100644 index 00000000..646c6edf --- /dev/null +++ b/Tusker/Screens/Announcements/AnnouncementListRow.swift @@ -0,0 +1,246 @@ +// +// AnnouncementListRow.swift +// Tusker +// +// Created by Shadowfacts on 4/17/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm +import TuskerComponents +import WebURLFoundationExtras + +struct AnnouncementListRow: View { + @Binding var announcement: Announcement + let mastodonController: MastodonController + let navigationDelegate: TuskerNavigationDelegate? + let removeAnnouncement: @MainActor () -> Void + @State private var contentTextViewHeight: CGFloat? + @State private var isShowingAddReactionSheet = false + + var body: some View { + if #available(iOS 16.0, *) { + mostOfTheBody + .alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in + dimension[.leading] + }) + } else { + mostOfTheBody + } + } + + private var mostOfTheBody: some View { + VStack { + HStack(alignment: .top) { + AnnouncementContentTextViewRepresentable(announcement: announcement, navigationDelegate: navigationDelegate) { newHeight in + DispatchQueue.main.async { + contentTextViewHeight = newHeight + } + } + .frame(height: contentTextViewHeight) + + Text(announcement.publishedAt, format: .abbreviatedTimeAgo) + .fontWeight(.light) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + + ScrollView(.horizontal) { + LazyHStack { + Button { + isShowingAddReactionSheet = true + } label: { + Label { + Text("Add Reaction") + } icon: { + if #available(iOS 16.0, *) { + Image("face.smiling.badge.plus") + } else { + Image(systemName: "face.smiling") + } + } + } + .labelStyle(.iconOnly) + .padding(4) + .hoverEffect() + + ForEach($announcement.reactions, id: \.name) { $reaction in + ReactionButton(announcement: announcement, reaction: $reaction, mastodonController: mastodonController) + } + } + .frame(height: 32) + .padding(.horizontal, 16) + } + } + .padding(.vertical, 8) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .swipeActions { + Button(role: .destructive) { + Task { + await dismissAnnouncement() + } + } label: { + Text("Dismiss") + } + } + .contextMenu { + Button(role: .destructive) { + Task { + await dismissAnnouncement() + await removeAnnouncement() + } + } label: { + Label("Dismiss", systemImage: "xmark") + } + } + .sheet(isPresented: $isShowingAddReactionSheet) { + AddReactionView(mastodonController: mastodonController, addReaction: self.addReaction) + } + } + + private func dismissAnnouncement() async { + do { + _ = try await mastodonController.run(Announcement.dismiss(id: announcement.id)) + } catch { + Logging.general.error("Error dismissing attachment: \(String(describing: error))") + } + } + + @MainActor + private func addReaction(_ reaction: AddReactionView.Reaction) async throws { + let name = switch reaction { + case .emoji(let s): s + case .custom(let emoji): emoji.shortcode + } + _ = try await mastodonController.run(Announcement.react(id: announcement.id, name: name)) + for (idx, reaction) in announcement.reactions.enumerated() { + if reaction.name == name { + announcement.reactions[idx].me = true + announcement.reactions[idx].count += 1 + return + } + } + let url: URL? + let staticURL: URL? + if case .custom(let emoji) = reaction { + url = URL(emoji.url) + staticURL = URL(emoji.staticURL) + } else { + url = nil + staticURL = nil + } + announcement.reactions.append(.init(name: name, count: 1, me: true, url: url, staticURL: staticURL)) + } +} + +private struct AnnouncementContentTextViewRepresentable: UIViewRepresentable { + let announcement: Announcement + let navigationDelegate: TuskerNavigationDelegate? + let heightChanged: (CGFloat) -> Void + + func makeUIView(context: Context) -> AnnouncementContentTextView { + let view = AnnouncementContentTextView() + view.isScrollEnabled = true + view.backgroundColor = .clear + view.isEditable = false + view.isSelectable = false + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + view.adjustsFontForContentSizeCategory = true + return view + } + + func updateUIView(_ uiView: AnnouncementContentTextView, context: Context) { + uiView.navigationDelegate = navigationDelegate + uiView.setTextFrom(announcement: announcement, content: TimelineStatusCollectionViewCell.htmlConverter.convert(announcement.content)) + uiView.heightChanged = heightChanged + } +} + +private struct ReactionButton: View { + let announcement: Announcement + @Binding var reaction: Announcement.Reaction + let mastodonController: MastodonController + @State private var customEmojiImage: (Image, CGFloat)? + + var body: some View { + Button(action: self.toggleReaction) { + let countStr = reaction.count.formatted(.number) + let title = if reaction.name.count == 1 { + "\(reaction.name) \(countStr)" + } else { + countStr + } + if reaction.url != nil { + Label { + Text(title) + } icon: { + if let (image, aspectRatio) = customEmojiImage { + image.aspectRatio(aspectRatio, contentMode: .fit) + } + } + } else { + Text(title) + } + } + .buttonStyle(TintedButtonStyle(highlighted: reaction.me == true)) + .font(.body.monospacedDigit()) + .hoverEffect() + .task { + if let url = reaction.url, + let image = await ImageCache.emojis.get(url).1 { + let aspectRatio = image.size.width / image.size.height + customEmojiImage = ( + Image(uiImage: image).resizable(), + aspectRatio + ) + } + } + } + + private func toggleReaction() { + if reaction.me == true { + let oldCount = reaction.count + reaction.me = false + reaction.count -= 1 + Task { + do { + _ = try await mastodonController.run(Announcement.unreact(id: announcement.id, name: reaction.name)) + } catch { + reaction.me = true + reaction.count = oldCount + } + } + } else { + let oldCount = reaction.count + reaction.me = true + reaction.count += 1 + Task { + do { + _ = try await mastodonController.run(Announcement.react(id: announcement.id, name: reaction.name)) + } catch { + reaction.me = false + reaction.count = oldCount + } + } + } + } +} + +private struct TintedButtonStyle: ButtonStyle { + let highlighted: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(highlighted ? AnyShapeStyle(.white) : AnyShapeStyle(.tint)) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .frame(height: 32) + .background(.tint.opacity(highlighted ? 1 : 0.2), in: RoundedRectangle(cornerRadius: 4)) + .opacity(configuration.isPressed ? 0.8 : 1) + } +} + +//#Preview { +// AnnouncementListRow() +//} diff --git a/Tusker/Screens/Announcements/AnnouncementsCollection.swift b/Tusker/Screens/Announcements/AnnouncementsCollection.swift new file mode 100644 index 00000000..646881a3 --- /dev/null +++ b/Tusker/Screens/Announcements/AnnouncementsCollection.swift @@ -0,0 +1,18 @@ +// +// AnnouncementsCollection.swift +// Tusker +// +// Created by Shadowfacts on 4/17/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import Foundation +import Pachyderm + +class AnnouncementsCollection: ObservableObject { + @Published var announcements: [Announcement] + + init(announcements: [Announcement]) { + self.announcements = announcements + } +} diff --git a/Tusker/Screens/Announcements/AnnouncementsHostingController.swift b/Tusker/Screens/Announcements/AnnouncementsHostingController.swift new file mode 100644 index 00000000..d734cb46 --- /dev/null +++ b/Tusker/Screens/Announcements/AnnouncementsHostingController.swift @@ -0,0 +1,32 @@ +// +// AnnouncementsHostingController.swift +// Tusker +// +// Created by Shadowfacts on 4/17/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm + +class AnnouncementsHostingController: UIHostingController { + private let mastodonController: MastodonController + + init(announcements: AnnouncementsCollection, mastodonController: MastodonController) { + self.mastodonController = mastodonController + + @Box var boxedSelf: TuskerNavigationDelegate? + super.init(rootView: AnnouncementsView(announcements: announcements, mastodonController: mastodonController, navigationDelegate: _boxedSelf)) + boxedSelf = self + + navigationItem.title = "Announcements" + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension AnnouncementsHostingController: TuskerNavigationDelegate { + nonisolated var apiController: MastodonController! { mastodonController } +} diff --git a/Tusker/Screens/Announcements/AnnouncementsView.swift b/Tusker/Screens/Announcements/AnnouncementsView.swift new file mode 100644 index 00000000..d9573169 --- /dev/null +++ b/Tusker/Screens/Announcements/AnnouncementsView.swift @@ -0,0 +1,39 @@ +// +// AnnouncementsView.swift +// Tusker +// +// Created by Shadowfacts on 4/17/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm + +struct AnnouncementsView: View { + @ObservedObject var state: AnnouncementsCollection + let mastodonController: MastodonController + @Box var navigationDelegate: TuskerNavigationDelegate? + + init(announcements: AnnouncementsCollection, mastodonController: MastodonController, navigationDelegate: Box) { + self.state = announcements + self.mastodonController = mastodonController + self._navigationDelegate = navigationDelegate + } + + var body: some View { + List { + ForEach($state.announcements) { $announcement in + AnnouncementListRow(announcement: $announcement, mastodonController: mastodonController, navigationDelegate: navigationDelegate) { + withAnimation { + state.announcements.removeAll(where: { $0.id == announcement.id }) + } + } + } + } + .listStyle(.grouped) + } +} + +//#Preview { +// AnnouncementsView() +//} diff --git a/Tusker/Screens/Notifications/NotificationsPageViewController.swift b/Tusker/Screens/Notifications/NotificationsPageViewController.swift index e8f2122c..d40cf709 100644 --- a/Tusker/Screens/Notifications/NotificationsPageViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsPageViewController.swift @@ -16,6 +16,22 @@ class NotificationsPageViewController: SegmentedPageViewController