Improve message-list interactions: empty-state guidance, jump-to-latest, per-message actions (#1359)

* Improve message-list interactions: empty-state guidance, jump-to-latest, per-message actions

Three usability gaps in the message list, all presentation-layer:

- Empty timeline was a blank screen. It now narrates itself in dim,
  terminal-styled lines: what the channel is, that it's waiting for
  peers, and where the channel switcher and help live. Disappears with
  the first message.

- Scrolled up in a busy channel, nothing signalled that new messages
  arrived and there was no way back. A small "jump to latest" pill now
  appears while scrolled up, counting messages that arrived below, and
  taps back to the newest via the existing scroll helper. The unseen
  count re-baselines on channel switch so a cross-channel count delta is
  never shown as "new".

- A single tap anywhere on a message overwrote the composer draft with
  "@sender " and force-focused the field — casual taps while reading
  destroyed drafts. That whole-row tap is removed; mention/DM/hug/slap/
  block now live in the per-message context menu (reusing the handlers
  the existing action sheet already calls), and mention appends to the
  draft rather than replacing it. A failed own private message gets a
  resend item. The triple-tap-to-clear gesture gains a confirmation.

New strings are added source-language (en) only.

* Remove the failed original when resending a private message

Resend re-submitted the content but left the red failed bubble in
place, so every tap stacked another copy under it. Route resend
through ConversationUIModel, which drops the failed original from the
conversation store (removePrivateMessage) before sending the new copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Count only rendered human messages in the jump-to-latest pill

The unseen count was a raw delta of the messages array, so system
lines (join/leave narration) and whitespace-only messages that never
render as rows inflated the "N new" pill. Baseline the counters
against the number of messages that render as human message rows,
using the same predicates the row builder applies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Hide mention/DM context-menu actions inside 1:1 conversations

In a private conversation, mentioning the only other participant is
noise and the DM action just reopens the already-open conversation
(toggling the sidebar). Gate both behind privatePeer == nil so the
public-timeline context menu is unchanged; hug/slap/block/copy/resend
remain in DMs.

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:
Hot Pixel Group
2026-07-06 10:09:04 +02:00
committed by GitHub
co-authored by jack jack Claude Fable 5
parent 0d251ad20c
commit 7a0c821807
3 changed files with 353 additions and 22 deletions
+8
View File
@@ -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")
}
+108
View File
@@ -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" : {
+237 -22
View File
@@ -36,8 +36,17 @@ struct MessageListView: View {
var isTextFieldFocused: FocusState<Bool>.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)
}
}