mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-11 22:38:01 +00:00
Merge branch 'master' into nd/fix-pending-connection-modal-stacking
# Conflicts: # apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt
This commit is contained in:
commit
db8a2f8aa5
143 files changed
+9738
-10981
No files matched your search
@@ -1850,8 +1850,8 @@ func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandida
|
||||
try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer))
|
||||
}
|
||||
|
||||
func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: String) async throws {
|
||||
let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates)
|
||||
func apiSendCallExtraInfo(_ contact: Contact, _ rtcSession: String?, _ rtcIceCandidates: String) async throws {
|
||||
let extraInfo = WebRTCExtraInfo(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
|
||||
try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo))
|
||||
}
|
||||
|
||||
@@ -2987,7 +2987,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
|
||||
}
|
||||
case let .callExtraInfo(_, contact, extraInfo):
|
||||
await withCall(contact) { _ in
|
||||
await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates))
|
||||
await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates, session: extraInfo.rtcSession))
|
||||
}
|
||||
case let .callEnded(_, contact):
|
||||
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
|
||||
|
||||
@@ -161,10 +161,10 @@ struct ActiveCallView: View {
|
||||
CallSoundsPlayer.shared.stop()
|
||||
}
|
||||
}
|
||||
case let .ice(iceCandidates):
|
||||
case let .ice(iceCandidates, session):
|
||||
Task {
|
||||
do {
|
||||
try await apiSendCallExtraInfo(call.contact, iceCandidates)
|
||||
try await apiSendCallExtraInfo(call.contact, session, iceCandidates)
|
||||
} catch {
|
||||
logger.error("apiSendCallExtraInfo \(responseError(error))")
|
||||
}
|
||||
@@ -172,15 +172,24 @@ struct ActiveCallView: View {
|
||||
case let .connection(state):
|
||||
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
|
||||
case .connected = callStatus {
|
||||
call.direction == .outgoing
|
||||
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
||||
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
|
||||
// connectedAt is kept on reconnection, so that call duration is not reset
|
||||
if call.connectedAt == nil {
|
||||
call.direction == .outgoing
|
||||
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
||||
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
|
||||
call.connectedAt = .now
|
||||
}
|
||||
call.callState = .connected
|
||||
call.connectedAt = .now
|
||||
CallSoundsPlayer.shared.stop()
|
||||
if !wasConnected {
|
||||
CallSoundsPlayer.shared.vibrate(long: false)
|
||||
wasConnected = true
|
||||
}
|
||||
} else if state.connectionState == "reconnecting" {
|
||||
if call.callState != .reconnecting {
|
||||
CallSoundsPlayer.shared.startConnectingCallSound()
|
||||
}
|
||||
call.callState = .reconnecting
|
||||
}
|
||||
if state.connectionState == "closed" {
|
||||
closeCallView(client)
|
||||
@@ -190,17 +199,22 @@ struct ActiveCallView: View {
|
||||
m.activeCall = nil
|
||||
m.activeCallViewIsCollapsed = false
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await apiCallStatus(call.contact, state.connectionState)
|
||||
} catch {
|
||||
logger.error("apiCallStatus \(responseError(error))")
|
||||
// "reconnecting" is not a core call status
|
||||
if state.connectionState != "reconnecting" {
|
||||
Task {
|
||||
do {
|
||||
try await apiCallStatus(call.contact, state.connectionState)
|
||||
} catch {
|
||||
logger.error("apiCallStatus \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .connected(connectionInfo):
|
||||
call.callState = .connected
|
||||
call.connectionInfo = connectionInfo
|
||||
call.connectedAt = .now
|
||||
if call.connectedAt == nil {
|
||||
call.connectedAt = .now
|
||||
}
|
||||
if !wasConnected {
|
||||
CallSoundsPlayer.shared.vibrate(long: false)
|
||||
wasConnected = true
|
||||
|
||||
@@ -77,6 +77,7 @@ enum CallState {
|
||||
case answerReceived // incoming - webrtc answer received via API
|
||||
case negotiated // outgoing - webrtc offer processed and answer sent, incoming - webrtc answer processed
|
||||
case connected
|
||||
case reconnecting
|
||||
case ended
|
||||
|
||||
var text: LocalizedStringKey {
|
||||
@@ -89,6 +90,7 @@ enum CallState {
|
||||
case .answerReceived: return "received confirmation…"
|
||||
case .negotiated: return "connecting…"
|
||||
case .connected: return "connected"
|
||||
case .reconnecting: return "reconnecting…"
|
||||
case .ended: return "ended"
|
||||
}
|
||||
}
|
||||
@@ -119,7 +121,7 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
||||
case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
||||
case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
||||
case answer(answer: String, iceCandidates: String)
|
||||
case ice(iceCandidates: String)
|
||||
case ice(iceCandidates: String, session: String? = nil)
|
||||
case media(source: CallMediaSource, enable: Bool)
|
||||
case end
|
||||
|
||||
@@ -131,6 +133,7 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
||||
case offer
|
||||
case answer
|
||||
case iceCandidates
|
||||
case session
|
||||
case enable
|
||||
case iceServers
|
||||
case relay
|
||||
@@ -174,9 +177,10 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
||||
try container.encode("answer", forKey: .type)
|
||||
try container.encode(answer, forKey: .answer)
|
||||
try container.encode(iceCandidates, forKey: .iceCandidates)
|
||||
case let .ice(iceCandidates):
|
||||
case let .ice(iceCandidates, session):
|
||||
try container.encode("ice", forKey: .type)
|
||||
try container.encode(iceCandidates, forKey: .iceCandidates)
|
||||
try container.encodeIfPresent(session, forKey: .session)
|
||||
case let .media(source, enable):
|
||||
try container.encode("media", forKey: .type)
|
||||
try container.encode(source, forKey: .media)
|
||||
@@ -213,7 +217,8 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
||||
self = .answer(answer: answer, iceCandidates: iceCandidates)
|
||||
case "ice":
|
||||
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
|
||||
self = .ice(iceCandidates: iceCandidates)
|
||||
let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session)
|
||||
self = .ice(iceCandidates: iceCandidates, session: session)
|
||||
case "media":
|
||||
let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
|
||||
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
|
||||
@@ -231,7 +236,7 @@ enum WCallResponse: Equatable, Decodable {
|
||||
case capabilities(capabilities: CallCapabilities)
|
||||
case offer(offer: String, iceCandidates: String, capabilities: CallCapabilities)
|
||||
case answer(answer: String, iceCandidates: String)
|
||||
case ice(iceCandidates: String)
|
||||
case ice(iceCandidates: String, session: String? = nil)
|
||||
case connection(state: ConnectionState)
|
||||
case connected(connectionInfo: ConnectionInfo)
|
||||
case peerMedia(source: CallMediaSource, enabled: Bool)
|
||||
@@ -246,6 +251,7 @@ enum WCallResponse: Equatable, Decodable {
|
||||
case offer
|
||||
case answer
|
||||
case iceCandidates
|
||||
case session
|
||||
case state
|
||||
case connectionInfo
|
||||
case message
|
||||
@@ -290,7 +296,8 @@ enum WCallResponse: Equatable, Decodable {
|
||||
self = .answer(answer: answer, iceCandidates: iceCandidates)
|
||||
case "ice":
|
||||
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
|
||||
self = .ice(iceCandidates: iceCandidates)
|
||||
let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session)
|
||||
self = .ice(iceCandidates: iceCandidates, session: session)
|
||||
case "connection":
|
||||
let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state)
|
||||
self = .connection(state: state)
|
||||
@@ -333,9 +340,10 @@ extension WCallResponse: Encodable {
|
||||
try container.encode("answer", forKey: .type)
|
||||
try container.encode(answer, forKey: .answer)
|
||||
try container.encode(iceCandidates, forKey: .iceCandidates)
|
||||
case let .ice(iceCandidates):
|
||||
case let .ice(iceCandidates, session):
|
||||
try container.encode("ice", forKey: .type)
|
||||
try container.encode(iceCandidates, forKey: .iceCandidates)
|
||||
try container.encodeIfPresent(session, forKey: .session)
|
||||
case let .connection(state):
|
||||
try container.encode("connection", forKey: .type)
|
||||
try container.encode(state, forKey: .state)
|
||||
|
||||
@@ -20,6 +20,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}()
|
||||
private static let ivTagBytes: Int = 28
|
||||
private static let enableEncryption: Bool = true
|
||||
private static let reconnectTimeout: UInt64 = 30_000_000_000
|
||||
private static let iceRestartInterval: UInt64 = 10_000_000_000
|
||||
private var chat_ctrl = getChatCtrl()
|
||||
|
||||
struct Call {
|
||||
@@ -37,6 +39,9 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
var frameEncryptor: RTCFrameEncryptor?
|
||||
var frameDecryptor: RTCFrameDecryptor?
|
||||
var peerHasOldVersion: Bool
|
||||
var wasConnected = false
|
||||
var reconnection: Task<Void, Never>? = nil
|
||||
var iceRestarts: Task<Void, Never>? = nil
|
||||
}
|
||||
|
||||
struct NotConnectedCall {
|
||||
@@ -257,11 +262,15 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
resp = .error(message: "answer: remote description is not set")
|
||||
}
|
||||
}
|
||||
case let .ice(iceCandidates):
|
||||
case let .ice(iceCandidates, session):
|
||||
if let pc = pc,
|
||||
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
|
||||
addIceCandidates(pc, remoteIceCandidates)
|
||||
resp = .ok
|
||||
if let session {
|
||||
resp = await receiveRestartSession(pc, session, remoteIceCandidates)
|
||||
} else {
|
||||
addIceCandidates(pc, remoteIceCandidates)
|
||||
resp = .ok
|
||||
}
|
||||
} else {
|
||||
resp = .error(message: "ice: call not started")
|
||||
}
|
||||
@@ -301,6 +310,101 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
private func startReconnecting(_ connection: RTCPeerConnection) async {
|
||||
activeCall?.reconnection = Task {
|
||||
_ = try? await Task.sleep(nanoseconds: WebRTCClient.reconnectTimeout)
|
||||
if Task.isCancelled { return }
|
||||
await sendConnectionState(connection, "disconnected")
|
||||
endCall()
|
||||
}
|
||||
// Only the party that sent the initial offer restarts ICE, so that restart offers never collide
|
||||
if connection.localDescription?.type == .offer {
|
||||
activeCall?.iceRestarts = Task {
|
||||
while !Task.isCancelled {
|
||||
await restartIce(connection)
|
||||
_ = try? await Task.sleep(nanoseconds: WebRTCClient.iceRestartInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
await sendConnectionState(connection, "reconnecting")
|
||||
}
|
||||
|
||||
private func stopReconnecting() {
|
||||
activeCall?.reconnection?.cancel()
|
||||
activeCall?.reconnection = nil
|
||||
activeCall?.iceRestarts?.cancel()
|
||||
activeCall?.iceRestarts = nil
|
||||
}
|
||||
|
||||
private func restartIce(_ connection: RTCPeerConnection) async {
|
||||
// the previous restart offer is not answered yet
|
||||
if connection.signalingState != .stable { return }
|
||||
let (offer, error) = await connection.offer(iceRestart: true)
|
||||
if let offer {
|
||||
await sendRestartSession(offer)
|
||||
} else {
|
||||
logger.error("WebRTCClient: ICE restart error: \(error?.localizedDescription ?? "unknown error")")
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveRestartSession(_ pc: RTCPeerConnection, _ session: String, _ remoteIceCandidates: [RTCIceCandidate]) async -> WCallResponse {
|
||||
guard let desc: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: session)),
|
||||
let type = desc.type, let sdp = desc.sdp else {
|
||||
return .error(message: "ice: invalid session")
|
||||
}
|
||||
let remoteDesc = RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp)
|
||||
if type == .offer && pc.localDescription?.type != .offer {
|
||||
if (try? await pc.setRemoteDescription(remoteDesc)) == nil {
|
||||
return .error(message: "ice: remote offer is not set")
|
||||
}
|
||||
let (answer, error) = await pc.answer()
|
||||
guard let answer else {
|
||||
return .error(message: "ice: answer error: \(error?.localizedDescription ?? "unknown error")")
|
||||
}
|
||||
addIceCandidates(pc, remoteIceCandidates)
|
||||
await sendRestartSession(answer)
|
||||
} else if type == .answer && pc.signalingState == .haveLocalOffer {
|
||||
if (try? await pc.setRemoteDescription(remoteDesc)) == nil {
|
||||
return .error(message: "ice: remote answer is not set")
|
||||
}
|
||||
addIceCandidates(pc, remoteIceCandidates)
|
||||
} else {
|
||||
return .error(message: "ice: unexpected ICE restart \(type.rawValue)")
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
|
||||
private func sendRestartSession(_ desc: RTCSessionDescription) async {
|
||||
await sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .ice(
|
||||
iceCandidates: compressToBase64(input: encodeJSON(await getInitialIceCandidates())),
|
||||
session: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: desc.type.toSdpType(), sdp: desc.sdp)))
|
||||
),
|
||||
command: nil)
|
||||
)
|
||||
waitForMoreIceCandidates()
|
||||
}
|
||||
|
||||
private func sendConnectionState(_ connection: RTCPeerConnection, _ connectionState: String) async {
|
||||
guard let iceConnectionState = connection.iceConnectionState.toString(),
|
||||
let iceGatheringState = connection.iceGatheringState.toString(),
|
||||
let signalingState = connection.signalingState.toString()
|
||||
else {
|
||||
return
|
||||
}
|
||||
await sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .connection(state: ConnectionState(
|
||||
connectionState: connectionState,
|
||||
iceConnectionState: iceConnectionState,
|
||||
iceGatheringState: iceGatheringState,
|
||||
signalingState: signalingState)
|
||||
),
|
||||
command: nil)
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#sendIceCandidates
|
||||
func sendIceCandidates(_ candidates: [RTCIceCandidate]) async {
|
||||
await self.sendCallResponse(.init(
|
||||
@@ -657,6 +761,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
(notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
|
||||
guard let call = activeCall else { return }
|
||||
logger.debug("WebRTCClient: ending the call")
|
||||
call.reconnection?.cancel()
|
||||
call.iceRestarts?.cancel()
|
||||
call.connection.close()
|
||||
call.connection.delegate = nil
|
||||
call.frameEncryptor?.delegate = nil
|
||||
@@ -678,16 +784,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
|
||||
|
||||
extension WebRTC.RTCPeerConnection {
|
||||
func mediaConstraints() -> RTCMediaConstraints {
|
||||
RTCMediaConstraints(
|
||||
mandatoryConstraints: [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue,
|
||||
kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue],
|
||||
optionalConstraints: nil)
|
||||
func mediaConstraints(iceRestart: Bool = false) -> RTCMediaConstraints {
|
||||
var constraints = [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue,
|
||||
kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue]
|
||||
if iceRestart {
|
||||
constraints[kRTCMediaConstraintsIceRestart] = kRTCMediaConstraintsValueTrue
|
||||
}
|
||||
return RTCMediaConstraints(mandatoryConstraints: constraints, optionalConstraints: nil)
|
||||
}
|
||||
|
||||
func offer() async -> (RTCSessionDescription?, Error?) {
|
||||
func offer(iceRestart: Bool = false) async -> (RTCSessionDescription?, Error?) {
|
||||
await withCheckedContinuation { cont in
|
||||
offer(for: mediaConstraints()) { (sdp, error) in
|
||||
offer(for: mediaConstraints(iceRestart: iceRestart)) { (sdp, error) in
|
||||
self.processSDP(cont, sdp, error)
|
||||
}
|
||||
}
|
||||
@@ -766,24 +874,20 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
|
||||
debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)")
|
||||
|
||||
guard let connectionStateString = newState.toString(),
|
||||
let iceConnectionStateString = connection.iceConnectionState.toString(),
|
||||
let iceGatheringStateString = connection.iceGatheringState.toString(),
|
||||
let signalingStateString = connection.signalingState.toString()
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard let connectionStateString = newState.toString() else { return }
|
||||
Task {
|
||||
await sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .connection(state: ConnectionState(
|
||||
connectionState: connectionStateString,
|
||||
iceConnectionState: iceConnectionStateString,
|
||||
iceGatheringState: iceGatheringStateString,
|
||||
signalingState: signalingStateString)
|
||||
),
|
||||
command: nil)
|
||||
)
|
||||
if activeCall?.wasConnected == true {
|
||||
switch newState {
|
||||
case .disconnected, .failed:
|
||||
if activeCall?.reconnection == nil {
|
||||
await startReconnecting(connection)
|
||||
}
|
||||
return
|
||||
case .new, .checking: return
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
await sendConnectionState(connection, connectionStateString)
|
||||
|
||||
switch newState {
|
||||
case .checking:
|
||||
@@ -792,7 +896,10 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
}
|
||||
let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true
|
||||
setSpeakerEnabledAndConfigureSession(enableSpeaker)
|
||||
case .connected: sendConnectedEvent(connection)
|
||||
case .connected:
|
||||
stopReconnecting()
|
||||
activeCall?.wasConnected = true
|
||||
sendConnectedEvent(connection)
|
||||
case .disconnected, .failed: endCall()
|
||||
default: ()
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ struct FramedItemView: View {
|
||||
|
||||
@inline(__always)
|
||||
private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View {
|
||||
MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline, prefix: prefix, stripLink: stripLink)
|
||||
MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, markdownBlocks: qi.markdownBlocks, textStyle: .subheadline, prefix: prefix, stripLink: stripLink)
|
||||
.lineLimit(lines)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
@@ -364,13 +364,16 @@ struct FramedItemView: View {
|
||||
chat: chat,
|
||||
text: text,
|
||||
formattedText: ft,
|
||||
markdownBlocks: text == "" ? nil : ci.markdownBlocks,
|
||||
textStyle: .body,
|
||||
meta: ci.meta,
|
||||
mentions: ci.mentions,
|
||||
userMemberId: chat.chatInfo.groupInfo?.membership.memberId,
|
||||
rightToLeft: rtl,
|
||||
prefix: txtPrefix,
|
||||
stripLink: stripLink
|
||||
stripLink: stripLink,
|
||||
blockLayout: true,
|
||||
cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote
|
||||
)
|
||||
.environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme)))
|
||||
.multilineTextAlignment(rtl ? .trailing : .leading)
|
||||
|
||||
@@ -32,6 +32,7 @@ struct MsgContentView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var text: String
|
||||
var formattedText: [FormattedText]? = nil
|
||||
var markdownBlocks: [MarkdownBlock]? = nil
|
||||
var textStyle: UIFont.TextStyle
|
||||
var sender: String? = nil
|
||||
var meta: CIMeta? = nil
|
||||
@@ -40,6 +41,8 @@ struct MsgContentView: View {
|
||||
var rightToLeft = false
|
||||
var prefix: NSAttributedString? = nil
|
||||
var stripLink: String? = nil
|
||||
var blockLayout = false
|
||||
var cardColor: Color? = nil
|
||||
@State private var showSecrets: Set<Int> = []
|
||||
@State private var typingIdx = 0
|
||||
@State private var timer: Timer?
|
||||
@@ -51,7 +54,7 @@ struct MsgContentView: View {
|
||||
@AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true
|
||||
|
||||
var body: some View {
|
||||
let v = msgContentView()
|
||||
let v = contentView()
|
||||
if meta?.isLive == true {
|
||||
v.onAppear {
|
||||
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||||
@@ -95,11 +98,67 @@ struct MsgContentView: View {
|
||||
typingIdx = 0
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func contentView() -> some View {
|
||||
let fts = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText }
|
||||
if blockLayout, let fts, !fts.isEmpty, let blocks = markdownBlocks {
|
||||
MarkdownBlocksView(
|
||||
formattedText: fts,
|
||||
blocks: blocks,
|
||||
textStyle: textStyle,
|
||||
cardColor: cardColor ?? theme.appColors.receivedQuote,
|
||||
rightToLeft: rightToLeft,
|
||||
blockText: { index, range, font, secretStart, last in blockTextView(fts, blocks, index, range, font, secretStart, last) },
|
||||
metaLine: { metaLineView() }
|
||||
)
|
||||
} else {
|
||||
msgContentView()
|
||||
}
|
||||
}
|
||||
|
||||
private func blockTextView(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ index: Int, _ range: Range<Int>, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> some View {
|
||||
let r = messageText(
|
||||
text,
|
||||
fts,
|
||||
markdownBlocks: blocks,
|
||||
textStyle: textStyle,
|
||||
sender: index == 0 ? sender : nil,
|
||||
mentions: mentions,
|
||||
userMemberId: userMemberId,
|
||||
showSecrets: showSecrets,
|
||||
commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady,
|
||||
backgroundColor: containerBackground,
|
||||
prefix: index == 0 ? prefix : nil,
|
||||
range: range,
|
||||
baseFont: font,
|
||||
secretStart: secretStart
|
||||
)
|
||||
let s = r.string
|
||||
let t: Text
|
||||
if last, let mt = meta {
|
||||
if mt.isLive {
|
||||
s.append(typingIndicator(mt.recent))
|
||||
}
|
||||
t = Text(AttributedString(s)) + reserveSpaceForMeta(mt)
|
||||
} else {
|
||||
t = Text(AttributedString(s))
|
||||
}
|
||||
return msgTextResultView(r, t, showSecrets: $showSecrets, sendCommand: { cmd in sendCommandMsg(chat, cmd) })
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func metaLineView() -> some View {
|
||||
if let mt = meta {
|
||||
(mt.isLive ? Text(AttributedString(typingIndicator(mt.recent))) : Text(verbatim: "")) + reserveSpaceForMeta(mt, newLine: false)
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func msgContentView() -> some View {
|
||||
let r = messageText(
|
||||
text,
|
||||
formattedText,
|
||||
markdownBlocks: markdownBlocks,
|
||||
textStyle: textStyle,
|
||||
sender: sender,
|
||||
mentions: mentions,
|
||||
@@ -131,8 +190,8 @@ struct MsgContentView: View {
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||
(rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature)
|
||||
private func reserveSpaceForMeta(_ mt: CIMeta, newLine: Bool = true) -> Text {
|
||||
(rightToLeft && newLine ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,8 +376,11 @@ private struct FullProfileDescriptionView: View {
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
Section {
|
||||
let r = markdownText(description, showSecrets: showSecrets, backgroundColor: theme.colors.background)
|
||||
msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
|
||||
let parsed = parseSimpleXMarkdownText(description)
|
||||
MarkdownView(
|
||||
text: description, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks,
|
||||
backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -338,9 +400,11 @@ func markdownText(
|
||||
showSecrets: Set<Int>? = nil,
|
||||
backgroundColor: Color
|
||||
) -> MsgTextResult {
|
||||
messageText(
|
||||
let parsed = parseSimpleXMarkdownText(s)
|
||||
return messageText(
|
||||
s,
|
||||
parseSimpleXMarkdown(s),
|
||||
parsed?.formattedText,
|
||||
markdownBlocks: parsed?.markdownBlocks,
|
||||
textStyle: textStyle,
|
||||
sender: sender,
|
||||
preview: preview,
|
||||
@@ -396,6 +460,7 @@ func markdownProfileDescription(
|
||||
func messageText(
|
||||
_ text: String,
|
||||
_ formattedText: [FormattedText]?,
|
||||
markdownBlocks: [MarkdownBlock]? = nil,
|
||||
textStyle: UIFont.TextStyle = .body,
|
||||
sender: String?,
|
||||
preview: Bool = false,
|
||||
@@ -405,21 +470,24 @@ func messageText(
|
||||
commands: Bool = false,
|
||||
backgroundColor: UIColor,
|
||||
prefix: NSAttributedString? = nil,
|
||||
stripLink: String? = nil
|
||||
stripLink: String? = nil,
|
||||
range: Range<Int>? = nil,
|
||||
baseFont: UIFont? = nil,
|
||||
secretStart: Int = 0
|
||||
) -> MsgTextResult {
|
||||
let text = if let stripLink { stripTextLink(text, stripLink) } else { text }
|
||||
let formattedText = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText }
|
||||
let res = NSMutableAttributedString()
|
||||
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
|
||||
let font = UIFont.preferredFont(forTextStyle: textStyle)
|
||||
let descr = baseFont?.fontDescriptor ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
|
||||
let font = baseFont ?? UIFont.preferredFont(forTextStyle: textStyle)
|
||||
let plain: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: UIColor.label
|
||||
]
|
||||
let secretColor = backgroundColor.withAlphaComponent(1)
|
||||
var link: [NSAttributedString.Key: Any]?
|
||||
var hasSecrets = false
|
||||
var handleTaps = false
|
||||
var secretIdx: Int = secretStart
|
||||
|
||||
if let sender {
|
||||
if preview {
|
||||
@@ -437,19 +505,57 @@ func messageText(
|
||||
}
|
||||
|
||||
if let fts = formattedText, fts.count > 0 {
|
||||
if let range {
|
||||
let start = min(max(range.lowerBound, 0), fts.count)
|
||||
appendSegments(fts[start..<max(start, min(range.upperBound, fts.count))], false)
|
||||
} else if let markdownBlocks {
|
||||
let headings = headingSegments(fts.count, markdownBlocks)
|
||||
var i = 0
|
||||
while i < fts.count {
|
||||
var j = i + 1
|
||||
while j < fts.count && headings[j] == headings[i] { j += 1 }
|
||||
appendSegments(fts[i..<j], headings[i])
|
||||
i = j
|
||||
}
|
||||
} else {
|
||||
appendSegments(fts[...], false)
|
||||
}
|
||||
} else {
|
||||
res.append(NSMutableAttributedString(string: text, attributes: plain))
|
||||
}
|
||||
|
||||
return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps)
|
||||
|
||||
func segmentFonts(_ heading: Bool) -> (descr: UIFontDescriptor, font: UIFont) {
|
||||
if !heading { return (descr, font) }
|
||||
let d = descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]])
|
||||
return (d, UIFont(descriptor: d, size: d.pointSize))
|
||||
}
|
||||
|
||||
func appendSegments(_ fts: ArraySlice<FormattedText>, _ heading: Bool) {
|
||||
let (descr, font) = segmentFonts(heading)
|
||||
let plain: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: UIColor.label
|
||||
]
|
||||
var link: [NSAttributedString.Key: Any]?
|
||||
var bold: UIFont?
|
||||
var italic: UIFont?
|
||||
var snippet: UIFont?
|
||||
var small: UIFont?
|
||||
var mention: UIFont?
|
||||
var secretIdx: Int = 0
|
||||
for ft in fts {
|
||||
var t = ft.text
|
||||
var attrs = plain
|
||||
switch (ft.format) {
|
||||
case .bold:
|
||||
bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize)
|
||||
attrs[.font] = bold
|
||||
case let .bold(singleStar):
|
||||
if singleStar && markdownBlocks != nil {
|
||||
italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
|
||||
attrs[.font] = italic
|
||||
} else {
|
||||
bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize)
|
||||
attrs[.font] = bold
|
||||
}
|
||||
case .italic:
|
||||
italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
|
||||
attrs[.font] = italic
|
||||
@@ -496,7 +602,7 @@ func messageText(
|
||||
if let text { t = text }
|
||||
if !preview {
|
||||
attrs[linkAttrKey] = uri
|
||||
attrs[webLinkAttrKey] = true
|
||||
if !ft.isEmailLink { attrs[webLinkAttrKey] = true }
|
||||
handleTaps = true
|
||||
}
|
||||
case let .simplexLink(text, linkType, simplexUri, smpHosts):
|
||||
@@ -563,24 +669,319 @@ func messageText(
|
||||
attrs[modalAttrKey] = Format.modal(modalName: modalName, text: text)
|
||||
handleTaps = true
|
||||
}
|
||||
case .escape: t = ""
|
||||
case let .markup(token):
|
||||
if range != nil { t = token.drawnText } else if markdownBlocks != nil { t = token.previewText }
|
||||
case .unknown: ()
|
||||
case .none: ()
|
||||
}
|
||||
res.append(NSAttributedString(string: t, attributes: attrs))
|
||||
}
|
||||
} else {
|
||||
res.append(NSMutableAttributedString(string: text, attributes: plain))
|
||||
}
|
||||
|
||||
return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps)
|
||||
|
||||
func linkAttrs() -> [NSAttributedString.Key: Any] {
|
||||
link = link ?? [
|
||||
.font: font,
|
||||
.foregroundColor: uiLinkColor,
|
||||
.underlineStyle: NSUnderlineStyle.single.rawValue
|
||||
]
|
||||
return link!
|
||||
func linkAttrs() -> [NSAttributedString.Key: Any] {
|
||||
link = link ?? [
|
||||
.font: font,
|
||||
.foregroundColor: uiLinkColor,
|
||||
.underlineStyle: NSUnderlineStyle.single.rawValue
|
||||
]
|
||||
return link!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let swipeExclusionSpace = "swipeExclusion"
|
||||
|
||||
private let maxGridCells = 1000
|
||||
|
||||
struct SwipeExclusionFrames: PreferenceKey {
|
||||
static var defaultValue: [CGRect] = []
|
||||
|
||||
static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
|
||||
value += nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
struct MarkdownView: View {
|
||||
var text: String
|
||||
var formattedText: [FormattedText]?
|
||||
var markdownBlocks: [MarkdownBlock]?
|
||||
var sender: String? = nil
|
||||
var mentions: [String: CIMention]? = nil
|
||||
var userMemberId: String? = nil
|
||||
var backgroundColor: Color
|
||||
var cardColor: Color
|
||||
@Binding var showSecrets: Set<Int>
|
||||
|
||||
var body: some View {
|
||||
if let fts = formattedText, !fts.isEmpty, let blocks = markdownBlocks {
|
||||
MarkdownBlocksView(
|
||||
formattedText: fts,
|
||||
blocks: blocks,
|
||||
textStyle: .body,
|
||||
cardColor: cardColor,
|
||||
rightToLeft: isRightToLeft(text),
|
||||
blockText: { index, range, font, secretStart, _ in
|
||||
let r = messageText(
|
||||
text, fts, markdownBlocks: blocks, sender: index == 0 ? sender : nil, mentions: mentions, userMemberId: userMemberId,
|
||||
showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor), range: range, baseFont: font, secretStart: secretStart
|
||||
)
|
||||
msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
|
||||
},
|
||||
metaLine: { EmptyView() }
|
||||
)
|
||||
} else {
|
||||
let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor))
|
||||
msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BlocksLayout {
|
||||
let font: UIFont
|
||||
let starts: [Int]
|
||||
let secrets: [Int]
|
||||
let lastIsText: Bool
|
||||
|
||||
init(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ textStyle: UIFont.TextStyle) {
|
||||
font = UIFont.preferredFont(forTextStyle: textStyle)
|
||||
starts = blocks.reduce(into: [0]) { $0.append($0[$0.count - 1] + $1.textRanges.count) }
|
||||
secrets = fts.reduce(into: [0]) { $0.append($0[$0.count - 1] + ($1.isSecret ? 1 : 0)) }
|
||||
lastIsText = switch blocks.last {
|
||||
case .paragraph, .heading, .quote, .list: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MarkdownBlocksView<BlockText: View, MetaLine: View>: View {
|
||||
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
|
||||
var formattedText: [FormattedText]
|
||||
var blocks: [MarkdownBlock]
|
||||
var textStyle: UIFont.TextStyle
|
||||
var cardColor: Color
|
||||
var rightToLeft: Bool
|
||||
@ViewBuilder var blockText: (_ index: Int, _ range: Range<Int>, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> BlockText
|
||||
@ViewBuilder var metaLine: () -> MetaLine
|
||||
|
||||
var body: some View {
|
||||
let l = BlocksLayout(formattedText, blocks, textStyle)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(blocks.enumerated()), id: \.offset) { k, block in
|
||||
if k > 0 && paragraphBreak(blocks[k - 1].endIndex, block.startIndex) {
|
||||
Color.clear.frame(height: l.font.lineHeight / 2)
|
||||
}
|
||||
blockView(block, l.starts[k], l)
|
||||
}
|
||||
if !l.lastIsText {
|
||||
metaLine()
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.leading)
|
||||
.environment(\.layoutDirection, rightToLeft ? .rightToLeft : .leftToRight)
|
||||
}
|
||||
|
||||
private func text(_ index: Int, _ r: SegmentRange, _ font: UIFont, _ l: BlocksLayout) -> BlockText {
|
||||
blockText(index, r.from..<max(r.from, r.to), font, l.secrets[min(max(r.from, 0), formattedText.count)], l.lastIsText && index == l.starts[l.starts.count - 1] - 1)
|
||||
}
|
||||
|
||||
@ViewBuilder private func blockView(_ block: MarkdownBlock, _ start: Int, _ l: BlocksLayout) -> some View {
|
||||
switch block {
|
||||
case let .paragraph(content):
|
||||
text(start, content, l.font, l)
|
||||
case let .heading(level, content):
|
||||
text(start, content, headingFont(l.font, level), l)
|
||||
case let .quote(content):
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: 2).fill(Color(uiColor: .label).opacity(0.16)).frame(width: 4)
|
||||
text(start, content, l.font, l).opacity(0.72)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
case let .list(items):
|
||||
ForEach(Array(listEntries(items, 0).enumerated()), id: \.offset) { j, e in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(e.entry.number ?? "•").font(.system(size: l.font.pointSize))
|
||||
text(start + j, e.entry.content, l.font, l)
|
||||
}
|
||||
.padding(.leading, 20 * CGFloat(e.level))
|
||||
}
|
||||
case let .codeBlock(_, content):
|
||||
CodeBlockCard(color: cardColor, radius: 12 * roundness, copyText: codeText(content)) {
|
||||
text(start, content, monospacedFont(l.font), l).fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
case let .table(columns, header, rows):
|
||||
tableView(columns, [header] + rows, start, l)
|
||||
case .rule:
|
||||
Rectangle().fill(Color(uiColor: .secondaryLabel).opacity(0.35)).frame(height: 1).padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func tableView(_ columns: [TableAlign], _ tableRows: [[SegmentRange]], _ start: Int, _ l: BlocksLayout) -> some View {
|
||||
let rowStarts = tableRows.reduce(into: [start]) { $0.append($0[$0.count - 1] + $1.count) }
|
||||
let columnCount = max(columns.count, tableRows.map(\.count).max() ?? 0)
|
||||
let lineColor = Color(uiColor: .secondaryLabel).opacity(0.35)
|
||||
let radius = 12 * roundness
|
||||
if #available(iOS 16, *) {
|
||||
if columnCount * tableRows.count <= maxGridCells {
|
||||
HScroll {
|
||||
Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) {
|
||||
ForEach(Array(tableRows.enumerated()), id: \.offset) { r, row in
|
||||
GridRow {
|
||||
ForEach(0..<columnCount, id: \.self) { c in
|
||||
Group {
|
||||
if c < row.count {
|
||||
text(rowStarts[r] + c, row[c], r == 0 ? boldFont(l.font) : l.font, l).fixedSize()
|
||||
} else {
|
||||
Text(verbatim: "")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.frame(maxWidth: .infinity, alignment: cellAlignment(c < columns.count ? columns[c] : .none))
|
||||
.background(r == 0 ? cardColor : r % 2 == 0 ? Color(uiColor: .secondaryLabel).opacity(0.1) : Color.clear)
|
||||
.overlay(Rectangle().stroke(lineColor, lineWidth: 0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.fixedSize()
|
||||
.clipShape(RoundedRectangle(cornerRadius: radius))
|
||||
.overlay(RoundedRectangle(cornerRadius: radius).stroke(lineColor, lineWidth: 1))
|
||||
}
|
||||
} else {
|
||||
tableSourceCard(tableRows, l, radius)
|
||||
}
|
||||
} else {
|
||||
tableSourceCard(tableRows, l, radius)
|
||||
}
|
||||
}
|
||||
|
||||
private func tableSourceCard(_ tableRows: [[SegmentRange]], _ l: BlocksLayout, _ radius: CGFloat) -> some View {
|
||||
let source = tableSource(tableRows)
|
||||
return CodeBlockCard(color: cardColor, radius: radius, copyText: source) {
|
||||
Text(verbatim: source).font(.system(size: l.font.pointSize * 0.875, design: .monospaced)).fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func paragraphBreak(_ from: Int, _ to: Int) -> Bool {
|
||||
let start = min(max(from, 0), formattedText.count)
|
||||
return formattedText[start..<max(start, min(to, formattedText.count))].contains {
|
||||
if case .markup(.paragraphBreak) = $0.format { true } else { false }
|
||||
}
|
||||
}
|
||||
|
||||
private func codeText(_ r: SegmentRange) -> String {
|
||||
let start = min(max(r.from, 0), formattedText.count)
|
||||
return formattedText[start..<max(start, min(r.to, formattedText.count))].map {
|
||||
switch $0.format {
|
||||
case let .markup(token): token.drawnText
|
||||
case .escape: ""
|
||||
default: $0.text
|
||||
}
|
||||
}.joined()
|
||||
}
|
||||
|
||||
private func tableSource(_ tableRows: [[SegmentRange]]) -> String {
|
||||
let start = min(max((tableRows.first?.first?.from ?? 1) - 1, 0), formattedText.count)
|
||||
let end = min((tableRows.last?.last?.to ?? 0) + 1, formattedText.count)
|
||||
return formattedText[start..<max(start, end)].map(\.text).joined()
|
||||
}
|
||||
}
|
||||
|
||||
private extension MarkdownBlock {
|
||||
var startIndex: Int {
|
||||
if case let .rule(mark) = self { mark.from } else { textRanges.first?.range.from ?? 0 }
|
||||
}
|
||||
|
||||
var endIndex: Int {
|
||||
if case let .rule(mark) = self { mark.to } else { textRanges.last?.range.to ?? 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func headingFont(_ font: UIFont, _ level: Int) -> UIFont {
|
||||
let scale: CGFloat = switch level {
|
||||
case 1: 1.5
|
||||
case 2: 1.3
|
||||
case 3: 1.15
|
||||
default: 1
|
||||
}
|
||||
return UIFont(descriptor: boldFont(font).fontDescriptor, size: font.pointSize * scale)
|
||||
}
|
||||
|
||||
private func boldFont(_ font: UIFont) -> UIFont {
|
||||
UIFont(descriptor: font.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: font.pointSize)
|
||||
}
|
||||
|
||||
private func monospacedFont(_ font: UIFont) -> UIFont {
|
||||
UIFont.monospacedSystemFont(ofSize: font.pointSize * 0.875, weight: .regular)
|
||||
}
|
||||
|
||||
private func cellAlignment(_ align: TableAlign) -> Alignment {
|
||||
switch align {
|
||||
case .right: .trailing
|
||||
case .center: .center
|
||||
default: .leading
|
||||
}
|
||||
}
|
||||
|
||||
private struct CodeBlockCard<Content: View>: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var color: Color
|
||||
var radius: CGFloat
|
||||
var copyText: String
|
||||
@ViewBuilder var content: () -> Content
|
||||
@State private var showCopy = false
|
||||
|
||||
var body: some View {
|
||||
HScroll {
|
||||
content()
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if showCopy {
|
||||
Button {
|
||||
UIPasteboard.general.string = copyText
|
||||
showCopy = false
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: radius * 2 / 3))
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
.background(color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: radius))
|
||||
.onTapGesture { showCopy.toggle() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct HScroll<Content: View>: View {
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16, *) {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
content()
|
||||
scrollView()
|
||||
}
|
||||
} else {
|
||||
scrollView()
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollView() -> some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
content()
|
||||
}
|
||||
.background {
|
||||
GeometryReader { g in
|
||||
Color.clear.preference(key: SwipeExclusionFrames.self, value: [g.frame(in: .named(swipeExclusionSpace))])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,7 +1009,9 @@ func stripTextLink(_ text: String, _ link: String) -> String {
|
||||
func stripFormattedTextLink(_ ft: [FormattedText]?, _ link: String) -> [FormattedText]? {
|
||||
guard var ft, ft.last?.text == link else { return ft }
|
||||
ft.removeLast()
|
||||
if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") {
|
||||
if let i = ft.indices.last, case .markup = ft[i].format {
|
||||
ft.removeLast()
|
||||
} else if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") {
|
||||
ft[i].text = String(ft[i].text.dropLast())
|
||||
if ft[i].text.isEmpty { ft.removeLast() }
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ struct ChatItemInfoView: View {
|
||||
private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
||||
let backgroundColor = chatItemFrameColor(ci, theme)
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: backgroundColor)
|
||||
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, itemVersion.markdownBlocks, nil, backgroundColor: backgroundColor)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(backgroundColor)
|
||||
@@ -295,9 +295,12 @@ struct ChatItemInfoView: View {
|
||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: Color) -> some View {
|
||||
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ markdownBlocks: [MarkdownBlock]?, _ sender: String? = nil, backgroundColor: Color) -> some View {
|
||||
if text != "" {
|
||||
TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor)
|
||||
TextBubble(
|
||||
text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: ci.mentions, userMemberId: userMemberId,
|
||||
backgroundColor: backgroundColor, cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote
|
||||
)
|
||||
} else {
|
||||
Text("no text")
|
||||
.italic()
|
||||
@@ -309,15 +312,19 @@ struct ChatItemInfoView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var text: String
|
||||
var formattedText: [FormattedText]?
|
||||
var markdownBlocks: [MarkdownBlock]?
|
||||
var sender: String? = nil
|
||||
var mentions: [String: CIMention]?
|
||||
var userMemberId: String?
|
||||
var backgroundColor: Color
|
||||
var cardColor: Color
|
||||
@State private var showSecrets: Set<Int> = []
|
||||
|
||||
var body: some View {
|
||||
let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor))
|
||||
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
|
||||
MarkdownView(
|
||||
text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: mentions, userMemberId: userMemberId,
|
||||
backgroundColor: backgroundColor, cardColor: cardColor, showSecrets: $showSecrets
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,7 +349,7 @@ struct ChatItemInfoView: View {
|
||||
private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
|
||||
let backgroundColor = quotedMsgFrameColor(qi, theme)
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: backgroundColor)
|
||||
textBubble(qi.text, qi.formattedText, qi.markdownBlocks, qi.getSender(nil), backgroundColor: backgroundColor)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(quotedMsgFrameColor(qi, theme))
|
||||
|
||||
@@ -1735,6 +1735,7 @@ struct ChatView: View {
|
||||
@State private var markReadTask: Task<Void, Never>? = nil
|
||||
@State private var actionSheet: SomeActionSheet? = nil
|
||||
@State private var swipeOffset: CGFloat = 0
|
||||
@State private var swipeExclusionFrames: [CGRect] = []
|
||||
|
||||
var revealed: Bool { revealedItems.contains(chatItem.id) }
|
||||
|
||||
@@ -2156,12 +2157,14 @@ struct ChatView: View {
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: swipeExclusionSpace)
|
||||
.onPreferenceChange(SwipeExclusionFrames.self) { swipeExclusionFrames = $0 }
|
||||
.offset(x: swipeOffset)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 10)
|
||||
DragGesture(minimumDistance: 10, coordinateSpace: .named(swipeExclusionSpace))
|
||||
.onChanged { value in
|
||||
guard canReply else { return }
|
||||
guard canReply, !swipeExclusionFrames.contains(where: { $0.contains(value.startLocation) }) else { return }
|
||||
let x = value.translation.width
|
||||
if x < 0 {
|
||||
swipeOffset = max(x * 0.63, -56)
|
||||
|
||||
@@ -71,7 +71,7 @@ struct ContextItemView: View {
|
||||
}
|
||||
|
||||
private func contextMsgPreview(_ contextItem: ChatItem) -> some View {
|
||||
let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr)
|
||||
let r = messageText(contextItem.text, contextItem.formattedText, markdownBlocks: contextItem.markdownBlocks, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr)
|
||||
let t = attachment() + Text(AttributedString(r.string))
|
||||
return t.if(r.hasSecrets, transform: hiddenSecretsView)
|
||||
|
||||
|
||||
@@ -59,8 +59,11 @@ struct GroupWelcomeView: View {
|
||||
}
|
||||
|
||||
private func textPreview() -> some View {
|
||||
let r = markdownText(welcomeText, showSecrets: showSecrets, backgroundColor: theme.colors.background)
|
||||
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
|
||||
let parsed = parseSimpleXMarkdownText(welcomeText)
|
||||
return MarkdownView(
|
||||
text: welcomeText, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks,
|
||||
backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets
|
||||
)
|
||||
.frame(minHeight: 130, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ struct ChatPreviewView: View {
|
||||
|
||||
func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) {
|
||||
let (itemText, itemFormattedText) = chatItemPreviewText(cItem)
|
||||
let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix())
|
||||
let r = messageText(itemText, itemFormattedText, markdownBlocks: itemFormattedText == nil ? nil : cItem.markdownBlocks, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix())
|
||||
return (Text(AttributedString(r.string)), r.hasSecrets)
|
||||
|
||||
func chatItemPreviewText(_ ci: ChatItem) -> (String, [FormattedText]?) {
|
||||
|
||||
@@ -149,12 +149,15 @@ public func recvSimpleXMsg<R: ChatAPIResult>(_ ctrl: chat_ctrl? = nil, messageTi
|
||||
}
|
||||
|
||||
public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? {
|
||||
parseSimpleXMarkdownText(s)?.formattedText
|
||||
}
|
||||
|
||||
public func parseSimpleXMarkdownText(_ s: String) -> ParsedMarkdown? {
|
||||
var c = s.cString(using: .utf8)!
|
||||
if let cjson = chat_parse_markdown(&c) {
|
||||
if let d = dataFromCString(cjson) {
|
||||
do {
|
||||
let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d)
|
||||
return r.formattedText
|
||||
return try jsonDecoder.decode(ParsedMarkdown.self, from: d)
|
||||
} catch {
|
||||
logger.error("parseSimpleXMarkdown jsonDecoder.decode error: \(error.localizedDescription)")
|
||||
}
|
||||
@@ -176,8 +179,9 @@ public func parseBadgeCode(_ s: String) -> String? {
|
||||
return canonical.isEmpty ? nil : canonical
|
||||
}
|
||||
|
||||
struct ParsedMarkdown: Decodable {
|
||||
var formattedText: [FormattedText]?
|
||||
public struct ParsedMarkdown: Decodable {
|
||||
public var formattedText: [FormattedText]?
|
||||
public var markdownBlocks: [MarkdownBlock]?
|
||||
}
|
||||
|
||||
public func parseServerAddress(_ s: String) -> ServerAddress? {
|
||||
|
||||
@@ -34,10 +34,12 @@ public struct WebRTCSession: Codable {
|
||||
|
||||
// Spec: spec/services/calls.md#WebRTCExtraInfo
|
||||
public struct WebRTCExtraInfo: Codable {
|
||||
public init(rtcIceCandidates: String) {
|
||||
public init(rtcSession: String?, rtcIceCandidates: String) {
|
||||
self.rtcSession = rtcSession
|
||||
self.rtcIceCandidates = rtcIceCandidates
|
||||
}
|
||||
|
||||
public var rtcSession: String?
|
||||
public var rtcIceCandidates: String
|
||||
}
|
||||
|
||||
|
||||
@@ -3793,11 +3793,12 @@ public struct CIReaction: Decodable, Hashable {
|
||||
}
|
||||
|
||||
public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||
public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) {
|
||||
public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, markdownBlocks: [MarkdownBlock]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) {
|
||||
self.chatDir = chatDir
|
||||
self.meta = meta
|
||||
self.content = content
|
||||
self.formattedText = formattedText
|
||||
self.markdownBlocks = markdownBlocks
|
||||
self.mentions = mentions
|
||||
self.quotedItem = quotedItem
|
||||
self.reactions = reactions
|
||||
@@ -3808,6 +3809,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||
public var meta: CIMeta
|
||||
public var content: CIContent
|
||||
public var formattedText: [FormattedText]?
|
||||
public var markdownBlocks: [MarkdownBlock]?
|
||||
public var mentions: [String: CIMention]?
|
||||
public var quotedItem: CIQuote?
|
||||
public var reactions: [CIReactionCount]
|
||||
@@ -3817,7 +3819,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||
public var isLiveDummy: Bool = false
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file
|
||||
case chatDir, meta, content, formattedText, markdownBlocks, mentions, quotedItem, reactions, file
|
||||
}
|
||||
|
||||
public var id: Int64 { meta.itemId }
|
||||
@@ -4859,6 +4861,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable {
|
||||
public var sentAt: Date
|
||||
public var content: MsgContent
|
||||
public var formattedText: [FormattedText]?
|
||||
public var markdownBlocks: [MarkdownBlock]?
|
||||
public var text: String {
|
||||
switch (content.text, content) {
|
||||
case let ("", .voice(_, duration)): return durationText(duration)
|
||||
@@ -5672,14 +5675,119 @@ public struct FormattedText: Decodable, Hashable {
|
||||
public var linkUri: String? {
|
||||
switch format {
|
||||
case .uri: text
|
||||
case let .hyperLink(_, linkUri): linkUri
|
||||
case let .hyperLink(_, linkUri): isEmailLink ? nil : linkUri
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
public var isEmailLink: Bool {
|
||||
if case let .hyperLink(_, linkUri) = format { linkUri.hasPrefix("mailto:") } else { false }
|
||||
}
|
||||
}
|
||||
|
||||
public enum BlockToken: Decodable, Hashable {
|
||||
case lineBreak
|
||||
case softBreak
|
||||
case hardBreak
|
||||
case paragraphBreak(lineBreaks: Int)
|
||||
case indent
|
||||
case headingMark(level: Int)
|
||||
case headingClose
|
||||
case headingUnderline(level: Int)
|
||||
case listMark(number: String?)
|
||||
case quoteMark
|
||||
case fenceMark(language: String?)
|
||||
case rowMark
|
||||
case cellMark
|
||||
case delimiterRow(columns: [TableAlign])
|
||||
case ruleMark
|
||||
|
||||
public var drawnText: String {
|
||||
switch self {
|
||||
case .softBreak: " "
|
||||
case .lineBreak, .hardBreak: "\n"
|
||||
case let .paragraphBreak(lineBreaks): String(repeating: "\n", count: lineBreaks)
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
|
||||
public var previewText: String {
|
||||
switch self {
|
||||
case .softBreak, .hardBreak, .cellMark: " "
|
||||
case .lineBreak, .paragraphBreak: "\n"
|
||||
case let .listMark(number): (number ?? "•") + " "
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum MarkdownBlock: Decodable, Hashable {
|
||||
case paragraph(content: SegmentRange)
|
||||
case heading(level: Int, content: SegmentRange)
|
||||
case codeBlock(language: String?, content: SegmentRange)
|
||||
case quote(content: SegmentRange)
|
||||
case list(items: [ListEntry])
|
||||
case table(columns: [TableAlign], header: [SegmentRange], rows: [[SegmentRange]])
|
||||
case rule(mark: SegmentRange)
|
||||
}
|
||||
|
||||
public struct ListEntry: Decodable, Hashable {
|
||||
public var number: String?
|
||||
public var content: SegmentRange
|
||||
public var items: [ListEntry]
|
||||
}
|
||||
|
||||
public struct SegmentRange: Decodable, Hashable {
|
||||
public var from: Int
|
||||
public var to: Int
|
||||
}
|
||||
|
||||
public enum TableAlign: String, Decodable, Hashable {
|
||||
case none
|
||||
case left
|
||||
case center
|
||||
case right
|
||||
}
|
||||
|
||||
public struct BlockTextRange {
|
||||
public var range: SegmentRange
|
||||
public var inRow: Bool
|
||||
}
|
||||
|
||||
extension MarkdownBlock {
|
||||
public var textRanges: [BlockTextRange] {
|
||||
switch self {
|
||||
case let .paragraph(content), let .heading(_, content), let .codeBlock(_, content), let .quote(content):
|
||||
[BlockTextRange(range: content, inRow: false)]
|
||||
case let .list(items):
|
||||
listEntries(items, 0).map { BlockTextRange(range: $0.entry.content, inRow: false) }
|
||||
case let .table(_, header, rows):
|
||||
([header] + rows).flatMap { row in row.enumerated().map { c, cell in BlockTextRange(range: cell, inRow: c > 0) } }
|
||||
case .rule:
|
||||
[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func listEntries(_ items: [ListEntry], _ level: Int) -> [(entry: ListEntry, level: Int)] {
|
||||
items.flatMap { [(entry: $0, level: level)] + listEntries($0.items, level + 1) }
|
||||
}
|
||||
|
||||
public func blockTextRanges(_ blocks: [MarkdownBlock]) -> [BlockTextRange] {
|
||||
blocks.flatMap { $0.textRanges }
|
||||
}
|
||||
|
||||
public func headingSegments(_ size: Int, _ blocks: [MarkdownBlock]) -> [Bool] {
|
||||
var bold = [Bool](repeating: false, count: size)
|
||||
for case let .heading(_, content) in blocks {
|
||||
let from = min(max(content.from, 0), size)
|
||||
for i in from..<max(from, min(content.to, size)) { bold[i] = true }
|
||||
}
|
||||
return bold
|
||||
}
|
||||
|
||||
public enum Format: Decodable, Equatable, Hashable {
|
||||
case bold
|
||||
case bold(singleStar: Bool)
|
||||
case italic
|
||||
case strikeThrough
|
||||
case snippet
|
||||
@@ -5695,6 +5803,8 @@ public enum Format: Decodable, Equatable, Hashable {
|
||||
case modal(modalName: String, text: String)
|
||||
case email
|
||||
case phone
|
||||
case escape
|
||||
case markup(token: BlockToken)
|
||||
case unknown
|
||||
|
||||
// client-only format that opens a modal when tapped, see openMarkdownModal
|
||||
@@ -6398,6 +6508,7 @@ public struct ChatItemVersion: Decodable, Hashable {
|
||||
public var chatItemVersionId: Int64
|
||||
public var msgContent: MsgContent
|
||||
public var formattedText: [FormattedText]?
|
||||
public var markdownBlocks: [MarkdownBlock]?
|
||||
public var itemVersionTs: Date
|
||||
public var createdAt: Date
|
||||
}
|
||||
|
||||
@@ -201,10 +201,11 @@ func hideSecrets(_ cItem: ChatItem, isChannel: Bool = false) -> String {
|
||||
if let md = cItem.formattedText {
|
||||
var res = ""
|
||||
for ft in md {
|
||||
if case .secret = ft.format {
|
||||
res = res + "..."
|
||||
} else {
|
||||
res = res + ft.text
|
||||
switch ft.format {
|
||||
case .secret: res = res + "..."
|
||||
case let .markup(token): res = res + token.previewText
|
||||
case .escape: ()
|
||||
default: res = res + ft.text
|
||||
}
|
||||
}
|
||||
return res
|
||||
|
||||
@@ -103,6 +103,14 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central
|
||||
5. `CallViewRenderers` manages WebRTC video rendering surfaces.
|
||||
6. Call status updates are sent via `apiCallStatus(contact:status:)`.
|
||||
|
||||
### 6.1 Reconnection
|
||||
|
||||
1. A connected call loses its connection, e.g. when the device switches between Wi-Fi and mobile network.
|
||||
2. The call shows "reconnecting…" and plays the connecting sound. Call duration continues, as this state is not reported to the core.
|
||||
3. The callee (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer via `apiSendCallExtraInfo` with `rtcSession`; the caller answers the same way.
|
||||
4. When the connection is restored, the call returns to connected state.
|
||||
5. If the connection is not restored in 30 seconds, the call ends. Clients without ICE restart support ignore the restart offer, so the call ends in this case.
|
||||
|
||||
### 7. Audio Routing
|
||||
|
||||
1. `CallAudioDeviceManager` handles audio device selection.
|
||||
@@ -141,7 +149,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central
|
||||
| `RcvCallInvitation` | `SimpleXChat/CallTypes.swift` | Incoming call: user, contact, callType, sharedKey, callUUID, callTs |
|
||||
| `WebRTCCallOffer` | `SimpleXChat/CallTypes.swift` | SDP offer with call type and WebRTC session data |
|
||||
| `WebRTCSession` | `SimpleXChat/CallTypes.swift` | `rtcSession` (SDP) and `rtcIceCandidates` (serialized) |
|
||||
| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer |
|
||||
| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer, and ICE restart offer/answer |
|
||||
| `WebRTCCallStatus` | `SimpleXChat/CallTypes.swift` | Call lifecycle states for status reporting |
|
||||
| `CallMediaSource` | `SimpleXChat/CallTypes.swift` | `.mic`, `.camera`, `.screenAudio`, `.screenVideo`, `.unknown` |
|
||||
| `VideoCamera` | `SimpleXChat/CallTypes.swift` | `.user` (front) or `.environment` (rear) camera |
|
||||
@@ -152,7 +160,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central
|
||||
|-------|-------|----------|
|
||||
| Chat not ready on CallKit answer | App suspended, slow startup | `waitUntilChatStarted` with 30s timeout; `action.fail()` on timeout |
|
||||
| Call invitation not found | Race condition between notification and event processing | `justRefreshCallInvitations()` retry |
|
||||
| WebRTC peer connection failure | NAT traversal, network issues | Call ends with error status |
|
||||
| WebRTC peer connection failure | NAT traversal, network issues | Before connection: call ends with error status; after connection: reconnection with ICE restart, call ends if not reconnected in 30 seconds |
|
||||
| CallKit action fail | Internal state mismatch | `action.fail()` called, call cleaned up |
|
||||
| No camera/mic permission | User denied permissions | Permission request dialog shown |
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.sw
|
||||
| `apiRejectCall` | `contact` | Reject incoming call | [L158](../Shared/Model/AppAPITypes.swift#L158) |
|
||||
| `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L159](../Shared/Model/AppAPITypes.swift#L159) |
|
||||
| `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L160](../Shared/Model/AppAPITypes.swift#L160) |
|
||||
| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L161](../Shared/Model/AppAPITypes.swift#L161) |
|
||||
| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates or ICE restart offer/answer | [L171](../Shared/Model/AppAPITypes.swift#L171) |
|
||||
| `apiEndCall` | `contact` | End active call | [L162](../Shared/Model/AppAPITypes.swift#L162) |
|
||||
| `apiGetCallInvitations` | -- | Get pending call invitations | [L163](../Shared/Model/AppAPITypes.swift#L163) |
|
||||
| `apiCallStatus` | `contact, callStatus` | Report call status change | [L164](../Shared/Model/AppAPITypes.swift#L164) |
|
||||
|
||||
@@ -189,7 +189,7 @@ Renders emoji-only messages (messages containing only emoji characters) in a lar
|
||||
|
||||
**File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift)
|
||||
|
||||
Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions).
|
||||
Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). Blocks of `markdownBlocks` (headings, lists, quotes, code blocks, tables, rules) are drawn as a column of views by `MarkdownBlocksView`; code blocks and tables scroll sideways.
|
||||
|
||||
### DeletedItemView / MarkedDeletedItemView
|
||||
|
||||
|
||||
@@ -116,7 +116,10 @@ Manages the WebRTC peer connection, media streams, and data channels.
|
||||
| [`sendCallCommand`](../../Shared/Views/Call/WebRTCClient.swift#L176) | Dispatches WCallCommand (offer/answer/ICE) | L176 |
|
||||
| [`addIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L165) | `peerConnection.add(RTCIceCandidate)` | L165 |
|
||||
| [`getInitialIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L285) | Collects initial ICE candidates | L285 |
|
||||
| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L305) | Sends gathered ICE candidates | L305 |
|
||||
| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L409) | Sends gathered ICE candidates | L409 |
|
||||
| [`startReconnecting`](../../Shared/Views/Call/WebRTCClient.swift#L313) | Reports "reconnecting", starts reconnection timeout and, for the party that sent the initial offer, periodic ICE restarts | L313 |
|
||||
| [`restartIce`](../../Shared/Views/Call/WebRTCClient.swift#L339) | Creates ICE restart offer and sends it with candidates in `.ice(iceCandidates:session:)` | L339 |
|
||||
| [`receiveRestartSession`](../../Shared/Views/Call/WebRTCClient.swift#L350) | Applies ICE restart offer (and sends the answer) or answer received in `.ice` command | L350 |
|
||||
| [`enableMedia`](../../Shared/Views/Call/WebRTCClient.swift#L365) | Enable/disable audio or video track | L365 |
|
||||
| [`setupLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L423) | Creates audio/video tracks and adds to connection | L423 |
|
||||
| [`startCaptureLocalVideo`](../../Shared/Views/Call/WebRTCClient.swift#L581) | Front/back camera toggle and capture start | L581 |
|
||||
@@ -179,6 +182,10 @@ All call signaling travels through the same encrypted SMP message channels used
|
||||
5. P2P connection established
|
||||
```
|
||||
|
||||
### Reconnection
|
||||
|
||||
When a connected call loses ICE connection (`.disconnected` or `.failed`), `WebRTCClient` reports `"reconnecting"` connection state instead of ending the call. `ActiveCallView` sets `.reconnecting` call state and plays the connecting sound; this state is not sent to the core via `apiCallStatus`, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds: the restart offer and answer are sent via `apiSendCallExtraInfo` with `rtcSession` (`x.call.extra`). When ICE connects again, the call returns to `.connected`. If it does not reconnect in 30 seconds, `WebRTCClient` reports `"disconnected"` and ends the call.
|
||||
|
||||
### API Commands
|
||||
|
||||
| Command | Direction | Purpose |
|
||||
@@ -187,7 +194,7 @@ All call signaling travels through the same encrypted SMP message channels used
|
||||
| `apiRejectCall(contact:)` | Callee -> Caller | Reject call |
|
||||
| `apiSendCallOffer(contact:, callOffer:)` | Callee -> Caller | Send SDP offer |
|
||||
| `apiSendCallAnswer(contact:, answer:)` | Caller -> Callee | Send SDP answer |
|
||||
| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates |
|
||||
| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates, or ICE restart offer/answer in `rtcSession` |
|
||||
| `apiEndCall(contact:)` | Either | End call |
|
||||
| `apiGetCallInvitations` | -- | Get pending invitations |
|
||||
| `apiCallStatus(contact:, callStatus:)` | -- | Report status change |
|
||||
@@ -317,10 +324,11 @@ struct WebRTCSession {
|
||||
}
|
||||
```
|
||||
|
||||
### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L42)
|
||||
### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L45)
|
||||
|
||||
```swift
|
||||
struct WebRTCExtraInfo {
|
||||
var rtcSession: String? // ICE restart offer or answer
|
||||
var rtcIceCandidates: String // Additional ICE candidates
|
||||
}
|
||||
```
|
||||
@@ -330,7 +338,7 @@ struct WebRTCExtraInfo {
|
||||
Stored in `ChatModel.activeCall`:
|
||||
- Contact reference
|
||||
- Call UUID
|
||||
- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, etc.)
|
||||
- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, `.reconnecting`, etc.)
|
||||
- Media type
|
||||
- WebRTCClient reference
|
||||
|
||||
|
||||
+24
-15
@@ -52,8 +52,7 @@ import dev.icerock.moko.resources.StringResource
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.io.Closeable
|
||||
@@ -168,13 +167,24 @@ actual fun ActiveCallView() {
|
||||
CallSoundsPlayer.stop()
|
||||
}
|
||||
is WCallResponse.Ice -> withBGApi {
|
||||
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
|
||||
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates)
|
||||
}
|
||||
is WCallResponse.Connection ->
|
||||
try {
|
||||
// the call is restored and NOT sent to the core
|
||||
// Makes sure the core DOES NOT record the duration wrong if a reconnect happens
|
||||
if (r.state.connectionState == "reconnecting") {
|
||||
if (call.callState != CallState.Reconnecting) {
|
||||
CallSoundsPlayer.startConnectingCallSound(scope)
|
||||
}
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Reconnecting) }
|
||||
} else try {
|
||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||
if (callStatus == WebRTCCallStatus.Connected) {
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) }
|
||||
if (call.callState == CallState.Reconnecting) {
|
||||
CallSoundsPlayer.stop()
|
||||
}
|
||||
// connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = it.connectedAt ?: Clock.System.now()) }
|
||||
}
|
||||
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
|
||||
} catch (e: Throwable) {
|
||||
@@ -705,18 +715,17 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
||||
val wv = webView.value
|
||||
if (wv != null) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { callCommand.firstOrNull() }
|
||||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.collect {
|
||||
while (callCommand.isNotEmpty()) {
|
||||
val cmd = callCommand.removeFirstOrNull()
|
||||
Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
|
||||
if (cmd != null) {
|
||||
processCommand(wv, cmd)
|
||||
}
|
||||
while (isActive) {
|
||||
// waiting for the first command would miss a command equal to the previous one
|
||||
snapshotFlow { callCommand.isNotEmpty() }.first { it }
|
||||
while (callCommand.isNotEmpty()) {
|
||||
val cmd = callCommand.removeFirstOrNull()
|
||||
Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
|
||||
if (cmd != null) {
|
||||
processCommand(wv, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val assetLoader = WebViewAssetLoader.Builder()
|
||||
|
||||
+94
-4
@@ -3407,6 +3407,7 @@ data class ChatItem (
|
||||
val meta: CIMeta,
|
||||
val content: CIContent,
|
||||
val formattedText: List<FormattedText>? = null,
|
||||
val markdownBlocks: List<MarkdownBlock>? = null,
|
||||
val mentions: Map<String, CIMention>? = null,
|
||||
val quotedItem: CIQuote? = null,
|
||||
val reactions: List<CIReactionCount>,
|
||||
@@ -4412,7 +4413,8 @@ class CIQuote (
|
||||
val sharedMsgId: String? = null,
|
||||
val sentAt: Instant,
|
||||
val content: MsgContent,
|
||||
val formattedText: List<FormattedText>? = null
|
||||
val formattedText: List<FormattedText>? = null,
|
||||
val markdownBlocks: List<MarkdownBlock>? = null
|
||||
): ItemContent {
|
||||
override val text: String by lazy {
|
||||
if (content.text == "" && content is MsgContent.MCVoice)
|
||||
@@ -5164,7 +5166,7 @@ class FormattedText(val text: String, val format: Format? = null) {
|
||||
val linkUri: String? get() =
|
||||
when (format) {
|
||||
is Format.Uri -> text
|
||||
is Format.HyperLink -> format.linkUri
|
||||
is Format.HyperLink -> if (format.isEmail) null else format.linkUri
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -5175,7 +5177,7 @@ class FormattedText(val text: String, val format: Format? = null) {
|
||||
|
||||
@Serializable
|
||||
sealed class Format {
|
||||
@Serializable @SerialName("bold") class Bold: Format()
|
||||
@Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format()
|
||||
@Serializable @SerialName("italic") class Italic: Format()
|
||||
@Serializable @SerialName("strikeThrough") class StrikeThrough: Format()
|
||||
@Serializable @SerialName("snippet") class Snippet: Format()
|
||||
@@ -5183,7 +5185,9 @@ sealed class Format {
|
||||
@Serializable @SerialName("small") class Small: Format()
|
||||
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
|
||||
@Serializable @SerialName("uri") class Uri: Format()
|
||||
@Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format()
|
||||
@Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() {
|
||||
val isEmail: Boolean get() = linkUri.startsWith("mailto:")
|
||||
}
|
||||
@Serializable @SerialName("simplexLink") class SimplexLink(val showText: String?, val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List<String>): Format() {
|
||||
val simplexLinkText: String get() =
|
||||
"${linkType.description} $viaHosts"
|
||||
@@ -5200,6 +5204,8 @@ sealed class Format {
|
||||
}
|
||||
@Serializable @SerialName("email") class Email: Format()
|
||||
@Serializable @SerialName("phone") class Phone: Format()
|
||||
@Serializable @SerialName("escape") class Escape: Format()
|
||||
@Serializable @SerialName("markup") class Markup(val token: BlockToken): Format()
|
||||
@Serializable @SerialName("unknown") class Unknown: Format()
|
||||
|
||||
val style: SpanStyle @Composable get() = when (this) {
|
||||
@@ -5219,6 +5225,8 @@ sealed class Format {
|
||||
is Email -> linkStyle
|
||||
is Phone -> linkStyle
|
||||
is Modal -> linkStyle
|
||||
is Escape -> SpanStyle()
|
||||
is Markup -> SpanStyle()
|
||||
is Unknown -> SpanStyle()
|
||||
}
|
||||
|
||||
@@ -5229,6 +5237,87 @@ sealed class Format {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class BlockToken {
|
||||
@Serializable @SerialName("lineBreak") class LineBreak: BlockToken()
|
||||
@Serializable @SerialName("softBreak") class SoftBreak: BlockToken()
|
||||
@Serializable @SerialName("hardBreak") class HardBreak: BlockToken()
|
||||
@Serializable @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int): BlockToken()
|
||||
@Serializable @SerialName("indent") class Indent: BlockToken()
|
||||
@Serializable @SerialName("headingMark") class HeadingMark(val level: Int): BlockToken()
|
||||
@Serializable @SerialName("headingClose") class HeadingClose: BlockToken()
|
||||
@Serializable @SerialName("headingUnderline") class HeadingUnderline(val level: Int): BlockToken()
|
||||
@Serializable @SerialName("listMark") class ListMark(val number: String? = null): BlockToken()
|
||||
@Serializable @SerialName("quoteMark") class QuoteMark: BlockToken()
|
||||
@Serializable @SerialName("fenceMark") class FenceMark(val language: String? = null): BlockToken()
|
||||
@Serializable @SerialName("rowMark") class RowMark: BlockToken()
|
||||
@Serializable @SerialName("cellMark") class CellMark: BlockToken()
|
||||
@Serializable @SerialName("delimiterRow") class DelimiterRow(val columns: List<TableAlign>): BlockToken()
|
||||
@Serializable @SerialName("ruleMark") class RuleMark: BlockToken()
|
||||
|
||||
val drawnText: String get() = when (this) {
|
||||
is SoftBreak -> " "
|
||||
is LineBreak, is HardBreak -> "\n"
|
||||
is ParagraphBreak -> "\n".repeat(lineBreaks)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val previewText: String get() = when (this) {
|
||||
is SoftBreak, is HardBreak, is CellMark -> " "
|
||||
is LineBreak, is ParagraphBreak -> "\n"
|
||||
is ListMark -> (number ?: "•") + " "
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class MarkdownBlock {
|
||||
@Serializable @SerialName("paragraph") class Paragraph(val content: SegmentRange): MarkdownBlock()
|
||||
@Serializable @SerialName("heading") class Heading(val level: Int, val content: SegmentRange): MarkdownBlock()
|
||||
@Serializable @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange): MarkdownBlock()
|
||||
@Serializable @SerialName("quote") class Quote(val content: SegmentRange): MarkdownBlock()
|
||||
@Serializable @SerialName("list") class ItemList(val items: List<ListEntry>): MarkdownBlock()
|
||||
@Serializable @SerialName("table") class Table(val columns: List<TableAlign>, val header: List<SegmentRange>, val rows: List<List<SegmentRange>>): MarkdownBlock()
|
||||
@Serializable @SerialName("rule") class Rule(val mark: SegmentRange): MarkdownBlock()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ListEntry(val number: String? = null, val content: SegmentRange, val items: List<ListEntry>)
|
||||
|
||||
@Serializable
|
||||
class SegmentRange(val from: Int, val to: Int)
|
||||
|
||||
@Serializable
|
||||
enum class TableAlign {
|
||||
@SerialName("none") None,
|
||||
@SerialName("left") Left,
|
||||
@SerialName("center") Center,
|
||||
@SerialName("right") Right
|
||||
}
|
||||
|
||||
class BlockTextRange(val range: SegmentRange, val inRow: Boolean)
|
||||
|
||||
val MarkdownBlock.textRanges: List<BlockTextRange> get() = when (this) {
|
||||
is MarkdownBlock.Paragraph -> listOf(BlockTextRange(content, false))
|
||||
is MarkdownBlock.Heading -> listOf(BlockTextRange(content, false))
|
||||
is MarkdownBlock.CodeBlock -> listOf(BlockTextRange(content, false))
|
||||
is MarkdownBlock.Quote -> listOf(BlockTextRange(content, false))
|
||||
is MarkdownBlock.ItemList -> listEntries(items, 0).map { (entry, _) -> BlockTextRange(entry.content, false) }
|
||||
is MarkdownBlock.Table -> (listOf(header) + rows).flatMap { row -> row.mapIndexed { c, cell -> BlockTextRange(cell, c > 0) } }
|
||||
is MarkdownBlock.Rule -> emptyList()
|
||||
}
|
||||
|
||||
fun listEntries(items: List<ListEntry>, level: Int): List<Pair<ListEntry, Int>> =
|
||||
items.flatMap { listOf(it to level) + listEntries(it.items, level + 1) }
|
||||
|
||||
fun blockTextRanges(blocks: List<MarkdownBlock>): List<BlockTextRange> = blocks.flatMap { it.textRanges }
|
||||
|
||||
fun headingSegments(size: Int, blocks: List<MarkdownBlock>): BooleanArray {
|
||||
val bold = BooleanArray(size)
|
||||
blocks.forEach { if (it is MarkdownBlock.Heading) for (i in it.content.from.coerceIn(0, size) until it.content.to.coerceIn(0, size)) bold[i] = true }
|
||||
return bold
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SimplexLinkType(val linkType: String) {
|
||||
contact("contact"),
|
||||
@@ -5714,6 +5803,7 @@ data class ChatItemVersion(
|
||||
val chatItemVersionId: Long,
|
||||
val msgContent: MsgContent,
|
||||
val formattedText: List<FormattedText>?,
|
||||
val markdownBlocks: List<MarkdownBlock>? = null,
|
||||
val itemVersionTs: Instant,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
|
||||
+3
-3
@@ -2185,8 +2185,8 @@ object ChatController {
|
||||
return r.result is CR.CmdOk
|
||||
}
|
||||
|
||||
suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean {
|
||||
val extraInfo = WebRTCExtraInfo(rtcIceCandidates)
|
||||
suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String): Boolean {
|
||||
val extraInfo = WebRTCExtraInfo(rtcSession, rtcIceCandidates)
|
||||
val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo))
|
||||
return r.result is CR.CmdOk
|
||||
}
|
||||
@@ -3436,7 +3436,7 @@ object ChatController {
|
||||
}
|
||||
is CR.CallExtraInfo -> {
|
||||
withCall(r, r.contact) { _ ->
|
||||
chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates))
|
||||
chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates, session = r.extraInfo.rtcSession))
|
||||
}
|
||||
}
|
||||
is CR.CallEnded -> {
|
||||
|
||||
+6
-1
@@ -136,7 +136,12 @@ abstract class NtfManager {
|
||||
return if (md != null) {
|
||||
var res = ""
|
||||
for (ft in md) {
|
||||
res += if (ft.format is Format.Secret) "..." else ft.text
|
||||
res += when (ft.format) {
|
||||
is Format.Secret -> "..."
|
||||
is Format.Markup -> ft.format.token.previewText
|
||||
is Format.Escape -> ""
|
||||
else -> ft.text
|
||||
}
|
||||
}
|
||||
res
|
||||
} else {
|
||||
|
||||
+5
-3
@@ -56,6 +56,7 @@ enum class CallState {
|
||||
AnswerReceived,
|
||||
Negotiated,
|
||||
Connected,
|
||||
Reconnecting,
|
||||
Ended;
|
||||
|
||||
val text: String get() = when(this) {
|
||||
@@ -67,6 +68,7 @@ enum class CallState {
|
||||
AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation)
|
||||
Negotiated -> generalGetString(MR.strings.callstate_connecting)
|
||||
Connected -> generalGetString(MR.strings.callstate_connected)
|
||||
Reconnecting -> generalGetString(MR.strings.callstate_reconnecting)
|
||||
Ended -> generalGetString(MR.strings.callstate_ended)
|
||||
}
|
||||
}
|
||||
@@ -91,7 +93,7 @@ sealed class WCallCommand {
|
||||
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallCommand()
|
||||
@Serializable @SerialName("media") data class Media(val source: CallMediaSource, val enable: Boolean): WCallCommand()
|
||||
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
|
||||
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
|
||||
@@ -104,7 +106,7 @@ sealed class WCallResponse {
|
||||
@Serializable @SerialName("capabilities") data class Capabilities(val capabilities: CallCapabilities): WCallResponse()
|
||||
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
|
||||
@Serializable @SerialName("answer") data class Answer(val answer: String, val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallResponse()
|
||||
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
|
||||
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
||||
@Serializable @SerialName("peerMedia") data class PeerMedia(val source: CallMediaSource, val enabled: Boolean): WCallResponse()
|
||||
@@ -116,7 +118,7 @@ sealed class WCallResponse {
|
||||
|
||||
@Serializable data class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
|
||||
@Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
|
||||
@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String)
|
||||
@Serializable data class WebRTCExtraInfo(val rtcSession: String? = null, val rtcIceCandidates: String)
|
||||
@Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
||||
@Serializable data class RcvCallInvitation(
|
||||
val remoteHostId: Long?,
|
||||
|
||||
+4
-2
@@ -791,8 +791,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex
|
||||
val linkMode = chatModel.simplexLinkMode.value
|
||||
if (descr == null) {
|
||||
if (short != null) {
|
||||
val parsed = parseToMarkdownText(short)
|
||||
MarkdownText(
|
||||
short, parseToMarkdown(short), toggleSecrets = true, style = style, maxLines = 4,
|
||||
short, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode
|
||||
)
|
||||
}
|
||||
@@ -801,8 +802,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex
|
||||
val truncated = firstLine.length > 100
|
||||
val multiline = descr.length > firstLine.length
|
||||
if (short == null && !truncated && !multiline) {
|
||||
val parsed = parseToMarkdownText(descr)
|
||||
MarkdownText(
|
||||
descr, parseToMarkdown(descr), toggleSecrets = true, style = style, maxLines = 4,
|
||||
descr, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode
|
||||
)
|
||||
} else {
|
||||
|
||||
+7
-5
@@ -52,17 +52,19 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
val selection = remember { mutableStateOf<CIInfoTab>(CIInfoTab.History) }
|
||||
|
||||
@Composable
|
||||
fun TextBubble(text: String, formattedText: List<FormattedText>?, sender: String?, showMenu: MutableState<Boolean>, mentions: Map<String, CIMention>? = null, userMemberId: String? = null, ) {
|
||||
fun TextBubble(text: String, formattedText: List<FormattedText>?, markdownBlocks: List<MarkdownBlock>?, sender: String?, showMenu: MutableState<Boolean>, mentions: Map<String, CIMention>? = null, userMemberId: String? = null, ) {
|
||||
if (text != "") {
|
||||
MarkdownText(
|
||||
text, if (text.isEmpty()) emptyList() else formattedText,
|
||||
text, if (text.isEmpty()) emptyList() else formattedText, markdownBlocks,
|
||||
sender = sender,
|
||||
mentions = mentions,
|
||||
userMemberId = userMemberId,
|
||||
senderBold = true,
|
||||
toggleSecrets = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler,
|
||||
onLinkLongClick = { showMenu.value = true }
|
||||
onLinkLongClick = { showMenu.value = true },
|
||||
blockLayout = true,
|
||||
cardColor = if (sent) appColors.sentQuote else appColors.receivedQuote
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
@@ -85,7 +87,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
.onRightClick { showMenu.value = true }
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
TextBubble(text, ciVersion.formattedText, sender = null, showMenu = showMenu, mentions = ci.mentions,
|
||||
TextBubble(text, ciVersion.formattedText, ciVersion.markdownBlocks, sender = null, showMenu = showMenu, mentions = ci.mentions,
|
||||
userMemberId = when {
|
||||
chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId
|
||||
else -> null
|
||||
@@ -137,7 +139,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
.onRightClick { showMenu.value = true }
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
TextBubble(text, qi.formattedText, sender = qi.sender(null), showMenu)
|
||||
TextBubble(text, qi.formattedText, qi.markdownBlocks, sender = qi.sender(null), showMenu)
|
||||
}
|
||||
}
|
||||
Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) {
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ fun ContextItemView(
|
||||
}
|
||||
} else null
|
||||
MarkdownText(
|
||||
contextItem.text, contextItem.formattedText,
|
||||
contextItem.text, contextItem.formattedText, contextItem.markdownBlocks,
|
||||
sender = null,
|
||||
toggleSecrets = false,
|
||||
maxLines = lines,
|
||||
|
||||
+58
-39
@@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.item.itemPrefixText
|
||||
import chat.simplex.common.views.chat.item.itemSegmentDisplayText
|
||||
import chat.simplex.common.views.chat.item.itemDisplay
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
@@ -267,23 +267,22 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple
|
||||
if (sel.first < prefix.length) {
|
||||
sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1))
|
||||
}
|
||||
val formattedText = ci.formattedText ?: run {
|
||||
val parts = itemDisplay(ci, linkMode)?.parts ?: run {
|
||||
val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length)
|
||||
val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length)
|
||||
if (start < end) sb.append(ci.text, start, end)
|
||||
return sb.toString()
|
||||
}
|
||||
var displayOffset = prefix.length
|
||||
for (ft in formattedText) {
|
||||
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
|
||||
for ((segDisplay, source) in parts) {
|
||||
val displayEnd = displayOffset + segDisplay.length
|
||||
val overlapStart = maxOf(displayOffset, sel.first)
|
||||
val overlapEnd = minOf(displayEnd, sel.last + 1)
|
||||
if (overlapStart < overlapEnd) {
|
||||
if (ft.text.length == segDisplay.length) {
|
||||
sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset)
|
||||
if (source.length == segDisplay.length) {
|
||||
sb.append(source, overlapStart - displayOffset, overlapEnd - displayOffset)
|
||||
} else {
|
||||
sb.append(ft.text)
|
||||
sb.append(source)
|
||||
}
|
||||
}
|
||||
displayOffset = displayEnd
|
||||
@@ -293,12 +292,11 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple
|
||||
|
||||
// Snaps a boundary offset to include full transformed segments.
|
||||
private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int {
|
||||
val formattedText = ci.formattedText ?: return offset
|
||||
val parts = itemDisplay(ci, linkMode)?.parts ?: return offset
|
||||
var displayOffset = itemPrefixText(ci).length
|
||||
for (ft in formattedText) {
|
||||
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
|
||||
for ((segDisplay, source) in parts) {
|
||||
val displayEnd = displayOffset + segDisplay.length
|
||||
if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) {
|
||||
if (offset > displayOffset && offset < displayEnd && source.length != segDisplay.length) {
|
||||
return if (expandRight) displayEnd else displayOffset
|
||||
}
|
||||
displayOffset = displayEnd
|
||||
@@ -425,18 +423,55 @@ private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? {
|
||||
return idx
|
||||
}
|
||||
|
||||
class ItemSelection(
|
||||
class TextSelection(
|
||||
val highlightRange: IntRange?,
|
||||
val positionModifier: Modifier,
|
||||
val onTextLayoutResult: ((TextLayoutResult) -> Unit)?
|
||||
val onTextLayoutResult: (TextLayoutResult) -> Unit
|
||||
)
|
||||
|
||||
class SelectionText(var base: Int) {
|
||||
var bounds: Rect? = null
|
||||
var layout: TextLayoutResult? = null
|
||||
}
|
||||
|
||||
class ItemSelection(
|
||||
private val highlightRange: IntRange?,
|
||||
private val texts: MutableMap<Int, SelectionText>?,
|
||||
private val bases: List<Int>
|
||||
) {
|
||||
fun text(index: Int): TextSelection? {
|
||||
val texts = texts ?: return null
|
||||
val base = bases.getOrNull(index) ?: return null
|
||||
val t = texts.getOrPut(index) { SelectionText(base) }
|
||||
t.base = base
|
||||
val highlight = highlightRange?.let { (it.first - base).coerceAtLeast(0) .. (it.last - base) }?.takeUnless { it.isEmpty() }
|
||||
return TextSelection(
|
||||
highlight,
|
||||
Modifier.onGloballyPositioned {
|
||||
val pos = it.positionInWindow()
|
||||
t.bounds = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height)
|
||||
},
|
||||
{ t.layout = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class TextHit(val text: SelectionText, val bounds: Rect, val layout: TextLayoutResult, val offset: Int)
|
||||
|
||||
private fun textAt(texts: Collection<SelectionText>, x: Float, y: Float): TextHit? =
|
||||
texts.mapNotNull { t ->
|
||||
val b = t.bounds ?: return@mapNotNull null
|
||||
val l = t.layout?.takeIf { it.layoutInput.text.isNotEmpty() } ?: return@mapNotNull null
|
||||
Triple(t, b, l)
|
||||
}.minByOrNull { (_, b, _) -> maxOf(b.top - y, 0f, y - b.bottom) * 4 + maxOf(b.left - x, 0f, x - b.right) }
|
||||
?.let { (t, b, l) -> TextHit(t, b, l, l.getOffsetForPosition(Offset(x - b.left, y - b.top))) }
|
||||
|
||||
// Sets up selection tracking for a text item: anchor/focus offset resolution,
|
||||
// highlight range computation, and position/layout result capture.
|
||||
@Composable
|
||||
fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean): ItemSelection {
|
||||
val boundsState = remember { mutableStateOf<Rect?>(null) }
|
||||
val layoutResultState = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean, bases: List<Int>): ItemSelection {
|
||||
val texts = remember { HashMap<Int, SelectionText>() }
|
||||
texts.keys.retainAll { it < bases.size }
|
||||
|
||||
if (selectionManager != null && selectionIndex >= 0 && !isLive) {
|
||||
val isAnchor = remember(selectionIndex) {
|
||||
@@ -444,12 +479,8 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int,
|
||||
}
|
||||
LaunchedEffect(isAnchor.value) {
|
||||
if (!isAnchor.value) return@LaunchedEffect
|
||||
val bounds = boundsState.value ?: return@LaunchedEffect
|
||||
val layout = layoutResultState.value ?: return@LaunchedEffect
|
||||
val offset = layout.getOffsetForPosition(
|
||||
Offset(selectionManager.anchorWindowX - bounds.left, selectionManager.anchorWindowY - bounds.top)
|
||||
)
|
||||
selectionManager.setAnchorOffset(offset)
|
||||
val hit = textAt(texts.values, selectionManager.anchorWindowX, selectionManager.anchorWindowY) ?: return@LaunchedEffect
|
||||
selectionManager.setAnchorOffset(hit.text.base + hit.offset)
|
||||
}
|
||||
|
||||
val isFocus = remember(selectionIndex) {
|
||||
@@ -459,10 +490,9 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int,
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX }
|
||||
.collect { (py, px) ->
|
||||
val bounds = boundsState.value ?: return@collect
|
||||
val layout = layoutResultState.value ?: return@collect
|
||||
val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top))
|
||||
val charBox = layout.getBoundingBox(offset.coerceIn(0, layout.layoutInput.text.length - 1))
|
||||
val hit = textAt(texts.values, px, py) ?: return@collect
|
||||
val bounds = hit.bounds
|
||||
val charBox = hit.layout.getBoundingBox(hit.offset.coerceIn(0, hit.layout.layoutInput.text.length - 1))
|
||||
val ls = selectionManager.listState?.value
|
||||
val itemInfo = ls?.layoutInfo?.visibleItemsInfo?.find { it.index == selectionIndex }
|
||||
val charRect = if (ls != null && itemInfo != null) {
|
||||
@@ -474,7 +504,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int,
|
||||
bottom = bounds.top + charBox.bottom - itemWindowY
|
||||
)
|
||||
} else Rect.Zero
|
||||
selectionManager.updateFocusOffset(offset, charRect)
|
||||
selectionManager.updateFocusOffset(hit.text.base + hit.offset, charRect)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -484,18 +514,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int,
|
||||
remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value
|
||||
} else null
|
||||
|
||||
val positionModifier = if (selectionManager != null) {
|
||||
Modifier.onGloballyPositioned {
|
||||
val pos = it.positionInWindow()
|
||||
boundsState.value = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height)
|
||||
}
|
||||
} else Modifier
|
||||
|
||||
val onTextLayoutResult: ((TextLayoutResult) -> Unit)? = if (selectionManager != null) {
|
||||
{ layoutResultState.value = it }
|
||||
} else null
|
||||
|
||||
return ItemSelection(highlightRange, positionModifier, onTextLayoutResult)
|
||||
return ItemSelection(highlightRange, if (selectionManager != null) texts else null, bases)
|
||||
}
|
||||
|
||||
// Sets up full-item selection for emoji items (no character-level tracking).
|
||||
|
||||
+5
-2
@@ -146,13 +146,16 @@ private fun TextPreview(text: String, linkMode: SimplexLinkMode, markdown: Boole
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Column {
|
||||
SelectionContainer(Modifier.fillMaxWidth()) {
|
||||
val parsed = if (markdown) remember(text) { parseToMarkdownText(text) } else null
|
||||
MarkdownText(
|
||||
text,
|
||||
formattedText = if (markdown) remember(text) { parseToMarkdown(text) } else null,
|
||||
formattedText = parsed?.formattedText,
|
||||
markdownBlocks = parsed?.markdownBlocks,
|
||||
toggleSecrets = false,
|
||||
modifier = Modifier.fillMaxHeight().padding(horizontal = DEFAULT_PADDING),
|
||||
linkMode = linkMode, uriHandler = uriHandler,
|
||||
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp)
|
||||
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp),
|
||||
blockLayout = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -61,6 +61,7 @@ fun FramedItemView(
|
||||
MarkdownText(
|
||||
qi.text,
|
||||
qi.formattedText,
|
||||
qi.markdownBlocks,
|
||||
toggleSecrets = true,
|
||||
maxLines = lines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -457,11 +458,12 @@ fun CIMarkdownText(
|
||||
) {
|
||||
val chatInfo = chat.chatInfo
|
||||
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
||||
val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true)
|
||||
val bases = remember(ci, linkMode) { itemDisplay(ci, linkMode)?.bases ?: listOf(0) }
|
||||
val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true, bases)
|
||||
|
||||
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) {
|
||||
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) {
|
||||
MarkdownText(
|
||||
text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true,
|
||||
text, if (text.isEmpty()) emptyList() else ci.formattedText, if (text.isEmpty()) null else ci.markdownBlocks, toggleSecrets = true,
|
||||
sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null,
|
||||
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
|
||||
mentions = ci.mentions, userMemberId = when {
|
||||
@@ -470,8 +472,9 @@ fun CIMarkdownText(
|
||||
},
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix,
|
||||
stripLink = stripLink,
|
||||
selectionRange = selection.highlightRange,
|
||||
onTextLayoutResult = selection.onTextLayoutResult
|
||||
selection = selection,
|
||||
blockLayout = true,
|
||||
cardColor = if (ci.chatDir.sent) MaterialTheme.appColors.sentQuote else MaterialTheme.appColors.receivedQuote
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.takeOrElse
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@Composable
|
||||
fun MarkdownBlocks(
|
||||
formattedText: List<FormattedText>,
|
||||
blocks: List<MarkdownBlock>,
|
||||
style: TextStyle,
|
||||
modifier: Modifier,
|
||||
cardColor: Color,
|
||||
blockText: @Composable (index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier, softWrap: Boolean, last: Boolean) -> Unit,
|
||||
metaLine: @Composable () -> Unit
|
||||
) {
|
||||
val roundness = remember { appPreferences.chatItemRoundness.state }.value.coerceIn(0f, 1f)
|
||||
val cardShape = RoundedCornerShape(12.dp * roundness)
|
||||
val lineColor = MaterialTheme.colors.secondary.copy(alpha = 0.35f)
|
||||
val textColor = style.color.takeOrElse { MaterialTheme.colors.onSurface }
|
||||
val fontSize = style.fontSize.takeOrElse { 16.sp }
|
||||
val lineHeight = style.lineHeight.takeOrElse { 22.sp }
|
||||
val starts = remember(blocks) { blocks.runningFold(0) { n, b -> n + b.textRanges.size } }
|
||||
val lastIsText = when (blocks.lastOrNull()) {
|
||||
is MarkdownBlock.Paragraph, is MarkdownBlock.Heading, is MarkdownBlock.Quote, is MarkdownBlock.ItemList -> true
|
||||
else -> false
|
||||
}
|
||||
@Composable
|
||||
fun BlockText(index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier = Modifier, softWrap: Boolean = true) {
|
||||
blockText(index, range, style, modifier, softWrap, lastIsText && index == starts.last() - 1)
|
||||
}
|
||||
Column(modifier) {
|
||||
blocks.forEachIndexed { k, block ->
|
||||
if (k > 0 && paragraphBreak(formattedText, blocks[k - 1].end, block.start)) {
|
||||
Spacer(Modifier.height(with(LocalDensity.current) { (lineHeight.toPx() / 2).toDp() }))
|
||||
}
|
||||
val start = starts[k]
|
||||
when (block) {
|
||||
is MarkdownBlock.Paragraph -> BlockText(start, block.content, style)
|
||||
is MarkdownBlock.Heading -> {
|
||||
val scale = when (block.level) {
|
||||
1 -> 1.5f
|
||||
2 -> 1.3f
|
||||
3 -> 1.15f
|
||||
else -> 1f
|
||||
}
|
||||
BlockText(start, block.content, style.copy(fontSize = fontSize * scale, lineHeight = lineHeight * scale, fontWeight = FontWeight.Bold))
|
||||
}
|
||||
is MarkdownBlock.Quote -> Row(Modifier.height(IntrinsicSize.Min)) {
|
||||
Box(Modifier.width(4.dp).fillMaxHeight().clip(RoundedCornerShape(2.dp)).background(textColor.copy(alpha = if (MaterialTheme.colors.isLight) 0.16f else 0.22f)))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
BlockText(start, block.content, style.copy(color = textColor.copy(alpha = 0.7f)))
|
||||
}
|
||||
is MarkdownBlock.ItemList -> listEntries(block.items, 0).forEachIndexed { j, (entry, level) ->
|
||||
Row(Modifier.padding(start = 20.dp * level)) {
|
||||
Text(entry.number ?: "•", Modifier.widthIn(min = 14.dp), style = style)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
BlockText(start + j, entry.content, style, Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
is MarkdownBlock.CodeBlock -> CodeBlockCard(cardShape, cardColor, roundness, codeText(formattedText, block.content)) { m ->
|
||||
BlockText(start, block.content, style.copy(fontFamily = FontFamily.Monospace, fontSize = fontSize * 0.875f, lineHeight = lineHeight * 0.9f), m, softWrap = false)
|
||||
}
|
||||
is MarkdownBlock.Table -> {
|
||||
val tableRows = listOf(block.header) + block.rows
|
||||
val rowStarts = tableRows.runningFold(start) { n, row -> n + row.size }
|
||||
Box(Modifier.horizontalScroll(rememberScrollState())) {
|
||||
TableGrid(
|
||||
block.columns, tableRows.map { it.size }, cardColor, MaterialTheme.colors.secondary.copy(alpha = 0.1f), lineColor,
|
||||
Modifier.clip(cardShape).border(1.dp, lineColor, cardShape)
|
||||
) {
|
||||
tableRows.forEachIndexed { r, row ->
|
||||
row.forEachIndexed { c, cell ->
|
||||
BlockText(
|
||||
rowStarts[r] + c, cell, if (r == 0) style.copy(fontWeight = FontWeight.Bold) else style,
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 4.dp), softWrap = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is MarkdownBlock.Rule -> Box(Modifier.padding(vertical = 6.dp).fillMaxWidth().height(1.dp).background(lineColor))
|
||||
}
|
||||
}
|
||||
if (!lastIsText) metaLine()
|
||||
}
|
||||
}
|
||||
|
||||
private val MarkdownBlock.start: Int get() = when (this) {
|
||||
is MarkdownBlock.Rule -> mark.from
|
||||
else -> textRanges.firstOrNull()?.range?.from ?: 0
|
||||
}
|
||||
|
||||
private val MarkdownBlock.end: Int get() = when (this) {
|
||||
is MarkdownBlock.Rule -> mark.to
|
||||
else -> textRanges.lastOrNull()?.range?.to ?: 0
|
||||
}
|
||||
|
||||
private fun paragraphBreak(fts: List<FormattedText>, from: Int, to: Int): Boolean =
|
||||
(from.coerceIn(0, fts.size) until to.coerceIn(0, fts.size)).any { (fts[it].format as? Format.Markup)?.token is BlockToken.ParagraphBreak }
|
||||
|
||||
private fun codeText(fts: List<FormattedText>, r: SegmentRange): String =
|
||||
(r.from.coerceIn(0, fts.size) until r.to.coerceIn(0, fts.size)).joinToString("") { i ->
|
||||
when (val f = fts[i].format) {
|
||||
is Format.Markup -> f.token.drawnText
|
||||
is Format.Escape -> ""
|
||||
else -> fts[i].text
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeBlockCard(shape: Shape, color: Color, roundness: Float, copyText: String, content: @Composable (Modifier) -> Unit) {
|
||||
var tapped by remember { mutableStateOf(false) }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val hovered by interactionSource.collectIsHoveredAsState()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(color)
|
||||
.hoverable(interactionSource)
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
if (waitForUpOrCancellation() != null) tapped = !tapped
|
||||
}
|
||||
}
|
||||
) {
|
||||
content(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp))
|
||||
if (hovered || tapped) {
|
||||
Box(Modifier.matchParentSize(), contentAlignment = Alignment.TopEnd) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(4.dp)
|
||||
.size(28.dp)
|
||||
.clip(RoundedCornerShape(8.dp * roundness))
|
||||
.background(color)
|
||||
.clickable {
|
||||
clipboard.setText(AnnotatedString(copyText))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_content_copy), stringResource(MR.strings.copy_verb), Modifier.size(18.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TableGridLines {
|
||||
var xs: List<Int> = emptyList()
|
||||
var ys: List<Int> = emptyList()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableGrid(
|
||||
columns: List<TableAlign>,
|
||||
rowSizes: List<Int>,
|
||||
headerColor: Color,
|
||||
stripeColor: Color,
|
||||
lineColor: Color,
|
||||
modifier: Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val lines = remember { TableGridLines() }
|
||||
Layout(
|
||||
content,
|
||||
modifier.drawBehind {
|
||||
val xs = lines.xs
|
||||
val ys = lines.ys
|
||||
if (ys.size < 2) return@drawBehind
|
||||
drawRect(headerColor, Offset.Zero, Size(size.width, ys[1].toFloat()))
|
||||
for (r in 2 until ys.size - 1 step 2) {
|
||||
drawRect(stripeColor, Offset(0f, ys[r].toFloat()), Size(size.width, (ys[r + 1] - ys[r]).toFloat()))
|
||||
}
|
||||
val stroke = 1.dp.toPx()
|
||||
xs.drop(1).dropLast(1).forEach { x -> drawLine(lineColor, Offset(x.toFloat(), 0f), Offset(x.toFloat(), size.height), stroke) }
|
||||
ys.drop(1).dropLast(1).forEach { y -> drawLine(lineColor, Offset(0f, y.toFloat()), Offset(size.width, y.toFloat()), stroke) }
|
||||
}
|
||||
) { measurables, _ ->
|
||||
val cells = rowSizes.flatMapIndexed { r, n -> List(n) { c -> r to c } }.take(measurables.size)
|
||||
val widths = IntArray(maxOf(columns.size, rowSizes.maxOrNull() ?: 0))
|
||||
cells.forEachIndexed { k, (_, c) -> widths[c] = maxOf(widths[c], measurables[k].maxIntrinsicWidth(Constraints.Infinity)) }
|
||||
val placeables = cells.mapIndexed { k, (_, c) -> measurables[k].measure(Constraints(maxWidth = widths[c])) }
|
||||
val heights = IntArray(rowSizes.size)
|
||||
placeables.forEachIndexed { k, p -> heights[cells[k].first] = maxOf(heights[cells[k].first], p.height) }
|
||||
val xs = widths.toList().runningFold(0) { a, w -> a + w }
|
||||
val ys = heights.toList().runningFold(0) { a, h -> a + h }
|
||||
lines.xs = xs
|
||||
lines.ys = ys
|
||||
layout(xs.last(), ys.last()) {
|
||||
placeables.forEachIndexed { k, p ->
|
||||
val (r, c) = cells[k]
|
||||
val free = widths[c] - p.width
|
||||
val x = xs[c] + when (columns.getOrNull(c)) {
|
||||
TableAlign.Right -> free
|
||||
TableAlign.Center -> free / 2
|
||||
else -> 0
|
||||
}
|
||||
p.place(x, ys[r])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
-27
@@ -12,8 +12,20 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.ImageShader
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.layout.*
|
||||
import androidx.compose.ui.platform.*
|
||||
@@ -22,11 +34,14 @@ import androidx.compose.ui.text.AnnotatedString.Range
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.*
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.common.ui.theme.appColors
|
||||
import chat.simplex.common.views.chat.ItemSelection
|
||||
import chat.simplex.common.views.chat.SelectionHighlightColor
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.*
|
||||
@@ -55,14 +70,17 @@ private fun showFullProfileDescription(description: String) {
|
||||
key(this) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(generalGetString(MR.strings.profile_description__field))
|
||||
val parsed = parseToMarkdownText(description)
|
||||
MarkdownText(
|
||||
description,
|
||||
parseToMarkdown(description),
|
||||
parsed?.formattedText,
|
||||
parsed?.markdownBlocks,
|
||||
toggleSecrets = true,
|
||||
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp),
|
||||
uriHandler = LocalUriHandler.current,
|
||||
linkMode = chatModel.simplexLinkMode.value,
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING),
|
||||
blockLayout = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -88,10 +106,10 @@ private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
|
||||
AnnotatedString(".", SpanStyle(fontWeight = w))
|
||||
|
||||
// Display text for a single formatted segment — must be coordinated with MarkdownText.
|
||||
fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String =
|
||||
fun segmentDisplayText(ft: FormattedText, mentions: Map<String, CIMention>?, linkMode: SimplexLinkMode): String =
|
||||
when (ft.format) {
|
||||
is Format.Mention -> {
|
||||
val mention = ci.mentions?.get(ft.format.memberName)
|
||||
val mention = mentions?.get(ft.format.memberName)
|
||||
if (mention?.memberRef != null) {
|
||||
val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName
|
||||
else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})"
|
||||
@@ -107,13 +125,37 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin
|
||||
if (t != null) "$t ${ft.format.viaHosts}" else ft.text
|
||||
}
|
||||
is Format.Command -> ft.text
|
||||
is Format.Escape -> ""
|
||||
is Format.Markup -> ft.format.token.drawnText
|
||||
else -> ft.text
|
||||
}
|
||||
|
||||
// Full display text for a chat item — joins segment display texts.
|
||||
fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String {
|
||||
val formattedText = ci.formattedText ?: return ci.text
|
||||
return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) }
|
||||
class ItemDisplay(val parts: List<Pair<String, String>>, val bases: List<Int>)
|
||||
|
||||
fun itemDisplay(ci: ChatItem, linkMode: SimplexLinkMode): ItemDisplay? {
|
||||
val fts = ci.formattedText ?: return null
|
||||
val segment = { ft: FormattedText ->
|
||||
val display = segmentDisplayText(ft, ci.mentions, linkMode)
|
||||
display to if (ft.format is Format.Markup) display else ft.text
|
||||
}
|
||||
val blocks = ci.markdownBlocks ?: return ItemDisplay(fts.map(segment), listOf(0))
|
||||
val parts = mutableListOf<Pair<String, String>>()
|
||||
var offset = itemPrefixText(ci).length
|
||||
val bases = blockTextRanges(blocks).mapIndexed { i, t ->
|
||||
if (i > 0) {
|
||||
val separator = if (t.inRow) "\t" else "\n"
|
||||
parts += separator to separator
|
||||
offset += 1
|
||||
}
|
||||
val base = if (i == 0) 0 else offset
|
||||
for (j in t.range.from.coerceIn(0, fts.size) until t.range.to.coerceIn(0, fts.size)) {
|
||||
val part = segment(fts[j])
|
||||
parts += part
|
||||
offset += part.first.length
|
||||
}
|
||||
base
|
||||
}
|
||||
return ItemDisplay(parts, bases)
|
||||
}
|
||||
|
||||
// Display-only prefix rendered before ci.text (e.g. "Spam: " for reports).
|
||||
@@ -123,11 +165,12 @@ fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
// Text transformations in MarkdownText must match itemSegmentDisplayText above
|
||||
// Text transformations in MarkdownText must match segmentDisplayText above
|
||||
@Composable
|
||||
fun MarkdownText (
|
||||
text: CharSequence,
|
||||
formattedText: List<FormattedText>? = null,
|
||||
markdownBlocks: List<MarkdownBlock>? = null,
|
||||
sender: String? = null,
|
||||
meta: CIMeta? = null,
|
||||
chatTTL: Int? = null,
|
||||
@@ -148,15 +191,44 @@ fun MarkdownText (
|
||||
showTimestamp: Boolean = true,
|
||||
prefix: AnnotatedString? = null,
|
||||
stripLink: String? = null,
|
||||
selectionRange: IntRange? = null,
|
||||
onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null
|
||||
selection: ItemSelection? = null,
|
||||
textIndex: Int = 0,
|
||||
blockLayout: Boolean = false,
|
||||
range: IntRange? = null,
|
||||
softWrap: Boolean = true,
|
||||
cardColor: Color? = null,
|
||||
parentDirection: LayoutDirection? = null
|
||||
) {
|
||||
val text = if (stripLink != null) stripTextLink(text.toString(), stripLink) else text
|
||||
val formattedText = if (stripLink != null) stripFormattedTextLink(formattedText, stripLink) else formattedText
|
||||
val textLayoutDirection = remember (text) {
|
||||
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
}
|
||||
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
|
||||
if (blockLayout && formattedText != null && markdownBlocks != null && range == null) {
|
||||
val direction = LocalLayoutDirection.current
|
||||
CompositionLocalProvider(LocalLayoutDirection provides textLayoutDirection) {
|
||||
MarkdownBlocks(
|
||||
formattedText, markdownBlocks, style, modifier, cardColor ?: MaterialTheme.appColors.receivedQuote,
|
||||
blockText = { index, r, blockStyle, blockModifier, blockSoftWrap, last ->
|
||||
MarkdownText(
|
||||
text, formattedText, markdownBlocks, sender = if (index == 0) sender else null, meta = if (last) meta else null, chatTTL = chatTTL,
|
||||
mentions = mentions, userMemberId = userMemberId, toggleSecrets = toggleSecrets, sendCommandMsg = sendCommandMsg, style = blockStyle,
|
||||
uriHandler = uriHandler, senderBold = senderBold, modifier = blockModifier, linkMode = linkMode, onLinkLongClick = onLinkLongClick,
|
||||
showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = if (index == 0) prefix else null,
|
||||
selection = selection, textIndex = index, range = r.from until r.to, softWrap = blockSoftWrap, parentDirection = direction
|
||||
)
|
||||
},
|
||||
metaLine = {
|
||||
MarkdownText(
|
||||
text, formattedText, markdownBlocks, meta = meta, chatTTL = chatTTL, toggleSecrets = toggleSecrets, style = style,
|
||||
linkMode = linkMode, showViaProxy = showViaProxy, showTimestamp = showTimestamp, range = IntRange.EMPTY
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val reserve = if (textLayoutDirection != (parentDirection ?: LocalLayoutDirection.current) && meta != null) {
|
||||
"\n"
|
||||
} else if (meta != null) {
|
||||
reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
@@ -199,6 +271,7 @@ fun MarkdownText (
|
||||
}
|
||||
)
|
||||
}
|
||||
val textSelection = selection?.text(textIndex)
|
||||
if (formattedText == null) {
|
||||
var selectableEnd = 0
|
||||
val annotatedText = buildAnnotatedString {
|
||||
@@ -213,9 +286,9 @@ fun MarkdownText (
|
||||
}
|
||||
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||
}
|
||||
val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) }
|
||||
if (onTextLayoutResult != null) {
|
||||
SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult)
|
||||
val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() }
|
||||
if (textSelection != null) {
|
||||
SelectableText(annotatedText, style = style, modifier = modifier.then(textSelection.positionModifier), maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = textSelection.onTextLayoutResult)
|
||||
} else {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
|
||||
}
|
||||
@@ -225,29 +298,38 @@ fun MarkdownText (
|
||||
var hasSecrets = false
|
||||
var hasCommands = false
|
||||
var hasModals = false
|
||||
val hiddenSecrets = mutableListOf<IntRange>()
|
||||
val annotatedText = buildAnnotatedString {
|
||||
inlineContent?.first?.invoke(this)
|
||||
appendSender(this, sender, senderBold)
|
||||
if (prefix != null) append(prefix)
|
||||
for ((i, ft) in formattedText.withIndex()) {
|
||||
val headings = if (range == null && markdownBlocks != null) headingSegments(formattedText.size, markdownBlocks) else null
|
||||
fun appendSecret(s: String) {
|
||||
val start = length
|
||||
withStyle(hiddenSecretStyle) { append(s) }
|
||||
hiddenSecrets += start until length
|
||||
}
|
||||
for (i in range?.let { it.first.coerceAtLeast(0) .. minOf(it.last, formattedText.lastIndex) } ?: formattedText.indices) {
|
||||
val ft = formattedText[i]
|
||||
val heading = headings?.get(i) == true
|
||||
if (heading) pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
if (ft.format == null) append(ft.text)
|
||||
else when(ft.format) {
|
||||
is Format.Bold -> withStyle(ft.format.style) { append(ft.text) }
|
||||
is Format.Bold -> withStyle(if (markdownBlocks != null && ft.format.singleStar) Format.Italic().style else ft.format.style) { append(ft.text) }
|
||||
is Format.Italic -> withStyle(ft.format.style) { append(ft.text) }
|
||||
is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) }
|
||||
is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) }
|
||||
is Format.Small -> withStyle(ft.format.style) { append(ft.text) }
|
||||
is Format.Colored -> withStyle(ft.format.style) { append(ft.text) }
|
||||
is Format.Secret -> {
|
||||
val ftStyle = ft.format.style
|
||||
if (toggleSecrets) {
|
||||
hasSecrets = true
|
||||
val key = i.toString()
|
||||
withAnnotation(tag = "SECRET", annotation = key) {
|
||||
if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) }
|
||||
if (showSecrets[key] == true) append(ft.text) else appendSecret(ft.text)
|
||||
}
|
||||
} else {
|
||||
withStyle(ftStyle) { append(ft.text) }
|
||||
appendSecret(ft.text)
|
||||
}
|
||||
}
|
||||
is Format.Mention -> {
|
||||
@@ -293,7 +375,7 @@ fun MarkdownText (
|
||||
is Format.HyperLink -> {
|
||||
hasLinks = true
|
||||
val ftStyle = Format.linkStyle
|
||||
withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) {
|
||||
withAnnotation(tag = if (ft.format.isEmail) "OTHER_URL" else "WEB_URL", annotation = ft.format.linkUri) {
|
||||
withStyle(ftStyle) { append(ft.format.showText ?: ft.text) }
|
||||
}
|
||||
}
|
||||
@@ -341,8 +423,15 @@ fun MarkdownText (
|
||||
withStyle(ftStyle) { append(ft.text) }
|
||||
}
|
||||
}
|
||||
is Format.Escape -> {}
|
||||
is Format.Markup -> append(
|
||||
if (range != null) ft.format.token.drawnText
|
||||
else if (markdownBlocks != null) ft.format.token.previewText
|
||||
else ft.text
|
||||
)
|
||||
is Format.Unknown -> append(ft.text)
|
||||
}
|
||||
if (heading) pop()
|
||||
}
|
||||
selectableEnd = this.length
|
||||
if (meta?.isLive == true) {
|
||||
@@ -353,10 +442,21 @@ fun MarkdownText (
|
||||
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
|
||||
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||
}
|
||||
val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) }
|
||||
val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() }
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val onTextLayout: (TextLayoutResult) -> Unit = { r ->
|
||||
layoutResult.value = r
|
||||
textSelection?.onTextLayoutResult?.invoke(r)
|
||||
}
|
||||
val textModifier = modifier
|
||||
.then(textSelection?.positionModifier ?: Modifier)
|
||||
.then(if (hiddenSecrets.isEmpty()) Modifier else Modifier.drawWithContent {
|
||||
drawContent()
|
||||
drawSecretPattern(hiddenSecrets, layoutResult.value)
|
||||
})
|
||||
if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null) || hasModals) {
|
||||
val icon = remember { mutableStateOf(PointerIcon.Text) }
|
||||
ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
|
||||
ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = textModifier.pointerHoverIcon(icon.value), softWrap = softWrap, maxLines = maxLines, overflow = overflow,
|
||||
onLongClick = { offset ->
|
||||
if (hasLinks) {
|
||||
val withAnnotation: (String, (Range<String>) -> Unit) -> Unit = { tag, f ->
|
||||
@@ -413,13 +513,38 @@ fun MarkdownText (
|
||||
|| annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset)
|
||||
|| annotatedText.hasStringAnnotations(tag = "MODAL", start = offset, end = offset)
|
||||
},
|
||||
onTextLayout = { onTextLayoutResult?.invoke(it) }
|
||||
onTextLayout = onTextLayout
|
||||
)
|
||||
} else {
|
||||
if (onTextLayoutResult != null) {
|
||||
SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult)
|
||||
if (textSelection != null) {
|
||||
SelectableText(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayout)
|
||||
} else {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
|
||||
Text(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf(), onTextLayout = onTextLayout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val hiddenSecretStyle = SpanStyle(color = Color.Transparent)
|
||||
|
||||
private val secretTile: ImageBitmap? by lazy { MR.images.vertical_logo.toComposeImageBitmap() }
|
||||
|
||||
private fun ContentDrawScope.drawSecretPattern(ranges: List<IntRange>, layout: TextLayoutResult?) {
|
||||
val tile = secretTile ?: return
|
||||
if (layout == null) return
|
||||
val brush = ShaderBrush(ImageShader(tile, TileMode.Repeated, TileMode.Repeated))
|
||||
val tileScale = 8.dp.toPx() / tile.width
|
||||
val saturation = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.65f) })
|
||||
val textLength = layout.layoutInput.text.length
|
||||
ranges.forEach { r ->
|
||||
val path = layout.getPathForRange(r.first.coerceIn(0, textLength), (r.last + 1).coerceIn(0, textLength))
|
||||
val bounds = path.getBounds()
|
||||
val side = (bounds.width + bounds.height) / tileScale
|
||||
clipPath(path) {
|
||||
rotate(45f, bounds.center) {
|
||||
scale(tileScale, bounds.center) {
|
||||
drawRect(brush, Offset(bounds.center.x - side / 2, bounds.center.y - side / 2), Size(side, side), alpha = 0.35f, colorFilter = saturation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,6 +628,7 @@ private fun SelectableText(
|
||||
text: AnnotatedString,
|
||||
style: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
softWrap: Boolean = true,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
selectionRange: IntRange? = null,
|
||||
@@ -514,6 +640,7 @@ private fun SelectableText(
|
||||
text = text,
|
||||
modifier = modifier.pointerHoverIcon(PointerIcon.Text).then(selectionHighlight(selectionRange, text.length, layoutResult)),
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
onTextLayout = {
|
||||
@@ -627,7 +754,9 @@ fun stripFormattedTextLink(ft: List<FormattedText>?, link: String): List<Formatt
|
||||
val result = ft.toMutableList()
|
||||
result.removeAt(result.lastIndex)
|
||||
val i = result.lastIndex
|
||||
if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) {
|
||||
if (i >= 0 && result[i].format is Format.Markup) {
|
||||
result.removeAt(i)
|
||||
} else if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) {
|
||||
result[i] = FormattedText(result[i].text.dropLast(1), null)
|
||||
if (result[i].text.isEmpty()) result.removeAt(result.lastIndex)
|
||||
}
|
||||
|
||||
+1
@@ -277,6 +277,7 @@ fun ChatPreviewView(
|
||||
MarkdownText(
|
||||
text,
|
||||
formattedText,
|
||||
if (formattedText == null) null else ci.markdownBlocks,
|
||||
sender = when {
|
||||
cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName
|
||||
else -> null
|
||||
|
||||
+6
-3
@@ -132,13 +132,16 @@ fun PlainTextEditor(
|
||||
|
||||
@Serializable
|
||||
data class ParsedFormattedText(
|
||||
val formattedText: List<FormattedText>? = null
|
||||
val formattedText: List<FormattedText>? = null,
|
||||
val markdownBlocks: List<MarkdownBlock>? = null
|
||||
)
|
||||
|
||||
fun parseToMarkdown(text: String): List<FormattedText>? {
|
||||
fun parseToMarkdown(text: String): List<FormattedText>? = parseToMarkdownText(text)?.formattedText
|
||||
|
||||
fun parseToMarkdownText(text: String): ParsedFormattedText? {
|
||||
val formatted = chatParseMarkdown(text)
|
||||
return try {
|
||||
json.decodeFromString(ParsedFormattedText.serializer(), formatted).formattedText
|
||||
json.decodeFromString(ParsedFormattedText.serializer(), formatted)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse into markdown: " + e.stackTraceToString())
|
||||
null
|
||||
|
||||
@@ -1301,6 +1301,7 @@
|
||||
<string name="callstate_received_answer">received answer…</string>
|
||||
<string name="callstate_received_confirmation">received confirmation…</string>
|
||||
<string name="callstate_connecting">connecting…</string>
|
||||
<string name="callstate_reconnecting">reconnecting…</string>
|
||||
<string name="callstate_connected">connected</string>
|
||||
<string name="callstate_ended">ended</string>
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -54,6 +54,8 @@ let inactiveCallMediaSources = {
|
||||
let activeCall;
|
||||
let notConnectedCall;
|
||||
let answerTimeout = 30000;
|
||||
let reconnectTimeout = 30000;
|
||||
let iceRestartInterval = 10000;
|
||||
var useWorker = false;
|
||||
var isDesktop = false;
|
||||
var localizedState = "";
|
||||
@@ -75,7 +77,8 @@ const processCommand = (function () {
|
||||
return {
|
||||
peerConnectionConfig: {
|
||||
iceServers: iceServers !== null && iceServers !== void 0 ? iceServers : defaultIceServers,
|
||||
iceCandidatePoolSize: 10,
|
||||
// Chrome gathers no candidates on ICE restart when candidates pool is used
|
||||
iceCandidatePoolSize: 0,
|
||||
encodedInsertableStreams,
|
||||
iceTransportPolicy: relay ? "relay" : "all",
|
||||
// needed for Android WebView >= 69 && <= 72 where default was "plan-b" which is incompatible with transceivers
|
||||
@@ -202,10 +205,14 @@ const processCommand = (function () {
|
||||
layout: (_c = notConnectedCall === null || notConnectedCall === void 0 ? void 0 : notConnectedCall.layout) !== null && _c !== void 0 ? _c : LayoutType.Default,
|
||||
cameraTrackWasSetBefore: localStream.getVideoTracks().length > 0,
|
||||
peerHasOldVersion: false,
|
||||
receiveRestartSession,
|
||||
};
|
||||
localOrPeerMediaSourcesChanged(call);
|
||||
await setupMediaStreams(call);
|
||||
let connectionTimeout = setTimeout(connectionHandler, answerTimeout);
|
||||
let reconnectingTimeout;
|
||||
let iceRestartTimeout;
|
||||
let wasConnected = false;
|
||||
if (pc.connectionState) {
|
||||
pc.addEventListener("connectionstatechange", connectionStateChange);
|
||||
}
|
||||
@@ -216,30 +223,49 @@ const processCommand = (function () {
|
||||
async function connectionStateChange() {
|
||||
// "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView)
|
||||
// See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70)
|
||||
if (pc.connectionState !== "failed")
|
||||
if (pc.connectionState !== "failed" || wasConnected)
|
||||
connectionHandler();
|
||||
}
|
||||
async function connectionHandler() {
|
||||
async function connectionHandler(reconnectTimedOut = false) {
|
||||
var _a;
|
||||
let state = (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking"
|
||||
? pc.iceConnectionState
|
||||
: pc.iceConnectionState == "completed"
|
||||
? "connected"
|
||||
: "connecting"); /* webView 69-70 doesn't have connectionState yet */
|
||||
if (wasConnected) {
|
||||
if (reconnectTimedOut) {
|
||||
reconnectingTimeout = undefined;
|
||||
if (activeCall !== call || state == "connected")
|
||||
return;
|
||||
state = "disconnected";
|
||||
}
|
||||
else if (state == "disconnected" || state == "failed") {
|
||||
if (reconnectingTimeout !== undefined)
|
||||
return;
|
||||
reconnectingTimeout = setTimeout(() => connectionHandler(true), reconnectTimeout);
|
||||
if (isIceRestarter())
|
||||
restartIce();
|
||||
state = "reconnecting";
|
||||
}
|
||||
else if (state == "connecting" || state == "new") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendMessageToNative({
|
||||
resp: {
|
||||
type: "connection",
|
||||
state: {
|
||||
connectionState: (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking"
|
||||
? pc.iceConnectionState
|
||||
: pc.iceConnectionState == "completed"
|
||||
? "connected"
|
||||
: "connecting") /* webView 69-70 doesn't have connectionState yet */,
|
||||
connectionState: state,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
iceGatheringState: pc.iceGatheringState,
|
||||
signalingState: pc.signalingState,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (pc.connectionState == "disconnected" ||
|
||||
pc.connectionState == "failed" ||
|
||||
(!pc.connectionState && (pc.iceConnectionState == "disconnected" || pc.iceConnectionState == "failed"))) {
|
||||
if (state == "disconnected" || state == "failed") {
|
||||
clearConnectionTimeout();
|
||||
clearReconnecting();
|
||||
if (pc.connectionState) {
|
||||
pc.removeEventListener("connectionstatechange", connectionStateChange);
|
||||
}
|
||||
@@ -251,8 +277,10 @@ const processCommand = (function () {
|
||||
}
|
||||
endCall();
|
||||
}
|
||||
else if (pc.connectionState == "connected" || (!pc.connectionState && pc.iceConnectionState == "connected")) {
|
||||
else if (state == "connected") {
|
||||
clearConnectionTimeout();
|
||||
clearReconnecting();
|
||||
wasConnected = true;
|
||||
const stats = (await pc.getStats());
|
||||
for (const stat of stats.values()) {
|
||||
const { type, state } = stat;
|
||||
@@ -278,6 +306,53 @@ const processCommand = (function () {
|
||||
connectionTimeout = undefined;
|
||||
}
|
||||
}
|
||||
function clearReconnecting() {
|
||||
clearTimeout(reconnectingTimeout);
|
||||
reconnectingTimeout = undefined;
|
||||
clearTimeout(iceRestartTimeout);
|
||||
iceRestartTimeout = undefined;
|
||||
}
|
||||
// Only the party that sent the initial offer restarts ICE, so that restart offers never collide
|
||||
function isIceRestarter() {
|
||||
var _a;
|
||||
return ((_a = pc.localDescription) === null || _a === void 0 ? void 0 : _a.type) == "offer";
|
||||
}
|
||||
async function restartIce() {
|
||||
if (activeCall !== call || reconnectingTimeout === undefined)
|
||||
return;
|
||||
iceRestartTimeout = setTimeout(restartIce, iceRestartInterval);
|
||||
// the previous restart offer is not answered yet
|
||||
if (pc.signalingState != "stable")
|
||||
return;
|
||||
try {
|
||||
const iceCandidates = getIceCandidates(pc, config);
|
||||
const offer = await pc.createOffer({ iceRestart: true });
|
||||
await pc.setLocalDescription(offer);
|
||||
sendMessageToNative({ resp: { type: "ice", session: serialize(offer), iceCandidates: await iceCandidates } });
|
||||
}
|
||||
catch (e) {
|
||||
console.log("ICE restart error", e);
|
||||
}
|
||||
}
|
||||
async function receiveRestartSession(session, iceCandidates) {
|
||||
const desc = parse(session);
|
||||
const remoteDesc = new RTCSessionDescription(!webView69Or70() ? desc : adaptSdpToOldWebView(desc));
|
||||
if (desc.type == "offer" && !isIceRestarter()) {
|
||||
await pc.setRemoteDescription(remoteDesc);
|
||||
const localIceCandidates = getIceCandidates(pc, config);
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
addIceCandidates(pc, iceCandidates);
|
||||
sendMessageToNative({ resp: { type: "ice", session: serialize(answer), iceCandidates: await localIceCandidates } });
|
||||
}
|
||||
else if (desc.type == "answer" && pc.signalingState == "have-local-offer") {
|
||||
await pc.setRemoteDescription(remoteDesc);
|
||||
addIceCandidates(pc, iceCandidates);
|
||||
}
|
||||
else {
|
||||
throw new Error(`unexpected ICE restart ${desc.type} in ${pc.signalingState} state`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function serialize(x) {
|
||||
return LZString.compressToBase64(JSON.stringify(x));
|
||||
@@ -296,6 +371,8 @@ const processCommand = (function () {
|
||||
console.log("starting outgoing call - capabilities");
|
||||
if (activeCall)
|
||||
endCall();
|
||||
// Stop a preview stream from an earlier pre-connect outgoing call being replaced (activeCall may be null here)
|
||||
stopNotConnectedCall();
|
||||
let localStream = null;
|
||||
try {
|
||||
localStream = await getLocalMediaStream(true, command.media == CallMediaType.Video && (await browserHasCamera()), VideoCamera.User);
|
||||
@@ -332,7 +409,8 @@ const processCommand = (function () {
|
||||
if (activeCall)
|
||||
endCall();
|
||||
// It can be already defined on Android when switching calls (if the previous call was outgoing)
|
||||
notConnectedCall = undefined;
|
||||
// Stop its preview tracks before clearing, otherwise camera/mic stay live
|
||||
stopNotConnectedCall();
|
||||
inactiveCallMediaSources.mic = true;
|
||||
inactiveCallMediaSources.camera = command.media == CallMediaType.Video;
|
||||
inactiveCallMediaSourcesChanged(inactiveCallMediaSources);
|
||||
@@ -436,7 +514,11 @@ const processCommand = (function () {
|
||||
break;
|
||||
case "ice":
|
||||
const remoteIceCandidates = parse(command.iceCandidates);
|
||||
if (pc) {
|
||||
if (activeCall && command.session) {
|
||||
await activeCall.receiveRestartSession(command.session, remoteIceCandidates);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
else if (pc) {
|
||||
addIceCandidates(pc, remoteIceCandidates);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
@@ -1131,6 +1213,13 @@ const processCommand = (function () {
|
||||
activeCall.localScreenStream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
}
|
||||
// Call on any path that abandons notConnectedCall, otherwise its preview camera/mic tracks stay live.
|
||||
function stopNotConnectedCall() {
|
||||
if (notConnectedCall) {
|
||||
notConnectedCall.localStream.getTracks().forEach((track) => track.stop());
|
||||
notConnectedCall = undefined;
|
||||
}
|
||||
}
|
||||
function resetVideoElements() {
|
||||
const videos = getVideoElements();
|
||||
if (!videos)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Override defaults to enable worker on Chrome and Safari
|
||||
useWorker = typeof window.Worker !== "undefined";
|
||||
isDesktop = true;
|
||||
// Create WebSocket connection.
|
||||
// Create WebSocket connection. location.search carries the per-call ?token=... capability required by the server.
|
||||
const socket = new WebSocket(`ws://${location.host}${location.search}`);
|
||||
socket.addEventListener("open", (_event) => {
|
||||
console.log("Opened socket");
|
||||
@@ -192,4 +192,4 @@ function updateCallInfoView(state, description) {
|
||||
document.getElementById("state").innerText = state;
|
||||
document.getElementById("description").innerText = description;
|
||||
}
|
||||
//# sourceMappingURL=ui.js.map
|
||||
//# sourceMappingURL=ui.js.map
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.views.chat.item.itemDisplay
|
||||
import chat.simplex.common.views.chat.item.segmentDisplayText
|
||||
import chat.simplex.common.views.helpers.ParsedFormattedText
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class MarkdownBlocksTest {
|
||||
private val headingAndParagraph = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"headingMark","level":1}},"text":"# "},{"text":"Title "},{"format":{"type":"bold","singleStar":true},"text":"x"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"text":"Para a"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"text":"b"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":" \n"},{"text":"c"}],"markdownBlocks":[{"type":"heading","level":1,"content":{"from":1,"to":3}},{"type":"paragraph","content":{"from":4,"to":9}}]}""")
|
||||
|
||||
private val nestedList = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"listMark"}},"text":"- "},{"text":"x"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"indent"}},"text":" "},{"text":"y"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"listMark"}},"text":" - "},{"text":"z"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"listMark","number":"2."}},"text":"2. "},{"text":"w"}],"markdownBlocks":[{"type":"list","items":[{"content":{"from":1,"to":5},"items":[{"content":{"from":7,"to":8},"items":[]}]},{"number":"2.","content":{"from":10,"to":11},"items":[]}]}]}""")
|
||||
|
||||
private val quote = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"text":"q1"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"text":"q2"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":">"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"format":{"type":"bold","singleStar":false},"text":"q3"}],"markdownBlocks":[{"type":"quote","content":{"from":1,"to":10}}]}""")
|
||||
|
||||
private val codeBlockAndRule = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"fenceMark","language":"bash"}},"text":"```bash"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"ls -l"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"pwd"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"fenceMark"}},"text":"```"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"ruleMark"}},"text":"---"}],"markdownBlocks":[{"type":"codeBlock","language":"bash","content":{"from":2,"to":6}},{"type":"rule","mark":{"from":9,"to":10}}]}""")
|
||||
|
||||
private val tableAligned = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"a"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"b"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"c"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"d"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"delimiterRow","columns":["none","left","right","center"]}},"text":"|---|:--|--:|:-:|"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"xxx"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"format":{"type":"bold","singleStar":false},"text":"yyy"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"zzz"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"}],"markdownBlocks":[{"type":"table","columns":["none","left","right","center"],"header":[{"from":1,"to":2},{"from":3,"to":4},{"from":5,"to":6},{"from":7,"to":8}],"rows":[[{"from":13,"to":14},{"from":15,"to":16},{"from":17,"to":18}]]}]}""")
|
||||
|
||||
private val gaps = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":2}},"text":"\n\n"},{"text":"a"},{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":3}},"text":"\n\n\n"},{"format":{"type":"markup","token":{"type":"headingMark","level":1}},"text":"# "},{"text":"h"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"}],"markdownBlocks":[{"type":"paragraph","content":{"from":1,"to":2}},{"type":"heading","level":1,"content":{"from":4,"to":5}}]}""")
|
||||
|
||||
private val escapes = parsed("""{"formattedText":[{"text":"text "},{"format":{"type":"escape"},"text":"\\"},{"text":"*not"},{"format":{"type":"escape"},"text":"\\"},{"text":"*\nmore"}]}""")
|
||||
|
||||
@Test
|
||||
fun testDecodeTokens() {
|
||||
val tokens = (headingAndParagraph.formattedText!! + nestedList.formattedText!! + codeBlockAndRule.formattedText!! + tableAligned.formattedText!! + gaps.formattedText!!)
|
||||
.mapNotNull { (it.format as? Format.Markup)?.token }
|
||||
assertEquals(1, tokens.filterIsInstance<BlockToken.HeadingMark>().first().level)
|
||||
assertEquals(listOf(null, null, "2."), tokens.filterIsInstance<BlockToken.ListMark>().map { it.number })
|
||||
assertEquals(listOf("bash", null), tokens.filterIsInstance<BlockToken.FenceMark>().map { it.language })
|
||||
assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), tokens.filterIsInstance<BlockToken.DelimiterRow>().first().columns)
|
||||
assertEquals(listOf(2, 3), tokens.filterIsInstance<BlockToken.ParagraphBreak>().map { it.lineBreaks })
|
||||
listOf(
|
||||
BlockToken.LineBreak::class, BlockToken.SoftBreak::class, BlockToken.HardBreak::class, BlockToken.Indent::class,
|
||||
BlockToken.RowMark::class, BlockToken.CellMark::class, BlockToken.RuleMark::class
|
||||
).forEach { kind -> assert(tokens.any { kind.isInstance(it) }) { "no $kind" } }
|
||||
assertIs<BlockToken.QuoteMark>((quote.formattedText!![0].format as Format.Markup).token)
|
||||
val bold = headingAndParagraph.formattedText!!.mapNotNull { it.format as? Format.Bold } + quote.formattedText!!.mapNotNull { it.format as? Format.Bold }
|
||||
assertEquals(listOf(true, false), bold.map { it.singleStar })
|
||||
assertEquals(listOf("text ", "", "*not", "", "*\nmore"), escapes.formattedText!!.map(::display))
|
||||
assertNull(escapes.markdownBlocks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeBlocks() {
|
||||
val heading = assertIs<MarkdownBlock.Heading>(headingAndParagraph.markdownBlocks!![0])
|
||||
assertEquals(1, heading.level)
|
||||
assertEquals(1 to 3, heading.content.pair)
|
||||
assertEquals(4 to 9, assertIs<MarkdownBlock.Paragraph>(headingAndParagraph.markdownBlocks!![1]).content.pair)
|
||||
val list = assertIs<MarkdownBlock.ItemList>(nestedList.markdownBlocks!!.single())
|
||||
assertEquals(listOf(null, "2."), list.items.map { it.number })
|
||||
assertEquals(7 to 8, list.items[0].items.single().content.pair)
|
||||
assertEquals(1 to 10, assertIs<MarkdownBlock.Quote>(quote.markdownBlocks!!.single()).content.pair)
|
||||
val code = assertIs<MarkdownBlock.CodeBlock>(codeBlockAndRule.markdownBlocks!![0])
|
||||
assertEquals("bash", code.language)
|
||||
assertEquals(2 to 6, code.content.pair)
|
||||
assertEquals(9 to 10, assertIs<MarkdownBlock.Rule>(codeBlockAndRule.markdownBlocks!![1]).mark.pair)
|
||||
val table = assertIs<MarkdownBlock.Table>(tableAligned.markdownBlocks!!.single())
|
||||
assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), table.columns)
|
||||
assertEquals(listOf(1 to 2, 3 to 4, 5 to 6, 7 to 8), table.header.map { it.pair })
|
||||
assertEquals(listOf(listOf(13 to 14, 15 to 16, 17 to 18)), table.rows.map { row -> row.map { it.pair } })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBlockTextRanges() {
|
||||
assertEquals(listOf(1 to 3, 4 to 9), texts(headingAndParagraph).map { it.first })
|
||||
assertEquals(listOf(1 to 5, 7 to 8, 10 to 11), texts(nestedList).map { it.first })
|
||||
assertEquals(listOf(1 to 10), texts(quote).map { it.first })
|
||||
assertEquals(listOf(2 to 6), texts(codeBlockAndRule).map { it.first })
|
||||
assertEquals(
|
||||
listOf(1 to 2 to false, 3 to 4 to true, 5 to 6 to true, 7 to 8 to true, 13 to 14 to false, 15 to 16 to true, 17 to 18 to true),
|
||||
texts(tableAligned)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListEntryLevels() {
|
||||
val list = assertIs<MarkdownBlock.ItemList>(nestedList.markdownBlocks!!.single())
|
||||
assertEquals(listOf(1 to 0, 7 to 1, 10 to 0), listEntries(list.items, 0).map { (entry, level) -> entry.content.from to level })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHeadingSegments() {
|
||||
assertEquals(listOf(1, 2), headingSegments(9, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index })
|
||||
assertEquals(listOf(4), headingSegments(6, gaps.markdownBlocks!!).withIndex().filter { it.value }.map { it.index })
|
||||
assertEquals(listOf(1), headingSegments(2, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTokenTexts() {
|
||||
val tokens = listOf(
|
||||
BlockToken.SoftBreak(), BlockToken.LineBreak(), BlockToken.HardBreak(), BlockToken.ParagraphBreak(3), BlockToken.Indent(),
|
||||
BlockToken.HeadingMark(2), BlockToken.ListMark("1."), BlockToken.ListMark(null), BlockToken.QuoteMark(), BlockToken.FenceMark(null),
|
||||
BlockToken.RowMark(), BlockToken.CellMark(), BlockToken.DelimiterRow(listOf(TableAlign.None)), BlockToken.RuleMark()
|
||||
)
|
||||
assertEquals(listOf(" ", "\n", "\n", "\n\n\n", "", "", "", "", "", "", "", "", "", ""), tokens.map { it.drawnText })
|
||||
assertEquals(listOf(" ", "\n", " ", "\n", "", "", "1. ", "• ", "", "", "", " ", "", ""), tokens.map { it.previewText })
|
||||
assertEquals("", display(FormattedText("\\", Format.Escape())))
|
||||
assertEquals("Title x\nPara a b c", preview(headingAndParagraph))
|
||||
assertEquals("• x y\n• z\n2. w", preview(nestedList))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testItemDisplay() {
|
||||
val item = ChatItem.getSampleData(text = "> q1\n> q2\n>\n> **q3**").copy(formattedText = quote.formattedText, markdownBlocks = quote.markdownBlocks)
|
||||
val display = itemDisplay(item, SimplexLinkMode.DESCRIPTION)!!
|
||||
assertEquals("q1 q2\n\nq3", display.parts.joinToString("") { it.first })
|
||||
assertEquals(listOf(0), display.bases)
|
||||
val table = item.copy(formattedText = tableAligned.formattedText, markdownBlocks = tableAligned.markdownBlocks)
|
||||
val tableDisplay = itemDisplay(table, SimplexLinkMode.DESCRIPTION)!!
|
||||
assertEquals("a\tb\tc\td\nxxx\tyyy\tzzz", tableDisplay.parts.joinToString("") { it.first })
|
||||
assertEquals(listOf(0, 2, 4, 6, 8, 12, 16), tableDisplay.bases)
|
||||
val plain = item.copy(formattedText = escapes.formattedText, markdownBlocks = null)
|
||||
val plainDisplay = itemDisplay(plain, SimplexLinkMode.DESCRIPTION)!!
|
||||
assertEquals(listOf("text " to "text ", "" to "\\", "*not" to "*not", "" to "\\", "*\nmore" to "*\nmore"), plainDisplay.parts)
|
||||
assertEquals(listOf(0), plainDisplay.bases)
|
||||
}
|
||||
|
||||
private val SegmentRange.pair: Pair<Int, Int> get() = from to to
|
||||
|
||||
private fun parsed(s: String): ParsedFormattedText = json.decodeFromString(ParsedFormattedText.serializer(), s)
|
||||
|
||||
private fun display(ft: FormattedText): String = segmentDisplayText(ft, null, SimplexLinkMode.DESCRIPTION)
|
||||
|
||||
private fun texts(p: ParsedFormattedText): List<Pair<Pair<Int, Int>, Boolean>> =
|
||||
blockTextRanges(p.markdownBlocks!!).map { it.range.pair to it.inRow }
|
||||
|
||||
private fun preview(p: ParsedFormattedText): String =
|
||||
p.formattedText!!.joinToString("") { ft -> (ft.format as? Format.Markup)?.token?.previewText ?: ft.text }
|
||||
}
|
||||
+3
-1
@@ -313,7 +313,9 @@ actual object CallSoundsPlayer: CallSoundsPlayerInterface {
|
||||
override fun startConnectingCallSound(scope: CoroutineScope) {
|
||||
// Taken from https://github.com/TelegramOrg/Telegram-Android
|
||||
// https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE
|
||||
start("/media/connecting_call.mp3", 3000, scope)
|
||||
// the delay is the length of the track exactly, so it repeats with no delay, which already happens on ios and android
|
||||
// Android (isLooping) and iOS (which sleeps for the track duration)
|
||||
start("/media/connecting_call.mp3", 3536, scope)
|
||||
}
|
||||
|
||||
override fun startInCallSound(scope: CoroutineScope) {
|
||||
|
||||
+27
-16
@@ -8,6 +8,7 @@ import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -55,13 +56,24 @@ actual fun ActiveCallView() {
|
||||
CallSoundsPlayer.stop()
|
||||
}
|
||||
is WCallResponse.Ice -> withBGApi {
|
||||
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
|
||||
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates)
|
||||
}
|
||||
is WCallResponse.Connection ->
|
||||
try {
|
||||
// the call is restored and NOT sent to the core
|
||||
// Makes sure the core DOES NOT record the duration wrong if a reconnect happens
|
||||
if (r.state.connectionState == "reconnecting") {
|
||||
if (call.callState != CallState.Reconnecting) {
|
||||
CallSoundsPlayer.startConnectingCallSound(scope)
|
||||
}
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Reconnecting)
|
||||
} else try {
|
||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||
if (callStatus == WebRTCCallStatus.Connected) {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now())
|
||||
if (call.callState == CallState.Reconnecting) {
|
||||
CallSoundsPlayer.stop()
|
||||
}
|
||||
// connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = call.connectedAt ?: Clock.System.now())
|
||||
}
|
||||
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
|
||||
} catch (e: Throwable) {
|
||||
@@ -194,21 +206,20 @@ fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { callCommand.firstOrNull() }
|
||||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.collect {
|
||||
while (connections.isEmpty()) {
|
||||
delay(100)
|
||||
}
|
||||
while (callCommand.isNotEmpty()) {
|
||||
val cmd = callCommand.removeFirstOrNull()
|
||||
Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
|
||||
if (cmd != null) {
|
||||
processCommand(cmd)
|
||||
}
|
||||
while (isActive) {
|
||||
// waiting for the first command would miss a command equal to the previous one
|
||||
snapshotFlow { callCommand.isNotEmpty() }.first { it }
|
||||
while (connections.isEmpty()) {
|
||||
delay(100)
|
||||
}
|
||||
while (callCommand.isNotEmpty()) {
|
||||
val cmd = callCommand.removeFirstOrNull()
|
||||
Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
|
||||
if (cmd != null) {
|
||||
processCommand(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ Both platforms share a common signaling flow through the Haskell core API.
|
||||
```
|
||||
Outgoing: WaitCapabilities -> InvitationSent -> OfferSent -> AnswerReceived -> Negotiated -> Connected -> Ended
|
||||
Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ended
|
||||
Reconnection: Connected -> Reconnecting -> Connected | Ended
|
||||
```
|
||||
|
||||
| State | Description |
|
||||
@@ -111,8 +112,17 @@ Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ende
|
||||
| `AnswerReceived` | Caller received SDP answer |
|
||||
| `Negotiated` | ICE negotiation complete |
|
||||
| `Connected` | Media flowing |
|
||||
| `Reconnecting` | Connection lost, waiting for ICE restart |
|
||||
| `Ended` | Call terminated |
|
||||
|
||||
### 3.1 Reconnection
|
||||
|
||||
1. A connected call loses its WebRTC connection, e.g. when the device switches between Wi-Fi and mobile network.
|
||||
2. The WebRTC engine reports `"reconnecting"` connection state; the app shows "reconnecting…" and plays the connecting sound. This state is not reported to the core, and `connectedAt` is kept, so call duration continues.
|
||||
3. The callee's WebRTC engine (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer in `WCallResponse.Ice` with `session` via `apiSendCallExtraInfo`. The caller answers the same way.
|
||||
4. When the connection is restored, the call returns to `Connected` and the sound stops.
|
||||
5. If the connection is not restored in 30 seconds, the call ends as disconnected. Clients without ICE restart support ignore the restart offer, so the call ends in this case.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ending a Call
|
||||
@@ -184,7 +194,7 @@ Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ende
|
||||
| `apiRejectCall(rh, contact)` | Reject incoming call |
|
||||
| `apiSendCallOffer(rh, contact, rtcSession, rtcIceCandidates, media, capabilities)` | Send SDP offer |
|
||||
| `apiSendCallAnswer(rh, contact, rtcSession, rtcIceCandidates)` | Send SDP answer |
|
||||
| `apiSendCallExtraInfo(rh, contact, rtcIceCandidates)` | Send additional ICE candidates |
|
||||
| `apiSendCallExtraInfo(rh, contact, rtcSession, rtcIceCandidates)` | Send additional ICE candidates, or ICE restart offer/answer |
|
||||
| `apiEndCall(rh, contact)` | End active call |
|
||||
| `apiCallStatus(rh, contact, status)` | Report WebRTC connection status |
|
||||
|
||||
|
||||
@@ -71,8 +71,8 @@ Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call
|
||||
| 1. Capabilities | `WCallResponse.Capabilities` | Local capabilities reported; `apiSendCallInvitation()` called |
|
||||
| 2. Offer | `WCallResponse.Offer` | SDP offer + ICE candidates sent via `apiSendCallOffer()` |
|
||||
| 3. Answer | `WCallResponse.Answer` | SDP answer + ICE candidates sent via `apiSendCallAnswer()` |
|
||||
| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates exchanged via `apiSendCallExtraInfo()` |
|
||||
| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success |
|
||||
| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates, and ICE restart offer/answer in `session`, exchanged via `apiSendCallExtraInfo()` |
|
||||
| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success, `CallState.Reconnecting` when a connected call loses connection |
|
||||
| 6. Connected | `WCallResponse.Connected` | Connection info (relay/direct) stored in `call.connectionInfo` |
|
||||
| 7. PeerMedia | `WCallResponse.PeerMedia` | Remote party media source changes (mic, camera, screen) |
|
||||
| 8. Media control | `WCallCommand.Media` | Toggle local media sources (mic, camera, screen audio/video) |
|
||||
@@ -90,14 +90,15 @@ Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call
|
||||
| `OfferReceived` | Callee received SDP offer |
|
||||
| `AnswerReceived` | Caller received SDP answer |
|
||||
| `Negotiated` | ICE negotiation complete |
|
||||
| `Connected` | WebRTC media flowing; `connectedAt` timestamp set |
|
||||
| `Connected` | WebRTC media flowing; `connectedAt` timestamp set on the first connection |
|
||||
| `Reconnecting` | Connection lost; "reconnecting…" shown while ICE restarts, call ends after 30 seconds without connection |
|
||||
| `Ended` | Call terminated |
|
||||
|
||||
### Call Sounds
|
||||
|
||||
| Sound | Trigger |
|
||||
|---|---|
|
||||
| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent |
|
||||
| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent, and while reconnecting |
|
||||
| In-call sound | `CallSoundsPlayer.startInCallSound()` when delivery receipt received |
|
||||
| Ringtone | `SoundPlayer.start()` for incoming calls |
|
||||
| End vibration | `CallSoundsPlayer.vibrate()` on call end (if was connected) |
|
||||
|
||||
@@ -225,7 +225,7 @@ All functions below are `suspend fun` members of `ChatController` ([SimpleXAPI.k
|
||||
| `apiRejectCall` | `rh: Long?, contact: Contact` | Reject an incoming call | [L1854](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) |
|
||||
| `apiSendCallOffer` | `rh, contact, rtcSession, rtcIceCandidates, media, capabilities` | Send a WebRTC call offer | [L1859](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) |
|
||||
| `apiSendCallAnswer` | `rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String` | Send a WebRTC call answer | [L1866](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) |
|
||||
| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcIceCandidates: String` | Send additional ICE candidates during a call | [L1872](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) |
|
||||
| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String` | Send additional ICE candidates or ICE restart offer/answer during a call | [L2156](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2156) |
|
||||
| `apiEndCall` | `rh: Long?, contact: Contact` | End an active call | [L1878](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) |
|
||||
| `apiCallStatus` | `rh: Long?, contact: Contact, status: WebRTCCallStatus` | Report call status updates to the core | [L1883](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) |
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ Key sections: group profile, group link, member list with roles, group preferenc
|
||||
| `MarkdownFileView.kt` | Markdown file viewer, formatted or as text |
|
||||
| `IntegrityErrorItemView.kt` | Message integrity error |
|
||||
| `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message |
|
||||
| `TextItemView.kt` | Plain text message with markdown |
|
||||
| `TextItemView.kt` | Plain text message with markdown; blocks of `markdownBlocks` drawn as a column of views by `MarkdownBlocks` (`MarkdownBlocks.kt`) |
|
||||
|
||||
### `views/chat/group/`
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ enum class CallState {
|
||||
AnswerReceived, // SDP answer received from peer
|
||||
Negotiated, // ICE negotiation in progress
|
||||
Connected, // Media flowing
|
||||
Reconnecting, // Connection lost, waiting for ICE to recover
|
||||
Ended; // Call terminated
|
||||
}
|
||||
```
|
||||
@@ -53,6 +54,8 @@ enum class CallState {
|
||||
|
||||
State transitions are driven by `WCallResponse` messages from the WebRTC layer. Each transition typically triggers a corresponding API command (e.g., `apiSendCallInvitation`, `apiSendCallOffer`).
|
||||
|
||||
**Reconnection**: `Connected` -> `Reconnecting` -> `Connected` or `Ended`. When a connected call loses its WebRTC connection, `call.js` reports `"reconnecting"` connection state instead of ending the call. The app sets `CallState.Reconnecting` and plays the connecting sound; this state is not sent to the core with `apiCallStatus`, so the call item duration is not affected, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds until reconnected: the restart offer and the answer are sent as `WCallResponse.Ice` / `WCallCommand.Ice` with `session`, via `apiSendCallExtraInfo` (`x.call.extra` with `rtcSession`). If the connection is not restored in 30 seconds, `call.js` reports `"disconnected"` and ends the call.
|
||||
|
||||
---
|
||||
|
||||
<a id="ActiveCallView"></a>
|
||||
@@ -140,7 +143,7 @@ Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/com
|
||||
| `apiRejectCall` | [L1854](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | Reject an incoming call |
|
||||
| `apiSendCallOffer` | [L1859](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | Send SDP offer with ICE candidates and capabilities |
|
||||
| `apiSendCallAnswer` | [L1866](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | Send SDP answer with ICE candidates |
|
||||
| `apiSendCallExtraInfo` | [L1872](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | Send additional ICE candidates discovered after initial exchange |
|
||||
| `apiSendCallExtraInfo` | [L2156](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2156) | Send additional ICE candidates discovered after initial exchange, or ICE restart offer/answer in `rtcSession` |
|
||||
| `apiEndCall` | [L1878](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | Terminate a call |
|
||||
| `apiCallStatus` | [L1883](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | Report WebRTC connection status to the backend |
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import qualified Data.ByteString.Char8 as B
|
||||
import qualified Data.ByteString.Lazy as LB
|
||||
import Data.Int (Int64)
|
||||
import Data.List (isPrefixOf)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import Data.Maybe (catMaybes, fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
@@ -92,7 +93,9 @@ data DirectoryEntry = DirectoryEntry
|
||||
simplexName :: Maybe Text,
|
||||
groupLink :: PublicLink,
|
||||
shortDescr :: Maybe MarkdownList,
|
||||
shortDescrBlocks :: Maybe (NonEmpty MarkdownBlock),
|
||||
welcomeMessage :: Maybe MarkdownList,
|
||||
welcomeMessageBlocks :: Maybe (NonEmpty MarkdownBlock),
|
||||
imageFile :: Maybe String,
|
||||
activeAt :: Maybe UTCTime,
|
||||
createdAt :: Maybe UTCTime
|
||||
@@ -133,6 +136,8 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma
|
||||
linkLine = groupLinkLine displayName $ decodeUtf8 linkText
|
||||
in Just $ maybe linkLine (<> "\n\n" <> linkLine) description
|
||||
_ -> description
|
||||
shortDescr' = toFormattedText <$> shortDescr
|
||||
welcomeMessage' = toFormattedText <$> description'
|
||||
entry groupLink =
|
||||
let de =
|
||||
DirectoryEntry
|
||||
@@ -140,8 +145,10 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma
|
||||
displayName,
|
||||
simplexName = shortNameInfoStr . SimplexNameInfo NTPublicGroup <$> verifiedGroupDomain g,
|
||||
groupLink,
|
||||
shortDescr = toFormattedText <$> shortDescr,
|
||||
welcomeMessage = toFormattedText <$> description',
|
||||
shortDescr = fst <$> shortDescr',
|
||||
shortDescrBlocks = snd =<< shortDescr',
|
||||
welcomeMessage = fst <$> welcomeMessage',
|
||||
welcomeMessageBlocks = snd =<< welcomeMessage',
|
||||
imageFile = fst <$> imgData,
|
||||
activeAt = recentRoundedTime 900 now $ fromMaybe createdAt chatTs,
|
||||
createdAt = recentRoundedTime 86400 now createdAt
|
||||
@@ -194,5 +201,5 @@ generateListing dir gs = do
|
||||
where
|
||||
saveListing newDir f = LB.writeFile (newDir </> f) . J.encode . DirectoryListing . map fst
|
||||
|
||||
toFormattedText :: Text -> MarkdownList
|
||||
toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t
|
||||
toFormattedText :: Text -> (MarkdownList, Maybe (NonEmpty MarkdownBlock))
|
||||
toFormattedText t = let ParsedMarkdown ft blocks = parsedMarkdown t in (fromMaybe [FormattedText Nothing t] ft, blocks)
|
||||
@@ -1157,10 +1157,10 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o
|
||||
-- "Use */filter " <> tshow gId <> " <level>* to change spam filter level: no (disable), basic, moderate, strong.",
|
||||
-- "Or use */filter " <> tshow gId <> " [name[=noimage]] [captcha[=noimage]] [observer[=noimage]]* for advanced filter configuration."
|
||||
]
|
||||
<> ["/'filter " <> tshow gId <> " name' - enable name filter" | isNothing (rejectNames a)]
|
||||
<> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge" | isNothing (passCaptcha a)]
|
||||
<> ["/'filter " <> tshow gId <> " name captcha' - enable both" | isNothing (rejectNames a) || isNothing (passCaptcha a)]
|
||||
<> ["/'filter " <> tshow gId <> " off' - disable filter" | isJust (rejectNames a) || isJust (passCaptcha a)]
|
||||
<> ["/'filter " <> tshow gId <> " name' - enable name filter " | isNothing (rejectNames a)]
|
||||
<> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge " | isNothing (passCaptcha a)]
|
||||
<> ["/'filter " <> tshow gId <> " name captcha' - enable both " | isNothing (rejectNames a) || isNothing (passCaptcha a)]
|
||||
<> ["/'filter " <> tshow gId <> " off' - disable filter " | isJust (rejectNames a) || isJust (passCaptcha a)]
|
||||
showCondition = \case
|
||||
Nothing -> "_disabled_"
|
||||
Just PCAll -> "_enabled_"
|
||||
|
||||
Reference in new issue
Block a user