mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +00:00
feat: enhance password-protected rooms with key commitments and management
- Add key commitment scheme for immediate password verification - Implement ownership transfer with /transfer command - Add password change functionality with /pass command - Include room metadata in initialization messages - Remove /help command and rename /changepass to /pass - Make room names tappable to show sidebar - Remove spaces in person/room counter display - Update UI to show lock icons and orange colors for protected rooms - Fix password verification to work with empty rooms - Add comprehensive tests for new functionality This update significantly improves the security and usability of password-protected rooms by allowing immediate verification without waiting for encrypted messages.
This commit is contained in:
@@ -107,7 +107,9 @@ struct BinaryProtocol {
|
||||
var offset = 0
|
||||
|
||||
// Header
|
||||
_ = data[offset]; offset += 1 // version
|
||||
let version = data[offset]; offset += 1
|
||||
// Only support version 1
|
||||
guard version == 1 else { return nil }
|
||||
let type = data[offset]; offset += 1
|
||||
let ttl = data[offset]; offset += 1
|
||||
|
||||
|
||||
@@ -18,15 +18,18 @@ struct MessagePadding {
|
||||
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
|
||||
guard data.count < targetSize else { return data }
|
||||
|
||||
var padded = data
|
||||
let paddingNeeded = targetSize - data.count
|
||||
|
||||
// Add random padding bytes (more secure than zeros)
|
||||
// PKCS#7 only supports padding up to 255 bytes
|
||||
// If we need more padding than that, don't pad - return original data
|
||||
guard paddingNeeded <= 255 else { return data }
|
||||
|
||||
var padded = data
|
||||
|
||||
// Standard PKCS#7 padding
|
||||
var randomBytes = [UInt8](repeating: 0, count: paddingNeeded - 1)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, paddingNeeded - 1, &randomBytes)
|
||||
padded.append(contentsOf: randomBytes)
|
||||
|
||||
// Last byte indicates padding length (PKCS#7 style)
|
||||
padded.append(UInt8(paddingNeeded))
|
||||
|
||||
return padded
|
||||
@@ -161,7 +164,7 @@ protocol BitchatDelegate: AnyObject {
|
||||
func didDisconnectFromPeer(_ peerID: String)
|
||||
func didUpdatePeerList(_ peers: [String])
|
||||
func didReceiveRoomLeave(_ room: String, from peerID: String)
|
||||
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?)
|
||||
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?, keyCommitment: String?)
|
||||
func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String?
|
||||
|
||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||
@@ -178,7 +181,7 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?) {
|
||||
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
|
||||
@@ -685,14 +685,15 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil) {
|
||||
func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Payload format: room|isProtected|creatorID
|
||||
// Payload format: room|isProtected|creatorID|keyCommitment
|
||||
let protectedFlag = isProtected ? "1" : "0"
|
||||
let creator = creatorID ?? self.myPeerID
|
||||
let payload = "\(room)|\(protectedFlag)|\(creator)"
|
||||
let commitment = keyCommitment ?? ""
|
||||
let payload = "\(room)|\(protectedFlag)|\(creator)|\(commitment)"
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.roomAnnounce.rawValue,
|
||||
@@ -719,9 +720,14 @@ class BluetoothMeshService: NSObject {
|
||||
// Encrypt the content
|
||||
guard let contentData = content.data(using: .utf8) else { return }
|
||||
|
||||
// Debug logging
|
||||
let keyData = roomKey.withUnsafeBytes { Data($0) }
|
||||
bitchatLog("Encrypting message for room \(room) with key hash: \(keyData.prefix(8).hexEncodedString())", category: "crypto")
|
||||
|
||||
do {
|
||||
let sealedBox = try AES.GCM.seal(contentData, using: roomKey)
|
||||
let encryptedData = sealedBox.combined!
|
||||
bitchatLog("Successfully encrypted message, size: \(encryptedData.count) bytes", category: "crypto")
|
||||
|
||||
// Create message with encrypted content
|
||||
let message = BitchatMessage(
|
||||
@@ -1309,12 +1315,15 @@ class BluetoothMeshService: NSObject {
|
||||
// Handle encrypted room messages
|
||||
var finalContent = message.content
|
||||
if message.isEncrypted, let room = message.room, let encryptedData = message.encryptedContent {
|
||||
bitchatLog("Processing encrypted message in room \(room), encrypted data size: \(encryptedData.count)", category: "crypto")
|
||||
// Try to decrypt the content
|
||||
if let decryptedContent = self.delegate?.decryptRoomMessage(encryptedData, room: room) {
|
||||
finalContent = decryptedContent
|
||||
bitchatLog("Successfully decrypted message in room \(room): \(decryptedContent.prefix(20))...", category: "crypto")
|
||||
} else {
|
||||
// Unable to decrypt - show placeholder
|
||||
finalContent = "[Encrypted message - password required]"
|
||||
bitchatLog("Failed to decrypt message in room \(room) - will pass to ChatViewModel for re-attempt", category: "crypto")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1762,17 +1771,18 @@ class BluetoothMeshService: NSObject {
|
||||
|
||||
case .roomAnnounce:
|
||||
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
|
||||
// Parse payload: room|isProtected|creatorID
|
||||
// Parse payload: room|isProtected|creatorID|keyCommitment
|
||||
let components = payloadStr.split(separator: "|").map(String.init)
|
||||
if components.count >= 3 {
|
||||
let room = components[0]
|
||||
let isProtected = components[1] == "1"
|
||||
let creatorID = components[2]
|
||||
let keyCommitment = components.count >= 4 ? components[3] : nil
|
||||
|
||||
bitchatLog("Received room announcement: \(room) is \(isProtected ? "protected" : "public") by \(creatorID)", category: "room")
|
||||
bitchatLog("Received room announcement: \(room) is \(isProtected ? "protected" : "public") by \(creatorID)" + (keyCommitment != nil ? " with commitment" : ""), category: "room")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didReceivePasswordProtectedRoomAnnouncement(room, isProtected: isProtected, creatorID: creatorID)
|
||||
self.delegate?.didReceivePasswordProtectedRoomAnnouncement(room, isProtected: isProtected, creatorID: creatorID, keyCommitment: keyCommitment)
|
||||
}
|
||||
|
||||
// Relay announcement
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+175
-66
@@ -23,6 +23,9 @@ struct ContentView: View {
|
||||
@State private var passwordInput = ""
|
||||
@State private var showPasswordPrompt = false
|
||||
@State private var passwordPromptInput = ""
|
||||
@State private var showPasswordError = false
|
||||
@State private var showCommandSuggestions = false
|
||||
@State private var commandSuggestions: [String] = []
|
||||
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black : Color.white
|
||||
@@ -192,12 +195,23 @@ struct ContentView: View {
|
||||
}
|
||||
Button("Join") {
|
||||
if let room = viewModel.passwordPromptRoom, !passwordPromptInput.isEmpty {
|
||||
viewModel.joinRoom(room, password: passwordPromptInput)
|
||||
passwordPromptInput = ""
|
||||
let success = viewModel.joinRoom(room, password: passwordPromptInput)
|
||||
if success {
|
||||
passwordPromptInput = ""
|
||||
} else {
|
||||
// Wrong password - show error
|
||||
passwordPromptInput = ""
|
||||
showPasswordError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("This room is password protected. Enter the password to join.")
|
||||
Text("Room \(viewModel.passwordPromptRoom ?? "") is password protected. Enter the password to join.")
|
||||
}
|
||||
.alert("Wrong Password", isPresented: $showPasswordError) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text("The password you entered is incorrect. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,23 +258,46 @@ struct ContentView: View {
|
||||
.buttonStyle(.plain)
|
||||
} else if let currentRoom = viewModel.currentRoom {
|
||||
// Room header
|
||||
HStack(spacing: 4) {
|
||||
Button(action: {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
showSidebar.toggle()
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
}) {
|
||||
Text(currentRoom)
|
||||
.font(.system(size: 18, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
Button(action: {
|
||||
viewModel.switchToRoom(nil)
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 12))
|
||||
Text("back")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Password button for room creator
|
||||
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID || viewModel.roomCreators[currentRoom] == nil {
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
showSidebar.toggle()
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
if viewModel.passwordProtectedRooms.contains(currentRoom) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Color.orange)
|
||||
}
|
||||
Text("room: \(currentRoom)")
|
||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(viewModel.passwordProtectedRooms.contains(currentRoom) ? Color.orange : Color.blue)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
// Password button for room creator only
|
||||
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID {
|
||||
Button(action: {
|
||||
// Toggle password protection
|
||||
if viewModel.passwordProtectedRooms.contains(currentRoom) {
|
||||
@@ -271,14 +308,9 @@ struct ContentView: View {
|
||||
passwordInputRoom = currentRoom
|
||||
}
|
||||
}) {
|
||||
Image(systemName: viewModel.passwordProtectedRooms.contains(currentRoom) ? "lock.open" : "lock")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
Image(systemName: viewModel.passwordProtectedRooms.contains(currentRoom) ? "lock.fill" : "lock")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(viewModel.passwordProtectedRooms.contains(currentRoom) ? Color.yellow : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -287,31 +319,9 @@ struct ContentView: View {
|
||||
Button(action: {
|
||||
viewModel.leaveRoom(currentRoom)
|
||||
}) {
|
||||
Text("leave room")
|
||||
Text("leave")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color.red)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(Color.red.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Back to main button
|
||||
Button(action: {
|
||||
viewModel.switchToRoom(nil)
|
||||
}) {
|
||||
Text("main")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(textColor.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -373,7 +383,7 @@ struct ContentView: View {
|
||||
let statusText = if !viewModel.isConnected {
|
||||
"alone :/"
|
||||
} else if roomCount > 0 {
|
||||
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people") / \(roomCount) \(roomCount == 1 ? "room" : "rooms")"
|
||||
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")/\(roomCount) \(roomCount == 1 ? "room" : "rooms")"
|
||||
} else {
|
||||
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")"
|
||||
}
|
||||
@@ -419,6 +429,7 @@ struct ContentView: View {
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
// Regular messages with tappable sender name
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
@@ -426,6 +437,7 @@ struct ContentView: View {
|
||||
Text("[\(viewModel.formatTimestamp(message.timestamp))] ")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.textSelection(.enabled)
|
||||
|
||||
// Tappable sender name
|
||||
if message.sender != viewModel.nickname {
|
||||
@@ -445,6 +457,7 @@ struct ContentView: View {
|
||||
Text("<\(message.sender)>")
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
Text(" ")
|
||||
@@ -489,7 +502,57 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var inputView: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
VStack(spacing: 0) {
|
||||
// Command suggestions
|
||||
if showCommandSuggestions && !commandSuggestions.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
let commandDescriptions = [
|
||||
"/j": "join or create a room",
|
||||
"/list": "show your joined rooms",
|
||||
"/w": "see who's online",
|
||||
"/m": "send private message",
|
||||
"/clear": "clear chat messages",
|
||||
"/transfer": "transfer room ownership",
|
||||
"/pass": "change room password"
|
||||
]
|
||||
|
||||
ForEach(commandSuggestions, id: \.self) { command in
|
||||
Button(action: {
|
||||
// Replace current text with selected command
|
||||
messageText = command + " "
|
||||
showCommandSuggestions = false
|
||||
commandSuggestions = []
|
||||
}) {
|
||||
HStack {
|
||||
Text(command)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
if let description = commandDescriptions[command] {
|
||||
Text(description)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
if viewModel.selectedPrivateChatPeer != nil {
|
||||
Text("<\(viewModel.nickname)> →")
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
@@ -497,6 +560,13 @@ struct ContentView: View {
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
.padding(.leading, 12)
|
||||
} else if let currentRoom = viewModel.currentRoom, viewModel.passwordProtectedRooms.contains(currentRoom) {
|
||||
Text("<\(viewModel.nickname)> →")
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(Color.orange)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
Text("<\(viewModel.nickname)>")
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
@@ -515,6 +585,27 @@ struct ContentView: View {
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
|
||||
// Check for command autocomplete
|
||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||
let commandDescriptions = [
|
||||
("/j", "join or create a room"),
|
||||
("/list", "show your joined rooms"),
|
||||
("/w", "see who's online"),
|
||||
("/m", "send private message"),
|
||||
("/clear", "clear chat messages"),
|
||||
("/transfer", "transfer room ownership"),
|
||||
("/pass", "change room password")
|
||||
]
|
||||
let input = newValue.lowercased()
|
||||
commandSuggestions = commandDescriptions
|
||||
.filter { $0.0.starts(with: input) }
|
||||
.map { $0.0 }
|
||||
showCommandSuggestions = !commandSuggestions.isEmpty
|
||||
} else {
|
||||
showCommandSuggestions = false
|
||||
commandSuggestions = []
|
||||
}
|
||||
}
|
||||
.onSubmit {
|
||||
sendMessage()
|
||||
@@ -523,13 +614,16 @@ struct ContentView: View {
|
||||
Button(action: sendMessage) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)
|
||||
.foregroundColor((viewModel.selectedPrivateChatPeer != nil ||
|
||||
(viewModel.currentRoom != nil && viewModel.passwordProtectedRooms.contains(viewModel.currentRoom ?? "")))
|
||||
? Color.orange : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
.onAppear {
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
@@ -574,9 +668,17 @@ struct ContentView: View {
|
||||
|
||||
ForEach(Array(viewModel.joinedRooms).sorted(), id: \.self) { room in
|
||||
Button(action: {
|
||||
viewModel.switchToRoom(room)
|
||||
withAnimation(.spring()) {
|
||||
showSidebar = false
|
||||
// Check if room needs password and we don't have it
|
||||
if viewModel.passwordProtectedRooms.contains(room) && viewModel.roomKeys[room] == nil {
|
||||
// Need password
|
||||
viewModel.passwordPromptRoom = room
|
||||
viewModel.showPasswordPrompt = true
|
||||
} else {
|
||||
// Can enter room
|
||||
viewModel.switchToRoom(room)
|
||||
withAnimation(.spring()) {
|
||||
showSidebar = false
|
||||
}
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
@@ -607,7 +709,7 @@ struct ContentView: View {
|
||||
// Room controls
|
||||
if viewModel.currentRoom == room {
|
||||
HStack(spacing: 4) {
|
||||
// Password button for room creator
|
||||
// Password button for room creator only
|
||||
if viewModel.roomCreators[room] == viewModel.meshService.myPeerID {
|
||||
Button(action: {
|
||||
// Toggle password protection
|
||||
@@ -619,14 +721,18 @@ struct ContentView: View {
|
||||
passwordInputRoom = room
|
||||
}
|
||||
}) {
|
||||
Image(systemName: viewModel.passwordProtectedRooms.contains(room) ? "lock.open" : "lock")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(4)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: viewModel.passwordProtectedRooms.contains(room) ? "lock.fill" : "lock")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.foregroundColor(viewModel.passwordProtectedRooms.contains(room) ? backgroundColor : secondaryTextColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(viewModel.passwordProtectedRooms.contains(room) ? Color.orange : Color.clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(viewModel.passwordProtectedRooms.contains(room) ? Color.orange : secondaryTextColor.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -846,22 +952,25 @@ struct MessageContentView: View {
|
||||
ForEach(Array(buildTextSegments().enumerated()), id: \.offset) { _, segment in
|
||||
if segment.type == "hashtag" {
|
||||
Button(action: {
|
||||
viewModel.joinRoom(segment.text)
|
||||
_ = viewModel.joinRoom(segment.text)
|
||||
}) {
|
||||
Text(segment.text)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
.underline()
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if segment.type == "mention" {
|
||||
Text(segment.text)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(Color.orange)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
Text(segment.text)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(isMentioned ? .bold : .regular)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user