feat: render native chat markdown via Textual
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 2026.1.15 (unreleased)
|
## 2026.1.15 (unreleased)
|
||||||
|
|
||||||
|
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
||||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Sources/Voice/VoiceTab.swift
|
|||||||
Sources/Voice/VoiceWakeManager.swift
|
Sources/Voice/VoiceWakeManager.swift
|
||||||
Sources/Voice/VoiceWakePreferences.swift
|
Sources/Voice/VoiceWakePreferences.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Clawdbot
|
|||||||
options:
|
options:
|
||||||
bundleIdPrefix: com.clawdbot
|
bundleIdPrefix: com.clawdbot
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: "17.0"
|
iOS: "18.0"
|
||||||
xcodeVersion: "16.0"
|
xcodeVersion: "16.0"
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ extension InstancesSettings {
|
|||||||
host: "phone",
|
host: "phone",
|
||||||
ip: "10.0.0.3",
|
ip: "10.0.0.3",
|
||||||
version: "2.0.0",
|
version: "2.0.0",
|
||||||
platform: "iOS 17.2",
|
platform: "iOS 18.0",
|
||||||
deviceFamily: "iPhone",
|
deviceFamily: "iPhone",
|
||||||
modelIdentifier: nil,
|
modelIdentifier: nil,
|
||||||
lastInputSeconds: 35,
|
lastInputSeconds: 35,
|
||||||
@@ -446,7 +446,7 @@ extension InstancesSettings {
|
|||||||
_ = view.platformIcon("watchOS 10")
|
_ = view.platformIcon("watchOS 10")
|
||||||
_ = view.platformIcon("unknown 1.0")
|
_ = view.platformIcon("unknown 1.0")
|
||||||
_ = view.prettyPlatform("macOS 14.2")
|
_ = view.prettyPlatform("macOS 14.2")
|
||||||
_ = view.prettyPlatform("iOS 17")
|
_ = view.prettyPlatform("iOS 18")
|
||||||
_ = view.prettyPlatform("ipados 17.1")
|
_ = view.prettyPlatform("ipados 17.1")
|
||||||
_ = view.prettyPlatform("linux")
|
_ = view.prettyPlatform("linux")
|
||||||
_ = view.prettyPlatform(" ")
|
_ = view.prettyPlatform(" ")
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ struct InstancesSettingsSmokeTests {
|
|||||||
host: "gateway",
|
host: "gateway",
|
||||||
ip: "10.0.0.4",
|
ip: "10.0.0.4",
|
||||||
version: "3.0.0",
|
version: "3.0.0",
|
||||||
platform: "iOS 17",
|
platform: "iOS 18",
|
||||||
deviceFamily: nil,
|
deviceFamily: nil,
|
||||||
modelIdentifier: nil,
|
modelIdentifier: nil,
|
||||||
lastInputSeconds: nil,
|
lastInputSeconds: nil,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "ClawdbotKit",
|
name: "ClawdbotKit",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v17),
|
.iOS(.v18),
|
||||||
.macOS(.v15),
|
.macOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
@@ -14,6 +14,7 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
||||||
|
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@@ -29,7 +30,13 @@ let package = Package(
|
|||||||
]),
|
]),
|
||||||
.target(
|
.target(
|
||||||
name: "ClawdbotChatUI",
|
name: "ClawdbotChatUI",
|
||||||
dependencies: ["ClawdbotKit"],
|
dependencies: [
|
||||||
|
"ClawdbotKit",
|
||||||
|
.product(
|
||||||
|
name: "Textual",
|
||||||
|
package: "textual",
|
||||||
|
condition: .when(platforms: [.macOS, .iOS])),
|
||||||
|
],
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ChatMarkdownPreprocessor {
|
||||||
|
struct InlineImage: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let label: String
|
||||||
|
let image: ClawdbotPlatformImage?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Result {
|
||||||
|
let cleaned: String
|
||||||
|
let images: [InlineImage]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func preprocess(markdown raw: String) -> Result {
|
||||||
|
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
|
||||||
|
guard let re = try? NSRegularExpression(pattern: pattern) else {
|
||||||
|
return Result(cleaned: raw, images: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
let ns = raw as NSString
|
||||||
|
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
|
||||||
|
if matches.isEmpty { return Result(cleaned: raw, images: []) }
|
||||||
|
|
||||||
|
var images: [InlineImage] = []
|
||||||
|
var cleaned = raw
|
||||||
|
|
||||||
|
for match in matches.reversed() {
|
||||||
|
guard match.numberOfRanges >= 3 else { continue }
|
||||||
|
let label = ns.substring(with: match.range(at: 1))
|
||||||
|
let dataURL = ns.substring(with: match.range(at: 2))
|
||||||
|
|
||||||
|
let image: ClawdbotPlatformImage? = {
|
||||||
|
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
|
||||||
|
let b64 = String(dataURL[dataURL.index(after: comma)...])
|
||||||
|
guard let data = Data(base64Encoded: b64) else { return nil }
|
||||||
|
return ClawdbotPlatformImage(data: data)
|
||||||
|
}()
|
||||||
|
images.append(InlineImage(label: label, image: image))
|
||||||
|
|
||||||
|
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
|
||||||
|
let end = cleaned.index(start, offsetBy: match.range.length)
|
||||||
|
cleaned.replaceSubrange(start..<end, with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = cleaned
|
||||||
|
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return Result(cleaned: normalized, images: images.reversed())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum ChatMarkdownSplitter {
|
|
||||||
struct InlineImage: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let label: String
|
|
||||||
let image: ClawdbotPlatformImage?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Block: Identifiable {
|
|
||||||
enum Kind: Equatable {
|
|
||||||
case text
|
|
||||||
case code(language: String?)
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = UUID()
|
|
||||||
let kind: Kind
|
|
||||||
let text: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SplitResult {
|
|
||||||
let blocks: [Block]
|
|
||||||
let images: [InlineImage]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func split(markdown raw: String) -> SplitResult {
|
|
||||||
let extracted = self.extractInlineImages(from: raw)
|
|
||||||
let blocks = self.splitCodeBlocks(from: extracted.cleaned)
|
|
||||||
return SplitResult(blocks: blocks, images: extracted.images)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func splitCodeBlocks(from raw: String) -> [Block] {
|
|
||||||
var blocks: [Block] = []
|
|
||||||
var buffer: [String] = []
|
|
||||||
var inCode = false
|
|
||||||
var codeLang: String?
|
|
||||||
var codeLines: [String] = []
|
|
||||||
|
|
||||||
for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
|
|
||||||
if line.hasPrefix("```") {
|
|
||||||
if inCode {
|
|
||||||
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
|
|
||||||
codeLines.removeAll(keepingCapacity: true)
|
|
||||||
inCode = false
|
|
||||||
codeLang = nil
|
|
||||||
} else {
|
|
||||||
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !text.isEmpty {
|
|
||||||
blocks.append(Block(kind: .text, text: text))
|
|
||||||
}
|
|
||||||
buffer.removeAll(keepingCapacity: true)
|
|
||||||
inCode = true
|
|
||||||
codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if codeLang?.isEmpty == true { codeLang = nil }
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if inCode {
|
|
||||||
codeLines.append(line)
|
|
||||||
} else {
|
|
||||||
buffer.append(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if inCode {
|
|
||||||
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
|
|
||||||
} else {
|
|
||||||
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !text.isEmpty {
|
|
||||||
blocks.append(Block(kind: .text, text: text))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) {
|
|
||||||
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
|
|
||||||
guard let re = try? NSRegularExpression(pattern: pattern) else {
|
|
||||||
return (raw, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
let ns = raw as NSString
|
|
||||||
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
|
|
||||||
if matches.isEmpty { return (raw, []) }
|
|
||||||
|
|
||||||
var images: [InlineImage] = []
|
|
||||||
var cleaned = raw
|
|
||||||
|
|
||||||
for match in matches.reversed() {
|
|
||||||
guard match.numberOfRanges >= 3 else { continue }
|
|
||||||
let label = ns.substring(with: match.range(at: 1))
|
|
||||||
let dataURL = ns.substring(with: match.range(at: 2))
|
|
||||||
|
|
||||||
let image: ClawdbotPlatformImage? = {
|
|
||||||
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
|
|
||||||
let b64 = String(dataURL[dataURL.index(after: comma)...])
|
|
||||||
guard let data = Data(base64Encoded: b64) else { return nil }
|
|
||||||
return ClawdbotPlatformImage(data: data)
|
|
||||||
}()
|
|
||||||
images.append(InlineImage(label: label, image: image))
|
|
||||||
|
|
||||||
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
|
|
||||||
let end = cleaned.index(start, offsetBy: match.range.length)
|
|
||||||
cleaned.replaceSubrange(start..<end, with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalized = cleaned
|
|
||||||
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return (normalized, images.reversed())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Textual
|
||||||
|
|
||||||
private enum ChatUIConstants {
|
private enum ChatUIConstants {
|
||||||
static let bubbleMaxWidth: CGFloat = 560
|
static let bubbleMaxWidth: CGFloat = 560
|
||||||
@@ -169,37 +170,7 @@ private struct ChatMessageBody: View {
|
|||||||
isUser: self.isUser)
|
isUser: self.isUser)
|
||||||
}
|
}
|
||||||
} else if self.isUser {
|
} else if self.isUser {
|
||||||
let split = ChatMarkdownSplitter.split(markdown: text)
|
ChatMarkdownView(text: text, textColor: textColor, font: .system(size: 14))
|
||||||
ForEach(split.blocks) { block in
|
|
||||||
switch block.kind {
|
|
||||||
case .text:
|
|
||||||
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
|
|
||||||
case let .code(language):
|
|
||||||
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !split.images.isEmpty {
|
|
||||||
ForEach(
|
|
||||||
split.images,
|
|
||||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
|
||||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
|
||||||
if let img = item.image {
|
|
||||||
ClawdbotPlatformImageFactory.image(img)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(maxHeight: 260)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
||||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
|
||||||
} else {
|
|
||||||
Text(item.label.isEmpty ? "Image" : item.label)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ChatAssistantTextBody(text: text)
|
ChatAssistantTextBody(text: text)
|
||||||
}
|
}
|
||||||
@@ -613,26 +584,22 @@ private struct TypingDots: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private struct MarkdownTextView: View {
|
private struct ChatMarkdownView: View {
|
||||||
let text: String
|
let text: String
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
let font: Font
|
let font: Font
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let normalized = self.text.replacingOccurrences(
|
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
|
||||||
of: "(?<!\\n)\\n(?!\\n)",
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
with: " ",
|
StructuredText(markdown: processed.cleaned)
|
||||||
options: .regularExpression)
|
|
||||||
let options = AttributedString.MarkdownParsingOptions(
|
|
||||||
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
|
||||||
if let attributed = try? AttributedString(markdown: normalized, options: options) {
|
|
||||||
Text(attributed)
|
|
||||||
.font(self.font)
|
|
||||||
.foregroundStyle(self.textColor)
|
|
||||||
} else {
|
|
||||||
Text(normalized)
|
|
||||||
.font(self.font)
|
.font(self.font)
|
||||||
.foregroundStyle(self.textColor)
|
.foregroundStyle(self.textColor)
|
||||||
|
.textual.textSelection(.enabled)
|
||||||
|
|
||||||
|
if !processed.images.isEmpty {
|
||||||
|
InlineImageList(images: processed.images)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -646,80 +613,36 @@ private struct ChatAssistantTextBody: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ForEach(segments) { segment in
|
ForEach(segments) { segment in
|
||||||
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
|
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
|
||||||
ChatMarkdownBody(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
|
ChatMarkdownView(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private struct ChatMarkdownBody: View {
|
private struct InlineImageList: View {
|
||||||
let text: String
|
let images: [ChatMarkdownPreprocessor.InlineImage]
|
||||||
let textColor: Color
|
|
||||||
let font: Font
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let split = ChatMarkdownSplitter.split(markdown: self.text)
|
if images.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
EmptyView()
|
||||||
ForEach(split.blocks) { block in
|
} else {
|
||||||
switch block.kind {
|
ForEach(images, id: \.id) { item in
|
||||||
case .text:
|
if let img = item.image {
|
||||||
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
|
ClawdbotPlatformImageFactory.image(img)
|
||||||
case let .code(language):
|
.resizable()
|
||||||
CodeBlockView(code: block.text, language: language, isUser: false)
|
.scaledToFit()
|
||||||
}
|
.frame(maxHeight: 260)
|
||||||
}
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.overlay(
|
||||||
if !split.images.isEmpty {
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
ForEach(
|
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||||
split.images,
|
} else {
|
||||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
Text(item.label.isEmpty ? "Image" : item.label)
|
||||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
.font(.footnote)
|
||||||
if let img = item.image {
|
.foregroundStyle(.secondary)
|
||||||
ClawdbotPlatformImageFactory.image(img)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(maxHeight: 260)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
||||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
|
||||||
} else {
|
|
||||||
Text(item.label.isEmpty ? "Image" : item.label)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private struct CodeBlockView: View {
|
|
||||||
let code: String
|
|
||||||
let language: String?
|
|
||||||
let isUser: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
if let language, !language.isEmpty {
|
|
||||||
Text(language)
|
|
||||||
.font(.caption2.monospaced())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Text(self.code)
|
|
||||||
.font(.system(size: 13, weight: .regular, design: .monospaced))
|
|
||||||
.foregroundStyle(self.isUser ? .white : .primary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
||||||
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user