mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-25 21:21:25 +00:00
Surface the XFTP servers that hosted a file's chunks in the message info screen (Android, desktop, iOS), so a user can see which servers they are downloading from (or, for sent files, uploading to) whenever they want to know. APIGetChatItemInfo now returns fileXftpServers, derived from the stored file description (private snd descr for sent items, rcv descr for received items); extraction is best-effort and never fails the call.
605 lines
24 KiB
Swift
605 lines
24 KiB
Swift
//
|
|
// ChatItemInfoView.swift
|
|
// SimpleX (iOS)
|
|
//
|
|
// Created by spaced4ndy on 09.05.2023.
|
|
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SimpleXChat
|
|
|
|
// Spec: spec/client/chat-view.md#ChatItemInfoView
|
|
struct ChatItemInfoView: View {
|
|
@EnvironmentObject var chatModel: ChatModel
|
|
@Environment(\.dismiss) var dismiss
|
|
@EnvironmentObject var theme: AppTheme
|
|
var ci: ChatItem
|
|
var userMemberId: String?
|
|
@Binding var chatItemInfo: ChatItemInfo?
|
|
@State private var selection: CIInfoTab = .history
|
|
@State private var alert: CIInfoViewAlert? = nil
|
|
@State private var messageStatusLimited: Bool = true
|
|
@State private var fileStatusLimited: Bool = true
|
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
|
|
|
enum CIInfoTab {
|
|
case history
|
|
case quote
|
|
case forwarded
|
|
case delivery
|
|
}
|
|
|
|
enum CIInfoViewAlert: Identifiable {
|
|
case alert(title: String, text: String)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case let .alert(title, text): return "alert \(title) \(text)"
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
itemInfoView()
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button { showShareSheet(items: [itemInfoShareText()]) } label: {
|
|
Image(systemName: "square.and.arrow.up")
|
|
}
|
|
}
|
|
}
|
|
.alert(item: $alert) { a in
|
|
switch(a) {
|
|
case let .alert(title, text): return Alert(title: Text(title), message: Text(text))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var title: String {
|
|
ci.localNote
|
|
? NSLocalizedString("Saved message", comment: "message info title")
|
|
: ci.chatDir.sent
|
|
? NSLocalizedString("Sent message", comment: "message info title")
|
|
: NSLocalizedString("Received message", comment: "message info title")
|
|
}
|
|
|
|
private var numTabs: Int {
|
|
var numTabs = 1
|
|
if chatItemInfo?.memberDeliveryStatuses != nil {
|
|
numTabs += 1
|
|
}
|
|
if ci.quotedItem != nil {
|
|
numTabs += 1
|
|
}
|
|
if chatItemInfo?.forwardedFromChatItem != nil {
|
|
numTabs += 1
|
|
}
|
|
return numTabs
|
|
}
|
|
|
|
private var local: Bool {
|
|
switch ci.chatDir {
|
|
case .localSnd: true
|
|
case .localRcv: true
|
|
default: false
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func itemInfoView() -> some View {
|
|
if numTabs > 1 {
|
|
TabView(selection: $selection) {
|
|
if let mdss = chatItemInfo?.memberDeliveryStatuses {
|
|
deliveryTab(mdss)
|
|
.tabItem {
|
|
Label("Delivery", systemImage: "checkmark.message")
|
|
}
|
|
.tag(CIInfoTab.delivery)
|
|
}
|
|
historyTab()
|
|
.tabItem {
|
|
Label("History", systemImage: "clock")
|
|
}
|
|
.tag(CIInfoTab.history)
|
|
.modifier(ThemedBackground())
|
|
if let qi = ci.quotedItem {
|
|
quoteTab(qi)
|
|
.tabItem {
|
|
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
|
}
|
|
.tag(CIInfoTab.quote)
|
|
.modifier(ThemedBackground())
|
|
}
|
|
if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem {
|
|
forwardedFromTab(forwardedFromItem)
|
|
.tabItem {
|
|
Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward")
|
|
}
|
|
.tag(CIInfoTab.forwarded)
|
|
.modifier(ThemedBackground())
|
|
}
|
|
}
|
|
.onAppear {
|
|
if chatItemInfo?.memberDeliveryStatuses != nil {
|
|
selection = .delivery
|
|
}
|
|
}
|
|
} else {
|
|
historyTab()
|
|
.modifier(ThemedBackground())
|
|
}
|
|
}
|
|
|
|
private func details() -> some View {
|
|
let meta = ci.meta
|
|
return VStack(alignment: .leading, spacing: 16) {
|
|
Text(title)
|
|
.font(.largeTitle)
|
|
.bold()
|
|
.padding(.bottom)
|
|
|
|
if ci.localNote {
|
|
infoRow("Created at", localTimestamp(meta.itemTs))
|
|
} else {
|
|
infoRow("Sent at", localTimestamp(meta.itemTs))
|
|
}
|
|
if !ci.chatDir.sent {
|
|
infoRow("Received at", localTimestamp(meta.createdAt))
|
|
}
|
|
switch (meta.itemDeleted) {
|
|
case let .deleted(deletedTs):
|
|
if let deletedTs = deletedTs {
|
|
infoRow("Deleted at", localTimestamp(deletedTs))
|
|
}
|
|
case let .moderated(deletedTs, _):
|
|
if let deletedTs = deletedTs {
|
|
infoRow("Moderated at", localTimestamp(deletedTs))
|
|
}
|
|
default: EmptyView()
|
|
}
|
|
if let deleteAt = meta.itemTimed?.deleteAt {
|
|
infoRow("Disappears at", localTimestamp(deleteAt))
|
|
}
|
|
if meta.msgVerified?.verified == true {
|
|
let signedText: LocalizedStringKey = ci.chatDir.sent ? "Signed" : "Signed & verified"
|
|
HStack {
|
|
Label {
|
|
Text(signedText)
|
|
} icon: {
|
|
Text(Image(systemName: "checkmark.seal")).foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
} else if meta.msgVerified == .sigMissing {
|
|
HStack {
|
|
Label {
|
|
Text("Signature missing")
|
|
} icon: {
|
|
Text(Image(systemName: "xmark.seal")).foregroundColor(.red)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
if developerTools {
|
|
Divider().padding(.vertical)
|
|
infoRow("Database ID", "\(meta.itemId)")
|
|
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
|
let msv = infoRow("Message status", ci.meta.itemStatus.id)
|
|
Group {
|
|
if messageStatusLimited {
|
|
msv.lineLimit(1)
|
|
} else {
|
|
msv
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
withAnimation {
|
|
messageStatusLimited.toggle()
|
|
}
|
|
}
|
|
|
|
if let file = ci.file {
|
|
let fsv = infoRow("File status", file.fileStatus.id)
|
|
Group {
|
|
if fileStatusLimited {
|
|
fsv.lineLimit(1)
|
|
} else {
|
|
fsv
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
withAnimation {
|
|
fileStatusLimited.toggle()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty {
|
|
infoRow("File servers", servers.map(serverHostname).joined(separator: "\n"))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func historyTab() -> some View {
|
|
GeometryReader { g in
|
|
let maxWidth = (g.size.width - 32) * 0.84
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
details()
|
|
Divider().padding(.vertical)
|
|
if let chatItemInfo = chatItemInfo,
|
|
!chatItemInfo.itemVersions.isEmpty {
|
|
Text("History")
|
|
.font(.title2)
|
|
.padding(.bottom, 4)
|
|
LazyVStack(alignment: .leading, spacing: 16) {
|
|
ForEach(Array(chatItemInfo.itemVersions.enumerated()), id: \.element.chatItemVersionId) { index, itemVersion in
|
|
itemVersionView(itemVersion, maxWidth, current: index == 0 && ci.meta.itemDeleted == nil)
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
Text("No history")
|
|
.foregroundColor(theme.colors.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
}
|
|
|
|
private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
|
let backgroundColor = chatItemFrameColor(ci, theme)
|
|
return VStack(alignment: .leading, spacing: 4) {
|
|
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: backgroundColor)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(backgroundColor)
|
|
.modifier(ChatItemClipped())
|
|
.contextMenu {
|
|
if itemVersion.msgContent.text != "" {
|
|
Button {
|
|
showShareSheet(items: [itemVersion.msgContent.text])
|
|
} label: {
|
|
Label("Share", systemImage: "square.and.arrow.up")
|
|
}
|
|
Button {
|
|
UIPasteboard.general.string = itemVersion.msgContent.text
|
|
} label: {
|
|
Label("Copy", systemImage: "doc.on.doc")
|
|
}
|
|
}
|
|
}
|
|
let ts = localTimestamp(itemVersion.itemVersionTs)
|
|
(current ? Text("\(ts) (current)") : Text(ts))
|
|
.foregroundStyle(.secondary)
|
|
.font(.caption)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
.frame(maxWidth: maxWidth, alignment: .leading)
|
|
}
|
|
|
|
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: Color) -> some View {
|
|
if text != "" {
|
|
TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor)
|
|
} else {
|
|
Text("no text")
|
|
.italic()
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
|
|
private struct TextBubble: View {
|
|
@EnvironmentObject var theme: AppTheme
|
|
var text: String
|
|
var formattedText: [FormattedText]?
|
|
var sender: String? = nil
|
|
var mentions: [String: CIMention]?
|
|
var userMemberId: String?
|
|
var backgroundColor: Color
|
|
@State private var showSecrets: Set<Int> = []
|
|
|
|
var body: some View {
|
|
let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor))
|
|
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
|
|
}
|
|
}
|
|
|
|
private func quoteTab(_ qi: CIQuote) -> some View {
|
|
GeometryReader { g in
|
|
let maxWidth = (g.size.width - 32) * 0.84
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
details()
|
|
Divider().padding(.vertical)
|
|
Text("In reply to")
|
|
.font(.title2)
|
|
.padding(.bottom, 4)
|
|
quotedMsgView(qi, maxWidth)
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
}
|
|
|
|
private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
|
|
let backgroundColor = quotedMsgFrameColor(qi, theme)
|
|
return VStack(alignment: .leading, spacing: 4) {
|
|
textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: backgroundColor)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(quotedMsgFrameColor(qi, theme))
|
|
.modifier(ChatItemClipped())
|
|
.contextMenu {
|
|
if qi.text != "" {
|
|
Button {
|
|
showShareSheet(items: [qi.text])
|
|
} label: {
|
|
Label("Share", systemImage: "square.and.arrow.up")
|
|
}
|
|
Button {
|
|
UIPasteboard.general.string = qi.text
|
|
} label: {
|
|
Label("Copy", systemImage: "doc.on.doc")
|
|
}
|
|
}
|
|
}
|
|
Text(localTimestamp(qi.sentAt))
|
|
.foregroundStyle(.secondary)
|
|
.font(.caption)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
.frame(maxWidth: maxWidth, alignment: .leading)
|
|
}
|
|
|
|
func quotedMsgFrameColor(_ qi: CIQuote, _ theme: AppTheme) -> Color {
|
|
(qi.chatDir?.sent ?? false)
|
|
? theme.appColors.sentMessage
|
|
: theme.appColors.receivedMessage
|
|
}
|
|
|
|
private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
details()
|
|
Divider().padding(.vertical)
|
|
Text(local ? "Saved from" : "Forwarded from")
|
|
.font(.title2)
|
|
.padding(.bottom, 4)
|
|
forwardedFromView(forwardedFromItem)
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
|
|
private func forwardedFromView(_ forwardedFromItem: AChatItem) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Button {
|
|
Task {
|
|
await MainActor.run {
|
|
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
forwardedFromSender(forwardedFromItem)
|
|
}
|
|
|
|
if !local {
|
|
Divider().padding(.top, 32)
|
|
Text("Recipient(s) can't see who this message is from.")
|
|
.font(.caption)
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
|
|
HStack {
|
|
ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48)
|
|
.padding(.trailing, 6)
|
|
|
|
if forwardedFromItem.chatItem.chatDir.sent {
|
|
VStack(alignment: .leading) {
|
|
Text("you")
|
|
.italic()
|
|
.foregroundColor(theme.colors.onBackground)
|
|
NameWithBadge(
|
|
Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary),
|
|
forwardedFromItem.chatInfo.nameBadge
|
|
)
|
|
.lineLimit(1)
|
|
}
|
|
} else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
|
|
VStack(alignment: .leading) {
|
|
NameWithBadge(
|
|
Text(groupMember.chatViewName).foregroundColor(theme.colors.onBackground),
|
|
groupMember.nameBadge
|
|
)
|
|
.lineLimit(1)
|
|
NameWithBadge(
|
|
Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary),
|
|
forwardedFromItem.chatInfo.nameBadge
|
|
)
|
|
.lineLimit(1)
|
|
}
|
|
} else {
|
|
NameWithBadge(
|
|
Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.onBackground),
|
|
forwardedFromItem.chatInfo.nameBadge
|
|
)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
details()
|
|
Divider().padding(.vertical)
|
|
Text("Delivery")
|
|
.font(.title2)
|
|
.padding(.bottom, 4)
|
|
memberDeliveryStatusesView(memberDeliveryStatuses)
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
|
|
private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
|
LazyVStack(alignment: .leading, spacing: 12) {
|
|
let mss = membersStatuses(memberDeliveryStatuses)
|
|
if !mss.isEmpty {
|
|
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
|
memberDeliveryStatusView(memberStatus.0, memberStatus.1, memberStatus.2)
|
|
}
|
|
} else {
|
|
Text("No delivery information")
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] {
|
|
memberDeliveryStatuses.compactMap({ mds in
|
|
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
|
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
|
|
} else {
|
|
return nil
|
|
}
|
|
})
|
|
}
|
|
|
|
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
|
|
HStack{
|
|
MemberProfileImage(member, size: 30)
|
|
.padding(.trailing, 2)
|
|
NameWithBadge(Text(member.chatViewName), member.nameBadge)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
if sentViaProxy == true {
|
|
Image(systemName: "arrow.forward")
|
|
.foregroundColor(theme.colors.secondary).opacity(0.67)
|
|
}
|
|
let v = Group {
|
|
let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
|
|
image.foregroundColor(statusColor)
|
|
}
|
|
|
|
if let (title, text) = status.statusInfo {
|
|
v.onTapGesture {
|
|
alert = .alert(title: title, text: text)
|
|
}
|
|
} else {
|
|
v
|
|
}
|
|
}
|
|
}
|
|
|
|
private func itemInfoShareText() -> String {
|
|
let meta = ci.meta
|
|
var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""]
|
|
shareText += [String.localizedStringWithFormat(
|
|
ci.localNote
|
|
? NSLocalizedString("Created at: %@", comment: "copied message info")
|
|
: NSLocalizedString("Sent at: %@", comment: "copied message info"),
|
|
localTimestamp(meta.itemTs))
|
|
]
|
|
if !ci.chatDir.sent {
|
|
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
|
|
}
|
|
switch (ci.meta.itemDeleted) {
|
|
case let .deleted(deletedTs):
|
|
if let deletedTs = deletedTs {
|
|
shareText += [String.localizedStringWithFormat(NSLocalizedString("Deleted at: %@", comment: "copied message info"), localTimestamp(deletedTs))]
|
|
}
|
|
case let .moderated(deletedTs, _):
|
|
if let deletedTs = deletedTs {
|
|
shareText += [String.localizedStringWithFormat(NSLocalizedString("Moderated at: %@", comment: "copied message info"), localTimestamp(deletedTs))]
|
|
}
|
|
default: ()
|
|
}
|
|
if let deleteAt = meta.itemTimed?.deleteAt {
|
|
shareText += [String.localizedStringWithFormat(NSLocalizedString("Disappears at: %@", comment: "copied message info"), localTimestamp(deleteAt))]
|
|
}
|
|
if meta.msgVerified?.verified == true {
|
|
shareText += [ci.chatDir.sent
|
|
? NSLocalizedString("Signed", comment: "copied message info")
|
|
: NSLocalizedString("Signed & verified", comment: "copied message info")]
|
|
} else if meta.msgVerified == .sigMissing {
|
|
shareText += [NSLocalizedString("Signature missing", comment: "copied message info")]
|
|
}
|
|
if developerTools {
|
|
shareText += [
|
|
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
|
|
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt)),
|
|
String.localizedStringWithFormat(NSLocalizedString("Message status: %@", comment: "copied message info"), meta.itemStatus.id)
|
|
]
|
|
if let file = ci.file {
|
|
shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)]
|
|
}
|
|
}
|
|
if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty {
|
|
shareText += [String.localizedStringWithFormat(NSLocalizedString("File servers: %@", comment: "copied message info"), servers.map(serverHostname).joined(separator: ", "))]
|
|
}
|
|
if let qi = ci.quotedItem {
|
|
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
|
|
let t = qi.text
|
|
shareText += [""]
|
|
if let sender = qi.getSender(nil) {
|
|
shareText += [String.localizedStringWithFormat(
|
|
NSLocalizedString("%@ at %@:", comment: "copied message info, <sender> at <time>"),
|
|
sender,
|
|
localTimestamp(qi.sentAt)
|
|
)]
|
|
} else {
|
|
shareText += [String.localizedStringWithFormat(
|
|
NSLocalizedString("%@:", comment: "copied message info"),
|
|
localTimestamp(qi.sentAt)
|
|
)]
|
|
}
|
|
shareText += [t != "" ? t : NSLocalizedString("no text", comment: "copied message info in history")]
|
|
}
|
|
if let chatItemInfo = chatItemInfo,
|
|
!chatItemInfo.itemVersions.isEmpty {
|
|
shareText += ["", NSLocalizedString("## History", comment: "copied message info")]
|
|
for (index, itemVersion) in chatItemInfo.itemVersions.enumerated() {
|
|
let t = itemVersion.msgContent.text
|
|
shareText += [
|
|
"",
|
|
String.localizedStringWithFormat(
|
|
index == 0 && ci.meta.itemDeleted == nil
|
|
? NSLocalizedString("%@ (current):", comment: "copied message info")
|
|
: NSLocalizedString("%@:", comment: "copied message info"),
|
|
localTimestamp(itemVersion.itemVersionTs)
|
|
),
|
|
t != "" ? t : NSLocalizedString("no text", comment: "copied message info in history")
|
|
]
|
|
}
|
|
}
|
|
return shareText.joined(separator: "\n")
|
|
}
|
|
}
|
|
|
|
func localTimestamp(_ date: Date) -> String {
|
|
let localDateFormatter = DateFormatter()
|
|
localDateFormatter.dateStyle = .medium
|
|
localDateFormatter.timeStyle = .medium
|
|
return localDateFormatter.string(from: date)
|
|
}
|
|
|
|
struct ChatItemInfoView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), userMemberId: Chat.sampleData.chatInfo.groupInfo?.membership.memberId, chatItemInfo: Binding.constant(nil))
|
|
}
|
|
}
|