// // 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() //}