From 9a299425dec85cf62fa24934fd2da364b918a55c Mon Sep 17 00:00:00 2001 From: winrid Date: Tue, 30 Jun 2026 16:08:24 -0700 Subject: [PATCH 1/2] Add swift-format config, reformat the codebase, and add CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a .swift-format config (4-space indentation, 120 col, collapse blank lines to one). UseSynthesizedInitializer is disabled so the formatter never strips the explicit public inits this library relies on. - Apply swift format across all tracked Swift sources (Sources, Tests, ExampleApp). Formatting only — no behavioral changes. - Add a GitHub Actions workflow (.github/workflows/ci.yml) with two jobs: - format: swift format lint --strict (fails on any deviation) - build: compile the library and tests against the iOS simulator SDK, and build the example app Verified locally: lint is clean, and the library, tests, and example app all build for the iOS simulator. --- .github/workflows/ci.yml | 66 ++ .swift-format | 14 + .../FastCommentsExample/BenchmarkView.swift | 175 ++--- .../CommentsExampleView.swift | 2 +- .../FastCommentsExample/FPSMonitor.swift | 139 ++-- .../FastCommentsExampleApp.swift | 21 +- .../FastCommentsExample/FeedExampleView.swift | 12 +- .../ScreenshotTourView.swift | 2 +- .../ToolbarShowcaseView.swift | 2 +- .../FeedComposerUITests.swift | 7 +- .../FeedLifecycleUITests.swift | 4 +- .../FeedUserA_UITests.swift | 14 +- .../LiveEventUserA_UITests.swift | 16 +- .../ModerationUITests.swift | 2 +- .../FastCommentsUITests/SyncClient.swift | 3 +- .../FastCommentsUITests/UITestBase.swift | 94 +-- Package.swift | 2 +- .../Models/FastCommentsError.swift | 5 +- .../SDK/FastCommentsFeedSDK.swift | 59 +- .../FastCommentsUI/SDK/FastCommentsSDK.swift | 84 +-- .../FastCommentsUI/SDK/LiveEventHandler.swift | 5 +- .../FastCommentsUI/Sheets/AddLinkSheet.swift | 6 +- .../Sheets/CommentEditSheet.swift | 9 +- .../FastCommentsUI/Sheets/CommentsSheet.swift | 8 +- .../Sheets/FullImageSheet.swift | 4 +- .../Sheets/UserLoginSheet.swift | 13 +- .../FastCommentsUI/State/CommentsTree.swift | 22 +- .../State/RenderableComment.swift | 3 +- .../Theme/FastCommentsTheme.swift | 24 +- .../AttributedStringHTMLConverter.swift | 225 +++---- .../Utilities/DemoBannerModifier.swift | 3 +- .../Utilities/PlatformAliases.swift | 6 +- .../Views/AnimatedImageView.swift | 326 ++++----- .../FastCommentsUI/Views/AvatarImage.swift | 13 +- .../Views/CommentInputBar.swift | 485 +++++++------- .../FastCommentsUI/Views/CommentRowView.swift | 47 +- .../Views/FastCommentsFeedView.swift | 4 +- .../Views/FastCommentsView.swift | 30 +- .../Views/FeedPostCreateView.swift | 16 +- .../Views/FeedPostRowView.swift | 29 +- .../FastCommentsUI/Views/FollowButton.swift | 7 +- .../Views/HTMLContentView.swift | 59 +- .../FastCommentsUI/Views/LiveChatView.swift | 16 +- .../Views/MentionSuggestionsList.swift | 4 +- .../Views/NewFeedPostsBanner.swift | 2 +- .../Views/PaginationControls.swift | 19 +- .../Views/PostImagesCarousel.swift | 6 +- .../FastCommentsUI/Views/RichTextEditor.swift | 623 +++++++++--------- .../Views/SelectedMediaGrid.swift | 24 +- .../CommentCRUDIntegrationTests.swift | 4 +- .../FollowButtonTests.swift | 6 +- .../Helpers/IntegrationTestBase.swift | 26 +- .../LiveEventIntegrationTests.swift | 3 +- .../LiveFeedIntegrationTests.swift | 44 +- .../ModerationIntegrationTests.swift | 14 +- .../PresenceIntegrationTests.swift | 3 +- .../RenderableNodeTests.swift | 2 +- .../SortingIntegrationTests.swift | 8 +- .../ThreadingIntegrationTests.swift | 5 +- 59 files changed, 1559 insertions(+), 1317 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .swift-format diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da401bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + name: Format (swift-format lint) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Select latest stable Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Show toolchain + run: swift --version + + # Fails (exit 1) on any formatting deviation thanks to --strict. + # The .swift-format config at the repo root is auto-discovered. + - name: Check formatting + run: | + git ls-files '*.swift' -z \ + | xargs -0 swift format lint --strict --configuration .swift-format + + build: + name: Build (iOS simulator) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Select latest stable Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Show toolchain + run: swift --version + + # iOS-only package: build against the simulator SDK (a plain `swift build` + # would target macOS and fail on UIKit-dependent code). + - name: Build library + run: | + SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)" + swift build --sdk "$SDK_PATH" --triple arm64-apple-ios16.0-simulator + + - name: Compile tests + run: | + SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)" + swift build --build-tests --sdk "$SDK_PATH" --triple arm64-apple-ios16.0-simulator + + - name: Build example app + run: | + xcodebuild build \ + -project ExampleApp/FastCommentsExample.xcodeproj \ + -scheme FastCommentsExample \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..f9a9094 --- /dev/null +++ b/.swift-format @@ -0,0 +1,14 @@ +{ + "version": 1, + "lineLength": 120, + "indentation": { + "spaces": 4 + }, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, + "lineBreakBeforeEachArgument": false, + "prioritizeKeepingFunctionOutputTogether": true, + "rules": { + "UseSynthesizedInitializer": false + } +} diff --git a/ExampleApp/FastCommentsExample/BenchmarkView.swift b/ExampleApp/FastCommentsExample/BenchmarkView.swift index a780e08..bc2c896 100644 --- a/ExampleApp/FastCommentsExample/BenchmarkView.swift +++ b/ExampleApp/FastCommentsExample/BenchmarkView.swift @@ -2,7 +2,7 @@ import SwiftUI import FastCommentsUI import FastCommentsSwift #if canImport(UIKit) -import UIKit + import UIKit #endif struct BenchmarkView: View { @@ -12,7 +12,7 @@ struct BenchmarkView: View { config: FastCommentsWidgetConfig(tenantId: "benchmark", urlId: "benchmark") ) #if canImport(UIKit) - @StateObject private var fpsMonitor = FPSMonitor() + @StateObject private var fpsMonitor = FPSMonitor() #endif @State private var generationTimeMs: Double = 0 @@ -30,13 +30,13 @@ struct BenchmarkView: View { ZStack(alignment: .topTrailing) { LiveChatView(sdk: sdk) #if canImport(UIKit) - fpsOverlay + fpsOverlay #endif } } .navigationTitle("100k Benchmark") #if os(iOS) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .primaryAction) { @@ -50,11 +50,11 @@ struct BenchmarkView: View { } } #if canImport(UIKit) - .onAppear { fpsMonitor.start() } - .onDisappear { - fpsMonitor.stop() - sdk.commentsTree.build(comments: []) - } + .onAppear { fpsMonitor.start() } + .onDisappear { + fpsMonitor.stop() + sdk.commentsTree.build(comments: []) + } #endif .task { if autoRun && !hasRun { @@ -79,12 +79,12 @@ struct BenchmarkView: View { statItem("Delta", "+\(String(format: "%.1f", memoryAfterMB - memoryBeforeMB)) MB") } #if canImport(UIKit) - HStack(spacing: 16) { - statItem("FPS", String(format: "%.0f", fpsMonitor.currentFPS)) - statItem("Min", String(format: "%.0f", fpsMonitor.minFPS == .infinity ? 0 : fpsMonitor.minFPS)) - statItem("Avg", String(format: "%.0f", fpsMonitor.averageFPS)) - statItem("Drops", "\(fpsMonitor.droppedFrameCount)") - } + HStack(spacing: 16) { + statItem("FPS", String(format: "%.0f", fpsMonitor.currentFPS)) + statItem("Min", String(format: "%.0f", fpsMonitor.minFPS == .infinity ? 0 : fpsMonitor.minFPS)) + statItem("Avg", String(format: "%.0f", fpsMonitor.averageFPS)) + statItem("Drops", "\(fpsMonitor.droppedFrameCount)") + } #endif } else { Text("Tap Run to load 100,000 comments") @@ -112,22 +112,22 @@ struct BenchmarkView: View { // MARK: - FPS Overlay #if canImport(UIKit) - private var fpsOverlay: some View { - Text("\(Int(fpsMonitor.currentFPS)) fps") - .font(.caption.monospacedDigit().bold()) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(fpsColor.opacity(0.85)) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .padding(8) - } + private var fpsOverlay: some View { + Text("\(Int(fpsMonitor.currentFPS)) fps") + .font(.caption.monospacedDigit().bold()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(fpsColor.opacity(0.85)) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .padding(8) + } - private var fpsColor: Color { - if fpsMonitor.currentFPS >= 55 { return .green } - if fpsMonitor.currentFPS >= 30 { return .yellow } - return .red - } + private var fpsColor: Color { + if fpsMonitor.currentFPS >= 55 { return .green } + if fpsMonitor.currentFPS >= 30 { return .yellow } + return .red + } #endif // MARK: - Benchmark @@ -142,8 +142,8 @@ struct BenchmarkView: View { memoryBeforeMB = 0 memoryAfterMB = 0 #if canImport(UIKit) - fpsMonitor.stop() - fpsMonitor.start() + fpsMonitor.stop() + fpsMonitor.start() #endif // Small delay so the UI clears before re-populating try? await Task.sleep(nanoseconds: 200_000_000) @@ -178,9 +178,13 @@ struct BenchmarkView: View { let memAfter = getMemoryUsageMB() memoryAfterMB = memAfter - print("[Benchmark] Memory after: \(String(format: "%.1f", memAfter)) MB (delta: +\(String(format: "%.1f", memAfter - memBefore)) MB)") + print( + "[Benchmark] Memory after: \(String(format: "%.1f", memAfter)) MB (delta: +\(String(format: "%.1f", memAfter - memBefore)) MB)" + ) - print("[Benchmark] Tree stats — visibleNodes: \(sdk.commentsTree.visibleSize()), allComments: \(sdk.commentsTree.totalSize())") + print( + "[Benchmark] Tree stats — visibleNodes: \(sdk.commentsTree.visibleSize()), allComments: \(sdk.commentsTree.totalSize())" + ) // Let the first frame render, then run a scroll stress test try? await Task.sleep(nanoseconds: 500_000_000) @@ -188,65 +192,68 @@ struct BenchmarkView: View { } #if canImport(UIKit) - /// Programmatically scroll the content to stress-test LazyVStack rendering. - private func runScrollTest() async { - print("[Benchmark] Starting scroll stress test...") + /// Programmatically scroll the content to stress-test LazyVStack rendering. + private func runScrollTest() async { + print("[Benchmark] Starting scroll stress test...") - guard let scrollView = findUIScrollView() else { - print("[Benchmark] Could not find UIScrollView for scroll test") - return - } + guard let scrollView = findUIScrollView() else { + print("[Benchmark] Could not find UIScrollView for scroll test") + return + } - let contentHeight = scrollView.contentSize.height - let viewHeight = scrollView.bounds.height - guard contentHeight > viewHeight else { - print("[Benchmark] Content too small to scroll") - return - } + let contentHeight = scrollView.contentSize.height + let viewHeight = scrollView.bounds.height + guard contentHeight > viewHeight else { + print("[Benchmark] Content too small to scroll") + return + } - // Reset FPS tracking for the scroll portion - fpsMonitor.stop() - fpsMonitor.start() + // Reset FPS tracking for the scroll portion + fpsMonitor.stop() + fpsMonitor.start() - let steps = 20 - let stepSize = (contentHeight - viewHeight) / CGFloat(steps) + let steps = 20 + let stepSize = (contentHeight - viewHeight) / CGFloat(steps) - // Scroll down in steps - for i in 1...steps { - let y = min(stepSize * CGFloat(i), contentHeight - viewHeight) - scrollView.setContentOffset(CGPoint(x: 0, y: y), animated: false) - // Allow a few frames to render at each position - try? await Task.sleep(nanoseconds: 100_000_000) - } + // Scroll down in steps + for i in 1...steps { + let y = min(stepSize * CGFloat(i), contentHeight - viewHeight) + scrollView.setContentOffset(CGPoint(x: 0, y: y), animated: false) + // Allow a few frames to render at each position + try? await Task.sleep(nanoseconds: 100_000_000) + } - // Scroll back up - for i in stride(from: steps - 1, through: 0, by: -1) { - let y = stepSize * CGFloat(i) - scrollView.setContentOffset(CGPoint(x: 0, y: y), animated: false) - try? await Task.sleep(nanoseconds: 100_000_000) - } + // Scroll back up + for i in stride(from: steps - 1, through: 0, by: -1) { + let y = stepSize * CGFloat(i) + scrollView.setContentOffset(CGPoint(x: 0, y: y), animated: false) + try? await Task.sleep(nanoseconds: 100_000_000) + } - print("[Benchmark] Scroll test complete — FPS min: \(String(format: "%.1f", fpsMonitor.minFPS == .infinity ? 0 : fpsMonitor.minFPS)), avg: \(String(format: "%.1f", fpsMonitor.averageFPS)), max: \(String(format: "%.1f", fpsMonitor.maxFPS)), dropped: \(fpsMonitor.droppedFrameCount)") - } + print( + "[Benchmark] Scroll test complete — FPS min: \(String(format: "%.1f", fpsMonitor.minFPS == .infinity ? 0 : fpsMonitor.minFPS)), avg: \(String(format: "%.1f", fpsMonitor.averageFPS)), max: \(String(format: "%.1f", fpsMonitor.maxFPS)), dropped: \(fpsMonitor.droppedFrameCount)" + ) + } - /// Walk the view hierarchy to find the UIScrollView backing our SwiftUI ScrollView. - private func findUIScrollView() -> UIScrollView? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first else { return nil } - return findScrollView(in: window) - } + /// Walk the view hierarchy to find the UIScrollView backing our SwiftUI ScrollView. + private func findUIScrollView() -> UIScrollView? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first + else { return nil } + return findScrollView(in: window) + } - private func findScrollView(in view: UIView) -> UIScrollView? { - if let sv = view as? UIScrollView { return sv } - for sub in view.subviews { - if let found = findScrollView(in: sub) { return found } + private func findScrollView(in view: UIView) -> UIScrollView? { + if let sv = view as? UIScrollView { return sv } + for sub in view.subviews { + if let found = findScrollView(in: sub) { return found } + } + return nil } - return nil - } #else - private func runScrollTest() async { - print("[Benchmark] Scroll test not available on this platform") - } + private func runScrollTest() async { + print("[Benchmark] Scroll test not available on this platform") + } #endif // MARK: - Comment Generation @@ -264,7 +271,7 @@ struct BenchmarkView: View { "

Indeed, that makes sense.

", ] - let baseDate = Date().addingTimeInterval(-3 * 24 * 3600) // 3 days ago + let baseDate = Date().addingTimeInterval(-3 * 24 * 3600) // 3 days ago var comments: [PublicComment] = [] comments.reserveCapacity(count) @@ -273,7 +280,7 @@ struct BenchmarkView: View { id: "bench-\(i)", commenterName: names[i % names.count], commentHTML: messages[i % messages.count], - date: baseDate.addingTimeInterval(Double(i) * 2.6), // ~2.6s apart = 3 days over 100k + date: baseDate.addingTimeInterval(Double(i) * 2.6), // ~2.6s apart = 3 days over 100k verified: true ) comments.append(comment) diff --git a/ExampleApp/FastCommentsExample/CommentsExampleView.swift b/ExampleApp/FastCommentsExample/CommentsExampleView.swift index 90badce..83c25be 100644 --- a/ExampleApp/FastCommentsExample/CommentsExampleView.swift +++ b/ExampleApp/FastCommentsExample/CommentsExampleView.swift @@ -76,7 +76,7 @@ struct CommentsExampleView: View { theme.loadMoreButtonTextColor = .indigo // Vote colors - theme.voteActiveColor = .red // Heart fill color + theme.voteActiveColor = .red // Heart fill color theme.voteCountColor = .primary theme.voteCountZeroColor = .secondary diff --git a/ExampleApp/FastCommentsExample/FPSMonitor.swift b/ExampleApp/FastCommentsExample/FPSMonitor.swift index 58ce56f..41f99b1 100644 --- a/ExampleApp/FastCommentsExample/FPSMonitor.swift +++ b/ExampleApp/FastCommentsExample/FPSMonitor.swift @@ -1,87 +1,92 @@ #if canImport(UIKit) -import UIKit + import UIKit -@MainActor -final class FPSMonitor: ObservableObject { - @Published var currentFPS: Double = 0 - @Published var minFPS: Double = .infinity - @Published var maxFPS: Double = 0 - @Published var droppedFrameCount: Int = 0 + @MainActor + final class FPSMonitor: ObservableObject { + @Published var currentFPS: Double = 0 + @Published var minFPS: Double = .infinity + @Published var maxFPS: Double = 0 + @Published var droppedFrameCount: Int = 0 - private var displayLink: CADisplayLink? - private var lastTimestamp: CFTimeInterval = 0 - private var frameCount: Int = 0 - private var fpsSamples: [Double] = [] + private var displayLink: CADisplayLink? + private var lastTimestamp: CFTimeInterval = 0 + private var frameCount: Int = 0 + private var fpsSamples: [Double] = [] - func start() { - stop() - minFPS = .infinity - maxFPS = 0 - droppedFrameCount = 0 - fpsSamples.removeAll() + func start() { + stop() + minFPS = .infinity + maxFPS = 0 + droppedFrameCount = 0 + fpsSamples.removeAll() - let link = CADisplayLink(target: DisplayLinkTarget { [weak self] link in - self?.tick(link) - }, selector: #selector(DisplayLinkTarget.handleDisplayLink(_:))) - link.add(to: .main, forMode: .common) - displayLink = link - print("[Benchmark FPS] Monitor started") - } - - func stop() { - displayLink?.invalidate() - displayLink = nil - lastTimestamp = 0 - frameCount = 0 - if !fpsSamples.isEmpty { - let avg = fpsSamples.reduce(0, +) / Double(fpsSamples.count) - print("[Benchmark FPS] Monitor stopped — avg: \(String(format: "%.1f", avg)), min: \(String(format: "%.1f", minFPS == .infinity ? 0 : minFPS)), max: \(String(format: "%.1f", maxFPS)), samples: \(fpsSamples.count), dropped: \(droppedFrameCount)") + let link = CADisplayLink( + target: DisplayLinkTarget { [weak self] link in + self?.tick(link) + }, selector: #selector(DisplayLinkTarget.handleDisplayLink(_:))) + link.add(to: .main, forMode: .common) + displayLink = link + print("[Benchmark FPS] Monitor started") } - } - - var averageFPS: Double { - fpsSamples.isEmpty ? 0 : fpsSamples.reduce(0, +) / Double(fpsSamples.count) - } - private func tick(_ link: CADisplayLink) { - if lastTimestamp == 0 { - lastTimestamp = link.timestamp + func stop() { + displayLink?.invalidate() + displayLink = nil + lastTimestamp = 0 frameCount = 0 - return + if !fpsSamples.isEmpty { + let avg = fpsSamples.reduce(0, +) / Double(fpsSamples.count) + print( + "[Benchmark FPS] Monitor stopped — avg: \(String(format: "%.1f", avg)), min: \(String(format: "%.1f", minFPS == .infinity ? 0 : minFPS)), max: \(String(format: "%.1f", maxFPS)), samples: \(fpsSamples.count), dropped: \(droppedFrameCount)" + ) + } + } + + var averageFPS: Double { + fpsSamples.isEmpty ? 0 : fpsSamples.reduce(0, +) / Double(fpsSamples.count) } - frameCount += 1 - let elapsed = link.timestamp - lastTimestamp + private func tick(_ link: CADisplayLink) { + if lastTimestamp == 0 { + lastTimestamp = link.timestamp + frameCount = 0 + return + } - if elapsed >= 1.0 { - let fps = Double(frameCount) / elapsed - currentFPS = fps - fpsSamples.append(fps) + frameCount += 1 + let elapsed = link.timestamp - lastTimestamp - if fps < minFPS { minFPS = fps } - if fps > maxFPS { maxFPS = fps } + if elapsed >= 1.0 { + let fps = Double(frameCount) / elapsed + currentFPS = fps + fpsSamples.append(fps) - // A "dropped frame" = FPS dipped below half of max display refresh (assume 60) - if fps < 30 { - droppedFrameCount += 1 - } + if fps < minFPS { minFPS = fps } + if fps > maxFPS { maxFPS = fps } - print("[Benchmark FPS] \(String(format: "%.1f", fps)) fps (min: \(String(format: "%.1f", minFPS)), max: \(String(format: "%.1f", maxFPS)))") + // A "dropped frame" = FPS dipped below half of max display refresh (assume 60) + if fps < 30 { + droppedFrameCount += 1 + } - frameCount = 0 - lastTimestamp = link.timestamp + print( + "[Benchmark FPS] \(String(format: "%.1f", fps)) fps (min: \(String(format: "%.1f", minFPS)), max: \(String(format: "%.1f", maxFPS)))" + ) + + frameCount = 0 + lastTimestamp = link.timestamp + } } } -} -// CADisplayLink requires an @objc target; this avoids making FPSMonitor inherit NSObject. -private class DisplayLinkTarget: NSObject { - let handler: (CADisplayLink) -> Void - init(handler: @escaping (CADisplayLink) -> Void) { - self.handler = handler - } - @objc func handleDisplayLink(_ link: CADisplayLink) { - handler(link) + // CADisplayLink requires an @objc target; this avoids making FPSMonitor inherit NSObject. + private class DisplayLinkTarget: NSObject { + let handler: (CADisplayLink) -> Void + init(handler: @escaping (CADisplayLink) -> Void) { + self.handler = handler + } + @objc func handleDisplayLink(_ link: CADisplayLink) { + handler(link) + } } -} #endif diff --git a/ExampleApp/FastCommentsExample/FastCommentsExampleApp.swift b/ExampleApp/FastCommentsExample/FastCommentsExampleApp.swift index 11ee10a..e3e9deb 100644 --- a/ExampleApp/FastCommentsExample/FastCommentsExampleApp.swift +++ b/ExampleApp/FastCommentsExample/FastCommentsExampleApp.swift @@ -50,7 +50,8 @@ struct FastCommentsExampleApp: App { private var testConfig: FastCommentsWidgetConfig? { let args = ProcessInfo.processInfo.arguments guard let idx = args.firstIndex(of: "-test"), - idx + 3 < args.count else { return nil } + idx + 3 < args.count + else { return nil } let tenantId = args[idx + 1] let urlId = args[idx + 2] let sso = args[idx + 3] @@ -61,7 +62,8 @@ struct FastCommentsExampleApp: App { private var feedTestConfig: FastCommentsWidgetConfig? { let args = ProcessInfo.processInfo.arguments guard let idx = args.firstIndex(of: "-feed-test"), - idx + 3 < args.count else { return nil } + idx + 3 < args.count + else { return nil } let tenantId = args[idx + 1] let urlId = args[idx + 2] let sso = args[idx + 3] @@ -72,7 +74,8 @@ struct FastCommentsExampleApp: App { private var feedComposerTestConfig: FastCommentsWidgetConfig? { let args = ProcessInfo.processInfo.arguments guard let idx = args.firstIndex(of: "-feed-composer-test"), - idx + 3 < args.count else { return nil } + idx + 3 < args.count + else { return nil } let tenantId = args[idx + 1] let urlId = args[idx + 2] let sso = args[idx + 3] @@ -83,7 +86,8 @@ struct FastCommentsExampleApp: App { private var fullFeedTestConfig: FastCommentsWidgetConfig? { let args = ProcessInfo.processInfo.arguments guard let idx = args.firstIndex(of: "-full-feed-test"), - idx + 3 < args.count else { return nil } + idx + 3 < args.count + else { return nil } let tenantId = args[idx + 1] let urlId = args[idx + 2] let sso = args[idx + 3] @@ -94,7 +98,8 @@ struct FastCommentsExampleApp: App { private var feedLifecycleTestConfig: FastCommentsWidgetConfig? { let args = ProcessInfo.processInfo.arguments guard let idx = args.firstIndex(of: "-feed-lifecycle-test"), - idx + 3 < args.count else { return nil } + idx + 3 < args.count + else { return nil } let tenantId = args[idx + 1] let urlId = args[idx + 2] let sso = args[idx + 3] @@ -105,7 +110,8 @@ struct FastCommentsExampleApp: App { private var followTestConfig: FastCommentsWidgetConfig? { let args = ProcessInfo.processInfo.arguments guard let idx = args.firstIndex(of: "-follow-test"), - idx + 3 < args.count else { return nil } + idx + 3 < args.count + else { return nil } let tenantId = args[idx + 1] let urlId = args[idx + 2] let sso = args[idx + 3] @@ -118,7 +124,8 @@ struct FastCommentsExampleApp: App { private var followDemoTestConfig: FastCommentsWidgetConfig? { let args = ProcessInfo.processInfo.arguments guard let idx = args.firstIndex(of: "-follow-demo-test"), - idx + 3 < args.count else { return nil } + idx + 3 < args.count + else { return nil } let tenantId = args[idx + 1] let urlId = args[idx + 2] let sso = args[idx + 3] diff --git a/ExampleApp/FastCommentsExample/FeedExampleView.swift b/ExampleApp/FastCommentsExample/FeedExampleView.swift index 3ec1542..8d8972f 100644 --- a/ExampleApp/FastCommentsExample/FeedExampleView.swift +++ b/ExampleApp/FastCommentsExample/FeedExampleView.swift @@ -113,9 +113,11 @@ struct FeedExampleView: View { } // 8. Comments dialog for a post .sheet(item: $commentsPost) { post in - CommentsSheet(post: post, feedSDK: sdk, onUserClick: { context, userInfo, source in - selectedUser = userInfo - }) + CommentsSheet( + post: post, feedSDK: sdk, + onUserClick: { context, userInfo, source in + selectedUser = userInfo + }) } // Error alert .alert("Error", isPresented: .constant(errorMessage != nil)) { @@ -190,7 +192,9 @@ final class LoggingFollowStateProvider: ObservableObject, FollowStateProvider { result: @escaping @Sendable (Bool) -> Void ) { let userId = user.userId ?? "" - print("[FollowProvider] request user=\(user.displayName) id=\(userId) desired=\(desiredFollowing) — simulating 3s backend call") + print( + "[FollowProvider] request user=\(user.displayName) id=\(userId) desired=\(desiredFollowing) — simulating 3s backend call" + ) // Weak capture so the provider isn't kept alive purely by an // in-flight request after the viewer navigates away. diff --git a/ExampleApp/FastCommentsExample/ScreenshotTourView.swift b/ExampleApp/FastCommentsExample/ScreenshotTourView.swift index 08ac75b..34b606a 100644 --- a/ExampleApp/FastCommentsExample/ScreenshotTourView.swift +++ b/ExampleApp/FastCommentsExample/ScreenshotTourView.swift @@ -54,7 +54,7 @@ struct ScreenshotTourView: View { } } } - .padding(.bottom, 90) // Above input bar + .padding(.bottom, 90) // Above input bar } } } diff --git a/ExampleApp/FastCommentsExample/ToolbarShowcaseView.swift b/ExampleApp/FastCommentsExample/ToolbarShowcaseView.swift index 86b1418..919a54b 100644 --- a/ExampleApp/FastCommentsExample/ToolbarShowcaseView.swift +++ b/ExampleApp/FastCommentsExample/ToolbarShowcaseView.swift @@ -37,7 +37,7 @@ struct ToolbarShowcaseView: View { FastCommentsView( sdk: sdk, customToolbarButtons: [ - CodeBlockButton(), + CodeBlockButton() ] ) diff --git a/ExampleApp/FastCommentsUITests/FeedComposerUITests.swift b/ExampleApp/FastCommentsUITests/FeedComposerUITests.swift index 022ac2a..fe02454 100644 --- a/ExampleApp/FastCommentsUITests/FeedComposerUITests.swift +++ b/ExampleApp/FastCommentsUITests/FeedComposerUITests.swift @@ -76,7 +76,10 @@ final class FeedComposerUITests: UITestBase { scrollView.swipeDown() scrollView.swipeDown() - XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10), "App should remain running after refreshing twice") - XCTAssertTrue(app.staticTexts[postText].waitForExistence(timeout: 15), "Posted text should still render after refreshing twice") + XCTAssertTrue( + app.wait(for: .runningForeground, timeout: 10), "App should remain running after refreshing twice") + XCTAssertTrue( + app.staticTexts[postText].waitForExistence(timeout: 15), + "Posted text should still render after refreshing twice") } } diff --git a/ExampleApp/FastCommentsUITests/FeedLifecycleUITests.swift b/ExampleApp/FastCommentsUITests/FeedLifecycleUITests.swift index 289825a..39c1c3e 100644 --- a/ExampleApp/FastCommentsUITests/FeedLifecycleUITests.swift +++ b/ExampleApp/FastCommentsUITests/FeedLifecycleUITests.swift @@ -36,7 +36,9 @@ final class FeedLifecycleUITests: UITestBase { XCTAssertTrue(app.staticTexts["feed-lifecycle-hidden"].waitForExistence(timeout: 10)) toggleButton.tap() - XCTAssertTrue(app.scrollViews["feed-lifecycle-view"].waitForExistence(timeout: 10) || app.otherElements["feed-lifecycle-view"].waitForExistence(timeout: 10)) + XCTAssertTrue( + app.scrollViews["feed-lifecycle-view"].waitForExistence(timeout: 10) + || app.otherElements["feed-lifecycle-view"].waitForExistence(timeout: 10)) pollUntil(timeout: 10) { countLabel.label == "5" && lastIdLabel.label == beforeLastId && lastTextLabel.label == beforeLastText diff --git a/ExampleApp/FastCommentsUITests/FeedUserA_UITests.swift b/ExampleApp/FastCommentsUITests/FeedUserA_UITests.swift index f5a7ca5..f945b0a 100644 --- a/ExampleApp/FastCommentsUITests/FeedUserA_UITests.swift +++ b/ExampleApp/FastCommentsUITests/FeedUserA_UITests.swift @@ -17,12 +17,14 @@ final class FeedUserA_UITests: UITestBase { let ssoTokenA = makeSecureSSOToken(userId: "userA-feed") let ssoTokenB = makeSecureSSOToken(userId: "userB-feed") - SyncClient.postData(round: "setup", data: [ - "tenantId": testTenantId!, - "apiKey": testTenantApiKey!, - "urlId": urlId, - "ssoTokenB": ssoTokenB, - ]) + SyncClient.postData( + round: "setup", + data: [ + "tenantId": testTenantId!, + "apiKey": testTenantApiKey!, + "urlId": urlId, + "ssoTokenB": ssoTokenB, + ]) SyncClient.signalReady(round: "setup") // Single launch — both phases use the same app session and WebSocket connection. diff --git a/ExampleApp/FastCommentsUITests/LiveEventUserA_UITests.swift b/ExampleApp/FastCommentsUITests/LiveEventUserA_UITests.swift index e667805..7251648 100644 --- a/ExampleApp/FastCommentsUITests/LiveEventUserA_UITests.swift +++ b/ExampleApp/FastCommentsUITests/LiveEventUserA_UITests.swift @@ -18,13 +18,15 @@ final class LiveEventUserA_UITests: UITestBase { let ssoTokenB = makeSecureSSOToken(userId: "userB-live") let ssoTokenBAdmin = makeSecureSSOToken(userId: "userB-live", isAdmin: true) - SyncClient.postData(round: "setup", data: [ - "tenantId": testTenantId!, - "apiKey": testTenantApiKey!, - "urlId": urlId, - "ssoTokenB": ssoTokenB, - "ssoTokenBAdmin": ssoTokenBAdmin, - ]) + SyncClient.postData( + round: "setup", + data: [ + "tenantId": testTenantId!, + "apiKey": testTenantApiKey!, + "urlId": urlId, + "ssoTokenB": ssoTokenB, + "ssoTokenBAdmin": ssoTokenBAdmin, + ]) SyncClient.signalReady(round: "setup") // Single launch — all phases use the same app session and WebSocket connection diff --git a/ExampleApp/FastCommentsUITests/ModerationUITests.swift b/ExampleApp/FastCommentsUITests/ModerationUITests.swift index 2d54b05..06526a5 100644 --- a/ExampleApp/FastCommentsUITests/ModerationUITests.swift +++ b/ExampleApp/FastCommentsUITests/ModerationUITests.swift @@ -61,7 +61,7 @@ final class ModerationUITests: UITestBase { pollUntil(timeout: 5) { menu.tap() let hasUnflag = self.app.buttons["Unflag"].waitForExistence(timeout: 0.5) - if !hasUnflag { self.app.tap() } // dismiss menu if Unflag not there yet + if !hasUnflag { self.app.tap() } // dismiss menu if Unflag not there yet return hasUnflag } diff --git a/ExampleApp/FastCommentsUITests/SyncClient.swift b/ExampleApp/FastCommentsUITests/SyncClient.swift index 2fd7e47..255b74f 100644 --- a/ExampleApp/FastCommentsUITests/SyncClient.swift +++ b/ExampleApp/FastCommentsUITests/SyncClient.swift @@ -19,7 +19,8 @@ enum SyncClient { private static func readConfigFile(role: String) -> [String: String]? { let path = "/tmp/fc-uitest-\(role).json" guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { return nil } + let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] + else { return nil } return json } diff --git a/ExampleApp/FastCommentsUITests/UITestBase.swift b/ExampleApp/FastCommentsUITests/UITestBase.swift index c9389fd..c43a5f4 100644 --- a/ExampleApp/FastCommentsUITests/UITestBase.swift +++ b/ExampleApp/FastCommentsUITests/UITestBase.swift @@ -46,15 +46,19 @@ class UITestBase: XCTestCase { var req = URLRequest(url: signupURL) req.httpMethod = "POST" req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - req.httpBody = "username=\(username)&email=\(email)&companyName=\(username)&domains=\(username).example.com&packageId=adv&noTracking=true".data(using: .utf8) + req.httpBody = + "username=\(username)&email=\(email)&companyName=\(username)&domains=\(username).example.com&packageId=adv&noTracking=true" + .data(using: .utf8) session.dataTask(with: req) { _, _, _ in done() }.resume() } // Get tenant ID let encodedKey = e2eApiKey.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! - let tenantData = syncFetch(url: URL(string: "\(host)/test-e2e/api/tenant/by-email/\(email)?API_KEY=\(encodedKey)")!) + let tenantData = syncFetch( + url: URL(string: "\(host)/test-e2e/api/tenant/by-email/\(email)?API_KEY=\(encodedKey)")!) if let json = try? JSONSerialization.jsonObject(with: tenantData) as? [String: Any], - let tenant = json["tenant"] as? [String: Any] { + let tenant = json["tenant"] as? [String: Any] + { testTenantId = tenant["_id"] as? String } XCTAssertNotNil(testTenantId, "Should have tenant ID") @@ -128,7 +132,7 @@ class UITestBase: XCTestCase { "id": userId, "email": "tester-\(userId.prefix(8))@fctest.com", "username": "Tester \(userId.prefix(6))", - "avatar": "" + "avatar": "", ] if isAdmin { userData["isAdmin"] = true } @@ -142,10 +146,11 @@ class UITestBase: XCTestCase { var hmac = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) key.withUnsafeBytes { keyBytes in messageData.withUnsafeBytes { msgBytes in - CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA256), - keyBytes.baseAddress, key.count, - msgBytes.baseAddress, messageData.count, - &hmac) + CCHmac( + CCHmacAlgorithm(kCCHmacAlgSHA256), + keyBytes.baseAddress, key.count, + msgBytes.baseAddress, messageData.count, + &hmac) } } let hash = hmac.map { String(format: "%02x", $0) }.joined() @@ -153,7 +158,7 @@ class UITestBase: XCTestCase { let payload: [String: Any] = [ "userDataJSONBase64": userDataBase64, "verificationHash": hash, - "timestamp": timestamp + "timestamp": timestamp, ] let payloadData = try! JSONSerialization.data(withJSONObject: payload) return String(data: payloadData, encoding: .utf8)! @@ -260,7 +265,7 @@ class UITestBase: XCTestCase { func pollUntil(timeout: TimeInterval = 10, _ condition: () -> Bool) { let deadline = Date().addingTimeInterval(timeout) while !condition() && Date() < deadline { - usleep(50_000) // 50ms + usleep(50_000) // 50ms RunLoop.current.run(until: Date().addingTimeInterval(0.01)) } } @@ -288,14 +293,14 @@ class UITestBase: XCTestCase { defer { sem.signal() } do { let response = try await PublicAPI.createCommentPublic( - tenantId: testTenantId, - urlId: urlId, - broadcastId: UUID().uuidString, - commentData: commentData, - options: CreateCommentPublicOptions( - sso: ssoToken - ) - ) + tenantId: testTenantId, + urlId: urlId, + broadcastId: UUID().uuidString, + commentData: commentData, + options: CreateCommentPublicOptions( + sso: ssoToken + ) + ) resultId = response.comment?.id } catch { XCTFail("seedComment failed: \(error)") @@ -313,13 +318,13 @@ class UITestBase: XCTestCase { defer { sem.signal() } do { let response = try await PublicAPI.createFeedPostPublic( - tenantId: testTenantId, - createFeedPostParams: CreateFeedPostParams(contentHTML: text), - options: CreateFeedPostPublicOptions( - broadcastId: UUID().uuidString, - sso: ssoToken - ) - ) + tenantId: testTenantId, + createFeedPostParams: CreateFeedPostParams(contentHTML: text), + options: CreateFeedPostPublicOptions( + broadcastId: UUID().uuidString, + sso: ssoToken + ) + ) resultId = response.feedPost?.id } catch { XCTFail("seedFeedPost failed: \(error)") @@ -352,7 +357,10 @@ class UITestBase: XCTestCase { } /// Poll the admin API until a comment has at least `expected` children. - func waitForChildCount(parentId: String, urlId: String, expected: Int, timeout: TimeInterval = 10, file: StaticString = #file, line: UInt = #line) { + func waitForChildCount( + parentId: String, urlId: String, expected: Int, timeout: TimeInterval = 10, file: StaticString = #file, + line: UInt = #line + ) { let deadline = Date().addingTimeInterval(timeout) while true { let sem = DispatchSemaphore(value: 0) @@ -360,20 +368,22 @@ class UITestBase: XCTestCase { Task { defer { sem.signal() } let response = try? await DefaultAPI.getComments( - tenantId: self.testTenantId, - options: GetCommentsOptions( - limit: expected, - urlId: urlId, - parentId: parentId - ), - apiConfiguration: self.adminApiConfig - ) + tenantId: self.testTenantId, + options: GetCommentsOptions( + limit: expected, + urlId: urlId, + parentId: parentId + ), + apiConfiguration: self.adminApiConfig + ) childCount = response?.comments?.count ?? 0 } sem.wait() if childCount >= expected { return } if Date() > deadline { - XCTFail("waitForChildCount: only \(childCount)/\(expected) children found for \(parentId)", file: file, line: line) + XCTFail( + "waitForChildCount: only \(childCount)/\(expected) children found for \(parentId)", file: file, + line: line) return } Thread.sleep(forTimeInterval: 0.5) @@ -388,13 +398,13 @@ class UITestBase: XCTestCase { defer { sem.signal() } do { let response = try await DefaultAPI.getComments( - tenantId: testTenantId, - options: GetCommentsOptions( - limit: 1, - urlId: urlId - ), - apiConfiguration: adminApiConfig - ) + tenantId: testTenantId, + options: GetCommentsOptions( + limit: 1, + urlId: urlId + ), + apiConfiguration: adminApiConfig + ) resultId = response.comments?.first?.id } catch { XCTFail("fetchLatestCommentId failed: \(error)", file: file, line: line) diff --git a/Package.swift b/Package.swift index c7944b1..18d383b 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,6 @@ let package = Package( .testTarget( name: "FastCommentsUITests", dependencies: ["FastCommentsUI"] - ) + ), ] ) diff --git a/Sources/FastCommentsUI/Models/FastCommentsError.swift b/Sources/FastCommentsUI/Models/FastCommentsError.swift index a36a77a..c8ca720 100644 --- a/Sources/FastCommentsUI/Models/FastCommentsError.swift +++ b/Sources/FastCommentsUI/Models/FastCommentsError.swift @@ -21,8 +21,9 @@ public struct FastCommentsError: LocalizedError, Sendable { /// Returns nil for transport-level errors with no decodable API error body. public init?(decoding error: Error) { guard case let ErrorResponse.error(_, data, _, _) = error, - let data, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } let translated = (json["translatedError"] as? String).flatMap { $0.isEmpty ? nil : $0 } diff --git a/Sources/FastCommentsUI/SDK/FastCommentsFeedSDK.swift b/Sources/FastCommentsUI/SDK/FastCommentsFeedSDK.swift index 5efd32a..dc4c368 100644 --- a/Sources/FastCommentsUI/SDK/FastCommentsFeedSDK.swift +++ b/Sources/FastCommentsUI/SDK/FastCommentsFeedSDK.swift @@ -120,7 +120,9 @@ public final class FastCommentsFeedSDK: ObservableObject { } catch { // A blocking condition now arrives as a thrown APIError; surface its localized message in // the blocking banner, then rethrow it so callers see the same text. - if let apiError = FastCommentsError(decoding: error), let blocking = apiError.translatedError ?? apiError.reason { + if let apiError = FastCommentsError(decoding: error), + let blocking = apiError.translatedError ?? apiError.reason + { blockingErrorMessage = blocking throw apiError } @@ -243,15 +245,15 @@ public final class FastCommentsFeedSDK: ObservableObject { let response: PublicFeedPostsResponse do { response = try await PublicAPI.getFeedPostsPublic( - tenantId: config.tenantId, - options: PublicAPI.GetFeedPostsPublicOptions( - limit: pageSize, - tags: tags, - sso: config.sso, - includeUserInfo: true - ), - apiConfiguration: apiConfig - ) + tenantId: config.tenantId, + options: PublicAPI.GetFeedPostsPublicOptions( + limit: pageSize, + tags: tags, + sso: config.sso, + includeUserInfo: true + ), + apiConfiguration: apiConfig + ) } catch { // Count stays so the banner remains visible on failure throw error @@ -361,16 +363,16 @@ public final class FastCommentsFeedSDK: ObservableObject { do { _ = try await PublicAPI.reactFeedPostPublic( - tenantId: config.tenantId, - postId: postId, - reactBodyParams: params, - options: PublicAPI.ReactFeedPostPublicOptions( - isUndo: isUndo, - broadcastId: broadcastId, - sso: config.sso - ), - apiConfiguration: apiConfig - ) + tenantId: config.tenantId, + postId: postId, + reactBodyParams: params, + options: PublicAPI.ReactFeedPostPublicOptions( + isUndo: isUndo, + broadcastId: broadcastId, + sso: config.sso + ), + apiConfiguration: apiConfig + ) } catch { // Rollback on failure if isUndo { @@ -533,7 +535,8 @@ public final class FastCommentsFeedSDK: ObservableObject { liveEventSubscription?.close() guard let _ = tenantIdWS, - let urlIdWS = urlIdWS else { return } + let urlIdWS = urlIdWS + else { return } let liveConfig = LiveEventConfig( tenantId: config.tenantId, @@ -574,7 +577,8 @@ public final class FastCommentsFeedSDK: ObservableObject { } // Check if non-stats content changed let existing = postsById[post.id] - let contentChanged = existing == nil + let contentChanged = + existing == nil || existing?.title != post.title || existing?.contentHTML != post.contentHTML || existing?.media != post.media @@ -680,10 +684,11 @@ public final class FastCommentsFeedSDK: ObservableObject { } private static func toFeedPostMediaItem(_ pubSub: PubSubFeedPostMediaItem) -> FeedPostMediaItem? { - let sizes = pubSub.sizes?.compactMap { asset -> FeedPostMediaItemAsset? in - guard let w = asset.w, let h = asset.h, let src = asset.src else { return nil } - return FeedPostMediaItemAsset(w: w, h: h, src: src) - } ?? [] + let sizes = + pubSub.sizes?.compactMap { asset -> FeedPostMediaItemAsset? in + guard let w = asset.w, let h = asset.h, let src = asset.src else { return nil } + return FeedPostMediaItemAsset(w: w, h: h, src: src) + } ?? [] return FeedPostMediaItem(title: pubSub.title, linkUrl: pubSub.linkUrl, sizes: sizes) } @@ -695,7 +700,7 @@ public final class FastCommentsFeedSDK: ObservableObject { statsPollTask?.cancel() statsPollTask = Task { [weak self] in while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds + try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds guard let self = self, !Task.isCancelled else { break } let postIds = Array(self.postsById.keys) try? await self.fetchPostStats(postIds: postIds) diff --git a/Sources/FastCommentsUI/SDK/FastCommentsSDK.swift b/Sources/FastCommentsUI/SDK/FastCommentsSDK.swift index 2d9a062..dc4f594 100644 --- a/Sources/FastCommentsUI/SDK/FastCommentsSDK.swift +++ b/Sources/FastCommentsUI/SDK/FastCommentsSDK.swift @@ -16,8 +16,10 @@ public struct FastCommentsWidgetConfig: Sendable { public var sso: String? public var locale: String? - public init(tenantId: String, urlId: String, url: String = "", pageTitle: String? = nil, - region: String? = nil, sso: String? = nil, locale: String? = nil) { + public init( + tenantId: String, urlId: String, url: String = "", pageTitle: String? = nil, + region: String? = nil, sso: String? = nil, locale: String? = nil + ) { self.tenantId = tenantId self.urlId = urlId self.url = url @@ -187,7 +189,9 @@ public final class FastCommentsSDK: ObservableObject { } catch { // A blocking condition (e.g. banned/closed) now arrives as a thrown APIError; surface its // localized message in the blocking banner, then rethrow it so callers see the same text. - if let apiError = FastCommentsError(decoding: error), let blocking = apiError.translatedError ?? apiError.reason { + if let apiError = FastCommentsError(decoding: error), + let blocking = apiError.translatedError ?? apiError.reason + { blockingErrorMessage = blocking throw apiError } @@ -215,7 +219,9 @@ public final class FastCommentsSDK: ObservableObject { var trimmedResponse = response trimmedResponse.comments = comments - fcLog.info("load: returned \(response.comments.count) roots=\(rootComments.count) hasMore=\(clientHasMore, privacy: .public) commentCount=\(response.commentCount ?? -1)") + fcLog.info( + "load: returned \(response.comments.count) roots=\(rootComments.count) hasMore=\(clientHasMore, privacy: .public) commentCount=\(response.commentCount ?? -1)" + ) processCommentsResponse(trimmedResponse, isInitialLoad: true, clientHasMore: clientHasMore) return response @@ -233,19 +239,19 @@ public final class FastCommentsSDK: ObservableObject { do { let response = try await PublicAPI.getCommentsPublic( - tenantId: config.tenantId, - urlId: config.urlId, - options: PublicAPI.GetCommentsPublicOptions( - direction: defaultSortDirection, - sso: config.sso, - skip: currentSkip, - limit: pageSize + 1, - countChildren: true, - asTree: true, - maxTreeDepth: 0 - ), - apiConfiguration: apiConfig - ) + tenantId: config.tenantId, + urlId: config.urlId, + options: PublicAPI.GetCommentsPublicOptions( + direction: defaultSortDirection, + sso: config.sso, + skip: currentSkip, + limit: pageSize + 1, + countChildren: true, + asTree: true, + maxTreeDepth: 0 + ), + apiConfiguration: apiConfig + ) var comments = response.comments let rootComments = comments.filter { $0.parentId == nil } @@ -256,7 +262,9 @@ public final class FastCommentsSDK: ObservableObject { comments.removeAll { $0.id == lastRoot.id } } - fcLog.info("loadMore: skip=\(self.currentSkip) limit=\(self.pageSize) returned=\(rootComments.count) roots, trimmed to \(comments.count), hasMore=\(moreAvailable, privacy: .public)") + fcLog.info( + "loadMore: skip=\(self.currentSkip) limit=\(self.pageSize) returned=\(rootComments.count) roots, trimmed to \(comments.count), hasMore=\(moreAvailable, privacy: .public)" + ) if !comments.isEmpty { if self.commentsTree.liveChatStyle { @@ -328,7 +336,9 @@ public final class FastCommentsSDK: ObservableObject { /// Post a new comment or reply. @discardableResult - public func postComment(text: String, parentId: String? = nil, mentions: [CommentUserMentionInfo]? = nil) async throws -> PublicComment { + public func postComment( + text: String, parentId: String? = nil, mentions: [CommentUserMentionInfo]? = nil + ) async throws -> PublicComment { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw FastCommentsError(reason: "Comment text cannot be empty") @@ -363,7 +373,6 @@ public final class FastCommentsSDK: ObservableObject { apiConfiguration: apiConfig ) - // Update user session if returned if let user = response.user { currentUser = user @@ -398,7 +407,6 @@ public final class FastCommentsSDK: ObservableObject { apiConfiguration: apiConfig ) - // Update local tree with server-rendered HTML let result = response.comment if let existing = commentsTree.commentsById[commentId] { @@ -425,7 +433,6 @@ public final class FastCommentsSDK: ObservableObject { apiConfiguration: apiConfig ) - commentsTree.removeComment(commentId: commentId) commentCountOnServer -= 1 } @@ -434,8 +441,10 @@ public final class FastCommentsSDK: ObservableObject { /// Vote on a comment (upvote or downvote). @discardableResult - public func voteComment(commentId: String, isUpvote: Bool, - commenterName: String? = nil, commenterEmail: String? = nil) async throws -> VoteResponse { + public func voteComment( + commentId: String, isUpvote: Bool, + commenterName: String? = nil, commenterEmail: String? = nil + ) async throws -> VoteResponse { let broadcastId = UUID().uuidString broadcastIdsSent.insert(broadcastId) @@ -459,7 +468,6 @@ public final class FastCommentsSDK: ObservableObject { apiConfiguration: apiConfig ) - // Update local comment state if let comment = commentsTree.commentsById[commentId] { if isUpvote { @@ -532,7 +540,6 @@ public final class FastCommentsSDK: ObservableObject { apiConfiguration: apiConfig ) - // Re-fetch after await — renderable may have been replaced by a live event if let renderable = commentsTree.commentsById[commentId] { renderable.comment.isFlagged = true @@ -550,7 +557,6 @@ public final class FastCommentsSDK: ObservableObject { apiConfiguration: apiConfig ) - if let renderable = commentsTree.commentsById[commentId] { renderable.comment.isFlagged = false renderable.objectWillChange.send() @@ -570,7 +576,6 @@ public final class FastCommentsSDK: ObservableObject { apiConfiguration: apiConfig ) - setBlockedStateForAuthor(commentId: commentId, blocked: true) } @@ -612,7 +617,6 @@ public final class FastCommentsSDK: ObservableObject { apiConfiguration: apiConfig ) - // Re-fetch after await — the renderable may have been replaced by a live event if let renderable = commentsTree.commentsById[commentId] { renderable.comment.isPinned = true @@ -634,7 +638,6 @@ public final class FastCommentsSDK: ObservableObject { apiConfiguration: apiConfig ) - if let renderable = commentsTree.commentsById[commentId] { renderable.comment.isPinned = false renderable.objectWillChange.send() @@ -655,7 +658,6 @@ public final class FastCommentsSDK: ObservableObject { apiConfiguration: apiConfig ) - if let renderable = commentsTree.commentsById[commentId] { renderable.comment.isLocked = true renderable.objectWillChange.send() @@ -676,7 +678,6 @@ public final class FastCommentsSDK: ObservableObject { apiConfiguration: apiConfig ) - if let renderable = commentsTree.commentsById[commentId] { renderable.comment.isLocked = false renderable.objectWillChange.send() @@ -770,7 +771,8 @@ public final class FastCommentsSDK: ObservableObject { liveEventSubscription?.close() guard let tenantIdWS = tenantIdWS, - let urlIdWS = urlIdWS else { return } + let urlIdWS = urlIdWS + else { return } let liveConfig = LiveEventConfig( tenantId: config.tenantId, @@ -814,7 +816,8 @@ public final class FastCommentsSDK: ObservableObject { case .newComment: if let comment = event.comment { let publicComment = LiveEventHandler.toPublicComment(comment) - commentsTree.addComment(publicComment, displayNow: showLiveRightAway, sortDirection: defaultSortDirection) + commentsTree.addComment( + publicComment, displayNow: showLiveRightAway, sortDirection: defaultSortDirection) commentCountOnServer += 1 } case .updatedComment: @@ -828,7 +831,8 @@ public final class FastCommentsSDK: ObservableObject { commentCountOnServer -= 1 } case .newVote: - if let vote = event.vote, let commentId = vote.commentId, let comment = commentsTree.commentsById[commentId] { + if let vote = event.vote, let commentId = vote.commentId, let comment = commentsTree.commentsById[commentId] + { let direction = vote.direction ?? 1 if direction > 0 { comment.comment.votesUp = (comment.comment.votesUp ?? 0) + 1 @@ -839,7 +843,8 @@ public final class FastCommentsSDK: ObservableObject { comment.objectWillChange.send() } case .deletedVote: - if let vote = event.vote, let commentId = vote.commentId, let comment = commentsTree.commentsById[commentId] { + if let vote = event.vote, let commentId = vote.commentId, let comment = commentsTree.commentsById[commentId] + { let direction = vote.direction ?? 1 if direction > 0 { comment.comment.votesUp = max(0, (comment.comment.votesUp ?? 0) - 1) @@ -867,7 +872,8 @@ public final class FastCommentsSDK: ObservableObject { } case .updateBadges: guard let userId = event.userId, - let eventBadges = event.badges, !eventBadges.isEmpty else { break } + let eventBadges = event.badges, !eventBadges.isEmpty + else { break } let convertedBadges = eventBadges.compactMap { LiveEventHandler.toCommentUserBadgeInfo($0) } guard !convertedBadges.isEmpty else { break } @@ -962,7 +968,9 @@ public final class FastCommentsSDK: ObservableObject { // MARK: - Private Helpers - private func processCommentsResponse(_ response: GetCommentsResponseWithPresencePublicComment, isInitialLoad: Bool, clientHasMore: Bool? = nil) { + private func processCommentsResponse( + _ response: GetCommentsResponseWithPresencePublicComment, isInitialLoad: Bool, clientHasMore: Bool? = nil + ) { commentsTree.build(comments: response.comments) commentCountOnServer = response.commentCount ?? 0 diff --git a/Sources/FastCommentsUI/SDK/LiveEventHandler.swift b/Sources/FastCommentsUI/SDK/LiveEventHandler.swift index 1ee18b6..37a8c47 100644 --- a/Sources/FastCommentsUI/SDK/LiveEventHandler.swift +++ b/Sources/FastCommentsUI/SDK/LiveEventHandler.swift @@ -7,8 +7,9 @@ enum LiveEventHandler { /// Convert a PubSubCommentUserBadgeInfo to a CommentUserBadgeInfo. static func toCommentUserBadgeInfo(_ pubSub: PubSubCommentUserBadgeInfo) -> CommentUserBadgeInfo? { guard let id = pubSub.id, - let type = pubSub.type, - let description = pubSub.description else { return nil } + let type = pubSub.type, + let description = pubSub.description + else { return nil } return CommentUserBadgeInfo( id: id, type: type, diff --git a/Sources/FastCommentsUI/Sheets/AddLinkSheet.swift b/Sources/FastCommentsUI/Sheets/AddLinkSheet.swift index 75b75fb..1837d25 100644 --- a/Sources/FastCommentsUI/Sheets/AddLinkSheet.swift +++ b/Sources/FastCommentsUI/Sheets/AddLinkSheet.swift @@ -15,8 +15,8 @@ public struct AddLinkSheet: View { Section { TextField("URL", text: $url) #if os(iOS) - .keyboardType(.URL) - .textInputAutocapitalization(.never) + .keyboardType(.URL) + .textInputAutocapitalization(.never) #endif .autocorrectionDisabled() TextField(NSLocalizedString("link_label", bundle: .module, comment: ""), text: $label) @@ -24,7 +24,7 @@ public struct AddLinkSheet: View { } .navigationTitle(NSLocalizedString("add_link", bundle: .module, comment: "")) #if os(iOS) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/Sources/FastCommentsUI/Sheets/CommentEditSheet.swift b/Sources/FastCommentsUI/Sheets/CommentEditSheet.swift index bad21fe..94ddac0 100644 --- a/Sources/FastCommentsUI/Sheets/CommentEditSheet.swift +++ b/Sources/FastCommentsUI/Sheets/CommentEditSheet.swift @@ -30,7 +30,8 @@ public struct CommentEditSheet: View { ) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(isFocused ? theme.resolveActionButtonColor().opacity(0.5) : Color.clear, lineWidth: 1.5) + .stroke( + isFocused ? theme.resolveActionButtonColor().opacity(0.5) : Color.clear, lineWidth: 1.5) ) .padding() @@ -38,7 +39,7 @@ public struct CommentEditSheet: View { } .navigationTitle(NSLocalizedString("edit_comment", bundle: .module, comment: "")) #if os(iOS) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -65,9 +66,9 @@ public struct CommentEditSheet: View { private var editorBackground: Color { #if os(iOS) - Color(uiColor: .tertiarySystemFill) + Color(uiColor: .tertiarySystemFill) #else - Color(nsColor: .controlBackgroundColor) + Color(nsColor: .controlBackgroundColor) #endif } } diff --git a/Sources/FastCommentsUI/Sheets/CommentsSheet.swift b/Sources/FastCommentsUI/Sheets/CommentsSheet.swift index 719b8ed..48d5dd7 100644 --- a/Sources/FastCommentsUI/Sheets/CommentsSheet.swift +++ b/Sources/FastCommentsUI/Sheets/CommentsSheet.swift @@ -11,8 +11,10 @@ public struct CommentsSheet: View { @StateObject private var commentsSDK: FastCommentsSDK @Environment(\.dismiss) private var dismiss - public init(post: FeedPost, feedSDK: FastCommentsFeedSDK, - onUserClick: ((UserClickContext, UserInfo, UserClickSource) -> Void)? = nil) { + public init( + post: FeedPost, feedSDK: FastCommentsFeedSDK, + onUserClick: ((UserClickContext, UserInfo, UserClickSource) -> Void)? = nil + ) { self.post = post self.feedSDK = feedSDK self.onUserClick = onUserClick @@ -24,7 +26,7 @@ public struct CommentsSheet: View { FastCommentsView(sdk: commentsSDK) .navigationTitle(NSLocalizedString("comments", bundle: .module, comment: "")) #if os(iOS) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/Sources/FastCommentsUI/Sheets/FullImageSheet.swift b/Sources/FastCommentsUI/Sheets/FullImageSheet.swift index cf50ce1..aac4e91 100644 --- a/Sources/FastCommentsUI/Sheets/FullImageSheet.swift +++ b/Sources/FastCommentsUI/Sheets/FullImageSheet.swift @@ -52,7 +52,7 @@ public struct FullImageSheet: View { } } #if os(iOS) - .tabViewStyle(.page(indexDisplayMode: .never)) + .tabViewStyle(.page(indexDisplayMode: .never)) #endif .ignoresSafeArea() @@ -87,7 +87,7 @@ public struct FullImageSheet: View { } } #if os(iOS) - .statusBarHidden(true) + .statusBarHidden(true) #endif } } diff --git a/Sources/FastCommentsUI/Sheets/UserLoginSheet.swift b/Sources/FastCommentsUI/Sheets/UserLoginSheet.swift index b04d85d..5a45773 100644 --- a/Sources/FastCommentsUI/Sheets/UserLoginSheet.swift +++ b/Sources/FastCommentsUI/Sheets/UserLoginSheet.swift @@ -25,19 +25,20 @@ public struct UserLoginSheet: View { TextField(NSLocalizedString("email", bundle: .module, comment: ""), text: $email) .textContentType(.emailAddress) #if os(iOS) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) #endif } header: { - Text(action == .vote - ? NSLocalizedString("login_to_vote", bundle: .module, comment: "") - : NSLocalizedString("login_to_comment", bundle: .module, comment: "") + Text( + action == .vote + ? NSLocalizedString("login_to_vote", bundle: .module, comment: "") + : NSLocalizedString("login_to_comment", bundle: .module, comment: "") ) } } .navigationTitle(NSLocalizedString("login", bundle: .module, comment: "")) #if os(iOS) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/Sources/FastCommentsUI/State/CommentsTree.swift b/Sources/FastCommentsUI/State/CommentsTree.swift index 8924219..1e9a719 100644 --- a/Sources/FastCommentsUI/State/CommentsTree.swift +++ b/Sources/FastCommentsUI/State/CommentsTree.swift @@ -136,10 +136,12 @@ public final class CommentsTree: ObservableObject { if liveChatStyle, !newNodes.isEmpty, !visibleNodes.isEmpty { if let firstExisting = visibleNodes.first, firstExisting is DateSeparator { if let lastPrependedComment = newNodes.last(where: { $0 is RenderableComment }) as? RenderableComment, - let firstExistingComment = visibleNodes.first(where: { $0 is RenderableComment }) as? RenderableComment, - let d1 = lastPrependedComment.comment.date, - let d2 = firstExistingComment.comment.date, - calendar.isDate(d1, inSameDayAs: d2) { + let firstExistingComment = visibleNodes.first(where: { $0 is RenderableComment }) + as? RenderableComment, + let d1 = lastPrependedComment.comment.date, + let d2 = firstExistingComment.comment.date, + calendar.isDate(d1, inSameDayAs: d2) + { visibleNodes.removeFirst() } } @@ -227,11 +229,13 @@ public final class CommentsTree: ObservableObject { for i in stride(from: updatedNodes.count - 1, through: 0, by: -1) { let node = updatedNodes[i] if let separator = node as? DateSeparator { - let sepComponents = calendar.dateComponents([.year, .month, .day], from: separator.date) + let sepComponents = calendar.dateComponents( + [.year, .month, .day], from: separator.date) needDateSeparator = sepComponents != commentComponents break } else if let lastComment = node as? RenderableComment, - let lastDate = lastComment.comment.date { + let lastDate = lastComment.comment.date + { let lastComponents = calendar.dateComponents([.year, .month, .day], from: lastDate) needDateSeparator = lastComponents != commentComponents break @@ -260,7 +264,8 @@ public final class CommentsTree: ObservableObject { newRootCommentsButton = button updatedNodes.insert(button, at: 0) } else if let existingButton = newRootCommentsButton, - let buttonIndex = updatedNodes.firstIndex(where: { $0.id == existingButton.id }) { + let buttonIndex = updatedNodes.firstIndex(where: { $0.id == existingButton.id }) + { let newButton = RenderableButton( buttonType: .newRootComments, commentCount: newRootComments.count @@ -626,7 +631,8 @@ public final class CommentsTree: ObservableObject { var updatedNodes = visibleNodes if let existingButton = newChildCommentsButtons[parentId], - let buttonIndex = updatedNodes.firstIndex(where: { $0.id == existingButton.id }) { + let buttonIndex = updatedNodes.firstIndex(where: { $0.id == existingButton.id }) + { let newButton = RenderableButton( buttonType: .newChildComments, commentCount: parent.getNewChildCommentsCount(), diff --git a/Sources/FastCommentsUI/State/RenderableComment.swift b/Sources/FastCommentsUI/State/RenderableComment.swift index 0be10a4..4ee2ffb 100644 --- a/Sources/FastCommentsUI/State/RenderableComment.swift +++ b/Sources/FastCommentsUI/State/RenderableComment.swift @@ -22,7 +22,8 @@ public final class RenderableComment: RenderableNode { // Determine if there are more children to load based on API counts if let childCount = comment.childCount, - let nestedChildrenCount = comment.nestedChildrenCount { + let nestedChildrenCount = comment.nestedChildrenCount + { self.hasMoreChildren = childCount > nestedChildrenCount } else if let childCount = comment.childCount, childCount > 0 { let loadedChildren = comment.children?.count ?? 0 diff --git a/Sources/FastCommentsUI/Theme/FastCommentsTheme.swift b/Sources/FastCommentsUI/Theme/FastCommentsTheme.swift index 9f10b67..e7b4823 100644 --- a/Sources/FastCommentsUI/Theme/FastCommentsTheme.swift +++ b/Sources/FastCommentsUI/Theme/FastCommentsTheme.swift @@ -222,9 +222,9 @@ public struct FastCommentsTheme: Sendable { public func resolveVoteDividerColor() -> Color { #if os(iOS) - voteDividerColor ?? Color(uiColor: .separator) + voteDividerColor ?? Color(uiColor: .separator) #else - voteDividerColor ?? Color(nsColor: .separatorColor) + voteDividerColor ?? Color(nsColor: .separatorColor) #endif } @@ -258,41 +258,41 @@ public struct FastCommentsTheme: Sendable { public func resolveCommentBackgroundColor() -> Color { #if os(iOS) - commentBackgroundColor ?? Color(uiColor: .secondarySystemGroupedBackground) + commentBackgroundColor ?? Color(uiColor: .secondarySystemGroupedBackground) #else - commentBackgroundColor ?? Color(nsColor: .controlBackgroundColor) + commentBackgroundColor ?? Color(nsColor: .controlBackgroundColor) #endif } public func resolveContainerBackgroundColor() -> Color { #if os(iOS) - containerBackgroundColor ?? Color(uiColor: .systemGroupedBackground) + containerBackgroundColor ?? Color(uiColor: .systemGroupedBackground) #else - containerBackgroundColor ?? Color(nsColor: .windowBackgroundColor) + containerBackgroundColor ?? Color(nsColor: .windowBackgroundColor) #endif } public func resolveInputBarBackgroundColor() -> Color { #if os(iOS) - inputBarBackgroundColor ?? Color(uiColor: .systemBackground) + inputBarBackgroundColor ?? Color(uiColor: .systemBackground) #else - inputBarBackgroundColor ?? Color(nsColor: .windowBackgroundColor) + inputBarBackgroundColor ?? Color(nsColor: .windowBackgroundColor) #endif } public func resolveInputBarBorderColor() -> Color { #if os(iOS) - inputBarBorderColor ?? Color(uiColor: .separator) + inputBarBorderColor ?? Color(uiColor: .separator) #else - inputBarBorderColor ?? Color(nsColor: .separatorColor) + inputBarBorderColor ?? Color(nsColor: .separatorColor) #endif } public func resolveSeparatorColor() -> Color { #if os(iOS) - separatorColor ?? Color(uiColor: .separator) + separatorColor ?? Color(uiColor: .separator) #else - separatorColor ?? Color(nsColor: .separatorColor) + separatorColor ?? Color(nsColor: .separatorColor) #endif } diff --git a/Sources/FastCommentsUI/Utilities/AttributedStringHTMLConverter.swift b/Sources/FastCommentsUI/Utilities/AttributedStringHTMLConverter.swift index 00967db..911f21c 100644 --- a/Sources/FastCommentsUI/Utilities/AttributedStringHTMLConverter.swift +++ b/Sources/FastCommentsUI/Utilities/AttributedStringHTMLConverter.swift @@ -1,142 +1,143 @@ #if canImport(UIKit) -import Foundation -import UIKit - -/// Converts NSAttributedString to HTML, supporting bold, italic, code, and links. -/// Uses a stack-based approach to correctly nest and close tags. -enum AttributedStringHTMLConverter { - - /// Convert an attributed string to an HTML string. - static func convert(_ attributedString: NSAttributedString) -> String { - guard attributedString.length > 0 else { return "" } - - var html = "" - var activeTags: [Tag] = [] - let fullRange = NSRange(location: 0, length: attributedString.length) - - attributedString.enumerateAttributes(in: fullRange, options: []) { attrs, range, _ in - // Handle image attachments - if let imageUrl = attrs[.imageURL] as? String { - // Close any active tags first - while let last = activeTags.popLast() { - html += last.closeTag + import Foundation + import UIKit + + /// Converts NSAttributedString to HTML, supporting bold, italic, code, and links. + /// Uses a stack-based approach to correctly nest and close tags. + enum AttributedStringHTMLConverter { + + /// Convert an attributed string to an HTML string. + static func convert(_ attributedString: NSAttributedString) -> String { + guard attributedString.length > 0 else { return "" } + + var html = "" + var activeTags: [Tag] = [] + let fullRange = NSRange(location: 0, length: attributedString.length) + + attributedString.enumerateAttributes(in: fullRange, options: []) { attrs, range, _ in + // Handle image attachments + if let imageUrl = attrs[.imageURL] as? String { + // Close any active tags first + while let last = activeTags.popLast() { + html += last.closeTag + } + html += "" + return } - html += "" - return - } - // Skip attachment characters (object replacement char) without imageURL - let text = (attributedString.string as NSString).substring(with: range) - if text == "\u{FFFC}" { return } + // Skip attachment characters (object replacement char) without imageURL + let text = (attributedString.string as NSString).substring(with: range) + if text == "\u{FFFC}" { return } - let desiredTags = tagsForAttributes(attrs) + let desiredTags = tagsForAttributes(attrs) - // Find longest matching prefix between active and desired tags - let commonPrefix = zip(activeTags, desiredTags).prefix(while: { $0 == $1 }).count + // Find longest matching prefix between active and desired tags + let commonPrefix = zip(activeTags, desiredTags).prefix(while: { $0 == $1 }).count - // Close everything after the common prefix (reverse order) - while activeTags.count > commonPrefix { - html += activeTags.removeLast().closeTag - } + // Close everything after the common prefix (reverse order) + while activeTags.count > commonPrefix { + html += activeTags.removeLast().closeTag + } - // Open new tags after the common prefix - for tag in desiredTags[commonPrefix...] { - html += tag.openTag - activeTags.append(tag) + // Open new tags after the common prefix + for tag in desiredTags[commonPrefix...] { + html += tag.openTag + activeTags.append(tag) + } + + // Append HTML-escaped text + html += escapeHTML(text) } - // Append HTML-escaped text - html += escapeHTML(text) - } + // Close remaining open tags + while let last = activeTags.popLast() { + html += last.closeTag + } - // Close remaining open tags - while let last = activeTags.popLast() { - html += last.closeTag + return html } - return html - } - - // MARK: - Tag Model - - private enum Tag: Equatable { - case bold - case italic - case strikethrough - case code - case codeBlock - case link(URL) - - var openTag: String { - switch self { - case .bold: return "" - case .italic: return "" - case .strikethrough: return "" - case .code: return "" - case .codeBlock: return "
"
-            case .link(let url): return ""
+        // MARK: - Tag Model
+
+        private enum Tag: Equatable {
+            case bold
+            case italic
+            case strikethrough
+            case code
+            case codeBlock
+            case link(URL)
+
+            var openTag: String {
+                switch self {
+                case .bold: return ""
+                case .italic: return ""
+                case .strikethrough: return ""
+                case .code: return ""
+                case .codeBlock: return "
"
+                case .link(let url): return ""
+                }
             }
-        }
 
