ios: disappearing messages (#1614)

* ios: disappearing messages

* show ttl in meta if different

* mark messages as disappearing when read

* previews
This commit is contained in:
Evgeny Poberezkin 2022-12-21 12:59:45 +00:00 committed by GitHub
parent 36eba01ef4
commit e1740a8be4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 388 additions and 150 deletions

View file

@ -289,10 +289,7 @@ final class ChatModel: ObservableObject {
private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i
while j < reversedChatItems.count {
if case .rcvNew = reversedChatItems[j].meta.itemStatus {
reversedChatItems[j].meta.itemStatus = .rcvRead
reversedChatItems[j].viewTimestamp = .now
}
markChatItemRead_(j)
j += 1
}
}
@ -347,9 +344,19 @@ final class ChatModel: ObservableObject {
// update preview
decreaseUnreadCounter(cInfo)
// update current chat
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
reversedChatItems[j].meta.itemStatus = .rcvRead
reversedChatItems[j].viewTimestamp = .now
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
markChatItemRead_(i)
}
}
private func markChatItemRead_(_ i: Int) {
let meta = reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus {
reversedChatItems[i].meta.itemStatus = .rcvRead
reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
}
}
}
@ -513,4 +520,6 @@ final class Chat: ObservableObject, Identifiable {
var id: ChatId { get { chatInfo.id } }
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}

View file

@ -161,5 +161,6 @@ struct CIFileView_Previews: PreviewProvider {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
}
}

View file

@ -10,37 +10,20 @@ import SwiftUI
import SimpleXChat
struct CIMetaView: View {
@EnvironmentObject var chat: Chat
var chatItem: ChatItem
var metaColor = Color.secondary
var body: some View {
HStack(alignment: .center, spacing: 4) {
if !chatItem.isDeletedContent {
if chatItem.meta.itemEdited {
statusImage("pencil", metaColor, 9)
}
switch chatItem.meta.itemStatus {
case .sndSent:
statusImage("checkmark", metaColor)
case .sndErrorAuth:
statusImage("multiply", .red)
case .sndError:
statusImage("exclamationmark.triangle.fill", .yellow)
case .rcvNew:
statusImage("circlebadge.fill", Color.accentColor)
default: EmptyView()
if chatItem.isDeletedContent {
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
} else {
ciMetaText(chatItem.meta, chatTTL: chat.chatInfo.timedMessagesTTL, color: metaColor)
}
}
chatItem.timestampText
.font(.caption)
.foregroundColor(metaColor)
}
}
private func statusImage(_ systemName: String, _ color: Color, _ maxHeight: CGFloat = 8) -> some View {
Image(systemName: systemName)
private func statusImage(_ icon: String, _ color: Color, _ maxHeight: CGFloat = 8) -> some View {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(color)
@ -48,6 +31,31 @@ struct CIMetaView: View {
}
}
func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear) -> Text {
var r = Text("")
if meta.itemEdited {
r = r + statusIconText("pencil", color)
}
if meta.disappearing {
r = r + statusIconText("timer", color).font(.caption2)
let ttl = meta.itemTimed?.ttl
if ttl != chatTTL {
r = r + Text(TimedMessagesPreference.shortTtlText(ttl)).foregroundColor(color)
}
r = r + Text(" ")
}
if let (icon, color) = meta.statusIcon(color) {
r = r + statusIconText(icon, color) + Text(" ")
} else if !meta.disappearing {
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
}
return (r + meta.timestampText.foregroundColor(color)).font(.caption)
}
private func statusIconText(_ icon: String, _ color: Color) -> Text {
Text(Image(systemName: icon)).foregroundColor(color)
}
struct CIMetaView_Previews: PreviewProvider {
static var previews: some View {
Group {
@ -56,5 +64,6 @@ struct CIMetaView_Previews: PreviewProvider {
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
}
.previewLayout(.fixed(width: 360, height: 100))
.environmentObject(Chat.sampleData)
}
}

View file

@ -231,16 +231,12 @@ struct CIVoiceView_Previews: PreviewProvider {
playbackState: .playing,
playbackTime: TimeInterval(20)
)
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false))
.environmentObject(ChatModel())
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
}
}

View file

@ -61,16 +61,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
)
Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
.environmentObject(ChatModel())
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
}
}

