Room screen: timeline message bubbles (#91)

* #34 Create `TimelineItemStylerView`

* #34 Add styler view into different type of cells

* #34 Use placeholder avatar image on room list

* #34 Add `isOutgoing` param to event based timeline item

* #34 Compute `isOutgoing` for timeline items

* #34 Update sender info view in timeline

* #34 Update mock timeline items

* #34 Rename `EventBasedTimelineView` to `EventBasedTimelineSenderView`

* #34 Change padding on timeline cells

* #34 Create `TimelineItemStylerView` to move content into a bubble if needed

* #34 Use styler view in all of the timeline item views

* #34 Make timestamp more readable on images

* #34 Little layout tweaks

* #34 Add changelog

* #34 Fix code smells

* #34 Set text colors on timeline items

* #34 Fix background color of the timeline

* #34 Fix PR remarks

* #34 Set background colors explicitly on remaining screens

* #34 Reduce min bubble width and make it a scaled metric

* #34 Refactor `PlaceholderAvatarImage` to accept a text only

* #34 Fix code smell

* #34 Fix further comments
This commit is contained in:
ismailgulek 2022-06-23 14:54:29 +03:00 committed by GitHub
parent 5df1411a7e
commit b6b8b4be26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 451 additions and 162 deletions

View File

@ -72,7 +72,7 @@
36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */; };
3772354754450F2B54107E17 /* TemplateSimpleScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4EDB32B97910AAAFE632B2 /* TemplateSimpleScreenViewModelProtocol.swift */; };
38546A6010A2CF240EC9AF73 /* BindableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */; };
39AE84C8E5F2FE9D2DC7775C /* EventBasedTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56008790A9C4479A6B31FDF4 /* EventBasedTimelineView.swift */; };
39AE84C8E5F2FE9D2DC7775C /* EventBasedTimelineSenderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56008790A9C4479A6B31FDF4 /* EventBasedTimelineSenderView.swift */; };
3B770CB4DED51CC362C66D47 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4990FDBDA96B88E214F92F48 /* SettingsModels.swift */; };
3C549A0BF39F8A854D45D9FD /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 997C7385E1A07E061D7E2100 /* GZIP */; };
3D325A1147F6281C57BFCDF6 /* EventBrief.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4411C0DA0087A1CB143E96FA /* EventBrief.swift */; };
@ -222,6 +222,7 @@
EA31DD9043B91ECB8E45A9A6 /* ScreenshotDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03C9D319676F3C0DC6B0203 /* ScreenshotDetectorTests.swift */; };
EA65360A0EC026DD83AC0CF5 /* AuthenticationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA5F386C7701C129398945 /* AuthenticationCoordinator.swift */; };
EBD6C79705B3DDB2F7E5F554 /* UserSessionStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1B52D0ABBA7091A991CAFE /* UserSessionStoreProtocol.swift */; };
EC8128A028620A970012F05B /* TimelineItemStylerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC81289F28620A970012F05B /* TimelineItemStylerView.swift */; };
ED4F663C783E9A8C0E80B983 /* TemplateSimpleScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47543EB19F3DCF308751F53C /* TemplateSimpleScreenViewModel.swift */; };
EE8491AD81F47DF3C192497B /* DecorationTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */; };
EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */; };
@ -376,7 +377,7 @@
534A5C8FCDE2CBC50266B9F2 /* gl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = gl; path = gl.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
55BC11560C8A2598964FFA4C /* bs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bs; path = bs.lproj/Localizable.strings; sourceTree = "<group>"; };
55D7187F6B0C0A651AC3DFFA /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = in; path = in.lproj/Localizable.strings; sourceTree = "<group>"; };
56008790A9C4479A6B31FDF4 /* EventBasedTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBasedTimelineView.swift; sourceTree = "<group>"; };
56008790A9C4479A6B31FDF4 /* EventBasedTimelineSenderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBasedTimelineSenderView.swift; sourceTree = "<group>"; };
56F01DD1BBD4450E18115916 /* LabelledActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelledActivityIndicatorView.swift; sourceTree = "<group>"; };
5773C86AF04AEF26515AD00C /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = "<group>"; };
5872785B9C7934940146BFBA /* MXLogger.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MXLogger.h; sourceTree = "<group>"; };
@ -576,6 +577,7 @@
E8FD25EB4DF66625B74E4505 /* LoginScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModel.swift; sourceTree = "<group>"; };
E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
EC81289F28620A970012F05B /* TimelineItemStylerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemStylerView.swift; sourceTree = "<group>"; };
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = "<group>"; };
EDB3E99D445CFCB3AA3F34FB /* FramePreferenceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramePreferenceKey.swift; sourceTree = "<group>"; };
EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
@ -1031,6 +1033,7 @@
B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */,
804F9B0FABE093C7284CD09B /* TimelineItemList.swift */,
874A1842477895F199567BD7 /* TimelineView.swift */,
EC81289F28620A970012F05B /* TimelineItemStylerView.swift */,
B7D3886505ECC85A06DA8258 /* Timeline */,
);
path = View;
@ -1250,7 +1253,7 @@
isa = PBXGroup;
children = (
471EB7D96AFEA8D787659686 /* EmoteRoomTimelineView.swift */,
56008790A9C4479A6B31FDF4 /* EventBasedTimelineView.swift */,
56008790A9C4479A6B31FDF4 /* EventBasedTimelineSenderView.swift */,
F73FF1A33198F5FAE9D34B1F /* FormattedBodyText.swift */,
D0A45283CF1DB96E583BECA6 /* ImageRoomTimelineView.swift */,
B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */,
@ -1733,7 +1736,7 @@
6647430A45B4A8E692909A8F /* EmoteRoomTimelineItem.swift in Sources */,
68AC3C84E2B438036B174E30 /* EmoteRoomTimelineView.swift in Sources */,
02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */,
39AE84C8E5F2FE9D2DC7775C /* EventBasedTimelineView.swift in Sources */,
39AE84C8E5F2FE9D2DC7775C /* EventBasedTimelineSenderView.swift in Sources */,
3D325A1147F6281C57BFCDF6 /* EventBrief.swift in Sources */,
418B4AEFD03DC7A6D2C9D5C8 /* EventBriefFactory.swift in Sources */,
F78C57B197DA74735FEBB42C /* EventBriefFactoryProtocol.swift in Sources */,
@ -1843,6 +1846,7 @@
01CB8ACFA5E143E89C168CA8 /* TimelineItemContextMenu.swift in Sources */,
4D970CB606276717B43E2332 /* TimelineItemList.swift in Sources */,
500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */,
EC8128A028620A970012F05B /* TimelineItemStylerView.swift in Sources */,
4669804D0369FBED4E8625D1 /* ToastViewPresenter.swift in Sources */,
9CB5129C83F75921E5E28028 /* ToastViewState.swift in Sources */,
36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */,

View File

@ -238,6 +238,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
MXLog.error("Invalid room identifier: \(roomIdentifier)")
return
}
let userId = userSession.clientProxy.userIdentifier
let memberDetailProvider = memberDetailProviderManager.memberDetailProviderForRoomProxy(roomProxy)
@ -245,7 +246,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
memberDetailProvider: memberDetailProvider,
attributedStringBuilder: AttributedStringBuilder())
let timelineController = RoomTimelineController(timelineProvider: RoomTimelineProvider(roomProxy: roomProxy),
let timelineController = RoomTimelineController(userId: userId,
timelineProvider: RoomTimelineProvider(roomProxy: roomProxy),
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider,
memberDetailProvider: memberDetailProvider)

View File

@ -50,6 +50,7 @@ struct BugReport: View {
}
.navigationTitle(ElementL10n.titleActivityBugReport)
}
.background(Color.element.background, ignoresSafeAreaEdges: .all)
}
/// The main content of the view to be shown in a scroll view.
@ -59,11 +60,11 @@ struct BugReport: View {
.accessibilityIdentifier("reportBugDescription")
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(UIColor.secondarySystemBackground))
.fill(Color.element.system)
if context.reportText.isEmpty {
Text(ElementL10n.sendBugReportPlaceholder)
.foregroundColor(Color(UIColor.placeholderText))
.foregroundColor(Color.element.secondaryContent)
.padding(.horizontal, 8)
.padding(.vertical, 12)
}
@ -131,10 +132,14 @@ struct BugReport: View {
struct BugReport_Previews: PreviewProvider {
static var previews: some View {
Group {
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: Asset.Images.appLogo.image)
BugReport(context: viewModel.context)
.previewInterfaceOrientation(.portrait)
}
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: Asset.Images.appLogo.image)
BugReport(context: viewModel.context)
.previewInterfaceOrientation(.portrait)
}
}

