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
}
}