mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-29 04:39:53 +00:00
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:
parent
36eba01ef4
commit
e1740a8be4
14 changed files with 388 additions and 150 deletions
|
@ -289,10 +289,7 @@ final class ChatModel: ObservableObject {
|
||||||
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
||||||
var j = i
|
var j = i
|
||||||
while j < reversedChatItems.count {
|
while j < reversedChatItems.count {
|
||||||
if case .rcvNew = reversedChatItems[j].meta.itemStatus {
|
markChatItemRead_(j)
|
||||||
reversedChatItems[j].meta.itemStatus = .rcvRead
|
|
||||||
reversedChatItems[j].viewTimestamp = .now
|
|
||||||
}
|
|
||||||
j += 1
|
j += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -347,9 +344,19 @@ final class ChatModel: ObservableObject {
|
||||||
// update preview
|
// update preview
|
||||||
decreaseUnreadCounter(cInfo)
|
decreaseUnreadCounter(cInfo)
|
||||||
// update current chat
|
// update current chat
|
||||||
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||||
reversedChatItems[j].meta.itemStatus = .rcvRead
|
markChatItemRead_(i)
|
||||||
reversedChatItems[j].viewTimestamp = .now
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 id: ChatId { get { chatInfo.id } }
|
||||||
|
|
||||||
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
||||||
|
|
||||||
|
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||||
}
|
}
|
||||||
|
|
|
@ -161,5 +161,6 @@ struct CIFileView_Previews: PreviewProvider {
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 360))
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
|
.environmentObject(Chat.sampleData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,37 +10,20 @@ import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct CIMetaView: View {
|
struct CIMetaView: View {
|
||||||
|
@EnvironmentObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var metaColor = Color.secondary
|
var metaColor = Color.secondary
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
if chatItem.isDeletedContent {
|
||||||
if !chatItem.isDeletedContent {
|
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
|
||||||
if chatItem.meta.itemEdited {
|
} else {
|
||||||
statusImage("pencil", metaColor, 9)
|
ciMetaText(chatItem.meta, chatTTL: chat.chatInfo.timedMessagesTTL, color: metaColor)
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chatItem.timestampText
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(metaColor)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func statusImage(_ systemName: String, _ color: Color, _ maxHeight: CGFloat = 8) -> some View {
|
private func statusImage(_ icon: String, _ color: Color, _ maxHeight: CGFloat = 8) -> some View {
|
||||||
Image(systemName: systemName)
|
Image(systemName: icon)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.foregroundColor(color)
|
.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 {
|
struct CIMetaView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
|
@ -56,5 +64,6 @@ struct CIMetaView_Previews: PreviewProvider {
|
||||||
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
|
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 100))
|
.previewLayout(.fixed(width: 360, height: 100))
|
||||||
|
.environmentObject(Chat.sampleData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -231,16 +231,12 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||||
playbackState: .playing,
|
playbackState: .playing,
|
||||||
playbackTime: TimeInterval(20)
|
playbackTime: TimeInterval(20)
|
||||||
)
|
)
|
||||||
.environmentObject(ChatModel())
|
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
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))
|
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))
|
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))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false))
|
||||||
.environmentObject(ChatModel())
|
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 360))
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
|
.environmentObject(Chat.sampleData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,16 +61,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||||
)
|
)
|
||||||
Group {
|
Group {
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
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))
|
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))
|
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))
|
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))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
|
||||||
.environmentObject(ChatModel())
|
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 360))
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
|
.environmentObject(Chat.sampleData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09)
|
||||||
|
|
||||||
struct FramedItemView: View {
|
struct FramedItemView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
var chatInfo: ChatInfo
|
var chatInfo: ChatInfo
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
@ -40,7 +39,7 @@ struct FramedItemView: View {
|
||||||
ciQuoteView(qi)
|
ciQuoteView(qi)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let proxy = scrollProxy,
|
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 {
|
withAnimation {
|
||||||
proxy.scrollTo(ci.viewId, anchor: .bottom)
|
proxy.scrollTo(ci.viewId, anchor: .bottom)
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MsgContentView: View {
|
struct MsgContentView: View {
|
||||||
|
@EnvironmentObject var chat: Chat
|
||||||
var text: String
|
var text: String
|
||||||
var formattedText: [FormattedText]? = nil
|
var formattedText: [FormattedText]? = nil
|
||||||
var sender: String? = nil
|
var sender: String? = nil
|
||||||
|
@ -66,7 +67,7 @@ struct MsgContentView: View {
|
||||||
if mt.isLive {
|
if mt.isLive {
|
||||||
v = v + typingIndicator(mt.recent)
|
v = v + typingIndicator(mt.recent)
|
||||||
}
|
}
|
||||||
v = v + reserveSpaceForMeta(mt.timestampText, mt.itemEdited)
|
v = v + reserveSpaceForMeta(mt)
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
@ -78,10 +79,8 @@ struct MsgContentView: View {
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reserveSpaceForMeta(_ mt: Text, _ edited: Bool) -> Text {
|
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||||
let reserve = rightToLeft ? "\n" : edited ? " " : " "
|
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL)
|
||||||
return (Text(reserve) + mt)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.clear)
|
.foregroundColor(.clear)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -157,5 +156,6 @@ struct MsgContentView_Previews: PreviewProvider {
|
||||||
sender: chatItem.memberDisplayName,
|
sender: chatItem.memberDisplayName,
|
||||||
meta: chatItem.meta
|
meta: chatItem.meta
|
||||||
)
|
)
|
||||||
|
.environmentObject(Chat.sampleData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,10 +62,10 @@ struct ChatItemContentView<Content: View>: View {
|
||||||
case .sndGroupEvent: eventItemView()
|
case .sndGroupEvent: eventItemView()
|
||||||
case .rcvConnEvent: eventItemView()
|
case .rcvConnEvent: eventItemView()
|
||||||
case .sndConnEvent: eventItemView()
|
case .sndConnEvent: eventItemView()
|
||||||
case let .rcvChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
|
case let .rcvChatFeature(feature, enabled, param): chatFeatureView(feature, enabled.iconColor, param)
|
||||||
case let .sndChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
|
case let .sndChatFeature(feature, enabled, param): chatFeatureView(feature, enabled.iconColor, param)
|
||||||
case let .rcvGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
|
case let .rcvGroupFeature(feature, preference, param): chatFeatureView(feature, preference.enable.iconColor, param)
|
||||||
case let .sndGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
|
case let .sndGroupFeature(feature, preference, param): chatFeatureView(feature, preference.enable.iconColor, param)
|
||||||
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||||
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
|
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||||
}
|
}
|
||||||
|
@ -87,7 +87,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||||
CIEventView(chatItem: chatItem)
|
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)
|
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))
|
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))
|
.previewLayout(.fixed(width: 360, height: 70))
|
||||||
|
.environmentObject(Chat.sampleData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
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{
|
Group{
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
chatInfo: ChatInfo.sampleData.direct,
|
chatInfo: ChatInfo.sampleData.direct,
|
||||||
|
@ -159,5 +160,6 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 70))
|
.previewLayout(.fixed(width: 360, height: 70))
|
||||||
|
.environmentObject(Chat.sampleData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -396,7 +396,6 @@ struct ChatView: View {
|
||||||
.frame(width: memberImageSize, height: memberImageSize)
|
.frame(width: memberImageSize, height: memberImageSize)
|
||||||
}
|
}
|
||||||
ChatItemWithMenu(
|
ChatItemWithMenu(
|
||||||
chat: chat,
|
|
||||||
ci: ci,
|
ci: ci,
|
||||||
showMember: showMember,
|
showMember: showMember,
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
|
@ -405,13 +404,14 @@ struct ChatView: View {
|
||||||
deletingItem: $deletingItem,
|
deletingItem: $deletingItem,
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
showDeleteMessage: $showDeleteMessage
|
showDeleteMessage: $showDeleteMessage
|
||||||
).padding(.leading, 8)
|
)
|
||||||
|
.padding(.leading, 8)
|
||||||
|
.environmentObject(chat)
|
||||||
}
|
}
|
||||||
.padding(.trailing)
|
.padding(.trailing)
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
} else {
|
} else {
|
||||||
ChatItemWithMenu(
|
ChatItemWithMenu(
|
||||||
chat: chat,
|
|
||||||
ci: ci,
|
ci: ci,
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
scrollProxy: scrollProxy,
|
scrollProxy: scrollProxy,
|
||||||
|
@ -419,12 +419,14 @@ struct ChatView: View {
|
||||||
deletingItem: $deletingItem,
|
deletingItem: $deletingItem,
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
showDeleteMessage: $showDeleteMessage
|
showDeleteMessage: $showDeleteMessage
|
||||||
).padding(.horizontal)
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.environmentObject(chat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ChatItemWithMenu: View {
|
private struct ChatItemWithMenu: View {
|
||||||
var chat: Chat
|
@EnvironmentObject var chat: Chat
|
||||||
var ci: ChatItem
|
var ci: ChatItem
|
||||||
var showMember: Bool = false
|
var showMember: Bool = false
|
||||||
var maxWidth: CGFloat
|
var maxWidth: CGFloat
|
||||||
|
|
|
@ -642,7 +642,7 @@ struct ComposeView: View {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
var prefs = contactUserPreferencesToPreferences(contact.mergedPreferences)
|
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) {
|
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
chatModel.updateContact(toContact)
|
chatModel.updateContact(toContact)
|
||||||
|
|
|
@ -22,6 +22,7 @@ struct ContactPreferencesView: View {
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
List {
|
List {
|
||||||
|
timedMessagesFeatureSection()
|
||||||
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
|
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
|
||||||
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
|
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(
|
let enabled = FeatureEnabled.enabled(
|
||||||
asymmetric: feature.asymmetric,
|
asymmetric: feature.asymmetric,
|
||||||
user: Preference(allow: allowFeature.wrappedValue.allowed),
|
user: SimplePreference(allow: allowFeature.wrappedValue.allowed),
|
||||||
contact: pref.contactPreference
|
contact: pref.contactPreference
|
||||||
)
|
)
|
||||||
return Section {
|
return Section {
|
||||||
|
@ -62,16 +63,51 @@ struct ContactPreferencesView: View {
|
||||||
}
|
}
|
||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
infoRow("Contact allows", pref.contactPreference.allow.text)
|
infoRow("Contact allows", pref.contactPreference.allow.text)
|
||||||
} header: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "\(feature.icon).fill")
|
|
||||||
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
|
|
||||||
Text(feature.text)
|
|
||||||
}
|
|
||||||
} footer: {
|
|
||||||
Text(feature.enabledDescription(enabled))
|
|
||||||
.frame(height: 36, alignment: .topLeading)
|
|
||||||
}
|
}
|
||||||
|
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.iconFilled)
|
||||||
|
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
|
||||||
|
Text(feature.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
||||||
|
Text(feature.enabledDescription(enabled))
|
||||||
|
.frame(height: 36, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func savePreferences() {
|
private func savePreferences() {
|
||||||
|
@ -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 {
|
struct ContactPreferencesView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ContactPreferencesView(
|
ContactPreferencesView(
|
||||||
|
|
|
@ -22,6 +22,7 @@ struct GroupPreferencesView: View {
|
||||||
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
|
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
|
||||||
VStack {
|
VStack {
|
||||||
List {
|
List {
|
||||||
|
featureSection(.timedMessages, $preferences.timedMessages.enable)
|
||||||
featureSection(.fullDelete, $preferences.fullDelete.enable)
|
featureSection(.fullDelete, $preferences.fullDelete.enable)
|
||||||
featureSection(.directMessages, $preferences.directMessages.enable)
|
featureSection(.directMessages, $preferences.directMessages.enable)
|
||||||
featureSection(.voice, $preferences.voice.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 {
|
.modifier(BackButton {
|
||||||
if currentPreferences == preferences {
|
if currentPreferences == preferences {
|
||||||
dismiss()
|
dismiss()
|
||||||
|
@ -55,20 +65,25 @@ struct GroupPreferencesView: View {
|
||||||
Section {
|
Section {
|
||||||
let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
|
let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
|
||||||
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
|
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) {
|
settingsRow(icon, color: color) {
|
||||||
Picker(feature.text, selection: enableFeature) {
|
Toggle(feature.text, isOn: enable)
|
||||||
ForEach(GroupFeatureEnabled.values) { enable in
|
|
||||||
Text(enable.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: 36)
|
|
||||||
}
|
}
|
||||||
}
|
if timedOn {
|
||||||
else {
|
timedMessagesTTLPicker($preferences.timedMessages.ttl)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
settingsRow(icon, color: color) {
|
settingsRow(icon, color: color) {
|
||||||
infoRow(feature.text, enableFeature.wrappedValue.text)
|
infoRow(feature.text, enableFeature.wrappedValue.text)
|
||||||
}
|
}
|
||||||
|
if timedOn {
|
||||||
|
infoRow("Delete after", TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||||
|
|
|
@ -18,6 +18,7 @@ struct PreferencesView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
List {
|
List {
|
||||||
|
timedMessagesFeatureSection($preferences.timedMessages.allow)
|
||||||
featureSection(.fullDelete, $preferences.fullDelete.allow)
|
featureSection(.fullDelete, $preferences.fullDelete.allow)
|
||||||
featureSection(.voice, $preferences.voice.allow)
|
featureSection(.voice, $preferences.voice.allow)
|
||||||
|
|
||||||
|
@ -40,10 +41,27 @@ struct PreferencesView: View {
|
||||||
}
|
}
|
||||||
.frame(height: 36)
|
.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() {
|
private func savePreferences() {
|
||||||
|
|
|
@ -125,35 +125,39 @@ extension NamedChat {
|
||||||
public typealias ChatId = String
|
public typealias ChatId = String
|
||||||
|
|
||||||
public struct FullPreferences: Decodable, Equatable {
|
public struct FullPreferences: Decodable, Equatable {
|
||||||
public var timedMessages: Preference
|
public var timedMessages: TimedMessagesPreference
|
||||||
public var fullDelete: Preference
|
public var fullDelete: SimplePreference
|
||||||
public var voice: Preference
|
public var voice: SimplePreference
|
||||||
|
|
||||||
public init(timedMessages: Preference, fullDelete: Preference, voice: Preference) {
|
public init(timedMessages: TimedMessagesPreference, fullDelete: SimplePreference, voice: SimplePreference) {
|
||||||
self.timedMessages = timedMessages
|
self.timedMessages = timedMessages
|
||||||
self.fullDelete = fullDelete
|
self.fullDelete = fullDelete
|
||||||
self.voice = voice
|
self.voice = voice
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let sampleData = FullPreferences(
|
public static let sampleData = FullPreferences(
|
||||||
timedMessages: Preference(allow: .no),
|
timedMessages: TimedMessagesPreference(allow: .no),
|
||||||
fullDelete: Preference(allow: .no),
|
fullDelete: SimplePreference(allow: .no),
|
||||||
voice: Preference(allow: .yes)
|
voice: SimplePreference(allow: .yes)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Preferences: Codable {
|
public struct Preferences: Codable {
|
||||||
public var timedMessages: Preference?
|
public var timedMessages: TimedMessagesPreference?
|
||||||
public var fullDelete: Preference?
|
public var fullDelete: SimplePreference?
|
||||||
public var voice: Preference?
|
public var voice: SimplePreference?
|
||||||
|
|
||||||
public init(timedMessages: Preference?, fullDelete: Preference?, voice: Preference?) {
|
public init(timedMessages: TimedMessagesPreference?, fullDelete: SimplePreference?, voice: SimplePreference?) {
|
||||||
self.timedMessages = timedMessages
|
self.timedMessages = timedMessages
|
||||||
self.fullDelete = fullDelete
|
self.fullDelete = fullDelete
|
||||||
self.voice = voice
|
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 {
|
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 var allow: FeatureAllowed
|
||||||
|
|
||||||
public init(allow: FeatureAllowed) {
|
public init(allow: FeatureAllowed) {
|
||||||
|
@ -180,42 +188,96 @@ public struct Preference: Codable, Equatable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ContactUserPreferences: Decodable {
|
public struct TimedMessagesPreference: Preference {
|
||||||
public var timedMessages: ContactUserPreference
|
public var allow: FeatureAllowed
|
||||||
public var fullDelete: ContactUserPreference
|
public var ttl: Int?
|
||||||
public var voice: ContactUserPreference
|
|
||||||
|
|
||||||
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.timedMessages = timedMessages
|
||||||
self.fullDelete = fullDelete
|
self.fullDelete = fullDelete
|
||||||
self.voice = voice
|
self.voice = voice
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let sampleData = ContactUserPreferences(
|
public static let sampleData = ContactUserPreferences(
|
||||||
timedMessages: ContactUserPreference(
|
timedMessages: ContactUserPreference<TimedMessagesPreference>(
|
||||||
enabled: FeatureEnabled(forUser: false, forContact: false),
|
enabled: FeatureEnabled(forUser: false, forContact: false),
|
||||||
userPreference: .user(preference: Preference(allow: .no)),
|
userPreference: ContactUserPref<TimedMessagesPreference>.user(preference: TimedMessagesPreference(allow: .no)),
|
||||||
contactPreference: Preference(allow: .no)
|
contactPreference: TimedMessagesPreference(allow: .no)
|
||||||
),
|
),
|
||||||
fullDelete: ContactUserPreference(
|
fullDelete: ContactUserPreference<SimplePreference>(
|
||||||
enabled: FeatureEnabled(forUser: false, forContact: false),
|
enabled: FeatureEnabled(forUser: false, forContact: false),
|
||||||
userPreference: .user(preference: Preference(allow: .no)),
|
userPreference: ContactUserPref<SimplePreference>.user(preference: SimplePreference(allow: .no)),
|
||||||
contactPreference: Preference(allow: .no)
|
contactPreference: SimplePreference(allow: .no)
|
||||||
),
|
),
|
||||||
voice: ContactUserPreference(
|
voice: ContactUserPreference<SimplePreference>(
|
||||||
enabled: FeatureEnabled(forUser: true, forContact: true),
|
enabled: FeatureEnabled(forUser: true, forContact: true),
|
||||||
userPreference: .user(preference: Preference(allow: .yes)),
|
userPreference: ContactUserPref<SimplePreference>.user(preference: SimplePreference(allow: .yes)),
|
||||||
contactPreference: Preference(allow: .yes)
|
contactPreference: SimplePreference(allow: .yes)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ContactUserPreference: Decodable {
|
public struct ContactUserPreference<P: Preference>: Decodable {
|
||||||
public var enabled: FeatureEnabled
|
public var enabled: FeatureEnabled
|
||||||
public var userPreference: ContactUserPref
|
public var userPreference: ContactUserPref<P>
|
||||||
public var contactPreference: Preference
|
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.enabled = enabled
|
||||||
self.userPreference = userPreference
|
self.userPreference = userPreference
|
||||||
self.contactPreference = contactPreference
|
self.contactPreference = contactPreference
|
||||||
|
@ -231,7 +293,7 @@ public struct FeatureEnabled: Decodable {
|
||||||
self.forContact = forContact
|
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) {
|
switch (user.allow, contact.allow) {
|
||||||
case (.always, .no): return FeatureEnabled(forUser: false, forContact: asymmetric)
|
case (.always, .no): return FeatureEnabled(forUser: false, forContact: asymmetric)
|
||||||
case (.no, .always): return FeatureEnabled(forUser: asymmetric, forContact: false)
|
case (.no, .always): return FeatureEnabled(forUser: asymmetric, forContact: false)
|
||||||
|
@ -253,20 +315,29 @@ public struct FeatureEnabled: Decodable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ContactUserPref: Decodable {
|
public enum ContactUserPref<P: Preference>: Decodable {
|
||||||
case contact(preference: Preference) // contact override is set
|
case contact(preference: P) // contact override is set
|
||||||
case user(preference: Preference) // global user default is used
|
case user(preference: P) // global user default is used
|
||||||
|
|
||||||
public var preference: Preference {
|
public var preference: P {
|
||||||
switch self {
|
switch self {
|
||||||
case let .contact(preference): return preference
|
case let .contact(preference): return preference
|
||||||
case let .user(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 {
|
public protocol Feature {
|
||||||
var iconFilled: String { get }
|
var iconFilled: String { get }
|
||||||
|
var hasParam: Bool { get }
|
||||||
|
var text: String { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ChatFeature: String, Decodable, Feature {
|
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 {
|
public var text: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature")
|
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 id: Self { self }
|
||||||
|
|
||||||
|
public var hasParam: Bool {
|
||||||
|
switch self {
|
||||||
|
case .timedMessages: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public var text: String {
|
public var text: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature")
|
case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature")
|
||||||
|
@ -482,32 +567,38 @@ public enum ContactFeatureAllowed: Identifiable, Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ContactFeaturesAllowed: Equatable {
|
public struct ContactFeaturesAllowed: Equatable {
|
||||||
public var timedMessages: ContactFeatureAllowed
|
public var timedMessagesAllowed: Bool
|
||||||
|
public var timedMessagesTTL: Int?
|
||||||
public var fullDelete: ContactFeatureAllowed
|
public var fullDelete: ContactFeatureAllowed
|
||||||
public var voice: ContactFeatureAllowed
|
public var voice: ContactFeatureAllowed
|
||||||
|
|
||||||
public init(timedMessages: ContactFeatureAllowed, fullDelete: ContactFeatureAllowed, voice: ContactFeatureAllowed) {
|
public init(timedMessagesAllowed: Bool, timedMessagesTTL: Int?, fullDelete: ContactFeatureAllowed, voice: ContactFeatureAllowed) {
|
||||||
self.timedMessages = timedMessages
|
self.timedMessagesAllowed = timedMessagesAllowed
|
||||||
|
self.timedMessagesTTL = timedMessagesTTL
|
||||||
self.fullDelete = fullDelete
|
self.fullDelete = fullDelete
|
||||||
self.voice = voice
|
self.voice = voice
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let sampleData = ContactFeaturesAllowed(
|
public static let sampleData = ContactFeaturesAllowed(
|
||||||
timedMessages: ContactFeatureAllowed.userDefault(.no),
|
timedMessagesAllowed: false,
|
||||||
|
timedMessagesTTL: nil,
|
||||||
fullDelete: ContactFeatureAllowed.userDefault(.no),
|
fullDelete: ContactFeatureAllowed.userDefault(.no),
|
||||||
voice: ContactFeatureAllowed.userDefault(.yes)
|
voice: ContactFeatureAllowed.userDefault(.yes)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func contactUserPrefsToFeaturesAllowed(_ contactUserPreferences: ContactUserPreferences) -> ContactFeaturesAllowed {
|
public func contactUserPrefsToFeaturesAllowed(_ contactUserPreferences: ContactUserPreferences) -> ContactFeaturesAllowed {
|
||||||
ContactFeaturesAllowed(
|
let pref = contactUserPreferences.timedMessages.userPreference
|
||||||
timedMessages: contactUserPrefToFeatureAllowed(contactUserPreferences.timedMessages),
|
let allow = pref.contactOverride?.allow
|
||||||
|
return ContactFeaturesAllowed(
|
||||||
|
timedMessagesAllowed: allow == .yes || allow == .always,
|
||||||
|
timedMessagesTTL: pref.preference.ttl,
|
||||||
fullDelete: contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
|
fullDelete: contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
|
||||||
voice: contactUserPrefToFeatureAllowed(contactUserPreferences.voice)
|
voice: contactUserPrefToFeatureAllowed(contactUserPreferences.voice)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func contactUserPrefToFeatureAllowed(_ contactUserPreference: ContactUserPreference) -> ContactFeatureAllowed {
|
public func contactUserPrefToFeatureAllowed(_ contactUserPreference: ContactUserPreference<SimplePreference>) -> ContactFeatureAllowed {
|
||||||
switch contactUserPreference.userPreference {
|
switch contactUserPreference.userPreference {
|
||||||
case let .user(preference): return .userDefault(preference.allow)
|
case let .user(preference): return .userDefault(preference.allow)
|
||||||
case let .contact(preference):
|
case let .contact(preference):
|
||||||
|
@ -521,18 +612,18 @@ public func contactUserPrefToFeatureAllowed(_ contactUserPreference: ContactUser
|
||||||
|
|
||||||
public func contactFeaturesAllowedToPrefs(_ contactFeaturesAllowed: ContactFeaturesAllowed) -> Preferences {
|
public func contactFeaturesAllowedToPrefs(_ contactFeaturesAllowed: ContactFeaturesAllowed) -> Preferences {
|
||||||
Preferences(
|
Preferences(
|
||||||
timedMessages: contactFeatureAllowedToPref(contactFeaturesAllowed.timedMessages),
|
timedMessages: TimedMessagesPreference(allow: contactFeaturesAllowed.timedMessagesAllowed ? .yes : .no, ttl: contactFeaturesAllowed.timedMessagesTTL),
|
||||||
fullDelete: contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
|
fullDelete: contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
|
||||||
voice: contactFeatureAllowedToPref(contactFeaturesAllowed.voice)
|
voice: contactFeatureAllowedToPref(contactFeaturesAllowed.voice)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func contactFeatureAllowedToPref(_ contactFeatureAllowed: ContactFeatureAllowed) -> Preference? {
|
public func contactFeatureAllowedToPref(_ contactFeatureAllowed: ContactFeatureAllowed) -> SimplePreference? {
|
||||||
switch contactFeatureAllowed {
|
switch contactFeatureAllowed {
|
||||||
case .userDefault: return nil
|
case .userDefault: return nil
|
||||||
case .always: return Preference(allow: .always)
|
case .always: return SimplePreference(allow: .always)
|
||||||
case .yes: return Preference(allow: .yes)
|
case .yes: return SimplePreference(allow: .yes)
|
||||||
case .no: return Preference(allow: .no)
|
case .no: return SimplePreference(allow: .no)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -555,12 +646,12 @@ public enum FeatureAllowed: String, Codable, Identifiable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct FullGroupPreferences: Decodable, Equatable {
|
public struct FullGroupPreferences: Decodable, Equatable {
|
||||||
public var timedMessages: GroupPreference
|
public var timedMessages: TimedMessagesGroupPreference
|
||||||
public var directMessages: GroupPreference
|
public var directMessages: GroupPreference
|
||||||
public var fullDelete: GroupPreference
|
public var fullDelete: GroupPreference
|
||||||
public var voice: 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.timedMessages = timedMessages
|
||||||
self.directMessages = directMessages
|
self.directMessages = directMessages
|
||||||
self.fullDelete = fullDelete
|
self.fullDelete = fullDelete
|
||||||
|
@ -568,7 +659,7 @@ public struct FullGroupPreferences: Decodable, Equatable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let sampleData = FullGroupPreferences(
|
public static let sampleData = FullGroupPreferences(
|
||||||
timedMessages: GroupPreference(enable: .off),
|
timedMessages: TimedMessagesGroupPreference(enable: .off),
|
||||||
directMessages: GroupPreference(enable: .off),
|
directMessages: GroupPreference(enable: .off),
|
||||||
fullDelete: GroupPreference(enable: .off),
|
fullDelete: GroupPreference(enable: .off),
|
||||||
voice: GroupPreference(enable: .on)
|
voice: GroupPreference(enable: .on)
|
||||||
|
@ -576,12 +667,12 @@ public struct FullGroupPreferences: Decodable, Equatable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct GroupPreferences: Codable {
|
public struct GroupPreferences: Codable {
|
||||||
public var timedMessages: GroupPreference?
|
public var timedMessages: TimedMessagesGroupPreference?
|
||||||
public var directMessages: GroupPreference?
|
public var directMessages: GroupPreference?
|
||||||
public var fullDelete: GroupPreference?
|
public var fullDelete: GroupPreference?
|
||||||
public var voice: 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.timedMessages = timedMessages
|
||||||
self.directMessages = directMessages
|
self.directMessages = directMessages
|
||||||
self.fullDelete = fullDelete
|
self.fullDelete = fullDelete
|
||||||
|
@ -589,7 +680,7 @@ public struct GroupPreferences: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let sampleData = GroupPreferences(
|
public static let sampleData = GroupPreferences(
|
||||||
timedMessages: GroupPreference(enable: .off),
|
timedMessages: TimedMessagesGroupPreference(enable: .off),
|
||||||
directMessages: GroupPreference(enable: .off),
|
directMessages: GroupPreference(enable: .off),
|
||||||
fullDelete: GroupPreference(enable: .off),
|
fullDelete: GroupPreference(enable: .off),
|
||||||
voice: GroupPreference(enable: .on)
|
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 {
|
public enum GroupFeatureEnabled: String, Codable, Identifiable {
|
||||||
case on
|
case on
|
||||||
case off
|
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 {
|
public enum ShowEnableVoiceMessagesAlert {
|
||||||
case userEnable
|
case userEnable
|
||||||
case askContact
|
case askContact
|
||||||
|
@ -1476,10 +1594,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var isRcvNew: Bool {
|
public var isRcvNew: Bool { meta.isRcvNew }
|
||||||
if case .rcvNew = meta.itemStatus { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
public var isDeletedContent: Bool {
|
public var isDeletedContent: Bool {
|
||||||
switch content {
|
switch content {
|
||||||
|
@ -1618,7 +1733,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getChatFeatureSample(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> ChatItem {
|
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(
|
return ChatItem(
|
||||||
chatDir: .directRcv,
|
chatDir: .directRcv,
|
||||||
meta: CIMeta.getSample(1, .now, content.text, .rcvRead, false, false, false),
|
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 updatedAt: Date
|
||||||
public var itemDeleted: Bool
|
public var itemDeleted: Bool
|
||||||
public var itemEdited: Bool
|
public var itemEdited: Bool
|
||||||
|
public var itemTimed: CITimed?
|
||||||
public var itemLive: Bool?
|
public var itemLive: Bool?
|
||||||
public var editable: Bool
|
public var editable: Bool
|
||||||
|
|
||||||
public var timestampText: Text { get { formatTimestampText(itemTs) } }
|
public var timestampText: Text { get { formatTimestampText(itemTs) } }
|
||||||
public var recent: Bool { updatedAt + 10 > .now }
|
public var recent: Bool { updatedAt + 10 > .now }
|
||||||
public var isLive: Bool { itemLive == true }
|
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 {
|
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(
|
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 msgTimeFormat = Date.FormatStyle.dateTime.hour().minute()
|
||||||
let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits)
|
let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits)
|
||||||
|
|
||||||
|
@ -1753,10 +1890,10 @@ public enum CIContent: Decodable, ItemContent {
|
||||||
case sndGroupEvent(sndGroupEvent: SndGroupEvent)
|
case sndGroupEvent(sndGroupEvent: SndGroupEvent)
|
||||||
case rcvConnEvent(rcvConnEvent: RcvConnEvent)
|
case rcvConnEvent(rcvConnEvent: RcvConnEvent)
|
||||||
case sndConnEvent(sndConnEvent: SndConnEvent)
|
case sndConnEvent(sndConnEvent: SndConnEvent)
|
||||||
case rcvChatFeature(feature: ChatFeature, enabled: FeatureEnabled)
|
case rcvChatFeature(feature: ChatFeature, enabled: FeatureEnabled, param: Int?)
|
||||||
case sndChatFeature(feature: ChatFeature, enabled: FeatureEnabled)
|
case sndChatFeature(feature: ChatFeature, enabled: FeatureEnabled, param: Int?)
|
||||||
case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference)
|
case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?)
|
||||||
case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference)
|
case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?)
|
||||||
case rcvChatFeatureRejected(feature: ChatFeature)
|
case rcvChatFeatureRejected(feature: ChatFeature)
|
||||||
case rcvGroupFeatureRejected(groupFeature: GroupFeature)
|
case rcvGroupFeatureRejected(groupFeature: GroupFeature)
|
||||||
|
|
||||||
|
@ -1776,16 +1913,22 @@ public enum CIContent: Decodable, ItemContent {
|
||||||
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text
|
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text
|
||||||
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
|
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
|
||||||
case let .sndConnEvent(sndConnEvent): return sndConnEvent.text
|
case let .sndConnEvent(sndConnEvent): return sndConnEvent.text
|
||||||
case let .rcvChatFeature(feature, enabled): return "\(feature.text): \(enabled.text)"
|
case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
|
||||||
case let .sndChatFeature(feature, enabled): return "\(feature.text): \(enabled.text)"
|
case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
|
||||||
case let .rcvGroupFeature(feature, preference): return "\(feature.text): \(preference.enable.text)"
|
case let .rcvGroupFeature(feature, preference, param): return CIContent.featureText(feature, preference.enable.text, param)
|
||||||
case let .sndGroupFeature(feature, preference): return "\(feature.text): \(preference.enable.text)"
|
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 .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text)
|
||||||
case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.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? {
|
public var msgContent: MsgContent? {
|
||||||
get {
|
get {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue