diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index f012edd3..cb34ca62 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -49,6 +49,14 @@ final class ConversationUIModel: ObservableObject { chatViewModel.sendMessage(message) } + /// Resends a failed private message through the normal send path, + /// removing the failed original so the re-submission replaces it + /// instead of stacking a duplicate under the red bubble. + func resendFailedPrivateMessage(_ message: BitchatMessage) { + chatViewModel.removePrivateMessage(withID: message.id) + chatViewModel.sendMessage(message.content) + } + func clearCurrentConversation() { chatViewModel.sendMessage("/clear") } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 2f430bb9..8895e6fa 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -9144,6 +9144,18 @@ } } }, + "content.accessibility.jump_to_latest" : { + "comment" : "Accessibility label for the jump to latest messages button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "jump to latest messages" + } + } + } + }, "content.accessibility.location_channels" : { "extractionState" : "manual", "localizations" : { @@ -12571,6 +12583,18 @@ } } }, + "content.actions.resend" : { + "comment" : "Context menu action that resends a failed private message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "resend" + } + } + } + }, "content.actions.slap" : { "extractionState" : "manual", "localizations" : { @@ -14182,6 +14206,30 @@ } } }, + "content.clear.confirm_action" : { + "comment" : "Destructive confirmation button that clears the current chat", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "clear chat" + } + } + } + }, + "content.clear.confirm_title" : { + "comment" : "Title of the confirmation dialog shown before clearing the current chat", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "clear this chat?" + } + } + } + }, "content.commands.block" : { "extractionState" : "manual", "localizations" : { @@ -17488,6 +17536,54 @@ } } }, + "content.empty.location_intro" : { + "comment" : "First line of an empty geohash timeline naming the channel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you're in #%@ โ€” a public location channel over the internet" + } + } + } + }, + "content.empty.mesh_intro" : { + "comment" : "First line of the empty mesh timeline explaining what the mesh channel is", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you're on #mesh โ€” reaches people within bluetooth range" + } + } + } + }, + "content.empty.mesh_waiting" : { + "comment" : "Second line of the empty mesh timeline saying no peers are in range yet", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nobody in range yet... messages appear here" + } + } + } + }, + "content.empty.switch_hint" : { + "comment" : "Empty timeline hint pointing at the channel switcher and the help screen", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap the channel name above to switch ยท tap bitchat/ for help" + } + } + } + }, "content.header.people" : { "extractionState" : "manual", "localizations" : { @@ -18085,6 +18181,18 @@ } } }, + "content.jump.new_count" : { + "comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld new" + } + } + } + }, "content.location.enable" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index da7fa116..696fcb8f 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -36,8 +36,17 @@ struct MessageListView: View { var isTextFieldFocused: FocusState.Binding @State private var showMessageActions = false + @State private var showClearConfirmation = false @State private var lastScrollTime: Date = .distantPast @State private var scrollThrottleTimer: Timer? + @State private var unseenCount = 0 + @State private var lastSeenMessageCount = 0 + /// Context key the unseen counters were baselined against. Channel + /// switches swap the timeline wholesale, so a count delta is only a + /// "new messages" signal while the context is unchanged. + @State private var unseenBaselineKey = "" + + @ThemedPalette private var palette var body: some View { let currentWindowCount: Int = { @@ -65,6 +74,9 @@ struct MessageListView: View { ScrollViewReader { proxy in ScrollView { + if messageItems.isEmpty && privatePeer == nil { + publicEmptyState + } LazyVStack(alignment: .leading, spacing: 0) { ForEach(messageItems) { item in let message = item.message @@ -72,6 +84,7 @@ struct MessageListView: View { .onAppear { if message.id == windowedMessages.last?.id { isAtBottom = true + unseenCount = 0 } if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count { @@ -89,13 +102,32 @@ struct MessageListView: View { } } .contentShape(Rectangle()) - .onTapGesture { - if message.sender != "system" { - messageText = "@\(message.sender) " - isTextFieldFocused.wrappedValue = true - } - } .contextMenu { + let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message) + if showsUserActions { + // Mention and DM are redundant inside a 1:1 conversation: + // mentioning the only other participant is noise, and "DM" + // would just reopen the conversation that is already open. + if privatePeer == nil { + Button("content.actions.mention") { + insertMention(message.sender) + } + if let peerID = message.senderPeerID { + Button("content.actions.direct_message") { + privateConversationModel.openConversation(for: peerID) + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + showSidebar = true + } + } + } + } + Button("content.actions.hug") { + conversationUIModel.sendHug(to: message.sender) + } + Button("content.actions.slap") { + conversationUIModel.sendSlap(to: message.sender) + } + } Button("content.message.copy") { #if os(iOS) UIPasteboard.general.string = message.content @@ -105,6 +137,16 @@ struct MessageListView: View { pb.setString(message.content, forType: .string) #endif } + if isResendableFailedMessage(message) { + Button("content.actions.resend") { + conversationUIModel.resendFailedPrivateMessage(message) + } + } + if showsUserActions { + Button("content.actions.block", role: .destructive) { + conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender) + } + } } .padding(.horizontal, 12) .padding(.vertical, 1) @@ -113,9 +155,24 @@ struct MessageListView: View { .transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } } .padding(.vertical, 2) } + .overlay(alignment: .bottomTrailing) { + if !isAtBottom && !messageItems.isEmpty { + jumpToLatestPill(proxy: proxy) + } + } .onOpenURL(perform: handleOpenURL) .onTapGesture(count: 3) { - conversationUIModel.clearCurrentConversation() + showClearConfirmation = true + } + .confirmationDialog( + "content.clear.confirm_title", + isPresented: $showClearConfirmation, + titleVisibility: .visible + ) { + Button("content.clear.confirm_action", role: .destructive) { + conversationUIModel.clearCurrentConversation() + } + Button("common.cancel", role: .cancel) {} } .onAppear { scrollToBottom(on: proxy) @@ -139,9 +196,7 @@ struct MessageListView: View { ) { Button("content.actions.mention") { if let sender = selectedMessageSender { - // Pre-fill the input with an @mention and focus the field - messageText = "@\(sender) " - isTextFieldFocused.wrappedValue = true + insertMention(sender) } } @@ -208,6 +263,140 @@ struct MessageListView: View { } private extension MessageListView { + var currentContextKey: String { + if let peer = privatePeer { + return "dm:\(peer)" + } + return locationChannelsModel.selectedChannel.contextKey + } + + /// Terminal-styled narration for an empty public timeline: says which + /// channel this is, that the app is waiting for peers, and where to go + /// next. Rendered inside the ScrollView; disappears with the first row. + var publicEmptyState: some View { + VStack(alignment: .leading, spacing: 6) { + switch locationChannelsModel.selectedChannel { + case .mesh: + emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is")) + emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet")) + emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")) + case .location(let channel): + emptyStateLine( + String( + format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"), + locale: .current, + channel.geohash + ) + ) + emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")) + } + } + .padding(.horizontal, 12) + .padding(.top, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + + func emptyStateLine(_ text: String) -> some View { + Text(verbatim: "* \(text) *") + .bitchatFont(size: 13) + .foregroundColor(palette.secondary.opacity(0.9)) + .fixedSize(horizontal: false, vertical: true) + } + + /// Messages the unseen counters may book as "new": rows that render as + /// human messages. System lines render as narration and whitespace-only + /// content never renders at all, so neither belongs in the pill count. + func unseenEligibleCount(in messages: [BitchatMessage]) -> Int { + messages.filter { $0.sender != "system" && !$0.content.trimmed.isEmpty }.count + } + + /// Updates the unseen-count baseline for the current context and returns + /// how many messages were appended since the last observation. A context + /// change (timeline swapped wholesale) re-baselines and reports zero, so + /// cross-channel count differences are never booked as "new" messages. + func rebaselinedAppendedCount(newCount: Int) -> Int { + let key = currentContextKey + if unseenBaselineKey != key { + unseenBaselineKey = key + unseenCount = 0 + lastSeenMessageCount = newCount + return 0 + } + let appended = max(0, newCount - lastSeenMessageCount) + lastSeenMessageCount = newCount + return appended + } + + /// A failed private text message of our own can be resent through the + /// normal send path (the context menu removes the failed original and + /// re-submits its content). + func isResendableFailedMessage(_ message: BitchatMessage) -> Bool { + guard message.isPrivate, + conversationUIModel.isSentByCurrentUser(message), + conversationUIModel.mediaAttachment(for: message) == nil, + case .some(.failed) = message.deliveryStatus + else { return false } + return true + } + + /// Appends an @mention to the composer draft (never overwrites what the + /// user has already typed) and focuses the input field. + func insertMention(_ sender: String) { + let mention = "@\(sender) " + if messageText.isEmpty { + messageText = mention + } else if messageText.hasSuffix(" ") { + messageText += mention + } else { + messageText += " " + mention + } + isTextFieldFocused.wrappedValue = true + } + + /// Floating pill shown while scrolled up: re-presents the isAtBottom / + /// unseenCount state the view already tracks, and jumps to the newest + /// message via the existing scrollToBottom helper. + func jumpToLatestPill(proxy: ScrollViewProxy) -> some View { + Button { + scrollToBottom(on: proxy) + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.down") + .font(.bitchatSystem(size: 11, weight: .semibold)) + if unseenCount > 0 { + Text( + String( + format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"), + locale: .current, + unseenCount + ) + ) + .bitchatFont(size: 12, weight: .medium) + } + } + .foregroundColor(palette.primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .themedOverlayPanel() + .padding(.trailing, 12) + .padding(.bottom, 10) + .accessibilityLabel(jumpToLatestAccessibilityLabel) + } + + var jumpToLatestAccessibilityLabel: String { + let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button") + guard unseenCount > 0 else { return base } + let count = String( + format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"), + locale: .current, + unseenCount + ) + return "\(base), \(count)" + } + @ViewBuilder func messageRow(for message: BitchatMessage) -> some View { Group { @@ -294,6 +483,9 @@ private extension MessageListView { func scrollToBottom(on proxy: ScrollViewProxy) { isAtBottom = true + unseenCount = 0 + lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer)) + unseenBaselineKey = currentContextKey if let targetPeerID { proxy.scrollTo(targetPeerID, anchor: .bottom) } @@ -316,15 +508,23 @@ private extension MessageListView { } func onMessagesChange(proxy: ScrollViewProxy) { + guard privatePeer == nil else { return } let messages = publicChatModel.messages - guard privatePeer == nil, let lastMsg = messages.last else { return } + let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages)) + guard let lastMsg = messages.last else { + // Timeline emptied (e.g. /clear): nothing below to jump to. + unseenCount = 0 + return + } // If the newest message is from me, always scroll to bottom let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom + unseenCount += appendedCount return } else { // Ensure we consider ourselves at bottom for subsequent messages isAtBottom = true + unseenCount = 0 } func scrollIfNeeded(date: Date) { @@ -352,18 +552,23 @@ private extension MessageListView { } func onPrivateChatsChange(proxy: ScrollViewProxy) { - guard let peerID = privatePeer, - let lastMsg = privateInboxModel.messages(for: peerID).last else { + guard let peerID = privatePeer else { return } + let messages = privateInboxModel.messages(for: peerID) + let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages)) + guard let lastMsg = messages.last else { + // Timeline emptied (e.g. /clear): nothing below to jump to. + unseenCount = 0 return } - let messages = privateInboxModel.messages(for: peerID) // If the newest private message is from me, always scroll let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom + unseenCount += appendedCount return } else { isAtBottom = true + unseenCount = 0 } func scrollIfNeeded(date: Date) { @@ -391,17 +596,27 @@ private extension MessageListView { func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) { // When switching to a new geohash channel, scroll to the bottom guard privatePeer == nil else { return } + // Invalidate the unseen baseline: the timeline is about to swap (or + // already has โ€” the ordering of this onChange vs the count onChange + // is not guaranteed), so the next count observation re-baselines + // instead of booking the cross-channel difference as "new". + unseenCount = 0 + unseenBaselineKey = "" + // Entering any public channel shows its latest messages: a channel + // switch swaps the timeline wholesale, so the prior scroll offset is + // meaningless. Landing at the bottom keeps isAtBottom honest (no + // stale jump-to-latest pill) and matches standard chat behavior. + isAtBottom = true + windowCountPublic = TransportConfig.uiWindowInitialCountPublic + let contextKey: String switch channel { case .mesh: - break + contextKey = "mesh" case .location(let ch): - // Reset window size - isAtBottom = true - windowCountPublic = TransportConfig.uiWindowInitialCountPublic - let contextKey = "geo:\(ch.geohash)" - if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) { - proxy.scrollTo(target, anchor: .bottom) - } + contextKey = "geo:\(ch.geohash)" + } + if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) { + proxy.scrollTo(target, anchor: .bottom) } }