-        var closeTag: String {
-            switch self {
-            case .bold: return ""
-            case .italic: return ""
-            case .strikethrough: return ""
-            case .code: return ""
-            case .codeBlock: return "
" - case .link: return "" + var closeTag: String { + switch self { + case .bold: return "" + case .italic: return "" + case .strikethrough: return "
" + case .code: return "" + case .codeBlock: return "
" + case .link: return "" + } } - } - static func == (lhs: Tag, rhs: Tag) -> Bool { - switch (lhs, rhs) { - case (.bold, .bold), (.italic, .italic), (.strikethrough, .strikethrough), - (.code, .code), (.codeBlock, .codeBlock): return true - case (.link(let a), .link(let b)): return a == b - default: return false + static func == (lhs: Tag, rhs: Tag) -> Bool { + switch (lhs, rhs) { + case (.bold, .bold), (.italic, .italic), (.strikethrough, .strikethrough), + (.code, .code), (.codeBlock, .codeBlock): + return true + case (.link(let a), .link(let b)): return a == b + default: return false + } } } - } - // MARK: - Helpers + // MARK: - Helpers - private static func tagsForAttributes(_ attrs: [NSAttributedString.Key: Any]) -> [Tag] { - var tags: [Tag] = [] + private static func tagsForAttributes(_ attrs: [NSAttributedString.Key: Any]) -> [Tag] { + var tags: [Tag] = [] - if let font = attrs[.font] as? UIFont { - let traits = font.fontDescriptor.symbolicTraits - if traits.contains(.traitMonoSpace) { - if attrs[.codeBlock] as? Bool == true { - tags.append(.codeBlock) + if let font = attrs[.font] as? UIFont { + let traits = font.fontDescriptor.symbolicTraits + if traits.contains(.traitMonoSpace) { + if attrs[.codeBlock] as? Bool == true { + tags.append(.codeBlock) + } else { + tags.append(.code) + } } else { - tags.append(.code) + if traits.contains(.traitBold) { tags.append(.bold) } + if traits.contains(.traitItalic) { tags.append(.italic) } } - } else { - if traits.contains(.traitBold) { tags.append(.bold) } - if traits.contains(.traitItalic) { tags.append(.italic) } } - } - if let strikeStyle = attrs[.strikethroughStyle] as? Int, strikeStyle == NSUnderlineStyle.single.rawValue { - tags.append(.strikethrough) - } + if let strikeStyle = attrs[.strikethroughStyle] as? Int, strikeStyle == NSUnderlineStyle.single.rawValue { + tags.append(.strikethrough) + } - if let url = attrs[.link] as? URL { - tags.append(.link(url)) - } else if let urlString = attrs[.link] as? String, let url = URL(string: urlString) { - tags.append(.link(url)) - } + if let url = attrs[.link] as? URL { + tags.append(.link(url)) + } else if let urlString = attrs[.link] as? String, let url = URL(string: urlString) { + tags.append(.link(url)) + } - return tags - } + return tags + } - private static func escapeHTML(_ string: String) -> String { - string - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "\"", with: """) + private static func escapeHTML(_ string: String) -> String { + string + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } } -} #endif diff --git a/Sources/FastCommentsUI/Utilities/DemoBannerModifier.swift b/Sources/FastCommentsUI/Utilities/DemoBannerModifier.swift index 761bd2c..0e91de7 100644 --- a/Sources/FastCommentsUI/Utilities/DemoBannerModifier.swift +++ b/Sources/FastCommentsUI/Utilities/DemoBannerModifier.swift @@ -84,7 +84,8 @@ struct DemoBannerModifier: ViewModifier { let match = regex.firstMatch(in: html, range: NSRange(location: 0, length: nsHTML.length)) if let match = match, - match.numberOfRanges >= 3 { + match.numberOfRanges >= 3 + { let href = nsHTML.substring(with: match.range(at: 1)) let linkText = nsHTML.substring(with: match.range(at: 2)) let url = URL(string: href) diff --git a/Sources/FastCommentsUI/Utilities/PlatformAliases.swift b/Sources/FastCommentsUI/Utilities/PlatformAliases.swift index 417978c..76ade91 100644 --- a/Sources/FastCommentsUI/Utilities/PlatformAliases.swift +++ b/Sources/FastCommentsUI/Utilities/PlatformAliases.swift @@ -1,6 +1,6 @@ #if canImport(UIKit) -import UIKit + import UIKit #elseif canImport(AppKit) -import AppKit -typealias UIImage = NSImage + import AppKit + typealias UIImage = NSImage #endif diff --git a/Sources/FastCommentsUI/Views/AnimatedImageView.swift b/Sources/FastCommentsUI/Views/AnimatedImageView.swift index be7f30b..38f40c3 100644 --- a/Sources/FastCommentsUI/Views/AnimatedImageView.swift +++ b/Sources/FastCommentsUI/Views/AnimatedImageView.swift @@ -1,201 +1,203 @@ #if canImport(UIKit) -import SwiftUI -import UIKit - -/// Shared image cache for all feed images (GIF and static). -/// Uses cost-based eviction so large decoded GIFs don't crowd out static images. -final class FeedImageCache { - static let shared: NSCache = { - let cache = NSCache() - cache.countLimit = 200 - cache.totalCostLimit = 50 * 1024 * 1024 // ~50 MB - return cache - }() - - /// Approximate byte cost of a UIImage for cache accounting. - static func cost(of image: UIImage) -> Int { - guard let cgImage = image.cgImage else { - // Animated images: estimate from frame count - if let images = image.images { - return images.reduce(0) { $0 + (cost(of: $1)) } + import SwiftUI + import UIKit + + /// Shared image cache for all feed images (GIF and static). + /// Uses cost-based eviction so large decoded GIFs don't crowd out static images. + final class FeedImageCache { + static let shared: NSCache = { + let cache = NSCache() + cache.countLimit = 200 + cache.totalCostLimit = 50 * 1024 * 1024 // ~50 MB + return cache + }() + + /// Approximate byte cost of a UIImage for cache accounting. + static func cost(of image: UIImage) -> Int { + guard let cgImage = image.cgImage else { + // Animated images: estimate from frame count + if let images = image.images { + return images.reduce(0) { $0 + (cost(of: $1)) } + } + return 0 } - return 0 + return cgImage.bytesPerRow * cgImage.height } - return cgImage.bytesPerRow * cgImage.height - } -} - -/// Displays an image from a URL, with animated GIF support. -/// Uses UIImageView for GIFs (which natively animates all frames) -/// and CachedStaticImageView for static images (backed by FeedImageCache). -struct SmartImage: View { - let url: URL - var contentMode: ContentMode = .fill - - var body: some View { - if url.pathExtension.lowercased() == "gif" { - AnimatedImageView(url: url, contentMode: contentMode) - } else { - CachedStaticImageView(url: url, contentMode: contentMode) - } - } -} - -/// UIViewRepresentable wrapper around UIImageView for animated GIF display. -private struct AnimatedImageView: UIViewRepresentable { - let url: URL - var contentMode: ContentMode = .fill - - func makeUIView(context: Context) -> UIImageView { - let imageView = UIImageView() - imageView.contentMode = contentMode == .fill ? .scaleAspectFill : .scaleAspectFit - imageView.clipsToBounds = true - imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - return imageView } - func updateUIView(_ imageView: UIImageView, context: Context) { - guard context.coordinator.loadedURL != url else { return } - context.coordinator.loadedURL = url + /// Displays an image from a URL, with animated GIF support. + /// Uses UIImageView for GIFs (which natively animates all frames) + /// and CachedStaticImageView for static images (backed by FeedImageCache). + struct SmartImage: View { + let url: URL + var contentMode: ContentMode = .fill - let key = url.absoluteString as NSString + var body: some View { + if url.pathExtension.lowercased() == "gif" { + AnimatedImageView(url: url, contentMode: contentMode) + } else { + CachedStaticImageView(url: url, contentMode: contentMode) + } + } + } - if let cached = FeedImageCache.shared.object(forKey: key) { - imageView.image = cached - return + /// UIViewRepresentable wrapper around UIImageView for animated GIF display. + private struct AnimatedImageView: UIViewRepresentable { + let url: URL + var contentMode: ContentMode = .fill + + func makeUIView(context: Context) -> UIImageView { + let imageView = UIImageView() + imageView.contentMode = contentMode == .fill ? .scaleAspectFill : .scaleAspectFit + imageView.clipsToBounds = true + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + return imageView } - imageView.image = nil + func updateUIView(_ imageView: UIImageView, context: Context) { + guard context.coordinator.loadedURL != url else { return } + context.coordinator.loadedURL = url + + let key = url.absoluteString as NSString - URLSession.shared.dataTask(with: url) { [weak imageView] data, _, _ in - guard let data else { return } - guard let image = UIImage.animatedImage(with: data) else { return } - FeedImageCache.shared.setObject(image, forKey: key, cost: FeedImageCache.cost(of: image)) - DispatchQueue.main.async { [weak imageView] in - imageView?.image = image + if let cached = FeedImageCache.shared.object(forKey: key) { + imageView.image = cached + return } - }.resume() - } - func makeCoordinator() -> Coordinator { - Coordinator() - } + imageView.image = nil - class Coordinator { - var loadedURL: URL? - } -} - -/// UIViewRepresentable wrapper for cached static image display. -/// Shares FeedImageCache with AnimatedImageView so images survive view re-renders. -private struct CachedStaticImageView: UIViewRepresentable { - let url: URL - var contentMode: ContentMode = .fill - - func makeUIView(context: Context) -> UIImageView { - let imageView = UIImageView() - imageView.contentMode = contentMode == .fill ? .scaleAspectFill : .scaleAspectFit - imageView.clipsToBounds = true - imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - return imageView - } + URLSession.shared.dataTask(with: url) { [weak imageView] data, _, _ in + guard let data else { return } + guard let image = UIImage.animatedImage(with: data) else { return } + FeedImageCache.shared.setObject(image, forKey: key, cost: FeedImageCache.cost(of: image)) + DispatchQueue.main.async { [weak imageView] in + imageView?.image = image + } + }.resume() + } - func updateUIView(_ imageView: UIImageView, context: Context) { - guard context.coordinator.loadedURL != url else { return } - context.coordinator.loadedURL = url + func makeCoordinator() -> Coordinator { + Coordinator() + } - let key = url.absoluteString as NSString + class Coordinator { + var loadedURL: URL? + } + } - if let cached = FeedImageCache.shared.object(forKey: key) { - imageView.image = cached - return + /// UIViewRepresentable wrapper for cached static image display. + /// Shares FeedImageCache with AnimatedImageView so images survive view re-renders. + private struct CachedStaticImageView: UIViewRepresentable { + let url: URL + var contentMode: ContentMode = .fill + + func makeUIView(context: Context) -> UIImageView { + let imageView = UIImageView() + imageView.contentMode = contentMode == .fill ? .scaleAspectFill : .scaleAspectFit + imageView.clipsToBounds = true + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + return imageView } - imageView.image = nil + func updateUIView(_ imageView: UIImageView, context: Context) { + guard context.coordinator.loadedURL != url else { return } + context.coordinator.loadedURL = url - URLSession.shared.dataTask(with: url) { [weak imageView] data, _, _ in - guard let data, let image = UIImage(data: data) else { return } - FeedImageCache.shared.setObject(image, forKey: key, cost: FeedImageCache.cost(of: image)) - DispatchQueue.main.async { [weak imageView] in - imageView?.image = image - } - }.resume() - } + let key = url.absoluteString as NSString - func makeCoordinator() -> Coordinator { - Coordinator() - } + if let cached = FeedImageCache.shared.object(forKey: key) { + imageView.image = cached + return + } - class Coordinator { - var loadedURL: URL? - } -} + imageView.image = nil -// MARK: - UIImage GIF helper + URLSession.shared.dataTask(with: url) { [weak imageView] data, _, _ in + guard let data, let image = UIImage(data: data) else { return } + FeedImageCache.shared.setObject(image, forKey: key, cost: FeedImageCache.cost(of: image)) + DispatchQueue.main.async { [weak imageView] in + imageView?.image = image + } + }.resume() + } -private extension UIImage { - /// Create an animated UIImage from GIF data. Falls back to static image. - static func animatedImage(with data: Data) -> UIImage? { - guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { - return UIImage(data: data) + func makeCoordinator() -> Coordinator { + Coordinator() } - let count = CGImageSourceGetCount(source) - guard count > 1 else { - return UIImage(data: data) + class Coordinator { + var loadedURL: URL? } + } - var images: [UIImage] = [] - var totalDuration: TimeInterval = 0 + // MARK: - UIImage GIF helper - for i in 0.. UIImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return UIImage(data: data) + } - // Get frame duration - if let properties = CGImageSourceCopyPropertiesAtIndex(source, i, nil) as? [String: Any], - let gifDict = properties[kCGImagePropertyGIFDictionary as String] as? [String: Any] { - let delay = (gifDict[kCGImagePropertyGIFUnclampedDelayTime as String] as? Double) - ?? (gifDict[kCGImagePropertyGIFDelayTime as String] as? Double) - ?? 0.1 - totalDuration += max(delay, 0.02) - } else { - totalDuration += 0.1 + let count = CGImageSourceGetCount(source) + guard count > 1 else { + return UIImage(data: data) } - } - return UIImage.animatedImage(with: images, duration: totalDuration) + var images: [UIImage] = [] + var totalDuration: TimeInterval = 0 + + for i in 0..\(url)" - : "\(label)" - text += linkHtml - } + AddLinkSheet { url, label in + let linkHtml = + label.isEmpty + ? "\(url)" + : "\(label)" + text += linkHtml + } #endif } } @@ -356,31 +361,31 @@ public struct CommentInputBar: View { /// Computed Binding for custom toolbar buttons that still operate on strings. private var plainTextBinding: Binding { #if os(iOS) - Binding( - get: { attributedText.string }, - set: { newValue in - let oldValue = attributedText.string - guard newValue != oldValue else { return } - - let defaultAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.preferredFont(forTextStyle: .subheadline), - .foregroundColor: UIColor.label - ] - - if newValue.hasPrefix(oldValue) { - // Pure append - let appended = String(newValue.dropFirst(oldValue.count)) - let mutable = NSMutableAttributedString(attributedString: attributedText) - mutable.append(NSAttributedString(string: appended, attributes: defaultAttrs)) - attributedText = mutable - } else { - // Full replacement or complex edit - attributedText = NSAttributedString(string: newValue, attributes: defaultAttrs) + Binding( + get: { attributedText.string }, + set: { newValue in + let oldValue = attributedText.string + guard newValue != oldValue else { return } + + let defaultAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.preferredFont(forTextStyle: .subheadline), + .foregroundColor: UIColor.label, + ] + + if newValue.hasPrefix(oldValue) { + // Pure append + let appended = String(newValue.dropFirst(oldValue.count)) + let mutable = NSMutableAttributedString(attributedString: attributedText) + mutable.append(NSAttributedString(string: appended, attributes: defaultAttrs)) + attributedText = mutable + } else { + // Full replacement or complex edit + attributedText = NSAttributedString(string: newValue, attributes: defaultAttrs) + } } - } - ) + ) #else - $text + $text #endif } @@ -388,9 +393,9 @@ public struct CommentInputBar: View { private var inputFieldBackground: Color { #if os(iOS) - Color(uiColor: .tertiarySystemFill) + Color(uiColor: .tertiarySystemFill) #else - Color(nsColor: .controlBackgroundColor) + Color(nsColor: .controlBackgroundColor) #endif } @@ -398,16 +403,16 @@ public struct CommentInputBar: View { private static func friendlyErrorMessage(from error: Error) -> String { #if os(iOS) - if case let ErrorResponse.error(_, data, _, _) = error, let data = data { - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - if let translated = json["translatedError"] as? String, !translated.isEmpty { - return translated - } - if let reason = json["reason"] as? String, !reason.isEmpty { - return reason + if case let ErrorResponse.error(_, data, _, _) = error, let data = data { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let translated = json["translatedError"] as? String, !translated.isEmpty { + return translated + } + if let reason = json["reason"] as? String, !reason.isEmpty { + return reason + } } } - } #endif return NSLocalizedString("comment_post_failed", bundle: .module, comment: "") } @@ -415,50 +420,58 @@ public struct CommentInputBar: View { // MARK: - Image Upload #if os(iOS) - private func handleImagePicked(_ item: PhotosPickerItem) async { - isUploadingImage = true - defer { isUploadingImage = false } - - do { - guard let file = try await item.loadTransferable(type: ImageFileTransferable.self) else { return } - - let imageUrl = try await sdk.uploadImage(fileURL: file.url) - defer { try? FileManager.default.removeItem(at: file.url) } - - // Generate a thumbnail for WYSIWYG display from the file on disk - guard let source = CGImageSourceCreateWithURL(file.url as CFURL, nil), - let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, [ - kCGImageSourceThumbnailMaxPixelSize: 300, - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - ] as CFDictionary) else { return } - let thumb = UIImage(cgImage: cgImage) + private func handleImagePicked(_ item: PhotosPickerItem) async { + isUploadingImage = true + defer { isUploadingImage = false } - // Insert image into the editor - let mutable = NSMutableAttributedString(attributedString: attributedText) - - let thumbWidth: CGFloat = 150 - let thumbHeight = thumbWidth * thumb.size.height / thumb.size.width - let attachment = NSTextAttachment() - attachment.image = thumb - attachment.bounds = CGRect(x: 0, y: 0, width: thumbWidth, height: thumbHeight) - - let attachmentString = NSMutableAttributedString(attachment: attachment) - // Store the URL so the HTML converter can find it - attachmentString.addAttribute(.link, value: URL(string: imageUrl)!, range: NSRange(location: 0, length: attachmentString.length)) - attachmentString.addAttribute(.imageURL, value: imageUrl, range: NSRange(location: 0, length: attachmentString.length)) - - mutable.append(NSAttributedString(string: "\n")) - mutable.append(attachmentString) - mutable.append(NSAttributedString(string: "\n", attributes: [ - .font: UIFont.preferredFont(forTextStyle: .subheadline), - .foregroundColor: UIColor.label - ])) - attributedText = mutable - } catch { - errorMessage = Self.friendlyErrorMessage(from: error) + do { + guard let file = try await item.loadTransferable(type: ImageFileTransferable.self) else { return } + + let imageUrl = try await sdk.uploadImage(fileURL: file.url) + defer { try? FileManager.default.removeItem(at: file.url) } + + // Generate a thumbnail for WYSIWYG display from the file on disk + guard let source = CGImageSourceCreateWithURL(file.url as CFURL, nil), + let cgImage = CGImageSourceCreateThumbnailAtIndex( + source, 0, + [ + kCGImageSourceThumbnailMaxPixelSize: 300, + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + ] as CFDictionary) + else { return } + let thumb = UIImage(cgImage: cgImage) + + // Insert image into the editor + let mutable = NSMutableAttributedString(attributedString: attributedText) + + let thumbWidth: CGFloat = 150 + let thumbHeight = thumbWidth * thumb.size.height / thumb.size.width + let attachment = NSTextAttachment() + attachment.image = thumb + attachment.bounds = CGRect(x: 0, y: 0, width: thumbWidth, height: thumbHeight) + + let attachmentString = NSMutableAttributedString(attachment: attachment) + // Store the URL so the HTML converter can find it + attachmentString.addAttribute( + .link, value: URL(string: imageUrl)!, range: NSRange(location: 0, length: attachmentString.length)) + attachmentString.addAttribute( + .imageURL, value: imageUrl, range: NSRange(location: 0, length: attachmentString.length)) + + mutable.append(NSAttributedString(string: "\n")) + mutable.append(attachmentString) + mutable.append( + NSAttributedString( + string: "\n", + attributes: [ + .font: UIFont.preferredFont(forTextStyle: .subheadline), + .foregroundColor: UIColor.label, + ])) + attributedText = mutable + } catch { + errorMessage = Self.friendlyErrorMessage(from: error) + } } - } #endif // MARK: - Submit @@ -470,9 +483,9 @@ public struct CommentInputBar: View { errorMessage = nil #if os(iOS) - let htmlText = AttributedStringHTMLConverter.convert(attributedText) + let htmlText = AttributedStringHTMLConverter.convert(attributedText) #else - let htmlText = text + let htmlText = text #endif do { @@ -482,14 +495,14 @@ public struct CommentInputBar: View { mentions: selectedMentions.isEmpty ? nil : selectedMentions ) #if os(iOS) - attributedText = NSAttributedString() - // Reset typing attributes to plain style - editorContext.textView?.typingAttributes = [ - .font: UIFont.preferredFont(forTextStyle: .subheadline), - .foregroundColor: UIColor.label - ] + attributedText = NSAttributedString() + // Reset typing attributes to plain style + editorContext.textView?.typingAttributes = [ + .font: UIFont.preferredFont(forTextStyle: .subheadline), + .foregroundColor: UIColor.label, + ] #else - text = "" + text = "" #endif replyingTo = nil selectedMentions.removeAll() @@ -534,25 +547,25 @@ public struct CommentInputBar: View { let displayName = user.displayName ?? user.name #if os(iOS) - let currentText = attributedText.string - guard let lastAtIndex = currentText.lastIndex(of: "@") else { return } - - let atPosition = currentText.distance(from: currentText.startIndex, to: lastAtIndex) - let replaceRange = NSRange(location: atPosition, length: currentText.count - atPosition) - - let mentionAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.preferredFont(forTextStyle: .subheadline).withTraits(.traitBold), - .foregroundColor: UIColor.tintColor, - .mentionUserId: user.id - ] - let mentionText = NSAttributedString(string: "@\(displayName) ", attributes: mentionAttrs) - - let mutable = NSMutableAttributedString(attributedString: attributedText) - mutable.replaceCharacters(in: replaceRange, with: mentionText) - attributedText = mutable + let currentText = attributedText.string + guard let lastAtIndex = currentText.lastIndex(of: "@") else { return } + + let atPosition = currentText.distance(from: currentText.startIndex, to: lastAtIndex) + let replaceRange = NSRange(location: atPosition, length: currentText.count - atPosition) + + let mentionAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.preferredFont(forTextStyle: .subheadline).withTraits(.traitBold), + .foregroundColor: UIColor.tintColor, + .mentionUserId: user.id, + ] + let mentionText = NSAttributedString(string: "@\(displayName) ", attributes: mentionAttrs) + + let mutable = NSMutableAttributedString(attributedString: attributedText) + mutable.replaceCharacters(in: replaceRange, with: mentionText) + attributedText = mutable #else - guard let lastAtIndex = text.lastIndex(of: "@") else { return } - text = String(text[.. UIFont { - guard let descriptor = fontDescriptor.withSymbolicTraits(fontDescriptor.symbolicTraits.union(traits)) else { - return self + private extension UIFont { + func withTraits(_ traits: UIFontDescriptor.SymbolicTraits) -> UIFont { + guard let descriptor = fontDescriptor.withSymbolicTraits(fontDescriptor.symbolicTraits.union(traits)) else { + return self + } + return UIFont(descriptor: descriptor, size: pointSize) } - return UIFont(descriptor: descriptor, size: pointSize) } -} #endif diff --git a/Sources/FastCommentsUI/Views/CommentRowView.swift b/Sources/FastCommentsUI/Views/CommentRowView.swift index f3f5124..1d1bea8 100644 --- a/Sources/FastCommentsUI/Views/CommentRowView.swift +++ b/Sources/FastCommentsUI/Views/CommentRowView.swift @@ -87,10 +87,12 @@ public struct CommentRowView: View { Button { onUserClick?(.comment(comment.comment), UserInfo.from(comment.comment), .name) } label: { - Text(comment.comment.isBlocked == true - ? NSLocalizedString("blocked_user", bundle: .module, comment: "") - : comment.comment.commenterName) - .font(theme.resolveCommenterNameFont()) + Text( + comment.comment.isBlocked == true + ? NSLocalizedString("blocked_user", bundle: .module, comment: "") + : comment.comment.commenterName + ) + .font(theme.resolveCommenterNameFont()) } .buttonStyle(.plain) .allowsHitTesting(!isBlocked) @@ -123,9 +125,9 @@ public struct CommentRowView: View { .padding(.horizontal, 6) .padding(.vertical, 2) #if os(iOS) - .background(Color(uiColor: .systemGray6)) + .background(Color(uiColor: .systemGray6)) #else - .background(Color(nsColor: .controlBackgroundColor)) + .background(Color(nsColor: .controlBackgroundColor)) #endif .foregroundStyle(.secondary) .clipShape(Capsule()) @@ -144,17 +146,23 @@ public struct CommentRowView: View { // Context menu Menu { if canEdit { - Button { onEdit?(comment) } label: { + Button { + onEdit?(comment) + } label: { Label(NSLocalizedString("edit", bundle: .module, comment: ""), systemImage: "pencil") } } if canDelete { - Button(role: .destructive) { onDelete?(comment) } label: { + Button(role: .destructive) { + onDelete?(comment) + } label: { Label(NSLocalizedString("delete", bundle: .module, comment: ""), systemImage: "trash") } } if sdk.isSiteAdmin { - Button { onPin?(comment) } label: { + Button { + onPin?(comment) + } label: { Label( comment.comment.isPinned == true ? NSLocalizedString("unpin", bundle: .module, comment: "") @@ -162,7 +170,9 @@ public struct CommentRowView: View { systemImage: comment.comment.isPinned == true ? "pin.slash" : "pin" ) } - Button { onLock?(comment) } label: { + Button { + onLock?(comment) + } label: { Label( comment.comment.isLocked == true ? NSLocalizedString("unlock", bundle: .module, comment: "") @@ -172,7 +182,9 @@ public struct CommentRowView: View { } } if !isOwnComment { - Button { onBlock?(comment) } label: { + Button { + onBlock?(comment) + } label: { Label( comment.comment.isBlocked == true ? NSLocalizedString("unblock_user", bundle: .module, comment: "") @@ -182,7 +194,9 @@ public struct CommentRowView: View { } } if !isOwnComment && !isBlocked { - Button { onFlag?(comment) } label: { + Button { + onFlag?(comment) + } label: { Label( comment.comment.isFlagged == true ? NSLocalizedString("unflag", bundle: .module, comment: "") @@ -280,9 +294,12 @@ public struct CommentRowView: View { Image(systemName: comment.isRepliesShown ? "chevron.up" : "chevron.down") .font(.system(size: 10, weight: .semibold)) } - Text(comment.isRepliesShown - ? NSLocalizedString("hide_replies", bundle: .module, comment: "") - : String(format: NSLocalizedString("show_replies_%lld", bundle: .module, comment: ""), childCount) + Text( + comment.isRepliesShown + ? NSLocalizedString("hide_replies", bundle: .module, comment: "") + : String( + format: NSLocalizedString("show_replies_%lld", bundle: .module, comment: ""), + childCount) ) } .font(theme.resolveActionFont()) diff --git a/Sources/FastCommentsUI/Views/FastCommentsFeedView.swift b/Sources/FastCommentsUI/Views/FastCommentsFeedView.swift index b653f9b..d423795 100644 --- a/Sources/FastCommentsUI/Views/FastCommentsFeedView.swift +++ b/Sources/FastCommentsUI/Views/FastCommentsFeedView.swift @@ -141,7 +141,9 @@ public struct FastCommentsFeedView: View { return copy } - public func onUserClick(_ handler: @escaping (UserClickContext, UserInfo, UserClickSource) -> Void) -> FastCommentsFeedView { + public func onUserClick( + _ handler: @escaping (UserClickContext, UserInfo, UserClickSource) -> Void + ) -> FastCommentsFeedView { var copy = self copy.onUserClick = handler return copy diff --git a/Sources/FastCommentsUI/Views/FastCommentsView.swift b/Sources/FastCommentsUI/Views/FastCommentsView.swift index 97c96c8..79b6579 100644 --- a/Sources/FastCommentsUI/Views/FastCommentsView.swift +++ b/Sources/FastCommentsUI/Views/FastCommentsView.swift @@ -17,7 +17,8 @@ public struct FastCommentsView: View { @State private var showDeleteAlert: RenderableComment? @State private var showBlockAlert: RenderableComment? - public init(sdk: FastCommentsSDK, voteStyle: VoteStyle = ._0, customToolbarButtons: [any CustomToolbarButton] = []) { + public init(sdk: FastCommentsSDK, voteStyle: VoteStyle = ._0, customToolbarButtons: [any CustomToolbarButton] = []) + { self.sdk = sdk self.voteStyle = voteStyle self.customToolbarButtons = customToolbarButtons @@ -168,18 +169,21 @@ public struct FastCommentsView: View { } } message: { if let comment = showBlockAlert { - Text(String( - format: comment.comment.isBlocked == true - ? NSLocalizedString("unblock_user_confirm", bundle: .module, comment: "") - : NSLocalizedString("block_user_confirm", bundle: .module, comment: ""), - comment.comment.commenterName - )) + Text( + String( + format: comment.comment.isBlocked == true + ? NSLocalizedString("unblock_user_confirm", bundle: .module, comment: "") + : NSLocalizedString("block_user_confirm", bundle: .module, comment: ""), + comment.comment.commenterName + )) } } - .sheet(isPresented: Binding( - get: { sdk.badgeAwardToShow != nil }, - set: { if !$0 { sdk.badgeAwardToShow = nil } } - )) { + .sheet( + isPresented: Binding( + get: { sdk.badgeAwardToShow != nil }, + set: { if !$0 { sdk.badgeAwardToShow = nil } } + ) + ) { if let badges = sdk.badgeAwardToShow { BadgeAwardSheet(badges: badges) } @@ -275,7 +279,9 @@ public struct FastCommentsView: View { extension FastCommentsView { /// Handle user avatar/name taps. - public func onUserClick(_ handler: @escaping (UserClickContext, UserInfo, UserClickSource) -> Void) -> FastCommentsView { + public func onUserClick( + _ handler: @escaping (UserClickContext, UserInfo, UserClickSource) -> Void + ) -> FastCommentsView { var copy = self copy.onUserClick = handler return copy diff --git a/Sources/FastCommentsUI/Views/FeedPostCreateView.swift b/Sources/FastCommentsUI/Views/FeedPostCreateView.swift index d4e93cb..b4a4386 100644 --- a/Sources/FastCommentsUI/Views/FeedPostCreateView.swift +++ b/Sources/FastCommentsUI/Views/FeedPostCreateView.swift @@ -2,9 +2,9 @@ import SwiftUI import PhotosUI import FastCommentsSwift #if canImport(UIKit) -import UIKit + import UIKit #elseif canImport(AppKit) -import AppKit + import AppKit #endif /// View for creating a new feed post with text, images, and links. @@ -27,8 +27,10 @@ public struct FeedPostCreateView: View { private let maxImages = 10 - public init(sdk: FastCommentsFeedSDK, customToolbarButtons: [any FeedCustomToolbarButton] = [], - onPostCreated: ((FeedPost) -> Void)? = nil, onCancelled: (() -> Void)? = nil) { + public init( + sdk: FastCommentsFeedSDK, customToolbarButtons: [any FeedCustomToolbarButton] = [], + onPostCreated: ((FeedPost) -> Void)? = nil, onCancelled: (() -> Void)? = nil + ) { self.sdk = sdk self.customToolbarButtons = customToolbarButtons self.onPostCreated = onPostCreated @@ -135,7 +137,7 @@ public struct FeedPostCreateView: View { } .navigationTitle(NSLocalizedString("create_post", bundle: .module, comment: "")) #if os(iOS) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -189,9 +191,9 @@ public struct FeedPostCreateView: View { ] guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } #if canImport(UIKit) - return UIImage(cgImage: cgImage) + return UIImage(cgImage: cgImage) #else - return UIImage(cgImage: cgImage, size: .zero) + return UIImage(cgImage: cgImage, size: .zero) #endif } diff --git a/Sources/FastCommentsUI/Views/FeedPostRowView.swift b/Sources/FastCommentsUI/Views/FeedPostRowView.swift index 77509ba..41c42da 100644 --- a/Sources/FastCommentsUI/Views/FeedPostRowView.swift +++ b/Sources/FastCommentsUI/Views/FeedPostRowView.swift @@ -59,7 +59,9 @@ public struct FeedPostRowView: View { // Delete menu if post.fromUserId == sdk.currentUser?.id { Menu { - Button(role: .destructive) { onDelete?(post) } label: { + Button(role: .destructive) { + onDelete?(post) + } label: { Label(NSLocalizedString("delete", bundle: .module, comment: ""), systemImage: "trash") } } label: { @@ -84,7 +86,8 @@ public struct FeedPostRowView: View { switch postType { case .singleImage: if let media = post.media, let item = media.first, - let asset = item.sizes.first, let url = URL(string: asset.src) { + let asset = item.sizes.first, let url = URL(string: asset.src) + { SmartImage(url: url, contentMode: .fill) .frame(maxWidth: .infinity) .frame(height: theme.feedMediaHeight) @@ -107,7 +110,7 @@ public struct FeedPostRowView: View { case .task: let taskMedia: (item: FeedPostMediaItem, list: [FeedPostMediaItem], url: URL)? = { guard let list = post.media, let item = list.first, - let asset = item.sizes.first, let url = URL(string: asset.src) + let asset = item.sizes.first, let url = URL(string: asset.src) else { return nil } return (item, list, url) }() @@ -181,13 +184,13 @@ public struct FeedPostRowView: View { .contentShape(Rectangle()) .onTapGesture { onPostClick?(post) } #if os(iOS) - .fullScreenCover(item: $fullImagePresentation) { presentation in - FullImageSheet(presentation: presentation) - } + .fullScreenCover(item: $fullImagePresentation) { presentation in + FullImageSheet(presentation: presentation) + } #else - .sheet(item: $fullImagePresentation) { presentation in - FullImageSheet(presentation: presentation) - } + .sheet(item: $fullImagePresentation) { presentation in + FullImageSheet(presentation: presentation) + } #endif } @@ -203,7 +206,9 @@ public struct FeedPostRowView: View { // MARK: - Action Button - private func actionButton(icon: String, count: Int? = nil, tint: Color? = nil, action: @escaping () -> Void) -> some View { + private func actionButton( + icon: String, count: Int? = nil, tint: Color? = nil, action: @escaping () -> Void + ) -> some View { Button(action: action) { HStack(spacing: 4) { Image(systemName: icon) @@ -226,9 +231,9 @@ public struct FeedPostRowView: View { private var imagePlaceholder: some View { Rectangle() #if os(iOS) - .fill(Color(uiColor: .systemGray6)) + .fill(Color(uiColor: .systemGray6)) #else - .fill(Color(nsColor: .quaternaryLabelColor)) + .fill(Color(nsColor: .quaternaryLabelColor)) #endif .overlay( Image(systemName: "photo") diff --git a/Sources/FastCommentsUI/Views/FollowButton.swift b/Sources/FastCommentsUI/Views/FollowButton.swift index ed5a2cc..f40956e 100644 --- a/Sources/FastCommentsUI/Views/FollowButton.swift +++ b/Sources/FastCommentsUI/Views/FollowButton.swift @@ -30,7 +30,8 @@ public struct FollowButton: View { public var body: some View { if let provider = sdk.followStateProvider, - Self.shouldShow(hasProvider: true, currentUserId: sdk.currentUser?.id, post: post) { + Self.shouldShow(hasProvider: true, currentUserId: sdk.currentUser?.id, post: post) + { let user = UserInfo.from(post) // `@ObservedObject var sdk` already subscribes this view to every // @Published on the SDK, including `followStateRevision` — no @@ -58,7 +59,8 @@ public struct FollowButton: View { @ViewBuilder private func label(text isFollowing: Bool, actionColor: Color) -> some View { - let title = isFollowing + let title = + isFollowing ? NSLocalizedString("following", bundle: .module, comment: "Follow button — already following") : NSLocalizedString("follow", bundle: .module, comment: "Follow button — not yet following") @@ -157,4 +159,3 @@ final class FollowButtonState: ObservableObject { } } } - diff --git a/Sources/FastCommentsUI/Views/HTMLContentView.swift b/Sources/FastCommentsUI/Views/HTMLContentView.swift index 103633c..bc416f3 100644 --- a/Sources/FastCommentsUI/Views/HTMLContentView.swift +++ b/Sources/FastCommentsUI/Views/HTMLContentView.swift @@ -1,8 +1,8 @@ import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #elseif canImport(AppKit) -import AppKit + import AppKit #endif /// Renders HTML content as styled text with support for inline images. @@ -82,13 +82,15 @@ public struct HTMLContentView: View { private func styledText(_ attributedString: AttributedString) -> some View { Text(attributedString) - .environment(\.openURL, OpenURLAction { url in - if let linkHandler = linkHandler { - linkHandler(url) - return .handled - } - return .systemAction - }) + .environment( + \.openURL, + OpenURLAction { url in + if let linkHandler = linkHandler { + linkHandler(url) + return .handled + } + return .systemAction + }) } // MARK: - Cache @@ -108,16 +110,17 @@ public struct HTMLContentView: View { /// Color.description is not stable across iOS versions or appearance changes. private static func stableCacheKey(for color: Color) -> String { #if canImport(UIKit) - let uiColor = UIColor(color) - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - uiColor.getRed(&r, green: &g, blue: &b, alpha: &a) - return String(format: "%.4f,%.4f,%.4f,%.4f", r, g, b, a) + let uiColor = UIColor(color) + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + uiColor.getRed(&r, green: &g, blue: &b, alpha: &a) + return String(format: "%.4f,%.4f,%.4f,%.4f", r, g, b, a) #elseif canImport(AppKit) - let nsColor = NSColor(color) - let converted = nsColor.usingColorSpace(.sRGB) ?? nsColor - return String(format: "%.4f,%.4f,%.4f,%.4f", - converted.redComponent, converted.greenComponent, - converted.blueComponent, converted.alphaComponent) + let nsColor = NSColor(color) + let converted = nsColor.usingColorSpace(.sRGB) ?? nsColor + return String( + format: "%.4f,%.4f,%.4f,%.4f", + converted.redComponent, converted.greenComponent, + converted.blueComponent, converted.alphaComponent) #endif } @@ -214,13 +217,13 @@ public struct HTMLContentView: View { @MainActor private static func parseHTMLToAttributedString(_ html: String, linkColor: Color) -> AttributedString? { let fullHTML = """ - \(html) - """ + \(html) + """ guard let data = fullHTML.data(using: .utf8) else { return nil } @@ -229,14 +232,14 @@ public struct HTMLContentView: View { data: data, options: [ .documentType: NSAttributedString.DocumentType.html, - .characterEncoding: String.Encoding.utf8.rawValue + .characterEncoding: String.Encoding.utf8.rawValue, ], documentAttributes: nil ) #if os(iOS) - return try AttributedString(nsAttrString, including: \.uiKit) + return try AttributedString(nsAttrString, including: \.uiKit) #else - return try AttributedString(nsAttrString, including: \.appKit) + return try AttributedString(nsAttrString, including: \.appKit) #endif } catch { return nil diff --git a/Sources/FastCommentsUI/Views/LiveChatView.swift b/Sources/FastCommentsUI/Views/LiveChatView.swift index 2a90332..8e78c13 100644 --- a/Sources/FastCommentsUI/Views/LiveChatView.swift +++ b/Sources/FastCommentsUI/Views/LiveChatView.swift @@ -119,12 +119,13 @@ public struct LiveChatView: View { } } message: { if let comment = showBlockAlert { - Text(String( - format: comment.comment.isBlocked == true - ? NSLocalizedString("unblock_user_confirm", bundle: .module, comment: "") - : NSLocalizedString("block_user_confirm", bundle: .module, comment: ""), - comment.comment.commenterName - )) + Text( + String( + format: comment.comment.isBlocked == true + ? NSLocalizedString("unblock_user_confirm", bundle: .module, comment: "") + : NSLocalizedString("block_user_confirm", bundle: .module, comment: ""), + comment.comment.commenterName + )) } } } @@ -220,7 +221,8 @@ extension LiveChatView { } /// Handle user avatar/name taps. - public func onUserClick(_ handler: @escaping (UserClickContext, UserInfo, UserClickSource) -> Void) -> LiveChatView { + public func onUserClick(_ handler: @escaping (UserClickContext, UserInfo, UserClickSource) -> Void) -> LiveChatView + { var copy = self copy.onUserClick = handler return copy diff --git a/Sources/FastCommentsUI/Views/MentionSuggestionsList.swift b/Sources/FastCommentsUI/Views/MentionSuggestionsList.swift index f700aff..95fab58 100644 --- a/Sources/FastCommentsUI/Views/MentionSuggestionsList.swift +++ b/Sources/FastCommentsUI/Views/MentionSuggestionsList.swift @@ -43,9 +43,9 @@ struct MentionSuggestionsList: View { } } #if os(iOS) - .background(Color(uiColor: .systemBackground)) + .background(Color(uiColor: .systemBackground)) #else - .background(Color(nsColor: .windowBackgroundColor)) + .background(Color(nsColor: .windowBackgroundColor)) #endif .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(0.12), radius: 12, y: 6) diff --git a/Sources/FastCommentsUI/Views/NewFeedPostsBanner.swift b/Sources/FastCommentsUI/Views/NewFeedPostsBanner.swift index ffeac97..20b0e09 100644 --- a/Sources/FastCommentsUI/Views/NewFeedPostsBanner.swift +++ b/Sources/FastCommentsUI/Views/NewFeedPostsBanner.swift @@ -15,7 +15,7 @@ struct NewFeedPostsBanner: View { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 14)) Text(String(localized: "show_new_posts_\(count)", bundle: .module)) - .font(.subheadline.weight(.medium)) + .font(.subheadline.weight(.medium)) } .foregroundStyle(theme.resolveLoadMoreButtonTextColor()) .padding(.vertical, 10) diff --git a/Sources/FastCommentsUI/Views/PaginationControls.swift b/Sources/FastCommentsUI/Views/PaginationControls.swift index 02ab339..b5c5fd8 100644 --- a/Sources/FastCommentsUI/Views/PaginationControls.swift +++ b/Sources/FastCommentsUI/Views/PaginationControls.swift @@ -28,10 +28,11 @@ public struct PaginationControls: View { ProgressView() .scaleEffect(0.7) } - Text(String( - format: NSLocalizedString("next_%lld", bundle: .module, comment: ""), - nextCount - )) + Text( + String( + format: NSLocalizedString("next_%lld", bundle: .module, comment: ""), + nextCount + )) } .font(.subheadline.weight(.medium)) .foregroundStyle(theme.resolveActionButtonColor()) @@ -51,10 +52,12 @@ public struct PaginationControls: View { isLoadingMore = false } } label: { - Text(String( - format: NSLocalizedString("load_all_%lld", bundle: .module, comment: ""), - sdk.commentCountOnServer - )) + Text( + String( + format: NSLocalizedString("load_all_%lld", bundle: .module, comment: ""), + sdk.commentCountOnServer + ) + ) .font(.subheadline.weight(.medium)) .foregroundStyle(theme.resolveActionButtonColor()) .padding(.horizontal, 16) diff --git a/Sources/FastCommentsUI/Views/PostImagesCarousel.swift b/Sources/FastCommentsUI/Views/PostImagesCarousel.swift index 2c4835b..5a019cd 100644 --- a/Sources/FastCommentsUI/Views/PostImagesCarousel.swift +++ b/Sources/FastCommentsUI/Views/PostImagesCarousel.swift @@ -25,7 +25,7 @@ public struct PostImagesCarousel: View { } } #if os(iOS) - .tabViewStyle(.page(indexDisplayMode: .automatic)) + .tabViewStyle(.page(indexDisplayMode: .automatic)) #endif .frame(height: theme.feedMediaHeight) @@ -46,9 +46,9 @@ public struct PostImagesCarousel: View { private var imagePlaceholder: some View { Rectangle() #if os(iOS) - .fill(Color(uiColor: .systemGray5)) + .fill(Color(uiColor: .systemGray5)) #else - .fill(Color(nsColor: .quaternaryLabelColor)) + .fill(Color(nsColor: .quaternaryLabelColor)) #endif .overlay( Image(systemName: "photo") diff --git a/Sources/FastCommentsUI/Views/RichTextEditor.swift b/Sources/FastCommentsUI/Views/RichTextEditor.swift index 6bdb394..607648b 100644 --- a/Sources/FastCommentsUI/Views/RichTextEditor.swift +++ b/Sources/FastCommentsUI/Views/RichTextEditor.swift @@ -1,383 +1,394 @@ #if canImport(UIKit) -import SwiftUI -import UIKit + import SwiftUI + import UIKit -private enum RichTextEditorLayout { - static let horizontalInset: CGFloat = 16 - static let verticalInset: CGFloat = 10 -} + private enum RichTextEditorLayout { + static let horizontalInset: CGFloat = 16 + static let verticalInset: CGFloat = 10 + } -// MARK: - RichTextEditorContext + // MARK: - RichTextEditorContext -/// Shared context between SwiftUI toolbar buttons and the UITextView. -@MainActor -public final class RichTextEditorContext: ObservableObject { - weak var textView: UITextView? - var onTextChange: ((NSAttributedString) -> Void)? - var onPlainTextChange: ((String) -> Void)? + /// Shared context between SwiftUI toolbar buttons and the UITextView. + @MainActor + public final class RichTextEditorContext: ObservableObject { + weak var textView: UITextView? + var onTextChange: ((NSAttributedString) -> Void)? + var onPlainTextChange: ((String) -> Void)? - // MARK: - Formatting + // MARK: - Formatting - func toggleBold() { - guard let textView else { return } - toggleTrait(.traitBold, on: textView) - } + func toggleBold() { + guard let textView else { return } + toggleTrait(.traitBold, on: textView) + } - func toggleItalic() { - guard let textView else { return } - toggleTrait(.traitItalic, on: textView) - } + func toggleItalic() { + guard let textView else { return } + toggleTrait(.traitItalic, on: textView) + } - func toggleStrikethrough() { - guard let textView else { return } - let range = textView.selectedRange + func toggleStrikethrough() { + guard let textView else { return } + let range = textView.selectedRange - textView.undoManager?.beginUndoGrouping() - if range.length > 0 { - let mutable = NSMutableAttributedString(attributedString: textView.attributedText) - let hasStrike = (mutable.attribute(.strikethroughStyle, at: range.location, effectiveRange: nil) as? Int) == NSUnderlineStyle.single.rawValue + textView.undoManager?.beginUndoGrouping() + if range.length > 0 { + let mutable = NSMutableAttributedString(attributedString: textView.attributedText) + let hasStrike = + (mutable.attribute(.strikethroughStyle, at: range.location, effectiveRange: nil) as? Int) + == NSUnderlineStyle.single.rawValue - if hasStrike { - mutable.removeAttribute(.strikethroughStyle, range: range) - } else { - mutable.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) - } - textView.attributedText = mutable - textView.selectedRange = range - notifyChange() - } else { - var attrs = textView.typingAttributes - let hasStrike = (attrs[.strikethroughStyle] as? Int) == NSUnderlineStyle.single.rawValue - if hasStrike { - attrs.removeValue(forKey: .strikethroughStyle) + if hasStrike { + mutable.removeAttribute(.strikethroughStyle, range: range) + } else { + mutable.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) + } + textView.attributedText = mutable + textView.selectedRange = range + notifyChange() } else { - attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + var attrs = textView.typingAttributes + let hasStrike = (attrs[.strikethroughStyle] as? Int) == NSUnderlineStyle.single.rawValue + if hasStrike { + attrs.removeValue(forKey: .strikethroughStyle) + } else { + attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + } + textView.typingAttributes = attrs } - textView.typingAttributes = attrs + textView.undoManager?.endUndoGrouping() } - textView.undoManager?.endUndoGrouping() - } - func toggleCode() { - guard let textView else { return } - let range = textView.selectedRange + func toggleCode() { + guard let textView else { return } + let range = textView.selectedRange - textView.undoManager?.beginUndoGrouping() - if range.length > 0 { - let mutable = NSMutableAttributedString(attributedString: textView.attributedText) - let currentFont = mutable.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont - let isCode = currentFont?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) ?? false + textView.undoManager?.beginUndoGrouping() + if range.length > 0 { + let mutable = NSMutableAttributedString(attributedString: textView.attributedText) + let currentFont = mutable.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont + let isCode = currentFont?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) ?? false - if isCode { - let baseFont = UIFont.preferredFont(forTextStyle: .subheadline) - mutable.addAttribute(.font, value: baseFont, range: range) - mutable.removeAttribute(.backgroundColor, range: range) + if isCode { + let baseFont = UIFont.preferredFont(forTextStyle: .subheadline) + mutable.addAttribute(.font, value: baseFont, range: range) + mutable.removeAttribute(.backgroundColor, range: range) + } else { + let codeFont = UIFont.monospacedSystemFont( + ofSize: UIFont.preferredFont(forTextStyle: .subheadline).pointSize, weight: .regular) + mutable.addAttribute(.font, value: codeFont, range: range) + mutable.addAttribute(.backgroundColor, value: UIColor.systemGray6, range: range) + } + textView.attributedText = mutable + textView.selectedRange = range + notifyChange() } else { - let codeFont = UIFont.monospacedSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .subheadline).pointSize, weight: .regular) - mutable.addAttribute(.font, value: codeFont, range: range) - mutable.addAttribute(.backgroundColor, value: UIColor.systemGray6, range: range) + var attrs = textView.typingAttributes + let currentFont = attrs[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .subheadline) + let isCode = currentFont.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) + + if isCode { + attrs[.font] = UIFont.preferredFont(forTextStyle: .subheadline) + attrs.removeValue(forKey: .backgroundColor) + } else { + attrs[.font] = UIFont.monospacedSystemFont(ofSize: currentFont.pointSize, weight: .regular) + attrs[.backgroundColor] = UIColor.systemGray6 + } + textView.typingAttributes = attrs } - textView.attributedText = mutable - textView.selectedRange = range - notifyChange() - } else { - var attrs = textView.typingAttributes - let currentFont = attrs[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .subheadline) - let isCode = currentFont.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) - - if isCode { - attrs[.font] = UIFont.preferredFont(forTextStyle: .subheadline) - attrs.removeValue(forKey: .backgroundColor) + textView.undoManager?.endUndoGrouping() + } + + func toggleCodeBlock() { + guard let textView else { return } + let range = textView.selectedRange + + textView.undoManager?.beginUndoGrouping() + if range.length > 0 { + let mutable = NSMutableAttributedString(attributedString: textView.attributedText) + let isCodeBlock = + mutable.attribute(.codeBlock, at: range.location, effectiveRange: nil) as? Bool ?? false + + if isCodeBlock { + let baseFont = UIFont.preferredFont(forTextStyle: .subheadline) + mutable.addAttribute(.font, value: baseFont, range: range) + mutable.removeAttribute(.backgroundColor, range: range) + mutable.removeAttribute(.codeBlock, range: range) + } else { + let codeFont = UIFont.monospacedSystemFont( + ofSize: UIFont.preferredFont(forTextStyle: .subheadline).pointSize, + weight: .regular + ) + mutable.addAttribute(.font, value: codeFont, range: range) + mutable.addAttribute(.backgroundColor, value: UIColor.systemGray5, range: range) + mutable.addAttribute(.codeBlock, value: true, range: range) + } + textView.attributedText = mutable + textView.selectedRange = range + notifyChange() } else { - attrs[.font] = UIFont.monospacedSystemFont(ofSize: currentFont.pointSize, weight: .regular) - attrs[.backgroundColor] = UIColor.systemGray6 + var attrs = textView.typingAttributes + let isCodeBlock = attrs[.codeBlock] as? Bool ?? false + + if isCodeBlock { + attrs[.font] = UIFont.preferredFont(forTextStyle: .subheadline) + attrs.removeValue(forKey: .backgroundColor) + attrs.removeValue(forKey: .codeBlock) + } else { + let currentFont = attrs[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .subheadline) + attrs[.font] = UIFont.monospacedSystemFont(ofSize: currentFont.pointSize, weight: .regular) + attrs[.backgroundColor] = UIColor.systemGray5 + attrs[.codeBlock] = true + } + textView.typingAttributes = attrs } - textView.typingAttributes = attrs + textView.undoManager?.endUndoGrouping() } - textView.undoManager?.endUndoGrouping() - } - func toggleCodeBlock() { - guard let textView else { return } - let range = textView.selectedRange + func applyLink(url: URL, label: String) { + guard let textView else { return } + let range = textView.selectedRange - textView.undoManager?.beginUndoGrouping() - if range.length > 0 { + textView.undoManager?.beginUndoGrouping() let mutable = NSMutableAttributedString(attributedString: textView.attributedText) - let isCodeBlock = mutable.attribute(.codeBlock, at: range.location, effectiveRange: nil) as? Bool ?? false - if isCodeBlock { - let baseFont = UIFont.preferredFont(forTextStyle: .subheadline) - mutable.addAttribute(.font, value: baseFont, range: range) - mutable.removeAttribute(.backgroundColor, range: range) - mutable.removeAttribute(.codeBlock, range: range) + if range.length > 0 { + mutable.addAttribute(.link, value: url, range: range) } else { - let codeFont = UIFont.monospacedSystemFont( - ofSize: UIFont.preferredFont(forTextStyle: .subheadline).pointSize, - weight: .regular - ) - mutable.addAttribute(.font, value: codeFont, range: range) - mutable.addAttribute(.backgroundColor, value: UIColor.systemGray5, range: range) - mutable.addAttribute(.codeBlock, value: true, range: range) + let linkText = NSAttributedString( + string: label.isEmpty ? url.absoluteString : label, + attributes: [ + .link: url, + .font: UIFont.preferredFont(forTextStyle: .subheadline), + ]) + mutable.insert(linkText, at: range.location) } textView.attributedText = mutable - textView.selectedRange = range + textView.undoManager?.endUndoGrouping() notifyChange() - } else { - var attrs = textView.typingAttributes - let isCodeBlock = attrs[.codeBlock] as? Bool ?? false - - if isCodeBlock { - attrs[.font] = UIFont.preferredFont(forTextStyle: .subheadline) - attrs.removeValue(forKey: .backgroundColor) - attrs.removeValue(forKey: .codeBlock) - } else { - let currentFont = attrs[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .subheadline) - attrs[.font] = UIFont.monospacedSystemFont(ofSize: currentFont.pointSize, weight: .regular) - attrs[.backgroundColor] = UIColor.systemGray5 - attrs[.codeBlock] = true - } - textView.typingAttributes = attrs } - textView.undoManager?.endUndoGrouping() - } - func applyLink(url: URL, label: String) { - guard let textView else { return } - let range = textView.selectedRange - - textView.undoManager?.beginUndoGrouping() - let mutable = NSMutableAttributedString(attributedString: textView.attributedText) - - if range.length > 0 { - mutable.addAttribute(.link, value: url, range: range) - } else { - let linkText = NSAttributedString(string: label.isEmpty ? url.absoluteString : label, attributes: [ - .link: url, - .font: UIFont.preferredFont(forTextStyle: .subheadline) - ]) - mutable.insert(linkText, at: range.location) + func selectedText() -> String? { + guard let textView else { return nil } + let range = textView.selectedRange + guard range.length > 0 else { return nil } + return (textView.text as NSString).substring(with: range) } - textView.attributedText = mutable - textView.undoManager?.endUndoGrouping() - notifyChange() - } - func selectedText() -> String? { - guard let textView else { return nil } - let range = textView.selectedRange - guard range.length > 0 else { return nil } - return (textView.text as NSString).substring(with: range) - } + // MARK: - Private - // MARK: - Private + private func notifyChange() { + guard let textView else { return } + onTextChange?(textView.attributedText) + (textView as? SelfSizingTextView)?.invalidateHeight() + } - private func notifyChange() { - guard let textView else { return } - onTextChange?(textView.attributedText) - (textView as? SelfSizingTextView)?.invalidateHeight() - } + private func toggleTrait(_ trait: UIFontDescriptor.SymbolicTraits, on textView: UITextView) { + let range = textView.selectedRange - private func toggleTrait(_ trait: UIFontDescriptor.SymbolicTraits, on textView: UITextView) { - let range = textView.selectedRange + textView.undoManager?.beginUndoGrouping() + if range.length > 0 { + let mutable = NSMutableAttributedString(attributedString: textView.attributedText) + let currentFont = + mutable.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont + ?? UIFont.preferredFont(forTextStyle: .subheadline) + let hasTrait = currentFont.fontDescriptor.symbolicTraits.contains(trait) + + mutable.enumerateAttribute(.font, in: range) { value, attrRange, _ in + guard let font = value as? UIFont else { return } + var newTraits = font.fontDescriptor.symbolicTraits + if hasTrait { + newTraits.remove(trait) + } else { + newTraits.insert(trait) + } + if let newDescriptor = font.fontDescriptor.withSymbolicTraits(newTraits) { + let newFont = UIFont(descriptor: newDescriptor, size: font.pointSize) + mutable.addAttribute(.font, value: newFont, range: attrRange) + } + } + textView.attributedText = mutable + textView.selectedRange = range + notifyChange() + } else { + var attrs = textView.typingAttributes + let currentFont = attrs[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .subheadline) + var newTraits = currentFont.fontDescriptor.symbolicTraits - textView.undoManager?.beginUndoGrouping() - if range.length > 0 { - let mutable = NSMutableAttributedString(attributedString: textView.attributedText) - let currentFont = mutable.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont - ?? UIFont.preferredFont(forTextStyle: .subheadline) - let hasTrait = currentFont.fontDescriptor.symbolicTraits.contains(trait) - - mutable.enumerateAttribute(.font, in: range) { value, attrRange, _ in - guard let font = value as? UIFont else { return } - var newTraits = font.fontDescriptor.symbolicTraits - if hasTrait { + if newTraits.contains(trait) { newTraits.remove(trait) } else { newTraits.insert(trait) } - if let newDescriptor = font.fontDescriptor.withSymbolicTraits(newTraits) { - let newFont = UIFont(descriptor: newDescriptor, size: font.pointSize) - mutable.addAttribute(.font, value: newFont, range: attrRange) + if let newDescriptor = currentFont.fontDescriptor.withSymbolicTraits(newTraits) { + attrs[.font] = UIFont(descriptor: newDescriptor, size: currentFont.pointSize) } + textView.typingAttributes = attrs } - textView.attributedText = mutable - textView.selectedRange = range - notifyChange() - } else { - var attrs = textView.typingAttributes - let currentFont = attrs[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .subheadline) - var newTraits = currentFont.fontDescriptor.symbolicTraits - - if newTraits.contains(trait) { - newTraits.remove(trait) - } else { - newTraits.insert(trait) - } - if let newDescriptor = currentFont.fontDescriptor.withSymbolicTraits(newTraits) { - attrs[.font] = UIFont(descriptor: newDescriptor, size: currentFont.pointSize) - } - textView.typingAttributes = attrs + textView.undoManager?.endUndoGrouping() } - textView.undoManager?.endUndoGrouping() } -} -// MARK: - Self-sizing UITextView + // MARK: - Self-sizing UITextView -/// UITextView that sizes itself via intrinsicContentSize and strips pastes to plain text. -class SelfSizingTextView: UITextView { - private let minHeight: CGFloat = 36 - private let maxHeight: CGFloat = 250 + /// UITextView that sizes itself via intrinsicContentSize and strips pastes to plain text. + class SelfSizingTextView: UITextView { + private let minHeight: CGFloat = 36 + private let maxHeight: CGFloat = 250 - override var intrinsicContentSize: CGSize { - let width = frame.width > 0 ? frame.width : 250 - let size = sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) - let clamped = min(max(size.height, minHeight), maxHeight) - return CGSize(width: UIView.noIntrinsicMetric, height: clamped) - } - - func invalidateHeight() { - invalidateIntrinsicContentSize() - superview?.setNeedsLayout() - } - - override func paste(_ sender: Any?) { - if let string = UIPasteboard.general.string { - let plainText = NSAttributedString(string: string, attributes: typingAttributes) - textStorage.insert(plainText, at: selectedRange.location) - selectedRange = NSRange(location: selectedRange.location + string.count, length: 0) - delegate?.textViewDidChange?(self) - } - } -} - -// MARK: - RichTextEditor - -struct RichTextEditor: UIViewRepresentable { - @Binding var attributedText: NSAttributedString - @Binding var selectedRange: NSRange - @Binding var isFocused: Bool - var context: RichTextEditorContext - var placeholder: String = "" - var baseFont: UIFont = .preferredFont(forTextStyle: .subheadline) - var textColor: UIColor = .label - - func makeUIView(context: Context) -> SelfSizingTextView { - let textView = SelfSizingTextView() - textView.delegate = context.coordinator - textView.font = baseFont - textView.textColor = textColor - textView.backgroundColor = .clear - textView.isScrollEnabled = false - textView.textContainerInset = UIEdgeInsets( - top: RichTextEditorLayout.verticalInset, - left: RichTextEditorLayout.horizontalInset, - bottom: RichTextEditorLayout.verticalInset, - right: RichTextEditorLayout.horizontalInset - ) - textView.textContainer.lineFragmentPadding = 0 - textView.typingAttributes = [ - .font: baseFont, - .foregroundColor: textColor - ] - textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - textView.setContentHuggingPriority(.defaultHigh, for: .vertical) - - let editorContext = self.context - editorContext.textView = textView - editorContext.onTextChange = { newText in - context.coordinator.parent.attributedText = newText + override var intrinsicContentSize: CGSize { + let width = frame.width > 0 ? frame.width : 250 + let size = sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) + let clamped = min(max(size.height, minHeight), maxHeight) + return CGSize(width: UIView.noIntrinsicMetric, height: clamped) } - return textView - } - - func updateUIView(_ textView: SelfSizingTextView, context: Context) { - context.coordinator.isUpdatingFromSwiftUI = true - defer { context.coordinator.isUpdatingFromSwiftUI = false } - - if textView.attributedText.string != attributedText.string { - textView.attributedText = attributedText + func invalidateHeight() { + invalidateIntrinsicContentSize() + superview?.setNeedsLayout() } - if isFocused && !textView.isFirstResponder { - textView.becomeFirstResponder() - } else if !isFocused && textView.isFirstResponder { - textView.resignFirstResponder() + override func paste(_ sender: Any?) { + if let string = UIPasteboard.general.string { + let plainText = NSAttributedString(string: string, attributes: typingAttributes) + textStorage.insert(plainText, at: selectedRange.location) + selectedRange = NSRange(location: selectedRange.location + string.count, length: 0) + delegate?.textViewDidChange?(self) + } } } - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - class Coordinator: NSObject, UITextViewDelegate { - var parent: RichTextEditor - var isUpdatingFromSwiftUI = false + // MARK: - RichTextEditor + + struct RichTextEditor: UIViewRepresentable { + @Binding var attributedText: NSAttributedString + @Binding var selectedRange: NSRange + @Binding var isFocused: Bool + var context: RichTextEditorContext + var placeholder: String = "" + var baseFont: UIFont = .preferredFont(forTextStyle: .subheadline) + var textColor: UIColor = .label + + func makeUIView(context: Context) -> SelfSizingTextView { + let textView = SelfSizingTextView() + textView.delegate = context.coordinator + textView.font = baseFont + textView.textColor = textColor + textView.backgroundColor = .clear + textView.isScrollEnabled = false + textView.textContainerInset = UIEdgeInsets( + top: RichTextEditorLayout.verticalInset, + left: RichTextEditorLayout.horizontalInset, + bottom: RichTextEditorLayout.verticalInset, + right: RichTextEditorLayout.horizontalInset + ) + textView.textContainer.lineFragmentPadding = 0 + textView.typingAttributes = [ + .font: baseFont, + .foregroundColor: textColor, + ] + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.setContentHuggingPriority(.defaultHigh, for: .vertical) + + let editorContext = self.context + editorContext.textView = textView + editorContext.onTextChange = { newText in + context.coordinator.parent.attributedText = newText + } - init(_ parent: RichTextEditor) { - self.parent = parent + return textView } - func textViewDidChange(_ textView: UITextView) { - guard !isUpdatingFromSwiftUI else { return } - parent.attributedText = textView.attributedText - parent.context.onPlainTextChange?(textView.text) - (textView as? SelfSizingTextView)?.invalidateHeight() - } + func updateUIView(_ textView: SelfSizingTextView, context: Context) { + context.coordinator.isUpdatingFromSwiftUI = true + defer { context.coordinator.isUpdatingFromSwiftUI = false } - func textViewDidChangeSelection(_ textView: UITextView) { - parent.selectedRange = textView.selectedRange - } + if textView.attributedText.string != attributedText.string { + textView.attributedText = attributedText + } - func textViewDidBeginEditing(_ textView: UITextView) { - parent.isFocused = true + if isFocused && !textView.isFirstResponder { + textView.becomeFirstResponder() + } else if !isFocused && textView.isFirstResponder { + textView.resignFirstResponder() + } } - func textViewDidEndEditing(_ textView: UITextView) { - parent.isFocused = false + func makeCoordinator() -> Coordinator { + Coordinator(self) } - func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String?) -> Bool { - let fullRange = NSRange(location: 0, length: textView.attributedText.length) - var mentionRange: NSRange? - textView.attributedText.enumerateAttribute(.mentionUserId, in: fullRange) { value, attrRange, stop in - guard value != nil else { return } - if NSIntersectionRange(attrRange, range).length > 0 || range.location == attrRange.location + attrRange.length { - if text == nil || text?.isEmpty == true { - mentionRange = attrRange - stop.pointee = true - } - } + class Coordinator: NSObject, UITextViewDelegate { + var parent: RichTextEditor + var isUpdatingFromSwiftUI = false + + init(_ parent: RichTextEditor) { + self.parent = parent } - if let mentionRange { - let mutable = NSMutableAttributedString(attributedString: textView.attributedText) - mutable.deleteCharacters(in: mentionRange) - textView.attributedText = mutable - textView.selectedRange = NSRange(location: mentionRange.location, length: 0) + func textViewDidChange(_ textView: UITextView) { + guard !isUpdatingFromSwiftUI else { return } parent.attributedText = textView.attributedText + parent.context.onPlainTextChange?(textView.text) (textView as? SelfSizingTextView)?.invalidateHeight() - return false } - var typingAttrs = textView.typingAttributes - if typingAttrs[.mentionUserId] != nil { - typingAttrs.removeValue(forKey: .mentionUserId) - textView.typingAttributes = typingAttrs + func textViewDidChangeSelection(_ textView: UITextView) { + parent.selectedRange = textView.selectedRange + } + + func textViewDidBeginEditing(_ textView: UITextView) { + parent.isFocused = true + } + + func textViewDidEndEditing(_ textView: UITextView) { + parent.isFocused = false } - return true + func textView( + _ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String? + ) -> Bool { + let fullRange = NSRange(location: 0, length: textView.attributedText.length) + var mentionRange: NSRange? + textView.attributedText.enumerateAttribute(.mentionUserId, in: fullRange) { value, attrRange, stop in + guard value != nil else { return } + if NSIntersectionRange(attrRange, range).length > 0 + || range.location == attrRange.location + attrRange.length + { + if text == nil || text?.isEmpty == true { + mentionRange = attrRange + stop.pointee = true + } + } + } + + if let mentionRange { + let mutable = NSMutableAttributedString(attributedString: textView.attributedText) + mutable.deleteCharacters(in: mentionRange) + textView.attributedText = mutable + textView.selectedRange = NSRange(location: mentionRange.location, length: 0) + parent.attributedText = textView.attributedText + (textView as? SelfSizingTextView)?.invalidateHeight() + return false + } + + var typingAttrs = textView.typingAttributes + if typingAttrs[.mentionUserId] != nil { + typingAttrs.removeValue(forKey: .mentionUserId) + textView.typingAttributes = typingAttrs + } + + return true + } } } -} -// MARK: - Custom attribute keys + // MARK: - Custom attribute keys -extension NSAttributedString.Key { - static let mentionUserId = NSAttributedString.Key("com.fastcomments.mentionUserId") - static let imageURL = NSAttributedString.Key("com.fastcomments.imageURL") - static let codeBlock = NSAttributedString.Key("com.fastcomments.codeBlock") -} + extension NSAttributedString.Key { + static let mentionUserId = NSAttributedString.Key("com.fastcomments.mentionUserId") + static let imageURL = NSAttributedString.Key("com.fastcomments.imageURL") + static let codeBlock = NSAttributedString.Key("com.fastcomments.codeBlock") + } #endif diff --git a/Sources/FastCommentsUI/Views/SelectedMediaGrid.swift b/Sources/FastCommentsUI/Views/SelectedMediaGrid.swift index 193f1bb..48b3f42 100644 --- a/Sources/FastCommentsUI/Views/SelectedMediaGrid.swift +++ b/Sources/FastCommentsUI/Views/SelectedMediaGrid.swift @@ -1,9 +1,9 @@ import SwiftUI import PhotosUI #if canImport(UIKit) -import UIKit + import UIKit #elseif canImport(AppKit) -import AppKit + import AppKit #endif /// Grid of selected media items for post creation with remove buttons. @@ -18,17 +18,17 @@ struct SelectedMediaGrid: View { ForEach(Array(loadedImages.enumerated()), id: \.offset) { index, image in ZStack(alignment: .topTrailing) { #if os(iOS) - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: 80, height: 80) - .clipShape(RoundedRectangle(cornerRadius: 8)) + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 8)) #else - Image(nsImage: image) - .resizable() - .scaledToFill() - .frame(width: 80, height: 80) - .clipShape(RoundedRectangle(cornerRadius: 8)) + Image(nsImage: image) + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 8)) #endif Button { diff --git a/Tests/FastCommentsUITests/CommentCRUDIntegrationTests.swift b/Tests/FastCommentsUITests/CommentCRUDIntegrationTests.swift index f7a7c19..53c16a7 100644 --- a/Tests/FastCommentsUITests/CommentCRUDIntegrationTests.swift +++ b/Tests/FastCommentsUITests/CommentCRUDIntegrationTests.swift @@ -149,7 +149,9 @@ final class CommentCRUDIntegrationTests: IntegrationTestBase { try await sdk2.load() let firstPageSize = sdk2.commentsTree.totalSize() - print("[FC] Pagination test: firstPage=\(firstPageSize) hasMore=\(sdk2.hasMore) commentCountOnServer=\(sdk2.commentCountOnServer)") + print( + "[FC] Pagination test: firstPage=\(firstPageSize) hasMore=\(sdk2.hasMore) commentCountOnServer=\(sdk2.commentCountOnServer)" + ) XCTAssertGreaterThan(firstPageSize, 0, "First page should have comments") XCTAssertTrue(sdk2.hasMore, "35 comments with default pageSize 30 should have more") diff --git a/Tests/FastCommentsUITests/FollowButtonTests.swift b/Tests/FastCommentsUITests/FollowButtonTests.swift index 0fabff9..bc03de9 100644 --- a/Tests/FastCommentsUITests/FollowButtonTests.swift +++ b/Tests/FastCommentsUITests/FollowButtonTests.swift @@ -92,7 +92,7 @@ final class FollowButtonTests: XCTestCase { state.tap(user: user(), provider: provider, sdk: sdk) - XCTAssertEqual(state.optimisticFollowing, true) // optimistic flip + XCTAssertEqual(state.optimisticFollowing, true) // optimistic flip XCTAssertTrue(state.isPending) // Provider failure: returns unchanged (still not following) — the @@ -120,14 +120,14 @@ final class FollowButtonTests: XCTestCase { // View is reused for post B before the callback fires. state.reset() - XCTAssertNil(state.optimisticFollowing) // no stale optimistic flip + XCTAssertNil(state.optimisticFollowing) // no stale optimistic flip XCTAssertFalse(state.isPending) // Late callback from the author-1 request arrives — must be ignored. provider.flushLastCallback(with: true) await waitForMainActor() - XCTAssertNil(state.optimisticFollowing) // undisturbed + XCTAssertNil(state.optimisticFollowing) // undisturbed XCTAssertFalse(state.isPending) } diff --git a/Tests/FastCommentsUITests/Helpers/IntegrationTestBase.swift b/Tests/FastCommentsUITests/Helpers/IntegrationTestBase.swift index 3044293..16df8a1 100644 --- a/Tests/FastCommentsUITests/Helpers/IntegrationTestBase.swift +++ b/Tests/FastCommentsUITests/Helpers/IntegrationTestBase.swift @@ -43,7 +43,8 @@ class IntegrationTestBase: XCTestCase { var signupRequest = URLRequest(url: signupURL) signupRequest.httpMethod = "POST" signupRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - let formBody = "username=\(username)&email=\(email)&companyName=\(username)&domains=\(username).example.com&packageId=adv&noTracking=true" + let formBody = + "username=\(username)&email=\(email)&companyName=\(username)&domains=\(username).example.com&packageId=adv&noTracking=true" signupRequest.httpBody = formBody.data(using: .utf8) // Use a shared URLSession that stores cookies @@ -54,7 +55,8 @@ class IntegrationTestBase: XCTestCase { let (_, signupResponse) = try await session.data(for: signupRequest) guard let httpSignup = signupResponse as? HTTPURLResponse, - (200..<400).contains(httpSignup.statusCode) else { + (200..<400).contains(httpSignup.statusCode) + else { XCTFail("Tenant signup failed") return } @@ -80,7 +82,7 @@ class IntegrationTestBase: XCTestCase { // Extract API key from: value="" if let range = html.range(of: #"value="([A-Z0-9]+)""#, options: .regularExpression) { let match = String(html[range]) - testTenantApiKey = String(match.dropFirst(7).dropLast(1)) // strip value=" and " + testTenantApiKey = String(match.dropFirst(7).dropLast(1)) // strip value=" and " } guard testTenantApiKey != nil else { @@ -99,7 +101,8 @@ class IntegrationTestBase: XCTestCase { if let email = testTenantEmail { let encodedEmail = email.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)! let encodedKey = TestConfig.e2eApiKey.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! - let url = URL(string: "\(TestConfig.host)/test-e2e/api/tenant/by-email/\(encodedEmail)?API_KEY=\(encodedKey)")! + let url = URL( + string: "\(TestConfig.host)/test-e2e/api/tenant/by-email/\(encodedEmail)?API_KEY=\(encodedKey)")! var request = URLRequest(url: url) request.httpMethod = "DELETE" _ = try? await URLSession.shared.data(for: request) @@ -188,7 +191,8 @@ class IntegrationTestBase: XCTestCase { /// Generate a unique urlId and register it for cleanup. func makeUrlId(testName: String = #function) -> String { - let sanitized = testName + let sanitized = + testName .replacingOccurrences(of: "()", with: "") .replacingOccurrences(of: " ", with: "-") let timestamp = Int(Date().timeIntervalSince1970) @@ -212,12 +216,12 @@ class IntegrationTestBase: XCTestCase { guard let tid = testTenantId else { return } do { let response = try await DefaultAPI.getComments( - tenantId: tid, - options: DefaultAPI.GetCommentsOptions( - urlId: urlId - ), - apiConfiguration: adminApiConfig - ) + tenantId: tid, + options: DefaultAPI.GetCommentsOptions( + urlId: urlId + ), + apiConfiguration: adminApiConfig + ) for comment in (response.comments ?? []) { _ = try? await DefaultAPI.deleteComment( tenantId: tid, id: comment.id, apiConfiguration: adminApiConfig diff --git a/Tests/FastCommentsUITests/LiveEventIntegrationTests.swift b/Tests/FastCommentsUITests/LiveEventIntegrationTests.swift index b0dbf95..0eb8a3f 100644 --- a/Tests/FastCommentsUITests/LiveEventIntegrationTests.swift +++ b/Tests/FastCommentsUITests/LiveEventIntegrationTests.swift @@ -24,7 +24,8 @@ final class LiveEventIntegrationTests: IntegrationTestBase { let received = sdk1.commentsTree.commentsById[posted.id] XCTAssertNotNil(received) - XCTAssertTrue(received!.comment.commentHTML.contains("Live comment"), "Live comment should carry the posted text") + XCTAssertTrue( + received!.comment.commentHTML.contains("Live comment"), "Live comment should carry the posted text") sdk1.cleanup() sdk2.cleanup() diff --git a/Tests/FastCommentsUITests/LiveFeedIntegrationTests.swift b/Tests/FastCommentsUITests/LiveFeedIntegrationTests.swift index 7cfc456..bac1a1f 100644 --- a/Tests/FastCommentsUITests/LiveFeedIntegrationTests.swift +++ b/Tests/FastCommentsUITests/LiveFeedIntegrationTests.swift @@ -32,8 +32,9 @@ final class LiveFeedIntegrationTests: IntegrationTestBase { XCTAssertGreaterThanOrEqual(sdk1.newPostsCount, 1) // Post should NOT be auto-inserted into feedPosts - XCTAssertEqual(sdk1.feedPosts.count, initialCount, - "New live posts should be buffered, not auto-inserted") + XCTAssertEqual( + sdk1.feedPosts.count, initialCount, + "New live posts should be buffered, not auto-inserted") // Cleanup try? await sdk2.deletePost(postId: post.id) @@ -65,8 +66,9 @@ final class LiveFeedIntegrationTests: IntegrationTestBase { try await sdk1.loadNewPosts() XCTAssertEqual(sdk1.newPostsCount, 0, "Count should reset after loading") - XCTAssertTrue(sdk1.feedPosts.contains { $0.id == post.id }, - "Buffered post should now appear in feed after loadNewPosts") + XCTAssertTrue( + sdk1.feedPosts.contains { $0.id == post.id }, + "Buffered post should now appear in feed after loadNewPosts") // Cleanup try? await sdk1.deletePost(postId: post.id) @@ -104,8 +106,9 @@ final class LiveFeedIntegrationTests: IntegrationTestBase { !sdk1.feedPosts.contains { $0.id == post.id } } - XCTAssertFalse(sdk1.feedPosts.contains { $0.id == post.id }, - "Deleted post should be removed from feed via live event") + XCTAssertFalse( + sdk1.feedPosts.contains { $0.id == post.id }, + "Deleted post should be removed from feed via live event") sdk1.cleanup() sdk2.cleanup() @@ -145,8 +148,9 @@ final class LiveFeedIntegrationTests: IntegrationTestBase { try await Task.sleep(nanoseconds: 500_000_000) } - XCTAssertGreaterThanOrEqual(sdk1.getLikeCount(postId: post.id), 1, - "Stats should reflect new reaction count after fetch") + XCTAssertGreaterThanOrEqual( + sdk1.getLikeCount(postId: post.id), 1, + "Stats should reflect new reaction count after fetch") // Cleanup try? await sdk2.deletePost(postId: post.id) @@ -168,8 +172,9 @@ final class LiveFeedIntegrationTests: IntegrationTestBase { try await Task.sleep(nanoseconds: 2_000_000_000) // newPostsCount should still be 0 — our own post should not trigger the banner - XCTAssertEqual(sdk.newPostsCount, 0, - "Own posts should be filtered by broadcastId, not counted as new") + XCTAssertEqual( + sdk.newPostsCount, 0, + "Own posts should be filtered by broadcastId, not counted as new") // The post should be in feedPosts from the direct insert in createPost let count = sdk.feedPosts.filter { $0.id == post.id }.count @@ -189,19 +194,22 @@ final class LiveFeedIntegrationTests: IntegrationTestBase { let sdk2 = makeFeedSDK(urlId: urlId) try await sdk2.load() - let post1 = try await sdk2.createPost(params: CreateFeedPostParams( - title: "Post 1", contentHTML: "

First

" - )) - let post2 = try await sdk2.createPost(params: CreateFeedPostParams( - title: "Post 2", contentHTML: "

Second

" - )) + let post1 = try await sdk2.createPost( + params: CreateFeedPostParams( + title: "Post 1", contentHTML: "

First

" + )) + let post2 = try await sdk2.createPost( + params: CreateFeedPostParams( + title: "Post 2", contentHTML: "

Second

" + )) try await waitFor(timeout: 10.0) { sdk1.newPostsCount >= 2 } - XCTAssertGreaterThanOrEqual(sdk1.newPostsCount, 2, - "Multiple new posts should each increment the count") + XCTAssertGreaterThanOrEqual( + sdk1.newPostsCount, 2, + "Multiple new posts should each increment the count") // Load them all try await sdk1.loadNewPosts() diff --git a/Tests/FastCommentsUITests/ModerationIntegrationTests.swift b/Tests/FastCommentsUITests/ModerationIntegrationTests.swift index 19de498..c77f048 100644 --- a/Tests/FastCommentsUITests/ModerationIntegrationTests.swift +++ b/Tests/FastCommentsUITests/ModerationIntegrationTests.swift @@ -76,7 +76,7 @@ final class ModerationIntegrationTests: IntegrationTestBase { } func testNonAdminCannotPin() async throws { - let sdk = makeSDK() // regular user, not admin + let sdk = makeSDK() // regular user, not admin try await sdk.load() let comment = try await sdk.postComment(text: "Try to pin without admin") @@ -87,15 +87,16 @@ final class ModerationIntegrationTests: IntegrationTestBase { // If it didn't throw, verify server didn't actually pin let sdk2 = FastCommentsSDK(config: sdk.config) try await sdk2.load() - XCTAssertNotEqual(sdk2.commentsTree.commentsById[comment.id]?.comment.isPinned, true, - "Non-admin should not be able to pin") + XCTAssertNotEqual( + sdk2.commentsTree.commentsById[comment.id]?.comment.isPinned, true, + "Non-admin should not be able to pin") } catch { // Expected — server rejects non-admin pin } } func testNonAdminCannotLock() async throws { - let sdk = makeSDK() // regular user, not admin + let sdk = makeSDK() // regular user, not admin try await sdk.load() let comment = try await sdk.postComment(text: "Try to lock without admin") @@ -104,8 +105,9 @@ final class ModerationIntegrationTests: IntegrationTestBase { try await sdk.lockComment(commentId: comment.id) let sdk2 = FastCommentsSDK(config: sdk.config) try await sdk2.load() - XCTAssertNotEqual(sdk2.commentsTree.commentsById[comment.id]?.comment.isLocked, true, - "Non-admin should not be able to lock") + XCTAssertNotEqual( + sdk2.commentsTree.commentsById[comment.id]?.comment.isLocked, true, + "Non-admin should not be able to lock") } catch { // Expected — server rejects non-admin lock } diff --git a/Tests/FastCommentsUITests/PresenceIntegrationTests.swift b/Tests/FastCommentsUITests/PresenceIntegrationTests.swift index 4ca1250..4caf8f0 100644 --- a/Tests/FastCommentsUITests/PresenceIntegrationTests.swift +++ b/Tests/FastCommentsUITests/PresenceIntegrationTests.swift @@ -15,7 +15,8 @@ final class PresenceIntegrationTests: IntegrationTestBase { _ = try await sdk.postComment(text: "Comment 2") // Server-posted comments should have userId indexed - XCTAssertFalse(sdk.commentsTree.commentsByUserId.isEmpty, "commentsByUserId should be populated from server data") + XCTAssertFalse( + sdk.commentsTree.commentsByUserId.isEmpty, "commentsByUserId should be populated from server data") // Set all online via the presence API let userId = sdk.commentsTree.commentsByUserId.keys.first! diff --git a/Tests/FastCommentsUITests/RenderableNodeTests.swift b/Tests/FastCommentsUITests/RenderableNodeTests.swift index 56c83c3..c3e9ed7 100644 --- a/Tests/FastCommentsUITests/RenderableNodeTests.swift +++ b/Tests/FastCommentsUITests/RenderableNodeTests.swift @@ -26,7 +26,7 @@ final class RenderableNodeTests: XCTestCase { let depth2 = RenderableComment(comment: MockComment.make(id: "d2", parentId: "d1")) let depth3 = RenderableComment(comment: MockComment.make(id: "d3", parentId: "d2")) let map: [String: RenderableComment] = [ - "root": root, "d1": depth1, "d2": depth2, "d3": depth3 + "root": root, "d1": depth1, "d2": depth2, "d3": depth3, ] XCTAssertEqual(depth3.nestingLevel(in: map), 3) diff --git a/Tests/FastCommentsUITests/SortingIntegrationTests.swift b/Tests/FastCommentsUITests/SortingIntegrationTests.swift index 321d0e6..d1978b5 100644 --- a/Tests/FastCommentsUITests/SortingIntegrationTests.swift +++ b/Tests/FastCommentsUITests/SortingIntegrationTests.swift @@ -119,8 +119,9 @@ final class SortingIntegrationTests: IntegrationTestBase { // First comment should be different between the two sorts if nfComments.count >= 2 && ofComments.count >= 2 { - XCTAssertNotEqual(nfComments.first?.id, ofComments.first?.id, - "Different sort directions should produce different orderings") + XCTAssertNotEqual( + nfComments.first?.id, ofComments.first?.id, + "Different sort directions should produce different orderings") } } @@ -142,7 +143,8 @@ final class SortingIntegrationTests: IntegrationTestBase { let visibleComments = reloadSDK.commentsTree.visibleNodes.compactMap { $0 as? RenderableComment } XCTAssertGreaterThanOrEqual(visibleComments.count, 2) - XCTAssertEqual(visibleComments.first?.id, a.id, "Pinned comment should appear first with sort direction \(direction)") + XCTAssertEqual( + visibleComments.first?.id, a.id, "Pinned comment should appear first with sort direction \(direction)") } } } diff --git a/Tests/FastCommentsUITests/ThreadingIntegrationTests.swift b/Tests/FastCommentsUITests/ThreadingIntegrationTests.swift index 775ad24..7a16fb9 100644 --- a/Tests/FastCommentsUITests/ThreadingIntegrationTests.swift +++ b/Tests/FastCommentsUITests/ThreadingIntegrationTests.swift @@ -122,7 +122,8 @@ final class ThreadingIntegrationTests: IntegrationTestBase { // Parent should have children or a childCount indicating children exist let childCount = parent?.comment.childCount ?? 0 let loadedChildren = parent?.comment.children?.count ?? 0 - XCTAssertTrue(childCount > 0 || loadedChildren > 0, - "Parent should have children (childCount=\(childCount), loaded=\(loadedChildren))") + XCTAssertTrue( + childCount > 0 || loadedChildren > 0, + "Parent should have children (childCount=\(childCount), loaded=\(loadedChildren))") } } From 783fa4a3fbd38dd074a477d776499d235d73335a Mon Sep 17 00:00:00 2001 From: winrid Date: Tue, 30 Jun 2026 16:12:01 -0700 Subject: [PATCH 2/2] CI: generate placeholder TestConfig before compiling tests TestConfig.swift is gitignored (real credentials), so the test target can't compile on a fresh checkout. Generate a placeholder with dummy values before swift build --build-tests; CI only compiles the tests. --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da401bf..bf7f3ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,20 @@ jobs: SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)" swift build --sdk "$SDK_PATH" --triple arm64-apple-ios16.0-simulator + # The integration tests reference TestConfig (gitignored — it holds real + # credentials). CI only compiles the tests, so write a placeholder with + # the same shape as Helpers/TestConfig.example.swift. + - name: Generate placeholder TestConfig + run: | + cat > Tests/FastCommentsUITests/Helpers/TestConfig.swift <<'SWIFT' + enum TestConfig { + static let tenantId = "ci-placeholder" + static let apiKey = "ci-placeholder" + static let e2eApiKey = "ci-placeholder" + static let host = "https://fastcomments.com" + } + SWIFT + - name: Compile tests run: | SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)"