View File

@ -34,6 +34,7 @@ struct HomeScreen: View {
Section("Rooms") {
ForEach(context.viewState.unencryptedRooms) { room in
RoomCell(room: room, context: context)
.listRowBackground(Color.clear)
}
let other = context.viewState.encryptedRooms
@ -44,6 +45,7 @@ struct HomeScreen: View {
RoomCell(room: room, context: context)
}
}
.listRowBackground(Color.clear)
}
}
@ -60,6 +62,7 @@ struct HomeScreen: View {
RoomCell(room: room, context: context)
}
}
.listRowBackground(Color.clear)
}
}
}
@ -69,6 +72,7 @@ struct HomeScreen: View {
Spacer()
}
.background(Color.element.background)
.ignoresSafeArea(.all, edges: .bottom)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@ -127,7 +131,8 @@ struct RoomCell: View {
.frame(width: 40, height: 40)
.mask(Circle())
} else {
Image(systemName: "person.3")
PlaceholderAvatarImage(text: room.displayName ?? room.id)
.clipShape(Circle())
.frame(width: 40, height: 40)
}
@ -168,6 +173,11 @@ struct RoomCell: View {
struct HomeScreen_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
static var body: some View {
let viewModel = HomeScreenViewModel(attributedStringBuilder: AttributedStringBuilder())
let eventBrief = EventBrief(eventId: "id",

View File

@ -45,7 +45,7 @@ struct RoomHeaderView: View {
.scaledToFill()
.accessibilityIdentifier("roomAvatarImage")
} else {
PlaceholderAvatarImage(firstCharacter: String(context.viewState.roomTitle.first ?? Character("")))
PlaceholderAvatarImage(text: context.viewState.roomTitle)
.accessibilityIdentifier("roomAvatarPlaceholderImage")
}
}
@ -78,7 +78,7 @@ struct RoomHeaderView_Previews: PreviewProvider {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
roomName: "Some Room name",
roomAvatar: Asset.Images.appLogo.image,
roomAvatar: nil,
roomEncryptionBadge: Asset.Images.encryptionTrusted.image
)

View File

@ -34,6 +34,7 @@ struct RoomScreen: View {
RoomHeaderView(context: context)
}
}
.background(Color.element.background, ignoresSafeAreaEdges: .all)
}
private func sendMessage() {

View File

@ -14,13 +14,17 @@ struct EmoteRoomTimelineView: View {
var body: some View {
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
HStack(alignment: .top) {
Image(systemName: "face.dashed").padding(.top, 1.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
} else {
Text(timelineItem.text)
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
HStack(alignment: .top) {
Image(systemName: "face.dashed").padding(.top, 1.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
} else {
Text(timelineItem.text)
.foregroundColor(.element.primaryContent)
}
}
}
}
@ -52,6 +56,7 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider {
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: senderId)
}
}

View File

@ -0,0 +1,93 @@
//
// EventBasedTimelineSenderView.swift
// ElementX
//
// Created by Stefan Ceriu on 18/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
struct EventBasedTimelineSenderView: View {
let timelineItem: EventBasedTimelineItemProtocol
@ScaledMetric private var avatarSize = 26
var body: some View {
if timelineItem.shouldShowSenderDetails {
VStack {
Spacer()
.frame(height: 8)
HStack(alignment: .top, spacing: 4) {
avatar
Text(timelineItem.senderDisplayName ?? timelineItem.senderId)
.font(.body)
.foregroundColor(.element.primaryContent)
.fontWeight(.semibold)
.lineLimit(1)
}
}
}
}
@ViewBuilder private var avatar: some View {
ZStack(alignment: .center) {
if let avatar = timelineItem.senderAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.overlay(Circle().stroke(Color.element.accent))
} else {
PlaceholderAvatarImage(text: timelineItem.senderDisplayName ?? timelineItem.senderId)
}
}
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
.overlay(
Circle()
.stroke(Color.element.background, lineWidth: 2)
)
.animation(.default, value: timelineItem.senderAvatar)
}
}
struct EventBasedTimelineSenderView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
VStack(alignment: .leading, spacing: 20.0) {
EventBasedTimelineSenderView(timelineItem: item1)
EventBasedTimelineSenderView(timelineItem: item2)
}
.frame(maxHeight: 160)
.previewLayout(.sizeThatFits)
}
private static var item1: EventBasedTimelineItemProtocol {
TextRoomTimelineItem(id: UUID().uuidString,
text: "Some text",
timestamp: "",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "",
senderDisplayName: "Bob")
}
private static var item2: EventBasedTimelineItemProtocol {
TextRoomTimelineItem(id: UUID().uuidString,
text: "Some text",
timestamp: "",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "",
senderDisplayName: "Some long display name for a user")
}
}

