android, desktop, ios: remove left padding on consecutive received messages in channels (#7108)

* android, desktop, ios: remove left padding on consecutive received messages in channels

In channels, a received message that does not show an avatar (a consecutive post from the same sender) drops the avatar-sized left padding and sits flush-left. Applies to both owner broadcasts (ChannelRcv) and contributor posts (GroupRcv); the first message of each run still shows the avatar. Gated on ChatInfo.isChannel, so regular groups, business and direct chats, sent messages, and avatar-shown messages are unchanged.

* docs: add plan justifying removing left padding on consecutive received messages in channels

* ios: fix right gap on consecutive received messages in channels

Removing the avatar-sized left padding from no-avatar received messages
(this PR) shifted those bubbles ~44pt left, but maxWidth still reserved
the avatar inset, so consecutive messages stopped ~44pt short of the
first (avatar) message on the right.

Widen maxWidth for no-avatar channel-received items so their right edge
matches the avatar-shown first message. The no-avatar predicate reuses
the exact shouldShowAvatar expression from the render path (lifted to a
file-scope function so the maxWidth site can call it), so the width and
the rendered layout can never disagree.

Android is unaffected: Compose derives content width from padding, so
reducing the start padding already widened the row there.

* ios: increase left padding

* kotlin: increase left gap

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Narasimha-sc
2026-06-26 22:12:39 +01:00
committed by GitHub
co-authored by Evgeny Poberezkin Evgeny Poberezkin
parent 3a38c39d35
commit e979b7efdc
3 changed files with 137 additions and 28 deletions
+32 -26
View File
@@ -14,6 +14,29 @@ import Combine
private let memberImageSize: CGFloat = 34
private func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool {
let oldIsGroupRcv = switch older?.chatDir {
case .groupRcv: true
case .channelRcv: true
default: false
}
let sameMember = switch (older?.chatDir, current.chatDir) {
case (.groupRcv(let oldMember), .groupRcv(let member)):
oldMember.memberId == member.memberId
case (.channelRcv, .channelRcv):
true
default:
false
}
if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
return true
} else if case .channelRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
return true
} else {
return false
}
}
// Spec: spec/client/chat-view.md#ChatView
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@@ -896,8 +919,14 @@ struct ChatView: View {
} else {
let voiceNoFrame = voiceWithoutFrame(ci)
let channelReceived = !ci.chatDir.sent && cInfo.isChannel
// consecutive (no-avatar) received messages in channels drop the avatar-sized
// left padding (see .leading padding below), so they get the full row width here
// too otherwise the reserved avatar inset would leave a gap on the right
let channelReceivedNoAvatar = channelReceived && !shouldShowAvatar(mergedItem.newest().item, mergedItem.oldest().nextItem)
let maxWidth = cInfo.chatType == .group
? voiceNoFrame || channelReceived
? channelReceivedNoAvatar
? g.size.width - 26
: voiceNoFrame || channelReceived
? (g.size.width - 28) - 42
: (g.size.width - 28) * 0.84 - 42
: voiceNoFrame
@@ -1733,29 +1762,6 @@ struct ChatView: View {
)
}
func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool {
let oldIsGroupRcv = switch older?.chatDir {
case .groupRcv: true
case .channelRcv: true
default: false
}
let sameMember = switch (older?.chatDir, current.chatDir) {
case (.groupRcv(let oldMember), .groupRcv(let member)):
oldMember.memberId == member.memberId
case (.channelRcv, .channelRcv):
true
default:
false
}
if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
return true
} else if case .channelRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
return true
} else {
return false
}
}
var body: some View {
let last = isLastItem ? im.reversedChatItems.last : nil
let listItem = merged.newest()
@@ -1979,7 +1985,7 @@ struct ChatView: View {
}
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.padding(.trailing)
.padding(.leading, 10 + memberImageSize + 12)
.padding(.leading, chat.chatInfo.isChannel ? nil : 10 + memberImageSize + 12)
}
.padding(.bottom, bottomPadding)
}
@@ -2076,7 +2082,7 @@ struct ChatView: View {
}
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.padding(.trailing)
.padding(.leading, 10 + memberImageSize + 12)
.padding(.leading, chat.chatInfo.isChannel ? nil : 10 + memberImageSize + 12)
}
.padding(.bottom, bottomPadding)
}
@@ -2084,7 +2084,7 @@ fun BoxScope.ChatItemsList(
}
Row(
Modifier
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.padding(start = if (chatInfo.isChannel) 12.dp else 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
.then(swipeableOrSelectionModifier)
) {
@@ -2167,7 +2167,7 @@ fun BoxScope.ChatItemsList(
}
Row(
Modifier
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.padding(start = if (chatInfo.isChannel) 12.dp else 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
.then(swipeableOrSelectionModifier)
) {
@@ -0,0 +1,103 @@
# Remove left padding on consecutive (no-avatar) received messages in channels
## Problem
In a channel, received messages show the sender avatar on the first message of a
run and hide it on consecutive messages, but those consecutive messages still
reserve the avatar-sized **left padding** so they line up under the first. For a
channel's feed-style layout this indentation wastes horizontal space —
consecutive received messages should sit flush-left where the avatar would be.
This applies to **both** the channel owner's broadcasts and contributors' posts.
Desired behaviour: in channels, any received message that does **not** show an
avatar (a consecutive post from the same sender) drops the avatar-sized left
padding. The first message of a run still shows the avatar and keeps its layout;
when the run is broken (a different sender, or a time gap), the next message
shows the avatar again — this run logic is unchanged, only the no-avatar left
padding is reduced.
## The two received directions in a channel
Received items in a channel arrive as one of two directions
(`Subscriber.hs`, `saveRcvCI`):
- **`ChannelRcv`** (no member) — the **owner's** broadcast, sent "as the channel".
The backend permits sending-as-group only to the owner, and a channel owner's
main-scope messages are always sent as group (`ChatInfo.sendAsGroup` is true
for `useRelays && memberRole >= Owner` in the main scope), so received owner
posts arrive as `ChannelRcv`. Shows the channel avatar.
- **`GroupRcv(member)`** (attributed) — a **contributor's** post, carrying the
member. Shows the member avatar.
Both are received messages, and the change now applies to **both** when they hide
the avatar. (An earlier revision scoped this to `ChannelRcv`/owner only; it now
covers contributors too, per request.)
## Change
In channels — gated on `ChatInfo.isChannel` (the `useRelays` flag, which is
reliably present, unlike the optional group-type predicate) — the no-avatar
branches for **both** `ChannelRcv` and `GroupRcv` drop the avatar-sized left
padding down to the base inset where the avatar itself starts. In non-channel
groups the `GroupRcv` no-avatar layout is unchanged (`isChannel` is false). The
avatar-shown layouts, sent messages, and all other chats are unchanged.
The same Row's `end` padding is already gated on `chatInfo.isChannel` (the merged
right-gap change #7106), so gating `start` on `isChannel` keeps each Row
internally consistent and the change precisely "in channels".
### Android / desktop (`apps/multiplatform`, `ChatView.kt`, `ChatItemsList`)
Both the `CIDirection.GroupRcv` and `CIDirection.ChannelRcv` `showAvatar == false`
rows:
```kotlin
// before
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = …)
// after
.padding(start = if (chatInfo.isChannel) 8.dp else 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = …)
```
### iOS (`apps/ios`, `ChatView.swift`, `chatItemListView`)
Both the `.groupRcv` and `.channelRcv` no-avatar branches:
```swift
// before
.padding(.leading, 10 + memberImageSize + 12)
// after
.padding(.leading, chat.chatInfo.isChannel ? 12 : 10 + memberImageSize + 12)
```
## Run behaviour (unchanged)
`shouldShowAvatar(current, older)` shows the avatar on the first message of a
same-sender run and hides it on consecutive ones; a different sender or a gap
resets the run. For `GroupRcv` "same sender" is the same `memberId`; for
`ChannelRcv` consecutive channel broadcasts count as the same sender. Only the
no-avatar left padding is changed.
## Scope
- Affects: all received consecutive (no-avatar) messages **in channels** — owner
broadcasts (`ChannelRcv`) and contributor posts (`GroupRcv`). This includes a
channel's member-support sub-scope, which renders through the same
`ChatItemsList` with the channel's `isChannel`; treating it the same way is
consistent with the merged right-gap change (#7106), which also gates that
Row's `end` padding on `isChannel` without a scope filter.
- Unchanged: the first message of each run (avatar shown), sent messages, regular
groups, business chats and direct chats (`isChannel` false — the `else` branch
preserves the original avatar-inset value exactly), and any non-channel
`ChannelRcv` welcome item.
## Verification
- Android/desktop: `:common:compileKotlinDesktop` compiles clean.
- iOS: small, type-safe constant change; build/verify on macOS (Xcode) — not
compilable on the Linux build host used here.
- Visual (both platforms), in a channel:
- First message of a run (owner or contributor): avatar shown, layout unchanged.
- Following messages from the same sender (no avatar): now flush-left.
- A different sender / time gap resets the run — the next message shows the
avatar again.
- Regular groups, business and direct chats keep their existing indentation.