mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Plain timeline styler (#94)
* #92 Create `TimelineStyle` and store it in user defaults * #92 Create `TimelineStyler` observable environment object * #92 Use timeline list row insets from the styler * #92 remove sender view, move header into the styler views * #92 Add timeline style option in settings * #92 Add changelog * #92 refactor some code duplications * #92 Fix `TimelineStyle`targets * #92 Fix `TimelineStyle` targets * #92 Fix PR remarks * #92 Fix inline PR comments * #92 Fix UI tests * #92 Rename bubbled to bubbles
This commit is contained in:
+25
-63
@@ -8,14 +8,10 @@
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
import Introspect
|
||||
|
||||
struct TimelineItemStylerView<Header: View, Content: View>: View {
|
||||
struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@ViewBuilder let header: () -> Header
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@@ -24,7 +20,7 @@ struct TimelineItemStylerView<Header: View, Content: View>: View {
|
||||
var body: some View {
|
||||
VStack(alignment: timelineItem.isOutgoing ? .trailing : .leading, spacing: -5) {
|
||||
if !timelineItem.isOutgoing {
|
||||
header()
|
||||
header
|
||||
.zIndex(1)
|
||||
}
|
||||
if timelineItem.isOutgoing {
|
||||
@@ -42,6 +38,24 @@ struct TimelineItemStylerView<Header: View, Content: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if timelineItem.shouldShowSenderDetails {
|
||||
VStack {
|
||||
Spacer()
|
||||
.frame(height: 8)
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
TimelineSenderAvatarView(timelineItem: timelineItem)
|
||||
Text(timelineItem.senderDisplayName ?? timelineItem.senderId)
|
||||
.font(.body)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var styledContent: some View {
|
||||
if shouldAvoidBubbling {
|
||||
@@ -85,7 +99,7 @@ struct TimelineItemStylerView<Header: View, Content: View>: View {
|
||||
|
||||
}
|
||||
|
||||
struct TimelineItemStylerView_Previews: PreviewProvider {
|
||||
struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
body.preferredColorScheme(.light)
|
||||
body.preferredColorScheme(.dark)
|
||||
@@ -94,65 +108,13 @@ struct TimelineItemStylerView_Previews: PreviewProvider {
|
||||
@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)
|
||||
ForEach((1..<MockRoomTimelineController().timelineItems.count), id: \.self) { index in
|
||||
let item = MockRoomTimelineController().timelineItems[index]
|
||||
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item)
|
||||
}
|
||||
}
|
||||
.timelineStyle(.bubbles)
|
||||
.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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// TimelineItemStyleView.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Ismail on 21.06.2022.
|
||||
// Copyright © 2022 Element. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineItemPlainStylerView<Content: View>: View {
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
header
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if timelineItem.shouldShowSenderDetails {
|
||||
HStack {
|
||||
TimelineSenderAvatarView(timelineItem: timelineItem)
|
||||
Text(timelineItem.senderDisplayName ?? timelineItem.senderId)
|
||||
.font(.body)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
Text(timelineItem.timestamp)
|
||||
.foregroundColor(Color.element.tertiaryContent)
|
||||
.font(.element.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineItemPlainStylerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
body.preferredColorScheme(.light)
|
||||
body.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
static var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
ForEach((1..<MockRoomTimelineController().timelineItems.count), id: \.self) { index in
|
||||
let item = MockRoomTimelineController().timelineItems[index]
|
||||
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item)
|
||||
}
|
||||
}
|
||||
.timelineStyle(.plain)
|
||||
.padding(.horizontal, 8)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// TimelineStyle.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Ismail on 24.06.2022.
|
||||
// Copyright © 2022 Element. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum TimelineStyle: String, CaseIterable {
|
||||
case plain
|
||||
case bubbles
|
||||
|
||||
/// List row insets for a timeline
|
||||
var listRowInsets: EdgeInsets {
|
||||
switch self {
|
||||
case .plain:
|
||||
return EdgeInsets(top: 4, leading: 20, bottom: 4, trailing: 20)
|
||||
case .bubbles:
|
||||
return EdgeInsets(top: 1, leading: 8, bottom: 1, trailing: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
private struct TimelineStyleKey: EnvironmentKey {
|
||||
static let defaultValue = BuildSettings.defaultRoomTimelineStyle
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var timelineStyle: TimelineStyle {
|
||||
get { self[TimelineStyleKey.self] }
|
||||
set { self[TimelineStyleKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func timelineStyle(_ style: TimelineStyle) -> some View {
|
||||
environment(\.timelineStyle, style)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// TimelineStyler.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Ismail on 24.06.2022.
|
||||
// Copyright © 2022 Element. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - TimelineStyler
|
||||
|
||||
struct TimelineStyler<Content: View>: View {
|
||||
@Environment(\.timelineStyle) private var style
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
switch style {
|
||||
case .plain:
|
||||
TimelineItemPlainStylerView(timelineItem: timelineItem, content: content)
|
||||
case .bubbles:
|
||||
TimelineItemBubbledStylerView(timelineItem: timelineItem, content: content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,14 @@ struct EmoteRoomTimelineView: View {
|
||||
let timelineItem: EmoteRoomTimelineItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
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)
|
||||
}
|
||||
TimelineStyler(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)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,43 +14,37 @@ struct ImageRoomTimelineView: View {
|
||||
|
||||
var body: some View {
|
||||
if timelineItem.image != nil || timelineItem.blurhash != nil { // Fixes view heights after loading finishes
|
||||
VStack(alignment: .leading) {
|
||||
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)) {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
if let image = timelineItem.image {
|
||||
if let aspectRatio = timelineItem.aspectRatio {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||
.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(timelineItem.aspectRatio, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
.id(timelineItem.id)
|
||||
.animation(.default, value: timelineItem.image)
|
||||
.frame(maxHeight: 1000.0)
|
||||
} else {
|
||||
VStack(alignment: .leading) {
|
||||
TimelineItemStylerView(timelineItem: timelineItem) {
|
||||
EventBasedTimelineSenderView(timelineItem: timelineItem)
|
||||
} content: {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("Loading")
|
||||
Spacer()
|
||||
}
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("Loading")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.id(timelineItem.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,18 +13,14 @@ struct NoticeRoomTimelineView: View {
|
||||
let timelineItem: NoticeRoomTimelineItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
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)
|
||||
}
|
||||
TimelineStyler(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)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,7 @@ struct TextRoomTimelineView: View {
|
||||
let timelineItem: TextRoomTimelineItem
|
||||
|
||||
var body: some View {
|
||||
TimelineItemStylerView(timelineItem: timelineItem) {
|
||||
EventBasedTimelineSenderView(timelineItem: timelineItem)
|
||||
} content: {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
if let attributedComponents = timelineItem.attributedComponents {
|
||||
FormattedBodyText(attributedComponents: attributedComponents)
|
||||
} else {
|
||||
@@ -42,12 +40,26 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
|
||||
shouldShowSenderDetails: true,
|
||||
isOutgoing: false,
|
||||
senderId: "Bob"))
|
||||
|
||||
|
||||
TextRoomTimelineView(timelineItem: itemWith(text: "Some other text",
|
||||
timestamp: "Later",
|
||||
shouldShowSenderDetails: true,
|
||||
isOutgoing: true,
|
||||
senderId: "Anne"))
|
||||
|
||||
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"))
|
||||
.timelineStyle(.plain)
|
||||
|
||||
TextRoomTimelineView(timelineItem: itemWith(text: "Some other text",
|
||||
timestamp: "Later",
|
||||
shouldShowSenderDetails: true,
|
||||
isOutgoing: true,
|
||||
senderId: "Anne"))
|
||||
.timelineStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ struct TimelineItemList: View {
|
||||
@State private var tableViewObserver: ListTableViewAdapter = ListTableViewAdapter()
|
||||
@State private var timelineItems: [RoomTimelineViewProvider] = []
|
||||
@State private var hasPendingChanges = false
|
||||
@ObservedObject private var settings = ElementSettings.shared
|
||||
|
||||
@ObservedObject var context: RoomScreenViewModel.Context
|
||||
|
||||
@@ -42,7 +43,7 @@ struct TimelineItemList: View {
|
||||
})
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 1, leading: 8, bottom: 1, trailing: 8))
|
||||
.listRowInsets(settings.timelineStyle.listRowInsets)
|
||||
.onAppear {
|
||||
context.send(viewAction: .itemAppeared(id: timelineItem.id))
|
||||
}
|
||||
@@ -56,6 +57,7 @@ struct TimelineItemList: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.timelineStyle(settings.timelineStyle)
|
||||
.environment(\.defaultMinListRowHeight, 0.0)
|
||||
.introspectTableView { tableView in
|
||||
if tableView == tableViewObserver.tableView {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// TimelineSenderAvatarView.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Ismail on 24.06.2022.
|
||||
// Copyright © 2022 Element. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineSenderAvatarView: View {
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
|
||||
@ScaledMetric private var avatarSize = 26
|
||||
|
||||
var body: 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)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user