From 8b4a81d9a86e00629010a95b0d6e727c2be36964 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Tue, 29 Sep 2026 13:38:56 +0200 Subject: [PATCH 1/9] Move MactrixLibrary to root --- .gitignore | 8 +- Mactrix.xcodeproj/project.pbxproj | 43 +++++++--- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../Extensions/MatrixRustSDK+Extensions.swift | 46 +++++------ Mactrix/Extensions/Models+Extensions.swift | 4 +- Mactrix/Models/AppState.swift | 2 +- Mactrix/Models/LiveRoom.swift | 6 +- Mactrix/Views/ChatView/ChatView.swift | 2 +- .../MessageReadReceiptsView.swift | 4 +- .../TimelineView/MessageRowView.swift | 4 +- .../TimelineView/TimelineTableView.swift | 2 +- .../Views/Inspector/InspectorRoomPins.swift | 2 +- Mactrix/Views/Inspector/InspectorScreen.swift | 2 +- .../Views/Inspector/InspectorThreads.swift | 2 +- .../Views/Inspector/InspectorUserInfo.swift | 2 +- .../Views/Search/SearchInspectorView.swift | 2 +- MactrixLibrary/.gitignore | 8 -- .../xcshareddata/xcschemes/Utils.xcscheme | 79 ------------------- .../Package.resolved => Package.resolved | 2 +- MactrixLibrary/Package.swift => Package.swift | 12 +-- .../MatrixProtocols}/CreateRoomParams.swift | 0 .../MatrixProtocols}/EncryptionState.swift | 0 .../MatrixProtocols}/EventTimelineItem.swift | 0 .../Extensions/Color+userID.swift | 0 .../Extensions/String+Markdown.swift | 0 .../MatrixProtocols}/InReplyToDetails.swift | 0 .../MatrixProtocols}/MsgLikeContent.swift | 0 .../MatrixProtocols}/Reaction.swift | 0 .../MatrixProtocols}/Room.swift | 0 .../MatrixProtocols}/RoomInfo.swift | 0 .../MatrixProtocols}/RoomMember.swift | 0 .../MatrixProtocols}/RoomPreviewInfo.swift | 0 .../SessionVerificationData.swift | 0 .../MatrixProtocols}/ThreadSummary.swift | 0 .../MatrixProtocols}/UserProfile.swift | 0 .../VirtualTimelineItem.swift | 0 .../AttributedTextView.swift | 0 .../MessageFormatting/Parser.swift | 0 .../MessageFormatting/Preview.swift | 0 .../Sources => Sources}/UI/AvatarImage.swift | 2 +- .../UI/Inspector/UserProfileRow.swift | 4 +- .../UI/Inspector/UserProfileView.swift | 2 +- .../UI/Logger+Extensions.swift | 0 .../UI/Rooms/RoomEncryptionBadge.swift | 2 +- .../UI/Rooms/RoomInspectorView.swift | 6 +- .../UI/Rooms/RoomPreviewView.swift | 2 +- .../UI/Screens/CreateRoomScreen.swift | 2 +- .../UI/SessionVerificationModal.swift | 2 +- .../UI/Sidebar/RoomRow.swift | 2 +- .../UI/Sidebar/SectionAction.swift | 0 .../UI/Timeline/MessageReplyView.swift | 0 .../UI/Timeline/ThreadTimelineHeader.swift | 0 .../Sources => Sources}/UI/Username.swift | 4 +- .../Utils/MatrixUriScheme.swift | 0 .../UtilsTests/MatrixUriSchemeTests.swift | 0 55 files changed, 100 insertions(+), 162 deletions(-) delete mode 100644 MactrixLibrary/.gitignore delete mode 100644 MactrixLibrary/.swiftpm/xcode/xcshareddata/xcschemes/Utils.xcscheme rename MactrixLibrary/Package.resolved => Package.resolved (87%) rename MactrixLibrary/Package.swift => Package.swift (82%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/CreateRoomParams.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/EncryptionState.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/EventTimelineItem.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/Extensions/Color+userID.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/Extensions/String+Markdown.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/InReplyToDetails.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/MsgLikeContent.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/Reaction.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/Room.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/RoomInfo.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/RoomMember.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/RoomPreviewInfo.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/SessionVerificationData.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/ThreadSummary.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/UserProfile.swift (100%) rename {MactrixLibrary/Sources/Models => Sources/MatrixProtocols}/VirtualTimelineItem.swift (100%) rename {MactrixLibrary/Sources => Sources}/MessageFormatting/AttributedTextView.swift (100%) rename {MactrixLibrary/Sources => Sources}/MessageFormatting/Parser.swift (100%) rename {MactrixLibrary/Sources => Sources}/MessageFormatting/Preview.swift (100%) rename {MactrixLibrary/Sources => Sources}/UI/AvatarImage.swift (99%) rename {MactrixLibrary/Sources => Sources}/UI/Inspector/UserProfileRow.swift (98%) rename {MactrixLibrary/Sources => Sources}/UI/Inspector/UserProfileView.swift (99%) rename {MactrixLibrary/Sources => Sources}/UI/Logger+Extensions.swift (100%) rename {MactrixLibrary/Sources => Sources}/UI/Rooms/RoomEncryptionBadge.swift (98%) rename {MactrixLibrary/Sources => Sources}/UI/Rooms/RoomInspectorView.swift (95%) rename {MactrixLibrary/Sources => Sources}/UI/Rooms/RoomPreviewView.swift (99%) rename {MactrixLibrary/Sources => Sources}/UI/Screens/CreateRoomScreen.swift (99%) rename {MactrixLibrary/Sources => Sources}/UI/SessionVerificationModal.swift (99%) rename {MactrixLibrary/Sources => Sources}/UI/Sidebar/RoomRow.swift (99%) rename {MactrixLibrary/Sources => Sources}/UI/Sidebar/SectionAction.swift (100%) rename {MactrixLibrary/Sources => Sources}/UI/Timeline/MessageReplyView.swift (100%) rename {MactrixLibrary/Sources => Sources}/UI/Timeline/ThreadTimelineHeader.swift (100%) rename {MactrixLibrary/Sources => Sources}/UI/Username.swift (94%) rename {MactrixLibrary/Sources => Sources}/Utils/MatrixUriScheme.swift (100%) rename {MactrixLibrary/Tests => Tests}/UtilsTests/MatrixUriSchemeTests.swift (100%) diff --git a/.gitignore b/.gitignore index b608a1e..87facae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ -xcuserdata/ .DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc build/ diff --git a/Mactrix.xcodeproj/project.pbxproj b/Mactrix.xcodeproj/project.pbxproj index c9935b6..8dc162a 100644 --- a/Mactrix.xcodeproj/project.pbxproj +++ b/Mactrix.xcodeproj/project.pbxproj @@ -8,15 +8,17 @@ /* Begin PBXBuildFile section */ 3409F4EC2EBFD8D4009537B4 /* UI in Frameworks */ = {isa = PBXBuildFile; productRef = 3409F4EB2EBFD8D4009537B4 /* UI */; }; + 3442F379306BD9E90030346A /* MatrixProtocols in Frameworks */ = {isa = PBXBuildFile; productRef = 3442F378306BD9E90030346A /* MatrixProtocols */; }; + 3442F37B306BD9E90030346A /* MessageFormatting in Frameworks */ = {isa = PBXBuildFile; productRef = 3442F37A306BD9E90030346A /* MessageFormatting */; }; + 3442F37D306BD9E90030346A /* UI in Frameworks */ = {isa = PBXBuildFile; productRef = 3442F37C306BD9E90030346A /* UI */; }; + 3442F37F306BD9E90030346A /* Utils in Frameworks */ = {isa = PBXBuildFile; productRef = 3442F37E306BD9E90030346A /* Utils */; }; 345E77E82ED9F309002E5B9A /* Utils in Frameworks */ = {isa = PBXBuildFile; productRef = 345E77E72ED9F309002E5B9A /* Utils */; }; 348E99882F40ABC3009F57A9 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 348E99872F40ABC3009F57A9 /* AsyncAlgorithms */; }; 34913F6E2EC0F532003034CB /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 34913F6D2EC0F532003034CB /* MatrixRustSDK */; }; - 34913F702EC0F59F003034CB /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 34913F6F2EC0F59F003034CB /* Models */; }; 349B041C2F5D77E80023FBF4 /* MessageFormatting in Frameworks */ = {isa = PBXBuildFile; productRef = 349B041B2F5D77E80023FBF4 /* MessageFormatting */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 3409F6012EBFDB93009537B4 /* MactrixLibrary */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MactrixLibrary; sourceTree = ""; }; 343858422EB394590010922A /* Mactrix.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mactrix.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -47,10 +49,13 @@ buildActionMask = 2147483647; files = ( 349B041C2F5D77E80023FBF4 /* MessageFormatting in Frameworks */, + 3442F37B306BD9E90030346A /* MessageFormatting in Frameworks */, + 3442F37F306BD9E90030346A /* Utils in Frameworks */, 3409F4EC2EBFD8D4009537B4 /* UI in Frameworks */, 34913F6E2EC0F532003034CB /* MatrixRustSDK in Frameworks */, + 3442F37D306BD9E90030346A /* UI in Frameworks */, 345E77E82ED9F309002E5B9A /* Utils in Frameworks */, - 34913F702EC0F59F003034CB /* Models in Frameworks */, + 3442F379306BD9E90030346A /* MatrixProtocols in Frameworks */, 348E99882F40ABC3009F57A9 /* AsyncAlgorithms in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -71,7 +76,6 @@ 343858442EB394590010922A /* Mactrix */, 342480052EBF91B500C85D82 /* Frameworks */, 343858432EB394590010922A /* Products */, - 3409F6012EBFDB93009537B4 /* MactrixLibrary */, ); sourceTree = ""; }; @@ -105,10 +109,13 @@ packageProductDependencies = ( 3409F4EB2EBFD8D4009537B4 /* UI */, 34913F6D2EC0F532003034CB /* MatrixRustSDK */, - 34913F6F2EC0F59F003034CB /* Models */, 345E77E72ED9F309002E5B9A /* Utils */, 348E99872F40ABC3009F57A9 /* AsyncAlgorithms */, 349B041B2F5D77E80023FBF4 /* MessageFormatting */, + 3442F378306BD9E90030346A /* MatrixProtocols */, + 3442F37A306BD9E90030346A /* MessageFormatting */, + 3442F37C306BD9E90030346A /* UI */, + 3442F37E306BD9E90030346A /* Utils */, ); productName = Mactrix; productReference = 343858422EB394590010922A /* Mactrix.app */; @@ -140,8 +147,8 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 342C624C2EB4DDE600E2426A /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */, - 3409F4E82EBFD8D4009537B4 /* XCLocalSwiftPackageReference "MactrixLibrary" */, 348E99862F40ABC3009F57A9 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, + 3442F377306BD9E90030346A /* XCLocalSwiftPackageReference "." */, ); preferredProjectObjectVersion = 77; productRefGroup = 343858432EB394590010922A /* Products */; @@ -424,9 +431,9 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 3409F4E82EBFD8D4009537B4 /* XCLocalSwiftPackageReference "MactrixLibrary" */ = { + 3442F377306BD9E90030346A /* XCLocalSwiftPackageReference "." */ = { isa = XCLocalSwiftPackageReference; - relativePath = MactrixLibrary; + relativePath = .; }; /* End XCLocalSwiftPackageReference section */ @@ -454,6 +461,22 @@ isa = XCSwiftPackageProductDependency; productName = UI; }; + 3442F378306BD9E90030346A /* MatrixProtocols */ = { + isa = XCSwiftPackageProductDependency; + productName = MatrixProtocols; + }; + 3442F37A306BD9E90030346A /* MessageFormatting */ = { + isa = XCSwiftPackageProductDependency; + productName = MessageFormatting; + }; + 3442F37C306BD9E90030346A /* UI */ = { + isa = XCSwiftPackageProductDependency; + productName = UI; + }; + 3442F37E306BD9E90030346A /* Utils */ = { + isa = XCSwiftPackageProductDependency; + productName = Utils; + }; 345E77E72ED9F309002E5B9A /* Utils */ = { isa = XCSwiftPackageProductDependency; productName = Utils; @@ -468,10 +491,6 @@ package = 342C624C2EB4DDE600E2426A /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */; productName = MatrixRustSDK; }; - 34913F6F2EC0F59F003034CB /* Models */ = { - isa = XCSwiftPackageProductDependency; - productName = Models; - }; 349B041B2F5D77E80023FBF4 /* MessageFormatting */ = { isa = XCSwiftPackageProductDependency; productName = MessageFormatting; diff --git a/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5e8c194..77304b3 100644 --- a/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2a437ca29320ff65154ec5a0ec9d5acc42dc07fb02366dcc6b167b1cddc56c19", + "originHash" : "50f217c91f7703749daea6fb0f9b8846da95b7ff8a20f4d16b17f38fcd3f93ba", "pins" : [ { "identity" : "matrix-rust-components-swift", diff --git a/Mactrix/Extensions/MatrixRustSDK+Extensions.swift b/Mactrix/Extensions/MatrixRustSDK+Extensions.swift index c51f8a3..2326999 100644 --- a/Mactrix/Extensions/MatrixRustSDK+Extensions.swift +++ b/Mactrix/Extensions/MatrixRustSDK+Extensions.swift @@ -1,21 +1,21 @@ import Foundation import MatrixRustSDK -import Models +import MatrixProtocols -extension MatrixRustSDK.RoomMember: Models.UserProfile {} +extension MatrixRustSDK.RoomMember: MatrixProtocols.UserProfile {} -extension MatrixRustSDK.RoomMember: @retroactive Identifiable, Models.RoomMember { +extension MatrixRustSDK.RoomMember: @retroactive Identifiable, MatrixProtocols.RoomMember { public var id: String { userId } - public var roleForPowerLevel: Models.RoomMemberRole { + public var roleForPowerLevel: MatrixProtocols.RoomMemberRole { suggestedRoleForPowerLevel.asModel } } extension MatrixRustSDK.RoomMemberRole { - var asModel: Models.RoomMemberRole { + var asModel: MatrixProtocols.RoomMemberRole { switch self { case .creator: return .creator @@ -30,7 +30,7 @@ extension MatrixRustSDK.RoomMemberRole { } extension MatrixRustSDK.EncryptionState { - var asModel: Models.EncryptionState { + var asModel: MatrixProtocols.EncryptionState { switch self { case .notEncrypted: return .notEncrypted @@ -58,7 +58,7 @@ extension MatrixRustSDK.Room: @retroactive Identifiable { } } -extension MatrixRustSDK.RoomInfo: Models.RoomInfo {} +extension MatrixRustSDK.RoomInfo: MatrixProtocols.RoomInfo {} /* extension MatrixRustSDK.TimelineItem: @retroactive Hashable, @retroactive Identifiable { public var id: String { @@ -127,11 +127,11 @@ extension MatrixRustSDK.Reaction: @retroactive Identifiable { } } -extension MatrixRustSDK.Reaction: Models.Reaction { +extension MatrixRustSDK.Reaction: MatrixProtocols.Reaction { public typealias SenderData = MatrixRustSDK.ReactionSenderData } -extension MatrixRustSDK.ReactionSenderData: Models.ReactionSenderData { +extension MatrixRustSDK.ReactionSenderData: MatrixProtocols.ReactionSenderData { public var date: Date { timestamp.date } @@ -144,7 +144,7 @@ public extension MatrixRustSDK.Timestamp { } extension MatrixRustSDK.VirtualTimelineItem { - var asModel: Models.VirtualTimelineItem { + var asModel: MatrixProtocols.VirtualTimelineItem { switch self { case let .dateDivider(ts: ts): return .dateDivider(date: ts.date) @@ -157,7 +157,7 @@ extension MatrixRustSDK.VirtualTimelineItem { } extension MatrixRustSDK.ProfileDetails { - var asModel: Models.ProfileDetails { + var asModel: MatrixProtocols.ProfileDetails { switch self { case .unavailable: return .unavailable @@ -171,12 +171,12 @@ extension MatrixRustSDK.ProfileDetails { } } -extension MatrixRustSDK.EventTimelineItem: Models.EventTimelineItem { - public var userReadReceipts: [String: Models.Receipt] { - readReceipts.mapValues { Models.Receipt(timestamp: $0.timestamp?.date) } +extension MatrixRustSDK.EventTimelineItem: MatrixProtocols.EventTimelineItem { + public var userReadReceipts: [String: MatrixProtocols.Receipt] { + readReceipts.mapValues { MatrixProtocols.Receipt(timestamp: $0.timestamp?.date) } } - public var senderProfileDetails: Models.ProfileDetails { + public var senderProfileDetails: MatrixProtocols.ProfileDetails { senderProfile.asModel } @@ -462,7 +462,7 @@ extension MatrixRustSDK.EmbeddedEventDetails { } } -extension MatrixRustSDK.ThreadSummary: Models.ThreadSummary { +extension MatrixRustSDK.ThreadSummary: MatrixProtocols.ThreadSummary { public var description: String? { latestEvent().message } @@ -485,7 +485,7 @@ extension MatrixRustSDK.EventOrTransactionId: @retroactive Identifiable { } } -extension MatrixRustSDK.UserProfile: @retroactive Identifiable, Models.UserProfile { +extension MatrixRustSDK.UserProfile: @retroactive Identifiable, MatrixProtocols.UserProfile { public var id: String { userId } } @@ -493,7 +493,7 @@ extension MatrixRustSDK.SessionVerificationEmoji: @retroactive Identifiable { public var id: String { description() } } -extension MatrixRustSDK.SessionVerificationEmoji: Models.SessionVerificationEmoji { +extension MatrixRustSDK.SessionVerificationEmoji: MatrixProtocols.SessionVerificationEmoji { public var description: String { self.description() } @@ -504,7 +504,7 @@ extension MatrixRustSDK.SessionVerificationEmoji: Models.SessionVerificationEmoj } extension MatrixRustSDK.SessionVerificationData { - var asModel: Models.SessionVerificationData { + var asModel: MatrixProtocols.SessionVerificationData { switch self { case let .emojis(emojis, indices): return .emojis(emojis: emojis, indices: indices) @@ -537,8 +537,8 @@ extension MatrixRustSDK.RoomPreviewInfo: @retroactive CustomDebugStringConvertib } } -extension MatrixRustSDK.RoomPreviewInfo: Models.RoomPreviewInfo { - public var userMembership: Models.Membership? { +extension MatrixRustSDK.RoomPreviewInfo: MatrixProtocols.RoomPreviewInfo { + public var userMembership: MatrixProtocols.Membership? { switch membership { case .joined: return .joined @@ -555,7 +555,7 @@ extension MatrixRustSDK.RoomPreviewInfo: Models.RoomPreviewInfo { } } - public var joinRuleInfo: Models.JoinRule? { + public var joinRuleInfo: MatrixProtocols.JoinRule? { switch joinRule { case .invite: return .invite @@ -570,7 +570,7 @@ extension MatrixRustSDK.RoomPreviewInfo: Models.RoomPreviewInfo { } } - public var roomKind: Models.RoomKind { + public var roomKind: MatrixProtocols.RoomKind { switch roomType { case .room: return .room diff --git a/Mactrix/Extensions/Models+Extensions.swift b/Mactrix/Extensions/Models+Extensions.swift index 8fff94b..dd868b4 100644 --- a/Mactrix/Extensions/Models+Extensions.swift +++ b/Mactrix/Extensions/Models+Extensions.swift @@ -1,8 +1,8 @@ import Foundation import MatrixRustSDK -import Models +import MatrixProtocols -extension Models.CreateRoomParams { +extension MatrixProtocols.CreateRoomParams { var asMatrixRequest: MatrixRustSDK.CreateRoomParameters { let preset: RoomPreset = switch access { case .privateRoom: diff --git a/Mactrix/Models/AppState.swift b/Mactrix/Models/AppState.swift index 496a09c..55c73be 100644 --- a/Mactrix/Models/AppState.swift +++ b/Mactrix/Models/AppState.swift @@ -1,6 +1,6 @@ import Foundation import MatrixRustSDK -import Models +import MatrixProtocols import OSLog @MainActor @Observable diff --git a/Mactrix/Models/LiveRoom.swift b/Mactrix/Models/LiveRoom.swift index 7e62f5d..8ef9b3d 100644 --- a/Mactrix/Models/LiveRoom.swift +++ b/Mactrix/Models/LiveRoom.swift @@ -1,6 +1,6 @@ import Foundation import MatrixRustSDK -import Models +import MatrixProtocols import OSLog @MainActor @Observable @@ -91,7 +91,7 @@ extension LiveRoom: Hashable { } } -extension LiveRoom: @MainActor Models.Room { +extension LiveRoom: @MainActor MatrixProtocols.Room { public var displayName: String? { room.displayName() } @@ -100,7 +100,7 @@ extension LiveRoom: @MainActor Models.Room { room.topic() } - public var encryptionState: Models.EncryptionState { + public var encryptionState: MatrixProtocols.EncryptionState { room.encryptionState().asModel } } diff --git a/Mactrix/Views/ChatView/ChatView.swift b/Mactrix/Views/ChatView/ChatView.swift index 13d8b1c..c72c0b8 100644 --- a/Mactrix/Views/ChatView/ChatView.swift +++ b/Mactrix/Views/ChatView/ChatView.swift @@ -1,5 +1,5 @@ import MatrixRustSDK -import Models +import MatrixProtocols import OSLog import SwiftUI diff --git a/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift b/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift index 2e2e7e8..db33414 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift @@ -1,6 +1,6 @@ import AppKit import MatrixRustSDK -import Models +import MatrixProtocols import UI /// Reuses the visible receipt avatars as the table row is recycled. @@ -31,7 +31,7 @@ final class MessageReadReceiptsView: NSButton { addSubview(countLabel) } - func configure(receipts: [String: Models.Receipt], members: [MatrixRustSDK.RoomMember], imageLoader: UI.ImageLoader?) { + func configure(receipts: [String: MatrixProtocols.Receipt], members: [MatrixRustSDK.RoomMember], imageLoader: UI.ImageLoader?) { self.receipts = receipts.map { ($0.key, $0.value.timestamp) } .sorted { ($0.date ?? .distantPast) < ($1.date ?? .distantPast) } self.members = Dictionary(members.map { ($0.userId, $0) }, uniquingKeysWith: { first, _ in first }) diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift index 43ca2fa..1ed2041 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift @@ -1,6 +1,6 @@ import AppKit import MatrixRustSDK -import Models +import MatrixProtocols import UI /// Shared row chrome. The content view is created once and retained across table reuse. @@ -200,7 +200,7 @@ final class MessageRowView: NSView { } } - private func configureReceipts(_ receipts: [String: Models.Receipt], roomMembers: [MatrixRustSDK.RoomMember], imageLoader: UI.ImageLoader?, onFocusUser: ((String) -> Void)?) { + private func configureReceipts(_ receipts: [String: MatrixProtocols.Receipt], roomMembers: [MatrixRustSDK.RoomMember], imageLoader: UI.ImageLoader?, onFocusUser: ((String) -> Void)?) { if !receipts.isEmpty, receiptsView == nil { let view = MessageReadReceiptsView() view.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index c23885c..843058d 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -1,6 +1,6 @@ import AppKit import MatrixRustSDK -import Models +import MatrixProtocols import OSLog enum TimelineSelectionDirection { diff --git a/Mactrix/Views/Inspector/InspectorRoomPins.swift b/Mactrix/Views/Inspector/InspectorRoomPins.swift index c8672db..3b2eb2f 100644 --- a/Mactrix/Views/Inspector/InspectorRoomPins.swift +++ b/Mactrix/Views/Inspector/InspectorRoomPins.swift @@ -1,5 +1,5 @@ import MatrixRustSDK -import Models +import MatrixProtocols import OSLog import SwiftUI import UI diff --git a/Mactrix/Views/Inspector/InspectorScreen.swift b/Mactrix/Views/Inspector/InspectorScreen.swift index fc2e654..1d884c3 100644 --- a/Mactrix/Views/Inspector/InspectorScreen.swift +++ b/Mactrix/Views/Inspector/InspectorScreen.swift @@ -1,5 +1,5 @@ import MatrixRustSDK -import Models +import MatrixProtocols import OSLog import SwiftUI import UI diff --git a/Mactrix/Views/Inspector/InspectorThreads.swift b/Mactrix/Views/Inspector/InspectorThreads.swift index 3dd88a2..26c9783 100644 --- a/Mactrix/Views/Inspector/InspectorThreads.swift +++ b/Mactrix/Views/Inspector/InspectorThreads.swift @@ -1,5 +1,5 @@ import MatrixRustSDK -import Models +import MatrixProtocols import OSLog import SwiftUI import UI diff --git a/Mactrix/Views/Inspector/InspectorUserInfo.swift b/Mactrix/Views/Inspector/InspectorUserInfo.swift index 77533b0..6d07f27 100644 --- a/Mactrix/Views/Inspector/InspectorUserInfo.swift +++ b/Mactrix/Views/Inspector/InspectorUserInfo.swift @@ -1,5 +1,5 @@ import MatrixRustSDK -import Models +import MatrixProtocols import OSLog import SwiftUI import UI diff --git a/Mactrix/Views/Search/SearchInspectorView.swift b/Mactrix/Views/Search/SearchInspectorView.swift index 5d2630c..49eba06 100644 --- a/Mactrix/Views/Search/SearchInspectorView.swift +++ b/Mactrix/Views/Search/SearchInspectorView.swift @@ -1,5 +1,5 @@ import MatrixRustSDK -import Models +import MatrixProtocols import OSLog import SwiftUI import UI diff --git a/MactrixLibrary/.gitignore b/MactrixLibrary/.gitignore deleted file mode 100644 index 0023a53..0000000 --- a/MactrixLibrary/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/MactrixLibrary/.swiftpm/xcode/xcshareddata/xcschemes/Utils.xcscheme b/MactrixLibrary/.swiftpm/xcode/xcshareddata/xcschemes/Utils.xcscheme deleted file mode 100644 index 36cae6f..0000000 --- a/MactrixLibrary/.swiftpm/xcode/xcshareddata/xcschemes/Utils.xcscheme +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/MactrixLibrary/Package.resolved b/Package.resolved similarity index 87% rename from MactrixLibrary/Package.resolved rename to Package.resolved index 5afcba9..9882ef7 100644 --- a/MactrixLibrary/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "957de3239916ad17ad4f4a9c97e2018e0fa1f71a8db77d112f2c7059329c1166", + "originHash" : "e5f16bacd4a4a267868453b778f87af0d39a683e5e32bb9b4b016b22c39b30ac", "pins" : [ { "identity" : "zmarkupparser", diff --git a/MactrixLibrary/Package.swift b/Package.swift similarity index 82% rename from MactrixLibrary/Package.swift rename to Package.swift index 6f570c4..f572f66 100644 --- a/MactrixLibrary/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( - name: "MactrixLibrary", + name: "Mactrix", platforms: [.macOS(.v15)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. @@ -13,8 +13,8 @@ let package = Package( targets: ["UI"] ), .library( - name: "Models", - targets: ["Models"] + name: "MatrixProtocols", + targets: ["MatrixProtocols"] ), .library(name: "Utils", targets: ["Utils"]), .library(name: "MessageFormatting", targets: ["MessageFormatting"]), @@ -28,16 +28,16 @@ let package = Package( // Targets can depend on other targets in this package and products from dependencies. .target( name: "UI", - dependencies: ["Models"] + dependencies: ["MatrixProtocols"] ), .target(name: "Utils"), .testTarget(name: "UtilsTests", dependencies: ["Utils"]), /* .target( name: "TimelineUI", - dependencies: ["Models", .product(name: "MatrixRustSDK", package: "matrix-rust-components-swift")] + dependencies: ["MatrixProtocols", .product(name: "MatrixRustSDK", package: "matrix-rust-components-swift")] ), */ .target( - name: "Models" + name: "MatrixProtocols" ), .target( name: "MessageFormatting", diff --git a/MactrixLibrary/Sources/Models/CreateRoomParams.swift b/Sources/MatrixProtocols/CreateRoomParams.swift similarity index 100% rename from MactrixLibrary/Sources/Models/CreateRoomParams.swift rename to Sources/MatrixProtocols/CreateRoomParams.swift diff --git a/MactrixLibrary/Sources/Models/EncryptionState.swift b/Sources/MatrixProtocols/EncryptionState.swift similarity index 100% rename from MactrixLibrary/Sources/Models/EncryptionState.swift rename to Sources/MatrixProtocols/EncryptionState.swift diff --git a/MactrixLibrary/Sources/Models/EventTimelineItem.swift b/Sources/MatrixProtocols/EventTimelineItem.swift similarity index 100% rename from MactrixLibrary/Sources/Models/EventTimelineItem.swift rename to Sources/MatrixProtocols/EventTimelineItem.swift diff --git a/MactrixLibrary/Sources/Models/Extensions/Color+userID.swift b/Sources/MatrixProtocols/Extensions/Color+userID.swift similarity index 100% rename from MactrixLibrary/Sources/Models/Extensions/Color+userID.swift rename to Sources/MatrixProtocols/Extensions/Color+userID.swift diff --git a/MactrixLibrary/Sources/Models/Extensions/String+Markdown.swift b/Sources/MatrixProtocols/Extensions/String+Markdown.swift similarity index 100% rename from MactrixLibrary/Sources/Models/Extensions/String+Markdown.swift rename to Sources/MatrixProtocols/Extensions/String+Markdown.swift diff --git a/MactrixLibrary/Sources/Models/InReplyToDetails.swift b/Sources/MatrixProtocols/InReplyToDetails.swift similarity index 100% rename from MactrixLibrary/Sources/Models/InReplyToDetails.swift rename to Sources/MatrixProtocols/InReplyToDetails.swift diff --git a/MactrixLibrary/Sources/Models/MsgLikeContent.swift b/Sources/MatrixProtocols/MsgLikeContent.swift similarity index 100% rename from MactrixLibrary/Sources/Models/MsgLikeContent.swift rename to Sources/MatrixProtocols/MsgLikeContent.swift diff --git a/MactrixLibrary/Sources/Models/Reaction.swift b/Sources/MatrixProtocols/Reaction.swift similarity index 100% rename from MactrixLibrary/Sources/Models/Reaction.swift rename to Sources/MatrixProtocols/Reaction.swift diff --git a/MactrixLibrary/Sources/Models/Room.swift b/Sources/MatrixProtocols/Room.swift similarity index 100% rename from MactrixLibrary/Sources/Models/Room.swift rename to Sources/MatrixProtocols/Room.swift diff --git a/MactrixLibrary/Sources/Models/RoomInfo.swift b/Sources/MatrixProtocols/RoomInfo.swift similarity index 100% rename from MactrixLibrary/Sources/Models/RoomInfo.swift rename to Sources/MatrixProtocols/RoomInfo.swift diff --git a/MactrixLibrary/Sources/Models/RoomMember.swift b/Sources/MatrixProtocols/RoomMember.swift similarity index 100% rename from MactrixLibrary/Sources/Models/RoomMember.swift rename to Sources/MatrixProtocols/RoomMember.swift diff --git a/MactrixLibrary/Sources/Models/RoomPreviewInfo.swift b/Sources/MatrixProtocols/RoomPreviewInfo.swift similarity index 100% rename from MactrixLibrary/Sources/Models/RoomPreviewInfo.swift rename to Sources/MatrixProtocols/RoomPreviewInfo.swift diff --git a/MactrixLibrary/Sources/Models/SessionVerificationData.swift b/Sources/MatrixProtocols/SessionVerificationData.swift similarity index 100% rename from MactrixLibrary/Sources/Models/SessionVerificationData.swift rename to Sources/MatrixProtocols/SessionVerificationData.swift diff --git a/MactrixLibrary/Sources/Models/ThreadSummary.swift b/Sources/MatrixProtocols/ThreadSummary.swift similarity index 100% rename from MactrixLibrary/Sources/Models/ThreadSummary.swift rename to Sources/MatrixProtocols/ThreadSummary.swift diff --git a/MactrixLibrary/Sources/Models/UserProfile.swift b/Sources/MatrixProtocols/UserProfile.swift similarity index 100% rename from MactrixLibrary/Sources/Models/UserProfile.swift rename to Sources/MatrixProtocols/UserProfile.swift diff --git a/MactrixLibrary/Sources/Models/VirtualTimelineItem.swift b/Sources/MatrixProtocols/VirtualTimelineItem.swift similarity index 100% rename from MactrixLibrary/Sources/Models/VirtualTimelineItem.swift rename to Sources/MatrixProtocols/VirtualTimelineItem.swift diff --git a/MactrixLibrary/Sources/MessageFormatting/AttributedTextView.swift b/Sources/MessageFormatting/AttributedTextView.swift similarity index 100% rename from MactrixLibrary/Sources/MessageFormatting/AttributedTextView.swift rename to Sources/MessageFormatting/AttributedTextView.swift diff --git a/MactrixLibrary/Sources/MessageFormatting/Parser.swift b/Sources/MessageFormatting/Parser.swift similarity index 100% rename from MactrixLibrary/Sources/MessageFormatting/Parser.swift rename to Sources/MessageFormatting/Parser.swift diff --git a/MactrixLibrary/Sources/MessageFormatting/Preview.swift b/Sources/MessageFormatting/Preview.swift similarity index 100% rename from MactrixLibrary/Sources/MessageFormatting/Preview.swift rename to Sources/MessageFormatting/Preview.swift diff --git a/MactrixLibrary/Sources/UI/AvatarImage.swift b/Sources/UI/AvatarImage.swift similarity index 99% rename from MactrixLibrary/Sources/UI/AvatarImage.swift rename to Sources/UI/AvatarImage.swift index b58027b..f33de54 100644 --- a/MactrixLibrary/Sources/UI/AvatarImage.swift +++ b/Sources/UI/AvatarImage.swift @@ -1,4 +1,4 @@ -import Models +import MatrixProtocols import OSLog import SwiftUI diff --git a/MactrixLibrary/Sources/UI/Inspector/UserProfileRow.swift b/Sources/UI/Inspector/UserProfileRow.swift similarity index 98% rename from MactrixLibrary/Sources/UI/Inspector/UserProfileRow.swift rename to Sources/UI/Inspector/UserProfileRow.swift index a38ee7a..c7ab8a2 100644 --- a/MactrixLibrary/Sources/UI/Inspector/UserProfileRow.swift +++ b/Sources/UI/Inspector/UserProfileRow.swift @@ -1,4 +1,4 @@ -import Models +import MatrixProtocols import SwiftUI public struct UserProfileRow: View { @@ -53,7 +53,7 @@ public struct UserProfileRowLarge: View { UserProfileRow(profile: MockUserProfile(), imageLoader: nil) UserProfileRow(profile: MockUserProfile(), imageLoader: nil) UserProfileRow(profile: MockUserProfile(), imageLoader: nil) - + UserProfileRowLarge(profile: MockUserProfile(), imageLoader: nil) UserProfileRowLarge(profile: MockUserProfile(), imageLoader: nil) UserProfileRowLarge(profile: MockUserProfile(), imageLoader: nil) diff --git a/MactrixLibrary/Sources/UI/Inspector/UserProfileView.swift b/Sources/UI/Inspector/UserProfileView.swift similarity index 99% rename from MactrixLibrary/Sources/UI/Inspector/UserProfileView.swift rename to Sources/UI/Inspector/UserProfileView.swift index 6c84b32..d7985e8 100644 --- a/MactrixLibrary/Sources/UI/Inspector/UserProfileView.swift +++ b/Sources/UI/Inspector/UserProfileView.swift @@ -1,4 +1,4 @@ -import Models +import MatrixProtocols import SwiftUI @MainActor diff --git a/MactrixLibrary/Sources/UI/Logger+Extensions.swift b/Sources/UI/Logger+Extensions.swift similarity index 100% rename from MactrixLibrary/Sources/UI/Logger+Extensions.swift rename to Sources/UI/Logger+Extensions.swift diff --git a/MactrixLibrary/Sources/UI/Rooms/RoomEncryptionBadge.swift b/Sources/UI/Rooms/RoomEncryptionBadge.swift similarity index 98% rename from MactrixLibrary/Sources/UI/Rooms/RoomEncryptionBadge.swift rename to Sources/UI/Rooms/RoomEncryptionBadge.swift index a03f881..5b8104f 100644 --- a/MactrixLibrary/Sources/UI/Rooms/RoomEncryptionBadge.swift +++ b/Sources/UI/Rooms/RoomEncryptionBadge.swift @@ -1,4 +1,4 @@ -import Models +import MatrixProtocols import SwiftUI public struct RoomEncryptionBadge: View { diff --git a/MactrixLibrary/Sources/UI/Rooms/RoomInspectorView.swift b/Sources/UI/Rooms/RoomInspectorView.swift similarity index 95% rename from MactrixLibrary/Sources/UI/Rooms/RoomInspectorView.swift rename to Sources/UI/Rooms/RoomInspectorView.swift index e296773..e6de688 100644 --- a/MactrixLibrary/Sources/UI/Rooms/RoomInspectorView.swift +++ b/Sources/UI/Rooms/RoomInspectorView.swift @@ -1,8 +1,8 @@ -import Models +import MatrixProtocols import OSLog import SwiftUI -struct RoomInspectorMemberRow: View { +struct RoomInspectorMemberRow: View { let member: RoomMember let imageLoader: ImageLoader? @@ -11,7 +11,7 @@ struct RoomInspectorMemberRow: View { } } -public struct RoomInspectorView: View { +public struct RoomInspectorView: View { let room: Room let members: [RoomMember] diff --git a/MactrixLibrary/Sources/UI/Rooms/RoomPreviewView.swift b/Sources/UI/Rooms/RoomPreviewView.swift similarity index 99% rename from MactrixLibrary/Sources/UI/Rooms/RoomPreviewView.swift rename to Sources/UI/Rooms/RoomPreviewView.swift index dbe106f..bc2fe93 100644 --- a/MactrixLibrary/Sources/UI/Rooms/RoomPreviewView.swift +++ b/Sources/UI/Rooms/RoomPreviewView.swift @@ -1,4 +1,4 @@ -import Models +import MatrixProtocols import OSLog import SwiftUI diff --git a/MactrixLibrary/Sources/UI/Screens/CreateRoomScreen.swift b/Sources/UI/Screens/CreateRoomScreen.swift similarity index 99% rename from MactrixLibrary/Sources/UI/Screens/CreateRoomScreen.swift rename to Sources/UI/Screens/CreateRoomScreen.swift index c045bfd..51ab32d 100644 --- a/MactrixLibrary/Sources/UI/Screens/CreateRoomScreen.swift +++ b/Sources/UI/Screens/CreateRoomScreen.swift @@ -1,4 +1,4 @@ -import Models +import MatrixProtocols import SwiftUI public struct CreateRoomScreen: View { diff --git a/MactrixLibrary/Sources/UI/SessionVerificationModal.swift b/Sources/UI/SessionVerificationModal.swift similarity index 99% rename from MactrixLibrary/Sources/UI/SessionVerificationModal.swift rename to Sources/UI/SessionVerificationModal.swift index aed27ba..6904a86 100644 --- a/MactrixLibrary/Sources/UI/SessionVerificationModal.swift +++ b/Sources/UI/SessionVerificationModal.swift @@ -1,4 +1,4 @@ -import Models +import MatrixProtocols import SwiftUI public enum SessionVerificationResponse { diff --git a/MactrixLibrary/Sources/UI/Sidebar/RoomRow.swift b/Sources/UI/Sidebar/RoomRow.swift similarity index 99% rename from MactrixLibrary/Sources/UI/Sidebar/RoomRow.swift rename to Sources/UI/Sidebar/RoomRow.swift index 0e5fe08..1660573 100644 --- a/MactrixLibrary/Sources/UI/Sidebar/RoomRow.swift +++ b/Sources/UI/Sidebar/RoomRow.swift @@ -1,4 +1,4 @@ -import Models +import MatrixProtocols import OSLog import SwiftUI diff --git a/MactrixLibrary/Sources/UI/Sidebar/SectionAction.swift b/Sources/UI/Sidebar/SectionAction.swift similarity index 100% rename from MactrixLibrary/Sources/UI/Sidebar/SectionAction.swift rename to Sources/UI/Sidebar/SectionAction.swift diff --git a/MactrixLibrary/Sources/UI/Timeline/MessageReplyView.swift b/Sources/UI/Timeline/MessageReplyView.swift similarity index 100% rename from MactrixLibrary/Sources/UI/Timeline/MessageReplyView.swift rename to Sources/UI/Timeline/MessageReplyView.swift diff --git a/MactrixLibrary/Sources/UI/Timeline/ThreadTimelineHeader.swift b/Sources/UI/Timeline/ThreadTimelineHeader.swift similarity index 100% rename from MactrixLibrary/Sources/UI/Timeline/ThreadTimelineHeader.swift rename to Sources/UI/Timeline/ThreadTimelineHeader.swift diff --git a/MactrixLibrary/Sources/UI/Username.swift b/Sources/UI/Username.swift similarity index 94% rename from MactrixLibrary/Sources/UI/Username.swift rename to Sources/UI/Username.swift index b409713..fe19978 100644 --- a/MactrixLibrary/Sources/UI/Username.swift +++ b/Sources/UI/Username.swift @@ -1,9 +1,9 @@ import SwiftUI -import Models +import MatrixProtocols struct Username: View { var userProfile: Profile - + var body: some View { Text(userProfile.displayName ?? userProfile.userId).foregroundStyle(Color(userID: userProfile.userId)) .lineLimit(1) diff --git a/MactrixLibrary/Sources/Utils/MatrixUriScheme.swift b/Sources/Utils/MatrixUriScheme.swift similarity index 100% rename from MactrixLibrary/Sources/Utils/MatrixUriScheme.swift rename to Sources/Utils/MatrixUriScheme.swift diff --git a/MactrixLibrary/Tests/UtilsTests/MatrixUriSchemeTests.swift b/Tests/UtilsTests/MatrixUriSchemeTests.swift similarity index 100% rename from MactrixLibrary/Tests/UtilsTests/MatrixUriSchemeTests.swift rename to Tests/UtilsTests/MatrixUriSchemeTests.swift From 25adcf1a4d144334bce4372a6f6756ec5c28e688 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Tue, 29 Sep 2026 13:41:02 +0200 Subject: [PATCH 2/9] Rename UI package to MactrixUI --- Mactrix/MainView.swift | 6 +++--- Mactrix/Models/MatrixClient.swift | 2 +- Mactrix/Views/ChatView/EmbeddedMessageView.swift | 4 ++-- .../ChatView/TimelineView/MessageProfileRowView.swift | 4 ++-- .../ChatView/TimelineView/MessageReadReceiptsView.swift | 6 +++--- Mactrix/Views/ChatView/TimelineView/MessageRowView.swift | 2 +- Mactrix/Views/Inspector/InspectorScreen.swift | 4 ++-- Mactrix/Views/Inspector/InspectorUserInfo.swift | 4 ++-- Mactrix/Views/Search/SearchInspectorView.swift | 8 ++++---- Mactrix/Views/Search/SearchUserInspectorView.swift | 2 +- Mactrix/Views/Sidebar/SidebarView.swift | 6 +++--- Mactrix/Views/Sidebar/SpaceDisclosureGroup.swift | 4 ++-- Package.swift | 6 +++--- Sources/{UI => MactrixUI}/AvatarImage.swift | 0 Sources/{UI => MactrixUI}/Inspector/UserProfileRow.swift | 0 Sources/{UI => MactrixUI}/Inspector/UserProfileView.swift | 0 Sources/{UI => MactrixUI}/Logger+Extensions.swift | 0 Sources/{UI => MactrixUI}/Rooms/RoomEncryptionBadge.swift | 0 Sources/{UI => MactrixUI}/Rooms/RoomInspectorView.swift | 0 Sources/{UI => MactrixUI}/Rooms/RoomPreviewView.swift | 0 Sources/{UI => MactrixUI}/Screens/CreateRoomScreen.swift | 0 Sources/{UI => MactrixUI}/SessionVerificationModal.swift | 0 Sources/{UI => MactrixUI}/Sidebar/RoomRow.swift | 2 +- Sources/{UI => MactrixUI}/Sidebar/SectionAction.swift | 0 Sources/{UI => MactrixUI}/Timeline/MessageReplyView.swift | 0 .../{UI => MactrixUI}/Timeline/ThreadTimelineHeader.swift | 0 Sources/{UI => MactrixUI}/Username.swift | 0 27 files changed, 30 insertions(+), 30 deletions(-) rename Sources/{UI => MactrixUI}/AvatarImage.swift (100%) rename Sources/{UI => MactrixUI}/Inspector/UserProfileRow.swift (100%) rename Sources/{UI => MactrixUI}/Inspector/UserProfileView.swift (100%) rename Sources/{UI => MactrixUI}/Logger+Extensions.swift (100%) rename Sources/{UI => MactrixUI}/Rooms/RoomEncryptionBadge.swift (100%) rename Sources/{UI => MactrixUI}/Rooms/RoomInspectorView.swift (100%) rename Sources/{UI => MactrixUI}/Rooms/RoomPreviewView.swift (100%) rename Sources/{UI => MactrixUI}/Screens/CreateRoomScreen.swift (100%) rename Sources/{UI => MactrixUI}/SessionVerificationModal.swift (100%) rename Sources/{UI => MactrixUI}/Sidebar/RoomRow.swift (98%) rename Sources/{UI => MactrixUI}/Sidebar/SectionAction.swift (100%) rename Sources/{UI => MactrixUI}/Timeline/MessageReplyView.swift (100%) rename Sources/{UI => MactrixUI}/Timeline/ThreadTimelineHeader.swift (100%) rename Sources/{UI => MactrixUI}/Username.swift (100%) diff --git a/Mactrix/MainView.swift b/Mactrix/MainView.swift index e195b63..00ed61e 100644 --- a/Mactrix/MainView.swift +++ b/Mactrix/MainView.swift @@ -17,13 +17,13 @@ struct MainView: View { case .joinedRoom(timeline: let timeline): ChatView(timeline: timeline).id(timeline.room.id) case .previewRoom(let room): - UI.RoomPreviewView( + MactrixUI.RoomPreviewView( preview: room.info(), imageLoader: appState.matrixClient, actions: appState.matrixClient?.roomPreviewActions(forRoomWithId: room.info().roomId, windowState: windowState) ) case .newRoom: - UI.CreateRoomScreen(onSubmit: { params in + MactrixUI.CreateRoomScreen(onSubmit: { params in guard let matrixClient = appState.matrixClient else { return } let newRoom = try await matrixClient.client.createRoom(request: params.asMatrixRequest) windowState.selectedRoomId = newRoom @@ -78,7 +78,7 @@ struct MainView: View { } .sheet(isPresented: verificationSheetPresented, content: { if let verificationData = appState.matrixClient?.sessionVerificationData { - UI.SessionVerificationModal(verificationData: verificationData.asModel, onComplete: { response in + MactrixUI.SessionVerificationModal(verificationData: verificationData.asModel, onComplete: { response in Task { switch response { case .accept: diff --git a/Mactrix/Models/MatrixClient.swift b/Mactrix/Models/MatrixClient.swift index ce748a7..bd8e4c6 100644 --- a/Mactrix/Models/MatrixClient.swift +++ b/Mactrix/Models/MatrixClient.swift @@ -234,7 +234,7 @@ extension MatrixClient: MatrixRustSDK.ClientSessionDelegate { } } -extension MatrixClient: UI.ImageLoader { +extension MatrixClient: MactrixUI.ImageLoader { // In-memory cache of decoded NSImage objects. Purpose is rendering performance — // keeping decoded bitmaps ready to display prevents flicker when scrolling back to // previously viewed images. This is distinct from the SDK-level media download cache diff --git a/Mactrix/Views/ChatView/EmbeddedMessageView.swift b/Mactrix/Views/ChatView/EmbeddedMessageView.swift index 61055d5..d06ad1e 100644 --- a/Mactrix/Views/ChatView/EmbeddedMessageView.swift +++ b/Mactrix/Views/ChatView/EmbeddedMessageView.swift @@ -9,14 +9,14 @@ struct EmbeddedMessageView: View { var body: some View { switch embeddedEvent { case .unavailable, .pending: - UI.MessageReplyView( + MactrixUI.MessageReplyView( username: "loading@username.org", message: "Phasellus sit amet purus ac enim semper convallis. Nullam a gravida libero.", action: action ) .redacted(reason: .placeholder) case let .ready(content, sender, senderProfile, _, _): - UI.MessageReplyView( + MactrixUI.MessageReplyView( username: { if case let .ready(name, _, _, _, _) = senderProfile, let name { return name } return sender diff --git a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift index 3ffb2cb..7f03736 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift @@ -8,7 +8,7 @@ class MessageProfileRowView: NSView { private var sender: String? private var focusUserAction: (_ sender: String) -> Void = { _ in } - private var imageLoader: UI.ImageLoader? + private var imageLoader: MactrixUI.ImageLoader? private var avatarTask: Task? private var avatarUrl: String? @@ -56,7 +56,7 @@ class MessageProfileRowView: NSView { fatalError("init(coder:) has not been implemented") } - func initialize(imageLoader: UI.ImageLoader?, focusUser: @escaping (_ sender: String) -> Void) { + func initialize(imageLoader: MactrixUI.ImageLoader?, focusUser: @escaping (_ sender: String) -> Void) { self.imageLoader = imageLoader focusUserAction = focusUser } diff --git a/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift b/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift index db33414..f1abab4 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift @@ -16,7 +16,7 @@ final class MessageReadReceiptsView: NSButton { private var avatars: [ReceiptAvatarView] = [] private var receipts: [(userId: String, date: Date?)] = [] private var members: [String: MatrixRustSDK.RoomMember] = [:] - private var imageLoader: UI.ImageLoader? + private var imageLoader: MactrixUI.ImageLoader? private let receiptPopover = NSPopover() override init(frame frameRect: NSRect) { @@ -31,7 +31,7 @@ final class MessageReadReceiptsView: NSButton { addSubview(countLabel) } - func configure(receipts: [String: MatrixProtocols.Receipt], members: [MatrixRustSDK.RoomMember], imageLoader: UI.ImageLoader?) { + func configure(receipts: [String: MatrixProtocols.Receipt], members: [MatrixRustSDK.RoomMember], imageLoader: MactrixUI.ImageLoader?) { self.receipts = receipts.map { ($0.key, $0.value.timestamp) } .sorted { ($0.date ?? .distantPast) < ($1.date ?? .distantPast) } self.members = Dictionary(members.map { ($0.userId, $0) }, uniquingKeysWith: { first, _ in first }) @@ -238,7 +238,7 @@ private final class ReceiptAvatarView: NSImageView { layer?.cornerRadius = bounds.width / 2 } - func configure(userId: String, avatarUrl: String?, imageLoader: UI.ImageLoader?) { + func configure(userId: String, avatarUrl: String?, imageLoader: MactrixUI.ImageLoader?) { cancelLoad() self.avatarUrl = avatarUrl image = nil diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift index 1ed2041..085e76c 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift @@ -200,7 +200,7 @@ final class MessageRowView: NSView { } } - private func configureReceipts(_ receipts: [String: MatrixProtocols.Receipt], roomMembers: [MatrixRustSDK.RoomMember], imageLoader: UI.ImageLoader?, onFocusUser: ((String) -> Void)?) { + private func configureReceipts(_ receipts: [String: MatrixProtocols.Receipt], roomMembers: [MatrixRustSDK.RoomMember], imageLoader: MactrixUI.ImageLoader?, onFocusUser: ((String) -> Void)?) { if !receipts.isEmpty, receiptsView == nil { let view = MessageReadReceiptsView() view.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mactrix/Views/Inspector/InspectorScreen.swift b/Mactrix/Views/Inspector/InspectorScreen.swift index 1d884c3..4363930 100644 --- a/Mactrix/Views/Inspector/InspectorScreen.swift +++ b/Mactrix/Views/Inspector/InspectorScreen.swift @@ -17,7 +17,7 @@ struct InspectorScreen: View { SearchInspectorView() case let .focusThread(threadTimeline: thread): VStack(spacing: 0) { - UI.ThreadTimelineHeader { + MactrixUI.ThreadTimelineHeader { windowState.inspectorVisible = false Task { // Insert sleep to delay UI update until close animation finishes @@ -31,7 +31,7 @@ struct InspectorScreen: View { case .roomInfo: switch windowState.selectedScreen { case let .joinedRoom(timeline: timeline): - UI.RoomInspectorView(room: timeline.room, members: timeline.room.members, roomInfo: timeline.room.roomInfo, imageLoader: appState.matrixClient, inspectorVisible: $windowState.inspectorVisible) + MactrixUI.RoomInspectorView(room: timeline.room, members: timeline.room.members, roomInfo: timeline.room.roomInfo, imageLoader: appState.matrixClient, inspectorVisible: $windowState.inspectorVisible) case .none, .newRoom, .previewRoom, .loadMatrixUrl(_), .user(profile: _): Text("No room selected") .inspectorColumnWidth(min: 200, ideal: 250, max: nil) diff --git a/Mactrix/Views/Inspector/InspectorUserInfo.swift b/Mactrix/Views/Inspector/InspectorUserInfo.swift index 6d07f27..0b2ec3b 100644 --- a/Mactrix/Views/Inspector/InspectorUserInfo.swift +++ b/Mactrix/Views/Inspector/InspectorUserInfo.swift @@ -19,7 +19,7 @@ struct InspectorUserInfo: View { @ViewBuilder var content: some View { if let profile { - UI.UserProfileView( + MactrixUI.UserProfileView( profile: profile, isUserIgnored: isUserIgnored, actions: appState.matrixClient?.userProfileActions(forUserId: userId, windowState: windowState), @@ -27,7 +27,7 @@ struct InspectorUserInfo: View { imageLoader: appState.matrixClient ) } else { - UI.UserProfileView( + MactrixUI.UserProfileView( profile: MockUserProfile(), isUserIgnored: isUserIgnored, actions: nil, diff --git a/Mactrix/Views/Search/SearchInspectorView.swift b/Mactrix/Views/Search/SearchInspectorView.swift index 49eba06..b8d153f 100644 --- a/Mactrix/Views/Search/SearchInspectorView.swift +++ b/Mactrix/Views/Search/SearchInspectorView.swift @@ -21,13 +21,13 @@ struct SearchResolvedRoomInspectorView: View { var body: some View { Group { if let roomPreview { - UI.RoomPreviewView( + MactrixUI.RoomPreviewView( preview: roomPreview.info(), imageLoader: appState.matrixClient, actions: roomActions ) } else { - UI.RoomPreviewView( + MactrixUI.RoomPreviewView( preview: MockRoomPreviewInfo(), imageLoader: appState.matrixClient, actions: roomActions @@ -64,13 +64,13 @@ struct SearchInspectorView: View { case let .resolvedRoomAlias(alias: alias, resolvedRoom: resolvedRoom): SearchResolvedRoomInspectorView(alias: alias, resolvedRoom: resolvedRoom) case let .resolvedRoomId(roomPreview: roomPreview): - UI.RoomPreviewView( + MactrixUI.RoomPreviewView( preview: roomPreview.info(), imageLoader: appState.matrixClient, actions: appState.matrixClient?.roomPreviewActions(forRoomWithId: roomPreview.info().roomId, windowState: windowState) ) case let .resolvedUser(profile: userProfile): - UI.UserProfileView( + MactrixUI.UserProfileView( profile: userProfile, isUserIgnored: appState.matrixClient?.isUserIgnored(userProfile.userId) == true, actions: appState.matrixClient?.userProfileActions(forUserId: userProfile.userId, windowState: windowState), diff --git a/Mactrix/Views/Search/SearchUserInspectorView.swift b/Mactrix/Views/Search/SearchUserInspectorView.swift index 221753f..baa133a 100644 --- a/Mactrix/Views/Search/SearchUserInspectorView.swift +++ b/Mactrix/Views/Search/SearchUserInspectorView.swift @@ -22,7 +22,7 @@ struct SearchUserInspectorView: View { }.redacted(reason: .placeholder) } else { ForEach(searchedUsers) { user in - UI.UserProfileRow(profile: user, imageLoader: appState.matrixClient) + MactrixUI.UserProfileRow(profile: user, imageLoader: appState.matrixClient) } } } diff --git a/Mactrix/Views/Sidebar/SidebarView.swift b/Mactrix/Views/Sidebar/SidebarView.swift index c4b9373..1cd9b1f 100644 --- a/Mactrix/Views/Sidebar/SidebarView.swift +++ b/Mactrix/Views/Sidebar/SidebarView.swift @@ -47,7 +47,7 @@ struct SidebarView: View { if !favorites.isEmpty { Section("Favorites", isExpanded: $windowState.sidebarSections.favorites) { ForEach(favorites) { room in - UI.RoomRow( + MactrixUI.RoomRow( title: room.room.displayName() ?? "Unknown room", avatarUrl: room.room.avatarUrl(), roomInfo: room.roomInfo, @@ -63,7 +63,7 @@ struct SidebarView: View { Section("Directs", isExpanded: $windowState.sidebarSections.directs) { ForEach(directs) { room in - UI.RoomRow( + MactrixUI.RoomRow( title: room.room.displayName() ?? "Unknown user", avatarUrl: room.room.avatarUrl(), roomInfo: room.roomInfo, @@ -78,7 +78,7 @@ struct SidebarView: View { Section("Rooms", isExpanded: $windowState.sidebarSections.rooms) { ForEach(rooms) { room in - UI.RoomRow( + MactrixUI.RoomRow( title: room.room.displayName() ?? "Unknown Room", avatarUrl: room.room.avatarUrl(), roomInfo: room.roomInfo, diff --git a/Mactrix/Views/Sidebar/SpaceDisclosureGroup.swift b/Mactrix/Views/Sidebar/SpaceDisclosureGroup.swift index b223e24..2e1d540 100644 --- a/Mactrix/Views/Sidebar/SpaceDisclosureGroup.swift +++ b/Mactrix/Views/Sidebar/SpaceDisclosureGroup.swift @@ -67,7 +67,7 @@ struct SpaceDisclosureGroup: View { @ViewBuilder var roomRow: some View { if let joinedRoom { - UI.RoomRow( + MactrixUI.RoomRow( title: space.spaceRoom.displayName, avatarUrl: space.spaceRoom.avatarUrl, roomInfo: joinedRoom.roomInfo, @@ -78,7 +78,7 @@ struct SpaceDisclosureGroup: View { RoomContextMenu(room: joinedRoom) } } else { - UI.RoomRow( + MactrixUI.RoomRow( title: space.spaceRoom.displayName, avatarUrl: space.spaceRoom.avatarUrl, roomInfo: nil, diff --git a/Package.swift b/Package.swift index f572f66..a248677 100644 --- a/Package.swift +++ b/Package.swift @@ -9,8 +9,8 @@ let package = Package( products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( - name: "UI", - targets: ["UI"] + name: "MactrixUI", + targets: ["MactrixUI"] ), .library( name: "MatrixProtocols", @@ -27,7 +27,7 @@ let package = Package( // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( - name: "UI", + name: "MactrixUI", dependencies: ["MatrixProtocols"] ), .target(name: "Utils"), diff --git a/Sources/UI/AvatarImage.swift b/Sources/MactrixUI/AvatarImage.swift similarity index 100% rename from Sources/UI/AvatarImage.swift rename to Sources/MactrixUI/AvatarImage.swift diff --git a/Sources/UI/Inspector/UserProfileRow.swift b/Sources/MactrixUI/Inspector/UserProfileRow.swift similarity index 100% rename from Sources/UI/Inspector/UserProfileRow.swift rename to Sources/MactrixUI/Inspector/UserProfileRow.swift diff --git a/Sources/UI/Inspector/UserProfileView.swift b/Sources/MactrixUI/Inspector/UserProfileView.swift similarity index 100% rename from Sources/UI/Inspector/UserProfileView.swift rename to Sources/MactrixUI/Inspector/UserProfileView.swift diff --git a/Sources/UI/Logger+Extensions.swift b/Sources/MactrixUI/Logger+Extensions.swift similarity index 100% rename from Sources/UI/Logger+Extensions.swift rename to Sources/MactrixUI/Logger+Extensions.swift diff --git a/Sources/UI/Rooms/RoomEncryptionBadge.swift b/Sources/MactrixUI/Rooms/RoomEncryptionBadge.swift similarity index 100% rename from Sources/UI/Rooms/RoomEncryptionBadge.swift rename to Sources/MactrixUI/Rooms/RoomEncryptionBadge.swift diff --git a/Sources/UI/Rooms/RoomInspectorView.swift b/Sources/MactrixUI/Rooms/RoomInspectorView.swift similarity index 100% rename from Sources/UI/Rooms/RoomInspectorView.swift rename to Sources/MactrixUI/Rooms/RoomInspectorView.swift diff --git a/Sources/UI/Rooms/RoomPreviewView.swift b/Sources/MactrixUI/Rooms/RoomPreviewView.swift similarity index 100% rename from Sources/UI/Rooms/RoomPreviewView.swift rename to Sources/MactrixUI/Rooms/RoomPreviewView.swift diff --git a/Sources/UI/Screens/CreateRoomScreen.swift b/Sources/MactrixUI/Screens/CreateRoomScreen.swift similarity index 100% rename from Sources/UI/Screens/CreateRoomScreen.swift rename to Sources/MactrixUI/Screens/CreateRoomScreen.swift diff --git a/Sources/UI/SessionVerificationModal.swift b/Sources/MactrixUI/SessionVerificationModal.swift similarity index 100% rename from Sources/UI/SessionVerificationModal.swift rename to Sources/MactrixUI/SessionVerificationModal.swift diff --git a/Sources/UI/Sidebar/RoomRow.swift b/Sources/MactrixUI/Sidebar/RoomRow.swift similarity index 98% rename from Sources/UI/Sidebar/RoomRow.swift rename to Sources/MactrixUI/Sidebar/RoomRow.swift index 1660573..2cf59bf 100644 --- a/Sources/UI/Sidebar/RoomRow.swift +++ b/Sources/MactrixUI/Sidebar/RoomRow.swift @@ -71,7 +71,7 @@ public struct RoomRow: View { .truncationMode(.tail) }, icon: { - UI.AvatarImage(avatarUrl: avatarUrl, imageLoader: imageLoader) { + MactrixUI.AvatarImage(avatarUrl: avatarUrl, imageLoader: imageLoader) { RoundedRectangle(cornerRadius: 4) .fill(.clear) .overlay { diff --git a/Sources/UI/Sidebar/SectionAction.swift b/Sources/MactrixUI/Sidebar/SectionAction.swift similarity index 100% rename from Sources/UI/Sidebar/SectionAction.swift rename to Sources/MactrixUI/Sidebar/SectionAction.swift diff --git a/Sources/UI/Timeline/MessageReplyView.swift b/Sources/MactrixUI/Timeline/MessageReplyView.swift similarity index 100% rename from Sources/UI/Timeline/MessageReplyView.swift rename to Sources/MactrixUI/Timeline/MessageReplyView.swift diff --git a/Sources/UI/Timeline/ThreadTimelineHeader.swift b/Sources/MactrixUI/Timeline/ThreadTimelineHeader.swift similarity index 100% rename from Sources/UI/Timeline/ThreadTimelineHeader.swift rename to Sources/MactrixUI/Timeline/ThreadTimelineHeader.swift diff --git a/Sources/UI/Username.swift b/Sources/MactrixUI/Username.swift similarity index 100% rename from Sources/UI/Username.swift rename to Sources/MactrixUI/Username.swift From 9c244134081a4723e70d368ffa9bcb355c490340 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Tue, 29 Sep 2026 22:13:03 +0200 Subject: [PATCH 3/9] Move all source code to Swift packages --- Mactrix.xcodeproj/project.pbxproj | 95 +-------- .../xcshareddata/swiftpm/Package.resolved | 6 +- Mactrix/MactrixApp.swift | 4 +- Package.resolved | 29 ++- Package.swift | 40 ++-- .../MactrixApp}/Commands.swift | 10 +- .../MactrixApp}/Extensions/Data+Mime.swift | 0 .../Extensions/Logger+Extensions.swift | 10 + .../Extensions/MatrixClient+Extensions.swift | 142 +++++++++++++ .../Extensions/Models+Extensions.swift | 0 .../Extensions/NSColor+userID.swift | 0 .../MactrixApp}/Extensions/NSTableView.swift | 0 .../MactrixApp}/MainView.swift | 9 +- .../MactrixApp}/Models/AppState.swift | 13 +- .../Models/SidebarSectionCollapsibility.swift | 10 +- .../MactrixApp}/Models/WindowState.swift | 55 ++--- {Mactrix => Sources/MactrixApp}/Toolbar.swift | 0 .../ChatInputView/ChatInputView.swift | 1 + .../ChatView/ChatInputView/ChatTextView.swift | 64 +++--- .../MactrixApp}/Views/ChatView/ChatView.swift | 1 + .../Views/ChatView/EmbeddedMessageView.swift | 5 +- .../TimelineView/MessageContentRowView.swift | 1 + .../TimelineView/MessageFileContentView.swift | 1 + .../MessageHoverOverlayView.swift | 0 .../MessageImageContentView.swift | 1 + .../TimelineView/MessageProfileRowView.swift | 2 +- .../TimelineView/MessageReactionsView.swift | 0 .../MessageReadReceiptsView.swift | 2 +- .../MessageReplyPreviewView.swift | 2 +- .../TimelineView/MessageRowView.swift | 3 +- .../TimelineView/MessageTextContentView.swift | 11 +- .../MessageThreadSummaryView.swift | 0 .../MessageVideoContentView.swift | 6 +- .../TimelineView/StateEventRowView.swift | 2 +- .../TimelineView/TimelineRowProjection.swift | 0 .../TimelineView/TimelineTableView.swift | 40 ++-- .../TimelineViewRepresentable.swift | 1 + .../TimelineView/TypingIndicatorRowView.swift | 0 .../TimelineView/VirtualItemRowView.swift | 0 .../Views/Inspector/InspectorRoomPins.swift | 6 +- .../Views/Inspector/InspectorScreen.swift | 3 +- .../Views/Inspector/InspectorThreads.swift | 6 +- .../Views/Inspector/InspectorUserInfo.swift | 2 +- .../Views/LoadMatrixUriScreen.swift | 10 +- .../MactrixApp}/Views/MatrixImageView.swift | 15 +- .../Views/MultilineBannerLabelStyle.swift | 0 .../Views/Search/SearchInspectorView.swift | 3 +- .../Search/SearchRoomInspectorView.swift | 3 +- .../Search/SearchUserInspectorView.swift | 2 +- .../Views/Search/SearchViewModifier.swift | 1 + .../Views/Settings/AccountSettingsView.swift | 0 .../Settings/AppearanceSettingsView.swift | 2 +- .../Views/Settings/SessionsSettingsView.swift | 3 +- .../Views/Settings/SettingsView.swift | 7 +- .../Views/Sidebar/RoomContextMenu.swift | 3 +- .../Sidebar/SidebarSessionVerification.swift | 0 .../Views/Sidebar/SidebarSyncState.swift | 0 .../Views/Sidebar/SidebarView.swift | 3 +- .../Views/Sidebar/SpaceDisclosureGroup.swift | 4 +- .../MactrixApp}/Views/WelcomeSheetView.swift | 2 + .../Extensions/Color+userID.swift | 0 .../{ => Extensions}/Logger+Extensions.swift | 2 +- .../MatrixIntegration}/AppKeychain.swift | 0 .../MatrixIntegration}/AsyncSDKListener.swift | 0 .../Extensions/Logger+Extensions.swift | 13 ++ .../Extensions/MatrixRustSDK+Extensions.swift | 34 +-- .../MatrixRustSDK+MessageContent.swift | 4 +- .../MatrixIntegration}/HomeserverLogin.swift | 14 +- .../MatrixIntegration}/LiveRoom.swift | 0 .../MatrixIntegration}/LiveRoomSearch.swift | 10 +- .../LiveSpaceRoomList.swift | 24 +-- .../MatrixIntegration}/LiveSpaceService.swift | 0 .../MatrixIntegration}/LiveTimeline.swift | 4 +- .../MatrixClient+Listeners.swift | 28 +-- .../MatrixIntegration}/MatrixClient.swift | 200 +++--------------- .../MatrixClientSessionDelegate.swift | 4 +- .../MatrixNotifications.swift | 10 +- .../MatrixIntegration}/SidebarRoom.swift | 4 +- .../MatrixIntegration}/SidebarSpaceRoom.swift | 14 +- .../MatrixIntegration}/UserSession.swift | 4 +- .../AttributedTextView.swift | 2 +- 81 files changed, 526 insertions(+), 486 deletions(-) rename {Mactrix => Sources/MactrixApp}/Commands.swift (96%) rename {Mactrix => Sources/MactrixApp}/Extensions/Data+Mime.swift (100%) create mode 100644 Sources/MactrixApp/Extensions/Logger+Extensions.swift create mode 100644 Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift rename {Mactrix => Sources/MactrixApp}/Extensions/Models+Extensions.swift (100%) rename {Mactrix => Sources/MactrixApp}/Extensions/NSColor+userID.swift (100%) rename {Mactrix => Sources/MactrixApp}/Extensions/NSTableView.swift (100%) rename {Mactrix => Sources/MactrixApp}/MainView.swift (98%) rename {Mactrix => Sources/MactrixApp}/Models/AppState.swift (56%) rename {Mactrix => Sources/MactrixApp}/Models/SidebarSectionCollapsibility.swift (83%) rename {Mactrix => Sources/MactrixApp}/Models/WindowState.swift (79%) rename {Mactrix => Sources/MactrixApp}/Toolbar.swift (100%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/ChatInputView/ChatInputView.swift (99%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/ChatInputView/ChatTextView.swift (95%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/ChatView.swift (99%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/EmbeddedMessageView.swift (91%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageContentRowView.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageFileContentView.swift (99%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageHoverOverlayView.swift (100%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageImageContentView.swift (99%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageProfileRowView.swift (99%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageReactionsView.swift (100%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageReadReceiptsView.swift (99%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageReplyPreviewView.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageRowView.swift (99%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageTextContentView.swift (94%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageThreadSummaryView.swift (100%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/MessageVideoContentView.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/StateEventRowView.swift (97%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/TimelineRowProjection.swift (100%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/TimelineTableView.swift (97%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/TimelineViewRepresentable.swift (97%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/TypingIndicatorRowView.swift (100%) rename {Mactrix => Sources/MactrixApp}/Views/ChatView/TimelineView/VirtualItemRowView.swift (100%) rename {Mactrix => Sources/MactrixApp}/Views/Inspector/InspectorRoomPins.swift (94%) rename {Mactrix => Sources/MactrixApp}/Views/Inspector/InspectorScreen.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/Inspector/InspectorThreads.swift (95%) rename {Mactrix => Sources/MactrixApp}/Views/Inspector/InspectorUserInfo.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/LoadMatrixUriScreen.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/MatrixImageView.swift (83%) rename {Mactrix => Sources/MactrixApp}/Views/MultilineBannerLabelStyle.swift (100%) rename {Mactrix => Sources/MactrixApp}/Views/Search/SearchInspectorView.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/Search/SearchRoomInspectorView.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/Search/SearchUserInspectorView.swift (99%) rename {Mactrix => Sources/MactrixApp}/Views/Search/SearchViewModifier.swift (99%) rename {Mactrix => Sources/MactrixApp}/Views/Settings/AccountSettingsView.swift (100%) rename {Mactrix => Sources/MactrixApp}/Views/Settings/AppearanceSettingsView.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/Settings/SessionsSettingsView.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/Settings/SettingsView.swift (83%) rename {Mactrix => Sources/MactrixApp}/Views/Sidebar/RoomContextMenu.swift (99%) rename {Mactrix => Sources/MactrixApp}/Views/Sidebar/SidebarSessionVerification.swift (100%) rename {Mactrix => Sources/MactrixApp}/Views/Sidebar/SidebarSyncState.swift (100%) rename {Mactrix => Sources/MactrixApp}/Views/Sidebar/SidebarView.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/Sidebar/SpaceDisclosureGroup.swift (98%) rename {Mactrix => Sources/MactrixApp}/Views/WelcomeSheetView.swift (99%) rename Sources/{MatrixProtocols => MactrixUI}/Extensions/Color+userID.swift (100%) rename Sources/MactrixUI/{ => Extensions}/Logger+Extensions.swift (64%) rename {Mactrix/Models => Sources/MatrixIntegration}/AppKeychain.swift (100%) rename {Mactrix/Models => Sources/MatrixIntegration}/AsyncSDKListener.swift (100%) create mode 100644 Sources/MatrixIntegration/Extensions/Logger+Extensions.swift rename {Mactrix => Sources/MatrixIntegration}/Extensions/MatrixRustSDK+Extensions.swift (95%) rename {Mactrix => Sources/MatrixIntegration}/Extensions/MatrixRustSDK+MessageContent.swift (89%) rename {Mactrix/Models => Sources/MatrixIntegration}/HomeserverLogin.swift (83%) rename {Mactrix/Models => Sources/MatrixIntegration}/LiveRoom.swift (100%) rename {Mactrix/Models => Sources/MatrixIntegration}/LiveRoomSearch.swift (89%) rename {Mactrix/Models => Sources/MatrixIntegration}/LiveSpaceRoomList.swift (84%) rename {Mactrix/Models => Sources/MatrixIntegration}/LiveSpaceService.swift (100%) rename {Mactrix/Models => Sources/MatrixIntegration}/LiveTimeline.swift (98%) rename {Mactrix/Models => Sources/MatrixIntegration}/MatrixClient+Listeners.swift (80%) rename {Mactrix/Models => Sources/MatrixIntegration}/MatrixClient.swift (54%) rename {Mactrix/Models => Sources/MatrixIntegration}/MatrixClientSessionDelegate.swift (97%) rename Mactrix/Models/Notifications.swift => Sources/MatrixIntegration/MatrixNotifications.swift (94%) rename {Mactrix/Models => Sources/MatrixIntegration}/SidebarRoom.swift (93%) rename {Mactrix/Models => Sources/MatrixIntegration}/SidebarSpaceRoom.swift (71%) rename {Mactrix/Models => Sources/MatrixIntegration}/UserSession.swift (97%) diff --git a/Mactrix.xcodeproj/project.pbxproj b/Mactrix.xcodeproj/project.pbxproj index 8dc162a..fc6edd7 100644 --- a/Mactrix.xcodeproj/project.pbxproj +++ b/Mactrix.xcodeproj/project.pbxproj @@ -7,15 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 3409F4EC2EBFD8D4009537B4 /* UI in Frameworks */ = {isa = PBXBuildFile; productRef = 3409F4EB2EBFD8D4009537B4 /* UI */; }; - 3442F379306BD9E90030346A /* MatrixProtocols in Frameworks */ = {isa = PBXBuildFile; productRef = 3442F378306BD9E90030346A /* MatrixProtocols */; }; - 3442F37B306BD9E90030346A /* MessageFormatting in Frameworks */ = {isa = PBXBuildFile; productRef = 3442F37A306BD9E90030346A /* MessageFormatting */; }; - 3442F37D306BD9E90030346A /* UI in Frameworks */ = {isa = PBXBuildFile; productRef = 3442F37C306BD9E90030346A /* UI */; }; - 3442F37F306BD9E90030346A /* Utils in Frameworks */ = {isa = PBXBuildFile; productRef = 3442F37E306BD9E90030346A /* Utils */; }; - 345E77E82ED9F309002E5B9A /* Utils in Frameworks */ = {isa = PBXBuildFile; productRef = 345E77E72ED9F309002E5B9A /* Utils */; }; - 348E99882F40ABC3009F57A9 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 348E99872F40ABC3009F57A9 /* AsyncAlgorithms */; }; - 34913F6E2EC0F532003034CB /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 34913F6D2EC0F532003034CB /* MatrixRustSDK */; }; - 349B041C2F5D77E80023FBF4 /* MessageFormatting in Frameworks */ = {isa = PBXBuildFile; productRef = 349B041B2F5D77E80023FBF4 /* MessageFormatting */; }; + 34A3DEF5306BF85D00866303 /* MactrixApp in Frameworks */ = {isa = PBXBuildFile; productRef = 34A3DEF4306BF85D00866303 /* MactrixApp */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -48,15 +40,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 349B041C2F5D77E80023FBF4 /* MessageFormatting in Frameworks */, - 3442F37B306BD9E90030346A /* MessageFormatting in Frameworks */, - 3442F37F306BD9E90030346A /* Utils in Frameworks */, - 3409F4EC2EBFD8D4009537B4 /* UI in Frameworks */, - 34913F6E2EC0F532003034CB /* MatrixRustSDK in Frameworks */, - 3442F37D306BD9E90030346A /* UI in Frameworks */, - 345E77E82ED9F309002E5B9A /* Utils in Frameworks */, - 3442F379306BD9E90030346A /* MatrixProtocols in Frameworks */, - 348E99882F40ABC3009F57A9 /* AsyncAlgorithms in Frameworks */, + 34A3DEF5306BF85D00866303 /* MactrixApp in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -107,15 +91,7 @@ ); name = Mactrix; packageProductDependencies = ( - 3409F4EB2EBFD8D4009537B4 /* UI */, - 34913F6D2EC0F532003034CB /* MatrixRustSDK */, - 345E77E72ED9F309002E5B9A /* Utils */, - 348E99872F40ABC3009F57A9 /* AsyncAlgorithms */, - 349B041B2F5D77E80023FBF4 /* MessageFormatting */, - 3442F378306BD9E90030346A /* MatrixProtocols */, - 3442F37A306BD9E90030346A /* MessageFormatting */, - 3442F37C306BD9E90030346A /* UI */, - 3442F37E306BD9E90030346A /* Utils */, + 34A3DEF4306BF85D00866303 /* MactrixApp */, ); productName = Mactrix; productReference = 343858422EB394590010922A /* Mactrix.app */; @@ -146,9 +122,7 @@ mainGroup = 343858392EB394590010922A; minimizedProjectReferenceProxies = 1; packageReferences = ( - 342C624C2EB4DDE600E2426A /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */, - 348E99862F40ABC3009F57A9 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, - 3442F377306BD9E90030346A /* XCLocalSwiftPackageReference "." */, + 34A3DEF3306BF85D00866303 /* XCLocalSwiftPackageReference "../Mactrix" */, ); preferredProjectObjectVersion = 77; productRefGroup = 343858432EB394590010922A /* Products */; @@ -431,69 +405,16 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 3442F377306BD9E90030346A /* XCLocalSwiftPackageReference "." */ = { + 34A3DEF3306BF85D00866303 /* XCLocalSwiftPackageReference "../Mactrix" */ = { isa = XCLocalSwiftPackageReference; - relativePath = .; + relativePath = ../Mactrix; }; /* End XCLocalSwiftPackageReference section */ -/* Begin XCRemoteSwiftPackageReference section */ - 342C624C2EB4DDE600E2426A /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/matrix-org/matrix-rust-components-swift"; - requirement = { - branch = main; - kind = branch; - }; - }; - 348E99862F40ABC3009F57A9 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.2; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - /* Begin XCSwiftPackageProductDependency section */ - 3409F4EB2EBFD8D4009537B4 /* UI */ = { - isa = XCSwiftPackageProductDependency; - productName = UI; - }; - 3442F378306BD9E90030346A /* MatrixProtocols */ = { - isa = XCSwiftPackageProductDependency; - productName = MatrixProtocols; - }; - 3442F37A306BD9E90030346A /* MessageFormatting */ = { - isa = XCSwiftPackageProductDependency; - productName = MessageFormatting; - }; - 3442F37C306BD9E90030346A /* UI */ = { - isa = XCSwiftPackageProductDependency; - productName = UI; - }; - 3442F37E306BD9E90030346A /* Utils */ = { - isa = XCSwiftPackageProductDependency; - productName = Utils; - }; - 345E77E72ED9F309002E5B9A /* Utils */ = { - isa = XCSwiftPackageProductDependency; - productName = Utils; - }; - 348E99872F40ABC3009F57A9 /* AsyncAlgorithms */ = { - isa = XCSwiftPackageProductDependency; - package = 348E99862F40ABC3009F57A9 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; - productName = AsyncAlgorithms; - }; - 34913F6D2EC0F532003034CB /* MatrixRustSDK */ = { - isa = XCSwiftPackageProductDependency; - package = 342C624C2EB4DDE600E2426A /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */; - productName = MatrixRustSDK; - }; - 349B041B2F5D77E80023FBF4 /* MessageFormatting */ = { + 34A3DEF4306BF85D00866303 /* MactrixApp */ = { isa = XCSwiftPackageProductDependency; - productName = MessageFormatting; + productName = MactrixApp; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 77304b3..c9e05e5 100644 --- a/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "50f217c91f7703749daea6fb0f9b8846da95b7ff8a20f4d16b17f38fcd3f93ba", + "originHash" : "635d716be3a94a148224a9984120ddd8f677bd24074126e4034d778a59c72f3a", "pins" : [ { "identity" : "matrix-rust-components-swift", "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-rust-components-swift", "state" : { - "branch" : "main", - "revision" : "d66ebb38271b75b1f101ccbc927c340aff09c71b" + "revision" : "d66ebb38271b75b1f101ccbc927c340aff09c71b", + "version" : "26.9.7" } }, { diff --git a/Mactrix/MactrixApp.swift b/Mactrix/MactrixApp.swift index 148cbe7..8c9571a 100644 --- a/Mactrix/MactrixApp.swift +++ b/Mactrix/MactrixApp.swift @@ -1,7 +1,7 @@ import OSLog import SwiftUI - -let applicationID = "dk.qpqp.mactrix" +import MactrixApp +import MatrixIntegration @main struct MactrixApp: App { diff --git a/Package.resolved b/Package.resolved index 9882ef7..48a9054 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,33 @@ { - "originHash" : "e5f16bacd4a4a267868453b778f87af0d39a683e5e32bb9b4b016b22c39b30ac", + "originHash" : "adf5b9a324052cbf70007b000a6faa9083f086975491cf25df827432434df3e6", "pins" : [ + { + "identity" : "matrix-rust-components-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/matrix-org/matrix-rust-components-swift", + "state" : { + "revision" : "d66ebb38271b75b1f101ccbc927c340aff09c71b", + "version" : "26.9.7" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "789dcf1f3d3f00251482f40a432ab7144c181987", + "version" : "1.1.6" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "98ef3c98609a1e31b7e157b5b619579001a789d6", + "version" : "1.7.1" + } + }, { "identity" : "zmarkupparser", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index a248677..31ceef4 100644 --- a/Package.swift +++ b/Package.swift @@ -8,20 +8,14 @@ let package = Package( platforms: [.macOS(.v15)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "MactrixUI", - targets: ["MactrixUI"] - ), - .library( - name: "MatrixProtocols", - targets: ["MatrixProtocols"] - ), - .library(name: "Utils", targets: ["Utils"]), - .library(name: "MessageFormatting", targets: ["MessageFormatting"]), + .library(name: "MactrixApp", targets: ["MactrixApp"]), ], dependencies: [ .package(url: "https://github.com/ZhgChgLi/ZMarkupParser.git", from: "1.12.0"), - // .package(url: "https://github.com/matrix-org/matrix-rust-components-swift", from: "25.10.27"), + .package( + url: "https://github.com/matrix-org/matrix-rust-components-swift", from: "26.09.07" + ), + .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.1.2"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -32,17 +26,29 @@ let package = Package( ), .target(name: "Utils"), .testTarget(name: "UtilsTests", dependencies: ["Utils"]), - /* .target( - name: "TimelineUI", - dependencies: ["MatrixProtocols", .product(name: "MatrixRustSDK", package: "matrix-rust-components-swift")] - ), */ + .target(name: "MatrixProtocols"), + .target( + name: "MatrixIntegration", + dependencies: [ + "Utils", + "MatrixProtocols", + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "MatrixRustSDK", package: "matrix-rust-components-swift"), + ] + ), .target( - name: "MatrixProtocols" + name: "MactrixApp", + dependencies: [ + "MatrixIntegration", + "MactrixUI", + "MessageFormatting", + .product(name: "MatrixRustSDK", package: "matrix-rust-components-swift"), + ] ), .target( name: "MessageFormatting", dependencies: [ - "ZMarkupParser", + .product(name: "ZMarkupParser", package: "zmarkupparser"), ] ), ] diff --git a/Mactrix/Commands.swift b/Sources/MactrixApp/Commands.swift similarity index 96% rename from Mactrix/Commands.swift rename to Sources/MactrixApp/Commands.swift index d3938e3..1cfe648 100644 --- a/Mactrix/Commands.swift +++ b/Sources/MactrixApp/Commands.swift @@ -1,10 +1,12 @@ import SwiftUI -struct AppCommands: Commands { +public struct AppCommands: Commands { @FocusedValue(WindowState.self) private var windowState: WindowState? @AppStorage("fontSize") var fontSize: Int = 13 - var body: some Commands { + public init() {} + + public var body: some Commands { SidebarCommands() InspectorCommands() TextEditingCommands() @@ -52,7 +54,7 @@ struct AppCommands: Commands { .help("Create a new room") .keyboardShortcut("N", modifiers: [.command, .shift]) } - + var fontSizeCommands: some Commands { CommandGroup(after: .toolbar) { Button { @@ -73,7 +75,7 @@ struct AppCommands: Commands { Button { fontSize -= 1 - + } label: { Text("Make Text Smaller") } diff --git a/Mactrix/Extensions/Data+Mime.swift b/Sources/MactrixApp/Extensions/Data+Mime.swift similarity index 100% rename from Mactrix/Extensions/Data+Mime.swift rename to Sources/MactrixApp/Extensions/Data+Mime.swift diff --git a/Sources/MactrixApp/Extensions/Logger+Extensions.swift b/Sources/MactrixApp/Extensions/Logger+Extensions.swift new file mode 100644 index 0000000..882f502 --- /dev/null +++ b/Sources/MactrixApp/Extensions/Logger+Extensions.swift @@ -0,0 +1,10 @@ +import OSLog + +extension Logger { + private static let subsystem = "dk.qpqp.mactrix.mactrix-app" + + static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") + static let matrixClient = Logger(subsystem: subsystem, category: "matrix-client") + static let windowState = Logger(subsystem: subsystem, category: "window-state") + static let timelineTableView = Logger(subsystem: subsystem, category: "timeline-table-view") +} diff --git a/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift b/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift new file mode 100644 index 0000000..d54313a --- /dev/null +++ b/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift @@ -0,0 +1,142 @@ +import MatrixIntegration +import MactrixUI +import MatrixRustSDK +import OSLog +import AppKit + +extension MatrixClient { + @MainActor + struct MatrixClientRoomPreviewActions: @MainActor RoomPreviewActions { + let roomId: String + let matrixClient: MatrixClient + let windowState: WindowState + + func joinRoom() async throws { + let room = try await matrixClient.client.joinRoomById(roomId: roomId) + let timeline = LiveTimeline(room: LiveRoom(matrixRoom: room)) + windowState.selectedScreen = .joinedRoom(timeline: timeline) + } + + func knockRoom() async throws { + let room = try await matrixClient.client.knock(roomIdOrAlias: roomId, reason: nil, serverNames: ["matrix.org"]) + let timeline = LiveTimeline(room: LiveRoom(matrixRoom: room)) + windowState.selectedScreen = .joinedRoom(timeline: timeline) + } + + func visitRoom() { + windowState.selectedRoomId = roomId + } + } + + func roomPreviewActions(forRoomWithId roomId: String, windowState: WindowState) -> RoomPreviewActions { + return MatrixClientRoomPreviewActions(roomId: roomId, matrixClient: self, windowState: windowState) + } +} + +extension MatrixClient: MactrixUI.ImageLoader { + // In-memory cache of decoded NSImage objects. Purpose is rendering performance — + // keeping decoded bitmaps ready to display prevents flicker when scrolling back to + // previously viewed images. This is distinct from the SDK-level media download cache + // (see clearCaches / getMediaFile(useCache:)) which avoids re-fetching from the server. + // Costs are tracked in decoded RGBA bytes (width × height × 4), not compressed sizes, + // since a typical JPEG can be 20-50× larger once decoded. + static let imageCache: NSCache = { + let cache = NSCache() + cache.totalCostLimit = 256 * 1024 * 1024 // 256MB decoded pixels + return cache + }() + + private static let imageCacheMaxObjectCost = 64 * 1024 * 1024 // 64MB per object (~8000x2000px RGBA) + + static func setCachedImage(_ image: NSImage, forKey key: NSString) { + let cost = Int(image.size.width * image.size.height) * 4 // decoded RGBA bytes + guard cost <= imageCacheMaxObjectCost else { return } + imageCache.setObject(image, forKey: key, cost: cost) + } + + public func cachedImage(matrixUrl: String) -> NSImage? { + return Self.imageCache.object(forKey: NSString(string: matrixUrl)) + } + + public func loadImage(matrixUrl: String, size: CGSize?) async throws -> NSImage? { + let cacheKey = if let size { + NSString(string: "\(matrixUrl)_\(Int(size.width))x\(Int(size.height))") + } else { + NSString(string: matrixUrl) + } + if let cached = Self.imageCache.object(forKey: cacheKey) { + return cached + } + + let mediaSource = try MediaSource.fromUrl(url: matrixUrl) + + let imageData: Data + if let size { + let width = UInt64(size.width) + let height = UInt64(size.height) + imageData = try await client.getMediaThumbnail(mediaSource: mediaSource, width: width, height: height) + } else { + imageData = try await client.getMediaContent(mediaSource: mediaSource) + } + + do { + let nsImage = try imageData.toOrientedImage(contentType: imageData.computeMimeType()) + Self.setCachedImage(nsImage, forKey: cacheKey) + return nsImage + } catch { + Logger.matrixClient.error("failed convert matrix media data to Image: \(error) \(imageData)") + throw error + } + } +} + +extension MatrixClient { + @MainActor + struct MatrixClientUserProfileActions: MactrixUI.UserProfileActions { + let userId: String + let matrixClient: MatrixClient + let windowState: WindowState + + func sendMessage() async { + do { + if let room = try matrixClient.client.getDmRoom(userId: userId) { + windowState.selectedRoomId = room.id + return + } + + let createRoomParams = MatrixRustSDK.CreateRoomParameters( + name: nil, isEncrypted: false, isDirect: true, visibility: .private, + preset: .privateChat, invite: [userId] + ) + let roomId = try await matrixClient.client.createRoom(request: createRoomParams) + windowState.selectedRoomId = roomId + } catch { + Logger.viewCycle.error("failed to get DM room for user \(userId): \(error)") + } + } + + func shareProfile() {} + + func ignoreUser() async { + Logger.viewCycle.info("Ignore user \(userId)") + do { + try await matrixClient.client.ignoreUser(userId: userId) + } catch { + Logger.viewCycle.error("failed to ignore user: \(error)") + } + } + + func unignoreUser() async { + Logger.viewCycle.info("Unignore user \(userId)") + do { + try await matrixClient.client.unignoreUser(userId: userId) + } catch { + Logger.viewCycle.error("failed to unignore user: \(error)") + } + } + } + + func userProfileActions(forUserId userId: String, windowState: WindowState) -> some MactrixUI.UserProfileActions { + return MatrixClientUserProfileActions(userId: userId, matrixClient: self, windowState: windowState) + } +} diff --git a/Mactrix/Extensions/Models+Extensions.swift b/Sources/MactrixApp/Extensions/Models+Extensions.swift similarity index 100% rename from Mactrix/Extensions/Models+Extensions.swift rename to Sources/MactrixApp/Extensions/Models+Extensions.swift diff --git a/Mactrix/Extensions/NSColor+userID.swift b/Sources/MactrixApp/Extensions/NSColor+userID.swift similarity index 100% rename from Mactrix/Extensions/NSColor+userID.swift rename to Sources/MactrixApp/Extensions/NSColor+userID.swift diff --git a/Mactrix/Extensions/NSTableView.swift b/Sources/MactrixApp/Extensions/NSTableView.swift similarity index 100% rename from Mactrix/Extensions/NSTableView.swift rename to Sources/MactrixApp/Extensions/NSTableView.swift diff --git a/Mactrix/MainView.swift b/Sources/MactrixApp/MainView.swift similarity index 98% rename from Mactrix/MainView.swift rename to Sources/MactrixApp/MainView.swift index 00ed61e..2845dbc 100644 --- a/Mactrix/MainView.swift +++ b/Sources/MactrixApp/MainView.swift @@ -1,16 +1,19 @@ import MatrixRustSDK import OSLog import SwiftUI -import UI +import MactrixUI import Utils +import MatrixIntegration -struct MainView: View { +public struct MainView: View { @Environment(AppState.self) var appState @SceneStorage("MainView.windowState") private var windowState: WindowState = .init() @State private var showWelcomeSheet: Bool = false + + public init() {} @ViewBuilder var details: some View { switch windowState.selectedScreen { @@ -61,7 +64,7 @@ struct MainView: View { ) } - var body: some View { + public var body: some View { @Bindable var windowState = windowState NavigationSplitView( diff --git a/Mactrix/Models/AppState.swift b/Sources/MactrixApp/Models/AppState.swift similarity index 56% rename from Mactrix/Models/AppState.swift rename to Sources/MactrixApp/Models/AppState.swift index 55c73be..0d91ce7 100644 --- a/Mactrix/Models/AppState.swift +++ b/Sources/MactrixApp/Models/AppState.swift @@ -1,13 +1,14 @@ -import Foundation -import MatrixRustSDK -import MatrixProtocols +import Observation +import MatrixIntegration import OSLog @MainActor @Observable -final class AppState { - var matrixClient: MatrixClient? +public final class AppState { + public var matrixClient: MatrixClient? + + public init() {} - func reset() async throws { + public func reset() async throws { do { try await matrixClient?.reset() } catch { diff --git a/Mactrix/Models/SidebarSectionCollapsibility.swift b/Sources/MactrixApp/Models/SidebarSectionCollapsibility.swift similarity index 83% rename from Mactrix/Models/SidebarSectionCollapsibility.swift rename to Sources/MactrixApp/Models/SidebarSectionCollapsibility.swift index 5e7f964..c4bb8a6 100644 --- a/Mactrix/Models/SidebarSectionCollapsibility.swift +++ b/Sources/MactrixApp/Models/SidebarSectionCollapsibility.swift @@ -18,16 +18,16 @@ /// /// > Note: You do not need to create this structure yourself. Rather, you should use the collapsibility information /// > from ``WindowState/sidebarSections``. -struct SidebarSectionCollapsibility: Codable, Equatable { +public struct SidebarSectionCollapsibility: Codable, Equatable { /// Whether the favorites section is expanded. - var favorites = true + public var favorites = true /// Whether the direct messages section is expanded. - var directs = true + public var directs = true /// Whether the rooms section is expanded. - var rooms = true + public var rooms = true /// Whether the spaces section is expanded. - var spaces = true + public var spaces = true } diff --git a/Mactrix/Models/WindowState.swift b/Sources/MactrixApp/Models/WindowState.swift similarity index 79% rename from Mactrix/Models/WindowState.swift rename to Sources/MactrixApp/Models/WindowState.swift index 6471b80..cca8170 100644 --- a/Mactrix/Models/WindowState.swift +++ b/Sources/MactrixApp/Models/WindowState.swift @@ -1,10 +1,11 @@ import Foundation +import MatrixIntegration import MatrixRustSDK import OSLog import SwiftUI import Utils -enum InspectorContent: Equatable { +public enum InspectorContent: Equatable { case roomInfo case search case userInfo(userId: String) @@ -13,7 +14,7 @@ enum InspectorContent: Equatable { case focusThread(threadTimeline: LiveTimeline) } -enum SearchDirectResult { +public enum SearchDirectResult { case lookingForRoom(alias: String), roomNotFound(alias: String) case resolvedRoomAlias(alias: String, resolvedRoom: MatrixRustSDK.ResolvedRoomAlias) case resolvedRoomId(roomPreview: MatrixRustSDK.RoomPreview) @@ -21,7 +22,7 @@ enum SearchDirectResult { case resolvedUser(profile: MatrixRustSDK.UserProfile) } -enum SelectedScreen { +public enum SelectedScreen { case joinedRoom(timeline: LiveTimeline) case loadMatrixUrl(_ url: Utils.MatrixUriScheme) case previewRoom(_ room: RoomPreview) @@ -31,24 +32,24 @@ enum SelectedScreen { } @MainActor @Observable -final class WindowState { - var selectedScreen: SelectedScreen = .none +public final class WindowState { + public var selectedScreen: SelectedScreen = .none - var selectedRoomId: String? - var inspectorVisible: Bool = false + public var selectedRoomId: String? + public var inspectorVisible: Bool = false - var inspectorContent: InspectorContent = .roomInfo + public var inspectorContent: InspectorContent = .roomInfo - var requestedVerification = false + public var requestedVerification = false - var searchQuery: String = "" - var searchTokens: [SearchToken] = [] - var searchDirectResult: SearchDirectResult? + public var searchQuery: String = "" + public var searchTokens: [SearchToken] = [] + public var searchDirectResult: SearchDirectResult? /// The collapsed/expanded states of the sections in the sidebar. - var sidebarSections = SidebarSectionCollapsibility() + public var sidebarSections = SidebarSectionCollapsibility() - var searchFocused: Binding { + public var searchFocused: Binding { Binding( get: { self.inspectorContent == .search }, set: { setFocused in @@ -64,7 +65,7 @@ final class WindowState { ) } - func toggleInspector() { + public func toggleInspector() { if inspectorVisible { if inspectorContent == .roomInfo { inspectorVisible = false @@ -77,7 +78,7 @@ final class WindowState { } } - func focusMessage(eventId: String) { + public func focusMessage(eventId: String) { guard case let .joinedRoom(timeline: roomTimeline) = selectedScreen else { Logger.windowState.warning("focus message failed, no active timeline") return @@ -87,14 +88,14 @@ final class WindowState { roomTimeline.focusEvent(id: .eventId(eventId: eventId)) } - func focusThread(rootEventId: String) { + public func focusThread(rootEventId: String) { guard case let .joinedRoom(timeline: roomTimeline) = selectedScreen else { return } inspectorVisible = true inspectorContent = .focusThread(threadTimeline: LiveTimeline(room: roomTimeline.room, focusThread: rootEventId)) } - func showRoomThreads() { + public func showRoomThreads() { if inspectorVisible, inspectorContent == .roomThreads { inspectorVisible = false } else { @@ -103,7 +104,7 @@ final class WindowState { } } - func showRoomPins() { + public func showRoomPins() { if inspectorVisible, inspectorContent == .roomPins { inspectorVisible = false } else { @@ -112,7 +113,7 @@ final class WindowState { } } - func focusUser(userId: String) { + public func focusUser(userId: String) { if inspectorVisible, inspectorContent == .userInfo(userId: userId) { inspectorVisible = false } else { @@ -123,7 +124,7 @@ final class WindowState { } extension WindowState: @MainActor RawRepresentable { - struct SceneStorageRepresentation: Codable { + public struct SceneStorageRepresentation: Codable { let selectedRoomId: String? let inspectorVisible: Bool let sidebarSections: SidebarSectionCollapsibility @@ -141,7 +142,7 @@ extension WindowState: @MainActor RawRepresentable { } } - convenience init?(rawValue: String) { + public convenience init?(rawValue: String) { guard let data = rawValue.data(using: .utf8), let decoded = try? JSONDecoder().decode(SceneStorageRepresentation.self, from: data) @@ -155,7 +156,7 @@ extension WindowState: @MainActor RawRepresentable { Logger.windowState.info("WindowState recovered from storage: \(rawValue)") } - var rawValue: String { + public var rawValue: String { guard let data = try? JSONEncoder().encode(SceneStorageRepresentation(windowState: self)), let result = String(data: data, encoding: .utf8) @@ -166,14 +167,16 @@ extension WindowState: @MainActor RawRepresentable { return result } - typealias RawValue = String + public typealias RawValue = String } -enum SearchToken: Hashable, Identifiable { +public enum SearchToken: Hashable, Identifiable { case users, rooms, spaces, messages case resolvedRoomAlias(alias: String, resolvedRoom: ResolvedRoomAlias) case resolvedRoomId(roomPreview: RoomPreview) case resolvedUser(profile: UserProfile) - var id: Self { self } + public var id: Self { + self + } } diff --git a/Mactrix/Toolbar.swift b/Sources/MactrixApp/Toolbar.swift similarity index 100% rename from Mactrix/Toolbar.swift rename to Sources/MactrixApp/Toolbar.swift diff --git a/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift similarity index 99% rename from Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift rename to Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift index 6ba6160..4cfc90b 100644 --- a/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift @@ -1,6 +1,7 @@ import MatrixRustSDK import OSLog import SwiftUI +import MatrixIntegration struct ChatInputView: View { @Environment(\.accessibilityReduceTransparency) var reduceTransparency diff --git a/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift similarity index 95% rename from Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift rename to Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift index 62d70f4..982cec1 100644 --- a/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift @@ -6,17 +6,17 @@ struct ChatTextView: NSViewRepresentable { typealias NSViewRepresentableType = NSTextView @AppStorage("fontSize") var fontSize: Int = 13 - + let text: Binding let placeholder: String let disabled: Bool let onSubmit: () -> Void - + func makeNSView(context: Context) -> DynamicTextView { let textView = DynamicTextView() - + textView.onSubmit = onSubmit - + textView.placeholderAttributedString = NSAttributedString( string: placeholder, attributes: [ @@ -24,32 +24,32 @@ struct ChatTextView: NSViewRepresentable { .font: NSFont.systemFont(ofSize: CGFloat(fontSize)) ] ) - + textView.backgroundColor = .clear textView.drawsBackground = false - + context.coordinator.textView = textView textView.delegate = context.coordinator - + textView.textContainerInset = DynamicTextView.padding - + textView.isVerticallyResizable = true textView.isHorizontallyResizable = false unsafe textView.textContainer?.widthTracksTextView = true - + textView.setContentHuggingPriority(.required, for: .vertical) textView.setContentCompressionResistancePriority(.required, for: .vertical) textView.font = NSFont.systemFont(ofSize: CGFloat(fontSize)) - + return textView } - + func updateNSView(_ textView: DynamicTextView, context: Context) { context.coordinator.text = text - + textView.onSubmit = onSubmit - + if textView.string != text.wrappedValue { textView.string = text.wrappedValue textView.invalidateIntrinsicContentSize() @@ -75,12 +75,12 @@ struct ChatTextView: NSViewRepresentable { ] ) } - + if textView.isEditable != !disabled { textView.isEditable = !disabled textView.isSelectable = !disabled textView.alphaValue = !disabled ? 1.0 : 0.5 - + if disabled { // resign first responder unsafe textView.window?.makeFirstResponder(nil) @@ -93,22 +93,22 @@ struct ChatTextView: NSViewRepresentable { textView.invalidateIntrinsicContentSize() } } - + func makeCoordinator() -> Coordinator { return Coordinator(text: text) } - + class Coordinator: NSObject, NSTextViewDelegate { var textView: NSTextView? var text: Binding - + init(text: Binding) { self.text = text } - + func textDidChange(_ notification: Notification) { guard let textView else { return } - + text.wrappedValue = textView.string } } @@ -116,38 +116,38 @@ struct ChatTextView: NSViewRepresentable { class DynamicTextView: NSTextView { @objc var placeholderAttributedString: NSAttributedString? - + static let padding = NSSize(width: 10, height: 10) - + var onSubmit: (() -> Void)? - + override var intrinsicContentSize: NSSize { guard let container = unsafe textContainer, let manager = unsafe layoutManager else { return .zero } - + // Force the layout for the current width manager.ensureLayout(for: container) let usedRect = manager.usedRect(for: container) - + // Return a flexible width but a fixed height based on text return NSSize(width: NSView.noIntrinsicMetric, height: ceil(usedRect.height) + CGFloat(2 * Self.padding.height)) } - + override func setFrameSize(_ newSize: NSSize) { let oldWidth = frame.width super.setFrameSize(newSize) - + if oldWidth != newSize.width { invalidateIntrinsicContentSize() } } - + override func didChangeText() { super.didChangeText() invalidateIntrinsicContentSize() } - + override func performKeyEquivalent(with event: NSEvent) -> Bool { // Always submit on cmd+enter if (event.specialKey == .enter || event.specialKey == .carriageReturn) @@ -156,10 +156,10 @@ class DynamicTextView: NSTextView { onSubmit?() return true } - + return super.performKeyEquivalent(with: event) } - + override func keyDown(with event: NSEvent) { // Handle enter as submit instead of newline if event.specialKey == .enter || event.specialKey == .carriageReturn, @@ -168,7 +168,7 @@ class DynamicTextView: NSTextView { onSubmit?() return } - + super.keyDown(with: event) } } diff --git a/Mactrix/Views/ChatView/ChatView.swift b/Sources/MactrixApp/Views/ChatView/ChatView.swift similarity index 99% rename from Mactrix/Views/ChatView/ChatView.swift rename to Sources/MactrixApp/Views/ChatView/ChatView.swift index c72c0b8..1df89c7 100644 --- a/Mactrix/Views/ChatView/ChatView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatView.swift @@ -2,6 +2,7 @@ import MatrixRustSDK import MatrixProtocols import OSLog import SwiftUI +import MatrixIntegration private struct TimelineReadKey: Equatable { let count: Int diff --git a/Mactrix/Views/ChatView/EmbeddedMessageView.swift b/Sources/MactrixApp/Views/ChatView/EmbeddedMessageView.swift similarity index 91% rename from Mactrix/Views/ChatView/EmbeddedMessageView.swift rename to Sources/MactrixApp/Views/ChatView/EmbeddedMessageView.swift index d06ad1e..d48bc60 100644 --- a/Mactrix/Views/ChatView/EmbeddedMessageView.swift +++ b/Sources/MactrixApp/Views/ChatView/EmbeddedMessageView.swift @@ -1,6 +1,7 @@ import MatrixRustSDK import SwiftUI -import UI +import MactrixUI +import MatrixIntegration struct EmbeddedMessageView: View { let embeddedEvent: MatrixRustSDK.EmbeddedEventDetails @@ -21,7 +22,7 @@ struct EmbeddedMessageView: View { if case let .ready(name, _, _, _, _) = senderProfile, let name { return name } return sender }(), - message: content.description, + message: content.userInterfaceText, action: action ) case let .error(message): diff --git a/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift similarity index 98% rename from Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift index cb467a9..9efb9ef 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift @@ -1,5 +1,6 @@ import AppKit import MatrixRustSDK +import MatrixIntegration /// Content retained by a message row across NSTableView reuse. protocol MessageContentRowView: NSView { diff --git a/Mactrix/Views/ChatView/TimelineView/MessageFileContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift similarity index 99% rename from Mactrix/Views/ChatView/TimelineView/MessageFileContentView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift index 9815cdb..0f19f39 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageFileContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift @@ -2,6 +2,7 @@ import AppKit import MatrixRustSDK import OSLog import UniformTypeIdentifiers +import MatrixIntegration final class MessageFileContentView: NSView, MessageContentRowView { var onSelectRequest: (() -> Void)? { diff --git a/Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift similarity index 100% rename from Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift similarity index 99% rename from Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift index fe3ef13..25795ef 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift @@ -2,6 +2,7 @@ import AppKit import MatrixRustSDK import OSLog import QuickLookUI +import MatrixIntegration /// One image and its optional text caption. Both views survive table row reuse. final class MessageImageContentView: NSView, MessageContentRowView { diff --git a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageProfileRowView.swift similarity index 99% rename from Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageProfileRowView.swift index 7f03736..630d6bd 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageProfileRowView.swift @@ -1,6 +1,6 @@ import AppKit import MatrixRustSDK -import UI +import MactrixUI class MessageProfileRowView: NSView { let profilePicture = NSButton() diff --git a/Mactrix/Views/ChatView/TimelineView/MessageReactionsView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReactionsView.swift similarity index 100% rename from Mactrix/Views/ChatView/TimelineView/MessageReactionsView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageReactionsView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReadReceiptsView.swift similarity index 99% rename from Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageReadReceiptsView.swift index f1abab4..71a97b2 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReadReceiptsView.swift @@ -1,7 +1,7 @@ import AppKit import MatrixRustSDK import MatrixProtocols -import UI +import MactrixUI /// Reuses the visible receipt avatars as the table row is recycled. final class MessageReadReceiptsView: NSButton { diff --git a/Mactrix/Views/ChatView/TimelineView/MessageReplyPreviewView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReplyPreviewView.swift similarity index 98% rename from Mactrix/Views/ChatView/TimelineView/MessageReplyPreviewView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageReplyPreviewView.swift index 1149fbc..3a74923 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageReplyPreviewView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReplyPreviewView.swift @@ -120,7 +120,7 @@ final class MessageReplyPreviewView: NSButton { } else { displayName = sender } - return (displayName, content.description, false) + return (displayName, content.userInterfaceText, false) case let .error(message): return ("Reply unavailable", message, false) } diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift similarity index 99% rename from Mactrix/Views/ChatView/TimelineView/MessageRowView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift index 085e76c..614210c 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift @@ -1,7 +1,8 @@ import AppKit import MatrixRustSDK import MatrixProtocols -import UI +import MactrixUI +import MatrixIntegration /// Shared row chrome. The content view is created once and retained across table reuse. final class MessageRowView: NSView { diff --git a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift similarity index 94% rename from Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift index 1a8e305..e91c1a7 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift @@ -1,4 +1,5 @@ import AppKit +import MatrixIntegration import MatrixRustSDK import MessageFormatting @@ -33,7 +34,7 @@ final class MessageTextContentView: NSView, MessageContentRowView { ]) } - func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { + func configure(content: MatrixRustSDK.MsgLikeContent, matrixClient: MatrixClient?) { bodyText.textStorage?.setAttributedString(Self.attributedBody(for: content)) } @@ -45,7 +46,7 @@ final class MessageTextContentView: NSView, MessageContentRowView { measure(Self.attributedCaption(caption, formatted: formatted), width: width) } - func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { + func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat) -> CGFloat { measure(Self.attributedBody(for: content), width: width) } @@ -78,7 +79,7 @@ final class MessageTextContentView: NSView, MessageContentRowView { return result } - private static func attributedBody(for content: MsgLikeContent) -> NSAttributedString { + private static func attributedBody(for content: MatrixRustSDK.MsgLikeContent) -> NSAttributedString { let fontSize = UserDefaults.standard.object(forKey: "fontSize") as? Int ?? 13 let font = NSFont.systemFont(ofSize: CGFloat(fontSize)) func plain(_ text: String, color: NSColor = .labelColor, italic: Bool = false) -> NSAttributedString { @@ -117,14 +118,14 @@ final class MessageTextContentView: NSView, MessageContentRowView { case .unableToDecrypt: return plain("Unable to decrypt", color: .secondaryLabelColor, italic: true) case let .other(eventType: eventType): - return plain("Custom event: \(eventType.description)") + return plain("Custom event: \(eventType.userInterfaceText)") case let .liveLocation(content: location): return plain("Live location: \(location.description ?? "no description")") } } private static func attributedText( - for message: some MessageContent, font: NSFont, color: NSColor + for message: some MatrixIntegration.MessageContent, font: NSFont, color: NSColor ) -> NSAttributedString { if let formatted = message.formatted, formatted.format == .html { let result = NSMutableAttributedString(attributedString: parseFormattedBody( diff --git a/Mactrix/Views/ChatView/TimelineView/MessageThreadSummaryView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageThreadSummaryView.swift similarity index 100% rename from Mactrix/Views/ChatView/TimelineView/MessageThreadSummaryView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageThreadSummaryView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageVideoContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift similarity index 98% rename from Mactrix/Views/ChatView/TimelineView/MessageVideoContentView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift index a85167e..8175c01 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageVideoContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift @@ -1,5 +1,6 @@ import AppKit import AVKit +import MatrixIntegration import MatrixRustSDK import OSLog @@ -180,8 +181,7 @@ final class MessageVideoContentView: NSView, MessageContentRowView { private func loadThumbnail(for video: VideoMessageContent, from client: MatrixClient?) { guard let source = video.info?.thumbnailSource, let client else { return } let url = video.source.url() - let cacheKey = NSString(string: source.url()) - if let cached = MatrixClient.imageCache.object(forKey: cacheKey) { + if let cached = client.cachedImage(matrixUrl: source.url()) { thumbnailView.image = cached return } @@ -191,7 +191,7 @@ final class MessageVideoContentView: NSView, MessageContentRowView { try Task.checkCancellation() let image = try data.toOrientedImage(contentType: data.computeMimeType()) try Task.checkCancellation() - MatrixClient.setCachedImage(image, forKey: cacheKey) + MatrixClient.setCachedImage(image, forKey: NSString(string: source.url())) guard let self, self.sourceURL == url else { return } self.thumbnailView.image = image self.thumbnailTask = nil diff --git a/Mactrix/Views/ChatView/TimelineView/StateEventRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/StateEventRowView.swift similarity index 97% rename from Mactrix/Views/ChatView/TimelineView/StateEventRowView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/StateEventRowView.swift index fd497e6..8449170 100644 --- a/Mactrix/Views/ChatView/TimelineView/StateEventRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/StateEventRowView.swift @@ -66,7 +66,7 @@ final class StateEventRowView: NSView { .font: NSFont.systemFont(ofSize: size), .foregroundColor: color, ])) - result.append(NSAttributedString(string: event.content.description, attributes: [ + result.append(NSAttributedString(string: event.content.userInterfaceText, attributes: [ .font: NSFontManager.shared.convert(NSFont.systemFont(ofSize: size), toHaveTrait: .italicFontMask), .foregroundColor: color, ])) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineRowProjection.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineRowProjection.swift similarity index 100% rename from Mactrix/Views/ChatView/TimelineView/TimelineRowProjection.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/TimelineRowProjection.swift diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift similarity index 97% rename from Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift index 843058d..e187f12 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift @@ -1,6 +1,7 @@ import AppKit -import MatrixRustSDK +import MatrixIntegration import MatrixProtocols +import MatrixRustSDK import OSLog enum TimelineSelectionDirection { @@ -16,7 +17,9 @@ enum TimelineSelectionDirection { } } - var rowStep: Int { self == .up ? 1 : -1 } + var rowStep: Int { + self == .up ? 1 : -1 + } } enum TimelineItemRowInfo { @@ -75,7 +78,9 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT let timeline: LiveTimeline private var projection: TimelineRowProjection - var timelineItems: [TimelineItemRowInfo] { projection.rows } + var timelineItems: [TimelineItemRowInfo] { + projection.rows + } init(coordinator: TimelineViewRepresentable.Coordinator, timeline: LiveTimeline) { self.coordinator = coordinator @@ -458,13 +463,13 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT func focusTimelineEvent(id eventId: MatrixRustSDK.EventOrTransactionId) { guard let rowIndex = timelineItems.firstIndex(where: { item in - switch item { - case .message(item: _, event: let event, content: _): - return event.eventOrTransactionId == eventId - default: - return false - } - }) else { return } + switch item { + case .message(item: _, event: let event, content: _): + return event.eventOrTransactionId == eventId + default: + return false + } + }) else { return } tableView.selectRowIndexes(IndexSet(integer: rowIndex), byExtendingSelection: false) updateSelectedMessage() @@ -557,8 +562,11 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT if let hoveredMessageId { hoveredRowIndex = newIDs.firstIndex(of: hoveredMessageId) - if hoveredRowIndex == nil { hideHoverOverlay() } - else { positionHoverOverlay() } + if hoveredRowIndex == nil { + hideHoverOverlay() + } else { + positionHoverOverlay() + } } updateSelectedMessage() } @@ -568,7 +576,9 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT private var measurementMessageViews: [MessageContentKind: MessageRowView] = [:] private func measurementView(for kind: MessageContentKind) -> MessageRowView { - if let view = measurementMessageViews[kind] { return view } + if let view = measurementMessageViews[kind] { + return view + } let view = kind.makeRowView() measurementMessageViews[kind] = view return view @@ -582,7 +592,9 @@ extension TimelineViewController: NSTableViewDelegate { func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { guard timelineItems.indices.contains(row) else { return false } - if case .message = timelineItems[row] { return true } + if case .message = timelineItems[row] { + return true + } return false } diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineViewRepresentable.swift similarity index 97% rename from Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/TimelineViewRepresentable.swift index 1919261..a91c8b8 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineViewRepresentable.swift @@ -1,3 +1,4 @@ +import MatrixIntegration import MatrixRustSDK import SwiftUI diff --git a/Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/TypingIndicatorRowView.swift similarity index 100% rename from Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/TypingIndicatorRowView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/VirtualItemRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/VirtualItemRowView.swift similarity index 100% rename from Mactrix/Views/ChatView/TimelineView/VirtualItemRowView.swift rename to Sources/MactrixApp/Views/ChatView/TimelineView/VirtualItemRowView.swift diff --git a/Mactrix/Views/Inspector/InspectorRoomPins.swift b/Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift similarity index 94% rename from Mactrix/Views/Inspector/InspectorRoomPins.swift rename to Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift index 3b2eb2f..86bed47 100644 --- a/Mactrix/Views/Inspector/InspectorRoomPins.swift +++ b/Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift @@ -2,7 +2,9 @@ import MatrixRustSDK import MatrixProtocols import OSLog import SwiftUI -import UI +import MactrixUI +import MatrixIntegration +import MactrixApp struct RoomPinRow: View { @Environment(AppState.self) private var appState @@ -18,7 +20,7 @@ struct RoomPinRow: View { if let event { VStack { Text(event.sender).bold() - Text(event.content.description) + Text(event.content.userInterfaceText) } } else { Text("Loading message...") diff --git a/Mactrix/Views/Inspector/InspectorScreen.swift b/Sources/MactrixApp/Views/Inspector/InspectorScreen.swift similarity index 98% rename from Mactrix/Views/Inspector/InspectorScreen.swift rename to Sources/MactrixApp/Views/Inspector/InspectorScreen.swift index 4363930..537314b 100644 --- a/Mactrix/Views/Inspector/InspectorScreen.swift +++ b/Sources/MactrixApp/Views/Inspector/InspectorScreen.swift @@ -2,7 +2,8 @@ import MatrixRustSDK import MatrixProtocols import OSLog import SwiftUI -import UI +import MactrixUI +import MatrixIntegration struct InspectorScreen: View { @Environment(AppState.self) var appState diff --git a/Mactrix/Views/Inspector/InspectorThreads.swift b/Sources/MactrixApp/Views/Inspector/InspectorThreads.swift similarity index 95% rename from Mactrix/Views/Inspector/InspectorThreads.swift rename to Sources/MactrixApp/Views/Inspector/InspectorThreads.swift index 26c9783..4161dd5 100644 --- a/Mactrix/Views/Inspector/InspectorThreads.swift +++ b/Sources/MactrixApp/Views/Inspector/InspectorThreads.swift @@ -1,8 +1,9 @@ -import MatrixRustSDK +import MactrixUI +import MatrixIntegration import MatrixProtocols +import MatrixRustSDK import OSLog import SwiftUI -import UI struct InspectorThreads: View { @Environment(AppState.self) private var appState @@ -18,7 +19,6 @@ struct InspectorThreads: View { return timeline } - @ViewBuilder var content: some View { Text("Threads") } diff --git a/Mactrix/Views/Inspector/InspectorUserInfo.swift b/Sources/MactrixApp/Views/Inspector/InspectorUserInfo.swift similarity index 98% rename from Mactrix/Views/Inspector/InspectorUserInfo.swift rename to Sources/MactrixApp/Views/Inspector/InspectorUserInfo.swift index 0b2ec3b..451d61f 100644 --- a/Mactrix/Views/Inspector/InspectorUserInfo.swift +++ b/Sources/MactrixApp/Views/Inspector/InspectorUserInfo.swift @@ -2,7 +2,7 @@ import MatrixRustSDK import MatrixProtocols import OSLog import SwiftUI -import UI +import MactrixUI struct InspectorUserInfo: View { @Environment(AppState.self) private var appState diff --git a/Mactrix/Views/LoadMatrixUriScreen.swift b/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift similarity index 98% rename from Mactrix/Views/LoadMatrixUriScreen.swift rename to Sources/MactrixApp/Views/LoadMatrixUriScreen.swift index 8ee0776..a20e538 100644 --- a/Mactrix/Views/LoadMatrixUriScreen.swift +++ b/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift @@ -6,9 +6,9 @@ import Utils struct LoadMatrixUriScreen: View { @Environment(AppState.self) var appState @Environment(WindowState.self) var windowState - + let matrixUri: Utils.MatrixUriScheme - + @ViewBuilder var content: some View { switch matrixUri.kind { @@ -26,17 +26,17 @@ struct LoadMatrixUriScreen: View { } } } - + var body: some View { content .task(id: matrixUri) { Logger.viewCycle.debug("LoadMatrixUriScreen TASK") - + guard let matrixClient = appState.matrixClient else { Logger.viewCycle.warning("Matrix client was nil so could not follow url") return } - + switch matrixUri.kind { case .roomId(let roomId): Logger.viewCycle.debug("Matrix uri match roomId") diff --git a/Mactrix/Views/MatrixImageView.swift b/Sources/MactrixApp/Views/MatrixImageView.swift similarity index 83% rename from Mactrix/Views/MatrixImageView.swift rename to Sources/MactrixApp/Views/MatrixImageView.swift index 87e3661..fa7d158 100644 --- a/Mactrix/Views/MatrixImageView.swift +++ b/Sources/MactrixApp/Views/MatrixImageView.swift @@ -1,3 +1,4 @@ +import MatrixIntegration import MatrixRustSDK import SwiftUI import UniformTypeIdentifiers @@ -14,7 +15,8 @@ struct MatrixImageView: View { self.mediaSource = mediaSource self.mimeType = mimeType if let url = mediaSource?.url(), - let cached = MatrixClient.imageCache.object(forKey: NSString(string: url)) { + let cached = MatrixClient.imageCache.object(forKey: NSString(string: url)) + { self._image = State(initialValue: Image(nsImage: cached)) } } @@ -58,7 +60,16 @@ struct MatrixImageView: View { do { let data = try await matrixClient.client.getMediaContent(mediaSource: mediaSource) let contentType = mimeType.flatMap { UTType(mimeType: $0) } - image = try await Image(importing: data, contentType: contentType) + + if #available(macOS 15.2, *) { + image = try await Image(importing: data, contentType: contentType) + } else { + // Fallback for older macOS versions + if let nsImage = NSImage(data: data) { + image = Image(nsImage: nsImage) + } + } + if let nsImage = NSImage(data: data) { MatrixClient.setCachedImage(nsImage, forKey: cacheKey) } diff --git a/Mactrix/Views/MultilineBannerLabelStyle.swift b/Sources/MactrixApp/Views/MultilineBannerLabelStyle.swift similarity index 100% rename from Mactrix/Views/MultilineBannerLabelStyle.swift rename to Sources/MactrixApp/Views/MultilineBannerLabelStyle.swift diff --git a/Mactrix/Views/Search/SearchInspectorView.swift b/Sources/MactrixApp/Views/Search/SearchInspectorView.swift similarity index 98% rename from Mactrix/Views/Search/SearchInspectorView.swift rename to Sources/MactrixApp/Views/Search/SearchInspectorView.swift index b8d153f..f51bc64 100644 --- a/Mactrix/Views/Search/SearchInspectorView.swift +++ b/Sources/MactrixApp/Views/Search/SearchInspectorView.swift @@ -2,7 +2,8 @@ import MatrixRustSDK import MatrixProtocols import OSLog import SwiftUI -import UI +import MactrixUI +import MactrixApp struct SearchResolvedRoomInspectorView: View { @Environment(AppState.self) var appState diff --git a/Mactrix/Views/Search/SearchRoomInspectorView.swift b/Sources/MactrixApp/Views/Search/SearchRoomInspectorView.swift similarity index 98% rename from Mactrix/Views/Search/SearchRoomInspectorView.swift rename to Sources/MactrixApp/Views/Search/SearchRoomInspectorView.swift index 66a957d..fe60428 100644 --- a/Mactrix/Views/Search/SearchRoomInspectorView.swift +++ b/Sources/MactrixApp/Views/Search/SearchRoomInspectorView.swift @@ -1,7 +1,8 @@ +import MactrixUI +import MatrixIntegration import MatrixRustSDK import OSLog import SwiftUI -import UI struct SearchRoomInspectorView: View { @Environment(AppState.self) var appState diff --git a/Mactrix/Views/Search/SearchUserInspectorView.swift b/Sources/MactrixApp/Views/Search/SearchUserInspectorView.swift similarity index 99% rename from Mactrix/Views/Search/SearchUserInspectorView.swift rename to Sources/MactrixApp/Views/Search/SearchUserInspectorView.swift index baa133a..e03c8fe 100644 --- a/Mactrix/Views/Search/SearchUserInspectorView.swift +++ b/Sources/MactrixApp/Views/Search/SearchUserInspectorView.swift @@ -1,7 +1,7 @@ import MatrixRustSDK import OSLog import SwiftUI -import UI +import MactrixUI struct SearchUserInspectorView: View { @Environment(AppState.self) var appState diff --git a/Mactrix/Views/Search/SearchViewModifier.swift b/Sources/MactrixApp/Views/Search/SearchViewModifier.swift similarity index 99% rename from Mactrix/Views/Search/SearchViewModifier.swift rename to Sources/MactrixApp/Views/Search/SearchViewModifier.swift index f5eee0e..187fe8a 100644 --- a/Mactrix/Views/Search/SearchViewModifier.swift +++ b/Sources/MactrixApp/Views/Search/SearchViewModifier.swift @@ -1,6 +1,7 @@ import MatrixRustSDK import OSLog import SwiftUI +import MactrixApp struct SearchViewModifier: ViewModifier { @Environment(AppState.self) var appState diff --git a/Mactrix/Views/Settings/AccountSettingsView.swift b/Sources/MactrixApp/Views/Settings/AccountSettingsView.swift similarity index 100% rename from Mactrix/Views/Settings/AccountSettingsView.swift rename to Sources/MactrixApp/Views/Settings/AccountSettingsView.swift diff --git a/Mactrix/Views/Settings/AppearanceSettingsView.swift b/Sources/MactrixApp/Views/Settings/AppearanceSettingsView.swift similarity index 98% rename from Mactrix/Views/Settings/AppearanceSettingsView.swift rename to Sources/MactrixApp/Views/Settings/AppearanceSettingsView.swift index e0dc34d..2539713 100644 --- a/Mactrix/Views/Settings/AppearanceSettingsView.swift +++ b/Sources/MactrixApp/Views/Settings/AppearanceSettingsView.swift @@ -2,7 +2,7 @@ import SwiftUI struct AppearanceSettingsView: View { @AppStorage("fontSize") var fontSize: Int = 13 - + var body: some View { Form { Picker("Font size", selection: $fontSize) { diff --git a/Mactrix/Views/Settings/SessionsSettingsView.swift b/Sources/MactrixApp/Views/Settings/SessionsSettingsView.swift similarity index 98% rename from Mactrix/Views/Settings/SessionsSettingsView.swift rename to Sources/MactrixApp/Views/Settings/SessionsSettingsView.swift index 1a81e19..273855c 100644 --- a/Mactrix/Views/Settings/SessionsSettingsView.swift +++ b/Sources/MactrixApp/Views/Settings/SessionsSettingsView.swift @@ -1,3 +1,4 @@ +import MatrixIntegration import MatrixRustSDK import SwiftUI @@ -5,7 +6,6 @@ struct SessionsSettingsView: View { @Environment(AppState.self) var appState @Environment(\.colorScheme) var colorScheme - @ViewBuilder func sessionBadge(systemIcon: String, color: Color) -> some View { Image(systemName: systemIcon) .font(.system(size: 20)) @@ -14,7 +14,6 @@ struct SessionsSettingsView: View { .foregroundStyle(color.mix(with: colorScheme == .light ? .black : .white, by: 0.2)) } - @ViewBuilder func sessionVerificationState(badge: some View, title: LocalizedStringKey, subtitle: LocalizedStringKey) -> some View { HStack { badge diff --git a/Mactrix/Views/Settings/SettingsView.swift b/Sources/MactrixApp/Views/Settings/SettingsView.swift similarity index 83% rename from Mactrix/Views/Settings/SettingsView.swift rename to Sources/MactrixApp/Views/Settings/SettingsView.swift index 2254b1d..9c7a785 100644 --- a/Mactrix/Views/Settings/SettingsView.swift +++ b/Sources/MactrixApp/Views/Settings/SettingsView.swift @@ -1,8 +1,9 @@ -import MatrixRustSDK import SwiftUI -struct SettingsView: View { - var body: some View { +public struct SettingsView: View { + public init() {} + + public var body: some View { TabView { Tab("Account", systemImage: "person") { AccountSettingsView() diff --git a/Mactrix/Views/Sidebar/RoomContextMenu.swift b/Sources/MactrixApp/Views/Sidebar/RoomContextMenu.swift similarity index 99% rename from Mactrix/Views/Sidebar/RoomContextMenu.swift rename to Sources/MactrixApp/Views/Sidebar/RoomContextMenu.swift index 3b32abb..6e65174 100644 --- a/Mactrix/Views/Sidebar/RoomContextMenu.swift +++ b/Sources/MactrixApp/Views/Sidebar/RoomContextMenu.swift @@ -1,6 +1,7 @@ +import MatrixIntegration import MatrixRustSDK -import SwiftUI import OSLog +import SwiftUI struct RoomContextMenu: View { @Environment(WindowState.self) var windowState diff --git a/Mactrix/Views/Sidebar/SidebarSessionVerification.swift b/Sources/MactrixApp/Views/Sidebar/SidebarSessionVerification.swift similarity index 100% rename from Mactrix/Views/Sidebar/SidebarSessionVerification.swift rename to Sources/MactrixApp/Views/Sidebar/SidebarSessionVerification.swift diff --git a/Mactrix/Views/Sidebar/SidebarSyncState.swift b/Sources/MactrixApp/Views/Sidebar/SidebarSyncState.swift similarity index 100% rename from Mactrix/Views/Sidebar/SidebarSyncState.swift rename to Sources/MactrixApp/Views/Sidebar/SidebarSyncState.swift diff --git a/Mactrix/Views/Sidebar/SidebarView.swift b/Sources/MactrixApp/Views/Sidebar/SidebarView.swift similarity index 98% rename from Mactrix/Views/Sidebar/SidebarView.swift rename to Sources/MactrixApp/Views/Sidebar/SidebarView.swift index 1cd9b1f..895f143 100644 --- a/Mactrix/Views/Sidebar/SidebarView.swift +++ b/Sources/MactrixApp/Views/Sidebar/SidebarView.swift @@ -1,6 +1,7 @@ +import MactrixUI +import MatrixIntegration import MatrixRustSDK import SwiftUI -import UI struct SidebarView: View { @Environment(AppState.self) var appState diff --git a/Mactrix/Views/Sidebar/SpaceDisclosureGroup.swift b/Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift similarity index 98% rename from Mactrix/Views/Sidebar/SpaceDisclosureGroup.swift rename to Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift index 2e1d540..b9661ba 100644 --- a/Mactrix/Views/Sidebar/SpaceDisclosureGroup.swift +++ b/Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift @@ -1,7 +1,9 @@ import MatrixRustSDK import SwiftUI -import UI +import MactrixUI import OSLog +import MactrixApp +import MatrixIntegration struct SpaceDisclosureGroup: View { @Environment(AppState.self) var appState diff --git a/Mactrix/Views/WelcomeSheetView.swift b/Sources/MactrixApp/Views/WelcomeSheetView.swift similarity index 99% rename from Mactrix/Views/WelcomeSheetView.swift rename to Sources/MactrixApp/Views/WelcomeSheetView.swift index 31e1a20..8ad18de 100644 --- a/Mactrix/Views/WelcomeSheetView.swift +++ b/Sources/MactrixApp/Views/WelcomeSheetView.swift @@ -1,6 +1,8 @@ import AuthenticationServices import MatrixRustSDK import SwiftUI +import MactrixApp +import MatrixIntegration struct WelcomeSheetView: View { @Environment(AppState.self) private var appState diff --git a/Sources/MatrixProtocols/Extensions/Color+userID.swift b/Sources/MactrixUI/Extensions/Color+userID.swift similarity index 100% rename from Sources/MatrixProtocols/Extensions/Color+userID.swift rename to Sources/MactrixUI/Extensions/Color+userID.swift diff --git a/Sources/MactrixUI/Logger+Extensions.swift b/Sources/MactrixUI/Extensions/Logger+Extensions.swift similarity index 64% rename from Sources/MactrixUI/Logger+Extensions.swift rename to Sources/MactrixUI/Extensions/Logger+Extensions.swift index a6443a1..3fc2bfe 100644 --- a/Sources/MactrixUI/Logger+Extensions.swift +++ b/Sources/MactrixUI/Extensions/Logger+Extensions.swift @@ -1,7 +1,7 @@ import OSLog extension Logger { - private static let subsystem = "dk.qpqp.mactrix.library.ui" + private static let subsystem = "dk.qpqp.mactrix.mactrix-ui" static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") } diff --git a/Mactrix/Models/AppKeychain.swift b/Sources/MatrixIntegration/AppKeychain.swift similarity index 100% rename from Mactrix/Models/AppKeychain.swift rename to Sources/MatrixIntegration/AppKeychain.swift diff --git a/Mactrix/Models/AsyncSDKListener.swift b/Sources/MatrixIntegration/AsyncSDKListener.swift similarity index 100% rename from Mactrix/Models/AsyncSDKListener.swift rename to Sources/MatrixIntegration/AsyncSDKListener.swift diff --git a/Sources/MatrixIntegration/Extensions/Logger+Extensions.swift b/Sources/MatrixIntegration/Extensions/Logger+Extensions.swift new file mode 100644 index 0000000..9167b8f --- /dev/null +++ b/Sources/MatrixIntegration/Extensions/Logger+Extensions.swift @@ -0,0 +1,13 @@ +import OSLog + +extension Logger { + private static let subsystem = "dk.qpqp.mactrix.matrix-integration" + + static let matrixClient = Logger(subsystem: subsystem, category: "matrix-client") + static let liveRoom = Logger(subsystem: subsystem, category: "live-room") + static let liveSpaceRoomList = Logger(subsystem: subsystem, category: "live-space-room-list") + static let liveSpaceService = Logger(subsystem: subsystem, category: "live-space-service") + static let liveTimeline = Logger(subsystem: subsystem, category: "live-timeline") + static let sidebarRoom = Logger(subsystem: subsystem, category: "sidebar-room") + static let notification = Logger(subsystem: subsystem, category: "notification") +} diff --git a/Mactrix/Extensions/MatrixRustSDK+Extensions.swift b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+Extensions.swift similarity index 95% rename from Mactrix/Extensions/MatrixRustSDK+Extensions.swift rename to Sources/MatrixIntegration/Extensions/MatrixRustSDK+Extensions.swift index 2326999..2199e00 100644 --- a/Mactrix/Extensions/MatrixRustSDK+Extensions.swift +++ b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+Extensions.swift @@ -1,6 +1,6 @@ import Foundation -import MatrixRustSDK import MatrixProtocols +import MatrixRustSDK extension MatrixRustSDK.RoomMember: MatrixProtocols.UserProfile {} @@ -211,13 +211,13 @@ extension MatrixRustSDK.EventTimelineItem: @retroactive Identifiable { } } -extension MatrixRustSDK.TimelineItemContent: @retroactive CustomStringConvertible { - public var description: String { +public extension MatrixRustSDK.TimelineItemContent { + var userInterfaceText: String { switch self { case .callInvite: return "call invite" case let .msgLike(content: content): - return content.kind.description + return content.kind.userInterfaceText case .rtcNotification: return "rtc notification" case let .roomMembership(userId: userId, userDisplayName: _, change: change, reason: reason): @@ -242,7 +242,7 @@ extension MatrixRustSDK.TimelineItemContent: @retroactive CustomStringConvertibl } } - public static func roomMembershipDescription(userId: String, change: MembershipChange?, reason: String?) -> String { + static func roomMembershipDescription(userId: String, change: MembershipChange?, reason: String?) -> String { let changeMsg = switch change { case nil: "unknown membership change event" @@ -282,18 +282,16 @@ extension MatrixRustSDK.TimelineItemContent: @retroactive CustomStringConvertibl "room membership event not implemented" } - let message: String = if let reason { + return if let reason { "\(changeMsg) because \(reason)" } else { changeMsg } - - return message } } -extension MatrixRustSDK.MsgLikeKind: @retroactive CustomStringConvertible { - public var description: String { +public extension MatrixRustSDK.MsgLikeKind { + var userInterfaceText: String { switch self { case let .message(content: content): return content.body @@ -313,8 +311,8 @@ extension MatrixRustSDK.MsgLikeKind: @retroactive CustomStringConvertible { } } -extension MatrixRustSDK.MessageLikeEventType: @retroactive CustomStringConvertible { - public var description: String { +public extension MatrixRustSDK.MessageLikeEventType { + var userInterfaceText: String { switch self { case .callAnswer: "call answer" @@ -455,7 +453,7 @@ extension MatrixRustSDK.EmbeddedEventDetails { case .pending: return nil case let .ready(content: content, sender: _, senderProfile: _, timestamp: _, eventOrTransactionId: _): - return content.description + return content.userInterfaceText case let .error(message: message): return "Error: \(message)" } @@ -486,11 +484,15 @@ extension MatrixRustSDK.EventOrTransactionId: @retroactive Identifiable { } extension MatrixRustSDK.UserProfile: @retroactive Identifiable, MatrixProtocols.UserProfile { - public var id: String { userId } + public var id: String { + userId + } } extension MatrixRustSDK.SessionVerificationEmoji: @retroactive Identifiable { - public var id: String { description() } + public var id: String { + description() + } } extension MatrixRustSDK.SessionVerificationEmoji: MatrixProtocols.SessionVerificationEmoji { @@ -503,7 +505,7 @@ extension MatrixRustSDK.SessionVerificationEmoji: MatrixProtocols.SessionVerific } } -extension MatrixRustSDK.SessionVerificationData { +public extension MatrixRustSDK.SessionVerificationData { var asModel: MatrixProtocols.SessionVerificationData { switch self { case let .emojis(emojis, indices): diff --git a/Mactrix/Extensions/MatrixRustSDK+MessageContent.swift b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+MessageContent.swift similarity index 89% rename from Mactrix/Extensions/MatrixRustSDK+MessageContent.swift rename to Sources/MatrixIntegration/Extensions/MatrixRustSDK+MessageContent.swift index 078c727..2b29bb0 100644 --- a/Mactrix/Extensions/MatrixRustSDK+MessageContent.swift +++ b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+MessageContent.swift @@ -1,11 +1,11 @@ import MatrixRustSDK -protocol MessageContent { +public protocol MessageContent { var body: String { get } var formatted: FormattedBody? { get } } -protocol MediaMessageContent { +public protocol MediaMessageContent { var filename: String { get } var caption: String? { get } var formattedCaption: FormattedBody? { get } diff --git a/Mactrix/Models/HomeserverLogin.swift b/Sources/MatrixIntegration/HomeserverLogin.swift similarity index 83% rename from Mactrix/Models/HomeserverLogin.swift rename to Sources/MatrixIntegration/HomeserverLogin.swift index 9864a8f..9effdff 100644 --- a/Mactrix/Models/HomeserverLogin.swift +++ b/Sources/MatrixIntegration/HomeserverLogin.swift @@ -4,11 +4,11 @@ import MatrixRustSDK import OSLog import SwiftUI -struct HomeserverLogin { - let storeID: String - let storePassphrase: String - let unauthenticatedClient: ClientProtocol - let loginDetails: HomeserverLoginDetailsProtocol +public struct HomeserverLogin { + public let storeID: String + private let storePassphrase: String + public let unauthenticatedClient: ClientProtocol + public let loginDetails: HomeserverLoginDetailsProtocol init(storeID: String, storePassphrase: String, unauthenticatedClient: ClientProtocol, loginDetails: HomeserverLoginDetailsProtocol) { self.storeID = storeID @@ -18,7 +18,7 @@ struct HomeserverLogin { } @MainActor - func loginPassword(homeServer _: String, username: String, password: String) async throws -> MatrixClient { + public func loginPassword(homeServer _: String, username: String, password: String) async throws -> MatrixClient { // Login using password authentication. try await unauthenticatedClient.login(username: username, password: password, initialDeviceName: "Mactrix", deviceId: nil) return try await onSuccessfullLogin() @@ -30,7 +30,7 @@ struct HomeserverLogin { } @MainActor - func loginOidc(webAuthSession: WebAuthenticationSession) async throws -> MatrixClient { + public func loginOidc(webAuthSession: WebAuthenticationSession) async throws -> MatrixClient { Logger.matrixClient.debug("login oidc begin") let authInfo = try await unauthenticatedClient.urlForOauth(oauthConfiguration: oauthConfiguration, prompt: .login, loginHint: nil, deviceId: nil, additionalScopes: nil) let url = URL(string: authInfo.loginUrl())! diff --git a/Mactrix/Models/LiveRoom.swift b/Sources/MatrixIntegration/LiveRoom.swift similarity index 100% rename from Mactrix/Models/LiveRoom.swift rename to Sources/MatrixIntegration/LiveRoom.swift diff --git a/Mactrix/Models/LiveRoomSearch.swift b/Sources/MatrixIntegration/LiveRoomSearch.swift similarity index 89% rename from Mactrix/Models/LiveRoomSearch.swift rename to Sources/MatrixIntegration/LiveRoomSearch.swift index bae431c..fc1e525 100644 --- a/Mactrix/Models/LiveRoomSearch.swift +++ b/Sources/MatrixIntegration/LiveRoomSearch.swift @@ -4,14 +4,14 @@ import MatrixRustSDK import OSLog @MainActor @Observable -class LiveRoomSearch { - let roomDirectorySearch: RoomDirectorySearchProtocol +public class LiveRoomSearch { + public let roomDirectorySearch: RoomDirectorySearchProtocol @ObservationIgnored fileprivate var resultsHandle: TaskHandle? - var rooms: [RoomDescription] = [] + public var rooms: [RoomDescription] = [] - init(roomDirectorySearch: RoomDirectorySearchProtocol) async { + public init(roomDirectorySearch: RoomDirectorySearchProtocol) async { self.roomDirectorySearch = roomDirectorySearch await listenToRoomResults() } @@ -61,7 +61,7 @@ class LiveRoomSearch { } } - func search(query: String?) async throws { + public func search(query: String?) async throws { try await roomDirectorySearch.search(filter: query, batchSize: 100, viaServerName: nil) } } diff --git a/Mactrix/Models/LiveSpaceRoomList.swift b/Sources/MatrixIntegration/LiveSpaceRoomList.swift similarity index 84% rename from Mactrix/Models/LiveSpaceRoomList.swift rename to Sources/MatrixIntegration/LiveSpaceRoomList.swift index f629c76..d0a3c1b 100644 --- a/Mactrix/Models/LiveSpaceRoomList.swift +++ b/Sources/MatrixIntegration/LiveSpaceRoomList.swift @@ -4,19 +4,19 @@ import MatrixRustSDK import OSLog @MainActor @Observable -final class LiveSpaceRoomList { - let spaceService: LiveSpaceService - let spaceRoomList: SpaceRoomList +public final class LiveSpaceRoomList { + public let spaceService: LiveSpaceService + public let spaceRoomList: SpaceRoomList - var space: SpaceRoom? - var rooms: [SidebarSpaceRoom] = [] - var paginationState: SpaceRoomListPaginationState = .loading + public var space: SpaceRoom? + public var rooms: [SidebarSpaceRoom] = [] + public var paginationState: SpaceRoomListPaginationState = .loading @ObservationIgnored fileprivate var spaceHandle: TaskHandle? @ObservationIgnored fileprivate var roomsHandle: TaskHandle? @ObservationIgnored fileprivate var paginateHandle: TaskHandle? - init(spaceService: LiveSpaceService, spaceRoomList: SpaceRoomList) { + public init(spaceService: LiveSpaceService, spaceRoomList: SpaceRoomList) { self.spaceService = spaceService self.spaceRoomList = spaceRoomList @@ -33,7 +33,7 @@ final class LiveSpaceRoomList { Logger.liveSpaceRoomList.debug("LiveSpaceRoomList deinit") } - fileprivate func listenToSpaceRoom() { + private func listenToSpaceRoom() { let spaceListener = AsyncSDKListener() spaceHandle = spaceRoomList.subscribeToSpaceUpdates(listener: spaceListener) @@ -45,7 +45,7 @@ final class LiveSpaceRoomList { } } - fileprivate func listenToRooms() async { + private func listenToRooms() async { let roomsListener = AsyncSDKListener<[SpaceListUpdate]>() roomsHandle = await spaceRoomList.subscribeToRoomUpdate(listener: roomsListener) @@ -83,7 +83,7 @@ final class LiveSpaceRoomList { } } - fileprivate func listenToPagination() { + private func listenToPagination() { let paginateListener = AsyncSDKListener() paginateHandle = spaceRoomList.subscribeToPaginationStateUpdates(listener: paginateListener) @@ -94,11 +94,11 @@ final class LiveSpaceRoomList { } } - func loadChildRooms() async { + public func loadChildRooms() async { do { try await spaceRoomList.paginate() } catch { - Logger.viewCycle.error("Failed to paginate space list: \(error)") + Logger.liveSpaceRoomList.error("Failed to paginate space list: \(error)") } } } diff --git a/Mactrix/Models/LiveSpaceService.swift b/Sources/MatrixIntegration/LiveSpaceService.swift similarity index 100% rename from Mactrix/Models/LiveSpaceService.swift rename to Sources/MatrixIntegration/LiveSpaceService.swift diff --git a/Mactrix/Models/LiveTimeline.swift b/Sources/MatrixIntegration/LiveTimeline.swift similarity index 98% rename from Mactrix/Models/LiveTimeline.swift rename to Sources/MatrixIntegration/LiveTimeline.swift index eb22a6b..a73aeaa 100644 --- a/Mactrix/Models/LiveTimeline.swift +++ b/Sources/MatrixIntegration/LiveTimeline.swift @@ -10,7 +10,7 @@ public protocol LiveTimelineFocusDelegate: AnyObject { } @MainActor -protocol LiveTimelineDiffDelegate: AnyObject { +public protocol LiveTimelineDiffDelegate: AnyObject { func timelineDidApply(diffs: [TimelineDiff]) } @@ -27,7 +27,7 @@ public final class LiveTimeline { public var errorMessage: String? @ObservationIgnored public weak var focusDelegate: (any LiveTimelineFocusDelegate)? - @ObservationIgnored weak var diffDelegate: (any LiveTimelineDiffDelegate)? + @ObservationIgnored public weak var diffDelegate: (any LiveTimelineDiffDelegate)? public var sendReplyTo: MatrixRustSDK.EventTimelineItem? diff --git a/Mactrix/Models/MatrixClient+Listeners.swift b/Sources/MatrixIntegration/MatrixClient+Listeners.swift similarity index 80% rename from Mactrix/Models/MatrixClient+Listeners.swift rename to Sources/MatrixIntegration/MatrixClient+Listeners.swift index 054dca1..db133ff 100644 --- a/Mactrix/Models/MatrixClient+Listeners.swift +++ b/Sources/MatrixIntegration/MatrixClient+Listeners.swift @@ -46,7 +46,7 @@ extension MatrixClient { } extension MatrixClient: SyncServiceStateObserver { - nonisolated func onUpdate(state: MatrixRustSDK.SyncServiceState) { + nonisolated public func onUpdate(state: MatrixRustSDK.SyncServiceState) { Task { @MainActor in syncState = state } @@ -54,7 +54,7 @@ extension MatrixClient: SyncServiceStateObserver { } extension MatrixClient: VerificationStateListener { - nonisolated func onUpdate(status: MatrixRustSDK.VerificationState) { + nonisolated public func onUpdate(status: MatrixRustSDK.VerificationState) { Task { @MainActor in verificationState = status } @@ -62,7 +62,7 @@ extension MatrixClient: VerificationStateListener { } extension MatrixClient: RoomListServiceStateListener { - nonisolated func onUpdate(state: MatrixRustSDK.RoomListServiceState) { + nonisolated public func onUpdate(state: MatrixRustSDK.RoomListServiceState) { Task { @MainActor in roomListServiceState = state } @@ -70,7 +70,7 @@ extension MatrixClient: RoomListServiceStateListener { } extension MatrixClient: RoomListServiceSyncIndicatorListener { - nonisolated func onUpdate(syncIndicator: MatrixRustSDK.RoomListServiceSyncIndicator) { + nonisolated public func onUpdate(syncIndicator: MatrixRustSDK.RoomListServiceSyncIndicator) { Task { @MainActor in showRoomSyncIndicator = syncIndicator } @@ -78,11 +78,11 @@ extension MatrixClient: RoomListServiceSyncIndicatorListener { } extension MatrixClient: MatrixRustSDK.ClientDelegate { - nonisolated func onBackgroundTaskErrorReport(taskName: String, error: MatrixRustSDK.BackgroundTaskFailureReason) { + nonisolated public func onBackgroundTaskErrorReport(taskName: String, error: MatrixRustSDK.BackgroundTaskFailureReason) { Logger.matrixClient.error("onBackgroundTaskErrorReport taskName: \(taskName)") } - nonisolated func didReceiveAuthError(isSoftLogout: Bool) { + nonisolated public func didReceiveAuthError(isSoftLogout: Bool) { Task { @MainActor in Logger.matrixClient.debug("did receive auth error: soft logout \(isSoftLogout, privacy: .public)") if !isSoftLogout { @@ -93,7 +93,7 @@ extension MatrixClient: MatrixRustSDK.ClientDelegate { } extension MatrixClient: MatrixRustSDK.IgnoredUsersListener { - nonisolated func call(ignoredUserIds: [String]) { + nonisolated public func call(ignoredUserIds: [String]) { Task { @MainActor in Logger.matrixClient.debug("Updated ignored users: \(ignoredUserIds)") self.ignoredUserIds = ignoredUserIds @@ -102,29 +102,29 @@ extension MatrixClient: MatrixRustSDK.IgnoredUsersListener { } extension MatrixClient: SessionVerificationControllerDelegate { - nonisolated func didReceiveVerificationRequest(details: MatrixRustSDK.SessionVerificationRequestDetails) { + nonisolated public func didReceiveVerificationRequest(details: MatrixRustSDK.SessionVerificationRequestDetails) { Task { @MainActor in Logger.matrixClient.debug("session verification: didReceiveVerificationRequest") sessionVerificationRequest = details } } - nonisolated func didAcceptVerificationRequest() { + nonisolated public func didAcceptVerificationRequest() { Logger.matrixClient.debug("session verification: didAcceptVerificationRequest") } - nonisolated func didStartSasVerification() { + nonisolated public func didStartSasVerification() { Logger.matrixClient.debug("session verification: didStartSasVerification") } - nonisolated func didReceiveVerificationData(data: MatrixRustSDK.SessionVerificationData) { + nonisolated public func didReceiveVerificationData(data: MatrixRustSDK.SessionVerificationData) { Task { @MainActor in Logger.matrixClient.debug("session verification: didReceiveVerificationData") sessionVerificationData = data } } - nonisolated func didFail() { + nonisolated public func didFail() { Task { @MainActor in Logger.matrixClient.debug("session verification: didFail") sessionVerificationRequest = nil @@ -132,7 +132,7 @@ extension MatrixClient: SessionVerificationControllerDelegate { } } - nonisolated func didCancel() { + nonisolated public func didCancel() { Task { @MainActor in Logger.matrixClient.debug("session verification: didCancel") sessionVerificationRequest = nil @@ -140,7 +140,7 @@ extension MatrixClient: SessionVerificationControllerDelegate { } } - nonisolated func didFinish() { + nonisolated public func didFinish() { Task { @MainActor in Logger.matrixClient.debug("session verification: didFinish") sessionVerificationRequest = nil diff --git a/Mactrix/Models/MatrixClient.swift b/Sources/MatrixIntegration/MatrixClient.swift similarity index 54% rename from Mactrix/Models/MatrixClient.swift rename to Sources/MatrixIntegration/MatrixClient.swift index bd8e4c6..d8f2d2c 100644 --- a/Mactrix/Models/MatrixClient.swift +++ b/Sources/MatrixIntegration/MatrixClient.swift @@ -1,27 +1,27 @@ +import AppKit import AsyncAlgorithms import Foundation import MatrixRustSDK +import Observation import OSLog import Security -import SwiftUI -import UI import UniformTypeIdentifiers import Utils @MainActor @Observable -class MatrixClient { - let storeID: String - let storePassphrase: String - var client: ClientProtocol! +public class MatrixClient { + public let storeID: String + private let storePassphrase: String + public internal(set) var client: ClientProtocol! - var rooms: [SidebarRoom] = [] + public internal(set) var rooms: [SidebarRoom] = [] - var spaceService: LiveSpaceService! + public internal(set) var spaceService: LiveSpaceService! private var clientDelegateHandle: TaskHandle? - var authenticationFailed: Bool = false + public internal(set) var authenticationFailed: Bool = false - let notifications: MatrixNotifications = .init() + public let notifications: MatrixNotifications = .init() init(userSession: UserSession) async throws { storeID = userSession.storeID @@ -83,7 +83,7 @@ class MatrixClient { return Data(result).base64EncodedString() } - static func loginDetails(homeServer: String) async throws -> HomeserverLogin { + public static func loginDetails(homeServer: String) async throws -> HomeserverLogin { let storeID = UUID().uuidString let storePassphrase = try Self.generateStorePassphrase() @@ -93,7 +93,7 @@ class MatrixClient { return HomeserverLogin(storeID: storeID, storePassphrase: storePassphrase, unauthenticatedClient: client, loginDetails: details) } - static func attemptRestore() async throws -> MatrixClient? { + public static func attemptRestore() async throws -> MatrixClient? { guard let userSession = try UserSession.loadUserFromKeychain() else { return nil } let matrixClient = try await MatrixClient(userSession: userSession) @@ -104,7 +104,7 @@ class MatrixClient { return matrixClient } - func reset() async throws { + public func reset() async throws { Logger.matrixClient.debug("matrix client sign out") try? await client.logout() try? FileManager.default.removeItem(at: .sessionData(for: storeID)) @@ -113,12 +113,12 @@ class MatrixClient { Logger.matrixClient.debug("matrix client sign out complete") } - var syncService: SyncService? - var syncState: SyncServiceState = .terminated - var roomListService: RoomListService? - var roomListServiceState: RoomListServiceState? - var showRoomSyncIndicator: RoomListServiceSyncIndicator? - var ignoredUserIds: [String] = [] + public internal(set) var syncService: SyncService? + public internal(set) var syncState: SyncServiceState = .terminated + public internal(set) var roomListService: RoomListService? + public internal(set) var roomListServiceState: RoomListServiceState? + public internal(set) var showRoomSyncIndicator: RoomListServiceSyncIndicator? + public internal(set) var ignoredUserIds: [String] = [] @ObservationIgnored fileprivate var roomListEntriesHandle: RoomListEntriesWithDynamicAdaptersResult? @ObservationIgnored fileprivate var roomListServiceStateHandle: TaskHandle? @@ -128,13 +128,13 @@ class MatrixClient { @ObservationIgnored fileprivate var ignoredUsersHandle: TaskHandle? /// The latest session verification request received by another client - var sessionVerificationRequest: SessionVerificationRequestDetails? - var sessionVerificationData: SessionVerificationData? - var verificationState: VerificationState? + public var sessionVerificationRequest: SessionVerificationRequestDetails? + public var sessionVerificationData: SessionVerificationData? + public var verificationState: VerificationState? var notificationClient: NotificationClient? - func startSync() async throws { + public func startSync() async throws { let _syncService = try await client.syncService().withOfflineMode().finish() syncService = _syncService @@ -172,15 +172,15 @@ class MatrixClient { Logger.matrixClient.info("Matrix sync started") } - func clearCache() async throws { + public func clearCache() async throws { try await client.clearCaches(syncService: syncService) } - func isUserIgnored(_ userId: String) -> Bool { + public func isUserIgnored(_ userId: String) -> Bool { ignoredUserIds.contains(userId) } - func declineVerificationRequest(request: SessionVerificationRequestDetails) async throws { + public func declineVerificationRequest(request: SessionVerificationRequestDetails) async throws { try await client.getSessionVerificationController().acknowledgeVerificationRequest( senderId: request.senderProfile.userId, flowId: request.flowId ) @@ -188,7 +188,7 @@ class MatrixClient { try await client.getSessionVerificationController().cancelVerification() } - func acceptVerificationRequest(request: SessionVerificationRequestDetails) async throws { + public func acceptVerificationRequest(request: SessionVerificationRequestDetails) async throws { try await client.getSessionVerificationController().acknowledgeVerificationRequest( senderId: request.senderProfile.userId, flowId: request.flowId ) @@ -196,7 +196,7 @@ class MatrixClient { try await client.getSessionVerificationController().acceptVerificationRequest() } - func requestDeviceVerification() async throws { + public func requestDeviceVerification() async throws { try await client.getSessionVerificationController().requestDeviceVerification() } } @@ -206,7 +206,7 @@ enum MatrixClientRestoreSessionError: Error { } extension MatrixClient: MatrixRustSDK.ClientSessionDelegate { - nonisolated func retrieveSessionFromKeychain(userId: String) throws -> MatrixRustSDK.Session { + public nonisolated func retrieveSessionFromKeychain(userId: String) throws -> MatrixRustSDK.Session { Logger.matrixClient.debug("client session delegate: retrieve session from keychain: \(userId, privacy: .sensitive)") let userSession = try UserSession.loadUserFromKeychain() @@ -224,7 +224,7 @@ extension MatrixClient: MatrixRustSDK.ClientSessionDelegate { } } - nonisolated func saveSessionInKeychain(session: MatrixRustSDK.Session) { + public nonisolated func saveSessionInKeychain(session: MatrixRustSDK.Session) { Logger.matrixClient.debug("client session delegate: save session in keychain") do { try UserSession(session: session, storeID: storeID, storePassphrase: storePassphrase).saveUserToKeychain() @@ -234,152 +234,16 @@ extension MatrixClient: MatrixRustSDK.ClientSessionDelegate { } } -extension MatrixClient: MactrixUI.ImageLoader { - // In-memory cache of decoded NSImage objects. Purpose is rendering performance — - // keeping decoded bitmaps ready to display prevents flicker when scrolling back to - // previously viewed images. This is distinct from the SDK-level media download cache - // (see clearCaches / getMediaFile(useCache:)) which avoids re-fetching from the server. - // Costs are tracked in decoded RGBA bytes (width × height × 4), not compressed sizes, - // since a typical JPEG can be 20-50× larger once decoded. - static let imageCache: NSCache = { - let cache = NSCache() - cache.totalCostLimit = 256 * 1024 * 1024 // 256MB decoded pixels - return cache - }() - - private static let imageCacheMaxObjectCost = 64 * 1024 * 1024 // 64MB per object (~8000x2000px RGBA) - - static func setCachedImage(_ image: NSImage, forKey key: NSString) { - let cost = Int(image.size.width * image.size.height) * 4 // decoded RGBA bytes - guard cost <= imageCacheMaxObjectCost else { return } - imageCache.setObject(image, forKey: key, cost: cost) - } - - func cachedImage(matrixUrl: String) -> NSImage? { - return Self.imageCache.object(forKey: NSString(string: matrixUrl)) - } - - func loadImage(matrixUrl: String, size: CGSize?) async throws -> NSImage? { - let cacheKey = if let size { - NSString(string: "\(matrixUrl)_\(Int(size.width))x\(Int(size.height))") - } else { - NSString(string: matrixUrl) - } - if let cached = Self.imageCache.object(forKey: cacheKey) { - return cached - } - - let mediaSource = try MediaSource.fromUrl(url: matrixUrl) - - let imageData: Data - if let size { - let width = UInt64(size.width) - let height = UInt64(size.height) - imageData = try await client.getMediaThumbnail(mediaSource: mediaSource, width: width, height: height) - } else { - imageData = try await client.getMediaContent(mediaSource: mediaSource) - } - - do { - let nsImage = try imageData.toOrientedImage(contentType: imageData.computeMimeType()) - Self.setCachedImage(nsImage, forKey: cacheKey) - return nsImage - } catch { - Logger.matrixClient.error("failed convert matrix media data to Image: \(error) \(imageData)") - throw error - } - } -} - -extension MatrixClient { - struct MatrixClientUserProfileActions: UserProfileActions { - let userId: String - let matrixClient: MatrixClient - let windowState: WindowState - - func sendMessage() async { - do { - if let room = try matrixClient.client.getDmRoom(userId: userId) { - windowState.selectedRoomId = room.id - return - } - - let createRoomParams = CreateRoomParameters( - name: nil, isEncrypted: false, isDirect: true, visibility: .private, - preset: .privateChat, invite: [userId] - ) - let roomId = try await matrixClient.client.createRoom(request: createRoomParams) - windowState.selectedRoomId = roomId - } catch { - Logger.viewCycle.error("failed to get DM room for user \(userId): \(error)") - } - } - - func shareProfile() {} - - func ignoreUser() async { - Logger.viewCycle.info("Ignore user \(userId)") - do { - try await matrixClient.client.ignoreUser(userId: userId) - } catch { - Logger.viewCycle.error("failed to ignore user: \(error)") - } - } - - func unignoreUser() async { - Logger.viewCycle.info("Unignore user \(userId)") - do { - try await matrixClient.client.unignoreUser(userId: userId) - } catch { - Logger.viewCycle.error("failed to unignore user: \(error)") - } - } - } - - func userProfileActions(forUserId userId: String, windowState: WindowState) -> some UserProfileActions { - return MatrixClientUserProfileActions(userId: userId, matrixClient: self, windowState: windowState) - } -} - -extension MatrixClient { - @MainActor - struct MatrixClientRoomPreviewActions: @MainActor RoomPreviewActions { - let roomId: String - let matrixClient: MatrixClient - let windowState: WindowState - - func joinRoom() async throws { - let room = try await matrixClient.client.joinRoomById(roomId: roomId) - let timeline = LiveTimeline(room: LiveRoom(matrixRoom: room)) - windowState.selectedScreen = .joinedRoom(timeline: timeline) - } - - func knockRoom() async throws { - let room = try await matrixClient.client.knock(roomIdOrAlias: roomId, reason: nil, serverNames: ["matrix.org"]) - let timeline = LiveTimeline(room: LiveRoom(matrixRoom: room)) - windowState.selectedScreen = .joinedRoom(timeline: timeline) - } - - func visitRoom() { - windowState.selectedRoomId = roomId - } - } - - func roomPreviewActions(forRoomWithId roomId: String, windowState: WindowState) -> RoomPreviewActions { - return MatrixClientRoomPreviewActions(roomId: roomId, matrixClient: self, windowState: windowState) - } -} - private extension URL { static func sessionData(for sessionID: String) -> URL { applicationSupportDirectory - .appending(component: applicationID) + .appending(component: "dk.qpqp.mactrix") .appending(component: sessionID) } static func sessionCaches(for sessionID: String) -> URL { cachesDirectory - .appending(component: applicationID) + .appending(component: "dk.qpqp.mactrix") .appending(component: sessionID) } } diff --git a/Mactrix/Models/MatrixClientSessionDelegate.swift b/Sources/MatrixIntegration/MatrixClientSessionDelegate.swift similarity index 97% rename from Mactrix/Models/MatrixClientSessionDelegate.swift rename to Sources/MatrixIntegration/MatrixClientSessionDelegate.swift index 3bf6de6..6af2245 100644 --- a/Mactrix/Models/MatrixClientSessionDelegate.swift +++ b/Sources/MatrixIntegration/MatrixClientSessionDelegate.swift @@ -1,8 +1,8 @@ import Foundation import MatrixRustSDK import OSLog -import SwiftUI -import UI +import Observation +import MactrixUI import UniformTypeIdentifiers import Utils diff --git a/Mactrix/Models/Notifications.swift b/Sources/MatrixIntegration/MatrixNotifications.swift similarity index 94% rename from Mactrix/Models/Notifications.swift rename to Sources/MatrixIntegration/MatrixNotifications.swift index 6a7713a..c8862fc 100644 --- a/Mactrix/Models/Notifications.swift +++ b/Sources/MatrixIntegration/MatrixNotifications.swift @@ -3,15 +3,15 @@ import OSLog import UserNotifications // This class is Sendable because MatrixRustSDK.SyncNotificationListener requires it to be so -@MainActor @Observable final class MatrixNotifications: NSObject, Sendable { - var selectedRoomId: String? +@MainActor @Observable public final class MatrixNotifications: NSObject, Sendable { + public var selectedRoomId: String? typealias NotificationEvent = (item: MatrixRustSDK.NotificationItem, roomId: String) @ObservationIgnored private let notificationContinuation: AsyncStream.Continuation @ObservationIgnored private var streamTask: Task? - override init() { + public override init() { let (stream, continuation) = AsyncStream.makeStream() notificationContinuation = continuation @@ -28,7 +28,7 @@ import UserNotifications } extension MatrixNotifications: @MainActor UNUserNotificationCenterDelegate { - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { let roomId = response.notification.request.content.userInfo["roomId"] as? String Logger.notification.info("Notification delegate didReceive: \(roomId ?? "")") selectedRoomId = roomId @@ -36,7 +36,7 @@ extension MatrixNotifications: @MainActor UNUserNotificationCenterDelegate { } extension MatrixNotifications: MatrixRustSDK.SyncNotificationListener { - nonisolated func onNotification(notification: MatrixRustSDK.NotificationItem, roomId: String) { + public nonisolated func onNotification(notification: MatrixRustSDK.NotificationItem, roomId: String) { notificationContinuation.yield((notification, roomId)) } } diff --git a/Mactrix/Models/SidebarRoom.swift b/Sources/MatrixIntegration/SidebarRoom.swift similarity index 93% rename from Mactrix/Models/SidebarRoom.swift rename to Sources/MatrixIntegration/SidebarRoom.swift index 72898d8..e03aa50 100644 --- a/Mactrix/Models/SidebarRoom.swift +++ b/Sources/MatrixIntegration/SidebarRoom.swift @@ -20,7 +20,7 @@ public final class SidebarRoom: Identifiable { do { self.roomInfo = try await room.roomInfo() } catch { - Logger.SidebarRoom.error("Failed to fetch initial room info: \(error)") + Logger.sidebarRoom.error("Failed to fetch initial room info: \(error)") } listenToRoomInfo() @@ -39,7 +39,7 @@ public final class SidebarRoom: Identifiable { do { roomInfo = try await room.roomInfo() } catch { - Logger.SidebarRoom.error("Failed to fetch room info on update: \(error)") + Logger.sidebarRoom.error("Failed to fetch room info on update: \(error)") } listenToRoomInfo() } diff --git a/Mactrix/Models/SidebarSpaceRoom.swift b/Sources/MatrixIntegration/SidebarSpaceRoom.swift similarity index 71% rename from Mactrix/Models/SidebarSpaceRoom.swift rename to Sources/MatrixIntegration/SidebarSpaceRoom.swift index d6d0d84..8229234 100644 --- a/Mactrix/Models/SidebarSpaceRoom.swift +++ b/Sources/MatrixIntegration/SidebarSpaceRoom.swift @@ -3,18 +3,18 @@ import MatrixRustSDK @MainActor @Observable public final class SidebarSpaceRoom { - let spaceRoom: SpaceRoom + public let spaceRoom: SpaceRoom - var children: Children = .loading + public private(set) var children: Children = .loading private let spaceService: LiveSpaceService - init(spaceService: LiveSpaceService, spaceRoom: SpaceRoom) { + public init(spaceService: LiveSpaceService, spaceRoom: SpaceRoom) { self.spaceService = spaceService self.spaceRoom = spaceRoom } - func loadChildren() async { + public func loadChildren() async { if case .loaded(children: _) = children { return } @@ -27,7 +27,7 @@ public final class SidebarSpaceRoom { } } - enum Children { + public enum Children { case loading case loaded(children: LiveSpaceRoomList) case error(error: Error) @@ -35,5 +35,7 @@ public final class SidebarSpaceRoom { } extension SidebarSpaceRoom: Identifiable { - public nonisolated var id: String { spaceRoom.roomId } + public nonisolated var id: String { + spaceRoom.roomId + } } diff --git a/Mactrix/Models/UserSession.swift b/Sources/MatrixIntegration/UserSession.swift similarity index 97% rename from Mactrix/Models/UserSession.swift rename to Sources/MatrixIntegration/UserSession.swift index d526ccc..5ce8d09 100644 --- a/Mactrix/Models/UserSession.swift +++ b/Sources/MatrixIntegration/UserSession.swift @@ -3,8 +3,8 @@ import Foundation import MatrixRustSDK import OSLog import Security -import SwiftUI -import UI +import Observation +import MactrixUI import UniformTypeIdentifiers import Utils diff --git a/Sources/MessageFormatting/AttributedTextView.swift b/Sources/MessageFormatting/AttributedTextView.swift index 73a4d9a..d089193 100644 --- a/Sources/MessageFormatting/AttributedTextView.swift +++ b/Sources/MessageFormatting/AttributedTextView.swift @@ -34,7 +34,7 @@ public struct AttributedTextView: NSViewRepresentable { guard let size = textField.cell?.cellSize(forBounds: NSRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude)) else { return nil } - + return CGSize(width: ceil(size.width), height: ceil(size.height)) } } From 71d20180a6635a73c7752cb8da7b2cd797f9bae8 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Wed, 30 Sep 2026 09:13:57 +0200 Subject: [PATCH 4/9] Fix all warnings and errors --- .sourcekit-lsp/config.json | 4 + Package.swift | 4 +- .../Extensions/MatrixClient+Extensions.swift | 44 +++++---- Sources/MactrixApp/MainView.swift | 99 ++++++++++++------- .../Views/Inspector/InspectorRoomPins.swift | 12 +-- .../Views/LoadMatrixUriScreen.swift | 15 +-- .../Views/Search/SearchInspectorView.swift | 23 +++-- .../Views/Search/SearchViewModifier.swift | 37 ++++--- .../Views/Sidebar/SpaceDisclosureGroup.swift | 1 - .../MactrixApp/Views/WelcomeSheetView.swift | 1 - .../MatrixClientSessionDelegate.swift | 8 +- Sources/MatrixIntegration/UserSession.swift | 18 ++-- 12 files changed, 158 insertions(+), 108 deletions(-) create mode 100644 .sourcekit-lsp/config.json diff --git a/.sourcekit-lsp/config.json b/.sourcekit-lsp/config.json new file mode 100644 index 0000000..69545ec --- /dev/null +++ b/.sourcekit-lsp/config.json @@ -0,0 +1,4 @@ +{ + "backgroundIndexing": true, + "backgroundPreparationMode": "build" +} diff --git a/Package.swift b/Package.swift index 31ceef4..58986f7 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( platforms: [.macOS(.v15)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. - .library(name: "MactrixApp", targets: ["MactrixApp"]), + .library(name: "MactrixApp", targets: ["MactrixApp"]) ], dependencies: [ .package(url: "https://github.com/ZhgChgLi/ZMarkupParser.git", from: "1.12.0"), @@ -48,7 +48,7 @@ let package = Package( .target( name: "MessageFormatting", dependencies: [ - .product(name: "ZMarkupParser", package: "zmarkupparser"), + .product(name: "ZMarkupParser", package: "zmarkupparser") ] ), ] diff --git a/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift b/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift index d54313a..b195049 100644 --- a/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift +++ b/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift @@ -1,8 +1,8 @@ -import MatrixIntegration +import AppKit import MactrixUI +import MatrixIntegration import MatrixRustSDK import OSLog -import AppKit extension MatrixClient { @MainActor @@ -18,7 +18,8 @@ extension MatrixClient { } func knockRoom() async throws { - let room = try await matrixClient.client.knock(roomIdOrAlias: roomId, reason: nil, serverNames: ["matrix.org"]) + let room = try await matrixClient.client.knock( + roomIdOrAlias: roomId, reason: nil, serverNames: ["matrix.org"]) let timeline = LiveTimeline(room: LiveRoom(matrixRoom: room)) windowState.selectedScreen = .joinedRoom(timeline: timeline) } @@ -28,8 +29,11 @@ extension MatrixClient { } } - func roomPreviewActions(forRoomWithId roomId: String, windowState: WindowState) -> RoomPreviewActions { - return MatrixClientRoomPreviewActions(roomId: roomId, matrixClient: self, windowState: windowState) + func roomPreviewActions(forRoomWithId roomId: String, windowState: WindowState) + -> RoomPreviewActions + { + return MatrixClientRoomPreviewActions( + roomId: roomId, matrixClient: self, windowState: windowState) } } @@ -42,14 +46,14 @@ extension MatrixClient: MactrixUI.ImageLoader { // since a typical JPEG can be 20-50× larger once decoded. static let imageCache: NSCache = { let cache = NSCache() - cache.totalCostLimit = 256 * 1024 * 1024 // 256MB decoded pixels + cache.totalCostLimit = 256 * 1024 * 1024 // 256MB decoded pixels return cache }() - private static let imageCacheMaxObjectCost = 64 * 1024 * 1024 // 64MB per object (~8000x2000px RGBA) + private static let imageCacheMaxObjectCost = 64 * 1024 * 1024 // 64MB per object (~8000x2000px RGBA) static func setCachedImage(_ image: NSImage, forKey key: NSString) { - let cost = Int(image.size.width * image.size.height) * 4 // decoded RGBA bytes + let cost = Int(image.size.width * image.size.height) * 4 // decoded RGBA bytes guard cost <= imageCacheMaxObjectCost else { return } imageCache.setObject(image, forKey: key, cost: cost) } @@ -59,11 +63,12 @@ extension MatrixClient: MactrixUI.ImageLoader { } public func loadImage(matrixUrl: String, size: CGSize?) async throws -> NSImage? { - let cacheKey = if let size { - NSString(string: "\(matrixUrl)_\(Int(size.width))x\(Int(size.height))") - } else { - NSString(string: matrixUrl) - } + let cacheKey = + if let size { + NSString(string: "\(matrixUrl)_\(Int(size.width))x\(Int(size.height))") + } else { + NSString(string: matrixUrl) + } if let cached = Self.imageCache.object(forKey: cacheKey) { return cached } @@ -74,7 +79,8 @@ extension MatrixClient: MactrixUI.ImageLoader { if let size { let width = UInt64(size.width) let height = UInt64(size.height) - imageData = try await client.getMediaThumbnail(mediaSource: mediaSource, width: width, height: height) + imageData = try await client.getMediaThumbnail( + mediaSource: mediaSource, width: width, height: height) } else { imageData = try await client.getMediaContent(mediaSource: mediaSource) } @@ -84,7 +90,8 @@ extension MatrixClient: MactrixUI.ImageLoader { Self.setCachedImage(nsImage, forKey: cacheKey) return nsImage } catch { - Logger.matrixClient.error("failed convert matrix media data to Image: \(error) \(imageData)") + Logger.matrixClient.error( + "failed convert matrix media data to Image: \(error) \(imageData)") throw error } } @@ -136,7 +143,10 @@ extension MatrixClient { } } - func userProfileActions(forUserId userId: String, windowState: WindowState) -> some MactrixUI.UserProfileActions { - return MatrixClientUserProfileActions(userId: userId, matrixClient: self, windowState: windowState) + func userProfileActions(forUserId userId: String, windowState: WindowState) -> some MactrixUI + .UserProfileActions + { + return MatrixClientUserProfileActions( + userId: userId, matrixClient: self, windowState: windowState) } } diff --git a/Sources/MactrixApp/MainView.swift b/Sources/MactrixApp/MainView.swift index 2845dbc..b2ee029 100644 --- a/Sources/MactrixApp/MainView.swift +++ b/Sources/MactrixApp/MainView.swift @@ -1,9 +1,9 @@ +import MactrixUI +import MatrixIntegration import MatrixRustSDK import OSLog import SwiftUI -import MactrixUI import Utils -import MatrixIntegration public struct MainView: View { @Environment(AppState.self) var appState @@ -12,32 +12,37 @@ public struct MainView: View { private var windowState: WindowState = .init() @State private var showWelcomeSheet: Bool = false - + + let x: Bool = false + public init() {} @ViewBuilder var details: some View { switch windowState.selectedScreen { - case .joinedRoom(timeline: let timeline): + case .joinedRoom(let timeline): ChatView(timeline: timeline).id(timeline.room.id) case .previewRoom(let room): MactrixUI.RoomPreviewView( preview: room.info(), imageLoader: appState.matrixClient, - actions: appState.matrixClient?.roomPreviewActions(forRoomWithId: room.info().roomId, windowState: windowState) + actions: appState.matrixClient?.roomPreviewActions( + forRoomWithId: room.info().roomId, windowState: windowState) ) case .newRoom: MactrixUI.CreateRoomScreen(onSubmit: { params in guard let matrixClient = appState.matrixClient else { return } - let newRoom = try await matrixClient.client.createRoom(request: params.asMatrixRequest) + let newRoom = try await matrixClient.client.createRoom( + request: params.asMatrixRequest) windowState.selectedRoomId = newRoom }) case .loadMatrixUrl(let matrixUri): LoadMatrixUriScreen(matrixUri: matrixUri) - case .user(profile: let profile): + case .user(let profile): UserProfileView( profile: profile, isUserIgnored: appState.matrixClient?.isUserIgnored(profile.userId) ?? false, - actions: appState.matrixClient?.userProfileActions(forUserId: profile.userId, windowState: windowState), + actions: appState.matrixClient?.userProfileActions( + forUserId: profile.userId, windowState: windowState), timelineActions: nil, imageLoader: appState.matrixClient ) @@ -53,7 +58,8 @@ public struct MainView: View { if !isPresented { Task { do { - try await appState.matrixClient?.client.getSessionVerificationController().declineVerification() + try await appState.matrixClient?.client + .getSessionVerificationController().declineVerification() } catch { Logger.viewCycle.error("failed to decline verification: \(error)") appState.matrixClient?.sessionVerificationData = nil @@ -71,38 +77,52 @@ public struct MainView: View { sidebar: { SidebarView() }, detail: { details } ) - .inspector(isPresented: $windowState.inspectorVisible, content: { - InspectorScreen() - .environment(windowState) - }) + .inspector( + isPresented: $windowState.inspectorVisible, + content: { + InspectorScreen() + .environment(windowState) + } + ) .task { await attemptLoadUserSession() } .sheet(isPresented: $showWelcomeSheet, onDismiss: onLoginModalDismiss) { WelcomeSheetView() } - .sheet(isPresented: verificationSheetPresented, content: { - if let verificationData = appState.matrixClient?.sessionVerificationData { - MactrixUI.SessionVerificationModal(verificationData: verificationData.asModel, onComplete: { response in - Task { - switch response { - case .accept: - do { - try await appState.matrixClient?.client.getSessionVerificationController().approveVerification() - } catch { - Logger.viewCycle.error("failed to approve verification: \(error)") - appState.matrixClient?.sessionVerificationData = nil + .sheet( + isPresented: verificationSheetPresented, + content: { + if let verificationData = appState.matrixClient?.sessionVerificationData { + MactrixUI.SessionVerificationModal( + verificationData: verificationData.asModel, + onComplete: { response in + Task { + switch response { + case .accept: + do { + try await appState.matrixClient?.client + .getSessionVerificationController() + .approveVerification() + } catch { + Logger.viewCycle.error( + "failed to approve verification: \(error)") + appState.matrixClient?.sessionVerificationData = nil + } + case .decline: + do { + try await appState.matrixClient?.client + .getSessionVerificationController() + .declineVerification() + } catch { + Logger.viewCycle.error( + "failed to decline verification: \(error)") + appState.matrixClient?.sessionVerificationData = nil + } + } } - case .decline: - do { - try await appState.matrixClient?.client.getSessionVerificationController().declineVerification() - } catch { - Logger.viewCycle.error("failed to decline verification: \(error)") - appState.matrixClient?.sessionVerificationData = nil - } - } - } - }) + }) + } } - }) + ) .onChange(of: appState.matrixClient == nil) { _, matrixClientIsNil in if matrixClientIsNil { Logger.viewCycle.info("Matrix client is nil, present welcome sheet") @@ -191,11 +211,14 @@ public struct MainView: View { if let roomId = windowState.selectedRoomId { if let selectedRoom = try matrixClient.client.getRoom(roomId: roomId) { - windowState.selectedScreen = .joinedRoom(timeline: LiveTimeline(room: LiveRoom(matrixRoom: selectedRoom))) + windowState.selectedScreen = .joinedRoom( + timeline: LiveTimeline(room: LiveRoom(matrixRoom: selectedRoom))) } else { - let roomPreview = try await matrixClient.client.getRoomPreviewFromRoomId(roomId: roomId, viaServers: ["matrix.org"]) + let roomPreview = try await matrixClient.client.getRoomPreviewFromRoomId( + roomId: roomId, viaServers: ["matrix.org"]) - Logger.viewCycle.debug("Selected room preview: \(roomPreview.info().debugDescription)") + Logger.viewCycle.debug( + "Selected room preview: \(roomPreview.info().debugDescription)") windowState.selectedScreen = .previewRoom(roomPreview) } } else { diff --git a/Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift b/Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift index 86bed47..daa9906 100644 --- a/Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift +++ b/Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift @@ -1,10 +1,9 @@ -import MatrixRustSDK +import MactrixUI +import MatrixIntegration import MatrixProtocols +import MatrixRustSDK import OSLog import SwiftUI -import MactrixUI -import MatrixIntegration -import MactrixApp struct RoomPinRow: View { @Environment(AppState.self) private var appState @@ -32,7 +31,8 @@ struct RoomPinRow: View { content .task(id: eventId) { do { - event = try await timeline.timeline?.getEventTimelineItemByEventId(eventId: eventId) + event = try await timeline.timeline?.getEventTimelineItemByEventId( + eventId: eventId) } catch { Logger.viewCycle.error("failed to fetch details for pinned event \(error)") } @@ -48,7 +48,7 @@ struct InspectorRoomPins: View { @State var selectedId: String? var timeline: LiveTimeline? { - guard case let .joinedRoom(timeline: timeline) = windowState.selectedScreen else { + guard case .joinedRoom(timeline: let timeline) = windowState.selectedScreen else { return nil } diff --git a/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift b/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift index a20e538..084d1a2 100644 --- a/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift +++ b/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift @@ -42,10 +42,11 @@ struct LoadMatrixUriScreen: View { Logger.viewCycle.debug("Matrix uri match roomId") Task { do { - let roomPreview = try await matrixClient.client.getRoomPreviewFromRoomId( - roomId: roomId, - viaServers: matrixUri.routingVia - ) + let roomPreview = try await matrixClient.client + .getRoomPreviewFromRoomId( + roomId: roomId, + viaServers: matrixUri.routingVia + ) windowState.selectedScreen = .previewRoom(roomPreview) } catch { Logger.viewCycle.error("Failed to get room id from url: \(error)") @@ -55,7 +56,8 @@ struct LoadMatrixUriScreen: View { Logger.viewCycle.debug("Matrix uri match room alias") Task { do { - let roomPreview = try await matrixClient.client.getRoomPreviewFromRoomAlias(roomAlias: alias) + let roomPreview = try await matrixClient.client + .getRoomPreviewFromRoomAlias(roomAlias: alias) windowState.selectedScreen = .previewRoom(roomPreview) } catch { Logger.viewCycle.error("Failed to get room alias from url: \(error)") @@ -65,7 +67,8 @@ struct LoadMatrixUriScreen: View { Logger.viewCycle.debug("Matrix uri match user") Task { do { - let userProfile = try await matrixClient.client.getProfile(userId: userId) + let userProfile = try await matrixClient.client.getProfile( + userId: userId) windowState.selectedScreen = .user(profile: userProfile) } catch { Logger.viewCycle.error("Failed to get user profile from url: \(error)") diff --git a/Sources/MactrixApp/Views/Search/SearchInspectorView.swift b/Sources/MactrixApp/Views/Search/SearchInspectorView.swift index f51bc64..aec82bf 100644 --- a/Sources/MactrixApp/Views/Search/SearchInspectorView.swift +++ b/Sources/MactrixApp/Views/Search/SearchInspectorView.swift @@ -1,9 +1,8 @@ -import MatrixRustSDK +import MactrixUI import MatrixProtocols +import MatrixRustSDK import OSLog import SwiftUI -import MactrixUI -import MactrixApp struct SearchResolvedRoomInspectorView: View { @Environment(AppState.self) var appState @@ -16,7 +15,8 @@ struct SearchResolvedRoomInspectorView: View { var roomActions: RoomPreviewActions? { guard let preview = roomPreview else { return nil } - return appState.matrixClient?.roomPreviewActions(forRoomWithId: preview.info().roomId, windowState: windowState) + return appState.matrixClient?.roomPreviewActions( + forRoomWithId: preview.info().roomId, windowState: windowState) } var body: some View { @@ -38,7 +38,8 @@ struct SearchResolvedRoomInspectorView: View { } .task(id: alias, priority: .utility) { do { - let preview = try await appState.matrixClient?.client.getRoomPreviewFromRoomId(roomId: resolvedRoom.roomId, viaServers: resolvedRoom.servers) + let preview = try await appState.matrixClient?.client.getRoomPreviewFromRoomId( + roomId: resolvedRoom.roomId, viaServers: resolvedRoom.servers) self.roomPreview = preview } catch { Logger.viewCycle.error("failed to get room preview: \(error)") @@ -62,19 +63,21 @@ struct SearchInspectorView: View { SearchUserInspectorView() case .spaces: Text("Search spaces") - case let .resolvedRoomAlias(alias: alias, resolvedRoom: resolvedRoom): + case .resolvedRoomAlias(alias: let alias, resolvedRoom: let resolvedRoom): SearchResolvedRoomInspectorView(alias: alias, resolvedRoom: resolvedRoom) - case let .resolvedRoomId(roomPreview: roomPreview): + case .resolvedRoomId(roomPreview: let roomPreview): MactrixUI.RoomPreviewView( preview: roomPreview.info(), imageLoader: appState.matrixClient, - actions: appState.matrixClient?.roomPreviewActions(forRoomWithId: roomPreview.info().roomId, windowState: windowState) + actions: appState.matrixClient?.roomPreviewActions( + forRoomWithId: roomPreview.info().roomId, windowState: windowState) ) - case let .resolvedUser(profile: userProfile): + case .resolvedUser(profile: let userProfile): MactrixUI.UserProfileView( profile: userProfile, isUserIgnored: appState.matrixClient?.isUserIgnored(userProfile.userId) == true, - actions: appState.matrixClient?.userProfileActions(forUserId: userProfile.userId, windowState: windowState), + actions: appState.matrixClient?.userProfileActions( + forUserId: userProfile.userId, windowState: windowState), timelineActions: nil, imageLoader: appState.matrixClient ) diff --git a/Sources/MactrixApp/Views/Search/SearchViewModifier.swift b/Sources/MactrixApp/Views/Search/SearchViewModifier.swift index 187fe8a..6015819 100644 --- a/Sources/MactrixApp/Views/Search/SearchViewModifier.swift +++ b/Sources/MactrixApp/Views/Search/SearchViewModifier.swift @@ -1,7 +1,6 @@ import MatrixRustSDK import OSLog import SwiftUI -import MactrixApp struct SearchViewModifier: ViewModifier { @Environment(AppState.self) var appState @@ -10,7 +9,10 @@ struct SearchViewModifier: ViewModifier { func body(content: Content) -> some View { @Bindable var windowState = windowState content - .searchable(text: $windowState.searchQuery, tokens: $windowState.searchTokens, isPresented: windowState.searchFocused, placement: .automatic, prompt: "Search") { token in + .searchable( + text: $windowState.searchQuery, tokens: $windowState.searchTokens, + isPresented: windowState.searchFocused, placement: .automatic, prompt: "Search" + ) { token in switch token { case .users: Text("Users") @@ -20,11 +22,11 @@ struct SearchViewModifier: ViewModifier { Text("Public Spaces") case .messages: Text("Messages") - case let .resolvedRoomAlias(alias: alias, resolvedRoom: _): + case .resolvedRoomAlias(let alias, resolvedRoom: _): Text(alias) - case let .resolvedRoomId(roomPreview: preview): + case .resolvedRoomId(roomPreview: let preview): Text(preview.info().roomId) - case let .resolvedUser(profile: userProfile): + case .resolvedUser(profile: let userProfile): Text(userProfile.userId) } } @@ -63,7 +65,7 @@ struct SearchViewModifier: ViewModifier { Label("User not found", systemImage: "exclamationmark.circle") .foregroundStyle(.secondary) Divider() - case let .resolvedRoomAlias(alias: alias, resolvedRoom: resolvedRoom): + case .resolvedRoomAlias(let alias, let resolvedRoom): HStack(alignment: .firstTextBaseline) { Text("room") .font(.subheadline.bold()) @@ -72,9 +74,10 @@ struct SearchViewModifier: ViewModifier { .lineLimit(1) .truncationMode(.middle) } - .searchCompletion(SearchToken.resolvedRoomAlias(alias: alias, resolvedRoom: resolvedRoom)) + .searchCompletion( + SearchToken.resolvedRoomAlias(alias: alias, resolvedRoom: resolvedRoom)) Divider() - case let .resolvedRoomId(roomPreview: roomPreview): + case .resolvedRoomId(let roomPreview): HStack(alignment: .firstTextBaseline) { Text("room") .font(.subheadline.bold()) @@ -85,7 +88,7 @@ struct SearchViewModifier: ViewModifier { } .searchCompletion(SearchToken.resolvedRoomId(roomPreview: roomPreview)) Divider() - case let .resolvedUser(profile: userProfile): + case .resolvedUser(profile: let userProfile): HStack(alignment: .firstTextBaseline) { Text("user") .font(.subheadline.bold()) @@ -104,7 +107,8 @@ struct SearchViewModifier: ViewModifier { Label("Users", systemImage: "person").searchCompletion(SearchToken.users) Label("Public Rooms", systemImage: "number").searchCompletion(SearchToken.rooms) Label("Public Spaces", systemImage: "network").searchCompletion(SearchToken.spaces) - Label("Messages", systemImage: "magnifyingglass.circle").searchCompletion(SearchToken.messages) + Label("Messages", systemImage: "magnifyingglass.circle").searchCompletion( + SearchToken.messages) } } @@ -136,8 +140,11 @@ struct SearchViewModifier: ViewModifier { if try roomAliasRegex.wholeMatch(in: query) != nil { Logger.viewCycle.debug("Matched room alias in query: \(query)") windowState.searchDirectResult = .lookingForRoom(alias: query) - if let resolvedRoom = try await matrixClient.client.resolveRoomAlias(roomAlias: query) { - windowState.searchDirectResult = .resolvedRoomAlias(alias: query, resolvedRoom: resolvedRoom) + if let resolvedRoom = try await matrixClient.client.resolveRoomAlias( + roomAlias: query) + { + windowState.searchDirectResult = .resolvedRoomAlias( + alias: query, resolvedRoom: resolvedRoom) } else { windowState.searchDirectResult = .roomNotFound(alias: query) } @@ -155,7 +162,8 @@ struct SearchViewModifier: ViewModifier { Logger.viewCycle.debug("Matched room id in query: \(query)") windowState.searchDirectResult = .lookingForRoom(alias: query) do { - let roomPreview = try await matrixClient.client.getRoomPreviewFromRoomId(roomId: query, viaServers: ["matrix.org"]) + let roomPreview = try await matrixClient.client.getRoomPreviewFromRoomId( + roomId: query, viaServers: ["matrix.org"]) windowState.searchDirectResult = .resolvedRoomId(roomPreview: roomPreview) } catch { Logger.viewCycle.error("failed to resolve room id \(query): \(error)") @@ -164,8 +172,7 @@ struct SearchViewModifier: ViewModifier { } else { windowState.searchDirectResult = nil } - } catch is CancellationError {} - catch { + } catch is CancellationError {} catch { Logger.viewCycle.error("failed to resolve search query '\(query)': \(error)") } } diff --git a/Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift b/Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift index b9661ba..2b3ffe0 100644 --- a/Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift +++ b/Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift @@ -2,7 +2,6 @@ import MatrixRustSDK import SwiftUI import MactrixUI import OSLog -import MactrixApp import MatrixIntegration struct SpaceDisclosureGroup: View { diff --git a/Sources/MactrixApp/Views/WelcomeSheetView.swift b/Sources/MactrixApp/Views/WelcomeSheetView.swift index 8ad18de..2620dd0 100644 --- a/Sources/MactrixApp/Views/WelcomeSheetView.swift +++ b/Sources/MactrixApp/Views/WelcomeSheetView.swift @@ -1,7 +1,6 @@ import AuthenticationServices import MatrixRustSDK import SwiftUI -import MactrixApp import MatrixIntegration struct WelcomeSheetView: View { diff --git a/Sources/MatrixIntegration/MatrixClientSessionDelegate.swift b/Sources/MatrixIntegration/MatrixClientSessionDelegate.swift index 6af2245..4793cca 100644 --- a/Sources/MatrixIntegration/MatrixClientSessionDelegate.swift +++ b/Sources/MatrixIntegration/MatrixClientSessionDelegate.swift @@ -2,7 +2,6 @@ import Foundation import MatrixRustSDK import OSLog import Observation -import MactrixUI import UniformTypeIdentifiers import Utils @@ -16,7 +15,9 @@ final class MatrixClientSessionDelegate: MatrixRustSDK.ClientSessionDelegate { } func retrieveSessionFromKeychain(userId: String) throws -> MatrixRustSDK.Session { - Logger.matrixClient.debug("client session delegate: retrieve session from keychain: \(userId, privacy: .sensitive)") + Logger.matrixClient.debug( + "client session delegate: retrieve session from keychain: \(userId, privacy: .sensitive)" + ) let userSession = try UserSession.loadUserFromKeychain() if let userSession { @@ -36,7 +37,8 @@ final class MatrixClientSessionDelegate: MatrixRustSDK.ClientSessionDelegate { func saveSessionInKeychain(session: MatrixRustSDK.Session) { Logger.matrixClient.debug("client session delegate: save session in keychain") do { - try UserSession(session: session, storeID: storeID, storePassphrase: storePassphrase).saveUserToKeychain() + try UserSession(session: session, storeID: storeID, storePassphrase: storePassphrase) + .saveUserToKeychain() } catch { Logger.matrixClient.error("failed to save session in keychain: \(error)") } diff --git a/Sources/MatrixIntegration/UserSession.swift b/Sources/MatrixIntegration/UserSession.swift index 5ce8d09..48b544a 100644 --- a/Sources/MatrixIntegration/UserSession.swift +++ b/Sources/MatrixIntegration/UserSession.swift @@ -2,9 +2,8 @@ import AsyncAlgorithms import Foundation import MatrixRustSDK import OSLog -import Security import Observation -import MactrixUI +import Security import UniformTypeIdentifiers import Utils @@ -30,13 +29,14 @@ struct UserSession: Codable { } var session: Session { - Session(accessToken: accessToken, - refreshToken: refreshToken, - userId: userID, - deviceId: deviceID, - homeserverUrl: homeserverURL, - oauthData: oauthData, - slidingSyncVersion: .native) + Session( + accessToken: accessToken, + refreshToken: refreshToken, + userId: userID, + deviceId: deviceID, + homeserverUrl: homeserverURL, + oauthData: oauthData, + slidingSyncVersion: .native) } fileprivate static var keychainKey: String { "UserSession" } From e17b31ec364420ddb772f2b57b8096163fe1cb96 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Wed, 30 Sep 2026 09:27:54 +0200 Subject: [PATCH 5/9] Format everything using swift-format --- .swift-format | 75 + Mactrix/Extensions/Logger.swift | 24 +- Mactrix/MactrixApp.swift | 52 +- Package.swift | 96 +- Sources/MactrixApp/Commands.swift | 136 +- Sources/MactrixApp/Extensions/Data+Mime.swift | 83 +- .../Extensions/Logger+Extensions.swift | 10 +- .../Extensions/MatrixClient+Extensions.swift | 244 ++-- .../Extensions/Models+Extensions.swift | 52 +- .../Extensions/NSColor+userID.swift | 15 +- .../MactrixApp/Extensions/NSTableView.swift | 52 +- Sources/MactrixApp/MainView.swift | 404 +++--- Sources/MactrixApp/Models/AppState.swift | 22 +- .../Models/SidebarSectionCollapsibility.swift | 16 +- Sources/MactrixApp/Models/WindowState.swift | 271 ++-- Sources/MactrixApp/Toolbar.swift | 54 +- .../ChatInputView/ChatInputView.swift | 309 ++-- .../ChatView/ChatInputView/ChatTextView.swift | 260 ++-- .../MactrixApp/Views/ChatView/ChatView.swift | 205 +-- .../Views/ChatView/EmbeddedMessageView.swift | 50 +- .../TimelineView/MessageContentRowView.swift | 70 +- .../TimelineView/MessageFileContentView.swift | 358 ++--- .../MessageHoverOverlayView.swift | 439 +++--- .../MessageImageContentView.swift | 468 +++--- .../TimelineView/MessageProfileRowView.swift | 191 +-- .../TimelineView/MessageReactionsView.swift | 250 ++-- .../MessageReadReceiptsView.swift | 458 +++--- .../MessageReplyPreviewView.swift | 249 ++-- .../TimelineView/MessageRowView.swift | 741 +++++----- .../TimelineView/MessageTextContentView.swift | 361 ++--- .../MessageThreadSummaryView.swift | 168 +-- .../MessageVideoContentView.swift | 494 +++---- .../TimelineView/StateEventRowView.swift | 135 +- .../TimelineView/TimelineRowProjection.swift | 254 ++-- .../TimelineView/TimelineTableView.swift | 1259 +++++++++-------- .../TimelineViewRepresentable.swift | 44 +- .../TimelineView/TypingIndicatorRowView.swift | 163 +-- .../TimelineView/VirtualItemRowView.swift | 152 +- .../Views/Inspector/InspectorRoomPins.swift | 110 +- .../Views/Inspector/InspectorScreen.swift | 108 +- .../Views/Inspector/InspectorThreads.swift | 46 +- .../Views/Inspector/InspectorUserInfo.swift | 81 +- .../Views/LoadMatrixUriScreen.swift | 130 +- .../MactrixApp/Views/MatrixImageView.swift | 126 +- .../Views/MultilineBannerLabelStyle.swift | 16 +- .../Views/Search/SearchInspectorView.swift | 152 +- .../Search/SearchRoomInspectorView.swift | 117 +- .../Search/SearchUserInspectorView.swift | 87 +- .../Views/Search/SearchViewModifier.swift | 318 ++--- .../Views/Settings/AccountSettingsView.swift | 179 +-- .../Settings/AppearanceSettingsView.swift | 18 +- .../Views/Settings/SessionsSettingsView.swift | 96 +- .../Views/Settings/SettingsView.swift | 30 +- .../Views/Sidebar/RoomContextMenu.swift | 132 +- .../Sidebar/SidebarSessionVerification.swift | 177 +-- .../Views/Sidebar/SidebarSyncState.swift | 76 +- .../Views/Sidebar/SidebarView.swift | 168 +-- .../Views/Sidebar/SpaceDisclosureGroup.swift | 163 +-- .../MactrixApp/Views/WelcomeSheetView.swift | 254 ++-- Sources/MactrixUI/AvatarImage.swift | 188 +-- .../MactrixUI/Extensions/Color+userID.swift | 16 +- .../Extensions/Logger+Extensions.swift | 4 +- .../MactrixUI/Inspector/UserProfileRow.swift | 98 +- .../MactrixUI/Inspector/UserProfileView.swift | 220 +-- .../MactrixUI/Rooms/RoomEncryptionBadge.swift | 66 +- .../MactrixUI/Rooms/RoomInspectorView.swift | 212 +-- Sources/MactrixUI/Rooms/RoomPreviewView.swift | 392 ++--- .../MactrixUI/Screens/CreateRoomScreen.swift | 280 ++-- .../MactrixUI/SessionVerificationModal.swift | 114 +- Sources/MactrixUI/Sidebar/RoomRow.swift | 337 ++--- Sources/MactrixUI/Sidebar/SectionAction.swift | 82 +- .../MactrixUI/Timeline/MessageReplyView.swift | 86 +- .../Timeline/ThreadTimelineHeader.swift | 52 +- Sources/MactrixUI/Username.swift | 20 +- Sources/MatrixIntegration/AppKeychain.swift | 100 +- .../MatrixIntegration/AsyncSDKListener.swift | 105 +- .../Extensions/Logger+Extensions.swift | 16 +- .../Extensions/MatrixRustSDK+Extensions.swift | 873 ++++++------ .../MatrixRustSDK+MessageContent.swift | 12 +- .../MatrixIntegration/HomeserverLogin.swift | 97 +- Sources/MatrixIntegration/LiveRoom.swift | 146 +- .../MatrixIntegration/LiveRoomSearch.swift | 96 +- .../MatrixIntegration/LiveSpaceRoomList.swift | 158 ++- .../MatrixIntegration/LiveSpaceService.swift | 92 +- Sources/MatrixIntegration/LiveTimeline.swift | 367 ++--- .../MatrixClient+Listeners.swift | 212 +-- Sources/MatrixIntegration/MatrixClient.swift | 437 +++--- .../MatrixClientSessionDelegate.swift | 60 +- .../MatrixNotifications.swift | 270 ++-- Sources/MatrixIntegration/SidebarRoom.swift | 92 +- .../MatrixIntegration/SidebarSpaceRoom.swift | 51 +- Sources/MatrixIntegration/UserSession.swift | 78 +- .../MatrixProtocols/CreateRoomParams.swift | 59 +- Sources/MatrixProtocols/EncryptionState.swift | 27 +- .../MatrixProtocols/EventTimelineItem.swift | 144 +- .../Extensions/String+Markdown.swift | 17 +- .../MatrixProtocols/InReplyToDetails.swift | 38 +- Sources/MatrixProtocols/MsgLikeContent.swift | 93 +- Sources/MatrixProtocols/Reaction.swift | 44 +- Sources/MatrixProtocols/Room.swift | 78 +- Sources/MatrixProtocols/RoomInfo.swift | 242 ++-- Sources/MatrixProtocols/RoomMember.swift | 106 +- Sources/MatrixProtocols/RoomPreviewInfo.swift | 244 ++-- .../SessionVerificationData.swift | 22 +- Sources/MatrixProtocols/ThreadSummary.swift | 24 +- Sources/MatrixProtocols/UserProfile.swift | 26 +- .../MatrixProtocols/VirtualTimelineItem.swift | 30 +- .../AttributedTextView.swift | 85 +- Sources/MessageFormatting/Parser.swift | 151 +- Sources/MessageFormatting/Preview.swift | 6 +- Sources/Utils/MatrixUriScheme.swift | 120 +- Tests/UtilsTests/MatrixUriSchemeTests.swift | 50 +- 112 files changed, 9350 insertions(+), 8953 deletions(-) create mode 100644 .swift-format diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..efe5aa4 --- /dev/null +++ b/.swift-format @@ -0,0 +1,75 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 2 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : "never", + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : false, + "spacesBeforeEndOfLineComments" : 2, + "tabWidth" : 8, + "version" : 1 +} diff --git a/Mactrix/Extensions/Logger.swift b/Mactrix/Extensions/Logger.swift index f87790a..f2951c7 100644 --- a/Mactrix/Extensions/Logger.swift +++ b/Mactrix/Extensions/Logger.swift @@ -1,20 +1,20 @@ import OSLog extension Logger { - private static let subsystem = Bundle.main.bundleIdentifier! + private static let subsystem = Bundle.main.bundleIdentifier! - static let matrixClient = Logger(subsystem: subsystem, category: "matrix-client") - static let windowState = Logger(subsystem: subsystem, category: "window-state") + static let matrixClient = Logger(subsystem: subsystem, category: "matrix-client") + static let windowState = Logger(subsystem: subsystem, category: "window-state") - static let liveRoom = Logger(subsystem: subsystem, category: "live-room") - static let liveSpaceService = Logger(subsystem: subsystem, category: "live-space-service") - static let liveSpaceRoomList = Logger(subsystem: subsystem, category: "live-space-room-list") - static let liveTimeline = Logger(subsystem: subsystem, category: "live-timeline") - static let timelineTableView = Logger(subsystem: subsystem, category: "timeline-table-view") - static let SidebarRoom = Logger(subsystem: subsystem, category: "sidebar-room") - static let chatTextView = Logger(subsystem: subsystem, category: "chat-text-view") + static let liveRoom = Logger(subsystem: subsystem, category: "live-room") + static let liveSpaceService = Logger(subsystem: subsystem, category: "live-space-service") + static let liveSpaceRoomList = Logger(subsystem: subsystem, category: "live-space-room-list") + static let liveTimeline = Logger(subsystem: subsystem, category: "live-timeline") + static let timelineTableView = Logger(subsystem: subsystem, category: "timeline-table-view") + static let SidebarRoom = Logger(subsystem: subsystem, category: "sidebar-room") + static let chatTextView = Logger(subsystem: subsystem, category: "chat-text-view") - static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") + static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") - static let notification = Logger(subsystem: subsystem, category: "notification") + static let notification = Logger(subsystem: subsystem, category: "notification") } diff --git a/Mactrix/MactrixApp.swift b/Mactrix/MactrixApp.swift index 8c9571a..a964ab8 100644 --- a/Mactrix/MactrixApp.swift +++ b/Mactrix/MactrixApp.swift @@ -1,36 +1,38 @@ -import OSLog -import SwiftUI import MactrixApp import MatrixIntegration +import OSLog +import SwiftUI @main struct MactrixApp: App { - @State var appState = AppState() - @FocusedValue(WindowState.self) private var windowState: WindowState? + @State var appState = AppState() + @FocusedValue(WindowState.self) private var windowState: WindowState? - var body: some Scene { - WindowGroup(id: "main") { - MainView() - } - .windowToolbarStyle(.automatic) - .environment(appState) - .commands { - AppCommands() - } - .onChange(of: windowState == nil, focusNotification) - .onChange(of: appState.matrixClient?.notifications.selectedRoomId == nil, focusNotification) - - Settings { - SettingsView() - } - .environment(appState) + var body: some Scene { + WindowGroup(id: "main") { + MainView() + } + .windowToolbarStyle(.automatic) + .environment(appState) + .commands { + AppCommands() } + .onChange(of: windowState == nil, focusNotification) + .onChange(of: appState.matrixClient?.notifications.selectedRoomId == nil, focusNotification) - func focusNotification() { - guard let windowState else { return } - guard let notificationRoomId = appState.matrixClient?.notifications.selectedRoomId else { return } + Settings { + SettingsView() + } + .environment(appState) + } - windowState.selectedRoomId = notificationRoomId - appState.matrixClient?.notifications.selectedRoomId = nil + func focusNotification() { + guard let windowState else { return } + guard let notificationRoomId = appState.matrixClient?.notifications.selectedRoomId else { + return } + + windowState.selectedRoomId = notificationRoomId + appState.matrixClient?.notifications.selectedRoomId = nil + } } diff --git a/Package.swift b/Package.swift index 58986f7..8ac4ff5 100644 --- a/Package.swift +++ b/Package.swift @@ -4,52 +4,52 @@ import PackageDescription let package = Package( - name: "Mactrix", - platforms: [.macOS(.v15)], - products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library(name: "MactrixApp", targets: ["MactrixApp"]) - ], - dependencies: [ - .package(url: "https://github.com/ZhgChgLi/ZMarkupParser.git", from: "1.12.0"), - .package( - url: "https://github.com/matrix-org/matrix-rust-components-swift", from: "26.09.07" - ), - .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.1.2"), - ], - targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. - .target( - name: "MactrixUI", - dependencies: ["MatrixProtocols"] - ), - .target(name: "Utils"), - .testTarget(name: "UtilsTests", dependencies: ["Utils"]), - .target(name: "MatrixProtocols"), - .target( - name: "MatrixIntegration", - dependencies: [ - "Utils", - "MatrixProtocols", - .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), - .product(name: "MatrixRustSDK", package: "matrix-rust-components-swift"), - ] - ), - .target( - name: "MactrixApp", - dependencies: [ - "MatrixIntegration", - "MactrixUI", - "MessageFormatting", - .product(name: "MatrixRustSDK", package: "matrix-rust-components-swift"), - ] - ), - .target( - name: "MessageFormatting", - dependencies: [ - .product(name: "ZMarkupParser", package: "zmarkupparser") - ] - ), - ] + name: "Mactrix", + platforms: [.macOS(.v15)], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library(name: "MactrixApp", targets: ["MactrixApp"]) + ], + dependencies: [ + .package(url: "https://github.com/ZhgChgLi/ZMarkupParser.git", from: "1.12.0"), + .package( + url: "https://github.com/matrix-org/matrix-rust-components-swift", from: "26.09.07" + ), + .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.1.2"), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "MactrixUI", + dependencies: ["MatrixProtocols"] + ), + .target(name: "Utils"), + .testTarget(name: "UtilsTests", dependencies: ["Utils"]), + .target(name: "MatrixProtocols"), + .target( + name: "MatrixIntegration", + dependencies: [ + "Utils", + "MatrixProtocols", + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "MatrixRustSDK", package: "matrix-rust-components-swift"), + ] + ), + .target( + name: "MactrixApp", + dependencies: [ + "MatrixIntegration", + "MactrixUI", + "MessageFormatting", + .product(name: "MatrixRustSDK", package: "matrix-rust-components-swift"), + ] + ), + .target( + name: "MessageFormatting", + dependencies: [ + .product(name: "ZMarkupParser", package: "zmarkupparser") + ] + ), + ] ) diff --git a/Sources/MactrixApp/Commands.swift b/Sources/MactrixApp/Commands.swift index 1cfe648..b956ce2 100644 --- a/Sources/MactrixApp/Commands.swift +++ b/Sources/MactrixApp/Commands.swift @@ -1,86 +1,88 @@ import SwiftUI public struct AppCommands: Commands { - @FocusedValue(WindowState.self) private var windowState: WindowState? - @AppStorage("fontSize") var fontSize: Int = 13 + @FocusedValue(WindowState.self) private var windowState: WindowState? + @AppStorage("fontSize") var fontSize: Int = 13 - public init() {} + public init() {} - public var body: some Commands { - SidebarCommands() - InspectorCommands() - TextEditingCommands() - ToolbarCommands() + public var body: some Commands { + SidebarCommands() + InspectorCommands() + TextEditingCommands() + ToolbarCommands() - newTab - fontSizeCommands - } - - var newTab: some Commands { - CommandGroup(after: .newItem) { - Button(action: { - guard let currentMainWindow = NSApp.orderedWindows.first(where: { window in - window.identifier?.rawValue.starts(with: "main-AppWindow") ?? false - }) else { - return - } + newTab + fontSizeCommands + } - guard let windowController = currentMainWindow.windowController else { - return - } + var newTab: some Commands { + CommandGroup(after: .newItem) { + Button(action: { + guard + let currentMainWindow = NSApp.orderedWindows.first(where: { window in + window.identifier?.rawValue.starts(with: "main-AppWindow") ?? false + }) + else { + return + } - windowController.newWindowForTab(nil) - if let newWindow = NSApp.keyWindow, currentMainWindow != newWindow { - currentMainWindow.addTabbedWindow(newWindow, ordered: .above) - newWindow.makeKeyAndOrderFront(nil) - } - }) { - Text("New Tab") - } - .keyboardShortcut("t", modifiers: [.command]) + guard let windowController = currentMainWindow.windowController else { + return + } - if let windowState { - Self.createRoomButton(windowState: windowState) - } + windowController.newWindowForTab(nil) + if let newWindow = NSApp.keyWindow, currentMainWindow != newWindow { + currentMainWindow.addTabbedWindow(newWindow, ordered: .above) + newWindow.makeKeyAndOrderFront(nil) } + }) { + Text("New Tab") + } + .keyboardShortcut("t", modifiers: [.command]) + + if let windowState { + Self.createRoomButton(windowState: windowState) + } } + } - static func createRoomButton(windowState: WindowState) -> some View { - Button { - windowState.selectedScreen = .newRoom - } label: { - Label("Create room", systemImage: "plus.bubble") - } - .help("Create a new room") - .keyboardShortcut("N", modifiers: [.command, .shift]) + static func createRoomButton(windowState: WindowState) -> some View { + Button { + windowState.selectedScreen = .newRoom + } label: { + Label("Create room", systemImage: "plus.bubble") } + .help("Create a new room") + .keyboardShortcut("N", modifiers: [.command, .shift]) + } - var fontSizeCommands: some Commands { - CommandGroup(after: .toolbar) { - Button { - fontSize += 1 - } label: { - Text("Make Text Bigger") - } - .keyboardShortcut("+", modifiers: [.command]) - .disabled(fontSize >= 24) + var fontSizeCommands: some Commands { + CommandGroup(after: .toolbar) { + Button { + fontSize += 1 + } label: { + Text("Make Text Bigger") + } + .keyboardShortcut("+", modifiers: [.command]) + .disabled(fontSize >= 24) - Button { - fontSize = 13 - } label: { - Text("Make Text Normal Size") - } - .keyboardShortcut("0", modifiers: [.command]) - .disabled(fontSize == 13) + Button { + fontSize = 13 + } label: { + Text("Make Text Normal Size") + } + .keyboardShortcut("0", modifiers: [.command]) + .disabled(fontSize == 13) - Button { - fontSize -= 1 + Button { + fontSize -= 1 - } label: { - Text("Make Text Smaller") - } - .keyboardShortcut("-", modifiers: [.command]) - .disabled(fontSize <= 8) - } + } label: { + Text("Make Text Smaller") + } + .keyboardShortcut("-", modifiers: [.command]) + .disabled(fontSize <= 8) } + } } diff --git a/Sources/MactrixApp/Extensions/Data+Mime.swift b/Sources/MactrixApp/Extensions/Data+Mime.swift index 142fb3a..b3813ca 100644 --- a/Sources/MactrixApp/Extensions/Data+Mime.swift +++ b/Sources/MactrixApp/Extensions/Data+Mime.swift @@ -5,51 +5,52 @@ import SwiftUI import UniformTypeIdentifiers extension Data { - func computeMimeType() -> UTType? { - guard let b: UInt8 = first else { return nil } + func computeMimeType() -> UTType? { + guard let b: UInt8 = first else { return nil } - switch b { - case 0xff: - return .jpeg - case 0x89: - return .png - case 0x47: - return .gif - case 0x4d, 0x49: - return .tiff - case 0x25: - return .pdf - case 0x46: - return .plainText - case 0x52: - return .webP - default: - return nil - } + switch b { + case 0xff: + return .jpeg + case 0x89: + return .png + case 0x47: + return .gif + case 0x4d, 0x49: + return .tiff + case 0x25: + return .pdf + case 0x46: + return .plainText + case 0x52: + return .webP + default: + return nil } + } - /// Decode image data into an NSImage, applying EXIF orientation. - /// `Image(importing:contentType:)` on macOS does not apply EXIF orientation, - /// so we route through CIImage which handles it correctly. - struct ImageDecodeError: Error {} + /// Decode image data into an NSImage, applying EXIF orientation. + /// `Image(importing:contentType:)` on macOS does not apply EXIF orientation, + /// so we route through CIImage which handles it correctly. + struct ImageDecodeError: Error {} - func toOrientedImage(contentType: UTType? = nil) throws -> NSImage { - guard - let source = CGImageSourceCreateWithData(self as CFData, nil), - let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) - else { - throw ImageDecodeError() - } + func toOrientedImage(contentType: UTType? = nil) throws -> NSImage { + guard + let source = CGImageSourceCreateWithData(self as CFData, nil), + let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { + throw ImageDecodeError() + } - let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] - let rawOrientation = props?[kCGImagePropertyOrientation as String] as? UInt32 - ?? CGImagePropertyOrientation.up.rawValue - let orientation = CGImagePropertyOrientation(rawValue: rawOrientation) ?? .up + let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] + let rawOrientation = + props?[kCGImagePropertyOrientation as String] as? UInt32 + ?? CGImagePropertyOrientation.up.rawValue + let orientation = CGImagePropertyOrientation(rawValue: rawOrientation) ?? .up - let ciImage = CIImage(cgImage: cgImage).oriented(orientation) - let rep = NSCIImageRep(ciImage: ciImage) - let nsImage = NSImage(size: rep.size) - nsImage.addRepresentation(rep) - return nsImage - } + let ciImage = CIImage(cgImage: cgImage).oriented(orientation) + let rep = NSCIImageRep(ciImage: ciImage) + let nsImage = NSImage(size: rep.size) + nsImage.addRepresentation(rep) + return nsImage + } } diff --git a/Sources/MactrixApp/Extensions/Logger+Extensions.swift b/Sources/MactrixApp/Extensions/Logger+Extensions.swift index 882f502..75e41e3 100644 --- a/Sources/MactrixApp/Extensions/Logger+Extensions.swift +++ b/Sources/MactrixApp/Extensions/Logger+Extensions.swift @@ -1,10 +1,10 @@ import OSLog extension Logger { - private static let subsystem = "dk.qpqp.mactrix.mactrix-app" + private static let subsystem = "dk.qpqp.mactrix.mactrix-app" - static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") - static let matrixClient = Logger(subsystem: subsystem, category: "matrix-client") - static let windowState = Logger(subsystem: subsystem, category: "window-state") - static let timelineTableView = Logger(subsystem: subsystem, category: "timeline-table-view") + static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") + static let matrixClient = Logger(subsystem: subsystem, category: "matrix-client") + static let windowState = Logger(subsystem: subsystem, category: "window-state") + static let timelineTableView = Logger(subsystem: subsystem, category: "timeline-table-view") } diff --git a/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift b/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift index b195049..5883806 100644 --- a/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift +++ b/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift @@ -5,148 +5,148 @@ import MatrixRustSDK import OSLog extension MatrixClient { - @MainActor - struct MatrixClientRoomPreviewActions: @MainActor RoomPreviewActions { - let roomId: String - let matrixClient: MatrixClient - let windowState: WindowState - - func joinRoom() async throws { - let room = try await matrixClient.client.joinRoomById(roomId: roomId) - let timeline = LiveTimeline(room: LiveRoom(matrixRoom: room)) - windowState.selectedScreen = .joinedRoom(timeline: timeline) - } - - func knockRoom() async throws { - let room = try await matrixClient.client.knock( - roomIdOrAlias: roomId, reason: nil, serverNames: ["matrix.org"]) - let timeline = LiveTimeline(room: LiveRoom(matrixRoom: room)) - windowState.selectedScreen = .joinedRoom(timeline: timeline) - } + @MainActor + struct MatrixClientRoomPreviewActions: @MainActor RoomPreviewActions { + let roomId: String + let matrixClient: MatrixClient + let windowState: WindowState + + func joinRoom() async throws { + let room = try await matrixClient.client.joinRoomById(roomId: roomId) + let timeline = LiveTimeline(room: LiveRoom(matrixRoom: room)) + windowState.selectedScreen = .joinedRoom(timeline: timeline) + } - func visitRoom() { - windowState.selectedRoomId = roomId - } + func knockRoom() async throws { + let room = try await matrixClient.client.knock( + roomIdOrAlias: roomId, reason: nil, serverNames: ["matrix.org"]) + let timeline = LiveTimeline(room: LiveRoom(matrixRoom: room)) + windowState.selectedScreen = .joinedRoom(timeline: timeline) } - func roomPreviewActions(forRoomWithId roomId: String, windowState: WindowState) - -> RoomPreviewActions - { - return MatrixClientRoomPreviewActions( - roomId: roomId, matrixClient: self, windowState: windowState) + func visitRoom() { + windowState.selectedRoomId = roomId } + } + + func roomPreviewActions(forRoomWithId roomId: String, windowState: WindowState) + -> RoomPreviewActions + { + return MatrixClientRoomPreviewActions( + roomId: roomId, matrixClient: self, windowState: windowState) + } } extension MatrixClient: MactrixUI.ImageLoader { - // In-memory cache of decoded NSImage objects. Purpose is rendering performance — - // keeping decoded bitmaps ready to display prevents flicker when scrolling back to - // previously viewed images. This is distinct from the SDK-level media download cache - // (see clearCaches / getMediaFile(useCache:)) which avoids re-fetching from the server. - // Costs are tracked in decoded RGBA bytes (width × height × 4), not compressed sizes, - // since a typical JPEG can be 20-50× larger once decoded. - static let imageCache: NSCache = { - let cache = NSCache() - cache.totalCostLimit = 256 * 1024 * 1024 // 256MB decoded pixels - return cache - }() - - private static let imageCacheMaxObjectCost = 64 * 1024 * 1024 // 64MB per object (~8000x2000px RGBA) - - static func setCachedImage(_ image: NSImage, forKey key: NSString) { - let cost = Int(image.size.width * image.size.height) * 4 // decoded RGBA bytes - guard cost <= imageCacheMaxObjectCost else { return } - imageCache.setObject(image, forKey: key, cost: cost) - } - - public func cachedImage(matrixUrl: String) -> NSImage? { - return Self.imageCache.object(forKey: NSString(string: matrixUrl)) + // In-memory cache of decoded NSImage objects. Purpose is rendering performance — + // keeping decoded bitmaps ready to display prevents flicker when scrolling back to + // previously viewed images. This is distinct from the SDK-level media download cache + // (see clearCaches / getMediaFile(useCache:)) which avoids re-fetching from the server. + // Costs are tracked in decoded RGBA bytes (width × height × 4), not compressed sizes, + // since a typical JPEG can be 20-50× larger once decoded. + static let imageCache: NSCache = { + let cache = NSCache() + cache.totalCostLimit = 256 * 1024 * 1024 // 256MB decoded pixels + return cache + }() + + private static let imageCacheMaxObjectCost = 64 * 1024 * 1024 // 64MB per object (~8000x2000px RGBA) + + static func setCachedImage(_ image: NSImage, forKey key: NSString) { + let cost = Int(image.size.width * image.size.height) * 4 // decoded RGBA bytes + guard cost <= imageCacheMaxObjectCost else { return } + imageCache.setObject(image, forKey: key, cost: cost) + } + + public func cachedImage(matrixUrl: String) -> NSImage? { + return Self.imageCache.object(forKey: NSString(string: matrixUrl)) + } + + public func loadImage(matrixUrl: String, size: CGSize?) async throws -> NSImage? { + let cacheKey = + if let size { + NSString(string: "\(matrixUrl)_\(Int(size.width))x\(Int(size.height))") + } else { + NSString(string: matrixUrl) + } + if let cached = Self.imageCache.object(forKey: cacheKey) { + return cached } - public func loadImage(matrixUrl: String, size: CGSize?) async throws -> NSImage? { - let cacheKey = - if let size { - NSString(string: "\(matrixUrl)_\(Int(size.width))x\(Int(size.height))") - } else { - NSString(string: matrixUrl) - } - if let cached = Self.imageCache.object(forKey: cacheKey) { - return cached - } + let mediaSource = try MediaSource.fromUrl(url: matrixUrl) - let mediaSource = try MediaSource.fromUrl(url: matrixUrl) - - let imageData: Data - if let size { - let width = UInt64(size.width) - let height = UInt64(size.height) - imageData = try await client.getMediaThumbnail( - mediaSource: mediaSource, width: width, height: height) - } else { - imageData = try await client.getMediaContent(mediaSource: mediaSource) - } + let imageData: Data + if let size { + let width = UInt64(size.width) + let height = UInt64(size.height) + imageData = try await client.getMediaThumbnail( + mediaSource: mediaSource, width: width, height: height) + } else { + imageData = try await client.getMediaContent(mediaSource: mediaSource) + } - do { - let nsImage = try imageData.toOrientedImage(contentType: imageData.computeMimeType()) - Self.setCachedImage(nsImage, forKey: cacheKey) - return nsImage - } catch { - Logger.matrixClient.error( - "failed convert matrix media data to Image: \(error) \(imageData)") - throw error - } + do { + let nsImage = try imageData.toOrientedImage(contentType: imageData.computeMimeType()) + Self.setCachedImage(nsImage, forKey: cacheKey) + return nsImage + } catch { + Logger.matrixClient.error( + "failed convert matrix media data to Image: \(error) \(imageData)") + throw error } + } } extension MatrixClient { - @MainActor - struct MatrixClientUserProfileActions: MactrixUI.UserProfileActions { - let userId: String - let matrixClient: MatrixClient - let windowState: WindowState - - func sendMessage() async { - do { - if let room = try matrixClient.client.getDmRoom(userId: userId) { - windowState.selectedRoomId = room.id - return - } - - let createRoomParams = MatrixRustSDK.CreateRoomParameters( - name: nil, isEncrypted: false, isDirect: true, visibility: .private, - preset: .privateChat, invite: [userId] - ) - let roomId = try await matrixClient.client.createRoom(request: createRoomParams) - windowState.selectedRoomId = roomId - } catch { - Logger.viewCycle.error("failed to get DM room for user \(userId): \(error)") - } + @MainActor + struct MatrixClientUserProfileActions: MactrixUI.UserProfileActions { + let userId: String + let matrixClient: MatrixClient + let windowState: WindowState + + func sendMessage() async { + do { + if let room = try matrixClient.client.getDmRoom(userId: userId) { + windowState.selectedRoomId = room.id + return } - func shareProfile() {} + let createRoomParams = MatrixRustSDK.CreateRoomParameters( + name: nil, isEncrypted: false, isDirect: true, visibility: .private, + preset: .privateChat, invite: [userId] + ) + let roomId = try await matrixClient.client.createRoom(request: createRoomParams) + windowState.selectedRoomId = roomId + } catch { + Logger.viewCycle.error("failed to get DM room for user \(userId): \(error)") + } + } - func ignoreUser() async { - Logger.viewCycle.info("Ignore user \(userId)") - do { - try await matrixClient.client.ignoreUser(userId: userId) - } catch { - Logger.viewCycle.error("failed to ignore user: \(error)") - } - } + func shareProfile() {} - func unignoreUser() async { - Logger.viewCycle.info("Unignore user \(userId)") - do { - try await matrixClient.client.unignoreUser(userId: userId) - } catch { - Logger.viewCycle.error("failed to unignore user: \(error)") - } - } + func ignoreUser() async { + Logger.viewCycle.info("Ignore user \(userId)") + do { + try await matrixClient.client.ignoreUser(userId: userId) + } catch { + Logger.viewCycle.error("failed to ignore user: \(error)") + } } - func userProfileActions(forUserId userId: String, windowState: WindowState) -> some MactrixUI - .UserProfileActions - { - return MatrixClientUserProfileActions( - userId: userId, matrixClient: self, windowState: windowState) + func unignoreUser() async { + Logger.viewCycle.info("Unignore user \(userId)") + do { + try await matrixClient.client.unignoreUser(userId: userId) + } catch { + Logger.viewCycle.error("failed to unignore user: \(error)") + } } + } + + func userProfileActions(forUserId userId: String, windowState: WindowState) -> some MactrixUI + .UserProfileActions + { + return MatrixClientUserProfileActions( + userId: userId, matrixClient: self, windowState: windowState) + } } diff --git a/Sources/MactrixApp/Extensions/Models+Extensions.swift b/Sources/MactrixApp/Extensions/Models+Extensions.swift index dd868b4..678d149 100644 --- a/Sources/MactrixApp/Extensions/Models+Extensions.swift +++ b/Sources/MactrixApp/Extensions/Models+Extensions.swift @@ -1,32 +1,36 @@ import Foundation -import MatrixRustSDK import MatrixProtocols +import MatrixRustSDK extension MatrixProtocols.CreateRoomParams { - var asMatrixRequest: MatrixRustSDK.CreateRoomParameters { - let preset: RoomPreset = switch access { - case .privateRoom: - .privateChat - case .publicRoom: - .publicChat - } + var asMatrixRequest: MatrixRustSDK.CreateRoomParameters { + let preset: RoomPreset = + switch access { + case .privateRoom: + .privateChat + case .publicRoom: + .publicChat + } - let visibility: MatrixRustSDK.RoomVisibility = switch self.visibility { - case .published: - .public - case .unpublished: - .private - } + let visibility: MatrixRustSDK.RoomVisibility = + switch self.visibility { + case .published: + .public + case .unpublished: + .private + } - var topicValue: String? = topic.trimmingCharacters(in: .whitespacesAndNewlines) - topicValue = topicValue?.isEmpty == false ? topicValue : nil + var topicValue: String? = topic.trimmingCharacters( + in: .whitespacesAndNewlines + ) + topicValue = topicValue?.isEmpty == false ? topicValue : nil - return CreateRoomParameters( - name: name, - topic: topicValue, - isEncrypted: enableEncryption, - visibility: visibility, - preset: preset - ) - } + return CreateRoomParameters( + name: name, + topic: topicValue, + isEncrypted: enableEncryption, + visibility: visibility, + preset: preset + ) + } } diff --git a/Sources/MactrixApp/Extensions/NSColor+userID.swift b/Sources/MactrixApp/Extensions/NSColor+userID.swift index d02e06d..6bb9cc5 100644 --- a/Sources/MactrixApp/Extensions/NSColor+userID.swift +++ b/Sources/MactrixApp/Extensions/NSColor+userID.swift @@ -1,10 +1,11 @@ import AppKit -public extension NSColor { - convenience init(userID: String) { - unsafe self.init(named: String( - format: "Username%02d", - userID.unicodeScalars.reduce(0) { $0 + Int($1.value) } % 16 + 1 - ))! - } +extension NSColor { + public convenience init(userID: String) { + unsafe self.init( + named: String( + format: "Username%02d", + userID.unicodeScalars.reduce(0) { $0 + Int($1.value) } % 16 + 1 + ))! + } } diff --git a/Sources/MactrixApp/Extensions/NSTableView.swift b/Sources/MactrixApp/Extensions/NSTableView.swift index 1165651..e7eace9 100644 --- a/Sources/MactrixApp/Extensions/NSTableView.swift +++ b/Sources/MactrixApp/Extensions/NSTableView.swift @@ -1,35 +1,35 @@ import AppKit extension NSTableView { - /// Animates scrolling to row with a given index. - func animateRowToVisible(_ index: Int) { - guard index >= 0, index < numberOfRows else { return } - guard let scrollView = enclosingScrollView else { return } + /// Animates scrolling to row with a given index. + func animateRowToVisible(_ index: Int) { + guard index >= 0, index < numberOfRows else { return } + guard let scrollView = enclosingScrollView else { return } - let rowRect = rect(ofRow: index) - let clipView = scrollView.contentView - let visibleRect = clipView.bounds + let rowRect = rect(ofRow: index) + let clipView = scrollView.contentView + let visibleRect = clipView.bounds - var targetY = visibleRect.origin.y - if rowRect.origin.y < visibleRect.origin.y { - // Row is above: Scroll up until the top of the row is at the top - targetY = rowRect.origin.y - } else if rowRect.maxY > visibleRect.maxY { - // Row is below: Scroll down until the bottom of the row is at the bottom - targetY = rowRect.maxY - visibleRect.height - } else { - // Row is already fully visible: Exit - return - } + var targetY = visibleRect.origin.y + if rowRect.origin.y < visibleRect.origin.y { + // Row is above: Scroll up until the top of the row is at the top + targetY = rowRect.origin.y + } else if rowRect.maxY > visibleRect.maxY { + // Row is below: Scroll down until the bottom of the row is at the bottom + targetY = rowRect.maxY - visibleRect.height + } else { + // Row is already fully visible: Exit + return + } - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.3 - context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - context.allowsImplicitAnimation = true + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + context.allowsImplicitAnimation = true - var newBounds = clipView.bounds - newBounds.origin.y = targetY - clipView.animator().bounds = newBounds - } + var newBounds = clipView.bounds + newBounds.origin.y = targetY + clipView.animator().bounds = newBounds } + } } diff --git a/Sources/MactrixApp/MainView.swift b/Sources/MactrixApp/MainView.swift index b2ee029..5e07bbe 100644 --- a/Sources/MactrixApp/MainView.swift +++ b/Sources/MactrixApp/MainView.swift @@ -6,226 +6,226 @@ import SwiftUI import Utils public struct MainView: View { - @Environment(AppState.self) var appState - - @SceneStorage("MainView.windowState") - private var windowState: WindowState = .init() - - @State private var showWelcomeSheet: Bool = false - - let x: Bool = false - - public init() {} - - @ViewBuilder var details: some View { - switch windowState.selectedScreen { - case .joinedRoom(let timeline): - ChatView(timeline: timeline).id(timeline.room.id) - case .previewRoom(let room): - MactrixUI.RoomPreviewView( - preview: room.info(), - imageLoader: appState.matrixClient, - actions: appState.matrixClient?.roomPreviewActions( - forRoomWithId: room.info().roomId, windowState: windowState) - ) - case .newRoom: - MactrixUI.CreateRoomScreen(onSubmit: { params in - guard let matrixClient = appState.matrixClient else { return } - let newRoom = try await matrixClient.client.createRoom( - request: params.asMatrixRequest) - windowState.selectedRoomId = newRoom - }) - case .loadMatrixUrl(let matrixUri): - LoadMatrixUriScreen(matrixUri: matrixUri) - case .user(let profile): - UserProfileView( - profile: profile, - isUserIgnored: appState.matrixClient?.isUserIgnored(profile.userId) ?? false, - actions: appState.matrixClient?.userProfileActions( - forUserId: profile.userId, windowState: windowState), - timelineActions: nil, - imageLoader: appState.matrixClient - ) - case .none: - ContentUnavailableView("Select a room", systemImage: "message.fill") - } - } - - var verificationSheetPresented: Binding { - Binding( - get: { appState.matrixClient?.sessionVerificationData != nil }, - set: { isPresented in - if !isPresented { - Task { - do { - try await appState.matrixClient?.client - .getSessionVerificationController().declineVerification() - } catch { - Logger.viewCycle.error("failed to decline verification: \(error)") - appState.matrixClient?.sessionVerificationData = nil - } - } - } - } - ) + @Environment(AppState.self) var appState + + @SceneStorage("MainView.windowState") + private var windowState: WindowState = .init() + + @State private var showWelcomeSheet: Bool = false + + let x: Bool = false + + public init() {} + + @ViewBuilder var details: some View { + switch windowState.selectedScreen { + case .joinedRoom(let timeline): + ChatView(timeline: timeline).id(timeline.room.id) + case .previewRoom(let room): + MactrixUI.RoomPreviewView( + preview: room.info(), + imageLoader: appState.matrixClient, + actions: appState.matrixClient?.roomPreviewActions( + forRoomWithId: room.info().roomId, windowState: windowState) + ) + case .newRoom: + MactrixUI.CreateRoomScreen(onSubmit: { params in + guard let matrixClient = appState.matrixClient else { return } + let newRoom = try await matrixClient.client.createRoom( + request: params.asMatrixRequest) + windowState.selectedRoomId = newRoom + }) + case .loadMatrixUrl(let matrixUri): + LoadMatrixUriScreen(matrixUri: matrixUri) + case .user(let profile): + UserProfileView( + profile: profile, + isUserIgnored: appState.matrixClient?.isUserIgnored(profile.userId) ?? false, + actions: appState.matrixClient?.userProfileActions( + forUserId: profile.userId, windowState: windowState), + timelineActions: nil, + imageLoader: appState.matrixClient + ) + case .none: + ContentUnavailableView("Select a room", systemImage: "message.fill") } - - public var body: some View { - @Bindable var windowState = windowState - - NavigationSplitView( - sidebar: { SidebarView() }, - detail: { details } - ) - .inspector( - isPresented: $windowState.inspectorVisible, - content: { - InspectorScreen() - .environment(windowState) - } - ) - .task { await attemptLoadUserSession() } - .sheet(isPresented: $showWelcomeSheet, onDismiss: onLoginModalDismiss) { - WelcomeSheetView() - } - .sheet( - isPresented: verificationSheetPresented, - content: { - if let verificationData = appState.matrixClient?.sessionVerificationData { - MactrixUI.SessionVerificationModal( - verificationData: verificationData.asModel, - onComplete: { response in - Task { - switch response { - case .accept: - do { - try await appState.matrixClient?.client - .getSessionVerificationController() - .approveVerification() - } catch { - Logger.viewCycle.error( - "failed to approve verification: \(error)") - appState.matrixClient?.sessionVerificationData = nil - } - case .decline: - do { - try await appState.matrixClient?.client - .getSessionVerificationController() - .declineVerification() - } catch { - Logger.viewCycle.error( - "failed to decline verification: \(error)") - appState.matrixClient?.sessionVerificationData = nil - } - } - } - }) - } - } - ) - .onChange(of: appState.matrixClient == nil) { _, matrixClientIsNil in - if matrixClientIsNil { - Logger.viewCycle.info("Matrix client is nil, present welcome sheet") - showWelcomeSheet = true - } - } - .task(id: windowState.selectedRoomId) { - await onRoomSelected() - } - .onChange(of: appState.matrixClient?.authenticationFailed) { _, authFailed in - if authFailed == true { - Logger.viewCycle.info("Logging out since auth failed") - appState.matrixClient = nil - } - } - .onOpenURL { url in - Logger.viewCycle.debug("onOpenUrl \(url)") - + } + + var verificationSheetPresented: Binding { + Binding( + get: { appState.matrixClient?.sessionVerificationData != nil }, + set: { isPresented in + if !isPresented { + Task { do { - let matrixUri = try Utils.MatrixUriScheme(parseUrl: url.absoluteString) - Logger.viewCycle.info("Matched Matrix Uri") - - windowState.selectedScreen = .loadMatrixUrl(matrixUri) + try await appState.matrixClient?.client + .getSessionVerificationController().declineVerification() } catch { - Logger.viewCycle.error("Failed to parse Matrix Uri: \(error)") + Logger.viewCycle.error("failed to decline verification: \(error)") + appState.matrixClient?.sessionVerificationData = nil } + } } - .modifier(ToolbarViewModifier()) - .modifier(SearchViewModifier()) - .environment(windowState) - .focusedSceneValue(windowState) + } + ) + } + + public var body: some View { + @Bindable var windowState = windowState + + NavigationSplitView( + sidebar: { SidebarView() }, + detail: { details } + ) + .inspector( + isPresented: $windowState.inspectorVisible, + content: { + InspectorScreen() + .environment(windowState) + } + ) + .task { await attemptLoadUserSession() } + .sheet(isPresented: $showWelcomeSheet, onDismiss: onLoginModalDismiss) { + WelcomeSheetView() } - - func attemptLoadUserSession() async { - guard appState.matrixClient == nil else { return } - - do { - if let matrixClient = try await MatrixClient.attemptRestore() { - appState.matrixClient = matrixClient - } - } catch { - Logger.viewCycle.error("Failed to restore matrix session: \(error)") - } - - showWelcomeSheet = appState.matrixClient == nil - if let matrixClient = appState.matrixClient { - onMatrixLoaded(matrixClient: matrixClient) + .sheet( + isPresented: verificationSheetPresented, + content: { + if let verificationData = appState.matrixClient?.sessionVerificationData { + MactrixUI.SessionVerificationModal( + verificationData: verificationData.asModel, + onComplete: { response in + Task { + switch response { + case .accept: + do { + try await appState.matrixClient?.client + .getSessionVerificationController() + .approveVerification() + } catch { + Logger.viewCycle.error( + "failed to approve verification: \(error)") + appState.matrixClient?.sessionVerificationData = nil + } + case .decline: + do { + try await appState.matrixClient?.client + .getSessionVerificationController() + .declineVerification() + } catch { + Logger.viewCycle.error( + "failed to decline verification: \(error)") + appState.matrixClient?.sessionVerificationData = nil + } + } + } + }) } + } + ) + .onChange(of: appState.matrixClient == nil) { _, matrixClientIsNil in + if matrixClientIsNil { + Logger.viewCycle.info("Matrix client is nil, present welcome sheet") + showWelcomeSheet = true + } + } + .task(id: windowState.selectedRoomId) { + await onRoomSelected() + } + .onChange(of: appState.matrixClient?.authenticationFailed) { _, authFailed in + if authFailed == true { + Logger.viewCycle.info("Logging out since auth failed") + appState.matrixClient = nil + } } + .onOpenURL { url in + Logger.viewCycle.debug("onOpenUrl \(url)") - func onMatrixLoaded(matrixClient: MatrixClient) { - Task { - do { - try await matrixClient.startSync() - } catch { - Logger.matrixClient.error("Failed to start sync: \(error)") - } + do { + let matrixUri = try Utils.MatrixUriScheme(parseUrl: url.absoluteString) + Logger.viewCycle.info("Matched Matrix Uri") - // check if a room is selected and load it - await onRoomSelected() - } + windowState.selectedScreen = .loadMatrixUrl(matrixUri) + } catch { + Logger.viewCycle.error("Failed to parse Matrix Uri: \(error)") + } + } + .modifier(ToolbarViewModifier()) + .modifier(SearchViewModifier()) + .environment(windowState) + .focusedSceneValue(windowState) + } + + func attemptLoadUserSession() async { + guard appState.matrixClient == nil else { return } + + do { + if let matrixClient = try await MatrixClient.attemptRestore() { + appState.matrixClient = matrixClient + } + } catch { + Logger.viewCycle.error("Failed to restore matrix session: \(error)") } - func onLoginModalDismiss() { - Task { - do { - try await Task.sleep(for: .milliseconds(100)) - if let matrixClient = appState.matrixClient { - onMatrixLoaded(matrixClient: matrixClient) - } else { - NSApp.terminate(nil) - } - } catch is CancellationError { - // ignore - } catch { - Logger.matrixClient.error("Failed in onLoginModalDismiss: \(error)") - } + showWelcomeSheet = appState.matrixClient == nil + if let matrixClient = appState.matrixClient { + onMatrixLoaded(matrixClient: matrixClient) + } + } + + func onMatrixLoaded(matrixClient: MatrixClient) { + Task { + do { + try await matrixClient.startSync() + } catch { + Logger.matrixClient.error("Failed to start sync: \(error)") + } + + // check if a room is selected and load it + await onRoomSelected() + } + } + + func onLoginModalDismiss() { + Task { + do { + try await Task.sleep(for: .milliseconds(100)) + if let matrixClient = appState.matrixClient { + onMatrixLoaded(matrixClient: matrixClient) + } else { + NSApp.terminate(nil) } + } catch is CancellationError { + // ignore + } catch { + Logger.matrixClient.error("Failed in onLoginModalDismiss: \(error)") + } } + } - func onRoomSelected() async { - guard let matrixClient = appState.matrixClient else { return } + func onRoomSelected() async { + guard let matrixClient = appState.matrixClient else { return } - do { - Logger.viewCycle.debug("Selected room: \(windowState.selectedRoomId.debugDescription)") + do { + Logger.viewCycle.debug("Selected room: \(windowState.selectedRoomId.debugDescription)") - if let roomId = windowState.selectedRoomId { - if let selectedRoom = try matrixClient.client.getRoom(roomId: roomId) { - windowState.selectedScreen = .joinedRoom( - timeline: LiveTimeline(room: LiveRoom(matrixRoom: selectedRoom))) - } else { - let roomPreview = try await matrixClient.client.getRoomPreviewFromRoomId( - roomId: roomId, viaServers: ["matrix.org"]) + if let roomId = windowState.selectedRoomId { + if let selectedRoom = try matrixClient.client.getRoom(roomId: roomId) { + windowState.selectedScreen = .joinedRoom( + timeline: LiveTimeline(room: LiveRoom(matrixRoom: selectedRoom))) + } else { + let roomPreview = try await matrixClient.client.getRoomPreviewFromRoomId( + roomId: roomId, viaServers: ["matrix.org"]) - Logger.viewCycle.debug( - "Selected room preview: \(roomPreview.info().debugDescription)") - windowState.selectedScreen = .previewRoom(roomPreview) - } - } else { - windowState.selectedScreen = .none - } - } catch { - Logger.viewCycle.error("Failed to get room \(error)") + Logger.viewCycle.debug( + "Selected room preview: \(roomPreview.info().debugDescription)") + windowState.selectedScreen = .previewRoom(roomPreview) } + } else { + windowState.selectedScreen = .none + } + } catch { + Logger.viewCycle.error("Failed to get room \(error)") } + } } diff --git a/Sources/MactrixApp/Models/AppState.swift b/Sources/MactrixApp/Models/AppState.swift index 0d91ce7..83f347a 100644 --- a/Sources/MactrixApp/Models/AppState.swift +++ b/Sources/MactrixApp/Models/AppState.swift @@ -1,19 +1,19 @@ -import Observation import MatrixIntegration import OSLog +import Observation @MainActor @Observable public final class AppState { - public var matrixClient: MatrixClient? - - public init() {} + public var matrixClient: MatrixClient? + + public init() {} - public func reset() async throws { - do { - try await matrixClient?.reset() - } catch { - Logger.viewCycle.error("Failed to reset matrix client: \(error)") - } - matrixClient = nil + public func reset() async throws { + do { + try await matrixClient?.reset() + } catch { + Logger.viewCycle.error("Failed to reset matrix client: \(error)") } + matrixClient = nil + } } diff --git a/Sources/MactrixApp/Models/SidebarSectionCollapsibility.swift b/Sources/MactrixApp/Models/SidebarSectionCollapsibility.swift index c4bb8a6..03c05f0 100644 --- a/Sources/MactrixApp/Models/SidebarSectionCollapsibility.swift +++ b/Sources/MactrixApp/Models/SidebarSectionCollapsibility.swift @@ -19,15 +19,15 @@ /// > Note: You do not need to create this structure yourself. Rather, you should use the collapsibility information /// > from ``WindowState/sidebarSections``. public struct SidebarSectionCollapsibility: Codable, Equatable { - /// Whether the favorites section is expanded. - public var favorites = true + /// Whether the favorites section is expanded. + public var favorites = true - /// Whether the direct messages section is expanded. - public var directs = true + /// Whether the direct messages section is expanded. + public var directs = true - /// Whether the rooms section is expanded. - public var rooms = true + /// Whether the rooms section is expanded. + public var rooms = true - /// Whether the spaces section is expanded. - public var spaces = true + /// Whether the spaces section is expanded. + public var spaces = true } diff --git a/Sources/MactrixApp/Models/WindowState.swift b/Sources/MactrixApp/Models/WindowState.swift index cca8170..e6cc5f1 100644 --- a/Sources/MactrixApp/Models/WindowState.swift +++ b/Sources/MactrixApp/Models/WindowState.swift @@ -6,177 +6,180 @@ import SwiftUI import Utils public enum InspectorContent: Equatable { - case roomInfo - case search - case userInfo(userId: String) - case roomThreads - case roomPins - case focusThread(threadTimeline: LiveTimeline) + case roomInfo + case search + case userInfo(userId: String) + case roomThreads + case roomPins + case focusThread(threadTimeline: LiveTimeline) } public enum SearchDirectResult { - case lookingForRoom(alias: String), roomNotFound(alias: String) - case resolvedRoomAlias(alias: String, resolvedRoom: MatrixRustSDK.ResolvedRoomAlias) - case resolvedRoomId(roomPreview: MatrixRustSDK.RoomPreview) - case lookingForUser(userId: String), userNotFound(userId: String) - case resolvedUser(profile: MatrixRustSDK.UserProfile) + case lookingForRoom(alias: String) + case roomNotFound(alias: String) + case resolvedRoomAlias(alias: String, resolvedRoom: MatrixRustSDK.ResolvedRoomAlias) + case resolvedRoomId(roomPreview: MatrixRustSDK.RoomPreview) + case lookingForUser(userId: String) + case userNotFound(userId: String) + case resolvedUser(profile: MatrixRustSDK.UserProfile) } public enum SelectedScreen { - case joinedRoom(timeline: LiveTimeline) - case loadMatrixUrl(_ url: Utils.MatrixUriScheme) - case previewRoom(_ room: RoomPreview) - case user(profile: UserProfile) - case newRoom - case none + case joinedRoom(timeline: LiveTimeline) + case loadMatrixUrl(_ url: Utils.MatrixUriScheme) + case previewRoom(_ room: RoomPreview) + case user(profile: UserProfile) + case newRoom + case none } @MainActor @Observable public final class WindowState { - public var selectedScreen: SelectedScreen = .none + public var selectedScreen: SelectedScreen = .none - public var selectedRoomId: String? - public var inspectorVisible: Bool = false + public var selectedRoomId: String? + public var inspectorVisible: Bool = false - public var inspectorContent: InspectorContent = .roomInfo + public var inspectorContent: InspectorContent = .roomInfo - public var requestedVerification = false + public var requestedVerification = false - public var searchQuery: String = "" - public var searchTokens: [SearchToken] = [] - public var searchDirectResult: SearchDirectResult? + public var searchQuery: String = "" + public var searchTokens: [SearchToken] = [] + public var searchDirectResult: SearchDirectResult? - /// The collapsed/expanded states of the sections in the sidebar. - public var sidebarSections = SidebarSectionCollapsibility() + /// The collapsed/expanded states of the sections in the sidebar. + public var sidebarSections = SidebarSectionCollapsibility() - public var searchFocused: Binding { - Binding( - get: { self.inspectorContent == .search }, - set: { setFocused in - if setFocused { - self.inspectorContent = .search - self.inspectorVisible = true - } - - if !setFocused, self.inspectorContent == .search { - self.inspectorContent = .roomInfo - } - } - ) - } - - public func toggleInspector() { - if inspectorVisible { - if inspectorContent == .roomInfo { - inspectorVisible = false - } else { - inspectorContent = .roomInfo - } - } else { - inspectorVisible = true - inspectorContent = .roomInfo + public var searchFocused: Binding { + Binding( + get: { self.inspectorContent == .search }, + set: { setFocused in + if setFocused { + self.inspectorContent = .search + self.inspectorVisible = true } - } - public func focusMessage(eventId: String) { - guard case let .joinedRoom(timeline: roomTimeline) = selectedScreen else { - Logger.windowState.warning("focus message failed, no active timeline") - return + if !setFocused, self.inspectorContent == .search { + self.inspectorContent = .roomInfo } + } + ) + } + + public func toggleInspector() { + if inspectorVisible { + if inspectorContent == .roomInfo { + inspectorVisible = false + } else { + inspectorContent = .roomInfo + } + } else { + inspectorVisible = true + inspectorContent = .roomInfo + } + } - Logger.windowState.warning("scrolling to message \(eventId)") - roomTimeline.focusEvent(id: .eventId(eventId: eventId)) + public func focusMessage(eventId: String) { + guard case .joinedRoom(timeline: let roomTimeline) = selectedScreen else { + Logger.windowState.warning("focus message failed, no active timeline") + return } - public func focusThread(rootEventId: String) { - guard case let .joinedRoom(timeline: roomTimeline) = selectedScreen else { return } + Logger.windowState.warning("scrolling to message \(eventId)") + roomTimeline.focusEvent(id: .eventId(eventId: eventId)) + } - inspectorVisible = true - inspectorContent = .focusThread(threadTimeline: LiveTimeline(room: roomTimeline.room, focusThread: rootEventId)) - } + public func focusThread(rootEventId: String) { + guard case .joinedRoom(timeline: let roomTimeline) = selectedScreen else { return } - public func showRoomThreads() { - if inspectorVisible, inspectorContent == .roomThreads { - inspectorVisible = false - } else { - inspectorContent = .roomThreads - inspectorVisible = true - } - } + inspectorVisible = true + inspectorContent = .focusThread( + threadTimeline: LiveTimeline(room: roomTimeline.room, focusThread: rootEventId)) + } - public func showRoomPins() { - if inspectorVisible, inspectorContent == .roomPins { - inspectorVisible = false - } else { - inspectorContent = .roomPins - inspectorVisible = true - } + public func showRoomThreads() { + if inspectorVisible, inspectorContent == .roomThreads { + inspectorVisible = false + } else { + inspectorContent = .roomThreads + inspectorVisible = true } - - public func focusUser(userId: String) { - if inspectorVisible, inspectorContent == .userInfo(userId: userId) { - inspectorVisible = false - } else { - inspectorContent = .userInfo(userId: userId) - inspectorVisible = true - } + } + + public func showRoomPins() { + if inspectorVisible, inspectorContent == .roomPins { + inspectorVisible = false + } else { + inspectorContent = .roomPins + inspectorVisible = true + } + } + + public func focusUser(userId: String) { + if inspectorVisible, inspectorContent == .userInfo(userId: userId) { + inspectorVisible = false + } else { + inspectorContent = .userInfo(userId: userId) + inspectorVisible = true } + } } extension WindowState: @MainActor RawRepresentable { - public struct SceneStorageRepresentation: Codable { - let selectedRoomId: String? - let inspectorVisible: Bool - let sidebarSections: SidebarSectionCollapsibility - - @MainActor init(windowState: WindowState) { - self.selectedRoomId = windowState.selectedRoomId - self.inspectorVisible = windowState.inspectorVisible - self.sidebarSections = windowState.sidebarSections - } - - @MainActor func restore(windowState: WindowState) { - windowState.selectedRoomId = selectedRoomId - windowState.inspectorVisible = inspectorVisible - windowState.sidebarSections = sidebarSections - } + public struct SceneStorageRepresentation: Codable { + let selectedRoomId: String? + let inspectorVisible: Bool + let sidebarSections: SidebarSectionCollapsibility + + @MainActor init(windowState: WindowState) { + self.selectedRoomId = windowState.selectedRoomId + self.inspectorVisible = windowState.inspectorVisible + self.sidebarSections = windowState.sidebarSections } - public convenience init?(rawValue: String) { - guard - let data = rawValue.data(using: .utf8), - let decoded = try? JSONDecoder().decode(SceneStorageRepresentation.self, from: data) - else { - Logger.windowState.warning("Failed to recover WindowState from storage: \(rawValue)") - return nil - } - - self.init() - decoded.restore(windowState: self) - Logger.windowState.info("WindowState recovered from storage: \(rawValue)") + @MainActor func restore(windowState: WindowState) { + windowState.selectedRoomId = selectedRoomId + windowState.inspectorVisible = inspectorVisible + windowState.sidebarSections = sidebarSections + } + } + + public convenience init?(rawValue: String) { + guard + let data = rawValue.data(using: .utf8), + let decoded = try? JSONDecoder().decode(SceneStorageRepresentation.self, from: data) + else { + Logger.windowState.warning("Failed to recover WindowState from storage: \(rawValue)") + return nil } - public var rawValue: String { - guard - let data = try? JSONEncoder().encode(SceneStorageRepresentation(windowState: self)), - let result = String(data: data, encoding: .utf8) - else { - return "" - } - - return result + self.init() + decoded.restore(windowState: self) + Logger.windowState.info("WindowState recovered from storage: \(rawValue)") + } + + public var rawValue: String { + guard + let data = try? JSONEncoder().encode(SceneStorageRepresentation(windowState: self)), + let result = String(data: data, encoding: .utf8) + else { + return "" } - public typealias RawValue = String + return result + } + + public typealias RawValue = String } public enum SearchToken: Hashable, Identifiable { - case users, rooms, spaces, messages - case resolvedRoomAlias(alias: String, resolvedRoom: ResolvedRoomAlias) - case resolvedRoomId(roomPreview: RoomPreview) - case resolvedUser(profile: UserProfile) - - public var id: Self { - self - } + case users, rooms, spaces, messages + case resolvedRoomAlias(alias: String, resolvedRoom: ResolvedRoomAlias) + case resolvedRoomId(roomPreview: RoomPreview) + case resolvedUser(profile: UserProfile) + + public var id: Self { + self + } } diff --git a/Sources/MactrixApp/Toolbar.swift b/Sources/MactrixApp/Toolbar.swift index c559d20..bade9f5 100644 --- a/Sources/MactrixApp/Toolbar.swift +++ b/Sources/MactrixApp/Toolbar.swift @@ -2,34 +2,34 @@ import OSLog import SwiftUI struct ToolbarViewModifier: ViewModifier { - @Environment(WindowState.self) var windowState + @Environment(WindowState.self) var windowState - func body(content: Content) -> some View { - content - .toolbar { - Button { - Logger.viewCycle.info("Show pins") - windowState.showRoomPins() - } label: { - Label("Show Pins", systemImage: "pin.circle") - } - .help("Show Pins") - .disabled(windowState.selectedRoomId == nil) + func body(content: Content) -> some View { + content + .toolbar { + Button { + Logger.viewCycle.info("Show pins") + windowState.showRoomPins() + } label: { + Label("Show Pins", systemImage: "pin.circle") + } + .help("Show Pins") + .disabled(windowState.selectedRoomId == nil) - Button { - Logger.viewCycle.info("Show threads") - windowState.showRoomThreads() - } label: { - Label("Show Threads", systemImage: "list.bullet.circle") - } - .help("Show Threads") - .disabled(windowState.selectedRoomId == nil) + Button { + Logger.viewCycle.info("Show threads") + windowState.showRoomThreads() + } label: { + Label("Show Threads", systemImage: "list.bullet.circle") + } + .help("Show Threads") + .disabled(windowState.selectedRoomId == nil) - if #unavailable(macOS 26), !windowState.inspectorVisible { - HStack { - Divider() - } - } - } - } + if #unavailable(macOS 26), !windowState.inspectorVisible { + HStack { + Divider() + } + } + } + } } diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift index 4cfc90b..2bed23d 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift @@ -1,177 +1,180 @@ +import MatrixIntegration import MatrixRustSDK import OSLog import SwiftUI -import MatrixIntegration struct ChatInputView: View { - @Environment(\.accessibilityReduceTransparency) var reduceTransparency - - let room: Room - let timeline: LiveTimeline - @Binding var replyTo: MatrixRustSDK.EventTimelineItem? - @AppStorage("fontSize") var fontSize: Int = 13 - - @State private var isDraftLoaded: Bool = false - @State private var chatInput: String = "" - - func sendMessage() async { - guard !chatInput.isEmpty else { return } - guard let innerTimeline = timeline.timeline else { return } - - let msg = messageEventContentFromMarkdown(md: chatInput) - - do { - if let replyTo { - _ = try await innerTimeline.sendReply(msg: msg, eventId: replyTo.eventOrTransactionId.id) - } else { - _ = try await innerTimeline.send(msg: msg) - } - } catch { - Logger.viewCycle.error("failed to send message: \(error)") - } + @Environment(\.accessibilityReduceTransparency) var reduceTransparency + + let room: Room + let timeline: LiveTimeline + @Binding var replyTo: MatrixRustSDK.EventTimelineItem? + @AppStorage("fontSize") var fontSize: Int = 13 + + @State private var isDraftLoaded: Bool = false + @State private var chatInput: String = "" + + func sendMessage() async { + guard !chatInput.isEmpty else { return } + guard let innerTimeline = timeline.timeline else { return } + + let msg = messageEventContentFromMarkdown(md: chatInput) + + do { + if let replyTo { + _ = try await innerTimeline.sendReply(msg: msg, eventId: replyTo.eventOrTransactionId.id) + } else { + _ = try await innerTimeline.send(msg: msg) + } + } catch { + Logger.viewCycle.error("failed to send message: \(error)") + } - chatInput = "" - replyTo = nil + chatInput = "" + replyTo = nil + } + + private func saveDraft() async { + guard isDraftLoaded else { return } // avoid saving a draft hasn't yet been restored + if chatInput.isEmpty, replyTo == nil { + Logger.viewCycle.debug("clearing draft") + do { + try await room.clearComposerDraft(threadRoot: timeline.focusedThreadId) + } catch { + Logger.viewCycle.error("failed to clear draft: \(error)") + } + return } - private func saveDraft() async { - guard isDraftLoaded else { return } // avoid saving a draft hasn't yet been restored - if chatInput.isEmpty, replyTo == nil { - Logger.viewCycle.debug("clearing draft") - do { - try await room.clearComposerDraft(threadRoot: timeline.focusedThreadId) - } catch { - Logger.viewCycle.error("failed to clear draft: \(error)") - } - return + let draftType: ComposerDraftType + if let replyTo { + draftType = .reply(eventId: replyTo.eventOrTransactionId.id) + } else { + draftType = .newMessage + } + let draft = ComposerDraft( + plainText: chatInput, + htmlText: nil, + draftType: draftType, + attachments: [] + ) + do { + try await room.saveComposerDraft(draft: draft, threadRoot: timeline.focusedThreadId) + } catch { + Logger.viewCycle.error("failed save draft: \(error)") + } + } + + private func loadDraft() async { + guard !isDraftLoaded else { return } // don't load a draft more than once + do { + guard let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) + else { + // no draft to load + isDraftLoaded = true + return + } + chatInput = draft.plainText + switch draft.draftType { + case .reply(let eventId): + // we need a timeline to be able to populate the reply; return false so we can try again + guard let innerTimeline = timeline.timeline else { + isDraftLoaded = false + return } - let draftType: ComposerDraftType - if let replyTo { - draftType = .reply(eventId: replyTo.eventOrTransactionId.id) - } else { - draftType = .newMessage - } - let draft = ComposerDraft( - plainText: chatInput, - htmlText: nil, - draftType: draftType, - attachments: [] - ) do { - try await room.saveComposerDraft(draft: draft, threadRoot: timeline.focusedThreadId) + let item = try await innerTimeline.getEventTimelineItemByEventId(eventId: eventId) + timeline.sendReplyTo = item } catch { - Logger.viewCycle.error("failed save draft: \(error)") + Logger.viewCycle.error("failed to resolve reply target: \(error)") } + case .newMessage, .edit: + // nothing to do + isDraftLoaded = true + return + } + } catch { + Logger.viewCycle.error("failed to load draft: \(error)") } - - private func loadDraft() async { - guard !isDraftLoaded else { return } // don't load a draft more than once - do { - guard let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) else { - // no draft to load - isDraftLoaded = true - return - } - chatInput = draft.plainText - switch draft.draftType { - case .reply(eventId: let eventId): - // we need a timeline to be able to populate the reply; return false so we can try again - guard let innerTimeline = timeline.timeline else { - isDraftLoaded = false - return - } - - do { - let item = try await innerTimeline.getEventTimelineItemByEventId(eventId: eventId) - timeline.sendReplyTo = item - } catch { - Logger.viewCycle.error("failed to resolve reply target: \(error)") - } - case .newMessage, .edit: - // nothing to do - isDraftLoaded = true - return - } - } catch { - Logger.viewCycle.error("failed to load draft: \(error)") - } - isDraftLoaded = true // so we don't try again + isDraftLoaded = true // so we don't try again + } + + private func chatInputChanged() async { + guard isDraftLoaded else { return } // avoid working on a draft that's being restored + if !chatInput.isEmpty { + do { + try await room.typingNotice(isTyping: !chatInput.isEmpty) + } catch { + Logger.viewCycle.warning("Failed to send typing notice: \(error)") + } } - - private func chatInputChanged() async { - guard isDraftLoaded else { return } // avoid working on a draft that's being restored - if !chatInput.isEmpty { - do { - try await room.typingNotice(isTyping: !chatInput.isEmpty) - } catch { - Logger.viewCycle.warning("Failed to send typing notice: \(error)") - } + await saveDraft() + } + + var replyEmbeddedDetails: EmbeddedEventDetails? { + guard let replyTo else { return nil } + + return .ready( + content: replyTo.content, sender: replyTo.sender, senderProfile: replyTo.senderProfile, + timestamp: replyTo.timestamp, eventOrTransactionId: replyTo.eventOrTransactionId) + } + + var content: some View { + VStack(alignment: .leading) { + if let replyEmbeddedDetails { + EmbeddedMessageView(embeddedEvent: replyEmbeddedDetails) { + replyTo = nil } - await saveDraft() + } + ChatTextView( + text: $chatInput, + placeholder: "Message \(room.displayName() ?? "room")", + disabled: !isDraftLoaded, + onSubmit: { Task { await sendMessage() } } + ) } - - var replyEmbeddedDetails: EmbeddedEventDetails? { - guard let replyTo else { return nil } - - return .ready(content: replyTo.content, sender: replyTo.sender, senderProfile: replyTo.senderProfile, timestamp: replyTo.timestamp, eventOrTransactionId: replyTo.eventOrTransactionId) + .font(.system(size: .init(fontSize))) + .task(id: chatInput) { + await chatInputChanged() } - - var content: some View { - VStack(alignment: .leading) { - if let replyEmbeddedDetails { - EmbeddedMessageView(embeddedEvent: replyEmbeddedDetails) { - replyTo = nil - } - } - ChatTextView( - text: $chatInput, - placeholder: "Message \(room.displayName() ?? "room")", - disabled: !isDraftLoaded, - onSubmit: { Task { await sendMessage() } } - ) - } - .font(.system(size: .init(fontSize))) - .task(id: chatInput) { - await chatInputChanged() - } - .task(id: replyTo?.eventOrTransactionId) { - await saveDraft() - } - .task(id: timeline.timeline != nil) { - // we need the timeline to be populated before we load a draft - // (in case the draft holds a reply) - await loadDraft() - } + .task(id: replyTo?.eventOrTransactionId) { + await saveDraft() } - - @available(macOS 26.0, *) - var tahoeView: some View { - content - .glassEffect(in: .rect(cornerRadius: 16.0)) - .padding(.horizontal) - .padding(.bottom, 10) + .task(id: timeline.timeline != nil) { + // we need the timeline to be populated before we load a draft + // (in case the draft holds a reply) + await loadDraft() } - - var oldView: some View { - content - .background(Color(NSColor.textBackgroundColor)) - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 16.0) - .stroke(Color(NSColor.separatorColor), lineWidth: 1) - ) - .padding(.horizontal) - .padding(.bottom, 10) - } - - var body: some View { - if #available(macOS 26.0, *), !reduceTransparency { - tahoeView - } else { - oldView - } + } + + @available(macOS 26.0, *) + var tahoeView: some View { + content + .glassEffect(in: .rect(cornerRadius: 16.0)) + .padding(.horizontal) + .padding(.bottom, 10) + } + + var oldView: some View { + content + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 16.0) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + .padding(.horizontal) + .padding(.bottom, 10) + } + + var body: some View { + if #available(macOS 26.0, *), !reduceTransparency { + tahoeView + } else { + oldView } + } } /* #Preview { diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift index 982cec1..e70e5d1 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift @@ -3,172 +3,172 @@ import OSLog import SwiftUI struct ChatTextView: NSViewRepresentable { - typealias NSViewRepresentableType = NSTextView + typealias NSViewRepresentableType = NSTextView - @AppStorage("fontSize") var fontSize: Int = 13 + @AppStorage("fontSize") var fontSize: Int = 13 - let text: Binding - let placeholder: String - let disabled: Bool - let onSubmit: () -> Void + let text: Binding + let placeholder: String + let disabled: Bool + let onSubmit: () -> Void - func makeNSView(context: Context) -> DynamicTextView { - let textView = DynamicTextView() + func makeNSView(context: Context) -> DynamicTextView { + let textView = DynamicTextView() - textView.onSubmit = onSubmit + textView.onSubmit = onSubmit - textView.placeholderAttributedString = NSAttributedString( - string: placeholder, - attributes: [ - .foregroundColor: NSColor.secondaryLabelColor, - .font: NSFont.systemFont(ofSize: CGFloat(fontSize)) - ] - ) + textView.placeholderAttributedString = NSAttributedString( + string: placeholder, + attributes: [ + .foregroundColor: NSColor.secondaryLabelColor, + .font: NSFont.systemFont(ofSize: CGFloat(fontSize)), + ] + ) - textView.backgroundColor = .clear - textView.drawsBackground = false + textView.backgroundColor = .clear + textView.drawsBackground = false - context.coordinator.textView = textView - textView.delegate = context.coordinator + context.coordinator.textView = textView + textView.delegate = context.coordinator - textView.textContainerInset = DynamicTextView.padding + textView.textContainerInset = DynamicTextView.padding - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false - unsafe textView.textContainer?.widthTracksTextView = true + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + unsafe textView.textContainer?.widthTracksTextView = true - textView.setContentHuggingPriority(.required, for: .vertical) - textView.setContentCompressionResistancePriority(.required, for: .vertical) + textView.setContentHuggingPriority(.required, for: .vertical) + textView.setContentCompressionResistancePriority(.required, for: .vertical) - textView.font = NSFont.systemFont(ofSize: CGFloat(fontSize)) + textView.font = NSFont.systemFont(ofSize: CGFloat(fontSize)) - return textView + return textView + } + + func updateNSView(_ textView: DynamicTextView, context: Context) { + context.coordinator.text = text + + textView.onSubmit = onSubmit + + if textView.string != text.wrappedValue { + textView.string = text.wrappedValue + textView.invalidateIntrinsicContentSize() } - func updateNSView(_ textView: DynamicTextView, context: Context) { - context.coordinator.text = text - - textView.onSubmit = onSubmit - - if textView.string != text.wrappedValue { - textView.string = text.wrappedValue - textView.invalidateIntrinsicContentSize() - } - - let currentPlaceholderFont = - unsafe textView.placeholderAttributedString? - .attribute( - .font, - at: 0, - effectiveRange: nil - ) as? NSFont - let placeholderFontChanged = - currentPlaceholderFont?.pointSize != CGFloat(fontSize) - if textView.placeholderAttributedString?.string != placeholder || - placeholderFontChanged - { - textView.placeholderAttributedString = NSAttributedString( - string: placeholder, - attributes: [ - .foregroundColor: NSColor.secondaryLabelColor, - .font: NSFont.systemFont(ofSize: CGFloat(fontSize)) - ] - ) - } - - if textView.isEditable != !disabled { - textView.isEditable = !disabled - textView.isSelectable = !disabled - textView.alphaValue = !disabled ? 1.0 : 0.5 - - if disabled { - // resign first responder - unsafe textView.window?.makeFirstResponder(nil) - } - } - - let currentFont = NSFont.systemFont(ofSize: CGFloat(fontSize)) - if textView.font?.pointSize != currentFont.pointSize { - textView.font = currentFont - textView.invalidateIntrinsicContentSize() - } + let currentPlaceholderFont = + unsafe textView.placeholderAttributedString? + .attribute( + .font, + at: 0, + effectiveRange: nil + ) as? NSFont + let placeholderFontChanged = + currentPlaceholderFont?.pointSize != CGFloat(fontSize) + if textView.placeholderAttributedString?.string != placeholder || placeholderFontChanged { + textView.placeholderAttributedString = NSAttributedString( + string: placeholder, + attributes: [ + .foregroundColor: NSColor.secondaryLabelColor, + .font: NSFont.systemFont(ofSize: CGFloat(fontSize)), + ] + ) } - func makeCoordinator() -> Coordinator { - return Coordinator(text: text) + if textView.isEditable != !disabled { + textView.isEditable = !disabled + textView.isSelectable = !disabled + textView.alphaValue = !disabled ? 1.0 : 0.5 + + if disabled { + // resign first responder + unsafe textView.window?.makeFirstResponder(nil) + } } - class Coordinator: NSObject, NSTextViewDelegate { - var textView: NSTextView? - var text: Binding + let currentFont = NSFont.systemFont(ofSize: CGFloat(fontSize)) + if textView.font?.pointSize != currentFont.pointSize { + textView.font = currentFont + textView.invalidateIntrinsicContentSize() + } + } - init(text: Binding) { - self.text = text - } + func makeCoordinator() -> Coordinator { + return Coordinator(text: text) + } - func textDidChange(_ notification: Notification) { - guard let textView else { return } + class Coordinator: NSObject, NSTextViewDelegate { + var textView: NSTextView? + var text: Binding - text.wrappedValue = textView.string - } + init(text: Binding) { + self.text = text } + + func textDidChange(_ notification: Notification) { + guard let textView else { return } + + text.wrappedValue = textView.string + } + } } class DynamicTextView: NSTextView { - @objc var placeholderAttributedString: NSAttributedString? + @objc var placeholderAttributedString: NSAttributedString? - static let padding = NSSize(width: 10, height: 10) + static let padding = NSSize(width: 10, height: 10) - var onSubmit: (() -> Void)? + var onSubmit: (() -> Void)? - override var intrinsicContentSize: NSSize { - guard let container = unsafe textContainer, let manager = unsafe layoutManager else { - return .zero - } + override var intrinsicContentSize: NSSize { + guard let container = unsafe textContainer, let manager = unsafe layoutManager else { + return .zero + } - // Force the layout for the current width - manager.ensureLayout(for: container) - let usedRect = manager.usedRect(for: container) + // Force the layout for the current width + manager.ensureLayout(for: container) + let usedRect = manager.usedRect(for: container) - // Return a flexible width but a fixed height based on text - return NSSize(width: NSView.noIntrinsicMetric, height: ceil(usedRect.height) + CGFloat(2 * Self.padding.height)) - } + // Return a flexible width but a fixed height based on text + return NSSize( + width: NSView.noIntrinsicMetric, + height: ceil(usedRect.height) + CGFloat(2 * Self.padding.height)) + } - override func setFrameSize(_ newSize: NSSize) { - let oldWidth = frame.width - super.setFrameSize(newSize) + override func setFrameSize(_ newSize: NSSize) { + let oldWidth = frame.width + super.setFrameSize(newSize) - if oldWidth != newSize.width { - invalidateIntrinsicContentSize() - } + if oldWidth != newSize.width { + invalidateIntrinsicContentSize() } - - override func didChangeText() { - super.didChangeText() - invalidateIntrinsicContentSize() + } + + override func didChangeText() { + super.didChangeText() + invalidateIntrinsicContentSize() + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Always submit on cmd+enter + if (event.specialKey == .enter || event.specialKey == .carriageReturn) + && event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] + { + onSubmit?() + return true } - override func performKeyEquivalent(with event: NSEvent) -> Bool { - // Always submit on cmd+enter - if (event.specialKey == .enter || event.specialKey == .carriageReturn) - && event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] - { - onSubmit?() - return true - } + return super.performKeyEquivalent(with: event) + } - return super.performKeyEquivalent(with: event) + override func keyDown(with event: NSEvent) { + // Handle enter as submit instead of newline + if event.specialKey == .enter || event.specialKey == .carriageReturn, + event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [] + { + onSubmit?() + return } - override func keyDown(with event: NSEvent) { - // Handle enter as submit instead of newline - if event.specialKey == .enter || event.specialKey == .carriageReturn, - event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [] - { - onSubmit?() - return - } - - super.keyDown(with: event) - } + super.keyDown(with: event) + } } diff --git a/Sources/MactrixApp/Views/ChatView/ChatView.swift b/Sources/MactrixApp/Views/ChatView/ChatView.swift index 1df89c7..88cef63 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatView.swift @@ -1,126 +1,129 @@ -import MatrixRustSDK +import MatrixIntegration import MatrixProtocols +import MatrixRustSDK import OSLog import SwiftUI -import MatrixIntegration private struct TimelineReadKey: Equatable { - let count: Int - let active: Bool + let count: Int + let active: Bool } struct ChatJoinedRoom: View { - @Environment(AppState.self) private var appState - @Environment(\.appearsActive) private var appearsActive - @Bindable var timeline: LiveTimeline + @Environment(AppState.self) private var appState + @Environment(\.appearsActive) private var appearsActive + @Bindable var timeline: LiveTimeline - var room: LiveRoom { - timeline.room - } + var room: LiveRoom { + timeline.room + } - var toolbarSubtitle: String { - guard let topic = room.room.topic() else { return "" } - let firstLine = topic.split(whereSeparator: \.isNewline).first ?? "" - return String(firstLine) - } + var toolbarSubtitle: String { + guard let topic = room.room.topic() else { return "" } + let firstLine = topic.split(whereSeparator: \.isNewline).first ?? "" + return String(firstLine) + } - var body: some View { - TimelineViewRepresentable(timeline: timeline) - .ignoresSafeArea(edges: .top) - .safeAreaInset(edge: .bottom, spacing: 8) { - ChatInputView(room: room.room, timeline: timeline, replyTo: $timeline.sendReplyTo) - } - .background(Color(NSColor.controlBackgroundColor)) - .navigationTitle(room.room.displayName() ?? "Unknown room") - .navigationSubtitle(toolbarSubtitle) - .frame(minWidth: 250, minHeight: 200) - .task(priority: .background) { - do { - try await Task.sleep(for: .seconds(2)) - try await appState.matrixClient?.client.trackRecentlyVisitedRoom(room: timeline.room.id) - Logger.viewCycle.debug("marked room as recently visited: \(timeline.room.id)") - } catch is CancellationError { - /* sleep cancelled */ - } catch { - Logger.viewCycle.error("failed to mark room as recently visited: \(error)") - } - } - .task(id: TimelineReadKey(count: timeline.timelineItems.count, active: appearsActive), priority: .background) { - guard appearsActive else { return } - do { - try await Task.sleep(for: .seconds(1)) - Logger.viewCycle.debug("Mark room as read") - try await timeline.timeline?.markAsRead(receiptType: .read) - } catch is CancellationError { - /* sleep cancelled */ - } catch { - Logger.viewCycle.error("failed to send timeline read receipt: \(error)") - } - } - .onDisappear { - Task { - guard let timeline = timeline.timeline else { return } - do { - Logger.viewCycle.info("Unfocusing room, marking it as read") - try await timeline.markAsRead(receiptType: .fullyRead) - } catch { - Logger.viewCycle.error("Failed to mark room as read: \(error)") - } - } - } - } + var body: some View { + TimelineViewRepresentable(timeline: timeline) + .ignoresSafeArea(edges: .top) + .safeAreaInset(edge: .bottom, spacing: 8) { + ChatInputView(room: room.room, timeline: timeline, replyTo: $timeline.sendReplyTo) + } + .background(Color(NSColor.controlBackgroundColor)) + .navigationTitle(room.room.displayName() ?? "Unknown room") + .navigationSubtitle(toolbarSubtitle) + .frame(minWidth: 250, minHeight: 200) + .task(priority: .background) { + do { + try await Task.sleep(for: .seconds(2)) + try await appState.matrixClient?.client.trackRecentlyVisitedRoom(room: timeline.room.id) + Logger.viewCycle.debug("marked room as recently visited: \(timeline.room.id)") + } catch is CancellationError { + /* sleep cancelled */ + } catch { + Logger.viewCycle.error("failed to mark room as recently visited: \(error)") + } + } + .task( + id: TimelineReadKey(count: timeline.timelineItems.count, active: appearsActive), + priority: .background + ) { + guard appearsActive else { return } + do { + try await Task.sleep(for: .seconds(1)) + Logger.viewCycle.debug("Mark room as read") + try await timeline.timeline?.markAsRead(receiptType: .read) + } catch is CancellationError { + /* sleep cancelled */ + } catch { + Logger.viewCycle.error("failed to send timeline read receipt: \(error)") + } + } + .onDisappear { + Task { + guard let timeline = timeline.timeline else { return } + do { + Logger.viewCycle.info("Unfocusing room, marking it as read") + try await timeline.markAsRead(receiptType: .fullyRead) + } catch { + Logger.viewCycle.error("Failed to mark room as read: \(error)") + } + } + } + } } struct ChatView: View { - @Bindable var timeline: LiveTimeline + @Bindable var timeline: LiveTimeline - var room: LiveRoom { - timeline.room - } + var room: LiveRoom { + timeline.room + } - init(timeline: LiveTimeline) { - self.timeline = timeline - } + init(timeline: LiveTimeline) { + self.timeline = timeline + } - @ViewBuilder - var invitedRoom: some View { - Text("Invited to room") - .font(.title) + @ViewBuilder + var invitedRoom: some View { + Text("Invited to room") + .font(.title) - Button("Accept Invite") { - Task { - do { - try await room.room.join() - } catch { - Logger.viewCycle.error("failed to join: \(error)") - } - } + Button("Accept Invite") { + Task { + do { + try await room.room.join() + } catch { + Logger.viewCycle.error("failed to join: \(error)") } + } + } - Button("Reject Invite") { - Task { - do { - try await room.room.leave() - try await room.room.forget() - } catch { - Logger.viewCycle.error("failed to leave room: \(error)") - } - } + Button("Reject Invite") { + Task { + do { + try await room.room.leave() + try await room.room.forget() + } catch { + Logger.viewCycle.error("failed to leave room: \(error)") } + } } + } - var body: some View { - switch room.room.membership() { - case .joined: - ChatJoinedRoom(timeline: timeline) - case .invited: - invitedRoom - case .left: - Text("You have left this room") - case .knocked: - Text("You have knocked to ask to join this room") - case .banned: - Text("You are banned from this room") - } + var body: some View { + switch room.room.membership() { + case .joined: + ChatJoinedRoom(timeline: timeline) + case .invited: + invitedRoom + case .left: + Text("You have left this room") + case .knocked: + Text("You have knocked to ask to join this room") + case .banned: + Text("You are banned from this room") } + } } diff --git a/Sources/MactrixApp/Views/ChatView/EmbeddedMessageView.swift b/Sources/MactrixApp/Views/ChatView/EmbeddedMessageView.swift index d48bc60..55bb941 100644 --- a/Sources/MactrixApp/Views/ChatView/EmbeddedMessageView.swift +++ b/Sources/MactrixApp/Views/ChatView/EmbeddedMessageView.swift @@ -1,32 +1,32 @@ -import MatrixRustSDK -import SwiftUI import MactrixUI import MatrixIntegration +import MatrixRustSDK +import SwiftUI struct EmbeddedMessageView: View { - let embeddedEvent: MatrixRustSDK.EmbeddedEventDetails - let action: () -> Void + let embeddedEvent: MatrixRustSDK.EmbeddedEventDetails + let action: () -> Void - var body: some View { - switch embeddedEvent { - case .unavailable, .pending: - MactrixUI.MessageReplyView( - username: "loading@username.org", - message: "Phasellus sit amet purus ac enim semper convallis. Nullam a gravida libero.", - action: action - ) - .redacted(reason: .placeholder) - case let .ready(content, sender, senderProfile, _, _): - MactrixUI.MessageReplyView( - username: { - if case let .ready(name, _, _, _, _) = senderProfile, let name { return name } - return sender - }(), - message: content.userInterfaceText, - action: action - ) - case let .error(message): - Text("error: \(message)") - } + var body: some View { + switch embeddedEvent { + case .unavailable, .pending: + MactrixUI.MessageReplyView( + username: "loading@username.org", + message: "Phasellus sit amet purus ac enim semper convallis. Nullam a gravida libero.", + action: action + ) + .redacted(reason: .placeholder) + case .ready(let content, let sender, let senderProfile, _, _): + MactrixUI.MessageReplyView( + username: { + if case .ready(let name, _, _, _, _) = senderProfile, let name { return name } + return sender + }(), + message: content.userInterfaceText, + action: action + ) + case .error(let message): + Text("error: \(message)") } + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift index 9efb9ef..0a9b0fe 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift @@ -1,51 +1,51 @@ import AppKit -import MatrixRustSDK import MatrixIntegration +import MatrixRustSDK /// Content retained by a message row across NSTableView reuse. protocol MessageContentRowView: NSView { - var onSelectRequest: (() -> Void)? { get set } - var onArrowKey: ((TimelineSelectionDirection) -> Void)? { get set } - func configure(content: MatrixRustSDK.MsgLikeContent, matrixClient: MatrixClient?) - func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat) -> CGFloat + var onSelectRequest: (() -> Void)? { get set } + var onArrowKey: ((TimelineSelectionDirection) -> Void)? { get set } + func configure(content: MatrixRustSDK.MsgLikeContent, matrixClient: MatrixClient?) + func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat) -> CGFloat } /// Determines the reusable row and its retained content view for a message. @MainActor enum MessageContentKind: Hashable { - case text - case image - case video - case file + case text + case image + case video + case file - init(content: MatrixRustSDK.MsgLikeContent) { - if MessageImageContentView.supports(content: content) { - self = .image - } else if MessageVideoContentView.supports(content: content) { - self = .video - } else if MessageFileContentView.supports(content: content) { - self = .file - } else { - self = .text - } + init(content: MatrixRustSDK.MsgLikeContent) { + if MessageImageContentView.supports(content: content) { + self = .image + } else if MessageVideoContentView.supports(content: content) { + self = .video + } else if MessageFileContentView.supports(content: content) { + self = .file + } else { + self = .text } + } - var reuseIdentifier: NSUserInterfaceItemIdentifier { - switch self { - case .text: .init("message.text") - case .image: .init("message.image") - case .video: .init("message.video") - case .file: .init("message.file") - } + var reuseIdentifier: NSUserInterfaceItemIdentifier { + switch self { + case .text: .init("message.text") + case .image: .init("message.image") + case .video: .init("message.video") + case .file: .init("message.file") } + } - func makeRowView() -> MessageRowView { - let contentView: any MessageContentRowView - switch self { - case .text: contentView = MessageTextContentView() - case .image: contentView = MessageImageContentView() - case .video: contentView = MessageVideoContentView() - case .file: contentView = MessageFileContentView() - } - return MessageRowView(contentKind: self, contentView: contentView) + func makeRowView() -> MessageRowView { + let contentView: any MessageContentRowView + switch self { + case .text: contentView = MessageTextContentView() + case .image: contentView = MessageImageContentView() + case .video: contentView = MessageVideoContentView() + case .file: contentView = MessageFileContentView() } + return MessageRowView(contentKind: self, contentView: contentView) + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift index 0f19f39..fe1c620 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift @@ -1,195 +1,201 @@ import AppKit +import MatrixIntegration import MatrixRustSDK import OSLog import UniformTypeIdentifiers -import MatrixIntegration final class MessageFileContentView: NSView, MessageContentRowView { - var onSelectRequest: (() -> Void)? { - didSet { captionView.onSelectRequest = onSelectRequest } - } - - var onArrowKey: ((TimelineSelectionDirection) -> Void)? { - didSet { captionView.onArrowKey = onArrowKey } + var onSelectRequest: (() -> Void)? { + didSet { captionView.onSelectRequest = onSelectRequest } + } + + var onArrowKey: ((TimelineSelectionDirection) -> Void)? { + didSet { captionView.onArrowKey = onArrowKey } + } + + private let fileButton = FilePreviewButton() + private let captionView = MessageTextContentView() + private var fileBottomConstraint: NSLayoutConstraint! + private var captionBottomConstraint: NSLayoutConstraint! + private var fileContent: FileMessageContent? + private weak var matrixClient: MatrixClient? + private var sourceURL: String? + private var previewTask: Task? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + fileButton.target = self + fileButton.action = #selector(previewFile) + + for view in [fileButton, captionView] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) } - private let fileButton = FilePreviewButton() - private let captionView = MessageTextContentView() - private var fileBottomConstraint: NSLayoutConstraint! - private var captionBottomConstraint: NSLayoutConstraint! - private var fileContent: FileMessageContent? - private weak var matrixClient: MatrixClient? - private var sourceURL: String? - private var previewTask: Task? - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - fileButton.target = self - fileButton.action = #selector(previewFile) - - for view in [fileButton, captionView] { - view.translatesAutoresizingMaskIntoConstraints = false - addSubview(view) - } - - fileBottomConstraint = fileButton.bottomAnchor.constraint(equalTo: bottomAnchor) - captionBottomConstraint = captionView.bottomAnchor.constraint(equalTo: bottomAnchor) - fileBottomConstraint.priority = .init(999) - captionBottomConstraint.priority = .init(999) - NSLayoutConstraint.activate([ - fileButton.leadingAnchor.constraint(equalTo: leadingAnchor), - fileButton.trailingAnchor.constraint(equalTo: trailingAnchor), - fileButton.topAnchor.constraint(equalTo: topAnchor), - fileButton.heightAnchor.constraint(equalToConstant: 36), - captionView.leadingAnchor.constraint(equalTo: leadingAnchor), - captionView.trailingAnchor.constraint(equalTo: trailingAnchor), - captionView.topAnchor.constraint(equalTo: fileButton.bottomAnchor, constant: 10), - ]) + fileBottomConstraint = fileButton.bottomAnchor.constraint(equalTo: bottomAnchor) + captionBottomConstraint = captionView.bottomAnchor.constraint(equalTo: bottomAnchor) + fileBottomConstraint.priority = .init(999) + captionBottomConstraint.priority = .init(999) + NSLayoutConstraint.activate([ + fileButton.leadingAnchor.constraint(equalTo: leadingAnchor), + fileButton.trailingAnchor.constraint(equalTo: trailingAnchor), + fileButton.topAnchor.constraint(equalTo: topAnchor), + fileButton.heightAnchor.constraint(equalToConstant: 36), + captionView.leadingAnchor.constraint(equalTo: leadingAnchor), + captionView.trailingAnchor.constraint(equalTo: trailingAnchor), + captionView.topAnchor.constraint(equalTo: fileButton.bottomAnchor, constant: 10), + ]) + } + + static func supports(content: MsgLikeContent) -> Bool { + guard case .message(let message) = content.kind else { return false } + if case .file = message.msgType { + return true } - - static func supports(content: MsgLikeContent) -> Bool { - guard case let .message(message) = content.kind else { return false } - if case .file = message.msgType { - return true - } - return false + return false + } + + func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { + guard case .message(let message) = content.kind, + case .file(let file) = message.msgType + else { return } + + fileContent = file + self.matrixClient = matrixClient + let mimeType = file.info?.mimetype.flatMap(UTType.init) ?? .data + fileButton.configure( + icon: NSWorkspace.shared.icon(for: mimeType), + filename: file.filename, + size: file.info?.size?.formatted(.byteCount(style: .file)) + ) + + let hasCaption = file.caption?.isEmpty == false || file.formattedCaption != nil + captionView.isHidden = !hasCaption + captionView.configureCaption(file.caption, formatted: file.formattedCaption) + fileBottomConstraint.isActive = !hasCaption + captionBottomConstraint.isActive = hasCaption + + let url = file.source.url() + if sourceURL != url { + previewTask?.cancel() + previewTask = nil + sourceURL = url + fileButton.isEnabled = true } - - func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { - guard case let .message(message) = content.kind, - case let .file(file) = message.msgType else { return } - - fileContent = file - self.matrixClient = matrixClient - let mimeType = file.info?.mimetype.flatMap(UTType.init) ?? .data - fileButton.configure( - icon: NSWorkspace.shared.icon(for: mimeType), - filename: file.filename, - size: file.info?.size?.formatted(.byteCount(style: .file)) + } + + func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { + guard case .message(let message) = content.kind, + case .file(let file) = message.msgType + else { return 0 } + guard file.caption?.isEmpty == false || file.formattedCaption != nil else { return 36 } + return 36 + 10 + + ceil( + captionView.height(forCaption: file.caption, formatted: file.formattedCaption, width: width) + ) + } + + @objc private func previewFile() { + guard let fileContent, let matrixClient else { return } + let url = fileContent.source.url() + fileButton.isEnabled = false + previewTask?.cancel() + previewTask = Task { [weak self] in + do { + let handle = try await matrixClient.client.getMediaFile( + mediaSource: fileContent.source, + filename: fileContent.filename, + mimeType: fileContent.info?.mimetype ?? "", + useCache: true, + tempDir: NSTemporaryDirectory() ) - - let hasCaption = file.caption?.isEmpty == false || file.formattedCaption != nil - captionView.isHidden = !hasCaption - captionView.configureCaption(file.caption, formatted: file.formattedCaption) - fileBottomConstraint.isActive = !hasCaption - captionBottomConstraint.isActive = hasCaption - - let url = file.source.url() - if sourceURL != url { - previewTask?.cancel() - previewTask = nil - sourceURL = url - fileButton.isEnabled = true - } - } - - func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { - guard case let .message(message) = content.kind, - case let .file(file) = message.msgType else { return 0 } - guard file.caption?.isEmpty == false || file.formattedCaption != nil else { return 36 } - return 36 + 10 + ceil(captionView.height(forCaption: file.caption, formatted: file.formattedCaption, width: width)) - } - - @objc private func previewFile() { - guard let fileContent, let matrixClient else { return } - let url = fileContent.source.url() - fileButton.isEnabled = false - previewTask?.cancel() - previewTask = Task { [weak self] in - do { - let handle = try await matrixClient.client.getMediaFile( - mediaSource: fileContent.source, - filename: fileContent.filename, - mimeType: fileContent.info?.mimetype ?? "", - useCache: true, - tempDir: NSTemporaryDirectory() - ) - try Task.checkCancellation() - let path = try handle.path() - guard let self, self.sourceURL == url else { return } - MediaQuickLookPreview.shared.show(handle: handle, url: URL(filePath: path, directoryHint: .notDirectory)) - self.fileButton.setIcon(NSWorkspace.shared.icon(forFile: path)) - self.fileButton.isEnabled = true - self.previewTask = nil - } catch is CancellationError { - return - } catch { - guard let self, self.sourceURL == url else { return } - Logger.viewCycle.error("failed to preview file: \(error)") - self.fileButton.isEnabled = true - self.previewTask = nil - } - } + try Task.checkCancellation() + let path = try handle.path() + guard let self, self.sourceURL == url else { return } + MediaQuickLookPreview.shared.show( + handle: handle, url: URL(filePath: path, directoryHint: .notDirectory)) + self.fileButton.setIcon(NSWorkspace.shared.icon(forFile: path)) + self.fileButton.isEnabled = true + self.previewTask = nil + } catch is CancellationError { + return + } catch { + guard let self, self.sourceURL == url else { return } + Logger.viewCycle.error("failed to preview file: \(error)") + self.fileButton.isEnabled = true + self.previewTask = nil + } } + } - deinit { previewTask?.cancel() } + deinit { previewTask?.cancel() } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } private final class FilePreviewButton: NSButton { - private let iconView = NSImageView() - private let filenameLabel = NSTextField(labelWithString: "") - private let sizeLabel = NSTextField(labelWithString: "") - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - isBordered = false - title = "" - iconView.imageScaling = .scaleProportionallyDown - filenameLabel.lineBreakMode = .byTruncatingMiddle - filenameLabel.maximumNumberOfLines = 1 - sizeLabel.textColor = .secondaryLabelColor - sizeLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) - - for view in [iconView, filenameLabel, sizeLabel] { - view.translatesAutoresizingMaskIntoConstraints = false - addSubview(view) - } - NSLayoutConstraint.activate([ - iconView.leadingAnchor.constraint(equalTo: leadingAnchor), - iconView.topAnchor.constraint(equalTo: topAnchor), - iconView.widthAnchor.constraint(equalToConstant: 36), - iconView.heightAnchor.constraint(equalToConstant: 36), - filenameLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8), - filenameLabel.trailingAnchor.constraint(equalTo: trailingAnchor), - filenameLabel.topAnchor.constraint(equalTo: topAnchor), - sizeLabel.leadingAnchor.constraint(equalTo: filenameLabel.leadingAnchor), - sizeLabel.trailingAnchor.constraint(equalTo: filenameLabel.trailingAnchor), - sizeLabel.topAnchor.constraint(equalTo: filenameLabel.bottomAnchor, constant: 2), - ]) - } - - func configure(icon: NSImage, filename: String, size: String?) { - iconView.image = icon - filenameLabel.stringValue = filename - sizeLabel.stringValue = size ?? "" - sizeLabel.isHidden = size == nil - toolTip = "Preview \(filename)" - } - - func setIcon(_ icon: NSImage) { - iconView.image = icon - } - - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(convert(point, from: superview)) ? self : nil - } - - override func mouseDown(with event: NSEvent) { - alphaValue = 0.6 - window?.displayIfNeeded() - super.mouseDown(with: event) - alphaValue = 1 - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + private let iconView = NSImageView() + private let filenameLabel = NSTextField(labelWithString: "") + private let sizeLabel = NSTextField(labelWithString: "") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + isBordered = false + title = "" + iconView.imageScaling = .scaleProportionallyDown + filenameLabel.lineBreakMode = .byTruncatingMiddle + filenameLabel.maximumNumberOfLines = 1 + sizeLabel.textColor = .secondaryLabelColor + sizeLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + + for view in [iconView, filenameLabel, sizeLabel] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) } + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: leadingAnchor), + iconView.topAnchor.constraint(equalTo: topAnchor), + iconView.widthAnchor.constraint(equalToConstant: 36), + iconView.heightAnchor.constraint(equalToConstant: 36), + filenameLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8), + filenameLabel.trailingAnchor.constraint(equalTo: trailingAnchor), + filenameLabel.topAnchor.constraint(equalTo: topAnchor), + sizeLabel.leadingAnchor.constraint(equalTo: filenameLabel.leadingAnchor), + sizeLabel.trailingAnchor.constraint(equalTo: filenameLabel.trailingAnchor), + sizeLabel.topAnchor.constraint(equalTo: filenameLabel.bottomAnchor, constant: 2), + ]) + } + + func configure(icon: NSImage, filename: String, size: String?) { + iconView.image = icon + filenameLabel.stringValue = filename + sizeLabel.stringValue = size ?? "" + sizeLabel.isHidden = size == nil + toolTip = "Preview \(filename)" + } + + func setIcon(_ icon: NSImage) { + iconView.image = icon + } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + alphaValue = 0.6 + window?.displayIfNeeded() + super.mouseDown(with: event) + alphaValue = 1 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift index e4e1b98..1b891f1 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift @@ -2,224 +2,229 @@ import AppKit /// The action bar shown above the hovered message row. final class MessageHoverOverlayView: NSView { - enum Action { - case reaction(String) - case reactionPicker - case reply - case replyInThread - case pin - } - - var onAction: ((Action) -> Void)? - var onMouseExited: ((NSEvent) -> Void)? - - private let stack = NSStackView() - private let replyButton = HoverActionButton(symbol: "arrowshape.turn.up.left", label: "Reply") - private let threadButton = HoverActionButton(symbol: "ellipsis.message", label: "Reply in thread") - - init() { - super.init(frame: .zero) - - wantsLayer = true - layer?.cornerRadius = 4 - layer?.borderWidth = 1 - updateAppearance() - shadow = { - let shadow = NSShadow() - shadow.shadowColor = NSColor.black.withAlphaComponent(0.1) - shadow.shadowBlurRadius = 4 - shadow.shadowOffset = .zero - return shadow - }() - - stack.orientation = .horizontal - stack.alignment = .centerY - stack.spacing = 0 - stack.detachesHiddenViews = true - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), - stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2), - stack.topAnchor.constraint(equalTo: topAnchor), - stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - - for emoji in ["👍", "🎉", "❤️"] { - let button = HoverActionButton(emoji: emoji, label: "React with \(emoji)") - button.onClick = { [weak self] in self?.onAction?(.reaction(emoji)) } - stack.addArrangedSubview(button) - } - - let dividerContainer = NSView() - dividerContainer.translatesAutoresizingMaskIntoConstraints = false - dividerContainer.widthAnchor.constraint(equalToConstant: 6).isActive = true - dividerContainer.heightAnchor.constraint(equalToConstant: 28).isActive = true - let divider = NSBox() - divider.boxType = .separator - divider.translatesAutoresizingMaskIntoConstraints = false - dividerContainer.addSubview(divider) - NSLayoutConstraint.activate([ - divider.centerXAnchor.constraint(equalTo: dividerContainer.centerXAnchor), - divider.centerYAnchor.constraint(equalTo: dividerContainer.centerYAnchor), - divider.heightAnchor.constraint(equalToConstant: 18), - ]) - stack.addArrangedSubview(dividerContainer) - - let reactionPicker = HoverActionButton(symbol: "face.smiling", label: "React") - reactionPicker.onClick = { [weak self] in self?.onAction?(.reactionPicker) } - stack.addArrangedSubview(reactionPicker) - - replyButton.onClick = { [weak self] in self?.onAction?(.reply) } - threadButton.onClick = { [weak self] in self?.onAction?(.replyInThread) } - stack.addArrangedSubview(replyButton) - stack.addArrangedSubview(threadButton) - - let pinButton = HoverActionButton(symbol: "pin", label: "Pin") - pinButton.onClick = { [weak self] in self?.onAction?(.pin) } - stack.addArrangedSubview(pinButton) - - addTrackingArea(NSTrackingArea( - rect: .zero, - options: [.mouseEnteredAndExited, .mouseMoved, .cursorUpdate, .activeInKeyWindow, .inVisibleRect], - owner: self, - userInfo: nil - )) - - configure(canReply: true) - } - - func configure(canReply: Bool) { - replyButton.isHidden = !canReply - threadButton.isHidden = !canReply - setFrameSize(fittingSize) - } - - override func cursorUpdate(with event: NSEvent) { - NSCursor.arrow.set() - } - - override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - updateAppearance() - } - - private func updateAppearance() { - layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor - layer?.borderColor = NSColor.separatorColor.cgColor - } - - override func mouseExited(with event: NSEvent) { - onMouseExited?(event) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + enum Action { + case reaction(String) + case reactionPicker + case reply + case replyInThread + case pin + } + + var onAction: ((Action) -> Void)? + var onMouseExited: ((NSEvent) -> Void)? + + private let stack = NSStackView() + private let replyButton = HoverActionButton(symbol: "arrowshape.turn.up.left", label: "Reply") + private let threadButton = HoverActionButton(symbol: "ellipsis.message", label: "Reply in thread") + + init() { + super.init(frame: .zero) + + wantsLayer = true + layer?.cornerRadius = 4 + layer?.borderWidth = 1 + updateAppearance() + shadow = { + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.1) + shadow.shadowBlurRadius = 4 + shadow.shadowOffset = .zero + return shadow + }() + + stack.orientation = .horizontal + stack.alignment = .centerY + stack.spacing = 0 + stack.detachesHiddenViews = true + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2), + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + for emoji in ["👍", "🎉", "❤️"] { + let button = HoverActionButton(emoji: emoji, label: "React with \(emoji)") + button.onClick = { [weak self] in self?.onAction?(.reaction(emoji)) } + stack.addArrangedSubview(button) + } + + let dividerContainer = NSView() + dividerContainer.translatesAutoresizingMaskIntoConstraints = false + dividerContainer.widthAnchor.constraint(equalToConstant: 6).isActive = true + dividerContainer.heightAnchor.constraint(equalToConstant: 28).isActive = true + let divider = NSBox() + divider.boxType = .separator + divider.translatesAutoresizingMaskIntoConstraints = false + dividerContainer.addSubview(divider) + NSLayoutConstraint.activate([ + divider.centerXAnchor.constraint(equalTo: dividerContainer.centerXAnchor), + divider.centerYAnchor.constraint(equalTo: dividerContainer.centerYAnchor), + divider.heightAnchor.constraint(equalToConstant: 18), + ]) + stack.addArrangedSubview(dividerContainer) + + let reactionPicker = HoverActionButton(symbol: "face.smiling", label: "React") + reactionPicker.onClick = { [weak self] in self?.onAction?(.reactionPicker) } + stack.addArrangedSubview(reactionPicker) + + replyButton.onClick = { [weak self] in self?.onAction?(.reply) } + threadButton.onClick = { [weak self] in self?.onAction?(.replyInThread) } + stack.addArrangedSubview(replyButton) + stack.addArrangedSubview(threadButton) + + let pinButton = HoverActionButton(symbol: "pin", label: "Pin") + pinButton.onClick = { [weak self] in self?.onAction?(.pin) } + stack.addArrangedSubview(pinButton) + + addTrackingArea( + NSTrackingArea( + rect: .zero, + options: [ + .mouseEnteredAndExited, .mouseMoved, .cursorUpdate, .activeInKeyWindow, .inVisibleRect, + ], + owner: self, + userInfo: nil + )) + + configure(canReply: true) + } + + func configure(canReply: Bool) { + replyButton.isHidden = !canReply + threadButton.isHidden = !canReply + setFrameSize(fittingSize) + } + + override func cursorUpdate(with event: NSEvent) { + NSCursor.arrow.set() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateAppearance() + } + + private func updateAppearance() { + layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor + layer?.borderColor = NSColor.separatorColor.cgColor + } + + override func mouseExited(with event: NSEvent) { + onMouseExited?(event) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } private final class HoverActionButton: NSButton { - var onClick: (() -> Void)? - private var isHovered = false - private var symbolView: NSImageView? - private let hoverBackground = CALayer() - - init(emoji: String, label: String) { - super.init(frame: .zero) - title = emoji - font = .systemFont(ofSize: 13) - configure(label: label) - } - - init(symbol: String, label: String) { - super.init(frame: .zero) - configure(label: label) - - title = "" - - // NSButton.image makes AppKit grow the button to the symbol's cell height. - // Keep the symbol separate so AppKit does not change the button's hit area. - let symbolView = NSImageView() - symbolView.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) - symbolView.imageScaling = .scaleProportionallyDown - symbolView.contentTintColor = .labelColor - symbolView.translatesAutoresizingMaskIntoConstraints = false - addSubview(symbolView) - NSLayoutConstraint.activate([ - symbolView.centerXAnchor.constraint(equalTo: centerXAnchor), - symbolView.centerYAnchor.constraint(equalTo: centerYAnchor), - symbolView.widthAnchor.constraint(equalToConstant: 18), - symbolView.heightAnchor.constraint(equalToConstant: 18), - ]) - self.symbolView = symbolView - } - - private func configure(label: String) { - isBordered = false - toolTip = label - setAccessibilityLabel(label) - contentTintColor = .labelColor - wantsLayer = true - hoverBackground.cornerRadius = 4 - layer?.insertSublayer(hoverBackground, at: 0) - translatesAutoresizingMaskIntoConstraints = false - widthAnchor.constraint(equalToConstant: 24).isActive = true - heightAnchor.constraint(equalToConstant: 28).isActive = true - target = self - action = #selector(clicked) - - addTrackingArea(NSTrackingArea( - rect: .zero, - options: [.mouseEnteredAndExited, .cursorUpdate, .activeInKeyWindow, .inVisibleRect], - owner: self, - userInfo: nil - )) - } - - override func cursorUpdate(with event: NSEvent) { - NSCursor.arrow.set() - } - - override func mouseEntered(with event: NSEvent) { - isHovered = true - updateAppearance() - } - - override func mouseExited(with event: NSEvent) { - isHovered = false - updateAppearance() - } - - override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - updateAppearance() - } - - private func updateAppearance() { - contentTintColor = isHovered ? .controlAccentColor : .labelColor - symbolView?.contentTintColor = contentTintColor - hoverBackground.backgroundColor = isHovered - ? NSColor.controlAccentColor.withAlphaComponent(0.12).cgColor - : nil - } - - override func layout() { - super.layout() - hoverBackground.frame = bounds.insetBy(dx: 2, dy: 4) - } - - override func hitTest(_ point: NSPoint) -> NSView? { - // Keep the image view from intercepting the button's target/action click. - super.hitTest(point) == nil ? nil : self - } - - @objc private func clicked() { - onClick?() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + var onClick: (() -> Void)? + private var isHovered = false + private var symbolView: NSImageView? + private let hoverBackground = CALayer() + + init(emoji: String, label: String) { + super.init(frame: .zero) + title = emoji + font = .systemFont(ofSize: 13) + configure(label: label) + } + + init(symbol: String, label: String) { + super.init(frame: .zero) + configure(label: label) + + title = "" + + // NSButton.image makes AppKit grow the button to the symbol's cell height. + // Keep the symbol separate so AppKit does not change the button's hit area. + let symbolView = NSImageView() + symbolView.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + symbolView.imageScaling = .scaleProportionallyDown + symbolView.contentTintColor = .labelColor + symbolView.translatesAutoresizingMaskIntoConstraints = false + addSubview(symbolView) + NSLayoutConstraint.activate([ + symbolView.centerXAnchor.constraint(equalTo: centerXAnchor), + symbolView.centerYAnchor.constraint(equalTo: centerYAnchor), + symbolView.widthAnchor.constraint(equalToConstant: 18), + symbolView.heightAnchor.constraint(equalToConstant: 18), + ]) + self.symbolView = symbolView + } + + private func configure(label: String) { + isBordered = false + toolTip = label + setAccessibilityLabel(label) + contentTintColor = .labelColor + wantsLayer = true + hoverBackground.cornerRadius = 4 + layer?.insertSublayer(hoverBackground, at: 0) + translatesAutoresizingMaskIntoConstraints = false + widthAnchor.constraint(equalToConstant: 24).isActive = true + heightAnchor.constraint(equalToConstant: 28).isActive = true + target = self + action = #selector(clicked) + + addTrackingArea( + NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .cursorUpdate, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + )) + } + + override func cursorUpdate(with event: NSEvent) { + NSCursor.arrow.set() + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + updateAppearance() + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + updateAppearance() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateAppearance() + } + + private func updateAppearance() { + contentTintColor = isHovered ? .controlAccentColor : .labelColor + symbolView?.contentTintColor = contentTintColor + hoverBackground.backgroundColor = + isHovered + ? NSColor.controlAccentColor.withAlphaComponent(0.12).cgColor + : nil + } + + override func layout() { + super.layout() + hoverBackground.frame = bounds.insetBy(dx: 2, dy: 4) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + // Keep the image view from intercepting the button's target/action click. + super.hitTest(point) == nil ? nil : self + } + + @objc private func clicked() { + onClick?() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift index 25795ef..6f40e40 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift @@ -1,241 +1,251 @@ import AppKit +import MatrixIntegration import MatrixRustSDK import OSLog import QuickLookUI -import MatrixIntegration /// One image and its optional text caption. Both views survive table row reuse. final class MessageImageContentView: NSView, MessageContentRowView { - var onSelectRequest: (() -> Void)? { - didSet { captionView.onSelectRequest = onSelectRequest } - } - var onArrowKey: ((TimelineSelectionDirection) -> Void)? { - didSet { captionView.onArrowKey = onArrowKey } - } - - private let imageButton = NSButton() - private let spinner = NSProgressIndicator() - private let errorLabel = NSTextField(labelWithString: "") - private let captionView = MessageTextContentView() - private var imageWidthConstraint: NSLayoutConstraint! - private var imageHeightConstraint: NSLayoutConstraint! - private var imageBottomConstraint: NSLayoutConstraint! - private var captionBottomConstraint: NSLayoutConstraint! - private var sourceURL: String? - private var imageContent: ImageMessageContent? - private weak var matrixClient: MatrixClient? - private var loadTask: Task? - private var previewTask: Task? - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - imageButton.isBordered = false - imageButton.imagePosition = .imageOnly - imageButton.imageScaling = .scaleProportionallyDown - imageButton.target = self - imageButton.action = #selector(previewImage) - imageButton.wantsLayer = true - imageButton.layer?.cornerRadius = 6 - imageButton.layer?.masksToBounds = true - - spinner.style = .spinning - spinner.controlSize = .small - spinner.isDisplayedWhenStopped = false - errorLabel.textColor = .systemRed - errorLabel.alignment = .center - errorLabel.isSelectable = true - errorLabel.usesSingleLineMode = false - errorLabel.lineBreakMode = .byWordWrapping - errorLabel.maximumNumberOfLines = 0 - errorLabel.isHidden = true - - for view in [imageButton, spinner, errorLabel, captionView] { - view.translatesAutoresizingMaskIntoConstraints = false - addSubview(view) - } - - imageWidthConstraint = imageButton.widthAnchor.constraint(equalToConstant: 300) - imageHeightConstraint = imageButton.heightAnchor.constraint(equalToConstant: 300) - imageBottomConstraint = imageButton.bottomAnchor.constraint(equalTo: bottomAnchor) - captionBottomConstraint = captionView.bottomAnchor.constraint(equalTo: bottomAnchor) - imageBottomConstraint.priority = .init(999) - captionBottomConstraint.priority = .init(999) - NSLayoutConstraint.activate([ - imageButton.leadingAnchor.constraint(equalTo: leadingAnchor), - imageButton.topAnchor.constraint(equalTo: topAnchor), - imageWidthConstraint, - imageHeightConstraint, - spinner.centerXAnchor.constraint(equalTo: imageButton.centerXAnchor), - spinner.centerYAnchor.constraint(equalTo: imageButton.centerYAnchor), - errorLabel.leadingAnchor.constraint(equalTo: imageButton.leadingAnchor), - errorLabel.trailingAnchor.constraint(equalTo: imageButton.trailingAnchor), - errorLabel.centerYAnchor.constraint(equalTo: imageButton.centerYAnchor), - captionView.leadingAnchor.constraint(equalTo: leadingAnchor), - captionView.trailingAnchor.constraint(equalTo: trailingAnchor), - captionView.topAnchor.constraint(equalTo: imageButton.bottomAnchor, constant: 10), - ]) - } - - static func supports(content: MsgLikeContent) -> Bool { - guard case let .message(message) = content.kind else { return false } - if case .image = message.msgType { return true } - return false - } - - func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { - guard case let .message(message) = content.kind, - case let .image(image) = message.msgType else { return } - - self.imageContent = image - self.matrixClient = matrixClient - let hasCaption = image.caption?.isEmpty == false || image.formattedCaption != nil - captionView.isHidden = !hasCaption - captionView.configureCaption(image.caption, formatted: image.formattedCaption) - imageBottomConstraint.isActive = !hasCaption - captionBottomConstraint.isActive = hasCaption - updateImageSize(for: image, width: bounds.width) - - let url = image.source.url() - guard url != sourceURL else { return } - loadTask?.cancel() - previewTask?.cancel() - loadTask = nil - previewTask = nil - sourceURL = url - errorLabel.isHidden = true - imageButton.toolTip = image.filename - imageButton.image = MatrixClient.imageCache.object(forKey: NSString(string: url)) - imageButton.isEnabled = imageButton.image != nil - if imageButton.image == nil { - spinner.startAnimation(nil) - loadImage(image, from: matrixClient) - } else { - spinner.stopAnimation(nil) - } - } - - override func layout() { - super.layout() - guard let imageContent else { return } - updateImageSize(for: imageContent, width: bounds.width) - } - - func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { - guard case let .message(message) = content.kind, - case let .image(image) = message.msgType else { return 0 } - let imageHeight = Self.imageSize(for: image, width: width).height - guard image.caption?.isEmpty == false || image.formattedCaption != nil else { return imageHeight } - return imageHeight + 10 + ceil(captionView.height(forCaption: image.caption, formatted: image.formattedCaption, width: width)) - } - - private func updateImageSize(for image: ImageMessageContent, width: CGFloat) { - let size = Self.imageSize(for: image, width: max(width, 1)) - imageWidthConstraint.constant = size.width - imageHeightConstraint.constant = size.height - } - - private static func imageSize(for image: ImageMessageContent, width: CGFloat) -> CGSize { - guard let info = image.info, let pixelWidth = info.width, let pixelHeight = info.height, - pixelWidth > 0, pixelHeight > 0 else { - let side = min(width, 300) - return CGSize(width: side, height: side) - } - let ratio = CGFloat(pixelWidth) / CGFloat(pixelHeight) - let imageWidth = min(width, CGFloat(pixelWidth), 300 * ratio) - return CGSize(width: imageWidth, height: imageWidth / ratio) - } - - private func loadImage(_ image: ImageMessageContent, from client: MatrixClient?) { - guard let client else { - showError("Matrix client not available") - return - } - let url = image.source.url() - loadTask = Task { [weak self] in - do { - let data = try await client.client.getMediaContent(mediaSource: image.source) - try Task.checkCancellation() - let cacheKey = NSString(string: url) - let decoded = try MatrixClient.imageCache.object(forKey: cacheKey) - ?? data.toOrientedImage(contentType: data.computeMimeType()) - try Task.checkCancellation() - MatrixClient.setCachedImage(decoded, forKey: cacheKey) - guard let self, self.sourceURL == url else { return } - self.imageButton.image = decoded - self.imageButton.isEnabled = true - self.spinner.stopAnimation(nil) - self.loadTask = nil - } catch is CancellationError { - return - } catch { - guard let self, self.sourceURL == url else { return } - self.showError(error.localizedDescription) - self.loadTask = nil - } - } - } - - private func showError(_ message: String) { - spinner.stopAnimation(nil) - errorLabel.stringValue = message - errorLabel.isHidden = false - } - - @objc private func previewImage() { - guard let imageContent, let matrixClient else { return } - let url = imageContent.source.url() - previewTask?.cancel() - previewTask = Task { [weak self] in - do { - let handle = try await matrixClient.client.getMediaFile( - mediaSource: imageContent.source, - filename: imageContent.filename, - mimeType: imageContent.info?.mimetype ?? "", - useCache: true, - tempDir: NSTemporaryDirectory() - ) - try Task.checkCancellation() - let path = try handle.path() - guard let self, self.sourceURL == url else { return } - MediaQuickLookPreview.shared.show(handle: handle, url: URL(filePath: path, directoryHint: .notDirectory)) - } catch is CancellationError { - return - } catch { - Logger.viewCycle.error("failed to preview image: \(error)") - } - } - } - - deinit { - loadTask?.cancel() - previewTask?.cancel() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + var onSelectRequest: (() -> Void)? { + didSet { captionView.onSelectRequest = onSelectRequest } + } + var onArrowKey: ((TimelineSelectionDirection) -> Void)? { + didSet { captionView.onArrowKey = onArrowKey } + } + + private let imageButton = NSButton() + private let spinner = NSProgressIndicator() + private let errorLabel = NSTextField(labelWithString: "") + private let captionView = MessageTextContentView() + private var imageWidthConstraint: NSLayoutConstraint! + private var imageHeightConstraint: NSLayoutConstraint! + private var imageBottomConstraint: NSLayoutConstraint! + private var captionBottomConstraint: NSLayoutConstraint! + private var sourceURL: String? + private var imageContent: ImageMessageContent? + private weak var matrixClient: MatrixClient? + private var loadTask: Task? + private var previewTask: Task? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + imageButton.isBordered = false + imageButton.imagePosition = .imageOnly + imageButton.imageScaling = .scaleProportionallyDown + imageButton.target = self + imageButton.action = #selector(previewImage) + imageButton.wantsLayer = true + imageButton.layer?.cornerRadius = 6 + imageButton.layer?.masksToBounds = true + + spinner.style = .spinning + spinner.controlSize = .small + spinner.isDisplayedWhenStopped = false + errorLabel.textColor = .systemRed + errorLabel.alignment = .center + errorLabel.isSelectable = true + errorLabel.usesSingleLineMode = false + errorLabel.lineBreakMode = .byWordWrapping + errorLabel.maximumNumberOfLines = 0 + errorLabel.isHidden = true + + for view in [imageButton, spinner, errorLabel, captionView] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + + imageWidthConstraint = imageButton.widthAnchor.constraint(equalToConstant: 300) + imageHeightConstraint = imageButton.heightAnchor.constraint(equalToConstant: 300) + imageBottomConstraint = imageButton.bottomAnchor.constraint(equalTo: bottomAnchor) + captionBottomConstraint = captionView.bottomAnchor.constraint(equalTo: bottomAnchor) + imageBottomConstraint.priority = .init(999) + captionBottomConstraint.priority = .init(999) + NSLayoutConstraint.activate([ + imageButton.leadingAnchor.constraint(equalTo: leadingAnchor), + imageButton.topAnchor.constraint(equalTo: topAnchor), + imageWidthConstraint, + imageHeightConstraint, + spinner.centerXAnchor.constraint(equalTo: imageButton.centerXAnchor), + spinner.centerYAnchor.constraint(equalTo: imageButton.centerYAnchor), + errorLabel.leadingAnchor.constraint(equalTo: imageButton.leadingAnchor), + errorLabel.trailingAnchor.constraint(equalTo: imageButton.trailingAnchor), + errorLabel.centerYAnchor.constraint(equalTo: imageButton.centerYAnchor), + captionView.leadingAnchor.constraint(equalTo: leadingAnchor), + captionView.trailingAnchor.constraint(equalTo: trailingAnchor), + captionView.topAnchor.constraint(equalTo: imageButton.bottomAnchor, constant: 10), + ]) + } + + static func supports(content: MsgLikeContent) -> Bool { + guard case .message(let message) = content.kind else { return false } + if case .image = message.msgType { return true } + return false + } + + func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { + guard case .message(let message) = content.kind, + case .image(let image) = message.msgType + else { return } + + self.imageContent = image + self.matrixClient = matrixClient + let hasCaption = image.caption?.isEmpty == false || image.formattedCaption != nil + captionView.isHidden = !hasCaption + captionView.configureCaption(image.caption, formatted: image.formattedCaption) + imageBottomConstraint.isActive = !hasCaption + captionBottomConstraint.isActive = hasCaption + updateImageSize(for: image, width: bounds.width) + + let url = image.source.url() + guard url != sourceURL else { return } + loadTask?.cancel() + previewTask?.cancel() + loadTask = nil + previewTask = nil + sourceURL = url + errorLabel.isHidden = true + imageButton.toolTip = image.filename + imageButton.image = MatrixClient.imageCache.object(forKey: NSString(string: url)) + imageButton.isEnabled = imageButton.image != nil + if imageButton.image == nil { + spinner.startAnimation(nil) + loadImage(image, from: matrixClient) + } else { + spinner.stopAnimation(nil) + } + } + + override func layout() { + super.layout() + guard let imageContent else { return } + updateImageSize(for: imageContent, width: bounds.width) + } + + func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { + guard case .message(let message) = content.kind, + case .image(let image) = message.msgType + else { return 0 } + let imageHeight = Self.imageSize(for: image, width: width).height + guard image.caption?.isEmpty == false || image.formattedCaption != nil else { + return imageHeight + } + return imageHeight + 10 + + ceil( + captionView.height( + forCaption: image.caption, formatted: image.formattedCaption, width: width)) + } + + private func updateImageSize(for image: ImageMessageContent, width: CGFloat) { + let size = Self.imageSize(for: image, width: max(width, 1)) + imageWidthConstraint.constant = size.width + imageHeightConstraint.constant = size.height + } + + private static func imageSize(for image: ImageMessageContent, width: CGFloat) -> CGSize { + guard let info = image.info, let pixelWidth = info.width, let pixelHeight = info.height, + pixelWidth > 0, pixelHeight > 0 + else { + let side = min(width, 300) + return CGSize(width: side, height: side) + } + let ratio = CGFloat(pixelWidth) / CGFloat(pixelHeight) + let imageWidth = min(width, CGFloat(pixelWidth), 300 * ratio) + return CGSize(width: imageWidth, height: imageWidth / ratio) + } + + private func loadImage(_ image: ImageMessageContent, from client: MatrixClient?) { + guard let client else { + showError("Matrix client not available") + return + } + let url = image.source.url() + loadTask = Task { [weak self] in + do { + let data = try await client.client.getMediaContent(mediaSource: image.source) + try Task.checkCancellation() + let cacheKey = NSString(string: url) + let decoded = + try MatrixClient.imageCache.object(forKey: cacheKey) + ?? data.toOrientedImage(contentType: data.computeMimeType()) + try Task.checkCancellation() + MatrixClient.setCachedImage(decoded, forKey: cacheKey) + guard let self, self.sourceURL == url else { return } + self.imageButton.image = decoded + self.imageButton.isEnabled = true + self.spinner.stopAnimation(nil) + self.loadTask = nil + } catch is CancellationError { + return + } catch { + guard let self, self.sourceURL == url else { return } + self.showError(error.localizedDescription) + self.loadTask = nil + } + } + } + + private func showError(_ message: String) { + spinner.stopAnimation(nil) + errorLabel.stringValue = message + errorLabel.isHidden = false + } + + @objc private func previewImage() { + guard let imageContent, let matrixClient else { return } + let url = imageContent.source.url() + previewTask?.cancel() + previewTask = Task { [weak self] in + do { + let handle = try await matrixClient.client.getMediaFile( + mediaSource: imageContent.source, + filename: imageContent.filename, + mimeType: imageContent.info?.mimetype ?? "", + useCache: true, + tempDir: NSTemporaryDirectory() + ) + try Task.checkCancellation() + let path = try handle.path() + guard let self, self.sourceURL == url else { return } + MediaQuickLookPreview.shared.show( + handle: handle, url: URL(filePath: path, directoryHint: .notDirectory)) + } catch is CancellationError { + return + } catch { + Logger.viewCycle.error("failed to preview image: \(error)") + } + } + } + + deinit { + loadTask?.cancel() + previewTask?.cancel() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } /// Keeps the temporary media file alive while Quick Look is displaying it. @MainActor final class MediaQuickLookPreview: NSObject, @preconcurrency QLPreviewPanelDataSource { - static let shared = MediaQuickLookPreview() - private var handle: MediaFileHandle? - private var url: URL? - - func show(handle: MediaFileHandle, url: URL) { - self.handle = handle - self.url = url - guard let panel = QLPreviewPanel.shared() else { return } - panel.dataSource = self - panel.reloadData() - panel.makeKeyAndOrderFront(nil) - } - - func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { url == nil ? 0 : 1 } - - func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { - url as NSURL? - } + static let shared = MediaQuickLookPreview() + private var handle: MediaFileHandle? + private var url: URL? + + func show(handle: MediaFileHandle, url: URL) { + self.handle = handle + self.url = url + guard let panel = QLPreviewPanel.shared() else { return } + panel.dataSource = self + panel.reloadData() + panel.makeKeyAndOrderFront(nil) + } + + func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { url == nil ? 0 : 1 } + + func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { + url as NSURL? + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageProfileRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageProfileRowView.swift index 630d6bd..ffc5e88 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageProfileRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageProfileRowView.swift @@ -1,109 +1,112 @@ import AppKit -import MatrixRustSDK import MactrixUI +import MatrixRustSDK class MessageProfileRowView: NSView { - let profilePicture = NSButton() - let name = NSTextField(labelWithString: "") - - private var sender: String? - private var focusUserAction: (_ sender: String) -> Void = { _ in } - private var imageLoader: MactrixUI.ImageLoader? - - private var avatarTask: Task? - private var avatarUrl: String? - - static let rowHeight: Double = 32 - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - profilePicture.target = self - profilePicture.action = #selector(onProfileClicked) - profilePicture.translatesAutoresizingMaskIntoConstraints = false - profilePicture.imageScaling = .scaleProportionallyDown - profilePicture.imagePosition = .imageOnly - profilePicture.isBordered = false - profilePicture.wantsLayer = true - profilePicture.layer?.cornerRadius = 16 - profilePicture.layer?.masksToBounds = true - profilePicture.layer?.backgroundColor = NSColor.gray.cgColor - - name.translatesAutoresizingMaskIntoConstraints = false - name.isSelectable = true - name.drawsBackground = false - name.font = .boldSystemFont(ofSize: NSFont.systemFontSize) - name.lineBreakMode = .byTruncatingTail - name.maximumNumberOfLines = 1 - - addSubview(profilePicture) - addSubview(name) - - NSLayoutConstraint.activate([ - profilePicture.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), - profilePicture.centerYAnchor.constraint(equalTo: centerYAnchor), - profilePicture.widthAnchor.constraint(equalToConstant: 32), - profilePicture.heightAnchor.constraint(equalToConstant: 32), - - name.leadingAnchor.constraint(equalTo: profilePicture.trailingAnchor, constant: 16), - name.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -10), - name.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func initialize(imageLoader: MactrixUI.ImageLoader?, focusUser: @escaping (_ sender: String) -> Void) { - self.imageLoader = imageLoader - focusUserAction = focusUser + let profilePicture = NSButton() + let name = NSTextField(labelWithString: "") + + private var sender: String? + private var focusUserAction: (_ sender: String) -> Void = { _ in } + private var imageLoader: MactrixUI.ImageLoader? + + private var avatarTask: Task? + private var avatarUrl: String? + + static let rowHeight: Double = 32 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + profilePicture.target = self + profilePicture.action = #selector(onProfileClicked) + profilePicture.translatesAutoresizingMaskIntoConstraints = false + profilePicture.imageScaling = .scaleProportionallyDown + profilePicture.imagePosition = .imageOnly + profilePicture.isBordered = false + profilePicture.wantsLayer = true + profilePicture.layer?.cornerRadius = 16 + profilePicture.layer?.masksToBounds = true + profilePicture.layer?.backgroundColor = NSColor.gray.cgColor + + name.translatesAutoresizingMaskIntoConstraints = false + name.isSelectable = true + name.drawsBackground = false + name.font = .boldSystemFont(ofSize: NSFont.systemFontSize) + name.lineBreakMode = .byTruncatingTail + name.maximumNumberOfLines = 1 + + addSubview(profilePicture) + addSubview(name) + + NSLayoutConstraint.activate([ + profilePicture.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + profilePicture.centerYAnchor.constraint(equalTo: centerYAnchor), + profilePicture.widthAnchor.constraint(equalToConstant: 32), + profilePicture.heightAnchor.constraint(equalToConstant: 32), + + name.leadingAnchor.constraint(equalTo: profilePicture.trailingAnchor, constant: 16), + name.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -10), + name.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func initialize( + imageLoader: MactrixUI.ImageLoader?, focusUser: @escaping (_ sender: String) -> Void + ) { + self.imageLoader = imageLoader + focusUserAction = focusUser + } + + func configure(event: EventTimelineItem) { + sender = event.sender + + let username: String + switch event.senderProfile { + case .ready(let displayName, _, let avatarUrl, _, _): + username = displayName ?? event.sender + self.avatarUrl = avatarUrl + default: + username = event.sender + avatarUrl = nil } - func configure(event: EventTimelineItem) { - sender = event.sender - - let username: String - switch event.senderProfile { - case .ready(let displayName, _, let avatarUrl, _, _): - username = displayName ?? event.sender - self.avatarUrl = avatarUrl - default: - username = event.sender - avatarUrl = nil - } + name.stringValue = username + name.textColor = NSColor(userID: event.sender) + name.toolTip = username - name.stringValue = username - name.textColor = NSColor(userID: event.sender) - name.toolTip = username + reloadAvatar() + } - reloadAvatar() + @objc func onProfileClicked(_ target: Any) { + if let sender { + focusUserAction(sender) } + } - @objc func onProfileClicked(_ target: Any) { - if let sender { - focusUserAction(sender) - } - } + private func reloadAvatar() { + avatarTask?.cancel() + profilePicture.image = nil + guard let avatarUrl, let imageLoader else { return } - private func reloadAvatar() { - avatarTask?.cancel() - profilePicture.image = nil - guard let avatarUrl, let imageLoader else { return } - - if let cached = imageLoader.cachedImage(matrixUrl: avatarUrl) { - profilePicture.image = cached - return - } + if let cached = imageLoader.cachedImage(matrixUrl: avatarUrl) { + profilePicture.image = cached + return + } - avatarTask = Task { [weak self] in - guard let image = try? await imageLoader.loadImage(matrixUrl: avatarUrl, size: nil), - !Task.isCancelled, - let self, - self.avatarUrl == avatarUrl else { return } + avatarTask = Task { [weak self] in + guard let image = try? await imageLoader.loadImage(matrixUrl: avatarUrl, size: nil), + !Task.isCancelled, + let self, + self.avatarUrl == avatarUrl + else { return } - self.profilePicture.image = image - } + self.profilePicture.image = image } + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReactionsView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReactionsView.swift index aef110d..8cd71e1 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReactionsView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReactionsView.swift @@ -3,136 +3,140 @@ import MatrixRustSDK /// Keeps reaction buttons alive when a table row is reused. final class MessageReactionsView: NSView { - var onReactionClick: ((String) -> Void)? - - private var buttons: [ReactionButton] = [] - private var reactions: [Reaction] = [] - - private static let rowHeight: CGFloat = 28 - private static let spacing: CGFloat = 6 - - override var isFlipped: Bool { - true + var onReactionClick: ((String) -> Void)? + + private var buttons: [ReactionButton] = [] + private var reactions: [Reaction] = [] + + private static let rowHeight: CGFloat = 28 + private static let spacing: CGFloat = 6 + + override var isFlipped: Bool { + true + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + } + + func configure(reactions: [Reaction], ownUserId: String?) { + self.reactions = reactions + while buttons.count < reactions.count { + let reaction = reactions[buttons.count] + let button = ReactionButton( + frame: NSRect( + x: 0, y: 0, width: ReactionButton.width(for: reaction), height: Self.rowHeight + )) + button.onClick = { [weak self] key in self?.onReactionClick?(key) } + addSubview(button) + buttons.append(button) } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) + for (button, reaction) in zip(buttons, reactions) { + button.configure(reaction: reaction, ownUserId: ownUserId) + button.isHidden = false } - - func configure(reactions: [Reaction], ownUserId: String?) { - self.reactions = reactions - while buttons.count < reactions.count { - let reaction = reactions[buttons.count] - let button = ReactionButton(frame: NSRect( - x: 0, y: 0, width: ReactionButton.width(for: reaction), height: Self.rowHeight - )) - button.onClick = { [weak self] key in self?.onReactionClick?(key) } - addSubview(button) - buttons.append(button) - } - for (button, reaction) in zip(buttons, reactions) { - button.configure(reaction: reaction, ownUserId: ownUserId) - button.isHidden = false - } - for button in buttons.dropFirst(reactions.count) { - button.isHidden = true - } - needsLayout = true + for button in buttons.dropFirst(reactions.count) { + button.isHidden = true } - - override func layout() { - super.layout() - var x: CGFloat = 0 - var y: CGFloat = 0 - for (reaction, button) in zip(reactions, buttons) { - let width = ReactionButton.width(for: reaction) - if x > 0, x + width > bounds.width { - x = 0 - y += Self.rowHeight + Self.spacing - } - button.frame = NSRect(x: x, y: y, width: width, height: Self.rowHeight) - x += width + Self.spacing - } + needsLayout = true + } + + override func layout() { + super.layout() + var x: CGFloat = 0 + var y: CGFloat = 0 + for (reaction, button) in zip(reactions, buttons) { + let width = ReactionButton.width(for: reaction) + if x > 0, x + width > bounds.width { + x = 0 + y += Self.rowHeight + Self.spacing + } + button.frame = NSRect(x: x, y: y, width: width, height: Self.rowHeight) + x += width + Self.spacing } - - static func height(for reactions: [Reaction], width: CGFloat) -> CGFloat { - guard !reactions.isEmpty else { return 0 } - var x: CGFloat = 0 - var rows = 1 - for reaction in reactions { - let buttonWidth = ReactionButton.width(for: reaction) - if x > 0 && x + buttonWidth > width { - x = 0 - rows += 1 - } - x += buttonWidth + spacing - } - return CGFloat(rows) * rowHeight + CGFloat(rows - 1) * spacing + } + + static func height(for reactions: [Reaction], width: CGFloat) -> CGFloat { + guard !reactions.isEmpty else { return 0 } + var x: CGFloat = 0 + var rows = 1 + for reaction in reactions { + let buttonWidth = ReactionButton.width(for: reaction) + if x > 0 && x + buttonWidth > width { + x = 0 + rows += 1 + } + x += buttonWidth + spacing } + return CGFloat(rows) * rowHeight + CGFloat(rows - 1) * spacing + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } private final class ReactionButton: NSButton { - var onClick: ((String) -> Void)? - private var reactionKey = "" - private let label = NSTextField(labelWithString: "") - private static let font = NSFont.systemFont(ofSize: 13) - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - isBordered = false - title = "" - target = self - action = #selector(activate) - wantsLayer = true - layer?.cornerRadius = 4 - layer?.borderWidth = 1 - - label.font = Self.font - label.translatesAutoresizingMaskIntoConstraints = false - addSubview(label) - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), - label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), - label.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) - } - - func configure(reaction: Reaction, ownUserId: String?) { - reactionKey = reaction.key - label.stringValue = "\(reaction.key) \(reaction.senders.count)" - setAccessibilityLabel(label.stringValue) - toolTip = "Reacted by " + reaction.senders.prefix(5).map(\.senderId).joined(separator: ", ") - let active = ownUserId.map { id in reaction.senders.contains { $0.senderId == id } } ?? false - layer?.borderColor = (active ? NSColor.systemBlue : .systemGray).cgColor - layer?.backgroundColor = (active ? NSColor.systemBlue : .systemGray).withAlphaComponent(0.12).cgColor - } - - static func width(for reaction: Reaction) -> CGFloat { - ceil(("\(reaction.key) \(reaction.senders.count)" as NSString).size(withAttributes: [.font: font]).width) + 16 - } - - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(convert(point, from: superview)) ? self : nil - } - - override func mouseDown(with event: NSEvent) { - alphaValue = 0.6 - window?.displayIfNeeded() - super.mouseDown(with: event) - alphaValue = 1 - } - - @objc private func activate(_ sender: NSButton) { - onClick?(reactionKey) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + var onClick: ((String) -> Void)? + private var reactionKey = "" + private let label = NSTextField(labelWithString: "") + private static let font = NSFont.systemFont(ofSize: 13) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + isBordered = false + title = "" + target = self + action = #selector(activate) + wantsLayer = true + layer?.cornerRadius = 4 + layer?.borderWidth = 1 + + label.font = Self.font + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + func configure(reaction: Reaction, ownUserId: String?) { + reactionKey = reaction.key + label.stringValue = "\(reaction.key) \(reaction.senders.count)" + setAccessibilityLabel(label.stringValue) + toolTip = "Reacted by " + reaction.senders.prefix(5).map(\.senderId).joined(separator: ", ") + let active = ownUserId.map { id in reaction.senders.contains { $0.senderId == id } } ?? false + layer?.borderColor = (active ? NSColor.systemBlue : .systemGray).cgColor + layer?.backgroundColor = + (active ? NSColor.systemBlue : .systemGray).withAlphaComponent(0.12).cgColor + } + + static func width(for reaction: Reaction) -> CGFloat { + ceil( + ("\(reaction.key) \(reaction.senders.count)" as NSString).size(withAttributes: [.font: font]) + .width) + 16 + } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + alphaValue = 0.6 + window?.displayIfNeeded() + super.mouseDown(with: event) + alphaValue = 1 + } + + @objc private func activate(_ sender: NSButton) { + onClick?(reactionKey) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReadReceiptsView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReadReceiptsView.swift index 71a97b2..0bd64e2 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReadReceiptsView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReadReceiptsView.swift @@ -1,267 +1,283 @@ import AppKit -import MatrixRustSDK -import MatrixProtocols import MactrixUI +import MatrixProtocols +import MatrixRustSDK /// Reuses the visible receipt avatars as the table row is recycled. final class MessageReadReceiptsView: NSButton { - static let rowHeight: CGFloat = 18 - private static let countFont = NSFont.systemFont(ofSize: 11) - var onFocusUser: ((String) -> Void)? - var receiptCount: Int { - receipts.count - } + static let rowHeight: CGFloat = 18 + private static let countFont = NSFont.systemFont(ofSize: 11) + var onFocusUser: ((String) -> Void)? + var receiptCount: Int { + receipts.count + } - private let countLabel = NSTextField(labelWithString: "") - private var avatars: [ReceiptAvatarView] = [] - private var receipts: [(userId: String, date: Date?)] = [] - private var members: [String: MatrixRustSDK.RoomMember] = [:] - private var imageLoader: MactrixUI.ImageLoader? - private let receiptPopover = NSPopover() + private let countLabel = NSTextField(labelWithString: "") + private var avatars: [ReceiptAvatarView] = [] + private var receipts: [(userId: String, date: Date?)] = [] + private var members: [String: MatrixRustSDK.RoomMember] = [:] + private var imageLoader: MactrixUI.ImageLoader? + private let receiptPopover = NSPopover() - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - isBordered = false - title = "" - target = self - action = #selector(showReceipts) - countLabel.font = Self.countFont - countLabel.textColor = .secondaryLabelColor - countLabel.alignment = .right - addSubview(countLabel) - } + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + isBordered = false + title = "" + target = self + action = #selector(showReceipts) + countLabel.font = Self.countFont + countLabel.textColor = .secondaryLabelColor + countLabel.alignment = .right + addSubview(countLabel) + } - func configure(receipts: [String: MatrixProtocols.Receipt], members: [MatrixRustSDK.RoomMember], imageLoader: MactrixUI.ImageLoader?) { - self.receipts = receipts.map { ($0.key, $0.value.timestamp) } - .sorted { ($0.date ?? .distantPast) < ($1.date ?? .distantPast) } - self.members = Dictionary(members.map { ($0.userId, $0) }, uniquingKeysWith: { first, _ in first }) - self.imageLoader = imageLoader - receiptPopover.close() + func configure( + receipts: [String: MatrixProtocols.Receipt], members: [MatrixRustSDK.RoomMember], + imageLoader: MactrixUI.ImageLoader? + ) { + self.receipts = receipts.map { ($0.key, $0.value.timestamp) } + .sorted { ($0.date ?? .distantPast) < ($1.date ?? .distantPast) } + self.members = Dictionary( + members.map { ($0.userId, $0) }, uniquingKeysWith: { first, _ in first }) + self.imageLoader = imageLoader + receiptPopover.close() - let visible = Array(self.receipts.suffix(3)) - while avatars.count < visible.count { - let avatar = ReceiptAvatarView(frame: NSRect(x: 0, y: 0, width: 14, height: 14)) - addSubview(avatar) - avatars.append(avatar) - } - for (avatar, receipt) in zip(avatars, visible) { - avatar.configure(userId: receipt.userId, avatarUrl: self.members[receipt.userId]?.avatarUrl, imageLoader: imageLoader) - avatar.isHidden = false - } - for avatar in avatars.dropFirst(visible.count) { - avatar.isHidden = true - avatar.cancelLoad() - } - let hiddenCount = self.receipts.count - visible.count - countLabel.stringValue = hiddenCount > 0 ? "+\(hiddenCount)" : "" - countLabel.isHidden = hiddenCount == 0 - toolTip = "Read by " + self.receipts.map { self.members[$0.userId]?.displayName ?? $0.userId }.joined(separator: ", ") - needsLayout = true + let visible = Array(self.receipts.suffix(3)) + while avatars.count < visible.count { + let avatar = ReceiptAvatarView(frame: NSRect(x: 0, y: 0, width: 14, height: 14)) + addSubview(avatar) + avatars.append(avatar) } - - static func width(for count: Int) -> CGFloat { - guard count > 0 else { return 0 } - let visible = min(count, 3) - let avatarsWidth = CGFloat(visible) * 14 - CGFloat(max(visible - 1, 0)) * 2 - return avatarsWidth + countWidth(for: count) + for (avatar, receipt) in zip(avatars, visible) { + avatar.configure( + userId: receipt.userId, avatarUrl: self.members[receipt.userId]?.avatarUrl, + imageLoader: imageLoader) + avatar.isHidden = false } - - private static func countWidth(for count: Int) -> CGFloat { - guard count > 3 else { return 0 } - let text = "+\(count - 3)" as NSString - return max(20, ceil(text.size(withAttributes: [.font: countFont]).width) + 6) + for avatar in avatars.dropFirst(visible.count) { + avatar.isHidden = true + avatar.cancelLoad() } + let hiddenCount = self.receipts.count - visible.count + countLabel.stringValue = hiddenCount > 0 ? "+\(hiddenCount)" : "" + countLabel.isHidden = hiddenCount == 0 + toolTip = + "Read by " + + self.receipts.map { self.members[$0.userId]?.displayName ?? $0.userId }.joined( + separator: ", ") + needsLayout = true + } - override func layout() { - super.layout() - var x: CGFloat = 0 - if !countLabel.isHidden { - x = Self.countWidth(for: receipts.count) - countLabel.frame = NSRect(x: 0, y: 2, width: x, height: 14) - } - for avatar in avatars where !avatar.isHidden { - avatar.frame = NSRect(x: x, y: 2, width: 14, height: 14) - x += 12 - } - } + static func width(for count: Int) -> CGFloat { + guard count > 0 else { return 0 } + let visible = min(count, 3) + let avatarsWidth = CGFloat(visible) * 14 - CGFloat(max(visible - 1, 0)) * 2 + return avatarsWidth + countWidth(for: count) + } - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(convert(point, from: superview)) ? self : nil - } + private static func countWidth(for count: Int) -> CGFloat { + guard count > 3 else { return 0 } + let text = "+\(count - 3)" as NSString + return max(20, ceil(text.size(withAttributes: [.font: countFont]).width) + 6) + } - override func mouseDown(with event: NSEvent) { - alphaValue = 0.6 - window?.displayIfNeeded() - super.mouseDown(with: event) - alphaValue = 1 + override func layout() { + super.layout() + var x: CGFloat = 0 + if !countLabel.isHidden { + x = Self.countWidth(for: receipts.count) + countLabel.frame = NSRect(x: 0, y: 2, width: x, height: 14) + } + for avatar in avatars where !avatar.isHidden { + avatar.frame = NSRect(x: x, y: 2, width: 14, height: 14) + x += 12 } + } - @objc private func showReceipts() { - if receiptPopover.isShown { - receiptPopover.close() - return - } - let stack = FlippedReceiptStackView() - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 8 + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } - let header = NSTextField(labelWithString: receipts.count == 1 ? "Read by 1 person" : "Read by \(receipts.count) people") - header.font = .boldSystemFont(ofSize: NSFont.systemFontSize) - stack.addArrangedSubview(header) - let width: CGFloat = 230 - for receipt in receipts.reversed() { - let row = ReceiptPopoverRowButton(userId: receipt.userId) - row.onClick = { [weak self] userId in - self?.receiptPopover.close() - self?.onFocusUser?(userId) - } - row.widthAnchor.constraint(equalToConstant: width - 24).isActive = true - row.heightAnchor.constraint(equalToConstant: 36).isActive = true - let content = row.content - let avatar = ReceiptAvatarView(frame: NSRect(x: 0, y: 0, width: 28, height: 28)) - avatar.configure(userId: receipt.userId, avatarUrl: members[receipt.userId]?.avatarUrl, imageLoader: imageLoader) - avatar.widthAnchor.constraint(equalToConstant: 28).isActive = true - avatar.heightAnchor.constraint(equalToConstant: 28).isActive = true - content.addArrangedSubview(avatar) + override func mouseDown(with event: NSEvent) { + alphaValue = 0.6 + window?.displayIfNeeded() + super.mouseDown(with: event) + alphaValue = 1 + } - let details = NSStackView() - details.orientation = .vertical - details.alignment = .leading - details.spacing = 2 - let name = members[receipt.userId]?.displayName ?? receipt.userId - let nameLabel = NSTextField(labelWithString: name) - nameLabel.lineBreakMode = .byTruncatingTail - nameLabel.toolTip = name - details.addArrangedSubview(nameLabel) - if let date = receipt.date { - let dateLabel = NSTextField(labelWithString: Self.formattedTimestamp(date)) - dateLabel.font = .systemFont(ofSize: 11) - dateLabel.textColor = .secondaryLabelColor - details.addArrangedSubview(dateLabel) - } - content.addArrangedSubview(details) - stack.addArrangedSubview(row) - } - let scroll = NSScrollView() - scroll.drawsBackground = false - scroll.hasVerticalScroller = receipts.count > 6 - stack.frame = NSRect(x: 0, y: 0, width: width - 24, height: stack.fittingSize.height) - scroll.documentView = stack - scroll.frame = NSRect(x: 12, y: 12, width: width - 24, height: min(stack.fittingSize.height, 220)) - let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: scroll.frame.height + 24)) - container.addSubview(scroll) - let controller = NSViewController() - controller.view = container - receiptPopover.contentViewController = controller - receiptPopover.contentSize = container.bounds.size - receiptPopover.behavior = .transient - receiptPopover.show(relativeTo: bounds, of: self, preferredEdge: .maxY) + @objc private func showReceipts() { + if receiptPopover.isShown { + receiptPopover.close() + return } + let stack = FlippedReceiptStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 8 + + let header = NSTextField( + labelWithString: receipts.count == 1 ? "Read by 1 person" : "Read by \(receipts.count) people" + ) + header.font = .boldSystemFont(ofSize: NSFont.systemFontSize) + stack.addArrangedSubview(header) + let width: CGFloat = 230 + for receipt in receipts.reversed() { + let row = ReceiptPopoverRowButton(userId: receipt.userId) + row.onClick = { [weak self] userId in + self?.receiptPopover.close() + self?.onFocusUser?(userId) + } + row.widthAnchor.constraint(equalToConstant: width - 24).isActive = true + row.heightAnchor.constraint(equalToConstant: 36).isActive = true + let content = row.content + let avatar = ReceiptAvatarView(frame: NSRect(x: 0, y: 0, width: 28, height: 28)) + avatar.configure( + userId: receipt.userId, avatarUrl: members[receipt.userId]?.avatarUrl, + imageLoader: imageLoader) + avatar.widthAnchor.constraint(equalToConstant: 28).isActive = true + avatar.heightAnchor.constraint(equalToConstant: 28).isActive = true + content.addArrangedSubview(avatar) - private static func formattedTimestamp(_ date: Date) -> String { - if Calendar.current.isDateInToday(date) { - return date.formatted(.dateTime.hour().minute()) - } - return date.formatted(.dateTime.weekday(.abbreviated).hour().minute()) + let details = NSStackView() + details.orientation = .vertical + details.alignment = .leading + details.spacing = 2 + let name = members[receipt.userId]?.displayName ?? receipt.userId + let nameLabel = NSTextField(labelWithString: name) + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.toolTip = name + details.addArrangedSubview(nameLabel) + if let date = receipt.date { + let dateLabel = NSTextField(labelWithString: Self.formattedTimestamp(date)) + dateLabel.font = .systemFont(ofSize: 11) + dateLabel.textColor = .secondaryLabelColor + details.addArrangedSubview(dateLabel) + } + content.addArrangedSubview(details) + stack.addArrangedSubview(row) } + let scroll = NSScrollView() + scroll.drawsBackground = false + scroll.hasVerticalScroller = receipts.count > 6 + stack.frame = NSRect(x: 0, y: 0, width: width - 24, height: stack.fittingSize.height) + scroll.documentView = stack + scroll.frame = NSRect( + x: 12, y: 12, width: width - 24, height: min(stack.fittingSize.height, 220)) + let container = NSView( + frame: NSRect(x: 0, y: 0, width: width, height: scroll.frame.height + 24)) + container.addSubview(scroll) + let controller = NSViewController() + controller.view = container + receiptPopover.contentViewController = controller + receiptPopover.contentSize = container.bounds.size + receiptPopover.behavior = .transient + receiptPopover.show(relativeTo: bounds, of: self, preferredEdge: .maxY) + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + private static func formattedTimestamp(_ date: Date) -> String { + if Calendar.current.isDateInToday(date) { + return date.formatted(.dateTime.hour().minute()) } + return date.formatted(.dateTime.weekday(.abbreviated).hour().minute()) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } private final class FlippedReceiptStackView: NSStackView { - override var isFlipped: Bool { true } + override var isFlipped: Bool { true } } private final class ReceiptPopoverRowButton: NSButton { - let content = NSStackView() - var onClick: ((String) -> Void)? - private let userId: String + let content = NSStackView() + var onClick: ((String) -> Void)? + private let userId: String - init(userId: String) { - self.userId = userId - super.init(frame: .zero) - isBordered = false - title = "" - target = self - action = #selector(activate) + init(userId: String) { + self.userId = userId + super.init(frame: .zero) + isBordered = false + title = "" + target = self + action = #selector(activate) - content.orientation = .horizontal - content.alignment = .centerY - content.spacing = 10 - content.translatesAutoresizingMaskIntoConstraints = false - addSubview(content) - NSLayoutConstraint.activate([ - content.leadingAnchor.constraint(equalTo: leadingAnchor), - content.trailingAnchor.constraint(equalTo: trailingAnchor), - content.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) - } + content.orientation = .horizontal + content.alignment = .centerY + content.spacing = 10 + content.translatesAutoresizingMaskIntoConstraints = false + addSubview(content) + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: leadingAnchor), + content.trailingAnchor.constraint(equalTo: trailingAnchor), + content.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(convert(point, from: superview)) ? self : nil - } + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } - override func mouseDown(with event: NSEvent) { - alphaValue = 0.6 - window?.displayIfNeeded() - super.mouseDown(with: event) - alphaValue = 1 - } + override func mouseDown(with event: NSEvent) { + alphaValue = 0.6 + window?.displayIfNeeded() + super.mouseDown(with: event) + alphaValue = 1 + } - @objc private func activate() { - onClick?(userId) - } + @objc private func activate() { + onClick?(userId) + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } private final class ReceiptAvatarView: NSImageView { - private var loadTask: Task? - private var avatarUrl: String? + private var loadTask: Task? + private var avatarUrl: String? - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - imageScaling = .scaleProportionallyUpOrDown - wantsLayer = true - layer?.masksToBounds = true - } + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + imageScaling = .scaleProportionallyUpOrDown + wantsLayer = true + layer?.masksToBounds = true + } - override func layout() { - super.layout() - layer?.cornerRadius = bounds.width / 2 - } + override func layout() { + super.layout() + layer?.cornerRadius = bounds.width / 2 + } - func configure(userId: String, avatarUrl: String?, imageLoader: MactrixUI.ImageLoader?) { - cancelLoad() - self.avatarUrl = avatarUrl - image = nil - layer?.backgroundColor = NSColor(userID: userId).cgColor - guard let avatarUrl, let imageLoader else { return } - if let cached = imageLoader.cachedImage(matrixUrl: avatarUrl) { - image = cached - return - } - loadTask = Task { [weak self] in - guard let image = try? await imageLoader.loadImage(matrixUrl: avatarUrl, size: nil), - !Task.isCancelled, let self, self.avatarUrl == avatarUrl else { return } - self.image = image - } + func configure(userId: String, avatarUrl: String?, imageLoader: MactrixUI.ImageLoader?) { + cancelLoad() + self.avatarUrl = avatarUrl + image = nil + layer?.backgroundColor = NSColor(userID: userId).cgColor + guard let avatarUrl, let imageLoader else { return } + if let cached = imageLoader.cachedImage(matrixUrl: avatarUrl) { + image = cached + return } - - func cancelLoad() { - loadTask?.cancel() - loadTask = nil + loadTask = Task { [weak self] in + guard let image = try? await imageLoader.loadImage(matrixUrl: avatarUrl, size: nil), + !Task.isCancelled, let self, self.avatarUrl == avatarUrl + else { return } + self.image = image } + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + func cancelLoad() { + loadTask?.cancel() + loadTask = nil + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReplyPreviewView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReplyPreviewView.swift index 3a74923..e98d52c 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReplyPreviewView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReplyPreviewView.swift @@ -3,131 +3,134 @@ import MatrixRustSDK /// A reusable, clickable summary of the message being replied to. final class MessageReplyPreviewView: NSButton { - var onClick: (() -> Void)? - private var isPending = false - - private let accent = NSView() - private let name = NSTextField(labelWithString: "") - private let message = NSTextField(labelWithString: "") - - private static let fontSize: CGFloat = 13 - private static let nameFont = NSFontManager.shared.convert( - NSFont.boldSystemFont(ofSize: fontSize), toHaveTrait: .italicFontMask - ) - private static let messageFont = NSFontManager.shared.convert( - NSFont.systemFont(ofSize: fontSize), toHaveTrait: .italicFontMask - ) - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - isBordered = false - title = "" - target = self - action = #selector(activate) - - wantsLayer = true - layer?.cornerRadius = 4 - layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.05).cgColor - - accent.wantsLayer = true - accent.layer?.cornerRadius = 1.5 - accent.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.5).cgColor - accent.translatesAutoresizingMaskIntoConstraints = false - - for field in [name, message] { - field.textColor = .labelColor - field.usesSingleLineMode = false - field.lineBreakMode = .byWordWrapping - field.maximumNumberOfLines = 0 - field.translatesAutoresizingMaskIntoConstraints = false - } - name.font = Self.nameFont - message.font = Self.messageFont - - addSubview(accent) - addSubview(name) - addSubview(message) - NSLayoutConstraint.activate([ - accent.leadingAnchor.constraint(equalTo: leadingAnchor), - accent.topAnchor.constraint(equalTo: topAnchor), - accent.bottomAnchor.constraint(equalTo: bottomAnchor), - accent.widthAnchor.constraint(equalToConstant: 3), - - name.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), - name.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), - name.topAnchor.constraint(equalTo: topAnchor, constant: 4), - - message.leadingAnchor.constraint(equalTo: name.leadingAnchor), - message.trailingAnchor.constraint(equalTo: name.trailingAnchor), - message.topAnchor.constraint(equalTo: name.bottomAnchor, constant: 8), - message.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), - ]) + var onClick: (() -> Void)? + private var isPending = false + + private let accent = NSView() + private let name = NSTextField(labelWithString: "") + private let message = NSTextField(labelWithString: "") + + private static let fontSize: CGFloat = 13 + private static let nameFont = NSFontManager.shared.convert( + NSFont.boldSystemFont(ofSize: fontSize), toHaveTrait: .italicFontMask + ) + private static let messageFont = NSFontManager.shared.convert( + NSFont.systemFont(ofSize: fontSize), toHaveTrait: .italicFontMask + ) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + isBordered = false + title = "" + target = self + action = #selector(activate) + + wantsLayer = true + layer?.cornerRadius = 4 + layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.05).cgColor + + accent.wantsLayer = true + accent.layer?.cornerRadius = 1.5 + accent.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.5).cgColor + accent.translatesAutoresizingMaskIntoConstraints = false + + for field in [name, message] { + field.textColor = .labelColor + field.usesSingleLineMode = false + field.lineBreakMode = .byWordWrapping + field.maximumNumberOfLines = 0 + field.translatesAutoresizingMaskIntoConstraints = false } - - func configure(details: EmbeddedEventDetails) { - let text = Self.text(for: details) - name.stringValue = text.name - message.stringValue = text.message - isPending = text.isPending - updateOpacity() - } - - static func height(for details: EmbeddedEventDetails, width: CGFloat) -> CGFloat { - let text = text(for: details) - let textWidth = max(width - 20, 1) - func height(_ string: String, font: NSFont) -> CGFloat { - ceil((string as NSString).boundingRect( - with: NSSize(width: textWidth, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: [.font: font] - ).height) - } - return 16 + height(text.name, font: nameFont) + height(text.message, font: messageFont) - } - - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(convert(point, from: superview)) ? self : nil + name.font = Self.nameFont + message.font = Self.messageFont + + addSubview(accent) + addSubview(name) + addSubview(message) + NSLayoutConstraint.activate([ + accent.leadingAnchor.constraint(equalTo: leadingAnchor), + accent.topAnchor.constraint(equalTo: topAnchor), + accent.bottomAnchor.constraint(equalTo: bottomAnchor), + accent.widthAnchor.constraint(equalToConstant: 3), + + name.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + name.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + name.topAnchor.constraint(equalTo: topAnchor, constant: 4), + + message.leadingAnchor.constraint(equalTo: name.leadingAnchor), + message.trailingAnchor.constraint(equalTo: name.trailingAnchor), + message.topAnchor.constraint(equalTo: name.bottomAnchor, constant: 8), + message.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + ]) + } + + func configure(details: EmbeddedEventDetails) { + let text = Self.text(for: details) + name.stringValue = text.name + message.stringValue = text.message + isPending = text.isPending + updateOpacity() + } + + static func height(for details: EmbeddedEventDetails, width: CGFloat) -> CGFloat { + let text = text(for: details) + let textWidth = max(width - 20, 1) + func height(_ string: String, font: NSFont) -> CGFloat { + ceil( + (string as NSString).boundingRect( + with: NSSize(width: textWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font] + ).height) } - - override func mouseDown(with event: NSEvent) { - updateOpacity(pressed: true) - window?.displayIfNeeded() - - // AppKit tracks the press and sends the action only for a completed click. - // Its cell highlighting does not affect this button's custom subviews. - super.mouseDown(with: event) - - updateOpacity() + return 16 + height(text.name, font: nameFont) + height(text.message, font: messageFont) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + updateOpacity(pressed: true) + window?.displayIfNeeded() + + // AppKit tracks the press and sends the action only for a completed click. + // Its cell highlighting does not affect this button's custom subviews. + super.mouseDown(with: event) + + updateOpacity() + } + + private func updateOpacity(pressed: Bool = false) { + alphaValue = (isPending ? 0.5 : 1) * (pressed ? 0.6 : 1) + } + + @objc private func activate(_ sender: NSButton) { + onClick?() + } + + private static func text(for details: EmbeddedEventDetails) -> ( + name: String, message: String, isPending: Bool + ) { + switch details { + case .unavailable, .pending: + return ("Loading reply…", "", true) + case .ready(let content, let sender, let senderProfile, _, _): + let displayName: String + if case .ready(let name, _, _, _, _) = senderProfile, let name { + displayName = name + } else { + displayName = sender + } + return (displayName, content.userInterfaceText, false) + case .error(let message): + return ("Reply unavailable", message, false) } + } - private func updateOpacity(pressed: Bool = false) { - alphaValue = (isPending ? 0.5 : 1) * (pressed ? 0.6 : 1) - } - - @objc private func activate(_ sender: NSButton) { - onClick?() - } - - private static func text(for details: EmbeddedEventDetails) -> (name: String, message: String, isPending: Bool) { - switch details { - case .unavailable, .pending: - return ("Loading reply…", "", true) - case let .ready(content, sender, senderProfile, _, _): - let displayName: String - if case let .ready(name, _, _, _, _) = senderProfile, let name { - displayName = name - } else { - displayName = sender - } - return (displayName, content.userInterfaceText, false) - case let .error(message): - return ("Reply unavailable", message, false) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift index 614210c..36fd9be 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift @@ -1,379 +1,428 @@ import AppKit -import MatrixRustSDK -import MatrixProtocols import MactrixUI import MatrixIntegration +import MatrixProtocols +import MatrixRustSDK /// Shared row chrome. The content view is created once and retained across table reuse. final class MessageRowView: NSView { - let contentKind: MessageContentKind - var onHoverChange: ((MessageRowView, Bool, NSEvent) -> Bool)? - var onSelectRequest: ((MessageRowView) -> Void)? - var onArrowKey: ((TimelineSelectionDirection) -> Void)? - private var isHovered = false - private var isMessageSelected = false - - private static let contentHorizontalInset: CGFloat = 74 - private static let replySpacing: CGFloat = 20 - private static let threadSpacing: CGFloat = 10 - private static let reactionSpacing: CGFloat = 10 - private static let receiptSpacing: CGFloat = 10 - - private let timestamp = NSTextField(labelWithString: "") - private let contentView: any MessageContentRowView - private var replyPreview: MessageReplyPreviewView? - private var replyDetails: MatrixRustSDK.EmbeddedEventDetails? - private var replyHeightConstraint: NSLayoutConstraint? - - private var contentTopToReply: NSLayoutConstraint? - private lazy var contentTopToRow = contentView.topAnchor.constraint(equalTo: topAnchor, constant: 4) - private lazy var contentBottomToRow = contentView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) - - private var threadSummaryView: MessageThreadSummaryView? - private var threadHeightConstraint: NSLayoutConstraint? - private var threadTopConstraint: NSLayoutConstraint? - private var threadBottomConstraint: NSLayoutConstraint? - - private var reactionsView: MessageReactionsView? - private var reactionsHeightConstraint: NSLayoutConstraint? - private var reactionsTopToContent: NSLayoutConstraint? - private var reactionsTopToThread: NSLayoutConstraint? - private var reactionsBottomToRow: NSLayoutConstraint? - private var reactionsTrailingToContent: NSLayoutConstraint? - private var reactionsTrailingToReceipts: NSLayoutConstraint? - private var reactions: [MatrixRustSDK.Reaction] = [] - - private var receiptsView: MessageReadReceiptsView? - private var receiptsWidthConstraint: NSLayoutConstraint? - private var receiptsTopToContent: NSLayoutConstraint? - private var receiptsTopToThread: NSLayoutConstraint? - private var receiptsTopToReactions: NSLayoutConstraint? - private var receiptsBottomToRow: NSLayoutConstraint? - - private static let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm" - return formatter - }() - - init(contentKind: MessageContentKind, contentView: any MessageContentRowView, frame frameRect: NSRect = .zero) { - self.contentKind = contentKind - self.contentView = contentView - super.init(frame: frameRect) - - timestamp.font = .systemFont(ofSize: NSFont.labelFontSize) - timestamp.textColor = .secondaryLabelColor - timestamp.alignment = .right - timestamp.translatesAutoresizingMaskIntoConstraints = false - contentView.translatesAutoresizingMaskIntoConstraints = false - contentView.onSelectRequest = { [weak self] in - guard let self else { return } - self.onSelectRequest?(self) - } - contentView.onArrowKey = { [weak self] direction in - self?.onArrowKey?(direction) - } - - wantsLayer = true - layer?.cornerRadius = 4 - addTrackingArea(NSTrackingArea( - rect: .zero, - options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], - owner: self, - userInfo: nil - )) - - addSubview(timestamp) - addSubview(contentView) - // NSTableView may retain an old encapsulated height while a reused row is reconfigured. - contentBottomToRow.priority = .init(999) - NSLayoutConstraint.activate([ - timestamp.leadingAnchor.constraint(equalTo: leadingAnchor), - timestamp.topAnchor.constraint(equalTo: topAnchor, constant: 6), - timestamp.widthAnchor.constraint(equalToConstant: 48), - - contentView.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), - contentView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), - contentTopToRow, - contentBottomToRow, - ]) + let contentKind: MessageContentKind + var onHoverChange: ((MessageRowView, Bool, NSEvent) -> Bool)? + var onSelectRequest: ((MessageRowView) -> Void)? + var onArrowKey: ((TimelineSelectionDirection) -> Void)? + private var isHovered = false + private var isMessageSelected = false + + private static let contentHorizontalInset: CGFloat = 74 + private static let replySpacing: CGFloat = 20 + private static let threadSpacing: CGFloat = 10 + private static let reactionSpacing: CGFloat = 10 + private static let receiptSpacing: CGFloat = 10 + + private let timestamp = NSTextField(labelWithString: "") + private let contentView: any MessageContentRowView + private var replyPreview: MessageReplyPreviewView? + private var replyDetails: MatrixRustSDK.EmbeddedEventDetails? + private var replyHeightConstraint: NSLayoutConstraint? + + private var contentTopToReply: NSLayoutConstraint? + private lazy var contentTopToRow = contentView.topAnchor.constraint( + equalTo: topAnchor, constant: 4) + private lazy var contentBottomToRow = contentView.bottomAnchor.constraint( + equalTo: bottomAnchor, constant: -4) + + private var threadSummaryView: MessageThreadSummaryView? + private var threadHeightConstraint: NSLayoutConstraint? + private var threadTopConstraint: NSLayoutConstraint? + private var threadBottomConstraint: NSLayoutConstraint? + + private var reactionsView: MessageReactionsView? + private var reactionsHeightConstraint: NSLayoutConstraint? + private var reactionsTopToContent: NSLayoutConstraint? + private var reactionsTopToThread: NSLayoutConstraint? + private var reactionsBottomToRow: NSLayoutConstraint? + private var reactionsTrailingToContent: NSLayoutConstraint? + private var reactionsTrailingToReceipts: NSLayoutConstraint? + private var reactions: [MatrixRustSDK.Reaction] = [] + + private var receiptsView: MessageReadReceiptsView? + private var receiptsWidthConstraint: NSLayoutConstraint? + private var receiptsTopToContent: NSLayoutConstraint? + private var receiptsTopToThread: NSLayoutConstraint? + private var receiptsTopToReactions: NSLayoutConstraint? + private var receiptsBottomToRow: NSLayoutConstraint? + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() + + init( + contentKind: MessageContentKind, contentView: any MessageContentRowView, + frame frameRect: NSRect = .zero + ) { + self.contentKind = contentKind + self.contentView = contentView + super.init(frame: frameRect) + + timestamp.font = .systemFont(ofSize: NSFont.labelFontSize) + timestamp.textColor = .secondaryLabelColor + timestamp.alignment = .right + timestamp.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.onSelectRequest = { [weak self] in + guard let self else { return } + self.onSelectRequest?(self) } - - func configure( - event: MatrixRustSDK.EventTimelineItem, - content: MatrixRustSDK.MsgLikeContent, - replyDetails: MatrixRustSDK.EmbeddedEventDetails?, - onReplyClick: (() -> Void)?, - onThreadClick: (() -> Void)?, - ownUserId: String?, - onReactionClick: ((String) -> Void)?, - roomMembers: [MatrixRustSDK.RoomMember], - matrixClient: MatrixClient?, - onFocusUser: ((String) -> Void)? - ) { - // A reused row may still have its previous table height while its content changes. - contentBottomToRow.isActive = false - threadBottomConstraint?.isActive = false - reactionsBottomToRow?.isActive = false - receiptsBottomToRow?.isActive = false - - let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) - timestamp.stringValue = Self.timeFormatter.string(from: date) - contentView.configure(content: content, matrixClient: matrixClient) - configureReply(details: replyDetails, onClick: onReplyClick) - configureThread(summary: content.threadSummary, onClick: onThreadClick) - configureReactions(content.reactions, ownUserId: ownUserId, onClick: onReactionClick) - configureReceipts(event.userReadReceipts, roomMembers: roomMembers, imageLoader: matrixClient, onFocusUser: onFocusUser) - isHovered = false - updateBackground() + contentView.onArrowKey = { [weak self] direction in + self?.onArrowKey?(direction) } - private func configureThread(summary: MatrixRustSDK.ThreadSummary?, onClick: (() -> Void)?) { - guard let summary else { - threadSummaryView?.isHidden = true - threadSummaryView?.onClick = nil - threadTopConstraint?.isActive = false - return - } - - if threadSummaryView == nil { - let view = MessageThreadSummaryView() - view.translatesAutoresizingMaskIntoConstraints = false - addSubview(view) - NSLayoutConstraint.activate([ - view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - ]) - threadSummaryView = view - threadHeightConstraint = view.heightAnchor.constraint(equalToConstant: 0) - threadHeightConstraint?.isActive = true - threadTopConstraint = view.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: Self.threadSpacing) - threadBottomConstraint = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) - threadBottomConstraint?.priority = .init(999) - if let reactionsView { - reactionsTopToThread = reactionsView.topAnchor.constraint(equalTo: view.bottomAnchor, constant: Self.reactionSpacing) - } - if let receiptsView { - receiptsTopToThread = receiptsView.topAnchor.constraint(equalTo: view.bottomAnchor, constant: Self.receiptSpacing) - } - } - - threadSummaryView?.configure(summary: summary) - threadSummaryView?.onClick = onClick - threadSummaryView?.isHidden = false - threadHeightConstraint?.constant = MessageThreadSummaryView.height(for: summary) - threadTopConstraint?.isActive = true + wantsLayer = true + layer?.cornerRadius = 4 + addTrackingArea( + NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + )) + + addSubview(timestamp) + addSubview(contentView) + // NSTableView may retain an old encapsulated height while a reused row is reconfigured. + contentBottomToRow.priority = .init(999) + NSLayoutConstraint.activate([ + timestamp.leadingAnchor.constraint(equalTo: leadingAnchor), + timestamp.topAnchor.constraint(equalTo: topAnchor, constant: 6), + timestamp.widthAnchor.constraint(equalToConstant: 48), + + contentView.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + contentTopToRow, + contentBottomToRow, + ]) + } + + func configure( + event: MatrixRustSDK.EventTimelineItem, + content: MatrixRustSDK.MsgLikeContent, + replyDetails: MatrixRustSDK.EmbeddedEventDetails?, + onReplyClick: (() -> Void)?, + onThreadClick: (() -> Void)?, + ownUserId: String?, + onReactionClick: ((String) -> Void)?, + roomMembers: [MatrixRustSDK.RoomMember], + matrixClient: MatrixClient?, + onFocusUser: ((String) -> Void)? + ) { + // A reused row may still have its previous table height while its content changes. + contentBottomToRow.isActive = false + threadBottomConstraint?.isActive = false + reactionsBottomToRow?.isActive = false + receiptsBottomToRow?.isActive = false + + let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) + timestamp.stringValue = Self.timeFormatter.string(from: date) + contentView.configure(content: content, matrixClient: matrixClient) + configureReply(details: replyDetails, onClick: onReplyClick) + configureThread(summary: content.threadSummary, onClick: onThreadClick) + configureReactions(content.reactions, ownUserId: ownUserId, onClick: onReactionClick) + configureReceipts( + event.userReadReceipts, roomMembers: roomMembers, imageLoader: matrixClient, + onFocusUser: onFocusUser) + isHovered = false + updateBackground() + } + + private func configureThread(summary: MatrixRustSDK.ThreadSummary?, onClick: (() -> Void)?) { + guard let summary else { + threadSummaryView?.isHidden = true + threadSummaryView?.onClick = nil + threadTopConstraint?.isActive = false + return } - private func configureReactions(_ reactions: [MatrixRustSDK.Reaction], ownUserId: String?, onClick: ((String) -> Void)?) { - self.reactions = reactions - if !reactions.isEmpty, reactionsView == nil { - let view = MessageReactionsView(frame: .zero) - view.translatesAutoresizingMaskIntoConstraints = false - addSubview(view) - view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true - reactionsTrailingToContent = view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) - reactionsView = view - reactionsHeightConstraint = view.heightAnchor.constraint(equalToConstant: 0) - reactionsHeightConstraint?.isActive = true - reactionsTopToContent = view.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: Self.reactionSpacing) - if let threadSummaryView { - reactionsTopToThread = view.topAnchor.constraint(equalTo: threadSummaryView.bottomAnchor, constant: Self.reactionSpacing) - } - reactionsBottomToRow = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) - reactionsBottomToRow?.priority = .init(999) - if let receiptsView { - receiptsTopToReactions = receiptsView.topAnchor.constraint(equalTo: view.topAnchor, constant: 5) - reactionsTrailingToReceipts = view.trailingAnchor.constraint(equalTo: receiptsView.leadingAnchor, constant: -Self.receiptSpacing) - } - } - - reactionsView?.configure(reactions: reactions, ownUserId: ownUserId) - reactionsView?.onReactionClick = onClick - reactionsView?.isHidden = reactions.isEmpty - - reactionsTopToContent?.isActive = false - reactionsTopToThread?.isActive = false - - if !reactions.isEmpty { - (threadSummaryView?.isHidden == false ? reactionsTopToThread : reactionsTopToContent)?.isActive = true - updateReactionsHeight() - } + if threadSummaryView == nil { + let view = MessageThreadSummaryView() + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + threadSummaryView = view + threadHeightConstraint = view.heightAnchor.constraint(equalToConstant: 0) + threadHeightConstraint?.isActive = true + threadTopConstraint = view.topAnchor.constraint( + equalTo: contentView.bottomAnchor, constant: Self.threadSpacing) + threadBottomConstraint = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + threadBottomConstraint?.priority = .init(999) + if let reactionsView { + reactionsTopToThread = reactionsView.topAnchor.constraint( + equalTo: view.bottomAnchor, constant: Self.reactionSpacing) + } + if let receiptsView { + receiptsTopToThread = receiptsView.topAnchor.constraint( + equalTo: view.bottomAnchor, constant: Self.receiptSpacing) + } } - private func configureReceipts(_ receipts: [String: MatrixProtocols.Receipt], roomMembers: [MatrixRustSDK.RoomMember], imageLoader: MactrixUI.ImageLoader?, onFocusUser: ((String) -> Void)?) { - if !receipts.isEmpty, receiptsView == nil { - let view = MessageReadReceiptsView() - view.translatesAutoresizingMaskIntoConstraints = false - addSubview(view) - receiptsView = view - NSLayoutConstraint.activate([ - view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - view.heightAnchor.constraint(equalToConstant: MessageReadReceiptsView.rowHeight), - ]) - receiptsWidthConstraint = view.widthAnchor.constraint(equalToConstant: 0) - receiptsWidthConstraint?.isActive = true - receiptsTopToContent = view.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: Self.receiptSpacing) - if let threadSummaryView { - receiptsTopToThread = view.topAnchor.constraint(equalTo: threadSummaryView.bottomAnchor, constant: Self.receiptSpacing) - } - if let reactionsView { - receiptsTopToReactions = view.topAnchor.constraint(equalTo: reactionsView.topAnchor, constant: 5) - reactionsTrailingToReceipts = reactionsView.trailingAnchor.constraint(equalTo: view.leadingAnchor, constant: -Self.receiptSpacing) - } - receiptsBottomToRow = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) - // NSTableView can briefly keep the old row height while configuring a reused view. - receiptsBottomToRow?.priority = .init(999) - } - - receiptsView?.isHidden = receipts.isEmpty - receiptsView?.onFocusUser = onFocusUser - if !receipts.isEmpty { - receiptsView?.configure(receipts: receipts, members: roomMembers, imageLoader: imageLoader) - receiptsWidthConstraint?.constant = MessageReadReceiptsView.width(for: receipts.count) - } - - contentBottomToRow.isActive = false - threadBottomConstraint?.isActive = false - reactionsBottomToRow?.isActive = false - receiptsTopToContent?.isActive = false - receiptsTopToThread?.isActive = false - receiptsTopToReactions?.isActive = false - receiptsBottomToRow?.isActive = false - let trailingConstraints = [reactionsTrailingToContent, reactionsTrailingToReceipts].compactMap { $0 } - NSLayoutConstraint.deactivate(trailingConstraints) - (receipts.isEmpty || reactions.isEmpty ? reactionsTrailingToContent : reactionsTrailingToReceipts)?.isActive = true - - if receipts.isEmpty { - if !reactions.isEmpty { - reactionsBottomToRow?.isActive = true - } else if threadSummaryView?.isHidden == false { - threadBottomConstraint?.isActive = true - } else { - contentBottomToRow.isActive = true - } - } else { - if !reactions.isEmpty { - receiptsTopToReactions?.isActive = true - reactionsBottomToRow?.isActive = true - } else if threadSummaryView?.isHidden == false { - receiptsTopToThread?.isActive = true - receiptsBottomToRow?.isActive = true - } else { - receiptsTopToContent?.isActive = true - receiptsBottomToRow?.isActive = true - } - } - updateReactionsHeight() + threadSummaryView?.configure(summary: summary) + threadSummaryView?.onClick = onClick + threadSummaryView?.isHidden = false + threadHeightConstraint?.constant = MessageThreadSummaryView.height(for: summary) + threadTopConstraint?.isActive = true + } + + private func configureReactions( + _ reactions: [MatrixRustSDK.Reaction], ownUserId: String?, onClick: ((String) -> Void)? + ) { + self.reactions = reactions + if !reactions.isEmpty, reactionsView == nil { + let view = MessageReactionsView(frame: .zero) + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true + reactionsTrailingToContent = view.trailingAnchor.constraint( + equalTo: contentView.trailingAnchor) + reactionsView = view + reactionsHeightConstraint = view.heightAnchor.constraint(equalToConstant: 0) + reactionsHeightConstraint?.isActive = true + reactionsTopToContent = view.topAnchor.constraint( + equalTo: contentView.bottomAnchor, constant: Self.reactionSpacing) + if let threadSummaryView { + reactionsTopToThread = view.topAnchor.constraint( + equalTo: threadSummaryView.bottomAnchor, constant: Self.reactionSpacing) + } + reactionsBottomToRow = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + reactionsBottomToRow?.priority = .init(999) + if let receiptsView { + receiptsTopToReactions = receiptsView.topAnchor.constraint( + equalTo: view.topAnchor, constant: 5) + reactionsTrailingToReceipts = view.trailingAnchor.constraint( + equalTo: receiptsView.leadingAnchor, constant: -Self.receiptSpacing) + } } - func configureReply(details: MatrixRustSDK.EmbeddedEventDetails?, onClick: (() -> Void)?) { - replyDetails = details - guard let details else { - replyPreview?.isHidden = true - replyPreview?.onClick = nil - contentTopToReply?.isActive = false - contentTopToRow.isActive = true - return - } - - if replyPreview == nil { - let preview = MessageReplyPreviewView() - preview.translatesAutoresizingMaskIntoConstraints = false - addSubview(preview) - NSLayoutConstraint.activate([ - preview.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - preview.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - preview.topAnchor.constraint(equalTo: topAnchor, constant: 4), - ]) - replyPreview = preview - replyHeightConstraint = preview.heightAnchor.constraint(equalToConstant: 28) - replyHeightConstraint?.isActive = true - contentTopToReply = contentView.topAnchor.constraint(equalTo: preview.bottomAnchor, constant: Self.replySpacing) - } - - replyPreview?.configure(details: details) - replyPreview?.onClick = onClick - replyPreview?.isHidden = false - contentTopToRow.isActive = false - contentTopToReply?.isActive = true - updateReplyHeight() - } + reactionsView?.configure(reactions: reactions, ownUserId: ownUserId) + reactionsView?.onReactionClick = onClick + reactionsView?.isHidden = reactions.isEmpty - override func layout() { - super.layout() - updateReplyHeight() - updateReactionsHeight() - } + reactionsTopToContent?.isActive = false + reactionsTopToThread?.isActive = false - private func updateReplyHeight() { - guard let replyDetails, bounds.width > 0 else { return } - let height = MessageReplyPreviewView.height(for: replyDetails, width: max(bounds.width - Self.contentHorizontalInset, 1)) - if replyHeightConstraint?.constant != height { - replyHeightConstraint?.constant = height - } + if !reactions.isEmpty { + (threadSummaryView?.isHidden == false ? reactionsTopToThread : reactionsTopToContent)? + .isActive = true + updateReactionsHeight() } - - private func updateReactionsHeight() { - guard !reactions.isEmpty, bounds.width > 0 else { return } - let receiptWidth = receiptsView?.isHidden == false - ? MessageReadReceiptsView.width(for: receiptsView?.receiptCount ?? 0) + Self.receiptSpacing : 0 - let height = MessageReactionsView.height(for: reactions, width: max(bounds.width - Self.contentHorizontalInset - receiptWidth, 1)) - if reactionsHeightConstraint?.constant != height { - reactionsHeightConstraint?.constant = height - } + } + + private func configureReceipts( + _ receipts: [String: MatrixProtocols.Receipt], roomMembers: [MatrixRustSDK.RoomMember], + imageLoader: MactrixUI.ImageLoader?, onFocusUser: ((String) -> Void)? + ) { + if !receipts.isEmpty, receiptsView == nil { + let view = MessageReadReceiptsView() + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + receiptsView = view + NSLayoutConstraint.activate([ + view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + view.heightAnchor.constraint(equalToConstant: MessageReadReceiptsView.rowHeight), + ]) + receiptsWidthConstraint = view.widthAnchor.constraint(equalToConstant: 0) + receiptsWidthConstraint?.isActive = true + receiptsTopToContent = view.topAnchor.constraint( + equalTo: contentView.bottomAnchor, constant: Self.receiptSpacing) + if let threadSummaryView { + receiptsTopToThread = view.topAnchor.constraint( + equalTo: threadSummaryView.bottomAnchor, constant: Self.receiptSpacing) + } + if let reactionsView { + receiptsTopToReactions = view.topAnchor.constraint( + equalTo: reactionsView.topAnchor, constant: 5) + reactionsTrailingToReceipts = reactionsView.trailingAnchor.constraint( + equalTo: view.leadingAnchor, constant: -Self.receiptSpacing) + } + receiptsBottomToRow = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + // NSTableView can briefly keep the old row height while configuring a reused view. + receiptsBottomToRow?.priority = .init(999) } - override func mouseEntered(with event: NSEvent) { - if onHoverChange?(self, true, event) ?? true { - setHoverHighlight(true) - } + receiptsView?.isHidden = receipts.isEmpty + receiptsView?.onFocusUser = onFocusUser + if !receipts.isEmpty { + receiptsView?.configure(receipts: receipts, members: roomMembers, imageLoader: imageLoader) + receiptsWidthConstraint?.constant = MessageReadReceiptsView.width(for: receipts.count) } - override func mouseDown(with event: NSEvent) { - onSelectRequest?(self) - super.mouseDown(with: event) + contentBottomToRow.isActive = false + threadBottomConstraint?.isActive = false + reactionsBottomToRow?.isActive = false + receiptsTopToContent?.isActive = false + receiptsTopToThread?.isActive = false + receiptsTopToReactions?.isActive = false + receiptsBottomToRow?.isActive = false + let trailingConstraints = [reactionsTrailingToContent, reactionsTrailingToReceipts].compactMap { + $0 } - - override func mouseExited(with event: NSEvent) { - setHoverHighlight(onHoverChange?(self, false, event) ?? false) + NSLayoutConstraint.deactivate(trailingConstraints) + (receipts.isEmpty || reactions.isEmpty + ? reactionsTrailingToContent : reactionsTrailingToReceipts)?.isActive = true + + if receipts.isEmpty { + if !reactions.isEmpty { + reactionsBottomToRow?.isActive = true + } else if threadSummaryView?.isHidden == false { + threadBottomConstraint?.isActive = true + } else { + contentBottomToRow.isActive = true + } + } else { + if !reactions.isEmpty { + receiptsTopToReactions?.isActive = true + reactionsBottomToRow?.isActive = true + } else if threadSummaryView?.isHidden == false { + receiptsTopToThread?.isActive = true + receiptsBottomToRow?.isActive = true + } else { + receiptsTopToContent?.isActive = true + receiptsBottomToRow?.isActive = true + } } - - func setHoverHighlight(_ highlighted: Bool) { - isHovered = highlighted - updateBackground() + updateReactionsHeight() + } + + func configureReply(details: MatrixRustSDK.EmbeddedEventDetails?, onClick: (() -> Void)?) { + replyDetails = details + guard let details else { + replyPreview?.isHidden = true + replyPreview?.onClick = nil + contentTopToReply?.isActive = false + contentTopToRow.isActive = true + return } - func setSelected(_ selected: Bool) { - isMessageSelected = selected - updateBackground() + if replyPreview == nil { + let preview = MessageReplyPreviewView() + preview.translatesAutoresizingMaskIntoConstraints = false + addSubview(preview) + NSLayoutConstraint.activate([ + preview.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + preview.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + preview.topAnchor.constraint(equalTo: topAnchor, constant: 4), + ]) + replyPreview = preview + replyHeightConstraint = preview.heightAnchor.constraint(equalToConstant: 28) + replyHeightConstraint?.isActive = true + contentTopToReply = contentView.topAnchor.constraint( + equalTo: preview.bottomAnchor, constant: Self.replySpacing) } - private func updateBackground() { - if isMessageSelected { - layer?.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.1).cgColor - } else { - layer?.backgroundColor = isHovered ? .init(gray: 0.5, alpha: 0.1) : nil - } + replyPreview?.configure(details: details) + replyPreview?.onClick = onClick + replyPreview?.isHidden = false + contentTopToRow.isActive = false + contentTopToReply?.isActive = true + updateReplyHeight() + } + + override func layout() { + super.layout() + updateReplyHeight() + updateReactionsHeight() + } + + private func updateReplyHeight() { + guard let replyDetails, bounds.width > 0 else { return } + let height = MessageReplyPreviewView.height( + for: replyDetails, width: max(bounds.width - Self.contentHorizontalInset, 1)) + if replyHeightConstraint?.constant != height { + replyHeightConstraint?.constant = height } - - func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat, replyDetails: MatrixRustSDK.EmbeddedEventDetails?, receiptCount: Int) -> CGFloat { - let contentWidth = max(width - Self.contentHorizontalInset, 1) - let replyHeight = replyDetails.map { - MessageReplyPreviewView.height(for: $0, width: contentWidth) + Self.replySpacing - } ?? 0 - let threadHeight = content.threadSummary.map { - MessageThreadSummaryView.height(for: $0) + Self.threadSpacing - } ?? 0 - let availableReactionWidth = contentWidth - (receiptCount > 0 ? MessageReadReceiptsView.width(for: receiptCount) + Self.receiptSpacing : 0) - let reactionsHeight = content.reactions.isEmpty ? 0 : - MessageReactionsView.height(for: content.reactions, width: max(availableReactionWidth, 1)) + Self.reactionSpacing - let receiptsHeight = receiptCount == 0 || !content.reactions.isEmpty ? 0 : MessageReadReceiptsView.rowHeight + Self.receiptSpacing - return max(ceil(contentView.height(for: content, width: contentWidth)) + replyHeight + threadHeight + reactionsHeight + receiptsHeight + 8, 28) + } + + private func updateReactionsHeight() { + guard !reactions.isEmpty, bounds.width > 0 else { return } + let receiptWidth = + receiptsView?.isHidden == false + ? MessageReadReceiptsView.width(for: receiptsView?.receiptCount ?? 0) + Self.receiptSpacing + : 0 + let height = MessageReactionsView.height( + for: reactions, width: max(bounds.width - Self.contentHorizontalInset - receiptWidth, 1)) + if reactionsHeightConstraint?.constant != height { + reactionsHeightConstraint?.constant = height } + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + override func mouseEntered(with event: NSEvent) { + if onHoverChange?(self, true, event) ?? true { + setHoverHighlight(true) + } + } + + override func mouseDown(with event: NSEvent) { + onSelectRequest?(self) + super.mouseDown(with: event) + } + + override func mouseExited(with event: NSEvent) { + setHoverHighlight(onHoverChange?(self, false, event) ?? false) + } + + func setHoverHighlight(_ highlighted: Bool) { + isHovered = highlighted + updateBackground() + } + + func setSelected(_ selected: Bool) { + isMessageSelected = selected + updateBackground() + } + + private func updateBackground() { + if isMessageSelected { + layer?.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.1).cgColor + } else { + layer?.backgroundColor = isHovered ? .init(gray: 0.5, alpha: 0.1) : nil } + } + + func height( + for content: MatrixRustSDK.MsgLikeContent, width: CGFloat, + replyDetails: MatrixRustSDK.EmbeddedEventDetails?, receiptCount: Int + ) -> CGFloat { + let contentWidth = max(width - Self.contentHorizontalInset, 1) + let replyHeight = + replyDetails.map { + MessageReplyPreviewView.height(for: $0, width: contentWidth) + Self.replySpacing + } ?? 0 + let threadHeight = + content.threadSummary.map { + MessageThreadSummaryView.height(for: $0) + Self.threadSpacing + } ?? 0 + let availableReactionWidth = + contentWidth + - (receiptCount > 0 + ? MessageReadReceiptsView.width(for: receiptCount) + Self.receiptSpacing : 0) + let reactionsHeight = + content.reactions.isEmpty + ? 0 + : MessageReactionsView.height(for: content.reactions, width: max(availableReactionWidth, 1)) + + Self.reactionSpacing + let receiptsHeight = + receiptCount == 0 || !content.reactions.isEmpty + ? 0 : MessageReadReceiptsView.rowHeight + Self.receiptSpacing + return max( + ceil(contentView.height(for: content, width: contentWidth)) + replyHeight + threadHeight + + reactionsHeight + receiptsHeight + 8, 28) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift index e91c1a7..24ffaa6 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift @@ -5,188 +5,207 @@ import MessageFormatting /// Retains the NSTextView and its layout machinery when its table row is reused. final class MessageTextContentView: NSView, MessageContentRowView { - var onSelectRequest: (() -> Void)? - var onArrowKey: ((TimelineSelectionDirection) -> Void)? - private let bodyText = OcclusionAwareTextView(frame: .zero) - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - bodyText.isSelectable = true - bodyText.isEditable = false - bodyText.isRichText = true - bodyText.drawsBackground = false - bodyText.textContainerInset = .zero - bodyText.textContainer?.lineFragmentPadding = 0 - bodyText.textContainer?.widthTracksTextView = true - bodyText.isHorizontallyResizable = false - bodyText.isVerticallyResizable = true - bodyText.translatesAutoresizingMaskIntoConstraints = false - bodyText.onMouseDown = { [weak self] in self?.onSelectRequest?() } - bodyText.onArrowKey = { [weak self] direction in self?.onArrowKey?(direction) } - - addSubview(bodyText) - NSLayoutConstraint.activate([ - bodyText.leadingAnchor.constraint(equalTo: leadingAnchor), - bodyText.trailingAnchor.constraint(equalTo: trailingAnchor), - bodyText.topAnchor.constraint(equalTo: topAnchor), - bodyText.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - func configure(content: MatrixRustSDK.MsgLikeContent, matrixClient: MatrixClient?) { - bodyText.textStorage?.setAttributedString(Self.attributedBody(for: content)) - } - - func configureCaption(_ caption: String?, formatted: FormattedBody?) { - bodyText.textStorage?.setAttributedString(Self.attributedCaption(caption, formatted: formatted)) - } - - func height(forCaption caption: String?, formatted: FormattedBody?, width: CGFloat) -> CGFloat { - measure(Self.attributedCaption(caption, formatted: formatted), width: width) - } - - func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat) -> CGFloat { - measure(Self.attributedBody(for: content), width: width) - } - - private func measure(_ text: NSAttributedString, width: CGFloat) -> CGFloat { - guard let textStorage = bodyText.textStorage, - let layoutManager = bodyText.layoutManager, - let textContainer = bodyText.textContainer else { return 20 } - - textContainer.widthTracksTextView = false - textContainer.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) - textStorage.setAttributedString(text) - layoutManager.ensureLayout(for: textContainer) - return layoutManager.usedRect(for: textContainer).height + var onSelectRequest: (() -> Void)? + var onArrowKey: ((TimelineSelectionDirection) -> Void)? + private let bodyText = OcclusionAwareTextView(frame: .zero) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + bodyText.isSelectable = true + bodyText.isEditable = false + bodyText.isRichText = true + bodyText.drawsBackground = false + bodyText.textContainerInset = .zero + bodyText.textContainer?.lineFragmentPadding = 0 + bodyText.textContainer?.widthTracksTextView = true + bodyText.isHorizontallyResizable = false + bodyText.isVerticallyResizable = true + bodyText.translatesAutoresizingMaskIntoConstraints = false + bodyText.onMouseDown = { [weak self] in self?.onSelectRequest?() } + bodyText.onArrowKey = { [weak self] direction in self?.onArrowKey?(direction) } + + addSubview(bodyText) + NSLayoutConstraint.activate([ + bodyText.leadingAnchor.constraint(equalTo: leadingAnchor), + bodyText.trailingAnchor.constraint(equalTo: trailingAnchor), + bodyText.topAnchor.constraint(equalTo: topAnchor), + bodyText.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + func configure(content: MatrixRustSDK.MsgLikeContent, matrixClient: MatrixClient?) { + bodyText.textStorage?.setAttributedString(Self.attributedBody(for: content)) + } + + func configureCaption(_ caption: String?, formatted: FormattedBody?) { + bodyText.textStorage?.setAttributedString(Self.attributedCaption(caption, formatted: formatted)) + } + + func height(forCaption caption: String?, formatted: FormattedBody?, width: CGFloat) -> CGFloat { + measure(Self.attributedCaption(caption, formatted: formatted), width: width) + } + + func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat) -> CGFloat { + measure(Self.attributedBody(for: content), width: width) + } + + private func measure(_ text: NSAttributedString, width: CGFloat) -> CGFloat { + guard let textStorage = bodyText.textStorage, + let layoutManager = bodyText.layoutManager, + let textContainer = bodyText.textContainer + else { return 20 } + + textContainer.widthTracksTextView = false + textContainer.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) + textStorage.setAttributedString(text) + layoutManager.ensureLayout(for: textContainer) + return layoutManager.usedRect(for: textContainer).height + } + + private static func attributedCaption(_ caption: String?, formatted: FormattedBody?) + -> NSAttributedString + { + let fontSize = UserDefaults.standard.object(forKey: "fontSize") as? Int ?? 13 + if let formatted, formatted.format == .html { + return parseFormattedBody(formatted.body, baseFontSize: CGFloat(fontSize)) } - - private static func attributedCaption(_ caption: String?, formatted: FormattedBody?) -> NSAttributedString { - let fontSize = UserDefaults.standard.object(forKey: "fontSize") as? Int ?? 13 - if let formatted, formatted.format == .html { - return parseFormattedBody(formatted.body, baseFontSize: CGFloat(fontSize)) - } - let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) - let attributed = (try? AttributedString(markdown: caption ?? "", options: options)) ?? AttributedString(caption ?? "") - let result = NSMutableAttributedString(attributedString: NSAttributedString(attributed)) - let fullRange = NSRange(location: 0, length: result.length) - result.enumerateAttribute(.font, in: fullRange) { font, range, _ in - if font == nil { - result.addAttribute(.font, value: NSFont.systemFont(ofSize: CGFloat(fontSize)), range: range) - } - } - return result - } - - private static func attributedBody(for content: MatrixRustSDK.MsgLikeContent) -> NSAttributedString { - let fontSize = UserDefaults.standard.object(forKey: "fontSize") as? Int ?? 13 - let font = NSFont.systemFont(ofSize: CGFloat(fontSize)) - func plain(_ text: String, color: NSColor = .labelColor, italic: Bool = false) -> NSAttributedString { - NSAttributedString(string: text, attributes: [ - .font: italic ? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font, - .foregroundColor: color, - ]) - } - - switch content.kind { - case let .message(message): - switch message.msgType { - case let .text(text): - return attributedText(for: text, font: font, color: .labelColor) - case let .notice(notice): - return attributedText(for: notice, font: font, color: .secondaryLabelColor) - case let .emote(emote): - return plain("Emote: \(emote.body)") - case let .audio(audio): - return plain("Audio: \(audio.caption ?? "no caption") \(audio.filename)") - case let .gallery(gallery): - return plain("Gallery: \(gallery.body)") - case let .location(location): - return plain("Location: \(location.body) \(location.geoUri)") - case let .other(msgtype: msgtype, body: body): - return plain("Other: \(msgtype) \(body)") - case .image, .video, .file: - return NSAttributedString() - } - case let .sticker(body: body, info: _, source: _): - return plain("Sticker: \(body)") - case let .poll(question: question, kind: _, maxSelections: _, answers: _, votes: _, endTime: _, hasBeenEdited: _): - return plain("Poll: \(question)") - case .redacted: - return plain("Message redacted", color: .secondaryLabelColor, italic: true) - case .unableToDecrypt: - return plain("Unable to decrypt", color: .secondaryLabelColor, italic: true) - case let .other(eventType: eventType): - return plain("Custom event: \(eventType.userInterfaceText)") - case let .liveLocation(content: location): - return plain("Live location: \(location.description ?? "no description")") - } + let options = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace) + let attributed = + (try? AttributedString(markdown: caption ?? "", options: options)) + ?? AttributedString(caption ?? "") + let result = NSMutableAttributedString(attributedString: NSAttributedString(attributed)) + let fullRange = NSRange(location: 0, length: result.length) + result.enumerateAttribute(.font, in: fullRange) { font, range, _ in + if font == nil { + result.addAttribute( + .font, value: NSFont.systemFont(ofSize: CGFloat(fontSize)), range: range) + } } - - private static func attributedText( - for message: some MatrixIntegration.MessageContent, font: NSFont, color: NSColor - ) -> NSAttributedString { - if let formatted = message.formatted, formatted.format == .html { - let result = NSMutableAttributedString(attributedString: parseFormattedBody( - formatted.body, baseFontSize: font.pointSize - )) - if color == .secondaryLabelColor { - result.addAttribute(.foregroundColor, value: color, range: NSRange(location: 0, length: result.length)) - } - return result - } - return NSAttributedString(string: message.body.trimmingCharacters(in: .whitespacesAndNewlines), attributes: [ - .font: font, - .foregroundColor: color, + return result + } + + private static func attributedBody(for content: MatrixRustSDK.MsgLikeContent) + -> NSAttributedString + { + let fontSize = UserDefaults.standard.object(forKey: "fontSize") as? Int ?? 13 + let font = NSFont.systemFont(ofSize: CGFloat(fontSize)) + func plain(_ text: String, color: NSColor = .labelColor, italic: Bool = false) + -> NSAttributedString + { + NSAttributedString( + string: text, + attributes: [ + .font: italic ? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font, + .foregroundColor: color, ]) } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + switch content.kind { + case .message(let message): + switch message.msgType { + case .text(let text): + return attributedText(for: text, font: font, color: .labelColor) + case .notice(let notice): + return attributedText(for: notice, font: font, color: .secondaryLabelColor) + case .emote(let emote): + return plain("Emote: \(emote.body)") + case .audio(let audio): + return plain("Audio: \(audio.caption ?? "no caption") \(audio.filename)") + case .gallery(let gallery): + return plain("Gallery: \(gallery.body)") + case .location(let location): + return plain("Location: \(location.body) \(location.geoUri)") + case .other(let msgtype, let body): + return plain("Other: \(msgtype) \(body)") + case .image, .video, .file: + return NSAttributedString() + } + case .sticker(let body, info: _, source: _): + return plain("Sticker: \(body)") + case .poll( + let question, kind: _, maxSelections: _, answers: _, votes: _, endTime: _, + hasBeenEdited: _): + return plain("Poll: \(question)") + case .redacted: + return plain("Message redacted", color: .secondaryLabelColor, italic: true) + case .unableToDecrypt: + return plain("Unable to decrypt", color: .secondaryLabelColor, italic: true) + case .other(let eventType): + return plain("Custom event: \(eventType.userInterfaceText)") + case .liveLocation(content: let location): + return plain("Live location: \(location.description ?? "no description")") + } + } + + private static func attributedText( + for message: some MatrixIntegration.MessageContent, font: NSFont, color: NSColor + ) -> NSAttributedString { + if let formatted = message.formatted, formatted.format == .html { + let result = NSMutableAttributedString( + attributedString: parseFormattedBody( + formatted.body, baseFontSize: font.pointSize + )) + if color == .secondaryLabelColor { + result.addAttribute( + .foregroundColor, value: color, range: NSRange(location: 0, length: result.length)) + } + return result } + return NSAttributedString( + string: message.body.trimmingCharacters(in: .whitespacesAndNewlines), + attributes: [ + .font: font, + .foregroundColor: color, + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } /// Lets a view drawn above the text own the cursor without knowing what that view is. private final class OcclusionAwareTextView: NSTextView { - var onMouseDown: (() -> Void)? - var onArrowKey: ((TimelineSelectionDirection) -> Void)? - - override func keyDown(with event: NSEvent) { - if let direction = TimelineSelectionDirection(event: event), let onArrowKey { - onArrowKey(direction) - } else { - super.keyDown(with: event) - } - } - - override func mouseDown(with event: NSEvent) { - onMouseDown?() - super.mouseDown(with: event) - } - - override func cursorUpdate(with event: NSEvent) { - guard shouldHandlePointerEvent(event) else { return } - super.cursorUpdate(with: event) + var onMouseDown: (() -> Void)? + var onArrowKey: ((TimelineSelectionDirection) -> Void)? + + override func keyDown(with event: NSEvent) { + if let direction = TimelineSelectionDirection(event: event), let onArrowKey { + onArrowKey(direction) + } else { + super.keyDown(with: event) } - - override func mouseMoved(with event: NSEvent) { - // NSTextView also sets its cursor during mouse movement, independently - // of cursorUpdate. Covered text must yield to the view above it here too. - guard shouldHandlePointerEvent(event) else { return } - super.mouseMoved(with: event) + } + + override func mouseDown(with event: NSEvent) { + onMouseDown?() + super.mouseDown(with: event) + } + + override func cursorUpdate(with event: NSEvent) { + guard shouldHandlePointerEvent(event) else { return } + super.cursorUpdate(with: event) + } + + override func mouseMoved(with event: NSEvent) { + // NSTextView also sets its cursor during mouse movement, independently + // of cursorUpdate. Covered text must yield to the view above it here too. + guard shouldHandlePointerEvent(event) else { return } + super.mouseMoved(with: event) + } + + private func shouldHandlePointerEvent(_ event: NSEvent) -> Bool { + guard let window, + let contentView = window.contentView, + let contentSuperview = contentView.superview, + let hitView = contentView.hitTest(contentSuperview.convert(event.locationInWindow, from: nil)) + else { + return true } - private func shouldHandlePointerEvent(_ event: NSEvent) -> Bool { - guard let window, - let contentView = window.contentView, - let contentSuperview = contentView.superview, - let hitView = contentView.hitTest(contentSuperview.convert(event.locationInWindow, from: nil)) - else { - return true - } - - return hitView === self || hitView.isDescendant(of: self) - } + return hitView === self || hitView.isDescendant(of: self) + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageThreadSummaryView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageThreadSummaryView.swift index 8837a81..3291202 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageThreadSummaryView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageThreadSummaryView.swift @@ -3,89 +3,89 @@ import MatrixRustSDK /// A reusable button for opening the replies to a message. final class MessageThreadSummaryView: NSButton { - var onClick: (() -> Void)? - - private let icon = NSImageView() - private let heading = NSTextField(labelWithString: "") - private let snippet = NSTextField(labelWithString: "") - - private static let font = NSFontManager.shared.convert( - NSFont.systemFont(ofSize: 13), toHaveTrait: .italicFontMask - ) - private static let lineHeight = ceil(NSFont.systemFont(ofSize: 13).boundingRectForFont.height) - private static let padding: CGFloat = 8 - private static let lineSpacing: CGFloat = 4 - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - isBordered = false - title = "" - target = self - action = #selector(activate) - wantsLayer = true - layer?.cornerRadius = 4 - layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.05).cgColor - - icon.image = NSImage(systemSymbolName: "arrow.turn.down.right", accessibilityDescription: nil) - icon.contentTintColor = .labelColor - icon.translatesAutoresizingMaskIntoConstraints = false - - for field in [heading, snippet] { - field.font = Self.font - field.lineBreakMode = .byTruncatingTail - field.maximumNumberOfLines = 1 - field.translatesAutoresizingMaskIntoConstraints = false - } - snippet.textColor = .secondaryLabelColor - - addSubview(icon) - addSubview(heading) - addSubview(snippet) - NSLayoutConstraint.activate([ - icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.padding), - icon.centerYAnchor.constraint(equalTo: heading.centerYAnchor), - icon.widthAnchor.constraint(equalToConstant: 16), - icon.heightAnchor.constraint(equalToConstant: 16), - - heading.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4), - heading.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.padding), - heading.topAnchor.constraint(equalTo: topAnchor, constant: Self.padding), - - snippet.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.padding), - snippet.trailingAnchor.constraint(equalTo: heading.trailingAnchor), - snippet.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: Self.lineSpacing), - ]) - } - - func configure(summary: ThreadSummary) { - heading.stringValue = "Thread (\(summary.numReplies()) messages)" - snippet.stringValue = summary.description ?? "" - snippet.isHidden = summary.description == nil - } - - static func height(for summary: ThreadSummary) -> CGFloat { - padding * 2 + lineHeight * (summary.description == nil ? 1 : 2) - + (summary.description == nil ? 0 : lineSpacing) - } - - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(convert(point, from: superview)) ? self : nil - } - - override func mouseDown(with event: NSEvent) { - alphaValue = 0.6 - window?.displayIfNeeded() - super.mouseDown(with: event) - alphaValue = 1 - } - - @objc private func activate(_ sender: NSButton) { - onClick?() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + var onClick: (() -> Void)? + + private let icon = NSImageView() + private let heading = NSTextField(labelWithString: "") + private let snippet = NSTextField(labelWithString: "") + + private static let font = NSFontManager.shared.convert( + NSFont.systemFont(ofSize: 13), toHaveTrait: .italicFontMask + ) + private static let lineHeight = ceil(NSFont.systemFont(ofSize: 13).boundingRectForFont.height) + private static let padding: CGFloat = 8 + private static let lineSpacing: CGFloat = 4 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + isBordered = false + title = "" + target = self + action = #selector(activate) + wantsLayer = true + layer?.cornerRadius = 4 + layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.05).cgColor + + icon.image = NSImage(systemSymbolName: "arrow.turn.down.right", accessibilityDescription: nil) + icon.contentTintColor = .labelColor + icon.translatesAutoresizingMaskIntoConstraints = false + + for field in [heading, snippet] { + field.font = Self.font + field.lineBreakMode = .byTruncatingTail + field.maximumNumberOfLines = 1 + field.translatesAutoresizingMaskIntoConstraints = false } + snippet.textColor = .secondaryLabelColor + + addSubview(icon) + addSubview(heading) + addSubview(snippet) + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.padding), + icon.centerYAnchor.constraint(equalTo: heading.centerYAnchor), + icon.widthAnchor.constraint(equalToConstant: 16), + icon.heightAnchor.constraint(equalToConstant: 16), + + heading.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4), + heading.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.padding), + heading.topAnchor.constraint(equalTo: topAnchor, constant: Self.padding), + + snippet.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.padding), + snippet.trailingAnchor.constraint(equalTo: heading.trailingAnchor), + snippet.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: Self.lineSpacing), + ]) + } + + func configure(summary: ThreadSummary) { + heading.stringValue = "Thread (\(summary.numReplies()) messages)" + snippet.stringValue = summary.description ?? "" + snippet.isHidden = summary.description == nil + } + + static func height(for summary: ThreadSummary) -> CGFloat { + padding * 2 + lineHeight * (summary.description == nil ? 1 : 2) + + (summary.description == nil ? 0 : lineSpacing) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + alphaValue = 0.6 + window?.displayIfNeeded() + super.mouseDown(with: event) + alphaValue = 1 + } + + @objc private func activate(_ sender: NSButton) { + onClick?() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift index 8175c01..261cc19 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift @@ -1,280 +1,288 @@ -import AppKit import AVKit +import AppKit import MatrixIntegration import MatrixRustSDK import OSLog /// A retained video player, thumbnail, and optional caption for a reusable message row. final class MessageVideoContentView: NSView, MessageContentRowView { - var onSelectRequest: (() -> Void)? { - didSet { captionView.onSelectRequest = onSelectRequest } - } - - var onArrowKey: ((TimelineSelectionDirection) -> Void)? { - didSet { captionView.onArrowKey = onArrowKey } - } + var onSelectRequest: (() -> Void)? { + didSet { captionView.onSelectRequest = onSelectRequest } + } - private let mediaView = NSView() - private let thumbnailView = NSImageView() - private let playButton = NSButton() - private var playerView: AVPlayerView? - private let spinner = NSProgressIndicator() - private let errorLabel = NSTextField(labelWithString: "") - private let captionView = MessageTextContentView() - private var mediaWidthConstraint: NSLayoutConstraint! - private var mediaHeightConstraint: NSLayoutConstraint! - private var mediaBottomConstraint: NSLayoutConstraint! - private var captionBottomConstraint: NSLayoutConstraint! - private var videoContent: VideoMessageContent? - private var sourceURL: String? - private weak var matrixClient: MatrixClient? - private var fileHandle: MediaFileHandle? - private var thumbnailTask: Task? - private var videoTask: Task? + var onArrowKey: ((TimelineSelectionDirection) -> Void)? { + didSet { captionView.onArrowKey = onArrowKey } + } - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) + private let mediaView = NSView() + private let thumbnailView = NSImageView() + private let playButton = NSButton() + private var playerView: AVPlayerView? + private let spinner = NSProgressIndicator() + private let errorLabel = NSTextField(labelWithString: "") + private let captionView = MessageTextContentView() + private var mediaWidthConstraint: NSLayoutConstraint! + private var mediaHeightConstraint: NSLayoutConstraint! + private var mediaBottomConstraint: NSLayoutConstraint! + private var captionBottomConstraint: NSLayoutConstraint! + private var videoContent: VideoMessageContent? + private var sourceURL: String? + private weak var matrixClient: MatrixClient? + private var fileHandle: MediaFileHandle? + private var thumbnailTask: Task? + private var videoTask: Task? - mediaView.wantsLayer = true - mediaView.layer?.backgroundColor = NSColor.darkGray.cgColor - mediaView.layer?.cornerRadius = 6 - mediaView.layer?.masksToBounds = true - thumbnailView.imageScaling = .scaleProportionallyUpOrDown + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) - playButton.isBordered = false - playButton.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: "Play video")? - .withSymbolConfiguration(.init(pointSize: 32, weight: .regular)) - playButton.contentTintColor = .white - playButton.target = self - playButton.action = #selector(playVideo) - playButton.toolTip = "Play video" + mediaView.wantsLayer = true + mediaView.layer?.backgroundColor = NSColor.darkGray.cgColor + mediaView.layer?.cornerRadius = 6 + mediaView.layer?.masksToBounds = true + thumbnailView.imageScaling = .scaleProportionallyUpOrDown - spinner.style = .spinning - spinner.controlSize = .small - spinner.isDisplayedWhenStopped = false - errorLabel.textColor = .systemRed - errorLabel.alignment = .center - errorLabel.isSelectable = true - errorLabel.usesSingleLineMode = false - errorLabel.lineBreakMode = .byWordWrapping - errorLabel.maximumNumberOfLines = 0 - errorLabel.isHidden = true + playButton.isBordered = false + playButton.image = NSImage( + systemSymbolName: "play.fill", accessibilityDescription: "Play video")? + .withSymbolConfiguration(.init(pointSize: 32, weight: .regular)) + playButton.contentTintColor = .white + playButton.target = self + playButton.action = #selector(playVideo) + playButton.toolTip = "Play video" - for view in [mediaView, captionView] { - view.translatesAutoresizingMaskIntoConstraints = false - addSubview(view) - } - for view in [thumbnailView, playButton, spinner, errorLabel] { - view.translatesAutoresizingMaskIntoConstraints = false - mediaView.addSubview(view) - } + spinner.style = .spinning + spinner.controlSize = .small + spinner.isDisplayedWhenStopped = false + errorLabel.textColor = .systemRed + errorLabel.alignment = .center + errorLabel.isSelectable = true + errorLabel.usesSingleLineMode = false + errorLabel.lineBreakMode = .byWordWrapping + errorLabel.maximumNumberOfLines = 0 + errorLabel.isHidden = true - mediaWidthConstraint = mediaView.widthAnchor.constraint(equalToConstant: 300) - mediaHeightConstraint = mediaView.heightAnchor.constraint(equalToConstant: 300) - mediaBottomConstraint = mediaView.bottomAnchor.constraint(equalTo: bottomAnchor) - captionBottomConstraint = captionView.bottomAnchor.constraint(equalTo: bottomAnchor) - mediaBottomConstraint.priority = .init(999) - captionBottomConstraint.priority = .init(999) - NSLayoutConstraint.activate([ - mediaView.leadingAnchor.constraint(equalTo: leadingAnchor), - mediaView.topAnchor.constraint(equalTo: topAnchor), - mediaWidthConstraint, - mediaHeightConstraint, - thumbnailView.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), - thumbnailView.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), - thumbnailView.topAnchor.constraint(equalTo: mediaView.topAnchor), - thumbnailView.bottomAnchor.constraint(equalTo: mediaView.bottomAnchor), - playButton.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), - playButton.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), - playButton.topAnchor.constraint(equalTo: mediaView.topAnchor), - playButton.bottomAnchor.constraint(equalTo: mediaView.bottomAnchor), - spinner.centerXAnchor.constraint(equalTo: mediaView.centerXAnchor), - spinner.centerYAnchor.constraint(equalTo: mediaView.centerYAnchor), - errorLabel.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), - errorLabel.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), - errorLabel.centerYAnchor.constraint(equalTo: mediaView.centerYAnchor), - captionView.leadingAnchor.constraint(equalTo: leadingAnchor), - captionView.trailingAnchor.constraint(equalTo: trailingAnchor), - captionView.topAnchor.constraint(equalTo: mediaView.bottomAnchor, constant: 10), - ]) + for view in [mediaView, captionView] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) } - - static func supports(content: MsgLikeContent) -> Bool { - guard case let .message(message) = content.kind else { return false } - if case .video = message.msgType { - return true - } - return false + for view in [thumbnailView, playButton, spinner, errorLabel] { + view.translatesAutoresizingMaskIntoConstraints = false + mediaView.addSubview(view) } - func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { - guard case let .message(message) = content.kind, - case let .video(video) = message.msgType else { return } + mediaWidthConstraint = mediaView.widthAnchor.constraint(equalToConstant: 300) + mediaHeightConstraint = mediaView.heightAnchor.constraint(equalToConstant: 300) + mediaBottomConstraint = mediaView.bottomAnchor.constraint(equalTo: bottomAnchor) + captionBottomConstraint = captionView.bottomAnchor.constraint(equalTo: bottomAnchor) + mediaBottomConstraint.priority = .init(999) + captionBottomConstraint.priority = .init(999) + NSLayoutConstraint.activate([ + mediaView.leadingAnchor.constraint(equalTo: leadingAnchor), + mediaView.topAnchor.constraint(equalTo: topAnchor), + mediaWidthConstraint, + mediaHeightConstraint, + thumbnailView.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), + thumbnailView.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), + thumbnailView.topAnchor.constraint(equalTo: mediaView.topAnchor), + thumbnailView.bottomAnchor.constraint(equalTo: mediaView.bottomAnchor), + playButton.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), + playButton.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), + playButton.topAnchor.constraint(equalTo: mediaView.topAnchor), + playButton.bottomAnchor.constraint(equalTo: mediaView.bottomAnchor), + spinner.centerXAnchor.constraint(equalTo: mediaView.centerXAnchor), + spinner.centerYAnchor.constraint(equalTo: mediaView.centerYAnchor), + errorLabel.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), + errorLabel.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), + errorLabel.centerYAnchor.constraint(equalTo: mediaView.centerYAnchor), + captionView.leadingAnchor.constraint(equalTo: leadingAnchor), + captionView.trailingAnchor.constraint(equalTo: trailingAnchor), + captionView.topAnchor.constraint(equalTo: mediaView.bottomAnchor, constant: 10), + ]) + } - videoContent = video - self.matrixClient = matrixClient - let hasCaption = video.caption?.isEmpty == false || video.formattedCaption != nil - captionView.isHidden = !hasCaption - captionView.configureCaption(video.caption, formatted: video.formattedCaption) - mediaBottomConstraint.isActive = !hasCaption - captionBottomConstraint.isActive = hasCaption - if bounds.width > 0 { - updateMediaSize(for: video, width: bounds.width) - } - - let url = video.source.url() - guard sourceURL != url else { return } - thumbnailTask?.cancel() - videoTask?.cancel() - thumbnailTask = nil - videoTask = nil - sourceURL = url - playerView?.player?.pause() - playerView?.player = nil - playerView?.isHidden = true - fileHandle = nil - spinner.stopAnimation(nil) - errorLabel.isHidden = true - playButton.isHidden = false - playButton.isEnabled = true - thumbnailView.image = nil - loadThumbnail(for: video, from: matrixClient) + static func supports(content: MsgLikeContent) -> Bool { + guard case .message(let message) = content.kind else { return false } + if case .video = message.msgType { + return true } + return false + } - override func layout() { - if let videoContent, bounds.width > 0 { - updateMediaSize(for: videoContent, width: bounds.width) - } - super.layout() - } + func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { + guard case .message(let message) = content.kind, + case .video(let video) = message.msgType + else { return } - func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { - guard case let .message(message) = content.kind, - case let .video(video) = message.msgType else { return 0 } - let mediaHeight = Self.mediaSize(for: video, width: width).height - guard video.caption?.isEmpty == false || video.formattedCaption != nil else { return mediaHeight } - return mediaHeight + 10 + ceil(captionView.height(forCaption: video.caption, formatted: video.formattedCaption, width: width)) + videoContent = video + self.matrixClient = matrixClient + let hasCaption = video.caption?.isEmpty == false || video.formattedCaption != nil + captionView.isHidden = !hasCaption + captionView.configureCaption(video.caption, formatted: video.formattedCaption) + mediaBottomConstraint.isActive = !hasCaption + captionBottomConstraint.isActive = hasCaption + if bounds.width > 0 { + updateMediaSize(for: video, width: bounds.width) } - private func updateMediaSize(for video: VideoMessageContent, width: CGFloat) { - let size = Self.mediaSize(for: video, width: width) - if mediaWidthConstraint.constant != size.width { - mediaWidthConstraint.constant = size.width - } - if mediaHeightConstraint.constant != size.height { - mediaHeightConstraint.constant = size.height - } - } + let url = video.source.url() + guard sourceURL != url else { return } + thumbnailTask?.cancel() + videoTask?.cancel() + thumbnailTask = nil + videoTask = nil + sourceURL = url + playerView?.player?.pause() + playerView?.player = nil + playerView?.isHidden = true + fileHandle = nil + spinner.stopAnimation(nil) + errorLabel.isHidden = true + playButton.isHidden = false + playButton.isEnabled = true + thumbnailView.image = nil + loadThumbnail(for: video, from: matrixClient) + } - private static func mediaSize(for video: VideoMessageContent, width: CGFloat) -> CGSize { - guard let info = video.info, let pixelWidth = info.width, let pixelHeight = info.height, - pixelWidth > 0, pixelHeight > 0 - else { - let side = min(width, 300) - return CGSize(width: side, height: side) - } - let ratio = CGFloat(pixelWidth) / CGFloat(pixelHeight) - let mediaWidth = min(width, CGFloat(pixelWidth), 300 * ratio) - return CGSize(width: mediaWidth, height: mediaWidth / ratio) + override func layout() { + if let videoContent, bounds.width > 0 { + updateMediaSize(for: videoContent, width: bounds.width) } + super.layout() + } - private func loadThumbnail(for video: VideoMessageContent, from client: MatrixClient?) { - guard let source = video.info?.thumbnailSource, let client else { return } - let url = video.source.url() - if let cached = client.cachedImage(matrixUrl: source.url()) { - thumbnailView.image = cached - return - } - thumbnailTask = Task { [weak self] in - do { - let data = try await client.client.getMediaContent(mediaSource: source) - try Task.checkCancellation() - let image = try data.toOrientedImage(contentType: data.computeMimeType()) - try Task.checkCancellation() - MatrixClient.setCachedImage(image, forKey: NSString(string: source.url())) - guard let self, self.sourceURL == url else { return } - self.thumbnailView.image = image - self.thumbnailTask = nil - } catch is CancellationError { - return - } catch { - guard let self, self.sourceURL == url else { return } - Logger.viewCycle.error("Failed to load video thumbnail: \(error)") - self.thumbnailTask = nil - } - } + func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { + guard case .message(let message) = content.kind, + case .video(let video) = message.msgType + else { return 0 } + let mediaHeight = Self.mediaSize(for: video, width: width).height + guard video.caption?.isEmpty == false || video.formattedCaption != nil else { + return mediaHeight } + return mediaHeight + 10 + + ceil( + captionView.height( + forCaption: video.caption, formatted: video.formattedCaption, width: width)) + } - @objc private func playVideo() { - guard let videoContent, let matrixClient else { return } - let url = videoContent.source.url() - playButton.isEnabled = false - spinner.startAnimation(nil) - videoTask?.cancel() - videoTask = Task { [weak self] in - do { - let handle = try await matrixClient.client.getMediaFile( - mediaSource: videoContent.source, - filename: videoContent.filename, - mimeType: videoContent.info?.mimetype ?? "", - useCache: true, - tempDir: NSTemporaryDirectory() - ) - try Task.checkCancellation() - let path = try handle.path() - guard let self, self.sourceURL == url else { return } - self.fileHandle = handle - let player = AVPlayer(url: URL(filePath: path, directoryHint: .notDirectory)) - let playerView = self.preparePlayerView() - playerView.player = player - playerView.isHidden = false - self.playButton.isHidden = true - self.spinner.stopAnimation(nil) - self.videoTask = nil - player.play() - } catch is CancellationError { - return - } catch { - guard let self, self.sourceURL == url else { return } - self.spinner.stopAnimation(nil) - self.playButton.isEnabled = true - self.errorLabel.stringValue = error.localizedDescription - self.errorLabel.isHidden = false - self.videoTask = nil - } - } + private func updateMediaSize(for video: VideoMessageContent, width: CGFloat) { + let size = Self.mediaSize(for: video, width: width) + if mediaWidthConstraint.constant != size.width { + mediaWidthConstraint.constant = size.width + } + if mediaHeightConstraint.constant != size.height { + mediaHeightConstraint.constant = size.height } + } - private func preparePlayerView() -> AVPlayerView { - if let playerView { - return playerView - } + private static func mediaSize(for video: VideoMessageContent, width: CGFloat) -> CGSize { + guard let info = video.info, let pixelWidth = info.width, let pixelHeight = info.height, + pixelWidth > 0, pixelHeight > 0 + else { + let side = min(width, 300) + return CGSize(width: side, height: side) + } + let ratio = CGFloat(pixelWidth) / CGFloat(pixelHeight) + let mediaWidth = min(width, CGFloat(pixelWidth), 300 * ratio) + return CGSize(width: mediaWidth, height: mediaWidth / ratio) + } - let view = AVPlayerView(frame: mediaView.bounds) - view.allowsPictureInPicturePlayback = true - view.showsFullScreenToggleButton = true - view.showsSharingServiceButton = true - view.translatesAutoresizingMaskIntoConstraints = false - mediaView.addSubview(view, positioned: .above, relativeTo: thumbnailView) - NSLayoutConstraint.activate([ - view.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), - view.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), - view.topAnchor.constraint(equalTo: mediaView.topAnchor), - view.bottomAnchor.constraint(equalTo: mediaView.bottomAnchor), - ]) - playerView = view - return view + private func loadThumbnail(for video: VideoMessageContent, from client: MatrixClient?) { + guard let source = video.info?.thumbnailSource, let client else { return } + let url = video.source.url() + if let cached = client.cachedImage(matrixUrl: source.url()) { + thumbnailView.image = cached + return + } + thumbnailTask = Task { [weak self] in + do { + let data = try await client.client.getMediaContent(mediaSource: source) + try Task.checkCancellation() + let image = try data.toOrientedImage(contentType: data.computeMimeType()) + try Task.checkCancellation() + MatrixClient.setCachedImage(image, forKey: NSString(string: source.url())) + guard let self, self.sourceURL == url else { return } + self.thumbnailView.image = image + self.thumbnailTask = nil + } catch is CancellationError { + return + } catch { + guard let self, self.sourceURL == url else { return } + Logger.viewCycle.error("Failed to load video thumbnail: \(error)") + self.thumbnailTask = nil + } } + } - @MainActor - deinit { - thumbnailTask?.cancel() - videoTask?.cancel() - playerView?.player?.pause() + @objc private func playVideo() { + guard let videoContent, let matrixClient else { return } + let url = videoContent.source.url() + playButton.isEnabled = false + spinner.startAnimation(nil) + videoTask?.cancel() + videoTask = Task { [weak self] in + do { + let handle = try await matrixClient.client.getMediaFile( + mediaSource: videoContent.source, + filename: videoContent.filename, + mimeType: videoContent.info?.mimetype ?? "", + useCache: true, + tempDir: NSTemporaryDirectory() + ) + try Task.checkCancellation() + let path = try handle.path() + guard let self, self.sourceURL == url else { return } + self.fileHandle = handle + let player = AVPlayer(url: URL(filePath: path, directoryHint: .notDirectory)) + let playerView = self.preparePlayerView() + playerView.player = player + playerView.isHidden = false + self.playButton.isHidden = true + self.spinner.stopAnimation(nil) + self.videoTask = nil + player.play() + } catch is CancellationError { + return + } catch { + guard let self, self.sourceURL == url else { return } + self.spinner.stopAnimation(nil) + self.playButton.isEnabled = true + self.errorLabel.stringValue = error.localizedDescription + self.errorLabel.isHidden = false + self.videoTask = nil + } } + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + private func preparePlayerView() -> AVPlayerView { + if let playerView { + return playerView } + + let view = AVPlayerView(frame: mediaView.bounds) + view.allowsPictureInPicturePlayback = true + view.showsFullScreenToggleButton = true + view.showsSharingServiceButton = true + view.translatesAutoresizingMaskIntoConstraints = false + mediaView.addSubview(view, positioned: .above, relativeTo: thumbnailView) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), + view.topAnchor.constraint(equalTo: mediaView.topAnchor), + view.bottomAnchor.constraint(equalTo: mediaView.bottomAnchor), + ]) + playerView = view + return view + } + + @MainActor + deinit { + thumbnailTask?.cancel() + videoTask?.cancel() + playerView?.player?.pause() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/StateEventRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/StateEventRowView.swift index 8449170..fe58493 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/StateEventRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/StateEventRowView.swift @@ -2,79 +2,88 @@ import AppKit import MatrixRustSDK final class StateEventRowView: NSView { - private let timestamp = NSTextField(labelWithString: "") - private let descriptionField = NSTextField(labelWithString: "") + private let timestamp = NSTextField(labelWithString: "") + private let descriptionField = NSTextField(labelWithString: "") - private static let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm" - return formatter - }() + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) - timestamp.font = .systemFont(ofSize: NSFont.labelFontSize) - timestamp.textColor = .secondaryLabelColor - timestamp.alignment = .right - timestamp.translatesAutoresizingMaskIntoConstraints = false + timestamp.font = .systemFont(ofSize: NSFont.labelFontSize) + timestamp.textColor = .secondaryLabelColor + timestamp.alignment = .right + timestamp.translatesAutoresizingMaskIntoConstraints = false - descriptionField.isSelectable = true - descriptionField.usesSingleLineMode = false - descriptionField.lineBreakMode = .byWordWrapping - descriptionField.maximumNumberOfLines = 0 - descriptionField.translatesAutoresizingMaskIntoConstraints = false - descriptionField.allowsEditingTextAttributes = true + descriptionField.isSelectable = true + descriptionField.usesSingleLineMode = false + descriptionField.lineBreakMode = .byWordWrapping + descriptionField.maximumNumberOfLines = 0 + descriptionField.translatesAutoresizingMaskIntoConstraints = false + descriptionField.allowsEditingTextAttributes = true - addSubview(timestamp) - addSubview(descriptionField) - NSLayoutConstraint.activate([ - timestamp.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0), - timestamp.topAnchor.constraint(equalTo: topAnchor, constant: 2), - timestamp.widthAnchor.constraint(equalToConstant: 32 + 16), + addSubview(timestamp) + addSubview(descriptionField) + NSLayoutConstraint.activate([ + timestamp.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0), + timestamp.topAnchor.constraint(equalTo: topAnchor, constant: 2), + timestamp.widthAnchor.constraint(equalToConstant: 32 + 16), - descriptionField.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), - descriptionField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), - descriptionField.topAnchor.constraint(equalTo: topAnchor, constant: 0), - descriptionField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0), - ]) - } + descriptionField.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), + descriptionField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + descriptionField.topAnchor.constraint(equalTo: topAnchor, constant: 0), + descriptionField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0), + ]) + } - func configure(event: EventTimelineItem) { - let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) - timestamp.stringValue = Self.timeFormatter.string(from: date) - descriptionField.attributedStringValue = Self.attributedDescription(for: event) - } + func configure(event: EventTimelineItem) { + let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) + timestamp.stringValue = Self.timeFormatter.string(from: date) + descriptionField.attributedStringValue = Self.attributedDescription(for: event) + } - static func height(for event: EventTimelineItem, width: CGFloat) -> CGFloat { - let textWidth = max(width - 74, 1) - let textHeight = attributedDescription(for: event).boundingRect( - with: NSSize(width: textWidth, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading] - ).height - return max(ceil(textHeight), 28) - } + static func height(for event: EventTimelineItem, width: CGFloat) -> CGFloat { + let textWidth = max(width - 74, 1) + let textHeight = attributedDescription(for: event).boundingRect( + with: NSSize(width: textWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading] + ).height + return max(ceil(textHeight), 28) + } - private static func attributedDescription(for event: EventTimelineItem) -> NSAttributedString { - let color = NSColor.secondaryLabelColor - let size = NSFont.systemFontSize - let result = NSMutableAttributedString(string: event.sender, attributes: [ - .font: NSFont.boldSystemFont(ofSize: size), - .foregroundColor: color, - ]) - result.append(NSAttributedString(string: ": ", attributes: [ - .font: NSFont.systemFont(ofSize: size), - .foregroundColor: color, + private static func attributedDescription(for event: EventTimelineItem) -> NSAttributedString { + let color = NSColor.secondaryLabelColor + let size = NSFont.systemFontSize + let result = NSMutableAttributedString( + string: event.sender, + attributes: [ + .font: NSFont.boldSystemFont(ofSize: size), + .foregroundColor: color, + ]) + result.append( + NSAttributedString( + string: ": ", + attributes: [ + .font: NSFont.systemFont(ofSize: size), + .foregroundColor: color, ])) - result.append(NSAttributedString(string: event.content.userInterfaceText, attributes: [ - .font: NSFontManager.shared.convert(NSFont.systemFont(ofSize: size), toHaveTrait: .italicFontMask), - .foregroundColor: color, + result.append( + NSAttributedString( + string: event.content.userInterfaceText, + attributes: [ + .font: NSFontManager.shared.convert( + NSFont.systemFont(ofSize: size), toHaveTrait: .italicFontMask), + .foregroundColor: color, ])) - return result - } + return result + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineRowProjection.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineRowProjection.swift index 30ffdc1..277c9cb 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineRowProjection.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineRowProjection.swift @@ -3,147 +3,149 @@ import MatrixRustSDK /// Maps contiguous SDK item ranges to ordered table rows. The current rules emit /// one segment per item; a rule may later consume a run and emit a single row. struct TimelineRowProjection { - struct Segment { - let sourceItems: [TimelineItem] - let rows: [TimelineItemRowInfo] - } - - struct Change { - let oldIDs: [String] - let changedIDs: Set - } + struct Segment { + let sourceItems: [TimelineItem] + let rows: [TimelineItemRowInfo] + } - private(set) var sourceItems: [TimelineItem] - private var segments: [Segment] - private(set) var rows: [TimelineItemRowInfo] + struct Change { + let oldIDs: [String] + let changedIDs: Set + } - init(items: [TimelineItem]) { - sourceItems = items - segments = Self.makeSegments(items, previous: nil) - rows = Self.flatten(segments) - } + private(set) var sourceItems: [TimelineItem] + private var segments: [Segment] + private(set) var rows: [TimelineItemRowInfo] - mutating func apply(_ diffs: [TimelineDiff]) -> Change { - let oldIDs = rows.map(\.id) - var changedIDs = Set() + init(items: [TimelineItem]) { + sourceItems = items + segments = Self.makeSegments(items, previous: nil) + rows = Self.flatten(segments) + } - for diff in diffs { - switch diff { - case let .append(values): - replace(at: sourceItems.count, removing: 0, with: values, changedIDs: &changedIDs) - case .clear: - reset(to: [], changedIDs: &changedIDs) - case let .pushFront(item): - replace(at: 0, removing: 0, with: [item], changedIDs: &changedIDs) - case let .pushBack(item): - replace(at: sourceItems.count, removing: 0, with: [item], changedIDs: &changedIDs) - case .popFront: - replace(at: 0, removing: 1, with: [], changedIDs: &changedIDs) - case .popBack: - replace(at: sourceItems.count - 1, removing: 1, with: [], changedIDs: &changedIDs) - case let .insert(index, item): - replace(at: Int(index), removing: 0, with: [item], changedIDs: &changedIDs) - case let .set(index, item): - replace(at: Int(index), removing: 1, with: [item], changedIDs: &changedIDs) - case let .remove(index): - replace(at: Int(index), removing: 1, with: [], changedIDs: &changedIDs) - case let .truncate(length): - replace(at: Int(length), removing: sourceItems.count - Int(length), with: [], changedIDs: &changedIDs) - case let .reset(values): - reset(to: values, changedIDs: &changedIDs) - } - } + mutating func apply(_ diffs: [TimelineDiff]) -> Change { + let oldIDs = rows.map(\.id) + var changedIDs = Set() - rows = Self.flatten(segments) - return Change(oldIDs: oldIDs, changedIDs: changedIDs) + for diff in diffs { + switch diff { + case .append(let values): + replace(at: sourceItems.count, removing: 0, with: values, changedIDs: &changedIDs) + case .clear: + reset(to: [], changedIDs: &changedIDs) + case .pushFront(let item): + replace(at: 0, removing: 0, with: [item], changedIDs: &changedIDs) + case .pushBack(let item): + replace(at: sourceItems.count, removing: 0, with: [item], changedIDs: &changedIDs) + case .popFront: + replace(at: 0, removing: 1, with: [], changedIDs: &changedIDs) + case .popBack: + replace(at: sourceItems.count - 1, removing: 1, with: [], changedIDs: &changedIDs) + case .insert(let index, let item): + replace(at: Int(index), removing: 0, with: [item], changedIDs: &changedIDs) + case .set(let index, let item): + replace(at: Int(index), removing: 1, with: [item], changedIDs: &changedIDs) + case .remove(let index): + replace(at: Int(index), removing: 1, with: [], changedIDs: &changedIDs) + case .truncate(let length): + replace( + at: Int(length), removing: sourceItems.count - Int(length), with: [], + changedIDs: &changedIDs) + case .reset(let values): + reset(to: values, changedIDs: &changedIDs) + } } - private mutating func reset(to items: [TimelineItem], changedIDs: inout Set) { - changedIDs.formUnion(segments.flatMap { $0.rows.map(\.id) }) - sourceItems = items - segments = Self.makeSegments(items, previous: nil) - changedIDs.formUnion(segments.flatMap { $0.rows.map(\.id) }) - } + rows = Self.flatten(segments) + return Change(oldIDs: oldIDs, changedIDs: changedIDs) + } - private mutating func replace( - at index: Int, removing count: Int, with items: [TimelineItem], changedIDs: inout Set - ) { - guard count > 0 || !items.isEmpty else { return } + private mutating func reset(to items: [TimelineItem], changedIDs: inout Set) { + changedIDs.formUnion(segments.flatMap { $0.rows.map(\.id) }) + sourceItems = items + segments = Self.makeSegments(items, previous: nil) + changedIDs.formUnion(segments.flatMap { $0.rows.map(\.id) }) + } - // The predecessor determines whether the first changed message needs a - // profile. The successor may gain or lose its own profile after the edit. - let requestedStart = max(index - 1, 0) - let requestedEnd = min(index + count + 1, sourceItems.count) - let (segmentRange, sourceRange) = segmentWindow(containing: requestedStart ..< requestedEnd) - changedIDs.formUnion(segments[segmentRange].flatMap { $0.rows.map(\.id) }) + private mutating func replace( + at index: Int, removing count: Int, with items: [TimelineItem], changedIDs: inout Set + ) { + guard count > 0 || !items.isEmpty else { return } - sourceItems.replaceSubrange(index ..< index + count, with: items) - let newEnd = sourceRange.upperBound + items.count - count - let previous = sourceRange.lowerBound > 0 ? sourceItems[sourceRange.lowerBound - 1] : nil - let replacement = Self.makeSegments( - Array(sourceItems[sourceRange.lowerBound ..< newEnd]), previous: previous - ) - changedIDs.formUnion(replacement.flatMap { $0.rows.map(\.id) }) - segments.replaceSubrange(segmentRange, with: replacement) - } + // The predecessor determines whether the first changed message needs a + // profile. The successor may gain or lose its own profile after the edit. + let requestedStart = max(index - 1, 0) + let requestedEnd = min(index + count + 1, sourceItems.count) + let (segmentRange, sourceRange) = segmentWindow(containing: requestedStart..) -> (Range, Range) { - var sourceOffset = 0 - var first: Int? - var last = 0 - var lower = requested.lowerBound - var upper = requested.upperBound - for (index, segment) in segments.enumerated() { - let end = sourceOffset + segment.sourceItems.count - if first == nil, end > requested.lowerBound { - first = index - lower = sourceOffset - } - if sourceOffset < requested.upperBound { - last = index + 1 - upper = end - } - sourceOffset = end - } - if let first { return (first ..< last, lower ..< upper) } - return (segments.count ..< segments.count, requested.lowerBound ..< requested.upperBound) + sourceItems.replaceSubrange(index.. 0 ? sourceItems[sourceRange.lowerBound - 1] : nil + let replacement = Self.makeSegments( + Array(sourceItems[sourceRange.lowerBound..) -> (Range, Range) { + var sourceOffset = 0 + var first: Int? + var last = 0 + var lower = requested.lowerBound + var upper = requested.upperBound + for (index, segment) in segments.enumerated() { + let end = sourceOffset + segment.sourceItems.count + if first == nil, end > requested.lowerBound { + first = index + lower = sourceOffset + } + if sourceOffset < requested.upperBound { + last = index + 1 + upper = end + } + sourceOffset = end } + if let first { return (first.. [Segment] { - var previousSender = sender(of: previous) - return items.map { item in - var projectedRows: [TimelineItemRowInfo] = [] - if let event = item.asEvent() { - switch event.content { - case let .msgLike(content): - if previousSender != event.sender { - projectedRows.append(.profile(item: item, event: event)) - } - projectedRows.append(.message(item: item, event: event, content: content)) - previousSender = event.sender - default: - projectedRows.append(.state(item: item, event: event)) - previousSender = nil - } - } else if let virtual = item.asVirtual() { - projectedRows.append(.virtual(item: item, virtual: virtual)) - previousSender = nil - } - return Segment(sourceItems: [item], rows: projectedRows) + private static func makeSegments(_ items: [TimelineItem], previous: TimelineItem?) -> [Segment] { + var previousSender = sender(of: previous) + return items.map { item in + var projectedRows: [TimelineItemRowInfo] = [] + if let event = item.asEvent() { + switch event.content { + case .msgLike(let content): + if previousSender != event.sender { + projectedRows.append(.profile(item: item, event: event)) + } + projectedRows.append(.message(item: item, event: event, content: content)) + previousSender = event.sender + default: + projectedRows.append(.state(item: item, event: event)) + previousSender = nil } + } else if let virtual = item.asVirtual() { + projectedRows.append(.virtual(item: item, virtual: virtual)) + previousSender = nil + } + return Segment(sourceItems: [item], rows: projectedRows) } + } - private static func sender(of item: TimelineItem?) -> String? { - guard let event = item?.asEvent(), case .msgLike = event.content else { return nil } - return event.sender - } + private static func sender(of item: TimelineItem?) -> String? { + guard let event = item?.asEvent(), case .msgLike = event.content else { return nil } + return event.sender + } - private static func flatten(_ segments: [Segment]) -> [TimelineItemRowInfo] { - var result = segments.flatMap(\.rows) - result.append(.typingIndicator) - result.reverse() - return result - } + private static func flatten(_ segments: [Segment]) -> [TimelineItemRowInfo] { + var result = segments.flatMap(\.rows) + result.append(.typingIndicator) + result.reverse() + return result + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift index e187f12..8611d54 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift @@ -5,661 +5,688 @@ import MatrixRustSDK import OSLog enum TimelineSelectionDirection { - case up - case down - - init?(event: NSEvent) { - guard event.modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty else { return nil } - switch event.keyCode { - case 126: self = .up - case 125: self = .down - default: return nil - } - } + case up + case down - var rowStep: Int { - self == .up ? 1 : -1 + init?(event: NSEvent) { + guard event.modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty else { + return nil + } + switch event.keyCode { + case 126: self = .up + case 125: self = .down + default: return nil } + } + + var rowStep: Int { + self == .up ? 1 : -1 + } } enum TimelineItemRowInfo { - case profile(item: TimelineItem, event: MatrixRustSDK.EventTimelineItem) - case message(item: TimelineItem, event: MatrixRustSDK.EventTimelineItem, content: MatrixRustSDK.MsgLikeContent) - case state(item: TimelineItem, event: MatrixRustSDK.EventTimelineItem) - case virtual(item: TimelineItem, virtual: MatrixRustSDK.VirtualTimelineItem) - case typingIndicator - - @MainActor - var reuseIdentifier: NSUserInterfaceItemIdentifier { - switch self { - case .profile(profile: _): - return NSUserInterfaceItemIdentifier("profile") - case .message(_, _, let content): - return MessageContentKind(content: content).reuseIdentifier - case .state: - return NSUserInterfaceItemIdentifier("state") - case .virtual: - return NSUserInterfaceItemIdentifier("virtual") - case .typingIndicator: - return NSUserInterfaceItemIdentifier("typing-indicator") - } - } + case profile(item: TimelineItem, event: MatrixRustSDK.EventTimelineItem) + case message( + item: TimelineItem, event: MatrixRustSDK.EventTimelineItem, + content: MatrixRustSDK.MsgLikeContent) + case state(item: TimelineItem, event: MatrixRustSDK.EventTimelineItem) + case virtual(item: TimelineItem, virtual: MatrixRustSDK.VirtualTimelineItem) + case typingIndicator + + @MainActor + var reuseIdentifier: NSUserInterfaceItemIdentifier { + switch self { + case .profile(profile: _): + return NSUserInterfaceItemIdentifier("profile") + case .message(_, _, let content): + return MessageContentKind(content: content).reuseIdentifier + case .state: + return NSUserInterfaceItemIdentifier("state") + case .virtual: + return NSUserInterfaceItemIdentifier("virtual") + case .typingIndicator: + return NSUserInterfaceItemIdentifier("typing-indicator") + } + } } extension TimelineItemRowInfo: Identifiable { - var id: String { - switch self { - case .profile(_, let event): - "profile:\(event.eventOrTransactionId.id)" - case .message(_, let event, _): - "message:\(event.eventOrTransactionId.id)" - case .state(_, let event): - "state:\(event.eventOrTransactionId.id)" - case .virtual(let item, _): - "virtual:\(item.uniqueId().id)" - case .typingIndicator: - "typing-indicator" - } - } + var id: String { + switch self { + case .profile(_, let event): + "profile:\(event.eventOrTransactionId.id)" + case .message(_, let event, _): + "message:\(event.eventOrTransactionId.id)" + case .state(_, let event): + "state:\(event.eventOrTransactionId.id)" + case .virtual(let item, _): + "virtual:\(item.uniqueId().id)" + case .typingIndicator: + "typing-indicator" + } + } } -class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveTimelineDiffDelegate { - let coordinator: TimelineViewRepresentable.Coordinator - - private var dataSource: NSTableViewDiffableDataSource? - - let scrollView = NSScrollView() - let tableView = BottomStickyTableView() - private let hoverOverlay = MessageHoverOverlayView() - private weak var hoveredMessageView: MessageRowView? - private var hoveredMessageId: String? - private var hoveredRowIndex: Int? - private var updatingTimelineItems = false - - let timeline: LiveTimeline - private var projection: TimelineRowProjection - var timelineItems: [TimelineItemRowInfo] { - projection.rows - } - - init(coordinator: TimelineViewRepresentable.Coordinator, timeline: LiveTimeline) { - self.coordinator = coordinator - self.timeline = timeline - self.projection = TimelineRowProjection(items: timeline.timelineItems) - super.init(nibName: nil, bundle: nil) - } - - override func viewDidLoad() { - super.viewDidLoad() - - tableView.addTableColumn(NSTableColumn()) - tableView.headerView = nil - tableView.style = .plain - tableView.allowsColumnSelection = false - tableView.allowsMultipleSelection = false - tableView.allowsEmptySelection = true - tableView.selectionHighlightStyle = .none - - // Every row is sized by tableView(_:heightOfRow:). Automatic heights can - // collapse NSTextView rows because the view has no intrinsic height. - tableView.rowSizeStyle = .custom - tableView.rowHeight = 28 - tableView.usesAutomaticRowHeights = false - tableView.onLayout = { [weak self] in - self?.positionHoverOverlay() - } - tableView.onArrowKey = { [weak self] direction in - self?.moveSelection(direction) - } - - oldWidth = tableView.frame.width - - dataSource = .init(tableView: tableView) { [weak self] tableView, _, row, _ in - guard let self else { return NSView() } - - let item = self.timelineItems[row] - - switch item { - case .profile(item: _, event: let event): - if let recycledView = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) - as? MessageProfileRowView - { - recycledView.configure(event: event) - return recycledView - } else { - let view = MessageProfileRowView() - view.initialize(imageLoader: coordinator.appState.matrixClient, focusUser: { [weak self] in - self?.coordinator.windowState.focusUser(userId: $0) - }) - view.configure(event: event) - view.identifier = item.reuseIdentifier - return view - } - case .typingIndicator: - let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) - as? TypingIndicatorRowView ?? TypingIndicatorRowView() - view.configure(names: self.typingNames) - view.identifier = item.reuseIdentifier - return view - case .virtual(_, let virtual): - let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) - as? VirtualItemRowView ?? VirtualItemRowView() - view.configure(item: virtual) - view.identifier = item.reuseIdentifier - return view - case .state(_, let event): - let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) - as? StateEventRowView ?? StateEventRowView() - view.configure(event: event) - view.identifier = item.reuseIdentifier - return view - case .message(_, let event, let content): - let kind = MessageContentKind(content: content) - let recycled = tableView.makeView(withIdentifier: kind.reuseIdentifier, owner: self) as? MessageRowView - let view: MessageRowView - if let recycled, recycled.contentKind == kind { - view = recycled - } else { - view = kind.makeRowView() - } - view.onHoverChange = { [weak self] rowView, hovering, event in - self?.updateHoverOverlay(for: rowView, hovering: hovering, event: event) ?? false - } - view.onSelectRequest = { [weak self] rowView in - self?.selectMessageRow(for: rowView) - } - view.onArrowKey = { [weak self] direction in - self?.moveSelection(direction) - } - view.configure( - event: event, - content: content, - replyDetails: self.replyDetails(for: content), - onReplyClick: self.replyClick(for: content), - onThreadClick: { [weak self] in - self?.coordinator.windowState.focusThread(rootEventId: event.eventOrTransactionId.id) - }, - ownUserId: try? self.coordinator.appState.matrixClient?.client.userId(), - onReactionClick: { [weak self] key in - self?.toggleReaction(key, for: event) - }, - roomMembers: self.timeline.room.members, - matrixClient: self.coordinator.appState.matrixClient, - onFocusUser: { [weak self] userId in - self?.coordinator.windowState.focusUser(userId: userId) - } - ) - view.setSelected(tableView.selectedRow == row) - view.identifier = kind.reuseIdentifier - return view - } - } - - tableView.delegate = self - - scrollView.documentView = tableView - hoverOverlay.isHidden = true - hoverOverlay.onMouseExited = { [weak self] event in - self?.hoverOverlayDidExit(with: event) - } - hoverOverlay.onAction = { [weak self] action in - self?.performHoverAction(action) - } - scrollView.contentView.addSubview(hoverOverlay, positioned: .above, relativeTo: tableView) - scrollView.hasVerticalScroller = true - - scrollView.automaticallyAdjustsContentInsets = false - - scrollView.drawsBackground = false - tableView.backgroundColor = .clear - view = scrollView - - // Subscribe to view resize notifications - scrollView.contentView.postsBoundsChangedNotifications = true - NotificationCenter.default.addObserver( - self, - selector: #selector(handleTableResize), - name: NSView.frameDidChangeNotification, - object: scrollView.contentView - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(viewDidScroll(_:)), - name: NSView.boundsDidChangeNotification, - object: scrollView.contentView - ) - - timeline.focusDelegate = self - timeline.diffDelegate = self - applyProjectedRows(oldIDs: [], changedIDs: []) - listenForTypingUsers() - listenForReplyDetails() - } - - private func replyDetails(for content: MatrixRustSDK.MsgLikeContent) -> MatrixRustSDK.EmbeddedEventDetails? { - guard let reply = content.inReplyTo else { return nil } - return timeline.loadedReplyDetails[reply.eventId()]?.event() ?? reply.event() - } - - private func replyClick(for content: MatrixRustSDK.MsgLikeContent) -> (() -> Void)? { - guard let replyId = content.inReplyTo?.eventId() else { return nil } - return { [weak self] in - self?.timeline.focusEvent(id: .eventId(eventId: replyId)) - } - } - - private func listenForReplyDetails() { - withObservationTracking { - _ = timeline.loadedReplyDetails - } onChange: { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - self.listenForReplyDetails() - self.refreshVisibleReplyRows() - } - } - } - - private func refreshVisibleReplyRows() { - let visibleRows = tableView.rows(in: tableView.visibleRect) - var changedRows = IndexSet() - for row in visibleRows.lowerBound ..< visibleRows.upperBound { - guard row < timelineItems.count, - case .message(_, _, let content) = timelineItems[row], - content.inReplyTo != nil, - let view = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView - else { continue } - - view.configureReply(details: replyDetails(for: content), onClick: replyClick(for: content)) - changedRows.insert(row) - } - if !changedRows.isEmpty { - tableView.noteHeightOfRows(withIndexesChanged: changedRows) - } - } - - @discardableResult - private func updateHoverOverlay(for rowView: MessageRowView, hovering: Bool, event: NSEvent) -> Bool { - if !hovering { - if hoveredMessageView === rowView { - let mousePoint = hoverOverlay.convert(event.locationInWindow, from: nil) - if hoverOverlay.bounds.contains(mousePoint) { - return true - } - hideHoverOverlay() - } - return false - } - - // Tracking areas can enter a row even while the overlay covers it. - let overlayPoint = hoverOverlay.convert(event.locationInWindow, from: nil) - if !hoverOverlay.isHidden && hoverOverlay.bounds.contains(overlayPoint) { - return false - } - - let rowPoint = tableView.convert(NSPoint(x: rowView.bounds.midX, y: rowView.bounds.midY), from: rowView) - let row = tableView.row(at: rowPoint) - guard row >= 0, row < timelineItems.count, - case .message(_, let event, _) = timelineItems[row] else { return false } - hoverOverlay.configure(canReply: event.canBeRepliedTo) - if hoveredMessageView !== rowView { - hoveredMessageView?.setHoverHighlight(false) - } - hoveredMessageView = rowView - hoveredMessageId = timelineItems[row].id - hoveredRowIndex = row - hoverOverlay.isHidden = false - positionHoverOverlay() - return true - } - - private func positionHoverOverlay() { - guard !hoverOverlay.isHidden else { return } - guard let row = hoveredRowIndex, - let hoveredMessageId, - row < timelineItems.count, - timelineItems[row].id == hoveredMessageId, - let rowView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView - else { - hideHoverOverlay() - return - } - - if hoveredMessageView !== rowView { - hoveredMessageView?.setHoverHighlight(false) - hoveredMessageView = rowView - } - rowView.setHoverHighlight(true) - - let rowRect = tableView.convert(tableView.rect(ofRow: row), to: scrollView.contentView) - let origin = NSPoint(x: rowRect.maxX - hoverOverlay.frame.width - 20, y: rowRect.maxY) - if hoverOverlay.frame.origin != origin { - hoverOverlay.setFrameOrigin(origin) - } - } - - private func hoverOverlayDidExit(with event: NSEvent) { - let tablePoint = tableView.convert(event.locationInWindow, from: nil) - let row = tableView.row(at: tablePoint) - if row >= 0, - let rowView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView +class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveTimelineDiffDelegate +{ + let coordinator: TimelineViewRepresentable.Coordinator + + private var dataSource: NSTableViewDiffableDataSource? + + let scrollView = NSScrollView() + let tableView = BottomStickyTableView() + private let hoverOverlay = MessageHoverOverlayView() + private weak var hoveredMessageView: MessageRowView? + private var hoveredMessageId: String? + private var hoveredRowIndex: Int? + private var updatingTimelineItems = false + + let timeline: LiveTimeline + private var projection: TimelineRowProjection + var timelineItems: [TimelineItemRowInfo] { + projection.rows + } + + init(coordinator: TimelineViewRepresentable.Coordinator, timeline: LiveTimeline) { + self.coordinator = coordinator + self.timeline = timeline + self.projection = TimelineRowProjection(items: timeline.timelineItems) + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.addTableColumn(NSTableColumn()) + tableView.headerView = nil + tableView.style = .plain + tableView.allowsColumnSelection = false + tableView.allowsMultipleSelection = false + tableView.allowsEmptySelection = true + tableView.selectionHighlightStyle = .none + + // Every row is sized by tableView(_:heightOfRow:). Automatic heights can + // collapse NSTextView rows because the view has no intrinsic height. + tableView.rowSizeStyle = .custom + tableView.rowHeight = 28 + tableView.usesAutomaticRowHeights = false + tableView.onLayout = { [weak self] in + self?.positionHoverOverlay() + } + tableView.onArrowKey = { [weak self] direction in + self?.moveSelection(direction) + } + + oldWidth = tableView.frame.width + + dataSource = .init(tableView: tableView) { [weak self] tableView, _, row, _ in + guard let self else { return NSView() } + + let item = self.timelineItems[row] + + switch item { + case .profile(item: _, let event): + if let recycledView = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) + as? MessageProfileRowView { - if rowView !== hoveredMessageView { - rowView.setHoverHighlight(updateHoverOverlay(for: rowView, hovering: true, event: event)) - } else { - rowView.setHoverHighlight(true) - } - return - } - hideHoverOverlay() - } - - private func hideHoverOverlay() { - hoveredMessageView?.setHoverHighlight(false) - hoveredMessageView = nil - hoveredMessageId = nil - hoveredRowIndex = nil - hoverOverlay.isHidden = true - } - - private func toggleReaction(_ key: String, for event: MatrixRustSDK.EventTimelineItem) { - Task { - do { - _ = try await timeline.timeline?.toggleReaction(itemId: event.eventOrTransactionId, key: key) - } catch { - Logger.timelineTableView.error("Failed to toggle reaction: \(error)") - } - } - } - - private func performHoverAction(_ action: MessageHoverOverlayView.Action) { - guard let hoveredMessageView else { return } - let rowPoint = tableView.convert( - NSPoint(x: hoveredMessageView.bounds.midX, y: hoveredMessageView.bounds.midY), - from: hoveredMessageView + recycledView.configure(event: event) + return recycledView + } else { + let view = MessageProfileRowView() + view.initialize( + imageLoader: coordinator.appState.matrixClient, + focusUser: { [weak self] in + self?.coordinator.windowState.focusUser(userId: $0) + }) + view.configure(event: event) + view.identifier = item.reuseIdentifier + return view + } + case .typingIndicator: + let view = + tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) + as? TypingIndicatorRowView ?? TypingIndicatorRowView() + view.configure(names: self.typingNames) + view.identifier = item.reuseIdentifier + return view + case .virtual(_, let virtual): + let view = + tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) + as? VirtualItemRowView ?? VirtualItemRowView() + view.configure(item: virtual) + view.identifier = item.reuseIdentifier + return view + case .state(_, let event): + let view = + tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) + as? StateEventRowView ?? StateEventRowView() + view.configure(event: event) + view.identifier = item.reuseIdentifier + return view + case .message(_, let event, let content): + let kind = MessageContentKind(content: content) + let recycled = + tableView.makeView(withIdentifier: kind.reuseIdentifier, owner: self) as? MessageRowView + let view: MessageRowView + if let recycled, recycled.contentKind == kind { + view = recycled + } else { + view = kind.makeRowView() + } + view.onHoverChange = { [weak self] rowView, hovering, event in + self?.updateHoverOverlay(for: rowView, hovering: hovering, event: event) ?? false + } + view.onSelectRequest = { [weak self] rowView in + self?.selectMessageRow(for: rowView) + } + view.onArrowKey = { [weak self] direction in + self?.moveSelection(direction) + } + view.configure( + event: event, + content: content, + replyDetails: self.replyDetails(for: content), + onReplyClick: self.replyClick(for: content), + onThreadClick: { [weak self] in + self?.coordinator.windowState.focusThread(rootEventId: event.eventOrTransactionId.id) + }, + ownUserId: try? self.coordinator.appState.matrixClient?.client.userId(), + onReactionClick: { [weak self] key in + self?.toggleReaction(key, for: event) + }, + roomMembers: self.timeline.room.members, + matrixClient: self.coordinator.appState.matrixClient, + onFocusUser: { [weak self] userId in + self?.coordinator.windowState.focusUser(userId: userId) + } ) - let row = tableView.row(at: rowPoint) - guard row >= 0, row < timelineItems.count, - case .message(_, let event, _) = timelineItems[row] else { return } - - switch action { - case .reaction(let key): - toggleReaction(key, for: event) - case .reactionPicker: - break // The old picker button does not have an action yet. - case .reply: - timeline.sendReplyTo = event - case .replyInThread: - coordinator.windowState.focusThread(rootEventId: event.eventOrTransactionId.id) - case .pin: - guard case .eventId(eventId: let eventId) = event.eventOrTransactionId else { return } - Task { - do { - _ = try await timeline.timeline?.pinEvent(eventId: eventId) - } catch { - Logger.timelineTableView.error("Failed to pin message: \(error)") - } - } - } - } - - private var typingNames: [String] { - let members = timeline.room.members - return timeline.room.typingUserIds.map { userId in - members.first(where: { $0.userId == userId })?.displayName ?? userId - } - } - - private func listenForTypingUsers() { - withObservationTracking { - _ = timeline.room.typingUserIds - _ = timeline.room.members - } onChange: { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - self.listenForTypingUsers() - guard self.tableView.numberOfRows > 0, - let view = self.tableView.view(atColumn: 0, row: 0, makeIfNecessary: false) as? TypingIndicatorRowView - else { return } - view.configure(names: self.typingNames) - } - } - } - - @objc func handleTableResize(_ notification: Notification) { - if oldWidth != tableView.frame.width { - oldWidth = tableView.frame.width - - NSAnimationContext.runAnimationGroup { context in - context.duration = 0 - context.allowsImplicitAnimation = false - - let visibleRect = tableView.visibleRect - let visibleRows = tableView.rows(in: visibleRect) - tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: visibleRows.lowerBound ..< visibleRows.upperBound)) - } - } - } - - var timelineFetchTask: Task? - - @objc func viewDidScroll(_ notification: Notification) { - let currentOffset = scrollView.contentView.bounds.origin.y - let timelineHeight = scrollView.contentView.documentRect.height - let viewHeight = scrollView.contentView.documentVisibleRect.height - - let distanceFromTop = timelineHeight - viewHeight - currentOffset - let threshold: CGFloat = 200.0 // Pixels from the top to trigger load - - if distanceFromTop <= threshold, timelineFetchTask == nil { - Logger.timelineTableView.info("Fetching older messages (scroll near top)") - timelineFetchTask = Task { - await timeline.fetchOlderMessages() - timelineFetchTask = nil - } - } - } - - func focusTimelineEvent(id eventId: MatrixRustSDK.EventOrTransactionId) { - guard let rowIndex = timelineItems.firstIndex(where: { item in - switch item { - case .message(item: _, event: let event, content: _): - return event.eventOrTransactionId == eventId - default: - return false - } - }) else { return } - - tableView.selectRowIndexes(IndexSet(integer: rowIndex), byExtendingSelection: false) - updateSelectedMessage() - tableView.animateRowToVisible(rowIndex) - } - - private func updateSelectedMessage() { - let selectedRow = tableView.selectedRow - - let visibleRows = tableView.rows(in: tableView.visibleRect) - for row in visibleRows.lowerBound ..< visibleRows.upperBound { - (tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView)? - .setSelected(row == selectedRow) + view.setSelected(tableView.selectedRow == row) + view.identifier = kind.reuseIdentifier + return view + } + } + + tableView.delegate = self + + scrollView.documentView = tableView + hoverOverlay.isHidden = true + hoverOverlay.onMouseExited = { [weak self] event in + self?.hoverOverlayDidExit(with: event) + } + hoverOverlay.onAction = { [weak self] action in + self?.performHoverAction(action) + } + scrollView.contentView.addSubview(hoverOverlay, positioned: .above, relativeTo: tableView) + scrollView.hasVerticalScroller = true + + scrollView.automaticallyAdjustsContentInsets = false + + scrollView.drawsBackground = false + tableView.backgroundColor = .clear + view = scrollView + + // Subscribe to view resize notifications + scrollView.contentView.postsBoundsChangedNotifications = true + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTableResize), + name: NSView.frameDidChangeNotification, + object: scrollView.contentView + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(viewDidScroll(_:)), + name: NSView.boundsDidChangeNotification, + object: scrollView.contentView + ) + + timeline.focusDelegate = self + timeline.diffDelegate = self + applyProjectedRows(oldIDs: [], changedIDs: []) + listenForTypingUsers() + listenForReplyDetails() + } + + private func replyDetails(for content: MatrixRustSDK.MsgLikeContent) -> MatrixRustSDK + .EmbeddedEventDetails? + { + guard let reply = content.inReplyTo else { return nil } + return timeline.loadedReplyDetails[reply.eventId()]?.event() ?? reply.event() + } + + private func replyClick(for content: MatrixRustSDK.MsgLikeContent) -> (() -> Void)? { + guard let replyId = content.inReplyTo?.eventId() else { return nil } + return { [weak self] in + self?.timeline.focusEvent(id: .eventId(eventId: replyId)) + } + } + + private func listenForReplyDetails() { + withObservationTracking { + _ = timeline.loadedReplyDetails + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.listenForReplyDetails() + self.refreshVisibleReplyRows() + } + } + } + + private func refreshVisibleReplyRows() { + let visibleRows = tableView.rows(in: tableView.visibleRect) + var changedRows = IndexSet() + for row in visibleRows.lowerBound.. Bool + { + if !hovering { + if hoveredMessageView === rowView { + let mousePoint = hoverOverlay.convert(event.locationInWindow, from: nil) + if hoverOverlay.bounds.contains(mousePoint) { + return true } - } - - private func selectMessageRow(for rowView: MessageRowView) { - let point = tableView.convert(NSPoint(x: rowView.bounds.midX, y: rowView.bounds.midY), from: rowView) - let row = tableView.row(at: point) - guard timelineItems.indices.contains(row), case .message = timelineItems[row] else { return } + hideHoverOverlay() + } + return false + } + + // Tracking areas can enter a row even while the overlay covers it. + let overlayPoint = hoverOverlay.convert(event.locationInWindow, from: nil) + if !hoverOverlay.isHidden && hoverOverlay.bounds.contains(overlayPoint) { + return false + } + + let rowPoint = tableView.convert( + NSPoint(x: rowView.bounds.midX, y: rowView.bounds.midY), from: rowView) + let row = tableView.row(at: rowPoint) + guard row >= 0, row < timelineItems.count, + case .message(_, let event, _) = timelineItems[row] + else { return false } + hoverOverlay.configure(canReply: event.canBeRepliedTo) + if hoveredMessageView !== rowView { + hoveredMessageView?.setHoverHighlight(false) + } + hoveredMessageView = rowView + hoveredMessageId = timelineItems[row].id + hoveredRowIndex = row + hoverOverlay.isHidden = false + positionHoverOverlay() + return true + } + + private func positionHoverOverlay() { + guard !hoverOverlay.isHidden else { return } + guard let row = hoveredRowIndex, + let hoveredMessageId, + row < timelineItems.count, + timelineItems[row].id == hoveredMessageId, + let rowView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView + else { + hideHoverOverlay() + return + } + + if hoveredMessageView !== rowView { + hoveredMessageView?.setHoverHighlight(false) + hoveredMessageView = rowView + } + rowView.setHoverHighlight(true) + + let rowRect = tableView.convert(tableView.rect(ofRow: row), to: scrollView.contentView) + let origin = NSPoint(x: rowRect.maxX - hoverOverlay.frame.width - 20, y: rowRect.maxY) + if hoverOverlay.frame.origin != origin { + hoverOverlay.setFrameOrigin(origin) + } + } + + private func hoverOverlayDidExit(with event: NSEvent) { + let tablePoint = tableView.convert(event.locationInWindow, from: nil) + let row = tableView.row(at: tablePoint) + if row >= 0, + let rowView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView + { + if rowView !== hoveredMessageView { + rowView.setHoverHighlight(updateHoverOverlay(for: rowView, hovering: true, event: event)) + } else { + rowView.setHoverHighlight(true) + } + return + } + hideHoverOverlay() + } + + private func hideHoverOverlay() { + hoveredMessageView?.setHoverHighlight(false) + hoveredMessageView = nil + hoveredMessageId = nil + hoveredRowIndex = nil + hoverOverlay.isHidden = true + } + + private func toggleReaction(_ key: String, for event: MatrixRustSDK.EventTimelineItem) { + Task { + do { + _ = try await timeline.timeline?.toggleReaction( + itemId: event.eventOrTransactionId, key: key) + } catch { + Logger.timelineTableView.error("Failed to toggle reaction: \(error)") + } + } + } + + private func performHoverAction(_ action: MessageHoverOverlayView.Action) { + guard let hoveredMessageView else { return } + let rowPoint = tableView.convert( + NSPoint(x: hoveredMessageView.bounds.midX, y: hoveredMessageView.bounds.midY), + from: hoveredMessageView + ) + let row = tableView.row(at: rowPoint) + guard row >= 0, row < timelineItems.count, + case .message(_, let event, _) = timelineItems[row] + else { return } + + switch action { + case .reaction(let key): + toggleReaction(key, for: event) + case .reactionPicker: + break // The old picker button does not have an action yet. + case .reply: + timeline.sendReplyTo = event + case .replyInThread: + coordinator.windowState.focusThread(rootEventId: event.eventOrTransactionId.id) + case .pin: + guard case .eventId(eventId: let eventId) = event.eventOrTransactionId else { return } + Task { + do { + _ = try await timeline.timeline?.pinEvent(eventId: eventId) + } catch { + Logger.timelineTableView.error("Failed to pin message: \(error)") + } + } + } + } + + private var typingNames: [String] { + let members = timeline.room.members + return timeline.room.typingUserIds.map { userId in + members.first(where: { $0.userId == userId })?.displayName ?? userId + } + } + + private func listenForTypingUsers() { + withObservationTracking { + _ = timeline.room.typingUserIds + _ = timeline.room.members + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.listenForTypingUsers() + guard self.tableView.numberOfRows > 0, + let view = self.tableView.view(atColumn: 0, row: 0, makeIfNecessary: false) + as? TypingIndicatorRowView + else { return } + view.configure(names: self.typingNames) + } + } + } + + @objc func handleTableResize(_ notification: Notification) { + if oldWidth != tableView.frame.width { + oldWidth = tableView.frame.width + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0 + context.allowsImplicitAnimation = false + + let visibleRect = tableView.visibleRect + let visibleRows = tableView.rows(in: visibleRect) + tableView.noteHeightOfRows( + withIndexesChanged: IndexSet(integersIn: visibleRows.lowerBound..? + + @objc func viewDidScroll(_ notification: Notification) { + let currentOffset = scrollView.contentView.bounds.origin.y + let timelineHeight = scrollView.contentView.documentRect.height + let viewHeight = scrollView.contentView.documentVisibleRect.height + + let distanceFromTop = timelineHeight - viewHeight - currentOffset + let threshold: CGFloat = 200.0 // Pixels from the top to trigger load + + if distanceFromTop <= threshold, timelineFetchTask == nil { + Logger.timelineTableView.info("Fetching older messages (scroll near top)") + timelineFetchTask = Task { + await timeline.fetchOlderMessages() + timelineFetchTask = nil + } + } + } + + func focusTimelineEvent(id eventId: MatrixRustSDK.EventOrTransactionId) { + guard + let rowIndex = timelineItems.firstIndex(where: { item in + switch item { + case .message(item: _, let event, content: _): + return event.eventOrTransactionId == eventId + default: + return false + } + }) + else { return } + + tableView.selectRowIndexes(IndexSet(integer: rowIndex), byExtendingSelection: false) + updateSelectedMessage() + tableView.animateRowToVisible(rowIndex) + } + + private func updateSelectedMessage() { + let selectedRow = tableView.selectedRow + + let visibleRows = tableView.rows(in: tableView.visibleRect) + for row in visibleRows.lowerBound..) { + let selectedId = + oldIDs.indices.contains(tableView.selectedRow) ? oldIDs[tableView.selectedRow] : nil + let newIDs = timelineItems.map(\.id) + let structureChanged = oldIDs != newIDs + + if structureChanged { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(newIDs.map { TimelineUniqueId(id: $0) }, toSection: .main) + updatingTimelineItems = true + dataSource?.apply(snapshot, animatingDifferences: false) + if let selectedId, let row = newIDs.firstIndex(of: selectedId) { + tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } else { + tableView.deselectAll(nil) + } + updatingTimelineItems = false } - private func moveSelection(_ direction: TimelineSelectionDirection) { - let step = direction.rowStep - let visibleRows = tableView.rows(in: tableView.visibleRect) - var row = tableView.selectedRow - if row < 0 { - row = direction == .up ? visibleRows.lowerBound - 1 : visibleRows.upperBound - } - - row += step - while timelineItems.indices.contains(row) { - if case .message = timelineItems[row] { - tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - updateSelectedMessage() - tableView.animateRowToVisible(row) - tableView.window?.makeFirstResponder(tableView) - return - } - row += step - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) is not available") - } - - enum TimelineSection { - case main - // case typingIndicator + let oldIDSet = Set(oldIDs) + var changedRows = IndexSet() + for (row, id) in newIDs.enumerated() where changedIDs.contains(id) && oldIDSet.contains(id) { + changedRows.insert(row) } - - func timelineDidApply(diffs: [TimelineDiff]) { - let change = projection.apply(diffs) - assert(projection.sourceItems.map { $0.uniqueId().id } == timeline.timelineItems.map { $0.uniqueId().id }) - applyProjectedRows(oldIDs: change.oldIDs, changedIDs: change.changedIDs) + if !changedRows.isEmpty { + tableView.noteHeightOfRows(withIndexesChanged: changedRows) + tableView.reloadData(forRowIndexes: changedRows, columnIndexes: IndexSet(integer: 0)) } - private func applyProjectedRows(oldIDs: [String], changedIDs: Set) { - let selectedId = oldIDs.indices.contains(tableView.selectedRow) ? oldIDs[tableView.selectedRow] : nil - let newIDs = timelineItems.map(\.id) - let structureChanged = oldIDs != newIDs - - if structureChanged { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(newIDs.map { TimelineUniqueId(id: $0) }, toSection: .main) - updatingTimelineItems = true - dataSource?.apply(snapshot, animatingDifferences: false) - if let selectedId, let row = newIDs.firstIndex(of: selectedId) { - tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - } else { - tableView.deselectAll(nil) - } - updatingTimelineItems = false - } - - let oldIDSet = Set(oldIDs) - var changedRows = IndexSet() - for (row, id) in newIDs.enumerated() where changedIDs.contains(id) && oldIDSet.contains(id) { - changedRows.insert(row) - } - if !changedRows.isEmpty { - tableView.noteHeightOfRows(withIndexesChanged: changedRows) - tableView.reloadData(forRowIndexes: changedRows, columnIndexes: IndexSet(integer: 0)) - } - - if let hoveredMessageId { - hoveredRowIndex = newIDs.firstIndex(of: hoveredMessageId) - if hoveredRowIndex == nil { - hideHoverOverlay() - } else { - positionHoverOverlay() - } - } - updateSelectedMessage() + if let hoveredMessageId { + hoveredRowIndex = newIDs.firstIndex(of: hoveredMessageId) + if hoveredRowIndex == nil { + hideHoverOverlay() + } else { + positionHoverOverlay() + } } + updateSelectedMessage() + } - // values used to track width changes - var oldWidth: CGFloat? - private var measurementMessageViews: [MessageContentKind: MessageRowView] = [:] + // values used to track width changes + var oldWidth: CGFloat? + private var measurementMessageViews: [MessageContentKind: MessageRowView] = [:] - private func measurementView(for kind: MessageContentKind) -> MessageRowView { - if let view = measurementMessageViews[kind] { - return view - } - let view = kind.makeRowView() - measurementMessageViews[kind] = view - return view + private func measurementView(for kind: MessageContentKind) -> MessageRowView { + if let view = measurementMessageViews[kind] { + return view } + let view = kind.makeRowView() + measurementMessageViews[kind] = view + return view + } } extension TimelineViewController: NSTableViewDelegate { - func selectionShouldChange(in tableView: NSTableView) -> Bool { - return true - } - - func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { - guard timelineItems.indices.contains(row) else { return false } - if case .message = timelineItems[row] { - return true - } - return false - } + func selectionShouldChange(in tableView: NSTableView) -> Bool { + return true + } - func tableViewSelectionDidChange(_ notification: Notification) { - guard !updatingTimelineItems else { return } - updateSelectedMessage() + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + guard timelineItems.indices.contains(row) else { return false } + if case .message = timelineItems[row] { + return true } + return false + } - func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { - let item = timelineItems[row] - - if case .profile = item { - return MessageProfileRowView.rowHeight - } + func tableViewSelectionDidChange(_ notification: Notification) { + guard !updatingTimelineItems else { return } + updateSelectedMessage() + } - if case .typingIndicator = item { - return TypingIndicatorRowView.rowHeight - } + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + let item = timelineItems[row] - if case .virtual = item { - return VirtualItemRowView.rowHeight - } - - if case .state(_, let event) = item { - return StateEventRowView.height(for: event, width: tableView.tableColumns[0].width) - } - - if case .message(_, let event, let content) = item { - let kind = MessageContentKind(content: content) - return measurementView(for: kind).height( - for: content, - width: tableView.tableColumns[0].width, - replyDetails: replyDetails(for: content), - receiptCount: event.readReceipts.count - ) - } - - preconditionFailure("Unsupported timeline row") + if case .profile = item { + return MessageProfileRowView.rowHeight } -} -class BottomStickyTableView: NSTableView { - var onLayout: (() -> Void)? - var onArrowKey: ((TimelineSelectionDirection) -> Void)? - - override func keyDown(with event: NSEvent) { - if let direction = TimelineSelectionDirection(event: event) { - onArrowKey?(direction) - } else { - super.keyDown(with: event) - } + if case .typingIndicator = item { + return TypingIndicatorRowView.rowHeight } - override func mouseDown(with event: NSEvent) { - guard row(at: convert(event.locationInWindow, from: nil)) >= 0 else { return } - super.mouseDown(with: event) + if case .virtual = item { + return VirtualItemRowView.rowHeight } - override func layout() { - super.layout() - onLayout?() + if case .state(_, let event) = item { + return StateEventRowView.height(for: event, width: tableView.tableColumns[0].width) } - // By returning false, the table starts drawing from the bottom up - override var isFlipped: Bool { - return false + if case .message(_, let event, let content) = item { + let kind = MessageContentKind(content: content) + return measurementView(for: kind).height( + for: content, + width: tableView.tableColumns[0].width, + replyDetails: replyDetails(for: content), + receiptCount: event.readReceipts.count + ) } + + preconditionFailure("Unsupported timeline row") + } +} + +class BottomStickyTableView: NSTableView { + var onLayout: (() -> Void)? + var onArrowKey: ((TimelineSelectionDirection) -> Void)? + + override func keyDown(with event: NSEvent) { + if let direction = TimelineSelectionDirection(event: event) { + onArrowKey?(direction) + } else { + super.keyDown(with: event) + } + } + + override func mouseDown(with event: NSEvent) { + guard row(at: convert(event.locationInWindow, from: nil)) >= 0 else { return } + super.mouseDown(with: event) + } + + override func layout() { + super.layout() + onLayout?() + } + + // By returning false, the table starts drawing from the bottom up + override var isFlipped: Bool { + return false + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineViewRepresentable.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineViewRepresentable.swift index a91c8b8..d1223d7 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineViewRepresentable.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineViewRepresentable.swift @@ -3,33 +3,33 @@ import MatrixRustSDK import SwiftUI struct TimelineViewRepresentable: NSViewControllerRepresentable { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState - let timeline: LiveTimeline - init(timeline: LiveTimeline) { - self.timeline = timeline - } + let timeline: LiveTimeline + init(timeline: LiveTimeline) { + self.timeline = timeline + } - func makeCoordinator() -> Coordinator { - return Coordinator(appState: appState, windowState: windowState) - } + func makeCoordinator() -> Coordinator { + return Coordinator(appState: appState, windowState: windowState) + } - class Coordinator { - let appState: AppState - let windowState: WindowState + class Coordinator { + let appState: AppState + let windowState: WindowState - init(appState: AppState, windowState: WindowState) { - self.appState = appState - self.windowState = windowState - } + init(appState: AppState, windowState: WindowState) { + self.appState = appState + self.windowState = windowState } + } - func makeNSViewController(context: Context) -> TimelineViewController { - return TimelineViewController(coordinator: context.coordinator, timeline: timeline) - } + func makeNSViewController(context: Context) -> TimelineViewController { + return TimelineViewController(coordinator: context.coordinator, timeline: timeline) + } - func updateNSViewController(_ timelineViewController: TimelineViewController, context: Context) { - // SDK diff batches update the controller directly through LiveTimeline. - } + func updateNSViewController(_ timelineViewController: TimelineViewController, context: Context) { + // SDK diff batches update the controller directly through LiveTimeline. + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/TypingIndicatorRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/TypingIndicatorRowView.swift index 7e9f7b3..88c8e34 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/TypingIndicatorRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TypingIndicatorRowView.swift @@ -2,100 +2,101 @@ import AppKit import QuartzCore final class TypingIndicatorRowView: NSView { - static let rowHeight: Double = 32 + static let rowHeight: Double = 32 - private let label = NSTextField(labelWithString: "") - private let dots = (0 ..< 3).map { _ in NSView() } - private let content = NSStackView() - private var isAnimating = false + private let label = NSTextField(labelWithString: "") + private let dots = (0..<3).map { _ in NSView() } + private let content = NSStackView() + private var isAnimating = false - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) - let dotStack = NSStackView(views: dots) - dotStack.orientation = .horizontal - dotStack.alignment = .centerY - dotStack.spacing = 2 + let dotStack = NSStackView(views: dots) + dotStack.orientation = .horizontal + dotStack.alignment = .centerY + dotStack.spacing = 2 - for dot in dots { - dot.wantsLayer = true - dot.layer?.backgroundColor = NSColor.secondaryLabelColor.cgColor - dot.layer?.cornerRadius = 3 - NSLayoutConstraint.activate([ - dot.widthAnchor.constraint(equalToConstant: 6), - dot.heightAnchor.constraint(equalToConstant: 6), - ]) - } - - label.isBordered = false - label.isBezeled = false - label.drawsBackground = false - label.lineBreakMode = .byTruncatingTail - label.maximumNumberOfLines = 1 - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + for dot in dots { + dot.wantsLayer = true + dot.layer?.backgroundColor = NSColor.secondaryLabelColor.cgColor + dot.layer?.cornerRadius = 3 + NSLayoutConstraint.activate([ + dot.widthAnchor.constraint(equalToConstant: 6), + dot.heightAnchor.constraint(equalToConstant: 6), + ]) + } - content.orientation = .horizontal - content.alignment = .centerY - content.spacing = 8 - content.addArrangedSubview(dotStack) - content.addArrangedSubview(label) - content.translatesAutoresizingMaskIntoConstraints = false - content.isHidden = true - addSubview(content) + label.isBordered = false + label.isBezeled = false + label.drawsBackground = false + label.lineBreakMode = .byTruncatingTail + label.maximumNumberOfLines = 1 + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - NSLayoutConstraint.activate([ - content.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), - content.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), - content.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) - } + content.orientation = .horizontal + content.alignment = .centerY + content.spacing = 8 + content.addArrangedSubview(dotStack) + content.addArrangedSubview(label) + content.translatesAutoresizingMaskIntoConstraints = false + content.isHidden = true + addSubview(content) - func configure(names: [String]) { - guard !names.isEmpty else { - label.stringValue = "" - content.isHidden = true - stopAnimating() - return - } + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + content.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), + content.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } - let text = NSMutableAttributedString( - string: names.joined(separator: ", "), - attributes: [.font: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)] - ) - text.append(NSAttributedString( - string: String(localized: names.count == 1 ? " is typing" : " are typing"), - attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] - )) - label.attributedStringValue = text - content.isHidden = false - startAnimating() + func configure(names: [String]) { + guard !names.isEmpty else { + label.stringValue = "" + content.isHidden = true + stopAnimating() + return } - private func startAnimating() { - guard !isAnimating else { return } - isAnimating = true + let text = NSMutableAttributedString( + string: names.joined(separator: ", "), + attributes: [.font: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)] + ) + text.append( + NSAttributedString( + string: String(localized: names.count == 1 ? " is typing" : " are typing"), + attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] + )) + label.attributedStringValue = text + content.isHidden = false + startAnimating() + } - for (index, dot) in dots.enumerated() { - let opacity = CAKeyframeAnimation(keyPath: "opacity") - opacity.values = [0.2, 0.7, 0.2] - opacity.keyTimes = [0, 0.5, 1] - opacity.duration = 1.8 - opacity.beginTime = CACurrentMediaTime() + Double(index) * 0.18 - opacity.repeatCount = .infinity - dot.layer?.add(opacity, forKey: "typingPulse") - } - } + private func startAnimating() { + guard !isAnimating else { return } + isAnimating = true - private func stopAnimating() { - guard isAnimating else { return } - isAnimating = false - for dot in dots { - dot.layer?.removeAnimation(forKey: "typingPulse") - } + for (index, dot) in dots.enumerated() { + let opacity = CAKeyframeAnimation(keyPath: "opacity") + opacity.values = [0.2, 0.7, 0.2] + opacity.keyTimes = [0, 0.5, 1] + opacity.duration = 1.8 + opacity.beginTime = CACurrentMediaTime() + Double(index) * 0.18 + opacity.repeatCount = .infinity + dot.layer?.add(opacity, forKey: "typingPulse") } + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + private func stopAnimating() { + guard isAnimating else { return } + isAnimating = false + for dot in dots { + dot.layer?.removeAnimation(forKey: "typingPulse") } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/VirtualItemRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/VirtualItemRowView.swift index b1942c1..e4a19e3 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/VirtualItemRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/VirtualItemRowView.swift @@ -2,94 +2,94 @@ import AppKit import MatrixRustSDK final class VirtualItemRowView: NSView { - static let rowHeight: CGFloat = 40 + static let rowHeight: CGFloat = 40 - private let leadingLine = NSView() - private let trailingLine = NSView() - private let label = NSTextField(labelWithString: "") - private var isReadMarker = false + private let leadingLine = NSView() + private let trailingLine = NSView() + private let label = NSTextField(labelWithString: "") + private var isReadMarker = false - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) - for line in [leadingLine, trailingLine] { - line.wantsLayer = true - line.translatesAutoresizingMaskIntoConstraints = false - addSubview(line) - } - - label.translatesAutoresizingMaskIntoConstraints = false - label.lineBreakMode = .byTruncatingTail - label.maximumNumberOfLines = 1 - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - addSubview(label) + for line in [leadingLine, trailingLine] { + line.wantsLayer = true + line.translatesAutoresizingMaskIntoConstraints = false + addSubview(line) + } - NSLayoutConstraint.activate([ - leadingLine.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), - leadingLine.trailingAnchor.constraint(equalTo: label.leadingAnchor, constant: -10), - leadingLine.centerYAnchor.constraint(equalTo: centerYAnchor), - leadingLine.heightAnchor.constraint(equalToConstant: 1), + label.translatesAutoresizingMaskIntoConstraints = false + label.lineBreakMode = .byTruncatingTail + label.maximumNumberOfLines = 1 + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + addSubview(label) - label.centerYAnchor.constraint(equalTo: centerYAnchor), + NSLayoutConstraint.activate([ + leadingLine.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + leadingLine.trailingAnchor.constraint(equalTo: label.leadingAnchor, constant: -10), + leadingLine.centerYAnchor.constraint(equalTo: centerYAnchor), + leadingLine.heightAnchor.constraint(equalToConstant: 1), - trailingLine.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 10), - trailingLine.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), - trailingLine.centerYAnchor.constraint(equalTo: centerYAnchor), - trailingLine.heightAnchor.constraint(equalToConstant: 1), - trailingLine.widthAnchor.constraint(equalTo: leadingLine.widthAnchor), - ]) + label.centerYAnchor.constraint(equalTo: centerYAnchor), - updateLineColor() - } + trailingLine.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 10), + trailingLine.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + trailingLine.centerYAnchor.constraint(equalTo: centerYAnchor), + trailingLine.heightAnchor.constraint(equalToConstant: 1), + trailingLine.widthAnchor.constraint(equalTo: leadingLine.widthAnchor), + ]) - func configure(item: VirtualTimelineItem) { - switch item { - case let .dateDivider(ts): - let date = Date(timeIntervalSince1970: Double(ts) / 1000) - let dayInSeconds: TimeInterval = 24 * 60 * 60 - let elapsed = Date.now.timeIntervalSince(date) - if elapsed < dayInSeconds { - label.stringValue = String(localized: "Today") - } else if elapsed < dayInSeconds * 2 { - label.stringValue = String(localized: "Yesterday") - } else { - label.stringValue = date.formatted(date: .long, time: .omitted) - } - label.font = .systemFont(ofSize: NSFont.systemFontSize) - isReadMarker = false - case .readMarker: - label.stringValue = String(localized: "Read Marker") - label.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .medium) - isReadMarker = true - case .timelineStart: - label.stringValue = String(localized: "Start of conversation") - label.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .medium) - isReadMarker = false - } + updateLineColor() + } - updateLineColor() + func configure(item: VirtualTimelineItem) { + switch item { + case .dateDivider(let ts): + let date = Date(timeIntervalSince1970: Double(ts) / 1000) + let dayInSeconds: TimeInterval = 24 * 60 * 60 + let elapsed = Date.now.timeIntervalSince(date) + if elapsed < dayInSeconds { + label.stringValue = String(localized: "Today") + } else if elapsed < dayInSeconds * 2 { + label.stringValue = String(localized: "Yesterday") + } else { + label.stringValue = date.formatted(date: .long, time: .omitted) + } + label.font = .systemFont(ofSize: NSFont.systemFontSize) + isReadMarker = false + case .readMarker: + label.stringValue = String(localized: "Read Marker") + label.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .medium) + isReadMarker = true + case .timelineStart: + label.stringValue = String(localized: "Start of conversation") + label.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .medium) + isReadMarker = false } - override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - updateLineColor() - } + updateLineColor() + } - private func updateLineColor() { - let color: NSColor - if isReadMarker { - let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - color = NSColor.red.blended(withFraction: 0.1, of: isDark ? .white : .black) ?? .red - } else { - color = .separatorColor - } - label.textColor = isReadMarker ? color : .labelColor - leadingLine.layer?.backgroundColor = color.cgColor - trailingLine.layer?.backgroundColor = color.cgColor - } + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateLineColor() + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + private func updateLineColor() { + let color: NSColor + if isReadMarker { + let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + color = NSColor.red.blended(withFraction: 0.1, of: isDark ? .white : .black) ?? .red + } else { + color = .separatorColor } + label.textColor = isReadMarker ? color : .labelColor + leadingLine.layer?.backgroundColor = color.cgColor + trailingLine.layer?.backgroundColor = color.cgColor + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift b/Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift index daa9906..ea5e2f4 100644 --- a/Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift +++ b/Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift @@ -6,74 +6,74 @@ import OSLog import SwiftUI struct RoomPinRow: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState - let timeline: LiveTimeline - let eventId: String + let timeline: LiveTimeline + let eventId: String - @State private var event: MatrixRustSDK.EventTimelineItem? + @State private var event: MatrixRustSDK.EventTimelineItem? - @ViewBuilder - var content: some View { - if let event { - VStack { - Text(event.sender).bold() - Text(event.content.userInterfaceText) - } - } else { - Text("Loading message...") - .foregroundStyle(.secondary) - } + @ViewBuilder + var content: some View { + if let event { + VStack { + Text(event.sender).bold() + Text(event.content.userInterfaceText) + } + } else { + Text("Loading message...") + .foregroundStyle(.secondary) } + } - var body: some View { - content - .task(id: eventId) { - do { - event = try await timeline.timeline?.getEventTimelineItemByEventId( - eventId: eventId) - } catch { - Logger.viewCycle.error("failed to fetch details for pinned event \(error)") - } - } - } + var body: some View { + content + .task(id: eventId) { + do { + event = try await timeline.timeline?.getEventTimelineItemByEventId( + eventId: eventId) + } catch { + Logger.viewCycle.error("failed to fetch details for pinned event \(error)") + } + } + } } struct InspectorRoomPins: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - - @State var pinnedMessages: [String] = [] - @State var selectedId: String? + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState - var timeline: LiveTimeline? { - guard case .joinedRoom(timeline: let timeline) = windowState.selectedScreen else { - return nil - } + @State var pinnedMessages: [String] = [] + @State var selectedId: String? - return timeline + var timeline: LiveTimeline? { + guard case .joinedRoom(timeline: let timeline) = windowState.selectedScreen else { + return nil } - var pinnedEventIds: [String] { - guard let timeline, let roomInfo = timeline.room.roomInfo else { - return [] - } - // TODO: Use a pinned timeline instead - return roomInfo.pinnedEventIds + return timeline + } + + var pinnedEventIds: [String] { + guard let timeline, let roomInfo = timeline.room.roomInfo else { + return [] } + // TODO: Use a pinned timeline instead + return roomInfo.pinnedEventIds + } - var body: some View { - if let timeline, !pinnedEventIds.isEmpty { - List(pinnedEventIds, id: \.self, selection: $selectedId) { eventId in - RoomPinRow(timeline: timeline, eventId: eventId) - } - .onChange(of: selectedId) { - guard let selectedId else { return } - windowState.focusMessage(eventId: selectedId) - } - } else { - ContentUnavailableView("No pinned messages", systemImage: "pin.circle") - } + var body: some View { + if let timeline, !pinnedEventIds.isEmpty { + List(pinnedEventIds, id: \.self, selection: $selectedId) { eventId in + RoomPinRow(timeline: timeline, eventId: eventId) + } + .onChange(of: selectedId) { + guard let selectedId else { return } + windowState.focusMessage(eventId: selectedId) + } + } else { + ContentUnavailableView("No pinned messages", systemImage: "pin.circle") } + } } diff --git a/Sources/MactrixApp/Views/Inspector/InspectorScreen.swift b/Sources/MactrixApp/Views/Inspector/InspectorScreen.swift index 537314b..44ceef2 100644 --- a/Sources/MactrixApp/Views/Inspector/InspectorScreen.swift +++ b/Sources/MactrixApp/Views/Inspector/InspectorScreen.swift @@ -1,65 +1,67 @@ -import MatrixRustSDK +import MactrixUI +import MatrixIntegration import MatrixProtocols +import MatrixRustSDK import OSLog import SwiftUI -import MactrixUI -import MatrixIntegration struct InspectorScreen: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState - @ViewBuilder - var content: some View { - @Bindable var windowState = windowState + @ViewBuilder + var content: some View { + @Bindable var windowState = windowState - switch windowState.inspectorContent { - case .search: - SearchInspectorView() - case let .focusThread(threadTimeline: thread): - VStack(spacing: 0) { - MactrixUI.ThreadTimelineHeader { - windowState.inspectorVisible = false - Task { - // Insert sleep to delay UI update until close animation finishes - try? await Task.sleep(for: .milliseconds(500)) - windowState.inspectorContent = .roomInfo - } - } - ChatView(timeline: thread) - } - .inspectorColumnWidth(min: 200, ideal: 400, max: 900) - case .roomInfo: - switch windowState.selectedScreen { - case let .joinedRoom(timeline: timeline): - MactrixUI.RoomInspectorView(room: timeline.room, members: timeline.room.members, roomInfo: timeline.room.roomInfo, imageLoader: appState.matrixClient, inspectorVisible: $windowState.inspectorVisible) - case .none, .newRoom, .previewRoom, .loadMatrixUrl(_), .user(profile: _): - Text("No room selected") - .inspectorColumnWidth(min: 200, ideal: 250, max: nil) - } - case let .userInfo(userId: userId): - InspectorUserInfo(userId: userId) - .inspectorColumnWidth(min: 200, ideal: 250, max: nil) - case .roomThreads: - InspectorThreads() - .inspectorColumnWidth(min: 200, ideal: 250, max: nil) - case .roomPins: - InspectorRoomPins() - .inspectorColumnWidth(min: 200, ideal: 250, max: nil) + switch windowState.inspectorContent { + case .search: + SearchInspectorView() + case .focusThread(threadTimeline: let thread): + VStack(spacing: 0) { + MactrixUI.ThreadTimelineHeader { + windowState.inspectorVisible = false + Task { + // Insert sleep to delay UI update until close animation finishes + try? await Task.sleep(for: .milliseconds(500)) + windowState.inspectorContent = .roomInfo + } } + ChatView(timeline: thread) + } + .inspectorColumnWidth(min: 200, ideal: 400, max: 900) + case .roomInfo: + switch windowState.selectedScreen { + case .joinedRoom(let timeline): + MactrixUI.RoomInspectorView( + room: timeline.room, members: timeline.room.members, roomInfo: timeline.room.roomInfo, + imageLoader: appState.matrixClient, inspectorVisible: $windowState.inspectorVisible) + case .none, .newRoom, .previewRoom, .loadMatrixUrl(_), .user(profile: _): + Text("No room selected") + .inspectorColumnWidth(min: 200, ideal: 250, max: nil) + } + case .userInfo(let userId): + InspectorUserInfo(userId: userId) + .inspectorColumnWidth(min: 200, ideal: 250, max: nil) + case .roomThreads: + InspectorThreads() + .inspectorColumnWidth(min: 200, ideal: 250, max: nil) + case .roomPins: + InspectorRoomPins() + .inspectorColumnWidth(min: 200, ideal: 250, max: nil) } + } - var body: some View { - @Bindable var windowState = windowState + var body: some View { + @Bindable var windowState = windowState - content - .toolbar { - Button { - windowState.toggleInspector() - } label: { - Label("Toggle Inspector", systemImage: "info.circle") - } - .help("Toggle Inspector") - } - } + content + .toolbar { + Button { + windowState.toggleInspector() + } label: { + Label("Toggle Inspector", systemImage: "info.circle") + } + .help("Toggle Inspector") + } + } } diff --git a/Sources/MactrixApp/Views/Inspector/InspectorThreads.swift b/Sources/MactrixApp/Views/Inspector/InspectorThreads.swift index 4161dd5..1ceeb70 100644 --- a/Sources/MactrixApp/Views/Inspector/InspectorThreads.swift +++ b/Sources/MactrixApp/Views/Inspector/InspectorThreads.swift @@ -6,33 +6,33 @@ import OSLog import SwiftUI struct InspectorThreads: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState - @State var threads: [String] = [] + @State var threads: [String] = [] - var timeline: LiveTimeline? { - guard case let .joinedRoom(timeline: timeline) = windowState.selectedScreen else { - return nil - } - - return timeline + var timeline: LiveTimeline? { + guard case .joinedRoom(timeline: let timeline) = windowState.selectedScreen else { + return nil } - var content: some View { - Text("Threads") - } + return timeline + } - var body: some View { - content - .task(id: timeline?.room.id ?? "") { - guard let _ = timeline else { - threads = [] - return - } + var content: some View { + Text("Threads") + } - // TODO: Fetch threads when Room::list_threads is added to the FFI - // timeline.room.room.list_threads(...) - } - } + var body: some View { + content + .task(id: timeline?.room.id ?? "") { + guard timeline != nil else { + threads = [] + return + } + + // TODO: Fetch threads when Room::list_threads is added to the FFI + // timeline.room.room.list_threads(...) + } + } } diff --git a/Sources/MactrixApp/Views/Inspector/InspectorUserInfo.swift b/Sources/MactrixApp/Views/Inspector/InspectorUserInfo.swift index 451d61f..4b791b9 100644 --- a/Sources/MactrixApp/Views/Inspector/InspectorUserInfo.swift +++ b/Sources/MactrixApp/Views/Inspector/InspectorUserInfo.swift @@ -1,53 +1,54 @@ -import MatrixRustSDK +import MactrixUI import MatrixProtocols +import MatrixRustSDK import OSLog import SwiftUI -import MactrixUI struct InspectorUserInfo: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState - let userId: String + let userId: String - @State private var profile: MatrixRustSDK.UserProfile? + @State private var profile: MatrixRustSDK.UserProfile? - var isUserIgnored: Bool { - appState.matrixClient?.ignoredUserIds.contains(userId) == true - } + var isUserIgnored: Bool { + appState.matrixClient?.ignoredUserIds.contains(userId) == true + } - @ViewBuilder - var content: some View { - if let profile { - MactrixUI.UserProfileView( - profile: profile, - isUserIgnored: isUserIgnored, - actions: appState.matrixClient?.userProfileActions(forUserId: userId, windowState: windowState), - timelineActions: nil, - imageLoader: appState.matrixClient - ) - } else { - MactrixUI.UserProfileView( - profile: MockUserProfile(), - isUserIgnored: isUserIgnored, - actions: nil, - timelineActions: nil, - imageLoader: appState.matrixClient - ) - .redacted(reason: .placeholder) - } + @ViewBuilder + var content: some View { + if let profile { + MactrixUI.UserProfileView( + profile: profile, + isUserIgnored: isUserIgnored, + actions: appState.matrixClient?.userProfileActions( + forUserId: userId, windowState: windowState), + timelineActions: nil, + imageLoader: appState.matrixClient + ) + } else { + MactrixUI.UserProfileView( + profile: MockUserProfile(), + isUserIgnored: isUserIgnored, + actions: nil, + timelineActions: nil, + imageLoader: appState.matrixClient + ) + .redacted(reason: .placeholder) } + } - var body: some View { - content - .task(id: userId) { - guard let matrixClient = appState.matrixClient else { return } + var body: some View { + content + .task(id: userId) { + guard let matrixClient = appState.matrixClient else { return } - do { - profile = try await matrixClient.client.getProfile(userId: userId) - } catch { - Logger.viewCycle.error("failed to get profile \(error)") - } - } - } + do { + profile = try await matrixClient.client.getProfile(userId: userId) + } catch { + Logger.viewCycle.error("failed to get profile \(error)") + } + } + } } diff --git a/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift b/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift index 084d1a2..6ecc5c4 100644 --- a/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift +++ b/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift @@ -4,77 +4,77 @@ import SwiftUI import Utils struct LoadMatrixUriScreen: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState - let matrixUri: Utils.MatrixUriScheme + let matrixUri: Utils.MatrixUriScheme + + @ViewBuilder + var content: some View { + switch matrixUri.kind { + case .roomAlias(let alias): + ProgressView { + Text("Loading room \(alias)") + } + case .roomId(let roomId): + ProgressView { + Text("Loading room \(roomId)") + } + case .user(let userId): + ProgressView { + Text("Loading user \(userId)") + } + } + } + + var body: some View { + content + .task(id: matrixUri) { + Logger.viewCycle.debug("LoadMatrixUriScreen TASK") + + guard let matrixClient = appState.matrixClient else { + Logger.viewCycle.warning("Matrix client was nil so could not follow url") + return + } - @ViewBuilder - var content: some View { switch matrixUri.kind { - case .roomAlias(let alias): - ProgressView { - Text("Loading room \(alias)") - } case .roomId(let roomId): - ProgressView { - Text("Loading room \(roomId)") + Logger.viewCycle.debug("Matrix uri match roomId") + Task { + do { + let roomPreview = try await matrixClient.client + .getRoomPreviewFromRoomId( + roomId: roomId, + viaServers: matrixUri.routingVia + ) + windowState.selectedScreen = .previewRoom(roomPreview) + } catch { + Logger.viewCycle.error("Failed to get room id from url: \(error)") } + } + case .roomAlias(let alias): + Logger.viewCycle.debug("Matrix uri match room alias") + Task { + do { + let roomPreview = try await matrixClient.client + .getRoomPreviewFromRoomAlias(roomAlias: alias) + windowState.selectedScreen = .previewRoom(roomPreview) + } catch { + Logger.viewCycle.error("Failed to get room alias from url: \(error)") + } + } case .user(let userId): - ProgressView { - Text("Loading user \(userId)") + Logger.viewCycle.debug("Matrix uri match user") + Task { + do { + let userProfile = try await matrixClient.client.getProfile( + userId: userId) + windowState.selectedScreen = .user(profile: userProfile) + } catch { + Logger.viewCycle.error("Failed to get user profile from url: \(error)") } + } } - } - - var body: some View { - content - .task(id: matrixUri) { - Logger.viewCycle.debug("LoadMatrixUriScreen TASK") - - guard let matrixClient = appState.matrixClient else { - Logger.viewCycle.warning("Matrix client was nil so could not follow url") - return - } - - switch matrixUri.kind { - case .roomId(let roomId): - Logger.viewCycle.debug("Matrix uri match roomId") - Task { - do { - let roomPreview = try await matrixClient.client - .getRoomPreviewFromRoomId( - roomId: roomId, - viaServers: matrixUri.routingVia - ) - windowState.selectedScreen = .previewRoom(roomPreview) - } catch { - Logger.viewCycle.error("Failed to get room id from url: \(error)") - } - } - case .roomAlias(let alias): - Logger.viewCycle.debug("Matrix uri match room alias") - Task { - do { - let roomPreview = try await matrixClient.client - .getRoomPreviewFromRoomAlias(roomAlias: alias) - windowState.selectedScreen = .previewRoom(roomPreview) - } catch { - Logger.viewCycle.error("Failed to get room alias from url: \(error)") - } - } - case .user(let userId): - Logger.viewCycle.debug("Matrix uri match user") - Task { - do { - let userProfile = try await matrixClient.client.getProfile( - userId: userId) - windowState.selectedScreen = .user(profile: userProfile) - } catch { - Logger.viewCycle.error("Failed to get user profile from url: \(error)") - } - } - } - } - } + } + } } diff --git a/Sources/MactrixApp/Views/MatrixImageView.swift b/Sources/MactrixApp/Views/MatrixImageView.swift index fa7d158..25dabc3 100644 --- a/Sources/MactrixApp/Views/MatrixImageView.swift +++ b/Sources/MactrixApp/Views/MatrixImageView.swift @@ -4,78 +4,78 @@ import SwiftUI import UniformTypeIdentifiers struct MatrixImageView: View { - let mediaSource: MediaSource? - let mimeType: String? + let mediaSource: MediaSource? + let mimeType: String? - @Environment(AppState.self) private var appState - @State private var image: Image? = nil - @State private var errorMessage: String? = nil + @Environment(AppState.self) private var appState + @State private var image: Image? = nil + @State private var errorMessage: String? = nil - init(mediaSource: MediaSource?, mimeType: String?) { - self.mediaSource = mediaSource - self.mimeType = mimeType - if let url = mediaSource?.url(), - let cached = MatrixClient.imageCache.object(forKey: NSString(string: url)) - { - self._image = State(initialValue: Image(nsImage: cached)) - } + init(mediaSource: MediaSource?, mimeType: String?) { + self.mediaSource = mediaSource + self.mimeType = mimeType + if let url = mediaSource?.url(), + let cached = MatrixClient.imageCache.object(forKey: NSString(string: url)) + { + self._image = State(initialValue: Image(nsImage: cached)) } + } - @ViewBuilder - var content: some View { - if let image { - image - } else if let errorMessage { - VStack { - ContentUnavailableView("Error loading image", image: "photo.badge.exclamationmark") - Text(errorMessage) - } - } else if mediaSource == nil { - Rectangle() - .fill(Color.gray.opacity(0.3)) - } else { - ProgressView { - Text("Fetching image") - } - } + @ViewBuilder + var content: some View { + if let image { + image + } else if let errorMessage { + VStack { + ContentUnavailableView("Error loading image", image: "photo.badge.exclamationmark") + Text(errorMessage) + } + } else if mediaSource == nil { + Rectangle() + .fill(Color.gray.opacity(0.3)) + } else { + ProgressView { + Text("Fetching image") + } } + } - var body: some View { - content - .task(id: mediaSource?.url(), priority: .utility) { - guard image == nil else { return } - guard let matrixClient = appState.matrixClient else { - errorMessage = "Matrix client not available" - return - } - - guard let mediaSource else { return } + var body: some View { + content + .task(id: mediaSource?.url(), priority: .utility) { + guard image == nil else { return } + guard let matrixClient = appState.matrixClient else { + errorMessage = "Matrix client not available" + return + } - let cacheKey = NSString(string: mediaSource.url()) - if let cached = MatrixClient.imageCache.object(forKey: cacheKey) { - image = Image(nsImage: cached) - return - } + guard let mediaSource else { return } - do { - let data = try await matrixClient.client.getMediaContent(mediaSource: mediaSource) - let contentType = mimeType.flatMap { UTType(mimeType: $0) } + let cacheKey = NSString(string: mediaSource.url()) + if let cached = MatrixClient.imageCache.object(forKey: cacheKey) { + image = Image(nsImage: cached) + return + } - if #available(macOS 15.2, *) { - image = try await Image(importing: data, contentType: contentType) - } else { - // Fallback for older macOS versions - if let nsImage = NSImage(data: data) { - image = Image(nsImage: nsImage) - } - } + do { + let data = try await matrixClient.client.getMediaContent(mediaSource: mediaSource) + let contentType = mimeType.flatMap { UTType(mimeType: $0) } - if let nsImage = NSImage(data: data) { - MatrixClient.setCachedImage(nsImage, forKey: cacheKey) - } - } catch { - errorMessage = error.localizedDescription - } + if #available(macOS 15.2, *) { + image = try await Image(importing: data, contentType: contentType) + } else { + // Fallback for older macOS versions + if let nsImage = NSImage(data: data) { + image = Image(nsImage: nsImage) } - } + } + + if let nsImage = NSImage(data: data) { + MatrixClient.setCachedImage(nsImage, forKey: cacheKey) + } + } catch { + errorMessage = error.localizedDescription + } + } + } } diff --git a/Sources/MactrixApp/Views/MultilineBannerLabelStyle.swift b/Sources/MactrixApp/Views/MultilineBannerLabelStyle.swift index 9ee6cb2..5b9426c 100644 --- a/Sources/MactrixApp/Views/MultilineBannerLabelStyle.swift +++ b/Sources/MactrixApp/Views/MultilineBannerLabelStyle.swift @@ -8,16 +8,16 @@ import SwiftUI struct MultilineBannerLabelStyle: LabelStyle { - func makeBody(configuration: Configuration) -> some View { - HStack(alignment: .firstTextBaseline) { - configuration.icon - VStack(alignment: .leading) { - configuration.title - } - } + func makeBody(configuration: Configuration) -> some View { + HStack(alignment: .firstTextBaseline) { + configuration.icon + VStack(alignment: .leading) { + configuration.title + } } + } } extension LabelStyle where Self == MultilineBannerLabelStyle { - static var multiline: MultilineBannerLabelStyle { MultilineBannerLabelStyle() } + static var multiline: MultilineBannerLabelStyle { MultilineBannerLabelStyle() } } diff --git a/Sources/MactrixApp/Views/Search/SearchInspectorView.swift b/Sources/MactrixApp/Views/Search/SearchInspectorView.swift index aec82bf..7124e5f 100644 --- a/Sources/MactrixApp/Views/Search/SearchInspectorView.swift +++ b/Sources/MactrixApp/Views/Search/SearchInspectorView.swift @@ -5,91 +5,91 @@ import OSLog import SwiftUI struct SearchResolvedRoomInspectorView: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState - @State private var roomPreview: MatrixRustSDK.RoomPreview? + @State private var roomPreview: MatrixRustSDK.RoomPreview? - let alias: String - let resolvedRoom: ResolvedRoomAlias + let alias: String + let resolvedRoom: ResolvedRoomAlias - var roomActions: RoomPreviewActions? { - guard let preview = roomPreview else { return nil } - return appState.matrixClient?.roomPreviewActions( - forRoomWithId: preview.info().roomId, windowState: windowState) - } + var roomActions: RoomPreviewActions? { + guard let preview = roomPreview else { return nil } + return appState.matrixClient?.roomPreviewActions( + forRoomWithId: preview.info().roomId, windowState: windowState) + } - var body: some View { - Group { - if let roomPreview { - MactrixUI.RoomPreviewView( - preview: roomPreview.info(), - imageLoader: appState.matrixClient, - actions: roomActions - ) - } else { - MactrixUI.RoomPreviewView( - preview: MockRoomPreviewInfo(), - imageLoader: appState.matrixClient, - actions: roomActions - ) - .redacted(reason: .placeholder) - } - } - .task(id: alias, priority: .utility) { - do { - let preview = try await appState.matrixClient?.client.getRoomPreviewFromRoomId( - roomId: resolvedRoom.roomId, viaServers: resolvedRoom.servers) - self.roomPreview = preview - } catch { - Logger.viewCycle.error("failed to get room preview: \(error)") - } - } + var body: some View { + Group { + if let roomPreview { + MactrixUI.RoomPreviewView( + preview: roomPreview.info(), + imageLoader: appState.matrixClient, + actions: roomActions + ) + } else { + MactrixUI.RoomPreviewView( + preview: MockRoomPreviewInfo(), + imageLoader: appState.matrixClient, + actions: roomActions + ) + .redacted(reason: .placeholder) + } + } + .task(id: alias, priority: .utility) { + do { + let preview = try await appState.matrixClient?.client.getRoomPreviewFromRoomId( + roomId: resolvedRoom.roomId, viaServers: resolvedRoom.servers) + self.roomPreview = preview + } catch { + Logger.viewCycle.error("failed to get room preview: \(error)") + } } + } } struct SearchInspectorView: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState - @ViewBuilder - var viewSelector: some View { - switch windowState.searchTokens.first { - case .messages: - Text("Search messages") - case .rooms: - SearchRoomInspectorView() - case .users: - SearchUserInspectorView() - case .spaces: - Text("Search spaces") - case .resolvedRoomAlias(alias: let alias, resolvedRoom: let resolvedRoom): - SearchResolvedRoomInspectorView(alias: alias, resolvedRoom: resolvedRoom) - case .resolvedRoomId(roomPreview: let roomPreview): - MactrixUI.RoomPreviewView( - preview: roomPreview.info(), - imageLoader: appState.matrixClient, - actions: appState.matrixClient?.roomPreviewActions( - forRoomWithId: roomPreview.info().roomId, windowState: windowState) - ) - case .resolvedUser(profile: let userProfile): - MactrixUI.UserProfileView( - profile: userProfile, - isUserIgnored: appState.matrixClient?.isUserIgnored(userProfile.userId) == true, - actions: appState.matrixClient?.userProfileActions( - forUserId: userProfile.userId, windowState: windowState), - timelineActions: nil, - imageLoader: appState.matrixClient - ) - case nil: - ContentUnavailableView("Select a search term", systemImage: "magnifyingglass") - Text("or enter a room alias or a user id") - .foregroundStyle(.secondary) - } + @ViewBuilder + var viewSelector: some View { + switch windowState.searchTokens.first { + case .messages: + Text("Search messages") + case .rooms: + SearchRoomInspectorView() + case .users: + SearchUserInspectorView() + case .spaces: + Text("Search spaces") + case .resolvedRoomAlias(let alias, let resolvedRoom): + SearchResolvedRoomInspectorView(alias: alias, resolvedRoom: resolvedRoom) + case .resolvedRoomId(let roomPreview): + MactrixUI.RoomPreviewView( + preview: roomPreview.info(), + imageLoader: appState.matrixClient, + actions: appState.matrixClient?.roomPreviewActions( + forRoomWithId: roomPreview.info().roomId, windowState: windowState) + ) + case .resolvedUser(profile: let userProfile): + MactrixUI.UserProfileView( + profile: userProfile, + isUserIgnored: appState.matrixClient?.isUserIgnored(userProfile.userId) == true, + actions: appState.matrixClient?.userProfileActions( + forUserId: userProfile.userId, windowState: windowState), + timelineActions: nil, + imageLoader: appState.matrixClient + ) + case nil: + ContentUnavailableView("Select a search term", systemImage: "magnifyingglass") + Text("or enter a room alias or a user id") + .foregroundStyle(.secondary) } + } - var body: some View { - viewSelector - .inspectorColumnWidth(min: 300, ideal: 300, max: 600) - } + var body: some View { + viewSelector + .inspectorColumnWidth(min: 300, ideal: 300, max: 600) + } } diff --git a/Sources/MactrixApp/Views/Search/SearchRoomInspectorView.swift b/Sources/MactrixApp/Views/Search/SearchRoomInspectorView.swift index fe60428..5a4a1d0 100644 --- a/Sources/MactrixApp/Views/Search/SearchRoomInspectorView.swift +++ b/Sources/MactrixApp/Views/Search/SearchRoomInspectorView.swift @@ -5,73 +5,74 @@ import OSLog import SwiftUI struct SearchRoomInspectorView: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState - @State var searching: Bool = false - @State var roomListSelection: String? = nil + @State var searching: Bool = false + @State var roomListSelection: String? = nil - @State var roomSearch: LiveRoomSearch? = nil + @State var roomSearch: LiveRoomSearch? = nil - var body: some View { - List(selection: $roomListSelection) { - Section("Room search results") { - if searching { - Group { - Text("First room") - Text("Second room") - Text("Third room") - }.redacted(reason: .placeholder) - } else { - ForEach(roomSearch?.rooms ?? [], id: \.roomId) { room in - VStack(alignment: .leading) { - Text(room.name ?? "unknown name") - .help(room.name ?? "unknown name") - .lineLimit(1) - .truncationMode(.tail) - Text(room.roomId) - .font(.caption) - .foregroundStyle(.secondary) - .help(room.roomId) - .lineLimit(1) - .truncationMode(.middle) - } - } - } + var body: some View { + List(selection: $roomListSelection) { + Section("Room search results") { + if searching { + Group { + Text("First room") + Text("Second room") + Text("Third room") + }.redacted(reason: .placeholder) + } else { + ForEach(roomSearch?.rooms ?? [], id: \.roomId) { room in + VStack(alignment: .leading) { + Text(room.name ?? "unknown name") + .help(room.name ?? "unknown name") + .lineLimit(1) + .truncationMode(.tail) + Text(room.roomId) + .font(.caption) + .foregroundStyle(.secondary) + .help(room.roomId) + .lineLimit(1) + .truncationMode(.middle) } + } } - .onChange(of: roomListSelection) { _, selectedRoom in - guard let selectedRoom else { return } - windowState.selectedRoomId = selectedRoom - } - .task(id: windowState.searchQuery) { - do { - guard let matrixClient = appState.matrixClient else { return } + } + } + .onChange(of: roomListSelection) { _, selectedRoom in + guard let selectedRoom else { return } + windowState.selectedRoomId = selectedRoom + } + .task(id: windowState.searchQuery) { + do { + guard let matrixClient = appState.matrixClient else { return } - let roomSearch: LiveRoomSearch - if let rs = self.roomSearch { - roomSearch = rs - } else { - roomSearch = await LiveRoomSearch(roomDirectorySearch: matrixClient.client.roomDirectorySearch()) - self.roomSearch = roomSearch - } + let roomSearch: LiveRoomSearch + if let rs = self.roomSearch { + roomSearch = rs + } else { + roomSearch = await LiveRoomSearch( + roomDirectorySearch: matrixClient.client.roomDirectorySearch()) + self.roomSearch = roomSearch + } - guard !windowState.searchQuery.isEmpty else { - roomSearch.rooms.removeAll() - return - } + guard !windowState.searchQuery.isEmpty else { + roomSearch.rooms.removeAll() + return + } - searching = true - defer { searching = false } + searching = true + defer { searching = false } - try await Task.sleep(for: .milliseconds(500)) + try await Task.sleep(for: .milliseconds(500)) - try await roomSearch.search(query: windowState.searchQuery) - } catch is CancellationError { - /* search cancelled */ - } catch { - Logger.viewCycle.error("room search failed: \(error)") - } - } + try await roomSearch.search(query: windowState.searchQuery) + } catch is CancellationError { + /* search cancelled */ + } catch { + Logger.viewCycle.error("room search failed: \(error)") + } } + } } diff --git a/Sources/MactrixApp/Views/Search/SearchUserInspectorView.swift b/Sources/MactrixApp/Views/Search/SearchUserInspectorView.swift index e03c8fe..4e2b8b5 100644 --- a/Sources/MactrixApp/Views/Search/SearchUserInspectorView.swift +++ b/Sources/MactrixApp/Views/Search/SearchUserInspectorView.swift @@ -1,58 +1,59 @@ +import MactrixUI import MatrixRustSDK import OSLog import SwiftUI -import MactrixUI struct SearchUserInspectorView: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState - @State var searchedUsers: [UserProfile] = [] - @State var searching: Bool = false - @State var userListSelection: String? = nil + @State var searchedUsers: [UserProfile] = [] + @State var searching: Bool = false + @State var userListSelection: String? = nil - var body: some View { - List(selection: $userListSelection) { - Section("User search results") { - if searching { - Group { - Text("First user") - Text("Second user") - Text("Third user") - }.redacted(reason: .placeholder) - } else { - ForEach(searchedUsers) { user in - MactrixUI.UserProfileRow(profile: user, imageLoader: appState.matrixClient) - } - } - } + var body: some View { + List(selection: $userListSelection) { + Section("User search results") { + if searching { + Group { + Text("First user") + Text("Second user") + Text("Third user") + }.redacted(reason: .placeholder) + } else { + ForEach(searchedUsers) { user in + MactrixUI.UserProfileRow(profile: user, imageLoader: appState.matrixClient) + } } - .onChange(of: userListSelection) { _, selectedUserId in - if let user = searchedUsers.first(where: { $0.id == selectedUserId }) { - windowState.selectedScreen = .user(profile: user) - } + } + } + .onChange(of: userListSelection) { _, selectedUserId in + if let user = searchedUsers.first(where: { $0.id == selectedUserId }) { + windowState.selectedScreen = .user(profile: user) + } + } + .task(id: windowState.searchQuery) { + do { + guard let matrixClient = appState.matrixClient else { return } + guard !windowState.searchQuery.isEmpty else { + searchedUsers = [] + return } - .task(id: windowState.searchQuery) { - do { - guard let matrixClient = appState.matrixClient else { return } - guard !windowState.searchQuery.isEmpty else { - searchedUsers = [] - return - } - searching = true - defer { searching = false } + searching = true + defer { searching = false } - try await Task.sleep(for: .milliseconds(500)) + try await Task.sleep(for: .milliseconds(500)) - let results = try await matrixClient.client.searchUsers(searchTerm: windowState.searchQuery, limit: 100) + let results = try await matrixClient.client.searchUsers( + searchTerm: windowState.searchQuery, limit: 100) - searchedUsers = results.results - } catch is CancellationError { - /* search cancelled */ - } catch { - Logger.viewCycle.error("user search failed: \(error)") - } - } + searchedUsers = results.results + } catch is CancellationError { + /* search cancelled */ + } catch { + Logger.viewCycle.error("user search failed: \(error)") + } } + } } diff --git a/Sources/MactrixApp/Views/Search/SearchViewModifier.swift b/Sources/MactrixApp/Views/Search/SearchViewModifier.swift index 6015819..bb93ab5 100644 --- a/Sources/MactrixApp/Views/Search/SearchViewModifier.swift +++ b/Sources/MactrixApp/Views/Search/SearchViewModifier.swift @@ -3,177 +3,177 @@ import OSLog import SwiftUI struct SearchViewModifier: ViewModifier { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState - func body(content: Content) -> some View { - @Bindable var windowState = windowState - content - .searchable( - text: $windowState.searchQuery, tokens: $windowState.searchTokens, - isPresented: windowState.searchFocused, placement: .automatic, prompt: "Search" - ) { token in - switch token { - case .users: - Text("Users") - case .rooms: - Text("Public Rooms") - case .spaces: - Text("Public Spaces") - case .messages: - Text("Messages") - case .resolvedRoomAlias(let alias, resolvedRoom: _): - Text(alias) - case .resolvedRoomId(roomPreview: let preview): - Text(preview.info().roomId) - case .resolvedUser(profile: let userProfile): - Text(userProfile.userId) - } - } - .searchSuggestions { - searchSuggestions - } - .task(id: windowState.searchQuery) { - await calculateDirectSearchResults() - } - } - - @ViewBuilder - var searchSuggestions: some View { - switch windowState.searchDirectResult { - case .lookingForRoom: - HStack { - ProgressView() - .scaleEffect(0.5) - Text("Searching for room") - .foregroundStyle(.secondary) - } - Divider() - case .lookingForUser(userId: _): - HStack { - ProgressView() - .scaleEffect(0.5) - Text("Searching for user") - .foregroundStyle(.secondary) - } - Divider() - case .roomNotFound: - Label("Room not found", systemImage: "exclamationmark.circle") - .foregroundStyle(.secondary) - Divider() - case .userNotFound(userId: _): - Label("User not found", systemImage: "exclamationmark.circle") - .foregroundStyle(.secondary) - Divider() - case .resolvedRoomAlias(let alias, let resolvedRoom): - HStack(alignment: .firstTextBaseline) { - Text("room") - .font(.subheadline.bold()) - .foregroundStyle(.secondary) - Text(alias) - .lineLimit(1) - .truncationMode(.middle) - } - .searchCompletion( - SearchToken.resolvedRoomAlias(alias: alias, resolvedRoom: resolvedRoom)) - Divider() - case .resolvedRoomId(let roomPreview): - HStack(alignment: .firstTextBaseline) { - Text("room") - .font(.subheadline.bold()) - .foregroundStyle(.secondary) - Text(roomPreview.info().roomId) - .lineLimit(1) - .truncationMode(.middle) - } - .searchCompletion(SearchToken.resolvedRoomId(roomPreview: roomPreview)) - Divider() + func body(content: Content) -> some View { + @Bindable var windowState = windowState + content + .searchable( + text: $windowState.searchQuery, tokens: $windowState.searchTokens, + isPresented: windowState.searchFocused, placement: .automatic, prompt: "Search" + ) { token in + switch token { + case .users: + Text("Users") + case .rooms: + Text("Public Rooms") + case .spaces: + Text("Public Spaces") + case .messages: + Text("Messages") + case .resolvedRoomAlias(let alias, resolvedRoom: _): + Text(alias) + case .resolvedRoomId(roomPreview: let preview): + Text(preview.info().roomId) case .resolvedUser(profile: let userProfile): - HStack(alignment: .firstTextBaseline) { - Text("user") - .font(.subheadline.bold()) - .foregroundStyle(.secondary) - Text(userProfile.userId) - .lineLimit(1) - .truncationMode(.middle) - } - .searchCompletion(SearchToken.resolvedUser(profile: userProfile)) - Divider() - case nil: - EmptyView() + Text(userProfile.userId) } + } + .searchSuggestions { + searchSuggestions + } + .task(id: windowState.searchQuery) { + await calculateDirectSearchResults() + } + } - if windowState.searchTokens.isEmpty { - Label("Users", systemImage: "person").searchCompletion(SearchToken.users) - Label("Public Rooms", systemImage: "number").searchCompletion(SearchToken.rooms) - Label("Public Spaces", systemImage: "network").searchCompletion(SearchToken.spaces) - Label("Messages", systemImage: "magnifyingglass.circle").searchCompletion( - SearchToken.messages) - } + @ViewBuilder + var searchSuggestions: some View { + switch windowState.searchDirectResult { + case .lookingForRoom: + HStack { + ProgressView() + .scaleEffect(0.5) + Text("Searching for room") + .foregroundStyle(.secondary) + } + Divider() + case .lookingForUser(userId: _): + HStack { + ProgressView() + .scaleEffect(0.5) + Text("Searching for user") + .foregroundStyle(.secondary) + } + Divider() + case .roomNotFound: + Label("Room not found", systemImage: "exclamationmark.circle") + .foregroundStyle(.secondary) + Divider() + case .userNotFound(userId: _): + Label("User not found", systemImage: "exclamationmark.circle") + .foregroundStyle(.secondary) + Divider() + case .resolvedRoomAlias(let alias, let resolvedRoom): + HStack(alignment: .firstTextBaseline) { + Text("room") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + Text(alias) + .lineLimit(1) + .truncationMode(.middle) + } + .searchCompletion( + SearchToken.resolvedRoomAlias(alias: alias, resolvedRoom: resolvedRoom)) + Divider() + case .resolvedRoomId(let roomPreview): + HStack(alignment: .firstTextBaseline) { + Text("room") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + Text(roomPreview.info().roomId) + .lineLimit(1) + .truncationMode(.middle) + } + .searchCompletion(SearchToken.resolvedRoomId(roomPreview: roomPreview)) + Divider() + case .resolvedUser(profile: let userProfile): + HStack(alignment: .firstTextBaseline) { + Text("user") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + Text(userProfile.userId) + .lineLimit(1) + .truncationMode(.middle) + } + .searchCompletion(SearchToken.resolvedUser(profile: userProfile)) + Divider() + case nil: + EmptyView() } - func calculateDirectSearchResults() async { - let query: String = { - var result = windowState.searchQuery.trimmingCharacters(in: .whitespaces) + if windowState.searchTokens.isEmpty { + Label("Users", systemImage: "person").searchCompletion(SearchToken.users) + Label("Public Rooms", systemImage: "number").searchCompletion(SearchToken.rooms) + Label("Public Spaces", systemImage: "network").searchCompletion(SearchToken.spaces) + Label("Messages", systemImage: "magnifyingglass.circle").searchCompletion( + SearchToken.messages) + } + } - let matrixToPrefix = "https://matrix.to/#/" - if result.starts(with: matrixToPrefix) { - result = String(result.dropFirst(matrixToPrefix.count)) - result = result.removingPercentEncoding ?? result - } + func calculateDirectSearchResults() async { + let query: String = { + var result = windowState.searchQuery.trimmingCharacters(in: .whitespaces) - if result.starts(with: "@") { - result = result.lowercased() - } + let matrixToPrefix = "https://matrix.to/#/" + if result.starts(with: matrixToPrefix) { + result = String(result.dropFirst(matrixToPrefix.count)) + result = result.removingPercentEncoding ?? result + } - return result - }() + if result.starts(with: "@") { + result = result.lowercased() + } - do { - guard let matrixClient = appState.matrixClient else { return } - try await Task.sleep(for: .milliseconds(300)) + return result + }() - let roomAliasRegex = /\#[^:]+\:[^\s]*/ - let userIdRegex = /\@[a-z0-9._=\-\/+]+\:[^\s]*/ - let roomIdRegex = /\![^:]+\:[^\s]*/ + do { + guard let matrixClient = appState.matrixClient else { return } + try await Task.sleep(for: .milliseconds(300)) - if try roomAliasRegex.wholeMatch(in: query) != nil { - Logger.viewCycle.debug("Matched room alias in query: \(query)") - windowState.searchDirectResult = .lookingForRoom(alias: query) - if let resolvedRoom = try await matrixClient.client.resolveRoomAlias( - roomAlias: query) - { - windowState.searchDirectResult = .resolvedRoomAlias( - alias: query, resolvedRoom: resolvedRoom) - } else { - windowState.searchDirectResult = .roomNotFound(alias: query) - } - } else if try userIdRegex.wholeMatch(in: query) != nil { - Logger.viewCycle.debug("Matched user id in query: \(query)") - windowState.searchDirectResult = .lookingForUser(userId: query) - do { - let userProfile = try await matrixClient.client.getProfile(userId: query) - windowState.searchDirectResult = .resolvedUser(profile: userProfile) - } catch { - Logger.viewCycle.error("failed to resolve user \(query): \(error)") - windowState.searchDirectResult = .userNotFound(userId: query) - } - } else if try roomIdRegex.wholeMatch(in: query) != nil { - Logger.viewCycle.debug("Matched room id in query: \(query)") - windowState.searchDirectResult = .lookingForRoom(alias: query) - do { - let roomPreview = try await matrixClient.client.getRoomPreviewFromRoomId( - roomId: query, viaServers: ["matrix.org"]) - windowState.searchDirectResult = .resolvedRoomId(roomPreview: roomPreview) - } catch { - Logger.viewCycle.error("failed to resolve room id \(query): \(error)") - windowState.searchDirectResult = .roomNotFound(alias: query) - } - } else { - windowState.searchDirectResult = nil - } - } catch is CancellationError {} catch { - Logger.viewCycle.error("failed to resolve search query '\(query)': \(error)") + let roomAliasRegex = /\#[^:]+\:[^\s]*/ + let userIdRegex = /\@[a-z0-9._=\-\/+]+\:[^\s]*/ + let roomIdRegex = /\![^:]+\:[^\s]*/ + + if try roomAliasRegex.wholeMatch(in: query) != nil { + Logger.viewCycle.debug("Matched room alias in query: \(query)") + windowState.searchDirectResult = .lookingForRoom(alias: query) + if let resolvedRoom = try await matrixClient.client.resolveRoomAlias( + roomAlias: query) + { + windowState.searchDirectResult = .resolvedRoomAlias( + alias: query, resolvedRoom: resolvedRoom) + } else { + windowState.searchDirectResult = .roomNotFound(alias: query) + } + } else if try userIdRegex.wholeMatch(in: query) != nil { + Logger.viewCycle.debug("Matched user id in query: \(query)") + windowState.searchDirectResult = .lookingForUser(userId: query) + do { + let userProfile = try await matrixClient.client.getProfile(userId: query) + windowState.searchDirectResult = .resolvedUser(profile: userProfile) + } catch { + Logger.viewCycle.error("failed to resolve user \(query): \(error)") + windowState.searchDirectResult = .userNotFound(userId: query) + } + } else if try roomIdRegex.wholeMatch(in: query) != nil { + Logger.viewCycle.debug("Matched room id in query: \(query)") + windowState.searchDirectResult = .lookingForRoom(alias: query) + do { + let roomPreview = try await matrixClient.client.getRoomPreviewFromRoomId( + roomId: query, viaServers: ["matrix.org"]) + windowState.searchDirectResult = .resolvedRoomId(roomPreview: roomPreview) + } catch { + Logger.viewCycle.error("failed to resolve room id \(query): \(error)") + windowState.searchDirectResult = .roomNotFound(alias: query) } + } else { + windowState.searchDirectResult = nil + } + } catch is CancellationError {} catch { + Logger.viewCycle.error("failed to resolve search query '\(query)': \(error)") } + } } diff --git a/Sources/MactrixApp/Views/Settings/AccountSettingsView.swift b/Sources/MactrixApp/Views/Settings/AccountSettingsView.swift index d309adc..ab08c17 100644 --- a/Sources/MactrixApp/Views/Settings/AccountSettingsView.swift +++ b/Sources/MactrixApp/Views/Settings/AccountSettingsView.swift @@ -3,112 +3,115 @@ import OSLog import SwiftUI struct ChangableField: View { - let name: String - let value: String - let onSave: (_ newValue: String) async -> Void + let name: String + let value: String + let onSave: (_ newValue: String) async -> Void - @State private var isEditing: Bool = false - @State private var editValue: String = "" - @State private var saving: Bool = false + @State private var isEditing: Bool = false + @State private var editValue: String = "" + @State private var saving: Bool = false - private func save() async { - saving = true - await onSave(editValue) - isEditing = false - saving = false - } + private func save() async { + saving = true + await onSave(editValue) + isEditing = false + saving = false + } - var body: some View { - if isEditing { - HStack { - TextField(name, text: $editValue) - .onSubmit { Task { await save() } } - .disabled(saving) - Button("Save") { Task { await save() } } - Button("Cancel") { isEditing = false } - } - } else { - LabeledContent(name) { - Text(value) - .textSelection(.enabled) - Button("Edit") { - editValue = value - isEditing = true - } - .padding(.leading, 10) - } + var body: some View { + if isEditing { + HStack { + TextField(name, text: $editValue) + .onSubmit { Task { await save() } } + .disabled(saving) + Button("Save") { Task { await save() } } + Button("Cancel") { isEditing = false } + } + } else { + LabeledContent(name) { + Text(value) + .textSelection(.enabled) + Button("Edit") { + editValue = value + isEditing = true } + .padding(.leading, 10) + } } + } } struct AccountSettingsView: View { - @Environment(AppState.self) var appState + @Environment(AppState.self) var appState - @State private var logoutError: String? = nil - @State private var displayName: String? = nil + @State private var logoutError: String? = nil + @State private var displayName: String? = nil - var body: some View { - if let matrixClient = appState.matrixClient { - Form { - ChangableField(name: "Display name", value: displayName ?? "loading...", onSave: { newDisplayName in - do { - try await matrixClient.client.setDisplayName(name: newDisplayName) - displayName = newDisplayName - } catch { - Logger.viewCycle.error("Failed to update display name: \(error)") - } - }) - .task { - do { - displayName = try await matrixClient.client.displayName() - } catch { - Logger.viewCycle.error("Failed to load display name: \(error)") - } - } + var body: some View { + if let matrixClient = appState.matrixClient { + Form { + ChangableField( + name: "Display name", value: displayName ?? "loading...", + onSave: { newDisplayName in + do { + try await matrixClient.client.setDisplayName(name: newDisplayName) + displayName = newDisplayName + } catch { + Logger.viewCycle.error("Failed to update display name: \(error)") + } + } + ) + .task { + do { + displayName = try await matrixClient.client.displayName() + } catch { + Logger.viewCycle.error("Failed to load display name: \(error)") + } + } - LabeledContent("User") { - Text((try? matrixClient.client.userId()) ?? "error") - .textSelection(.enabled) - } + LabeledContent("User") { + Text((try? matrixClient.client.userId()) ?? "error") + .textSelection(.enabled) + } - LabeledContent("Device") { - Text((try? matrixClient.client.deviceId()) ?? "error") - .textSelection(.enabled) - } + LabeledContent("Device") { + Text((try? matrixClient.client.deviceId()) ?? "error") + .textSelection(.enabled) + } - HStack { - Button("Sign out", role: .destructive) { - Task { - do { - try await appState.reset() - } catch { - logoutError = error.localizedDescription - } - } - } - if let logoutError = logoutError { - Text(logoutError) - .textSelection(.enabled) - .foregroundStyle(Color.red) - } - } + HStack { + Button("Sign out", role: .destructive) { + Task { + do { + try await appState.reset() + } catch { + logoutError = error.localizedDescription + } + } + } + if let logoutError = logoutError { + Text(logoutError) + .textSelection(.enabled) + .foregroundStyle(Color.red) + } + } - Button("Clear cache") { - Task { - do { - try await appState.matrixClient?.clearCache() - } catch { - Logger.viewCycle.error("Failed to clear cache: \(error)") - } - } - } + Button("Clear cache") { + Task { + do { + try await appState.matrixClient?.clearCache() + } catch { + Logger.viewCycle.error("Failed to clear cache: \(error)") } - } else { - ContentUnavailableView("User not logged in", systemImage: "person") + } } + } + } else { + ContentUnavailableView("User not logged in", systemImage: "person") } + } } #Preview { - AccountSettingsView() + AccountSettingsView() } diff --git a/Sources/MactrixApp/Views/Settings/AppearanceSettingsView.swift b/Sources/MactrixApp/Views/Settings/AppearanceSettingsView.swift index 2539713..7d665ff 100644 --- a/Sources/MactrixApp/Views/Settings/AppearanceSettingsView.swift +++ b/Sources/MactrixApp/Views/Settings/AppearanceSettingsView.swift @@ -1,16 +1,16 @@ import SwiftUI struct AppearanceSettingsView: View { - @AppStorage("fontSize") var fontSize: Int = 13 + @AppStorage("fontSize") var fontSize: Int = 13 - var body: some View { - Form { - Picker("Font size", selection: $fontSize) { - ForEach(8..<25) { - Text("\($0)") - .tag($0) - } - } + var body: some View { + Form { + Picker("Font size", selection: $fontSize) { + ForEach(8..<25) { + Text("\($0)") + .tag($0) } + } } + } } diff --git a/Sources/MactrixApp/Views/Settings/SessionsSettingsView.swift b/Sources/MactrixApp/Views/Settings/SessionsSettingsView.swift index 273855c..3212581 100644 --- a/Sources/MactrixApp/Views/Settings/SessionsSettingsView.swift +++ b/Sources/MactrixApp/Views/Settings/SessionsSettingsView.swift @@ -3,58 +3,60 @@ import MatrixRustSDK import SwiftUI struct SessionsSettingsView: View { - @Environment(AppState.self) var appState - @Environment(\.colorScheme) var colorScheme + @Environment(AppState.self) var appState + @Environment(\.colorScheme) var colorScheme - func sessionBadge(systemIcon: String, color: Color) -> some View { - Image(systemName: systemIcon) - .font(.system(size: 20)) - .padding(12) - .background(RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.2))) - .foregroundStyle(color.mix(with: colorScheme == .light ? .black : .white, by: 0.2)) - } + func sessionBadge(systemIcon: String, color: Color) -> some View { + Image(systemName: systemIcon) + .font(.system(size: 20)) + .padding(12) + .background(RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.2))) + .foregroundStyle(color.mix(with: colorScheme == .light ? .black : .white, by: 0.2)) + } - func sessionVerificationState(badge: some View, title: LocalizedStringKey, subtitle: LocalizedStringKey) -> some View { - HStack { - badge - VStack(alignment: .leading) { - Text(title) - .font(.title3) - Text(subtitle) - .foregroundStyle(.secondary) - } - } + func sessionVerificationState( + badge: some View, title: LocalizedStringKey, subtitle: LocalizedStringKey + ) -> some View { + HStack { + badge + VStack(alignment: .leading) { + Text(title) + .font(.title3) + Text(subtitle) + .foregroundStyle(.secondary) + } } + } - @ViewBuilder - func sessionVerificationView(matrixClient: MatrixClient) -> some View { - switch matrixClient.verificationState { - case nil, .unknown: - sessionVerificationState( - badge: sessionBadge(systemIcon: "shield.fill", color: .gray), - title: "Session verification is unknown", - subtitle: "The information could not be retrieved." - ) - case .verified: - sessionVerificationState( - badge: sessionBadge(systemIcon: "checkmark.shield.fill", color: .green), - title: "Verified session", - subtitle: "This session is verified and ready for secure messaging." - ) - case .unverified: - sessionVerificationState( - badge: sessionBadge(systemIcon: "xmark.shield.fill", color: .red), - title: "Unverified session", - subtitle: "Verify the session for better security and to decrypt all messages." - ) - } + @ViewBuilder + func sessionVerificationView(matrixClient: MatrixClient) -> some View { + switch matrixClient.verificationState { + case nil, .unknown: + sessionVerificationState( + badge: sessionBadge(systemIcon: "shield.fill", color: .gray), + title: "Session verification is unknown", + subtitle: "The information could not be retrieved." + ) + case .verified: + sessionVerificationState( + badge: sessionBadge(systemIcon: "checkmark.shield.fill", color: .green), + title: "Verified session", + subtitle: "This session is verified and ready for secure messaging." + ) + case .unverified: + sessionVerificationState( + badge: sessionBadge(systemIcon: "xmark.shield.fill", color: .red), + title: "Unverified session", + subtitle: "Verify the session for better security and to decrypt all messages." + ) } + } - var body: some View { - if let matrixClient = appState.matrixClient { - sessionVerificationView(matrixClient: matrixClient) - } else { - ContentUnavailableView("User not logged in", systemImage: "person") - } + var body: some View { + if let matrixClient = appState.matrixClient { + sessionVerificationView(matrixClient: matrixClient) + } else { + ContentUnavailableView("User not logged in", systemImage: "person") } + } } diff --git a/Sources/MactrixApp/Views/Settings/SettingsView.swift b/Sources/MactrixApp/Views/Settings/SettingsView.swift index 9c7a785..0fde5c5 100644 --- a/Sources/MactrixApp/Views/Settings/SettingsView.swift +++ b/Sources/MactrixApp/Views/Settings/SettingsView.swift @@ -1,21 +1,21 @@ import SwiftUI public struct SettingsView: View { - public init() {} + public init() {} - public var body: some View { - TabView { - Tab("Account", systemImage: "person") { - AccountSettingsView() - } - Tab("Appearance", systemImage: "eye") { - AppearanceSettingsView() - } - Tab("Sessions", systemImage: "desktopcomputer.and.macbook") { - SessionsSettingsView() - } - } - .scenePadding() - .frame(maxWidth: 450, minHeight: 200) + public var body: some View { + TabView { + Tab("Account", systemImage: "person") { + AccountSettingsView() + } + Tab("Appearance", systemImage: "eye") { + AppearanceSettingsView() + } + Tab("Sessions", systemImage: "desktopcomputer.and.macbook") { + SessionsSettingsView() + } } + .scenePadding() + .frame(maxWidth: 450, minHeight: 200) + } } diff --git a/Sources/MactrixApp/Views/Sidebar/RoomContextMenu.swift b/Sources/MactrixApp/Views/Sidebar/RoomContextMenu.swift index 6e65174..5786b30 100644 --- a/Sources/MactrixApp/Views/Sidebar/RoomContextMenu.swift +++ b/Sources/MactrixApp/Views/Sidebar/RoomContextMenu.swift @@ -4,80 +4,80 @@ import OSLog import SwiftUI struct RoomContextMenu: View { - @Environment(WindowState.self) var windowState + @Environment(WindowState.self) var windowState - let room: SidebarRoom + let room: SidebarRoom - var body: some View { - if let roomInfo = room.roomInfo { - Button { - Task { - do { - Logger.viewCycle.info("marking room unread/read") - try await room.room.setUnreadFlag(newValue: !roomInfo.isMarkedUnread) - } catch { - Logger.viewCycle.error("failed to mark room unread/read: \(error)") - } - } - } label: { - if roomInfo.isMarkedUnread { - Text("Mark as Read") - } else { - Text("Mark as Unread") - } - } + var body: some View { + if let roomInfo = room.roomInfo { + Button { + Task { + do { + Logger.viewCycle.info("marking room unread/read") + try await room.room.setUnreadFlag(newValue: !roomInfo.isMarkedUnread) + } catch { + Logger.viewCycle.error("failed to mark room unread/read: \(error)") + } + } + } label: { + if roomInfo.isMarkedUnread { + Text("Mark as Read") + } else { + Text("Mark as Unread") + } + } - Button { - Task { - do { - Logger.viewCycle.info("mark room favourite: \(!roomInfo.isFavourite)") - try await room.room.setIsFavourite(isFavourite: !roomInfo.isFavourite, tagOrder: nil) - } catch { - Logger.viewCycle.error("failed to mark room favourite: \(error)") - } - } + Button { + Task { + do { + Logger.viewCycle.info("mark room favourite: \(!roomInfo.isFavourite)") + try await room.room.setIsFavourite(isFavourite: !roomInfo.isFavourite, tagOrder: nil) + } catch { + Logger.viewCycle.error("failed to mark room favourite: \(error)") + } + } - } label: { - if roomInfo.isFavourite { - Label("Unfavorite", systemImage: "heart.slash") - } else { - Label("Favorite", systemImage: "heart") - } - } + } label: { + if roomInfo.isFavourite { + Label("Unfavorite", systemImage: "heart.slash") + } else { + Label("Favorite", systemImage: "heart") + } + } - Button { - Task { - do { - let permalink = try await room.room.matrixToPermalink() - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(permalink, forType: .string) - Logger.viewCycle.debug("copy room link: \(permalink)") - } catch { - Logger.viewCycle.error("unable to copy room link: \(error)") - } - } - } label: { - Label("Copy Link", systemImage: "link") - } - .disabled(roomInfo.isDirect) + Button { + Task { + do { + let permalink = try await room.room.matrixToPermalink() + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(permalink, forType: .string) + Logger.viewCycle.debug("copy room link: \(permalink)") + } catch { + Logger.viewCycle.error("unable to copy room link: \(error)") + } } + } label: { + Label("Copy Link", systemImage: "link") + } + .disabled(roomInfo.isDirect) + } - Button { - Task { - do { - Logger.viewCycle.info("leaving room: \(room.room.id())") - try await room.room.leave() - try await room.room.forget() + Button { + Task { + do { + Logger.viewCycle.info("leaving room: \(room.room.id())") + try await room.room.leave() + try await room.room.forget() - if windowState.selectedRoomId == room.room.id() { - windowState.selectedRoomId = nil - } - } catch { - Logger.viewCycle.error("failed to leave room: \(error)") - } - } - } label: { - Label("Leave Room", systemImage: "minus.circle") + if windowState.selectedRoomId == room.room.id() { + windowState.selectedRoomId = nil + } + } catch { + Logger.viewCycle.error("failed to leave room: \(error)") } + } + } label: { + Label("Leave Room", systemImage: "minus.circle") } + } } diff --git a/Sources/MactrixApp/Views/Sidebar/SidebarSessionVerification.swift b/Sources/MactrixApp/Views/Sidebar/SidebarSessionVerification.swift index 58e063c..c99f004 100644 --- a/Sources/MactrixApp/Views/Sidebar/SidebarSessionVerification.swift +++ b/Sources/MactrixApp/Views/Sidebar/SidebarSessionVerification.swift @@ -3,102 +3,107 @@ import OSLog import SwiftUI struct SessionVerificationStatusView: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState - @Environment(\.colorScheme) var colorScheme + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + @Environment(\.colorScheme) var colorScheme - @ViewBuilder - var selfVerificationView: some View { - switch appState.matrixClient?.verificationState { - case nil: - EmptyView() - case .unknown: - Text("Unknown verification state") - .padding(10) - .background(Color.gray.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(maxWidth: .infinity) - case .verified: - EmptyView() - case .unverified: - VStack { - Label { - Text("This session is unverified.") - .bold() + @ViewBuilder + var selfVerificationView: some View { + switch appState.matrixClient?.verificationState { + case nil: + EmptyView() + case .unknown: + Text("Unknown verification state") + .padding(10) + .background(Color.gray.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(maxWidth: .infinity) + case .verified: + EmptyView() + case .unverified: + VStack { + Label { + Text("This session is unverified.") + .bold() - // NOTE(alicerunsonfedora): Setting a hard line limit because SwiftUI will otherwise truncate the - // text, even when there's enough room to do so. - Text("Verify the session for better security and to decrypt all messages.") - .lineLimit(9) - } icon: { - Image(systemName: "exclamationmark.shield") - .bold() - } - .labelStyle(.multiline) - .frame(maxWidth: .infinity) - HStack { - if windowState.requestedVerification { - ProgressView("Requesting verification from your trusted devices...") - } - Button(windowState.requestedVerification ? "Try again" : "Verify session...") { - Task { - await startVerificationRequest() - } - } - } + // NOTE(alicerunsonfedora): Setting a hard line limit because SwiftUI will otherwise truncate the + // text, even when there's enough room to do so. + Text("Verify the session for better security and to decrypt all messages.") + .lineLimit(9) + } icon: { + Image(systemName: "exclamationmark.shield") + .bold() + } + .labelStyle(.multiline) + .frame(maxWidth: .infinity) + HStack { + if windowState.requestedVerification { + ProgressView("Requesting verification from your trusted devices...") + } + Button(windowState.requestedVerification ? "Try again" : "Verify session...") { + Task { + await startVerificationRequest() } - .padding(10) - .background(Color.red.opacity(0.2)) - .foregroundStyle(Color.red.mix(with: colorScheme == .light ? .black : .white, by: 0.5)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + } } + } + .padding(10) + .background(Color.red.opacity(0.2)) + .foregroundStyle(Color.red.mix(with: colorScheme == .light ? .black : .white, by: 0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) } + } - var body: some View { - if let verificationRequest = appState.matrixClient?.sessionVerificationRequest { - VStack { - Label("Verification request from \(verificationRequest.senderProfile.userId)", systemImage: "person.badge.key") - .frame(maxWidth: .infinity) - HStack { - Button("Start Verification") { - Task { - do { - try await appState.matrixClient?.acceptVerificationRequest(request: verificationRequest) - } catch { - Logger.viewCycle.error("failed to accept verification request: \(error)") - appState.matrixClient?.sessionVerificationRequest = nil - } - } - } - .buttonStyle(.borderedProminent) + var body: some View { + if let verificationRequest = appState.matrixClient?.sessionVerificationRequest { + VStack { + Label( + "Verification request from \(verificationRequest.senderProfile.userId)", + systemImage: "person.badge.key" + ) + .frame(maxWidth: .infinity) + HStack { + Button("Start Verification") { + Task { + do { + try await appState.matrixClient?.acceptVerificationRequest( + request: verificationRequest) + } catch { + Logger.viewCycle.error("failed to accept verification request: \(error)") + appState.matrixClient?.sessionVerificationRequest = nil + } + } + } + .buttonStyle(.borderedProminent) - Button("Decline") { - Task { - do { - try await appState.matrixClient?.declineVerificationRequest(request: verificationRequest) - } catch { - Logger.viewCycle.error("failed to decline verification request: \(error)") - appState.matrixClient?.sessionVerificationRequest = nil - } - } - } - } + Button("Decline") { + Task { + do { + try await appState.matrixClient?.declineVerificationRequest( + request: verificationRequest) + } catch { + Logger.viewCycle.error("failed to decline verification request: \(error)") + appState.matrixClient?.sessionVerificationRequest = nil + } } - .padding(10) - .background(Color.green.opacity(0.2)) - .foregroundStyle(Color.green.mix(with: colorScheme == .light ? .black : .white, by: 0.5)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else { - selfVerificationView + } } + } + .padding(10) + .background(Color.green.opacity(0.2)) + .foregroundStyle(Color.green.mix(with: colorScheme == .light ? .black : .white, by: 0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + selfVerificationView } + } - private func startVerificationRequest() async { - do { - try await appState.matrixClient?.requestDeviceVerification() - windowState.requestedVerification = true - } catch { - Logger.viewCycle.error("request device verification failed: \(error)") - } + private func startVerificationRequest() async { + do { + try await appState.matrixClient?.requestDeviceVerification() + windowState.requestedVerification = true + } catch { + Logger.viewCycle.error("request device verification failed: \(error)") } + } } diff --git a/Sources/MactrixApp/Views/Sidebar/SidebarSyncState.swift b/Sources/MactrixApp/Views/Sidebar/SidebarSyncState.swift index 8186c3c..11d2263 100644 --- a/Sources/MactrixApp/Views/Sidebar/SidebarSyncState.swift +++ b/Sources/MactrixApp/Views/Sidebar/SidebarSyncState.swift @@ -3,50 +3,52 @@ import OSLog import SwiftUI struct SidebarSyncStateView: View { - @Environment(AppState.self) var appState + @Environment(AppState.self) var appState - @State var restarting: Bool = false + @State var restarting: Bool = false - var syncState: String { - switch appState.matrixClient?.syncState { - case .idle: - "idle" - case .running: - "online" - case .terminated: - "terminated" - case .error: - "error" - case .offline: - "offline" - case nil: - "logged out" - } + var syncState: String { + switch appState.matrixClient?.syncState { + case .idle: + "idle" + case .running: + "online" + case .terminated: + "terminated" + case .error: + "error" + case .offline: + "offline" + case nil: + "logged out" } + } - var canRestart: Bool { - return [.error, .offline, .terminated].contains(where: { $0 == appState.matrixClient?.syncState }) - } + var canRestart: Bool { + return [.error, .offline, .terminated].contains(where: { + $0 == appState.matrixClient?.syncState + }) + } - var body: some View { - VStack { - Text("Sync: \(syncState)") - .foregroundStyle(.secondary) - if canRestart { - Button("Restart sync") { - Task { - restarting = true - defer { restarting = false } + var body: some View { + VStack { + Text("Sync: \(syncState)") + .foregroundStyle(.secondary) + if canRestart { + Button("Restart sync") { + Task { + restarting = true + defer { restarting = false } - do { - try await appState.matrixClient?.startSync() - } catch { - Logger.viewCycle.error("failed to restart sync: \(error)") - } - } - } - .disabled(restarting) + do { + try await appState.matrixClient?.startSync() + } catch { + Logger.viewCycle.error("failed to restart sync: \(error)") } + } } + .disabled(restarting) + } } + } } diff --git a/Sources/MactrixApp/Views/Sidebar/SidebarView.swift b/Sources/MactrixApp/Views/Sidebar/SidebarView.swift index 895f143..047b3a9 100644 --- a/Sources/MactrixApp/Views/Sidebar/SidebarView.swift +++ b/Sources/MactrixApp/Views/Sidebar/SidebarView.swift @@ -4,103 +4,103 @@ import MatrixRustSDK import SwiftUI struct SidebarView: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState - @State private var searchText: String = "" + @State private var searchText: String = "" - var favorites: [SidebarRoom] { - (appState.matrixClient?.rooms ?? []) - .filter { $0.roomInfo?.isFavourite == true } - } + var favorites: [SidebarRoom] { + (appState.matrixClient?.rooms ?? []) + .filter { $0.roomInfo?.isFavourite == true } + } - var directs: [SidebarRoom] { - (appState.matrixClient?.rooms ?? []) - .filter { room in - let isDirect = room.roomInfo?.isDirect == true - let favoriteIDs = Set(favorites.map { $0.id }) - return isDirect && !favoriteIDs.contains(room.id) - } - } + var directs: [SidebarRoom] { + (appState.matrixClient?.rooms ?? []) + .filter { room in + let isDirect = room.roomInfo?.isDirect == true + let favoriteIDs = Set(favorites.map { $0.id }) + return isDirect && !favoriteIDs.contains(room.id) + } + } - var rooms: [SidebarRoom] { - (appState.matrixClient?.rooms ?? []) - .filter { room in - let isSpace = room.room.isSpace() - let isDirect = room.roomInfo?.isDirect == true - let favoriteIDs = Set(favorites.map(\.id)) - return !isSpace && !isDirect && !favoriteIDs.contains(room.id) - } - } - - var spaces: [SidebarSpaceRoom] { - appState.matrixClient?.spaceService.spaceRooms ?? [] - } + var rooms: [SidebarRoom] { + (appState.matrixClient?.rooms ?? []) + .filter { room in + let isSpace = room.room.isSpace() + let isDirect = room.roomInfo?.isDirect == true + let favoriteIDs = Set(favorites.map(\.id)) + return !isSpace && !isDirect && !favoriteIDs.contains(room.id) + } + } - var body: some View { - @Bindable var windowState = windowState + var spaces: [SidebarSpaceRoom] { + appState.matrixClient?.spaceService.spaceRooms ?? [] + } - List(selection: $windowState.selectedRoomId) { - SidebarSyncStateView() + var body: some View { + @Bindable var windowState = windowState - SessionVerificationStatusView() + List(selection: $windowState.selectedRoomId) { + SidebarSyncStateView() - if !favorites.isEmpty { - Section("Favorites", isExpanded: $windowState.sidebarSections.favorites) { - ForEach(favorites) { room in - MactrixUI.RoomRow( - title: room.room.displayName() ?? "Unknown room", - avatarUrl: room.room.avatarUrl(), - roomInfo: room.roomInfo, - imageLoader: appState.matrixClient, - joinRoom: nil - ) - .contextMenu { - RoomContextMenu(room: room) - } - } - } - } + SessionVerificationStatusView() - Section("Directs", isExpanded: $windowState.sidebarSections.directs) { - ForEach(directs) { room in - MactrixUI.RoomRow( - title: room.room.displayName() ?? "Unknown user", - avatarUrl: room.room.avatarUrl(), - roomInfo: room.roomInfo, - imageLoader: appState.matrixClient, - joinRoom: nil - ) - .contextMenu { - RoomContextMenu(room: room) - } - } + if !favorites.isEmpty { + Section("Favorites", isExpanded: $windowState.sidebarSections.favorites) { + ForEach(favorites) { room in + MactrixUI.RoomRow( + title: room.room.displayName() ?? "Unknown room", + avatarUrl: room.room.avatarUrl(), + roomInfo: room.roomInfo, + imageLoader: appState.matrixClient, + joinRoom: nil + ) + .contextMenu { + RoomContextMenu(room: room) } + } + } + } - Section("Rooms", isExpanded: $windowState.sidebarSections.rooms) { - ForEach(rooms) { room in - MactrixUI.RoomRow( - title: room.room.displayName() ?? "Unknown Room", - avatarUrl: room.room.avatarUrl(), - roomInfo: room.roomInfo, - imageLoader: appState.matrixClient, - joinRoom: nil - ) - .contextMenu { - RoomContextMenu(room: room) - } - } - } + Section("Directs", isExpanded: $windowState.sidebarSections.directs) { + ForEach(directs) { room in + MactrixUI.RoomRow( + title: room.room.displayName() ?? "Unknown user", + avatarUrl: room.room.avatarUrl(), + roomInfo: room.roomInfo, + imageLoader: appState.matrixClient, + joinRoom: nil + ) + .contextMenu { + RoomContextMenu(room: room) + } + } + } - Section("Spaces", isExpanded: $windowState.sidebarSections.spaces) { - ForEach(spaces) { space in - SpaceDisclosureGroup(space: space) - } - } + Section("Rooms", isExpanded: $windowState.sidebarSections.rooms) { + ForEach(rooms) { room in + MactrixUI.RoomRow( + title: room.room.displayName() ?? "Unknown Room", + avatarUrl: room.room.avatarUrl(), + roomInfo: room.roomInfo, + imageLoader: appState.matrixClient, + joinRoom: nil + ) + .contextMenu { + RoomContextMenu(room: room) + } } - .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: nil) - .toolbar { - AppCommands.createRoomButton(windowState: windowState) + } + + Section("Spaces", isExpanded: $windowState.sidebarSections.spaces) { + ForEach(spaces) { space in + SpaceDisclosureGroup(space: space) } + } + } + .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: nil) + .toolbar { + AppCommands.createRoomButton(windowState: windowState) } + } } diff --git a/Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift b/Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift index 2b3ffe0..3208743 100644 --- a/Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift +++ b/Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift @@ -1,99 +1,100 @@ -import MatrixRustSDK -import SwiftUI import MactrixUI -import OSLog import MatrixIntegration +import MatrixRustSDK +import OSLog +import SwiftUI struct SpaceDisclosureGroup: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState - @State var space: SidebarSpaceRoom - @State private var isExpanded: Bool = false + @State var space: SidebarSpaceRoom + @State private var isExpanded: Bool = false - var loadingRooms: some View { - Label { - Text("Loading rooms") - .foregroundStyle(.secondary) - } icon: { - ProgressView().scaleEffect(0.5) - } + var loadingRooms: some View { + Label { + Text("Loading rooms") + .foregroundStyle(.secondary) + } icon: { + ProgressView().scaleEffect(0.5) } + } - var spaceRow: some View { - DisclosureGroup(isExpanded: $isExpanded) { - switch space.children { - case .loading: - loadingRooms - case let .loaded(children): - if children.paginationState == .loading { - loadingRooms - } else { - ForEach(children.rooms) { room in - SpaceDisclosureGroup(space: room) - } - } - case let .error(error): - Text("Error: \(error.localizedDescription)") - .foregroundStyle(Color.red) - .textSelection(.enabled) - } - } label: { - roomRow - } - .task(id: isExpanded) { - if isExpanded { - await space.loadChildren() - } + var spaceRow: some View { + DisclosureGroup(isExpanded: $isExpanded) { + switch space.children { + case .loading: + loadingRooms + case .loaded(let children): + if children.paginationState == .loading { + loadingRooms + } else { + ForEach(children.rooms) { room in + SpaceDisclosureGroup(space: room) + } } + case .error(let error): + Text("Error: \(error.localizedDescription)") + .foregroundStyle(Color.red) + .textSelection(.enabled) + } + } label: { + roomRow } - - var joinRoom: (() async throws -> Void)? { - if appState.matrixClient?.rooms.contains(where: { $0.room.id() == space.id }) == false { - return { - Logger.viewCycle.error("Joining room: \(space.id)") - guard let matrixClient = appState.matrixClient else { return } - let room = try await matrixClient.client.joinRoomById(roomId: space.id) - windowState.selectedScreen = .joinedRoom(timeline: LiveTimeline(room: LiveRoom(matrixRoom: room))) - } - } - - return nil + .task(id: isExpanded) { + if isExpanded { + await space.loadChildren() + } } + } - var joinedRoom: SidebarRoom? { - return appState.matrixClient?.rooms.first(where: { $0.room.id() == space.id }) + var joinRoom: (() async throws -> Void)? { + if appState.matrixClient?.rooms.contains(where: { $0.room.id() == space.id }) == false { + return { + Logger.viewCycle.error("Joining room: \(space.id)") + guard let matrixClient = appState.matrixClient else { return } + let room = try await matrixClient.client.joinRoomById(roomId: space.id) + windowState.selectedScreen = .joinedRoom( + timeline: LiveTimeline(room: LiveRoom(matrixRoom: room))) + } } - @ViewBuilder - var roomRow: some View { - if let joinedRoom { - MactrixUI.RoomRow( - title: space.spaceRoom.displayName, - avatarUrl: space.spaceRoom.avatarUrl, - roomInfo: joinedRoom.roomInfo, - imageLoader: appState.matrixClient, - joinRoom: nil - ) - .contextMenu { - RoomContextMenu(room: joinedRoom) - } - } else { - MactrixUI.RoomRow( - title: space.spaceRoom.displayName, - avatarUrl: space.spaceRoom.avatarUrl, - roomInfo: nil, - imageLoader: appState.matrixClient, - joinRoom: joinRoom - ) - } + return nil + } + + var joinedRoom: SidebarRoom? { + return appState.matrixClient?.rooms.first(where: { $0.room.id() == space.id }) + } + + @ViewBuilder + var roomRow: some View { + if let joinedRoom { + MactrixUI.RoomRow( + title: space.spaceRoom.displayName, + avatarUrl: space.spaceRoom.avatarUrl, + roomInfo: joinedRoom.roomInfo, + imageLoader: appState.matrixClient, + joinRoom: nil + ) + .contextMenu { + RoomContextMenu(room: joinedRoom) + } + } else { + MactrixUI.RoomRow( + title: space.spaceRoom.displayName, + avatarUrl: space.spaceRoom.avatarUrl, + roomInfo: nil, + imageLoader: appState.matrixClient, + joinRoom: joinRoom + ) } + } - var body: some View { - if space.spaceRoom.roomType == .space { - spaceRow - } else { - roomRow - } + var body: some View { + if space.spaceRoom.roomType == .space { + spaceRow + } else { + roomRow } + } } diff --git a/Sources/MactrixApp/Views/WelcomeSheetView.swift b/Sources/MactrixApp/Views/WelcomeSheetView.swift index 2620dd0..5736f1b 100644 --- a/Sources/MactrixApp/Views/WelcomeSheetView.swift +++ b/Sources/MactrixApp/Views/WelcomeSheetView.swift @@ -1,149 +1,153 @@ import AuthenticationServices +import MatrixIntegration import MatrixRustSDK import SwiftUI -import MatrixIntegration struct WelcomeSheetView: View { - @Environment(AppState.self) private var appState - @Environment(\.dismiss) private var dismiss + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss - @Environment(\.webAuthenticationSession) private var webAuthenticationSession + @Environment(\.webAuthenticationSession) private var webAuthenticationSession - @State private var homeserverLogin: HomeserverLogin? = nil + @State private var homeserverLogin: HomeserverLogin? = nil - @State private var homeserverField: String = "" - @State private var usernameField: String = "" - @State private var passwordField: String = "" + @State private var homeserverField: String = "" + @State private var usernameField: String = "" + @State private var passwordField: String = "" - @State private var loading: Bool = false - @State private var showError: Error? = nil + @State private var loading: Bool = false + @State private var showError: Error? = nil - private let defaultHomeserver = "matrix.org" + private let defaultHomeserver = "matrix.org" - func loadHomeserver() { - if homeserverField.isEmpty { - homeserverField = defaultHomeserver - } - Task { - loading = true - defer { loading = false } - - do { - homeserverLogin = try await MatrixClient.loginDetails(homeServer: homeserverField) - showError = nil - } catch { - homeserverLogin = nil - showError = error - } - } + func loadHomeserver() { + if homeserverField.isEmpty { + homeserverField = defaultHomeserver } - - func signInPassword() { - Task { - guard let homeserverLogin = homeserverLogin else { return } - loading = true - defer { loading = false } - - do { - let client = try await homeserverLogin.loginPassword(homeServer: homeserverField, username: usernameField, password: passwordField) - appState.matrixClient = client - dismiss() - } catch { - showError = error - } - } + Task { + loading = true + defer { loading = false } + + do { + homeserverLogin = try await MatrixClient.loginDetails(homeServer: homeserverField) + showError = nil + } catch { + homeserverLogin = nil + showError = error + } } - - func signInOidc() { - Task { - guard let homeserverLogin = homeserverLogin else { return } - loading = true - defer { loading = false } - - do { - let client = try await homeserverLogin.loginOidc(webAuthSession: webAuthenticationSession) - appState.matrixClient = client - dismiss() - } catch { - showError = error - } - } + } + + func signInPassword() { + Task { + guard let homeserverLogin = homeserverLogin else { return } + loading = true + defer { loading = false } + + do { + let client = try await homeserverLogin.loginPassword( + homeServer: homeserverField, username: usernameField, password: passwordField) + appState.matrixClient = client + dismiss() + } catch { + showError = error + } } - - @ViewBuilder - var passwordLogin: some View { - TextField("Username", text: $usernameField) - .disabled(loading) - .onSubmit { signInPassword() } - SecureField("Password", text: $passwordField) - .disabled(loading) - .onSubmit { signInPassword() } - - HStack { - Button("Sign in") { signInPassword() } - .disabled(loading) - Button("Register account") {} - .buttonStyle(.link) - .disabled(loading) - ProgressView() - .scaleEffect(0.5) - .opacity(loading ? 1 : 0) - } + } + + func signInOidc() { + Task { + guard let homeserverLogin = homeserverLogin else { return } + loading = true + defer { loading = false } + + do { + let client = try await homeserverLogin.loginOidc(webAuthSession: webAuthenticationSession) + appState.matrixClient = client + dismiss() + } catch { + showError = error + } } + } + + @ViewBuilder + var passwordLogin: some View { + TextField("Username", text: $usernameField) + .disabled(loading) + .onSubmit { signInPassword() } + SecureField("Password", text: $passwordField) + .disabled(loading) + .onSubmit { signInPassword() } + + HStack { + Button("Sign in") { signInPassword() } + .disabled(loading) + Button("Register account") {} + .buttonStyle(.link) + .disabled(loading) + ProgressView() + .scaleEffect(0.5) + .opacity(loading ? 1 : 0) + } + } - @ViewBuilder - var oauthLogin: some View { - Button("Sign in with OAuth") { - signInOidc() - } + @ViewBuilder + var oauthLogin: some View { + Button("Sign in with OAuth") { + signInOidc() } + } + + var body: some View { + VStack { + Text("Welcome to Mactrix") + .font(.headline) + .padding(.bottom) + + Form { + TextField( + "Homeserver", text: $homeserverField, + prompt: + Text(defaultHomeserver) + ) + .disabled(loading) + .onSubmit { loadHomeserver() } + + if homeserverLogin?.loginDetails.supportsOauthLogin() == true { + oauthLogin + } - var body: some View { - VStack { - Text("Welcome to Mactrix") - .font(.headline) - .padding(.bottom) - - Form { - TextField("Homeserver", text: $homeserverField, prompt: - Text(defaultHomeserver)) - .disabled(loading) - .onSubmit { loadHomeserver() } - - if homeserverLogin?.loginDetails.supportsOauthLogin() == true { - oauthLogin - } - - if homeserverLogin?.loginDetails.supportsPasswordLogin() == true { - passwordLogin - } - } - .frame(maxWidth: 300) - - if let showError = showError { - let message: String = { - switch showError { - case let MatrixRustSDK.ClientBuildError - .InvalidServerName(message: msg): - return msg - case let MatrixRustSDK.ClientBuildError - .ServerUnreachable(message: msg): - return msg - default: - return showError.localizedDescription - } - }() - - Text(message) - .foregroundStyle(Color.red) - .textSelection(.enabled) - } + if homeserverLogin?.loginDetails.supportsPasswordLogin() == true { + passwordLogin } - .padding() + } + .frame(maxWidth: 300) + + if let showError = showError { + let message: String = { + switch showError { + case MatrixRustSDK.ClientBuildError + .InvalidServerName(message: let msg): + return msg + case MatrixRustSDK.ClientBuildError + .ServerUnreachable(message: let msg): + return msg + default: + return showError.localizedDescription + } + }() + + Text(message) + .foregroundStyle(Color.red) + .textSelection(.enabled) + } } + .padding() + } } #Preview { - WelcomeSheetView() - .environment(AppState()) + WelcomeSheetView() + .environment(AppState()) } diff --git a/Sources/MactrixUI/AvatarImage.swift b/Sources/MactrixUI/AvatarImage.swift index f33de54..67fe099 100644 --- a/Sources/MactrixUI/AvatarImage.swift +++ b/Sources/MactrixUI/AvatarImage.swift @@ -4,115 +4,115 @@ import SwiftUI @MainActor public protocol ImageLoader { - func loadImage(matrixUrl: String, size: CGSize?) async throws -> NSImage? - func cachedImage(matrixUrl: String) -> NSImage? + func loadImage(matrixUrl: String, size: CGSize?) async throws -> NSImage? + func cachedImage(matrixUrl: String) -> NSImage? } public struct AvatarImage: View { - let avatarUrl: String? - let placeholder: () -> Preview - let imageLoader: ImageLoader? - - public init( - avatarUrl: String?, - imageLoader: ImageLoader?, - placeholder: @escaping () -> Preview = { - Rectangle().foregroundStyle(Color.gray) - } - ) { - self.avatarUrl = avatarUrl - self.imageLoader = imageLoader - self.placeholder = placeholder - if let avatarUrl, let cached = imageLoader?.cachedImage(matrixUrl: avatarUrl) { - self._avatar = State(initialValue: Image(nsImage: cached)) - } + let avatarUrl: String? + let placeholder: () -> Preview + let imageLoader: ImageLoader? + + public init( + avatarUrl: String?, + imageLoader: ImageLoader?, + placeholder: @escaping () -> Preview = { + Rectangle().foregroundStyle(Color.gray) } - - public init( - userProfile: Profile, - imageLoader: ImageLoader? - ) where Preview == UserAvatarPlaceholder { - self.init(avatarUrl: userProfile.avatarUrl, imageLoader: imageLoader) { - UserAvatarPlaceholder(userProfile: userProfile) - } + ) { + self.avatarUrl = avatarUrl + self.imageLoader = imageLoader + self.placeholder = placeholder + if let avatarUrl, let cached = imageLoader?.cachedImage(matrixUrl: avatarUrl) { + self._avatar = State(initialValue: Image(nsImage: cached)) + } + } + + public init( + userProfile: Profile, + imageLoader: ImageLoader? + ) where Preview == UserAvatarPlaceholder { + self.init(avatarUrl: userProfile.avatarUrl, imageLoader: imageLoader) { + UserAvatarPlaceholder(userProfile: userProfile) } + } - @State private var avatar: Image? = nil + @State private var avatar: Image? = nil - @ViewBuilder - var imageOrPlaceholder: some View { - if let avatar = avatar { - avatar.resizable() - } else { - placeholder() - } + @ViewBuilder + var imageOrPlaceholder: some View { + if let avatar = avatar { + avatar.resizable() + } else { + placeholder() } + } + + public var body: some View { + imageOrPlaceholder + .scaledToFill() + .transaction { $0.animation = nil } + .task(id: avatarUrl, priority: .utility) { + guard let avatarUrl else { + avatar = nil + return + } - public var body: some View { - imageOrPlaceholder - .scaledToFill() - .transaction { $0.animation = nil } - .task(id: avatarUrl, priority: .utility) { - guard let avatarUrl else { - avatar = nil - return - } - - // Check cache first (handles cell reuse with stale @State) - if let cached = imageLoader?.cachedImage(matrixUrl: avatarUrl) { - avatar = Image(nsImage: cached) - return - } - - avatar = nil - - do { - if let image = try await imageLoader?.loadImage(matrixUrl: avatarUrl, size: nil) { - avatar = Image(nsImage: image) - } else { - avatar = nil - } - } catch { - Logger.viewCycle.error("failed to load avatar (\(avatarUrl): \(error)") - } - } - } + // Check cache first (handles cell reuse with stale @State) + if let cached = imageLoader?.cachedImage(matrixUrl: avatarUrl) { + avatar = Image(nsImage: cached) + return + } + + avatar = nil + + do { + if let image = try await imageLoader?.loadImage(matrixUrl: avatarUrl, size: nil) { + avatar = Image(nsImage: image) + } else { + avatar = nil + } + } catch { + Logger.viewCycle.error("failed to load avatar (\(avatarUrl): \(error)") + } + } + } } public struct UserAvatarPlaceholder: View { - let userProfile: Profile - - public var body: some View { - GeometryReader { g in - ZStack { - Color(userID: userProfile.id) - - if - let initial = (userProfile.displayName ?? userProfile.id).uppercased().filter({ $0 != Character("@") }).first.map({ String($0) }) - { - Text(initial) - .font(.system(size: g.size.width * 0.7)) - .fontWeight(.bold) - .foregroundStyle(.background) - } - } + let userProfile: Profile + + public var body: some View { + GeometryReader { g in + ZStack { + Color(userID: userProfile.id) + + if let initial = (userProfile.displayName ?? userProfile.id).uppercased().filter({ + $0 != Character("@") + }).first.map({ String($0) }) { + Text(initial) + .font(.system(size: g.size.width * 0.7)) + .fontWeight(.bold) + .foregroundStyle(.background) } + } } + } } #Preview { - AvatarImage(userProfile: MockUserProfile(), imageLoader: nil) - .frame(width: 200, height: 200) - .background(Circle().fill(.blue)) - .clipShape(Circle()) - - AvatarImage(userProfile: MockUserProfile(), imageLoader: nil) - .frame(width: 100, height: 100) - .background(Circle().fill(.blue)) - .clipShape(Circle()) - - AvatarImage(userProfile: MockUserProfile(), imageLoader: nil) - .frame(width: 25, height: 25) - .background(Circle().fill(.blue)) - .clipShape(Circle()) + AvatarImage(userProfile: MockUserProfile(), imageLoader: nil) + .frame(width: 200, height: 200) + .background(Circle().fill(.blue)) + .clipShape(Circle()) + + AvatarImage(userProfile: MockUserProfile(), imageLoader: nil) + .frame(width: 100, height: 100) + .background(Circle().fill(.blue)) + .clipShape(Circle()) + + AvatarImage(userProfile: MockUserProfile(), imageLoader: nil) + .frame(width: 25, height: 25) + .background(Circle().fill(.blue)) + .clipShape(Circle()) } diff --git a/Sources/MactrixUI/Extensions/Color+userID.swift b/Sources/MactrixUI/Extensions/Color+userID.swift index 9b26111..05b04a2 100644 --- a/Sources/MactrixUI/Extensions/Color+userID.swift +++ b/Sources/MactrixUI/Extensions/Color+userID.swift @@ -1,12 +1,12 @@ import SwiftUI extension Color { - public init(userID: String) { - self.init( - String( - format: "Username%02d", - userID.unicodeScalars.reduce(0) { $0 + Int($1.value) } % 16 + 1 - ) - ) - } + public init(userID: String) { + self.init( + String( + format: "Username%02d", + userID.unicodeScalars.reduce(0) { $0 + Int($1.value) } % 16 + 1 + ) + ) + } } diff --git a/Sources/MactrixUI/Extensions/Logger+Extensions.swift b/Sources/MactrixUI/Extensions/Logger+Extensions.swift index 3fc2bfe..23c1c8d 100644 --- a/Sources/MactrixUI/Extensions/Logger+Extensions.swift +++ b/Sources/MactrixUI/Extensions/Logger+Extensions.swift @@ -1,7 +1,7 @@ import OSLog extension Logger { - private static let subsystem = "dk.qpqp.mactrix.mactrix-ui" + private static let subsystem = "dk.qpqp.mactrix.mactrix-ui" - static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") + static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") } diff --git a/Sources/MactrixUI/Inspector/UserProfileRow.swift b/Sources/MactrixUI/Inspector/UserProfileRow.swift index c7ab8a2..f5910d6 100644 --- a/Sources/MactrixUI/Inspector/UserProfileRow.swift +++ b/Sources/MactrixUI/Inspector/UserProfileRow.swift @@ -2,61 +2,61 @@ import MatrixProtocols import SwiftUI public struct UserProfileRow: View { - let profile: Profile - let imageLoader: ImageLoader? - - @State private var image: Image? = nil - - public init(profile: Profile, imageLoader: ImageLoader?) { - self.profile = profile - self.imageLoader = imageLoader - } - - public var body: some View { - Label { - Username(userProfile: profile) - } icon: { - AvatarImage(userProfile: profile, imageLoader: imageLoader) - .clipShape(Circle()) - } + let profile: Profile + let imageLoader: ImageLoader? + + @State private var image: Image? = nil + + public init(profile: Profile, imageLoader: ImageLoader?) { + self.profile = profile + self.imageLoader = imageLoader + } + + public var body: some View { + Label { + Username(userProfile: profile) + } icon: { + AvatarImage(userProfile: profile, imageLoader: imageLoader) + .clipShape(Circle()) } + } } public struct UserProfileRowLarge: View { - let profile: Profile - let imageLoader: ImageLoader? - - public init(profile: Profile, imageLoader: ImageLoader?) { - self.profile = profile - self.imageLoader = imageLoader - } - - public var body: some View { - HStack { - AvatarImage(userProfile: profile, imageLoader: imageLoader) - .frame(width: 32, height: 32) - .clipShape(.circle) - VStack(alignment: .leading) { - Username(userProfile: profile) - Text(profile.userId) - .font(.subheadline) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - Spacer() - } + let profile: Profile + let imageLoader: ImageLoader? + + public init(profile: Profile, imageLoader: ImageLoader?) { + self.profile = profile + self.imageLoader = imageLoader + } + + public var body: some View { + HStack { + AvatarImage(userProfile: profile, imageLoader: imageLoader) + .frame(width: 32, height: 32) + .clipShape(.circle) + VStack(alignment: .leading) { + Username(userProfile: profile) + Text(profile.userId) + .font(.subheadline) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + Spacer() } + } } #Preview { - List { - UserProfileRow(profile: MockUserProfile(), imageLoader: nil) - UserProfileRow(profile: MockUserProfile(), imageLoader: nil) - UserProfileRow(profile: MockUserProfile(), imageLoader: nil) - - UserProfileRowLarge(profile: MockUserProfile(), imageLoader: nil) - UserProfileRowLarge(profile: MockUserProfile(), imageLoader: nil) - UserProfileRowLarge(profile: MockUserProfile(), imageLoader: nil) - } - .frame(width: 250) + List { + UserProfileRow(profile: MockUserProfile(), imageLoader: nil) + UserProfileRow(profile: MockUserProfile(), imageLoader: nil) + UserProfileRow(profile: MockUserProfile(), imageLoader: nil) + + UserProfileRowLarge(profile: MockUserProfile(), imageLoader: nil) + UserProfileRowLarge(profile: MockUserProfile(), imageLoader: nil) + UserProfileRowLarge(profile: MockUserProfile(), imageLoader: nil) + } + .frame(width: 250) } diff --git a/Sources/MactrixUI/Inspector/UserProfileView.swift b/Sources/MactrixUI/Inspector/UserProfileView.swift index d7985e8..e085e8b 100644 --- a/Sources/MactrixUI/Inspector/UserProfileView.swift +++ b/Sources/MactrixUI/Inspector/UserProfileView.swift @@ -3,131 +3,137 @@ import SwiftUI @MainActor public protocol UserProfileActions { - func sendMessage() async - func shareProfile() - func ignoreUser() async - func unignoreUser() async + func sendMessage() async + func shareProfile() + func ignoreUser() async + func unignoreUser() async } public protocol UserProfileTimelineActions { - func jumpToReadReceipt() - func mentionUser() + func jumpToReadReceipt() + func mentionUser() } public struct UserProfileView: View { - let profile: Profile - let isUserIgnored: Bool - let actions: UserProfileActions? - let timelineActions: UserProfileTimelineActions? - let imageLoader: ImageLoader? - - @State private var ignoreUserLoading: Bool = false - @State private var sendMessageLoading: Bool = false - - public init(profile: Profile, isUserIgnored: Bool, actions: UserProfileActions?, timelineActions: UserProfileTimelineActions?, imageLoader: ImageLoader?) { - self.profile = profile - self.isUserIgnored = isUserIgnored - self.actions = actions - self.timelineActions = timelineActions - self.imageLoader = imageLoader + let profile: Profile + let isUserIgnored: Bool + let actions: UserProfileActions? + let timelineActions: UserProfileTimelineActions? + let imageLoader: ImageLoader? + + @State private var ignoreUserLoading: Bool = false + @State private var sendMessageLoading: Bool = false + + public init( + profile: Profile, isUserIgnored: Bool, actions: UserProfileActions?, + timelineActions: UserProfileTimelineActions?, imageLoader: ImageLoader? + ) { + self.profile = profile + self.isUserIgnored = isUserIgnored + self.actions = actions + self.timelineActions = timelineActions + self.imageLoader = imageLoader + } + + @ViewBuilder + var profileHeader: some View { + VStack(alignment: .center) { + AvatarImage(userProfile: profile, imageLoader: imageLoader) + .frame(width: 72, height: 72) + .clipShape(.circle) + .padding(.bottom, 6) + + Username(userProfile: profile) + Text(profile.userId) + .font(.subheadline) + .foregroundStyle(.secondary) + .textSelection(.enabled) } - - @ViewBuilder - var profileHeader: some View { - VStack(alignment: .center) { - AvatarImage(userProfile: profile, imageLoader: imageLoader) - .frame(width: 72, height: 72) - .clipShape(.circle) - .padding(.bottom, 6) - - Username(userProfile: profile) - Text(profile.userId) - .font(.subheadline) - .foregroundStyle(.secondary) - .textSelection(.enabled) + .frame(maxWidth: .infinity) + .listRowSeparator(.hidden) + .padding(.top) + } + + public var body: some View { + List { + profileHeader + + Section("Timeline") { + Button(action: { timelineActions?.jumpToReadReceipt() }) { + Label("Jump to read receipt", systemImage: "checkmark.circle") } - .frame(maxWidth: .infinity) - .listRowSeparator(.hidden) - .padding(.top) - } - - public var body: some View { - List { - profileHeader + .buttonStyle(.link) - Section("Timeline") { - Button(action: { timelineActions?.jumpToReadReceipt() }) { - Label("Jump to read receipt", systemImage: "checkmark.circle") - } - .buttonStyle(.link) + Button(action: { timelineActions?.mentionUser() }) { + Label("Mention", systemImage: "at") + } + .buttonStyle(.link) + } + .disabled(timelineActions == nil) + + Section("Actions") { + Button { + Task { + sendMessageLoading = true + defer { sendMessageLoading = false } + + await actions?.sendMessage() + } + } label: { + Label("Send message", systemImage: "envelope") + } + .buttonStyle(.link) + .disabled(sendMessageLoading) - Button(action: { timelineActions?.mentionUser() }) { - Label("Mention", systemImage: "at") - } - .buttonStyle(.link) + Button(action: { actions?.shareProfile() }) { + Label("Share profile", systemImage: "square.and.arrow.up") + } + .buttonStyle(.link) + + if !isUserIgnored { + Button { + Task { + ignoreUserLoading = true + defer { ignoreUserLoading = false } + await actions?.ignoreUser() } - .disabled(timelineActions == nil) - - Section("Actions") { - Button { - Task { - sendMessageLoading = true - defer { sendMessageLoading = false } - - await actions?.sendMessage() - } - } label: { - Label("Send message", systemImage: "envelope") - } - .buttonStyle(.link) - .disabled(sendMessageLoading) - - Button(action: { actions?.shareProfile() }) { - Label("Share profile", systemImage: "square.and.arrow.up") - } - .buttonStyle(.link) - - if !isUserIgnored { - Button { - Task { - ignoreUserLoading = true - defer { ignoreUserLoading = false } - await actions?.ignoreUser() - } - } label: { - Label("Ignore user", systemImage: "person.slash") - } - .buttonStyle(.link) - .foregroundStyle(.red) - .disabled(ignoreUserLoading) - } else { - Button { - Task { - ignoreUserLoading = true - defer { ignoreUserLoading = false } - await actions?.unignoreUser() - } - } label: { - Label("Unignore user", systemImage: "person.slash.fill") - } - .buttonStyle(.link) - .foregroundStyle(.red) - .disabled(ignoreUserLoading) - } + } label: { + Label("Ignore user", systemImage: "person.slash") + } + .buttonStyle(.link) + .foregroundStyle(.red) + .disabled(ignoreUserLoading) + } else { + Button { + Task { + ignoreUserLoading = true + defer { ignoreUserLoading = false } + await actions?.unignoreUser() } - .disabled(actions == nil) + } label: { + Label("Unignore user", systemImage: "person.slash.fill") + } + .buttonStyle(.link) + .foregroundStyle(.red) + .disabled(ignoreUserLoading) } + } + .disabled(actions == nil) } + } } struct MockUserProfileActions: UserProfileActions { - func sendMessage() {} - func shareProfile() {} - func ignoreUser() {} - func unignoreUser() {} + func sendMessage() {} + func shareProfile() {} + func ignoreUser() {} + func unignoreUser() {} } #Preview { - UserProfileView(profile: MockUserProfile(), isUserIgnored: false, actions: MockUserProfileActions(), timelineActions: nil, imageLoader: nil) - .frame(width: 250, height: 500) + UserProfileView( + profile: MockUserProfile(), isUserIgnored: false, actions: MockUserProfileActions(), + timelineActions: nil, imageLoader: nil + ) + .frame(width: 250, height: 500) } diff --git a/Sources/MactrixUI/Rooms/RoomEncryptionBadge.swift b/Sources/MactrixUI/Rooms/RoomEncryptionBadge.swift index 5b8104f..7275db4 100644 --- a/Sources/MactrixUI/Rooms/RoomEncryptionBadge.swift +++ b/Sources/MactrixUI/Rooms/RoomEncryptionBadge.swift @@ -2,45 +2,45 @@ import MatrixProtocols import SwiftUI public struct RoomEncryptionBadge: View { - let state: EncryptionState + let state: EncryptionState - public init(state: EncryptionState) { - self.state = state - } + public init(state: EncryptionState) { + self.state = state + } - struct Badge { - let label: String - let icon: String - let color: Color - } + struct Badge { + let label: String + let icon: String + let color: Color + } - var badge: Badge { - switch state { - case .encrypted: - return Badge(label: "Encrypted", icon: "lock.fill", color: .green) - case .notEncrypted: - return Badge(label: "Not encrypted", icon: "lock.open.fill", color: .blue) - case .unknown: - return Badge(label: "Encryption unknown", icon: "questionmark", color: .gray) - } + var badge: Badge { + switch state { + case .encrypted: + return Badge(label: "Encrypted", icon: "lock.fill", color: .green) + case .notEncrypted: + return Badge(label: "Not encrypted", icon: "lock.open.fill", color: .blue) + case .unknown: + return Badge(label: "Encryption unknown", icon: "questionmark", color: .gray) } + } - public var body: some View { - Label(badge.label, systemImage: badge.icon) - .font(.subheadline.bold()) - .padding(.vertical, 6) - .padding(.horizontal, 10) - .foregroundStyle(badge.color.mix(with: .black, by: 0.1)) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(badge.color.quaternary) - // .stroke(badge.color.secondary) - ) - } + public var body: some View { + Label(badge.label, systemImage: badge.icon) + .font(.subheadline.bold()) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .foregroundStyle(badge.color.mix(with: .black, by: 0.1)) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(badge.color.quaternary) + // .stroke(badge.color.secondary) + ) + } } #Preview { - RoomEncryptionBadge(state: .encrypted) - RoomEncryptionBadge(state: .notEncrypted) - RoomEncryptionBadge(state: .unknown) + RoomEncryptionBadge(state: .encrypted) + RoomEncryptionBadge(state: .notEncrypted) + RoomEncryptionBadge(state: .unknown) } diff --git a/Sources/MactrixUI/Rooms/RoomInspectorView.swift b/Sources/MactrixUI/Rooms/RoomInspectorView.swift index e6de688..101aec9 100644 --- a/Sources/MactrixUI/Rooms/RoomInspectorView.swift +++ b/Sources/MactrixUI/Rooms/RoomInspectorView.swift @@ -3,118 +3,128 @@ import OSLog import SwiftUI struct RoomInspectorMemberRow: View { - let member: RoomMember - let imageLoader: ImageLoader? + let member: RoomMember + let imageLoader: ImageLoader? - var body: some View { - UserProfileRow(profile: member, imageLoader: imageLoader) - } + var body: some View { + UserProfileRow(profile: member, imageLoader: imageLoader) + } } -public struct RoomInspectorView: View { - let room: Room - let members: [RoomMember] - - let roomInfo: RoomInfo? - let imageLoader: ImageLoader? - - @Binding var inspectorVisible: Bool - - public init(room: Room, members: [RoomMember], roomInfo: RoomInfo?, imageLoader: ImageLoader?, inspectorVisible: Binding) { - self.room = room - self.members = members - self.imageLoader = imageLoader - self.roomInfo = roomInfo - _inspectorVisible = inspectorVisible +public struct RoomInspectorView: + View +{ + let room: Room + let members: [RoomMember] + + let roomInfo: RoomInfo? + let imageLoader: ImageLoader? + + @Binding var inspectorVisible: Bool + + public init( + room: Room, members: [RoomMember], roomInfo: RoomInfo?, imageLoader: ImageLoader?, + inspectorVisible: Binding + ) { + self.room = room + self.members = members + self.imageLoader = imageLoader + self.roomInfo = roomInfo + _inspectorVisible = inspectorVisible + } + + @ViewBuilder + func userSection( + title: LocalizedStringResource, allMembers: [RoomMember], withRole role: RoomMemberRole + ) -> some View { + let roleMembers = allMembers.filter { $0.roleForPowerLevel == role } + Section("\(title) (\(roleMembers.count))") { + ForEach(roleMembers) { member in + RoomInspectorMemberRow(member: member, imageLoader: imageLoader) + } } - - @ViewBuilder - func userSection(title: LocalizedStringResource, allMembers: [RoomMember], withRole role: RoomMemberRole) -> some View { - let roleMembers = allMembers.filter { $0.roleForPowerLevel == role } - Section("\(title) (\(roleMembers.count))") { - ForEach(roleMembers) { member in - RoomInspectorMemberRow(member: member, imageLoader: imageLoader) - } + } + + @ViewBuilder + var header: some View { + VStack(alignment: .center, spacing: 20) { + VStack(alignment: .center) { + AvatarImage(avatarUrl: roomInfo?.avatarUrl, imageLoader: imageLoader) + .frame(width: 72, height: 72) + .clipShape(.circle) + + Text(room.displayName ?? "Unknown room") + .font(.title) + .textSelection(.enabled) + + if let alias = roomInfo?.canonicalAlias { + Text(alias) + .foregroundStyle(.secondary) + .textSelection(.enabled) } - } + } - @ViewBuilder - var header: some View { - VStack(alignment: .center, spacing: 20) { - VStack(alignment: .center) { - AvatarImage(avatarUrl: roomInfo?.avatarUrl, imageLoader: imageLoader) - .frame(width: 72, height: 72) - .clipShape(.circle) - - Text(room.displayName ?? "Unknown room") - .font(.title) - .textSelection(.enabled) - - if let alias = roomInfo?.canonicalAlias { - Text(alias) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - } - - RoomEncryptionBadge(state: room.encryptionState) - - if let topic = room.topic { - Text(topic.formatAsMarkdown) - .frame(maxWidth: .infinity, alignment: .leading) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - } - } - .frame(maxWidth: .infinity) - .listRowSeparator(.hidden) - } + RoomEncryptionBadge(state: room.encryptionState) - @ViewBuilder - var usersPlaceholder: some View { - Group { - Section("Admins (2)") { - Text("First admin") - Text("Second admin") - } - - Section("Users (4)") { - Text("First user") - Text("Second user") - Text("Third user") - Text("Fourth user") - } - } - .redacted(reason: .placeholder) + if let topic = room.topic { + Text(topic.formatAsMarkdown) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + } } - - public var body: some View { - List { - header - - userSection(title: "Admins", allMembers: members, withRole: .administrator) - userSection(title: "Moderators", allMembers: members, withRole: .moderator) - userSection(title: "Users", allMembers: members, withRole: .user) - - if let roomInfo { - Section("Extra room info") { - Text("Room version: \(roomInfo.roomVersion, default: "Unknown")") - Text("Notification count: \(roomInfo.notificationCount)") - Text("Highlight count: \(roomInfo.highlightCount)") - Text("Marked unread: \(roomInfo.isMarkedUnread.description)") - Text("Unread mentions: \(roomInfo.numUnreadMentions)") - Text("Unread messages: \(roomInfo.numUnreadMessages)") - Text("Unread notifications: \(roomInfo.numUnreadNotifications)") - } - .font(.callout) - .textSelection(.enabled) - } + .frame(maxWidth: .infinity) + .listRowSeparator(.hidden) + } + + @ViewBuilder + var usersPlaceholder: some View { + Group { + Section("Admins (2)") { + Text("First admin") + Text("Second admin") + } + + Section("Users (4)") { + Text("First user") + Text("Second user") + Text("Third user") + Text("Fourth user") + } + } + .redacted(reason: .placeholder) + } + + public var body: some View { + List { + header + + userSection(title: "Admins", allMembers: members, withRole: .administrator) + userSection(title: "Moderators", allMembers: members, withRole: .moderator) + userSection(title: "Users", allMembers: members, withRole: .user) + + if let roomInfo { + Section("Extra room info") { + Text("Room version: \(roomInfo.roomVersion, default: "Unknown")") + Text("Notification count: \(roomInfo.notificationCount)") + Text("Highlight count: \(roomInfo.highlightCount)") + Text("Marked unread: \(roomInfo.isMarkedUnread.description)") + Text("Unread mentions: \(roomInfo.numUnreadMentions)") + Text("Unread messages: \(roomInfo.numUnreadMessages)") + Text("Unread notifications: \(roomInfo.numUnreadNotifications)") } - .inspectorColumnWidth(min: 200, ideal: 250, max: nil) + .font(.callout) + .textSelection(.enabled) + } } + .inspectorColumnWidth(min: 200, ideal: 250, max: nil) + } } #Preview { - RoomInspectorView(room: MockRoom.previewRoom, members: [], roomInfo: MockRoomInfo(), imageLoader: nil, inspectorVisible: .constant(true)) - .frame(width: 250, height: 500) + RoomInspectorView( + room: MockRoom.previewRoom, members: [], roomInfo: MockRoomInfo(), imageLoader: nil, + inspectorVisible: .constant(true) + ) + .frame(width: 250, height: 500) } diff --git a/Sources/MactrixUI/Rooms/RoomPreviewView.swift b/Sources/MactrixUI/Rooms/RoomPreviewView.swift index bc2fe93..97c7d0e 100644 --- a/Sources/MactrixUI/Rooms/RoomPreviewView.swift +++ b/Sources/MactrixUI/Rooms/RoomPreviewView.swift @@ -3,227 +3,231 @@ import OSLog import SwiftUI struct SmallBadgeModifier: ViewModifier { - let color: Color - - func body(content: Content) -> some View { - content - .foregroundStyle(color.mix(with: Color(NSColor(.primary)), by: 0.3)) - .padding(.vertical, 5) - .padding(.horizontal, 8) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(color.opacity(0.2)) - ) - } + let color: Color + + func body(content: Content) -> some View { + content + .foregroundStyle(color.mix(with: Color(NSColor(.primary)), by: 0.3)) + .padding(.vertical, 5) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(color.opacity(0.2)) + ) + } } @MainActor public protocol RoomPreviewActions { - func joinRoom() async throws - func knockRoom() async throws - func visitRoom() + func joinRoom() async throws + func knockRoom() async throws + func visitRoom() } struct MockRoomPreviewActions: RoomPreviewActions { - func joinRoom() async throws {} - func knockRoom() async throws {} - func visitRoom() {} + func joinRoom() async throws {} + func knockRoom() async throws {} + func visitRoom() {} } public struct RoomPreviewView: View { - let preview: RoomPreview - let imageLoader: ImageLoader? - let actions: RoomPreviewActions? - - @State var actionLoading: Bool = false - - public init(preview: RoomPreview, imageLoader: ImageLoader?, actions: RoomPreviewActions?) { - self.preview = preview - self.imageLoader = imageLoader - self.actions = actions + let preview: RoomPreview + let imageLoader: ImageLoader? + let actions: RoomPreviewActions? + + @State var actionLoading: Bool = false + + public init(preview: RoomPreview, imageLoader: ImageLoader?, actions: RoomPreviewActions?) { + self.preview = preview + self.imageLoader = imageLoader + self.actions = actions + } + + var kindName: String { + switch preview.roomKind { + case .space: + "space" + default: + "room" } + } + + var header: some View { + HStack(alignment: .top, spacing: 15) { + AvatarImage(avatarUrl: preview.avatarUrl, imageLoader: imageLoader) + .frame(width: 72, height: 72) + .clipShape(.circle) + + VStack(alignment: .leading) { + Text(preview.name ?? "Unknown name") + .font(.title) + .textSelection(.enabled) + + if let alias = preview.canonicalAlias { + Text(alias) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } - var kindName: String { - switch preview.roomKind { - case .space: - "space" - default: - "room" + HStack { + Text("^[\(preview.numJoinedMembers) member](inflect: true)") + .modifier(SmallBadgeModifier(color: .green)) + .help( + "^[\(preview.numJoinedMembers) member](inflect: true) have joined this \(kindName)") + + switch preview.joinRuleInfo { + case .public: + Label("Public", systemImage: "globe") + .modifier(SmallBadgeModifier(color: .gray)) + .help("Everyone can join this room") + case .invite: + Label("Invite only", systemImage: "lock") + .modifier(SmallBadgeModifier(color: .gray)) + .help("You need an invitation to join this \(kindName)") + case .knock where preview.userMembership != .knocked: + Label("Knock", systemImage: "hand.wave") + .modifier(SmallBadgeModifier(color: .gray)) + .help("Send a request to join the \(kindName)") + default: + EmptyView() + } + + switch preview.userMembership { + case .invited: + Label("Invited", systemImage: "envelope.badge") + .modifier(SmallBadgeModifier(color: .gray)) + .help("You have been invited to join this \(kindName)") + case .joined: + Label("Joined", systemImage: "checkmark.square.fill") + .modifier(SmallBadgeModifier(color: .gray)) + .help("You are a member of this \(kindName)") + case .knocked: + Label("Knocked", systemImage: "envelope") + .modifier(SmallBadgeModifier(color: .gray)) + .help("You have requested to join this \(kindName)") + case .banned: + Label("Banned", systemImage: "exclamationmark.square") + .modifier(SmallBadgeModifier(color: .gray)) + .help("You are banned from this \(kindName)") + default: + EmptyView() + } + + switch preview.roomKind { + case .space: + Text("Space").modifier(SmallBadgeModifier(color: .blue)) + // case .room: + // Text("Room").modifier(SmallBadgeModifier(color: .blue)) + case .custom(let value): + Text(value).modifier(SmallBadgeModifier(color: .blue)) + default: + EmptyView() + } } + } } - - var header: some View { - HStack(alignment: .top, spacing: 15) { - AvatarImage(avatarUrl: preview.avatarUrl, imageLoader: imageLoader) - .frame(width: 72, height: 72) - .clipShape(.circle) - - VStack(alignment: .leading) { - Text(preview.name ?? "Unknown name") - .font(.title) - .textSelection(.enabled) - - if let alias = preview.canonicalAlias { - Text(alias) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - - HStack { - Text("^[\(preview.numJoinedMembers) member](inflect: true)") - .modifier(SmallBadgeModifier(color: .green)) - .help("^[\(preview.numJoinedMembers) member](inflect: true) have joined this \(kindName)") - - switch preview.joinRuleInfo { - case .public: - Label("Public", systemImage: "globe") - .modifier(SmallBadgeModifier(color: .gray)) - .help("Everyone can join this room") - case .invite: - Label("Invite only", systemImage: "lock") - .modifier(SmallBadgeModifier(color: .gray)) - .help("You need an invitation to join this \(kindName)") - case .knock where preview.userMembership != .knocked: - Label("Knock", systemImage: "hand.wave") - .modifier(SmallBadgeModifier(color: .gray)) - .help("Send a request to join the \(kindName)") - default: - EmptyView() - } - - switch preview.userMembership { - case .invited: - Label("Invited", systemImage: "envelope.badge") - .modifier(SmallBadgeModifier(color: .gray)) - .help("You have been invited to join this \(kindName)") - case .joined: - Label("Joined", systemImage: "checkmark.square.fill") - .modifier(SmallBadgeModifier(color: .gray)) - .help("You are a member of this \(kindName)") - case .knocked: - Label("Knocked", systemImage: "envelope") - .modifier(SmallBadgeModifier(color: .gray)) - .help("You have requested to join this \(kindName)") - case .banned: - Label("Banned", systemImage: "exclamationmark.square") - .modifier(SmallBadgeModifier(color: .gray)) - .help("You are banned from this \(kindName)") - default: - EmptyView() - } - - switch preview.roomKind { - case .space: - Text("Space").modifier(SmallBadgeModifier(color: .blue)) - // case .room: - // Text("Room").modifier(SmallBadgeModifier(color: .blue)) - case let .custom(value: value): - Text(value).modifier(SmallBadgeModifier(color: .blue)) - default: - EmptyView() - } - } + } + + @ViewBuilder + var actionButton: some View { + HStack { + switch preview.joinRuleInfo { + case .public, .other, nil: + if preview.userMembership != .joined { + Button("Join \(kindName)") { + guard let actions = self.actions else { return } + Task { + actionLoading = true + defer { actionLoading = false } + + do { + try await actions.joinRoom() + } catch { + Logger.viewCycle.error("Failed to join room \(error)") + } } + } + .disabled(actions == nil || actionLoading) + + HStack(spacing: 0) { + ProgressView() + .scaleEffect(0.5) + + Text("Joining \(kindName)...") + } + .foregroundStyle(.secondary) + .opacity(actionLoading ? 1 : 0) + } else { + Button("Visit \(kindName)") { + actions?.visitRoom() + } + .disabled(actions == nil || actionLoading) } - } - - @ViewBuilder - var actionButton: some View { - HStack { - switch preview.joinRuleInfo { - case .public, .other, nil: - if preview.userMembership != .joined { - Button("Join \(kindName)") { - guard let actions = self.actions else { return } - Task { - actionLoading = true - defer { actionLoading = false } - - do { - try await actions.joinRoom() - } catch { - Logger.viewCycle.error("Failed to join room \(error)") - } - } - } - .disabled(actions == nil || actionLoading) - - HStack(spacing: 0) { - ProgressView() - .scaleEffect(0.5) - - Text("Joining \(kindName)...") - } - .foregroundStyle(.secondary) - .opacity(actionLoading ? 1 : 0) - } else { - Button("Visit \(kindName)") { - actions?.visitRoom() - } - .disabled(actions == nil || actionLoading) - } - case .knock where preview.userMembership != .knocked: - Button("Request to join \(kindName)") { - guard let actions = self.actions else { return } - Task { - actionLoading = true - defer { actionLoading = false } - - do { - try await actions.knockRoom() - } catch { - Logger.viewCycle.error("Failed to knock room \(error)") - } - } - } - .disabled(actions == nil || actionLoading) - - HStack(spacing: 0) { - ProgressView() - .scaleEffect(0.5) - - Text("Requesting to join \(kindName)...") - } - .foregroundStyle(.secondary) - .opacity(actionLoading ? 1 : 0) - case .knock where preview.userMembership == .knocked: - Button("Join request sent") {} - .disabled(true) - default: - EmptyView() + case .knock where preview.userMembership != .knocked: + Button("Request to join \(kindName)") { + guard let actions = self.actions else { return } + Task { + actionLoading = true + defer { actionLoading = false } + + do { + try await actions.knockRoom() + } catch { + Logger.viewCycle.error("Failed to knock room \(error)") } + } } - } + .disabled(actions == nil || actionLoading) - public var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - header + HStack(spacing: 0) { + ProgressView() + .scaleEffect(0.5) - actionButton + Text("Requesting to join \(kindName)...") + } + .foregroundStyle(.secondary) + .opacity(actionLoading ? 1 : 0) + case .knock where preview.userMembership == .knocked: + Button("Join request sent") {} + .disabled(true) + default: + EmptyView() + } + } + } - Divider() + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header - if let topic = preview.topic { - Text(topic.formatAsMarkdown) - .textSelection(.enabled) - Divider() - } + actionButton - LabeledContent(content: { - Text(preview.roomId) - .textSelection(.enabled) - }, label: { Text("Room ID:").bold() }) + Divider() - Spacer() - } - .padding(.horizontal) - .padding(.vertical, 30) + if let topic = preview.topic { + Text(topic.formatAsMarkdown) + .textSelection(.enabled) + Divider() } + + LabeledContent( + content: { + Text(preview.roomId) + .textSelection(.enabled) + }, label: { Text("Room ID:").bold() }) + + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 30) } + } } #Preview { - RoomPreviewView(preview: MockRoomPreviewInfo(), imageLoader: nil, actions: MockRoomPreviewActions()) - .frame(width: 400, height: 600) + RoomPreviewView( + preview: MockRoomPreviewInfo(), imageLoader: nil, actions: MockRoomPreviewActions() + ) + .frame(width: 400, height: 600) } diff --git a/Sources/MactrixUI/Screens/CreateRoomScreen.swift b/Sources/MactrixUI/Screens/CreateRoomScreen.swift index 51ab32d..412aaf6 100644 --- a/Sources/MactrixUI/Screens/CreateRoomScreen.swift +++ b/Sources/MactrixUI/Screens/CreateRoomScreen.swift @@ -2,158 +2,158 @@ import MatrixProtocols import SwiftUI public struct CreateRoomScreen: View { - @State private var params = CreateRoomParams() - @State private var submitting = false - @State private var errorMsg: Error? = nil + @State private var params = CreateRoomParams() + @State private var submitting = false + @State private var errorMsg: Error? = nil - let onSubmit: (_ params: CreateRoomParams) async throws -> Void + let onSubmit: (_ params: CreateRoomParams) async throws -> Void - public init(onSubmit: @escaping (_ params: CreateRoomParams) async throws -> Void) { - self.onSubmit = onSubmit - } + public init(onSubmit: @escaping (_ params: CreateRoomParams) async throws -> Void) { + self.onSubmit = onSubmit + } - @ViewBuilder - var header: some View { - VStack(spacing: 10) { - Image(systemName: "number") - .font(.largeTitle) - Text("Create Room") - .font(.largeTitle) + @ViewBuilder + var header: some View { + VStack(spacing: 10) { + Image(systemName: "number") + .font(.largeTitle) + Text("Create Room") + .font(.largeTitle) - Text("Create a new chat room to start a conversation.") - } + Text("Create a new chat room to start a conversation.") } - - @ViewBuilder - var form: some View { - Form { - TextField("Name", text: $params.name) - .textFieldStyle(.roundedBorder) - .padding(.bottom, 8) - - LabeledContent("Topic") { - TextEditor(text: $params.topic) - .textEditorStyle(.plain) - .padding(6) - .background( - RoundedRectangle(cornerRadius: 6) - .foregroundStyle(Color.white) - ) - .frame(height: 80) - } - .padding(.bottom, 30) - - Picker("Access", selection: $params.access) { - VStack(alignment: .leading) { - Label("Private", systemImage: "lock") - .font(.title3) - Text("Only invited people can join.") - .font(.subheadline) - } - .tag(RoomAccess.privateRoom) - .padding(.bottom, 8) - - VStack(alignment: .leading) { - Label("Public", systemImage: "lock.open") - .font(.title3) - Text("Everyone can join.") - .font(.subheadline) - } - .tag(RoomAccess.publicRoom) - .padding(.bottom, 8) - } - .pickerStyle(.radioGroup) - .padding(.bottom, 20) - - Picker("Visibility", selection: $params.visibility) { - VStack(alignment: .leading) { - Label("Unpublished", systemImage: "eye.slash.circle") - .font(.title3) - Text("The room will not be shown in the published room list.") - .font(.subheadline) - } - .tag(RoomVisibility.unpublished) - .padding(.bottom, 8) - - VStack(alignment: .leading) { - Label("Published", systemImage: "globe") - .font(.title3) - Text("The room will be shown in the published room list.") - .font(.subheadline) - } - .tag(RoomVisibility.published) - .padding(.bottom, 8) - } - .pickerStyle(.radioGroup) - .padding(.bottom, 20) - - Toggle("End to End Encryption", systemImage: "lock", isOn: $params.enableEncryption) - Text("Once encryption is enabled, it can not be turned off again.") - .font(.subheadline) - .padding(.bottom, 8) - - HStack { - Button(action: submitForm) { - Text("Create") - } - .buttonStyle(.borderedProminent) - - HStack(spacing: 0) { - ProgressView() - .scaleEffect(0.5) - Text("Creating room...") - .foregroundStyle(.secondary) - } - .opacity(submitting ? 1 : 0) - } - - if let errorMsg { - Text("Failed to create room: \(errorMsg.localizedDescription)") - .foregroundStyle(.red) - .textSelection(.enabled) - } + } + + @ViewBuilder + var form: some View { + Form { + TextField("Name", text: $params.name) + .textFieldStyle(.roundedBorder) + .padding(.bottom, 8) + + LabeledContent("Topic") { + TextEditor(text: $params.topic) + .textEditorStyle(.plain) + .padding(6) + .background( + RoundedRectangle(cornerRadius: 6) + .foregroundStyle(Color.white) + ) + .frame(height: 80) + } + .padding(.bottom, 30) + + Picker("Access", selection: $params.access) { + VStack(alignment: .leading) { + Label("Private", systemImage: "lock") + .font(.title3) + Text("Only invited people can join.") + .font(.subheadline) } - .disabled(submitting) - .frame(minWidth: 300, maxWidth: 450) - .padding() - .onSubmit { submitForm() } - } + .tag(RoomAccess.privateRoom) + .padding(.bottom, 8) + + VStack(alignment: .leading) { + Label("Public", systemImage: "lock.open") + .font(.title3) + Text("Everyone can join.") + .font(.subheadline) + } + .tag(RoomAccess.publicRoom) + .padding(.bottom, 8) + } + .pickerStyle(.radioGroup) + .padding(.bottom, 20) + + Picker("Visibility", selection: $params.visibility) { + VStack(alignment: .leading) { + Label("Unpublished", systemImage: "eye.slash.circle") + .font(.title3) + Text("The room will not be shown in the published room list.") + .font(.subheadline) + } + .tag(RoomVisibility.unpublished) + .padding(.bottom, 8) + + VStack(alignment: .leading) { + Label("Published", systemImage: "globe") + .font(.title3) + Text("The room will be shown in the published room list.") + .font(.subheadline) + } + .tag(RoomVisibility.published) + .padding(.bottom, 8) + } + .pickerStyle(.radioGroup) + .padding(.bottom, 20) + + Toggle("End to End Encryption", systemImage: "lock", isOn: $params.enableEncryption) + Text("Once encryption is enabled, it can not be turned off again.") + .font(.subheadline) + .padding(.bottom, 8) + + HStack { + Button(action: submitForm) { + Text("Create") + } + .buttonStyle(.borderedProminent) - public var body: some View { - ScrollView { - HStack { - Spacer() - VStack(spacing: 40) { - Spacer() - header - form - Spacer() - } - Spacer() - } + HStack(spacing: 0) { + ProgressView() + .scaleEffect(0.5) + Text("Creating room...") + .foregroundStyle(.secondary) } - .scrollBounceBehavior(.basedOnSize) - .navigationTitle("Create Room") + .opacity(submitting ? 1 : 0) + } + + if let errorMsg { + Text("Failed to create room: \(errorMsg.localizedDescription)") + .foregroundStyle(.red) + .textSelection(.enabled) + } } - - func submitForm() { - guard !submitting else { return } - let paramsCopy = params - Task { - submitting = true - defer { submitting = false } - - do { - try await onSubmit(paramsCopy) - } catch { - errorMsg = error - } + .disabled(submitting) + .frame(minWidth: 300, maxWidth: 450) + .padding() + .onSubmit { submitForm() } + } + + public var body: some View { + ScrollView { + HStack { + Spacer() + VStack(spacing: 40) { + Spacer() + header + form + Spacer() } + Spacer() + } } + .scrollBounceBehavior(.basedOnSize) + .navigationTitle("Create Room") + } + + func submitForm() { + guard !submitting else { return } + let paramsCopy = params + Task { + submitting = true + defer { submitting = false } + + do { + try await onSubmit(paramsCopy) + } catch { + errorMsg = error + } + } + } } #Preview { - CreateRoomScreen { _ in - try await Task.sleep(for: .seconds(3)) - } + CreateRoomScreen { _ in + try await Task.sleep(for: .seconds(3)) + } } diff --git a/Sources/MactrixUI/SessionVerificationModal.swift b/Sources/MactrixUI/SessionVerificationModal.swift index 6904a86..d885599 100644 --- a/Sources/MactrixUI/SessionVerificationModal.swift +++ b/Sources/MactrixUI/SessionVerificationModal.swift @@ -2,78 +2,82 @@ import MatrixProtocols import SwiftUI public enum SessionVerificationResponse { - case accept, decline + case accept, decline } public struct SessionVerificationModal: View { - let verificationData: SessionVerificationData - var onComplete: (_ response: SessionVerificationResponse) -> Void + let verificationData: SessionVerificationData + var onComplete: (_ response: SessionVerificationResponse) -> Void - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) var dismiss - public init(verificationData: SessionVerificationData, onComplete: @escaping (_ response: SessionVerificationResponse) -> Void) { - self.verificationData = verificationData - self.onComplete = onComplete - } + public init( + verificationData: SessionVerificationData, + onComplete: @escaping (_ response: SessionVerificationResponse) -> Void + ) { + self.verificationData = verificationData + self.onComplete = onComplete + } - var subtitle: String { - switch verificationData { - case .emojis(emojis: _, indices: _): - "Do the emojis match with the other device?" - case .decimals(values: _): - "Do the numbers match with the other device?" - } + var subtitle: String { + switch verificationData { + case .emojis(emojis: _, indices: _): + "Do the emojis match with the other device?" + case .decimals(values: _): + "Do the numbers match with the other device?" } + } - public var body: some View { - VStack(spacing: 20) { - VStack { - Text("Session Verification") - .font(.title) - - Text(subtitle) - } + public var body: some View { + VStack(spacing: 20) { + VStack { + Text("Session Verification") + .font(.title) - switch verificationData { - case let .emojis(emojis, _): - HStack(spacing: 20) { - ForEach(emojis) { emoji in - VStack { - Text(emoji.symbol) - .font(.system(size: 32)) - Text(emoji.description) - .font(.caption) - } - } - } + Text(subtitle) + } - case let .decimals(values): - ForEach(values, id: \.self) { value in - Text("\(value)") - } + switch verificationData { + case .emojis(let emojis, _): + HStack(spacing: 20) { + ForEach(emojis) { emoji in + VStack { + Text(emoji.symbol) + .font(.system(size: 32)) + Text(emoji.description) + .font(.caption) } + } + } - HStack { - Button("They match") { onComplete(.accept) } - .buttonStyle(.borderedProminent) - - Button("They don't match") { onComplete(.decline) } - } + case .decimals(let values): + ForEach(values, id: \.self) { value in + Text("\(value)") } - .padding() + } + + HStack { + Button("They match") { onComplete(.accept) } + .buttonStyle(.borderedProminent) + + Button("They don't match") { onComplete(.decline) } + } } + .padding() + } } #Preview { - let data = SessionVerificationData.emojis(emojis: [ - MockSessionVerificationEmoji(description: "smiling", symbol: "😄"), - MockSessionVerificationEmoji(description: "dog", symbol: "🐶"), - MockSessionVerificationEmoji(description: "rocket", symbol: "🚀"), - MockSessionVerificationEmoji(description: "fish", symbol: "🐠"), - MockSessionVerificationEmoji(description: "butterfly", symbol: "🦋"), - MockSessionVerificationEmoji(description: "rose", symbol: "🌹"), - MockSessionVerificationEmoji(description: "sun", symbol: "☀️"), + let data = SessionVerificationData.emojis( + emojis: [ + MockSessionVerificationEmoji(description: "smiling", symbol: "😄"), + MockSessionVerificationEmoji(description: "dog", symbol: "🐶"), + MockSessionVerificationEmoji(description: "rocket", symbol: "🚀"), + MockSessionVerificationEmoji(description: "fish", symbol: "🐠"), + MockSessionVerificationEmoji(description: "butterfly", symbol: "🦋"), + MockSessionVerificationEmoji(description: "rose", symbol: "🌹"), + MockSessionVerificationEmoji(description: "sun", symbol: "☀️"), ], indices: Data()) - SessionVerificationModal(verificationData: data, onComplete: { _ in }) + SessionVerificationModal(verificationData: data, onComplete: { _ in }) } diff --git a/Sources/MactrixUI/Sidebar/RoomRow.swift b/Sources/MactrixUI/Sidebar/RoomRow.swift index 2cf59bf..29f1248 100644 --- a/Sources/MactrixUI/Sidebar/RoomRow.swift +++ b/Sources/MactrixUI/Sidebar/RoomRow.swift @@ -3,192 +3,197 @@ import OSLog import SwiftUI struct ErrorPopover: View { - let error: Error - - var body: some View { - VStack(alignment: .leading) { - Label("Failed to join room", systemImage: "exclamationmark.triangle") - .textSelection(.enabled) - .font(.headline) - Text(error.localizedDescription) - .lineLimit(nil) - .textSelection(.enabled) - } - .frame(width: 400) - .padding() + let error: Error + + var body: some View { + VStack(alignment: .leading) { + Label("Failed to join room", systemImage: "exclamationmark.triangle") + .textSelection(.enabled) + .font(.headline) + Text(error.localizedDescription) + .lineLimit(nil) + .textSelection(.enabled) } + .frame(width: 400) + .padding() + } } struct MockError: LocalizedError { - var errorDescription: String? { - "Something failed, this is a long and detailed explaination of the error." - } + var errorDescription: String? { + "Something failed, this is a long and detailed explaination of the error." + } } #Preview { - ErrorPopover(error: MockError()) - /* Text("Hello") - .popover(isPresented: .constant(true)) { - ErrorPopover(error: MockError()) - } */ + ErrorPopover(error: MockError()) + /* Text("Hello") + .popover(isPresented: .constant(true)) { + ErrorPopover(error: MockError()) + } */ } public struct RoomRow: View { - let title: String - let avatarUrl: String? - let roomInfo: RoomInfo? - let imageLoader: ImageLoader? - let joinRoom: (() async throws -> Void)? - - @State private var joining: Bool = false - @State private var error: Error? = nil - @State private var isErrorVisible: Bool = false - @ScaledMetric(relativeTo: .body) private var roomAvatarSize: CGFloat = 22.0 - - public init(title: String, avatarUrl: String?, roomInfo: RoomInfo?, imageLoader: ImageLoader?, joinRoom: (() async throws -> Void)?) { - self.title = title - self.avatarUrl = avatarUrl - self.roomInfo = roomInfo - self.imageLoader = imageLoader - self.joinRoom = joinRoom - } - - var placeholderImageName: String { - if roomInfo?.isSpace == true { - "network" - } else if roomInfo?.isDirect == true { - "person.fill" - } else { - "number" - } + let title: String + let avatarUrl: String? + let roomInfo: RoomInfo? + let imageLoader: ImageLoader? + let joinRoom: (() async throws -> Void)? + + @State private var joining: Bool = false + @State private var error: Error? = nil + @State private var isErrorVisible: Bool = false + @ScaledMetric(relativeTo: .body) private var roomAvatarSize: CGFloat = 22.0 + + public init( + title: String, avatarUrl: String?, roomInfo: RoomInfo?, imageLoader: ImageLoader?, + joinRoom: (() async throws -> Void)? + ) { + self.title = title + self.avatarUrl = avatarUrl + self.roomInfo = roomInfo + self.imageLoader = imageLoader + self.joinRoom = joinRoom + } + + var placeholderImageName: String { + if roomInfo?.isSpace == true { + "network" + } else if roomInfo?.isDirect == true { + "person.fill" + } else { + "number" } - - var label: some View { - Label( - title: { - Text(title) - .lineLimit(1) - .truncationMode(.tail) - }, - icon: { - MactrixUI.AvatarImage(avatarUrl: avatarUrl, imageLoader: imageLoader) { - RoundedRectangle(cornerRadius: 4) - .fill(.clear) - .overlay { - Image(systemName: placeholderImageName) - } - } - .frame(width: roomAvatarSize, height: roomAvatarSize) - .clipShape(RoundedRectangle(cornerRadius: 4)) + } + + var label: some View { + Label( + title: { + Text(title) + .lineLimit(1) + .truncationMode(.tail) + }, + icon: { + MactrixUI.AvatarImage(avatarUrl: avatarUrl, imageLoader: imageLoader) { + RoundedRectangle(cornerRadius: 4) + .fill(.clear) + .overlay { + Image(systemName: placeholderImageName) } - ) - .fontWeight(isUnread ? .bold : .regular) - .help(title) - } - - var badgeProminence: BadgeProminence { - guard let roomInfo else { return .standard } - return roomInfo.numUnreadNotifications > 0 || roomInfo.numUnreadMentions > 0 ? .increased : .standard - } - - var notifications: Int { - guard let roomInfo else { return 0 } - - let highlightMessages = Int(max( - roomInfo.numUnreadNotifications, - roomInfo.numUnreadMentions - )) - - if highlightMessages > 0 { - return highlightMessages - } else { - return Int(roomInfo.numUnreadMessages) } + .frame(width: roomAvatarSize, height: roomAvatarSize) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + ) + .fontWeight(isUnread ? .bold : .regular) + .help(title) + } + + var badgeProminence: BadgeProminence { + guard let roomInfo else { return .standard } + return roomInfo.numUnreadNotifications > 0 || roomInfo.numUnreadMentions > 0 + ? .increased : .standard + } + + var notifications: Int { + guard let roomInfo else { return 0 } + + let highlightMessages = Int( + max( + roomInfo.numUnreadNotifications, + roomInfo.numUnreadMentions + )) + + if highlightMessages > 0 { + return highlightMessages + } else { + return Int(roomInfo.numUnreadMessages) } - - var isUnread: Bool { - return notifications > 0 || roomInfo?.isMarkedUnread == true - } - - public var body: some View { - Group { - if joinRoom != nil { - HStack { - label - Spacer() - if let error { - Button { - isErrorVisible.toggle() - } label: { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(Color.red) - } - .buttonStyle(.plain) - .popover(isPresented: $isErrorVisible) { - ErrorPopover(error: error) - } - } else if joining { - ProgressView().scaleEffect(0.4) - } else { - Button("Join") { - joining = true - } - .buttonStyle(.link) - .foregroundStyle(Color.accentColor) - } - } - } else { - label + } + + var isUnread: Bool { + return notifications > 0 || roomInfo?.isMarkedUnread == true + } + + public var body: some View { + Group { + if joinRoom != nil { + HStack { + label + Spacer() + if let error { + Button { + isErrorVisible.toggle() + } label: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.red) } - } - .badge(notifications) - .badgeProminence(badgeProminence) - .listItemTint(.gray) - .task(id: joining) { - guard joining else { return } - guard let joinRoom else { return } - - do { - try await joinRoom() - } catch { - Logger.viewCycle.error("failed to join room \(error)") - self.error = error - self.isErrorVisible = true + .buttonStyle(.plain) + .popover(isPresented: $isErrorVisible) { + ErrorPopover(error: error) } - - joining = false + } else if joining { + ProgressView().scaleEffect(0.4) + } else { + Button("Join") { + joining = true + } + .buttonStyle(.link) + .foregroundStyle(Color.accentColor) + } } + } else { + label + } } + .badge(notifications) + .badgeProminence(badgeProminence) + .listItemTint(.gray) + .task(id: joining) { + guard joining else { return } + guard let joinRoom else { return } + + do { + try await joinRoom() + } catch { + Logger.viewCycle.error("failed to join room \(error)") + self.error = error + self.isErrorVisible = true + } + + joining = false + } + } } #Preview { - List { - Section("Rooms") { - RoomRow( - title: "Room row 1", - avatarUrl: nil, - roomInfo: nil, - imageLoader: nil, - joinRoom: nil - ) - - RoomRow( - title: "Room row 2", - avatarUrl: nil, - roomInfo: nil, - imageLoader: nil, - joinRoom: nil - ) - - RoomRow( - title: "Room row 3", - avatarUrl: nil, - roomInfo: nil, - imageLoader: nil, - joinRoom: {} - ) - } + List { + Section("Rooms") { + RoomRow( + title: "Room row 1", + avatarUrl: nil, + roomInfo: nil, + imageLoader: nil, + joinRoom: nil + ) + + RoomRow( + title: "Room row 2", + avatarUrl: nil, + roomInfo: nil, + imageLoader: nil, + joinRoom: nil + ) + + RoomRow( + title: "Room row 3", + avatarUrl: nil, + roomInfo: nil, + imageLoader: nil, + joinRoom: {} + ) } - .listStyle(.sidebar) - .frame(width: 200) + } + .listStyle(.sidebar) + .frame(width: 200) } diff --git a/Sources/MactrixUI/Sidebar/SectionAction.swift b/Sources/MactrixUI/Sidebar/SectionAction.swift index 963139e..6fa71ee 100644 --- a/Sources/MactrixUI/Sidebar/SectionAction.swift +++ b/Sources/MactrixUI/Sidebar/SectionAction.swift @@ -1,53 +1,53 @@ import SwiftUI struct SectionAction: View { - let title: LocalizedStringKey - let systemIcon: String - let action: () -> Void - let content: () -> Content + let title: LocalizedStringKey + let systemIcon: String + let action: () -> Void + let content: () -> Content - @State private var isHovering = false + @State private var isHovering = false - init( - title: LocalizedStringKey, - systemIcon: String, - action: @escaping () -> Void, - @ViewBuilder content: @escaping () -> Content - ) { - self.title = title - self.systemIcon = systemIcon - self.action = action - self.content = content - } + init( + title: LocalizedStringKey, + systemIcon: String, + action: @escaping () -> Void, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.systemIcon = systemIcon + self.action = action + self.content = content + } - var body: some View { - Section { - content() - } header: { - HStack { - Text(title) - Spacer() - Button("Add room", systemImage: systemIcon, action: action) - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .opacity(isHovering ? 1 : 0) - .padding(.trailing, 2) - } - .onHover { hover in - self.isHovering = hover - } - } + var body: some View { + Section { + content() + } header: { + HStack { + Text(title) + Spacer() + Button("Add room", systemImage: systemIcon, action: action) + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .opacity(isHovering ? 1 : 0) + .padding(.trailing, 2) + } + .onHover { hover in + self.isHovering = hover + } } + } } #Preview { - List { - SectionAction(title: "Section name", systemIcon: "plus.circle", action: {}) { - Text("Content") - Text("Content") - Text("Content") - } + List { + SectionAction(title: "Section name", systemIcon: "plus.circle", action: {}) { + Text("Content") + Text("Content") + Text("Content") } - .listStyle(.sidebar) - .frame(width: 200, height: 200) + } + .listStyle(.sidebar) + .frame(width: 200, height: 200) } diff --git a/Sources/MactrixUI/Timeline/MessageReplyView.swift b/Sources/MactrixUI/Timeline/MessageReplyView.swift index 407cfec..5060921 100644 --- a/Sources/MactrixUI/Timeline/MessageReplyView.swift +++ b/Sources/MactrixUI/Timeline/MessageReplyView.swift @@ -1,55 +1,55 @@ import SwiftUI public struct MessageReplyView: View { - let username: String - let message: String - let action: () -> Void + let username: String + let message: String + let action: () -> Void - public init(username: String, message: String, action: @escaping () -> Void = {}) { - self.username = username - self.message = message - self.action = action - } + public init(username: String, message: String, action: @escaping () -> Void = {}) { + self.username = username + self.message = message + self.action = action + } - var content: some View { - HStack { - VStack(alignment: .leading) { - Text(username) - .bold() - .textSelection(.enabled) - Text(message.formatAsMarkdown) - .textSelection(.enabled) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background( - ZStack { - HStack(spacing: 0) { - RoundedRectangle(cornerRadius: 4).opacity(0.5).frame(width: 3) - Spacer() - } - RoundedRectangle(cornerRadius: 4) - .padding(.leading, 2) - .opacity(0.05) - } - ) - .italic() + var content: some View { + HStack { + VStack(alignment: .leading) { + Text(username) + .bold() + .textSelection(.enabled) + Text(message.formatAsMarkdown) + .textSelection(.enabled) + } } - - public var body: some View { - Button { - action() - } label: { - content + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + ZStack { + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 4).opacity(0.5).frame(width: 3) + Spacer() } - .buttonStyle(.plain) + RoundedRectangle(cornerRadius: 4) + .padding(.leading, 2) + .opacity(0.05) + } + ) + .italic() + } + + public var body: some View { + Button { + action() + } label: { + content } + .buttonStyle(.plain) + } } #Preview { - VStack(alignment: .leading, spacing: 10) { - MessageReplyView(username: "user@example.com", message: "This is the root message") - Text("Real content") - }.padding() + VStack(alignment: .leading, spacing: 10) { + MessageReplyView(username: "user@example.com", message: "This is the root message") + Text("Real content") + }.padding() } diff --git a/Sources/MactrixUI/Timeline/ThreadTimelineHeader.swift b/Sources/MactrixUI/Timeline/ThreadTimelineHeader.swift index 5cecff3..747be3f 100644 --- a/Sources/MactrixUI/Timeline/ThreadTimelineHeader.swift +++ b/Sources/MactrixUI/Timeline/ThreadTimelineHeader.swift @@ -1,37 +1,37 @@ import SwiftUI public struct ThreadTimelineHeader: View { - let dismiss: () -> Void + let dismiss: () -> Void - public init(dismiss: @escaping () -> Void) { - self.dismiss = dismiss - } - - public var body: some View { - VStack(alignment: .leading, spacing: 0) { - Divider() - HStack(alignment: .firstTextBaseline) { - Button(action: dismiss) { - Label("Close thread", systemImage: "xmark.circle.fill") - } - .buttonStyle(.plain) - .labelStyle(.iconOnly) + public init(dismiss: @escaping () -> Void) { + self.dismiss = dismiss + } - Text("Thread") - .font(.headline) - } - .padding(10) - Divider() + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + Divider() + HStack(alignment: .firstTextBaseline) { + Button(action: dismiss) { + Label("Close thread", systemImage: "xmark.circle.fill") } - .background(.thinMaterial) + .buttonStyle(.plain) + .labelStyle(.iconOnly) + + Text("Thread") + .font(.headline) + } + .padding(10) + Divider() } + .background(.thinMaterial) + } } #Preview { - VStack(spacing: 0) { - ThreadTimelineHeader {} - Text("Body") - .frame(maxWidth: .infinity, maxHeight: 40) - .background(.white) - } + VStack(spacing: 0) { + ThreadTimelineHeader {} + Text("Body") + .frame(maxWidth: .infinity, maxHeight: 40) + .background(.white) + } } diff --git a/Sources/MactrixUI/Username.swift b/Sources/MactrixUI/Username.swift index fe19978..20d6a37 100644 --- a/Sources/MactrixUI/Username.swift +++ b/Sources/MactrixUI/Username.swift @@ -1,14 +1,16 @@ -import SwiftUI import MatrixProtocols +import SwiftUI struct Username: View { - var userProfile: Profile + var userProfile: Profile - var body: some View { - Text(userProfile.displayName ?? userProfile.userId).foregroundStyle(Color(userID: userProfile.userId)) - .lineLimit(1) - .truncationMode(.tail) - .help(userProfile.displayName ?? userProfile.userId) - .textSelection(.enabled) - } + var body: some View { + Text(userProfile.displayName ?? userProfile.userId).foregroundStyle( + Color(userID: userProfile.userId) + ) + .lineLimit(1) + .truncationMode(.tail) + .help(userProfile.displayName ?? userProfile.userId) + .textSelection(.enabled) + } } diff --git a/Sources/MatrixIntegration/AppKeychain.swift b/Sources/MatrixIntegration/AppKeychain.swift index 53b9bdb..e7462ad 100644 --- a/Sources/MatrixIntegration/AppKeychain.swift +++ b/Sources/MatrixIntegration/AppKeychain.swift @@ -2,67 +2,67 @@ import Foundation import Security enum KeychainError: Error { - case unexpectedStatus(OSStatus) + case unexpectedStatus(OSStatus) } struct AppKeychain { - private let service: String + private let service: String - init(service: String = Bundle.main.bundleIdentifier!) { - self.service = service - } + init(service: String = Bundle.main.bundleIdentifier!) { + self.service = service + } - private var baseQuery: [CFString: Any] { - [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - /// change to true for Data Protection keychain mode - kSecUseDataProtectionKeychain: false, - ] - } + private var baseQuery: [CFString: Any] { + [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + /// change to true for Data Protection keychain mode + kSecUseDataProtectionKeychain: false, + ] + } - /// Saves data for the given key, creating or updating the keychain item. - func save(_ data: Data, forKey key: String) throws { - var query = baseQuery - query[kSecAttrAccount] = key + /// Saves data for the given key, creating or updating the keychain item. + func save(_ data: Data, forKey key: String) throws { + var query = baseQuery + query[kSecAttrAccount] = key - let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary) - if updateStatus == errSecItemNotFound { - query[kSecValueData] = data - query[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlocked - let addStatus = SecItemAdd(query as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw KeychainError.unexpectedStatus(addStatus) - } - } else if updateStatus != errSecSuccess { - /// with kSecUseDataProtectionKeychain set to true, this will throw with error -34018, which according to - /// https://github.com/apple-oss-distributions/Security/blob/09becc8fb155462f0853f99cd09c26b10a38e2f4/sec/Security/SecBasePriv.h#L113 - /// is the error "Internal error when a required entitlement isn't present" - throw KeychainError.unexpectedStatus(updateStatus) - } + let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary) + if updateStatus == errSecItemNotFound { + query[kSecValueData] = data + query[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlocked + let addStatus = SecItemAdd(query as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw KeychainError.unexpectedStatus(addStatus) + } + } else if updateStatus != errSecSuccess { + /// with kSecUseDataProtectionKeychain set to true, this will throw with error -34018, which according to + /// https://github.com/apple-oss-distributions/Security/blob/09becc8fb155462f0853f99cd09c26b10a38e2f4/sec/Security/SecBasePriv.h#L113 + /// is the error "Internal error when a required entitlement isn't present" + throw KeychainError.unexpectedStatus(updateStatus) } + } - /// Returns the data stored for the given key, or `nil` if no item exists. - func load(forKey key: String) throws -> Data? { - var query = baseQuery - query[kSecAttrAccount] = key - query[kSecReturnData] = true - query[kSecMatchLimit] = kSecMatchLimitOne + /// Returns the data stored for the given key, or `nil` if no item exists. + func load(forKey key: String) throws -> Data? { + var query = baseQuery + query[kSecAttrAccount] = key + query[kSecReturnData] = true + query[kSecMatchLimit] = kSecMatchLimitOne - var result: AnyObject? - let status = unsafe SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess else { - throw KeychainError.unexpectedStatus(status) - } - return result as? Data + var result: AnyObject? + let status = unsafe SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { + throw KeychainError.unexpectedStatus(status) } + return result as? Data + } - /// Removes all keychain items stored under this service. - func removeAll() throws { - let status = SecItemDelete(baseQuery as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw KeychainError.unexpectedStatus(status) - } + /// Removes all keychain items stored under this service. + func removeAll() throws { + let status = SecItemDelete(baseQuery as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) } + } } diff --git a/Sources/MatrixIntegration/AsyncSDKListener.swift b/Sources/MatrixIntegration/AsyncSDKListener.swift index 96da084..5a3c0c9 100644 --- a/Sources/MatrixIntegration/AsyncSDKListener.swift +++ b/Sources/MatrixIntegration/AsyncSDKListener.swift @@ -3,83 +3,88 @@ import MatrixRustSDK import OSLog final class AsyncSDKListener: AsyncSequence, Sendable { - typealias Element = Element - typealias AsyncIterator = AsyncStream.Iterator + typealias Element = Element + typealias AsyncIterator = AsyncStream.Iterator - private let stream: AsyncStream - private let continuation: AsyncStream.Continuation + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation - init() { - let (s, c) = AsyncStream.makeStream() - stream = s - continuation = c - } + init() { + let (s, c) = AsyncStream.makeStream() + stream = s + continuation = c + } - func publishValue(_ element: Element) { - continuation.yield(element) - } + func publishValue(_ element: Element) { + continuation.yield(element) + } - func makeAsyncIterator() -> AsyncStream.Iterator { - return stream.makeAsyncIterator() - } + func makeAsyncIterator() -> AsyncStream.Iterator { + return stream.makeAsyncIterator() + } } extension AsyncSDKListener: TypingNotificationsListener where Element == [String] { - func call(typingUserIds: [String]) { - publishValue(typingUserIds) - } + func call(typingUserIds: [String]) { + publishValue(typingUserIds) + } } -extension AsyncSDKListener: RoomDirectorySearchEntriesListener where Element == [MatrixRustSDK.RoomDirectorySearchEntryUpdate] { - func onUpdate(roomEntriesUpdate: [MatrixRustSDK.RoomDirectorySearchEntryUpdate]) { - publishValue(roomEntriesUpdate) - } +extension AsyncSDKListener: RoomDirectorySearchEntriesListener +where Element == [MatrixRustSDK.RoomDirectorySearchEntryUpdate] { + func onUpdate(roomEntriesUpdate: [MatrixRustSDK.RoomDirectorySearchEntryUpdate]) { + publishValue(roomEntriesUpdate) + } } -extension AsyncSDKListener: SpaceRoomListPaginationStateListener where Element == MatrixRustSDK.SpaceRoomListPaginationState { - func onUpdate(paginationState: MatrixRustSDK.SpaceRoomListPaginationState) { - publishValue(paginationState) - } +extension AsyncSDKListener: SpaceRoomListPaginationStateListener +where Element == MatrixRustSDK.SpaceRoomListPaginationState { + func onUpdate(paginationState: MatrixRustSDK.SpaceRoomListPaginationState) { + publishValue(paginationState) + } } -extension AsyncSDKListener: SpaceRoomListEntriesListener where Element == [MatrixRustSDK.SpaceListUpdate] { - func onUpdate(rooms: [MatrixRustSDK.SpaceListUpdate]) { - publishValue(rooms) - } +extension AsyncSDKListener: SpaceRoomListEntriesListener +where Element == [MatrixRustSDK.SpaceListUpdate] { + func onUpdate(rooms: [MatrixRustSDK.SpaceListUpdate]) { + publishValue(rooms) + } } extension AsyncSDKListener: SpaceRoomListSpaceListener where Element == MatrixRustSDK.SpaceRoom? { - func onUpdate(space: MatrixRustSDK.SpaceRoom?) { - publishValue(space) - } + func onUpdate(space: MatrixRustSDK.SpaceRoom?) { + publishValue(space) + } } -extension AsyncSDKListener: SpaceServiceJoinedSpacesListener where Element == [MatrixRustSDK.SpaceListUpdate] { - func onUpdate(roomUpdates: [MatrixRustSDK.SpaceListUpdate]) { - publishValue(roomUpdates) - } +extension AsyncSDKListener: SpaceServiceJoinedSpacesListener +where Element == [MatrixRustSDK.SpaceListUpdate] { + func onUpdate(roomUpdates: [MatrixRustSDK.SpaceListUpdate]) { + publishValue(roomUpdates) + } } extension AsyncSDKListener: TimelineListener where Element == [MatrixRustSDK.TimelineDiff] { - func onUpdate(diff: [TimelineDiff]) { - publishValue(diff) - } + func onUpdate(diff: [TimelineDiff]) { + publishValue(diff) + } } -extension AsyncSDKListener: PaginationStatusListener where Element == MatrixRustSDK.PaginationStatus { - func onUpdate(status: MatrixRustSDK.PaginationStatus) { - publishValue(status) - } +extension AsyncSDKListener: PaginationStatusListener +where Element == MatrixRustSDK.PaginationStatus { + func onUpdate(status: MatrixRustSDK.PaginationStatus) { + publishValue(status) + } } extension AsyncSDKListener: RoomInfoListener where Element == MatrixRustSDK.RoomInfo { - func call(roomInfo: MatrixRustSDK.RoomInfo) { - publishValue(roomInfo) - } + func call(roomInfo: MatrixRustSDK.RoomInfo) { + publishValue(roomInfo) + } } extension AsyncSDKListener: RoomListEntriesListener where Element == [RoomListEntriesUpdate] { - func onUpdate(roomEntriesUpdate: [MatrixRustSDK.RoomListEntriesUpdate]) { - publishValue(roomEntriesUpdate) - } + func onUpdate(roomEntriesUpdate: [MatrixRustSDK.RoomListEntriesUpdate]) { + publishValue(roomEntriesUpdate) + } } diff --git a/Sources/MatrixIntegration/Extensions/Logger+Extensions.swift b/Sources/MatrixIntegration/Extensions/Logger+Extensions.swift index 9167b8f..489b1ab 100644 --- a/Sources/MatrixIntegration/Extensions/Logger+Extensions.swift +++ b/Sources/MatrixIntegration/Extensions/Logger+Extensions.swift @@ -1,13 +1,13 @@ import OSLog extension Logger { - private static let subsystem = "dk.qpqp.mactrix.matrix-integration" + private static let subsystem = "dk.qpqp.mactrix.matrix-integration" - static let matrixClient = Logger(subsystem: subsystem, category: "matrix-client") - static let liveRoom = Logger(subsystem: subsystem, category: "live-room") - static let liveSpaceRoomList = Logger(subsystem: subsystem, category: "live-space-room-list") - static let liveSpaceService = Logger(subsystem: subsystem, category: "live-space-service") - static let liveTimeline = Logger(subsystem: subsystem, category: "live-timeline") - static let sidebarRoom = Logger(subsystem: subsystem, category: "sidebar-room") - static let notification = Logger(subsystem: subsystem, category: "notification") + static let matrixClient = Logger(subsystem: subsystem, category: "matrix-client") + static let liveRoom = Logger(subsystem: subsystem, category: "live-room") + static let liveSpaceRoomList = Logger(subsystem: subsystem, category: "live-space-room-list") + static let liveSpaceService = Logger(subsystem: subsystem, category: "live-space-service") + static let liveTimeline = Logger(subsystem: subsystem, category: "live-timeline") + static let sidebarRoom = Logger(subsystem: subsystem, category: "sidebar-room") + static let notification = Logger(subsystem: subsystem, category: "notification") } diff --git a/Sources/MatrixIntegration/Extensions/MatrixRustSDK+Extensions.swift b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+Extensions.swift index 2199e00..abfb7c6 100644 --- a/Sources/MatrixIntegration/Extensions/MatrixRustSDK+Extensions.swift +++ b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+Extensions.swift @@ -5,57 +5,57 @@ import MatrixRustSDK extension MatrixRustSDK.RoomMember: MatrixProtocols.UserProfile {} extension MatrixRustSDK.RoomMember: @retroactive Identifiable, MatrixProtocols.RoomMember { - public var id: String { - userId - } + public var id: String { + userId + } - public var roleForPowerLevel: MatrixProtocols.RoomMemberRole { - suggestedRoleForPowerLevel.asModel - } + public var roleForPowerLevel: MatrixProtocols.RoomMemberRole { + suggestedRoleForPowerLevel.asModel + } } extension MatrixRustSDK.RoomMemberRole { - var asModel: MatrixProtocols.RoomMemberRole { - switch self { - case .creator: - return .creator - case .administrator: - return .administrator - case .moderator: - return .moderator - case .user: - return .user - } - } + var asModel: MatrixProtocols.RoomMemberRole { + switch self { + case .creator: + return .creator + case .administrator: + return .administrator + case .moderator: + return .moderator + case .user: + return .user + } + } } extension MatrixRustSDK.EncryptionState { - var asModel: MatrixProtocols.EncryptionState { - switch self { - case .notEncrypted: - return .notEncrypted - case .encrypted: - return .encrypted - case .unknown: - return .unknown - } - } + var asModel: MatrixProtocols.EncryptionState { + switch self { + case .notEncrypted: + return .notEncrypted + case .encrypted: + return .encrypted + case .unknown: + return .unknown + } + } } extension MatrixRustSDK.Room: @retroactive Equatable, @retroactive Hashable { - public static func == (lhs: MatrixRustSDK.Room, rhs: MatrixRustSDK.Room) -> Bool { - return lhs.id() == rhs.id() - } + public static func == (lhs: MatrixRustSDK.Room, rhs: MatrixRustSDK.Room) -> Bool { + return lhs.id() == rhs.id() + } - public func hash(into hasher: inout Hasher) { - return hasher.combine(id()) - } + public func hash(into hasher: inout Hasher) { + return hasher.combine(id()) + } } extension MatrixRustSDK.Room: @retroactive Identifiable { - public var id: String { - self.id() - } + public var id: String { + self.id() + } } extension MatrixRustSDK.RoomInfo: MatrixProtocols.RoomInfo {} @@ -122,474 +122,487 @@ extension MatrixRustSDK.RoomInfo: MatrixProtocols.RoomInfo {} } */ extension MatrixRustSDK.Reaction: @retroactive Identifiable { - public var id: String { - key - } + public var id: String { + key + } } extension MatrixRustSDK.Reaction: MatrixProtocols.Reaction { - public typealias SenderData = MatrixRustSDK.ReactionSenderData + public typealias SenderData = MatrixRustSDK.ReactionSenderData } extension MatrixRustSDK.ReactionSenderData: MatrixProtocols.ReactionSenderData { - public var date: Date { - timestamp.date - } + public var date: Date { + timestamp.date + } } -public extension MatrixRustSDK.Timestamp { - var date: Date { - Date(timeIntervalSince1970: Double(self) / 1000) - } +extension MatrixRustSDK.Timestamp { + public var date: Date { + Date(timeIntervalSince1970: Double(self) / 1000) + } } extension MatrixRustSDK.VirtualTimelineItem { - var asModel: MatrixProtocols.VirtualTimelineItem { - switch self { - case let .dateDivider(ts: ts): - return .dateDivider(date: ts.date) - case .readMarker: - return .readMarker - case .timelineStart: - return .timelineStart - } - } + var asModel: MatrixProtocols.VirtualTimelineItem { + switch self { + case .dateDivider(let ts): + return .dateDivider(date: ts.date) + case .readMarker: + return .readMarker + case .timelineStart: + return .timelineStart + } + } } extension MatrixRustSDK.ProfileDetails { - var asModel: MatrixProtocols.ProfileDetails { - switch self { - case .unavailable: - return .unavailable - case .pending: - return .pending - case let .ready(displayName, displayNameAmbiguous, avatarUrl, _, _): - return .ready(displayName: displayName, displayNameAmbiguous: displayNameAmbiguous, avatarUrl: avatarUrl) - case let .error(message): - return .error(message: message) - } - } + var asModel: MatrixProtocols.ProfileDetails { + switch self { + case .unavailable: + return .unavailable + case .pending: + return .pending + case .ready(let displayName, let displayNameAmbiguous, let avatarUrl, _, _): + return .ready( + displayName: displayName, displayNameAmbiguous: displayNameAmbiguous, avatarUrl: avatarUrl) + case .error(let message): + return .error(message: message) + } + } } extension MatrixRustSDK.EventTimelineItem: MatrixProtocols.EventTimelineItem { - public var userReadReceipts: [String: MatrixProtocols.Receipt] { - readReceipts.mapValues { MatrixProtocols.Receipt(timestamp: $0.timestamp?.date) } - } - - public var senderProfileDetails: MatrixProtocols.ProfileDetails { - senderProfile.asModel - } + public var userReadReceipts: [String: MatrixProtocols.Receipt] { + readReceipts.mapValues { MatrixProtocols.Receipt(timestamp: $0.timestamp?.date) } + } - public var date: Date { - timestamp.date - } + public var senderProfileDetails: MatrixProtocols.ProfileDetails { + senderProfile.asModel + } - public var userId: String { - sender - } + public var date: Date { + timestamp.date + } - public var displayName: String? { - if case let .ready(displayName: displayName, displayNameAmbiguous: _, avatarUrl: _) = senderProfileDetails { - return displayName - } + public var userId: String { + sender + } - return nil + public var displayName: String? { + if case .ready(displayName: let displayName, displayNameAmbiguous: _, avatarUrl: _) = + senderProfileDetails + { + return displayName } - public var avatarUrl: String? { - if case let .ready(displayName: _, displayNameAmbiguous: _, avatarUrl: avatarUrl) = senderProfileDetails { - return avatarUrl - } + return nil + } - return nil + public var avatarUrl: String? { + if case .ready(displayName: _, displayNameAmbiguous: _, avatarUrl: let avatarUrl) = + senderProfileDetails + { + return avatarUrl } + + return nil + } } extension MatrixRustSDK.EventTimelineItem: @retroactive Identifiable { - public var id: MatrixRustSDK.EventOrTransactionId { - eventOrTransactionId - } + public var id: MatrixRustSDK.EventOrTransactionId { + eventOrTransactionId + } } -public extension MatrixRustSDK.TimelineItemContent { - var userInterfaceText: String { - switch self { - case .callInvite: - return "call invite" - case let .msgLike(content: content): - return content.kind.userInterfaceText - case .rtcNotification: - return "rtc notification" - case let .roomMembership(userId: userId, userDisplayName: _, change: change, reason: reason): - return Self.roomMembershipDescription(userId: userId, change: change, reason: reason) - case let .profileChange(displayName: displayName, prevDisplayName: prevDisplayName, avatarUrl: avatarUrl, prevAvatarUrl: prevAvatarUrl): - switch (displayName, prevDisplayName, avatarUrl, prevAvatarUrl) { - case (.some(_), .some(_), .some(_), .some(_)): - return "changed their display name and avatar" - case let (.some(displayName), .some(prevDisplayName), _, _): - return "changed their display name from \(prevDisplayName) to \(displayName)" - case (_, _, .some(_), .some(_)): - return "changed their avatar" - case _: - return "unknown profile change" - } - case let .state(stateKey: _, content: content): - return content.description - case let .failedToParseMessageLike(eventType: eventType, error: error): - return "failed to parse \(eventType): \(error)" - case let .failedToParseState(eventType: eventType, stateKey: stateKey, error: error): - return "failed to parse \(eventType) \(stateKey): \(error)" - } - } - - static func roomMembershipDescription(userId: String, change: MembershipChange?, reason: String?) -> String { - let changeMsg = switch change { - case nil: - "unknown membership change event" - case .some(.none): - "room membership event was none" - case .banned: - "banned \(userId)" - case .error: - "room membership event error" - case .joined: - "joined room" - case .left: - "left the room" - case .unbanned: - "unbanned \(userId)" - case .kicked: - "kicked \(userId)" - case .invited: - "invited \(userId)" - case .kickedAndBanned: - "kicked and banned \(userId)" - case .invitationAccepted: - "accepted invitiation to join the room" - case .invitationRejected: - "rejected invitiation to join the room" - case .invitationRevoked: - "revoked invitiation for \(userId) to join the room" - case .knocked: - "requested to join the room" - case .knockAccepted: - "accepted join request from \(userId)" - case .knockRetracted: - "request to join the room was retracted" - case .knockDenied: - "denied join request from \(userId)" - case .notImplemented: - "room membership event not implemented" - } - - return if let reason { - "\(changeMsg) because \(reason)" - } else { - changeMsg - } - } +extension MatrixRustSDK.TimelineItemContent { + public var userInterfaceText: String { + switch self { + case .callInvite: + return "call invite" + case .msgLike(let content): + return content.kind.userInterfaceText + case .rtcNotification: + return "rtc notification" + case .roomMembership(let userId, userDisplayName: _, let change, let reason): + return Self.roomMembershipDescription(userId: userId, change: change, reason: reason) + case .profileChange(let displayName, let prevDisplayName, let avatarUrl, let prevAvatarUrl): + switch (displayName, prevDisplayName, avatarUrl, prevAvatarUrl) { + case (.some(_), .some(_), .some(_), .some(_)): + return "changed their display name and avatar" + case (.some(let displayName), .some(let prevDisplayName), _, _): + return "changed their display name from \(prevDisplayName) to \(displayName)" + case (_, _, .some(_), .some(_)): + return "changed their avatar" + case _: + return "unknown profile change" + } + case .state(stateKey: _, let content): + return content.description + case .failedToParseMessageLike(let eventType, let error): + return "failed to parse \(eventType): \(error)" + case .failedToParseState(let eventType, let stateKey, let error): + return "failed to parse \(eventType) \(stateKey): \(error)" + } + } + + public static func roomMembershipDescription( + userId: String, change: MembershipChange?, reason: String? + ) -> String { + let changeMsg = + switch change { + case nil: + "unknown membership change event" + case .some(.none): + "room membership event was none" + case .banned: + "banned \(userId)" + case .error: + "room membership event error" + case .joined: + "joined room" + case .left: + "left the room" + case .unbanned: + "unbanned \(userId)" + case .kicked: + "kicked \(userId)" + case .invited: + "invited \(userId)" + case .kickedAndBanned: + "kicked and banned \(userId)" + case .invitationAccepted: + "accepted invitiation to join the room" + case .invitationRejected: + "rejected invitiation to join the room" + case .invitationRevoked: + "revoked invitiation for \(userId) to join the room" + case .knocked: + "requested to join the room" + case .knockAccepted: + "accepted join request from \(userId)" + case .knockRetracted: + "request to join the room was retracted" + case .knockDenied: + "denied join request from \(userId)" + case .notImplemented: + "room membership event not implemented" + } + + return if let reason { + "\(changeMsg) because \(reason)" + } else { + changeMsg + } + } } -public extension MatrixRustSDK.MsgLikeKind { - var userInterfaceText: String { - switch self { - case let .message(content: content): - return content.body - case .sticker(body: let body, info: _, source: _): - return "Sticker: \(body)" - case .poll(question: let question, kind: _, maxSelections: _, answers: _, votes: _, endTime: _, hasBeenEdited: _): - return "Poll: \(question)" - case .redacted: - return "Redacted" - case .unableToDecrypt(msg: _): - return "Unable to decrypt" - case let .other(eventType: eventType): - return "Other: \(eventType)" - case let .liveLocation(content: content): - return "Live location: \(content.description ?? "no description")" - } - } +extension MatrixRustSDK.MsgLikeKind { + public var userInterfaceText: String { + switch self { + case .message(let content): + return content.body + case .sticker(let body, info: _, source: _): + return "Sticker: \(body)" + case .poll( + let question, kind: _, maxSelections: _, answers: _, votes: _, endTime: _, hasBeenEdited: _): + return "Poll: \(question)" + case .redacted: + return "Redacted" + case .unableToDecrypt(msg: _): + return "Unable to decrypt" + case .other(let eventType): + return "Other: \(eventType)" + case .liveLocation(let content): + return "Live location: \(content.description ?? "no description")" + } + } } -public extension MatrixRustSDK.MessageLikeEventType { - var userInterfaceText: String { - switch self { - case .callAnswer: - "call answer" - case .callCandidates: - "call candidates" - case .callHangup: - "call hangup" - case .callInvite: - "call invite" - case .rtcNotification: - "rtc notification" - case .keyVerificationAccept: - "key verification accept" - case .keyVerificationCancel: - "key verification cancel" - case .keyVerificationDone: - "key verification done" - case .keyVerificationKey: - "key verification key" - case .keyVerificationMac: - "key verification mac" - case .keyVerificationReady: - "key verification ready" - case .keyVerificationStart: - "key verification start" - case .pollEnd: - "poll end" - case .pollResponse: - "poll reponse" - case .pollStart: - "poll start" - case .reaction: - "reaction" - case .roomEncrypted: - "room encrypted" - case .roomMessage: - "room message" - case .roomRedaction: - "room redaction" - case .sticker: - "sticker" - case .unstablePollEnd: - "unstable poll end" - case .unstablePollResponse: - "unstable poll response" - case .unstablePollStart: - "unstable poll start" - case .audio: - "audio" - case .beacon: - "beacon" - case .callNegotiate: - "call negotiate" - case .callNotify: - "call notify" - case .callReject: - "call reject" - case .callSdpStreamMetadataChanged: - "call sdp stream metadata changed" - case .callSelectAnswer: - "call select answer" - case .emote: - "emote" - case .encrypted: - "encrypted" - case .file: - "file" - case .image: - "image" - case .location: - "location" - case .message: - "message" - case .rtcDecline: - "rtc decline" - case .video: - "video" - case .voice: - "voice" - case .custom: - "custom" - } - } +extension MatrixRustSDK.MessageLikeEventType { + public var userInterfaceText: String { + switch self { + case .callAnswer: + "call answer" + case .callCandidates: + "call candidates" + case .callHangup: + "call hangup" + case .callInvite: + "call invite" + case .rtcNotification: + "rtc notification" + case .keyVerificationAccept: + "key verification accept" + case .keyVerificationCancel: + "key verification cancel" + case .keyVerificationDone: + "key verification done" + case .keyVerificationKey: + "key verification key" + case .keyVerificationMac: + "key verification mac" + case .keyVerificationReady: + "key verification ready" + case .keyVerificationStart: + "key verification start" + case .pollEnd: + "poll end" + case .pollResponse: + "poll reponse" + case .pollStart: + "poll start" + case .reaction: + "reaction" + case .roomEncrypted: + "room encrypted" + case .roomMessage: + "room message" + case .roomRedaction: + "room redaction" + case .sticker: + "sticker" + case .unstablePollEnd: + "unstable poll end" + case .unstablePollResponse: + "unstable poll response" + case .unstablePollStart: + "unstable poll start" + case .audio: + "audio" + case .beacon: + "beacon" + case .callNegotiate: + "call negotiate" + case .callNotify: + "call notify" + case .callReject: + "call reject" + case .callSdpStreamMetadataChanged: + "call sdp stream metadata changed" + case .callSelectAnswer: + "call select answer" + case .emote: + "emote" + case .encrypted: + "encrypted" + case .file: + "file" + case .image: + "image" + case .location: + "location" + case .message: + "message" + case .rtcDecline: + "rtc decline" + case .video: + "video" + case .voice: + "voice" + case .custom: + "custom" + } + } } extension MatrixRustSDK.OtherState: @retroactive CustomStringConvertible { - public var description: String { - switch self { - case .policyRuleRoom: - return "changed policy rules for room" - case .policyRuleServer: - return "changed policy rules for server" - case .policyRuleUser: - return "changed policy rule for user" - case .roomAvatar(url: _): - return "changed room avatar" - case .roomCanonicalAlias: - return "changed room canonical alias" - case .roomCreate: - return "created room" - case .roomEncryption: - return "changed room encryption" - case .roomGuestAccess: - return "changed room guest access" - case .roomHistoryVisibility: - return "change room history visibility" - case .roomJoinRules: - return "changed room join rules" - case let .roomName(name: name): - return "changed room name to '\(name ?? "empty")'" - case .roomPinnedEvents(change: _): - return "changed room pinned events" - case .roomPowerLevels(events: _, previousEvents: _, users: _, previousUsers: _, thresholds: _, previousThresholds: _): - return "changed room power levels" - case .roomServerAcl: - return "changed room server acl" - case .roomThirdPartyInvite(displayName: _): - return "changed room third party invite" - case .roomTombstone: - return "room tombstone" - case let .roomTopic(topic: topic): - return "changed room topic to '\(topic ?? "none")'" - case .spaceChild: - return "changed space child" - case .spaceParent: - return "changed space parent" - case let .custom(eventType: eventType): - return "changed custom state '\(eventType)'" - } - } + public var description: String { + switch self { + case .policyRuleRoom: + return "changed policy rules for room" + case .policyRuleServer: + return "changed policy rules for server" + case .policyRuleUser: + return "changed policy rule for user" + case .roomAvatar(url: _): + return "changed room avatar" + case .roomCanonicalAlias: + return "changed room canonical alias" + case .roomCreate: + return "created room" + case .roomEncryption: + return "changed room encryption" + case .roomGuestAccess: + return "changed room guest access" + case .roomHistoryVisibility: + return "change room history visibility" + case .roomJoinRules: + return "changed room join rules" + case .roomName(let name): + return "changed room name to '\(name ?? "empty")'" + case .roomPinnedEvents(change: _): + return "changed room pinned events" + case .roomPowerLevels( + events: _, previousEvents: _, users: _, previousUsers: _, thresholds: _, previousThresholds: _ + ): + return "changed room power levels" + case .roomServerAcl: + return "changed room server acl" + case .roomThirdPartyInvite(displayName: _): + return "changed room third party invite" + case .roomTombstone: + return "room tombstone" + case .roomTopic(let topic): + return "changed room topic to '\(topic ?? "none")'" + case .spaceChild: + return "changed space child" + case .spaceParent: + return "changed space parent" + case .custom(let eventType): + return "changed custom state '\(eventType)'" + } + } } extension MatrixRustSDK.EmbeddedEventDetails { - var message: String? { - switch self { - case .unavailable: - return nil - case .pending: - return nil - case let .ready(content: content, sender: _, senderProfile: _, timestamp: _, eventOrTransactionId: _): - return content.userInterfaceText - case let .error(message: message): - return "Error: \(message)" - } - } + var message: String? { + switch self { + case .unavailable: + return nil + case .pending: + return nil + case .ready(let content, sender: _, senderProfile: _, timestamp: _, eventOrTransactionId: _): + return content.userInterfaceText + case .error(let message): + return "Error: \(message)" + } + } } extension MatrixRustSDK.ThreadSummary: MatrixProtocols.ThreadSummary { - public var description: String? { - latestEvent().message - } + public var description: String? { + latestEvent().message + } } extension MatrixRustSDK.SpaceRoom: @retroactive Identifiable { - public var id: String { - roomId - } + public var id: String { + roomId + } } extension MatrixRustSDK.EventOrTransactionId: @retroactive Identifiable { - public var id: String { - switch self { - case let .eventId(eventId): - return eventId - case let .transactionId(transactionId): - return transactionId - } - } + public var id: String { + switch self { + case .eventId(let eventId): + return eventId + case .transactionId(let transactionId): + return transactionId + } + } } extension MatrixRustSDK.UserProfile: @retroactive Identifiable, MatrixProtocols.UserProfile { - public var id: String { - userId - } + public var id: String { + userId + } } extension MatrixRustSDK.SessionVerificationEmoji: @retroactive Identifiable { - public var id: String { - description() - } + public var id: String { + description() + } } extension MatrixRustSDK.SessionVerificationEmoji: MatrixProtocols.SessionVerificationEmoji { - public var description: String { - self.description() - } + public var description: String { + self.description() + } - public var symbol: String { - self.symbol() - } + public var symbol: String { + self.symbol() + } } -public extension MatrixRustSDK.SessionVerificationData { - var asModel: MatrixProtocols.SessionVerificationData { - switch self { - case let .emojis(emojis, indices): - return .emojis(emojis: emojis, indices: indices) - case let .decimals(values): - return .decimals(values: values) - } - } +extension MatrixRustSDK.SessionVerificationData { + public var asModel: + MatrixProtocols.SessionVerificationData + { + switch self { + case .emojis(let emojis, let indices): + return .emojis(emojis: emojis, indices: indices) + case .decimals(let values): + return .decimals(values: values) + } + } } extension MatrixRustSDK.PaginationStatus: @retroactive CustomDebugStringConvertible { - public var debugDescription: String { - switch self { - case let .idle(hitTimelineStart): - "idle, hitTimelineStart=\(hitTimelineStart)" - case .paginating: - "paginating" - } - } + public var debugDescription: String { + switch self { + case .idle(let hitTimelineStart): + "idle, hitTimelineStart=\(hitTimelineStart)" + case .paginating: + "paginating" + } + } } extension MatrixRustSDK.Room: @retroactive CustomDebugStringConvertible { - public var debugDescription: String { - "room \(id())" - } + public var debugDescription: String { + "room \(id())" + } } extension MatrixRustSDK.RoomPreviewInfo: @retroactive CustomDebugStringConvertible { - public var debugDescription: String { - "preview room info: \(roomId) \(name ?? "")" - } + public var debugDescription: String { + "preview room info: \(roomId) \(name ?? "")" + } } extension MatrixRustSDK.RoomPreviewInfo: MatrixProtocols.RoomPreviewInfo { - public var userMembership: MatrixProtocols.Membership? { - switch membership { - case .joined: - return .joined - case .invited: - return .invited - case .left: - return .left - case .knocked: - return .knocked - case .banned: - return .banned - case nil: - return nil - } - } - - public var joinRuleInfo: MatrixProtocols.JoinRule? { - switch joinRule { - case .invite: - return .invite - case .knock: - return .knock - case .public: - return .public - case nil: - return nil - default: - return .other - } - } - - public var roomKind: MatrixProtocols.RoomKind { - switch roomType { - case .room: - return .room - case .space: - return .space - case let .custom(value: value): - return .custom(value: value) - } - } + public var userMembership: MatrixProtocols.Membership? { + switch membership { + case .joined: + return .joined + case .invited: + return .invited + case .left: + return .left + case .knocked: + return .knocked + case .banned: + return .banned + case nil: + return nil + } + } + + public var joinRuleInfo: MatrixProtocols.JoinRule? { + switch joinRule { + case .invite: + return .invite + case .knock: + return .knock + case .public: + return .public + case nil: + return nil + default: + return .other + } + } + + public var roomKind: MatrixProtocols.RoomKind { + switch roomType { + case .room: + return .room + case .space: + return .space + case .custom(let value): + return .custom(value: value) + } + } } extension MatrixRustSDK.RoomPreview: @retroactive Hashable { - public static func == (lhs: MatrixRustSDK.RoomPreview, rhs: MatrixRustSDK.RoomPreview) -> Bool { - lhs.info() == rhs.info() - } + public static func == (lhs: MatrixRustSDK.RoomPreview, rhs: MatrixRustSDK.RoomPreview) -> Bool { + lhs.info() == rhs.info() + } - public func hash(into hasher: inout Hasher) { - hasher.combine(info()) - } + public func hash(into hasher: inout Hasher) { + hasher.combine(info()) + } } diff --git a/Sources/MatrixIntegration/Extensions/MatrixRustSDK+MessageContent.swift b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+MessageContent.swift index 2b29bb0..349cf04 100644 --- a/Sources/MatrixIntegration/Extensions/MatrixRustSDK+MessageContent.swift +++ b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+MessageContent.swift @@ -1,15 +1,15 @@ import MatrixRustSDK public protocol MessageContent { - var body: String { get } - var formatted: FormattedBody? { get } + var body: String { get } + var formatted: FormattedBody? { get } } public protocol MediaMessageContent { - var filename: String { get } - var caption: String? { get } - var formattedCaption: FormattedBody? { get } - var source: MediaSource { get } + var filename: String { get } + var caption: String? { get } + var formattedCaption: FormattedBody? { get } + var source: MediaSource { get } } extension FileMessageContent: MediaMessageContent {} diff --git a/Sources/MatrixIntegration/HomeserverLogin.swift b/Sources/MatrixIntegration/HomeserverLogin.swift index 9effdff..daf964e 100644 --- a/Sources/MatrixIntegration/HomeserverLogin.swift +++ b/Sources/MatrixIntegration/HomeserverLogin.swift @@ -5,58 +5,71 @@ import OSLog import SwiftUI public struct HomeserverLogin { - public let storeID: String - private let storePassphrase: String - public let unauthenticatedClient: ClientProtocol - public let loginDetails: HomeserverLoginDetailsProtocol - - init(storeID: String, storePassphrase: String, unauthenticatedClient: ClientProtocol, loginDetails: HomeserverLoginDetailsProtocol) { - self.storeID = storeID - self.storePassphrase = storePassphrase - self.unauthenticatedClient = unauthenticatedClient - self.loginDetails = loginDetails - } + public let storeID: String + private let storePassphrase: String + public let unauthenticatedClient: ClientProtocol + public let loginDetails: HomeserverLoginDetailsProtocol - @MainActor - public func loginPassword(homeServer _: String, username: String, password: String) async throws -> MatrixClient { - // Login using password authentication. - try await unauthenticatedClient.login(username: username, password: password, initialDeviceName: "Mactrix", deviceId: nil) - return try await onSuccessfullLogin() - } + init( + storeID: String, storePassphrase: String, unauthenticatedClient: ClientProtocol, + loginDetails: HomeserverLoginDetailsProtocol + ) { + self.storeID = storeID + self.storePassphrase = storePassphrase + self.unauthenticatedClient = unauthenticatedClient + self.loginDetails = loginDetails + } - private var oauthConfiguration: OAuthConfiguration { - // redirect uri must be reverse domain of client uri - OAuthConfiguration(clientName: "Mactrix", redirectUri: "com.github:/", clientUri: "https://github.com/viktorstrate/mactrix", logoUri: nil, tosUri: nil, policyUri: nil, staticRegistrations: [:]) - } + @MainActor + public func loginPassword(homeServer _: String, username: String, password: String) async throws + -> MatrixClient + { + // Login using password authentication. + try await unauthenticatedClient.login( + username: username, password: password, initialDeviceName: "Mactrix", deviceId: nil) + return try await onSuccessfullLogin() + } - @MainActor - public func loginOidc(webAuthSession: WebAuthenticationSession) async throws -> MatrixClient { - Logger.matrixClient.debug("login oidc begin") - let authInfo = try await unauthenticatedClient.urlForOauth(oauthConfiguration: oauthConfiguration, prompt: .login, loginHint: nil, deviceId: nil, additionalScopes: nil) - let url = URL(string: authInfo.loginUrl())! + private var oauthConfiguration: OAuthConfiguration { + // redirect uri must be reverse domain of client uri + OAuthConfiguration( + clientName: "Mactrix", redirectUri: "com.github:/", + clientUri: "https://github.com/viktorstrate/mactrix", logoUri: nil, tosUri: nil, + policyUri: nil, staticRegistrations: [:]) + } - Logger.matrixClient.debug("Auth url: \(url, privacy: .sensitive)") + @MainActor + public func loginOidc(webAuthSession: WebAuthenticationSession) async throws -> MatrixClient { + Logger.matrixClient.debug("login oidc begin") + let authInfo = try await unauthenticatedClient.urlForOauth( + oauthConfiguration: oauthConfiguration, prompt: .login, loginHint: nil, deviceId: nil, + additionalScopes: nil) + let url = URL(string: authInfo.loginUrl())! - let callbackUrl = try await webAuthSession.authenticate(using: url, callback: .customScheme("com.github"), additionalHeaderFields: [:]) + Logger.matrixClient.debug("Auth url: \(url, privacy: .sensitive)") - Logger.matrixClient.debug("after sign in") + let callbackUrl = try await webAuthSession.authenticate( + using: url, callback: .customScheme("com.github"), additionalHeaderFields: [:]) - try await unauthenticatedClient.loginWithOauthCallback(callbackUrl: callbackUrl.absoluteString) + Logger.matrixClient.debug("after sign in") - return try await onSuccessfullLogin() - } + try await unauthenticatedClient.loginWithOauthCallback(callbackUrl: callbackUrl.absoluteString) - @MainActor - fileprivate func onSuccessfullLogin() async throws -> MatrixClient { - let matrixClient = await MatrixClient(storeID: storeID, storePassphrase: storePassphrase, client: unauthenticatedClient) + return try await onSuccessfullLogin() + } - let userSession = try matrixClient.userSession() - do { - try userSession.saveUserToKeychain() - } catch { - print(error.localizedDescription) - } + @MainActor + fileprivate func onSuccessfullLogin() async throws -> MatrixClient { + let matrixClient = await MatrixClient( + storeID: storeID, storePassphrase: storePassphrase, client: unauthenticatedClient) - return matrixClient + let userSession = try matrixClient.userSession() + do { + try userSession.saveUserToKeychain() + } catch { + print(error.localizedDescription) } + + return matrixClient + } } diff --git a/Sources/MatrixIntegration/LiveRoom.swift b/Sources/MatrixIntegration/LiveRoom.swift index 8ef9b3d..60a9144 100644 --- a/Sources/MatrixIntegration/LiveRoom.swift +++ b/Sources/MatrixIntegration/LiveRoom.swift @@ -1,106 +1,106 @@ import Foundation -import MatrixRustSDK import MatrixProtocols +import MatrixRustSDK import OSLog @MainActor @Observable public final class LiveRoom: Identifiable { - let sidebarRoom: SidebarRoom + let sidebarRoom: SidebarRoom - public var typingUserIds: [String] = [] - public var members: [MatrixRustSDK.RoomMember] = [] + public var typingUserIds: [String] = [] + public var members: [MatrixRustSDK.RoomMember] = [] - @ObservationIgnored private var typingHandle: TaskHandle? + @ObservationIgnored private var typingHandle: TaskHandle? - public var room: MatrixRustSDK.Room { - sidebarRoom.room - } + public var room: MatrixRustSDK.Room { + sidebarRoom.room + } - public var roomInfo: MatrixRustSDK.RoomInfo? { - sidebarRoom.roomInfo - } + public var roomInfo: MatrixRustSDK.RoomInfo? { + sidebarRoom.roomInfo + } - public nonisolated var id: String { - sidebarRoom.id - } + public nonisolated var id: String { + sidebarRoom.id + } - public init(sidebarRoom: SidebarRoom) { - self.sidebarRoom = sidebarRoom + public init(sidebarRoom: SidebarRoom) { + self.sidebarRoom = sidebarRoom - startListening() + startListening() - Task(priority: .utility) { - do { - try await syncMembers() - } catch { - Logger.liveRoom.error("failed to sync room members: \(error)") - } - } + Task(priority: .utility) { + do { + try await syncMembers() + } catch { + Logger.liveRoom.error("failed to sync room members: \(error)") + } } + } - public convenience init(matrixRoom: MatrixRustSDK.Room) { - self.init(sidebarRoom: SidebarRoom(room: matrixRoom)) - } + public convenience init(matrixRoom: MatrixRustSDK.Room) { + self.init(sidebarRoom: SidebarRoom(room: matrixRoom)) + } - isolated deinit { - Logger.matrixClient.info("live room deinit") - } + isolated deinit { + Logger.matrixClient.info("live room deinit") + } - fileprivate func startListening() { - Logger.matrixClient.info("typing indicator start listening") + fileprivate func startListening() { + Logger.matrixClient.info("typing indicator start listening") - let listener = AsyncSDKListener<[String]>() - typingHandle = room.subscribeToTypingNotifications(listener: listener) + let listener = AsyncSDKListener<[String]>() + typingHandle = room.subscribeToTypingNotifications(listener: listener) - Task { [weak self] in - for await typingUserIds in listener { - guard let self else { break } - Logger.matrixClient.info("typing indicator updating UI") - self.typingUserIds = typingUserIds - } - } + Task { [weak self] in + for await typingUserIds in listener { + guard let self else { break } + Logger.matrixClient.info("typing indicator updating UI") + self.typingUserIds = typingUserIds + } } + } + + public func syncMembers() async throws { + let id = self.id + Logger.liveRoom.debug("syncing members for room: \(id)") - public func syncMembers() async throws { - let id = self.id - Logger.liveRoom.debug("syncing members for room: \(id)") - - // Get the locally cached members first - let membersNoSyncIter = try await room.membersNoSync() - if let result = membersNoSyncIter.nextChunk(chunkSize: membersNoSyncIter.len()) { - members = result - Logger.liveRoom.debug("loaded \(result.count) members locally for room \(id)") - } - - // Fetch the latest members from the homeserver, this gets the latest member list. - let memberIter = try await room.members() - if let result = memberIter.nextChunk(chunkSize: memberIter.len()) { - members = result - Logger.liveRoom.debug("synced \(result.count) members for room \(id)") - } + // Get the locally cached members first + let membersNoSyncIter = try await room.membersNoSync() + if let result = membersNoSyncIter.nextChunk(chunkSize: membersNoSyncIter.len()) { + members = result + Logger.liveRoom.debug("loaded \(result.count) members locally for room \(id)") } + + // Fetch the latest members from the homeserver, this gets the latest member list. + let memberIter = try await room.members() + if let result = memberIter.nextChunk(chunkSize: memberIter.len()) { + members = result + Logger.liveRoom.debug("synced \(result.count) members for room \(id)") + } + } } extension LiveRoom: Hashable { - public nonisolated static func == (lhs: LiveRoom, rhs: LiveRoom) -> Bool { - lhs.id == rhs.id - } + public nonisolated static func == (lhs: LiveRoom, rhs: LiveRoom) -> Bool { + lhs.id == rhs.id + } - public nonisolated func hash(into hasher: inout Hasher) { - hasher.combine(id) - } + public nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(id) + } } extension LiveRoom: @MainActor MatrixProtocols.Room { - public var displayName: String? { - room.displayName() - } + public var displayName: String? { + room.displayName() + } - public var topic: String? { - room.topic() - } + public var topic: String? { + room.topic() + } - public var encryptionState: MatrixProtocols.EncryptionState { - room.encryptionState().asModel - } + public var encryptionState: MatrixProtocols.EncryptionState { + room.encryptionState().asModel + } } diff --git a/Sources/MatrixIntegration/LiveRoomSearch.swift b/Sources/MatrixIntegration/LiveRoomSearch.swift index fc1e525..2e4e18f 100644 --- a/Sources/MatrixIntegration/LiveRoomSearch.swift +++ b/Sources/MatrixIntegration/LiveRoomSearch.swift @@ -5,63 +5,63 @@ import OSLog @MainActor @Observable public class LiveRoomSearch { - public let roomDirectorySearch: RoomDirectorySearchProtocol + public let roomDirectorySearch: RoomDirectorySearchProtocol - @ObservationIgnored fileprivate var resultsHandle: TaskHandle? + @ObservationIgnored fileprivate var resultsHandle: TaskHandle? - public var rooms: [RoomDescription] = [] + public var rooms: [RoomDescription] = [] - public init(roomDirectorySearch: RoomDirectorySearchProtocol) async { - self.roomDirectorySearch = roomDirectorySearch - await listenToRoomResults() - } + public init(roomDirectorySearch: RoomDirectorySearchProtocol) async { + self.roomDirectorySearch = roomDirectorySearch + await listenToRoomResults() + } - deinit { - Logger.matrixClient.info("LiveRoomSearch deinit") - } + deinit { + Logger.matrixClient.info("LiveRoomSearch deinit") + } - private func listenToRoomResults() async { - Logger.matrixClient.info("room search start listening") + private func listenToRoomResults() async { + Logger.matrixClient.info("room search start listening") - let listener = AsyncSDKListener<[RoomDirectorySearchEntryUpdate]>() - resultsHandle = await roomDirectorySearch.results(listener: listener) + let listener = AsyncSDKListener<[RoomDirectorySearchEntryUpdate]>() + resultsHandle = await roomDirectorySearch.results(listener: listener) - Task { [weak self] in - for await roomEntriesUpdate in listener { - guard let self else { break } + Task { [weak self] in + for await roomEntriesUpdate in listener { + guard let self else { break } - Logger.matrixClient.info("room search updating UI") - for update in roomEntriesUpdate { - switch update { - case let .append(values): - self.rooms.append(contentsOf: values) - case .clear: - self.rooms.removeAll() - case let .pushFront(room): - self.rooms.insert(room, at: 0) - case let .pushBack(room): - self.rooms.append(room) - case .popFront: - self.rooms.removeFirst() - case .popBack: - self.rooms.removeLast() - case let .insert(index, room): - self.rooms.insert(room, at: Int(index)) - case let .set(index, room): - self.rooms[Int(index)] = room - case let .remove(index): - self.rooms.remove(at: Int(index)) - case let .truncate(length): - self.rooms.removeSubrange(Int(length) ..< self.rooms.count) - case let .reset(values: values): - self.rooms = values - } - } - } + Logger.matrixClient.info("room search updating UI") + for update in roomEntriesUpdate { + switch update { + case .append(let values): + self.rooms.append(contentsOf: values) + case .clear: + self.rooms.removeAll() + case .pushFront(let room): + self.rooms.insert(room, at: 0) + case .pushBack(let room): + self.rooms.append(room) + case .popFront: + self.rooms.removeFirst() + case .popBack: + self.rooms.removeLast() + case .insert(let index, let room): + self.rooms.insert(room, at: Int(index)) + case .set(let index, let room): + self.rooms[Int(index)] = room + case .remove(let index): + self.rooms.remove(at: Int(index)) + case .truncate(let length): + self.rooms.removeSubrange(Int(length)..() - spaceHandle = spaceRoomList.subscribeToSpaceUpdates(listener: spaceListener) + private func listenToSpaceRoom() { + let spaceListener = AsyncSDKListener() + spaceHandle = spaceRoomList.subscribeToSpaceUpdates(listener: spaceListener) - Task { [weak self] in - for await space in spaceListener.debounce(for: .milliseconds(500)) { - guard let self else { break } - self.space = space - } - } + Task { [weak self] in + for await space in spaceListener.debounce(for: .milliseconds(500)) { + guard let self else { break } + self.space = space + } } + } - private func listenToRooms() async { - let roomsListener = AsyncSDKListener<[SpaceListUpdate]>() - roomsHandle = await spaceRoomList.subscribeToRoomUpdate(listener: roomsListener) + private func listenToRooms() async { + let roomsListener = AsyncSDKListener<[SpaceListUpdate]>() + roomsHandle = await spaceRoomList.subscribeToRoomUpdate(listener: roomsListener) - Task { [weak self] in - for await roomUpdates in roomsListener { - guard let self else { return } + Task { [weak self] in + for await roomUpdates in roomsListener { + guard let self else { return } - for update in roomUpdates { - switch update { - case let .append(values): - self.rooms.append(contentsOf: values.map { SidebarSpaceRoom(spaceService: self.spaceService, spaceRoom: $0) }) - case .clear: - self.rooms.removeAll() - case let .pushFront(room): - self.rooms.insert(SidebarSpaceRoom(spaceService: self.spaceService, spaceRoom: room), at: 0) - case let .pushBack(room): - self.rooms.append(SidebarSpaceRoom(spaceService: self.spaceService, spaceRoom: room)) - case .popFront: - self.rooms.removeFirst() - case .popBack: - self.rooms.removeLast() - case let .insert(index, room): - self.rooms.insert(SidebarSpaceRoom(spaceService: self.spaceService, spaceRoom: room), at: Int(index)) - case let .set(index, room): - self.rooms[Int(index)] = SidebarSpaceRoom(spaceService: self.spaceService, spaceRoom: room) - case let .remove(index): - self.rooms.remove(at: Int(index)) - case let .truncate(length): - self.rooms.removeSubrange(Int(length) ..< self.rooms.count) - case let .reset(values: values): - self.rooms = values.map { SidebarSpaceRoom(spaceService: self.spaceService, spaceRoom: $0) } - } - } + for update in roomUpdates { + switch update { + case .append(let values): + self.rooms.append( + contentsOf: values.map { + SidebarSpaceRoom(spaceService: self.spaceService, spaceRoom: $0) + }) + case .clear: + self.rooms.removeAll() + case .pushFront(let room): + self.rooms.insert( + SidebarSpaceRoom(spaceService: self.spaceService, spaceRoom: room), at: 0) + case .pushBack(let room): + self.rooms.append(SidebarSpaceRoom(spaceService: self.spaceService, spaceRoom: room)) + case .popFront: + self.rooms.removeFirst() + case .popBack: + self.rooms.removeLast() + case .insert(let index, let room): + self.rooms.insert( + SidebarSpaceRoom(spaceService: self.spaceService, spaceRoom: room), at: Int(index)) + case .set(let index, let room): + self.rooms[Int(index)] = SidebarSpaceRoom( + spaceService: self.spaceService, spaceRoom: room) + case .remove(let index): + self.rooms.remove(at: Int(index)) + case .truncate(let length): + self.rooms.removeSubrange(Int(length)..() - paginateHandle = spaceRoomList.subscribeToPaginationStateUpdates(listener: paginateListener) + private func listenToPagination() { + let paginateListener = AsyncSDKListener() + paginateHandle = spaceRoomList.subscribeToPaginationStateUpdates(listener: paginateListener) - Task { [weak self] in - for await state in paginateListener.debounce(for: .milliseconds(500)) { - self?.paginationState = state - } - } + Task { [weak self] in + for await state in paginateListener.debounce(for: .milliseconds(500)) { + self?.paginationState = state + } } + } - public func loadChildRooms() async { - do { - try await spaceRoomList.paginate() - } catch { - Logger.liveSpaceRoomList.error("Failed to paginate space list: \(error)") - } + public func loadChildRooms() async { + do { + try await spaceRoomList.paginate() + } catch { + Logger.liveSpaceRoomList.error("Failed to paginate space list: \(error)") } + } } diff --git a/Sources/MatrixIntegration/LiveSpaceService.swift b/Sources/MatrixIntegration/LiveSpaceService.swift index ca984cc..e5fdace 100644 --- a/Sources/MatrixIntegration/LiveSpaceService.swift +++ b/Sources/MatrixIntegration/LiveSpaceService.swift @@ -5,58 +5,60 @@ import OSLog @MainActor @Observable public final class LiveSpaceService { - public let spaceService: SpaceService + public let spaceService: SpaceService - public var spaceRooms: [SidebarSpaceRoom] = [] + public var spaceRooms: [SidebarSpaceRoom] = [] - @ObservationIgnored private var spaceHandle: TaskHandle? + @ObservationIgnored private var spaceHandle: TaskHandle? - public init(spaceService: SpaceService) { - self.spaceService = spaceService + public init(spaceService: SpaceService) { + self.spaceService = spaceService - Task { - await self.listenToJoinedSpaces() + Task { + await self.listenToJoinedSpaces() - let joinedSpaces = await spaceService.topLevelJoinedSpaces() - Logger.liveSpaceService.debug("Joined spaces: \(joinedSpaces)") - } + let joinedSpaces = await spaceService.topLevelJoinedSpaces() + Logger.liveSpaceService.debug("Joined spaces: \(joinedSpaces)") } + } + + private func listenToJoinedSpaces() async { + let listener = AsyncSDKListener<[SpaceListUpdate]>() + self.spaceHandle = await self.spaceService.subscribeToTopLevelJoinedSpaces(listener: listener) + + Task { [weak self] in + for await roomUpdates in listener { + guard let self else { break } - private func listenToJoinedSpaces() async { - let listener = AsyncSDKListener<[SpaceListUpdate]>() - self.spaceHandle = await self.spaceService.subscribeToTopLevelJoinedSpaces(listener: listener) - - Task { [weak self] in - for await roomUpdates in listener { - guard let self else { break } - - for update in roomUpdates { - switch update { - case let .append(values): - self.spaceRooms.append(contentsOf: values.map { SidebarSpaceRoom(spaceService: self, spaceRoom: $0) }) - case .clear: - self.spaceRooms.removeAll() - case let .pushFront(room): - self.spaceRooms.insert(SidebarSpaceRoom(spaceService: self, spaceRoom: room), at: 0) - case let .pushBack(room): - self.spaceRooms.append(SidebarSpaceRoom(spaceService: self, spaceRoom: room)) - case .popFront: - self.spaceRooms.removeFirst() - case .popBack: - self.spaceRooms.removeLast() - case let .insert(index, room): - self.spaceRooms.insert(SidebarSpaceRoom(spaceService: self, spaceRoom: room), at: Int(index)) - case let .set(index, room): - self.spaceRooms[Int(index)] = SidebarSpaceRoom(spaceService: self, spaceRoom: room) - case let .remove(index): - self.spaceRooms.remove(at: Int(index)) - case let .truncate(length): - self.spaceRooms.removeSubrange(Int(length) ..< self.spaceRooms.count) - case let .reset(values: values): - self.spaceRooms = values.map { SidebarSpaceRoom(spaceService: self, spaceRoom: $0) } - } - } - } + for update in roomUpdates { + switch update { + case .append(let values): + self.spaceRooms.append( + contentsOf: values.map { SidebarSpaceRoom(spaceService: self, spaceRoom: $0) }) + case .clear: + self.spaceRooms.removeAll() + case .pushFront(let room): + self.spaceRooms.insert(SidebarSpaceRoom(spaceService: self, spaceRoom: room), at: 0) + case .pushBack(let room): + self.spaceRooms.append(SidebarSpaceRoom(spaceService: self, spaceRoom: room)) + case .popFront: + self.spaceRooms.removeFirst() + case .popBack: + self.spaceRooms.removeLast() + case .insert(let index, let room): + self.spaceRooms.insert( + SidebarSpaceRoom(spaceService: self, spaceRoom: room), at: Int(index)) + case .set(let index, let room): + self.spaceRooms[Int(index)] = SidebarSpaceRoom(spaceService: self, spaceRoom: room) + case .remove(let index): + self.spaceRooms.remove(at: Int(index)) + case .truncate(let length): + self.spaceRooms.removeSubrange(Int(length)..() + timelineHandle = await timeline.addListener(listener: listener) - await listenToTimelineChanges() - - do { - try await listenToPaginationStatus(threadId: threadId) - } catch { - Logger.liveTimeline.error("Failed to listen to pagination status: \(error)") - } + Task { [weak self] in + for await diff in listener { + guard let self else { break } + updateTimeline(diff: diff) + } } - - private func listenToTimelineChanges() async { - guard let timeline else { return } - - let listener = AsyncSDKListener<[TimelineDiff]>() - timelineHandle = await timeline.addListener(listener: listener) - - Task { [weak self] in - for await diff in listener { - guard let self else { break } - updateTimeline(diff: diff) - } - } + } + + private func listenToPaginationStatus(threadId: String?) async throws { + guard let timeline else { return } + + let listener = AsyncSDKListener() + // Only main timelines can subscibe to back pagination status + if threadId == nil { + paginateHandle = try await timeline.subscribeToBackPaginationStatus(listener: listener) + } else { + // if in a thread, instead push one initial status manually to kick off message fetching + listener.publishValue(.idle(hitTimelineStart: false)) } - private func listenToPaginationStatus(threadId: String?) async throws { - guard let timeline else { return } - - let listener = AsyncSDKListener() - // Only main timelines can subscibe to back pagination status - if threadId == nil { - paginateHandle = try await timeline.subscribeToBackPaginationStatus(listener: listener) - } else { - // if in a thread, instead push one initial status manually to kick off message fetching - listener.publishValue(.idle(hitTimelineStart: false)) - } - - Task { [weak self] in - for await status in listener { - guard let self else { break } + Task { [weak self] in + for await status in listener { + guard let self else { break } - Logger.liveTimeline.debug("updating timeline paginating: \(status.debugDescription)") - paginating = status + Logger.liveTimeline.debug("updating timeline paginating: \(status.debugDescription)") + paginating = status - if paginating == .idle(hitTimelineStart: false) && timelineItems.count < 100 { - await fetchOlderMessages() - } - } + if paginating == .idle(hitTimelineStart: false) && timelineItems.count < 100 { + await fetchOlderMessages() } + } } + } - public func fetchOlderMessages() async { - guard !fetchingOlderMessages else { - Logger.liveTimeline.debug("fetchOlderMessages deferred, already fetching messages") - fetchMessagesAgain = true - return - } - guard !hitTimelineStart else { - Logger.liveTimeline.debug("fetchOlderMessages cancelled, timeline start already reached") - return - } + public func fetchOlderMessages() async { + guard !fetchingOlderMessages else { + Logger.liveTimeline.debug("fetchOlderMessages deferred, already fetching messages") + fetchMessagesAgain = true + return + } + guard !hitTimelineStart else { + Logger.liveTimeline.debug("fetchOlderMessages cancelled, timeline start already reached") + return + } - Logger.liveTimeline.info("fetch more messages") + Logger.liveTimeline.info("fetch more messages") - do { - _ = try await timeline?.paginateBackwards(numEvents: 100) - fetchingOlderMessages = false + do { + _ = try await timeline?.paginateBackwards(numEvents: 100) + fetchingOlderMessages = false - if fetchMessagesAgain { - fetchMessagesAgain = false - await fetchOlderMessages() - } - } catch { - Logger.liveTimeline.error("Failed to paginate backwards: \(error)") - } + if fetchMessagesAgain { + fetchMessagesAgain = false + await fetchOlderMessages() + } + } catch { + Logger.liveTimeline.error("Failed to paginate backwards: \(error)") } + } - public func focusEvent(id eventId: EventOrTransactionId) { - Logger.liveTimeline.info("focus event: \(eventId.id)") - focusDelegate?.focusTimelineEvent(id: eventId) - } + public func focusEvent(id eventId: EventOrTransactionId) { + Logger.liveTimeline.info("focus event: \(eventId.id)") + focusDelegate?.focusTimelineEvent(id: eventId) + } } extension LiveTimeline { - private func updateTimeline(diff: [TimelineDiff]) { - for update in diff { - switch update { - case let .append(values): - timelineItems.append(contentsOf: values) - case .clear: - timelineItems.removeAll() - case let .pushFront(room): - timelineItems.insert(room, at: 0) - case let .pushBack(room): - timelineItems.append(room) - case .popFront: - timelineItems.removeFirst() - case .popBack: - timelineItems.removeLast() - case let .insert(index, room): - timelineItems.insert(room, at: Int(index)) - case let .set(index, room): - timelineItems[Int(index)] = room - case let .remove(index): - timelineItems.remove(at: Int(index)) - case let .truncate(length): - timelineItems.removeSubrange(Int(length) ..< timelineItems.count) - case let .reset(values: values): - timelineItems = values - } - } - - if timelineItems.first?.asVirtual() == .timelineStart { - hitTimelineStart = true - } + private func updateTimeline(diff: [TimelineDiff]) { + for update in diff { + switch update { + case .append(let values): + timelineItems.append(contentsOf: values) + case .clear: + timelineItems.removeAll() + case .pushFront(let room): + timelineItems.insert(room, at: 0) + case .pushBack(let room): + timelineItems.append(room) + case .popFront: + timelineItems.removeFirst() + case .popBack: + timelineItems.removeLast() + case .insert(let index, let room): + timelineItems.insert(room, at: Int(index)) + case .set(let index, let room): + timelineItems[Int(index)] = room + case .remove(let index): + timelineItems.remove(at: Int(index)) + case .truncate(let length): + timelineItems.removeSubrange(Int(length).. Bool { - lhs.room.id == rhs.room.id && lhs.focusedThreadId == rhs.focusedThreadId - } + public nonisolated static func == (lhs: LiveTimeline, rhs: LiveTimeline) -> Bool { + lhs.room.id == rhs.room.id && lhs.focusedThreadId == rhs.focusedThreadId + } } diff --git a/Sources/MatrixIntegration/MatrixClient+Listeners.swift b/Sources/MatrixIntegration/MatrixClient+Listeners.swift index db133ff..942275a 100644 --- a/Sources/MatrixIntegration/MatrixClient+Listeners.swift +++ b/Sources/MatrixIntegration/MatrixClient+Listeners.swift @@ -3,148 +3,154 @@ import MatrixRustSDK import OSLog extension MatrixClient { - func updateRoomEntries(roomEntriesUpdate: [RoomListEntriesUpdate]) { - for update in roomEntriesUpdate { - switch update { - case let .append(values): - self.rooms.append(contentsOf: values.map(SidebarRoom.init(room:))) - case .clear: - self.rooms.removeAll() - case let .pushFront(room): - self.rooms.insert(SidebarRoom(room: room), at: 0) - case let .pushBack(room): - self.rooms.append(SidebarRoom(room: room)) - case .popFront: - self.rooms.removeFirst() - case .popBack: - self.rooms.removeLast() - case let .insert(index, room): - self.rooms.insert(SidebarRoom(room: room), at: Int(index)) - case let .set(index, room): - let existing = self.rooms[Int(index)] - if existing.id == room.id() { - existing.updateRoom(room) - } else { - self.rooms[Int(index)] = SidebarRoom(room: room) - } - case let .remove(index): - self.rooms.remove(at: Int(index)) - case let .truncate(length): - self.rooms.removeSubrange(Int(length) ..< self.rooms.count) - case let .reset(values: values): - let existingById = Dictionary(self.rooms.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) - self.rooms = values.map { room in - if let existing = existingById[room.id()] { - existing.updateRoom(room) - return existing - } - return SidebarRoom(room: room) - } - } + func updateRoomEntries(roomEntriesUpdate: [RoomListEntriesUpdate]) { + for update in roomEntriesUpdate { + switch update { + case .append(let values): + self.rooms.append(contentsOf: values.map(SidebarRoom.init(room:))) + case .clear: + self.rooms.removeAll() + case .pushFront(let room): + self.rooms.insert(SidebarRoom(room: room), at: 0) + case .pushBack(let room): + self.rooms.append(SidebarRoom(room: room)) + case .popFront: + self.rooms.removeFirst() + case .popBack: + self.rooms.removeLast() + case .insert(let index, let room): + self.rooms.insert(SidebarRoom(room: room), at: Int(index)) + case .set(let index, let room): + let existing = self.rooms[Int(index)] + if existing.id == room.id() { + existing.updateRoom(room) + } else { + self.rooms[Int(index)] = SidebarRoom(room: room) } + case .remove(let index): + self.rooms.remove(at: Int(index)) + case .truncate(let length): + self.rooms.removeSubrange(Int(length).. UserSession { - return try UserSession(session: client.session(), storeID: storeID, storePassphrase: storePassphrase) - } - - static func clientBuilder(homeServer: String, storeId: String, storePassphrase: String) -> ClientBuilder { - let sqliteConfig = SqliteStoreBuilder( - dataPath: URL.sessionData(for: storeId).path(percentEncoded: false), - cachePath: URL.sessionCaches(for: storeId).path(percentEncoded: false) - ) - .passphrase(passphrase: storePassphrase) - - return ClientBuilder() - .serverNameOrHomeserverUrl(serverNameOrUrl: homeServer) - .sqliteStore(config: sqliteConfig) - .slidingSyncVersionBuilder(versionBuilder: .discoverNative) - .threadsEnabled(enabled: true, threadSubscriptions: true) - .autoEnableCrossSigning(autoEnableCrossSigning: true) - .userAgent(userAgent: "Mactrix macOS") - } - - struct SecureRandomBytesError: LocalizedError { - let code: Int32 - - var errorDescription: String? { - "Failed to generate secure bytes with status code \(code)" - } - } - - static func generateStorePassphrase() throws -> String { - var result = [UInt8](repeating: UInt8.random(in: 0 ..< UInt8.max), count: 32) - let status = unsafe SecRandomCopyBytes(kSecRandomDefault, result.count, &result) - if status != errSecSuccess { - throw SecureRandomBytesError(code: status) - } - - return Data(result).base64EncodedString() - } - - public static func loginDetails(homeServer: String) async throws -> HomeserverLogin { - let storeID = UUID().uuidString - let storePassphrase = try Self.generateStorePassphrase() - - let client = try await Self.clientBuilder(homeServer: homeServer, storeId: storeID, storePassphrase: storePassphrase).build() - - let details = await client.homeserverLoginDetails() - return HomeserverLogin(storeID: storeID, storePassphrase: storePassphrase, unauthenticatedClient: client, loginDetails: details) + public let storeID: String + private let storePassphrase: String + public internal(set) var client: ClientProtocol! + + public internal(set) var rooms: [SidebarRoom] = [] + + public internal(set) var spaceService: LiveSpaceService! + + private var clientDelegateHandle: TaskHandle? + public internal(set) var authenticationFailed: Bool = false + + public let notifications: MatrixNotifications = .init() + + init(userSession: UserSession) async throws { + storeID = userSession.storeID + storePassphrase = userSession.storePassphrase + + client = try await Self.clientBuilder( + homeServer: userSession.homeserverURL, storeId: storeID, storePassphrase: storePassphrase + ) + // .enableOidcRefreshLock() + .setSessionDelegate(sessionDelegate: self) + .build() + + spaceService = LiveSpaceService(spaceService: await client.spaceService()) + clientDelegateHandle = try? client.setDelegate(delegate: self) + } + + init(storeID: String, storePassphrase: String, client: ClientProtocol) async { + self.storeID = storeID + self.storePassphrase = storePassphrase + self.client = client + + spaceService = LiveSpaceService(spaceService: await client.spaceService()) + clientDelegateHandle = try? self.client.setDelegate(delegate: self) + } + + func userSession() throws -> UserSession { + return try UserSession( + session: client.session(), storeID: storeID, storePassphrase: storePassphrase) + } + + static func clientBuilder(homeServer: String, storeId: String, storePassphrase: String) + -> ClientBuilder + { + let sqliteConfig = SqliteStoreBuilder( + dataPath: URL.sessionData(for: storeId).path(percentEncoded: false), + cachePath: URL.sessionCaches(for: storeId).path(percentEncoded: false) + ) + .passphrase(passphrase: storePassphrase) + + return ClientBuilder() + .serverNameOrHomeserverUrl(serverNameOrUrl: homeServer) + .sqliteStore(config: sqliteConfig) + .slidingSyncVersionBuilder(versionBuilder: .discoverNative) + .threadsEnabled(enabled: true, threadSubscriptions: true) + .autoEnableCrossSigning(autoEnableCrossSigning: true) + .userAgent(userAgent: "Mactrix macOS") + } + + struct SecureRandomBytesError: LocalizedError { + let code: Int32 + + var errorDescription: String? { + "Failed to generate secure bytes with status code \(code)" } + } - public static func attemptRestore() async throws -> MatrixClient? { - guard let userSession = try UserSession.loadUserFromKeychain() else { return nil } - - let matrixClient = try await MatrixClient(userSession: userSession) - - // Restore the client using the session. - try await matrixClient.client.restoreSession(session: userSession.session) - - return matrixClient + static func generateStorePassphrase() throws -> String { + var result = [UInt8](repeating: UInt8.random(in: 0.. HomeserverLogin { + let storeID = UUID().uuidString + let storePassphrase = try Self.generateStorePassphrase() + + let client = try await Self.clientBuilder( + homeServer: homeServer, storeId: storeID, storePassphrase: storePassphrase + ).build() + + let details = await client.homeserverLoginDetails() + return HomeserverLogin( + storeID: storeID, storePassphrase: storePassphrase, unauthenticatedClient: client, + loginDetails: details) + } + + public static func attemptRestore() async throws -> MatrixClient? { + guard let userSession = try UserSession.loadUserFromKeychain() else { return nil } + + let matrixClient = try await MatrixClient(userSession: userSession) + + // Restore the client using the session. + try await matrixClient.client.restoreSession(session: userSession.session) + + return matrixClient + } + + public func reset() async throws { + Logger.matrixClient.debug("matrix client sign out") + try? await client.logout() + try? FileManager.default.removeItem(at: .sessionData(for: storeID)) + try? FileManager.default.removeItem(at: .sessionCaches(for: storeID)) + try AppKeychain().removeAll() + Logger.matrixClient.debug("matrix client sign out complete") + } + + public internal(set) var syncService: SyncService? + public internal(set) var syncState: SyncServiceState = .terminated + public internal(set) var roomListService: RoomListService? + public internal(set) var roomListServiceState: RoomListServiceState? + public internal(set) var showRoomSyncIndicator: RoomListServiceSyncIndicator? + public internal(set) var ignoredUserIds: [String] = [] + + @ObservationIgnored fileprivate var roomListEntriesHandle: + RoomListEntriesWithDynamicAdaptersResult? + @ObservationIgnored fileprivate var roomListServiceStateHandle: TaskHandle? + @ObservationIgnored fileprivate var syncIndicatorHandle: TaskHandle? + @ObservationIgnored fileprivate var syncStateHandle: TaskHandle? + @ObservationIgnored fileprivate var verificationStateHandle: TaskHandle? + @ObservationIgnored fileprivate var ignoredUsersHandle: TaskHandle? + + /// The latest session verification request received by another client + public var sessionVerificationRequest: SessionVerificationRequestDetails? + public var sessionVerificationData: SessionVerificationData? + public var verificationState: VerificationState? + + var notificationClient: NotificationClient? + + public func startSync() async throws { + let _syncService = try await client.syncService().withOfflineMode().finish() + syncService = _syncService + + syncStateHandle = _syncService.state(listener: self) + + let _roomListService = _syncService.roomListService() + roomListService = _roomListService + roomListServiceStateHandle = _roomListService.state(listener: self) + syncIndicatorHandle = _roomListService.syncIndicator( + delayBeforeShowingInMs: 200, delayBeforeHidingInMs: 200, listener: self) + + let roomEntriesListener = AsyncSDKListener<[RoomListEntriesUpdate]>() + let _roomListEntriesHandle = try await _roomListService.allRooms().entriesWithDynamicAdapters( + pageSize: 100, listener: roomEntriesListener) + _ = _roomListEntriesHandle.controller().setFilter(kind: .all(filters: [])) + roomListEntriesHandle = _roomListEntriesHandle + + Task { [weak self] in + for await roomEntries in roomEntriesListener { + guard let self else { break } + self.updateRoomEntries(roomEntriesUpdate: roomEntries) + } } - public internal(set) var syncService: SyncService? - public internal(set) var syncState: SyncServiceState = .terminated - public internal(set) var roomListService: RoomListService? - public internal(set) var roomListServiceState: RoomListServiceState? - public internal(set) var showRoomSyncIndicator: RoomListServiceSyncIndicator? - public internal(set) var ignoredUserIds: [String] = [] - - @ObservationIgnored fileprivate var roomListEntriesHandle: RoomListEntriesWithDynamicAdaptersResult? - @ObservationIgnored fileprivate var roomListServiceStateHandle: TaskHandle? - @ObservationIgnored fileprivate var syncIndicatorHandle: TaskHandle? - @ObservationIgnored fileprivate var syncStateHandle: TaskHandle? - @ObservationIgnored fileprivate var verificationStateHandle: TaskHandle? - @ObservationIgnored fileprivate var ignoredUsersHandle: TaskHandle? - - /// The latest session verification request received by another client - public var sessionVerificationRequest: SessionVerificationRequestDetails? - public var sessionVerificationData: SessionVerificationData? - public var verificationState: VerificationState? - - var notificationClient: NotificationClient? - - public func startSync() async throws { - let _syncService = try await client.syncService().withOfflineMode().finish() - syncService = _syncService - - syncStateHandle = _syncService.state(listener: self) - - let _roomListService = _syncService.roomListService() - roomListService = _roomListService - roomListServiceStateHandle = _roomListService.state(listener: self) - syncIndicatorHandle = _roomListService.syncIndicator(delayBeforeShowingInMs: 200, delayBeforeHidingInMs: 200, listener: self) - - let roomEntriesListener = AsyncSDKListener<[RoomListEntriesUpdate]>() - let _roomListEntriesHandle = try await _roomListService.allRooms().entriesWithDynamicAdapters(pageSize: 100, listener: roomEntriesListener) - _ = _roomListEntriesHandle.controller().setFilter(kind: .all(filters: [])) - roomListEntriesHandle = _roomListEntriesHandle - - Task { [weak self] in - for await roomEntries in roomEntriesListener { - guard let self else { break } - self.updateRoomEntries(roomEntriesUpdate: roomEntries) - } - } - - notificationClient = try await client.notificationClient(processSetup: .singleProcess(syncService: _syncService)) - await client.registerNotificationHandler(listener: notifications) + notificationClient = try await client.notificationClient( + processSetup: .singleProcess(syncService: _syncService)) + await client.registerNotificationHandler(listener: notifications) - try await client.getSessionVerificationController().setDelegate(delegate: self) + try await client.getSessionVerificationController().setDelegate(delegate: self) - verificationStateHandle = client.encryption().verificationStateListener(listener: self) + verificationStateHandle = client.encryption().verificationStateListener(listener: self) - ignoredUsersHandle = client.subscribeToIgnoredUsers(listener: self) - ignoredUserIds = try await client.ignoredUsers() + ignoredUsersHandle = client.subscribeToIgnoredUsers(listener: self) + ignoredUserIds = try await client.ignoredUsers() - // Start the sync loop. - await _syncService.start() - Logger.matrixClient.info("Matrix sync started") - } + // Start the sync loop. + await _syncService.start() + Logger.matrixClient.info("Matrix sync started") + } - public func clearCache() async throws { - try await client.clearCaches(syncService: syncService) - } + public func clearCache() async throws { + try await client.clearCaches(syncService: syncService) + } - public func isUserIgnored(_ userId: String) -> Bool { - ignoredUserIds.contains(userId) - } + public func isUserIgnored(_ userId: String) -> Bool { + ignoredUserIds.contains(userId) + } - public func declineVerificationRequest(request: SessionVerificationRequestDetails) async throws { - try await client.getSessionVerificationController().acknowledgeVerificationRequest( - senderId: request.senderProfile.userId, flowId: request.flowId - ) + public func declineVerificationRequest(request: SessionVerificationRequestDetails) async throws { + try await client.getSessionVerificationController().acknowledgeVerificationRequest( + senderId: request.senderProfile.userId, flowId: request.flowId + ) - try await client.getSessionVerificationController().cancelVerification() - } + try await client.getSessionVerificationController().cancelVerification() + } - public func acceptVerificationRequest(request: SessionVerificationRequestDetails) async throws { - try await client.getSessionVerificationController().acknowledgeVerificationRequest( - senderId: request.senderProfile.userId, flowId: request.flowId - ) + public func acceptVerificationRequest(request: SessionVerificationRequestDetails) async throws { + try await client.getSessionVerificationController().acknowledgeVerificationRequest( + senderId: request.senderProfile.userId, flowId: request.flowId + ) - try await client.getSessionVerificationController().acceptVerificationRequest() - } + try await client.getSessionVerificationController().acceptVerificationRequest() + } - public func requestDeviceVerification() async throws { - try await client.getSessionVerificationController().requestDeviceVerification() - } + public func requestDeviceVerification() async throws { + try await client.getSessionVerificationController().requestDeviceVerification() + } } enum MatrixClientRestoreSessionError: Error { - case sessionNotFound, wrongUserId + case sessionNotFound, wrongUserId } extension MatrixClient: MatrixRustSDK.ClientSessionDelegate { - public nonisolated func retrieveSessionFromKeychain(userId: String) throws -> MatrixRustSDK.Session { - Logger.matrixClient.debug("client session delegate: retrieve session from keychain: \(userId, privacy: .sensitive)") - - let userSession = try UserSession.loadUserFromKeychain() - if let userSession { - if userSession.userID == userId { - return userSession.session - } else { - Logger.matrixClient.debug( - "restored user session has wrong userId: \(userSession.userID, privacy: .sensitive), expected \(userId, privacy: .sensitive)" - ) - throw MatrixClientRestoreSessionError.wrongUserId - } - } else { - throw MatrixClientRestoreSessionError.sessionNotFound - } + public nonisolated func retrieveSessionFromKeychain(userId: String) throws + -> MatrixRustSDK.Session + { + Logger.matrixClient.debug( + "client session delegate: retrieve session from keychain: \(userId, privacy: .sensitive)") + + let userSession = try UserSession.loadUserFromKeychain() + if let userSession { + if userSession.userID == userId { + return userSession.session + } else { + Logger.matrixClient.debug( + "restored user session has wrong userId: \(userSession.userID, privacy: .sensitive), expected \(userId, privacy: .sensitive)" + ) + throw MatrixClientRestoreSessionError.wrongUserId + } + } else { + throw MatrixClientRestoreSessionError.sessionNotFound } - - public nonisolated func saveSessionInKeychain(session: MatrixRustSDK.Session) { - Logger.matrixClient.debug("client session delegate: save session in keychain") - do { - try UserSession(session: session, storeID: storeID, storePassphrase: storePassphrase).saveUserToKeychain() - } catch { - Logger.matrixClient.error("failed to save session in keychain: \(error)") - } + } + + public nonisolated func saveSessionInKeychain(session: MatrixRustSDK.Session) { + Logger.matrixClient.debug("client session delegate: save session in keychain") + do { + try UserSession(session: session, storeID: storeID, storePassphrase: storePassphrase) + .saveUserToKeychain() + } catch { + Logger.matrixClient.error("failed to save session in keychain: \(error)") } + } } -private extension URL { - static func sessionData(for sessionID: String) -> URL { - applicationSupportDirectory - .appending(component: "dk.qpqp.mactrix") - .appending(component: sessionID) - } - - static func sessionCaches(for sessionID: String) -> URL { - cachesDirectory - .appending(component: "dk.qpqp.mactrix") - .appending(component: sessionID) - } +extension URL { + fileprivate static func sessionData(for sessionID: String) -> URL { + applicationSupportDirectory + .appending(component: "dk.qpqp.mactrix") + .appending(component: sessionID) + } + + fileprivate static func sessionCaches(for sessionID: String) -> URL { + cachesDirectory + .appending(component: "dk.qpqp.mactrix") + .appending(component: sessionID) + } } diff --git a/Sources/MatrixIntegration/MatrixClientSessionDelegate.swift b/Sources/MatrixIntegration/MatrixClientSessionDelegate.swift index 4793cca..f0d60d6 100644 --- a/Sources/MatrixIntegration/MatrixClientSessionDelegate.swift +++ b/Sources/MatrixIntegration/MatrixClientSessionDelegate.swift @@ -6,41 +6,41 @@ import UniformTypeIdentifiers import Utils final class MatrixClientSessionDelegate: MatrixRustSDK.ClientSessionDelegate { - let storeID: String - let storePassphrase: String + let storeID: String + let storePassphrase: String - init(storeID: String, storePassphrase: String) { - self.storeID = storeID - self.storePassphrase = storePassphrase - } + init(storeID: String, storePassphrase: String) { + self.storeID = storeID + self.storePassphrase = storePassphrase + } + + func retrieveSessionFromKeychain(userId: String) throws -> MatrixRustSDK.Session { + Logger.matrixClient.debug( + "client session delegate: retrieve session from keychain: \(userId, privacy: .sensitive)" + ) - func retrieveSessionFromKeychain(userId: String) throws -> MatrixRustSDK.Session { + let userSession = try UserSession.loadUserFromKeychain() + if let userSession { + if userSession.userID == userId { + return userSession.session + } else { Logger.matrixClient.debug( - "client session delegate: retrieve session from keychain: \(userId, privacy: .sensitive)" + "restored user session has wrong userId: \(userSession.userID, privacy: .sensitive), expected \(userId, privacy: .sensitive)" ) - - let userSession = try UserSession.loadUserFromKeychain() - if let userSession { - if userSession.userID == userId { - return userSession.session - } else { - Logger.matrixClient.debug( - "restored user session has wrong userId: \(userSession.userID, privacy: .sensitive), expected \(userId, privacy: .sensitive)" - ) - throw MatrixClientRestoreSessionError.wrongUserId - } - } else { - throw MatrixClientRestoreSessionError.sessionNotFound - } + throw MatrixClientRestoreSessionError.wrongUserId + } + } else { + throw MatrixClientRestoreSessionError.sessionNotFound } + } - func saveSessionInKeychain(session: MatrixRustSDK.Session) { - Logger.matrixClient.debug("client session delegate: save session in keychain") - do { - try UserSession(session: session, storeID: storeID, storePassphrase: storePassphrase) - .saveUserToKeychain() - } catch { - Logger.matrixClient.error("failed to save session in keychain: \(error)") - } + func saveSessionInKeychain(session: MatrixRustSDK.Session) { + Logger.matrixClient.debug("client session delegate: save session in keychain") + do { + try UserSession(session: session, storeID: storeID, storePassphrase: storePassphrase) + .saveUserToKeychain() + } catch { + Logger.matrixClient.error("failed to save session in keychain: \(error)") } + } } diff --git a/Sources/MatrixIntegration/MatrixNotifications.swift b/Sources/MatrixIntegration/MatrixNotifications.swift index c8862fc..4632855 100644 --- a/Sources/MatrixIntegration/MatrixNotifications.swift +++ b/Sources/MatrixIntegration/MatrixNotifications.swift @@ -4,162 +4,168 @@ import UserNotifications // This class is Sendable because MatrixRustSDK.SyncNotificationListener requires it to be so @MainActor @Observable public final class MatrixNotifications: NSObject, Sendable { - public var selectedRoomId: String? + public var selectedRoomId: String? - typealias NotificationEvent = (item: MatrixRustSDK.NotificationItem, roomId: String) + typealias NotificationEvent = (item: MatrixRustSDK.NotificationItem, roomId: String) - @ObservationIgnored private let notificationContinuation: AsyncStream.Continuation - @ObservationIgnored private var streamTask: Task? + @ObservationIgnored private let notificationContinuation: + AsyncStream.Continuation + @ObservationIgnored private var streamTask: Task? - public override init() { - let (stream, continuation) = AsyncStream.makeStream() - notificationContinuation = continuation + public override init() { + let (stream, continuation) = AsyncStream.makeStream() + notificationContinuation = continuation - super.init() - UNUserNotificationCenter.current().delegate = self + super.init() + UNUserNotificationCenter.current().delegate = self - streamTask = Task { [weak self] in - for await (notification, roomId) in stream { - guard let self else { break } - await self.sendNotification(notification: notification, roomId: roomId) - } - } + streamTask = Task { [weak self] in + for await (notification, roomId) in stream { + guard let self else { break } + await self.sendNotification(notification: notification, roomId: roomId) + } } + } } extension MatrixNotifications: @MainActor UNUserNotificationCenterDelegate { - public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { - let roomId = response.notification.request.content.userInfo["roomId"] as? String - Logger.notification.info("Notification delegate didReceive: \(roomId ?? "")") - selectedRoomId = roomId - } + public func userNotificationCenter( + _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse + ) async { + let roomId = response.notification.request.content.userInfo["roomId"] as? String + Logger.notification.info("Notification delegate didReceive: \(roomId ?? "")") + selectedRoomId = roomId + } } extension MatrixNotifications: MatrixRustSDK.SyncNotificationListener { - public nonisolated func onNotification(notification: MatrixRustSDK.NotificationItem, roomId: String) { - notificationContinuation.yield((notification, roomId)) - } + public nonisolated func onNotification( + notification: MatrixRustSDK.NotificationItem, roomId: String + ) { + notificationContinuation.yield((notification, roomId)) + } } extension MatrixNotifications { - func sendNotification(notification: MatrixRustSDK.NotificationItem, roomId: String) async { - do { - let success = try await UNUserNotificationCenter.current() - .requestAuthorization(options: [.alert, .badge, .sound]) - guard success else { - Logger.notification.warning("user rejected notification request") - return - } - } catch { - Logger.notification.error("failed to request notification permissions: \(error)") - return - } + func sendNotification(notification: MatrixRustSDK.NotificationItem, roomId: String) async { + do { + let success = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .badge, .sound]) + guard success else { + Logger.notification.warning("user rejected notification request") + return + } + } catch { + Logger.notification.error("failed to request notification permissions: \(error)") + return + } - Logger.notification.debug("sending notification from room \(roomId)") + Logger.notification.debug("sending notification from room \(roomId)") - let content = UNMutableNotificationContent() - content.title = notificationTitle(for: notification) - content.subtitle = notificationBody(for: notification) - content.sound = UNNotificationSound.default - content.interruptionLevel = .timeSensitive - content.userInfo = ["roomId": roomId] + let content = UNMutableNotificationContent() + content.title = notificationTitle(for: notification) + content.subtitle = notificationBody(for: notification) + content.sound = UNNotificationSound.default + content.interruptionLevel = .timeSensitive + content.userInfo = ["roomId": roomId] - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.01, repeats: false) - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.01, repeats: false) + let request = UNNotificationRequest( + identifier: UUID().uuidString, content: content, trigger: trigger) - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - Logger.notification.error("failed to schedule notification: \(error)") - } + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + Logger.notification.error("failed to schedule notification: \(error)") } + } - func notificationTitle(for notification: NotificationItem) -> String { - switch notification.event { - case let .invite(sender: sender): - return "\(sender) invited you to a room" - case let .timeline(event: event): - let sender = notification.senderInfo.displayName ?? event.senderId() - let roomName = notification.roomInfo.displayName - return "\(sender) (\(roomName))" - } + func notificationTitle(for notification: NotificationItem) -> String { + switch notification.event { + case .invite(let sender): + return "\(sender) invited you to a room" + case .timeline(let event): + let sender = notification.senderInfo.displayName ?? event.senderId() + let roomName = notification.roomInfo.displayName + return "\(sender) (\(roomName))" } + } - func notificationBody(for notification: NotificationItem) -> String { - switch notification.event { - case .invite(sender: _): - return "Room \(notification.roomInfo.displayName)" - case let .timeline(event: event): - do { - switch try event.content() { - case let .messageLike(content: msgLike): - switch msgLike { - case .callAnswer: - return "Call answered" - case .callInvite: - return "Call invitation" - case .rtcNotification: - return "RTC Notification" - case .callHangup: - return "Call hang up" - case .callCandidates: - return "Call candidates" - case .keyVerificationReady: - return "Key verification ready" - case .keyVerificationStart: - return "Key verification start" - case .keyVerificationCancel: - return "Key verification cancel" - case .keyVerificationAccept: - return "Key verification accept" - case .keyVerificationKey: - return "Key verification key" - case .keyVerificationMac: - return "Key verification mac" - case .keyVerificationDone: - return "Key verification done" - case let .poll(question): - return "Asked \(question)" - case .reactionContent: - return "Reaction content" - case .roomEncrypted: - return "Room encrypted" - case let .roomMessage(messageType, _): - switch messageType { - case .emote: - return "Emote" - case .image: - return "Image" - case .audio: - return "Audio" - case .video: - return "Video" - case .file: - return "File" - case .gallery: - return "Gallery" - case .notice: - return "Notice" - case let .text(content): - return content.body - case .location: - return "Location" - case let .other(msgtype, body): - return "\(msgtype): \(body)" - } - case .roomRedaction: - return "Message redacted" - case .sticker: - return "Sent a sticker" - case .beacon: - return "Beacon" - } - case .state(content:): - return "State change" - } - } catch { - return error.localizedDescription + func notificationBody(for notification: NotificationItem) -> String { + switch notification.event { + case .invite(sender: _): + return "Room \(notification.roomInfo.displayName)" + case .timeline(let event): + do { + switch try event.content() { + case .messageLike(content: let msgLike): + switch msgLike { + case .callAnswer: + return "Call answered" + case .callInvite: + return "Call invitation" + case .rtcNotification: + return "RTC Notification" + case .callHangup: + return "Call hang up" + case .callCandidates: + return "Call candidates" + case .keyVerificationReady: + return "Key verification ready" + case .keyVerificationStart: + return "Key verification start" + case .keyVerificationCancel: + return "Key verification cancel" + case .keyVerificationAccept: + return "Key verification accept" + case .keyVerificationKey: + return "Key verification key" + case .keyVerificationMac: + return "Key verification mac" + case .keyVerificationDone: + return "Key verification done" + case .poll(let question): + return "Asked \(question)" + case .reactionContent: + return "Reaction content" + case .roomEncrypted: + return "Room encrypted" + case .roomMessage(let messageType, _): + switch messageType { + case .emote: + return "Emote" + case .image: + return "Image" + case .audio: + return "Audio" + case .video: + return "Video" + case .file: + return "File" + case .gallery: + return "Gallery" + case .notice: + return "Notice" + case .text(let content): + return content.body + case .location: + return "Location" + case .other(let msgtype, let body): + return "\(msgtype): \(body)" } + case .roomRedaction: + return "Message redacted" + case .sticker: + return "Sent a sticker" + case .beacon: + return "Beacon" + } + case .state(content:): + return "State change" } + } catch { + return error.localizedDescription + } } + } } diff --git a/Sources/MatrixIntegration/SidebarRoom.swift b/Sources/MatrixIntegration/SidebarRoom.swift index e03aa50..56fefdd 100644 --- a/Sources/MatrixIntegration/SidebarRoom.swift +++ b/Sources/MatrixIntegration/SidebarRoom.swift @@ -5,55 +5,55 @@ import OSLog @MainActor @Observable public final class SidebarRoom: Identifiable { - public let id: String - public private(set) var room: MatrixRustSDK.Room - public var roomInfo: RoomInfo? - - @ObservationIgnored private var roomInfoHandle: TaskHandle? - @ObservationIgnored private var listenerTask: Task? - - public init(room: MatrixRustSDK.Room) { - self.id = room.id() - self.room = room - - Task { - do { - self.roomInfo = try await room.roomInfo() - } catch { - Logger.sidebarRoom.error("Failed to fetch initial room info: \(error)") - } - - listenToRoomInfo() - } - } + public let id: String + public private(set) var room: MatrixRustSDK.Room + public var roomInfo: RoomInfo? + + @ObservationIgnored private var roomInfoHandle: TaskHandle? + @ObservationIgnored private var listenerTask: Task? + + public init(room: MatrixRustSDK.Room) { + self.id = room.id() + self.room = room - /// Updates the underlying room reference without replacing this instance. - /// Preserves object identity and loaded roomInfo while ensuring the room - /// object stays current. Re-subscribes to room info updates on the new reference. - public func updateRoom(_ newRoom: MatrixRustSDK.Room) { - assert(id == newRoom.id()) - room = newRoom - listenerTask?.cancel() - roomInfoHandle = nil - Task { - do { - roomInfo = try await room.roomInfo() - } catch { - Logger.sidebarRoom.error("Failed to fetch room info on update: \(error)") - } - listenToRoomInfo() - } + Task { + do { + self.roomInfo = try await room.roomInfo() + } catch { + Logger.sidebarRoom.error("Failed to fetch initial room info: \(error)") + } + + listenToRoomInfo() + } + } + + /// Updates the underlying room reference without replacing this instance. + /// Preserves object identity and loaded roomInfo while ensuring the room + /// object stays current. Re-subscribes to room info updates on the new reference. + public func updateRoom(_ newRoom: MatrixRustSDK.Room) { + assert(id == newRoom.id()) + room = newRoom + listenerTask?.cancel() + roomInfoHandle = nil + Task { + do { + roomInfo = try await room.roomInfo() + } catch { + Logger.sidebarRoom.error("Failed to fetch room info on update: \(error)") + } + listenToRoomInfo() } + } - private func listenToRoomInfo() { - let listener = AsyncSDKListener() - roomInfoHandle = room.subscribeToRoomInfoUpdates(listener: listener) + private func listenToRoomInfo() { + let listener = AsyncSDKListener() + roomInfoHandle = room.subscribeToRoomInfoUpdates(listener: listener) - listenerTask = Task { [weak self] in - for await roomInfo in listener.debounce(for: .milliseconds(500)) { - guard let self, !Task.isCancelled else { break } - self.roomInfo = roomInfo - } - } + listenerTask = Task { [weak self] in + for await roomInfo in listener.debounce(for: .milliseconds(500)) { + guard let self, !Task.isCancelled else { break } + self.roomInfo = roomInfo + } } + } } diff --git a/Sources/MatrixIntegration/SidebarSpaceRoom.swift b/Sources/MatrixIntegration/SidebarSpaceRoom.swift index 8229234..a9bde9c 100644 --- a/Sources/MatrixIntegration/SidebarSpaceRoom.swift +++ b/Sources/MatrixIntegration/SidebarSpaceRoom.swift @@ -3,39 +3,40 @@ import MatrixRustSDK @MainActor @Observable public final class SidebarSpaceRoom { - public let spaceRoom: SpaceRoom + public let spaceRoom: SpaceRoom - public private(set) var children: Children = .loading + public private(set) var children: Children = .loading - private let spaceService: LiveSpaceService + private let spaceService: LiveSpaceService - public init(spaceService: LiveSpaceService, spaceRoom: SpaceRoom) { - self.spaceService = spaceService - self.spaceRoom = spaceRoom - } + public init(spaceService: LiveSpaceService, spaceRoom: SpaceRoom) { + self.spaceService = spaceService + self.spaceRoom = spaceRoom + } - public func loadChildren() async { - if case .loaded(children: _) = children { - return - } - - do { - let result = try await spaceService.spaceService.spaceRoomList(spaceId: spaceRoom.roomId) - children = .loaded(children: LiveSpaceRoomList(spaceService: spaceService, spaceRoomList: result)) - } catch { - children = .error(error: error) - } + public func loadChildren() async { + if case .loaded(children: _) = children { + return } - public enum Children { - case loading - case loaded(children: LiveSpaceRoomList) - case error(error: Error) + do { + let result = try await spaceService.spaceService.spaceRoomList(spaceId: spaceRoom.roomId) + children = .loaded( + children: LiveSpaceRoomList(spaceService: spaceService, spaceRoomList: result)) + } catch { + children = .error(error: error) } + } + + public enum Children { + case loading + case loaded(children: LiveSpaceRoomList) + case error(error: Error) + } } extension SidebarSpaceRoom: Identifiable { - public nonisolated var id: String { - spaceRoom.roomId - } + public nonisolated var id: String { + spaceRoom.roomId + } } diff --git a/Sources/MatrixIntegration/UserSession.swift b/Sources/MatrixIntegration/UserSession.swift index 48b544a..b80a0bf 100644 --- a/Sources/MatrixIntegration/UserSession.swift +++ b/Sources/MatrixIntegration/UserSession.swift @@ -8,49 +8,49 @@ import UniformTypeIdentifiers import Utils struct UserSession: Codable { - let accessToken: String - let refreshToken: String? - let userID: String - let deviceID: String - let homeserverURL: String - let oauthData: String? - let storeID: String - let storePassphrase: String + let accessToken: String + let refreshToken: String? + let userID: String + let deviceID: String + let homeserverURL: String + let oauthData: String? + let storeID: String + let storePassphrase: String - init(session: Session, storeID: String, storePassphrase: String) { - accessToken = session.accessToken - refreshToken = session.refreshToken - userID = session.userId - deviceID = session.deviceId - homeserverURL = session.homeserverUrl - oauthData = session.oauthData - self.storeID = storeID - self.storePassphrase = storePassphrase - } + init(session: Session, storeID: String, storePassphrase: String) { + accessToken = session.accessToken + refreshToken = session.refreshToken + userID = session.userId + deviceID = session.deviceId + homeserverURL = session.homeserverUrl + oauthData = session.oauthData + self.storeID = storeID + self.storePassphrase = storePassphrase + } - var session: Session { - Session( - accessToken: accessToken, - refreshToken: refreshToken, - userId: userID, - deviceId: deviceID, - homeserverUrl: homeserverURL, - oauthData: oauthData, - slidingSyncVersion: .native) - } + var session: Session { + Session( + accessToken: accessToken, + refreshToken: refreshToken, + userId: userID, + deviceId: deviceID, + homeserverUrl: homeserverURL, + oauthData: oauthData, + slidingSyncVersion: .native) + } - fileprivate static var keychainKey: String { "UserSession" } + fileprivate static var keychainKey: String { "UserSession" } - func saveUserToKeychain() throws { - let keychainData = try JSONEncoder().encode(self) - try AppKeychain().save(keychainData, forKey: Self.keychainKey) - } + func saveUserToKeychain() throws { + let keychainData = try JSONEncoder().encode(self) + try AppKeychain().save(keychainData, forKey: Self.keychainKey) + } - static func loadUserFromKeychain() throws -> Self? { - Logger.matrixClient.debug("Load user from keychain") - if let keychainData = try AppKeychain().load(forKey: keychainKey) { - return try JSONDecoder().decode(Self.self, from: keychainData) - } - return nil + static func loadUserFromKeychain() throws -> Self? { + Logger.matrixClient.debug("Load user from keychain") + if let keychainData = try AppKeychain().load(forKey: keychainKey) { + return try JSONDecoder().decode(Self.self, from: keychainData) } + return nil + } } diff --git a/Sources/MatrixProtocols/CreateRoomParams.swift b/Sources/MatrixProtocols/CreateRoomParams.swift index 497426e..4e5c349 100644 --- a/Sources/MatrixProtocols/CreateRoomParams.swift +++ b/Sources/MatrixProtocols/CreateRoomParams.swift @@ -1,40 +1,43 @@ import Foundation public enum RoomAccess: Sendable, Hashable { - case publicRoom, privateRoom + case publicRoom, privateRoom } public enum RoomVisibility: Sendable { - case published, unpublished + case published, unpublished } public struct CreateRoomParams: Sendable { - public var name: String - public var topic: String - public var enableEncryption: Bool - // public var isDirect: Bool - public var access: RoomAccess - public var visibility: RoomVisibility - // public var invite: [String]? - // public var avatar: String? - // public var powerLevelContentOverride: PowerLevels? - // public var joinRuleOverride: JoinRule? - // public var historyVisibilityOverride: RoomHistoryVisibility? - // public var canonicalAlias: String? + public var name: String + public var topic: String + public var enableEncryption: Bool + // public var isDirect: Bool + public var access: RoomAccess + public var visibility: RoomVisibility + // public var invite: [String]? + // public var avatar: String? + // public var powerLevelContentOverride: PowerLevels? + // public var joinRuleOverride: JoinRule? + // public var historyVisibilityOverride: RoomHistoryVisibility? + // public var canonicalAlias: String? - public init(name: String, topic: String, enableEncryption: Bool, access: RoomAccess, visibility: RoomVisibility) { - self.name = name - self.topic = topic - self.enableEncryption = enableEncryption - self.access = access - self.visibility = visibility - } + public init( + name: String, topic: String, enableEncryption: Bool, access: RoomAccess, + visibility: RoomVisibility + ) { + self.name = name + self.topic = topic + self.enableEncryption = enableEncryption + self.access = access + self.visibility = visibility + } - public init() { - name = "" - topic = "" - enableEncryption = false - access = .privateRoom - visibility = .unpublished - } + public init() { + name = "" + topic = "" + enableEncryption = false + access = .privateRoom + visibility = .unpublished + } } diff --git a/Sources/MatrixProtocols/EncryptionState.swift b/Sources/MatrixProtocols/EncryptionState.swift index dd94936..30e6eff 100644 --- a/Sources/MatrixProtocols/EncryptionState.swift +++ b/Sources/MatrixProtocols/EncryptionState.swift @@ -1,17 +1,16 @@ - @MainActor public enum EncryptionState { - /** - * The room is encrypted. - */ - case encrypted - /** - * The room is not encrypted. - */ - case notEncrypted - /** - * The state of the room encryption is unknown, probably because the - * `/sync` did not provide all data needed to decide. - */ - case unknown + /** + * The room is encrypted. + */ + case encrypted + /** + * The room is not encrypted. + */ + case notEncrypted + /** + * The state of the room encryption is unknown, probably because the + * `/sync` did not provide all data needed to decide. + */ + case unknown } diff --git a/Sources/MatrixProtocols/EventTimelineItem.swift b/Sources/MatrixProtocols/EventTimelineItem.swift index d4ce4cc..879c2b4 100644 --- a/Sources/MatrixProtocols/EventTimelineItem.swift +++ b/Sources/MatrixProtocols/EventTimelineItem.swift @@ -1,87 +1,87 @@ import Foundation public enum ProfileDetails { - case unavailable - case pending - case ready(displayName: String?, displayNameAmbiguous: Bool, avatarUrl: String?) - case error(message: String) - - public var avatarUrl: String? { - if case let .ready(displayName: _, displayNameAmbiguous: _, avatarUrl: avatarUrl) = self { - return avatarUrl - } - return nil + case unavailable + case pending + case ready(displayName: String?, displayNameAmbiguous: Bool, avatarUrl: String?) + case error(message: String) + + public var avatarUrl: String? { + if case .ready(displayName: _, displayNameAmbiguous: _, avatarUrl: let avatarUrl) = self { + return avatarUrl } + return nil + } } public struct Receipt: Equatable, Hashable { - public var timestamp: Date? + public var timestamp: Date? - public init(timestamp: Date?) { - self.timestamp = timestamp - } + public init(timestamp: Date?) { + self.timestamp = timestamp + } } public protocol EventTimelineItem: UserProfile { - var isRemote: Bool { get } - // var eventOrTransactionId: EventOrTransactionId { get } - var sender: String { get } - var senderProfileDetails: ProfileDetails { get } - var isOwn: Bool { get } - var isEditable: Bool { get } - // var content: TimelineItemContent { get } - var date: Date { get } - // var localSendState: EventSendState? { get } - var localCreatedAt: UInt64? { get } - var userReadReceipts: [String: Receipt] { get } - // var origin: EventItemOrigin? { get } - var canBeRepliedTo: Bool { get } - // var lazyProvider: LazyTimelineItemProvider { get } + var isRemote: Bool { get } + // var eventOrTransactionId: EventOrTransactionId { get } + var sender: String { get } + var senderProfileDetails: ProfileDetails { get } + var isOwn: Bool { get } + var isEditable: Bool { get } + // var content: TimelineItemContent { get } + var date: Date { get } + // var localSendState: EventSendState? { get } + var localCreatedAt: UInt64? { get } + var userReadReceipts: [String: Receipt] { get } + // var origin: EventItemOrigin? { get } + var canBeRepliedTo: Bool { get } + // var lazyProvider: LazyTimelineItemProvider { get } } public struct MockEventTimelineItem: EventTimelineItem { - public init() {} - - public var isRemote: Bool { - true - } - - public var sender: String { - "sender@address" - } - - public var senderProfileDetails: ProfileDetails { - .ready(displayName: "Sender Name", displayNameAmbiguous: false, avatarUrl: nil) - } - - public var isOwn: Bool { - false - } - - public var isEditable: Bool { - false - } - - public var date: Date { - .now - } - - public var localCreatedAt: UInt64? { - nil - } - - public var userReadReceipts: [String: Receipt] { - [ - "foo@matrix.org": .init(timestamp: .now), - "bar@matrix.org": .init(timestamp: .now + 2) - ] - } - - public var canBeRepliedTo: Bool { - true - } - - public var userId: String { sender } - public var displayName: String? { nil } - public var avatarUrl: String? { nil } + public init() {} + + public var isRemote: Bool { + true + } + + public var sender: String { + "sender@address" + } + + public var senderProfileDetails: ProfileDetails { + .ready(displayName: "Sender Name", displayNameAmbiguous: false, avatarUrl: nil) + } + + public var isOwn: Bool { + false + } + + public var isEditable: Bool { + false + } + + public var date: Date { + .now + } + + public var localCreatedAt: UInt64? { + nil + } + + public var userReadReceipts: [String: Receipt] { + [ + "foo@matrix.org": .init(timestamp: .now), + "bar@matrix.org": .init(timestamp: .now + 2), + ] + } + + public var canBeRepliedTo: Bool { + true + } + + public var userId: String { sender } + public var displayName: String? { nil } + public var avatarUrl: String? { nil } } diff --git a/Sources/MatrixProtocols/Extensions/String+Markdown.swift b/Sources/MatrixProtocols/Extensions/String+Markdown.swift index a48c295..8c1154c 100644 --- a/Sources/MatrixProtocols/Extensions/String+Markdown.swift +++ b/Sources/MatrixProtocols/Extensions/String+Markdown.swift @@ -1,12 +1,13 @@ import Foundation -public extension String { - var formatAsMarkdown: AttributedString { - let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) - if let formattedString = try? AttributedString(markdown: self, options: options) { - return formattedString - } else { - return AttributedString(self) - } +extension String { + public var formatAsMarkdown: AttributedString { + let options = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace) + if let formattedString = try? AttributedString(markdown: self, options: options) { + return formattedString + } else { + return AttributedString(self) } + } } diff --git a/Sources/MatrixProtocols/InReplyToDetails.swift b/Sources/MatrixProtocols/InReplyToDetails.swift index b16d9a9..30e098e 100644 --- a/Sources/MatrixProtocols/InReplyToDetails.swift +++ b/Sources/MatrixProtocols/InReplyToDetails.swift @@ -1,32 +1,36 @@ import Foundation public enum EventOrTransactionId { - case eventId(eventId: String) - case transactionId(transactionId: String) + case eventId(eventId: String) + case transactionId(transactionId: String) } public enum TimelineItemContent { - case msgLike(content: MsgContent) - case callInvite - case rtcNotification - case roomMembership(userId: String, userDisplayName: String?, change: Void /* MembershipChange? */, reason: String?) - case profileChange(displayName: String?, prevDisplayName: String?, avatarUrl: String?, prevAvatarUrl: String?) - case state(stateKey: String, content: Void /* OtherState */ ) - case failedToParseMessageLike(eventType: String, error: String) - case failedToParseState(eventType: String, stateKey: String, error: String) + case msgLike(content: MsgContent) + case callInvite + case rtcNotification + case roomMembership( + userId: String, userDisplayName: String?, change: Void /* MembershipChange? */, reason: String?) + case profileChange( + displayName: String?, prevDisplayName: String?, avatarUrl: String?, prevAvatarUrl: String?) + case state(stateKey: String, content: Void /* OtherState */) + case failedToParseMessageLike(eventType: String, error: String) + case failedToParseState(eventType: String, stateKey: String, error: String) } public enum EmbeddedEventDetails { - case unavailable - case pending - case ready(content: TimelineItemContent, sender: String, senderProfile: ProfileDetails, date: Date, eventOrTransactionId: EventOrTransactionId) - case error(message: String) + case unavailable + case pending + case ready( + content: TimelineItemContent, sender: String, senderProfile: ProfileDetails, + date: Date, eventOrTransactionId: EventOrTransactionId) + case error(message: String) } public protocol InReplyToDetailsProtocol { - associatedtype MsgContent: MsgLikeContent + associatedtype MsgContent: MsgLikeContent - var event: EmbeddedEventDetails { get } + var event: EmbeddedEventDetails { get } - var eventId: String { get } + var eventId: String { get } } diff --git a/Sources/MatrixProtocols/MsgLikeContent.swift b/Sources/MatrixProtocols/MsgLikeContent.swift index ba643f2..71c7687 100644 --- a/Sources/MatrixProtocols/MsgLikeContent.swift +++ b/Sources/MatrixProtocols/MsgLikeContent.swift @@ -1,59 +1,62 @@ import Foundation public protocol Mentions { - var userIds: [String] { get } - var room: Bool { get } + var userIds: [String] { get } + var room: Bool { get } } public protocol MessageContent { - //var msgType: MessageType { get } - var body: String { get } - var isEdited: Bool { get } - var mentions: Mentions? { get } + //var msgType: MessageType { get } + var body: String { get } + var isEdited: Bool { get } + var mentions: Mentions? { get } } public enum MsgLikeKind { - /** - * An `m.room.message` event or extensible event, including edits. - */ - case message(content: MessageContent) - /** - * An `m.sticker` event. - */ - case sticker(body: String, info: Void /* ImageInfo */, source: Void /* MediaSource */ ) - /** - * An `m.poll.start` event. - */ - case poll(question: String, kind: Void /* PollKind */, maxSelections: UInt64, answers: Void /* [PollAnswer] */, votes: [String: [String]], endTime: Date?, hasBeenEdited: Bool) - /** - * A redacted message. - */ - case redacted - /** - * An `m.room.encrypted` event that could not be decrypted. - */ - case unableToDecrypt(msg: Void /* EncryptedMessage */ ) - /** - * A custom message like event. - */ - case other(eventType: Void /* MessageLikeEventType */ ) + /** + * An `m.room.message` event or extensible event, including edits. + */ + case message(content: MessageContent) + /** + * An `m.sticker` event. + */ + case sticker(body: String, info: Void /* ImageInfo */, source: Void /* MediaSource */) + /** + * An `m.poll.start` event. + */ + case poll( + question: String, kind: Void /* PollKind */, maxSelections: UInt64, + answers: Void /* [PollAnswer] */, votes: [String: [String]], endTime: Date?, hasBeenEdited: Bool + ) + /** + * A redacted message. + */ + case redacted + /** + * An `m.room.encrypted` event that could not be decrypted. + */ + case unableToDecrypt(msg: Void /* EncryptedMessage */) + /** + * A custom message like event. + */ + case other(eventType: Void /* MessageLikeEventType */) } public protocol MsgLikeContent { - associatedtype MsgReaction: Reaction + associatedtype MsgReaction: Reaction - var kind: MsgLikeKind { get } - var reactions: [MsgReaction] { get } - /** - * The event this message is replying to, if any. - */ - // var inReplyTo: InReplyToDetails? { get } - /** - * Event ID of the thread root, if this is a message in a thread. - */ - var threadRoot: String? { get } - /** - * Details about the thread this message is the root of. - */ - // var threadSummary: ThreadSummary? { get } + var kind: MsgLikeKind { get } + var reactions: [MsgReaction] { get } + /** + * The event this message is replying to, if any. + */ + // var inReplyTo: InReplyToDetails? { get } + /** + * Event ID of the thread root, if this is a message in a thread. + */ + var threadRoot: String? { get } + /** + * Details about the thread this message is the root of. + */ + // var threadSummary: ThreadSummary? { get } } diff --git a/Sources/MatrixProtocols/Reaction.swift b/Sources/MatrixProtocols/Reaction.swift index 4abc178..893394b 100644 --- a/Sources/MatrixProtocols/Reaction.swift +++ b/Sources/MatrixProtocols/Reaction.swift @@ -1,41 +1,41 @@ import Foundation public protocol Reaction: Identifiable { - associatedtype SenderData: ReactionSenderData + associatedtype SenderData: ReactionSenderData - var key: String { get } - var senders: [SenderData] { get } + var key: String { get } + var senders: [SenderData] { get } } public protocol ReactionSenderData { - var senderId: String { get } - var date: Date { get } + var senderId: String { get } + var date: Date { get } } public struct MockReactionSenderData: ReactionSenderData { - public var senderId: String { - "sender@address" - } + public var senderId: String { + "sender@address" + } - public var date: Date { - .now - } + public var date: Date { + .now + } } public struct MockReaction: Reaction { - public init() {} + public init() {} - public var senders: [MockReactionSenderData] { - [MockReactionSenderData()] - } + public var senders: [MockReactionSenderData] { + [MockReactionSenderData()] + } - public typealias SenderData = MockReactionSenderData + public typealias SenderData = MockReactionSenderData - public var key: String { - "😄" - } + public var key: String { + "😄" + } - public var id: String { - key - } + public var id: String { + key + } } diff --git a/Sources/MatrixProtocols/Room.swift b/Sources/MatrixProtocols/Room.swift index 269bd1f..e3c7fe4 100644 --- a/Sources/MatrixProtocols/Room.swift +++ b/Sources/MatrixProtocols/Room.swift @@ -1,56 +1,58 @@ import Foundation public enum SelectedRoom { - case joinedRoom(_ room: R) - case previewRoom(_ room: RP) + case joinedRoom(_ room: R) + case previewRoom(_ room: RP) } public protocol RoomPreview: Hashable, Identifiable {} public protocol Room: Hashable, Identifiable { - var displayName: String? { get } - var topic: String? { get } - var encryptionState: EncryptionState { get } + var displayName: String? { get } + var topic: String? { get } + var encryptionState: EncryptionState { get } } public enum Membership { - case invited - case joined - case left - case knocked - case banned + case invited + case joined + case left + case knocked + case banned } public enum RoomKind { - /** - * It's a plain chat room. - */ - case room - /** - * It's a space that can group several rooms. - */ - case space - /** - * It's a custom implementation. - */ - case custom(value: String - ) + /** + * It's a plain chat room. + */ + case room + /** + * It's a space that can group several rooms. + */ + case space + /** + * It's a custom implementation. + */ + case custom( + value: String + ) } public struct MockRoom: Room, Identifiable { - public let id: String - - public let displayName: String? - public let topic: String? - public let encryptionState: EncryptionState - - public static var previewRoom: MockRoom { - let topic = """ - The topic of the room! - - The topic can span multiple lines like this. - It also supports markdown for **bold** and _italic_ text. - """ - return MockRoom(id: UUID().uuidString, displayName: "Test Room", topic: topic, encryptionState: .encrypted) - } + public let id: String + + public let displayName: String? + public let topic: String? + public let encryptionState: EncryptionState + + public static var previewRoom: MockRoom { + let topic = """ + The topic of the room! + + The topic can span multiple lines like this. + It also supports markdown for **bold** and _italic_ text. + """ + return MockRoom( + id: UUID().uuidString, displayName: "Test Room", topic: topic, encryptionState: .encrypted) + } } diff --git a/Sources/MatrixProtocols/RoomInfo.swift b/Sources/MatrixProtocols/RoomInfo.swift index b8a607b..de6d9dc 100644 --- a/Sources/MatrixProtocols/RoomInfo.swift +++ b/Sources/MatrixProtocols/RoomInfo.swift @@ -1,154 +1,154 @@ import Foundation public protocol RoomInfo { - var id: String { get } - // var encryptionState: EncryptionState { get } - var creators: [String]? { get } - /** - * The room's name from the room state event if received from sync, or one - * that's been computed otherwise. - */ - var displayName: String? { get } - /** - * Room name as defined by the room state event only. - */ - var rawName: String? { get } - var topic: String? { get } - var avatarUrl: String? { get } - var isDirect: Bool { get } - /** - * Whether the room is public or not, based on the join rules. - * - * Can be `None` if the join rules state event is not available for this - * room. - */ - var isPublic: Bool? { get } - var isSpace: Bool { get } - /** - * If present, it means the room has been archived/upgraded. - */ - // var successorRoom: SuccessorRoom? { get } - var isFavourite: Bool { get } - var canonicalAlias: String? { get } - var alternativeAliases: [String] { get } - // var membership: Membership { get } - /** - * Member who invited the current user to a room that's in the invited - * state. - * - * Can be missing if the room membership invite event is missing from the - * store. - */ - // var inviter: RoomMember? { get } - // var heroes: [RoomHero] { get } - var activeMembersCount: UInt64 { get } - var invitedMembersCount: UInt64 { get } - var joinedMembersCount: UInt64 { get } - var highlightCount: UInt64 { get } - var notificationCount: UInt64 { get } - // var cachedUserDefinedNotificationMode: RoomNotificationMode? { get } - var hasRoomCall: Bool { get } - var activeRoomCallParticipants: [String] { get } - /** - * Whether this room has been explicitly marked as unread - */ - var isMarkedUnread: Bool { get } - /** - * "Interesting" messages received in that room, independently of the - * notification settings. - */ - var numUnreadMessages: UInt64 { get } - /** - * Events that will notify the user, according to their - * notification settings. - */ - var numUnreadNotifications: UInt64 { get } - /** - * Events causing mentions/highlights for the user, according to their - * notification settings. - */ - var numUnreadMentions: UInt64 { get } - /** - * The currently pinned event ids. - */ - var pinnedEventIds: [String] { get } - /** - * The join rule for this room, if known. - */ - // var joinRule: JoinRule? { get } - /** - * The history visibility for this room, if known. - */ - // var historyVisibility: RoomHistoryVisibility { get } - /** - * This room's current power levels. - * - * Can be missing if the room power levels event is missing from the store. - */ - // var powerLevels: RoomPowerLevels? { get } - /** - * This room's version. - */ - var roomVersion: String? { get } - /** - * Whether creators are privileged over every other user (have infinite - * power level). - */ - var privilegedCreatorsRole: Bool { get } + var id: String { get } + // var encryptionState: EncryptionState { get } + var creators: [String]? { get } + /** + * The room's name from the room state event if received from sync, or one + * that's been computed otherwise. + */ + var displayName: String? { get } + /** + * Room name as defined by the room state event only. + */ + var rawName: String? { get } + var topic: String? { get } + var avatarUrl: String? { get } + var isDirect: Bool { get } + /** + * Whether the room is public or not, based on the join rules. + * + * Can be `None` if the join rules state event is not available for this + * room. + */ + var isPublic: Bool? { get } + var isSpace: Bool { get } + /** + * If present, it means the room has been archived/upgraded. + */ + // var successorRoom: SuccessorRoom? { get } + var isFavourite: Bool { get } + var canonicalAlias: String? { get } + var alternativeAliases: [String] { get } + // var membership: Membership { get } + /** + * Member who invited the current user to a room that's in the invited + * state. + * + * Can be missing if the room membership invite event is missing from the + * store. + */ + // var inviter: RoomMember? { get } + // var heroes: [RoomHero] { get } + var activeMembersCount: UInt64 { get } + var invitedMembersCount: UInt64 { get } + var joinedMembersCount: UInt64 { get } + var highlightCount: UInt64 { get } + var notificationCount: UInt64 { get } + // var cachedUserDefinedNotificationMode: RoomNotificationMode? { get } + var hasRoomCall: Bool { get } + var activeRoomCallParticipants: [String] { get } + /** + * Whether this room has been explicitly marked as unread + */ + var isMarkedUnread: Bool { get } + /** + * "Interesting" messages received in that room, independently of the + * notification settings. + */ + var numUnreadMessages: UInt64 { get } + /** + * Events that will notify the user, according to their + * notification settings. + */ + var numUnreadNotifications: UInt64 { get } + /** + * Events causing mentions/highlights for the user, according to their + * notification settings. + */ + var numUnreadMentions: UInt64 { get } + /** + * The currently pinned event ids. + */ + var pinnedEventIds: [String] { get } + /** + * The join rule for this room, if known. + */ + // var joinRule: JoinRule? { get } + /** + * The history visibility for this room, if known. + */ + // var historyVisibility: RoomHistoryVisibility { get } + /** + * This room's current power levels. + * + * Can be missing if the room power levels event is missing from the store. + */ + // var powerLevels: RoomPowerLevels? { get } + /** + * This room's version. + */ + var roomVersion: String? { get } + /** + * Whether creators are privileged over every other user (have infinite + * power level). + */ + var privilegedCreatorsRole: Bool { get } } public struct MockRoomInfo: RoomInfo { - public init() {} + public init() {} - public var id: String { "mock_id" } + public var id: String { "mock_id" } - public var creators: [String]? { nil } + public var creators: [String]? { nil } - public var displayName: String? { "Room Name" } + public var displayName: String? { "Room Name" } - public var rawName: String? { "room_name_raw" } + public var rawName: String? { "room_name_raw" } - public var topic: String? { "The topic of the room" } + public var topic: String? { "The topic of the room" } - public var avatarUrl: String? { nil } + public var avatarUrl: String? { nil } - public var isDirect: Bool { false } + public var isDirect: Bool { false } - public var isPublic: Bool? { true } + public var isPublic: Bool? { true } - public var isSpace: Bool { false } + public var isSpace: Bool { false } - public var isFavourite: Bool { false } + public var isFavourite: Bool { false } - public var canonicalAlias: String? { "#room:matrix.org" } + public var canonicalAlias: String? { "#room:matrix.org" } - public var alternativeAliases: [String] { [] } + public var alternativeAliases: [String] { [] } - public var activeMembersCount: UInt64 { 3 } + public var activeMembersCount: UInt64 { 3 } - public var invitedMembersCount: UInt64 { 1 } + public var invitedMembersCount: UInt64 { 1 } - public var joinedMembersCount: UInt64 { 8 } + public var joinedMembersCount: UInt64 { 8 } - public var highlightCount: UInt64 { 0 } + public var highlightCount: UInt64 { 0 } - public var notificationCount: UInt64 { 2 } + public var notificationCount: UInt64 { 2 } - public var hasRoomCall: Bool { false } + public var hasRoomCall: Bool { false } - public var activeRoomCallParticipants: [String] { [] } + public var activeRoomCallParticipants: [String] { [] } - public var isMarkedUnread: Bool { false } + public var isMarkedUnread: Bool { false } - public var numUnreadMessages: UInt64 { 4 } + public var numUnreadMessages: UInt64 { 4 } - public var numUnreadNotifications: UInt64 { 2 } + public var numUnreadNotifications: UInt64 { 2 } - public var numUnreadMentions: UInt64 { 2 } + public var numUnreadMentions: UInt64 { 2 } - public var pinnedEventIds: [String] { [] } + public var pinnedEventIds: [String] { [] } - public var roomVersion: String? { "12" } + public var roomVersion: String? { "12" } - public var privilegedCreatorsRole: Bool { false } + public var privilegedCreatorsRole: Bool { false } } diff --git a/Sources/MatrixProtocols/RoomMember.swift b/Sources/MatrixProtocols/RoomMember.swift index 442f429..731e297 100644 --- a/Sources/MatrixProtocols/RoomMember.swift +++ b/Sources/MatrixProtocols/RoomMember.swift @@ -1,71 +1,71 @@ import Foundation public enum RoomMemberRole { - /** - * The member is a creator. - * - * A creator has an infinite power level and cannot be demoted, so this - * role is immutable. A room can have several creators. - */ - case creator - /** - * The member is an administrator. - */ - case administrator - /** - * The member is a moderator. - */ - case moderator - /** - * The member is a regular user. - */ - case user + /** + * The member is a creator. + * + * A creator has an infinite power level and cannot be demoted, so this + * role is immutable. A room can have several creators. + */ + case creator + /** + * The member is an administrator. + */ + case administrator + /** + * The member is a moderator. + */ + case moderator + /** + * The member is a regular user. + */ + case user } public protocol RoomMember: Identifiable, UserProfile { - var userId: String { get } - var displayName: String? { get } - var avatarUrl: String? { get } - // var membership: MembershipState { get } - var isNameAmbiguous: Bool { get } - // var powerLevel: PowerLevel { get } - var isIgnored: Bool { get } - var roleForPowerLevel: RoomMemberRole { get } - var membershipChangeReason: String? { get } + var userId: String { get } + var displayName: String? { get } + var avatarUrl: String? { get } + // var membership: MembershipState { get } + var isNameAmbiguous: Bool { get } + // var powerLevel: PowerLevel { get } + var isIgnored: Bool { get } + var roleForPowerLevel: RoomMemberRole { get } + var membershipChangeReason: String? { get } } public struct MockRoomMember: RoomMember { - public init() {} + public init() {} - public var id: String { - userId - } + public var id: String { + userId + } - public var userId: String { - "user@id" - } + public var userId: String { + "user@id" + } - public var displayName: String? { - "User Name" - } + public var displayName: String? { + "User Name" + } - public var avatarUrl: String? { - nil - } + public var avatarUrl: String? { + nil + } - public var isNameAmbiguous: Bool { - false - } + public var isNameAmbiguous: Bool { + false + } - public var isIgnored: Bool { - false - } + public var isIgnored: Bool { + false + } - public var roleForPowerLevel: RoomMemberRole { - .user - } + public var roleForPowerLevel: RoomMemberRole { + .user + } - public var membershipChangeReason: String? { - nil - } + public var membershipChangeReason: String? { + nil + } } diff --git a/Sources/MatrixProtocols/RoomPreviewInfo.swift b/Sources/MatrixProtocols/RoomPreviewInfo.swift index a912085..1320cfc 100644 --- a/Sources/MatrixProtocols/RoomPreviewInfo.swift +++ b/Sources/MatrixProtocols/RoomPreviewInfo.swift @@ -5,133 +5,131 @@ import Foundation */ public enum JoinRule { - /** - * Anyone can join the room without any prior action. - */ - case `public` - /** - * A user who wishes to join the room must first receive an invite to the - * room from someone already inside of the room. - */ - case invite - /** - * Users can join the room if they are invited, or they can request an - * invite to the room. - * - * They can be allowed (invited) or denied (kicked/banned) access. - */ - case knock - - case other + /** + * Anyone can join the room without any prior action. + */ + case `public` + /** + * A user who wishes to join the room must first receive an invite to the + * room from someone already inside of the room. + */ + case invite + /** + * Users can join the room if they are invited, or they can request an + * invite to the room. + * + * They can be allowed (invited) or denied (kicked/banned) access. + */ + case knock + + case other } -/** - * The preview of a room, be it invited/joined/left, or not. - */ +/// The preview of a room, be it invited/joined/left, or not. public protocol RoomPreviewInfo { - /** - * The room id for this room. - */ - var roomId: String { get } - /** - * The canonical alias for the room. - */ - var canonicalAlias: String? { get } - /** - * The room's name, if set. - */ - var name: String? { get } - /** - * The room's topic, if set. - */ - var topic: String? { get } - /** - * The MXC URI to the room's avatar, if set. - */ - var avatarUrl: String? { get } - /** - * The number of joined members. - */ - var numJoinedMembers: UInt64 { get } - /** - * The number of active members, if known (joined + invited). - */ - var numActiveMembers: UInt64? { get } - /** - * The room type (space, custom) or nothing, if it's a regular room. - */ - var roomKind: RoomKind { get } - /** - * Is the history world-readable for this room? - */ - var isHistoryWorldReadable: Bool? { get } - /** - * The membership state for the current user, if known. - */ - var userMembership: Membership? { get } - - /** - * The join rule for this room (private, public, knock, etc.). - */ - var joinRuleInfo: JoinRule? { get } - /** - * Whether the room is direct or not, if known. - */ - var isDirect: Bool? { get } - /** - * Room heroes. - */ - // var heroes: [RoomHero]? { get } + /** + * The room id for this room. + */ + var roomId: String { get } + /** + * The canonical alias for the room. + */ + var canonicalAlias: String? { get } + /** + * The room's name, if set. + */ + var name: String? { get } + /** + * The room's topic, if set. + */ + var topic: String? { get } + /** + * The MXC URI to the room's avatar, if set. + */ + var avatarUrl: String? { get } + /** + * The number of joined members. + */ + var numJoinedMembers: UInt64 { get } + /** + * The number of active members, if known (joined + invited). + */ + var numActiveMembers: UInt64? { get } + /** + * The room type (space, custom) or nothing, if it's a regular room. + */ + var roomKind: RoomKind { get } + /** + * Is the history world-readable for this room? + */ + var isHistoryWorldReadable: Bool? { get } + /** + * The membership state for the current user, if known. + */ + var userMembership: Membership? { get } + + /** + * The join rule for this room (private, public, knock, etc.). + */ + var joinRuleInfo: JoinRule? { get } + /** + * Whether the room is direct or not, if known. + */ + var isDirect: Bool? { get } + /** + * Room heroes. + */ + // var heroes: [RoomHero]? { get } } public struct MockRoomPreviewInfo: RoomPreviewInfo { - public init() {} - - public var userMembership: Membership? { - nil - } - - public var joinRuleInfo: JoinRule? { - .public - } - - public var roomKind: RoomKind { - .room - } - - public var roomId: String { - "roomId" - } - - public var canonicalAlias: String? { - "#room:matrix.org" - } - - public var name: String? { - "Room Name" - } - - public var topic: String? { - "The topic of the **room** being previewed" - } - - public var avatarUrl: String? { - nil - } - - public var numJoinedMembers: UInt64 { - 22 - } - - public var numActiveMembers: UInt64? { - 4 - } - - public var isHistoryWorldReadable: Bool? { - true - } - - public var isDirect: Bool? { - false - } + public init() {} + + public var userMembership: Membership? { + nil + } + + public var joinRuleInfo: JoinRule? { + .public + } + + public var roomKind: RoomKind { + .room + } + + public var roomId: String { + "roomId" + } + + public var canonicalAlias: String? { + "#room:matrix.org" + } + + public var name: String? { + "Room Name" + } + + public var topic: String? { + "The topic of the **room** being previewed" + } + + public var avatarUrl: String? { + nil + } + + public var numJoinedMembers: UInt64 { + 22 + } + + public var numActiveMembers: UInt64? { + 4 + } + + public var isHistoryWorldReadable: Bool? { + true + } + + public var isDirect: Bool? { + false + } } diff --git a/Sources/MatrixProtocols/SessionVerificationData.swift b/Sources/MatrixProtocols/SessionVerificationData.swift index 3d56a38..f79ebed 100644 --- a/Sources/MatrixProtocols/SessionVerificationData.swift +++ b/Sources/MatrixProtocols/SessionVerificationData.swift @@ -1,23 +1,23 @@ import Foundation public enum SessionVerificationData { - case emojis(emojis: [VerificationEmoji], indices: Data) - case decimals(values: [UInt16]) + case emojis(emojis: [VerificationEmoji], indices: Data) + case decimals(values: [UInt16]) } public protocol SessionVerificationEmoji: Identifiable { - var description: String { get } - var symbol: String { get } + var description: String { get } + var symbol: String { get } } public struct MockSessionVerificationEmoji: SessionVerificationEmoji { - public var id: String { symbol } + public var id: String { symbol } - public var description: String - public var symbol: String + public var description: String + public var symbol: String - public init(description: String, symbol: String) { - self.description = description - self.symbol = symbol - } + public init(description: String, symbol: String) { + self.description = description + self.symbol = symbol + } } diff --git a/Sources/MatrixProtocols/ThreadSummary.swift b/Sources/MatrixProtocols/ThreadSummary.swift index 3064dce..b7ed70c 100644 --- a/Sources/MatrixProtocols/ThreadSummary.swift +++ b/Sources/MatrixProtocols/ThreadSummary.swift @@ -1,21 +1,21 @@ public protocol ThreadSummary { - func numReplies() -> UInt64 + func numReplies() -> UInt64 - var description: String? { get } + var description: String? { get } } public struct MockThreadSummary: ThreadSummary { - public init() {} + public init() {} - public func numReplies() -> UInt64 { - 4 - } + public func numReplies() -> UInt64 { + 4 + } - public var description: String? { - """ - This is a thread message, that is _very_ long. + public var description: String? { + """ + This is a thread message, that is _very_ long. - It event has multiple lines. - """ - } + It event has multiple lines. + """ + } } diff --git a/Sources/MatrixProtocols/UserProfile.swift b/Sources/MatrixProtocols/UserProfile.swift index afac5ea..b1ff9c0 100644 --- a/Sources/MatrixProtocols/UserProfile.swift +++ b/Sources/MatrixProtocols/UserProfile.swift @@ -1,25 +1,25 @@ import Foundation public protocol UserProfile: Identifiable { - var userId: String { get } - var displayName: String? { get } - var avatarUrl: String? { get } + var userId: String { get } + var displayName: String? { get } + var avatarUrl: String? { get } } -public extension UserProfile { - var id: String { userId } +extension UserProfile { + public var id: String { userId } } public struct MockUserProfile: UserProfile { - public init() {} + public init() {} - public var userId: String { - "@user:matrix.org" - } + public var userId: String { + "@user:matrix.org" + } - public var displayName: String? { - "Matrix User" - } + public var displayName: String? { + "Matrix User" + } - public var avatarUrl: String? { nil } + public var avatarUrl: String? { nil } } diff --git a/Sources/MatrixProtocols/VirtualTimelineItem.swift b/Sources/MatrixProtocols/VirtualTimelineItem.swift index 739698d..83f0df0 100644 --- a/Sources/MatrixProtocols/VirtualTimelineItem.swift +++ b/Sources/MatrixProtocols/VirtualTimelineItem.swift @@ -1,19 +1,19 @@ import Foundation public enum VirtualTimelineItem { - /** - * A divider between messages of different day or month depending on - * timeline settings. - */ - case dateDivider( - date: Date - ) - /** - * The user's own read marker. - */ - case readMarker - /** - * The timeline start, that is, the *oldest* event in time for that room. - */ - case timelineStart + /** + * A divider between messages of different day or month depending on + * timeline settings. + */ + case dateDivider( + date: Date + ) + /** + * The user's own read marker. + */ + case readMarker + /** + * The timeline start, that is, the *oldest* event in time for that room. + */ + case timelineStart } diff --git a/Sources/MessageFormatting/AttributedTextView.swift b/Sources/MessageFormatting/AttributedTextView.swift index d089193..a88dae0 100644 --- a/Sources/MessageFormatting/AttributedTextView.swift +++ b/Sources/MessageFormatting/AttributedTextView.swift @@ -1,56 +1,61 @@ import SwiftUI public struct AttributedTextView: NSViewRepresentable { - public let attributedString: NSAttributedString + public let attributedString: NSAttributedString - public init(attributedString: NSAttributedString) { - self.attributedString = attributedString.trimmed - } + public init(attributedString: NSAttributedString) { + self.attributedString = attributedString.trimmed + } - public func makeNSView(context: Context) -> NSTextField { - let textField = NSTextField(labelWithAttributedString: attributedString) + public func makeNSView(context: Context) -> NSTextField { + let textField = NSTextField(labelWithAttributedString: attributedString) - textField.isEditable = false - textField.isSelectable = true - textField.allowsEditingTextAttributes = true + textField.isEditable = false + textField.isSelectable = true + textField.allowsEditingTextAttributes = true - textField.lineBreakStrategy = .standard - textField.lineBreakMode = .byWordWrapping - textField.usesSingleLineMode = false + textField.lineBreakStrategy = .standard + textField.lineBreakMode = .byWordWrapping + textField.usesSingleLineMode = false - return textField - } + return textField + } - public func updateNSView(_ textField: NSTextField, context: Context) { - if textField.attributedStringValue != attributedString { - textField.attributedStringValue = attributedString - } + public func updateNSView(_ textField: NSTextField, context: Context) { + if textField.attributedStringValue != attributedString { + textField.attributedStringValue = attributedString } - - public func sizeThatFits(_ proposal: ProposedViewSize, nsView textField: NSTextField, context: Context) -> CGSize? { - guard let width = proposal.width, width > 0, width != .infinity else { return nil } - - textField.preferredMaxLayoutWidth = width - guard let size = textField.cell?.cellSize(forBounds: NSRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude)) else { - return nil - } - - return CGSize(width: ceil(size.width), height: ceil(size.height)) + } + + public func sizeThatFits( + _ proposal: ProposedViewSize, nsView textField: NSTextField, context: Context + ) -> CGSize? { + guard let width = proposal.width, width > 0, width != .infinity else { return nil } + + textField.preferredMaxLayoutWidth = width + guard + let size = textField.cell?.cellSize( + forBounds: NSRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude)) + else { + return nil } + + return CGSize(width: ceil(size.width), height: ceil(size.height)) + } } extension NSAttributedString { - var trimmed: NSAttributedString { - let nonWhitespaces = CharacterSet.whitespacesAndNewlines.inverted - let startRange = string.rangeOfCharacter(from: nonWhitespaces) - let endRange = string.rangeOfCharacter(from: nonWhitespaces, options: .backwards) - guard let startLocation = startRange?.upperBound, let endLocation = endRange?.lowerBound else { - return self - } - let location = string.distance(from: string.startIndex, to: startLocation) - 1 - let length = string.distance(from: startLocation, to: endLocation) + 2 - let range = NSRange(location: location, length: length) - - return attributedSubstring(from: range) + var trimmed: NSAttributedString { + let nonWhitespaces = CharacterSet.whitespacesAndNewlines.inverted + let startRange = string.rangeOfCharacter(from: nonWhitespaces) + let endRange = string.rangeOfCharacter(from: nonWhitespaces, options: .backwards) + guard let startLocation = startRange?.upperBound, let endLocation = endRange?.lowerBound else { + return self } + let location = string.distance(from: string.startIndex, to: startLocation) - 1 + let length = string.distance(from: startLocation, to: endLocation) + 2 + let range = NSRange(location: location, length: length) + + return attributedSubstring(from: range) + } } diff --git a/Sources/MessageFormatting/Parser.swift b/Sources/MessageFormatting/Parser.swift index d4ca40f..870c23f 100644 --- a/Sources/MessageFormatting/Parser.swift +++ b/Sources/MessageFormatting/Parser.swift @@ -4,84 +4,85 @@ import ZMarkupParser @MainActor public func parseFormattedBody(_ body: String, baseFontSize: CGFloat = 13) -> NSAttributedString { - let headingParagraphSpacing = MarkupStyleParagraphStyle( - // paragraphSpacing: 5, - paragraphSpacingBefore: baseFontSize * 0.8 - ) + let headingParagraphSpacing = MarkupStyleParagraphStyle( + // paragraphSpacing: 5, + paragraphSpacingBefore: baseFontSize * 0.8 + ) - let parser = ZHTMLParserBuilder - .initWithDefault() - .set(rootStyle: MarkupStyle(font: MarkupStyleFont(size: baseFontSize))) - .add( - H1_HTMLTagName(), - withCustomStyle: MarkupStyle( - font: MarkupStyleFont(size: baseFontSize * 1.8), - paragraphStyle: headingParagraphSpacing - ) - ) - .add( - H2_HTMLTagName(), - withCustomStyle: MarkupStyle( - font: MarkupStyleFont(size: baseFontSize * 1.4), - paragraphStyle: headingParagraphSpacing - ) - ) - .add( - H3_HTMLTagName(), - withCustomStyle: MarkupStyle( - font: MarkupStyleFont(size: baseFontSize * 1.2), - paragraphStyle: headingParagraphSpacing - ) - ) - .add( - H4_HTMLTagName(), - withCustomStyle: MarkupStyle( - font: MarkupStyleFont(size: baseFontSize * 1.1, weight: .style(.medium)), - paragraphStyle: headingParagraphSpacing - ) - ) - .add( - H5_HTMLTagName(), - withCustomStyle: MarkupStyle( - font: MarkupStyleFont(size: baseFontSize, weight: .style(.semibold)), - paragraphStyle: headingParagraphSpacing - ) - ) - .add( - H6_HTMLTagName(), - withCustomStyle: MarkupStyle( - font: MarkupStyleFont(size: baseFontSize, weight: .style(.semibold)), - paragraphStyle: headingParagraphSpacing - ) - ) - .add( - P_HTMLTagName(), - withCustomStyle: MarkupStyle( - paragraphStyle: MarkupStyleParagraphStyle( - paragraphSpacing: baseFontSize * 0.4, - paragraphSpacingBefore: baseFontSize * 0.4 - ) - ) - ) - .add( - CODE_HTMLTagName(), - withCustomStyle: MarkupStyle( - font: MarkupStyleFont(size: baseFontSize, familyName: .familyNames(["Menlo"])) - // backgroundColor: .init(color: NSColor(red: 0.8, green: 0.8, blue: 1, alpha: 0.5)) - ) + let parser = + ZHTMLParserBuilder + .initWithDefault() + .set(rootStyle: MarkupStyle(font: MarkupStyleFont(size: baseFontSize))) + .add( + H1_HTMLTagName(), + withCustomStyle: MarkupStyle( + font: MarkupStyleFont(size: baseFontSize * 1.8), + paragraphStyle: headingParagraphSpacing + ) + ) + .add( + H2_HTMLTagName(), + withCustomStyle: MarkupStyle( + font: MarkupStyleFont(size: baseFontSize * 1.4), + paragraphStyle: headingParagraphSpacing + ) + ) + .add( + H3_HTMLTagName(), + withCustomStyle: MarkupStyle( + font: MarkupStyleFont(size: baseFontSize * 1.2), + paragraphStyle: headingParagraphSpacing + ) + ) + .add( + H4_HTMLTagName(), + withCustomStyle: MarkupStyle( + font: MarkupStyleFont(size: baseFontSize * 1.1, weight: .style(.medium)), + paragraphStyle: headingParagraphSpacing + ) + ) + .add( + H5_HTMLTagName(), + withCustomStyle: MarkupStyle( + font: MarkupStyleFont(size: baseFontSize, weight: .style(.semibold)), + paragraphStyle: headingParagraphSpacing + ) + ) + .add( + H6_HTMLTagName(), + withCustomStyle: MarkupStyle( + font: MarkupStyleFont(size: baseFontSize, weight: .style(.semibold)), + paragraphStyle: headingParagraphSpacing + ) + ) + .add( + P_HTMLTagName(), + withCustomStyle: MarkupStyle( + paragraphStyle: MarkupStyleParagraphStyle( + paragraphSpacing: baseFontSize * 0.4, + paragraphSpacingBefore: baseFontSize * 0.4 ) - .build() + ) + ) + .add( + CODE_HTMLTagName(), + withCustomStyle: MarkupStyle( + font: MarkupStyleFont(size: baseFontSize, familyName: .familyNames(["Menlo"])) + // backgroundColor: .init(color: NSColor(red: 0.8, green: 0.8, blue: 1, alpha: 0.5)) + ) + ) + .build() - let rendered = parser.render(body) - let characters = rendered.string as NSString - var length = characters.length + let rendered = parser.render(body) + let characters = rendered.string as NSString + var length = characters.length - while length > 0, - let character = Unicode.Scalar(characters.character(at: length - 1)), - CharacterSet.whitespacesAndNewlines.contains(character) - { - length -= 1 - } + while length > 0, + let character = Unicode.Scalar(characters.character(at: length - 1)), + CharacterSet.whitespacesAndNewlines.contains(character) + { + length -= 1 + } - return rendered.attributedSubstring(from: NSRange(location: 0, length: length)) + return rendered.attributedSubstring(from: NSRange(location: 0, length: length)) } diff --git a/Sources/MessageFormatting/Preview.swift b/Sources/MessageFormatting/Preview.swift index 38ea8ce..43a9ec4 100644 --- a/Sources/MessageFormatting/Preview.swift +++ b/Sources/MessageFormatting/Preview.swift @@ -1,7 +1,7 @@ import SwiftUI #Preview { - let sample = """ + let sample = """

This is a title

Header 1

@@ -67,6 +67,6 @@ import SwiftUI This is a link to google.com """ - AttributedTextView(attributedString: parseFormattedBody(sample)) - .padding() + AttributedTextView(attributedString: parseFormattedBody(sample)) + .padding() } diff --git a/Sources/Utils/MatrixUriScheme.swift b/Sources/Utils/MatrixUriScheme.swift index d3745e5..a43cbb8 100644 --- a/Sources/Utils/MatrixUriScheme.swift +++ b/Sources/Utils/MatrixUriScheme.swift @@ -1,81 +1,81 @@ import Foundation public enum MatrixUriError: LocalizedError { - case parserError(reason: LocalizedStringResource) + case parserError(reason: LocalizedStringResource) - public var errorDescription: String? { - switch self { - case .parserError(let reason): - return "failed to parse matrix uri: \(reason)" - } + public var errorDescription: String? { + switch self { + case .parserError(let reason): + return "failed to parse matrix uri: \(reason)" } + } } public struct MatrixUriScheme: Hashable, Codable { - public let kind: Kind - public let routingVia: [String] - public let action: Action? - public let event: String? + public let kind: Kind + public let routingVia: [String] + public let action: Action? + public let event: String? - public enum Kind: Hashable, Codable { - case roomId(String) - case roomAlias(String) - case user(String) - } + public enum Kind: Hashable, Codable { + case roomId(String) + case roomAlias(String) + case user(String) + } - public enum Action: Hashable, Codable { - case join, chat - } + public enum Action: Hashable, Codable { + case join, chat + } - public init(parseUrl url: String) throws(MatrixUriError) { - let urlRegex = /matrix:(?:\/\/[^\/])?(roomid|u|r)\/([^\/\?]+)(\/e\/[^\/\?]+)?(?:\?(.*))?/ + public init(parseUrl url: String) throws(MatrixUriError) { + let urlRegex = /matrix:(?:\/\/[^\/])?(roomid|u|r)\/([^\/\?]+)(\/e\/[^\/\?]+)?(?:\?(.*))?/ - guard let match = try? urlRegex.wholeMatch(in: url) else { - throw .parserError(reason: "did not match url structure") - } + guard let match = try? urlRegex.wholeMatch(in: url) else { + throw .parserError(reason: "did not match url structure") + } - let (_, kind, id, _ /* event */, query) = match.output + let (_, kind, id, _ /* event */, query) = match.output - switch kind { - case "roomid": - self.kind = .roomId("!\(id)") - case "r": - self.kind = .roomAlias("#\(id)") - case "u": - self.kind = .user("@\(id)") - default: - throw .parserError(reason: "invalid type \(kind)") - } + switch kind { + case "roomid": + self.kind = .roomId("!\(id)") + case "r": + self.kind = .roomAlias("#\(id)") + case "u": + self.kind = .user("@\(id)") + default: + throw .parserError(reason: "invalid type \(kind)") + } - let queryComponents = query?.split(separator: "&") ?? [] + let queryComponents = query?.split(separator: "&") ?? [] - var routingVia = [String]() - var action: Action? = nil + var routingVia = [String]() + var action: Action? = nil - for queryComponent in queryComponents { - let component = queryComponent.split(separator: "=") - guard component.count == 2 else { continue } + for queryComponent in queryComponents { + let component = queryComponent.split(separator: "=") + guard component.count == 2 else { continue } - switch component[0] { - case "via": - routingVia.append(String(component[1])) - case "action": - switch component[1] { - case "join": - action = .join - case "chat": - action = .chat - default: - continue - } - default: - continue - } + switch component[0] { + case "via": + routingVia.append(String(component[1])) + case "action": + switch component[1] { + case "join": + action = .join + case "chat": + action = .chat + default: + continue } - - // todo - self.routingVia = routingVia - self.action = action - self.event = nil + default: + continue + } } + + // todo + self.routingVia = routingVia + self.action = action + self.event = nil + } } diff --git a/Tests/UtilsTests/MatrixUriSchemeTests.swift b/Tests/UtilsTests/MatrixUriSchemeTests.swift index fff9eea..521b16a 100644 --- a/Tests/UtilsTests/MatrixUriSchemeTests.swift +++ b/Tests/UtilsTests/MatrixUriSchemeTests.swift @@ -3,29 +3,29 @@ import Testing @testable import Utils struct MatrixUriSchemeTests { - @Test func parseSimpleRoomAlias() throws { - let url = "matrix:r/somewhere:example.org" - - let parsed = try Utils.MatrixUriScheme(parseUrl: url) - - #expect(parsed.kind == .roomAlias("#somewhere:example.org")) - } - - @Test func parseSimpleRoomId() throws { - let url = "matrix:roomid/somewhere:example.org?via=elsewhere.ca" - - let parsed = try Utils.MatrixUriScheme(parseUrl: url) - - #expect(parsed.kind == .roomId("!somewhere:example.org")) - #expect(parsed.routingVia == ["elsewhere.ca"]) - } - - @Test func parseUserChat() throws { - let url = "matrix:u/alice:example.org?action=chat" - - let parsed = try Utils.MatrixUriScheme(parseUrl: url) - - #expect(parsed.kind == .user("@alice:example.org")) - #expect(parsed.action == .chat) - } + @Test func parseSimpleRoomAlias() throws { + let url = "matrix:r/somewhere:example.org" + + let parsed = try Utils.MatrixUriScheme(parseUrl: url) + + #expect(parsed.kind == .roomAlias("#somewhere:example.org")) + } + + @Test func parseSimpleRoomId() throws { + let url = "matrix:roomid/somewhere:example.org?via=elsewhere.ca" + + let parsed = try Utils.MatrixUriScheme(parseUrl: url) + + #expect(parsed.kind == .roomId("!somewhere:example.org")) + #expect(parsed.routingVia == ["elsewhere.ca"]) + } + + @Test func parseUserChat() throws { + let url = "matrix:u/alice:example.org?action=chat" + + let parsed = try Utils.MatrixUriScheme(parseUrl: url) + + #expect(parsed.kind == .user("@alice:example.org")) + #expect(parsed.action == .chat) + } } From 9bd82d75c29df775d103e541e39ea6abaf294e1e Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Wed, 30 Sep 2026 10:06:36 +0200 Subject: [PATCH 6/9] Make swift tests runnable from XCode --- .github/workflows/xcode-build.yml | 23 ++++------ .../xcschemes/MactrixApp.xcscheme | 46 +++++++++++-------- Mactrix.xcodeproj/project.pbxproj | 21 +++++---- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../xcshareddata/xcschemes/Mactrix.xcscheme | 2 +- .../TestPlan.xctestplan => Mactrix.xctestplan | 10 ++-- Mactrix.xcworkspace/contents.xcworkspacedata | 13 ++++++ Mactrix/Extensions/Logger.swift | 20 -------- 8 files changed, 68 insertions(+), 69 deletions(-) rename Mactrix.xcodeproj/xcshareddata/xcschemes/MactrixTests.xcscheme => .swiftpm/xcode/xcshareddata/xcschemes/MactrixApp.xcscheme (63%) rename Mactrix/TestPlan.xctestplan => Mactrix.xctestplan (50%) create mode 100644 Mactrix.xcworkspace/contents.xcworkspacedata delete mode 100644 Mactrix/Extensions/Logger.swift diff --git a/.github/workflows/xcode-build.yml b/.github/workflows/xcode-build.yml index ed721dd..b8aa602 100644 --- a/.github/workflows/xcode-build.yml +++ b/.github/workflows/xcode-build.yml @@ -2,18 +2,18 @@ name: Xcode - Build and Test on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] -jobs: +jobs: build: name: Build and Test strategy: max-parallel: 2 fail-fast: false matrix: - macos-version: [macos-15, macos-26] + macos-version: [macos-15, macos-26, macos-27] runs-on: ${{ matrix.macos-version }} env: scheme: ${{ 'Mactrix' }} @@ -31,14 +31,11 @@ jobs: -destination 'platform=macOS' | xcpretty && exit ${PIPESTATUS[0]} - name: Test run: | - xcodebuild test \ - -scheme "$scheme" \ - -project Mactrix.xcodeproj \ - -destination 'platform=macOS' | xcpretty && exit ${PIPESTATUS[0]} + swift test - name: Build release artifact if: | - matrix.macos-version == 'macos-26' && - github.event_name == 'push' && + matrix.macos-version == 'macos-27' && + github.event_name == 'push' && github.ref == 'refs/heads/main' run: | xcodebuild archive \ @@ -49,12 +46,10 @@ jobs: -archivePath ./Mactrix.xcarchive | xcpretty && exit ${PIPESTATUS[0]} - name: Upload Artifact if: | - matrix.macos-version == 'macos-26' && - github.event_name == 'push' && + matrix.macos-version == 'macos-26' && + github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@v4 with: name: Mactrix.app path: ./Mactrix.xcarchive/Products/Applications/ - - diff --git a/Mactrix.xcodeproj/xcshareddata/xcschemes/MactrixTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MactrixApp.xcscheme similarity index 63% rename from Mactrix.xcodeproj/xcshareddata/xcschemes/MactrixTests.xcscheme rename to .swiftpm/xcode/xcshareddata/xcschemes/MactrixApp.xcscheme index ddec28c..77c12cb 100644 --- a/Mactrix.xcodeproj/xcshareddata/xcschemes/MactrixTests.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/MactrixApp.xcscheme @@ -1,11 +1,26 @@ + + + + + + - - - - - - - - + allowLocationSimulation = "YES" + queueDebuggingEnabled = "No"> + + + + diff --git a/Mactrix.xcodeproj/project.pbxproj b/Mactrix.xcodeproj/project.pbxproj index fc6edd7..8241663 100644 --- a/Mactrix.xcodeproj/project.pbxproj +++ b/Mactrix.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 3444A623306CF7A000673405 /* MactrixApp in Frameworks */ = {isa = PBXBuildFile; productRef = 3444A622306CF7A000673405 /* MactrixApp */; }; 34A3DEF5306BF85D00866303 /* MactrixApp in Frameworks */ = {isa = PBXBuildFile; productRef = 34A3DEF4306BF85D00866303 /* MactrixApp */; }; /* End PBXBuildFile section */ @@ -41,24 +42,17 @@ buildActionMask = 2147483647; files = ( 34A3DEF5306BF85D00866303 /* MactrixApp in Frameworks */, + 3444A623306CF7A000673405 /* MactrixApp in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 342480052EBF91B500C85D82 /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; 343858392EB394590010922A = { isa = PBXGroup; children = ( 343858442EB394590010922A /* Mactrix */, - 342480052EBF91B500C85D82 /* Frameworks */, 343858432EB394590010922A /* Products */, ); sourceTree = ""; @@ -92,6 +86,7 @@ name = Mactrix; packageProductDependencies = ( 34A3DEF4306BF85D00866303 /* MactrixApp */, + 3444A622306CF7A000673405 /* MactrixApp */, ); productName = Mactrix; productReference = 343858422EB394590010922A /* Mactrix.app */; @@ -122,7 +117,7 @@ mainGroup = 343858392EB394590010922A; minimizedProjectReferenceProxies = 1; packageReferences = ( - 34A3DEF3306BF85D00866303 /* XCLocalSwiftPackageReference "../Mactrix" */, + 3444A621306CF7A000673405 /* XCLocalSwiftPackageReference "../Mactrix" */, ); preferredProjectObjectVersion = 77; productRefGroup = 343858432EB394590010922A /* Products */; @@ -311,6 +306,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 15; MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = dk.qpqp.mactrix; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -363,6 +359,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 15; MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = dk.qpqp.mactrix; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -405,13 +402,17 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 34A3DEF3306BF85D00866303 /* XCLocalSwiftPackageReference "../Mactrix" */ = { + 3444A621306CF7A000673405 /* XCLocalSwiftPackageReference "../Mactrix" */ = { isa = XCLocalSwiftPackageReference; relativePath = ../Mactrix; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 3444A622306CF7A000673405 /* MactrixApp */ = { + isa = XCSwiftPackageProductDependency; + productName = MactrixApp; + }; 34A3DEF4306BF85D00866303 /* MactrixApp */ = { isa = XCSwiftPackageProductDependency; productName = MactrixApp; diff --git a/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c9e05e5..fe709ab 100644 --- a/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "635d716be3a94a148224a9984120ddd8f677bd24074126e4034d778a59c72f3a", + "originHash" : "f304e521852bedb10afeddd93f956b71ea9eb9fdf73fce50bb6f66df593ce2d6", "pins" : [ { "identity" : "matrix-rust-components-swift", diff --git a/Mactrix.xcodeproj/xcshareddata/xcschemes/Mactrix.xcscheme b/Mactrix.xcodeproj/xcshareddata/xcschemes/Mactrix.xcscheme index 81e07e0..8c4e20a 100644 --- a/Mactrix.xcodeproj/xcshareddata/xcschemes/Mactrix.xcscheme +++ b/Mactrix.xcodeproj/xcshareddata/xcschemes/Mactrix.xcscheme @@ -30,7 +30,7 @@ shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/Mactrix/TestPlan.xctestplan b/Mactrix.xctestplan similarity index 50% rename from Mactrix/TestPlan.xctestplan rename to Mactrix.xctestplan index 568f157..43a6c56 100644 --- a/Mactrix/TestPlan.xctestplan +++ b/Mactrix.xctestplan @@ -1,20 +1,22 @@ { "configurations" : [ { - "id" : "2B8801C5-EA6C-461B-83CC-FCDA249AA12E", - "name" : "Configuration 1", + "id" : "FC9941BB-17A8-4253-9496-7943E2509EB5", + "name" : "Test Scheme Action", "options" : { } } ], "defaultOptions" : { - "testTimeoutsEnabled" : true + "codeCoverage" : false, + "performanceAntipatternCheckerEnabled" : true, + "testInteropMode" : "complete" }, "testTargets" : [ { "target" : { - "containerPath" : "container:MactrixLibrary", + "containerPath" : "container:", "identifier" : "UtilsTests", "name" : "UtilsTests" } diff --git a/Mactrix.xcworkspace/contents.xcworkspacedata b/Mactrix.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..16d8565 --- /dev/null +++ b/Mactrix.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/Mactrix/Extensions/Logger.swift b/Mactrix/Extensions/Logger.swift deleted file mode 100644 index f2951c7..0000000 --- a/Mactrix/Extensions/Logger.swift +++ /dev/null @@ -1,20 +0,0 @@ -import OSLog - -extension Logger { - private static let subsystem = Bundle.main.bundleIdentifier! - - static let matrixClient = Logger(subsystem: subsystem, category: "matrix-client") - static let windowState = Logger(subsystem: subsystem, category: "window-state") - - static let liveRoom = Logger(subsystem: subsystem, category: "live-room") - static let liveSpaceService = Logger(subsystem: subsystem, category: "live-space-service") - static let liveSpaceRoomList = Logger(subsystem: subsystem, category: "live-space-room-list") - static let liveTimeline = Logger(subsystem: subsystem, category: "live-timeline") - static let timelineTableView = Logger(subsystem: subsystem, category: "timeline-table-view") - static let SidebarRoom = Logger(subsystem: subsystem, category: "sidebar-room") - static let chatTextView = Logger(subsystem: subsystem, category: "chat-text-view") - - static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") - - static let notification = Logger(subsystem: subsystem, category: "notification") -} From 12165b1e43cffe331b512a837446113743abab5e Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Wed, 30 Sep 2026 10:19:24 +0200 Subject: [PATCH 7/9] Update github workflow to better reuse cache --- .github/workflows/xcode-build.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/xcode-build.yml b/.github/workflows/xcode-build.yml index b8aa602..5cec2e3 100644 --- a/.github/workflows/xcode-build.yml +++ b/.github/workflows/xcode-build.yml @@ -27,11 +27,14 @@ jobs: run: | xcodebuild clean build analyze \ -scheme "$scheme" \ - -project Mactrix.xcodeproj \ + -workspace Mactrix.xcworkspace \ -destination 'platform=macOS' | xcpretty && exit ${PIPESTATUS[0]} - name: Test run: | - swift test + xcodebuild test \ + -workspace Mactrix.xcworkspace \ + -scheme "$scheme" \ + -destination 'platform=macOS' | xcpretty && exit ${PIPESTATUS[0]} - name: Build release artifact if: | matrix.macos-version == 'macos-27' && @@ -39,7 +42,7 @@ jobs: github.ref == 'refs/heads/main' run: | xcodebuild archive \ - -project Mactrix.xcodeproj \ + -workspace Mactrix.xcworkspace \ -scheme "$scheme" \ -configuration Release \ -destination 'platform=macOS' \ From 05d00e9fe10614dbe049ffc2e6491ad3bcf7f985 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Wed, 30 Sep 2026 10:23:21 +0200 Subject: [PATCH 8/9] Fix runner name in xcode workflow --- .github/workflows/xcode-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/xcode-build.yml b/.github/workflows/xcode-build.yml index 5cec2e3..7e12357 100644 --- a/.github/workflows/xcode-build.yml +++ b/.github/workflows/xcode-build.yml @@ -13,7 +13,7 @@ jobs: max-parallel: 2 fail-fast: false matrix: - macos-version: [macos-15, macos-26, macos-27] + macos-version: [macos-15, macos-26, xcode-27] runs-on: ${{ matrix.macos-version }} env: scheme: ${{ 'Mactrix' }} From 1b906bcb3b7b33c8a1be7e884be85fe11339ca4f Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Wed, 30 Sep 2026 10:30:03 +0200 Subject: [PATCH 9/9] Fix typo in CI script --- .github/workflows/xcode-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/xcode-build.yml b/.github/workflows/xcode-build.yml index 7e12357..22b0561 100644 --- a/.github/workflows/xcode-build.yml +++ b/.github/workflows/xcode-build.yml @@ -37,7 +37,7 @@ jobs: -destination 'platform=macOS' | xcpretty && exit ${PIPESTATUS[0]} - name: Build release artifact if: | - matrix.macos-version == 'macos-27' && + matrix.macos-version == 'macos-26' && github.event_name == 'push' && github.ref == 'refs/heads/main' run: |