ios: reactions UI (#2442)

* ios: reactions UI

* remove JSON

* remove print

* align reactions, show all allowed reactions in  menu

* move react to the menu top

* ios: update preference texts

* always allow removing reactions, reduce spacing

* revent allow removing (backend does not allow it anyway)
This commit is contained in:
Evgeny Poberezkin
2023-05-16 10:34:25 +02:00
committed by GitHub
parent a059739210
commit 904405ebee
10 changed files with 333 additions and 88 deletions

View File

@@ -1,20 +1,11 @@
//import UIKit
import SimpleXChat
let s = """
{
"contactConnection" : {
"contactConnection" : {
"viaContactUri" : false,
"pccConnId" : 456,
"pccAgentConnId" : "cTdjbmR4ZzVzSmhEZHdzMQ==",
"pccConnStatus" : "new",
"updatedAt" : "2022-04-24T11:59:23.703162Z",
"createdAt" : "2022-04-24T11:59:23.703162Z"
}
}
}
let s =
"""
{}
"""
//let s = "\"2022-04-24T11:59:23.703162Z\""
//let json = getJSONDecoder()
//let d = s.data(using: .utf8)!
//print (try! json.decode(ChatInfo.self, from: d))
let json = getJSONDecoder()
let d = s.data(using: .utf8)!
print (try! json.decode(APIResponse.self, from: d))

View File

@@ -238,16 +238,9 @@ final class ChatModel: ObservableObject {
}
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
let ci = reversedChatItems[i]
if let i = getChatItemIndex(cItem) {
withAnimation {
self.reversedChatItems[i] = cItem
self.reversedChatItems[i].viewTimestamp = .now
// on some occasions the confirmation of message being accepted by the server (tick)
// arrives earlier than the response from API, and item remains without tick
if case .sndNew = cItem.meta.itemStatus {
self.reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
}
_updateChatItem(at: i, with: cItem)
}
return false
} else {
@@ -264,7 +257,30 @@ final class ChatModel: ObservableObject {
}
}
}
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
withAnimation {
_updateChatItem(at: i, with: cItem)
}
}
}
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
let ci = reversedChatItems[i]
reversedChatItems[i] = cItem
reversedChatItems[i].viewTimestamp = .now
// on some occasions the confirmation of message being accepted by the server (tick)
// arrives earlier than the response from API, and item remains without tick
if case .sndNew = cItem.meta.itemStatus {
reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
}
}
private func getChatItemIndex(_ cItem: ChatItem) -> Int? {
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew {
decreaseUnreadCounter(cInfo)
@@ -277,7 +293,7 @@ final class ChatModel: ObservableObject {
}
// remove from current chat
if chatId == cInfo.id {
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
if let i = getChatItemIndex(cItem) {
_ = withAnimation {
self.reversedChatItems.remove(at: i)
}
@@ -357,7 +373,7 @@ final class ChatModel: ObservableObject {
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
if let cItem = aboveItem {
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
markCurrentChatRead(fromIndex: i)
_updateChat(cInfo.id) { chat in
var unreadBelow = 0
@@ -405,7 +421,7 @@ final class ChatModel: ObservableObject {
// update preview
decreaseUnreadCounter(cInfo)
// update current chat
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
markChatItemRead_(i)
}
}
@@ -450,7 +466,7 @@ final class ChatModel: ObservableObject {
}
func getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
if let i = reversedChatItems.firstIndex(where: { $0.id == ci.id }), i < reversedChatItems.count - 1 {
if let i = getChatItemIndex(ci), i < reversedChatItems.count - 1 {
return reversedChatItems[i + 1]
} else {
return nil

View File

@@ -343,6 +343,12 @@ func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent
throw r
}
func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, reaction: MsgReaction, add: Bool) async throws -> ChatItem {
let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, reaction: reaction, add: add), bgDelay: msgDelay)
if case let .chatItemReaction(_, reaction, _) = r { return reaction.chatReaction.chatItem }
throw r
}
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
@@ -1264,6 +1270,10 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
case let .chatItemUpdated(user, aChatItem):
chatItemSimpleUpdate(user, aChatItem)
case let .chatItemReaction(user, r, _):
if active(user) {
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
}
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
if !active(user) {
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {

View File

@@ -444,6 +444,7 @@ struct ChatView: View {
private struct ChatItemWithMenu: View {
@EnvironmentObject var chat: Chat
@Environment(\.colorScheme) var colorScheme
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
var ci: ChatItem
var showMember: Bool = false
var maxWidth: CGFloat
@@ -470,11 +471,11 @@ struct ChatView: View {
set: { _ in }
)
VStack(alignment: .trailing, spacing: 4) {
VStack(alignment: alignment.horizontal, spacing: 3) {
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
if ci.reactions.count > 0 {
chatItemReactions(ci.reactions)
if ci.content.msgContent != nil && ci.meta.itemDeleted == nil && ci.reactions.count > 0 {
chatItemReactions()
.padding(.bottom, 4)
}
}
@@ -505,28 +506,42 @@ struct ChatView: View {
}
}
private func chatItemReactions(_ reactions: [CIReaction]) -> some View {
private func chatItemReactions() -> some View {
HStack(spacing: 4) {
ForEach(reactions, id: \.reaction) { r in
HStack(spacing: 4) {
ForEach(ci.reactions, id: \.reaction) { r in
let v = HStack(spacing: 4) {
switch r.reaction {
case let .emoji(emoji): Text(emoji).font(.caption)
case let .emoji(emoji): Text(emoji.rawValue).font(.caption)
case .unknown: EmptyView()
}
if r.totalReacted > 1 {
Text("\(r.totalReacted)").font(.caption).foregroundColor(.secondary)
Text("\(r.totalReacted)")
.font(.caption)
.fontWeight(r.userReacted ? .bold : .light)
.foregroundColor(r.userReacted ? .accentColor : .secondary)
}
}
.padding(.horizontal, 8)
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(!r.userReacted ? Color.clear : colorScheme == .dark ? sentColorDark : sentColorLight)
.cornerRadius(16)
if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
v.onTapGesture {
setReaction(r.reaction, add: !r.userReacted)
}
} else {
v
}
}
}
}
private func menu(live: Bool) -> [UIAction] {
var menu: [UIAction] = []
private func menu(live: Bool) -> [UIMenuElement] {
var menu: [UIMenuElement] = []
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction && developerTools,
let rm = reactionUIMenu() {
menu.append(rm)
}
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live {
menu.append(replyUIAction())
}
@@ -586,7 +601,42 @@ struct ChatView: View {
}
}
}
private func reactionUIMenu() -> UIMenu? {
let rs = MsgReaction.values.compactMap { r in
ci.reactions.contains(where: { $0.userReacted && $0.reaction == r })
? nil
: UIAction(title: r.text) { _ in setReaction(r, add: true) }
}
if rs.count > 0 {
return UIMenu(
title: NSLocalizedString("React...", comment: "chat item menu"),
image: UIImage(systemName: "hand.thumbsup"),
children: rs
)
}
return nil
}
private func setReaction(_ r: MsgReaction, add: Bool) {
Task {
do {
let chatItem = try await apiChatItemReaction(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: ci.id,
reaction: r,
add: add
)
await MainActor.run {
ChatModel.shared.updateChatItem(chat.chatInfo, chatItem)
}
} catch let error {
logger.error("apiChatItemReaction error: \(responseError(error))")
}
}
}
private func shareUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Share", comment: "chat item action"),

View File

@@ -12,6 +12,7 @@ import SimpleXChat
struct ContactPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@Binding var contact: Contact
@State var featuresAllowed: ContactFeaturesAllowed
@State var currentFeaturesAllowed: ContactFeaturesAllowed
@@ -24,6 +25,9 @@ struct ContactPreferencesView: View {
List {
timedMessagesFeatureSection()
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
if developerTools {
featureSection(.reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, $featuresAllowed.reactions)
}
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
featureSection(.calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, $featuresAllowed.calls)

View File

@@ -12,6 +12,7 @@ import SimpleXChat
struct GroupPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@Binding var groupInfo: GroupInfo
@State var preferences: FullGroupPreferences
@State var currentPreferences: FullGroupPreferences
@@ -25,6 +26,9 @@ struct GroupPreferencesView: View {
featureSection(.timedMessages, $preferences.timedMessages.enable)
featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.directMessages, $preferences.directMessages.enable)
if developerTools {
featureSection(.reactions, $preferences.reactions.enable)
}
featureSection(.voice, $preferences.voice.enable)
if groupInfo.canEdit {

View File

@@ -11,6 +11,7 @@ import SimpleXChat
struct PreferencesView: View {
@EnvironmentObject var chatModel: ChatModel
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State var profile: LocalProfile
@State var preferences: FullPreferences
@State var currentPreferences: FullPreferences
@@ -20,6 +21,9 @@ struct PreferencesView: View {
List {
timedMessagesFeatureSection($preferences.timedMessages.allow)
featureSection(.fullDelete, $preferences.fullDelete.allow)
if developerTools {
featureSection(.reactions, $preferences.reactions.allow)
}
featureSection(.voice, $preferences.voice.allow)
featureSection(.calls, $preferences.calls.allow)

View File

@@ -41,6 +41,7 @@ public enum ChatCommand {
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
case apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64)
case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, reaction: MsgReaction, add: Bool)
case apiGetNtfToken
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
@@ -148,6 +149,7 @@ public enum ChatCommand {
case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)"
case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)"
case let .apiDeleteMemberChatItem(groupId, groupMemberId, itemId): return "/_delete member item #\(groupId) \(groupMemberId) \(itemId)"
case let .apiChatItemReaction(type, id, itemId, reaction, add): return "/_reaction \(ref(type, id)) \(itemId) \(reaction.cmdString) \(onOff(add))"
case .apiGetNtfToken: return "/_ntf get "
case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)"
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
@@ -253,6 +255,7 @@ public enum ChatCommand {
case .apiUpdateChatItem: return "apiUpdateChatItem"
case .apiDeleteChatItem: return "apiDeleteChatItem"
case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem"
case .apiChatItemReaction: return "apiChatItemReaction"
case .apiGetNtfToken: return "apiGetNtfToken"
case .apiRegisterToken: return "apiRegisterToken"
case .apiVerifyToken: return "apiVerifyToken"
@@ -373,7 +376,7 @@ public enum ChatCommand {
}
}
struct APIResponse: Decodable {
public struct APIResponse: Decodable {
var resp: ChatResponse
}
@@ -430,6 +433,7 @@ public enum ChatResponse: Decodable, Error {
case newChatItem(user: User, chatItem: AChatItem)
case chatItemStatusUpdated(user: User, chatItem: AChatItem)
case chatItemUpdated(user: User, chatItem: AChatItem)
case chatItemReaction(user: User, reaction: ACIReaction, added: Bool)
case chatItemDeleted(user: User, deletedChatItem: AChatItem, toChatItem: AChatItem?, byUser: Bool)
case contactsList(user: User, contacts: [Contact])
// group events
@@ -547,6 +551,7 @@ public enum ChatResponse: Decodable, Error {
case .newChatItem: return "newChatItem"
case .chatItemStatusUpdated: return "chatItemStatusUpdated"
case .chatItemUpdated: return "chatItemUpdated"
case .chatItemReaction: return "chatItemReaction"
case .chatItemDeleted: return "chatItemDeleted"
case .contactsList: return "contactsList"
case .groupCreated: return "groupCreated"
@@ -663,6 +668,7 @@ public enum ChatResponse: Decodable, Error {
case let .newChatItem(u, chatItem): return withUser(u, String(describing: chatItem))
case let .chatItemStatusUpdated(u, chatItem): return withUser(u, String(describing: chatItem))
case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem))
case let .chatItemReaction(u, reaction, added): return withUser(u, "added: \(added)\n\(String(describing: reaction))")
case let .chatItemDeleted(u, deletedChatItem, toChatItem, byUser): return withUser(u, "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))\nbyUser: \(byUser)")
case let .contactsList(u, contacts): return withUser(u, String(describing: contacts))
case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo))

View File

@@ -183,12 +183,20 @@ public typealias ChatId = String
public struct FullPreferences: Decodable, Equatable {
public var timedMessages: TimedMessagesPreference
public var fullDelete: SimplePreference
public var reactions: SimplePreference
public var voice: SimplePreference
public var calls: SimplePreference
public init(timedMessages: TimedMessagesPreference, fullDelete: SimplePreference, voice: SimplePreference, calls: SimplePreference) {
public init(
timedMessages: TimedMessagesPreference,
fullDelete: SimplePreference,
reactions: SimplePreference,
voice: SimplePreference,
calls: SimplePreference
) {
self.timedMessages = timedMessages
self.fullDelete = fullDelete
self.reactions = reactions
self.voice = voice
self.calls = calls
}
@@ -196,6 +204,7 @@ public struct FullPreferences: Decodable, Equatable {
public static let sampleData = FullPreferences(
timedMessages: TimedMessagesPreference(allow: .no),
fullDelete: SimplePreference(allow: .no),
reactions: SimplePreference(allow: .yes),
voice: SimplePreference(allow: .yes),
calls: SimplePreference(allow: .yes)
)
@@ -204,20 +213,35 @@ public struct FullPreferences: Decodable, Equatable {
public struct Preferences: Codable {
public var timedMessages: TimedMessagesPreference?
public var fullDelete: SimplePreference?
public var reactions: SimplePreference?
public var voice: SimplePreference?
public var calls: SimplePreference?
public init(timedMessages: TimedMessagesPreference?, fullDelete: SimplePreference?, voice: SimplePreference?, calls: SimplePreference?) {
public init(
timedMessages: TimedMessagesPreference?,
fullDelete: SimplePreference?,
reactions: SimplePreference?,
voice: SimplePreference?,
calls: SimplePreference?
) {
self.timedMessages = timedMessages
self.fullDelete = fullDelete
self.reactions = reactions
self.voice = voice
self.calls = calls
}
func copy(timedMessages: TimedMessagesPreference? = nil, fullDelete: SimplePreference? = nil, voice: SimplePreference? = nil, calls: SimplePreference? = nil) -> Preferences {
func copy(
timedMessages: TimedMessagesPreference? = nil,
fullDelete: SimplePreference? = nil,
reactions: SimplePreference? = nil,
voice: SimplePreference? = nil,
calls: SimplePreference? = nil
) -> Preferences {
Preferences(
timedMessages: timedMessages ?? self.timedMessages,
fullDelete: fullDelete ?? self.fullDelete,
reactions: reactions ?? self.reactions,
voice: voice ?? self.voice,
calls: calls ?? self.calls
)
@@ -227,6 +251,7 @@ public struct Preferences: Codable {
switch feature {
case .timedMessages: return copy(timedMessages: TimedMessagesPreference(allow: allowed, ttl: param ?? timedMessages?.ttl))
case .fullDelete: return copy(fullDelete: SimplePreference(allow: allowed))
case .reactions: return copy(reactions: SimplePreference(allow: allowed))
case .voice: return copy(voice: SimplePreference(allow: allowed))
case .calls: return copy(calls: SimplePreference(allow: allowed))
}
@@ -235,6 +260,7 @@ public struct Preferences: Codable {
public static let sampleData = Preferences(
timedMessages: TimedMessagesPreference(allow: .no),
fullDelete: SimplePreference(allow: .no),
reactions: SimplePreference(allow: .yes),
voice: SimplePreference(allow: .yes),
calls: SimplePreference(allow: .yes)
)
@@ -244,6 +270,7 @@ public func fullPreferencesToPreferences(_ fullPreferences: FullPreferences) ->
Preferences(
timedMessages: fullPreferences.timedMessages,
fullDelete: fullPreferences.fullDelete,
reactions: fullPreferences.reactions,
voice: fullPreferences.voice,
calls: fullPreferences.calls
)
@@ -253,6 +280,7 @@ public func contactUserPreferencesToPreferences(_ contactUserPreferences: Contac
Preferences(
timedMessages: contactUserPreferences.timedMessages.userPreference.preference,
fullDelete: contactUserPreferences.fullDelete.userPreference.preference,
reactions: contactUserPreferences.reactions.userPreference.preference,
voice: contactUserPreferences.voice.userPreference.preference,
calls: contactUserPreferences.calls.userPreference.preference
)
@@ -386,17 +414,20 @@ public func shortTimeText(_ seconds: Int?) -> LocalizedStringKey {
public struct ContactUserPreferences: Decodable {
public var timedMessages: ContactUserPreference<TimedMessagesPreference>
public var fullDelete: ContactUserPreference<SimplePreference>
public var reactions: ContactUserPreference<SimplePreference>
public var voice: ContactUserPreference<SimplePreference>
public var calls: ContactUserPreference<SimplePreference>
public init(
timedMessages: ContactUserPreference<TimedMessagesPreference>,
fullDelete: ContactUserPreference<SimplePreference>,
reactions: ContactUserPreference<SimplePreference>,
voice: ContactUserPreference<SimplePreference>,
calls: ContactUserPreference<SimplePreference>
) {
self.timedMessages = timedMessages
self.fullDelete = fullDelete
self.reactions = reactions
self.voice = voice
self.calls = calls
}
@@ -412,6 +443,11 @@ public struct ContactUserPreferences: Decodable {
userPreference: ContactUserPref<SimplePreference>.user(preference: SimplePreference(allow: .no)),
contactPreference: SimplePreference(allow: .no)
),
reactions: ContactUserPreference<SimplePreference>(
enabled: FeatureEnabled(forUser: true, forContact: true),
userPreference: ContactUserPref<SimplePreference>.user(preference: SimplePreference(allow: .yes)),
contactPreference: SimplePreference(allow: .yes)
),
voice: ContactUserPreference<SimplePreference>(
enabled: FeatureEnabled(forUser: true, forContact: true),
userPreference: ContactUserPref<SimplePreference>.user(preference: SimplePreference(allow: .yes)),
@@ -491,11 +527,10 @@ public protocol Feature {
public enum ChatFeature: String, Decodable, Feature {
case timedMessages
case fullDelete
case reactions
case voice
case calls
public var values: [ChatFeature] { [.fullDelete, .voice] }
public var id: Self { self }
public var asymmetric: Bool {
@@ -516,6 +551,7 @@ public enum ChatFeature: String, Decodable, Feature {
switch self {
case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature")
case .fullDelete: return NSLocalizedString("Delete for everyone", comment: "chat feature")
case .reactions: return NSLocalizedString("Message reactions", comment: "chat feature")
case .voice: return NSLocalizedString("Voice messages", comment: "chat feature")
case .calls: return NSLocalizedString("Audio/video calls", comment: "chat feature")
}
@@ -525,6 +561,7 @@ public enum ChatFeature: String, Decodable, Feature {
switch self {
case .timedMessages: return "stopwatch"
case .fullDelete: return "trash.slash"
case .reactions: return "hand.thumbsup"
case .voice: return "mic"
case .calls: return "phone"
}
@@ -534,6 +571,7 @@ public enum ChatFeature: String, Decodable, Feature {
switch self {
case .timedMessages: return "stopwatch.fill"
case .fullDelete: return "trash.slash.fill"
case .reactions: return "hand.thumbsup.fill"
case .voice: return "mic.fill"
case .calls: return "phone.fill"
}
@@ -560,6 +598,12 @@ public enum ChatFeature: String, Decodable, Feature {
case .yes: return "Allow irreversible message deletion only if your contact allows it to you."
case .no: return "Contacts can mark messages for deletion; you will be able to view them."
}
case .reactions:
switch allowed {
case .always: return "Allow your contacts adding message reactions."
case .yes: return "Allow message reactions only if your contact allows them."
case .no: return "Prohibit message reactions."
}
case .voice:
switch allowed {
case .always: return "Allow your contacts to send voice messages."
@@ -593,6 +637,14 @@ public enum ChatFeature: String, Decodable, Feature {
: enabled.forContact
? "Only your contact can irreversibly delete messages (you can mark them for deletion)."
: "Irreversible message deletion is prohibited in this chat."
case .reactions:
return enabled.forUser && enabled.forContact
? "Both you and your contact can add message reactions."
: enabled.forUser
? "Only you can add message reactions."
: enabled.forContact
? "Only your contact can add message reactions."
: "Message reactions are prohibited in this chat."
case .voice:
return enabled.forUser && enabled.forContact
? "Both you and your contact can send voice messages."
@@ -616,11 +668,10 @@ public enum ChatFeature: String, Decodable, Feature {
public enum GroupFeature: String, Decodable, Feature {
case timedMessages
case fullDelete
case reactions
case voice
case directMessages
public var values: [GroupFeature] { [.directMessages, .fullDelete, .voice] }
public var id: Self { self }
public var hasParam: Bool {
@@ -635,6 +686,7 @@ public enum GroupFeature: String, Decodable, Feature {
case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature")
case .directMessages: return NSLocalizedString("Direct messages", comment: "chat feature")
case .fullDelete: return NSLocalizedString("Delete for everyone", comment: "chat feature")
case .reactions: return NSLocalizedString("Message reactions", comment: "chat feature")
case .voice: return NSLocalizedString("Voice messages", comment: "chat feature")
}
}
@@ -644,6 +696,7 @@ public enum GroupFeature: String, Decodable, Feature {
case .timedMessages: return "stopwatch"
case .directMessages: return "arrow.left.and.right.circle"
case .fullDelete: return "trash.slash"
case .reactions: return "hand.thumbsup"
case .voice: return "mic"
}
}
@@ -653,6 +706,7 @@ public enum GroupFeature: String, Decodable, Feature {
case .timedMessages: return "stopwatch.fill"
case .directMessages: return "arrow.left.and.right.circle.fill"
case .fullDelete: return "trash.slash.fill"
case .reactions: return "hand.thumbsup.fill"
case .voice: return "mic.fill"
}
}
@@ -682,6 +736,11 @@ public enum GroupFeature: String, Decodable, Feature {
case .on: return "Allow to irreversibly delete sent messages."
case .off: return "Prohibit irreversible message deletion."
}
case .reactions:
switch enabled {
case .on: return "Allow message reactions."
case .off: return "Prohibit messages reactions."
}
case .voice:
switch enabled {
case .on: return "Allow to send voice messages."
@@ -705,6 +764,11 @@ public enum GroupFeature: String, Decodable, Feature {
case .on: return "Group members can irreversibly delete sent messages."
case .off: return "Irreversible message deletion is prohibited in this group."
}
case .reactions:
switch enabled {
case .on: return "Group members can add message reactions."
case .off: return "Message reactions are prohibited in this group."
}
case .voice:
switch enabled {
case .on: return "Group members can send voice messages."
@@ -750,13 +814,22 @@ public struct ContactFeaturesAllowed: Equatable {
public var timedMessagesAllowed: Bool
public var timedMessagesTTL: Int?
public var fullDelete: ContactFeatureAllowed
public var reactions: ContactFeatureAllowed
public var voice: ContactFeatureAllowed
public var calls: ContactFeatureAllowed
public init(timedMessagesAllowed: Bool, timedMessagesTTL: Int?, fullDelete: ContactFeatureAllowed, voice: ContactFeatureAllowed, calls: ContactFeatureAllowed) {
public init(
timedMessagesAllowed: Bool,
timedMessagesTTL: Int?,
fullDelete: ContactFeatureAllowed,
reactions: ContactFeatureAllowed,
voice: ContactFeatureAllowed,
calls: ContactFeatureAllowed
) {
self.timedMessagesAllowed = timedMessagesAllowed
self.timedMessagesTTL = timedMessagesTTL
self.fullDelete = fullDelete
self.reactions = reactions
self.voice = voice
self.calls = calls
}
@@ -765,6 +838,7 @@ public struct ContactFeaturesAllowed: Equatable {
timedMessagesAllowed: false,
timedMessagesTTL: nil,
fullDelete: ContactFeatureAllowed.userDefault(.no),
reactions: ContactFeatureAllowed.userDefault(.yes),
voice: ContactFeatureAllowed.userDefault(.yes),
calls: ContactFeatureAllowed.userDefault(.yes)
)
@@ -777,6 +851,7 @@ public func contactUserPrefsToFeaturesAllowed(_ contactUserPreferences: ContactU
timedMessagesAllowed: allow == .yes || allow == .always,
timedMessagesTTL: pref.preference.ttl,
fullDelete: contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
reactions: contactUserPrefToFeatureAllowed(contactUserPreferences.reactions),
voice: contactUserPrefToFeatureAllowed(contactUserPreferences.voice),
calls: contactUserPrefToFeatureAllowed(contactUserPreferences.calls)
)
@@ -798,6 +873,7 @@ public func contactFeaturesAllowedToPrefs(_ contactFeaturesAllowed: ContactFeatu
Preferences(
timedMessages: TimedMessagesPreference(allow: contactFeaturesAllowed.timedMessagesAllowed ? .yes : .no, ttl: contactFeaturesAllowed.timedMessagesTTL),
fullDelete: contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
reactions: contactFeatureAllowedToPref(contactFeaturesAllowed.reactions),
voice: contactFeatureAllowedToPref(contactFeaturesAllowed.voice),
calls: contactFeatureAllowedToPref(contactFeaturesAllowed.calls)
)
@@ -834,12 +910,20 @@ public struct FullGroupPreferences: Decodable, Equatable {
public var timedMessages: TimedMessagesGroupPreference
public var directMessages: GroupPreference
public var fullDelete: GroupPreference
public var reactions: GroupPreference
public var voice: GroupPreference
public init(timedMessages: TimedMessagesGroupPreference, directMessages: GroupPreference, fullDelete: GroupPreference, voice: GroupPreference) {
public init(
timedMessages: TimedMessagesGroupPreference,
directMessages: GroupPreference,
fullDelete: GroupPreference,
reactions: GroupPreference,
voice: GroupPreference
) {
self.timedMessages = timedMessages
self.directMessages = directMessages
self.fullDelete = fullDelete
self.reactions = reactions
self.voice = voice
}
@@ -847,6 +931,7 @@ public struct FullGroupPreferences: Decodable, Equatable {
timedMessages: TimedMessagesGroupPreference(enable: .off),
directMessages: GroupPreference(enable: .off),
fullDelete: GroupPreference(enable: .off),
reactions: GroupPreference(enable: .on),
voice: GroupPreference(enable: .on)
)
}
@@ -855,12 +940,20 @@ public struct GroupPreferences: Codable {
public var timedMessages: TimedMessagesGroupPreference?
public var directMessages: GroupPreference?
public var fullDelete: GroupPreference?
public var reactions: GroupPreference?
public var voice: GroupPreference?
public init(timedMessages: TimedMessagesGroupPreference?, directMessages: GroupPreference?, fullDelete: GroupPreference?, voice: GroupPreference?) {
public init(
timedMessages: TimedMessagesGroupPreference?,
directMessages: GroupPreference?,
fullDelete: GroupPreference?,
reactions: GroupPreference?,
voice: GroupPreference?
) {
self.timedMessages = timedMessages
self.directMessages = directMessages
self.fullDelete = fullDelete
self.reactions = reactions
self.voice = voice
}
@@ -868,6 +961,7 @@ public struct GroupPreferences: Codable {
timedMessages: TimedMessagesGroupPreference(enable: .off),
directMessages: GroupPreference(enable: .off),
fullDelete: GroupPreference(enable: .off),
reactions: GroupPreference(enable: .on),
voice: GroupPreference(enable: .on)
)
}
@@ -877,6 +971,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group
timedMessages: fullPreferences.timedMessages,
directMessages: fullPreferences.directMessages,
fullDelete: fullPreferences.fullDelete,
reactions: fullPreferences.reactions,
voice: fullPreferences.voice
)
}
@@ -1086,6 +1181,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
switch feature {
case .timedMessages: return cups.timedMessages.enabled.forUser
case .fullDelete: return cups.fullDelete.enabled.forUser
case .reactions: return cups.reactions.enabled.forUser
case .voice: return cups.voice.enabled.forUser
case .calls: return cups.calls.enabled.forUser
}
@@ -1094,6 +1190,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
switch feature {
case .timedMessages: return prefs.timedMessages.on
case .fullDelete: return prefs.fullDelete.on
case .reactions: return prefs.reactions.on
case .voice: return prefs.voice.on
case .calls: return false
}
@@ -1249,6 +1346,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
switch feature {
case .timedMessages: return mergedPreferences.timedMessages.contactPreference.allow != .no
case .fullDelete: return mergedPreferences.fullDelete.contactPreference.allow != .no
case .reactions: return mergedPreferences.reactions.contactPreference.allow != .no
case .voice: return mergedPreferences.voice.contactPreference.allow != .no
case .calls: return mergedPreferences.calls.contactPreference.allow != .no
}
@@ -1258,6 +1356,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
switch feature {
case .timedMessages: return mergedPreferences.timedMessages.userPreference.preference.allow != .no
case .fullDelete: return mergedPreferences.fullDelete.userPreference.preference.allow != .no
case .reactions: return mergedPreferences.reactions.userPreference.preference.allow != .no
case .voice: return mergedPreferences.voice.userPreference.preference.allow != .no
case .calls: return mergedPreferences.calls.userPreference.preference.allow != .no
}
@@ -1802,24 +1901,26 @@ public struct AChatItem: Decodable {
}
}
public struct ACIReaction: Decodable {
public var chatInfo: ChatInfo
public var chatReaction: CIReaction
}
public struct CIReaction: Decodable {
public var chatDir: CIDirection
public var chatItem: ChatItem
public var sentAt: Date
public var reaction: MsgReaction
}
public struct ChatItem: Identifiable, Decodable {
public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, quotedItem: CIQuote? = nil, file: CIFile? = nil) {
public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) {
self.chatDir = chatDir
self.meta = meta
self.content = content
self.formattedText = formattedText
self.quotedItem = quotedItem
self.reactions = [] // [
// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: false, totalReacted: 1),
// CIReaction(reaction: .emoji(emoji: ""), userReacted: false, totalReacted: 1),
// CIReaction(reaction: .emoji(emoji: "🚀"), userReacted: false, totalReacted: 3),
// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "👎"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "👀"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "🎉"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "😀"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "😕"), userReacted: true, totalReacted: 2),
// ]
self.reactions = reactions
self.file = file
}
@@ -1828,24 +1929,14 @@ public struct ChatItem: Identifiable, Decodable {
public var content: CIContent
public var formattedText: [FormattedText]?
public var quotedItem: CIQuote?
public var reactions: [CIReaction] = [] // [
// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: false, totalReacted: 1),
// CIReaction(reaction: .emoji(emoji: ""), userReacted: false, totalReacted: 1),
// CIReaction(reaction: .emoji(emoji: "🚀"), userReacted: false, totalReacted: 3),
// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "👎"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "👀"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "🎉"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "😀"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "😕"), userReacted: true, totalReacted: 2),
// ]
public var reactions: [CIReactionCount]
public var file: CIFile?
public var viewTimestamp = Date.now
public var isLiveDummy: Bool = false
private enum CodingKeys: String, CodingKey {
case chatDir, meta, content, formattedText, quotedItem, file
case chatDir, meta, content, formattedText, quotedItem, reactions, file
}
public var id: Int64 { meta.itemId }
@@ -1920,6 +2011,10 @@ public struct ChatItem: Identifiable, Decodable {
}
}
public var allowAddReaction: Bool {
meta.itemDeleted == nil && !isLiveDummy && reactions.filter({ $0.userReacted }).count < 3
}
public func autoReceiveFile() -> CIFile? {
if let file = file,
let mc = content.msgContent,
@@ -2367,14 +2462,79 @@ public struct CIQuote: Decodable, ItemContent {
}
}
public struct CIReaction: Decodable {
public struct CIReactionCount: Decodable {
public var reaction: MsgReaction
public var userReacted: Bool
public var totalReacted: Int
}
public enum MsgReaction: Decodable, Hashable {
case emoji(emoji: String)
public enum MsgReaction: Hashable {
case emoji(MREmojiChar)
case unknown
public var text: String {
switch self {
case let .emoji(emoji): return emoji.rawValue
case .unknown: return ""
}
}
public var cmdString: String {
switch self {
case let .emoji(emoji): return emoji.cmdString
case .unknown: return ""
}
}
public static var values: [MsgReaction] =
MREmojiChar.allCases.map { .emoji($0) }
enum CodingKeys: String, CodingKey {
case type
case emoji
}
}
public enum MREmojiChar: String, Codable, CaseIterable {
case thumbsup = "👍"
case thumbsdown = "👎"
case smile = "😀"
case celebration = "🎉"
case confused = "😕"
case heart = ""
case launch = "🚀"
case looking = "👀"
public var cmdString: String {
switch self {
case .thumbsup: return "+"
case .thumbsdown: return "-"
case .smile: return ")"
case .celebration: return "!"
case .confused: return "?"
case .heart: return "*"
case .launch: return "^"
case .looking: return "%"
}
}
}
extension MsgReaction: Decodable {
public init(from decoder: Decoder) throws {
do {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: CodingKeys.type)
switch type {
case "emoji":
let emoji = try container.decode(MREmojiChar.self, forKey: CodingKeys.emoji)
self = .emoji(emoji)
default:
self = .unknown
}
} catch {
self = .unknown
}
}
}
public struct CIFile: Decodable {

View File

@@ -91,9 +91,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, testView} liveItems ts
CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts
CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci
CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts testView
CRChatItemReaction u (ACIReaction _ _ chat reaction) added
| showReactions -> ttyUser u $ unmutedReaction chat reaction $ viewItemReaction chat reaction added ts tz
| otherwise -> []
CRChatItemReaction u (ACIReaction _ _ chat reaction) added -> ttyUser u $ unmutedReaction chat reaction $ viewItemReaction showReactions chat reaction added ts tz
CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"]
CRBroadcastSent u mc n t -> ttyUser u $ viewSentBroadcast mc n ts t
CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr
@@ -514,8 +512,8 @@ viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} toItem by
Just (AChatItem _ _ _ ci) -> chatItemDeletedText ci $ chatInfoMembership chat
prohibited = [styled (colored Red) ("[unexpected message deletion, please report to developers]" :: String)]
viewItemReaction :: forall c d. ChatInfo c -> CIReaction c d -> Bool -> CurrentTime -> TimeZone -> [StyledString]
viewItemReaction chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {chatDir = itemDir, content}, sentAt, reaction} added ts tz =
viewItemReaction :: forall c d. Bool -> ChatInfo c -> CIReaction c d -> Bool -> CurrentTime -> TimeZone -> [StyledString]
viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {chatDir = itemDir, content}, sentAt, reaction} added ts tz =
case (chat, chatDir) of
(DirectChat c, CIDirectRcv) -> case content of
CIRcvMsgContent mc -> view from $ reactionMsg mc
@@ -534,7 +532,9 @@ viewItemReaction chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {cha
(_, CIDirectSnd) -> [sentText]
(_, CIGroupSnd) -> [sentText]
where
view from msg = viewReceivedReaction from msg reactionText ts $ utcToZonedTime tz sentAt
view from msg
| showReactions = viewReceivedReaction from msg reactionText ts $ utcToZonedTime tz sentAt
| otherwise = []
reactionText = plain $ (if added then "+ " else "- ") <> [emoji]
MREmoji (MREmojiChar emoji) = reaction
sentText = plain $ (if added then "added " else "removed ") <> [emoji]