mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:45:20 +00:00
iOS: deterministic Tor recovery + 100% gating; BLE-first; session rebuild
- Restart/wake Tor on foreground via ControlPort (ACTIVE/SHUTDOWN), avoid restarts during bootstrap; add NWPathMonitor to trigger checks - Use NWConnection control polling for GETINFO; remove blocking CFStream readers to avoid QoS inversions; compute readiness from SOCKS + 100% - Rebuild TorURLSession on resume; reset Nostr connections to rebind - Gate all internet after full bootstrap; keep BLE mesh startup fast - Fix Swift 6 capture issues; hop UI updates to @MainActor - Remove Tor progress spam; persist initial "starting tor..." system message
This commit is contained in:
@@ -43,6 +43,8 @@ struct BitchatApp: App {
|
||||
#elseif os(macOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#endif
|
||||
// Spin up Tor early; all internet will gate on Tor 100%
|
||||
TorManager.shared.startIfNeeded()
|
||||
// Check for shared content
|
||||
checkForSharedContent()
|
||||
}
|
||||
@@ -54,10 +56,18 @@ struct BitchatApp: App {
|
||||
switch newPhase {
|
||||
case .background:
|
||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||
// Optionally nudge Tor to dormant to save power
|
||||
TorManager.shared.goDormantOnBackground()
|
||||
break
|
||||
case .active:
|
||||
// Restart services when becoming active
|
||||
chatViewModel.meshService.startServices()
|
||||
// Ensure Tor is healthy; restart if suspended; wake ACTIVE
|
||||
TorManager.shared.ensureRunningOnForeground()
|
||||
// Rebuild proxied sessions to bind to live Tor
|
||||
TorURLSession.shared.rebuild()
|
||||
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
||||
NostrRelayManager.shared.resetAllConnections()
|
||||
checkForSharedContent()
|
||||
case .inactive:
|
||||
break
|
||||
|
||||
@@ -24,11 +24,16 @@ final class TorManager: ObservableObject {
|
||||
let controlPort: Int = 39051
|
||||
|
||||
// State
|
||||
// True only when SOCKS is reachable AND bootstrap has reached 100%.
|
||||
@Published private(set) var isReady: Bool = false
|
||||
@Published private(set) var isStarting: Bool = false
|
||||
@Published private(set) var lastError: Error?
|
||||
@Published private(set) var bootstrapProgress: Int = 0
|
||||
@Published private(set) var bootstrapSummary: String = ""
|
||||
|
||||
// Internal readiness trackers
|
||||
private var socksReady: Bool = false { didSet { recomputeReady() } }
|
||||
private var restarting: Bool = false
|
||||
|
||||
// Whether the app must enforce Tor for all connections (fail-closed).
|
||||
// This is the default. For local development, you may compile with
|
||||
@@ -50,6 +55,7 @@ final class TorManager: ObservableObject {
|
||||
|
||||
private var didStart = false
|
||||
private var controlMonitorStarted = false
|
||||
private var pathMonitor: NWPathMonitor?
|
||||
|
||||
private init() {}
|
||||
|
||||
@@ -62,6 +68,7 @@ final class TorManager: ObservableObject {
|
||||
lastError = nil
|
||||
ensureFilesystemLayout()
|
||||
startTor()
|
||||
startPathMonitorIfNeeded()
|
||||
}
|
||||
|
||||
/// Await Tor bootstrap to readiness. Returns true if network is permitted (Tor ready or dev bypass).
|
||||
@@ -257,20 +264,18 @@ final class TorManager: ObservableObject {
|
||||
|
||||
// Start control-port monitor and probe readiness asynchronously
|
||||
startControlMonitorIfNeeded()
|
||||
// Start control-port monitor and probe readiness asynchronously
|
||||
startControlMonitorIfNeeded()
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self else { return }
|
||||
let ready = await self.waitForSocksReady(timeout: 60.0)
|
||||
await MainActor.run {
|
||||
self.isReady = ready
|
||||
self.isStarting = false
|
||||
self.socksReady = ready
|
||||
if !ready {
|
||||
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"])
|
||||
SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error)
|
||||
} else {
|
||||
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info)
|
||||
}
|
||||
// isStarting will be cleared when bootstrap reaches 100%
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,18 +335,18 @@ final class TorManager: ObservableObject {
|
||||
|
||||
// Start control monitor early
|
||||
startControlMonitorIfNeeded()
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self else { return }
|
||||
let ready = await self.waitForSocksReady(timeout: 60.0)
|
||||
await MainActor.run {
|
||||
self.isReady = ready
|
||||
self.isStarting = false
|
||||
self.socksReady = ready
|
||||
if ready {
|
||||
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info)
|
||||
} else {
|
||||
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
|
||||
SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error)
|
||||
}
|
||||
// isStarting will be cleared when bootstrap reaches 100%
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -349,102 +354,170 @@ final class TorManager: ObservableObject {
|
||||
|
||||
// MARK: - ControlPort monitoring (bootstrap progress)
|
||||
private func startControlMonitorIfNeeded() {
|
||||
#if os(iOS)
|
||||
// iOS: no-op; we skip ControlPort monitoring to keep startup lean.
|
||||
return
|
||||
#else
|
||||
guard !controlMonitorStarted else { return }
|
||||
controlMonitorStarted = true
|
||||
Task.detached(priority: .background) { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.controlMonitorLoop()
|
||||
// Use a simple GETINFO poll on all platforms to avoid long-lived blocking streams
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
await self?.bootstrapPollLoop()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func controlMonitorLoop() async {
|
||||
private func controlMonitorLoop() async {}
|
||||
|
||||
private func tryControlSessionOnce() async -> Bool { false }
|
||||
|
||||
// iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers.
|
||||
private func bootstrapPollLoop() async {
|
||||
let deadline = Date().addingTimeInterval(75)
|
||||
while Date() < deadline {
|
||||
if await self.tryControlSessionOnce() { return }
|
||||
if let info = await controlGetBootstrapInfo() {
|
||||
await MainActor.run {
|
||||
self.bootstrapProgress = info.progress
|
||||
self.bootstrapSummary = info.summary
|
||||
if info.progress >= 100 { self.isStarting = false }
|
||||
self.recomputeReady()
|
||||
}
|
||||
if info.progress >= 100 { break }
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func tryControlSessionOnce() async -> Bool {
|
||||
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie") else { return false }
|
||||
guard let cookie = try? Data(contentsOf: cookiePath) else { return false }
|
||||
private func controlGetBootstrapInfo() async -> (progress: Int, summary: String)? {
|
||||
guard let text = await controlExchange(lines: ["GETINFO status/bootstrap-phase"], timeout: 2.0) else { return nil }
|
||||
var progress = self.bootstrapProgress
|
||||
var summary = self.bootstrapSummary
|
||||
// Search entire response for PROGRESS and SUMMARY tokens
|
||||
// Typical: "250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=75 TAG=... SUMMARY=\"...\"\r\n250 OK\r\n"
|
||||
let tokens = text.replacingOccurrences(of: "\r", with: " ").replacingOccurrences(of: "\n", with: " ").split(separator: " ")
|
||||
for t in tokens {
|
||||
if t.hasPrefix("PROGRESS=") {
|
||||
progress = Int(t.split(separator: "=").last ?? "0") ?? progress
|
||||
} else if t.hasPrefix("SUMMARY=") {
|
||||
let raw = String(t.dropFirst("SUMMARY=".count))
|
||||
summary = raw.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
}
|
||||
}
|
||||
return (progress, summary)
|
||||
}
|
||||
|
||||
// MARK: - Foreground recovery and control helpers
|
||||
|
||||
func ensureRunningOnForeground() {
|
||||
// If we can talk to ControlPort, wake Tor and verify bootstrap; else restart.
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Avoid restarts while starting/restarting
|
||||
if await MainActor.run(body: { self.isStarting || self.restarting }) { return }
|
||||
let ok = await self.controlPingBootstrap()
|
||||
if ok {
|
||||
_ = await self.controlSendSignal("ACTIVE")
|
||||
return
|
||||
}
|
||||
// If Tor is still bootstrapping (SOCKS up but progress < 100), don't thrash; let monitor update
|
||||
let stillBootstrapping = await MainActor.run(body: { self.socksReady && self.bootstrapProgress < 100 })
|
||||
if stillBootstrapping { return }
|
||||
await self.restartTor()
|
||||
}
|
||||
}
|
||||
|
||||
func goDormantOnBackground() {
|
||||
Task.detached { [weak self] in
|
||||
_ = await self?.controlSendSignal("DORMANT")
|
||||
}
|
||||
}
|
||||
|
||||
private func restartTor() async {
|
||||
await MainActor.run { self.restarting = true; self.isReady = false; self.socksReady = false; self.bootstrapProgress = 0; self.bootstrapSummary = ""; self.isStarting = true }
|
||||
// Try graceful shutdown if control is reachable
|
||||
_ = await controlSendSignal("SHUTDOWN")
|
||||
// Wait for SOCKS to go down
|
||||
let downDeadline = Date().addingTimeInterval(5)
|
||||
while Date() < downDeadline {
|
||||
if await !probeSocksOnce() { break }
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
await MainActor.run { self.didStart = false }
|
||||
await MainActor.run { self.startIfNeeded() }
|
||||
await MainActor.run { self.restarting = false }
|
||||
}
|
||||
|
||||
private func recomputeReady() {
|
||||
let ready = socksReady && bootstrapProgress >= 100
|
||||
if ready != isReady {
|
||||
isReady = ready
|
||||
}
|
||||
}
|
||||
|
||||
private func startPathMonitorIfNeeded() {
|
||||
guard pathMonitor == nil else { return }
|
||||
let monitor = NWPathMonitor()
|
||||
pathMonitor = monitor
|
||||
let queue = DispatchQueue(label: "TorPathMonitor")
|
||||
monitor.pathUpdateHandler = { [weak self] _ in
|
||||
// On any path change, poke Tor/recover (hop to main actor).
|
||||
Task { @MainActor in
|
||||
self?.ensureRunningOnForeground()
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
// Lightweight control: authenticate and GETINFO bootstrap-phase.
|
||||
private func controlPingBootstrap(timeout: TimeInterval = 3.0) async -> Bool {
|
||||
let data = await controlExchange(lines: ["GETINFO status/bootstrap-phase"], timeout: timeout)
|
||||
guard let text = data else { return false }
|
||||
return text.contains("status/bootstrap-phase")
|
||||
}
|
||||
|
||||
private func controlSendSignal(_ signal: String, timeout: TimeInterval = 3.0) async -> Bool {
|
||||
let text = await controlExchange(lines: ["SIGNAL \(signal)"], timeout: timeout)
|
||||
return (text?.contains("250")) == true
|
||||
}
|
||||
|
||||
private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? {
|
||||
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"),
|
||||
let cookie = try? Data(contentsOf: cookiePath) else { return nil }
|
||||
let cookieHex = cookie.map { String(format: "%02X", $0) }.joined()
|
||||
|
||||
var inStream: InputStream?
|
||||
var outStream: OutputStream?
|
||||
Stream.getStreamsToHost(withName: controlHost, port: controlPort, inputStream: &inStream, outputStream: &outStream)
|
||||
guard let input = inStream, let output = outStream else { return false }
|
||||
input.open(); output.open()
|
||||
let queue = DispatchQueue(label: "TorControl", qos: .userInitiated)
|
||||
let params = NWParameters.tcp
|
||||
guard let port = NWEndpoint.Port(rawValue: UInt16(controlPort)) else { return nil }
|
||||
let endpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: port)
|
||||
let conn = NWConnection(to: endpoint, using: params)
|
||||
|
||||
func send(_ s: String) {
|
||||
let bytes = Array(s.utf8)
|
||||
_ = bytes.withUnsafeBytes { raw -> Int in
|
||||
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return 0 }
|
||||
return output.write(base, maxLength: bytes.count)
|
||||
}
|
||||
var resultText = ""
|
||||
var completed = false
|
||||
func send(_ text: String) {
|
||||
let data = (text + "\r\n").data(using: .utf8) ?? Data()
|
||||
conn.send(content: data, completion: .contentProcessed { _ in })
|
||||
}
|
||||
func readLine(timeout: TimeInterval = 3.0) -> String? {
|
||||
var data = Data()
|
||||
let start = Date()
|
||||
var buf = [UInt8](repeating: 0, count: 1024)
|
||||
while Date().timeIntervalSince(start) < timeout {
|
||||
if input.hasBytesAvailable {
|
||||
let n = input.read(&buf, maxLength: buf.count)
|
||||
if n > 0 {
|
||||
data.append(buf, count: n)
|
||||
if let range = data.range(of: Data([13,10])) { // CRLF
|
||||
let lineData = data.prefix(upTo: range.lowerBound)
|
||||
let line = String(data: lineData, encoding: .utf8)
|
||||
let rest = data.suffix(from: range.upperBound)
|
||||
data = Data(rest)
|
||||
return line
|
||||
func receiveLoop(deadline: Date) async {
|
||||
while Date() < deadline {
|
||||
let ok: Bool = await withCheckedContinuation { cont in
|
||||
conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, isComplete, error in
|
||||
if let data = data, !data.isEmpty, let s = String(data: data, encoding: .utf8) {
|
||||
resultText.append(s)
|
||||
}
|
||||
} else if n == 0 {
|
||||
break
|
||||
if isComplete || error != nil { completed = true }
|
||||
cont.resume(returning: true)
|
||||
}
|
||||
}
|
||||
usleep(20_000)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Greeting
|
||||
_ = readLine()
|
||||
send("AUTHENTICATE \(cookieHex)\r\n")
|
||||
guard let auth = readLine(), auth.hasPrefix("250") else {
|
||||
input.close(); output.close(); return false
|
||||
}
|
||||
send("SETEVENTS STATUS_CLIENT\r\n")
|
||||
_ = readLine() // 250 OK
|
||||
|
||||
while true {
|
||||
if Task.isCancelled { break }
|
||||
guard let line = readLine(timeout: 10.0) else { continue }
|
||||
if line.hasPrefix("650 ") && line.contains("BOOTSTRAP") {
|
||||
var progress = self.bootstrapProgress
|
||||
var summary = self.bootstrapSummary
|
||||
for part in line.split(separator: " ") {
|
||||
if part.hasPrefix("PROGRESS=") {
|
||||
progress = Int(part.split(separator: "=").last ?? "0") ?? progress
|
||||
} else if part.hasPrefix("SUMMARY=") {
|
||||
let raw = String(part.dropFirst("SUMMARY=".count))
|
||||
summary = raw.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
self.bootstrapProgress = progress
|
||||
self.bootstrapSummary = summary
|
||||
}
|
||||
if progress >= 100 { break }
|
||||
if !ok || completed { break }
|
||||
// Small delay to avoid tight loop
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
input.close(); output.close()
|
||||
return true
|
||||
conn.start(queue: queue)
|
||||
// Send immediately; NWConnection will queue until ready
|
||||
send("AUTHENTICATE \(cookieHex)")
|
||||
// Send requested lines
|
||||
for line in lines { send(line) }
|
||||
// Ask tor to close
|
||||
send("QUIT")
|
||||
await receiveLoop(deadline: Date().addingTimeInterval(timeout))
|
||||
conn.cancel()
|
||||
return resultText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,38 @@ final class TorURLSession {
|
||||
static let shared = TorURLSession()
|
||||
|
||||
// Default (no proxy) session for local development when dev bypass is enabled.
|
||||
private lazy var defaultSession: URLSession = {
|
||||
private var defaultSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
// Proxied (SOCKS5) session that routes through Tor.
|
||||
private lazy var torSession: URLSession = {
|
||||
private var torSession: URLSession = TorURLSession.makeTorSession()
|
||||
|
||||
var session: URLSession {
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
// Dev bypass: use direct session. Call sites may still await Tor if desired.
|
||||
return defaultSession
|
||||
#else
|
||||
// Production: always use the Tor-proxied session. Call sites ensure readiness.
|
||||
return torSession
|
||||
#endif
|
||||
}
|
||||
|
||||
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
|
||||
func rebuild() {
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
defaultSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
#endif
|
||||
torSession = TorURLSession.makeTorSession()
|
||||
}
|
||||
|
||||
private static func makeTorSession() -> URLSession {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.waitsForConnectivity = true
|
||||
// Keep in sync with TorManager defaults
|
||||
@@ -31,10 +55,6 @@ final class TorURLSession {
|
||||
]
|
||||
#else
|
||||
// iOS: CFNetwork SOCKS proxy keys are unavailable at compile time.
|
||||
// Using the documented string keys keeps the build green. On some iOS
|
||||
// versions, URLSession may ignore per-session SOCKS; we still enforce
|
||||
// Tor via fail-closed gating and can add platform-specific transport
|
||||
// if needed.
|
||||
cfg.connectionProxyDictionary = [
|
||||
"SOCKSEnable": 1,
|
||||
"SOCKSProxy": host,
|
||||
@@ -42,15 +62,5 @@ final class TorURLSession {
|
||||
]
|
||||
#endif
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
var session: URLSession {
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
// Dev bypass: use direct session. Call sites may still await Tor if desired.
|
||||
return defaultSession
|
||||
#else
|
||||
// Production: always use the Tor-proxied session. Call sites ensure readiness.
|
||||
return torSession
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,30 +533,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Announce Tor status to the chat timeline as early as possible
|
||||
if TorManager.shared.torEnforced && !torStatusAnnounced {
|
||||
torStatusAnnounced = true
|
||||
addPublicSystemMessage("Starting Tor…")
|
||||
torProgressCancellable = TorManager.shared.$bootstrapProgress
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] p in
|
||||
guard let self = self else { return }
|
||||
if p == 0 { return }
|
||||
if p == 100 || p / 10 > self.lastTorProgressAnnounced / 10 {
|
||||
self.lastTorProgressAnnounced = p
|
||||
let summary = TorManager.shared.bootstrapSummary
|
||||
let msg = summary.isEmpty ? "Tor \(p)%" : "Tor \(p)% – \(summary)"
|
||||
self.addPublicSystemMessage(msg)
|
||||
}
|
||||
}
|
||||
Task.detached { [weak self] in
|
||||
addPublicSystemMessage("starting tor...")
|
||||
// Suppress incremental Tor progress messages; only show initial start and readiness.
|
||||
torProgressCancellable = nil
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
guard let strongSelf = self else { return }
|
||||
if ready { strongSelf.addPublicSystemMessage("Tor ready. Routing all connections via Tor.") }
|
||||
else { strongSelf.addPublicSystemMessage("Still waiting for Tor to start…") }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if ready { self.addPublicSystemMessage("tor started. routing all chats via tor for privacy.") }
|
||||
else { self.addPublicSystemMessage("still waiting for tor to start...") }
|
||||
}
|
||||
}
|
||||
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
||||
torStatusAnnounced = true
|
||||
addPublicSystemMessage("Development build: Tor bypass enabled.")
|
||||
addPublicSystemMessage("development build: tor bypass enabled.")
|
||||
}
|
||||
|
||||
// Initialize Nostr services
|
||||
@@ -4906,10 +4896,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
messages.append(systemMessage)
|
||||
}
|
||||
|
||||
/// Public helper to add a system message to the public chat timeline
|
||||
/// Public helper to add a system message to the public chat timeline.
|
||||
/// Also persists the message into the active channel's backing store so it survives timeline rebinds.
|
||||
@MainActor
|
||||
func addPublicSystemMessage(_ content: String) {
|
||||
addSystemMessage(content)
|
||||
let systemMessage = BitchatMessage(
|
||||
sender: "system",
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
// Append to current visible messages
|
||||
messages.append(systemMessage)
|
||||
// Persist into the backing store for the active channel to survive rebinds
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
meshTimeline.append(systemMessage)
|
||||
case .location(let ch):
|
||||
var arr = geoTimelines[ch.geohash] ?? []
|
||||
arr.append(systemMessage)
|
||||
geoTimelines[ch.geohash] = arr
|
||||
}
|
||||
objectWillChange.send()
|
||||
}
|
||||
// Send a public message without adding a local user echo.
|
||||
|
||||
Reference in New Issue
Block a user