View File

@ -1,79 +0,0 @@
//
// EventBasedTimelineView.swift
// ElementX
//
// Created by Stefan Ceriu on 18/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
struct EventBasedTimelineView: View {
let timelineItem: EventBasedTimelineItemProtocol
var body: some View {
if timelineItem.shouldShowSenderDetails {
HStack {
avatar
Text(timelineItem.senderDisplayName ?? timelineItem.senderId)
.font(.footnote)
.bold()
Text(timelineItem.timestamp)
.font(.footnote)
}
}
}
@ViewBuilder private var avatar: some View {
ZStack(alignment: .center) {
if let avatar = timelineItem.senderAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.overlay(Circle().stroke(Color.element.accent))
} else {
PlaceholderAvatarImage(firstCharacter: String(firstLetter))
}
}
.clipShape(Circle())
.frame(width: 24.0, height: 24.0)
.animation(.default, value: timelineItem.senderAvatar)
}
private var firstLetter: String {
if let senderDisplayName = timelineItem.senderDisplayName {
return senderDisplayName.prefix(1).uppercased()
} else {
return timelineItem.senderId.prefix(2).suffix(1).uppercased()
}
}
}
struct EventBasedTimelineView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
VStack(alignment: .leading, spacing: 20.0) {
EventBasedTimelineView(timelineItem: itemWith(text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.",
timestamp: "Now",
senderId: "Bob"))
EventBasedTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
senderId: "Anne"))
}
}
private static func itemWith(text: String, timestamp: String, senderId: String) -> TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
senderId: senderId)
}
}

