diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..bf7f3ca
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,80 @@
+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
+
+ # 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)"
+ 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.."
- }
- toolbarButton(icon: "italic", accessibilityKey: "format_italic") {
- text += ""
- }
- toolbarButton(icon: "link", accessibilityKey: "add_link") {
- showAddLinkSheet = true
- }
+ toolbarButton(icon: "bold", accessibilityKey: "format_bold") {
+ text += ""
+ }
+ toolbarButton(icon: "italic", accessibilityKey: "format_italic") {
+ text += ""
+ }
+ toolbarButton(icon: "link", accessibilityKey: "add_link") {
+ showAddLinkSheet = true
+ }
#endif
}
@@ -224,9 +226,9 @@ public struct CommentInputBar: View {
.padding(.vertical, 4)
}
#if os(iOS)
- .background(Color(uiColor: .secondarySystemBackground).opacity(0.5))
+ .background(Color(uiColor: .secondarySystemBackground).opacity(0.5))
#else
- .background(Color(nsColor: .controlBackgroundColor).opacity(0.5))
+ .background(Color(nsColor: .controlBackgroundColor).opacity(0.5))
#endif
}
@@ -238,50 +240,52 @@ public struct CommentInputBar: View {
AvatarImage(url: sdk.currentUser?.avatarSrc, size: 30)
#if os(iOS)
- ZStack(alignment: .leading) {
- if attributedText.length == 0 && !isEditorFocused {
- Text(replyingTo != nil
- ? NSLocalizedString("reply_hint", bundle: .module, comment: "")
- : NSLocalizedString("comment_hint", bundle: .module, comment: ""))
- .font(.subheadline)
- .foregroundStyle(.tertiary)
- .padding(.horizontal, Layout.inputHorizontalPadding)
- .padding(.vertical, Layout.inputVerticalPadding)
- }
+ ZStack(alignment: .leading) {
+ if attributedText.length == 0 && !isEditorFocused {
+ Text(
+ replyingTo != nil
+ ? NSLocalizedString("reply_hint", bundle: .module, comment: "")
+ : NSLocalizedString("comment_hint", bundle: .module, comment: "")
+ )
+ .font(.subheadline)
+ .foregroundStyle(.tertiary)
+ .padding(.horizontal, Layout.inputHorizontalPadding)
+ .padding(.vertical, Layout.inputVerticalPadding)
+ }
- RichTextEditor(
- attributedText: $attributedText,
- selectedRange: $selectedRange,
- isFocused: $isEditorFocused,
- context: editorContext
+ RichTextEditor(
+ attributedText: $attributedText,
+ selectedRange: $selectedRange,
+ isFocused: $isEditorFocused,
+ context: editorContext
+ )
+ .frame(minHeight: 36)
+ .accessibilityIdentifier("comment-input")
+ }
+ .background(
+ RoundedRectangle(cornerRadius: 20)
+ .fill(inputFieldBackground)
)
- .frame(minHeight: 36)
- .accessibilityIdentifier("comment-input")
- }
- .background(
- RoundedRectangle(cornerRadius: 20)
- .fill(inputFieldBackground)
- )
#else
- TextField(
- replyingTo != nil
- ? NSLocalizedString("reply_hint", bundle: .module, comment: "")
- : NSLocalizedString("comment_hint", bundle: .module, comment: ""),
- text: $text,
- axis: .vertical
- )
- .textFieldStyle(.plain)
- .font(.subheadline)
- .lineLimit(1...5)
- .padding(.vertical, Layout.inputVerticalPadding)
- .padding(.horizontal, Layout.inputHorizontalPadding)
- .background(
- RoundedRectangle(cornerRadius: 20)
- .fill(inputFieldBackground)
- )
- .onChange(of: text) { newValue in
- handleTextChange(newValue)
- }
+ TextField(
+ replyingTo != nil
+ ? NSLocalizedString("reply_hint", bundle: .module, comment: "")
+ : NSLocalizedString("comment_hint", bundle: .module, comment: ""),
+ text: $text,
+ axis: .vertical
+ )
+ .textFieldStyle(.plain)
+ .font(.subheadline)
+ .lineLimit(1...5)
+ .padding(.vertical, Layout.inputVerticalPadding)
+ .padding(.horizontal, Layout.inputHorizontalPadding)
+ .background(
+ RoundedRectangle(cornerRadius: 20)
+ .fill(inputFieldBackground)
+ )
+ .onChange(of: text) { newValue in
+ handleTextChange(newValue)
+ }
#endif
Button {
@@ -314,26 +318,27 @@ public struct CommentInputBar: View {
}
.background(theme.resolveInputBarBackgroundColor())
#if os(iOS)
- .onAppear {
- editorContext.onPlainTextChange = { newText in
- handleTextChange(newText)
+ .onAppear {
+ editorContext.onPlainTextChange = { newText in
+ handleTextChange(newText)
+ }
}
- }
#endif
.sheet(isPresented: $showAddLinkSheet) {
#if os(iOS)
- AddLinkSheet { url, label in
- if let linkURL = URL(string: url) {
- editorContext.applyLink(url: linkURL, label: label)
+ AddLinkSheet { url, label in
+ if let linkURL = URL(string: url) {
+ editorContext.applyLink(url: linkURL, label: label)
+ }
}
- }
#else
- AddLinkSheet { url, label in
- let linkHtml = label.isEmpty
- ? "\(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))")
}
}