Update Send button bg color (#5170)

* Update Send button bg color

Change the gradient bg to accent/rest.

* Tidy-up ComposerToolbar to match iOS 18 Figma.

Also simplifies the tests a bit.

* Add a .glassEffect to Compound's SendButton.

* Add a border to TimelineReplyView.

Also use the same sizes in both the message bubbles and the composer.

* Change icon size and container in message bubbles

- Container size = 36x36px
- Icon size = 24x24px

* Update icon of reply contents to be 24x24

* Update the VoiceMessageButton to match the designs.

* Adopt Liquid Glass in the ComposerToolbar.

* Generate and fix snapshots.

---------

Co-authored-by: Doug <douglase@element.io>
This commit is contained in:
Aaron Thornburgh
2026-03-25 18:42:10 +01:00
committed by GitHub
parent a2481b59ba
commit 96b6d69d5a
181 changed files with 790 additions and 719 deletions
@@ -17,8 +17,25 @@ enum TimelineReplyViewPlacement {
struct TimelineReplyView: View {
let placement: TimelineReplyViewPlacement
let timelineItemReplyDetails: TimelineItemReplyDetails?
var maxWidth: CGFloat?
private let backgroundShape = RoundedRectangle(cornerRadius: 6)
var body: some View {
content
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: maxWidth, alignment: .leading)
.padding(4.0)
.background {
ZStack {
backgroundShape.fill(.compound.bgCanvasDefault)
backgroundShape.stroke(.compound.borderInteractiveSecondary)
}
}
}
@ViewBuilder
private var content: some View {
if let timelineItemReplyDetails {
switch timelineItemReplyDetails {
case .loaded(let sender, _, let content):
@@ -29,7 +46,7 @@ struct TimelineReplyView: View {
ReplyView(sender: sender,
plainBody: content.caption ?? content.filename,
formattedBody: content.formattedCaption,
icon: .init(kind: .systemIcon("waveform"), cornerRadii: iconCornerRadii))
icon: .init(kind: .systemIcon("waveform")))
case .emote(let content):
ReplyView(sender: sender,
plainBody: content.body,
@@ -38,12 +55,12 @@ struct TimelineReplyView: View {
ReplyView(sender: sender,
plainBody: content.caption ?? content.filename,
formattedBody: content.formattedCaption,
icon: .init(kind: .icon(\.document), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.document)))
case .image(let content):
ReplyView(sender: sender,
plainBody: content.caption ?? content.filename,
formattedBody: content.formattedCaption,
icon: .init(kind: .mediaSource(content.thumbnailInfo?.source ?? content.imageInfo.source), cornerRadii: iconCornerRadii))
icon: .init(kind: .mediaSource(content.thumbnailInfo?.source ?? content.imageInfo.source)))
case .notice(let content):
ReplyView(sender: sender,
plainBody: content.body,
@@ -56,33 +73,33 @@ struct TimelineReplyView: View {
ReplyView(sender: sender,
plainBody: content.caption ?? content.filename,
formattedBody: content.formattedCaption,
icon: content.thumbnailInfo.map { .init(kind: .mediaSource($0.source), cornerRadii: iconCornerRadii) })
icon: content.thumbnailInfo.map { .init(kind: .mediaSource($0.source)) })
case .voice:
ReplyView(sender: sender,
plainBody: L10n.commonVoiceMessage,
formattedBody: nil,
icon: .init(kind: .icon(\.micOn), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.micOn)))
case .location:
ReplyView(sender: sender,
plainBody: L10n.commonSharedLocation,
formattedBody: nil,
icon: .init(kind: .icon(\.locationPin), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.locationPin)))
}
case .poll(let question):
ReplyView(sender: sender,
plainBody: question,
formattedBody: nil,
icon: .init(kind: .icon(\.polls), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.polls)))
case .liveLocation:
ReplyView(sender: sender,
plainBody: L10n.commonSharedLiveLocation,
formattedBody: nil,
icon: .init(kind: .icon(\.locationPin), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.locationPin)))
case .redacted:
ReplyView(sender: sender,
plainBody: L10n.commonMessageRemoved,
formattedBody: nil,
icon: .init(kind: .icon(\.delete), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.delete)))
}
default:
LoadingReplyView()
@@ -90,15 +107,6 @@ struct TimelineReplyView: View {
}
}
private var iconCornerRadii: Double {
switch placement {
case .composer:
return 9.0
case .timeline:
return 4.0
}
}
private struct LoadingReplyView: View {
var body: some View {
ReplyView(sender: .init(id: "@alice:matrix.org"), plainBody: "Hello world", formattedBody: nil)
@@ -117,7 +125,7 @@ struct TimelineReplyView: View {
}
let kind: Kind
let cornerRadii: Double
let cornerRadii = 4.0
}
@EnvironmentObject private var context: TimelineViewModel.Context
@@ -180,7 +188,7 @@ struct TimelineReplyView: View {
.aspectRatio(contentMode: .fit)
.padding(8.0)
case .icon(let keyPath):
CompoundIcon(keyPath, size: .small, relativeTo: .body)
CompoundIcon(keyPath, size: .medium, relativeTo: .body)
}
}
}
@@ -197,12 +197,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
// The rendered reply bubble with a greedy width. The custom layout prevents
// the infinite width from increasing the overall width of the view.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true)
.padding(4.0)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.compound.bgCanvasDefault)
.cornerRadius(8)
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails, maxWidth: .infinity)
.timelineBubbleLayoutSize(.bubbleWidth(mode: .rendering))
.onTapGesture {
if context.viewState.timelineKind != .pinned {
@@ -212,8 +207,6 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
// Add a fixed width reply bubble that is used for layout calculations but won't be rendered.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true)
.padding(4.0)
.timelineBubbleLayoutSize(.bubbleWidth(mode: .layout))
.hidden()
}
@@ -90,9 +90,9 @@ struct MediaFileRoomTimelineContent: View {
.foregroundStyle(.compound.textPrimary)
.lineLimit(2)
} icon: {
CompoundIcon(icon, size: .xSmall, relativeTo: .body)
CompoundIcon(icon, size: .medium, relativeTo: .body)
.foregroundColor(.compound.iconPrimary)
.scaledPadding(8)
.scaledPadding(6)
.background(.compound.iconOnSolidPrimary,
in: RoundedRectangle(cornerRadius: 4, style: .continuous))
}
@@ -88,6 +88,7 @@ struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
static let roomProxyMock = JoinedRoomProxyMock(.init(name: "Preview room"))
static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
static let focussedEventID = "RoomTimelineItemFixtures.default.5"
static let composerViewModel = ComposerToolbarViewModel.mock()
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
focussedEventID: focussedEventID,
timelineController: MockTimelineController(),
@@ -105,7 +106,7 @@ struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
ElementNavigationStack {
RoomScreen(context: roomViewModel.context,
timelineContext: timelineViewModel.context,
composerToolbar: ComposerToolbar.mock())
composerToolbar: ComposerToolbar(context: composerViewModel.context))
}
.previewDisplayName("Timeline")
}
@@ -143,6 +143,7 @@ struct TimelineView_Previews: PreviewProvider { // Not testable as this preview
static let roomProxyMock = JoinedRoomProxyMock(.init(id: "stable_id",
name: "Preview room"))
static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
static let composerViewModel = ComposerToolbarViewModel.mock()
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockTimelineController(),
userSession: UserSessionMock(.init()),
@@ -159,7 +160,7 @@ struct TimelineView_Previews: PreviewProvider { // Not testable as this preview
ElementNavigationStack {
RoomScreen(context: roomViewModel.context,
timelineContext: timelineViewModel.context,
composerToolbar: ComposerToolbar.mock())
composerToolbar: ComposerToolbar(context: composerViewModel.context))
}
}
}