diff --git a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index a608acf3..36b89fc6 100644 --- a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,38 +3,4 @@ uuid = "3C51FC77-CE2B-4F32-B3F3-96CDC8C1DACC" type = "0" version = "2.0"> - - - - - - - - - - diff --git a/Paicord/App/PaicordAppState.swift b/Paicord/App/PaicordAppState.swift index ffa249af..5edb2638 100644 --- a/Paicord/App/PaicordAppState.swift +++ b/Paicord/App/PaicordAppState.swift @@ -37,6 +37,12 @@ final class PaicordAppState { // MARK: - General var showingQuickSwitcher: Bool = false + + // MARK: - Attachment Viewer + var showingAttachmentViewer: Bool = false + var attachmentViewerAttachments: [DiscordMedia] = [] + var attachmentViewerIndex: Int? = nil + var attachmentViewerContextMessage: DiscordChannel.PartialMessage? = nil // MARK: - Selected Guild & Channel Persistence diff --git a/Paicord/App/RootView.swift b/Paicord/App/RootView.swift index 42d71a99..887402a4 100644 --- a/Paicord/App/RootView.swift +++ b/Paicord/App/RootView.swift @@ -58,6 +58,7 @@ struct RootView: View { .quickSwitcher() .sponsorSheet() .updateSheet() + .attachmentViewer() .task { appState.loadPrevGuild() #if os(iOS) diff --git a/Paicord/Common/Chat/ChatView.swift b/Paicord/Common/Chat/ChatView.swift index da2195bc..b78c8a89 100644 --- a/Paicord/Common/Chat/ChatView.swift +++ b/Paicord/Common/Chat/ChatView.swift @@ -118,6 +118,7 @@ struct ChatView: View { name: .chatViewShouldScrollToBottom, object: ["channelId": vm.channelId, "immediate": true] ) + InputBar.inputVMs[vm.channelId]?.uploadItems = [] return .handled } .introspect(.scrollView, on: .macOS(.v14...)) { scrollView in @@ -204,7 +205,6 @@ struct ChatView: View { .scrollDismissesKeyboard(.interactively) .background(theme.common.secondaryBackground) .ignoresSafeArea(.keyboard, edges: .all) - #if os(macOS) .onDisappear { if let observer = scrollObserver { diff --git a/Paicord/Common/Chat/Messages/Message Body/Attachments/Attachments.swift b/Paicord/Common/Chat/Messages/Message Body/Attachments/Attachments.swift index 498937c5..814ac005 100644 --- a/Paicord/Common/Chat/Messages/Message Body/Attachments/Attachments.swift +++ b/Paicord/Common/Chat/Messages/Message Body/Attachments/Attachments.swift @@ -19,15 +19,41 @@ import SwiftUIX extension MessageCell { struct AttachmentsView: View { + @Environment(\.appState) var appState + var message: DiscordChannel.PartialMessage var previewableAttachments: [DiscordChannel.Message.Attachment] = [] var audioAttachments: [DiscordChannel.Message.Attachment] = [] var fileAttachments: [DiscordChannel.Message.Attachment] = [] - init(attachments: [DiscordChannel.Message.Attachment]) { + init( + message: DiscordChannel.PartialMessage, + attachments: [DiscordChannel.Message.Attachment] + ) { + self.message = message for att in attachments { if let type = UTType(mimeType: att.content_type ?? ""), - AttachmentGridItemPreview.supportedTypes.contains(type) + AttachmentItemPreview.supportedTypes.contains(type) + { + previewableAttachments.append(att) + } else if let type = UTType(mimeType: att.content_type ?? ""), + AttachmentAudioPlayer.supportedTypes.contains(type) + { + audioAttachments.append(att) + } else { + fileAttachments.append(att) + } + } + } + + init( + message: DiscordChannel.Message, + attachments: [DiscordChannel.Message.Attachment] + ) { + self.message = message.toPartialMessage() + for att in attachments { + if let type = UTType(mimeType: att.content_type ?? ""), + AttachmentItemPreview.supportedTypes.contains(type) { previewableAttachments.append(att) } else if let type = UTType(mimeType: att.content_type ?? ""), @@ -78,17 +104,27 @@ extension MessageCell { @ViewBuilder var mosaic: some View { + #warning("Do this soon") list } @ViewBuilder var list: some View { - ForEach(previewableAttachments) { attachment in - AttachmentSizedView(attachment: attachment) { - AttachmentGridItemPreview( - attachment: attachment - ) + ForEach(previewableAttachments.indices, id: \.self) { i in + let attachment = previewableAttachments[i] + Button { + appState.attachmentViewerIndex = i + appState.attachmentViewerAttachments = previewableAttachments + appState.attachmentViewerContextMessage = message + appState.showingAttachmentViewer = true + } label: { + AttachmentSizedView(attachment: attachment) { + AttachmentItemPreview( + attachment: attachment + ) + } } + .buttonStyle(.plain) .debugRender() .debugCompute() } @@ -107,20 +143,30 @@ extension MessageCell { self.content = content() } - private let maxWidth: CGFloat = 500 - private let maxHeight: CGFloat = 300 + private let maxWidth = 500 + private let maxHeight = 300 + + var width: CGFloat { + .init(min(maxWidth, attachment.width ?? maxWidth)) + } + var height: CGFloat { + .init(min(maxHeight, attachment.height ?? maxHeight)) + } var body: some View { - content + Color.almostClear .aspectRatio(attachment.aspectRatio, contentMode: .fit) - .clipShape(.rounded) - .frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: width, maxHeight: height, alignment: .leading) + .overlay(alignment: .topLeading) { + content + .clipShape(.rounded) + .scaledToFit() + } } } /// Handles images, videos - struct AttachmentGridItemPreview: View { + struct AttachmentItemPreview: View { static let supportedTypes: [UTType] = [ .png, .jpeg, @@ -132,22 +178,48 @@ extension MessageCell { var attachment: DiscordMedia + var displayPoster = false + func displayMode(asPoster: Bool) -> some View { + var copy = self + copy.displayPoster = asPoster + return copy + } + var body: some View { switch attachment.type { case .png, .jpeg, .jpeg, .webP, .gif: ImageView(attachment: attachment) case .mpeg4Movie, .quickTimeMovie: - VideoView(attachment: attachment) + if displayPoster { + ImageView(attachment: attachment, needsPoster: true) + } else { + VideoView(attachment: attachment) + } default: - Text("\(attachment.type) unsupported") + // unknown type. sadly discord can sometimes not provide content type. + // fields like thumbnail may not always send content type field. + ImageView(attachment: attachment, needsPoster: true) } } // preview for image struct ImageView: View { var attachment: DiscordMedia + var needsPoster: Bool = false var body: some View { - AnimatedImage(url: URL(string: attachment.proxyurl)) { + let url: URL? = { + if needsPoster { + var components = URLComponents(string: attachment.proxyurl)! + components.queryItems = + (components.queryItems ?? []) + [ + URLQueryItem(name: "format", value: "png") + ] + return components.url + } else { + return URL(string: attachment.proxyurl) + } + }() + AnimatedImage(url: url) { if let placeholder = attachment.placeholder, let data = Data(base64Encoded: placeholder) { @@ -155,12 +227,18 @@ extension MessageCell { #if os(macOS) Image(nsImage: img) .resizable() + .aspectRatio(attachment.aspectRatio, contentMode: .fit) + .scaledToFill() #else Image(uiImage: img) .resizable() + .aspectRatio(attachment.aspectRatio, contentMode: .fit) + .scaledToFill() #endif } else { Color.gray.opacity(0.2) + .aspectRatio(attachment.aspectRatio, contentMode: .fit) + .scaledToFill() } } @@ -234,7 +312,6 @@ extension MessageCell { VideoPlayer(player: player) } } - } } @@ -404,7 +481,8 @@ extension MessageCell { // set up session delegate to track progress final class SessionDelegate: NSObject, URLSessionDownloadDelegate { let proxy: DownloadProxyType - nonisolated(unsafe) var continuation: CheckedContinuation? + nonisolated(unsafe) var continuation: + CheckedContinuation? func urlSession( _ session: URLSession, @@ -675,7 +753,8 @@ extension MessageCell { DownloadButton { proxy in final class SessionDelegate: NSObject, URLSessionDownloadDelegate { let proxy: DownloadButton.DownloadProxy - nonisolated(unsafe) var continuation: CheckedContinuation? + nonisolated(unsafe) var continuation: + CheckedContinuation? let destinationURL: URL func urlSession( @@ -684,7 +763,8 @@ extension MessageCell { didFinishDownloadingTo location: URL ) { do { - if FileManager.default.fileExists(atPath: destinationURL.path) { + if FileManager.default.fileExists(atPath: destinationURL.path) + { try FileManager.default.removeItem(at: destinationURL) } try FileManager.default.moveItem( @@ -828,11 +908,148 @@ extension MessageCell { } #endif } + + struct GifvView: View { + @State private var controller: PlayerController + private let media: Embed.Media + private let staticMedia: Embed.Media? + + private let maxWidth: CGFloat = 500 + private let maxHeight: CGFloat = 300 + + init(media: Embed.Media, staticMedia: Embed.Media? = nil) { + self.media = media + self.staticMedia = staticMedia + _controller = State(initialValue: PlayerController(media: media)) + } + + var body: some View { + AVPlayerLayerContainer(player: controller.player) + .aspectRatio(media.aspectRatio, contentMode: .fit) + .clipShape(.rounded) + .frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .leading) + .onAppear { controller.play() } + .onDisappear { controller.pauseAndReset() } + } + + @Observable + final class PlayerController { + let player: AVPlayer + private var observerToken: Any? + + init(media: Embed.Media) { + guard let url = URL(string: media.proxyurl) + else { + self.player = AVPlayer() + return + } + let item = AVPlayerItem(asset: AVAsset(url: url)) + self.player = AVPlayer(playerItem: item) + + observerToken = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: .main + ) { [weak self] _ in + self?.player.seek(to: .zero) + self?.player.play() + } + } + + deinit { + if let o = observerToken { + NotificationCenter.default.removeObserver(o) + } + player.pause() + } + + func play() { player.play() } + func pauseAndReset() { + player.seek(to: .zero) + player.pause() + } + } + + struct AVPlayerLayerContainer: AppKitOrUIKitViewRepresentable { + var player: AVPlayer + + typealias AppKitOrUIKitViewType = AppKitOrUIKitView + + func makeAppKitOrUIKitView(context: Context) -> AppKitOrUIKitView { + #if os(iOS) + let view = PlayerView_iOS() + view.player = player + return view + #elseif os(macOS) + let view = PlayerView_macOS() + view.player = player + return view + #endif + } + + func updateAppKitOrUIKitView( + _ view: AppKitOrUIKitViewType, + context: Context + ) { + #if os(iOS) + (view as? PlayerView_iOS)?.player = player + #elseif os(macOS) + (view as? PlayerView_macOS)?.player = player + #endif + } + + #if os(iOS) + class PlayerView_iOS: AppKitOrUIKitView { + override class var layerClass: AnyClass { AVPlayerLayer.self } + var player: AVPlayer? { + get { (layer as? AVPlayerLayer)?.player } + set { (layer as? AVPlayerLayer)?.player = newValue } + } + } + #elseif os(macOS) + class PlayerView_macOS: AppKitOrUIKitView { + override init(frame frameRect: CGRect) { + super.init(frame: frameRect) + wantsLayer = true + } + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + } + + var player: AVPlayer? { + didSet { updatePlayerLayer() } + } + + private var playerLayer: AVPlayerLayer? + + override func layout() { + super.layout() + playerLayer?.frame = bounds + } + + private func updatePlayerLayer() { + playerLayer?.removeFromSuperlayer() + guard let player = player else { + playerLayer = nil + return + } + let pl = AVPlayerLayer(player: player) + pl.frame = bounds + pl.videoGravity = .resizeAspect + layer?.addSublayer(pl) + playerLayer = pl + } + } + #endif + } + } + } } #Preview { - MessageCell.AttachmentsView(attachments: [ + let attachments: [DiscordChannel.Message.Attachment] = [ .init( id: try! .makeFake(), filename: "meow.zip", @@ -860,28 +1077,27 @@ extension MessageCell { waveform: nil, flags: nil ), - ]) + ] + MessageCell.AttachmentsView( + message: .init( + id: try! .makeFake(), + channel_id: try! .makeFake(), + content: "gm", + timestamp: .init(date: .now), + tts: false, + mention_everyone: false, + mentions: [], + mention_roles: [], + attachments: attachments, + embeds: [], + pinned: false, + type: .default + ), + attachments: attachments + ) .padding() } -extension DiscordMedia { - var type: UTType { - if let mimeType = content_type, let type = UTType(mimeType: mimeType) { - return type - } else { - return .data - } - } - - var aspectRatio: CGFloat? { - if let width = self.width, let height = self.height { - return width.toCGFloat / height.toCGFloat - } else { - return nil - } - } -} - #if os(iOS) private struct ActivityViewController: UIViewControllerRepresentable { let activityItems: [URL] @@ -920,8 +1136,24 @@ extension DiscordMedia { flags: nil ) - MessageCell.AttachmentsView(attachments: [data]) - .padding() + MessageCell.AttachmentsView( + message: .init( + id: try! .makeFake(), + channel_id: try! .makeFake(), + content: "gm", + timestamp: .init(date: .now), + tts: false, + mention_everyone: false, + mentions: [], + mention_roles: [], + attachments: [data], + embeds: [], + pinned: false, + type: .default + ), + attachments: [data] + ) + .padding() } extension SFSpeechRecognizer { diff --git a/Paicord/Common/Chat/Messages/Message Body/Embeds/EmbedsView.swift b/Paicord/Common/Chat/Messages/Message Body/Embeds/EmbedsView.swift index 37d9b252..8d6ca793 100644 --- a/Paicord/Common/Chat/Messages/Message Body/Embeds/EmbedsView.swift +++ b/Paicord/Common/Chat/Messages/Message Body/Embeds/EmbedsView.swift @@ -22,31 +22,37 @@ extension MessageCell { private let maxHeight: CGFloat = 300 var body: some View { - #warning( - "redo this to support all embed types properly, eg gifs, embeds with multiple images." - ) - ForEach(embeds) { embed in + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(embeds.combineEmbedRuns(), id: \.embed) { embed in + EmbedRow(embedData: embed) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + struct EmbedRow: View { + let embedData: (embed: Embed, items: [Embed.Media]) + var embed: Embed { + embedData.embed + } + + var body: some View { Group { switch embed.type { case .rich, .article: EmbedView(embed: embed) case .image: - if let image = embed.image ?? embed.thumbnail, - let url = URL(string: image.proxyurl) - { - AnimatedImage(url: url) - .resizable() - .aspectRatio(image.aspectRatio, contentMode: .fit) - .clipShape(.rounded) - .frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) + if let image = embed.image ?? embed.thumbnail { + AttachmentsView.AttachmentSizedView(attachment: image) { + AttachmentsView.AttachmentItemPreview(attachment: image) + } } case .gifv: if let video = embed.video { - GifvView(media: video, staticMedia: embed.image) + AttachmentsView.GifvView(media: video, staticMedia: embed.image) } case .link: - LinkEmbedView(embed: embed) + LinkEmbedView(embed: embed, items: embedData.items) default: Text("Unsupported embed type: \(embed.type)") } @@ -57,333 +63,280 @@ extension MessageCell { } struct EmbedView: View { - var embed: Embed + let embed: Embed + let items: [Embed.Media] + + init(embed: Embed, items: [Embed.Media] = []) { + self.embed = embed + self.items = items + } @Environment(\.userInterfaceIdiom) var idiom @Environment(\.channelStore) var channelStore @Environment(\.theme) var theme - var embedWidth: CGFloat { + private var embedWidth: CGFloat { switch idiom { case .phone: - return 425 - 50 + return 345 default: return 425 } } - private var inlineColumns: [GridItem] { - [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] + private var authorIconURL: URL? { + if let icon = embed.author?.proxy_icon_url, + let url = URL(string: icon) + { + return url + } else if let icon = embed.author?.icon_url, + let url = URL(string: icon.asString) + { + return url + } + return nil + } + + private var footerIconURL: URL? { + if let icon = embed.footer?.proxy_icon_url, + let url = URL(string: icon) + { + return url + } else if let icon = embed.footer?.icon_url, + let url = URL(string: icon.asString) + { + return url + } + return nil + } + + private var leftStripeColor: Color { + if let color = embed.color?.asColor() { return color } + return Color(hexadecimal6: 0x202225) + } + + private func columns(forInlineCount count: Int) -> [GridItem] { + let num = max(1, min(3, count)) + return Array(repeating: GridItem(.flexible(), spacing: 8), count: num) } var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 8) { - VStack(alignment: .leading, spacing: 6) { - if let author = embed.author { - HStack(spacing: 8) { - if let icon = author.proxy_icon_url, - let url = URL(string: icon) - { - AnimatedImage(url: url) - .resizable() - .scaledToFit() - .background(Color.gray.opacity(0.15)) - .clipShape(.circle) - .frame(width: 20, height: 20) + HStack(spacing: 0) { + leftStripeColor + .frame(width: 4) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { + if let author = embed.author { + HStack(spacing: 8) { + if let url = authorIconURL { + AnimatedImage(url: url) + .resizable() + .scaledToFill() + .background(Color.gray.opacity(0.12)) + .clipShape(Circle()) + .frame(width: 20, height: 20) + } + Text(author.name) + .font(.caption) + .lineLimit(1) } - Text(author.name) - .font(.caption) - .lineLimit(1) } - } - if let title = embed.title { - if let link = embed.url, - let url = URL(string: link) - { - Link(destination: url) { + if let title = embed.title { + if let link = embed.url, let url = URL(string: link) { + Link(destination: url) { + Text(title) + .font(.headline) + .multilineTextAlignment(.leading) + } + .tint(Color(hexadecimal6: 0x00aafc)) + } else { Text(title) .font(.headline) + .foregroundColor(theme.markdown.text) .multilineTextAlignment(.leading) } - .tint(Color(hexadecimal6: 0x00aafc)) - } else { - Text(title) - .font(.headline) - .foregroundColor(theme.markdown.text) - .multilineTextAlignment(.leading) } - } - if let desc = embed.description { - MarkdownText(content: desc, channelStore: channelStore) - .equatable() + if let desc = embed.description { + MarkdownText(content: desc, channelStore: channelStore) + .equatable() + } + } + .padding(.trailing, embed.thumbnail == nil ? 40 : 0) + + if embed.type != .article, let thumbnail = embed.thumbnail { + AttachmentsView.AttachmentItemPreview(attachment: thumbnail) + .scaledToFill() + .frame(width: 72, height: 72) + .clipped() + .cornerRadius(6) } } - if let thumb = embed.thumbnail?.proxy_url, - let url = URL(string: thumb) - { - AnimatedImage(url: url) - .resizable() - .scaledToFit() - .scaledToFill() - .frame(width: 72, height: 72) - .clipped() - .cornerRadius(6) - .id("embed-thumbnail-\(url.description)") - } - } - - if let fields = embed.fields, !fields.isEmpty { - VStack(alignment: .leading, spacing: 8) { - // Separate inline and block fields + if let fields = embed.fields, !fields.isEmpty { let inlineFields = fields.filter { $0.inline ?? false } let blockFields = fields.filter { ($0.inline ?? false) == false } - if !inlineFields.isEmpty { - LazyVGrid( - columns: inlineColumns, - alignment: .leading, - spacing: 8 - ) { - ForEach(inlineFields) { field in - VStack(alignment: .leading, spacing: 4) { - Text(field.name) - .font(.headline) - .fontWeight(.semibold) - MarkdownText(content: field.value) - .equatable() - .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 8) { + if !inlineFields.isEmpty { + LazyVGrid( + columns: columns(forInlineCount: inlineFields.count), + alignment: .leading, + spacing: 8 + ) { + ForEach(inlineFields) { field in + VStack(alignment: .leading, spacing: 4) { + Text(field.name) + .font(.headline) + .fontWeight(.semibold) + MarkdownText(content: field.value) + .equatable() + } } } } - } - ForEach(blockFields) { field in - VStack(alignment: .leading, spacing: 4) { - Text(field.name) - .font(.headline) - .fontWeight(.semibold) - MarkdownText(content: field.value) - .equatable() - .fixedSize(horizontal: false, vertical: true) + ForEach(blockFields) { field in + VStack(alignment: .leading, spacing: 4) { + Text(field.name) + .font(.headline) + .fontWeight(.semibold) + MarkdownText(content: field.value) + .equatable() + } } } } - } - if let image = embed.image, - let imageURL = image.proxy_url, - let url = URL(string: imageURL) - { - let aspectRatio: CGFloat? = { - if let width = image.width, let height = image.height { - return width.toCGFloat / height.toCGFloat - } else { - return nil + if !items.isEmpty { + images + } else if let image = + (embed.type == .article + ? embed.image ?? embed.thumbnail : embed.image) + { + AttachmentsView.AttachmentSizedView(attachment: image) { + AttachmentsView.AttachmentItemPreview(attachment: image) } - }() - AnimatedImage(url: url) - .resizable() - .aspectRatio(aspectRatio, contentMode: .fit) - .clipShape(.rounded) - .frame( - minWidth: 1, - maxWidth: min(image.width?.toCGFloat, 400), - minHeight: 1, - maxHeight: min(image.height?.toCGFloat, 300), - alignment: .leading - ) - .id("embed-image-\(url.description)") - } + } - if embed.footer != nil || embed.timestamp != nil { - HStack(spacing: 4) { - if let footer = embed.footer { - HStack(spacing: 6) { - if let icon = footer.proxy_icon_url, - let url = URL(string: icon) - { - AnimatedImage(url: url) - .resizable() - .scaledToFit() - .clipShape(Circle()) - .frame(width: 16, height: 16) + if embed.footer != nil || embed.timestamp != nil { + HStack(spacing: 6) { + if let footer = embed.footer { + HStack(spacing: 6) { + if let url = footerIconURL { + AnimatedImage(url: url) + .resizable() + .scaledToFit() + .clipShape(Circle()) + .frame(width: 16, height: 16) + } + Text(footer.text) + .font(.subheadline) } - Text(footer.text) - .font(.subheadline) } - } - if embed.footer != nil && embed.timestamp != nil { - Text(verbatim: "•") - } + if embed.footer != nil && embed.timestamp != nil { Text("•") } - if let ts = embed.timestamp { - Text(ts.date.formattedShort()) - .font(.subheadline) - } + if let ts = embed.timestamp { + Text(ts.date.formattedShort()) + .font(.subheadline) + } - Spacer() + Spacer() + } } } - } - .padding(8) - .frame(maxWidth: embedWidth) - .padding(.horizontal, 8) - .background { - if let color = embed.color?.asColor() { - Rectangle() - .fill(color) - .frame(width: 3) - .frame(maxWidth: .infinity, alignment: .leading) - } else { - Rectangle() - .fill(Color(hexadecimal6: 0x202225)) - .frame(width: 3) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .background(theme.common.tertiaryBackground) - .clipShape(.rect(cornerRadius: 5)) - } - } + .padding(8) + .frame(maxWidth: embedWidth, alignment: .leading) + .background(theme.common.tertiaryBackground) - struct GifvView: View { - var media: Embed.Media - var staticMedia: Embed.Media? = nil // soon, to use as poster - private var player: AVPlayer - - private let maxWidth: CGFloat = 500 - private let maxHeight: CGFloat = 300 - - init(media: Embed.Media, staticMedia: Embed.Media? = nil) { - self.media = media - self.staticMedia = staticMedia - let sourceURL = URL(string: media.proxyurl)! - let asset = AVAsset(url: sourceURL) - let item: AVPlayerItem = .init(asset: asset) - let player: AVPlayer = .init(playerItem: item) - self.player = player - // avplayerlooper is unreliable on both iOS and macOS, so we use a notification observer to loop instead - // idk how this even happens bro - NotificationCenter.default.addObserver( - forName: .AVPlayerItemDidPlayToEndTime, - object: item, - queue: .main - ) { _ in - player.seek(to: .zero) - player.play() } + .clipShape(.rounded) } - var body: some View { - AVPlayerLayerContainer(player: player) - .aspectRatio(media.aspectRatio, contentMode: .fit) - .clipShape(.rounded) - .frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - .onAppear { - player.play() - } - .onDisappear { - player.seek(to: .zero) - player.pause() - } - } - - struct AVPlayerLayerContainer: AppKitOrUIKitViewRepresentable { - var player: AVPlayer - - typealias AppKitOrUIKitViewType = AppKitOrUIKitView - - func makeAppKitOrUIKitView(context: Context) -> AppKitOrUIKitView { - #if os(iOS) - let view = PlayerView_iOS() - view.player = player - return view - #elseif os(macOS) - let view = PlayerView_macOS() - view.player = player - return view - #endif - } - - func updateAppKitOrUIKitView( - _ view: AppKitOrUIKitViewType, - context: Context - ) { - #if os(iOS) - (view as? PlayerView_iOS)?.player = player - #elseif os(macOS) - (view as? PlayerView_macOS)?.player = player - #endif - } - #if os(iOS) - /// On iOS, override layerClass so the view’s layer *is* an AVPlayerLayer. - class PlayerView_iOS: AppKitOrUIKitView { - override class var layerClass: AnyClass { - return AVPlayerLayer.self - } - - var player: AVPlayer? { - get { (layer as? AVPlayerLayer)?.player } - set { (layer as? AVPlayerLayer)?.player = newValue } - } + @ViewBuilder var images: some View { + switch items.count { + case 0: EmptyView() + case 1: + AttachmentsView.AttachmentSizedView(attachment: items[0]) { + AttachmentsView.AttachmentItemPreview(attachment: items[0]) } - #elseif os(macOS) - /// On macOS, NSView’s `layer` is a general CALayer; so we add/remove an AVPlayerLayer sublayer manually. - class PlayerView_macOS: AppKitOrUIKitView { - override init(frame frameRect: CGRect) { - super.init(frame: frameRect) - self.wantsLayer = true + .clipShape(.rounded) + case 2: + HStack(spacing: 4) { + ForEach(items.prefix(2), id: \.hashValue) { item in + Color.almostClear + .overlay { + AttachmentsView.AttachmentItemPreview(attachment: item) + .scaledToFill() + } + .clipShape(.rect(cornerRadius: 4)) } - required init?(coder: NSCoder) { - super.init(coder: coder) - self.wantsLayer = true + } + .clipShape(.rounded) + case 3: + HStack(spacing: 4) { + if let item = items.first { + Color.almostClear + .overlay { + AttachmentsView.AttachmentItemPreview(attachment: item) + .scaledToFill() + } + .clipShape(.rect(cornerRadius: 4)) } - var player: AVPlayer? { - didSet { - updatePlayerLayer() + VStack(spacing: 4) { + ForEach(items.suffix(2), id: \.hashValue) { item in + Color.almostClear + .overlay { + AttachmentsView.AttachmentItemPreview(attachment: item) + .scaledToFill() + } + .aspectRatio(1.6, contentMode: .fit) + .clipShape(.rect(cornerRadius: 4)) } } - - private var playerLayer: AVPlayerLayer? - - override func layout() { - super.layout() - playerLayer?.frame = bounds + } + .clipShape(.rounded) + default: + VStack(spacing: 4) { + HStack(spacing: 4) { + ForEach(items.prefix(2), id: \.hashValue) { item in + Color.almostClear + .overlay { + AttachmentsView.AttachmentItemPreview(attachment: item) + .scaledToFill() + } + .aspectRatio(1.6, contentMode: .fit) + .clipShape(.rect(cornerRadius: 4)) + } } - private func updatePlayerLayer() { - // remove old - playerLayer?.removeFromSuperlayer() - - guard let player = player else { - playerLayer = nil - return + HStack(spacing: 4) { + ForEach(items.suffix(2), id: \.hashValue) { item in + Color.almostClear + .overlay { + AttachmentsView.AttachmentItemPreview(attachment: item) + .scaledToFill() + } + .aspectRatio(1.6, contentMode: .fit) + .clipShape(.rect(cornerRadius: 4)) } - let pl = AVPlayerLayer(player: player) - pl.frame = bounds - pl.videoGravity = .resizeAspect - layer?.addSublayer(pl) - self.playerLayer = pl } } - #endif + .clipShape(.rounded) + } } } struct LinkEmbedView: View { var embed: Embed + var items: [Embed.Media] = [] - var linkType: SpecialLinkType { - .init(embed: embed) - } + var linkType: SpecialLinkType { .init(embed: embed) } @Environment(\.colorScheme) var cs @@ -398,87 +351,81 @@ extension MessageCell { appleMusicTrack(linkType.embedURL(colorScheme: cs)) case .appleMusicAlbum: appleMusicAlbum(linkType.embedURL(colorScheme: cs)) - - case .unknown: EmbedView(embed: embed) + case .unknown: + EmbedView(embed: embed, items: items) } } - .maxHeight(350) + .frame(maxHeight: 350) } @ViewBuilder func spotifyTrack(_ url: URL) -> some View { WebView(url: url) { ProgressView() - .maxWidth(.infinity) - .maxHeight(.infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.ultraThinMaterial) } .aspectRatio(350 / 80, contentMode: .fit) - .maxWidth(350) - .clipShape(.rect(cornerRadius: 14)) + .frame(maxWidth: 350) + .clipShape(RoundedRectangle(cornerRadius: 14)) } @ViewBuilder func spotifyAlbum(_ url: URL) -> some View { WebView(url: url) { ProgressView() - .maxWidth(.infinity) - .maxHeight(.infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.ultraThinMaterial) } .aspectRatio(1, contentMode: .fit) - .maxWidth(350) - .clipShape(.rect(cornerRadius: 14)) + .frame(maxWidth: 350) + .clipShape(RoundedRectangle(cornerRadius: 14)) } @ViewBuilder func appleMusicTrack(_ url: URL) -> some View { WebView(url: url) { ProgressView() - .maxWidth(.infinity) - .maxHeight(.infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.ultraThinMaterial) } .aspectRatio(660 / 170, contentMode: .fit) - .maxWidth(550) - .clipShape(.rect(cornerRadius: 14)) + .frame(maxWidth: 550) + .clipShape(RoundedRectangle(cornerRadius: 14)) } @ViewBuilder func appleMusicAlbum(_ url: URL) -> some View { WebView(url: url) { ProgressView() - .maxWidth(.infinity) - .maxHeight(.infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.ultraThinMaterial) } .aspectRatio(660 / 450, contentMode: .fit) - .maxWidth(550) - .clipShape(.rect(cornerRadius: 14)) + .frame(maxWidth: 550) + .clipShape(RoundedRectangle(cornerRadius: 14)) } enum SpecialLinkType { case spotifyTrack(id: String) case spotifyAlbum(id: String) - case appleMusicTrack(album: String, albumID: String, trackID: String) case appleMusicAlbum(album: String, albumID: String) - case unknown func embedURL(colorScheme: ColorScheme) -> URL { switch self { case .spotifyTrack(let id): - URL(string: "https://open.spotify.com/embed/track/\(id)")! + return URL(string: "https://open.spotify.com/embed/track/\(id)")! case .spotifyAlbum(let id): - URL(string: "https://open.spotify.com/embed/album/\(id)")! + return URL(string: "https://open.spotify.com/embed/album/\(id)")! case .appleMusicTrack(let album, let albumID, let trackID): - URL( + return URL( string: "https://embed.music.apple.com/album/\(album)/\(albumID)?i=\(trackID)&theme=\(colorScheme == .dark ? "dark":"light")" )! case .appleMusicAlbum(let album, let albumID): - URL( + return URL( string: "https://embed.music.apple.com/album/\(album)/\(albumID)" )! case .unknown: @@ -495,7 +442,7 @@ extension MessageCell { return } - // Spotify link + // Spotify if url.host?.contains("spotify.com") == true { let pathComponents = url.pathComponents if pathComponents.count >= 3 { @@ -508,42 +455,29 @@ extension MessageCell { case "album": self = .spotifyAlbum(id: id) return - default: - break + default: break } } } - // Apple Music link + // Apple Music if url.host?.contains("music.apple.com") == true { - - var parts = Array(url.pathComponents.dropFirst()) // remove initial "/" - - // Remove 2-letter country code if present - if let first = parts.first, first.count == 2 { - parts.removeFirst() - } - - if let first = parts.first, first == "album" { - parts.removeFirst() - } - + var parts = Array(url.pathComponents.dropFirst()) // drop leading "/" + if let first = parts.first, first.count == 2 { parts.removeFirst() } // country code + if let first = parts.first, first == "album" { parts.removeFirst() } guard parts.count >= 2 else { self = .unknown return } - let album = parts[0] let albumID = parts[1] - let components = URLComponents( url: url, resolvingAgainstBaseURL: false ) - let trackID = components?.queryItems? - .first(where: { $0.name == "i" })? - .value - + let trackID = components?.queryItems?.first(where: { + $0.name == "i" + })?.value if let trackID { self = .appleMusicTrack( album: album, @@ -559,7 +493,6 @@ extension MessageCell { self = .unknown } } - } } @@ -587,8 +520,10 @@ extension MessageCell { ), proxy_url: "https://computernewb.com/vncresolver/api/v1/screenshot/28938490", + width: 800, height: 480, - width: 800 + placeholder: nil, + content_type: "image/png" ), thumbnail: .init( url: .exact( @@ -596,8 +531,10 @@ extension MessageCell { ), proxy_url: "https://computernewb.com/vncresolver/api/v1/screenshot/28938490", + width: 100, height: 100, - width: 100 + placeholder: nil, + content_type: nil ), video: nil, provider: nil, @@ -630,12 +567,210 @@ extension MessageCell { ), ] ) + let githubEmbed = Embed( + title: + "GitHub - paigely/Navic: Navidrome client app for Android and iOS wi...", + type: .article, + description: + "Navidrome client app for Android and iOS with Material 3 Expressive design - paigely/Navic", + url: "https://github.com/paigely/navic", + timestamp: nil, + color: .mint, + footer: nil, + image: .init( + url: .exact( + "https://repository-images.githubusercontent.com/1126374551/8d14cb32-ef0e-4eac-ae32-1289702e8fac" + ), + proxy_url: + "https://images-ext-1.discordapp.net/external/kHB2MhpEpm8vjvTPasbNwLiKKSwEj8kO2bIQpJY6dKA/https/repository-images.githubusercontent.com/1126374551/8d14cb32-ef0e-4eac-ae32-1289702e8fac", + width: 1588, + height: 794, + placeholder: "DfgFDIAZWaiTaWiAe1sBlZj6dQ==", + content_type: "image/png" + ), + thumbnail: nil, + video: nil, + provider: .init(name: "GitHub", url: nil), + author: nil, + fields: nil + ) + + let multiImagesEmbed: [Embed] = [ + .init( + title: "Youtube downloader tool - Fastesttube!", + type: .link, + description: + "Youtube fastesttube downloader will make your internet expirience faster harder beter stronger.", + url: "https://kwizzu.com/", + timestamp: nil, + color: .orange, + footer: nil, + image: .init( + url: .exact("http://kwizzu.com/img/og_images/safari.jpg"), + proxy_url: + "https://images-ext-1.discordapp.net/external/4-JCkigetiYC_d9JQPgWi_CQ0WC-SKlE9XSQX-KgyA4/http/kwizzu.com/img/og_images/safari.jpg", + width: 254, + height: 254, + placeholder: "MccJJwjot5eKd3d/h4V3mHd3eK/393sP", + content_type: "image/jpeg" + ), + thumbnail: nil, + video: nil, + provider: nil, + author: nil, + fields: nil + ), + .init( + title: nil, + type: .rich, + description: nil, + url: "https://kwizzu.com/", + timestamp: nil, + color: nil, + footer: nil, + image: .init( + url: .exact("http://kwizzu.com/img/og_images/chrome.jpg"), + proxy_url: + "https://images-ext-1.discordapp.net/external/MgVI1VquJAhYTmdFAA83YXyzJG4yw2IWllbyIrRR0Ys/http/kwizzu.com/img/og_images/chrome.jpg", + width: 254, + height: 254, + placeholder: "bAkOLw71uaiJd4ifeHaHuFd3CnhZc58M", + content_type: "image/jpeg" + ), + thumbnail: nil, + video: nil, + provider: nil, + author: nil, + fields: nil + ), + .init( + title: nil, + type: .rich, + description: nil, + url: "https://kwizzu.com/", + timestamp: nil, + color: nil, + footer: nil, + image: .init( + url: .exact("http://kwizzu.com/img/og_images/firefox.jpg"), + proxy_url: + "https://images-ext-1.discordapp.net/external/u72B1u6S7la3q13xEZw_BhbwoLfdSM8jyl6dmT7hnUo/http/kwizzu.com/img/og_images/firefox.jpg", + width: 254, + height: 254, + placeholder: "6jgSLw73p4l5d4eOh3Z4qGiIqQwZm4AE", + content_type: "image/jpeg" + ), + thumbnail: nil, + video: nil, + provider: nil, + author: nil, + fields: nil + ), + .init( + title: nil, + type: .rich, + description: nil, + url: "https://kwizzu.com/", + timestamp: nil, + color: nil, + footer: nil, + image: .init( + url: .exact("http://kwizzu.com/img/og_images/install.jpg"), + proxy_url: + "https://images-ext-1.discordapp.net/external/txsq-4DfejuwkeMifer_3NYBIgxDiYSMOxVpzceL_7E/http/kwizzu.com/img/og_images/install.jpg", + width: 254, + height: 254, + placeholder: "eCkOVwqIiIh4d3d/d2eIOIh5eXcId4c", + content_type: "image/jpeg" + ), + thumbnail: nil, + video: nil, + provider: nil, + author: nil, + fields: nil + ), + ] + + let threeImagesEmbed: [Embed] = [ + .init( + title: "Youtube downloader tool - a!", + type: .link, + description: + "Youtube fastesttube downloader will make your internet expirience faster harder beter stronger.", + url: "https://a.com/", + timestamp: nil, + color: .green, + footer: nil, + image: .init( + url: .exact("http://kwizzu.com/img/og_images/safari.jpg"), + proxy_url: + "https://images-ext-1.discordapp.net/external/4-JCkigetiYC_d9JQPgWi_CQ0WC-SKlE9XSQX-KgyA4/http/kwizzu.com/img/og_images/safari.jpg", + width: 254, + height: 254, + placeholder: "MccJJwjot5eKd3d/h4V3mHd3eK/393sP", + content_type: "image/jpeg" + ), + thumbnail: nil, + video: nil, + provider: nil, + author: nil, + fields: nil + ), + .init( + title: nil, + type: .rich, + description: nil, + url: "https://a.com/", + timestamp: nil, + color: nil, + footer: nil, + image: .init( + url: .exact("http://kwizzu.com/img/og_images/chrome.jpg"), + proxy_url: + "https://images-ext-1.discordapp.net/external/MgVI1VquJAhYTmdFAA83YXyzJG4yw2IWllbyIrRR0Ys/http/kwizzu.com/img/og_images/chrome.jpg", + width: 254, + height: 254, + placeholder: "bAkOLw71uaiJd4ifeHaHuFd3CnhZc58M", + content_type: "image/jpeg" + ), + thumbnail: nil, + video: nil, + provider: nil, + author: nil, + fields: nil + ), + .init( + title: nil, + type: .rich, + description: nil, + url: "https://a.com/", + timestamp: nil, + color: nil, + footer: nil, + image: .init( + url: .exact("http://kwizzu.com/img/og_images/firefox.jpg"), + proxy_url: + "https://images-ext-1.discordapp.net/external/u72B1u6S7la3q13xEZw_BhbwoLfdSM8jyl6dmT7hnUo/http/kwizzu.com/img/og_images/firefox.jpg", + width: 254, + height: 254, + placeholder: "6jgSLw73p4l5d4eOh3Z4qGiIqQwZm4AE", + content_type: "image/jpeg" + ), + thumbnail: nil, + video: nil, + provider: nil, + author: nil, + fields: nil + ), + ] ScrollView { - MessageCell.EmbedsView.EmbedView(embed: sampleEmbed) - .padding() + MessageCell.EmbedsView.init( + embeds: [sampleEmbed, githubEmbed] + threeImagesEmbed + multiImagesEmbed + ) + .padding() } - .frame(height: 600) + .frame(height: 800) } extension Date { @@ -646,3 +781,34 @@ extension Date { return df.string(from: self) } } + +extension [Embed] { + /// Reads the embeds, combines into tuples grouping embeds with multiple images. + /// - Returns: An array of tuples, where each tuple contains an `Embed` and an array of `Embed.Media` objects that are associated with that embed. + func combineEmbedRuns() -> [(embed: Embed, items: [Embed.Media])] { + var combined = [(embed: Embed, items: [Embed.Media])]() + for embed in self { + // ensure type is rich, url is the same as focused embed, has image. every other field nil. else add embed as new entry. + if let currentEmbedFocused = combined.last?.embed, // 2nd embed or later. + currentEmbedFocused.type == .link, + embed.type == .rich, + let image = embed.image, + embed.url == currentEmbedFocused.url, + embed.author == nil, + embed.description == nil, + embed.fields == nil, + embed.footer == nil, + embed.provider == nil, + embed.thumbnail == nil, + embed.video == nil + { + combined[combined.count - 1].items.append(image) + } else { + // first embed. + let items = embed.image.map { [$0] } ?? [] + combined.append((embed: embed, items: items)) + } + } + return combined + } +} diff --git a/Paicord/Common/Chat/Messages/Message Body/Markdown/AttributedText.swift b/Paicord/Common/Chat/Messages/Message Body/Markdown/AttributedText.swift index 5d806ee6..f9240125 100644 --- a/Paicord/Common/Chat/Messages/Message Body/Markdown/AttributedText.swift +++ b/Paicord/Common/Chat/Messages/Message Body/Markdown/AttributedText.swift @@ -36,7 +36,7 @@ struct AttributedText: View { Coordinator(openURL: openURL) } - func makeNSView(context: Context) -> ModifiedCopyingTextView { + func makeNSView(context: Context) -> IntrinsicTextView { let textStorage = NSTextStorage() let layoutManager = NSLayoutManager() let textContainer = NSTextContainer() @@ -45,60 +45,39 @@ struct AttributedText: View { textStorage.addLayoutManager(layoutManager) layoutManager.addTextContainer(textContainer) - let tv = ModifiedCopyingTextView( - frame: .zero, - textContainer: textContainer - ) - + let tv = IntrinsicTextView(frame: .zero, textContainer: textContainer) tv.isEditable = false tv.isSelectable = true tv.drawsBackground = false tv.textContainerInset = .zero tv.textContainer?.lineFragmentPadding = 0 tv.textContainer?.widthTracksTextView = true + tv.textContainer?.heightTracksTextView = false tv.isAutomaticLinkDetectionEnabled = false tv.linkTextAttributes = [:] tv.delegate = context.coordinator tv.customCoordinator = context.coordinator - tv.textStorage?.setAttributedString(attributedString) - tv.textContainer?.maximumNumberOfLines = lineLimit ?? 0 tv.usesAdaptiveColorMappingForDarkAppearance = true + tv.setAttributedStringAndInvalidate(attributedString) + tv.setLineLimitAndInvalidate(lineLimit) + + tv.setContentHuggingPriority(.required, for: .vertical) + tv.setContentCompressionResistancePriority(.required, for: .vertical) + tv.setContentHuggingPriority(.defaultLow, for: .horizontal) + tv.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return tv } - func updateNSView(_ nsView: ModifiedCopyingTextView, context: Context) { + func updateNSView(_ nsView: IntrinsicTextView, context: Context) { nsView.customCoordinator = context.coordinator if nsView.attributedString() != attributedString { - nsView.textStorage?.setAttributedString(attributedString) + nsView.setAttributedStringAndInvalidate(attributedString) } - if nsView.textContainer?.maximumNumberOfLines != (lineLimit ?? 0) { - nsView.textContainer?.maximumNumberOfLines = lineLimit ?? 0 - } - } - - func sizeThatFits( - _ proposal: ProposedViewSize, - nsView: ModifiedCopyingTextView, - context: Context - ) -> CGSize? { - let targetWidth = proposal.width ?? 400 - guard let layoutManager = nsView.layoutManager, - let textContainer = nsView.textContainer - else { return nil } - - // Set size for calculation - textContainer.containerSize = CGSize( - width: targetWidth, - height: .greatestFiniteMagnitude - ) - layoutManager.ensureLayout(for: textContainer) - - let usedRect = layoutManager.usedRect(for: textContainer) - - return CGSize(width: targetWidth, height: ceil(usedRect.height)) + nsView.setLineLimitAndInvalidate(lineLimit) } final class Coordinator: NSObject, NSTextViewDelegate { @@ -119,8 +98,63 @@ struct AttributedText: View { } } - // Custom NSTextView to preserve rawContent on copy and avoid context-menu conflicts - final class ModifiedCopyingTextView: NSTextView { + final class IntrinsicTextView: ModifiedCopyingTextView { + private var lastMeasuredWidth: CGFloat = -1 + private var lastLineLimit: Int? = nil + + func setAttributedStringAndInvalidate(_ s: NSAttributedString) { + textStorage?.setAttributedString(s) + invalidateIntrinsicAndLayout() + } + + func setLineLimitAndInvalidate(_ lineLimit: Int?) { + let normalized = (lineLimit == 0) ? nil : lineLimit + if lastLineLimit != normalized { + lastLineLimit = normalized + textContainer?.maximumNumberOfLines = normalized ?? 0 + invalidateIntrinsicAndLayout() + } + } + + private func invalidateIntrinsicAndLayout() { + invalidateIntrinsicContentSize() + needsLayout = true + } + + override func layout() { + super.layout() + + let w = bounds.width + if w > 0, abs(w - lastMeasuredWidth) > 0.5 { + lastMeasuredWidth = w + textContainer?.containerSize = CGSize( + width: w, + height: .greatestFiniteMagnitude + ) + invalidateIntrinsicContentSize() + } + } + + override var intrinsicContentSize: NSSize { + let w = bounds.width > 0 ? bounds.width : 400 + guard let layoutManager = layoutManager, let textContainer = textContainer + else { + return NSSize(width: w, height: 0) + } + + textContainer.containerSize = CGSize( + width: w, + height: .greatestFiniteMagnitude + ) + + layoutManager.ensureLayout(for: textContainer) + let used = layoutManager.usedRect(for: textContainer) + + return NSSize(width: w, height: ceil(used.height)) + } + } + + class ModifiedCopyingTextView: NSTextView { weak fileprivate var customCoordinator: _AttributedTextView.Coordinator? override func rightMouseDown(with event: NSEvent) { @@ -177,7 +211,7 @@ struct AttributedText: View { Coordinator(openURL: openURL) } - func makeUIView(context: Context) -> ModifiedCopyingTextView { + func makeUIView(context: Context) -> IntrinsicTextView { let textStorage = NSTextStorage() let layoutManager = NSLayoutManager() let textContainer = NSTextContainer() @@ -186,81 +220,47 @@ struct AttributedText: View { textStorage.addLayoutManager(layoutManager) layoutManager.addTextContainer(textContainer) - let tv = ModifiedCopyingTextView( - frame: .zero, - textContainer: textContainer - ) - + let tv = IntrinsicTextView(frame: .zero, textContainer: textContainer) tv.isEditable = false tv.isSelectable = true tv.isScrollEnabled = false tv.backgroundColor = .clear tv.textContainerInset = .zero tv.textContainer.lineFragmentPadding = 0 + tv.textContainer.widthTracksTextView = true + tv.textContainer.heightTracksTextView = false tv.delegate = context.coordinator tv.dataDetectorTypes = [] tv.linkTextAttributes = [:] tv.setAttributedTextPreservingSelection(attributedString) + tv.setLineLimitAndInvalidate(lineLimit) + + tv.setContentHuggingPriority(.required, for: .vertical) + tv.setContentCompressionResistancePriority(.required, for: .vertical) + tv.setContentHuggingPriority(.defaultLow, for: .horizontal) + tv.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return tv } - func updateUIView(_ uiView: ModifiedCopyingTextView, context: Context) { - // Simplified update logic: NSAttributedString equality check is more robust than length hashing + func updateUIView(_ uiView: IntrinsicTextView, context: Context) { if uiView.attributedText != attributedString { uiView.setAttributedTextPreservingSelection(attributedString) - // Ensure the line limit is updated if it changed via Environment - uiView.textContainer.maximumNumberOfLines = lineLimit ?? 0 + uiView.invalidateIntrinsicAndLayout() } - } - func sizeThatFits( - _ proposal: ProposedViewSize, - uiView: ModifiedCopyingTextView, - context: Context - ) -> CGSize? { - // let targetWidth = proposal.width ?? 400 - // let size = uiView.sizeThatFits( - // CGSize(width: targetWidth, height: .greatestFiniteMagnitude) - // ) - // return CGSize(width: targetWidth, height: size.height) - - let targetWidth = proposal.width ?? 400 - let layoutManager = uiView.layoutManager - let textContainer = uiView.textContainer - - // Set size for calculation - textContainer.containerSize = CGSize( - width: targetWidth, - height: .greatestFiniteMagnitude - ) - layoutManager.ensureLayout(for: textContainer) - - let usedRect = layoutManager.usedRect(for: textContainer) - - return CGSize(width: targetWidth, height: ceil(usedRect.height)) + uiView.setLineLimitAndInvalidate(lineLimit) } final class Coordinator: NSObject, UITextViewDelegate { let openURL: OpenURLAction init(openURL: OpenURLAction) { self.openURL = openURL } - // func textView( - // _ textView: UITextView, - // shouldInteractWith URL: URL, - // in characterRange: NSRange, - // interaction: UITextItemInteraction - // ) -> Bool { - // openURL(URL) - // if PaicordChatLink.init(url: URL) != nil { - // return false - // } - // return true - // } - func textView( - _ textView: UITextView, primaryActionFor textItem: UITextItem, defaultAction: UIAction + _ textView: UITextView, + primaryActionFor textItem: UITextItem, + defaultAction: UIAction ) -> UIAction? { switch textItem.content { case .link(let url): @@ -276,7 +276,50 @@ struct AttributedText: View { } } - final class ModifiedCopyingTextView: UITextView { + final class IntrinsicTextView: ModifiedCopyingTextView { + private var lastMeasuredWidth: CGFloat = -1 + private var lastLineLimit: Int? = nil + + func setLineLimitAndInvalidate(_ lineLimit: Int?) { + let normalized = (lineLimit == 0) ? nil : lineLimit + if lastLineLimit != normalized { + lastLineLimit = normalized + textContainer.maximumNumberOfLines = normalized ?? 0 + invalidateIntrinsicAndLayout() + } + } + + fileprivate func invalidateIntrinsicAndLayout() { + invalidateIntrinsicContentSize() + setNeedsLayout() + layoutIfNeeded() + } + + override func layoutSubviews() { + super.layoutSubviews() + + let w = bounds.width + if w > 0, abs(w - lastMeasuredWidth) > 0.5 { + lastMeasuredWidth = w + textContainer.size = CGSize(width: w, height: .greatestFiniteMagnitude) + invalidateIntrinsicContentSize() + } + } + + override var intrinsicContentSize: CGSize { + let targetWidth = (bounds.width > 0) ? bounds.width : 400 + + textContainer.size = CGSize( + width: targetWidth, + height: .greatestFiniteMagnitude + ) + let used = layoutManager.usedRect(for: textContainer) + + return CGSize(width: targetWidth, height: ceil(used.height)) + } + } + + class ModifiedCopyingTextView: UITextView { override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { @@ -307,7 +350,6 @@ struct AttributedText: View { UIPasteboard.general.string = mutable.string } - /// Convenience to avoid clearing selection/caret flicker when resetting text. func setAttributedTextPreservingSelection(_ text: NSAttributedString) { let sel = selectedRange attributedText = text diff --git a/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift b/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift index 18303902..190a3693 100644 --- a/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift +++ b/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift @@ -373,7 +373,6 @@ class MarkdownRendererVM { // Shared cache (renderer instances) fileprivate static let cache: NSCache = { let c = NSCache() - c.countLimit = 512 return c }() fileprivate class CachedRender: NSObject { diff --git a/Paicord/Common/Chat/Messages/Message Body/MessageBody.swift b/Paicord/Common/Chat/Messages/Message Body/MessageBody.swift index 5ed4fa70..c70ec006 100644 --- a/Paicord/Common/Chat/Messages/Message Body/MessageBody.swift +++ b/Paicord/Common/Chat/Messages/Message Body/MessageBody.swift @@ -54,7 +54,7 @@ extension MessageCell { // Attachments let attachments = message.attachments ?? [] if !attachments.isEmpty { - AttachmentsView(attachments: attachments) + AttachmentsView(message: message, attachments: attachments) } // Embeds @@ -70,7 +70,6 @@ extension MessageCell { // Reactions let reactions = channelStore.reactions[message.id, default: [:]] - if !reactions.isEmpty { ReactionsView(reactions: reactions) } diff --git a/Paicord/Common/Chat/Messages/MessageCell.swift b/Paicord/Common/Chat/Messages/MessageCell.swift index 97348ece..5f016687 100644 --- a/Paicord/Common/Chat/Messages/MessageCell.swift +++ b/Paicord/Common/Chat/Messages/MessageCell.swift @@ -38,24 +38,30 @@ struct MessageCell: View { var userMentioned: Bool { let gw = GatewayStore.shared - guard let currentUserID = gw.user.currentUser?.id else { + guard let currentUserID = gw.user.currentUser?.id else { return false } + + if !message.mention_everyone, + message.mentions.isEmpty, + message.mention_roles.isEmpty + { return false } - let mentionedUser: Bool = message.mentions.contains(where: { - $0.id == currentUserID - }) - let mentionedEveryone: Bool = message.mention_everyone - let mentionedUserByRole: Bool = { - let usersRoles = - channelStore.guildStore?.members[currentUserID]?.roles ?? [] - for roleID in message.mention_roles { - if usersRoles.contains(roleID) { - return true - } - } + + if message.mention_everyone { return true } + + if message.mentions.contains(where: { $0.id == currentUserID }) { + return true + } + + guard let member = channelStore.guildStore?.members[currentUserID] else { return false - }() - return mentionedUser || mentionedEveryone || mentionedUserByRole + } + + let userRoles = Set(member.roles ?? []) + for roleID in message.mention_roles { + if userRoles.contains(roleID) { return true } + } + return false } var body: some View { diff --git a/Paicord/Common/Utilities/Modifiers/AttachmentViewer.swift b/Paicord/Common/Utilities/Modifiers/AttachmentViewer.swift index 0bbb1c89..f3189ce6 100644 --- a/Paicord/Common/Utilities/Modifiers/AttachmentViewer.swift +++ b/Paicord/Common/Utilities/Modifiers/AttachmentViewer.swift @@ -6,7 +6,781 @@ // Copyright © 2026 Lakhan Lothiyi. // +import AVFoundation +import AVKit import PaicordLib +import SDWebImageSwiftUI +@_spi(Advanced) import SwiftUIIntrospect import SwiftUIX -/// Le attachment viewer, a navigation destination. +#if os(macOS) + import AppKit + import SDWebImage +#elseif os(iOS) + import UIKit + import SDWebImage +#endif + +extension View { + @ViewBuilder + func attachmentViewer() -> some View { + self.modifier( + AttachmentViewerModifier() + ) + } +} + +private struct AttachmentViewerModifier: ViewModifier { + @Environment(\.appState) var appState + var contextMessage: DiscordChannel.PartialMessage? = nil + func body(content: Content) -> some View { + @Bindable var appState = appState + #if os(macOS) + content + .overlay { + if appState.showingAttachmentViewer { + Color.black.opacity(0.25) + .onTapGesture { + appState.showingAttachmentViewer = false + } + .transition(.opacity) + } + } + .overlay { + if appState.showingAttachmentViewer { + AttachmentViewer( + contextMessage: appState.attachmentViewerContextMessage, + attachments: $appState.attachmentViewerAttachments, + selectedIndex: $appState.attachmentViewerIndex, + isPresented: $appState.showingAttachmentViewer + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity.combined(with: .scale)) + } + } + .animation(.default, value: appState.showingAttachmentViewer) + #else + content + .sheet(isPresented: $appState.showingAttachmentViewer) { + AttachmentViewer( + contextMessage: appState.attachmentViewerContextMessage, + attachments: $appState.attachmentViewerAttachments, + selectedIndex: $appState.attachmentViewerIndex, + isPresented: $appState.showingAttachmentViewer + ) + } + #endif + } +} + +/// Le attachment viewer, a fullscreen sheet or an overlay that shows the attachments. +private struct AttachmentViewer: View { + var contextMessage: DiscordChannel.PartialMessage? = nil + @Binding var attachments: [DiscordMedia] + @Binding var selectedIndex: Int? + @Binding var isPresented: Bool + + var body: some View { + #if os(iOS) + TabView(selection: $selectedIndex) { + ForEach(attachments.indices, id: \.self) { index in + attachmentItemView(for: attachments[index], isPresented: $isPresented) + .ignoresSafeArea(.container) + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .ignoresSafeArea(.container) + #else + VStack(spacing: 0) { + if let selectedIndex, attachments.indices.contains(selectedIndex) { + attachmentItemView( + for: attachments[selectedIndex], + isPresented: $isPresented + ) + } else { + Text(verbatim: "") + .onAppear { + self.selectedIndex = attachments.indices.first + } + } + + } + .safeAreaInset(edge: .bottom, spacing: 0) { + Carousel(attachments: attachments, selectedIndex: $selectedIndex) + .padding() + } + #endif + } + + @ViewBuilder + func attachmentItemView( + for attachment: DiscordMedia, + isPresented: Binding + ) -> some View { + switch attachment.type { + case .mpeg4Movie, .quickTimeMovie, .mp3, .mpeg4Audio, + .init(mimeType: "audio/wav", conformingTo: .audio)!, + .init(mimeType: "audio/flac", conformingTo: .audio)!, + .init(mimeType: "audio/ogg", conformingTo: .audio)!: + VideoPlayerView(attachment: attachment) + case .png, .jpeg, .jpeg, .webP, .gif: + ZoomableImageView(attachment: attachment, isPresented: isPresented) + default: + Text("Unsupported attachment type") + } + } + + #if os(macOS) + struct Carousel: View { + let attachments: [DiscordMedia] + @Binding var selectedIndex: Int? + + @State private var scrollViewSize: CGSize = .zero + @State private var contentSize: CGSize = .zero + + var body: some View { + ScrollFadeMask(.horizontal) { + LazyHStack(spacing: 2) { + ForEach(attachments.indices, id: \.self) { index in + if let attachment = attachments[safe: index] { + Button { + selectedIndex = index + } label: { + Color.clear + .frame(width: 38 * 1.5, height: 38 * 1.5) + .overlay { + MessageCell.AttachmentsView.AttachmentItemPreview( + attachment: attachment + ) + .displayMode(asPoster: true) + .scaledToFill() + .clipped() + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(.borderless) + } + } + } + .maxHeight(38 * 1.5) + .onGeometryChange(for: CGSize.self) { + $0.size + } action: { + contentSize = $0 + } + } + .scrollIndicators(.hidden) + .onGeometryChange(for: CGSize.self) { + $0.size + } action: { + scrollViewSize = $0 + } + .maxWidth(min(contentSize.width, 380)) + } + } + #endif +} + +private struct VideoPlayerView: View { + let attachment: DiscordMedia + + var body: some View { + if let url = URL(string: attachment.proxyurl) { + VideoPlayer(player: AVPlayer(url: url)) + } else { + Text("Invalid video URL") + } + } +} + +private struct ZoomableImageView: View { + let attachment: DiscordMedia + @Binding var isPresented: Bool + var body: some View { + #if os(iOS) + IOSZoomableImageView(attachment: attachment) + #elseif os(macOS) + MacOSZoomableImageView(attachment: attachment, isPresented: $isPresented) + #endif + } + + #if os(iOS) + struct IOSZoomableImageView: UIViewRepresentable { + let attachment: DiscordMedia + var url: URL? { + URL(string: attachment.proxyurl) + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.delegate = context.coordinator + scrollView.maximumZoomScale = 8.0 + scrollView.minimumZoomScale = 1.0 + scrollView.zoomScale = 1.0 + scrollView.bouncesZoom = true + scrollView.bounces = true + scrollView.alwaysBounceHorizontal = false + scrollView.alwaysBounceVertical = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + + let imageView = SDAnimatedImageView() + imageView.contentMode = .scaleAspectFit + imageView.isUserInteractionEnabled = true + imageView.translatesAutoresizingMaskIntoConstraints = true + imageView.frame = .zero + imageView.autoresizingMask = [] + + scrollView.addSubview(imageView) + + context.coordinator.imageView = imageView + context.coordinator.scrollView = scrollView + + let doubleTap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleDoubleTap(_:)) + ) + doubleTap.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTap) + + return scrollView + } + + func updateUIView(_ uiView: UIScrollView, context: Context) { + if let url = url, context.coordinator.currentURL != url { + context.coordinator.currentURL = url + context.coordinator.imageView?.sd_setImage(with: url) { + image, + _, + _, + _ in + guard let image = image else { return } + DispatchQueue.main.async { + guard let imageView = context.coordinator.imageView else { + return + } + let bounds = uiView.bounds.size + guard bounds.width > 0, bounds.height > 0 else { return } + + let imageSize = image.size + let scale = min( + bounds.width / imageSize.width, + bounds.height / imageSize.height + ) + let fittedSize = CGSize( + width: imageSize.width * scale, + height: imageSize.height * scale + ) + + imageView.frame = CGRect(origin: .zero, size: fittedSize) + uiView.contentSize = fittedSize + + uiView.minimumZoomScale = 1.0 + uiView.maximumZoomScale = 8.0 + uiView.zoomScale = 1.0 + + context.coordinator.centerImage(in: uiView) + } + } + } else { + if let imageView = context.coordinator.imageView, + let image = imageView.image + { + let bounds = uiView.bounds.size + guard bounds.width > 0, bounds.height > 0 else { + context.coordinator.centerImage(in: uiView) + return + } + + let imageSize = image.size + let scale = min( + bounds.width / imageSize.width, + bounds.height / imageSize.height + ) + let fittedSize = CGSize( + width: imageSize.width * scale, + height: imageSize.height * scale + ) + + imageView.frame = CGRect(origin: .zero, size: fittedSize) + uiView.contentSize = fittedSize + uiView.minimumZoomScale = 1.0 + if uiView.zoomScale < uiView.minimumZoomScale { + uiView.zoomScale = uiView.minimumZoomScale + } + + context.coordinator.centerImage(in: uiView) + } else { + context.coordinator.centerImage(in: uiView) + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject, UIScrollViewDelegate { + var imageView: SDAnimatedImageView? + var scrollView: UIScrollView? + var currentURL: URL? + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return imageView + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + centerImage(in: scrollView) + } + + func centerImage(in scrollView: UIScrollView) { + guard let imageView = imageView else { return } + + let boundsSize = scrollView.bounds.size + let contentSize = scrollView.contentSize + + let horizontalPadding = max( + (boundsSize.width - contentSize.width) / 2.0, + 0 + ) + let verticalPadding = max( + (boundsSize.height - contentSize.height) / 2.0, + 0 + ) + scrollView.contentInset = UIEdgeInsets( + top: verticalPadding, + left: horizontalPadding, + bottom: verticalPadding, + right: horizontalPadding + ) + + var desiredOffset = scrollView.contentOffset + let maxOffsetX = max(0, contentSize.width - boundsSize.width) + let maxOffsetY = max(0, contentSize.height - boundsSize.height) + if contentSize.width <= boundsSize.width { + desiredOffset.x = -horizontalPadding + } else { + desiredOffset.x = min( + max(scrollView.contentOffset.x, 0), + maxOffsetX + ) + } + + if contentSize.height <= boundsSize.height { + desiredOffset.y = -verticalPadding + } else { + desiredOffset.y = min( + max(scrollView.contentOffset.y, 0), + maxOffsetY + ) + } + + if desiredOffset != scrollView.contentOffset { + DispatchQueue.main.async { + scrollView.setContentOffset(desiredOffset, animated: false) + } + } + } + + @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + guard let scrollView = gesture.view as? UIScrollView else { return } + + if scrollView.zoomScale > scrollView.minimumZoomScale { + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) + } else { + let point = gesture.location(in: imageView) + let newZoom = min(scrollView.maximumZoomScale, 3.0) + let scrollSize = CGSize( + width: scrollView.bounds.width / newZoom, + height: scrollView.bounds.height / newZoom + ) + + var origin = CGPoint( + x: point.x - scrollSize.width / 2, + y: point.y - scrollSize.height / 2 + ) + origin.x = max(origin.x, 0) + origin.y = max(origin.y, 0) + + scrollView.zoom( + to: CGRect(origin: origin, size: scrollSize), + animated: true + ) + } + } + } + } + #elseif os(macOS) + struct MacOSZoomableImageView: NSViewRepresentable { + let attachment: DiscordMedia + @Binding var isPresented: Bool + var url: URL? { + URL(string: attachment.proxyurl) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.wantsLayer = true + scrollView.layer?.backgroundColor = .clear + + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + + scrollView.documentView?.wantsLayer = true + scrollView.documentView?.layer?.backgroundColor = .clear + + scrollView.allowsMagnification = true + scrollView.maxMagnification = 8.0 + scrollView.minMagnification = 1.0 + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = false + + let clipView = NoScrollClipView() + clipView.drawsBackground = false + scrollView.contentView = clipView + + let imageView = SDAnimatedImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + + let containerView = FlippedView() + containerView.wantsLayer = true + containerView.layer?.backgroundColor = .clear + containerView.addSubview(imageView) + scrollView.documentView = containerView + + context.coordinator.imageView = imageView + context.coordinator.containerView = containerView + context.coordinator.scrollView = scrollView + + let doubleTap = NSClickGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleDoubleTap(_:)) + ) + doubleTap.numberOfClicksRequired = 2 + imageView.addGestureRecognizer(doubleTap) + + let panGesture = NSPanGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handlePan(_:)) + ) + imageView.addGestureRecognizer(panGesture) + + let backgroundTap = NSClickGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleBackgroundTap(_:)) + ) + backgroundTap.numberOfClicksRequired = 1 + scrollView.addGestureRecognizer(backgroundTap) + + return scrollView + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + if let url = url, context.coordinator.currentURL != url { + context.coordinator.currentURL = url + context.coordinator.imageView?.sd_setImage(with: url) { + [weak nsView, context] image, _, _, _ in + guard let nsView = nsView, + let imageView = context.coordinator.imageView, + let containerView = context.coordinator.containerView, + let image = image + else { return } + DispatchQueue.main.async { + let bounds = nsView.bounds.size + guard bounds.width > 0, bounds.height > 0 else { return } + let imageSize = image.size + let scale = min( + bounds.width / imageSize.width, + bounds.height / imageSize.height + ) + let fittedSize = CGSize( + width: imageSize.width * scale, + height: imageSize.height * scale + ) + // Make the container fill the scroll view so the image can be centered + containerView.frame = CGRect(origin: .zero, size: bounds) + // Center the image within the container (FlippedView: y=0 is top) + let originX = (bounds.width - fittedSize.width) / 2 + let originY = (bounds.height - fittedSize.height) / 2 + imageView.frame = CGRect( + origin: CGPoint(x: originX, y: originY), + size: fittedSize + ) + } + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented) + } + + class Coordinator: NSObject { + var imageView: NSImageView? + var containerView: NSView? + var scrollView: NSScrollView? + var currentURL: URL? + var isPresented: Binding + + init(isPresented: Binding) { + self.isPresented = isPresented + } + + @objc func handleDismissalTap() { + isPresented.wrappedValue = false + } + + @objc func handleBackgroundTap(_ gesture: NSClickGestureRecognizer) { + guard + let imageView = imageView, + let containerView = containerView + else { return } + // Convert click into the container (document) view's coordinate space + let clickInContainer = gesture.location(in: containerView) + if !imageView.frame.contains(clickInContainer) { + isPresented.wrappedValue = false + } + } + + @objc func handleDoubleTap(_ gesture: NSClickGestureRecognizer) { + guard let scrollView = scrollView else { return } + + if scrollView.magnification > 1.0 { + scrollView.animator().magnification = 1.0 + } else { + var point = gesture.location(in: scrollView) + point.y = scrollView.bounds.height - point.y + let newMag: CGFloat = 3.0 + scrollView.animator().setMagnification(newMag, centeredAt: point) + } + } + + @objc func handlePan(_ gesture: NSPanGestureRecognizer) { + guard let scrollView = scrollView, + let imageView = imageView + else { return } + let mag = scrollView.magnification + guard mag > 1.0 else { return } + + let imageFrame = imageView.frame + let visibleDocWidth = scrollView.contentView.bounds.width + let visibleDocHeight = scrollView.contentView.bounds.height + + let fitsX = imageFrame.width <= visibleDocWidth + let minScrollX: CGFloat + let maxScrollX: CGFloat + if fitsX { + let center = imageFrame.midX - visibleDocWidth / 2 + minScrollX = center + maxScrollX = center + } else { + minScrollX = imageFrame.minX + maxScrollX = imageFrame.maxX - visibleDocWidth + } + let fitsY = imageFrame.height <= visibleDocHeight + let minScrollY: CGFloat + let maxScrollY: CGFloat + if fitsY { + let center = imageFrame.midY - visibleDocHeight / 2 + minScrollY = center + maxScrollY = center + } else { + minScrollY = imageFrame.minY + maxScrollY = imageFrame.maxY - visibleDocHeight + } + + switch gesture.state { + case .began, .changed: + let translation = gesture.translation(in: scrollView) + let scale = 1.0 / mag + let currentOrigin = scrollView.contentView.bounds.origin + let newX = min( + max(minScrollX, currentOrigin.x - translation.x * scale), + maxScrollX + ) + let newY = min( + max(minScrollY, currentOrigin.y - translation.y * scale), + maxScrollY + ) + scrollView.contentView.scroll(to: CGPoint(x: newX, y: newY)) + gesture.setTranslation(.zero, in: scrollView) + default: + let currentOrigin = scrollView.contentView.bounds.origin + let clampedOrigin = CGPoint( + x: min(max(minScrollX, currentOrigin.x), maxScrollX), + y: min(max(minScrollY, currentOrigin.y), maxScrollY) + ) + if clampedOrigin != currentOrigin { + NSView.animate( + withDuration: 0.2, + delay: 0, + options: .curveEaseOut + ) { + scrollView.contentView.bounds.origin = clampedOrigin + } + } + } + } + } + } + + class FlippedView: NSView { + override var isFlipped: Bool { true } + } + + class NoScrollClipView: NSClipView { + override func scrollWheel(with event: NSEvent) { + guard let docView = documentView else { + super.scrollWheel(with: event) + return + } + + let imageFrame = docView.subviews.first?.frame ?? docView.frame + let visibleW = bounds.width + let visibleH = bounds.height + + let widthDiff = visibleW - imageFrame.width + let minScrollX: CGFloat + let maxScrollX: CGFloat + + if widthDiff >= 0 { + let desiredX = imageFrame.midX - visibleW / 2 + minScrollX = desiredX + maxScrollX = desiredX + } else { + minScrollX = imageFrame.minX + maxScrollX = imageFrame.maxX - visibleW + } + + let heightDiff = visibleH - imageFrame.height + let minScrollY: CGFloat + let maxScrollY: CGFloat + + if heightDiff >= 0 { + let desiredY = imageFrame.midY - visibleH / 2 + minScrollY = desiredY + maxScrollY = desiredY + } else { + minScrollY = imageFrame.minY + maxScrollY = imageFrame.maxY - visibleH + } + + let currentOrigin = bounds.origin + + let dx = event.scrollingDeltaX + let dy = event.scrollingDeltaY + + let newX = min(max(currentOrigin.x - dx, minScrollX), maxScrollX) + let newY = min(max(currentOrigin.y - dy, minScrollY), maxScrollY) + + if newX != currentOrigin.x || newY != currentOrigin.y { + scroll(to: NSPoint(x: newX, y: newY)) + } + } + } + #endif +} + +#Preview { + @Previewable @State var index: Int? = nil + + let attachments: [DiscordChannel.Message.Attachment] = [ + .init( + id: .init("1476578950737301543"), + filename: "RDT_20260225_1511335244677953476625047.jpg", + content_type: "image/jpeg", + size: 82688, + url: + "https://cdn.discordapp.com/attachments/1026504914131759104/1476578950737301543/RDT_20260225_1511335244677953476625047.jpg?ex=69a1a2cf&is=69a0514f&hm=e2a60c2f89f91ac634c4ccf88bef0b78bf7cb15d22995ef5b4c120995035c28c&", + proxy_url: + "https://media.discordapp.net/attachments/1026504914131759104/1476578950737301543/RDT_20260225_1511335244677953476625047.jpg?ex=69a1a2cf&is=69a0514f&hm=e2a60c2f89f91ac634c4ccf88bef0b78bf7cb15d22995ef5b4c120995035c28c&", + ), + .init( + id: .init("1476579186695995486"), + filename: "RDT_20260225_1504358545961818775118267.jpg", + content_type: "image/jpeg", + size: 22822, + url: + "https://cdn.discordapp.com/attachments/1026504914131759104/1476579186695995486/RDT_20260225_1504358545961818775118267.jpg?ex=69a1a307&is=69a05187&hm=c7285e0a16b09764d9d2bd9649cf43f3b8dad3043901feeb7ba428b4651a7c2a&", + proxy_url: + "https://media.discordapp.net/attachments/1026504914131759104/1476579186695995486/RDT_20260225_1504358545961818775118267.jpg?ex=69a1a307&is=69a05187&hm=c7285e0a16b09764d9d2bd9649cf43f3b8dad3043901feeb7ba428b4651a7c2a&" + ), + .init( + id: .init("1476582227981635625"), + filename: "image.png", + content_type: "image/png", + size: 60839, + url: + "https://cdn.discordapp.com/attachments/1026504914131759104/1476582227981635625/image.png?ex=69a1a5dc&is=69a0545c&hm=403cbd5fca9fc3c405750bd8ea544bddd4726629508a2b5d90a85d60c31cdaa7&", + proxy_url: + "https://media.discordapp.net/attachments/1026504914131759104/1476582227981635625/image.png?ex=69a1a5dc&is=69a0545c&hm=403cbd5fca9fc3c405750bd8ea544bddd4726629508a2b5d90a85d60c31cdaa7&" + ), + .init( + id: .init("1476679245957828638"), + filename: "IMG_4210.png", + content_type: "image/png", + size: 427375, + url: + "https://cdn.discordapp.com/attachments/1428522655555915867/1476679245957828638/IMG_4210.png?ex=69a20037&is=69a0aeb7&hm=f424951868cc9e970ebed5c2378d83662d2214481ff33f2ed5e633274ee3e9cf&", + proxy_url: + "https://media.discordapp.net/attachments/1428522655555915867/1476679245957828638/IMG_4210.png?ex=69a20037&is=69a0aeb7&hm=f424951868cc9e970ebed5c2378d83662d2214481ff33f2ed5e633274ee3e9cf&" + ), + .init( + id: .init("1476648572278669444"), + filename: "IMG_4037.webp", + content_type: "image/webp", + size: 22822, + url: + "https://cdn.discordapp.com/attachments/1428522655555915867/1476648572278669444/IMG_4037.webp?ex=69a1e3a6&is=69a09226&hm=d775243daccefef013812392e35e7449884deec01949394a8ae5b94375439bae&", + proxy_url: + "https://media.discordapp.net/attachments/1428522655555915867/1476648572278669444/IMG_4037.webp?ex=69a1e3a6&is=69a09226&hm=d775243daccefef013812392e35e7449884deec01949394a8ae5b94375439bae&" + ), + .init( + id: .init("1476236261185290420"), + filename: "IMG_4150.png", + content_type: "image/png", + size: 60839, + url: + "https://cdn.discordapp.com/attachments/1428522655555915867/1476236261185290420/IMG_4150.png?ex=69a1b527&is=69a063a7&hm=3be27217a3f9b64c1e8b7e4dfacc604073ce64e398e63eecdce1bf51dc698284&", + proxy_url: + "https://media.discordapp.net/attachments/1428522655555915867/1476236261185290420/IMG_4150.png?ex=69a1b527&is=69a063a7&hm=3be27217a3f9b64c1e8b7e4dfacc604073ce64e398e63eecdce1bf51dc698284&" + ), + .init( + id: .init("1475643804563538000"), + filename: "IMG_4127.png", + content_type: "image/png", + size: 82688, + url: + "https://cdn.discordapp.com/attachments/1428522655555915867/1475643804563538000/IMG_4127.png?ex=69a187a3&is=69a03623&hm=9de5c7e71af6b4993dfaed8e0d07c0377c967f2e1289bd1bf18952c0febd998c&", + proxy_url: + "https://media.discordapp.net/attachments/1428522655555915867/1475643804563538000/IMG_4127.png?ex=69a187a3&is=69a03623&hm=9de5c7e71af6b4993dfaed8e0d07c0377c967f2e1289bd1bf18952c0febd998c&" + ), + .init( + id: .init("1475987762531401738"), + filename: "19c91d6940045-master_playlist.mov", + content_type: "video/quicktime", + size: 9_021_795, + url: + "https://cdn.discordapp.com/attachments/453278377999335431/1475987762531401738/19c91d6940045-master_playlist.mov?ex=69a17679&is=69a024f9&hm=e1f4412bc2583b51390cc2016fe91a7f5fb56966f7ed60bd7d465d61ecbb6b70&", + proxy_url: + "https://media.discordapp.net/attachments/453278377999335431/1475987762531401738/19c91d6940045-master_playlist.mov?ex=69a17679&is=69a024f9&hm=e1f4412bc2583b51390cc2016fe91a7f5fb56966f7ed60bd7d465d61ecbb6b70&" + ), + .init( + id: .init("1476713612952342618"), + filename: "B392763F-FB18-4A15-BA8A-260D8EB25B0D.mp4", + content_type: "video/quicktime", + size: 15_482_571, + url: + "https://cdn.discordapp.com/attachments/453278377999335431/1476713612952342618/B392763F-FB18-4A15-BA8A-260D8EB25B0D.mp4?ex=69a22039&is=69a0ceb9&hm=f933379aef5881455ce84cef667483e84403e09768df3456301468403ebd56c7&", + proxy_url: + "https://media.discordapp.net/attachments/453278377999335431/1476713612952342618/B392763F-FB18-4A15-BA8A-260D8EB25B0D.mp4?ex=69a22039&is=69a0ceb9&hm=f933379aef5881455ce84cef667483e84403e09768df3456301468403ebd56c7&" + ), + ] + + AttachmentViewer( + attachments: .constant(attachments), + selectedIndex: $index, + isPresented: .constant(true) + ) +} diff --git a/Paicord/Resources/Localizable.xcstrings b/Paicord/Resources/Localizable.xcstrings index 2ffebe0f..32c6208c 100644 --- a/Paicord/Resources/Localizable.xcstrings +++ b/Paicord/Resources/Localizable.xcstrings @@ -27,9 +27,6 @@ }, "(edited)" : { - }, - "%@ unsupported" : { - }, "%lld members" : { @@ -43,6 +40,9 @@ } } } + }, + "•" : { + }, "1" : { @@ -202,6 +202,9 @@ }, "Installed Themes" : { + }, + "Invalid video URL" : { + }, "Join the [Paicord Server](https://discord.gg/fqhPGHPyaK) to manage badges!" : { @@ -340,6 +343,9 @@ }, "Unknown User" : { + }, + "Unsupported attachment type" : { + }, "Unsupported block: %@" : { diff --git a/Paicord/Stores/GuildStore.swift b/Paicord/Stores/GuildStore.swift index 99793c19..c0813775 100644 --- a/Paicord/Stores/GuildStore.swift +++ b/Paicord/Stores/GuildStore.swift @@ -340,7 +340,6 @@ class GuildStore: DiscordDataStore { // also the gateway doesnt take member list ids, we send channel snowflakes let subscriptions: [ChannelSnowflake: [IntPair]] = subscribedMemberListIDs.reduce(into: [:]) { partialResult, element in - let memberListId = element.key let channelSnowflake = element.value.channelID partialResult[channelSnowflake] = element.value.ranges } diff --git a/Paicord/Utilities/PaicordLib++/Conformances.swift b/Paicord/Utilities/PaicordLib++/Conformances.swift index d5990026..b4cedcee 100644 --- a/Paicord/Utilities/PaicordLib++/Conformances.swift +++ b/Paicord/Utilities/PaicordLib++/Conformances.swift @@ -8,6 +8,7 @@ import PaicordLib import SwiftProtobuf +import UniformTypeIdentifiers extension DiscordProtos_DiscordUsers_V1_PreloadedUserSettings.GuildFolder: @retroactive Identifiable @@ -60,6 +61,25 @@ extension DiscordChannel.Message.Attachment: DiscordMedia { } } +extension DiscordMedia { + var type: UTType { + if let mimeType = content_type, let type = UTType(mimeType: mimeType) { + return type + } else { + return .data + } + } + + var aspectRatio: CGFloat? { + if let width = self.width, let height = self.height { + return width.toCGFloat / height.toCGFloat + } else { + return nil + } + } +} + + extension Payloads.CreateMessage: @retroactive Identifiable { public var id: MessageSnowflake { .init(self.nonce?.asString ?? "unknown") diff --git a/PaicordLib/Sources/DiscordModels/Types/Channel.swift b/PaicordLib/Sources/DiscordModels/Types/Channel.swift index ee9e93f5..28825018 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Channel.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Channel.swift @@ -943,7 +943,8 @@ extension DiscordChannel { } /// https://discord.com/developers/docs/resources/message#embed-object -public struct Embed: Sendable, Codable, Equatable, Hashable, ValidatablePayload { +public struct Embed: Sendable, Codable, Equatable, Hashable, ValidatablePayload +{ /// https://discord.com/developers/docs/resources/message#embed-object-embed-types @UnstableEnum @@ -1025,21 +1026,25 @@ public struct Embed: Sendable, Codable, Equatable, Hashable, ValidatablePayload public struct Media: Sendable, Codable, Equatable, Hashable { public var url: DynamicURL public var proxy_url: String? - public var height: Int? public var width: Int? + public var height: Int? public var placeholder: String? public var content_type: String? public init( url: DynamicURL, proxy_url: String? = nil, + width: Int? = nil, height: Int? = nil, - width: Int? = nil + placeholder: String? = nil, + content_type: String? = nil ) { self.url = url self.proxy_url = proxy_url self.height = height self.width = width + self.placeholder = placeholder + self.content_type = content_type } }