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 {
@Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false
@@ -428,19 +436,17 @@ final class ChatModel: ObservableObject {
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) {
withConditionalAnimation {
_updateChatItem(at: i, with: cItem)
}
_updateChatItem(at: i, with: cItem)
ChatItemDummyModel.shared.sendUpdate()
return false
} else {
withConditionalAnimation(itemAnimation()) {
var ci = cItem
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
}
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true
var ci = cItem
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
}
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true
ChatItemDummyModel.shared.sendUpdate()
return true
}
@@ -12,6 +12,7 @@ import SimpleXChat
struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp
@ObservedObject var chat: Chat
var chatItem: ChatItem
var groupInvitation: CIGroupInvitation
@@ -45,7 +46,7 @@ struct CIGroupInvitationView: View {
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
.font(.callout)
+ 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())
}
@@ -53,7 +54,7 @@ struct CIGroupInvitationView: View {
(
groupInvitationText()
+ 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())
}
@@ -69,6 +70,7 @@ struct CIGroupInvitationView: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.modifier(ChatTailPadding(chatItem: chatItem))
.background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
@@ -12,6 +12,7 @@ import SimpleXChat
struct CIMetaView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp
var chatItem: ChatItem
var metaColor: Color
var paleMetaColor = Color(UIColor.tertiaryLabel)
@@ -30,24 +31,24 @@ struct CIMetaView: View {
switch meta.itemStatus {
case let .sndSent(sndProgress):
switch sndProgress {
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, 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)
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, showTimesamp: showTimestamp)
}
case let .sndRcvd(_, sndProgress):
switch sndProgress {
case .complete:
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: .rcvd2, 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, showTimesamp: showTimestamp)
}
case .partial:
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: .rcvd2, 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, showTimesamp: showTimestamp)
}
}
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,
showStatus: Bool = true,
showEdited: Bool = true,
showViaProxy: Bool
showViaProxy: Bool,
showTimesamp: Bool
) -> Text {
var r = Text("")
if showEdited, meta.itemEdited {
@@ -105,7 +107,9 @@ func ciMetaText(
if let enc = encrypted {
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)
}
@@ -15,6 +15,7 @@ struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp
var msgDecryptError: MsgDecryptError
var msgCount: UInt32
var chatItem: ChatItem
@@ -68,37 +69,41 @@ struct CIRcvDecryptionError: View {
}
@ViewBuilder private func viewBody() -> some View {
if case let .direct(contact) = chat.chatInfo,
let contactStats = contact.activeConn?.connectionStats {
if contactStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncContactConnection(contact) }
Group {
if case let .direct(contact) = chat.chatInfo,
let contactStats = contact.activeConn?.connectionStats {
if contactStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncContactConnection(contact) }
}
} else if !contactStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedContactAlert
}
} else {
basicDecryptionErrorItem()
}
} else if !contactStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedContactAlert
} 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()
}
} 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 {
@@ -122,7 +127,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout)
+ 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)
@@ -131,7 +136,6 @@ struct CIRcvDecryptionError: View {
}
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
}
@@ -142,7 +146,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(.red)
.italic()
+ 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)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
@@ -150,7 +154,6 @@ struct CIRcvDecryptionError: View {
}
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
}
@@ -72,6 +72,7 @@ struct FramedItemView: View {
.accessibilityLabel("")
}
}
.modifier(ChatTailPadding(chatItem: chatItem))
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme))
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
@@ -69,6 +69,7 @@ struct CIMsgError: View {
}
.padding(.leading, 12)
.padding(.vertical, 6)
.modifier(ChatTailPadding(chatItem: chatItem))
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
.onTapGesture(perform: onTap)
@@ -22,6 +22,7 @@ struct MarkedDeletedItemView: View {
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.modifier(ChatTailPadding(chatItem: chatItem))
.background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled)
}
@@ -26,6 +26,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
struct MsgContentView: View {
@ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp
@EnvironmentObject var theme: AppTheme
var text: String
var formattedText: [FormattedText]? = nil
@@ -84,7 +85,7 @@ struct MsgContentView: View {
}
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 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 {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp
var chatItem: ChatItem
var maxWidth: CGFloat = .infinity
@Binding var revealed: Bool
+46 -21
View File
@@ -696,6 +696,7 @@ struct ChatView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding @ObservedObject var chat: Chat
@ObservedObject var dummyModel: ChatItemDummyModel = .shared
let chatItem: ChatItem
let maxWidth: CGFloat
@Binding var composeState: ComposeState
@@ -716,31 +717,53 @@ struct ChatView: View {
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 {
let (currIndex, _) = m.getNextChatItem(chatItem)
let ciCategory = chatItem.mergeCategory
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden)
let timeSeparation = TimeSeparation(for: chatItem, at: currIndex)
let im = ItemsModel.shared
Group {
if revealed, let range = range {
let items = Array(zip(Array(range), im.reversedChatItems[range]))
ForEach(items.reversed(), id: \.1.viewId) { (i, ci) in
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
chatItemView(ci, nil, prev)
.overlay {
if let selected = selectedChatItems, ci.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(ci.id)
selectUnselectChatItem(select: !checked, ci)
VStack(spacing: 0) {
ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
chatItemView(ci, nil, prev, TimeSeparation(for: ci, at: i))
.overlay {
if let selected = selectedChatItems, ci.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(ci.id)
selectUnselectChatItem(select: !checked, ci)
}
}
}
}
}
}
} else {
chatItemView(chatItem, range, prevItem)
chatItemView(chatItem, range, prevItem, timeSeparation)
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
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,
case let .group(groupInfo) = chat.chatInfo {
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(.leading, 12)
} else {
@@ -846,11 +870,11 @@ struct ChatView: View {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12)
}
chatItemWithMenu(ci, range, maxWidth)
chatItemWithMenu(ci, range, maxWidth, timeSeparation)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
}
.padding(.bottom, 5)
.padding(.bottom, bottomPadding)
}
} else {
HStack(alignment: .center, spacing: 0) {
@@ -863,10 +887,10 @@ struct ChatView: View {
.padding(.leading)
}
}
chatItemWithMenu(ci, range, maxWidth)
chatItemWithMenu(ci, range, maxWidth, timeSeparation)
.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
VStack(alignment: alignment.horizontal, spacing: 3) {
ChatItemView(
@@ -891,7 +915,8 @@ struct ChatView: View {
revealed: .constant(revealed),
allowMenu: $allowMenu
)
.modifier(ChatItemClipped(ci))
.environment(\.showTimestamp, timeSeparation.isTimestampShown)
.modifier(ChatItemClipped(ci, isTailVisible: timeSeparation.isGapLarge))
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
.accessibilityLabel("")
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)
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
struct ChatItemClipped: ViewModifier {
struct ClipShape: Shape {
let maxCornerRadius: Double
func path(in rect: CGRect) -> Path {
Path(
roundedRect: rect,
cornerRadius: min((rect.height / 2), maxCornerRadius),
style: .circular
)
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
@AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var isTailEnabled = true
let chatItem: ChatItem?
let isTailVisible: Bool
init() {
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() {
clipShape = ClipShape(
maxCornerRadius: 18
)
// Tail is hidden for images and video without any text
private static func hidesTail(_ msgContent: MsgContent?) -> Bool {
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 {
let shape = ChatBubble(roundness: roundness, shapePath: itemShape())
content
.contentShape(.dragPreview, clipShape)
.contentShape(.contextMenuPreview, clipShape)
.clipShape(clipShape)
.contentShape(.dragPreview, shape)
.contentShape(.contextMenuPreview, shape)
.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
@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(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)) {
HStack(spacing: 16) {
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))
HStack {
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
.modifier(ChatItemClipped())
.modifier(ChatItemClipped(alice, isTailVisible: true))
Spacer()
}
HStack {
Spacer()
ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
.modifier(ChatItemClipped())
.modifier(ChatItemClipped(bob, isTailVisible: true))
.frame(alignment: .trailing)
}
} 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_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration
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_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
@@ -98,6 +100,8 @@ let appDefaults: [String: Any] = [
DEFAULT_DEVELOPER_TOOLS: false,
DEFAULT_ENCRYPTION_STARTED: false,
DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness,
DEFAULT_CHAT_ITEM_TAIL: true,
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
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 defaultChatItemRoundness: Double = 0.8
let GROUP_DEFAULT_APP_STATE = "appState"
let GROUP_DEFAULT_NSE_STATE = "nseState"
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)
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 {