View file

@ -15,7 +15,6 @@ private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1,
private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09)
struct FramedItemView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
var chatInfo: ChatInfo
var chatItem: ChatItem
@ -40,7 +39,7 @@ struct FramedItemView: View {
ciQuoteView(qi)
.onTapGesture {
if let proxy = scrollProxy,
let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
let ci = ChatModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
withAnimation {
proxy.scrollTo(ci.viewId, anchor: .bottom)
}

View file

@ -25,6 +25,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
}
struct MsgContentView: View {
@EnvironmentObject var chat: Chat
var text: String
var formattedText: [FormattedText]? = nil
var sender: String? = nil
@ -66,7 +67,7 @@ struct MsgContentView: View {
if mt.isLive {
v = v + typingIndicator(mt.recent)
}
v = v + reserveSpaceForMeta(mt.timestampText, mt.itemEdited)
v = v + reserveSpaceForMeta(mt)
}
return v
}
@ -78,10 +79,8 @@ struct MsgContentView: View {
.foregroundColor(.secondary)
}
private func reserveSpaceForMeta(_ mt: Text, _ edited: Bool) -> Text {
let reserve = rightToLeft ? "\n" : edited ? " " : " "
return (Text(reserve) + mt)
.font(.caption)
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL)
.foregroundColor(.clear)
}
}
@ -157,5 +156,6 @@ struct MsgContentView_Previews: PreviewProvider {
sender: chatItem.memberDisplayName,
meta: chatItem.meta
)
.environmentObject(Chat.sampleData)
}
}

View file

@ -62,10 +62,10 @@ struct ChatItemContentView<Content: View>: View {
case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView()
case .sndConnEvent: eventItemView()
case let .rcvChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
case let .sndChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
case let .rcvGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
case let .sndGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
case let .rcvChatFeature(feature, enabled, param): chatFeatureView(feature, enabled.iconColor, param)
case let .sndChatFeature(feature, enabled, param): chatFeatureView(feature, enabled.iconColor, param)
case let .rcvGroupFeature(feature, preference, param): chatFeatureView(feature, preference.enable.iconColor, param)
case let .sndGroupFeature(feature, preference, param): chatFeatureView(feature, preference.enable.iconColor, param)
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
}
@ -87,7 +87,7 @@ struct ChatItemContentView<Content: View>: View {
CIEventView(chatItem: chatItem)
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
private func chatFeatureView(_ feature: Feature, _ iconColor: Color, _ param: Int? = nil) -> some View {
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor)
}
}
@ -106,12 +106,13 @@ struct ChatItemView_Previews: PreviewProvider {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, false, false, true), revealed: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
}
}
struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
static var previews: some View {
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false))
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil)
Group{
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
@ -159,5 +160,6 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
)
}
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
}
}

View file

