mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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:
co-authored by
jack
jack
Claude Fable 5
parent
0d251ad20c
commit
7a0c821807
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user