View File

@ -22,10 +22,12 @@ struct FormattedBodyText: View {
.frame(width: 4.0)
Text(component.attributedString)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.element.primaryContent)
}
} else {
Text(component.attributedString)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.element.primaryContent)
}
}
}

View File

@ -15,36 +15,40 @@ struct ImageRoomTimelineView: View {
var body: some View {
if timelineItem.image != nil || timelineItem.blurhash != nil { // Fixes view heights after loading finishes
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
Text(timelineItem.text)
if let image = timelineItem.image {
if let aspectRatio = timelineItem.aspectRatio {
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
if let image = timelineItem.image {
if let aspectRatio = timelineItem.aspectRatio {
Image(uiImage: image)
.resizable()
.aspectRatio(aspectRatio, contentMode: .fit)
} else {
Image(uiImage: image)
.resizable()
.scaledToFit()
}
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(aspectRatio, contentMode: .fit)
} else {
Image(uiImage: image)
.resizable()
.scaledToFit()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}
.animation(.default, value: timelineItem.image)
.frame(maxHeight: 1000.0)
} else {
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
Text(timelineItem.text)
HStack {
Spacer()
ProgressView("Loading")
Spacer()
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
HStack {
Spacer()
ProgressView("Loading")
Spacer()
}
}
}
}
@ -64,6 +68,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Some image",
timestamp: "Now",
shouldShowSenderDetails: false,
isOutgoing: false,
senderId: "Bob",
source: nil,
image: UIImage(systemName: "photo")))
@ -72,6 +77,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Some other image",
timestamp: "Now",
shouldShowSenderDetails: false,
isOutgoing: false,
senderId: "Bob",
source: nil,
image: nil))
@ -80,6 +86,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Blurhashed image",
timestamp: "Now",
shouldShowSenderDetails: false,
isOutgoing: false,
senderId: "Bob",
source: nil,
image: nil,