@ -396,7 +396,6 @@ struct ChatView: View {
.frame(width: memberImageSize, height: memberImageSize)
}
ChatItemWithMenu(
chat: chat,
ci: ci,
showMember: showMember,
maxWidth: maxWidth,
@ -405,13 +404,14 @@ struct ChatView: View {
deletingItem: $deletingItem,
composeState: $composeState,
showDeleteMessage: $showDeleteMessage
).padding(.leading, 8)
)
.padding(.leading, 8)
.environmentObject(chat)
}
.padding(.trailing)
.padding(.leading, 12)
} else {
ChatItemWithMenu(
chat: chat,
ci: ci,
maxWidth: maxWidth,
scrollProxy: scrollProxy,
@ -419,12 +419,14 @@ struct ChatView: View {
deletingItem: $deletingItem,
composeState: $composeState,
showDeleteMessage: $showDeleteMessage
).padding(.horizontal)
)
.padding(.horizontal)
.environmentObject(chat)
}
}
private struct ChatItemWithMenu: View {
var chat: Chat
@EnvironmentObject var chat: Chat
var ci: ChatItem
var showMember: Bool = false
var maxWidth: CGFloat

View file

@ -642,7 +642,7 @@ struct ComposeView: View {
Task {
do {
var prefs = contactUserPreferencesToPreferences(contact.mergedPreferences)
prefs.voice = Preference(allow: .yes)
prefs.voice = SimplePreference(allow: .yes)
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
await MainActor.run {
chatModel.updateContact(toContact)

View file

@ -22,6 +22,7 @@ struct ContactPreferencesView: View {
VStack {
List {
timedMessagesFeatureSection()
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
@ -48,10 +49,10 @@ struct ContactPreferencesView: View {
}
}
private func featureSection(_ feature: ChatFeature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
private func featureSection(_ feature: ChatFeature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference<SimplePreference>, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
let enabled = FeatureEnabled.enabled(
asymmetric: feature.asymmetric,
user: Preference(allow: allowFeature.wrappedValue.allowed),
user: SimplePreference(allow: allowFeature.wrappedValue.allowed),
contact: pref.contactPreference
)
return Section {
@ -62,17 +63,52 @@ struct ContactPreferencesView: View {
}
.frame(height: 36)
infoRow("Contact allows", pref.contactPreference.allow.text)
} header: {
}
header: { featureHeader(feature, enabled) }
footer: { featureFooter(feature, enabled) }
}
private func timedMessagesFeatureSection() -> some View {
let pref = contact.mergedPreferences.timedMessages
let enabled = FeatureEnabled.enabled(
asymmetric: ChatFeature.timedMessages.asymmetric,
user: TimedMessagesPreference(allow: featuresAllowed.timedMessagesAllowed ? .yes : .no),
contact: pref.contactPreference
)
return Section {
Toggle("You allow", isOn: $featuresAllowed.timedMessagesAllowed)
.onChange(of: featuresAllowed.timedMessagesAllowed) { allow in
if allow {
if featuresAllowed.timedMessagesTTL == nil {
featuresAllowed.timedMessagesTTL = 86400
}
} else {
featuresAllowed.timedMessagesTTL = currentFeaturesAllowed.timedMessagesTTL
}
}
infoRow("Contact allows", pref.contactPreference.allow.text)
if featuresAllowed.timedMessagesAllowed {
timedMessagesTTLPicker($featuresAllowed.timedMessagesTTL)
} else if pref.contactPreference.allow == .yes || pref.contactPreference.allow == .always {
infoRow("Delete after", TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
}
}
header: { featureHeader(.timedMessages, enabled) }
footer: { featureFooter(.timedMessages, enabled) }
}
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
HStack {
Image(systemName: "\(feature.icon).fill")
Image(systemName: feature.iconFilled)
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
Text(feature.text)
}
} footer: {
}
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
Text(feature.enabledDescription(enabled))
.frame(height: 36, alignment: .topLeading)
}
}
private func savePreferences() {
Task {
@ -92,6 +128,18 @@ struct ContactPreferencesView: View {
}
}
func timedMessagesTTLPicker(_ selection: Binding<Int?>) -> some View {
Picker("Delete after", selection: selection) {
let selectedTTL = selection.wrappedValue
let ttlValues = TimedMessagesPreference.ttlValues
let values = ttlValues + (ttlValues.contains(selectedTTL) ? [] : [selectedTTL])
ForEach(values, id: \.self) { ttl in
Text(TimedMessagesPreference.ttlText(ttl))
}
}
.frame(height: 36)
}
struct ContactPreferencesView_Previews: PreviewProvider {
static var previews: some View {
ContactPreferencesView(

View file

@ -22,6 +22,7 @@ struct GroupPreferencesView: View {
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
VStack {
List {
featureSection(.timedMessages, $preferences.timedMessages.enable)
featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.directMessages, $preferences.directMessages.enable)
featureSection(.voice, $preferences.voice.enable)
@ -35,6 +36,15 @@ struct GroupPreferencesView: View {
}
}
}
.onChange(of: preferences.timedMessages.enable) { enable in
if enable == .on {
if preferences.timedMessages.ttl == nil {
preferences.timedMessages.ttl = 86400
}
} else {
preferences.timedMessages.ttl = currentPreferences.timedMessages.ttl
}
}
.modifier(BackButton {
if currentPreferences == preferences {
dismiss()
@ -55,20 +65,25 @@ struct GroupPreferencesView: View {
Section {
let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
if (groupInfo.canEdit) {
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
if groupInfo.canEdit {
let enable = Binding(
get: { enableFeature.wrappedValue == .on },
set: { on, _ in enableFeature.wrappedValue = on ? .on : .off }
)
settingsRow(icon, color: color) {
Picker(feature.text, selection: enableFeature) {
ForEach(GroupFeatureEnabled.values) { enable in
Text(enable.text)
Toggle(feature.text, isOn: enable)
}
if timedOn {
timedMessagesTTLPicker($preferences.timedMessages.ttl)
}
.frame(height: 36)
}
}
else {
} else {
settingsRow(icon, color: color) {
infoRow(feature.text, enableFeature.wrappedValue.text)
}
if timedOn {
infoRow("Delete after", TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
}
}
} footer: {
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))

View file

@ -18,6 +18,7 @@ struct PreferencesView: View {
var body: some View {
VStack {
List {
timedMessagesFeatureSection($preferences.timedMessages.allow)
featureSection(.fullDelete, $preferences.fullDelete.allow)
featureSection(.voice, $preferences.voice.allow)
@ -40,10 +41,27 @@ struct PreferencesView: View {
}
.frame(height: 36)
}
} footer: {
Text(feature.allowDescription(allowFeature.wrappedValue))
.frame(height: 36, alignment: .topLeading)
}
footer: { featureFooter(feature, allowFeature) }
}
private func timedMessagesFeatureSection(_ allowFeature: Binding<FeatureAllowed>) -> some View {
Section {
let allow = Binding(
get: { allowFeature.wrappedValue == .always || allowFeature.wrappedValue == .yes },
set: { yes, _ in allowFeature.wrappedValue = yes ? .yes : .no }
)
settingsRow(ChatFeature.timedMessages.icon) {
Toggle(ChatFeature.timedMessages.text, isOn: allow)
}
}
footer: { featureFooter(.timedMessages, allowFeature) }
}
private func featureFooter(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
Text(ChatFeature.timedMessages.allowDescription(allowFeature.wrappedValue))
.frame(height: 36, alignment: .topLeading)
}
private func savePreferences() {

View file

@ -125,35 +125,39 @@ extension NamedChat {
public typealias ChatId = String
public struct FullPreferences: Decodable, Equatable {
public var timedMessages: Preference
public var fullDelete: Preference
public var voice: Preference
public var timedMessages: TimedMessagesPreference
public var fullDelete: SimplePreference
public var voice: SimplePreference
public init(timedMessages: Preference, fullDelete: Preference, voice: Preference) {
public init(timedMessages: TimedMessagesPreference, fullDelete: SimplePreference, voice: SimplePreference) {
self.timedMessages = timedMessages
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = FullPreferences(
timedMessages: Preference(allow: .no),
fullDelete: Preference(allow: .no),
voice: Preference(allow: .yes)
timedMessages: TimedMessagesPreference(allow: .no),
fullDelete: SimplePreference(allow: .no),
voice: SimplePreference(allow: .yes)
)
}
public struct Preferences: Codable {
public var timedMessages: Preference?
public var fullDelete: Preference?
public var voice: Preference?
public var timedMessages: TimedMessagesPreference?
public var fullDelete: SimplePreference?
public var voice: SimplePreference?
public init(timedMessages: Preference?, fullDelete: Preference?, voice: Preference?) {
public init(timedMessages: TimedMessagesPreference?, fullDelete: SimplePreference?, voice: SimplePreference?) {
self.timedMessages = timedMessages
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = Preferences(timedMessages: Preference(allow: .no), fullDelete: Preference(allow: .no), voice: Preference(allow: .yes))
public static let sampleData = Preferences(
timedMessages: TimedMessagesPreference(allow: .no),
fullDelete: SimplePreference(allow: .no),
voice: SimplePreference(allow: .yes)
)
}
public func fullPreferencesToPreferences(_ fullPreferences: FullPreferences) -> Preferences {
@ -172,7 +176,11 @@ public func contactUserPreferencesToPreferences(_ contactUserPreferences: Contac
)
}
public struct Preference: Codable, Equatable {
public protocol Preference: Codable, Equatable {
var allow: FeatureAllowed { get set }
}
public struct SimplePreference: Preference {
public var allow: FeatureAllowed
public init(allow: FeatureAllowed) {
@ -180,42 +188,96 @@ public struct Preference: Codable, Equatable {
}
}
public struct ContactUserPreferences: Decodable {
public var timedMessages: ContactUserPreference
public var fullDelete: ContactUserPreference
public var voice: ContactUserPreference
public struct TimedMessagesPreference: Preference {
public var allow: FeatureAllowed
public var ttl: Int?
public init(timedMessages: ContactUserPreference, fullDelete: ContactUserPreference, voice: ContactUserPreference) {
public init(allow: FeatureAllowed, ttl: Int? = nil) {
self.allow = allow
self.ttl = ttl
}
public static var ttlValues: [Int?] {
[30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400]
}
public static func ttlText(_ ttl: Int?) -> String {
guard let ttl = ttl else { return "off" }
if ttl == 0 { return "0 sec" }
let (m_, s) = divMod(ttl, by: 60)
let (h_, m) = divMod(m_, by: 60)
let (d_, h) = divMod(h_, by: 24)
let (mm, d) = divMod(d_, by: 30)
return maybe(mm, mm == 1 ? "1 month" : "\(mm) months")
+ maybe(d, d == 1 ? "1 day" : d == 7 ? "1 week" : d == 14 ? "2 weeks" : "\(d) days")
+ maybe(h, h == 1 ? "1 hour" : "\(h) hours")
+ maybe(m, "\(m) min")
+ maybe (s, "\(s) sec")
}
public static func shortTtlText(_ ttl: Int?) -> String {
guard let ttl = ttl else { return "off" }
let m = ttl / 60
if m == 0 { return "\(ttl)s" }
let h = m / 60
if h == 0 { return "\(m)m" }
let d = h / 24
if d == 0 { return "\(h)h" }
let mm = d / 30
if mm > 0 { return "\(mm)mth" }
let w = d / 7
return w == 0 || d % 7 != 0 ? "\(d)d" : "\(w)w"
}
static func divMod(_ n: Int, by d: Int) -> (Int, Int) {
(n / d, n % d)
}
static func maybe(_ n: Int, _ s: String) -> String {
n == 0 ? "" : s
}
}
public struct ContactUserPreferences: Decodable {
public var timedMessages: ContactUserPreference<TimedMessagesPreference>
public var fullDelete: ContactUserPreference<SimplePreference>
public var voice: ContactUserPreference<SimplePreference>
public init(
timedMessages: ContactUserPreference<TimedMessagesPreference>,
fullDelete: ContactUserPreference<SimplePreference>,
voice: ContactUserPreference<SimplePreference>
) {
self.timedMessages = timedMessages
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = ContactUserPreferences(
timedMessages: ContactUserPreference(
timedMessages: ContactUserPreference<TimedMessagesPreference>(
enabled: FeatureEnabled(forUser: false, forContact: false),
userPreference: .user(preference: Preference(allow: .no)),
contactPreference: Preference(allow: .no)
userPreference: ContactUserPref<TimedMessagesPreference>.user(preference: TimedMessagesPreference(allow: .no)),
contactPreference: TimedMessagesPreference(allow: .no)
),
fullDelete: ContactUserPreference(
fullDelete: ContactUserPreference<SimplePreference>(
enabled: FeatureEnabled(forUser: false, forContact: false),
userPreference: .user(preference: Preference(allow: .no)),
contactPreference: Preference(allow: .no)
userPreference: ContactUserPref<SimplePreference>.user(preference: SimplePreference(allow: .no)),
contactPreference: SimplePreference(allow: .no)
),
voice: ContactUserPreference(
voice: ContactUserPreference<SimplePreference>(
enabled: FeatureEnabled(forUser: true, forContact: true),
userPreference: .user(preference: Preference(allow: .yes)),
contactPreference: Preference(allow: .yes)
userPreference: ContactUserPref<SimplePreference>.user(preference: SimplePreference(allow: .yes)),
contactPreference: SimplePreference(allow: .yes)
)
)
}
public struct ContactUserPreference: Decodable {
public struct ContactUserPreference<P: Preference>: Decodable {
public var enabled: FeatureEnabled
public var userPreference: ContactUserPref
public var contactPreference: Preference
public var userPreference: ContactUserPref<P>
public var contactPreference: P
public init(enabled: FeatureEnabled, userPreference: ContactUserPref, contactPreference: Preference) {
public init(enabled: FeatureEnabled, userPreference: ContactUserPref<P>, contactPreference: P) {
self.enabled = enabled
self.userPreference = userPreference
self.contactPreference = contactPreference
@ -231,7 +293,7 @@ public struct FeatureEnabled: Decodable {
self.forContact = forContact
}
public static func enabled(asymmetric: Bool, user: Preference, contact: Preference) -> FeatureEnabled {
public static func enabled(asymmetric: Bool, user: any Preference, contact: any Preference) -> FeatureEnabled {
switch (user.allow, contact.allow) {
case (.always, .no): return FeatureEnabled(forUser: false, forContact: asymmetric)
case (.no, .always): return FeatureEnabled(forUser: asymmetric, forContact: false)
@ -253,20 +315,29 @@ public struct FeatureEnabled: Decodable {
}
}
public enum ContactUserPref: Decodable {
case contact(preference: Preference) // contact override is set
case user(preference: Preference) // global user default is used
public enum ContactUserPref<P: Preference>: Decodable {
case contact(preference: P) // contact override is set
case user(preference: P) // global user default is used
public var preference: Preference {
public var preference: P {
switch self {
case let .contact(preference): return preference
case let .user(preference): return preference
}
}
var contactOverride: P? {
switch self {
case let .contact(preference): return preference
case .user: return nil
}
}
}
public protocol Feature {
var iconFilled: String { get }
var hasParam: Bool { get }
var text: String { get }
}
public enum ChatFeature: String, Decodable, Feature {
@ -285,6 +356,13 @@ public enum ChatFeature: String, Decodable, Feature {
}
}
public var hasParam: Bool {
switch self {
case .timedMessages: return true
default: return false
}
}
public var text: String {
switch self {
case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature")
@ -372,6 +450,13 @@ public enum GroupFeature: String, Decodable, Feature {
public var id: Self { self }
public var hasParam: Bool {
switch self {
case .timedMessages: return true
default: return false
}
}
public var text: String {
switch self {
case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature")
@ -482,32 +567,38 @@ public enum ContactFeatureAllowed: Identifiable, Hashable {
}
public struct ContactFeaturesAllowed: Equatable {
public var timedMessages: ContactFeatureAllowed
public var timedMessagesAllowed: Bool
public var timedMessagesTTL: Int?
public var fullDelete: ContactFeatureAllowed
public var voice: ContactFeatureAllowed
public init(timedMessages: ContactFeatureAllowed, fullDelete: ContactFeatureAllowed, voice: ContactFeatureAllowed) {
self.timedMessages = timedMessages
public init(timedMessagesAllowed: Bool, timedMessagesTTL: Int?, fullDelete: ContactFeatureAllowed, voice: ContactFeatureAllowed) {
self.timedMessagesAllowed = timedMessagesAllowed
self.timedMessagesTTL = timedMessagesTTL
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = ContactFeaturesAllowed(
timedMessages: ContactFeatureAllowed.userDefault(.no),
timedMessagesAllowed: false,
timedMessagesTTL: nil,
fullDelete: ContactFeatureAllowed.userDefault(.no),
voice: ContactFeatureAllowed.userDefault(.yes)
)
}
public func contactUserPrefsToFeaturesAllowed(_ contactUserPreferences: ContactUserPreferences) -> ContactFeaturesAllowed {
ContactFeaturesAllowed(
timedMessages: contactUserPrefToFeatureAllowed(contactUserPreferences.timedMessages),
let pref = contactUserPreferences.timedMessages.userPreference
let allow = pref.contactOverride?.allow
return ContactFeaturesAllowed(
timedMessagesAllowed: allow == .yes || allow == .always,
timedMessagesTTL: pref.preference.ttl,
fullDelete: contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
voice: contactUserPrefToFeatureAllowed(contactUserPreferences.voice)
)
}
public func contactUserPrefToFeatureAllowed(_ contactUserPreference: ContactUserPreference) -> ContactFeatureAllowed {
public func contactUserPrefToFeatureAllowed(_ contactUserPreference: ContactUserPreference<SimplePreference>) -> ContactFeatureAllowed {
switch contactUserPreference.userPreference {
case let .user(preference): return .userDefault(preference.allow)
case let .contact(preference):
@ -521,18 +612,18 @@ public func contactUserPrefToFeatureAllowed(_ contactUserPreference: ContactUser
public func contactFeaturesAllowedToPrefs(_ contactFeaturesAllowed: ContactFeaturesAllowed) -> Preferences {
Preferences(
timedMessages: contactFeatureAllowedToPref(contactFeaturesAllowed.timedMessages),
timedMessages: TimedMessagesPreference(allow: contactFeaturesAllowed.timedMessagesAllowed ? .yes : .no, ttl: contactFeaturesAllowed.timedMessagesTTL),
fullDelete: contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
voice: contactFeatureAllowedToPref(contactFeaturesAllowed.voice)
)
}
public func contactFeatureAllowedToPref(_ contactFeatureAllowed: ContactFeatureAllowed) -> Preference? {
public func contactFeatureAllowedToPref(_ contactFeatureAllowed: ContactFeatureAllowed) -> SimplePreference? {
switch contactFeatureAllowed {
case .userDefault: return nil
case .always: return Preference(allow: .always)
case .yes: return Preference(allow: .yes)
case .no: return Preference(allow: .no)
case .always: return SimplePreference(allow: .always)
case .yes: return SimplePreference(allow: .yes)
case .no: return SimplePreference(allow: .no)
}
}
@ -555,12 +646,12 @@ public enum FeatureAllowed: String, Codable, Identifiable {
}
public struct FullGroupPreferences: Decodable, Equatable {
public var timedMessages: GroupPreference
public var timedMessages: TimedMessagesGroupPreference
public var directMessages: GroupPreference
public var fullDelete: GroupPreference
public var voice: GroupPreference
public init(timedMessages: GroupPreference, directMessages: GroupPreference, fullDelete: GroupPreference, voice: GroupPreference) {
public init(timedMessages: TimedMessagesGroupPreference, directMessages: GroupPreference, fullDelete: GroupPreference, voice: GroupPreference) {
self.timedMessages = timedMessages
self.directMessages = directMessages
self.fullDelete = fullDelete
@ -568,7 +659,7 @@ public struct FullGroupPreferences: Decodable, Equatable {
}
public static let sampleData = FullGroupPreferences(
timedMessages: GroupPreference(enable: .off),
timedMessages: TimedMessagesGroupPreference(enable: .off),
directMessages: GroupPreference(enable: .off),
fullDelete: GroupPreference(enable: .off),
voice: GroupPreference(enable: .on)
@ -576,12 +667,12 @@ public struct FullGroupPreferences: Decodable, Equatable {
}
public struct GroupPreferences: Codable {
public var timedMessages: GroupPreference?
public var timedMessages: TimedMessagesGroupPreference?
public var directMessages: GroupPreference?
public var fullDelete: GroupPreference?
public var voice: GroupPreference?
public init(timedMessages: GroupPreference?, directMessages: GroupPreference?, fullDelete: GroupPreference?, voice: GroupPreference?) {
public init(timedMessages: TimedMessagesGroupPreference?, directMessages: GroupPreference?, fullDelete: GroupPreference?, voice: GroupPreference?) {
self.timedMessages = timedMessages
self.directMessages = directMessages
self.fullDelete = fullDelete
@ -589,7 +680,7 @@ public struct GroupPreferences: Codable {
}
public static let sampleData = GroupPreferences(
timedMessages: GroupPreference(enable: .off),
timedMessages: TimedMessagesGroupPreference(enable: .off),
directMessages: GroupPreference(enable: .off),
fullDelete: GroupPreference(enable: .off),
voice: GroupPreference(enable: .on)
@ -617,6 +708,20 @@ public struct GroupPreference: Codable, Equatable {
}
}
public struct TimedMessagesGroupPreference: Codable, Equatable {
public var enable: GroupFeatureEnabled
public var ttl: Int?
public var on: Bool {
enable == .on
}
public init(enable: GroupFeatureEnabled, ttl: Int? = nil) {
self.enable = enable
self.ttl = ttl
}
}
public enum GroupFeatureEnabled: String, Codable, Identifiable {
case on
case off
@ -790,6 +895,19 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
public var timedMessagesTTL: Int? {
switch self {
case let .direct(contact):
let pref = contact.mergedPreferences.timedMessages
return pref.enabled.forUser ? pref.userPreference.preference.ttl : nil
case let .group(groupInfo):
let pref = groupInfo.fullGroupPreferences.timedMessages
return pref.on ? pref.ttl : nil
default:
return nil
}
}
public enum ShowEnableVoiceMessagesAlert {
case userEnable
case askContact
@ -1476,10 +1594,7 @@ public struct ChatItem: Identifiable, Decodable {
}
}
public var isRcvNew: Bool {
if case .rcvNew = meta.itemStatus { return true }
return false
}
public var isRcvNew: Bool { meta.isRcvNew }
public var isDeletedContent: Bool {
switch content {
@ -1618,7 +1733,7 @@ public struct ChatItem: Identifiable, Decodable {
}
public static func getChatFeatureSample(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> ChatItem {
let content = CIContent.rcvChatFeature(feature: feature, enabled: enabled)
let content = CIContent.rcvChatFeature(feature: feature, enabled: enabled, param: nil)
return ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, content.text, .rcvRead, false, false, false),
@ -1677,12 +1792,29 @@ public struct CIMeta: Decodable {
public var updatedAt: Date
public var itemDeleted: Bool
public var itemEdited: Bool
public var itemTimed: CITimed?
public var itemLive: Bool?
public var editable: Bool
public var timestampText: Text { get { formatTimestampText(itemTs) } }
public var recent: Bool { updatedAt + 10 > .now }
public var isLive: Bool { itemLive == true }
public var disappearing: Bool { !isRcvNew && itemTimed?.deleteAt != nil }
public var isRcvNew: Bool {
if case .rcvNew = itemStatus { return true }
return false
}
public func statusIcon(_ metaColor: Color = .secondary) -> (String, Color)? {
switch itemStatus {
case .sndSent: return ("checkmark", metaColor)
case .sndErrorAuth: return ("multiply", .red)
case .sndError: return ("exclamationmark.triangle.fill", .yellow)
case .rcvNew: return ("circlebadge.fill", Color.accentColor)
default: return nil
}
}
public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ itemLive: Bool = false, _ editable: Bool = true) -> CIMeta {
CIMeta(
@ -1700,6 +1832,11 @@ public struct CIMeta: Decodable {
}
}
public struct CITimed: Decodable {
public var ttl: Int
public var deleteAt: Date?
}
let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute()
let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits)
@ -1753,10 +1890,10 @@ public enum CIContent: Decodable, ItemContent {
case sndGroupEvent(sndGroupEvent: SndGroupEvent)
case rcvConnEvent(rcvConnEvent: RcvConnEvent)
case sndConnEvent(sndConnEvent: SndConnEvent)
case rcvChatFeature(feature: ChatFeature, enabled: FeatureEnabled)
case sndChatFeature(feature: ChatFeature, enabled: FeatureEnabled)
case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference)
case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference)
case rcvChatFeature(feature: ChatFeature, enabled: FeatureEnabled, param: Int?)
case sndChatFeature(feature: ChatFeature, enabled: FeatureEnabled, param: Int?)
case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?)
case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?)
case rcvChatFeatureRejected(feature: ChatFeature)
case rcvGroupFeatureRejected(groupFeature: GroupFeature)
@ -1776,16 +1913,22 @@ public enum CIContent: Decodable, ItemContent {
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
case let .sndConnEvent(sndConnEvent): return sndConnEvent.text
case let .rcvChatFeature(feature, enabled): return "\(feature.text): \(enabled.text)"
case let .sndChatFeature(feature, enabled): return "\(feature.text): \(enabled.text)"
case let .rcvGroupFeature(feature, preference): return "\(feature.text): \(preference.enable.text)"
case let .sndGroupFeature(feature, preference): return "\(feature.text): \(preference.enable.text)"
case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .rcvGroupFeature(feature, preference, param): return CIContent.featureText(feature, preference.enable.text, param)
case let .sndGroupFeature(feature, preference, param): return CIContent.featureText(feature, preference.enable.text, param)
case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text)
case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text)
}
}
}
static func featureText(_ feature: Feature, _ value: String, _ param: Int?) -> String {
feature.hasParam && param != nil
? "\(feature.text): \(TimedMessagesPreference.ttlText(param))"
: "\(feature.text): \(value)"
}
public var msgContent: MsgContent? {
get {
switch self {