Compare commits

...

19 Commits

Author SHA1 Message Date
Levitating Pineapple 6ab5e7d02f tail toggle 2024-08-24 20:05:41 +03:00
Levitating Pineapple 29cc2cfb0c decryption error view padding 2024-08-24 17:30:49 +03:00
Levitating Pineapple 4826da0825 rename 2024-08-24 16:47:19 +03:00
Levitating Pineapple 4f9f2cecec ios: add tails and roundness slider to message bubbles 2024-08-24 16:31:44 +03:00
Levitating Pineapple a5c1be3b8f Merge branch 'master' into lp/time-grouped-messages 2024-08-23 19:07:05 +03:00
Levitating Pineapple f7cac769df cleanup 2024-08-23 17:19:22 +03:00
Levitating Pineapple a4b086ce7a cleanup 2024-08-23 17:18:30 +03:00
Levitating Pineapple 2a20afeda6 simplify timestamp logic; remove shape 2024-08-23 17:15:02 +03:00
Levitating Pineapple beefb1ba12 fix groups 2024-08-23 12:32:24 +03:00
Levitating Pineapple 6c1a821304 cleanup 2024-08-23 11:49:37 +03:00
Levitating Pineapple ec40c2d7e5 add date logic 2024-08-23 11:40:11 +03:00
Levitating Pineapple d6666b270f time separation struct 2024-08-23 11:20:31 +03:00
Levitating Pineapple 950b87b48f chat dir 2024-08-23 09:49:29 +03:00
Levitating Pineapple ebbafc12d4 separate by minute 2024-08-22 23:03:00 +03:00
Levitating Pineapple 8053d8fc39 round to minute 2024-08-22 22:42:29 +03:00
Levitating Pineapple d085a047fc fix chat item not getting updated 2024-08-22 22:32:01 +03:00
Levitating Pineapple 99d5a183c4 hide timestamp 2024-08-22 18:24:53 +03:00
Levitating Pineapple 8979306e68 cleanup 2024-08-22 16:37:54 +03:00
Levitating Pineapple e5a60b963e ios: time based message grouping 2024-08-22 16:31:54 +03:00
15 changed files with 284 additions and 117 deletions
+16 -10
View File
@@ -123,6 +123,14 @@ class NetworkModel: ObservableObject {
} }
} }
/// ChatItemWithMenu can depend on previous or next item for it's appearance
/// This dummy model is used to force an update of all chat items,
/// when they might have changed appearance.
class ChatItemDummyModel: ObservableObject {
static let shared = ChatItemDummyModel()
func sendUpdate() { objectWillChange.send() }
}
final class ChatModel: ObservableObject { final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage? @Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false @Published var setDeliveryReceipts = false
@@ -428,19 +436,17 @@ final class ChatModel: ObservableObject {
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) { if let i = getChatItemIndex(cItem) {
withConditionalAnimation { _updateChatItem(at: i, with: cItem)
_updateChatItem(at: i, with: cItem) ChatItemDummyModel.shared.sendUpdate()
}
return false return false
} else { } else {
withConditionalAnimation(itemAnimation()) { var ci = cItem
var ci = cItem if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { ci.meta.itemStatus = status
ci.meta.itemStatus = status
}
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true
} }
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true
ChatItemDummyModel.shared.sendUpdate()
return true return true
} }
@@ -12,6 +12,7 @@ import SimpleXChat
struct CIGroupInvitationView: View { struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var groupInvitation: CIGroupInvitation var groupInvitation: CIGroupInvitation
@@ -45,7 +46,7 @@ struct CIGroupInvitationView: View {
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary) .foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
.font(.callout) .font(.callout)
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
} }
@@ -53,7 +54,7 @@ struct CIGroupInvitationView: View {
( (
groupInvitationText() groupInvitationText()
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
} }
@@ -69,6 +70,7 @@ struct CIGroupInvitationView: View {
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.modifier(ChatTailPadding(chatItem: chatItem))
.background(chatItemFrameColor(chatItem, theme)) .background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled) .textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
@@ -12,6 +12,7 @@ import SimpleXChat
struct CIMetaView: View { struct CIMetaView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp
var chatItem: ChatItem var chatItem: ChatItem
var metaColor: Color var metaColor: Color
var paleMetaColor = Color(UIColor.tertiaryLabel) var paleMetaColor = Color(UIColor.tertiaryLabel)
@@ -30,24 +31,24 @@ struct CIMetaView: View {
switch meta.itemStatus { switch meta.itemStatus {
case let .sndSent(sndProgress): case let .sndSent(sndProgress):
switch sndProgress { switch sndProgress {
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
} }
case let .sndRcvd(_, sndProgress): case let .sndRcvd(_, sndProgress):
switch sndProgress { switch sndProgress {
case .complete: case .complete:
ZStack { ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
} }
case .partial: case .partial:
ZStack { ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
} }
} }
default: default:
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
} }
} }
} }
@@ -69,7 +70,8 @@ func ciMetaText(
sent: SentCheckmark? = nil, sent: SentCheckmark? = nil,
showStatus: Bool = true, showStatus: Bool = true,
showEdited: Bool = true, showEdited: Bool = true,
showViaProxy: Bool showViaProxy: Bool,
showTimesamp: Bool
) -> Text { ) -> Text {
var r = Text("") var r = Text("")
if showEdited, meta.itemEdited { if showEdited, meta.itemEdited {
@@ -105,7 +107,9 @@ func ciMetaText(
if let enc = encrypted { if let enc = encrypted {
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ") r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
} }
r = r + meta.timestampText.foregroundColor(color) if showTimesamp {
r = r + meta.timestampText.foregroundColor(color)
}
return r.font(.caption) return r.font(.caption)
} }
@@ -15,6 +15,7 @@ struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp
var msgDecryptError: MsgDecryptError var msgDecryptError: MsgDecryptError
var msgCount: UInt32 var msgCount: UInt32
var chatItem: ChatItem var chatItem: ChatItem
@@ -68,37 +69,41 @@ struct CIRcvDecryptionError: View {
} }
@ViewBuilder private func viewBody() -> some View { @ViewBuilder private func viewBody() -> some View {
if case let .direct(contact) = chat.chatInfo, Group {
let contactStats = contact.activeConn?.connectionStats { if case let .direct(contact) = chat.chatInfo,
if contactStats.ratchetSyncAllowed { let contactStats = contact.activeConn?.connectionStats {
decryptionErrorItemFixButton(syncSupported: true) { if contactStats.ratchetSyncAllowed {
alert = .syncAllowedAlert { syncContactConnection(contact) } decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncContactConnection(contact) }
}
} else if !contactStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedContactAlert
}
} else {
basicDecryptionErrorItem()
} }
} else if !contactStats.ratchetSyncSupported { } else if case let .group(groupInfo) = chat.chatInfo,
decryptionErrorItemFixButton(syncSupported: false) { case let .groupRcv(groupMember) = chatItem.chatDir,
alert = .syncNotSupportedContactAlert let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
} }
} else { } else {
basicDecryptionErrorItem() basicDecryptionErrorItem()
} }
} else if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir,
let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
}
} else {
basicDecryptionErrorItem()
} }
.modifier(ChatTailPadding(chatItem: chatItem))
.background(Color(uiColor: .tertiarySystemGroupedBackground))
} }
private func basicDecryptionErrorItem() -> some View { private func basicDecryptionErrorItem() -> some View {
@@ -122,7 +127,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout) .font(.callout)
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
) )
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -131,7 +136,6 @@ struct CIRcvDecryptionError: View {
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -142,7 +146,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
@@ -150,7 +154,6 @@ struct CIRcvDecryptionError: View {
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -72,6 +72,7 @@ struct FramedItemView: View {
.accessibilityLabel("") .accessibilityLabel("")
} }
} }
.modifier(ChatTailPadding(chatItem: chatItem))
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme)) .background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme))
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
@@ -69,6 +69,7 @@ struct CIMsgError: View {
} }
.padding(.leading, 12) .padding(.leading, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.modifier(ChatTailPadding(chatItem: chatItem))
.background(Color(uiColor: .tertiarySystemGroupedBackground)) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
.onTapGesture(perform: onTap) .onTapGesture(perform: onTap)
@@ -22,6 +22,7 @@ struct MarkedDeletedItemView: View {
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.modifier(ChatTailPadding(chatItem: chatItem))
.background(chatItemFrameColor(chatItem, theme)) .background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -26,6 +26,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
struct MsgContentView: View { struct MsgContentView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var text: String var text: String
var formattedText: [FormattedText]? = nil var formattedText: [FormattedText]? = nil
@@ -84,7 +85,7 @@ struct MsgContentView: View {
} }
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
} }
} }
@@ -9,9 +9,21 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
extension EnvironmentValues {
struct ShowTimestamp: EnvironmentKey {
static let defaultValue: Bool = true
}
var showTimestamp: Bool {
get { self[ShowTimestamp.self] }
set { self[ShowTimestamp.self] = newValue }
}
}
struct ChatItemView: View { struct ChatItemView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp
var chatItem: ChatItem var chatItem: ChatItem
var maxWidth: CGFloat = .infinity var maxWidth: CGFloat = .infinity
@Binding var revealed: Bool @Binding var revealed: Bool
+46 -21
View File
@@ -696,6 +696,7 @@ struct ChatView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding @ObservedObject var chat: Chat @Binding @ObservedObject var chat: Chat
@ObservedObject var dummyModel: ChatItemDummyModel = .shared
let chatItem: ChatItem let chatItem: ChatItem
let maxWidth: CGFloat let maxWidth: CGFloat
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@@ -716,31 +717,53 @@ struct ChatView: View {
var revealed: Bool { chatItem == revealedChatItem } var revealed: Bool { chatItem == revealedChatItem }
struct TimeSeparation {
let isTimestampShown: Bool
let isGapLarge: Bool
init(for chatItem: ChatItem, at index: Int?) {
let im = ItemsModel.shared
if let index, index > 0 && !im.reversedChatItems.isEmpty {
let nextItem = im.reversedChatItems[index - 1]
isGapLarge = nextItem.chatDir != chatItem.chatDir ||
nextItem.meta.createdAt.timeIntervalSince(chatItem.meta.createdAt) > 30
isTimestampShown = isGapLarge ||
formatTimestampText(chatItem.meta.createdAt) != formatTimestampText(nextItem.meta.createdAt)
} else {
isTimestampShown = true
isGapLarge = true
}
}
}
var body: some View { var body: some View {
let (currIndex, _) = m.getNextChatItem(chatItem) let (currIndex, _) = m.getNextChatItem(chatItem)
let ciCategory = chatItem.mergeCategory let ciCategory = chatItem.mergeCategory
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden) let range = itemsRange(currIndex, prevHidden)
let timeSeparation = TimeSeparation(for: chatItem, at: currIndex)
let im = ItemsModel.shared let im = ItemsModel.shared
Group { Group {
if revealed, let range = range { if revealed, let range = range {
let items = Array(zip(Array(range), im.reversedChatItems[range])) let items = Array(zip(Array(range), im.reversedChatItems[range]))
ForEach(items.reversed(), id: \.1.viewId) { (i, ci) in VStack(spacing: 0) {
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1] ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in
chatItemView(ci, nil, prev) let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
.overlay { chatItemView(ci, nil, prev, TimeSeparation(for: ci, at: i))
if let selected = selectedChatItems, ci.canBeDeletedForSelf { .overlay {
Color.clear if let selected = selectedChatItems, ci.canBeDeletedForSelf {
.contentShape(Rectangle()) Color.clear
.onTapGesture { .contentShape(Rectangle())
let checked = selected.contains(ci.id) .onTapGesture {
selectUnselectChatItem(select: !checked, ci) let checked = selected.contains(ci.id)
selectUnselectChatItem(select: !checked, ci)
}
} }
} }
} }
} }
} else { } else {
chatItemView(chatItem, range, prevItem) chatItemView(chatItem, range, prevItem, timeSeparation)
.overlay { .overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear Color.clear
@@ -791,7 +814,8 @@ struct ChatView: View {
} }
} }
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?) -> some View { @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?, _ timeSeparation: TimeSeparation) -> some View {
let bottomPadding: Double = timeSeparation.isGapLarge ? 6 : 2
if case let .groupRcv(member) = ci.chatDir, if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo { case let .group(groupInfo) = chat.chatInfo {
let (prevMember, memCount): (GroupMember?, Int) = let (prevMember, memCount): (GroupMember?, Int) =
@@ -833,11 +857,11 @@ struct ChatView: View {
} }
} }
} }
chatItemWithMenu(ci, range, maxWidth) chatItemWithMenu(ci, range, maxWidth, timeSeparation)
} }
} }
} }
.padding(.bottom, 5) .padding(.bottom, bottomPadding)
.padding(.trailing) .padding(.trailing)
.padding(.leading, 12) .padding(.leading, 12)
} else { } else {
@@ -846,11 +870,11 @@ struct ChatView: View {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12) .padding(.leading, 12)
} }
chatItemWithMenu(ci, range, maxWidth) chatItemWithMenu(ci, range, maxWidth, timeSeparation)
.padding(.trailing) .padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12) .padding(.leading, memberImageSize + 8 + 12)
} }
.padding(.bottom, 5) .padding(.bottom, bottomPadding)
} }
} else { } else {
HStack(alignment: .center, spacing: 0) { HStack(alignment: .center, spacing: 0) {
@@ -863,10 +887,10 @@ struct ChatView: View {
.padding(.leading) .padding(.leading)
} }
} }
chatItemWithMenu(ci, range, maxWidth) chatItemWithMenu(ci, range, maxWidth, timeSeparation)
.padding(.horizontal) .padding(.horizontal)
} }
.padding(.bottom, 5) .padding(.bottom, bottomPadding)
} }
} }
@@ -881,7 +905,7 @@ struct ChatView: View {
} }
} }
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat) -> some View { @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ timeSeparation: TimeSeparation) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
VStack(alignment: alignment.horizontal, spacing: 3) { VStack(alignment: alignment.horizontal, spacing: 3) {
ChatItemView( ChatItemView(
@@ -891,7 +915,8 @@ struct ChatView: View {
revealed: .constant(revealed), revealed: .constant(revealed),
allowMenu: $allowMenu allowMenu: $allowMenu
) )
.modifier(ChatItemClipped(ci)) .environment(\.showTimestamp, timeSeparation.isTimestampShown)
.modifier(ChatItemClipped(ci, isTailVisible: timeSeparation.isGapLarge))
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
.accessibilityLabel("") .accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
@@ -14,55 +14,150 @@ import SimpleXChat
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically) /// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius /// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
struct ChatItemClipped: ViewModifier { struct ChatItemClipped: ViewModifier {
struct ClipShape: Shape { @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
let maxCornerRadius: Double @AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var isTailEnabled = true
func path(in rect: CGRect) -> Path { let chatItem: ChatItem?
Path( let isTailVisible: Bool
roundedRect: rect,
cornerRadius: min((rect.height / 2), maxCornerRadius), init() {
style: .circular chatItem = nil
) isTailVisible = false
}
init(_ chatItem: ChatItem, isTailVisible: Bool) {
self.chatItem = chatItem
self.isTailVisible = isTailVisible
}
fileprivate func itemShape() -> ChatItemShape {
if let chatItem {
switch chatItem.content {
case
.sndMsgContent,
.rcvMsgContent,
.rcvDecryptionError,
.rcvGroupInvitation,
.sndGroupInvitation,
.sndDeleted,
.rcvDeleted,
.rcvIntegrityError,
.sndModerated,
.rcvModerated,
.rcvBlocked,
.invalidJSON: isTailEnabled
? .bubble(
padding: chatItem.chatDir.sent ? .trailing : .leading,
isTailVisible: Self.hidesTail(chatItem.content.msgContent)
? false
: isTailVisible
)
: .roundRect(maxRadius: ChatBubble.maxRadius)
default: .roundRect(maxRadius: 8)
}
} else {
.roundRect(maxRadius: 8)
} }
} }
init() { // Tail is hidden for images and video without any text
clipShape = ClipShape( private static func hidesTail(_ msgContent: MsgContent?) -> Bool {
maxCornerRadius: 18 if let msgContent, msgContent.isImageOrVideo && msgContent.text.isEmpty {
) true
} else {
false
}
} }
init(_ chatItem: ChatItem) {
clipShape = ClipShape(
maxCornerRadius: {
switch chatItem.content {
case
.sndMsgContent,
.rcvMsgContent,
.rcvDecryptionError,
.rcvGroupInvitation,
.sndGroupInvitation,
.sndDeleted,
.rcvDeleted,
.rcvIntegrityError,
.sndModerated,
.rcvModerated,
.rcvBlocked,
.invalidJSON: 18
default: 8
}
}()
)
}
private let clipShape: ClipShape
func body(content: Content) -> some View { func body(content: Content) -> some View {
let shape = ChatBubble(roundness: roundness, shapePath: itemShape())
content content
.contentShape(.dragPreview, clipShape) .contentShape(.dragPreview, shape)
.contentShape(.contextMenuPreview, clipShape) .contentShape(.contextMenuPreview, shape)
.clipShape(clipShape) .clipShape(shape)
} }
} }
struct ChatTailPadding: ViewModifier {
@AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var tailEnabled = true
let chatItem: ChatItem
func body(content: Content) -> some View {
if tailEnabled {
content.padding(
chatItem.chatDir.sent ? .trailing : .leading,
ChatBubble.tailSize
)
} else {
content
}
}
}
struct ChatBubble: Shape {
static let tailSize: Double = 8
static let maxRadius: Double = 16
fileprivate let roundness: Double
fileprivate let shapePath: ChatItemShape
func path(in rect: CGRect) -> Path {
switch shapePath {
case .bubble(let padding, let isTailVisible):
let rMax = min(Self.maxRadius, min(rect.width, rect.height) / 2)
let r = roundness * rMax
let tailHeight = rect.height - (Self.tailSize + (rMax - Self.tailSize) * roundness)
var path = Path()
path.addArc(
center: CGPoint(x: r + Self.tailSize, y: r),
radius: r,
startAngle: .degrees(270),
endAngle: .degrees(180),
clockwise: true
)
if isTailVisible {
path.addLine(
to: CGPoint(x: Self.tailSize, y: tailHeight)
)
path.addQuadCurve(
to: CGPoint(x: 0, y: rect.height),
control: CGPoint(x: Self.tailSize, y: tailHeight + r * 0.64)
)
} else {
path.addArc(
center: CGPoint(x: r + Self.tailSize, y: rect.height - r),
radius: r,
startAngle: .degrees(180),
endAngle: .degrees(90),
clockwise: true
)
}
path.addArc(
center: CGPoint(x: rect.width - r, y: rect.height - r),
radius: r,
startAngle: .degrees(90),
endAngle: .degrees(0),
clockwise: true
)
path.addArc(
center: CGPoint(x: rect.width - r, y: r),
radius: r,
startAngle: .degrees(0),
endAngle: .degrees(270),
clockwise: true
)
return switch padding {
case .leading: path
case .trailing: path
.scale(x: -1, y: 1, anchor: .center)
.path(in: rect)
}
case let .roundRect(radius):
return Path(roundedRect: rect, cornerRadius: radius * roundness)
}
}
}
fileprivate enum ChatItemShape {
case bubble(padding: HorizontalEdge, isTailVisible: Bool)
case roundRect(maxRadius: Double)
}
@@ -33,6 +33,8 @@ struct AppearanceSettings: View {
}() }()
@State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName @State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var chatItemRoundness = defaultChatItemRoundness
@AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var chatItemTail = true
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@@ -179,6 +181,14 @@ struct AppearanceSettings: View {
} }
} }
Section(header: Text("Messages").foregroundColor(theme.colors.secondary)) {
HStack {
Text("Roundness")
Slider(value: $chatItemRoundness, in: 0...1, step: 0.1)
}
Toggle("Tail", isOn: $chatItemTail)
}
Section(header: Text("Profile images").foregroundColor(theme.colors.secondary)) { Section(header: Text("Profile images").foregroundColor(theme.colors.secondary)) {
HStack(spacing: 16) { HStack(spacing: 16) {
if let img = m.currentUser?.image, img != "" { if let img = m.currentUser?.image, img != "" {
@@ -358,13 +368,13 @@ struct ChatThemePreview: View {
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir)) let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack { HStack {
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
.modifier(ChatItemClipped()) .modifier(ChatItemClipped(alice, isTailVisible: true))
Spacer() Spacer()
} }
HStack { HStack {
Spacer() Spacer()
ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
.modifier(ChatItemClipped()) .modifier(ChatItemClipped(bob, isTailVisible: true))
.frame(alignment: .trailing) .frame(alignment: .trailing)
} }
} else { } else {
@@ -47,6 +47,8 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration
let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius" let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
@@ -98,6 +100,8 @@ let appDefaults: [String: Any] = [
DEFAULT_DEVELOPER_TOOLS: false, DEFAULT_DEVELOPER_TOOLS: false,
DEFAULT_ENCRYPTION_STARTED: false, DEFAULT_ENCRYPTION_STARTED: false,
DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner, DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness,
DEFAULT_CHAT_ITEM_TAIL: true,
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
+2
View File
@@ -13,6 +13,8 @@ public let appSuspendTimeout: Int = 15 // seconds
public let defaultProfileImageCorner: Double = 22.5 public let defaultProfileImageCorner: Double = 22.5
public let defaultChatItemRoundness: Double = 0.8
let GROUP_DEFAULT_APP_STATE = "appState" let GROUP_DEFAULT_APP_STATE = "appState"
let GROUP_DEFAULT_NSE_STATE = "nseState" let GROUP_DEFAULT_NSE_STATE = "nseState"
let GROUP_DEFAULT_SE_STATE = "seState" let GROUP_DEFAULT_SE_STATE = "seState"
+1 -1
View File
@@ -2762,7 +2762,7 @@ let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute()
let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits)
public func formatTimestampText(_ date: Date) -> Text { public func formatTimestampText(_ date: Date) -> Text {
return Text(date, format: recent(date) ? msgTimeFormat : msgDateFormat) Text(verbatim: date.formatted(recent(date) ? msgTimeFormat : msgDateFormat))
} }
private func recent(_ date: Date) -> Bool { private func recent(_ date: Date) -> Bool {