View File

@ -14,13 +14,17 @@ struct NoticeRoomTimelineView: View {
var body: some View {
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
HStack(alignment: .top) {
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
} else {
Text(timelineItem.text)
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
HStack(alignment: .top) {
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
} else {
Text(timelineItem.text)
.foregroundColor(.element.primaryContent)
}
}
}
}
@ -50,9 +54,10 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider {
private static func itemWith(text: String, timestamp: String, senderId: String) -> NoticeRoomTimelineItem {
return NoticeRoomTimelineItem(id: UUID().uuidString,
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
senderId: senderId)
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: senderId)
}
}

View File

@ -18,12 +18,12 @@ import SwiftUI
struct PlaceholderAvatarImage: View {
let firstCharacter: String
private let textForImage: String
var body: some View {
ZStack {
Color.element.accent
Text(firstCharacter)
Text(textForImage)
.padding(4)
.foregroundColor(.white)
// Make the text resizable (i.e. Make it large and then allow it to scale down)
@ -32,6 +32,10 @@ struct PlaceholderAvatarImage: View {
}
.aspectRatio(1, contentMode: .fill)
}
init(text: String) {
textForImage = text.first?.uppercased() ?? ""
}
}
struct PlaceholderAvatarImage_Previews: PreviewProvider {
@ -42,7 +46,7 @@ struct PlaceholderAvatarImage_Previews: PreviewProvider {
@ViewBuilder
static var body: some View {
PlaceholderAvatarImage(firstCharacter: "X")
PlaceholderAvatarImage(text: "X")
.clipShape(Circle())
.frame(width: 150, height: 100)
}

View File

@ -15,6 +15,7 @@ struct SeparatorRoomTimelineView: View {
var body: some View {
LabelledDivider(label: timelineItem.text)
.id(timelineItem.id)
.padding(.vertical, 8)
}
}
@ -22,7 +23,7 @@ struct LabelledDivider: View {
let label: String
let color: Color
init(label: String, color: Color = .gray) {
init(label: String, color: Color = Color.element.secondaryContent) {
self.label = label
self.color = color
}

View File

@ -13,12 +13,15 @@ struct TextRoomTimelineView: View {
let timelineItem: TextRoomTimelineItem
var body: some View {
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
} else {
Text(timelineItem.text)
.font(.body)
.multilineTextAlignment(.leading)
}
}
.id(timelineItem.id)
@ -36,19 +39,25 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
VStack(alignment: .leading, spacing: 20.0) {
TextRoomTimelineView(timelineItem: itemWith(text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.",
timestamp: "Now",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "Bob"))
TextRoomTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
shouldShowSenderDetails: true,
isOutgoing: true,
senderId: "Anne"))
}
.padding(.horizontal, 8)
}
private static func itemWith(text: String, timestamp: String, senderId: String) -> TextRoomTimelineItem {
private static func itemWith(text: String, timestamp: String, shouldShowSenderDetails: Bool, isOutgoing: Bool, senderId: String) -> TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
senderId: senderId)
text: text,
timestamp: timestamp,
shouldShowSenderDetails: shouldShowSenderDetails,
isOutgoing: isOutgoing,
senderId: senderId)
}
}

View File

@ -32,6 +32,7 @@ struct TimelineItemList: View {
.animation(.default, value: context.viewState.isBackPaginating)
Spacer()
}
.listRowBackground(Color.clear)
// No idea why previews don't work otherwise
ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in
@ -39,7 +40,9 @@ struct TimelineItemList: View {
.contextMenu(menuItems: {
context.viewState.contextMenuBuilder?(timelineItem.id)
})
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 1, leading: 8, bottom: 1, trailing: 8))
.onAppear {
context.send(viewAction: .itemAppeared(id: timelineItem.id))
}

View File

