mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Give private DMs an unmistakable visual signature (#1357)
* Give private DMs an unmistakable visual signature An open DM renders identically to the public room — same view, same green-on-black surface, with a small header name and two orange icons as the only cues. For this audience the cost of misreading "am I in the encrypted DM or the public channel?" is severe: sensitive text typed into the wrong composer. Four presentation-layer cues; no formatter or cache changes: - The composer placeholder states the destination instead of a generic prompt: "message @jack — private" in a DM, "message #mesh — public, nearby" on mesh, "message #9q8yy — public" in a geohash channel. - A persistent lock caption sits above the DM composer. It reads "private · end-to-end encrypted" only once the Noise session is actually secured or verified, and "private conversation" before that — the caption must not overstate encryption mid-handshake. - The DM sheet header carries a faint orange wash (6%), extending the existing orange self-accent to the chrome. - Each private message row is prefixed with a small orange lock glyph (view-layer, hidden from VoiceOver — the caption carries the semantic; the cached AttributedString formatter is untouched). New strings are added source-language (en) only. * Fix geohash-DM caption and placeholder Two carve/review follow-ups: - The privacy caption showed "private conversation" for geohash DMs, implying they are not encrypted — but geohash DMs are NIP-17 gift-wrapped (always end-to-end encrypted), they just carry no Noise session status. Show the encrypted caption for geohash DMs and for secured Noise sessions; the pre-secured wording now applies only while a mesh handshake is still in progress. - The private-chat placeholder prepended "@" to the partner name, which for a geohash DM (whose display name is already "#geohash/@name") produced a doubled "@". The "@" is now added only for mesh nicknames. * Make the DM header orange wash visible in the matrix theme The 6% orange background was chained after .themedSurface(), so in the default matrix theme (whose themedSurface paints an opaque background) the wash sat behind the surface and never rendered — it was only visible in liquid glass. Apply the orange tint before .themedSurface() so it layers in front of the themed background. * Align DM lock glyph across text and media rows; keep header wash visible under glass Media rows in a private conversation now get the same leading lock glyph as text rows, so left edges line up instead of misaligning by the glyph's width. The DM header's orange wash gets a higher opacity under the liquid-glass theme, where themedSurface() adds no opaque backing and 6% orange disappears into the backdrop gradient. Also drops the dead sender != "system" guard in TextMessageView — system messages are routed to systemMessageRow before this view is built. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove orphaned content.input.message_placeholder from the string catalog The destination-stating placeholders replaced its last code reference; nothing on the branch resolves this key anymore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
jack
Claude Fable 5
parent
9ccff9cce4
commit
c8ceac1968
+39
-158
@@ -17846,181 +17846,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"content.input.message_placeholder" : {
|
"content.input.placeholder.location" : {
|
||||||
|
"comment" : "Composer placeholder for a public geohash channel, naming it",
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "اكتب رسالة..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bn" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "একটি বার্তা লিখুন..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"de" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "nachricht eingeben..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "type a message..."
|
"value" : "message #%@ — public"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"es" : {
|
}
|
||||||
|
},
|
||||||
|
"content.input.placeholder.mesh" : {
|
||||||
|
"comment" : "Composer placeholder for the public mesh channel",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "escribe un mensaje..."
|
"value" : "message #mesh — public, nearby"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"fil" : {
|
}
|
||||||
|
},
|
||||||
|
"content.input.placeholder.private" : {
|
||||||
|
"comment" : "Composer placeholder inside a private chat, naming the conversation partner",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "mag-type ng mensahe..."
|
"value" : "message %@ — private"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"fr" : {
|
}
|
||||||
|
},
|
||||||
|
"content.private.caption" : {
|
||||||
|
"comment" : "Caption above the private chat composer before encryption is established",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "écris un message..."
|
"value" : "private conversation"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"he" : {
|
}
|
||||||
|
},
|
||||||
|
"content.private.caption_encrypted" : {
|
||||||
|
"comment" : "Caption above the private chat composer once the session is end-to-end encrypted",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "כתוב הודעה..."
|
"value" : "private · end-to-end encrypted"
|
||||||
}
|
|
||||||
},
|
|
||||||
"hi" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "संदेश लिखें..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "ketik pesan..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"it" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "scrivi un messaggio..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "メッセージを入力..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ko" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "메시지를 입력하세요..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ms" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "ketik pesan..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ne" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "सन्देश टाइप गर..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nl" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "typ een bericht..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pl" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "wpisz wiadomość..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "escreve uma mensagem..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt-BR" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "digite uma mensagem..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ru" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "напиши сообщение..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sv" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "skriv ett meddelande..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ta" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "ஒரு செய்தியைத் தட்டச்சு செய்க..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"th" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "พิมพ์ข้อความ..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tr" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "bir mesaj yazın..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"uk" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "напиши повідомлення..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ur" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "پیغام ٹائپ کریں..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"vi" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "nhập tin nhắn..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hans" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "输入消息..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hant" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "輸入訊息..."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ struct TextMessageView: View {
|
|||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
|
if message.isPrivate {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.bitchatSystem(size: 8))
|
||||||
|
.foregroundColor(Color.orange.opacity(0.75))
|
||||||
|
.padding(.top, 5)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
|
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import UIKit
|
|||||||
struct ContentComposerView: View {
|
struct ContentComposerView: View {
|
||||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||||
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||||
@Environment(\.appTheme) private var theme
|
@Environment(\.appTheme) private var theme
|
||||||
@ThemedPalette private var palette
|
@ThemedPalette private var palette
|
||||||
|
|
||||||
@@ -60,10 +61,8 @@ struct ContentComposerView: View {
|
|||||||
TextField(
|
TextField(
|
||||||
"",
|
"",
|
||||||
text: $messageText,
|
text: $messageText,
|
||||||
prompt: Text(
|
prompt: Text(placeholderText)
|
||||||
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
|
.foregroundColor(palette.secondary.opacity(0.6))
|
||||||
)
|
|
||||||
.foregroundColor(palette.secondary.opacity(0.6))
|
|
||||||
)
|
)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.bitchatFont(size: 15)
|
.bitchatFont(size: 15)
|
||||||
@@ -110,6 +109,33 @@ struct ContentComposerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension ContentComposerView {
|
private extension ContentComposerView {
|
||||||
|
/// States where a message will land: the DM partner's name for private
|
||||||
|
/// chats, the channel (and its public nature) otherwise — so a stressed
|
||||||
|
/// user never has to guess who can read what they're typing.
|
||||||
|
var placeholderText: String {
|
||||||
|
if let header = privateConversationModel.selectedHeaderState {
|
||||||
|
// A geohash-DM display name already carries its own "#geohash/@name"
|
||||||
|
// form, so it must not get another "@" prefix; a mesh nickname does.
|
||||||
|
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||||
|
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
|
||||||
|
return String(
|
||||||
|
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
|
||||||
|
locale: .current,
|
||||||
|
target
|
||||||
|
)
|
||||||
|
}
|
||||||
|
switch locationChannelsModel.selectedChannel {
|
||||||
|
case .mesh:
|
||||||
|
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
|
||||||
|
case .location(let channel):
|
||||||
|
return String(
|
||||||
|
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
|
||||||
|
locale: .current,
|
||||||
|
channel.geohash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var recordingIndicator: some View {
|
var recordingIndicator: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "waveform.circle.fill")
|
Image(systemName: "waveform.circle.fill")
|
||||||
|
|||||||
@@ -363,6 +363,12 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
|
// Orange tint before themedSurface so it layers in front of the
|
||||||
|
// (opaque, in matrix) themed background rather than behind it.
|
||||||
|
// Glass has no opaque backing — themedSurface is a no-op there,
|
||||||
|
// so the wash needs more opacity to read over the backdrop
|
||||||
|
// gradient's blue/purple glows.
|
||||||
|
.background(Color.orange.opacity(theme.usesGlassChrome ? 0.14 : 0.06))
|
||||||
.themedSurface()
|
.themedSurface()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +391,8 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
privacyCaption
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
ContentComposerView(
|
ContentComposerView(
|
||||||
messageText: $messageText,
|
messageText: $messageText,
|
||||||
@@ -421,6 +429,41 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persistent one-line reminder that this composer feeds a private
|
||||||
|
/// conversation — the DM sheet otherwise renders identically to the
|
||||||
|
/// public timeline. Claims end-to-end encryption only once the session
|
||||||
|
/// is actually secured.
|
||||||
|
private var privacyCaption: some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.bitchatSystem(size: 9))
|
||||||
|
Text(verbatim: privacyCaptionText)
|
||||||
|
.bitchatFont(size: 11, weight: .medium)
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.orange)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.orange.opacity(0.08))
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var privacyCaptionText: String {
|
||||||
|
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
||||||
|
// even though they carry no Noise session status. Mesh DMs earn the
|
||||||
|
// "encrypted" claim only once the Noise handshake has secured.
|
||||||
|
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||||
|
let noiseSecured: Bool = {
|
||||||
|
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
|
||||||
|
case .noiseSecured, .noiseVerified: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if isGeoDM || noiseSecured {
|
||||||
|
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
|
||||||
|
}
|
||||||
|
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ContentPrivateHeaderInfoButton: View {
|
private struct ContentPrivateHeaderInfoButton: View {
|
||||||
|
|||||||
@@ -36,71 +36,81 @@ struct MediaMessageView: View {
|
|||||||
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
|
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
|
||||||
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
|
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
if message.isPrivate {
|
||||||
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
|
Image(systemName: "lock.fill")
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.font(.bitchatSystem(size: 8))
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.foregroundColor(Color.orange.opacity(0.75))
|
||||||
// Delivery status indicator for private messages. Tappable:
|
.padding(.top, 5)
|
||||||
// .help() tooltips only exist on macOS, so iOS users get the
|
.padding(.trailing, 4)
|
||||||
// explanation as a caption under the row instead.
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(alignment: .center, spacing: 4) {
|
||||||
|
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
// Delivery status indicator for private messages. Tappable:
|
||||||
|
// .help() tooltips only exist on macOS, so iOS users get the
|
||||||
|
// explanation as a caption under the row instead.
|
||||||
|
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||||
|
let status = deliveryStatus {
|
||||||
|
Button {
|
||||||
|
showDeliveryDetail.toggle()
|
||||||
|
} label: {
|
||||||
|
DeliveryStatusView(status: status)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityHint(
|
||||||
|
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure reasons stay visible without a tap; other statuses
|
||||||
|
// reveal on demand.
|
||||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||||
let status = deliveryStatus {
|
let status = deliveryStatus {
|
||||||
Button {
|
if case .failed = status {
|
||||||
showDeliveryDetail.toggle()
|
Text(verbatim: status.bitchatDescription)
|
||||||
} label: {
|
.bitchatFont(size: 11)
|
||||||
DeliveryStatusView(status: status)
|
.foregroundColor(Color.red.opacity(0.9))
|
||||||
.padding(.leading, 4)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.contentShape(Rectangle())
|
} else if showDeliveryDetail {
|
||||||
|
Text(verbatim: status.bitchatDescription)
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityHint(
|
|
||||||
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Failure reasons stay visible without a tap; other statuses
|
Group {
|
||||||
// reveal on demand.
|
switch media {
|
||||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
case .voice(let url):
|
||||||
let status = deliveryStatus {
|
VoiceNoteView(
|
||||||
if case .failed = status {
|
url: url,
|
||||||
Text(verbatim: status.bitchatDescription)
|
isSending: state.isSending,
|
||||||
.bitchatFont(size: 11)
|
sendProgress: state.progress,
|
||||||
.foregroundColor(Color.red.opacity(0.9))
|
onCancel: cancelAction
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
)
|
||||||
} else if showDeliveryDetail {
|
case .image(let url):
|
||||||
Text(verbatim: status.bitchatDescription)
|
BlockRevealImageView(
|
||||||
.bitchatFont(size: 11)
|
url: url,
|
||||||
.foregroundColor(.secondary)
|
revealProgress: state.progress,
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
isSending: state.isSending,
|
||||||
}
|
onCancel: cancelAction,
|
||||||
}
|
initiallyBlurred: !isFromMe,
|
||||||
|
onOpen: {
|
||||||
Group {
|
if !state.isSending {
|
||||||
switch media {
|
imagePreviewURL = url
|
||||||
case .voice(let url):
|
}
|
||||||
VoiceNoteView(
|
},
|
||||||
url: url,
|
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
|
||||||
isSending: state.isSending,
|
)
|
||||||
sendProgress: state.progress,
|
.frame(maxWidth: 280)
|
||||||
onCancel: cancelAction
|
}
|
||||||
)
|
|
||||||
case .image(let url):
|
|
||||||
BlockRevealImageView(
|
|
||||||
url: url,
|
|
||||||
revealProgress: state.progress,
|
|
||||||
isSending: state.isSending,
|
|
||||||
onCancel: cancelAction,
|
|
||||||
initiallyBlurred: !isFromMe,
|
|
||||||
onOpen: {
|
|
||||||
if !state.isSending {
|
|
||||||
imagePreviewURL = url
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
|
|
||||||
)
|
|
||||||
.frame(maxWidth: 280)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user