diff --git a/.github/workflows/xcode-build.yml b/.github/workflows/xcode-build.yml index ed721dd..22b0561 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, xcode-27] runs-on: ${{ matrix.macos-version }} env: scheme: ${{ 'Mactrix' }} @@ -27,34 +27,32 @@ jobs: run: | xcodebuild clean build analyze \ -scheme "$scheme" \ - -project Mactrix.xcodeproj \ + -workspace Mactrix.xcworkspace \ -destination 'platform=macOS' | xcpretty && exit ${PIPESTATUS[0]} - name: Test run: | xcodebuild test \ + -workspace Mactrix.xcworkspace \ -scheme "$scheme" \ - -project Mactrix.xcodeproj \ -destination 'platform=macOS' | xcpretty && exit ${PIPESTATUS[0]} - name: Build release 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' run: | xcodebuild archive \ - -project Mactrix.xcodeproj \ + -workspace Mactrix.xcworkspace \ -scheme "$scheme" \ -configuration Release \ -destination 'platform=macOS' \ -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/.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/.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/.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/MactrixLibrary/.swiftpm/xcode/xcshareddata/xcschemes/Utils.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MactrixApp.xcscheme similarity index 70% rename from MactrixLibrary/.swiftpm/xcode/xcshareddata/xcschemes/Utils.xcscheme rename to .swiftpm/xcode/xcshareddata/xcschemes/MactrixApp.xcscheme index 36cae6f..77c12cb 100644 --- a/MactrixLibrary/.swiftpm/xcode/xcshareddata/xcschemes/Utils.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/MactrixApp.xcscheme @@ -1,6 +1,6 @@ @@ -27,20 +26,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> - - - - - - + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + allowLocationSimulation = "YES" + queueDebuggingEnabled = "No"> diff --git a/Mactrix.xcodeproj/project.pbxproj b/Mactrix.xcodeproj/project.pbxproj index c9935b6..8241663 100644 --- a/Mactrix.xcodeproj/project.pbxproj +++ b/Mactrix.xcodeproj/project.pbxproj @@ -7,16 +7,11 @@ objects = { /* Begin PBXBuildFile section */ - 3409F4EC2EBFD8D4009537B4 /* UI in Frameworks */ = {isa = PBXBuildFile; productRef = 3409F4EB2EBFD8D4009537B4 /* UI */; }; - 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 */; }; + 3444A623306CF7A000673405 /* MactrixApp in Frameworks */ = {isa = PBXBuildFile; productRef = 3444A622306CF7A000673405 /* MactrixApp */; }; + 34A3DEF5306BF85D00866303 /* MactrixApp in Frameworks */ = {isa = PBXBuildFile; productRef = 34A3DEF4306BF85D00866303 /* MactrixApp */; }; /* 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 */ @@ -46,32 +41,19 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 349B041C2F5D77E80023FBF4 /* MessageFormatting in Frameworks */, - 3409F4EC2EBFD8D4009537B4 /* UI in Frameworks */, - 34913F6E2EC0F532003034CB /* MatrixRustSDK in Frameworks */, - 345E77E82ED9F309002E5B9A /* Utils in Frameworks */, - 34913F702EC0F59F003034CB /* Models in Frameworks */, - 348E99882F40ABC3009F57A9 /* AsyncAlgorithms in Frameworks */, + 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 */, - 3409F6012EBFDB93009537B4 /* MactrixLibrary */, ); sourceTree = ""; }; @@ -103,12 +85,8 @@ ); name = Mactrix; packageProductDependencies = ( - 3409F4EB2EBFD8D4009537B4 /* UI */, - 34913F6D2EC0F532003034CB /* MatrixRustSDK */, - 34913F6F2EC0F59F003034CB /* Models */, - 345E77E72ED9F309002E5B9A /* Utils */, - 348E99872F40ABC3009F57A9 /* AsyncAlgorithms */, - 349B041B2F5D77E80023FBF4 /* MessageFormatting */, + 34A3DEF4306BF85D00866303 /* MactrixApp */, + 3444A622306CF7A000673405 /* MactrixApp */, ); productName = Mactrix; productReference = 343858422EB394590010922A /* Mactrix.app */; @@ -139,9 +117,7 @@ mainGroup = 343858392EB394590010922A; minimizedProjectReferenceProxies = 1; packageReferences = ( - 342C624C2EB4DDE600E2426A /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */, - 3409F4E82EBFD8D4009537B4 /* XCLocalSwiftPackageReference "MactrixLibrary" */, - 348E99862F40ABC3009F57A9 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, + 3444A621306CF7A000673405 /* XCLocalSwiftPackageReference "../Mactrix" */, ); preferredProjectObjectVersion = 77; productRefGroup = 343858432EB394590010922A /* Products */; @@ -330,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)"; @@ -382,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)"; @@ -424,57 +402,20 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 3409F4E82EBFD8D4009537B4 /* XCLocalSwiftPackageReference "MactrixLibrary" */ = { + 3444A621306CF7A000673405 /* XCLocalSwiftPackageReference "../Mactrix" */ = { isa = XCLocalSwiftPackageReference; - relativePath = MactrixLibrary; + 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; - }; - 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; - }; - 34913F6F2EC0F59F003034CB /* Models */ = { + 3444A622306CF7A000673405 /* MactrixApp */ = { isa = XCSwiftPackageProductDependency; - productName = Models; + productName = MactrixApp; }; - 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 5e8c194..fe709ab 100644 --- a/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "2a437ca29320ff65154ec5a0ec9d5acc42dc07fb02366dcc6b167b1cddc56c19", + "originHash" : "f304e521852bedb10afeddd93f956b71ea9eb9fdf73fce50bb6f66df593ce2d6", "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.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.xcodeproj/xcshareddata/xcschemes/MactrixTests.xcscheme b/Mactrix.xcodeproj/xcshareddata/xcschemes/MactrixTests.xcscheme deleted file mode 100644 index ddec28c..0000000 --- a/Mactrix.xcodeproj/xcshareddata/xcschemes/MactrixTests.xcscheme +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - 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/Commands.swift b/Mactrix/Commands.swift deleted file mode 100644 index d3938e3..0000000 --- a/Mactrix/Commands.swift +++ /dev/null @@ -1,84 +0,0 @@ -import SwiftUI - -struct AppCommands: Commands { - @FocusedValue(WindowState.self) private var windowState: WindowState? - @AppStorage("fontSize") var fontSize: Int = 13 - - 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 - } - - guard let windowController = currentMainWindow.windowController 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]) - - 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]) - } - - 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 -= 1 - - } label: { - Text("Make Text Smaller") - } - .keyboardShortcut("-", modifiers: [.command]) - .disabled(fontSize <= 8) - } - } -} diff --git a/Mactrix/Extensions/Data+Mime.swift b/Mactrix/Extensions/Data+Mime.swift deleted file mode 100644 index 142fb3a..0000000 --- a/Mactrix/Extensions/Data+Mime.swift +++ /dev/null @@ -1,55 +0,0 @@ -import CoreImage -import Foundation -import ImageIO -import SwiftUI -import UniformTypeIdentifiers - -extension Data { - 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 - } - } - - /// 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() - } - - 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 - } -} diff --git a/Mactrix/Extensions/Logger.swift b/Mactrix/Extensions/Logger.swift deleted file mode 100644 index f87790a..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") -} diff --git a/Mactrix/Extensions/MatrixRustSDK+Extensions.swift b/Mactrix/Extensions/MatrixRustSDK+Extensions.swift deleted file mode 100644 index c51f8a3..0000000 --- a/Mactrix/Extensions/MatrixRustSDK+Extensions.swift +++ /dev/null @@ -1,593 +0,0 @@ -import Foundation -import MatrixRustSDK -import Models - -extension MatrixRustSDK.RoomMember: Models.UserProfile {} - -extension MatrixRustSDK.RoomMember: @retroactive Identifiable, Models.RoomMember { - public var id: String { - userId - } - - public var roleForPowerLevel: Models.RoomMemberRole { - suggestedRoleForPowerLevel.asModel - } -} - -extension MatrixRustSDK.RoomMemberRole { - var asModel: Models.RoomMemberRole { - switch self { - case .creator: - return .creator - case .administrator: - return .administrator - case .moderator: - return .moderator - case .user: - return .user - } - } -} - -extension MatrixRustSDK.EncryptionState { - var asModel: Models.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 func hash(into hasher: inout Hasher) { - return hasher.combine(id()) - } -} - -extension MatrixRustSDK.Room: @retroactive Identifiable { - public var id: String { - self.id() - } -} - -extension MatrixRustSDK.RoomInfo: Models.RoomInfo {} - -/* extension MatrixRustSDK.TimelineItem: @retroactive Hashable, @retroactive Identifiable { - public var id: String { - uniqueId().id - } - - public static func == (lhs: MatrixRustSDK.TimelineItem, rhs: MatrixRustSDK.TimelineItem) -> Bool { - return lhs.id == rhs.id - && lhs.asEvent() == rhs.asEvent() - && lhs.asVirtual() == rhs.asVirtual() - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(asEvent()) - hasher.combine(asVirtual()) - } - } */ - -/* extension MatrixRustSDK.EventTimelineItem: @retroactive Hashable { - public static func == (lhs: MatrixRustSDK.EventTimelineItem, rhs: MatrixRustSDK.EventTimelineItem) -> Bool { - return lhs.isRemote == rhs.isRemote - && lhs.eventOrTransactionId == rhs.eventOrTransactionId - && lhs.sender == rhs.sender - && lhs.senderProfile == rhs.senderProfile - && lhs.isOwn == rhs.isOwn - && lhs.isEditable == rhs.isEditable - && lhs.content == rhs.content - && lhs.timestamp == rhs.timestamp - && lhs.localSendState == rhs.localSendState - && lhs.localCreatedAt == rhs.localCreatedAt - && lhs.readReceipts == rhs.readReceipts - && lhs.origin == rhs.origin - && lhs.canBeRepliedTo == rhs.canBeRepliedTo - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(eventOrTransactionId) - hasher.combine(sender) - hasher.combine(senderProfile) - hasher.combine(isOwn) - hasher.combine(isEditable) - hasher.combine(content) - hasher.combine(timestamp) - hasher.combine(localSendState) - hasher.combine(localCreatedAt) - hasher.combine(readReceipts) - hasher.combine(origin) - hasher.combine(canBeRepliedTo) - } - } */ - -/* extension MatrixRustSDK.MsgLikeContent: @retroactive Hashable { - public static func == (lhs: MatrixRustSDK.MsgLikeContent, rhs: MatrixRustSDK.MsgLikeContent) -> Bool { - return lhs.kind == rhs.kind - && lhs.reactions == rhs.reactions - && lhs.inReplyTo?.eventId() == rhs.inReplyTo?.eventId() - && lhs.threadRoot == rhs.threadRoot - && lhs.threadSummary?.numReplies() == rhs.threadSummary?.numReplies() - } - } */ - -extension MatrixRustSDK.Reaction: @retroactive Identifiable { - public var id: String { - key - } -} - -extension MatrixRustSDK.Reaction: Models.Reaction { - public typealias SenderData = MatrixRustSDK.ReactionSenderData -} - -extension MatrixRustSDK.ReactionSenderData: Models.ReactionSenderData { - public var date: Date { - timestamp.date - } -} - -public extension MatrixRustSDK.Timestamp { - var date: Date { - Date(timeIntervalSince1970: Double(self) / 1000) - } -} - -extension MatrixRustSDK.VirtualTimelineItem { - var asModel: Models.VirtualTimelineItem { - switch self { - case let .dateDivider(ts: ts): - return .dateDivider(date: ts.date) - case .readMarker: - return .readMarker - case .timelineStart: - return .timelineStart - } - } -} - -extension MatrixRustSDK.ProfileDetails { - var asModel: Models.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) - } - } -} - -extension MatrixRustSDK.EventTimelineItem: Models.EventTimelineItem { - public var userReadReceipts: [String: Models.Receipt] { - readReceipts.mapValues { Models.Receipt(timestamp: $0.timestamp?.date) } - } - - public var senderProfileDetails: Models.ProfileDetails { - senderProfile.asModel - } - - public var date: Date { - timestamp.date - } - - public var userId: String { - sender - } - - public var displayName: String? { - if case let .ready(displayName: displayName, displayNameAmbiguous: _, avatarUrl: _) = senderProfileDetails { - return displayName - } - - return nil - } - - public var avatarUrl: String? { - if case let .ready(displayName: _, displayNameAmbiguous: _, avatarUrl: avatarUrl) = senderProfileDetails { - return avatarUrl - } - - return nil - } -} - -extension MatrixRustSDK.EventTimelineItem: @retroactive Identifiable { - public var id: MatrixRustSDK.EventOrTransactionId { - eventOrTransactionId - } -} - -extension MatrixRustSDK.TimelineItemContent: @retroactive CustomStringConvertible { - public var description: String { - switch self { - case .callInvite: - return "call invite" - case let .msgLike(content: content): - return content.kind.description - 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)" - } - } - - 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" - } - - let message: String = if let reason { - "\(changeMsg) because \(reason)" - } else { - changeMsg - } - - return message - } -} - -extension MatrixRustSDK.MsgLikeKind: @retroactive CustomStringConvertible { - public var description: 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.MessageLikeEventType: @retroactive CustomStringConvertible { - public var description: 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)'" - } - } -} - -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.description - case let .error(message: message): - return "Error: \(message)" - } - } -} - -extension MatrixRustSDK.ThreadSummary: Models.ThreadSummary { - public var description: String? { - latestEvent().message - } -} - -extension MatrixRustSDK.SpaceRoom: @retroactive Identifiable { - 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 - } - } -} - -extension MatrixRustSDK.UserProfile: @retroactive Identifiable, Models.UserProfile { - public var id: String { userId } -} - -extension MatrixRustSDK.SessionVerificationEmoji: @retroactive Identifiable { - public var id: String { description() } -} - -extension MatrixRustSDK.SessionVerificationEmoji: Models.SessionVerificationEmoji { - public var description: String { - self.description() - } - - public var symbol: String { - self.symbol() - } -} - -extension MatrixRustSDK.SessionVerificationData { - var asModel: Models.SessionVerificationData { - switch self { - case let .emojis(emojis, indices): - return .emojis(emojis: emojis, indices: indices) - case let .decimals(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" - } - } -} - -extension MatrixRustSDK.Room: @retroactive CustomDebugStringConvertible { - public var debugDescription: String { - "room \(id())" - } -} - -extension MatrixRustSDK.RoomPreviewInfo: @retroactive CustomDebugStringConvertible { - public var debugDescription: String { - "preview room info: \(roomId) \(name ?? "")" - } -} - -extension MatrixRustSDK.RoomPreviewInfo: Models.RoomPreviewInfo { - public var userMembership: Models.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: Models.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: Models.RoomKind { - switch roomType { - case .room: - return .room - case .space: - return .space - case let .custom(value: 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 func hash(into hasher: inout Hasher) { - hasher.combine(info()) - } -} diff --git a/Mactrix/Extensions/Models+Extensions.swift b/Mactrix/Extensions/Models+Extensions.swift deleted file mode 100644 index 8fff94b..0000000 --- a/Mactrix/Extensions/Models+Extensions.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation -import MatrixRustSDK -import Models - -extension Models.CreateRoomParams { - 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 - } - - 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 - ) - } -} diff --git a/Mactrix/Extensions/NSColor+userID.swift b/Mactrix/Extensions/NSColor+userID.swift deleted file mode 100644 index d02e06d..0000000 --- a/Mactrix/Extensions/NSColor+userID.swift +++ /dev/null @@ -1,10 +0,0 @@ -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 - ))! - } -} diff --git a/Mactrix/Extensions/NSTableView.swift b/Mactrix/Extensions/NSTableView.swift deleted file mode 100644 index 1165651..0000000 --- a/Mactrix/Extensions/NSTableView.swift +++ /dev/null @@ -1,35 +0,0 @@ -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 } - - 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 - } - - 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 - } - } -} diff --git a/Mactrix/MactrixApp.swift b/Mactrix/MactrixApp.swift index 148cbe7..a964ab8 100644 --- a/Mactrix/MactrixApp.swift +++ b/Mactrix/MactrixApp.swift @@ -1,36 +1,38 @@ +import MactrixApp +import MatrixIntegration import OSLog import SwiftUI -let applicationID = "dk.qpqp.mactrix" - @main struct MactrixApp: App { - @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) + @State var appState = AppState() + @FocusedValue(WindowState.self) private var windowState: WindowState? - 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/Mactrix/MainView.swift b/Mactrix/MainView.swift deleted file mode 100644 index e195b63..0000000 --- a/Mactrix/MainView.swift +++ /dev/null @@ -1,205 +0,0 @@ -import MatrixRustSDK -import OSLog -import SwiftUI -import UI -import Utils - -struct MainView: View { - @Environment(AppState.self) var appState - - @SceneStorage("MainView.windowState") - private var windowState: WindowState = .init() - - @State private var showWelcomeSheet: Bool = false - - @ViewBuilder var details: some View { - switch windowState.selectedScreen { - case .joinedRoom(timeline: let timeline): - ChatView(timeline: timeline).id(timeline.room.id) - case .previewRoom(let room): - UI.RoomPreviewView( - preview: room.info(), - imageLoader: appState.matrixClient, - actions: appState.matrixClient?.roomPreviewActions(forRoomWithId: room.info().roomId, windowState: windowState) - ) - case .newRoom: - UI.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(profile: 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 - } - } - } - } - ) - } - - 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 { - UI.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)") - - do { - let matrixUri = try Utils.MatrixUriScheme(parseUrl: url.absoluteString) - Logger.viewCycle.info("Matched Matrix Uri") - - 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)") - } - - 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 } - - 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"]) - - 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/Mactrix/Models/AppKeychain.swift b/Mactrix/Models/AppKeychain.swift deleted file mode 100644 index 53b9bdb..0000000 --- a/Mactrix/Models/AppKeychain.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import Security - -enum KeychainError: Error { - case unexpectedStatus(OSStatus) -} - -struct AppKeychain { - private let service: String - - 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, - ] - } - - /// 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) - } - } - - /// 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 - } - - /// 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/Mactrix/Models/AppState.swift b/Mactrix/Models/AppState.swift deleted file mode 100644 index 496a09c..0000000 --- a/Mactrix/Models/AppState.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation -import MatrixRustSDK -import Models -import OSLog - -@MainActor @Observable -final class AppState { - var matrixClient: MatrixClient? - - func reset() async throws { - do { - try await matrixClient?.reset() - } catch { - Logger.viewCycle.error("Failed to reset matrix client: \(error)") - } - matrixClient = nil - } -} diff --git a/Mactrix/Models/AsyncSDKListener.swift b/Mactrix/Models/AsyncSDKListener.swift deleted file mode 100644 index 96da084..0000000 --- a/Mactrix/Models/AsyncSDKListener.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation -import MatrixRustSDK -import OSLog - -final class AsyncSDKListener: AsyncSequence, Sendable { - typealias Element = Element - typealias AsyncIterator = AsyncStream.Iterator - - private let stream: AsyncStream - private let continuation: AsyncStream.Continuation - - init() { - let (s, c) = AsyncStream.makeStream() - stream = s - continuation = c - } - - func publishValue(_ element: Element) { - continuation.yield(element) - } - - func makeAsyncIterator() -> AsyncStream.Iterator { - return stream.makeAsyncIterator() - } -} - -extension AsyncSDKListener: TypingNotificationsListener where Element == [String] { - func call(typingUserIds: [String]) { - publishValue(typingUserIds) - } -} - -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: 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) - } -} - -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) - } -} - -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) - } -} - -extension AsyncSDKListener: RoomListEntriesListener where Element == [RoomListEntriesUpdate] { - func onUpdate(roomEntriesUpdate: [MatrixRustSDK.RoomListEntriesUpdate]) { - publishValue(roomEntriesUpdate) - } -} diff --git a/Mactrix/Models/HomeserverLogin.swift b/Mactrix/Models/HomeserverLogin.swift deleted file mode 100644 index 9864a8f..0000000 --- a/Mactrix/Models/HomeserverLogin.swift +++ /dev/null @@ -1,62 +0,0 @@ -import AuthenticationServices -import Foundation -import MatrixRustSDK -import OSLog -import SwiftUI - -struct HomeserverLogin { - let storeID: String - let storePassphrase: String - let unauthenticatedClient: ClientProtocol - let loginDetails: HomeserverLoginDetailsProtocol - - init(storeID: String, storePassphrase: String, unauthenticatedClient: ClientProtocol, loginDetails: HomeserverLoginDetailsProtocol) { - self.storeID = storeID - self.storePassphrase = storePassphrase - self.unauthenticatedClient = unauthenticatedClient - self.loginDetails = loginDetails - } - - @MainActor - 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() - } - - 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 - 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())! - - Logger.matrixClient.debug("Auth url: \(url, privacy: .sensitive)") - - let callbackUrl = try await webAuthSession.authenticate(using: url, callback: .customScheme("com.github"), additionalHeaderFields: [:]) - - Logger.matrixClient.debug("after sign in") - - try await unauthenticatedClient.loginWithOauthCallback(callbackUrl: callbackUrl.absoluteString) - - return try await onSuccessfullLogin() - } - - @MainActor - fileprivate func onSuccessfullLogin() async throws -> MatrixClient { - let matrixClient = await MatrixClient(storeID: storeID, storePassphrase: storePassphrase, client: unauthenticatedClient) - - let userSession = try matrixClient.userSession() - do { - try userSession.saveUserToKeychain() - } catch { - print(error.localizedDescription) - } - - return matrixClient - } -} diff --git a/Mactrix/Models/LiveRoom.swift b/Mactrix/Models/LiveRoom.swift deleted file mode 100644 index 7e62f5d..0000000 --- a/Mactrix/Models/LiveRoom.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Foundation -import MatrixRustSDK -import Models -import OSLog - -@MainActor @Observable -public final class LiveRoom: Identifiable { - let sidebarRoom: SidebarRoom - - public var typingUserIds: [String] = [] - public var members: [MatrixRustSDK.RoomMember] = [] - - @ObservationIgnored private var typingHandle: TaskHandle? - - public var room: MatrixRustSDK.Room { - sidebarRoom.room - } - - public var roomInfo: MatrixRustSDK.RoomInfo? { - sidebarRoom.roomInfo - } - - public nonisolated var id: String { - sidebarRoom.id - } - - public init(sidebarRoom: SidebarRoom) { - self.sidebarRoom = sidebarRoom - - startListening() - - 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)) - } - - isolated deinit { - Logger.matrixClient.info("live room deinit") - } - - fileprivate func startListening() { - Logger.matrixClient.info("typing indicator start listening") - - 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 - } - } - } - - 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)") - } - } -} - -extension LiveRoom: Hashable { - public nonisolated static func == (lhs: LiveRoom, rhs: LiveRoom) -> Bool { - lhs.id == rhs.id - } - - public nonisolated func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - -extension LiveRoom: @MainActor Models.Room { - public var displayName: String? { - room.displayName() - } - - public var topic: String? { - room.topic() - } - - public var encryptionState: Models.EncryptionState { - room.encryptionState().asModel - } -} diff --git a/Mactrix/Models/LiveRoomSearch.swift b/Mactrix/Models/LiveRoomSearch.swift deleted file mode 100644 index bae431c..0000000 --- a/Mactrix/Models/LiveRoomSearch.swift +++ /dev/null @@ -1,67 +0,0 @@ -import AsyncAlgorithms -import Foundation -import MatrixRustSDK -import OSLog - -@MainActor @Observable -class LiveRoomSearch { - let roomDirectorySearch: RoomDirectorySearchProtocol - - @ObservationIgnored fileprivate var resultsHandle: TaskHandle? - - var rooms: [RoomDescription] = [] - - init(roomDirectorySearch: RoomDirectorySearchProtocol) async { - self.roomDirectorySearch = roomDirectorySearch - await listenToRoomResults() - } - - deinit { - Logger.matrixClient.info("LiveRoomSearch deinit") - } - - private func listenToRoomResults() async { - Logger.matrixClient.info("room search start listening") - - let listener = AsyncSDKListener<[RoomDirectorySearchEntryUpdate]>() - resultsHandle = await roomDirectorySearch.results(listener: listener) - - 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 - } - } - } - } - } - - func search(query: String?) async throws { - try await roomDirectorySearch.search(filter: query, batchSize: 100, viaServerName: nil) - } -} diff --git a/Mactrix/Models/LiveSpaceRoomList.swift b/Mactrix/Models/LiveSpaceRoomList.swift deleted file mode 100644 index f629c76..0000000 --- a/Mactrix/Models/LiveSpaceRoomList.swift +++ /dev/null @@ -1,104 +0,0 @@ -import AsyncAlgorithms -import Foundation -import MatrixRustSDK -import OSLog - -@MainActor @Observable -final class LiveSpaceRoomList { - let spaceService: LiveSpaceService - let spaceRoomList: SpaceRoomList - - var space: SpaceRoom? - var rooms: [SidebarSpaceRoom] = [] - var paginationState: SpaceRoomListPaginationState = .loading - - @ObservationIgnored fileprivate var spaceHandle: TaskHandle? - @ObservationIgnored fileprivate var roomsHandle: TaskHandle? - @ObservationIgnored fileprivate var paginateHandle: TaskHandle? - - init(spaceService: LiveSpaceService, spaceRoomList: SpaceRoomList) { - self.spaceService = spaceService - self.spaceRoomList = spaceRoomList - - listenToSpaceRoom() - listenToPagination() - - Task { - await listenToRooms() - await loadChildRooms() - } - } - - deinit { - Logger.liveSpaceRoomList.debug("LiveSpaceRoomList deinit") - } - - fileprivate 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 - } - } - } - - fileprivate 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 } - - 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) } - } - } - } - } - } - - fileprivate 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 - } - } - } - - func loadChildRooms() async { - do { - try await spaceRoomList.paginate() - } catch { - Logger.viewCycle.error("Failed to paginate space list: \(error)") - } - } -} diff --git a/Mactrix/Models/LiveSpaceService.swift b/Mactrix/Models/LiveSpaceService.swift deleted file mode 100644 index ca984cc..0000000 --- a/Mactrix/Models/LiveSpaceService.swift +++ /dev/null @@ -1,62 +0,0 @@ -import AsyncAlgorithms -import Foundation -import MatrixRustSDK -import OSLog - -@MainActor @Observable -public final class LiveSpaceService { - public let spaceService: SpaceService - - public var spaceRooms: [SidebarSpaceRoom] = [] - - @ObservationIgnored private var spaceHandle: TaskHandle? - - public init(spaceService: SpaceService) { - self.spaceService = spaceService - - Task { - await self.listenToJoinedSpaces() - - 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 } - - 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) } - } - } - } - } - } -} diff --git a/Mactrix/Models/LiveTimeline.swift b/Mactrix/Models/LiveTimeline.swift deleted file mode 100644 index eb22a6b..0000000 --- a/Mactrix/Models/LiveTimeline.swift +++ /dev/null @@ -1,238 +0,0 @@ -import AsyncAlgorithms -import Foundation -import MatrixRustSDK -import OSLog - -/// Delegate subscribed to by the `NSTimelineView` to update the focused timeline row. -@MainActor -public protocol LiveTimelineFocusDelegate: AnyObject { - func focusTimelineEvent(id: EventOrTransactionId) -} - -@MainActor -protocol LiveTimelineDiffDelegate: AnyObject { - func timelineDidApply(diffs: [TimelineDiff]) -} - -@MainActor @Observable -public final class LiveTimeline { - public let room: LiveRoom - public let focusedThreadId: String? - - public var timeline: Timeline? - - @ObservationIgnored private var timelineHandle: TaskHandle? - @ObservationIgnored private var paginateHandle: TaskHandle? - - public var errorMessage: String? - - @ObservationIgnored public weak var focusDelegate: (any LiveTimelineFocusDelegate)? - @ObservationIgnored weak var diffDelegate: (any LiveTimelineDiffDelegate)? - - public var sendReplyTo: MatrixRustSDK.EventTimelineItem? - - public private(set) var timelineItems: [TimelineItem] = [] - public private(set) var loadedReplyDetails: [String: InReplyToDetails] = [:] - - public private(set) var paginating: PaginationStatus = .idle(hitTimelineStart: false) - public private(set) var hitTimelineStart: Bool = false - private var fetchMessagesAgain: Bool = false - private var fetchingOlderMessages: Bool = false - - public init(room: LiveRoom) { - self.focusedThreadId = nil - self.room = room - Task { - do { - try await configureTimeline() - } catch { - Logger.liveTimeline.error("failed to configure timeline: \(error)") - self.errorMessage = error.localizedDescription - } - } - } - - public init(room: LiveRoom, focusThread threadId: String) { - self.focusedThreadId = threadId - self.room = room - Task { - do { - try await configureTimeline(threadId: threadId) - } catch { - Logger.liveTimeline.error("failed to configure timeline: \(error)") - self.errorMessage = error.localizedDescription - } - } - } - - deinit { - Logger.liveTimeline.debug("Timeline deinit") - } - - private func configureTimeline(threadId: String? = nil) async throws { - Logger.liveTimeline.debug("configure timeline") - - let focus = if let threadId { - TimelineFocus.thread(rootEventId: threadId) - } else { - TimelineFocus.live(hideThreadedEvents: true) - } - - let config = TimelineConfiguration( - focus: focus, - filter: .all, - internalIdPrefix: nil, - dateDividerMode: .daily, - trackReadReceipts: .allEvents, - reportUtds: false - ) - timeline = try await room.room.timelineWithConfiguration(configuration: config) - - await listenToTimelineChanges() - - do { - try await listenToPaginationStatus(threadId: threadId) - } catch { - Logger.liveTimeline.error("Failed to listen to pagination status: \(error)") - } - } - - 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)) - } - - Task { [weak self] in - for await status in listener { - guard let self else { break } - - Logger.liveTimeline.debug("updating timeline paginating: \(status.debugDescription)") - paginating = status - - 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 - } - - Logger.liveTimeline.info("fetch more messages") - - do { - _ = try await timeline?.paginateBackwards(numEvents: 100) - fetchingOlderMessages = false - - 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) - } -} - -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 - } - - diffDelegate?.timelineDidApply(diffs: diff) - loadPendingReplyDetails() - } - - private func loadPendingReplyDetails() { - guard let sdkTimeline = timeline else { return } - for item in timelineItems { - guard let event = item.asEvent() else { continue } - guard case let .msgLike(content: msgLike) = event.content else { continue } - guard let inReplyTo = msgLike.inReplyTo else { continue } - let eventId = inReplyTo.eventId() - switch inReplyTo.event() { - case .ready, .error: continue - case .pending, .unavailable: break - } - guard loadedReplyDetails[eventId] == nil else { continue } - Task { [weak self] in - guard let self else { return } - do { - let details = try await sdkTimeline.loadReplyDetails(eventIdStr: eventId) - self.loadedReplyDetails[eventId] = details - } catch { - Logger.liveTimeline.error("loadPendingReplyDetails: failed \(eventId): \(error)") - } - } - } - } -} - -extension LiveTimeline: Equatable { - public nonisolated static func == (lhs: LiveTimeline, rhs: LiveTimeline) -> Bool { - lhs.room.id == rhs.room.id && lhs.focusedThreadId == rhs.focusedThreadId - } -} diff --git a/Mactrix/Models/MatrixClient+Listeners.swift b/Mactrix/Models/MatrixClient+Listeners.swift deleted file mode 100644 index 054dca1..0000000 --- a/Mactrix/Models/MatrixClient+Listeners.swift +++ /dev/null @@ -1,150 +0,0 @@ -import Foundation -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) - } - } - } - } -} - -extension MatrixClient: SyncServiceStateObserver { - nonisolated func onUpdate(state: MatrixRustSDK.SyncServiceState) { - Task { @MainActor in - syncState = state - } - } -} - -extension MatrixClient: VerificationStateListener { - nonisolated func onUpdate(status: MatrixRustSDK.VerificationState) { - Task { @MainActor in - verificationState = status - } - } -} - -extension MatrixClient: RoomListServiceStateListener { - nonisolated func onUpdate(state: MatrixRustSDK.RoomListServiceState) { - Task { @MainActor in - roomListServiceState = state - } - } -} - -extension MatrixClient: RoomListServiceSyncIndicatorListener { - nonisolated func onUpdate(syncIndicator: MatrixRustSDK.RoomListServiceSyncIndicator) { - Task { @MainActor in - showRoomSyncIndicator = syncIndicator - } - } -} - -extension MatrixClient: MatrixRustSDK.ClientDelegate { - nonisolated func onBackgroundTaskErrorReport(taskName: String, error: MatrixRustSDK.BackgroundTaskFailureReason) { - Logger.matrixClient.error("onBackgroundTaskErrorReport taskName: \(taskName)") - } - - nonisolated func didReceiveAuthError(isSoftLogout: Bool) { - Task { @MainActor in - Logger.matrixClient.debug("did receive auth error: soft logout \(isSoftLogout, privacy: .public)") - if !isSoftLogout { - authenticationFailed = true - } - } - } -} - -extension MatrixClient: MatrixRustSDK.IgnoredUsersListener { - nonisolated func call(ignoredUserIds: [String]) { - Task { @MainActor in - Logger.matrixClient.debug("Updated ignored users: \(ignoredUserIds)") - self.ignoredUserIds = ignoredUserIds - } - } -} - -extension MatrixClient: SessionVerificationControllerDelegate { - nonisolated func didReceiveVerificationRequest(details: MatrixRustSDK.SessionVerificationRequestDetails) { - Task { @MainActor in - Logger.matrixClient.debug("session verification: didReceiveVerificationRequest") - sessionVerificationRequest = details - } - } - - nonisolated func didAcceptVerificationRequest() { - Logger.matrixClient.debug("session verification: didAcceptVerificationRequest") - } - - nonisolated func didStartSasVerification() { - Logger.matrixClient.debug("session verification: didStartSasVerification") - } - - nonisolated func didReceiveVerificationData(data: MatrixRustSDK.SessionVerificationData) { - Task { @MainActor in - Logger.matrixClient.debug("session verification: didReceiveVerificationData") - sessionVerificationData = data - } - } - - nonisolated func didFail() { - Task { @MainActor in - Logger.matrixClient.debug("session verification: didFail") - sessionVerificationRequest = nil - sessionVerificationData = nil - } - } - - nonisolated func didCancel() { - Task { @MainActor in - Logger.matrixClient.debug("session verification: didCancel") - sessionVerificationRequest = nil - sessionVerificationData = nil - } - } - - nonisolated func didFinish() { - Task { @MainActor in - Logger.matrixClient.debug("session verification: didFinish") - sessionVerificationRequest = nil - sessionVerificationData = nil - } - } -} diff --git a/Mactrix/Models/MatrixClient.swift b/Mactrix/Models/MatrixClient.swift deleted file mode 100644 index ce748a7..0000000 --- a/Mactrix/Models/MatrixClient.swift +++ /dev/null @@ -1,385 +0,0 @@ -import AsyncAlgorithms -import Foundation -import MatrixRustSDK -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! - - var rooms: [SidebarRoom] = [] - - var spaceService: LiveSpaceService! - - private var clientDelegateHandle: TaskHandle? - var authenticationFailed: Bool = false - - 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)" - } - } - - 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() - } - - 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) - } - - 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 - } - - 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") - } - - var syncService: SyncService? - var syncState: SyncServiceState = .terminated - var roomListService: RoomListService? - var roomListServiceState: RoomListServiceState? - var showRoomSyncIndicator: RoomListServiceSyncIndicator? - 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 - var sessionVerificationRequest: SessionVerificationRequestDetails? - var sessionVerificationData: SessionVerificationData? - var verificationState: VerificationState? - - var notificationClient: NotificationClient? - - 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) - - try await client.getSessionVerificationController().setDelegate(delegate: self) - - verificationStateHandle = client.encryption().verificationStateListener(listener: self) - - ignoredUsersHandle = client.subscribeToIgnoredUsers(listener: self) - ignoredUserIds = try await client.ignoredUsers() - - // Start the sync loop. - await _syncService.start() - Logger.matrixClient.info("Matrix sync started") - } - - func clearCache() async throws { - try await client.clearCaches(syncService: syncService) - } - - func isUserIgnored(_ userId: String) -> Bool { - ignoredUserIds.contains(userId) - } - - func declineVerificationRequest(request: SessionVerificationRequestDetails) async throws { - try await client.getSessionVerificationController().acknowledgeVerificationRequest( - senderId: request.senderProfile.userId, flowId: request.flowId - ) - - try await client.getSessionVerificationController().cancelVerification() - } - - func acceptVerificationRequest(request: SessionVerificationRequestDetails) async throws { - try await client.getSessionVerificationController().acknowledgeVerificationRequest( - senderId: request.senderProfile.userId, flowId: request.flowId - ) - - try await client.getSessionVerificationController().acceptVerificationRequest() - } - - func requestDeviceVerification() async throws { - try await client.getSessionVerificationController().requestDeviceVerification() - } -} - -enum MatrixClientRestoreSessionError: Error { - case sessionNotFound, wrongUserId -} - -extension MatrixClient: MatrixRustSDK.ClientSessionDelegate { - 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 - } - } - - 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)") - } - } -} - -extension MatrixClient: UI.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: sessionID) - } - - static func sessionCaches(for sessionID: String) -> URL { - cachesDirectory - .appending(component: applicationID) - .appending(component: sessionID) - } -} diff --git a/Mactrix/Models/MatrixClientSessionDelegate.swift b/Mactrix/Models/MatrixClientSessionDelegate.swift deleted file mode 100644 index 3bf6de6..0000000 --- a/Mactrix/Models/MatrixClientSessionDelegate.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -import MatrixRustSDK -import OSLog -import SwiftUI -import UI -import UniformTypeIdentifiers -import Utils - -final class MatrixClientSessionDelegate: MatrixRustSDK.ClientSessionDelegate { - let storeID: String - let storePassphrase: String - - 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)") - - 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 - } - } - - 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/Mactrix/Models/Notifications.swift b/Mactrix/Models/Notifications.swift deleted file mode 100644 index 6a7713a..0000000 --- a/Mactrix/Models/Notifications.swift +++ /dev/null @@ -1,165 +0,0 @@ -import MatrixRustSDK -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? - - typealias NotificationEvent = (item: MatrixRustSDK.NotificationItem, roomId: String) - - @ObservationIgnored private let notificationContinuation: AsyncStream.Continuation - @ObservationIgnored private var streamTask: Task? - - override init() { - let (stream, continuation) = AsyncStream.makeStream() - notificationContinuation = continuation - - 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) - } - } - } -} - -extension MatrixNotifications: @MainActor UNUserNotificationCenterDelegate { - 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 { - 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 - } - - 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 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)") - } - } - - 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 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 - } - } - } -} diff --git a/Mactrix/Models/SidebarRoom.swift b/Mactrix/Models/SidebarRoom.swift deleted file mode 100644 index 72898d8..0000000 --- a/Mactrix/Models/SidebarRoom.swift +++ /dev/null @@ -1,59 +0,0 @@ -import AsyncAlgorithms -import Foundation -import MatrixRustSDK -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() - } - } - - /// 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) - - 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/Mactrix/Models/SidebarSpaceRoom.swift b/Mactrix/Models/SidebarSpaceRoom.swift deleted file mode 100644 index d6d0d84..0000000 --- a/Mactrix/Models/SidebarSpaceRoom.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation -import MatrixRustSDK - -@MainActor @Observable -public final class SidebarSpaceRoom { - let spaceRoom: SpaceRoom - - var children: Children = .loading - - private let spaceService: LiveSpaceService - - init(spaceService: LiveSpaceService, spaceRoom: SpaceRoom) { - self.spaceService = spaceService - self.spaceRoom = spaceRoom - } - - 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) - } - } - - enum Children { - case loading - case loaded(children: LiveSpaceRoomList) - case error(error: Error) - } -} - -extension SidebarSpaceRoom: Identifiable { - public nonisolated var id: String { spaceRoom.roomId } -} diff --git a/Mactrix/Models/UserSession.swift b/Mactrix/Models/UserSession.swift deleted file mode 100644 index d526ccc..0000000 --- a/Mactrix/Models/UserSession.swift +++ /dev/null @@ -1,56 +0,0 @@ -import AsyncAlgorithms -import Foundation -import MatrixRustSDK -import OSLog -import Security -import SwiftUI -import UI -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 - - 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) - } - - fileprivate static var keychainKey: String { "UserSession" } - - 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 - } -} diff --git a/Mactrix/Models/WindowState.swift b/Mactrix/Models/WindowState.swift deleted file mode 100644 index 6471b80..0000000 --- a/Mactrix/Models/WindowState.swift +++ /dev/null @@ -1,179 +0,0 @@ -import Foundation -import MatrixRustSDK -import OSLog -import SwiftUI -import Utils - -enum InspectorContent: Equatable { - case roomInfo - case search - case userInfo(userId: String) - case roomThreads - case roomPins - case focusThread(threadTimeline: LiveTimeline) -} - -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) -} - -enum SelectedScreen { - case joinedRoom(timeline: LiveTimeline) - case loadMatrixUrl(_ url: Utils.MatrixUriScheme) - case previewRoom(_ room: RoomPreview) - case user(profile: UserProfile) - case newRoom - case none -} - -@MainActor @Observable -final class WindowState { - var selectedScreen: SelectedScreen = .none - - var selectedRoomId: String? - var inspectorVisible: Bool = false - - var inspectorContent: InspectorContent = .roomInfo - - var requestedVerification = false - - var searchQuery: String = "" - var searchTokens: [SearchToken] = [] - var searchDirectResult: SearchDirectResult? - - /// The collapsed/expanded states of the sections in the sidebar. - var sidebarSections = SidebarSectionCollapsibility() - - 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 - } - } - ) - } - - func toggleInspector() { - if inspectorVisible { - if inspectorContent == .roomInfo { - inspectorVisible = false - } else { - inspectorContent = .roomInfo - } - } else { - inspectorVisible = true - inspectorContent = .roomInfo - } - } - - func focusMessage(eventId: String) { - guard case let .joinedRoom(timeline: roomTimeline) = selectedScreen else { - Logger.windowState.warning("focus message failed, no active timeline") - return - } - - Logger.windowState.warning("scrolling to message \(eventId)") - roomTimeline.focusEvent(id: .eventId(eventId: eventId)) - } - - 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() { - if inspectorVisible, inspectorContent == .roomThreads { - inspectorVisible = false - } else { - inspectorContent = .roomThreads - inspectorVisible = true - } - } - - func showRoomPins() { - if inspectorVisible, inspectorContent == .roomPins { - inspectorVisible = false - } else { - inspectorContent = .roomPins - inspectorVisible = true - } - } - - func focusUser(userId: String) { - if inspectorVisible, inspectorContent == .userInfo(userId: userId) { - inspectorVisible = false - } else { - inspectorContent = .userInfo(userId: userId) - inspectorVisible = true - } - } -} - -extension WindowState: @MainActor RawRepresentable { - 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 - } - } - - 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)") - } - - var rawValue: String { - guard - let data = try? JSONEncoder().encode(SceneStorageRepresentation(windowState: self)), - let result = String(data: data, encoding: .utf8) - else { - return "" - } - - return result - } - - typealias RawValue = String -} - -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 } -} diff --git a/Mactrix/Toolbar.swift b/Mactrix/Toolbar.swift deleted file mode 100644 index c559d20..0000000 --- a/Mactrix/Toolbar.swift +++ /dev/null @@ -1,35 +0,0 @@ -import OSLog -import SwiftUI - -struct ToolbarViewModifier: ViewModifier { - @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) - - 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() - } - } - } - } -} diff --git a/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift b/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift deleted file mode 100644 index 6ba6160..0000000 --- a/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift +++ /dev/null @@ -1,178 +0,0 @@ -import MatrixRustSDK -import OSLog -import SwiftUI - -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)") - } - - 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 - } - - 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(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 - } - - 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 - } - } - 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() - } - } - - @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 { - ChatInputView() - } */ diff --git a/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift b/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift deleted file mode 100644 index 62d70f4..0000000 --- a/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift +++ /dev/null @@ -1,174 +0,0 @@ -import AppKit -import OSLog -import SwiftUI - -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: [ - .foregroundColor: NSColor.secondaryLabelColor, - .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() - } - - 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() - } - } - - 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 - } - } -} - -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) - && event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] - { - 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, - event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [] - { - onSubmit?() - return - } - - super.keyDown(with: event) - } -} diff --git a/Mactrix/Views/ChatView/ChatView.swift b/Mactrix/Views/ChatView/ChatView.swift deleted file mode 100644 index 13d8b1c..0000000 --- a/Mactrix/Views/ChatView/ChatView.swift +++ /dev/null @@ -1,125 +0,0 @@ -import MatrixRustSDK -import Models -import OSLog -import SwiftUI - -private struct TimelineReadKey: Equatable { - let count: Int - let active: Bool -} - -struct ChatJoinedRoom: View { - @Environment(AppState.self) private var appState - @Environment(\.appearsActive) private var appearsActive - @Bindable var timeline: LiveTimeline - - 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 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 - - var room: LiveRoom { - timeline.room - } - - init(timeline: LiveTimeline) { - self.timeline = timeline - } - - @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("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") - } - } -} diff --git a/Mactrix/Views/ChatView/EmbeddedMessageView.swift b/Mactrix/Views/ChatView/EmbeddedMessageView.swift deleted file mode 100644 index 61055d5..0000000 --- a/Mactrix/Views/ChatView/EmbeddedMessageView.swift +++ /dev/null @@ -1,31 +0,0 @@ -import MatrixRustSDK -import SwiftUI -import UI - -struct EmbeddedMessageView: View { - let embeddedEvent: MatrixRustSDK.EmbeddedEventDetails - let action: () -> Void - - var body: some View { - switch embeddedEvent { - case .unavailable, .pending: - UI.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( - username: { - if case let .ready(name, _, _, _, _) = senderProfile, let name { return name } - return sender - }(), - message: content.description, - action: action - ) - case let .error(message): - Text("error: \(message)") - } - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift deleted file mode 100644 index cb467a9..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift +++ /dev/null @@ -1,50 +0,0 @@ -import AppKit -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 -} - -/// Determines the reusable row and its retained content view for a message. -@MainActor enum MessageContentKind: Hashable { - 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 - } - } - - 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) - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageFileContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageFileContentView.swift deleted file mode 100644 index 9815cdb..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageFileContentView.swift +++ /dev/null @@ -1,194 +0,0 @@ -import AppKit -import MatrixRustSDK -import OSLog -import UniformTypeIdentifiers - -final class MessageFileContentView: NSView, MessageContentRowView { - 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) - } - - 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 let .message(message) = content.kind else { return false } - if case .file = message.msgType { - return true - } - return false - } - - 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)) - ) - - 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 - } - } - } - - deinit { previewTask?.cancel() } - - @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") - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift b/Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift deleted file mode 100644 index e4e1b98..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift +++ /dev/null @@ -1,225 +0,0 @@ -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") - } -} - -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") - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift deleted file mode 100644 index fe3ef13..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift +++ /dev/null @@ -1,240 +0,0 @@ -import AppKit -import MatrixRustSDK -import OSLog -import QuickLookUI - -/// 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") - } -} - -/// 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? - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift deleted file mode 100644 index 3ffb2cb..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift +++ /dev/null @@ -1,109 +0,0 @@ -import AppKit -import MatrixRustSDK -import UI - -class MessageProfileRowView: NSView { - let profilePicture = NSButton() - let name = NSTextField(labelWithString: "") - - private var sender: String? - private var focusUserAction: (_ sender: String) -> Void = { _ in } - private var imageLoader: UI.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: UI.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 - } - - name.stringValue = username - name.textColor = NSColor(userID: event.sender) - name.toolTip = username - - reloadAvatar() - } - - @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 } - - 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 } - - self.profilePicture.image = image - } - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageReactionsView.swift b/Mactrix/Views/ChatView/TimelineView/MessageReactionsView.swift deleted file mode 100644 index aef110d..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageReactionsView.swift +++ /dev/null @@ -1,138 +0,0 @@ -import AppKit -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 - } - - 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) - } - 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 - } - - 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 - } - - @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") - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift b/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift deleted file mode 100644 index 2e2e7e8..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift +++ /dev/null @@ -1,267 +0,0 @@ -import AppKit -import MatrixRustSDK -import Models -import UI - -/// 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 - } - - 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: UI.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) - } - - func configure(receipts: [String: Models.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 }) - 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 - } - - 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) - } - - 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 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 - } - } - - 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 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) - - 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) - } - - 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 } -} - -private final class ReceiptPopoverRowButton: NSButton { - 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) - - 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 mouseDown(with event: NSEvent) { - alphaValue = 0.6 - window?.displayIfNeeded() - super.mouseDown(with: event) - alphaValue = 1 - } - - @objc private func activate() { - onClick?(userId) - } - - @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? - - 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 - } - - func configure(userId: String, avatarUrl: String?, imageLoader: UI.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 cancelLoad() { - loadTask?.cancel() - loadTask = nil - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageReplyPreviewView.swift b/Mactrix/Views/ChatView/TimelineView/MessageReplyPreviewView.swift deleted file mode 100644 index 1149fbc..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageReplyPreviewView.swift +++ /dev/null @@ -1,133 +0,0 @@ -import AppKit -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), - ]) - } - - 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 - } - - 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 let .ready(content, sender, senderProfile, _, _): - let displayName: String - if case let .ready(name, _, _, _, _) = senderProfile, let name { - displayName = name - } else { - displayName = sender - } - return (displayName, content.description, false) - case let .error(message): - return ("Reply unavailable", message, false) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift deleted file mode 100644 index 43ca2fa..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift +++ /dev/null @@ -1,378 +0,0 @@ -import AppKit -import MatrixRustSDK -import Models -import UI - -/// 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, - ]) - } - - 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 - } - - 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 - } - - 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() - } - } - - private func configureReceipts(_ receipts: [String: Models.Receipt], roomMembers: [MatrixRustSDK.RoomMember], imageLoader: UI.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() - } - - 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() - } - - 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 - } - } - - 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 - } - } - - 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/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift deleted file mode 100644 index 1a8e305..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift +++ /dev/null @@ -1,191 +0,0 @@ -import AppKit -import MatrixRustSDK -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: 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: 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)) - } - 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: 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.description)") - 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 - ) -> 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) - } - - 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 - } - - return hitView === self || hitView.isDescendant(of: self) - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageThreadSummaryView.swift b/Mactrix/Views/ChatView/TimelineView/MessageThreadSummaryView.swift deleted file mode 100644 index 8837a81..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageThreadSummaryView.swift +++ /dev/null @@ -1,91 +0,0 @@ -import AppKit -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") - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageVideoContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageVideoContentView.swift deleted file mode 100644 index a85167e..0000000 --- a/Mactrix/Views/ChatView/TimelineView/MessageVideoContentView.swift +++ /dev/null @@ -1,280 +0,0 @@ -import AppKit -import AVKit -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 } - } - - 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? - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - mediaView.wantsLayer = true - mediaView.layer?.backgroundColor = NSColor.darkGray.cgColor - mediaView.layer?.cornerRadius = 6 - mediaView.layer?.masksToBounds = true - thumbnailView.imageScaling = .scaleProportionallyUpOrDown - - 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" - - 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 [mediaView, captionView] { - view.translatesAutoresizingMaskIntoConstraints = false - addSubview(view) - } - for view in [thumbnailView, playButton, spinner, errorLabel] { - view.translatesAutoresizingMaskIntoConstraints = false - mediaView.addSubview(view) - } - - 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), - ]) - } - - 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 - } - - func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { - guard case let .message(message) = content.kind, - case let .video(video) = message.msgType else { return } - - 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) - } - - override func layout() { - if let videoContent, bounds.width > 0 { - updateMediaSize(for: videoContent, width: bounds.width) - } - super.layout() - } - - 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)) - } - - 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 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) - } - - 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) { - 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: cacheKey) - 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 - } - } - } - - @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 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/Mactrix/Views/ChatView/TimelineView/StateEventRowView.swift b/Mactrix/Views/ChatView/TimelineView/StateEventRowView.swift deleted file mode 100644 index fd497e6..0000000 --- a/Mactrix/Views/ChatView/TimelineView/StateEventRowView.swift +++ /dev/null @@ -1,80 +0,0 @@ -import AppKit -import MatrixRustSDK - -final class StateEventRowView: NSView { - private let timestamp = NSTextField(labelWithString: "") - private let descriptionField = NSTextField(labelWithString: "") - - private static let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm" - return formatter - }() - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - 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 - - 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), - ]) - } - - 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) - } - - 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.description, attributes: [ - .font: NSFontManager.shared.convert(NSFont.systemFont(ofSize: size), toHaveTrait: .italicFontMask), - .foregroundColor: color, - ])) - return result - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineRowProjection.swift b/Mactrix/Views/ChatView/TimelineView/TimelineRowProjection.swift deleted file mode 100644 index 30ffdc1..0000000 --- a/Mactrix/Views/ChatView/TimelineView/TimelineRowProjection.swift +++ /dev/null @@ -1,149 +0,0 @@ -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 - } - - private(set) var sourceItems: [TimelineItem] - private var segments: [Segment] - private(set) var rows: [TimelineItemRowInfo] - - init(items: [TimelineItem]) { - sourceItems = items - segments = Self.makeSegments(items, previous: nil) - rows = Self.flatten(segments) - } - - mutating func apply(_ diffs: [TimelineDiff]) -> Change { - let oldIDs = rows.map(\.id) - var changedIDs = Set() - - 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) - } - } - - rows = Self.flatten(segments) - return Change(oldIDs: oldIDs, 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) }) - } - - private mutating func replace( - at index: Int, removing count: Int, with items: [TimelineItem], changedIDs: inout Set - ) { - guard count > 0 || !items.isEmpty else { return } - - // 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) }) - - 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) - } - - /// Expands a source edit to whole segments. Future run based rules can - /// extend this window to the nearest unchanged grouping boundaries. - private func segmentWindow(containing requested: Range) -> (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) - } - - 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 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 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 - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift deleted file mode 100644 index c23885c..0000000 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ /dev/null @@ -1,653 +0,0 @@ -import AppKit -import MatrixRustSDK -import Models -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 - } - } - - 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") - } - } -} - -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" - } - } -} - -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 - { - 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 ..< 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) - } - } - - 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 } - tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - updateSelectedMessage() - } - - 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 - } - - 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) - } - - 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() - } - - // 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 - } -} - -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 tableViewSelectionDidChange(_ notification: Notification) { - guard !updatingTimelineItems else { return } - updateSelectedMessage() - } - - func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { - let item = timelineItems[row] - - if case .profile = item { - return MessageProfileRowView.rowHeight - } - - if case .typingIndicator = item { - return TypingIndicatorRowView.rowHeight - } - - 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") - } -} - -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/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift deleted file mode 100644 index 1919261..0000000 --- a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift +++ /dev/null @@ -1,34 +0,0 @@ -import MatrixRustSDK -import SwiftUI - -struct TimelineViewRepresentable: NSViewControllerRepresentable { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - - let timeline: LiveTimeline - init(timeline: LiveTimeline) { - self.timeline = timeline - } - - func makeCoordinator() -> Coordinator { - return Coordinator(appState: appState, windowState: windowState) - } - - class Coordinator { - let appState: AppState - let 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 updateNSViewController(_ timelineViewController: TimelineViewController, context: Context) { - // SDK diff batches update the controller directly through LiveTimeline. - } -} diff --git a/Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift b/Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift deleted file mode 100644 index 7e9f7b3..0000000 --- a/Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift +++ /dev/null @@ -1,101 +0,0 @@ -import AppKit -import QuartzCore - -final class TypingIndicatorRowView: NSView { - 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 - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - 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) - - content.orientation = .horizontal - content.alignment = .centerY - content.spacing = 8 - content.addArrangedSubview(dotStack) - content.addArrangedSubview(label) - content.translatesAutoresizingMaskIntoConstraints = false - content.isHidden = true - addSubview(content) - - NSLayoutConstraint.activate([ - content.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), - content.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), - content.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) - } - - func configure(names: [String]) { - guard !names.isEmpty else { - label.stringValue = "" - content.isHidden = true - stopAnimating() - return - } - - 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() - } - - private func startAnimating() { - guard !isAnimating else { return } - isAnimating = true - - 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 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/Mactrix/Views/ChatView/TimelineView/VirtualItemRowView.swift b/Mactrix/Views/ChatView/TimelineView/VirtualItemRowView.swift deleted file mode 100644 index b1942c1..0000000 --- a/Mactrix/Views/ChatView/TimelineView/VirtualItemRowView.swift +++ /dev/null @@ -1,95 +0,0 @@ -import AppKit -import MatrixRustSDK - -final class VirtualItemRowView: NSView { - static let rowHeight: CGFloat = 40 - - 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) - - 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) - - 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.centerYAnchor.constraint(equalTo: centerYAnchor), - - 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), - ]) - - updateLineColor() - } - - 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() - } - - override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - 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 - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/Mactrix/Views/Inspector/InspectorRoomPins.swift b/Mactrix/Views/Inspector/InspectorRoomPins.swift deleted file mode 100644 index c8672db..0000000 --- a/Mactrix/Views/Inspector/InspectorRoomPins.swift +++ /dev/null @@ -1,77 +0,0 @@ -import MatrixRustSDK -import Models -import OSLog -import SwiftUI -import UI - -struct RoomPinRow: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - - let timeline: LiveTimeline - let eventId: String - - @State private var event: MatrixRustSDK.EventTimelineItem? - - @ViewBuilder - var content: some View { - if let event { - VStack { - Text(event.sender).bold() - Text(event.content.description) - } - } 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)") - } - } - } -} - -struct InspectorRoomPins: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - - @State var pinnedMessages: [String] = [] - @State var selectedId: String? - - var timeline: LiveTimeline? { - guard case let .joinedRoom(timeline: timeline) = windowState.selectedScreen else { - return nil - } - - 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") - } - } -} diff --git a/Mactrix/Views/Inspector/InspectorScreen.swift b/Mactrix/Views/Inspector/InspectorScreen.swift deleted file mode 100644 index fc2e654..0000000 --- a/Mactrix/Views/Inspector/InspectorScreen.swift +++ /dev/null @@ -1,64 +0,0 @@ -import MatrixRustSDK -import Models -import OSLog -import SwiftUI -import UI - -struct InspectorScreen: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState - - @ViewBuilder - var content: some View { - @Bindable var windowState = windowState - - switch windowState.inspectorContent { - case .search: - SearchInspectorView() - case let .focusThread(threadTimeline: thread): - VStack(spacing: 0) { - UI.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): - UI.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) - } - } - - var body: some View { - @Bindable var windowState = windowState - - content - .toolbar { - Button { - windowState.toggleInspector() - } label: { - Label("Toggle Inspector", systemImage: "info.circle") - } - .help("Toggle Inspector") - } - } -} diff --git a/Mactrix/Views/Inspector/InspectorThreads.swift b/Mactrix/Views/Inspector/InspectorThreads.swift deleted file mode 100644 index 3dd88a2..0000000 --- a/Mactrix/Views/Inspector/InspectorThreads.swift +++ /dev/null @@ -1,38 +0,0 @@ -import MatrixRustSDK -import Models -import OSLog -import SwiftUI -import UI - -struct InspectorThreads: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - - @State var threads: [String] = [] - - var timeline: LiveTimeline? { - guard case let .joinedRoom(timeline: timeline) = windowState.selectedScreen else { - return nil - } - - return timeline - } - - @ViewBuilder - var content: some View { - Text("Threads") - } - - var body: some View { - content - .task(id: timeline?.room.id ?? "") { - guard let _ = timeline else { - threads = [] - return - } - - // TODO: Fetch threads when Room::list_threads is added to the FFI - // timeline.room.room.list_threads(...) - } - } -} diff --git a/Mactrix/Views/Inspector/InspectorUserInfo.swift b/Mactrix/Views/Inspector/InspectorUserInfo.swift deleted file mode 100644 index 77533b0..0000000 --- a/Mactrix/Views/Inspector/InspectorUserInfo.swift +++ /dev/null @@ -1,53 +0,0 @@ -import MatrixRustSDK -import Models -import OSLog -import SwiftUI -import UI - -struct InspectorUserInfo: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - - let userId: String - - @State private var profile: MatrixRustSDK.UserProfile? - - var isUserIgnored: Bool { - appState.matrixClient?.ignoredUserIds.contains(userId) == true - } - - @ViewBuilder - var content: some View { - if let profile { - UI.UserProfileView( - profile: profile, - isUserIgnored: isUserIgnored, - actions: appState.matrixClient?.userProfileActions(forUserId: userId, windowState: windowState), - timelineActions: nil, - imageLoader: appState.matrixClient - ) - } else { - UI.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 } - - do { - profile = try await matrixClient.client.getProfile(userId: userId) - } catch { - Logger.viewCycle.error("failed to get profile \(error)") - } - } - } -} diff --git a/Mactrix/Views/LoadMatrixUriScreen.swift b/Mactrix/Views/LoadMatrixUriScreen.swift deleted file mode 100644 index 8ee0776..0000000 --- a/Mactrix/Views/LoadMatrixUriScreen.swift +++ /dev/null @@ -1,77 +0,0 @@ -import MatrixRustSDK -import OSLog -import SwiftUI -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 { - 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 - } - - 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/Mactrix/Views/MatrixImageView.swift b/Mactrix/Views/MatrixImageView.swift deleted file mode 100644 index 87e3661..0000000 --- a/Mactrix/Views/MatrixImageView.swift +++ /dev/null @@ -1,70 +0,0 @@ -import MatrixRustSDK -import SwiftUI -import UniformTypeIdentifiers - -struct MatrixImageView: View { - let mediaSource: MediaSource? - let mimeType: String? - - @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)) - } - } - - @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 } - - let cacheKey = NSString(string: mediaSource.url()) - if let cached = MatrixClient.imageCache.object(forKey: cacheKey) { - image = Image(nsImage: cached) - return - } - - 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 let nsImage = NSImage(data: data) { - MatrixClient.setCachedImage(nsImage, forKey: cacheKey) - } - } catch { - errorMessage = error.localizedDescription - } - } - } -} diff --git a/Mactrix/Views/MultilineBannerLabelStyle.swift b/Mactrix/Views/MultilineBannerLabelStyle.swift deleted file mode 100644 index 9ee6cb2..0000000 --- a/Mactrix/Views/MultilineBannerLabelStyle.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// MultilineBanner.swift -// Mactrix -// -// Created by Marquis Kurt on 15-02-2026. -// - -import SwiftUI - -struct MultilineBannerLabelStyle: LabelStyle { - 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() } -} diff --git a/Mactrix/Views/Search/SearchInspectorView.swift b/Mactrix/Views/Search/SearchInspectorView.swift deleted file mode 100644 index 5d2630c..0000000 --- a/Mactrix/Views/Search/SearchInspectorView.swift +++ /dev/null @@ -1,91 +0,0 @@ -import MatrixRustSDK -import Models -import OSLog -import SwiftUI -import UI - -struct SearchResolvedRoomInspectorView: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState - - @State private var roomPreview: MatrixRustSDK.RoomPreview? - - 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 body: some View { - Group { - if let roomPreview { - UI.RoomPreviewView( - preview: roomPreview.info(), - imageLoader: appState.matrixClient, - actions: roomActions - ) - } else { - UI.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 - - @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 let .resolvedRoomAlias(alias: alias, resolvedRoom: resolvedRoom): - SearchResolvedRoomInspectorView(alias: alias, resolvedRoom: resolvedRoom) - case let .resolvedRoomId(roomPreview: roomPreview): - UI.RoomPreviewView( - preview: roomPreview.info(), - imageLoader: appState.matrixClient, - actions: appState.matrixClient?.roomPreviewActions(forRoomWithId: roomPreview.info().roomId, windowState: windowState) - ) - case let .resolvedUser(profile: userProfile): - UI.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) - } -} diff --git a/Mactrix/Views/Search/SearchRoomInspectorView.swift b/Mactrix/Views/Search/SearchRoomInspectorView.swift deleted file mode 100644 index 66a957d..0000000 --- a/Mactrix/Views/Search/SearchRoomInspectorView.swift +++ /dev/null @@ -1,76 +0,0 @@ -import MatrixRustSDK -import OSLog -import SwiftUI -import UI - -struct SearchRoomInspectorView: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState - - @State var searching: Bool = false - @State var roomListSelection: String? = 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) - } - } - } - } - } - .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 - } - - guard !windowState.searchQuery.isEmpty else { - roomSearch.rooms.removeAll() - return - } - - searching = true - defer { searching = false } - - 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)") - } - } - } -} diff --git a/Mactrix/Views/Search/SearchUserInspectorView.swift b/Mactrix/Views/Search/SearchUserInspectorView.swift deleted file mode 100644 index 221753f..0000000 --- a/Mactrix/Views/Search/SearchUserInspectorView.swift +++ /dev/null @@ -1,58 +0,0 @@ -import MatrixRustSDK -import OSLog -import SwiftUI -import UI - -struct SearchUserInspectorView: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState - - @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 - UI.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) - } - } - .task(id: windowState.searchQuery) { - do { - guard let matrixClient = appState.matrixClient else { return } - guard !windowState.searchQuery.isEmpty else { - searchedUsers = [] - return - } - - searching = true - defer { searching = false } - - try await Task.sleep(for: .milliseconds(500)) - - 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)") - } - } - } -} diff --git a/Mactrix/Views/Search/SearchViewModifier.swift b/Mactrix/Views/Search/SearchViewModifier.swift deleted file mode 100644 index f5eee0e..0000000 --- a/Mactrix/Views/Search/SearchViewModifier.swift +++ /dev/null @@ -1,171 +0,0 @@ -import MatrixRustSDK -import OSLog -import SwiftUI - -struct SearchViewModifier: ViewModifier { - @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 let .resolvedRoomAlias(alias: alias, resolvedRoom: _): - Text(alias) - case let .resolvedRoomId(roomPreview: preview): - Text(preview.info().roomId) - case let .resolvedUser(profile: 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 let .resolvedRoomAlias(alias: alias, resolvedRoom: 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 let .resolvedRoomId(roomPreview: 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 let .resolvedUser(profile: 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() - } - - 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) - } - } - - func calculateDirectSearchResults() async { - let query: String = { - var result = windowState.searchQuery.trimmingCharacters(in: .whitespaces) - - let matrixToPrefix = "https://matrix.to/#/" - if result.starts(with: matrixToPrefix) { - result = String(result.dropFirst(matrixToPrefix.count)) - result = result.removingPercentEncoding ?? result - } - - if result.starts(with: "@") { - result = result.lowercased() - } - - return result - }() - - do { - guard let matrixClient = appState.matrixClient else { return } - try await Task.sleep(for: .milliseconds(300)) - - 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/Mactrix/Views/Settings/AccountSettingsView.swift b/Mactrix/Views/Settings/AccountSettingsView.swift deleted file mode 100644 index d309adc..0000000 --- a/Mactrix/Views/Settings/AccountSettingsView.swift +++ /dev/null @@ -1,114 +0,0 @@ -import MatrixRustSDK -import OSLog -import SwiftUI - -struct ChangableField: View { - 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 - - 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) - } - } - } -} - -struct AccountSettingsView: View { - @Environment(AppState.self) var appState - - @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)") - } - } - - LabeledContent("User") { - Text((try? matrixClient.client.userId()) ?? "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) - } - } - - 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") - } - } -} - -#Preview { - AccountSettingsView() -} diff --git a/Mactrix/Views/Settings/AppearanceSettingsView.swift b/Mactrix/Views/Settings/AppearanceSettingsView.swift deleted file mode 100644 index e0dc34d..0000000 --- a/Mactrix/Views/Settings/AppearanceSettingsView.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SwiftUI - -struct AppearanceSettingsView: View { - @AppStorage("fontSize") var fontSize: Int = 13 - - var body: some View { - Form { - Picker("Font size", selection: $fontSize) { - ForEach(8..<25) { - Text("\($0)") - .tag($0) - } - } - } - } -} diff --git a/Mactrix/Views/Settings/SessionsSettingsView.swift b/Mactrix/Views/Settings/SessionsSettingsView.swift deleted file mode 100644 index 1a81e19..0000000 --- a/Mactrix/Views/Settings/SessionsSettingsView.swift +++ /dev/null @@ -1,61 +0,0 @@ -import MatrixRustSDK -import SwiftUI - -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)) - .padding(12) - .background(RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.2))) - .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 - 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." - ) - } - } - - var body: some View { - if let matrixClient = appState.matrixClient { - sessionVerificationView(matrixClient: matrixClient) - } else { - ContentUnavailableView("User not logged in", systemImage: "person") - } - } -} diff --git a/Mactrix/Views/Settings/SettingsView.swift b/Mactrix/Views/Settings/SettingsView.swift deleted file mode 100644 index 2254b1d..0000000 --- a/Mactrix/Views/Settings/SettingsView.swift +++ /dev/null @@ -1,20 +0,0 @@ -import MatrixRustSDK -import SwiftUI - -struct SettingsView: View { - 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/Mactrix/Views/Sidebar/RoomContextMenu.swift b/Mactrix/Views/Sidebar/RoomContextMenu.swift deleted file mode 100644 index 3b32abb..0000000 --- a/Mactrix/Views/Sidebar/RoomContextMenu.swift +++ /dev/null @@ -1,82 +0,0 @@ -import MatrixRustSDK -import SwiftUI -import OSLog - -struct RoomContextMenu: View { - @Environment(WindowState.self) var windowState - - 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") - } - } - - 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") - } - } - - 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() - - 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/Mactrix/Views/Sidebar/SidebarSessionVerification.swift b/Mactrix/Views/Sidebar/SidebarSessionVerification.swift deleted file mode 100644 index 58e063c..0000000 --- a/Mactrix/Views/Sidebar/SidebarSessionVerification.swift +++ /dev/null @@ -1,104 +0,0 @@ -import MatrixRustSDK -import OSLog -import SwiftUI - -struct SessionVerificationStatusView: View { - @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() - - // 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)) - } - } - - 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 - } - } - } - } - } - .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)") - } - } -} diff --git a/Mactrix/Views/Sidebar/SidebarSyncState.swift b/Mactrix/Views/Sidebar/SidebarSyncState.swift deleted file mode 100644 index 8186c3c..0000000 --- a/Mactrix/Views/Sidebar/SidebarSyncState.swift +++ /dev/null @@ -1,52 +0,0 @@ -import MatrixRustSDK -import OSLog -import SwiftUI - -struct SidebarSyncStateView: View { - @Environment(AppState.self) var appState - - @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 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 } - - do { - try await appState.matrixClient?.startSync() - } catch { - Logger.viewCycle.error("failed to restart sync: \(error)") - } - } - } - .disabled(restarting) - } - } - } -} diff --git a/Mactrix/Views/Sidebar/SidebarView.swift b/Mactrix/Views/Sidebar/SidebarView.swift deleted file mode 100644 index c4b9373..0000000 --- a/Mactrix/Views/Sidebar/SidebarView.swift +++ /dev/null @@ -1,105 +0,0 @@ -import MatrixRustSDK -import SwiftUI -import UI - -struct SidebarView: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState - - @State private var searchText: String = "" - - 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 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 body: some View { - @Bindable var windowState = windowState - - List(selection: $windowState.selectedRoomId) { - SidebarSyncStateView() - - SessionVerificationStatusView() - - if !favorites.isEmpty { - Section("Favorites", isExpanded: $windowState.sidebarSections.favorites) { - ForEach(favorites) { room in - UI.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 - UI.RoomRow( - title: room.room.displayName() ?? "Unknown user", - 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 - UI.RoomRow( - title: room.room.displayName() ?? "Unknown Room", - 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) - } - } - } - .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: nil) - .toolbar { - AppCommands.createRoomButton(windowState: windowState) - } - } -} diff --git a/Mactrix/Views/Sidebar/SpaceDisclosureGroup.swift b/Mactrix/Views/Sidebar/SpaceDisclosureGroup.swift deleted file mode 100644 index b223e24..0000000 --- a/Mactrix/Views/Sidebar/SpaceDisclosureGroup.swift +++ /dev/null @@ -1,98 +0,0 @@ -import MatrixRustSDK -import SwiftUI -import UI -import OSLog - -struct SpaceDisclosureGroup: View { - @Environment(AppState.self) var appState - @Environment(WindowState.self) var windowState - - @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 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 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 - } - - var joinedRoom: SidebarRoom? { - return appState.matrixClient?.rooms.first(where: { $0.room.id() == space.id }) - } - - @ViewBuilder - var roomRow: some View { - if let joinedRoom { - UI.RoomRow( - title: space.spaceRoom.displayName, - avatarUrl: space.spaceRoom.avatarUrl, - roomInfo: joinedRoom.roomInfo, - imageLoader: appState.matrixClient, - joinRoom: nil - ) - .contextMenu { - RoomContextMenu(room: joinedRoom) - } - } else { - UI.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 - } - } -} diff --git a/Mactrix/Views/WelcomeSheetView.swift b/Mactrix/Views/WelcomeSheetView.swift deleted file mode 100644 index 31e1a20..0000000 --- a/Mactrix/Views/WelcomeSheetView.swift +++ /dev/null @@ -1,148 +0,0 @@ -import AuthenticationServices -import MatrixRustSDK -import SwiftUI - -struct WelcomeSheetView: View { - @Environment(AppState.self) private var appState - @Environment(\.dismiss) private var dismiss - - @Environment(\.webAuthenticationSession) private var webAuthenticationSession - - @State private var homeserverLogin: HomeserverLogin? = nil - - @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 - - 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 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 - } - } - } - - 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() - } - } - - 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) - } - } - .padding() - } -} - -#Preview { - WelcomeSheetView() - .environment(AppState()) -} 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/Package.resolved b/MactrixLibrary/Package.resolved deleted file mode 100644 index 5afcba9..0000000 --- a/MactrixLibrary/Package.resolved +++ /dev/null @@ -1,24 +0,0 @@ -{ - "originHash" : "957de3239916ad17ad4f4a9c97e2018e0fa1f71a8db77d112f2c7059329c1166", - "pins" : [ - { - "identity" : "zmarkupparser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ZhgChgLi/ZMarkupParser.git", - "state" : { - "revision" : "f25c9838e9e77ae49cc3f434ee26e669cb4043c7", - "version" : "1.12.0" - } - }, - { - "identity" : "znstextattachment", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ZhgChgLi/ZNSTextAttachment", - "state" : { - "revision" : "91f718caf50649d817f189eb17ad028f03fd632b", - "version" : "1.2.0" - } - } - ], - "version" : 3 -} diff --git a/MactrixLibrary/Package.swift b/MactrixLibrary/Package.swift deleted file mode 100644 index 6f570c4..0000000 --- a/MactrixLibrary/Package.swift +++ /dev/null @@ -1,49 +0,0 @@ -// swift-tools-version: 6.1 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "MactrixLibrary", - platforms: [.macOS(.v15)], - products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "UI", - targets: ["UI"] - ), - .library( - name: "Models", - targets: ["Models"] - ), - .library(name: "Utils", targets: ["Utils"]), - .library(name: "MessageFormatting", targets: ["MessageFormatting"]), - ], - 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"), - ], - 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: "UI", - dependencies: ["Models"] - ), - .target(name: "Utils"), - .testTarget(name: "UtilsTests", dependencies: ["Utils"]), - /* .target( - name: "TimelineUI", - dependencies: ["Models", .product(name: "MatrixRustSDK", package: "matrix-rust-components-swift")] - ), */ - .target( - name: "Models" - ), - .target( - name: "MessageFormatting", - dependencies: [ - "ZMarkupParser", - ] - ), - ] -) diff --git a/MactrixLibrary/Sources/MessageFormatting/AttributedTextView.swift b/MactrixLibrary/Sources/MessageFormatting/AttributedTextView.swift deleted file mode 100644 index 73a4d9a..0000000 --- a/MactrixLibrary/Sources/MessageFormatting/AttributedTextView.swift +++ /dev/null @@ -1,56 +0,0 @@ -import SwiftUI - -public struct AttributedTextView: NSViewRepresentable { - public let attributedString: NSAttributedString - - public init(attributedString: NSAttributedString) { - self.attributedString = attributedString.trimmed - } - - public func makeNSView(context: Context) -> NSTextField { - let textField = NSTextField(labelWithAttributedString: attributedString) - - textField.isEditable = false - textField.isSelectable = true - textField.allowsEditingTextAttributes = true - - textField.lineBreakStrategy = .standard - textField.lineBreakMode = .byWordWrapping - textField.usesSingleLineMode = false - - return textField - } - - 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)) - } -} - -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) - } -} diff --git a/MactrixLibrary/Sources/MessageFormatting/Parser.swift b/MactrixLibrary/Sources/MessageFormatting/Parser.swift deleted file mode 100644 index d4ca40f..0000000 --- a/MactrixLibrary/Sources/MessageFormatting/Parser.swift +++ /dev/null @@ -1,87 +0,0 @@ -import AppKit -import Foundation -import ZMarkupParser - -@MainActor -public func parseFormattedBody(_ body: String, baseFontSize: CGFloat = 13) -> NSAttributedString { - 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)) - ) - ) - .build() - - 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 - } - - return rendered.attributedSubstring(from: NSRange(location: 0, length: length)) -} diff --git a/MactrixLibrary/Sources/Models/CreateRoomParams.swift b/MactrixLibrary/Sources/Models/CreateRoomParams.swift deleted file mode 100644 index 497426e..0000000 --- a/MactrixLibrary/Sources/Models/CreateRoomParams.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -public enum RoomAccess: Sendable, Hashable { - case publicRoom, privateRoom -} - -public enum RoomVisibility: Sendable { - 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 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 - } -} diff --git a/MactrixLibrary/Sources/Models/EncryptionState.swift b/MactrixLibrary/Sources/Models/EncryptionState.swift deleted file mode 100644 index dd94936..0000000 --- a/MactrixLibrary/Sources/Models/EncryptionState.swift +++ /dev/null @@ -1,17 +0,0 @@ - -@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 -} diff --git a/MactrixLibrary/Sources/Models/EventTimelineItem.swift b/MactrixLibrary/Sources/Models/EventTimelineItem.swift deleted file mode 100644 index d4ce4cc..0000000 --- a/MactrixLibrary/Sources/Models/EventTimelineItem.swift +++ /dev/null @@ -1,87 +0,0 @@ -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 - } -} - -public struct Receipt: Equatable, Hashable { - public var timestamp: Date? - - 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 } -} - -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 } -} diff --git a/MactrixLibrary/Sources/Models/Extensions/Color+userID.swift b/MactrixLibrary/Sources/Models/Extensions/Color+userID.swift deleted file mode 100644 index 9b26111..0000000 --- a/MactrixLibrary/Sources/Models/Extensions/Color+userID.swift +++ /dev/null @@ -1,12 +0,0 @@ -import SwiftUI - -extension Color { - public init(userID: String) { - self.init( - String( - format: "Username%02d", - userID.unicodeScalars.reduce(0) { $0 + Int($1.value) } % 16 + 1 - ) - ) - } -} diff --git a/MactrixLibrary/Sources/Models/Extensions/String+Markdown.swift b/MactrixLibrary/Sources/Models/Extensions/String+Markdown.swift deleted file mode 100644 index a48c295..0000000 --- a/MactrixLibrary/Sources/Models/Extensions/String+Markdown.swift +++ /dev/null @@ -1,12 +0,0 @@ -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) - } - } -} diff --git a/MactrixLibrary/Sources/Models/InReplyToDetails.swift b/MactrixLibrary/Sources/Models/InReplyToDetails.swift deleted file mode 100644 index b16d9a9..0000000 --- a/MactrixLibrary/Sources/Models/InReplyToDetails.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -public enum EventOrTransactionId { - 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) -} - -public enum EmbeddedEventDetails { - 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 - - var event: EmbeddedEventDetails { get } - - var eventId: String { get } -} diff --git a/MactrixLibrary/Sources/Models/MsgLikeContent.swift b/MactrixLibrary/Sources/Models/MsgLikeContent.swift deleted file mode 100644 index ba643f2..0000000 --- a/MactrixLibrary/Sources/Models/MsgLikeContent.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -public protocol Mentions { - 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 } -} - -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 */ ) -} - -public protocol MsgLikeContent { - 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 } -} diff --git a/MactrixLibrary/Sources/Models/Reaction.swift b/MactrixLibrary/Sources/Models/Reaction.swift deleted file mode 100644 index 4abc178..0000000 --- a/MactrixLibrary/Sources/Models/Reaction.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -public protocol Reaction: Identifiable { - associatedtype SenderData: ReactionSenderData - - var key: String { get } - var senders: [SenderData] { get } -} - -public protocol ReactionSenderData { - var senderId: String { get } - var date: Date { get } -} - -public struct MockReactionSenderData: ReactionSenderData { - public var senderId: String { - "sender@address" - } - - public var date: Date { - .now - } -} - -public struct MockReaction: Reaction { - public init() {} - - public var senders: [MockReactionSenderData] { - [MockReactionSenderData()] - } - - public typealias SenderData = MockReactionSenderData - - public var key: String { - "😄" - } - - public var id: String { - key - } -} diff --git a/MactrixLibrary/Sources/Models/Room.swift b/MactrixLibrary/Sources/Models/Room.swift deleted file mode 100644 index 269bd1f..0000000 --- a/MactrixLibrary/Sources/Models/Room.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation - -public enum SelectedRoom { - 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 } -} - -public enum Membership { - 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 - ) -} - -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) - } -} diff --git a/MactrixLibrary/Sources/Models/RoomInfo.swift b/MactrixLibrary/Sources/Models/RoomInfo.swift deleted file mode 100644 index b8a607b..0000000 --- a/MactrixLibrary/Sources/Models/RoomInfo.swift +++ /dev/null @@ -1,154 +0,0 @@ -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 } -} - -public struct MockRoomInfo: RoomInfo { - public init() {} - - public var id: String { "mock_id" } - - public var creators: [String]? { nil } - - public var displayName: String? { "Room Name" } - - public var rawName: String? { "room_name_raw" } - - public var topic: String? { "The topic of the room" } - - public var avatarUrl: String? { nil } - - public var isDirect: Bool { false } - - public var isPublic: Bool? { true } - - public var isSpace: Bool { false } - - public var isFavourite: Bool { false } - - public var canonicalAlias: String? { "#room:matrix.org" } - - public var alternativeAliases: [String] { [] } - - public var activeMembersCount: UInt64 { 3 } - - public var invitedMembersCount: UInt64 { 1 } - - public var joinedMembersCount: UInt64 { 8 } - - public var highlightCount: UInt64 { 0 } - - public var notificationCount: UInt64 { 2 } - - public var hasRoomCall: Bool { false } - - public var activeRoomCallParticipants: [String] { [] } - - public var isMarkedUnread: Bool { false } - - public var numUnreadMessages: UInt64 { 4 } - - public var numUnreadNotifications: UInt64 { 2 } - - public var numUnreadMentions: UInt64 { 2 } - - public var pinnedEventIds: [String] { [] } - - public var roomVersion: String? { "12" } - - public var privilegedCreatorsRole: Bool { false } -} diff --git a/MactrixLibrary/Sources/Models/RoomMember.swift b/MactrixLibrary/Sources/Models/RoomMember.swift deleted file mode 100644 index 442f429..0000000 --- a/MactrixLibrary/Sources/Models/RoomMember.swift +++ /dev/null @@ -1,71 +0,0 @@ -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 -} - -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 } -} - -public struct MockRoomMember: RoomMember { - public init() {} - - public var id: String { - userId - } - - public var userId: String { - "user@id" - } - - public var displayName: String? { - "User Name" - } - - public var avatarUrl: String? { - nil - } - - public var isNameAmbiguous: Bool { - false - } - - public var isIgnored: Bool { - false - } - - public var roleForPowerLevel: RoomMemberRole { - .user - } - - public var membershipChangeReason: String? { - nil - } -} diff --git a/MactrixLibrary/Sources/Models/RoomPreviewInfo.swift b/MactrixLibrary/Sources/Models/RoomPreviewInfo.swift deleted file mode 100644 index a912085..0000000 --- a/MactrixLibrary/Sources/Models/RoomPreviewInfo.swift +++ /dev/null @@ -1,137 +0,0 @@ -import Foundation - -/** - * The rule used for users wishing to join this room. - */ - -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 -} - -/** - * 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 } -} - -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 - } -} diff --git a/MactrixLibrary/Sources/Models/SessionVerificationData.swift b/MactrixLibrary/Sources/Models/SessionVerificationData.swift deleted file mode 100644 index 3d56a38..0000000 --- a/MactrixLibrary/Sources/Models/SessionVerificationData.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -public enum SessionVerificationData { - case emojis(emojis: [VerificationEmoji], indices: Data) - case decimals(values: [UInt16]) -} - -public protocol SessionVerificationEmoji: Identifiable { - var description: String { get } - var symbol: String { get } -} - -public struct MockSessionVerificationEmoji: SessionVerificationEmoji { - public var id: String { symbol } - - public var description: String - public var symbol: String - - public init(description: String, symbol: String) { - self.description = description - self.symbol = symbol - } -} diff --git a/MactrixLibrary/Sources/Models/ThreadSummary.swift b/MactrixLibrary/Sources/Models/ThreadSummary.swift deleted file mode 100644 index 3064dce..0000000 --- a/MactrixLibrary/Sources/Models/ThreadSummary.swift +++ /dev/null @@ -1,21 +0,0 @@ -public protocol ThreadSummary { - func numReplies() -> UInt64 - - var description: String? { get } -} - -public struct MockThreadSummary: ThreadSummary { - public init() {} - - public func numReplies() -> UInt64 { - 4 - } - - public var description: String? { - """ - This is a thread message, that is _very_ long. - - It event has multiple lines. - """ - } -} diff --git a/MactrixLibrary/Sources/Models/UserProfile.swift b/MactrixLibrary/Sources/Models/UserProfile.swift deleted file mode 100644 index afac5ea..0000000 --- a/MactrixLibrary/Sources/Models/UserProfile.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -public protocol UserProfile: Identifiable { - var userId: String { get } - var displayName: String? { get } - var avatarUrl: String? { get } -} - -public extension UserProfile { - var id: String { userId } -} - -public struct MockUserProfile: UserProfile { - public init() {} - - public var userId: String { - "@user:matrix.org" - } - - public var displayName: String? { - "Matrix User" - } - - public var avatarUrl: String? { nil } -} diff --git a/MactrixLibrary/Sources/Models/VirtualTimelineItem.swift b/MactrixLibrary/Sources/Models/VirtualTimelineItem.swift deleted file mode 100644 index 739698d..0000000 --- a/MactrixLibrary/Sources/Models/VirtualTimelineItem.swift +++ /dev/null @@ -1,19 +0,0 @@ -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 -} diff --git a/MactrixLibrary/Sources/UI/AvatarImage.swift b/MactrixLibrary/Sources/UI/AvatarImage.swift deleted file mode 100644 index b58027b..0000000 --- a/MactrixLibrary/Sources/UI/AvatarImage.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Models -import OSLog -import SwiftUI - -@MainActor -public protocol ImageLoader { - 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)) - } - } - - 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 - - @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 - } - - // 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) - } - } - } - } -} - -#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()) -} diff --git a/MactrixLibrary/Sources/UI/Inspector/UserProfileRow.swift b/MactrixLibrary/Sources/UI/Inspector/UserProfileRow.swift deleted file mode 100644 index a38ee7a..0000000 --- a/MactrixLibrary/Sources/UI/Inspector/UserProfileRow.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Models -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()) - } - } -} - -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() - } - } -} - -#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) -} diff --git a/MactrixLibrary/Sources/UI/Inspector/UserProfileView.swift b/MactrixLibrary/Sources/UI/Inspector/UserProfileView.swift deleted file mode 100644 index 6c84b32..0000000 --- a/MactrixLibrary/Sources/UI/Inspector/UserProfileView.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Models -import SwiftUI - -@MainActor -public protocol UserProfileActions { - func sendMessage() async - func shareProfile() - func ignoreUser() async - func unignoreUser() async -} - -public protocol UserProfileTimelineActions { - 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 - } - - @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") - } - .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: { 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) - } - } - .disabled(actions == nil) - } - } -} - -struct MockUserProfileActions: UserProfileActions { - 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) -} diff --git a/MactrixLibrary/Sources/UI/Logger+Extensions.swift b/MactrixLibrary/Sources/UI/Logger+Extensions.swift deleted file mode 100644 index a6443a1..0000000 --- a/MactrixLibrary/Sources/UI/Logger+Extensions.swift +++ /dev/null @@ -1,7 +0,0 @@ -import OSLog - -extension Logger { - private static let subsystem = "dk.qpqp.mactrix.library.ui" - - static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") -} diff --git a/MactrixLibrary/Sources/UI/Rooms/RoomEncryptionBadge.swift b/MactrixLibrary/Sources/UI/Rooms/RoomEncryptionBadge.swift deleted file mode 100644 index a03f881..0000000 --- a/MactrixLibrary/Sources/UI/Rooms/RoomEncryptionBadge.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Models -import SwiftUI - -public struct RoomEncryptionBadge: View { - let state: EncryptionState - - public init(state: EncryptionState) { - self.state = state - } - - 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) - } - } - - 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) -} diff --git a/MactrixLibrary/Sources/UI/Rooms/RoomInspectorView.swift b/MactrixLibrary/Sources/UI/Rooms/RoomInspectorView.swift deleted file mode 100644 index e296773..0000000 --- a/MactrixLibrary/Sources/UI/Rooms/RoomInspectorView.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Models -import OSLog -import SwiftUI - -struct RoomInspectorMemberRow: View { - let member: RoomMember - let 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 - } - - @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) - } - } - - 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) - } - - @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)") - } - .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) -} diff --git a/MactrixLibrary/Sources/UI/Rooms/RoomPreviewView.swift b/MactrixLibrary/Sources/UI/Rooms/RoomPreviewView.swift deleted file mode 100644 index dbe106f..0000000 --- a/MactrixLibrary/Sources/UI/Rooms/RoomPreviewView.swift +++ /dev/null @@ -1,229 +0,0 @@ -import Models -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)) - ) - } -} - -@MainActor -public protocol RoomPreviewActions { - func joinRoom() async throws - func knockRoom() async throws - func visitRoom() -} - -struct MockRoomPreviewActions: RoomPreviewActions { - 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 - } - - 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) - } - - 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) - } - 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() - } - } - } - - public var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - header - - actionButton - - Divider() - - 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) -} diff --git a/MactrixLibrary/Sources/UI/Screens/CreateRoomScreen.swift b/MactrixLibrary/Sources/UI/Screens/CreateRoomScreen.swift deleted file mode 100644 index c045bfd..0000000 --- a/MactrixLibrary/Sources/UI/Screens/CreateRoomScreen.swift +++ /dev/null @@ -1,159 +0,0 @@ -import Models -import SwiftUI - -public struct CreateRoomScreen: View { - @State private var params = CreateRoomParams() - @State private var submitting = false - @State private var errorMsg: Error? = nil - - let onSubmit: (_ params: CreateRoomParams) async throws -> Void - - 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) - - 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) - } - } - .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)) - } -} diff --git a/MactrixLibrary/Sources/UI/SessionVerificationModal.swift b/MactrixLibrary/Sources/UI/SessionVerificationModal.swift deleted file mode 100644 index aed27ba..0000000 --- a/MactrixLibrary/Sources/UI/SessionVerificationModal.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Models -import SwiftUI - -public enum SessionVerificationResponse { - case accept, decline -} - -public struct SessionVerificationModal: View { - let verificationData: SessionVerificationData - var onComplete: (_ response: SessionVerificationResponse) -> Void - - @Environment(\.dismiss) var dismiss - - 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?" - } - } - - public var body: some View { - VStack(spacing: 20) { - VStack { - Text("Session Verification") - .font(.title) - - Text(subtitle) - } - - 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) - } - } - } - - case let .decimals(values): - ForEach(values, id: \.self) { value in - Text("\(value)") - } - } - - 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: "☀️"), - ], indices: Data()) - - SessionVerificationModal(verificationData: data, onComplete: { _ in }) -} diff --git a/MactrixLibrary/Sources/UI/Sidebar/RoomRow.swift b/MactrixLibrary/Sources/UI/Sidebar/RoomRow.swift deleted file mode 100644 index 0e5fe08..0000000 --- a/MactrixLibrary/Sources/UI/Sidebar/RoomRow.swift +++ /dev/null @@ -1,194 +0,0 @@ -import Models -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() - } -} - -struct MockError: LocalizedError { - 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()) - } */ -} - -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" - } - } - - var label: some View { - Label( - title: { - Text(title) - .lineLimit(1) - .truncationMode(.tail) - }, - icon: { - UI.AvatarImage(avatarUrl: avatarUrl, imageLoader: imageLoader) { - RoundedRectangle(cornerRadius: 4) - .fill(.clear) - .overlay { - Image(systemName: placeholderImageName) - } - } - .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 - } - } - .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: {} - ) - } - } - .listStyle(.sidebar) - .frame(width: 200) -} diff --git a/MactrixLibrary/Sources/UI/Sidebar/SectionAction.swift b/MactrixLibrary/Sources/UI/Sidebar/SectionAction.swift deleted file mode 100644 index 963139e..0000000 --- a/MactrixLibrary/Sources/UI/Sidebar/SectionAction.swift +++ /dev/null @@ -1,53 +0,0 @@ -import SwiftUI - -struct SectionAction: View { - let title: LocalizedStringKey - let systemIcon: String - let action: () -> Void - let content: () -> Content - - @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 - } - - 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") - } - } - .listStyle(.sidebar) - .frame(width: 200, height: 200) -} diff --git a/MactrixLibrary/Sources/UI/Timeline/MessageReplyView.swift b/MactrixLibrary/Sources/UI/Timeline/MessageReplyView.swift deleted file mode 100644 index 407cfec..0000000 --- a/MactrixLibrary/Sources/UI/Timeline/MessageReplyView.swift +++ /dev/null @@ -1,55 +0,0 @@ -import SwiftUI - -public struct MessageReplyView: View { - 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 - } - - 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() - } - - 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() -} diff --git a/MactrixLibrary/Sources/UI/Timeline/ThreadTimelineHeader.swift b/MactrixLibrary/Sources/UI/Timeline/ThreadTimelineHeader.swift deleted file mode 100644 index 5cecff3..0000000 --- a/MactrixLibrary/Sources/UI/Timeline/ThreadTimelineHeader.swift +++ /dev/null @@ -1,37 +0,0 @@ -import SwiftUI - -public struct ThreadTimelineHeader: View { - 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) - - Text("Thread") - .font(.headline) - } - .padding(10) - Divider() - } - .background(.thinMaterial) - } -} - -#Preview { - VStack(spacing: 0) { - ThreadTimelineHeader {} - Text("Body") - .frame(maxWidth: .infinity, maxHeight: 40) - .background(.white) - } -} diff --git a/MactrixLibrary/Sources/UI/Username.swift b/MactrixLibrary/Sources/UI/Username.swift deleted file mode 100644 index b409713..0000000 --- a/MactrixLibrary/Sources/UI/Username.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SwiftUI -import Models - -struct Username: View { - 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) - } -} diff --git a/MactrixLibrary/Sources/Utils/MatrixUriScheme.swift b/MactrixLibrary/Sources/Utils/MatrixUriScheme.swift deleted file mode 100644 index d3745e5..0000000 --- a/MactrixLibrary/Sources/Utils/MatrixUriScheme.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation - -public enum MatrixUriError: LocalizedError { - case parserError(reason: LocalizedStringResource) - - 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 enum Kind: Hashable, Codable { - case roomId(String) - case roomAlias(String) - case user(String) - } - - public enum Action: Hashable, Codable { - case join, chat - } - - 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") - } - - 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)") - } - - let queryComponents = query?.split(separator: "&") ?? [] - - var routingVia = [String]() - var action: Action? = nil - - 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 - } - } - - // todo - self.routingVia = routingVia - self.action = action - self.event = nil - } -} diff --git a/MactrixLibrary/Tests/UtilsTests/MatrixUriSchemeTests.swift b/MactrixLibrary/Tests/UtilsTests/MatrixUriSchemeTests.swift deleted file mode 100644 index fff9eea..0000000 --- a/MactrixLibrary/Tests/UtilsTests/MatrixUriSchemeTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -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) - } -} diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..48a9054 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,51 @@ +{ + "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", + "location" : "https://github.com/ZhgChgLi/ZMarkupParser.git", + "state" : { + "revision" : "f25c9838e9e77ae49cc3f434ee26e669cb4043c7", + "version" : "1.12.0" + } + }, + { + "identity" : "znstextattachment", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ZhgChgLi/ZNSTextAttachment", + "state" : { + "revision" : "91f718caf50649d817f189eb17ad028f03fd632b", + "version" : "1.2.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..8ac4ff5 --- /dev/null +++ b/Package.swift @@ -0,0 +1,55 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +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") + ] + ), + ] +) diff --git a/Sources/MactrixApp/Commands.swift b/Sources/MactrixApp/Commands.swift new file mode 100644 index 0000000..b956ce2 --- /dev/null +++ b/Sources/MactrixApp/Commands.swift @@ -0,0 +1,88 @@ +import SwiftUI + +public struct AppCommands: Commands { + @FocusedValue(WindowState.self) private var windowState: WindowState? + @AppStorage("fontSize") var fontSize: Int = 13 + + public init() {} + + 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 + } + + guard let windowController = currentMainWindow.windowController 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]) + + 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]) + } + + 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 -= 1 + + } 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 new file mode 100644 index 0000000..b3813ca --- /dev/null +++ b/Sources/MactrixApp/Extensions/Data+Mime.swift @@ -0,0 +1,56 @@ +import CoreImage +import Foundation +import ImageIO +import SwiftUI +import UniformTypeIdentifiers + +extension Data { + 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 + } + } + + /// 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() + } + + 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 + } +} diff --git a/Sources/MactrixApp/Extensions/Logger+Extensions.swift b/Sources/MactrixApp/Extensions/Logger+Extensions.swift new file mode 100644 index 0000000..75e41e3 --- /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..5883806 --- /dev/null +++ b/Sources/MactrixApp/Extensions/MatrixClient+Extensions.swift @@ -0,0 +1,152 @@ +import AppKit +import MactrixUI +import MatrixIntegration +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) + } + + 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/Sources/MactrixApp/Extensions/Models+Extensions.swift b/Sources/MactrixApp/Extensions/Models+Extensions.swift new file mode 100644 index 0000000..678d149 --- /dev/null +++ b/Sources/MactrixApp/Extensions/Models+Extensions.swift @@ -0,0 +1,36 @@ +import Foundation +import MatrixProtocols +import MatrixRustSDK + +extension MatrixProtocols.CreateRoomParams { + 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 + } + + 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 + ) + } +} diff --git a/Sources/MactrixApp/Extensions/NSColor+userID.swift b/Sources/MactrixApp/Extensions/NSColor+userID.swift new file mode 100644 index 0000000..6bb9cc5 --- /dev/null +++ b/Sources/MactrixApp/Extensions/NSColor+userID.swift @@ -0,0 +1,11 @@ +import AppKit + +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 new file mode 100644 index 0000000..e7eace9 --- /dev/null +++ b/Sources/MactrixApp/Extensions/NSTableView.swift @@ -0,0 +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 } + + 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 + } + + 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 + } + } +} diff --git a/Sources/MactrixApp/MainView.swift b/Sources/MactrixApp/MainView.swift new file mode 100644 index 0000000..5e07bbe --- /dev/null +++ b/Sources/MactrixApp/MainView.swift @@ -0,0 +1,231 @@ +import MactrixUI +import MatrixIntegration +import MatrixRustSDK +import OSLog +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 + } + } + } + } + ) + } + + 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)") + + do { + let matrixUri = try Utils.MatrixUriScheme(parseUrl: url.absoluteString) + Logger.viewCycle.info("Matched Matrix Uri") + + 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)") + } + + 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 } + + 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"]) + + 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 new file mode 100644 index 0000000..83f347a --- /dev/null +++ b/Sources/MactrixApp/Models/AppState.swift @@ -0,0 +1,19 @@ +import MatrixIntegration +import OSLog +import Observation + +@MainActor @Observable +public final class AppState { + 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 + } +} diff --git a/Mactrix/Models/SidebarSectionCollapsibility.swift b/Sources/MactrixApp/Models/SidebarSectionCollapsibility.swift similarity index 66% rename from Mactrix/Models/SidebarSectionCollapsibility.swift rename to Sources/MactrixApp/Models/SidebarSectionCollapsibility.swift index 5e7f964..03c05f0 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 { - /// Whether the favorites section is expanded. - var favorites = true +public struct SidebarSectionCollapsibility: Codable, Equatable { + /// Whether the favorites section is expanded. + public var favorites = true - /// Whether the direct messages section is expanded. - var directs = true + /// Whether the direct messages section is expanded. + public var directs = true - /// Whether the rooms section is expanded. - var rooms = true + /// Whether the rooms section is expanded. + public var rooms = true - /// Whether the spaces section is expanded. - 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 new file mode 100644 index 0000000..e6cc5f1 --- /dev/null +++ b/Sources/MactrixApp/Models/WindowState.swift @@ -0,0 +1,185 @@ +import Foundation +import MatrixIntegration +import MatrixRustSDK +import OSLog +import SwiftUI +import Utils + +public enum InspectorContent: Equatable { + case roomInfo + case search + case userInfo(userId: String) + case roomThreads + case roomPins + case focusThread(threadTimeline: LiveTimeline) +} + +public enum SearchDirectResult { + 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 +} + +@MainActor @Observable +public final class WindowState { + public var selectedScreen: SelectedScreen = .none + + public var selectedRoomId: String? + public var inspectorVisible: Bool = false + + public var inspectorContent: InspectorContent = .roomInfo + + public var requestedVerification = false + + 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() + + 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 func focusMessage(eventId: String) { + guard case .joinedRoom(timeline: let roomTimeline) = selectedScreen else { + Logger.windowState.warning("focus message failed, no active timeline") + return + } + + Logger.windowState.warning("scrolling to message \(eventId)") + roomTimeline.focusEvent(id: .eventId(eventId: eventId)) + } + + public func focusThread(rootEventId: String) { + guard case .joinedRoom(timeline: let roomTimeline) = selectedScreen else { return } + + inspectorVisible = true + inspectorContent = .focusThread( + threadTimeline: LiveTimeline(room: roomTimeline.room, focusThread: rootEventId)) + } + + public func showRoomThreads() { + if inspectorVisible, inspectorContent == .roomThreads { + inspectorVisible = false + } else { + inspectorContent = .roomThreads + 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 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)") + } + + public var rawValue: String { + guard + let data = try? JSONEncoder().encode(SceneStorageRepresentation(windowState: self)), + let result = String(data: data, encoding: .utf8) + else { + return "" + } + + 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 + } +} diff --git a/Sources/MactrixApp/Toolbar.swift b/Sources/MactrixApp/Toolbar.swift new file mode 100644 index 0000000..bade9f5 --- /dev/null +++ b/Sources/MactrixApp/Toolbar.swift @@ -0,0 +1,35 @@ +import OSLog +import SwiftUI + +struct ToolbarViewModifier: ViewModifier { + @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) + + 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() + } + } + } + } +} diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift new file mode 100644 index 0000000..2bed23d --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift @@ -0,0 +1,182 @@ +import MatrixIntegration +import MatrixRustSDK +import OSLog +import SwiftUI + +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)") + } + + 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 + } + + 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 + } + + 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 + } + + 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 + } + } + 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() + } + } + + @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 { + ChatInputView() + } */ diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift new file mode 100644 index 0000000..e70e5d1 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift @@ -0,0 +1,174 @@ +import AppKit +import OSLog +import SwiftUI + +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: [ + .foregroundColor: NSColor.secondaryLabelColor, + .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() + } + + 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() + } + } + + 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 + } + } +} + +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) + && event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] + { + 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, + event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [] + { + onSubmit?() + return + } + + super.keyDown(with: event) + } +} diff --git a/Sources/MactrixApp/Views/ChatView/ChatView.swift b/Sources/MactrixApp/Views/ChatView/ChatView.swift new file mode 100644 index 0000000..88cef63 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/ChatView.swift @@ -0,0 +1,129 @@ +import MatrixIntegration +import MatrixProtocols +import MatrixRustSDK +import OSLog +import SwiftUI + +private struct TimelineReadKey: Equatable { + let count: Int + let active: Bool +} + +struct ChatJoinedRoom: View { + @Environment(AppState.self) private var appState + @Environment(\.appearsActive) private var appearsActive + @Bindable var timeline: LiveTimeline + + 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 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 + + var room: LiveRoom { + timeline.room + } + + init(timeline: LiveTimeline) { + self.timeline = timeline + } + + @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("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") + } + } +} diff --git a/Sources/MactrixApp/Views/ChatView/EmbeddedMessageView.swift b/Sources/MactrixApp/Views/ChatView/EmbeddedMessageView.swift new file mode 100644 index 0000000..55bb941 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/EmbeddedMessageView.swift @@ -0,0 +1,32 @@ +import MactrixUI +import MatrixIntegration +import MatrixRustSDK +import SwiftUI + +struct EmbeddedMessageView: View { + 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 .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 new file mode 100644 index 0000000..0a9b0fe --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift @@ -0,0 +1,51 @@ +import AppKit +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 +} + +/// Determines the reusable row and its retained content view for a message. +@MainActor enum MessageContentKind: Hashable { + 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 + } + } + + 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) + } +} diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift new file mode 100644 index 0000000..fe1c620 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift @@ -0,0 +1,201 @@ +import AppKit +import MatrixIntegration +import MatrixRustSDK +import OSLog +import UniformTypeIdentifiers + +final class MessageFileContentView: NSView, MessageContentRowView { + 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) + } + + 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 + } + 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 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() + ) + 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() } + + @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") + } +} diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift new file mode 100644 index 0000000..1b891f1 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift @@ -0,0 +1,230 @@ +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") + } +} + +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") + } +} diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift new file mode 100644 index 0000000..6f40e40 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift @@ -0,0 +1,251 @@ +import AppKit +import MatrixIntegration +import MatrixRustSDK +import OSLog +import QuickLookUI + +/// 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 .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? + } +} diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageProfileRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageProfileRowView.swift new file mode 100644 index 0000000..ffc5e88 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageProfileRowView.swift @@ -0,0 +1,112 @@ +import AppKit +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 + } + + 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 + + reloadAvatar() + } + + @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 } + + 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 } + + self.profilePicture.image = image + } + } +} diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReactionsView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReactionsView.swift new file mode 100644 index 0000000..8cd71e1 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReactionsView.swift @@ -0,0 +1,142 @@ +import AppKit +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 + } + + 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) + } + 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 + } + + 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 + } + + @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") + } +} diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReadReceiptsView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReadReceiptsView.swift new file mode 100644 index 0000000..0bd64e2 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReadReceiptsView.swift @@ -0,0 +1,283 @@ +import AppKit +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 + } + + 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) + } + + 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 + } + + 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) + } + + 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 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 + } + } + + 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 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) + + 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) + } + + 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 } +} + +private final class ReceiptPopoverRowButton: NSButton { + 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) + + 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 mouseDown(with event: NSEvent) { + alphaValue = 0.6 + window?.displayIfNeeded() + super.mouseDown(with: event) + alphaValue = 1 + } + + @objc private func activate() { + onClick?(userId) + } + + @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? + + 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 + } + + 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 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 new file mode 100644 index 0000000..e98d52c --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageReplyPreviewView.swift @@ -0,0 +1,136 @@ +import AppKit +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), + ]) + } + + 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 + } + + 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) + } + } + + @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 new file mode 100644 index 0000000..36fd9be --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift @@ -0,0 +1,428 @@ +import AppKit +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, + ]) + } + + 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 + } + + 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 + } + + 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() + } + } + + 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() + } + + 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() + } + + 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 + } + } + + 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 + } + } + + 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 new file mode 100644 index 0000000..24ffaa6 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift @@ -0,0 +1,211 @@ +import AppKit +import MatrixIntegration +import MatrixRustSDK +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 + } + + 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 .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) + } + + 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 + } + + 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 new file mode 100644 index 0000000..3291202 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageThreadSummaryView.swift @@ -0,0 +1,91 @@ +import AppKit +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") + } +} diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift new file mode 100644 index 0000000..261cc19 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift @@ -0,0 +1,288 @@ +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 } + } + + 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? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + mediaView.wantsLayer = true + mediaView.layer?.backgroundColor = NSColor.darkGray.cgColor + mediaView.layer?.cornerRadius = 6 + mediaView.layer?.masksToBounds = true + thumbnailView.imageScaling = .scaleProportionallyUpOrDown + + 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" + + 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 [mediaView, captionView] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + for view in [thumbnailView, playButton, spinner, errorLabel] { + view.translatesAutoresizingMaskIntoConstraints = false + mediaView.addSubview(view) + } + + 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), + ]) + } + + 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 + } + + func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { + guard case .message(let message) = content.kind, + case .video(let video) = message.msgType + else { return } + + 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) + } + + override func layout() { + if let videoContent, bounds.width > 0 { + updateMediaSize(for: videoContent, width: bounds.width) + } + super.layout() + } + + 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)) + } + + 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 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) + } + + 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 + } + } + } + + @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 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 new file mode 100644 index 0000000..fe58493 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/StateEventRowView.swift @@ -0,0 +1,89 @@ +import AppKit +import MatrixRustSDK + +final class StateEventRowView: NSView { + private let timestamp = NSTextField(labelWithString: "") + private let descriptionField = NSTextField(labelWithString: "") + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + 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 + + 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), + ]) + } + + 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) + } + + 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, + ])) + return result + } + + @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 new file mode 100644 index 0000000..277c9cb --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineRowProjection.swift @@ -0,0 +1,151 @@ +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 + } + + private(set) var sourceItems: [TimelineItem] + private var segments: [Segment] + private(set) var rows: [TimelineItemRowInfo] + + init(items: [TimelineItem]) { + sourceItems = items + segments = Self.makeSegments(items, previous: nil) + rows = Self.flatten(segments) + } + + mutating func apply(_ diffs: [TimelineDiff]) -> Change { + let oldIDs = rows.map(\.id) + var changedIDs = Set() + + 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) + } + } + + rows = Self.flatten(segments) + return Change(oldIDs: oldIDs, 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) }) + } + + private mutating func replace( + at index: Int, removing count: Int, with items: [TimelineItem], changedIDs: inout Set + ) { + guard count > 0 || !items.isEmpty else { return } + + // 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.. 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 .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 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 new file mode 100644 index 0000000..8611d54 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift @@ -0,0 +1,692 @@ +import AppKit +import MatrixIntegration +import MatrixProtocols +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 + } + } + + 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") + } + } +} + +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" + } + } +} + +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 + { + 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.. 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 + { + 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 + } + + 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() + } + + // 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 + } +} + +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 tableViewSelectionDidChange(_ notification: Notification) { + guard !updatingTimelineItems else { return } + updateSelectedMessage() + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + let item = timelineItems[row] + + if case .profile = item { + return MessageProfileRowView.rowHeight + } + + if case .typingIndicator = item { + return TypingIndicatorRowView.rowHeight + } + + 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") + } +} + +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 new file mode 100644 index 0000000..d1223d7 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineViewRepresentable.swift @@ -0,0 +1,35 @@ +import MatrixIntegration +import MatrixRustSDK +import SwiftUI + +struct TimelineViewRepresentable: NSViewControllerRepresentable { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + let timeline: LiveTimeline + init(timeline: LiveTimeline) { + self.timeline = timeline + } + + func makeCoordinator() -> Coordinator { + return Coordinator(appState: appState, windowState: windowState) + } + + class Coordinator { + let appState: AppState + let 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 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 new file mode 100644 index 0000000..88c8e34 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TypingIndicatorRowView.swift @@ -0,0 +1,102 @@ +import AppKit +import QuartzCore + +final class TypingIndicatorRowView: NSView { + 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 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + 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) + + content.orientation = .horizontal + content.alignment = .centerY + content.spacing = 8 + content.addArrangedSubview(dotStack) + content.addArrangedSubview(label) + content.translatesAutoresizingMaskIntoConstraints = false + content.isHidden = true + addSubview(content) + + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + content.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), + content.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + func configure(names: [String]) { + guard !names.isEmpty else { + label.stringValue = "" + content.isHidden = true + stopAnimating() + return + } + + 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() + } + + private func startAnimating() { + guard !isAnimating else { return } + isAnimating = true + + 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 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 new file mode 100644 index 0000000..e4a19e3 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/VirtualItemRowView.swift @@ -0,0 +1,95 @@ +import AppKit +import MatrixRustSDK + +final class VirtualItemRowView: NSView { + static let rowHeight: CGFloat = 40 + + 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) + + 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) + + 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.centerYAnchor.constraint(equalTo: centerYAnchor), + + 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), + ]) + + 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 + } + + updateLineColor() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + 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 + } + + @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 new file mode 100644 index 0000000..ea5e2f4 --- /dev/null +++ b/Sources/MactrixApp/Views/Inspector/InspectorRoomPins.swift @@ -0,0 +1,79 @@ +import MactrixUI +import MatrixIntegration +import MatrixProtocols +import MatrixRustSDK +import OSLog +import SwiftUI + +struct RoomPinRow: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + let timeline: LiveTimeline + let eventId: String + + @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) + } + } + + 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? + + var timeline: LiveTimeline? { + guard case .joinedRoom(timeline: let timeline) = windowState.selectedScreen else { + return nil + } + + 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") + } + } +} diff --git a/Sources/MactrixApp/Views/Inspector/InspectorScreen.swift b/Sources/MactrixApp/Views/Inspector/InspectorScreen.swift new file mode 100644 index 0000000..44ceef2 --- /dev/null +++ b/Sources/MactrixApp/Views/Inspector/InspectorScreen.swift @@ -0,0 +1,67 @@ +import MactrixUI +import MatrixIntegration +import MatrixProtocols +import MatrixRustSDK +import OSLog +import SwiftUI + +struct InspectorScreen: View { + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + + @ViewBuilder + var content: some View { + @Bindable var windowState = windowState + + 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 + + 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 new file mode 100644 index 0000000..1ceeb70 --- /dev/null +++ b/Sources/MactrixApp/Views/Inspector/InspectorThreads.swift @@ -0,0 +1,38 @@ +import MactrixUI +import MatrixIntegration +import MatrixProtocols +import MatrixRustSDK +import OSLog +import SwiftUI + +struct InspectorThreads: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + @State var threads: [String] = [] + + var timeline: LiveTimeline? { + guard case .joinedRoom(timeline: let timeline) = windowState.selectedScreen else { + return nil + } + + return timeline + } + + var content: some View { + Text("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 new file mode 100644 index 0000000..4b791b9 --- /dev/null +++ b/Sources/MactrixApp/Views/Inspector/InspectorUserInfo.swift @@ -0,0 +1,54 @@ +import MactrixUI +import MatrixProtocols +import MatrixRustSDK +import OSLog +import SwiftUI + +struct InspectorUserInfo: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + let userId: String + + @State private var profile: MatrixRustSDK.UserProfile? + + 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) + } + } + + 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)") + } + } + } +} diff --git a/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift b/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift new file mode 100644 index 0000000..6ecc5c4 --- /dev/null +++ b/Sources/MactrixApp/Views/LoadMatrixUriScreen.swift @@ -0,0 +1,80 @@ +import MatrixRustSDK +import OSLog +import SwiftUI +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 { + 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 + } + + 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 new file mode 100644 index 0000000..25dabc3 --- /dev/null +++ b/Sources/MactrixApp/Views/MatrixImageView.swift @@ -0,0 +1,81 @@ +import MatrixIntegration +import MatrixRustSDK +import SwiftUI +import UniformTypeIdentifiers + +struct MatrixImageView: View { + let mediaSource: MediaSource? + let mimeType: String? + + @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)) + } + } + + @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 } + + let cacheKey = NSString(string: mediaSource.url()) + if let cached = MatrixClient.imageCache.object(forKey: cacheKey) { + image = Image(nsImage: cached) + return + } + + do { + let data = try await matrixClient.client.getMediaContent(mediaSource: mediaSource) + let contentType = mimeType.flatMap { UTType(mimeType: $0) } + + 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 new file mode 100644 index 0000000..5b9426c --- /dev/null +++ b/Sources/MactrixApp/Views/MultilineBannerLabelStyle.swift @@ -0,0 +1,23 @@ +// +// MultilineBanner.swift +// Mactrix +// +// Created by Marquis Kurt on 15-02-2026. +// + +import SwiftUI + +struct MultilineBannerLabelStyle: LabelStyle { + 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() } +} diff --git a/Sources/MactrixApp/Views/Search/SearchInspectorView.swift b/Sources/MactrixApp/Views/Search/SearchInspectorView.swift new file mode 100644 index 0000000..7124e5f --- /dev/null +++ b/Sources/MactrixApp/Views/Search/SearchInspectorView.swift @@ -0,0 +1,95 @@ +import MactrixUI +import MatrixProtocols +import MatrixRustSDK +import OSLog +import SwiftUI + +struct SearchResolvedRoomInspectorView: View { + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + + @State private var roomPreview: MatrixRustSDK.RoomPreview? + + 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 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 + + @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) + } +} diff --git a/Sources/MactrixApp/Views/Search/SearchRoomInspectorView.swift b/Sources/MactrixApp/Views/Search/SearchRoomInspectorView.swift new file mode 100644 index 0000000..5a4a1d0 --- /dev/null +++ b/Sources/MactrixApp/Views/Search/SearchRoomInspectorView.swift @@ -0,0 +1,78 @@ +import MactrixUI +import MatrixIntegration +import MatrixRustSDK +import OSLog +import SwiftUI + +struct SearchRoomInspectorView: View { + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + + @State var searching: Bool = false + @State var roomListSelection: String? = 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) + } + } + } + } + } + .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 + } + + guard !windowState.searchQuery.isEmpty else { + roomSearch.rooms.removeAll() + return + } + + searching = true + defer { searching = false } + + 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)") + } + } + } +} diff --git a/Sources/MactrixApp/Views/Search/SearchUserInspectorView.swift b/Sources/MactrixApp/Views/Search/SearchUserInspectorView.swift new file mode 100644 index 0000000..4e2b8b5 --- /dev/null +++ b/Sources/MactrixApp/Views/Search/SearchUserInspectorView.swift @@ -0,0 +1,59 @@ +import MactrixUI +import MatrixRustSDK +import OSLog +import SwiftUI + +struct SearchUserInspectorView: View { + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + + @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) + } + } + } + } + .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 + } + + searching = true + defer { searching = false } + + try await Task.sleep(for: .milliseconds(500)) + + 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)") + } + } + } +} diff --git a/Sources/MactrixApp/Views/Search/SearchViewModifier.swift b/Sources/MactrixApp/Views/Search/SearchViewModifier.swift new file mode 100644 index 0000000..bb93ab5 --- /dev/null +++ b/Sources/MactrixApp/Views/Search/SearchViewModifier.swift @@ -0,0 +1,179 @@ +import MatrixRustSDK +import OSLog +import SwiftUI + +struct SearchViewModifier: ViewModifier { + @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() + 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() + } + + 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) + } + } + + func calculateDirectSearchResults() async { + let query: String = { + var result = windowState.searchQuery.trimmingCharacters(in: .whitespaces) + + let matrixToPrefix = "https://matrix.to/#/" + if result.starts(with: matrixToPrefix) { + result = String(result.dropFirst(matrixToPrefix.count)) + result = result.removingPercentEncoding ?? result + } + + if result.starts(with: "@") { + result = result.lowercased() + } + + return result + }() + + do { + guard let matrixClient = appState.matrixClient else { return } + try await Task.sleep(for: .milliseconds(300)) + + 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 new file mode 100644 index 0000000..ab08c17 --- /dev/null +++ b/Sources/MactrixApp/Views/Settings/AccountSettingsView.swift @@ -0,0 +1,117 @@ +import MatrixRustSDK +import OSLog +import SwiftUI + +struct ChangableField: View { + 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 + + 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) + } + } + } +} + +struct AccountSettingsView: View { + @Environment(AppState.self) var appState + + @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)") + } + } + + LabeledContent("User") { + Text((try? matrixClient.client.userId()) ?? "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) + } + } + + 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") + } + } +} + +#Preview { + AccountSettingsView() +} diff --git a/Sources/MactrixApp/Views/Settings/AppearanceSettingsView.swift b/Sources/MactrixApp/Views/Settings/AppearanceSettingsView.swift new file mode 100644 index 0000000..7d665ff --- /dev/null +++ b/Sources/MactrixApp/Views/Settings/AppearanceSettingsView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct AppearanceSettingsView: View { + @AppStorage("fontSize") var fontSize: Int = 13 + + 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 new file mode 100644 index 0000000..3212581 --- /dev/null +++ b/Sources/MactrixApp/Views/Settings/SessionsSettingsView.swift @@ -0,0 +1,62 @@ +import MatrixIntegration +import MatrixRustSDK +import SwiftUI + +struct SessionsSettingsView: View { + @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 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." + ) + } + } + + 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 new file mode 100644 index 0000000..0fde5c5 --- /dev/null +++ b/Sources/MactrixApp/Views/Settings/SettingsView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +public struct SettingsView: View { + 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) + } +} diff --git a/Sources/MactrixApp/Views/Sidebar/RoomContextMenu.swift b/Sources/MactrixApp/Views/Sidebar/RoomContextMenu.swift new file mode 100644 index 0000000..5786b30 --- /dev/null +++ b/Sources/MactrixApp/Views/Sidebar/RoomContextMenu.swift @@ -0,0 +1,83 @@ +import MatrixIntegration +import MatrixRustSDK +import OSLog +import SwiftUI + +struct RoomContextMenu: View { + @Environment(WindowState.self) var windowState + + 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") + } + } + + 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") + } + } + + 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() + + 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 new file mode 100644 index 0000000..c99f004 --- /dev/null +++ b/Sources/MactrixApp/Views/Sidebar/SidebarSessionVerification.swift @@ -0,0 +1,109 @@ +import MatrixRustSDK +import OSLog +import SwiftUI + +struct SessionVerificationStatusView: View { + @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() + + // 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)) + } + } + + 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 + } + } + } + } + } + .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)") + } + } +} diff --git a/Sources/MactrixApp/Views/Sidebar/SidebarSyncState.swift b/Sources/MactrixApp/Views/Sidebar/SidebarSyncState.swift new file mode 100644 index 0000000..11d2263 --- /dev/null +++ b/Sources/MactrixApp/Views/Sidebar/SidebarSyncState.swift @@ -0,0 +1,54 @@ +import MatrixRustSDK +import OSLog +import SwiftUI + +struct SidebarSyncStateView: View { + @Environment(AppState.self) var appState + + @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 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 } + + 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 new file mode 100644 index 0000000..047b3a9 --- /dev/null +++ b/Sources/MactrixApp/Views/Sidebar/SidebarView.swift @@ -0,0 +1,106 @@ +import MactrixUI +import MatrixIntegration +import MatrixRustSDK +import SwiftUI + +struct SidebarView: View { + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + + @State private var searchText: String = "" + + 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 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 body: some View { + @Bindable var windowState = windowState + + List(selection: $windowState.selectedRoomId) { + SidebarSyncStateView() + + SessionVerificationStatusView() + + 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("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("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("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 new file mode 100644 index 0000000..3208743 --- /dev/null +++ b/Sources/MactrixApp/Views/Sidebar/SpaceDisclosureGroup.swift @@ -0,0 +1,100 @@ +import MactrixUI +import MatrixIntegration +import MatrixRustSDK +import OSLog +import SwiftUI + +struct SpaceDisclosureGroup: View { + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + + @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 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 + } + .task(id: isExpanded) { + if isExpanded { + await space.loadChildren() + } + } + } + + 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 + } + + 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 + } + } +} diff --git a/Sources/MactrixApp/Views/WelcomeSheetView.swift b/Sources/MactrixApp/Views/WelcomeSheetView.swift new file mode 100644 index 0000000..5736f1b --- /dev/null +++ b/Sources/MactrixApp/Views/WelcomeSheetView.swift @@ -0,0 +1,153 @@ +import AuthenticationServices +import MatrixIntegration +import MatrixRustSDK +import SwiftUI + +struct WelcomeSheetView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @Environment(\.webAuthenticationSession) private var webAuthenticationSession + + @State private var homeserverLogin: HomeserverLogin? = nil + + @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 + + 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 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 + } + } + } + + 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() + } + } + + 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 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()) +} diff --git a/Sources/MactrixUI/AvatarImage.swift b/Sources/MactrixUI/AvatarImage.swift new file mode 100644 index 0000000..67fe099 --- /dev/null +++ b/Sources/MactrixUI/AvatarImage.swift @@ -0,0 +1,118 @@ +import MatrixProtocols +import OSLog +import SwiftUI + +@MainActor +public protocol ImageLoader { + 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)) + } + } + + 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 + + @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 + } + + // 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) + } + } + } + } +} + +#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()) +} diff --git a/Sources/MactrixUI/Extensions/Color+userID.swift b/Sources/MactrixUI/Extensions/Color+userID.swift new file mode 100644 index 0000000..05b04a2 --- /dev/null +++ b/Sources/MactrixUI/Extensions/Color+userID.swift @@ -0,0 +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 + ) + ) + } +} diff --git a/Sources/MactrixUI/Extensions/Logger+Extensions.swift b/Sources/MactrixUI/Extensions/Logger+Extensions.swift new file mode 100644 index 0000000..23c1c8d --- /dev/null +++ b/Sources/MactrixUI/Extensions/Logger+Extensions.swift @@ -0,0 +1,7 @@ +import OSLog + +extension Logger { + private static let subsystem = "dk.qpqp.mactrix.mactrix-ui" + + static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") +} diff --git a/Sources/MactrixUI/Inspector/UserProfileRow.swift b/Sources/MactrixUI/Inspector/UserProfileRow.swift new file mode 100644 index 0000000..f5910d6 --- /dev/null +++ b/Sources/MactrixUI/Inspector/UserProfileRow.swift @@ -0,0 +1,62 @@ +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()) + } + } +} + +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() + } + } +} + +#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) +} diff --git a/Sources/MactrixUI/Inspector/UserProfileView.swift b/Sources/MactrixUI/Inspector/UserProfileView.swift new file mode 100644 index 0000000..e085e8b --- /dev/null +++ b/Sources/MactrixUI/Inspector/UserProfileView.swift @@ -0,0 +1,139 @@ +import MatrixProtocols +import SwiftUI + +@MainActor +public protocol UserProfileActions { + func sendMessage() async + func shareProfile() + func ignoreUser() async + func unignoreUser() async +} + +public protocol UserProfileTimelineActions { + 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 + } + + @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") + } + .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: { 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) + } + } + .disabled(actions == nil) + } + } +} + +struct MockUserProfileActions: UserProfileActions { + 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) +} diff --git a/Sources/MactrixUI/Rooms/RoomEncryptionBadge.swift b/Sources/MactrixUI/Rooms/RoomEncryptionBadge.swift new file mode 100644 index 0000000..7275db4 --- /dev/null +++ b/Sources/MactrixUI/Rooms/RoomEncryptionBadge.swift @@ -0,0 +1,46 @@ +import MatrixProtocols +import SwiftUI + +public struct RoomEncryptionBadge: View { + let state: EncryptionState + + public init(state: EncryptionState) { + self.state = state + } + + 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) + } + } + + 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) +} diff --git a/Sources/MactrixUI/Rooms/RoomInspectorView.swift b/Sources/MactrixUI/Rooms/RoomInspectorView.swift new file mode 100644 index 0000000..101aec9 --- /dev/null +++ b/Sources/MactrixUI/Rooms/RoomInspectorView.swift @@ -0,0 +1,130 @@ +import MatrixProtocols +import OSLog +import SwiftUI + +struct RoomInspectorMemberRow: View { + let member: RoomMember + let 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 + } + + @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) + } + } + + 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) + } + + @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)") + } + .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) +} diff --git a/Sources/MactrixUI/Rooms/RoomPreviewView.swift b/Sources/MactrixUI/Rooms/RoomPreviewView.swift new file mode 100644 index 0000000..97c7d0e --- /dev/null +++ b/Sources/MactrixUI/Rooms/RoomPreviewView.swift @@ -0,0 +1,233 @@ +import MatrixProtocols +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)) + ) + } +} + +@MainActor +public protocol RoomPreviewActions { + func joinRoom() async throws + func knockRoom() async throws + func visitRoom() +} + +struct MockRoomPreviewActions: RoomPreviewActions { + 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 + } + + 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) + } + + 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() + } + } + } + } + } + + @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() + } + } + } + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + + actionButton + + Divider() + + 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) +} diff --git a/Sources/MactrixUI/Screens/CreateRoomScreen.swift b/Sources/MactrixUI/Screens/CreateRoomScreen.swift new file mode 100644 index 0000000..412aaf6 --- /dev/null +++ b/Sources/MactrixUI/Screens/CreateRoomScreen.swift @@ -0,0 +1,159 @@ +import MatrixProtocols +import SwiftUI + +public struct CreateRoomScreen: View { + @State private var params = CreateRoomParams() + @State private var submitting = false + @State private var errorMsg: Error? = nil + + let onSubmit: (_ params: CreateRoomParams) async throws -> Void + + 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) + + 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) + } + } + .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)) + } +} diff --git a/Sources/MactrixUI/SessionVerificationModal.swift b/Sources/MactrixUI/SessionVerificationModal.swift new file mode 100644 index 0000000..d885599 --- /dev/null +++ b/Sources/MactrixUI/SessionVerificationModal.swift @@ -0,0 +1,83 @@ +import MatrixProtocols +import SwiftUI + +public enum SessionVerificationResponse { + case accept, decline +} + +public struct SessionVerificationModal: View { + let verificationData: SessionVerificationData + var onComplete: (_ response: SessionVerificationResponse) -> Void + + @Environment(\.dismiss) var dismiss + + 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?" + } + } + + public var body: some View { + VStack(spacing: 20) { + VStack { + Text("Session Verification") + .font(.title) + + Text(subtitle) + } + + 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) + } + } + } + + case .decimals(let values): + ForEach(values, id: \.self) { value in + Text("\(value)") + } + } + + 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: "☀️"), + ], indices: Data()) + + SessionVerificationModal(verificationData: data, onComplete: { _ in }) +} diff --git a/Sources/MactrixUI/Sidebar/RoomRow.swift b/Sources/MactrixUI/Sidebar/RoomRow.swift new file mode 100644 index 0000000..29f1248 --- /dev/null +++ b/Sources/MactrixUI/Sidebar/RoomRow.swift @@ -0,0 +1,199 @@ +import MatrixProtocols +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() + } +} + +struct MockError: LocalizedError { + 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()) + } */ +} + +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" + } + } + + 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)) + } + ) + .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 + } + } + .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: {} + ) + } + } + .listStyle(.sidebar) + .frame(width: 200) +} diff --git a/Sources/MactrixUI/Sidebar/SectionAction.swift b/Sources/MactrixUI/Sidebar/SectionAction.swift new file mode 100644 index 0000000..6fa71ee --- /dev/null +++ b/Sources/MactrixUI/Sidebar/SectionAction.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct SectionAction: View { + let title: LocalizedStringKey + let systemIcon: String + let action: () -> Void + let content: () -> Content + + @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 + } + + 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") + } + } + .listStyle(.sidebar) + .frame(width: 200, height: 200) +} diff --git a/Sources/MactrixUI/Timeline/MessageReplyView.swift b/Sources/MactrixUI/Timeline/MessageReplyView.swift new file mode 100644 index 0000000..5060921 --- /dev/null +++ b/Sources/MactrixUI/Timeline/MessageReplyView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +public struct MessageReplyView: View { + 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 + } + + 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() + } + + 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() +} diff --git a/Sources/MactrixUI/Timeline/ThreadTimelineHeader.swift b/Sources/MactrixUI/Timeline/ThreadTimelineHeader.swift new file mode 100644 index 0000000..747be3f --- /dev/null +++ b/Sources/MactrixUI/Timeline/ThreadTimelineHeader.swift @@ -0,0 +1,37 @@ +import SwiftUI + +public struct ThreadTimelineHeader: View { + 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) + + Text("Thread") + .font(.headline) + } + .padding(10) + Divider() + } + .background(.thinMaterial) + } +} + +#Preview { + 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 new file mode 100644 index 0000000..20d6a37 --- /dev/null +++ b/Sources/MactrixUI/Username.swift @@ -0,0 +1,16 @@ +import MatrixProtocols +import SwiftUI + +struct Username: View { + 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) + } +} diff --git a/Sources/MatrixIntegration/AppKeychain.swift b/Sources/MatrixIntegration/AppKeychain.swift new file mode 100644 index 0000000..e7462ad --- /dev/null +++ b/Sources/MatrixIntegration/AppKeychain.swift @@ -0,0 +1,68 @@ +import Foundation +import Security + +enum KeychainError: Error { + case unexpectedStatus(OSStatus) +} + +struct AppKeychain { + private let service: String + + 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, + ] + } + + /// 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) + } + } + + /// 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 + } + + /// 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 new file mode 100644 index 0000000..5a3c0c9 --- /dev/null +++ b/Sources/MatrixIntegration/AsyncSDKListener.swift @@ -0,0 +1,90 @@ +import Foundation +import MatrixRustSDK +import OSLog + +final class AsyncSDKListener: AsyncSequence, Sendable { + typealias Element = Element + typealias AsyncIterator = AsyncStream.Iterator + + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + init() { + let (s, c) = AsyncStream.makeStream() + stream = s + continuation = c + } + + func publishValue(_ element: Element) { + continuation.yield(element) + } + + func makeAsyncIterator() -> AsyncStream.Iterator { + return stream.makeAsyncIterator() + } +} + +extension AsyncSDKListener: TypingNotificationsListener where Element == [String] { + func call(typingUserIds: [String]) { + publishValue(typingUserIds) + } +} + +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: 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) + } +} + +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) + } +} + +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) + } +} + +extension AsyncSDKListener: RoomListEntriesListener where Element == [RoomListEntriesUpdate] { + func onUpdate(roomEntriesUpdate: [MatrixRustSDK.RoomListEntriesUpdate]) { + publishValue(roomEntriesUpdate) + } +} diff --git a/Sources/MatrixIntegration/Extensions/Logger+Extensions.swift b/Sources/MatrixIntegration/Extensions/Logger+Extensions.swift new file mode 100644 index 0000000..489b1ab --- /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/Sources/MatrixIntegration/Extensions/MatrixRustSDK+Extensions.swift b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+Extensions.swift new file mode 100644 index 0000000..abfb7c6 --- /dev/null +++ b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+Extensions.swift @@ -0,0 +1,608 @@ +import Foundation +import MatrixProtocols +import MatrixRustSDK + +extension MatrixRustSDK.RoomMember: MatrixProtocols.UserProfile {} + +extension MatrixRustSDK.RoomMember: @retroactive Identifiable, MatrixProtocols.RoomMember { + public var id: String { + userId + } + + 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 + } + } +} + +extension MatrixRustSDK.EncryptionState { + 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 func hash(into hasher: inout Hasher) { + return hasher.combine(id()) + } +} + +extension MatrixRustSDK.Room: @retroactive Identifiable { + public var id: String { + self.id() + } +} + +extension MatrixRustSDK.RoomInfo: MatrixProtocols.RoomInfo {} + +/* extension MatrixRustSDK.TimelineItem: @retroactive Hashable, @retroactive Identifiable { + public var id: String { + uniqueId().id + } + + public static func == (lhs: MatrixRustSDK.TimelineItem, rhs: MatrixRustSDK.TimelineItem) -> Bool { + return lhs.id == rhs.id + && lhs.asEvent() == rhs.asEvent() + && lhs.asVirtual() == rhs.asVirtual() + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(asEvent()) + hasher.combine(asVirtual()) + } + } */ + +/* extension MatrixRustSDK.EventTimelineItem: @retroactive Hashable { + public static func == (lhs: MatrixRustSDK.EventTimelineItem, rhs: MatrixRustSDK.EventTimelineItem) -> Bool { + return lhs.isRemote == rhs.isRemote + && lhs.eventOrTransactionId == rhs.eventOrTransactionId + && lhs.sender == rhs.sender + && lhs.senderProfile == rhs.senderProfile + && lhs.isOwn == rhs.isOwn + && lhs.isEditable == rhs.isEditable + && lhs.content == rhs.content + && lhs.timestamp == rhs.timestamp + && lhs.localSendState == rhs.localSendState + && lhs.localCreatedAt == rhs.localCreatedAt + && lhs.readReceipts == rhs.readReceipts + && lhs.origin == rhs.origin + && lhs.canBeRepliedTo == rhs.canBeRepliedTo + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(eventOrTransactionId) + hasher.combine(sender) + hasher.combine(senderProfile) + hasher.combine(isOwn) + hasher.combine(isEditable) + hasher.combine(content) + hasher.combine(timestamp) + hasher.combine(localSendState) + hasher.combine(localCreatedAt) + hasher.combine(readReceipts) + hasher.combine(origin) + hasher.combine(canBeRepliedTo) + } + } */ + +/* extension MatrixRustSDK.MsgLikeContent: @retroactive Hashable { + public static func == (lhs: MatrixRustSDK.MsgLikeContent, rhs: MatrixRustSDK.MsgLikeContent) -> Bool { + return lhs.kind == rhs.kind + && lhs.reactions == rhs.reactions + && lhs.inReplyTo?.eventId() == rhs.inReplyTo?.eventId() + && lhs.threadRoot == rhs.threadRoot + && lhs.threadSummary?.numReplies() == rhs.threadSummary?.numReplies() + } + } */ + +extension MatrixRustSDK.Reaction: @retroactive Identifiable { + public var id: String { + key + } +} + +extension MatrixRustSDK.Reaction: MatrixProtocols.Reaction { + public typealias SenderData = MatrixRustSDK.ReactionSenderData +} + +extension MatrixRustSDK.ReactionSenderData: MatrixProtocols.ReactionSenderData { + public var date: Date { + timestamp.date + } +} + +extension MatrixRustSDK.Timestamp { + public var date: Date { + Date(timeIntervalSince1970: Double(self) / 1000) + } +} + +extension MatrixRustSDK.VirtualTimelineItem { + 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 .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 date: Date { + timestamp.date + } + + public var userId: String { + sender + } + + public var displayName: String? { + if case .ready(displayName: let displayName, displayNameAmbiguous: _, avatarUrl: _) = + senderProfileDetails + { + return displayName + } + + 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 + } +} + +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 + } + } +} + +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")" + } + } +} + +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 .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 .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 + } +} + +extension MatrixRustSDK.SpaceRoom: @retroactive Identifiable { + public var id: String { + roomId + } +} + +extension MatrixRustSDK.EventOrTransactionId: @retroactive Identifiable { + 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 + } +} + +extension MatrixRustSDK.SessionVerificationEmoji: @retroactive Identifiable { + public var id: String { + description() + } +} + +extension MatrixRustSDK.SessionVerificationEmoji: MatrixProtocols.SessionVerificationEmoji { + public var description: String { + self.description() + } + + public var symbol: String { + self.symbol() + } +} + +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 .idle(let hitTimelineStart): + "idle, hitTimelineStart=\(hitTimelineStart)" + case .paginating: + "paginating" + } + } +} + +extension MatrixRustSDK.Room: @retroactive CustomDebugStringConvertible { + public var debugDescription: String { + "room \(id())" + } +} + +extension MatrixRustSDK.RoomPreviewInfo: @retroactive CustomDebugStringConvertible { + 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 .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 func hash(into hasher: inout Hasher) { + hasher.combine(info()) + } +} diff --git a/Mactrix/Extensions/MatrixRustSDK+MessageContent.swift b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+MessageContent.swift similarity index 58% rename from Mactrix/Extensions/MatrixRustSDK+MessageContent.swift rename to Sources/MatrixIntegration/Extensions/MatrixRustSDK+MessageContent.swift index 078c727..349cf04 100644 --- a/Mactrix/Extensions/MatrixRustSDK+MessageContent.swift +++ b/Sources/MatrixIntegration/Extensions/MatrixRustSDK+MessageContent.swift @@ -1,15 +1,15 @@ import MatrixRustSDK -protocol MessageContent { - var body: String { get } - var formatted: FormattedBody? { get } +public protocol MessageContent { + var body: String { get } + var formatted: FormattedBody? { get } } -protocol MediaMessageContent { - var filename: String { get } - var caption: String? { get } - var formattedCaption: FormattedBody? { get } - var source: MediaSource { get } +public protocol MediaMessageContent { + 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 new file mode 100644 index 0000000..daf964e --- /dev/null +++ b/Sources/MatrixIntegration/HomeserverLogin.swift @@ -0,0 +1,75 @@ +import AuthenticationServices +import Foundation +import MatrixRustSDK +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 + } + + @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() + } + + 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 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())! + + Logger.matrixClient.debug("Auth url: \(url, privacy: .sensitive)") + + let callbackUrl = try await webAuthSession.authenticate( + using: url, callback: .customScheme("com.github"), additionalHeaderFields: [:]) + + Logger.matrixClient.debug("after sign in") + + try await unauthenticatedClient.loginWithOauthCallback(callbackUrl: callbackUrl.absoluteString) + + return try await onSuccessfullLogin() + } + + @MainActor + fileprivate func onSuccessfullLogin() async throws -> MatrixClient { + let matrixClient = await MatrixClient( + storeID: storeID, storePassphrase: storePassphrase, client: unauthenticatedClient) + + 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 new file mode 100644 index 0000000..60a9144 --- /dev/null +++ b/Sources/MatrixIntegration/LiveRoom.swift @@ -0,0 +1,106 @@ +import Foundation +import MatrixProtocols +import MatrixRustSDK +import OSLog + +@MainActor @Observable +public final class LiveRoom: Identifiable { + let sidebarRoom: SidebarRoom + + public var typingUserIds: [String] = [] + public var members: [MatrixRustSDK.RoomMember] = [] + + @ObservationIgnored private var typingHandle: TaskHandle? + + public var room: MatrixRustSDK.Room { + sidebarRoom.room + } + + public var roomInfo: MatrixRustSDK.RoomInfo? { + sidebarRoom.roomInfo + } + + public nonisolated var id: String { + sidebarRoom.id + } + + public init(sidebarRoom: SidebarRoom) { + self.sidebarRoom = sidebarRoom + + startListening() + + 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)) + } + + isolated deinit { + Logger.matrixClient.info("live room deinit") + } + + fileprivate func startListening() { + Logger.matrixClient.info("typing indicator start listening") + + 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 + } + } + } + + 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)") + } + } +} + +extension LiveRoom: Hashable { + public nonisolated static func == (lhs: LiveRoom, rhs: LiveRoom) -> Bool { + lhs.id == rhs.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 topic: String? { + room.topic() + } + + public var encryptionState: MatrixProtocols.EncryptionState { + room.encryptionState().asModel + } +} diff --git a/Sources/MatrixIntegration/LiveRoomSearch.swift b/Sources/MatrixIntegration/LiveRoomSearch.swift new file mode 100644 index 0000000..2e4e18f --- /dev/null +++ b/Sources/MatrixIntegration/LiveRoomSearch.swift @@ -0,0 +1,67 @@ +import AsyncAlgorithms +import Foundation +import MatrixRustSDK +import OSLog + +@MainActor @Observable +public class LiveRoomSearch { + public let roomDirectorySearch: RoomDirectorySearchProtocol + + @ObservationIgnored fileprivate var resultsHandle: TaskHandle? + + public var rooms: [RoomDescription] = [] + + public init(roomDirectorySearch: RoomDirectorySearchProtocol) async { + self.roomDirectorySearch = roomDirectorySearch + await listenToRoomResults() + } + + deinit { + Logger.matrixClient.info("LiveRoomSearch deinit") + } + + private func listenToRoomResults() async { + Logger.matrixClient.info("room search start listening") + + let listener = AsyncSDKListener<[RoomDirectorySearchEntryUpdate]>() + resultsHandle = await roomDirectorySearch.results(listener: listener) + + 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 .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) + + 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) + + Task { [weak self] in + for await roomUpdates in roomsListener { + guard let self else { return } + + 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) + + 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)") + } + } +} diff --git a/Sources/MatrixIntegration/LiveSpaceService.swift b/Sources/MatrixIntegration/LiveSpaceService.swift new file mode 100644 index 0000000..e5fdace --- /dev/null +++ b/Sources/MatrixIntegration/LiveSpaceService.swift @@ -0,0 +1,64 @@ +import AsyncAlgorithms +import Foundation +import MatrixRustSDK +import OSLog + +@MainActor @Observable +public final class LiveSpaceService { + public let spaceService: SpaceService + + public var spaceRooms: [SidebarSpaceRoom] = [] + + @ObservationIgnored private var spaceHandle: TaskHandle? + + public init(spaceService: SpaceService) { + self.spaceService = spaceService + + Task { + await self.listenToJoinedSpaces() + + 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 } + + 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) + + 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)) + } + + Task { [weak self] in + for await status in listener { + guard let self else { break } + + Logger.liveTimeline.debug("updating timeline paginating: \(status.debugDescription)") + paginating = status + + 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 + } + + Logger.liveTimeline.info("fetch more messages") + + do { + _ = try await timeline?.paginateBackwards(numEvents: 100) + fetchingOlderMessages = false + + 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) + } +} + +extension LiveTimeline { + 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 + } +} diff --git a/Sources/MatrixIntegration/MatrixClient+Listeners.swift b/Sources/MatrixIntegration/MatrixClient+Listeners.swift new file mode 100644 index 0000000..942275a --- /dev/null +++ b/Sources/MatrixIntegration/MatrixClient+Listeners.swift @@ -0,0 +1,156 @@ +import Foundation +import MatrixRustSDK +import OSLog + +extension MatrixClient { + 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.. 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) + } + } + + notificationClient = try await client.notificationClient( + processSetup: .singleProcess(syncService: _syncService)) + await client.registerNotificationHandler(listener: notifications) + + try await client.getSessionVerificationController().setDelegate(delegate: self) + + verificationStateHandle = client.encryption().verificationStateListener(listener: self) + + ignoredUsersHandle = client.subscribeToIgnoredUsers(listener: self) + ignoredUserIds = try await client.ignoredUsers() + + // 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 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 + ) + + try await client.getSessionVerificationController().cancelVerification() + } + + public func acceptVerificationRequest(request: SessionVerificationRequestDetails) async throws { + try await client.getSessionVerificationController().acknowledgeVerificationRequest( + senderId: request.senderProfile.userId, flowId: request.flowId + ) + + try await client.getSessionVerificationController().acceptVerificationRequest() + } + + public func requestDeviceVerification() async throws { + try await client.getSessionVerificationController().requestDeviceVerification() + } +} + +enum MatrixClientRestoreSessionError: Error { + 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 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)") + } + } +} + +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 new file mode 100644 index 0000000..f0d60d6 --- /dev/null +++ b/Sources/MatrixIntegration/MatrixClientSessionDelegate.swift @@ -0,0 +1,46 @@ +import Foundation +import MatrixRustSDK +import OSLog +import Observation +import UniformTypeIdentifiers +import Utils + +final class MatrixClientSessionDelegate: MatrixRustSDK.ClientSessionDelegate { + let storeID: String + let storePassphrase: String + + 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)" + ) + + 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 + } + } + + 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 new file mode 100644 index 0000000..4632855 --- /dev/null +++ b/Sources/MatrixIntegration/MatrixNotifications.swift @@ -0,0 +1,171 @@ +import MatrixRustSDK +import OSLog +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? + + typealias NotificationEvent = (item: MatrixRustSDK.NotificationItem, roomId: String) + + @ObservationIgnored private let notificationContinuation: + AsyncStream.Continuation + @ObservationIgnored private var streamTask: Task? + + public override init() { + let (stream, continuation) = AsyncStream.makeStream() + notificationContinuation = continuation + + 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) + } + } + } +} + +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 + } +} + +extension MatrixNotifications: MatrixRustSDK.SyncNotificationListener { + 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 + } + + 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 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)") + } + } + + 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 .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 new file mode 100644 index 0000000..56fefdd --- /dev/null +++ b/Sources/MatrixIntegration/SidebarRoom.swift @@ -0,0 +1,59 @@ +import AsyncAlgorithms +import Foundation +import MatrixRustSDK +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() + } + } + + /// 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) + + 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 new file mode 100644 index 0000000..a9bde9c --- /dev/null +++ b/Sources/MatrixIntegration/SidebarSpaceRoom.swift @@ -0,0 +1,42 @@ +import Foundation +import MatrixRustSDK + +@MainActor @Observable +public final class SidebarSpaceRoom { + public let spaceRoom: SpaceRoom + + public private(set) var children: Children = .loading + + private let spaceService: LiveSpaceService + + 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 enum Children { + case loading + case loaded(children: LiveSpaceRoomList) + case error(error: Error) + } +} + +extension SidebarSpaceRoom: Identifiable { + public nonisolated var id: String { + spaceRoom.roomId + } +} diff --git a/Sources/MatrixIntegration/UserSession.swift b/Sources/MatrixIntegration/UserSession.swift new file mode 100644 index 0000000..b80a0bf --- /dev/null +++ b/Sources/MatrixIntegration/UserSession.swift @@ -0,0 +1,56 @@ +import AsyncAlgorithms +import Foundation +import MatrixRustSDK +import OSLog +import Observation +import Security +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 + + 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) + } + + fileprivate static var keychainKey: String { "UserSession" } + + 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 + } +} diff --git a/Sources/MatrixProtocols/CreateRoomParams.swift b/Sources/MatrixProtocols/CreateRoomParams.swift new file mode 100644 index 0000000..4e5c349 --- /dev/null +++ b/Sources/MatrixProtocols/CreateRoomParams.swift @@ -0,0 +1,43 @@ +import Foundation + +public enum RoomAccess: Sendable, Hashable { + case publicRoom, privateRoom +} + +public enum RoomVisibility: Sendable { + 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 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 + } +} diff --git a/Sources/MatrixProtocols/EncryptionState.swift b/Sources/MatrixProtocols/EncryptionState.swift new file mode 100644 index 0000000..30e6eff --- /dev/null +++ b/Sources/MatrixProtocols/EncryptionState.swift @@ -0,0 +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 +} diff --git a/Sources/MatrixProtocols/EventTimelineItem.swift b/Sources/MatrixProtocols/EventTimelineItem.swift new file mode 100644 index 0000000..879c2b4 --- /dev/null +++ b/Sources/MatrixProtocols/EventTimelineItem.swift @@ -0,0 +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 .ready(displayName: _, displayNameAmbiguous: _, avatarUrl: let avatarUrl) = self { + return avatarUrl + } + return nil + } +} + +public struct Receipt: Equatable, Hashable { + public var timestamp: Date? + + 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 } +} + +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 } +} diff --git a/Sources/MatrixProtocols/Extensions/String+Markdown.swift b/Sources/MatrixProtocols/Extensions/String+Markdown.swift new file mode 100644 index 0000000..8c1154c --- /dev/null +++ b/Sources/MatrixProtocols/Extensions/String+Markdown.swift @@ -0,0 +1,13 @@ +import Foundation + +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 new file mode 100644 index 0000000..30e098e --- /dev/null +++ b/Sources/MatrixProtocols/InReplyToDetails.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum EventOrTransactionId { + 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) +} + +public enum EmbeddedEventDetails { + 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 + + var event: EmbeddedEventDetails { get } + + var eventId: String { get } +} diff --git a/Sources/MatrixProtocols/MsgLikeContent.swift b/Sources/MatrixProtocols/MsgLikeContent.swift new file mode 100644 index 0000000..71c7687 --- /dev/null +++ b/Sources/MatrixProtocols/MsgLikeContent.swift @@ -0,0 +1,62 @@ +import Foundation + +public protocol Mentions { + 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 } +} + +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 */) +} + +public protocol MsgLikeContent { + 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 } +} diff --git a/Sources/MatrixProtocols/Reaction.swift b/Sources/MatrixProtocols/Reaction.swift new file mode 100644 index 0000000..893394b --- /dev/null +++ b/Sources/MatrixProtocols/Reaction.swift @@ -0,0 +1,41 @@ +import Foundation + +public protocol Reaction: Identifiable { + associatedtype SenderData: ReactionSenderData + + var key: String { get } + var senders: [SenderData] { get } +} + +public protocol ReactionSenderData { + var senderId: String { get } + var date: Date { get } +} + +public struct MockReactionSenderData: ReactionSenderData { + public var senderId: String { + "sender@address" + } + + public var date: Date { + .now + } +} + +public struct MockReaction: Reaction { + public init() {} + + public var senders: [MockReactionSenderData] { + [MockReactionSenderData()] + } + + public typealias SenderData = MockReactionSenderData + + public var key: String { + "😄" + } + + public var id: String { + key + } +} diff --git a/Sources/MatrixProtocols/Room.swift b/Sources/MatrixProtocols/Room.swift new file mode 100644 index 0000000..e3c7fe4 --- /dev/null +++ b/Sources/MatrixProtocols/Room.swift @@ -0,0 +1,58 @@ +import Foundation + +public enum SelectedRoom { + 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 } +} + +public enum Membership { + 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 + ) +} + +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) + } +} diff --git a/Sources/MatrixProtocols/RoomInfo.swift b/Sources/MatrixProtocols/RoomInfo.swift new file mode 100644 index 0000000..de6d9dc --- /dev/null +++ b/Sources/MatrixProtocols/RoomInfo.swift @@ -0,0 +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 } +} + +public struct MockRoomInfo: RoomInfo { + public init() {} + + public var id: String { "mock_id" } + + public var creators: [String]? { nil } + + public var displayName: String? { "Room Name" } + + public var rawName: String? { "room_name_raw" } + + public var topic: String? { "The topic of the room" } + + public var avatarUrl: String? { nil } + + public var isDirect: Bool { false } + + public var isPublic: Bool? { true } + + public var isSpace: Bool { false } + + public var isFavourite: Bool { false } + + public var canonicalAlias: String? { "#room:matrix.org" } + + public var alternativeAliases: [String] { [] } + + public var activeMembersCount: UInt64 { 3 } + + public var invitedMembersCount: UInt64 { 1 } + + public var joinedMembersCount: UInt64 { 8 } + + public var highlightCount: UInt64 { 0 } + + public var notificationCount: UInt64 { 2 } + + public var hasRoomCall: Bool { false } + + public var activeRoomCallParticipants: [String] { [] } + + public var isMarkedUnread: Bool { false } + + public var numUnreadMessages: UInt64 { 4 } + + public var numUnreadNotifications: UInt64 { 2 } + + public var numUnreadMentions: UInt64 { 2 } + + public var pinnedEventIds: [String] { [] } + + public var roomVersion: String? { "12" } + + public var privilegedCreatorsRole: Bool { false } +} diff --git a/Sources/MatrixProtocols/RoomMember.swift b/Sources/MatrixProtocols/RoomMember.swift new file mode 100644 index 0000000..731e297 --- /dev/null +++ b/Sources/MatrixProtocols/RoomMember.swift @@ -0,0 +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 +} + +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 } +} + +public struct MockRoomMember: RoomMember { + public init() {} + + public var id: String { + userId + } + + public var userId: String { + "user@id" + } + + public var displayName: String? { + "User Name" + } + + public var avatarUrl: String? { + nil + } + + public var isNameAmbiguous: Bool { + false + } + + public var isIgnored: Bool { + false + } + + public var roleForPowerLevel: RoomMemberRole { + .user + } + + public var membershipChangeReason: String? { + nil + } +} diff --git a/Sources/MatrixProtocols/RoomPreviewInfo.swift b/Sources/MatrixProtocols/RoomPreviewInfo.swift new file mode 100644 index 0000000..1320cfc --- /dev/null +++ b/Sources/MatrixProtocols/RoomPreviewInfo.swift @@ -0,0 +1,135 @@ +import Foundation + +/** + * The rule used for users wishing to join this room. + */ + +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 +} + +/// 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 } +} + +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 + } +} diff --git a/Sources/MatrixProtocols/SessionVerificationData.swift b/Sources/MatrixProtocols/SessionVerificationData.swift new file mode 100644 index 0000000..f79ebed --- /dev/null +++ b/Sources/MatrixProtocols/SessionVerificationData.swift @@ -0,0 +1,23 @@ +import Foundation + +public enum SessionVerificationData { + case emojis(emojis: [VerificationEmoji], indices: Data) + case decimals(values: [UInt16]) +} + +public protocol SessionVerificationEmoji: Identifiable { + var description: String { get } + var symbol: String { get } +} + +public struct MockSessionVerificationEmoji: SessionVerificationEmoji { + public var id: String { symbol } + + public var description: String + public var symbol: String + + public init(description: String, symbol: String) { + self.description = description + self.symbol = symbol + } +} diff --git a/Sources/MatrixProtocols/ThreadSummary.swift b/Sources/MatrixProtocols/ThreadSummary.swift new file mode 100644 index 0000000..b7ed70c --- /dev/null +++ b/Sources/MatrixProtocols/ThreadSummary.swift @@ -0,0 +1,21 @@ +public protocol ThreadSummary { + func numReplies() -> UInt64 + + var description: String? { get } +} + +public struct MockThreadSummary: ThreadSummary { + public init() {} + + public func numReplies() -> UInt64 { + 4 + } + + public var description: String? { + """ + This is a thread message, that is _very_ long. + + It event has multiple lines. + """ + } +} diff --git a/Sources/MatrixProtocols/UserProfile.swift b/Sources/MatrixProtocols/UserProfile.swift new file mode 100644 index 0000000..b1ff9c0 --- /dev/null +++ b/Sources/MatrixProtocols/UserProfile.swift @@ -0,0 +1,25 @@ +import Foundation + +public protocol UserProfile: Identifiable { + var userId: String { get } + var displayName: String? { get } + var avatarUrl: String? { get } +} + +extension UserProfile { + public var id: String { userId } +} + +public struct MockUserProfile: UserProfile { + public init() {} + + public var userId: String { + "@user:matrix.org" + } + + public var displayName: String? { + "Matrix User" + } + + public var avatarUrl: String? { nil } +} diff --git a/Sources/MatrixProtocols/VirtualTimelineItem.swift b/Sources/MatrixProtocols/VirtualTimelineItem.swift new file mode 100644 index 0000000..83f0df0 --- /dev/null +++ b/Sources/MatrixProtocols/VirtualTimelineItem.swift @@ -0,0 +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 +} diff --git a/Sources/MessageFormatting/AttributedTextView.swift b/Sources/MessageFormatting/AttributedTextView.swift new file mode 100644 index 0000000..a88dae0 --- /dev/null +++ b/Sources/MessageFormatting/AttributedTextView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +public struct AttributedTextView: NSViewRepresentable { + public let attributedString: NSAttributedString + + public init(attributedString: NSAttributedString) { + self.attributedString = attributedString.trimmed + } + + public func makeNSView(context: Context) -> NSTextField { + let textField = NSTextField(labelWithAttributedString: attributedString) + + textField.isEditable = false + textField.isSelectable = true + textField.allowsEditingTextAttributes = true + + textField.lineBreakStrategy = .standard + textField.lineBreakMode = .byWordWrapping + textField.usesSingleLineMode = false + + return textField + } + + 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)) + } +} + +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) + } +} diff --git a/Sources/MessageFormatting/Parser.swift b/Sources/MessageFormatting/Parser.swift new file mode 100644 index 0000000..870c23f --- /dev/null +++ b/Sources/MessageFormatting/Parser.swift @@ -0,0 +1,88 @@ +import AppKit +import Foundation +import ZMarkupParser + +@MainActor +public func parseFormattedBody(_ body: String, baseFontSize: CGFloat = 13) -> NSAttributedString { + 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)) + ) + ) + .build() + + 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 + } + + return rendered.attributedSubstring(from: NSRange(location: 0, length: length)) +} diff --git a/MactrixLibrary/Sources/MessageFormatting/Preview.swift b/Sources/MessageFormatting/Preview.swift similarity index 91% rename from MactrixLibrary/Sources/MessageFormatting/Preview.swift rename to Sources/MessageFormatting/Preview.swift index 38ea8ce..43a9ec4 100644 --- a/MactrixLibrary/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 new file mode 100644 index 0000000..a43cbb8 --- /dev/null +++ b/Sources/Utils/MatrixUriScheme.swift @@ -0,0 +1,81 @@ +import Foundation + +public enum MatrixUriError: LocalizedError { + case parserError(reason: LocalizedStringResource) + + 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 enum Kind: Hashable, Codable { + case roomId(String) + case roomAlias(String) + case user(String) + } + + public enum Action: Hashable, Codable { + case join, chat + } + + 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") + } + + 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)") + } + + let queryComponents = query?.split(separator: "&") ?? [] + + var routingVia = [String]() + var action: Action? = nil + + 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 + } + } + + // todo + self.routingVia = routingVia + self.action = action + self.event = nil + } +} diff --git a/Tests/UtilsTests/MatrixUriSchemeTests.swift b/Tests/UtilsTests/MatrixUriSchemeTests.swift new file mode 100644 index 0000000..521b16a --- /dev/null +++ b/Tests/UtilsTests/MatrixUriSchemeTests.swift @@ -0,0 +1,31 @@ +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) + } +}