@ -0,0 +1,158 @@
//
// TimelineItemStyleView.swift
// ElementX
//
// Created by Ismail on 21.06.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
import Combine
import Introspect
struct TimelineItemStylerView<Header: View, Content: View>: View {
let timelineItem: EventBasedTimelineItemProtocol
@ViewBuilder let header: () -> Header
@ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme
@ScaledMetric private var minBubbleWidth = 44
var body: some View {
VStack(alignment: timelineItem.isOutgoing ? .trailing : .leading, spacing: -5) {
if !timelineItem.isOutgoing {
header()
.zIndex(1)
}
if timelineItem.isOutgoing {
HStack {
Spacer()
styledContent
}
.padding(.trailing, 16)
.padding(.leading, 51)
} else {
styledContent
.padding(.leading, 16)
.padding(.trailing, 51)
}
}
}
@ViewBuilder
var styledContent: some View {
if shouldAvoidBubbling {
ZStack(alignment: .bottomTrailing) {
content()
.clipped()
.cornerRadius(8)
Text(timelineItem.timestamp)
.foregroundColor(.global.white)
.font(.element.caption2)
.padding(4)
.background(Color(white: 0, opacity: 0.7))
.clipped()
.cornerRadius(8)
.offset(x: -8, y: -8)
}
} else {
VStack(alignment: .trailing, spacing: 4) {
content()
.frame(minWidth: minBubbleWidth, alignment: .leading)
Text(timelineItem.timestamp)
.foregroundColor(Color.element.tertiaryContent)
.font(.element.caption2)
}
.padding(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.clipped()
.background(bubbleColor)
.cornerRadius(12)
}
}
private var shouldAvoidBubbling: Bool {
return timelineItem is ImageRoomTimelineItem
}
private var bubbleColor: Color {
let opacity = colorScheme == .light ? 0.06 : 0.15
return timelineItem.isOutgoing ? .element.accent.opacity(opacity) : .element.system
}
}
struct TimelineItemStylerView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
VStack(alignment: .leading) {
TimelineItemStylerView(timelineItem: item1) {
EventBasedTimelineSenderView(timelineItem: item1)
} content: {
Text(item1.text)
}
TimelineItemStylerView(timelineItem: item2) {
EventBasedTimelineSenderView(timelineItem: item2)
} content: {
Text(item2.text)
}
TimelineItemStylerView(timelineItem: item3) {
EventBasedTimelineSenderView(timelineItem: item3)
} content: {
Text(item3.text)
}
TimelineItemStylerView(timelineItem: item4) {
EventBasedTimelineSenderView(timelineItem: item4)
} content: {
Text(item4.text)
}
}
.padding(.horizontal, 8)
.frame(maxHeight: 400)
.previewLayout(.sizeThatFits)
}
private static var item1: TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: "Short",
timestamp: "07:05",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "Bob")
}
private static var item2: TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: "Short loin ground round tongue hamburger, fatback salami shoulder.",
timestamp: "08:05",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "Bob")
}
private static var item3: TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: "Short loin ground round tongue hamburger, fatback salami shoulder.",
timestamp: "08:07",
shouldShowSenderDetails: false,
isOutgoing: true,
senderId: "Bob")
}
private static var item4: TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: "Short",
timestamp: "08:08",
shouldShowSenderDetails: false,
isOutgoing: true,
senderId: "Bob")
}
}

View File

@ -21,6 +21,7 @@ struct Settings: View {
// MARK: Private
@State private var showingLogoutConfirmation = false
@Environment(\.colorScheme) private var colorScheme
// MARK: Public
@ -41,9 +42,11 @@ struct Settings: View {
Button("Crash the app",
role: .destructive) { context.send(viewAction: .crash)
}
.accessibilityIdentifier("crashButton")
}
}
.listRowBackground(rowBackgroundColor)
Section {
Button { showingLogoutConfirmation = true } label: {
@ -62,23 +65,40 @@ struct Settings: View {
} footer: {
versionText
}
.listRowBackground(rowBackgroundColor)
}
.introspectTableView { tableView in
tableView.backgroundColor = .clear
}
.navigationTitle(ElementL10n.settings)
.background(backgroundColor, ignoresSafeAreaEdges: .all)
}
var versionText: some View {
Text(ElementL10n.settingsVersion + ": " + ElementInfoPlist.cfBundleShortVersionString + " (" + ElementInfoPlist.cfBundleVersion + ")")
}
private var backgroundColor: Color {
colorScheme == .light ? .element.system : .element.background
}
private var rowBackgroundColor: Color {
colorScheme == .light ? .element.background : .element.system
}
}
// MARK: - Previews
struct Settings_Previews: PreviewProvider {
static var previews: some View {
Group {
let viewModel = SettingsViewModel()
Settings(context: viewModel.context)
.previewInterfaceOrientation(.portrait)
}
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
let viewModel = SettingsViewModel()
Settings(context: viewModel.context)
.previewInterfaceOrientation(.portrait)
}
}

View File

@ -13,11 +13,31 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
var timelineItems: [RoomTimelineItemProtocol] = [SeparatorRoomTimelineItem(id: UUID().uuidString, text: "Yesterday"),
TextRoomTimelineItem(id: UUID().uuidString, text: "You rock!", timestamp: "10:10 AM", shouldShowSenderDetails: true, senderId: "Alice"),
TextRoomTimelineItem(id: UUID().uuidString, text: "You also rule!", timestamp: "10:11 AM", shouldShowSenderDetails: false, senderId: "Alice"),
SeparatorRoomTimelineItem(id: UUID().uuidString, text: "Today"),
TextRoomTimelineItem(id: UUID().uuidString, text: "You too!", timestamp: "5 PM", shouldShowSenderDetails: true, senderId: "Bob")]
var timelineItems: [RoomTimelineItemProtocol] = [SeparatorRoomTimelineItem(id: UUID().uuidString,
text: "Yesterday"),
TextRoomTimelineItem(id: UUID().uuidString,
text: "You rock!",
timestamp: "10:10 AM",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "",
senderDisplayName: "Some user with a really long long long long long display name"),
TextRoomTimelineItem(id: UUID().uuidString,
text: "You also rule!",
timestamp: "10:11 AM",
shouldShowSenderDetails: false,
isOutgoing: false,
senderId: "",
senderDisplayName: "Alice"),
SeparatorRoomTimelineItem(id: UUID().uuidString,
text: "Today"),
TextRoomTimelineItem(id: UUID().uuidString,
text: "You too!",
timestamp: "5 PM",
shouldShowSenderDetails: false,
isOutgoing: true,
senderId: "",
senderDisplayName: "Bob")]
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineControllerError> {
return .failure(.generic)

View File

@ -11,6 +11,7 @@ import Combine
import UIKit
class RoomTimelineController: RoomTimelineControllerProtocol {
private let userId: String
private let timelineProvider: RoomTimelineProviderProtocol
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
private let mediaProvider: MediaProviderProtocol
@ -22,10 +23,12 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private(set) var timelineItems = [RoomTimelineItemProtocol]()
init(timelineProvider: RoomTimelineProviderProtocol,
init(userId: String,
timelineProvider: RoomTimelineProviderProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol,
memberDetailProvider: MemberDetailProviderProtocol) {
self.userId = userId
self.timelineProvider = timelineProvider
self.timelineItemFactory = timelineItemFactory
self.mediaProvider = mediaProvider
@ -110,7 +113,9 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
let areMessagesFromTheSameSender = (previousMessage?.sender == message.sender)
let shouldShowSenderDetails = !areMessagesFromTheSameSender || !areMessagesFromTheSameDay
newTimelineItems.append(timelineItemFactory.buildTimelineItemFor(message: message, showSenderDetails: shouldShowSenderDetails))
newTimelineItems.append(timelineItemFactory.buildTimelineItemFor(message: message,
isOutgoing: message.sender == userId,
showSenderDetails: shouldShowSenderDetails))
previousMessage = message
}

View File

@ -13,6 +13,7 @@ protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol {
var text: String { get }
var timestamp: String { get }
var shouldShowSenderDetails: Bool { get }
var isOutgoing: Bool { get }
var senderId: String { get }
var senderDisplayName: String? { get set }

View File

@ -15,6 +15,7 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa
var attributedComponents: [AttributedStringBuilderComponent]?
let timestamp: String
let shouldShowSenderDetails: Bool
let isOutgoing: Bool
let senderId: String
var senderDisplayName: String?

View File

@ -14,6 +14,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa
let text: String
let timestamp: String
let shouldShowSenderDetails: Bool
let isOutgoing: Bool
let senderId: String
var senderDisplayName: String?

View File

@ -15,6 +15,7 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equ
var attributedComponents: [AttributedStringBuilderComponent]?
let timestamp: String
let shouldShowSenderDetails: Bool
let isOutgoing: Bool
let senderId: String
var senderDisplayName: String?

View File

@ -15,6 +15,7 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equat
var attributedComponents: [AttributedStringBuilderComponent]?
let timestamp: String
let shouldShowSenderDetails: Bool
let isOutgoing: Bool
let senderId: String
var senderDisplayName: String?

View File

@ -22,20 +22,20 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
self.attributedStringBuilder = attributedStringBuilder
}
func buildTimelineItemFor(message: RoomMessageProtocol, showSenderDetails: Bool) -> RoomTimelineItemProtocol {
func buildTimelineItemFor(message: RoomMessageProtocol, isOutgoing: Bool, showSenderDetails: Bool) -> RoomTimelineItemProtocol {
let displayName = memberDetailProvider.displayNameForUserId(message.sender)
let avatarURL = memberDetailProvider.avatarURLStringForUserId(message.sender)
let avatarImage = mediaProvider.imageFromURLString(avatarURL)
switch message {
case let message as TextRoomMessage:
return buildTextTimelineItemFromMessage(message, showSenderDetails, displayName, avatarImage)
return buildTextTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
case let message as ImageRoomMessage:
return buildImageTimelineItemFromMessage(message, showSenderDetails, displayName, avatarImage)
return buildImageTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
case let message as NoticeRoomMessage:
return buildNoticeTimelineItemFromMessage(message, showSenderDetails, displayName, avatarImage)
return buildNoticeTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
case let message as EmoteRoomMessage:
return buildEmoteTimelineItemFromMessage(message, showSenderDetails, displayName, avatarImage)
return buildEmoteTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
default:
fatalError("Unknown room message.")
}
@ -43,6 +43,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
// MARK: - Private
private func buildTextTimelineItemFromMessage(_ message: TextRoomMessage,
_ isOutgoing: Bool,
_ showSenderDetails: Bool,
_ displayName: String?,
_ avatarImage: UIImage?) -> RoomTimelineItemProtocol {
@ -54,12 +55,14 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
attributedComponents: attributedComponents,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
isOutgoing: isOutgoing,
senderId: message.sender,
senderDisplayName: displayName,
senderAvatar: avatarImage)
}
private func buildImageTimelineItemFromMessage(_ message: ImageRoomMessage,
_ isOutgoing: Bool,
_ showSenderDetails: Bool,
_ displayName: String?,
_ avatarImage: UIImage?) -> RoomTimelineItemProtocol {
@ -74,6 +77,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
text: message.body,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
isOutgoing: isOutgoing,
senderId: message.sender,
senderDisplayName: displayName,
senderAvatar: avatarImage,
@ -86,6 +90,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
}
private func buildNoticeTimelineItemFromMessage(_ message: NoticeRoomMessage,
_ isOutgoing: Bool,
_ showSenderDetails: Bool,
_ displayName: String?,
_ avatarImage: UIImage?) -> RoomTimelineItemProtocol {
@ -97,12 +102,14 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
attributedComponents: attributedComponents,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
isOutgoing: isOutgoing,
senderId: message.sender,
senderDisplayName: displayName,
senderAvatar: avatarImage)
}
private func buildEmoteTimelineItemFromMessage(_ message: EmoteRoomMessage,
_ isOutgoing: Bool,
_ showSenderDetails: Bool,
_ displayName: String?,
_ avatarImage: UIImage?) -> RoomTimelineItemProtocol {
@ -114,6 +121,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
attributedComponents: attributedComponents,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
isOutgoing: isOutgoing,
senderId: message.sender,
senderDisplayName: displayName,
senderAvatar: avatarImage)

View File

@ -10,5 +10,5 @@ import Foundation
@MainActor
protocol RoomTimelineItemFactoryProtocol {
func buildTimelineItemFor(message: RoomMessageProtocol, showSenderDetails: Bool) -> RoomTimelineItemProtocol
func buildTimelineItemFor(message: RoomMessageProtocol, isOutgoing: Bool, showSenderDetails: Bool) -> RoomTimelineItemProtocol
}

1
changelog.d/34.change Normal file
View File

@ -0,0 +1 @@
Room: Use bubbles in the timeline.