ios: knocking ui (#5851)

* ios: knocking ui types

* update types

* member admission

* remove chatItemStatuses

* member support view

* member support chat view wip

* ios: secondary ItemsModel (#5862)

* toolbar

* more chats

* remove theme

* preview icon

* chat toolbar fast markers

* change icon

* change icon

* remove/accept buttons

* item style

* get item ItemsModel in chat model methods (chat view doesn't work dynamically)

* fix support chat

* fix other chats

* refresh on exit

* refresh button

* dynamic marker in chat list

* prohibit multi select actions

* prohibited send field
This commit is contained in:
spaced4ndy
2025-05-04 21:27:20 +00:00
committed by GitHub
parent 524daf178b
commit 32fb5a5d12
42 changed files with 1873 additions and 644 deletions
+53 -31
View File
@@ -39,9 +39,9 @@ enum ChatCommand: ChatCmdProtocol {
case apiGetSettings(settings: AppSettings)
case apiGetChatTags(userId: Int64)
case apiGetChats(userId: Int64)
case apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String)
case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64)
case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String)
case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64)
case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiCreateChatTag(tag: ChatTagData)
case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64])
case apiDeleteChatTag(tagId: Int64)
@@ -49,15 +49,15 @@ enum ChatCommand: ChatCmdProtocol {
case apiReorderChatTags(tagIds: [Int64])
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String)
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
case apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode)
case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
case apiArchiveReceivedReports(groupId: Int64)
case apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode)
case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction)
case apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction)
case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction)
case apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64])
case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?)
case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64])
case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?)
case apiGetNtfToken
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
@@ -68,6 +68,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
case apiJoinGroup(groupId: Int64)
case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole)
case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole)
case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool)
case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool)
@@ -147,8 +148,8 @@ enum ChatCommand: ChatCmdProtocol {
case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus)
// WebRTC calls /
case apiGetNetworkStatuses
case apiChatRead(type: ChatType, id: Int64)
case apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64])
case apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?)
case apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64])
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?)
case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?)
@@ -209,15 +210,16 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))"
case let .apiGetChatTags(userId): return "/_get tags \(userId)"
case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on"
case let .apiGetChat(chatId, pagination, search): return "/_get chat \(chatId) \(pagination.cmdString)" +
(search == "" ? "" : " search=\(search)")
case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)"
case let .apiSendMessages(type, id, live, ttl, composedMessages):
case let .apiGetChat(chatId, scope, contentTag, pagination, search):
let tag = contentTag != nil ? " content=\(contentTag!.rawValue)" : ""
return "/_get chat \(chatId)\(scopeRef(scope: scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)")
case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)"
case let .apiSendMessages(type, id, scope, live, ttl, composedMessages):
let msgs = encodeJSON(composedMessages)
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))"
case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id)) \(tagIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)"
case let .apiUpdateChatTag(tagId, tagData): return "/_update tag \(tagId) \(encodeJSON(tagData))"
case let .apiReorderChatTags(tagIds): return "/_reorder tags \(tagIds.map({ "\($0)" }).joined(separator: ","))"
@@ -226,17 +228,17 @@ enum ChatCommand: ChatCmdProtocol {
return "/_create *\(noteFolderId) json \(msgs)"
case let .apiReportMessage(groupId, chatItemId, reportReason, reportText):
return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)"
case let .apiUpdateChatItem(type, id, itemId, um, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(um.cmdString)"
case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
case let .apiUpdateChatItem(type, id, scope, itemId, um, live): return "/_update item \(ref(type, id, scope: scope)) \(itemId) live=\(onOff(live)) \(um.cmdString)"
case let .apiDeleteChatItem(type, id, scope, itemIds, mode): return "/_delete item \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiArchiveReceivedReports(groupId): return "/_archive reports #\(groupId)"
case let .apiDeleteReceivedReports(groupId, itemIds, mode): return "/_delete reports #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
case let .apiChatItemReaction(type, id, scope, itemId, add, reaction): return "/_reaction \(ref(type, id, scope: scope)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))"
case let .apiPlanForwardChatItems(type, id, itemIds): return "/_forward plan \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl):
case let .apiPlanForwardChatItems(type, id, scope, itemIds): return "/_forward plan \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl):
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
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)"
@@ -247,6 +249,7 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)"
case let .apiMembersRole(groupId, memberIds, memberRole): return "/_member role #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) \(memberRole.rawValue)"
case let .apiBlockMembersForAll(groupId, memberIds, blocked): return "/_block #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) blocked=\(onOff(blocked))"
case let .apiRemoveMembers(groupId, memberIds, withMessages): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))"
@@ -270,13 +273,13 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))"
case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(chatItemTTLStr(seconds: seconds))"
case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id, scope: nil)) \(chatItemTTLStr(seconds: seconds))"
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
case .apiGetNetworkConfig: return "/network"
case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))"
case .reconnectAllServers: return "/reconnect"
case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)"
case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))"
case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id, scope: nil)) \(encodeJSON(chatSettings))"
case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))"
case let .apiContactInfo(contactId): return "/_info @\(contactId)"
case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)"
@@ -308,8 +311,8 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)"
case let .apiConnect(userId, incognito, connLink): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connLink.connFullLink) \(connLink.connShortLink ?? "")"
case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)"
case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id)) \(chatDeleteMode.cmdString)"
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id, scope: nil)) \(chatDeleteMode.cmdString)"
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id, scope: nil))"
case let .apiListContacts(userId): return "/_contacts \(userId)"
case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))"
case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))"
@@ -334,9 +337,9 @@ enum ChatCommand: ChatCmdProtocol {
case .apiGetCallInvitations: return "/_call get"
case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)"
case .apiGetNetworkStatuses: return "/_network_statuses"
case let .apiChatRead(type, id): return "/_read chat \(ref(type, id))"
case let .apiChatItemsRead(type, id, itemIds): return "/_read chat items \(ref(type, id)) \(joinedIds(itemIds))"
case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
case let .apiChatRead(type, id, scope): return "/_read chat \(ref(type, id, scope: scope))"
case let .apiChatItemsRead(type, id, scope, itemIds): return "/_read chat items \(ref(type, id, scope: scope)) \(joinedIds(itemIds))"
case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id, scope: nil)) \(onOff(unreadChat))"
case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))"
case let .cancelFile(fileId): return "/fcancel \(fileId)"
@@ -421,6 +424,7 @@ enum ChatCommand: ChatCmdProtocol {
case .apiNewGroup: return "apiNewGroup"
case .apiAddMember: return "apiAddMember"
case .apiJoinGroup: return "apiJoinGroup"
case .apiAcceptMember: return "apiAcceptMember"
case .apiMembersRole: return "apiMembersRole"
case .apiBlockMembersForAll: return "apiBlockMembersForAll"
case .apiRemoveMembers: return "apiRemoveMembers"
@@ -523,8 +527,20 @@ enum ChatCommand: ChatCmdProtocol {
}
}
func ref(_ type: ChatType, _ id: Int64) -> String {
"\(type.rawValue)\(id)"
func ref(_ type: ChatType, _ id: Int64, scope: GroupChatScope?) -> String {
"\(type.rawValue)\(id)\(scopeRef(scope: scope))"
}
func scopeRef(scope: GroupChatScope?) -> String {
switch (scope) {
case .none: ""
case let .memberSupport(groupMemberId_):
if let groupMemberId = groupMemberId_ {
"(_support:\(groupMemberId))"
} else {
"(_support)"
}
}
}
func joinedIds(_ ids: [Int64]) -> String {
@@ -676,6 +692,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol {
case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole)
case groupDeletedUser(user: UserRef, groupInfo: GroupInfo)
case joinedGroupMemberConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember)
case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
case memberAcceptedByOther(user: UserRef, groupInfo: GroupInfo, acceptingMember: GroupMember, member: GroupMember)
case memberRole(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole)
case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole)
case memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool)
@@ -856,6 +874,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol {
case .receivedGroupInvitation: return "receivedGroupInvitation"
case .groupDeletedUser: return "groupDeletedUser"
case .joinedGroupMemberConnecting: return "joinedGroupMemberConnecting"
case .memberAccepted: return "memberAccepted"
case .memberAcceptedByOther: return "memberAcceptedByOther"
case .memberRole: return "memberRole"
case .membersRoleUser: return "membersRoleUser"
case .memberBlockedForAll: return "memberBlockedForAll"
@@ -1043,6 +1063,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol {
case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)")
case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)")
case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .memberAcceptedByOther(u, groupInfo, acceptingMember, member): return withUser(u, "groupInfo: \(groupInfo)\nacceptingMember: \(acceptingMember)\nmember: \(member)")
case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)")
case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)")
case let .memberBlockedForAll(u, groupInfo, byMember, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nblocked: \(blocked)")
+192 -87
View File
@@ -43,8 +43,26 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
items.append(item)
}
// analogue for SecondaryContextFilter in Kotlin
enum SecondaryItemsModelFilter {
case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo)
case msgContentTagContext(contentTag: MsgContentTag)
func descr() -> String {
switch self {
case let .groupChatScopeContext(groupScopeInfo):
return "groupChatScopeContext \(groupScopeInfo.toChatScope())"
case let .msgContentTagContext(contentTag):
return "msgContentTagContext \(contentTag.rawValue)"
}
}
}
// analogue for ChatsContext in Kotlin
class ItemsModel: ObservableObject {
static let shared = ItemsModel()
static let shared = ItemsModel(secondaryIMFilter: nil)
public var secondaryIMFilter: SecondaryItemsModelFilter?
public var preloadState = PreloadState()
private let publisher = ObservableObjectPublisher()
private var bag = Set<AnyCancellable>()
var reversedChatItems: [ChatItem] = [] {
@@ -68,7 +86,8 @@ class ItemsModel: ObservableObject {
chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id
}
init() {
init(secondaryIMFilter: SecondaryItemsModelFilter? = nil) {
self.secondaryIMFilter = secondaryIMFilter
publisher
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
@@ -83,6 +102,9 @@ class ItemsModel: ObservableObject {
try await Task.sleep(nanoseconds: 250_000000)
await MainActor.run {
ChatModel.shared.chatId = chatId
if secondaryIMFilter != nil {
ChatModel.shared.secondaryIM = self
}
willNavigate()
}
} catch {}
@@ -90,7 +112,7 @@ class ItemsModel: ObservableObject {
loadChatTask = Task {
await MainActor.run { self.isLoading = true }
// try? await Task.sleep(nanoseconds: 1000_000000)
await loadChat(chatId: chatId)
await loadChat(chatId: chatId, im: self)
if !Task.isCancelled {
await MainActor.run {
self.isLoading = false
@@ -105,7 +127,7 @@ class ItemsModel: ObservableObject {
loadChatTask?.cancel()
loadChatTask = Task {
// try? await Task.sleep(nanoseconds: 1000_000000)
await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
await loadChat(chatId: chatId, im: self, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
if !Task.isCancelled {
await MainActor.run {
if openAroundItemId == nil {
@@ -115,6 +137,34 @@ class ItemsModel: ObservableObject {
}
}
}
public var contentTag: MsgContentTag? {
switch secondaryIMFilter {
case nil: nil
case .groupChatScopeContext: nil
case let .msgContentTagContext(contentTag): contentTag
}
}
public var groupScopeInfo: GroupChatScopeInfo? {
switch secondaryIMFilter {
case nil: nil
case let .groupChatScopeContext(scopeInfo): scopeInfo
case .msgContentTagContext: nil
}
}
}
class PreloadState {
var prevFirstVisible: Int64 = Int64.min
var prevItemsCount: Int = 0
var preloading: Bool = false
func clear() {
prevFirstVisible = Int64.min
prevItemsCount = 0
preloading = false
}
}
class ChatTagsModel: ObservableObject {
@@ -278,7 +328,6 @@ final class ChatModel: ObservableObject {
// current chat
@Published var chatId: String?
@Published var openAroundItemId: ChatItem.ID? = nil
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String?
@Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
@@ -327,6 +376,9 @@ final class ChatModel: ObservableObject {
let im = ItemsModel.shared
// ItemsModel for secondary chat view (such as support scope chat), as opposed to ItemsModel.shared used for primary chat
@Published var secondaryIM: ItemsModel? = nil
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true
@@ -384,7 +436,7 @@ final class ChatModel: ObservableObject {
func getGroupChat(_ groupId: Int64) -> Chat? {
chats.first { chat in
if case let .group(groupInfo) = chat.chatInfo {
if case let .group(groupInfo, _) = chat.chatInfo {
return groupInfo.groupId == groupId
} else {
return false
@@ -459,7 +511,7 @@ final class ChatModel: ObservableObject {
}
func updateGroup(_ groupInfo: GroupInfo) {
updateChat(.group(groupInfo: groupInfo))
updateChat(.group(groupInfo: groupInfo, groupChatScope: nil))
}
private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) {
@@ -502,77 +554,105 @@ final class ChatModel: ObservableObject {
// }
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// mark chat non deleted
if case let .direct(contact) = cInfo, contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
updateContact(updatedContact)
}
// update previews
if let i = getChatIndex(cInfo.id) {
chats[i].chatItems = switch cInfo {
case .group:
if let currentPreviewItem = chats[i].chatItems.first {
if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs {
[cItem]
// update chat list
if cInfo.groupChatScope() == nil {
// mark chat non deleted
if case let .direct(contact) = cInfo, contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
updateContact(updatedContact)
}
// update preview
if let i = getChatIndex(cInfo.id) {
chats[i].chatItems = switch cInfo {
case .group:
if let currentPreviewItem = chats[i].chatItems.first {
if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs {
[cItem]
} else {
[currentPreviewItem]
}
} else {
[currentPreviewItem]
[cItem]
}
} else {
default:
[cItem]
}
default:
[cItem]
if case .rcvNew = cItem.meta.itemStatus {
unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0)
}
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
}
if case .rcvNew = cItem.meta.itemStatus {
unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0)
}
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
}
// add to current chat
if chatId == cInfo.id {
_ = _upsertChatItem(cInfo, cItem)
// add to current scope
if let ciIM = getCIItemsModel(cInfo, cItem) {
_ = _upsertChatItem(ciIM, cInfo, cItem)
}
}
func getCIItemsModel(_ cInfo: ChatInfo, _ ci: ChatItem) -> ItemsModel? {
let cInfoScope = cInfo.groupChatScope()
if let cInfoScope = cInfoScope {
switch cInfoScope {
case .memberSupport:
switch secondaryIM?.secondaryIMFilter {
case .none:
return nil
case let .groupChatScopeContext(groupScopeInfo):
return (cInfo.id == chatId && sameChatScope(cInfoScope, groupScopeInfo.toChatScope())) ? secondaryIM : nil
case let .msgContentTagContext(contentTag):
return (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil
}
}
} else {
return cInfo.id == chatId ? im : nil
}
}
func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
// update previews
var res: Bool
if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last {
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
// update chat list
var itemAdded: Bool = false
if cInfo.groupChatScope() == nil {
if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last {
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
chat.chatItems = [cItem]
}
} else {
chat.chatItems = [cItem]
}
} else {
chat.chatItems = [cItem]
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
itemAdded = true
}
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
res = false
} else {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
res = true
}
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
// update current scope
if let ciIM = getCIItemsModel(cInfo, cItem) {
itemAdded = _upsertChatItem(ciIM, cInfo, cItem)
}
// update current chat
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
return itemAdded
}
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) {
_updateChatItem(at: i, with: cItem)
ChatItemDummyModel.shared.sendUpdate()
private func _upsertChatItem(_ ciIM: ItemsModel, _ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(ciIM, cItem) {
let oldStatus = ciIM.reversedChatItems[i].meta.itemStatus
let newStatus = cItem.meta.itemStatus
var ci = cItem
if shouldKeepOldSndCIStatus(oldStatus: oldStatus, newStatus: newStatus) {
ci.meta.itemStatus = oldStatus
}
_updateChatItem(ciIM: ciIM, at: i, with: ci)
ChatItemDummyModel.shared.sendUpdate() // TODO [knocking] review what's this
return false
} else {
var ci = cItem
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
}
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.chatState.itemAdded((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0)
im.itemAdded = true
ciIM.reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0)
ciIM.chatState.itemAdded((cItem.id, cItem.isRcvNew), hasLiveDummy ? 1 : 0)
ciIM.itemAdded = true
ChatItemDummyModel.shared.sendUpdate()
return true
}
@@ -586,40 +666,42 @@ final class ChatModel: ObservableObject {
}
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
if let ciIM = getCIItemsModel(cInfo, cItem),
let i = getChatItemIndex(ciIM, cItem) {
withConditionalAnimation {
_updateChatItem(at: i, with: cItem)
_updateChatItem(ciIM: ciIM, at: i, with: cItem)
}
} else if let status = status {
chatItemStatuses.updateValue(status, forKey: cItem.id)
}
}
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
im.reversedChatItems[i] = cItem
im.reversedChatItems[i].viewTimestamp = .now
private func _updateChatItem(ciIM: ItemsModel, at i: Int, with cItem: ChatItem) {
ciIM.reversedChatItems[i] = cItem
ciIM.reversedChatItems[i].viewTimestamp = .now
}
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
func getChatItemIndex(_ ciIM: ItemsModel, _ cItem: ChatItem) -> Int? {
ciIM.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew {
unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0)
}
// update previews
if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last, pItem.id == cItem.id {
chat.chatItems = [ChatItem.deletedItemDummy()]
// update chat list
if cInfo.groupChatScope() == nil {
if cItem.isRcvNew {
unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0)
}
// update previews
if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last, pItem.id == cItem.id {
chat.chatItems = [ChatItem.deletedItemDummy()]
}
}
}
// remove from current chat
if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) {
// remove from current scope
if let ciIM = getCIItemsModel(cInfo, cItem) {
if let i = getChatItemIndex(ciIM, cItem) {
withAnimation {
let item = im.reversedChatItems.remove(at: i)
im.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
let item = ciIM.reversedChatItems.remove(at: i)
ciIM.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
}
}
}
@@ -635,7 +717,7 @@ final class ChatModel: ObservableObject {
if chatId == groupInfo.id {
for i in 0..<im.reversedChatItems.count {
if let updatedItem = removedUpdatedItem(im.reversedChatItems[i]) {
_updateChatItem(at: i, with: updatedItem)
_updateChatItem(ciIM: im, at: i, with: updatedItem) // TODO [knocking] review: use getCIItemsModel?
}
}
} else if let chat = getChat(groupInfo.id),
@@ -763,7 +845,6 @@ final class ChatModel: ObservableObject {
}
// clear current chat
if chatId == cInfo.id {
chatItemStatuses = [:]
im.reversedChatItems = []
im.chatState.clear()
}
@@ -938,6 +1019,23 @@ final class ChatModel: ObservableObject {
return unread
}
func increaseGroupSupportChatsUnreadCounter(_ chatId: ChatId) {
changeGroupSupportChatsUnreadCounter(chatId, 1)
}
func decreaseGroupSupportChatsUnreadCounter(_ chatId: ChatId, by: Int = 1) {
changeGroupSupportChatsUnreadCounter(chatId, -by)
}
private func changeGroupSupportChatsUnreadCounter(_ chatId: ChatId, _ by: Int = 0) {
if by == 0 { return }
if let i = getChatIndex(chatId) {
let chat = chats[i]
chat.chatStats.supportChatsUnreadCount = max(0, chat.chatStats.supportChatsUnreadCount + by)
}
}
func increaseGroupReportsCounter(_ chatId: ChatId) {
changeGroupReportsCounter(chatId, 1)
}
@@ -964,7 +1062,7 @@ final class ChatModel: ObservableObject {
var count = 0
var ns: [String] = []
if let ciCategory = chatItem.mergeCategory,
var i = getChatItemIndex(chatItem) {
var i = getChatItemIndex(im, chatItem) { // TODO [knocking] review: use getCIItemsModel?
while i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
if ci.mergeCategory != ciCategory { break }
@@ -980,7 +1078,7 @@ final class ChatModel: ObservableObject {
// returns the index of the passed item and the next item (it has smaller index)
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
if let i = getChatItemIndex(ci) {
if let i = getChatItemIndex(im, ci) { // TODO [knocking] review: use getCIItemsModel?
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
} else {
(nil, nil)
@@ -1091,7 +1189,7 @@ final class ChatModel: ObservableObject {
func removeWallpaperFilesFromChat(_ chat: Chat) {
if case let .direct(contact) = chat.chatInfo {
removeWallpaperFilesFromTheme(contact.uiThemes)
} else if case let .group(groupInfo) = chat.chatInfo {
} else if case let .group(groupInfo, _) = chat.chatInfo {
removeWallpaperFilesFromTheme(groupInfo.uiThemes)
}
}
@@ -1146,9 +1244,9 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
var userCanSend: Bool {
switch chatInfo {
case .direct: return true
case let .group(groupInfo):
case let .group(groupInfo, groupChatScope):
let m = groupInfo.membership
return m.memberActive && m.memberRole >= .member
return (m.memberActive && m.memberRole >= .member && !m.memberPending) || groupChatScope != nil
case .local:
return true
default: return false
@@ -1157,13 +1255,20 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
var userIsObserver: Bool {
switch chatInfo {
case let .group(groupInfo):
case let .group(groupInfo, _):
let m = groupInfo.membership
return m.memberActive && m.memberRole == .observer
default: return false
}
}
var userIsPending: Bool {
switch chatInfo {
case let .group(groupInfo, _): groupInfo.membership.memberPending
default: false
}
}
var unreadTag: Bool {
switch chatInfo.chatSettings?.enableNtfs {
case .all: chatStats.unreadChat || chatStats.unreadCount > 0
+59 -32
View File
@@ -328,43 +328,54 @@ func apiGetChatTagsAsync() async throws -> [ChatTag] {
let loadItemsPerPage = 50
func apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
let r = await chatSendCmd(.apiGetChat(chatId: chatId, pagination: pagination, search: search))
func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag? = nil, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
let r = await chatSendCmd(.apiGetChat(chatId: chatId, scope: scope, contentTag: contentTag, pagination: pagination, search: search))
if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) }
throw r
}
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems)
func loadChat(chat: Chat, im: ItemsModel, search: String = "", clearItems: Bool = true) async {
await loadChat(chatId: chat.chatInfo.id, im: im, search: search, clearItems: clearItems)
}
func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
let m = ChatModel.shared
let im = ItemsModel.shared
func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
await MainActor.run {
m.chatItemStatuses = [:]
if clearItems {
im.reversedChatItems = []
ItemsModel.shared.chatState.clear()
im.chatState.clear()
}
}
await apiLoadMessages(chatId, openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : (search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)), im.chatState, search, openAroundItemId, { 0...0 })
await apiLoadMessages(
chatId,
im,
( // pagination
openAroundItemId != nil
? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage)
: (
search == ""
? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)
)
),
search,
openAroundItemId,
{ 0...0 }
)
}
func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
let r = await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId))
func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> ChatItemInfo {
let r = await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, scope: scope, itemId: itemId))
if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo }
throw r
}
func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
let r = await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds))
func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
let r = await chatSendCmd(.apiPlanForwardChatItems(fromChatType: type, fromChatId: id, fromScope: scope, itemIds: itemIds))
if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) }
throw r
}
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
@@ -396,8 +407,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws {
try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds))
}
func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages)
func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, live: live, ttl: ttl, composedMessages: composedMessages)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
}
@@ -474,14 +485,14 @@ private func createChatItemsErrorAlert(_ r: ChatResponse) {
)
}
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem {
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay)
func apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem {
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, scope: scope, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay)
if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem }
throw r
}
func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem {
let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
func apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem {
let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, scope: scope, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem }
throw r
}
@@ -493,8 +504,8 @@ func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction)
throw r
}
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
func apiDeleteChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, scope: scope, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
throw r
}
@@ -1212,12 +1223,12 @@ func apiRejectContactRequest(contactReqId: Int64) async throws {
throw r
}
func apiChatRead(type: ChatType, id: Int64) async throws {
try await sendCommandOkResp(.apiChatRead(type: type, id: id))
func apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) async throws {
try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: scope))
}
func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds))
func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws {
try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds))
}
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
@@ -1523,7 +1534,7 @@ func markChatRead(_ chat: Chat) async {
do {
if chat.chatStats.unreadCount > 0 {
let cInfo = chat.chatInfo
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId)
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope())
await MainActor.run {
withAnimation { ChatModel.shared.markAllChatItemsRead(cInfo) }
}
@@ -1550,7 +1561,7 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async {
do {
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds)
DispatchQueue.main.async {
ChatModel.shared.markChatItemsRead(cInfo, itemIds, mentionsRead)
}
@@ -1600,6 +1611,12 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
}
}
func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
let r = await chatSendCmd(.apiAcceptMember(groupId: groupId, groupMemberId: groupMemberId, memberRole: memberRole))
if case let .memberAccepted(_, _, member) = r { return member }
throw r
}
func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> [GroupMember] {
let r = await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false)
if case let .userDeletedMembers(_, _, members, withMessages) = r { return members }
@@ -2086,6 +2103,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
if cItem.isActiveReport {
m.increaseGroupReportsCounter(cInfo.id)
}
if cInfo.groupChatScope() != nil && cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) {
m.increaseGroupSupportChatsUnreadCounter(cInfo.id)
}
} else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) {
m.increaseUnreadCounter(user: user)
}
@@ -2104,7 +2124,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
let cInfo = chatItem.chatInfo
let cItem = chatItem.chatItem
if !cItem.isDeletedContent && active(user) {
await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
_ = await MainActor.run { m.upsertChatItem(cInfo, cItem) }
}
if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus {
@@ -2200,6 +2220,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
_ = m.upsertGroupMember(groupInfo, member)
}
}
case let .memberAcceptedByOther(user, groupInfo, _, member):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
}
case let .deletedMemberUser(user, groupInfo, member, withMessages): // TODO update user member
if active(user) {
await MainActor.run {
@@ -2231,6 +2257,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
}
case let .userJoinedGroup(user, groupInfo):
// TODO [knocking] close support scope for this group if it's currently opened
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
@@ -2517,7 +2544,7 @@ func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemID
return
}
let im = ItemsModel.shared
let cInfo = ChatInfo.group(groupInfo: groupInfo)
let cInfo = ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)
await MainActor.run {
m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count)
}
+1 -1
View File
@@ -159,7 +159,7 @@ struct SimpleXApp: App {
if let id = chatModel.chatId,
let chat = chatModel.getChat(id),
!NtfManager.shared.navigatingToChat {
Task { await loadChat(chat: chat, clearItems: false) }
Task { await loadChat(chat: chat, im: ItemsModel.shared, clearItems: false) }
}
if let ncr = chatModel.ntfContactRequest {
await MainActor.run { chatModel.ntfContactRequest = nil }
@@ -22,11 +22,28 @@ struct ChatInfoToolbar: View {
Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo)
Spacer().frame(width: 16)
}
ChatInfoImage(
chat: chat,
size: imageSize,
color: Color(uiColor: .tertiaryLabel)
)
ZStack(alignment: .bottomTrailing) {
ChatInfoImage(
chat: chat,
size: imageSize,
color: Color(uiColor: .tertiaryLabel)
)
if chat.chatStats.reportsCount > 0 {
Image(systemName: "flag.circle.fill")
.resizable()
.scaledToFit()
.frame(width: 14, height: 14)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .red)
} else if chat.chatStats.supportChatsUnreadCount > 0 {
Image(systemName: "flag.circle.fill")
.resizable()
.scaledToFit()
.frame(width: 14, height: 14)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, theme.colors.primary)
}
}
.padding(.trailing, 4)
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
@@ -687,7 +687,7 @@ struct ChatTTLOption: View {
let m = ChatModel.shared
do {
try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl)
await loadChat(chat: chat, clearItems: true)
await loadChat(chat: chat, im: ItemsModel.shared, clearItems: true)
await MainActor.run {
progressIndicator = false
currentChatItemTTL = chatItemTTL
@@ -700,7 +700,7 @@ struct ChatTTLOption: View {
}
catch let error {
logger.error("setChatTTL error \(responseError(error))")
await loadChat(chat: chat, clearItems: true)
await loadChat(chat: chat, im: ItemsModel.shared, clearItems: true)
await MainActor.run {
chatItemTTL = currentChatItemTTL
progressIndicator = false
@@ -938,7 +938,7 @@ struct ChatWallpaperEditorSheet: View {
self.chat = chat
self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
uiThemes
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
} else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
@@ -974,7 +974,7 @@ struct ChatWallpaperEditorSheet: View {
private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides {
if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
uiThemes
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
} else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
@@ -1052,12 +1052,12 @@ struct ChatWallpaperEditorSheet: View {
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact))
themes = themesFromChat(chat.wrappedValue)
}
} else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo {
} else if case var ChatInfo.group(groupInfo, _) = chat.wrappedValue.chatInfo {
groupInfo.uiThemes = changedThemesConstant
await MainActor.run {
ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo))
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo))
ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil))
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil))
themes = themesFromChat(chat.wrappedValue)
}
}
@@ -12,8 +12,8 @@ import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.revealed) var revealed: Bool
@ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat
@ObservedObject var im: ItemsModel
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var feature: Feature
@@ -53,7 +53,7 @@ struct CIChatFeatureView: View {
private func mergedFeatures() -> [FeatureInfo]? {
var fs: [FeatureInfo] = []
var icons: Set<String> = []
if var i = m.getChatItemIndex(chatItem) {
if var i = m.getChatItemIndex(im, chatItem) {
while i < im.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) {
if !icons.contains(f.icon) {
@@ -108,6 +108,7 @@ struct CIChatFeatureView_Previews: PreviewProvider {
let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(
chat: Chat.sampleData,
im: ItemsModel.shared,
chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
).environment(\.revealed, true)
}
@@ -278,6 +278,7 @@ func showFileErrorAlert(_ err: FileError, temporary: Bool = false) {
struct CIFileView_Previews: PreviewProvider {
static var previews: some View {
let im = ItemsModel.shared
let sentFile: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
@@ -293,16 +294,16 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(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.", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentFile, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(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.", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: fileChatItemWtFile, scrollToItemId: { _ in })
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
@@ -45,7 +45,7 @@ struct CIRcvDecryptionError: View {
viewBody()
.onAppear {
// for direct chat ConnectionStats are populated on opening chat, see ChatView onAppear
if case let .group(groupInfo) = chat.chatInfo,
if case let .group(groupInfo, _) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir {
do {
let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId)
@@ -83,7 +83,7 @@ struct CIRcvDecryptionError: View {
} else {
basicDecryptionErrorItem()
}
} else if case let .group(groupInfo) = chat.chatInfo,
} else if case let .group(groupInfo, _) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir,
let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
@@ -476,6 +476,7 @@ class VoiceItemState {
struct CIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let im = ItemsModel.shared
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
@@ -498,10 +499,10 @@ struct CIVoiceView_Previews: PreviewProvider {
duration: 30,
allowMenu: Binding.constant(true)
)
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true))
}
.previewLayout(.fixed(width: 360, height: 360))
}
@@ -77,6 +77,7 @@ struct FramedCIVoiceView: View {
struct FramedCIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let im = ItemsModel.shared
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
@@ -92,11 +93,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, 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."), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, 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."), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in })
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
File diff suppressed because one or more lines are too long
@@ -14,6 +14,7 @@ struct MarkedDeletedItemView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
@ObservedObject var im: ItemsModel
var chatItem: ChatItem
var body: some View {
@@ -29,14 +30,14 @@ struct MarkedDeletedItemView: View {
var mergedMarkedDeletedText: LocalizedStringKey {
if !revealed,
let ciCategory = chatItem.mergeCategory,
var i = m.getChatItemIndex(chatItem) {
var i = m.getChatItemIndex(im, chatItem) {
var moderated = 0
var blocked = 0
var blockedByAdmin = 0
var deleted = 0
var moderatedBy: Set<String> = []
while i < ItemsModel.shared.reversedChatItems.count,
let ci = .some(ItemsModel.shared.reversedChatItems[i]),
while i < im.reversedChatItems.count,
let ci = .some(im.reversedChatItems[i]),
ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted {
@@ -85,6 +86,7 @@ struct MarkedDeletedItemView_Previews: PreviewProvider {
Group {
MarkedDeletedItemView(
chat: Chat.sampleData,
im: ItemsModel.shared,
chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))
).environment(\.revealed, true)
}
+34 -13
View File
@@ -31,6 +31,7 @@ extension EnvironmentValues {
struct ChatItemView: View {
@ObservedObject var chat: Chat
@ObservedObject var im: ItemsModel
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@Environment(\.revealed) var revealed: Bool
@@ -41,6 +42,7 @@ struct ChatItemView: View {
init(
chat: Chat,
im: ItemsModel,
chatItem: ChatItem,
scrollToItemId: @escaping (ChatItem.ID) -> Void,
showMember: Bool = false,
@@ -48,6 +50,7 @@ struct ChatItemView: View {
allowMenu: Binding<Bool> = .constant(false)
) {
self.chat = chat
self.im = im
self.chatItem = chatItem
self.scrollToItemId = scrollToItemId
self.maxWidth = maxWidth
@@ -57,14 +60,14 @@ struct ChatItemView: View {
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
MarkedDeletedItemView(chat: chat, chatItem: chatItem)
MarkedDeletedItemView(chat: chat, im: im, chatItem: chatItem)
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
@@ -92,6 +95,7 @@ struct ChatItemView: View {
}()
return FramedItemView(
chat: chat,
im: im,
chatItem: chatItem,
scrollToItemId: scrollToItemId,
preview: preview,
@@ -108,6 +112,7 @@ struct ChatItemContentView<Content: View>: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
@ObservedObject var im: ItemsModel
var chatItem: ChatItem
var msgContentView: () -> Content
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -131,7 +136,9 @@ struct ChatItemContentView<Content: View>: View {
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case .rcvDirectEvent: eventItemView()
case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
case .rcvGroupEvent(.newMemberPendingReview): CIEventView(eventText: pendingReviewEventItemText())
case .rcvGroupEvent: eventItemView()
case .sndGroupEvent(.userPendingReview): CIEventView(eventText: pendingReviewEventItemText())
case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView()
case .sndConnEvent: eventItemView()
@@ -140,7 +147,7 @@ struct ChatItemContentView<Content: View>: View {
case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _):
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
@@ -172,6 +179,13 @@ struct ChatItemContentView<Content: View>: View {
CIEventView(eventText: eventItemViewText(theme.colors.secondary))
}
private func pendingReviewEventItemText() -> Text {
Text(chatItem.content.text)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.fontWeight(.bold)
}
private func eventItemViewText(_ secondaryColor: Color) -> Text {
if !revealed, let t = mergedGroupEventText {
return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor)
@@ -187,7 +201,7 @@ struct ChatItemContentView<Content: View>: View {
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor)
CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, iconColor: iconColor)
}
private var mergedGroupEventText: Text? {
@@ -247,16 +261,17 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
let im = ItemsModel.shared
Group{
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 70))
@@ -266,10 +281,12 @@ struct ChatItemView_Previews: PreviewProvider {
struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
static var previews: some View {
let im = ItemsModel.shared
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil)
Group{
ChatItemView(
chat: Chat.sampleData,
im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -281,6 +298,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
)
ChatItemView(
chat: Chat.sampleData,
im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead),
@@ -292,6 +310,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
)
ChatItemView(
chat: Chat.sampleData,
im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -303,6 +322,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
)
ChatItemView(
chat: Chat.sampleData,
im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -314,6 +334,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
)
ChatItemView(
chat: Chat.sampleData,
im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -13,8 +13,8 @@ let TRIM_KEEP_COUNT = 200
func apiLoadMessages(
_ chatId: ChatId,
_ im: ItemsModel,
_ pagination: ChatPagination,
_ chatState: ActiveChatState,
_ search: String = "",
_ openAroundItemId: ChatItem.ID? = nil,
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int> = { 0 ... 0 }
@@ -22,7 +22,7 @@ func apiLoadMessages(
let chat: Chat
let navInfo: NavigationInfo
do {
(chat, navInfo) = try await apiGetChat(chatId: chatId, pagination: pagination, search: search)
(chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: im.contentTag, pagination: pagination, search: search)
} catch let error {
logger.error("apiLoadMessages error: \(responseError(error))")
return
@@ -38,30 +38,31 @@ func apiLoadMessages(
return
}
let unreadAfterItemId = chatState.unreadAfterItemId
let unreadAfterItemId = im.chatState.unreadAfterItemId
let oldItems = Array(ItemsModel.shared.reversedChatItems.reversed())
let oldItems = Array(im.reversedChatItems.reversed())
var newItems: [ChatItem] = []
switch pagination {
case .initial:
let newSplits: [Int64] = if !chat.chatItems.isEmpty && navInfo.afterTotal > 0 { [chat.chatItems.last!.id] } else { [] }
if chatModel.getChat(chat.id) == nil {
if im.secondaryIMFilter == nil && chatModel.getChat(chat.id) == nil {
chatModel.addChat(chat)
}
await MainActor.run {
chatModel.chatItemStatuses.removeAll()
ItemsModel.shared.reversedChatItems = chat.chatItems.reversed()
chatModel.updateChatInfo(chat.chatInfo)
chatState.splits = newSplits
if !chat.chatItems.isEmpty {
chatState.unreadAfterItemId = chat.chatItems.last!.id
im.reversedChatItems = chat.chatItems.reversed()
if im.secondaryIMFilter == nil {
chatModel.updateChatInfo(chat.chatInfo)
}
chatState.totalAfter = navInfo.afterTotal
chatState.unreadTotal = chat.chatStats.unreadCount
chatState.unreadAfter = navInfo.afterUnread
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
im.chatState.splits = newSplits
if !chat.chatItems.isEmpty {
im.chatState.unreadAfterItemId = chat.chatItems.last!.id
}
im.chatState.totalAfter = navInfo.afterTotal
im.chatState.unreadTotal = chat.chatStats.unreadCount
im.chatState.unreadAfter = navInfo.afterUnread
im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread
PreloadState.shared.clear()
im.preloadState.clear()
}
case let .before(paginationChatItemId, _):
newItems.append(contentsOf: oldItems)
@@ -71,15 +72,15 @@ func apiLoadMessages(
let wasSize = newItems.count
let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination(
unreadAfterItemId, &newItems, newIds, chatState.splits, visibleItemIndexes
unreadAfterItemId, &newItems, newIds, im.chatState.splits, visibleItemIndexes
)
let insertAt = max((indexInCurrentItems - (wasSize - newItems.count) + modifiedSplits.trimmedIds.count), 0)
newItems.insert(contentsOf: chat.chatItems, at: insertAt)
let newReversed: [ChatItem] = newItems.reversed()
await MainActor.run {
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = modifiedSplits.newSplits
chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems)
im.reversedChatItems = newReversed
im.chatState.splits = modifiedSplits.newSplits
im.chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems)
}
case let .after(paginationChatItemId, _):
newItems.append(contentsOf: oldItems)
@@ -89,7 +90,7 @@ func apiLoadMessages(
let mappedItems = mapItemsToIds(chat.chatItems)
let newIds = mappedItems.0
let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination(
mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits
mappedItems.1, paginationChatItemId, &newItems, newIds, chat, im.chatState.splits
)
let indexToAdd = min(indexInCurrentItems + 1, newItems.count)
let indexToAddIsLast = indexToAdd == newItems.count
@@ -97,19 +98,19 @@ func apiLoadMessages(
let new: [ChatItem] = newItems
let newReversed: [ChatItem] = newItems.reversed()
await MainActor.run {
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = newSplits
chatState.moveUnreadAfterItem(chatState.splits.first ?? new.last!.id, new)
im.reversedChatItems = newReversed
im.chatState.splits = newSplits
im.chatState.moveUnreadAfterItem(im.chatState.splits.first ?? new.last!.id, new)
// loading clear bottom area, updating number of unread items after the newest loaded item
if indexToAddIsLast {
chatState.unreadAfterNewestLoaded -= unreadInLoaded
im.chatState.unreadAfterNewestLoaded -= unreadInLoaded
}
}
case .around:
var newSplits: [Int64]
if openAroundItemId == nil {
newItems.append(contentsOf: oldItems)
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, im.chatState.splits, visibleItemIndexesNonReversed)
} else {
newSplits = []
}
@@ -120,33 +121,37 @@ func apiLoadMessages(
let newReversed: [ChatItem] = newItems.reversed()
let orderedSplits = newSplits
await MainActor.run {
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = orderedSplits
chatState.unreadAfterItemId = chat.chatItems.last!.id
chatState.totalAfter = navInfo.afterTotal
chatState.unreadTotal = chat.chatStats.unreadCount
chatState.unreadAfter = navInfo.afterUnread
im.reversedChatItems = newReversed
im.chatState.splits = orderedSplits
im.chatState.unreadAfterItemId = chat.chatItems.last!.id
im.chatState.totalAfter = navInfo.afterTotal
im.chatState.unreadTotal = chat.chatStats.unreadCount
im.chatState.unreadAfter = navInfo.afterUnread
if let openAroundItemId {
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
ChatModel.shared.openAroundItemId = openAroundItemId
ChatModel.shared.chatId = chatId
im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread
if im.secondaryIMFilter == nil {
ChatModel.shared.openAroundItemId = openAroundItemId // TODO [knocking] move openAroundItemId from ChatModel to ItemsModel?
ChatModel.shared.chatId = chat.id
}
} else {
// no need to set it, count will be wrong
// chatState.unreadAfterNewestLoaded = navInfo.afterUnread
}
PreloadState.shared.clear()
im.preloadState.clear()
}
case .last:
newItems.append(contentsOf: oldItems)
let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits)
let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, im.chatState.splits)
newItems.append(contentsOf: chat.chatItems)
let items = newItems
await MainActor.run {
ItemsModel.shared.reversedChatItems = items.reversed()
chatState.splits = newSplits
chatModel.updateChatInfo(chat.chatInfo)
chatState.unreadAfterNewestLoaded = 0
im.reversedChatItems = items.reversed()
im.chatState.splits = newSplits
if im.secondaryIMFilter == nil {
chatModel.updateChatInfo(chat.chatInfo)
}
im.chatState.unreadAfterNewestLoaded = 0
}
}
}
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct MergedItems: Hashable, Equatable {
let im: ItemsModel
let items: [MergedItem]
let splits: [SplitRange]
// chat item id, index in list
@@ -23,15 +24,15 @@ struct MergedItems: Hashable, Equatable {
hasher.combine("\(items.hashValue)")
}
static func create(_ items: [ChatItem], _ revealedItems: Set<Int64>, _ chatState: ActiveChatState) -> MergedItems {
if items.isEmpty {
return MergedItems(items: [], splits: [], indexInParentItems: [:])
static func create(_ im: ItemsModel, _ revealedItems: Set<Int64>) -> MergedItems {
if im.reversedChatItems.isEmpty {
return MergedItems(im: im, items: [], splits: [], indexInParentItems: [:])
}
let unreadCount = chatState.unreadTotal
let unreadCount = im.chatState.unreadTotal
let unreadAfterItemId = chatState.unreadAfterItemId
let itemSplits = chatState.splits
let unreadAfterItemId = im.chatState.unreadAfterItemId
let itemSplits = im.chatState.splits
var mergedItems: [MergedItem] = []
// Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
var splitRanges: [SplitRange] = []
@@ -40,19 +41,19 @@ struct MergedItems: Hashable, Equatable {
var unclosedSplitIndex: Int? = nil
var unclosedSplitIndexInParent: Int? = nil
var visibleItemIndexInParent = -1
var unreadBefore = unreadCount - chatState.unreadAfterNewestLoaded
var unreadBefore = unreadCount - im.chatState.unreadAfterNewestLoaded
var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil
var lastRangeInReversedForMergedItems: BoxedValue<ClosedRange<Int>>? = nil
var recent: MergedItem? = nil
while index < items.count {
let item = items[index]
let prev = index >= 1 ? items[index - 1] : nil
let next = index + 1 < items.count ? items[index + 1] : nil
while index < im.reversedChatItems.count {
let item = im.reversedChatItems[index]
let prev = index >= 1 ? im.reversedChatItems[index - 1] : nil
let next = index + 1 < im.reversedChatItems.count ? im.reversedChatItems[index + 1] : nil
let category = item.mergeCategory
let itemIsSplit = itemSplits.contains(item.id)
if item.id == unreadAfterItemId {
unreadBefore = unreadCount - chatState.unreadAfter
unreadBefore = unreadCount - im.chatState.unreadAfter
}
if item.isRcvNew {
unreadBefore -= 1
@@ -106,18 +107,19 @@ struct MergedItems: Hashable, Equatable {
// found item that is considered as a split
if let unclosedSplitIndex, let unclosedSplitIndexInParent {
// it was at least second split in the list
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
splitRanges.append(SplitRange(itemId: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
}
unclosedSplitIndex = index
unclosedSplitIndexInParent = visibleItemIndexInParent
} else if index + 1 == items.count, let unclosedSplitIndex, let unclosedSplitIndexInParent {
} else if index + 1 == im.reversedChatItems.count, let unclosedSplitIndex, let unclosedSplitIndexInParent {
// just one split for the whole list, there will be no more, it's the end
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
splitRanges.append(SplitRange(itemId: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
}
indexInParentItems[item.id] = visibleItemIndexInParent
index += 1
}
return MergedItems(
im: im,
items: mergedItems,
splits: splitRanges,
indexInParentItems: indexInParentItems
@@ -127,7 +129,6 @@ struct MergedItems: Hashable, Equatable {
// Use this check to ensure that mergedItems state based on currently actual state of global
// splits and reversedChatItems
func isActualState() -> Bool {
let im = ItemsModel.shared
// do not load anything if global splits state is different than in merged items because it
// will produce undefined results in terms of loading and placement of items.
// Same applies to reversedChatItems
@@ -434,7 +435,7 @@ class BoxedValue<T: Hashable>: Equatable, Hashable {
}
@MainActor
func visibleItemIndexesNonReversed(_ listState: EndlessScrollView<MergedItem>.ListState, _ mergedItems: MergedItems) -> ClosedRange<Int> {
func visibleItemIndexesNonReversed(_ im: ItemsModel, _ listState: EndlessScrollView<MergedItem>.ListState, _ mergedItems: MergedItems) -> ClosedRange<Int> {
let zero = 0 ... 0
let items = mergedItems.items
if items.isEmpty {
@@ -445,12 +446,12 @@ func visibleItemIndexesNonReversed(_ listState: EndlessScrollView<MergedItem>.Li
guard let newest, let oldest else {
return zero
}
let size = ItemsModel.shared.reversedChatItems.count
let size = im.reversedChatItems.count
let range = size - oldest ... size - newest
if range.lowerBound < 0 || range.upperBound < 0 {
return zero
}
// visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed()
// visible items mapped to their underlying data structure which is im.reversedChatItems.reversed()
return range
}
@@ -9,7 +9,7 @@
import SwiftUI
import SimpleXChat
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) async {
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat, _ im: ItemsModel) async {
await MainActor.run {
loadingMoreItems.wrappedValue = true
loadingBottomItems.wrappedValue = true
@@ -22,27 +22,15 @@ func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Bindin
}
return
}
await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState)
await apiLoadMessages(chat.chatInfo.id, im, ChatPagination.last(count: 50))
await MainActor.run {
loadingMoreItems.wrappedValue = false
loadingBottomItems.wrappedValue = false
}
}
class PreloadState {
static let shared = PreloadState()
var prevFirstVisible: Int64 = Int64.min
var prevItemsCount: Int = 0
var preloading: Bool = false
func clear() {
prevFirstVisible = Int64.min
prevItemsCount = 0
preloading = false
}
}
func preloadIfNeeded(
_ im: ItemsModel,
_ allowLoadMoreItems: Binding<Bool>,
_ ignoreLoadingRequests: Binding<Int64?>,
_ listState: EndlessScrollView<MergedItem>.ListState,
@@ -50,7 +38,7 @@ func preloadIfNeeded(
loadItems: @escaping (Bool, ChatPagination) async -> Bool,
loadLastItems: @escaping () async -> Void
) {
let state = PreloadState.shared
let state = im.preloadState
guard !listState.isScrolling && !listState.isAnimatedScrolling,
!state.preloading,
listState.totalItemsCount > 0
@@ -63,7 +51,7 @@ func preloadIfNeeded(
Task {
defer { state.preloading = false }
var triedToLoad = true
await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
await preloadItems(im, mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
triedToLoad = await loadItems(false, pagination)
return triedToLoad
}
@@ -73,11 +61,11 @@ func preloadIfNeeded(
}
// it's important to ask last items when the view is fully covered with items. Otherwise, visible items from one
// split will be merged with last items and position of scroll will change unexpectedly.
if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
if listState.itemsCanCoverScreen && !im.lastItemsLoaded {
await loadLastItems()
}
}
} else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
} else if listState.itemsCanCoverScreen && !im.lastItemsLoaded {
state.preloading = true
Task {
defer { state.preloading = false }
@@ -87,6 +75,7 @@ func preloadIfNeeded(
}
func preloadItems(
_ im: ItemsModel,
_ mergedItems: MergedItems,
_ allowLoadMoreItems: Bool,
_ listState: EndlessScrollView<MergedItem>.ListState,
@@ -105,7 +94,7 @@ async {
let splits = mergedItems.splits
let lastVisibleIndex = listState.lastVisibleItemIndex
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
let items: [ChatItem] = ItemsModel.shared.reversedChatItems.reversed()
let items: [ChatItem] = im.reversedChatItems.reversed()
if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining {
lastIndexToLoadFrom = items.count - 1
}
@@ -122,7 +111,7 @@ async {
let sizeWas = items.count
let firstItemIdWas = items.first?.id
let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id {
if triedToLoad && sizeWas == im.reversedChatItems.count && firstItemIdWas == im.reversedChatItems.last?.id {
ignoreLoadingRequests.wrappedValue = loadFromItemId
return false
}
@@ -133,7 +122,7 @@ async {
let splits = mergedItems.splits
let split = splits.last(where: { $0.indexRangeInParentItems.contains(firstVisibleIndex) })
// we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom)
let reversedItems: [ChatItem] = ItemsModel.shared.reversedChatItems
let reversedItems: [ChatItem] = im.reversedChatItems
if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex {
let index = split.indexRangeInReversed.lowerBound
if index >= 0 {
+310 -233
View File
@@ -15,8 +15,6 @@ private let memberImageSize: CGFloat = 34
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var im = ItemsModel.shared
@State var mergedItems: BoxedValue<MergedItems> = BoxedValue(MergedItems.create(ItemsModel.shared.reversedChatItems, [], ItemsModel.shared.chatState))
@State var revealedItems: Set<Int64> = Set()
@State var theme: AppTheme = buildTheme()
@Environment(\.dismiss) var dismiss
@@ -24,6 +22,9 @@ struct ChatView: View {
@Environment(\.presentationMode) var presentationMode
@Environment(\.scenePhase) var scenePhase
@State @ObservedObject var chat: Chat
@ObservedObject var im: ItemsModel
@State var mergedItems: BoxedValue<MergedItems>
@State var floatingButtonModel: FloatingButtonModel
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@@ -55,7 +56,6 @@ struct ChatView: View {
@State private var allowLoadMoreItems: Bool = false
@State private var ignoreLoadingRequests: Int64? = nil
@State private var animatedScrollingInProgress: Bool = false
@State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel()
@State private var scrollView: EndlessScrollView<MergedItem> = EndlessScrollView(frame: .zero)
@@ -80,7 +80,7 @@ struct ChatView: View {
let backgroundColor = theme.wallpaper.background ?? wallpaperType.defaultBackgroundColor(theme.base, theme.colors.background)
let tintColor = theme.wallpaper.tint ?? wallpaperType.defaultTintColor(theme.base)
Color.clear.ignoresSafeArea(.all)
.if(wallpaperImage != nil) { view in
.if(wallpaperImage != nil && im.secondaryIMFilter == nil) { view in
view.modifier(
ChatViewBackground(image: wallpaperImage!, imageType: wallpaperType, background: backgroundColor, tint: tintColor)
)
@@ -91,8 +91,8 @@ struct ChatView: View {
if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible)
}
FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: {
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
FloatingButtons(im: im, theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: {
mergedItems.boxedValue = MergedItems.create(im, revealedItems)
scrollView.updateItems(mergedItems.boxedValue.items)
}
)
@@ -101,6 +101,7 @@ struct ChatView: View {
if selectedChatItems == nil {
ComposeView(
chat: chat,
im: im,
composeState: $composeState,
keyboardVisible: $keyboardVisible,
keyboardHiddenDate: $keyboardHiddenDate,
@@ -109,7 +110,7 @@ struct ChatView: View {
.disabled(!cInfo.sendMsgEnabled)
} else {
SelectedItemsBottomToolbar(
chatItems: ItemsModel.shared.reversedChatItems,
im: im,
selectedChatItems: $selectedChatItems,
chatInfo: chat.chatInfo,
deleteItems: { forAll in
@@ -120,7 +121,7 @@ struct ChatView: View {
showArchiveSelectedReports = true
},
moderateItems: {
if case let .group(groupInfo) = chat.chatInfo {
if case let .group(groupInfo, _) = chat.chatInfo {
showModerateSelectedMessagesAlert(groupInfo)
}
},
@@ -163,7 +164,7 @@ struct ChatView: View {
archiveReports(chat.chatInfo, selected.sorted(), false, deletedSelectedMessages)
}
}
if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive {
if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive {
Button("For all moderators", role: .destructive) {
if let selected = selectedChatItems {
archiveReports(chat.chatInfo, selected.sorted(), true, deletedSelectedMessages)
@@ -173,7 +174,7 @@ struct ChatView: View {
}
.appSheet(item: $selectedMember) { member in
Group {
if case let .group(groupInfo) = chat.chatInfo {
if case let .group(groupInfo, _) = chat.chatInfo {
GroupMemberInfoView(
groupInfo: groupInfo,
chat: chat,
@@ -186,7 +187,7 @@ struct ChatView: View {
// it should be presented on top level in order to prevent a bug in SwiftUI on iOS 16 related to .focused() modifier in AddGroupMembersView's search field
.appSheet(isPresented: $showAddMembersSheet) {
Group {
if case let .group(groupInfo) = cInfo {
if case let .group(groupInfo, _) = cInfo {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
}
@@ -236,7 +237,7 @@ struct ChatView: View {
initChatView()
theme = buildTheme()
closeSearch()
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
mergedItems.boxedValue = MergedItems.create(im, revealedItems)
scrollView.updateItems(mergedItems.boxedValue.items)
if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] {
@@ -256,7 +257,7 @@ struct ChatView: View {
.onChange(of: chatModel.openAroundItemId) { openAround in
if let openAround {
closeSearch()
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
mergedItems.boxedValue = MergedItems.create(im, revealedItems)
scrollView.updateItems(mergedItems.boxedValue.items)
chatModel.openAroundItemId = nil
@@ -279,9 +280,8 @@ struct ChatView: View {
if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if chatModel.chatId == nil {
chatModel.chatItemStatuses = [:]
ItemsModel.shared.reversedChatItems = []
ItemsModel.shared.chatState.clear()
im.reversedChatItems = []
im.chatState.clear()
chatModel.groupMembers = []
chatModel.groupMembersIndexes.removeAll()
chatModel.membersLoaded = false
@@ -292,124 +292,178 @@ struct ChatView: View {
.onChange(of: colorScheme) { _ in
theme = buildTheme()
}
.toolbar {
ToolbarItem(placement: .principal) {
if selectedChatItems != nil {
SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
} else if case let .direct(contact) = cInfo {
Button {
Task {
showChatInfoSheet = true
}
} label: {
ChatInfoToolbar(chat: chat)
}
.appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
ChatInfoView(
chat: chat,
contact: contact,
localAlias: chat.chatInfo.localAlias,
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
onSearch: { focusSearch() }
)
}
} else if case let .group(groupInfo) = cInfo {
Button {
Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
} label: {
ChatInfoToolbar(chat: chat)
.tint(theme.colors.primary)
}
.appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
GroupChatInfoView(
chat: chat,
groupInfo: Binding(
get: { groupInfo },
set: { gInfo in
chat.chatInfo = .group(groupInfo: gInfo)
chat.created = Date.now
}
),
onSearch: { focusSearch() },
localAlias: groupInfo.localAlias
)
}
} else if case .local = cInfo {
ChatInfoToolbar(chat: chat)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
if selectedChatItems != nil {
Button {
withAnimation {
selectedChatItems = nil
}
} label: {
Text("Cancel")
}
} else {
switch cInfo {
case let .direct(contact):
HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
if chatModel.activeCall == nil {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
.if(im.secondaryIMFilter == nil) {
$0.toolbar {
ToolbarItem(placement: .principal) {
if selectedChatItems != nil {
SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
} else if case let .direct(contact) = cInfo {
Button {
Task {
showChatInfoSheet = true
}
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .video)
} label: {
Label("Video call", systemImage: "video")
} label: {
ChatInfoToolbar(chat: chat)
}
.appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
ChatInfoView(
chat: chat,
contact: contact,
localAlias: chat.chatInfo.localAlias,
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
onSearch: { focusSearch() }
)
}
} else if case let .group(groupInfo, _) = cInfo {
Button {
Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
} label: {
ChatInfoToolbar(chat: chat)
.tint(theme.colors.primary)
}
.appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
GroupChatInfoView(
chat: chat,
groupInfo: Binding(
get: { groupInfo },
set: { gInfo in
chat.chatInfo = .group(groupInfo: gInfo, groupChatScope: nil)
chat.created = Date.now
}
.disabled(!contact.ready || !contact.active)
}
searchButton()
ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active)
} label: {
Image(systemName: "ellipsis")
}
),
onSearch: { focusSearch() },
localAlias: groupInfo.localAlias
)
}
case let .group(groupInfo):
HStack {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
groupLinkButton()
.appSheet(isPresented: $showGroupLinkSheet) {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
} else if case .local = cInfo {
ChatInfoToolbar(chat: chat)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
if selectedChatItems != nil {
Button {
withAnimation {
selectedChatItems = nil
}
} label: {
Text("Cancel")
}
} else {
switch cInfo {
case let .direct(contact):
HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
if chatModel.activeCall == nil {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
}
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .video)
} label: {
Label("Video call", systemImage: "video")
}
} else {
addMembersButton()
.disabled(!contact.ready || !contact.active)
}
searchButton()
ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active)
} label: {
Image(systemName: "ellipsis")
}
}
Menu {
searchButton()
ToggleNtfsButton(chat: chat)
} label: {
Image(systemName: "ellipsis")
case let .group(groupInfo, _):
HStack {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
groupLinkButton()
.appSheet(isPresented: $showGroupLinkSheet) {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else {
addMembersButton()
}
}
Menu {
searchButton()
ToggleNtfsButton(chat: chat)
} label: {
Image(systemName: "ellipsis")
}
}
case .local:
searchButton()
default:
EmptyView()
}
case .local:
searchButton()
default:
EmptyView()
}
}
}
}
.if(im.secondaryIMFilter != nil) {
$0.toolbar {
ToolbarItem(placement: .principal) {
if selectedChatItems != nil {
SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
} else {
switch im.secondaryIMFilter {
case let .groupChatScopeContext(groupScopeInfo):
switch groupScopeInfo {
case let .memberSupport(groupMember_):
if let groupMember = groupMember_ {
MemberSupportChatToolbar(groupMember: groupMember)
} else {
textChatToolbar("Support")
}
}
case let .msgContentTagContext(contentTag):
switch contentTag {
case .report:
textChatToolbar("Member reports")
default:
EmptyView()
}
case .none:
EmptyView()
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
if selectedChatItems != nil {
Button {
withAnimation {
selectedChatItems = nil
}
} label: {
Text("Cancel")
}
} else {
searchButton()
}
}
}
}
}
func textChatToolbar(_ text: LocalizedStringKey) -> some View {
HStack {
Text(text).font(.headline)
.lineLimit(1)
}
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
}
private func initChatView() {
@@ -448,13 +502,13 @@ struct ChatView: View {
var index = mergedItems.boxedValue.indexInParentItems[itemId]
if index == nil {
let pagination = ChatPagination.around(chatItemId: itemId, count: ChatPagination.PRELOAD_COUNT * 2)
let oldSize = ItemsModel.shared.reversedChatItems.count
let oldSize = im.reversedChatItems.count
let triedToLoad = await loadChatItems(chat, pagination)
if !triedToLoad {
return
}
var repeatsLeft = 50
while oldSize == ItemsModel.shared.reversedChatItems.count && repeatsLeft > 0 {
while oldSize == im.reversedChatItems.count && repeatsLeft > 0 {
try await Task.sleep(nanoseconds: 20_000000)
repeatsLeft -= 1
}
@@ -464,7 +518,7 @@ struct ChatView: View {
closeKeyboardAndRun {
Task {
await MainActor.run { animatedScrollingInProgress = true }
await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index))
await scrollView.scrollToItemAnimated(min(im.reversedChatItems.count - 1, index))
await MainActor.run { animatedScrollingInProgress = false }
}
}
@@ -539,6 +593,7 @@ struct ChatView: View {
? (g.size.width - 32)
: (g.size.width - 32) * 0.84
return ChatItemWithMenu(
im: im,
chat: $chat,
index: index,
isLastItem: index == mergedItems.boxedValue.items.count - 1,
@@ -571,7 +626,7 @@ struct ChatView: View {
}
}
.onChange(of: im.reversedChatItems) { items in
mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState)
mergedItems.boxedValue = MergedItems.create(im, revealedItems)
scrollView.updateItems(mergedItems.boxedValue.items)
if im.itemAdded {
im.itemAdded = false
@@ -583,7 +638,7 @@ struct ChatView: View {
}
}
.onChange(of: revealedItems) { revealed in
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState)
mergedItems.boxedValue = MergedItems.create(im, revealed)
scrollView.updateItems(mergedItems.boxedValue.items)
}
.onChange(of: chat.id) { _ in
@@ -618,7 +673,7 @@ struct ChatView: View {
private func updateWithInitiallyLoadedItems() {
if mergedItems.boxedValue.items.isEmpty {
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, ItemsModel.shared.chatState)
mergedItems.boxedValue = MergedItems.create(im, revealedItems)
}
let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() })
let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil }
@@ -638,8 +693,8 @@ struct ChatView: View {
private func searchTextChanged(_ s: String) {
Task {
await loadChat(chat: chat, search: s)
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
await loadChat(chat: chat, im: im, search: s)
mergedItems.boxedValue = MergedItems.create(im, revealedItems)
await MainActor.run {
scrollView.updateItems(mergedItems.boxedValue.items)
}
@@ -654,79 +709,8 @@ struct ChatView: View {
}
}
class FloatingButtonModel: ObservableObject {
@Published var unreadAbove: Int = 0
@Published var unreadBelow: Int = 0
@Published var isNearBottom: Bool = true
@Published var date: Date? = nil
@Published var isDateVisible: Bool = false
var hideDateWorkItem: DispatchWorkItem? = nil
func updateOnListChange(_ listState: EndlessScrollView<MergedItem>.ListState) {
let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState)
let unreadBelow = if let lastVisibleItem {
max(0, ItemsModel.shared.chatState.unreadTotal - lastVisibleItem.unreadBefore)
} else {
0
}
let unreadAbove = ItemsModel.shared.chatState.unreadTotal - unreadBelow
let date: Date? =
if let lastVisible = listState.visibleItems.last {
Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs)
} else {
nil
}
// set the counters and date indicator
DispatchQueue.main.async { [weak self] in
guard let it = self else { return }
it.setDate(visibility: true)
it.unreadAbove = unreadAbove
it.unreadBelow = unreadBelow
it.date = date
}
// set floating button indication mode
let nearBottom = listState.firstVisibleItemIndex < 1
if nearBottom != self.isNearBottom {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
self?.isNearBottom = nearBottom
}
}
// hide Date indicator after 1 second of no scrolling
hideDateWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let it = self else { return }
it.setDate(visibility: false)
it.hideDateWorkItem = nil
}
DispatchQueue.main.async { [weak self] in
self?.hideDateWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
}
}
func resetDate() {
date = nil
isDateVisible = false
}
private func setDate(visibility isVisible: Bool) {
if isVisible {
if !isNearBottom,
!isDateVisible,
let date, !Calendar.current.isDateInToday(date) {
withAnimation { self.isDateVisible = true }
}
} else if isDateVisible {
withAnimation { self.isDateVisible = false }
}
}
}
private struct FloatingButtons: View {
@ObservedObject var im: ItemsModel
let theme: AppTheme
let scrollView: EndlessScrollView<MergedItem>
let chat: Chat
@@ -796,7 +780,7 @@ struct ChatView: View {
}
}
.onTapGesture {
if loadingBottomItems || !ItemsModel.shared.lastItemsLoaded {
if loadingBottomItems || !im.lastItemsLoaded {
requestedTopScroll = false
requestedBottomScroll = true
} else {
@@ -816,7 +800,7 @@ struct ChatView: View {
}
}
.onChange(of: loadingBottomItems) { loading in
if !loading && requestedBottomScroll && ItemsModel.shared.lastItemsLoaded {
if !loading && requestedBottomScroll && im.lastItemsLoaded {
requestedBottomScroll = false
scrollToBottom()
}
@@ -826,9 +810,9 @@ struct ChatView: View {
private func scrollToTopUnread() {
Task {
if !ItemsModel.shared.chatState.splits.isEmpty {
if !im.chatState.splits.isEmpty {
await MainActor.run { loadingMoreItems = true }
await loadChat(chatId: chat.id, openAroundItemId: nil, clearItems: false)
await loadChat(chatId: chat.id, im: im, openAroundItemId: nil, clearItems: false)
await MainActor.run { reloadItems() }
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
await MainActor.run { animatedScrollingInProgress = true }
@@ -938,7 +922,7 @@ struct ChatView: View {
private func addMembersButton() -> some View {
Button {
if case let .group(gInfo) = chat.chatInfo {
if case let .group(gInfo, _) = chat.chatInfo {
Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } }
}
} label: {
@@ -948,7 +932,7 @@ struct ChatView: View {
private func groupLinkButton() -> some View {
Button {
if case let .group(gInfo) = chat.chatInfo {
if case let .group(gInfo, _) = chat.chatInfo {
Task {
do {
if let link = try apiGetGroupLink(gInfo.groupId) {
@@ -999,6 +983,7 @@ struct ChatView: View {
let (validItems, confirmation) = try await apiPlanForwardChatItems(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
scope: chat.chatInfo.groupChatScope(),
itemIds: Array(selectedChatItems)
)
if let confirmation {
@@ -1088,7 +1073,6 @@ struct ChatView: View {
}
func openForwardingSheet(_ items: [Int64]) async {
let im = ItemsModel.shared
var items = Set(items)
var fci = [ChatItem]()
for reversedChatItem in im.reversedChatItems {
@@ -1127,11 +1111,11 @@ struct ChatView: View {
private func loadChatItemsUnchecked(_ chat: Chat, _ pagination: ChatPagination) async -> Bool {
await apiLoadMessages(
chat.chatInfo.id,
im,
pagination,
im.chatState,
searchText,
nil,
{ visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) }
{ visibleItemIndexesNonReversed(im, scrollView.listState, mergedItems.boxedValue) }
)
return true
}
@@ -1143,11 +1127,12 @@ struct ChatView: View {
func onChatItemsUpdated() {
if !mergedItems.boxedValue.isActualState() {
//logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(ItemsModel.shared.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(ItemsModel.shared.reversedChatItems.count)")
//logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(im.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(im.reversedChatItems.count)")
return
}
floatingButtonModel.updateOnListChange(scrollView.listState)
preloadIfNeeded(
im,
$allowLoadMoreItems,
$ignoreLoadingRequests,
scrollView.listState,
@@ -1161,13 +1146,14 @@ struct ChatView: View {
},
loadLastItems: {
if !loadingMoreItems {
await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat, im)
}
}
)
}
private struct ChatItemWithMenu: View {
@ObservedObject var im: ItemsModel
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileRadius = defaultProfileImageCorner
@@ -1252,8 +1238,6 @@ struct ChatView: View {
}
var body: some View {
let im = ItemsModel.shared
let last = isLastItem ? im.reversedChatItems.last : nil
let listItem = merged.newest()
let item = listItem.item
@@ -1298,11 +1282,17 @@ struct ChatView: View {
if !itemIds.isEmpty {
waitToMarkRead {
await apiMarkChatItemsRead(chat.chatInfo, itemIds, mentionsRead: unreadMentions)
if (im.secondaryIMFilter != nil) {
m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id, by: itemIds.count )
}
}
}
} else if chatItem.isRcvNew {
waitToMarkRead {
await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0)
if (im.secondaryIMFilter != nil) {
m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id)
}
}
}
}
@@ -1324,7 +1314,6 @@ struct ChatView: View {
}
private func unreadItemIds(_ range: ClosedRange<Int>) -> ([ChatItem.ID], Int) {
let im = ItemsModel.shared
var unreadItems: [ChatItem.ID] = []
var unreadMentions: Int = 0
@@ -1537,6 +1526,7 @@ struct ChatView: View {
}
ChatItemView(
chat: chat,
im: im,
chatItem: ci,
scrollToItemId: scrollToItemId,
maxWidth: maxWidth,
@@ -1578,7 +1568,7 @@ struct ChatView: View {
self.archivingReports = []
}
}
if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive {
if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive {
Button("For all moderators", role: .destructive) {
if let reports = self.archivingReports {
archiveReports(chat.chatInfo, reports.sorted(), true)
@@ -1627,7 +1617,7 @@ struct ChatView: View {
}
}
switch chat.chatInfo {
case let .group(groupInfo):
case let .group(groupInfo, _):
v.contextMenu {
ReactionContextMenu(
groupInfo: groupInfo,
@@ -1650,7 +1640,7 @@ struct ChatView: View {
@ViewBuilder
private func menu(_ ci: ChatItem, _ range: ClosedRange<Int>?, live: Bool) -> some View {
if case let .group(gInfo) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil {
if case let .group(gInfo, _) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil {
if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator {
archiveReportButton(ci)
}
@@ -1709,7 +1699,7 @@ struct ChatView: View {
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
moderateButton(ci, groupInfo)
} else if ci.meta.itemDeleted == nil && chat.groupFeatureEnabled(.reports),
case let .group(gInfo) = chat.chatInfo,
case let .group(gInfo, _) = chat.chatInfo,
gInfo.membership.memberRole == .member
&& !live
&& composeState.voiceMessageRecordingState == .noRecording {
@@ -1820,6 +1810,7 @@ struct ChatView: View {
let chatItem = try await apiChatItemReaction(
type: cInfo.chatType,
id: cInfo.apiId,
scope: cInfo.groupChatScope(),
itemId: ci.id,
add: add,
reaction: reaction
@@ -1933,11 +1924,11 @@ struct ChatView: View {
Task {
do {
let cInfo = chat.chatInfo
let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, itemId: ci.id)
let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemId: ci.id)
await MainActor.run {
chatItemInfo = ciInfo
}
if case let .group(gInfo) = chat.chatInfo {
if case let .group(gInfo, _) = chat.chatInfo {
await m.loadGroupMembers(gInfo)
}
} catch let error {
@@ -1991,13 +1982,13 @@ struct ChatView: View {
private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button<some View> {
Button(role: .destructive) {
if !revealed,
let currIndex = m.getChatItemIndex(ci),
let currIndex = m.getChatItemIndex(im, ci),
let ciCategory = ci.mergeCategory {
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
if let range = itemsRange(currIndex, prevHidden) {
var itemIds: [Int64] = []
for i in range {
itemIds.append(ItemsModel.shared.reversedChatItems[i].id)
itemIds.append(im.reversedChatItems[i].id)
}
showDeleteMessages = true
deletingItems = itemIds
@@ -2135,12 +2126,12 @@ struct ChatView: View {
selectedChatItems = selectedChatItems ?? []
var itemIds: [Int64] = []
if !revealed,
let currIndex = m.getChatItemIndex(ci),
let currIndex = m.getChatItemIndex(im, ci),
let ciCategory = ci.mergeCategory {
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
if let range = itemsRange(currIndex, prevHidden) {
for i in range {
itemIds.append(ItemsModel.shared.reversedChatItems[i].id)
itemIds.append(im.reversedChatItems[i].id)
}
} else {
itemIds.append(ci.id)
@@ -2174,6 +2165,7 @@ struct ChatView: View {
try await apiDeleteChatItems(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
scope: chat.chatInfo.groupChatScope(),
itemIds: [di.id],
mode: mode
)
@@ -2231,14 +2223,14 @@ struct ChatView: View {
if searchIsNotBlank {
goToItemInnerButton(alignStart, "magnifyingglass", touchInProgress: touchInProgress) {
closeKeyboardAndRun {
ItemsModel.shared.loadOpenChatNoWait(chat.id, chatItem.id)
im.loadOpenChatNoWait(chat.id, chatItem.id)
}
}
} else if let chatTypeApiIdMsgId {
goToItemInnerButton(alignStart, "arrow.right", touchInProgress: touchInProgress) {
closeKeyboardAndRun {
let (chatType, apiId, msgId) = chatTypeApiIdMsgId
ItemsModel.shared.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId)
im.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId)
}
}
}
@@ -2265,6 +2257,84 @@ struct ChatView: View {
}
}
class FloatingButtonModel: ObservableObject {
@ObservedObject var im: ItemsModel
public init(im: ItemsModel) {
self.im = im
}
@Published var unreadAbove: Int = 0
@Published var unreadBelow: Int = 0
@Published var isNearBottom: Bool = true
@Published var date: Date? = nil
@Published var isDateVisible: Bool = false
var hideDateWorkItem: DispatchWorkItem? = nil
func updateOnListChange(_ listState: EndlessScrollView<MergedItem>.ListState) {
let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState)
let unreadBelow = if let lastVisibleItem {
max(0, im.chatState.unreadTotal - lastVisibleItem.unreadBefore)
} else {
0
}
let unreadAbove = im.chatState.unreadTotal - unreadBelow
let date: Date? =
if let lastVisible = listState.visibleItems.last {
Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs)
} else {
nil
}
// set the counters and date indicator
DispatchQueue.main.async { [weak self] in
guard let it = self else { return }
it.setDate(visibility: true)
it.unreadAbove = unreadAbove
it.unreadBelow = unreadBelow
it.date = date
}
// set floating button indication mode
let nearBottom = listState.firstVisibleItemIndex < 1
if nearBottom != self.isNearBottom {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
self?.isNearBottom = nearBottom
}
}
// hide Date indicator after 1 second of no scrolling
hideDateWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let it = self else { return }
it.setDate(visibility: false)
it.hideDateWorkItem = nil
}
DispatchQueue.main.async { [weak self] in
self?.hideDateWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
}
}
func resetDate() {
date = nil
isDateVisible = false
}
private func setDate(visibility isVisible: Bool) {
if isVisible {
if !isNearBottom,
!isDateVisible,
let date, !Calendar.current.isDateInToday(date) {
withAnimation { self.isDateVisible = true }
}
} else if isDateVisible {
withAnimation { self.isDateVisible = false }
}
}
}
private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
@@ -2286,6 +2356,7 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
try await apiDeleteChatItems(
type: chatInfo.chatType,
id: chatInfo.apiId,
scope: chatInfo.groupChatScope(),
itemIds: itemIds,
mode: mode
)
@@ -2347,7 +2418,7 @@ private func buildTheme() -> AppTheme {
if let cId = ChatModel.shared.chatId, let chat = ChatModel.shared.getChat(cId) {
let perChatTheme = if case let .direct(contact) = chat.chatInfo {
contact.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight)
} else if case let .group(groupInfo) = chat.chatInfo {
} else if case let .group(groupInfo, _) = chat.chatInfo {
groupInfo.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight)
} else {
nil as ThemeModeOverride?
@@ -2500,7 +2571,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
case var .direct(contact):
contact.chatSettings = chatSettings
ChatModel.shared.updateContact(contact)
case var .group(groupInfo):
case var .group(groupInfo, _):
groupInfo.chatSettings = chatSettings
ChatModel.shared.updateGroup(groupInfo)
default: ()
@@ -2517,7 +2588,8 @@ struct ChatView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chatId = "@1"
ItemsModel.shared.reversedChatItems = [
let im = ItemsModel.shared
im.reversedChatItems = [
ChatItem.getSample(1, .directSnd, .now, "hello"),
ChatItem.getSample(2, .directRcv, .now, "hi"),
ChatItem.getSample(3, .directRcv, .now, "hi there"),
@@ -2529,7 +2601,12 @@ struct ChatView_Previews: PreviewProvider {
ChatItem.getSample(9, .directSnd, .now, "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.")
]
@State var showChatInfo = false
return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
.environmentObject(chatModel)
return ChatView(
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
im: im,
mergedItems: BoxedValue(MergedItems.create(im, [])),
floatingButtonModel: FloatingButtonModel(im: im)
)
.environmentObject(chatModel)
}
}
@@ -323,6 +323,7 @@ struct ComposeView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
@ObservedObject var im: ItemsModel
@Binding var composeState: ComposeState
@Binding var keyboardVisible: Bool
@Binding var keyboardHiddenDate: Date
@@ -355,6 +356,20 @@ struct ComposeView: View {
var body: some View {
VStack(spacing: 0) {
Divider()
if let groupInfo = chat.chatInfo.groupInfo,
case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter,
case let .memberSupport(member) = groupScopeInfo,
let member = member,
member.memberPending,
composeState.contextItem == .noContextItem,
composeState.noPreview {
ContextPendingMemberActionsView(
groupInfo: groupInfo,
member: member
)
Divider()
}
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
ContextInvitingContactMemberView()
Divider()
@@ -396,7 +411,7 @@ struct ComposeView: View {
.padding(.bottom, 16)
.padding(.leading, 12)
.tint(theme.colors.primary)
if case let .group(g) = chat.chatInfo,
if case let .group(g, _) = chat.chatInfo,
!g.fullGroupPreferences.files.on(for: g.membership) {
b.disabled(true).onTapGesture {
AlertManager.shared.showAlertMsg(
@@ -443,17 +458,30 @@ struct ComposeView: View {
.padding(.trailing, 12)
.disabled(!chat.userCanSend)
if chat.userIsObserver {
Text("you are observer")
.italic()
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.onTapGesture {
AlertManager.shared.showAlertMsg(
title: "You can't send messages!",
message: "Please contact group admin."
)
}
if im.secondaryIMFilter == nil {
if chat.userIsPending {
Text("reviewed by moderators")
.italic()
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.onTapGesture {
AlertManager.shared.showAlertMsg(
title: "You can't send messages!",
message: "Please contact group admin."
)
}
} else if chat.userIsObserver {
Text("you are observer")
.italic()
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.onTapGesture {
AlertManager.shared.showAlertMsg(
title: "You can't send messages!",
message: "Please contact group admin."
)
}
}
}
}
}
@@ -944,6 +972,7 @@ struct ComposeView: View {
let chatItem = try await apiUpdateChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
scope: chat.chatInfo.groupChatScope(),
itemId: ei.id,
updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions),
live: live
@@ -1031,6 +1060,7 @@ struct ComposeView: View {
: await apiSendMessages(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
scope: chat.chatInfo.groupChatScope(),
live: live,
ttl: ttl,
composedMessages: msgs
@@ -1055,8 +1085,10 @@ struct ComposeView: View {
if let chatItems = await apiForwardChatItems(
toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId,
toScope: chat.chatInfo.groupChatScope(),
fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId,
fromScope: fromChatInfo.groupChatScope(),
itemIds: forwardedItems.map { $0.id },
ttl: ttl
) {
@@ -1274,12 +1306,14 @@ struct ComposeView: View {
struct ComposeView_Previews: PreviewProvider {
static var previews: some View {
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
let im = ItemsModel.shared
@State var composeState = ComposeState(message: "hello")
@State var selectedRange = NSRange()
return Group {
ComposeView(
chat: chat,
im: im,
composeState: $composeState,
keyboardVisible: Binding.constant(true),
keyboardHiddenDate: Binding.constant(Date.now),
@@ -1288,6 +1322,7 @@ struct ComposeView_Previews: PreviewProvider {
.environmentObject(ChatModel())
ComposeView(
chat: chat,
im: im,
composeState: $composeState,
keyboardVisible: Binding.constant(true),
keyboardHiddenDate: Binding.constant(Date.now),
@@ -0,0 +1,97 @@
//
// ContextPendingMemberActionsView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 02.05.2025.
// Copyright © 2025 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// TODO [knocking] go back (close secondary ChatView) on actions
struct ContextPendingMemberActionsView: View {
@EnvironmentObject var theme: AppTheme
var groupInfo: GroupInfo
var member: GroupMember
var body: some View {
HStack(spacing: 0) {
ZStack {
Text("Remove")
.foregroundColor(.red)
}
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.onTapGesture {
showRemoveMemberAlert(groupInfo, member)
}
ZStack {
Text("Accept")
.foregroundColor(theme.colors.primary)
}
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.onTapGesture {
showAcceptMemberAlert(groupInfo, member)
}
}
.frame(minHeight: 54)
.frame(maxWidth: .infinity)
.background(.thinMaterial)
}
}
func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember) {
showAlert(
NSLocalizedString("Accept member", comment: "alert title"),
message: NSLocalizedString("Member will join the group, accept member?", comment: "alert message"),
actions: {[
UIAlertAction(
title: NSLocalizedString("Accept as member", comment: "alert action"),
style: .default,
handler: { _ in
acceptMember(groupInfo, member, .member)
}
),
UIAlertAction(
title: NSLocalizedString("Accept as observer", comment: "alert action"),
style: .default,
handler: { _ in
acceptMember(groupInfo, member, .observer)
}
),
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .default
)
]}
)
}
func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMemberRole) {
Task {
do {
let acceptedMember = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role)
await MainActor.run {
_ = ChatModel.shared.upsertGroupMember(groupInfo, acceptedMember)
}
} catch let error {
logger.error("apiAcceptMember error: \(responseError(error))")
await MainActor.run {
showAlert(
NSLocalizedString("Error accepting member", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
#Preview {
ContextPendingMemberActionsView(
groupInfo: GroupInfo.sampleData,
member: GroupMember.sampleData
)
}
@@ -87,7 +87,26 @@ struct GroupChatInfoView: View {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Section {
if groupInfo.membership.supportChat != nil {
let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil)
UserSupportChatNavLink(
chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
im: ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo))
)
}
if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator {
memberSupportButton()
}
if groupInfo.canModerate {
GroupReportsChatNavLink(
chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil), chatItems: [], chatStats: ChatStats()),
im: ItemsModel(secondaryIMFilter: .msgContentTagContext(contentTag: .report))
)
}
}
Section {
if groupInfo.isOwner && groupInfo.businessChat == nil {
editGroupButton()
@@ -96,17 +115,6 @@ struct GroupChatInfoView: View {
addOrEditWelcomeMessage()
}
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
sendReceiptsOption()
} else {
sendReceiptsOptionDisabled()
}
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
} header: {
Text("")
} footer: {
@@ -120,6 +128,16 @@ struct GroupChatInfoView: View {
}
Section {
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
sendReceiptsOption()
} else {
sendReceiptsOptionDisabled()
}
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
} footer: {
Text("Delete chat messages from your device.")
@@ -519,6 +537,81 @@ struct GroupChatInfoView: View {
.navigationBarTitleDisplayMode(.large)
}
struct UserSupportChatNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@State private var userSupportChatNavLinkActive = false
@ObservedObject var chat: Chat
var im: ItemsModel
var body: some View {
ZStack {
Button {
im.loadOpenChat(chat.id) {
userSupportChatNavLinkActive = true
}
} label: {
Label("Support chat", systemImage: "flag")
}
NavigationLink(isActive: $userSupportChatNavLinkActive) {
if let secondaryIM = chatModel.secondaryIM {
SecondaryChatView(
chat: chat,
im: secondaryIM
)
}
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
}
private func memberSupportButton() -> some View {
NavigationLink {
MemberSupportView(groupInfo: groupInfo)
.navigationBarTitle("Member support")
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Member support", systemImage: "flag")
}
}
struct GroupReportsChatNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@State private var groupReportsChatNavLinkActive = false
@ObservedObject var chat: Chat
var im: ItemsModel
var body: some View {
ZStack {
Button {
im.loadOpenChat(chat.id) {
groupReportsChatNavLinkActive = true
}
} label: {
Label("Member reports", systemImage: "flag")
}
NavigationLink(isActive: $groupReportsChatNavLinkActive) {
if let secondaryIM = chatModel.secondaryIM {
SecondaryChatView(
chat: chat,
im: secondaryIM
)
}
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
}
private func editGroupButton() -> some View {
NavigationLink {
GroupProfileView(
@@ -679,26 +772,34 @@ struct GroupChatInfoView: View {
title: Text("Remove member?"),
message: Text(messageLabel),
primaryButton: .destructive(Text("Remove")) {
Task {
do {
let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
await MainActor.run {
updatedMembers.forEach { updatedMember in
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
}
}
} catch let error {
logger.error("apiRemoveMembers error: \(responseError(error))")
let a = getErrorAlert(error, "Error removing member")
alert = .error(title: a.title, error: a.message)
}
}
removeMember(groupInfo, mem)
},
secondaryButton: .cancel()
)
}
}
func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember) {
Task {
do {
let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
await MainActor.run {
updatedMembers.forEach { updatedMember in
_ = ChatModel.shared.upsertGroupMember(groupInfo, updatedMember)
}
}
} catch let error {
logger.error("apiRemoveMembers error: \(responseError(error))")
await MainActor.run {
showAlert(
NSLocalizedString("Error removing member", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
groupInfo.businessChat == nil ? (
groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
@@ -278,7 +278,7 @@ struct GroupMemberInfoView: View {
}
}
.onChange(of: chat.chatInfo) { c in
if case let .group(gI) = chat.chatInfo {
if case let .group(gI, _) = chat.chatInfo {
groupInfo = gI
}
}
@@ -30,6 +30,14 @@ struct GroupPreferencesView: View {
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
VStack {
List {
Section {
MemberAdmissionButton(
groupInfo: $groupInfo,
admission: groupInfo.groupProfile.memberAdmission ?? GroupMemberAdmission(),
currentAdmission: groupInfo.groupProfile.memberAdmission ?? GroupMemberAdmission(),
creatingGroup: creatingGroup
)
}
featureSection(.timedMessages, $preferences.timedMessages.enable)
featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role)
@@ -77,6 +85,62 @@ struct GroupPreferencesView: View {
}
}
struct MemberAdmissionButton: View {
@Binding var groupInfo: GroupInfo
@State var admission: GroupMemberAdmission
@State var currentAdmission: GroupMemberAdmission
var creatingGroup: Bool = false
var body: some View {
NavigationLink {
MemberAdmissionView(
groupInfo: $groupInfo,
admission: $admission,
currentAdmission: currentAdmission,
creatingGroup: creatingGroup,
saveAdmission: saveAdmission
)
.navigationBarTitle("Member admission")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
.onDisappear {
let saveText = NSLocalizedString(
creatingGroup ? "Save" : "Save and notify group members",
comment: "alert button"
)
if groupInfo.groupProfile.memberAdmission != admission {
showAlert(
title: NSLocalizedString("Save admission settings?", comment: "alert title"),
buttonTitle: saveText,
buttonAction: { saveAdmission() },
cancelButton: true
)
}
}
} label: {
Label("Member admission", systemImage: "switch.2")
}
}
private func saveAdmission() {
Task {
do {
var gp = groupInfo.groupProfile
gp.memberAdmission = admission
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
await MainActor.run {
groupInfo = gInfo
ChatModel.shared.updateGroup(gInfo)
currentAdmission = admission
}
} catch {
logger.error("MemberAdmissionView apiUpdateGroup error: \(responseError(error))")
}
}
}
}
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = nil) -> some View {
Section {
let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary
@@ -0,0 +1,92 @@
//
// MemberAdmissionView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.04.2025.
// Copyright © 2025 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
private let memberCriterias: [(criteria: MemberCriteria?, text: LocalizedStringKey)] = [
(nil, "off"),
(.all, "all")
]
struct MemberAdmissionView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var groupInfo: GroupInfo
@Binding var admission: GroupMemberAdmission
var currentAdmission: GroupMemberAdmission
let creatingGroup: Bool
let saveAdmission: () -> Void
@State private var showSaveDialogue = false
var body: some View {
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
VStack {
List {
admissionSection(
NSLocalizedString("Review", comment: "admission stage"),
NSLocalizedString("Review new members before admitting to group.", comment: "admission stage description"),
$admission.review
)
if groupInfo.isOwner {
Section {
Button("Reset") { admission = currentAdmission }
Button(saveText) { saveAdmission() }
}
.disabled(currentAdmission == admission)
}
}
}
.modifier(BackButton(disabled: Binding.constant(false)) {
if currentAdmission == admission {
dismiss()
} else {
showSaveDialogue = true
}
})
.confirmationDialog("Save admission settings?", isPresented: $showSaveDialogue) {
Button(saveText) {
saveAdmission()
dismiss()
}
Button("Exit without saving") {
admission = currentAdmission
dismiss()
}
}
}
private func admissionSection(_ admissionStageStr: String, _ admissionStageDescrStr: String, _ memberCriteria: Binding<MemberCriteria?>) -> some View {
Section {
if groupInfo.isOwner {
Picker(admissionStageStr, selection: memberCriteria) {
ForEach(memberCriterias, id: \.criteria) { mc in
Text(mc.text)
}
}
} else {
infoRow(Text(admissionStageStr), memberCriteria.wrappedValue?.text ?? NSLocalizedString("off", comment: "member criteria value"))
}
} footer: {
Text(admissionStageDescrStr)
.foregroundColor(theme.colors.secondary)
}
}
}
#Preview {
MemberAdmissionView(
groupInfo: Binding.constant(GroupInfo.sampleData),
admission: Binding.constant(GroupMemberAdmission.sampleData),
currentAdmission: GroupMemberAdmission.sampleData,
creatingGroup: false,
saveAdmission: {}
)
}
@@ -0,0 +1,44 @@
//
// MemberSupportChatToolbar.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 01.05.2025.
// Copyright © 2025 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct MemberSupportChatToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
var groupMember: GroupMember
var imageSize: CGFloat = 32
var body: some View {
return HStack {
MemberProfileImage(groupMember, size: imageSize)
.padding(.trailing, 4)
let t = Text(groupMember.displayName).font(.headline)
(groupMember.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
}
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
}
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + textSpace)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.baselineOffset(1)
.kerning(-2)
}
}
#Preview {
MemberSupportChatToolbar(
groupMember: GroupMember.sampleData
)
.environmentObject(CurrentColors.toAppTheme())
}
@@ -0,0 +1,267 @@
//
// MemberSupportView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.04.2025.
// Copyright © 2025 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct MemberSupportView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State private var searchText: String = ""
@FocusState private var searchFocussed
var groupInfo: GroupInfo
var body: some View {
viewBody()
.onAppear {
Task {
await chatModel.loadGroupMembers(groupInfo)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
Task {
await chatModel.loadGroupMembers(groupInfo)
}
} label: {
Image(systemName: "arrow.clockwise")
}
}
}
}
@ViewBuilder private func viewBody() -> some View {
let membersWithChats = sortedMembersWithChats()
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let filteredMembersWithChats = s == ""
? membersWithChats
: membersWithChats.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
if membersWithChats.isEmpty {
Text("No support chats")
.foregroundColor(.secondary)
} else {
List {
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 8)
ForEach(filteredMembersWithChats) { memberWithChat in
let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: memberWithChat.wrapped)
MemberSupportChatNavLink(
groupInfo: groupInfo,
memberWithChat: memberWithChat,
chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
im: ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo))
)
}
}
}
}
struct MemberSupportChatNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@State private var memberSupportChatNavLinkActive = false
var groupInfo: GroupInfo
var memberWithChat: GMember
@ObservedObject var chat: Chat
var im: ItemsModel
var body: some View {
ZStack {
Button {
im.loadOpenChat(chat.id) {
memberSupportChatNavLinkActive = true
}
} label: {
SupportChatRowView(groupMember: memberWithChat, groupInfo: groupInfo)
}
NavigationLink(isActive: $memberSupportChatNavLinkActive) {
if let secondaryIM = chatModel.secondaryIM {
SecondaryChatView(
chat: chat,
im: secondaryIM
)
}
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
}
func sortedMembersWithChats() -> [GMember] {
chatModel.groupMembers
.filter {
$0.wrapped.supportChat != nil &&
$0.wrapped.memberStatus != .memLeft &&
$0.wrapped.memberStatus != .memRemoved
}
.sorted { (m0: GMember, m1: GMember) -> Bool in
if m0.wrapped.memberPending != m1.wrapped.memberPending {
return m0.wrapped.memberPending
}
let mentions0 = (m0.wrapped.supportChat?.mentions ?? 0) > 0
let mentions1 = (m1.wrapped.supportChat?.mentions ?? 0) > 0
if mentions0 != mentions1 {
return mentions0
}
let attention0 = (m0.wrapped.supportChat?.memberAttention ?? 0) > 0
let attention1 = (m1.wrapped.supportChat?.memberAttention ?? 0) > 0
if attention0 != attention1 {
return attention0
}
let unread0 = (m0.wrapped.supportChat?.unread ?? 0) > 0
let unread1 = (m1.wrapped.supportChat?.unread ?? 0) > 0
if unread0 != unread1 {
return unread0
}
return (m0.wrapped.supportChat?.chatTs ?? .distantPast) > (m1.wrapped.supportChat?.chatTs ?? .distantPast)
}
}
private struct SupportChatRowView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var groupMember: GMember
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var groupInfo: GroupInfo
var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
var body: some View {
let member = groupMember.wrapped
HStack{
MemberProfileImage(member, size: 38)
.padding(.trailing, 2)
VStack(alignment: .leading) {
let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground)
(member.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
Text(memberStatus(member))
.lineLimit(1)
.font(.caption)
.foregroundColor(theme.colors.secondary)
}
Spacer()
if member.memberPending {
Image(systemName: "flag.fill")
.resizable()
.scaledToFill()
.frame(width: dynamicChatInfoSize * 0.8, height: dynamicChatInfoSize * 0.8)
.foregroundColor(theme.colors.primary)
}
if let supportChat = member.supportChat {
SupportChatUnreadIndicator(supportChat: supportChat)
}
}
// TODO [knocking] swipe actions are broken
// .swipeActions(edge: .trailing, allowsFullSwipe: true) {
// if member.memberPending {
// Button {
// showAcceptMemberAlert(groupInfo, member)
// } label: {
// Label("Accept", systemImage: "checkmark")
// }
// .tint(theme.colors.primary)
// }
//
// Button {
// showRemoveMemberAlert(groupInfo, member)
// } label: {
// Label("Remove", systemImage: "trash")
// }
// .tint(.red)
// }
}
private func memberStatus(_ member: GroupMember) -> LocalizedStringKey {
if member.activeConn?.connDisabled ?? false {
return "disabled"
} else if member.activeConn?.connInactive ?? false {
return "inactive"
} else if member.memberPending {
return member.memberStatus.text
} else {
return LocalizedStringKey(member.memberRole.text)
}
}
struct SupportChatUnreadIndicator: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var supportChat: GroupSupportChat
var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
private var indicatorTint: Color {
if supportChat.mentions > 0 || supportChat.memberAttention > 0 {
return theme.colors.primary
} else {
return theme.colors.secondary
}
}
var body: some View {
HStack(alignment: .center, spacing: 2) {
if supportChat.unread > 0 || supportChat.mentions > 0 || supportChat.memberAttention > 0 {
if supportChat.mentions > 0 && supportChat.unread > 1 {
Text("\(MENTION_START)")
.font(userFont <= .xxxLarge ? .body : .callout)
.foregroundColor(indicatorTint)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
.cornerRadius(dynamicSize(userFont).unreadCorner)
.padding(.bottom, 1)
}
let singleUnreadIsMention = supportChat.mentions > 0 && supportChat.unread == 1
(singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(supportChat.unread))
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
.background(indicatorTint)
.cornerRadius(dynamicSize(userFont).unreadCorner)
}
}
.frame(height: dynamicChatInfoSize)
.frame(minWidth: 22)
}
}
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + textSpace)
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(theme.colors.secondary)
}
}
}
func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember) {
showAlert(
title: NSLocalizedString("Remove member?", comment: "alert title"),
buttonTitle: "Remove",
buttonAction: { removeMember(groupInfo, member) },
cancelButton: true
)
}
#Preview {
MemberSupportView(
groupInfo: GroupInfo.sampleData
)
}
@@ -0,0 +1,39 @@
//
// SecondaryChatView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 29.04.2025.
// Copyright © 2025 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SecondaryChatView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var chat: Chat
@ObservedObject var im: ItemsModel
var body: some View {
ChatView(
chat: chat,
im: im,
mergedItems: BoxedValue(MergedItems.create(im, [])),
floatingButtonModel: FloatingButtonModel(im: im)
)
.onDisappear {
chatModel.secondaryIM = nil
}
}
}
#Preview {
SecondaryChatView(
chat: Chat(
chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)),
chatItems: [],
chatStats: ChatStats()
),
im: ItemsModel.shared
)
}
@@ -25,7 +25,7 @@ struct SelectedItemsTopToolbar: View {
struct SelectedItemsBottomToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let chatItems: [ChatItem]
let im: ItemsModel
@Binding var selectedChatItems: Set<Int64>?
var chatInfo: ChatInfo
// Bool - delete for everyone is possible
@@ -75,9 +75,9 @@ struct SelectedItemsBottomToolbar: View {
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red)
.foregroundColor(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : .red)
}
.disabled(!moderateEnabled || deleteCountProhibited)
.disabled(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil)
.opacity(canModerate ? 1 : 0)
Spacer()
@@ -88,24 +88,24 @@ struct SelectedItemsBottomToolbar: View {
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary)
.foregroundColor(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : theme.colors.primary)
}
.disabled(!forwardEnabled || forwardCountProhibited)
.disabled(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
}
.onAppear {
recheckItems(chatInfo, chatItems, selectedChatItems)
recheckItems(chatInfo, im.reversedChatItems, selectedChatItems)
}
.onChange(of: chatInfo) { info in
recheckItems(info, chatItems, selectedChatItems)
recheckItems(info, im.reversedChatItems, selectedChatItems)
}
.onChange(of: chatItems) { items in
.onChange(of: im.reversedChatItems) { items in
recheckItems(chatInfo, items, selectedChatItems)
}
.onChange(of: selectedChatItems) { selected in
recheckItems(chatInfo, chatItems, selected)
recheckItems(chatInfo, im.reversedChatItems, selected)
}
.frame(height: 55.5)
.background(.thinMaterial)
@@ -116,7 +116,7 @@ struct SelectedItemsBottomToolbar: View {
deleteCountProhibited = count == 0 || count > 200
forwardCountProhibited = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo)
let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info) = chatInfo {
let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info, _) = chatInfo {
info
} else {
nil
@@ -145,7 +145,7 @@ struct SelectedItemsBottomToolbar: View {
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
return switch chatInfo {
case let .group(groupInfo):
case let .group(groupInfo, _):
groupInfo.membership.memberRole >= .admin
default: false
}
@@ -66,7 +66,7 @@ struct ChatListNavLink: View {
switch chat.chatInfo {
case let .direct(contact):
contactNavLink(contact)
case let .group(groupInfo):
case let .group(groupInfo, _):
groupNavLink(groupInfo)
case let .local(noteFolder):
noteFolderNavLink(noteFolder)
@@ -452,7 +452,13 @@ struct ChatListView: View {
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat)
let im = ItemsModel.shared
ChatView(
chat: chat,
im: im,
mergedItems: BoxedValue(MergedItems.create(im, [])),
floatingButtonModel: FloatingButtonModel(im: im)
)
}
}
@@ -898,12 +904,12 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: C
case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted
case .contactRequest: true
case .contactConnection: true
case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer
case let .group(groupInfo, _): groupInfo.businessChat?.chatType == .customer
default: false
}
case .groups:
switch chatInfo {
case let .group(groupInfo): groupInfo.businessChat == nil
case let .group(groupInfo, _): groupInfo.businessChat == nil
default: false
}
case .business:
@@ -141,7 +141,7 @@ struct ChatPreviewView: View {
} else {
EmptyView()
}
case let .group(groupInfo):
case let .group(groupInfo, _):
switch (groupInfo.membership.memberStatus) {
case .memRejected: inactiveIcon()
case .memLeft: inactiveIcon()
@@ -165,7 +165,7 @@ struct ChatPreviewView: View {
switch chat.chatInfo {
case let .direct(contact):
previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(deleting ? Color.secondary : nil)
case let .group(groupInfo):
case let .group(groupInfo, _):
let v = previewTitle(t)
switch (groupInfo.membership.memberStatus) {
case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary)
@@ -335,7 +335,7 @@ struct ChatPreviewView: View {
chatPreviewInfoText("connecting…")
}
}
case let .group(groupInfo):
case let .group(groupInfo, _):
switch (groupInfo.membership.memberStatus) {
case .memRejected: chatPreviewInfoText("rejected")
case .memInvited: groupInvitationPreviewText(groupInfo)
@@ -445,6 +445,8 @@ struct ChatPreviewView: View {
ProgressView()
} else if chat.chatStats.reportsCount > 0 {
groupReportsIcon(size: size * 0.8)
} else if chat.chatStats.supportChatsUnreadCount > 0 {
GroupSupportUnreadIcon(size: size * 0.8)
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
@@ -498,6 +500,19 @@ struct ChatPreviewView: View {
.foregroundColor(.red)
}
struct GroupSupportUnreadIcon: View {
@EnvironmentObject var theme: AppTheme
var size: CGFloat
var body: some View {
Image(systemName: "flag")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundColor(theme.colors.primary)
}
}
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
@@ -66,6 +66,8 @@ struct LocalAuthView: View {
m.chatId = nil
ItemsModel.shared.reversedChatItems = []
ItemsModel.shared.chatState.clear()
ChatModel.shared.secondaryIM?.reversedChatItems = []
ChatModel.shared.secondaryIM?.chatState.clear()
m.updateChats([])
m.users = []
_ = kcAppPassword.set(password)
@@ -193,7 +193,7 @@ struct AddGroupView: View {
Task {
await m.loadGroupMembers(gInfo)
}
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
let c = Chat(chatInfo: .group(groupInfo: gInfo, groupChatScope: nil), chatItems: [])
m.addChat(c)
withAnimation {
groupInfo = gInfo
@@ -367,13 +367,13 @@ struct ChatThemePreview: View {
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack {
ChatItemView(chat: Chat.sampleData, chatItem: alice, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: alice, scrollToItemId: { _ in })
.modifier(ChatItemClipped(alice, tailVisible: true))
Spacer()
}
HStack {
Spacer()
ChatItemView(chat: Chat.sampleData, chatItem: bob, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: bob, scrollToItemId: { _ in })
.modifier(ChatItemClipped(bob, tailVisible: true))
.frame(alignment: .trailing)
}
@@ -59,6 +59,7 @@ let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert"
let DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "showReportsInSupportChatAlert"
let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion"
let DEFAULT_ONBOARDING_STAGE = "onboardingStage"
let DEFAULT_MIGRATION_TO_STAGE = "migrationToStage"
@@ -118,6 +119,7 @@ let appDefaults: [String: Any] = [
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
DEFAULT_SHOW_MUTE_PROFILE_ALERT: true,
DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT: true,
DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue,
DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300,
DEFAULT_SHOW_UNREAD_AND_FAVORITES: false,
@@ -145,6 +147,7 @@ let hintDefaults = [
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
DEFAULT_SHOW_MUTE_PROFILE_ALERT,
DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT,
DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE,
DEFAULT_SHOW_DELETE_CONTACT_NOTICE
]
+18 -5
View File
@@ -67,6 +67,7 @@ func apiSendMessages(
: SEChatCommand.apiSendMessages(
type: chatInfo.chatType,
id: chatInfo.apiId,
scope: chatInfo.groupChatScope(),
live: false,
ttl: nil,
composedMessages: composedMessages
@@ -123,7 +124,7 @@ enum SEChatCommand: ChatCmdProtocol {
case apiSetEncryptLocalFiles(enable: Bool)
case apiGetChats(userId: Int64)
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
var cmdString: String {
switch self {
@@ -139,15 +140,27 @@ enum SEChatCommand: ChatCmdProtocol {
case let .apiCreateChatItems(noteFolderId, composedMessages):
let msgs = encodeJSON(composedMessages)
return "/_create *\(noteFolderId) json \(msgs)"
case let .apiSendMessages(type, id, live, ttl, composedMessages):
case let .apiSendMessages(type, id, scope, live, ttl, composedMessages):
let msgs = encodeJSON(composedMessages)
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
}
}
func ref(_ type: ChatType, _ id: Int64) -> String {
"\(type.rawValue)\(id)"
func ref(_ type: ChatType, _ id: Int64, scope: GroupChatScope?) -> String {
"\(type.rawValue)\(id)\(scopeRef(scope: scope))"
}
func scopeRef(scope: GroupChatScope?) -> String {
switch (scope) {
case .none: ""
case let .memberSupport(groupMemberId_):
if let groupMemberId = groupMemberId_ {
"(_support:\(groupMemberId))"
} else {
"(_support)"
}
}
}
}
@@ -168,6 +168,11 @@
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */; };
64A779F82DBFDBF200FDEF2F /* MemberSupportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */; };
64A779FC2DC1040000FDEF2F /* SecondaryChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */; };
64A779FE2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */; };
64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
@@ -527,6 +532,11 @@
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = "<group>"; };
64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberSupportView.swift; sourceTree = "<group>"; };
64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryChatView.swift; sourceTree = "<group>"; };
64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberSupportChatToolbar.swift; sourceTree = "<group>"; };
64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPendingMemberActionsView.swift; sourceTree = "<group>"; };
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
@@ -1076,6 +1086,7 @@
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */,
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */,
6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */,
64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */,
);
path = ComposeMessage;
sourceTree = "<group>";
@@ -1117,6 +1128,10 @@
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */,
1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */,
B70CE9E52D4BE5930080F36D /* GroupMentions.swift */,
64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */,
64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */,
64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */,
64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */,
);
path = Group;
sourceTree = "<group>";
@@ -1431,8 +1446,10 @@
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */,
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */,
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */,
64A779FE2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift in Sources */,
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
64A779F82DBFDBF200FDEF2F /* MemberSupportView.swift in Sources */,
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */,
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */,
@@ -1467,6 +1484,7 @@
5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */,
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */,
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */,
64A779FC2DC1040000FDEF2F /* SecondaryChatView.swift in Sources */,
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */,
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
@@ -1509,6 +1527,7 @@
5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */,
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */,
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */,
64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */,
8C74C3EA2C1B90AF00039E77 /* ThemeManager.swift in Sources */,
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */,
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */,
@@ -1587,6 +1606,7 @@
1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */,
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */,
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */,
64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */,
18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */,
8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */,
184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */,
+183 -27
View File
@@ -1197,7 +1197,7 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable, Hashable {
public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
case direct(contact: Contact)
case group(groupInfo: GroupInfo)
case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?)
case local(noteFolder: NoteFolder)
case contactRequest(contactRequest: UserContactRequest)
case contactConnection(contactConnection: PendingContactConnection)
@@ -1211,7 +1211,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
get {
switch self {
case let .direct(contact): return contact.localDisplayName
case let .group(groupInfo): return groupInfo.localDisplayName
case let .group(groupInfo, _): return groupInfo.localDisplayName
case .local: return ""
case let .contactRequest(contactRequest): return contactRequest.localDisplayName
case let .contactConnection(contactConnection): return contactConnection.localDisplayName
@@ -1224,7 +1224,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
get {
switch self {
case let .direct(contact): return contact.displayName
case let .group(groupInfo): return groupInfo.displayName
case let .group(groupInfo, _): return groupInfo.displayName
case .local: return ChatInfo.privateNotesChatName
case let .contactRequest(contactRequest): return contactRequest.displayName
case let .contactConnection(contactConnection): return contactConnection.displayName
@@ -1237,7 +1237,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
get {
switch self {
case let .direct(contact): return contact.fullName
case let .group(groupInfo): return groupInfo.fullName
case let .group(groupInfo, _): return groupInfo.fullName
case .local: return ""
case let .contactRequest(contactRequest): return contactRequest.fullName
case let .contactConnection(contactConnection): return contactConnection.fullName
@@ -1250,7 +1250,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
get {
switch self {
case let .direct(contact): return contact.image
case let .group(groupInfo): return groupInfo.image
case let .group(groupInfo, _): return groupInfo.image
case .local: return nil
case let .contactRequest(contactRequest): return contactRequest.image
case let .contactConnection(contactConnection): return contactConnection.image
@@ -1263,7 +1263,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
get {
switch self {
case let .direct(contact): return contact.localAlias
case let .group(groupInfo): return groupInfo.localAlias
case let .group(groupInfo, _): return groupInfo.localAlias
case .local: return ""
case let .contactRequest(contactRequest): return contactRequest.localAlias
case let .contactConnection(contactConnection): return contactConnection.localAlias
@@ -1276,7 +1276,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
get {
switch self {
case let .direct(contact): return contact.id
case let .group(groupInfo): return groupInfo.id
case let .group(groupInfo, _): return groupInfo.id
case let .local(noteFolder): return noteFolder.id
case let .contactRequest(contactRequest): return contactRequest.id
case let .contactConnection(contactConnection): return contactConnection.id
@@ -1302,7 +1302,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
get {
switch self {
case let .direct(contact): return contact.apiId
case let .group(groupInfo): return groupInfo.apiId
case let .group(groupInfo, _): return groupInfo.apiId
case let .local(noteFolder): return noteFolder.apiId
case let .contactRequest(contactRequest): return contactRequest.apiId
case let .contactConnection(contactConnection): return contactConnection.apiId
@@ -1315,7 +1315,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
get {
switch self {
case let .direct(contact): return contact.ready
case let .group(groupInfo): return groupInfo.ready
case let .group(groupInfo, _): return groupInfo.ready
case let .local(noteFolder): return noteFolder.ready
case let .contactRequest(contactRequest): return contactRequest.ready
case let .contactConnection(contactConnection): return contactConnection.ready
@@ -1337,7 +1337,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
get {
switch self {
case let .direct(contact): return contact.sendMsgEnabled
case let .group(groupInfo): return groupInfo.sendMsgEnabled
case let .group(groupInfo, _): return groupInfo.sendMsgEnabled
case let .local(noteFolder): return noteFolder.sendMsgEnabled
case let .contactRequest(contactRequest): return contactRequest.sendMsgEnabled
case let .contactConnection(contactConnection): return contactConnection.sendMsgEnabled
@@ -1350,7 +1350,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
get {
switch self {
case let .direct(contact): return contact.contactConnIncognito
case let .group(groupInfo): return groupInfo.membership.memberIncognito
case let .group(groupInfo, _): return groupInfo.membership.memberIncognito
case .local: return false
case .contactRequest: return false
case let .contactConnection(contactConnection): return contactConnection.incognito
@@ -1375,7 +1375,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
public var groupInfo: GroupInfo? {
switch self {
case let .group(groupInfo): return groupInfo
case let .group(groupInfo, _): return groupInfo
default: return nil
}
}
@@ -1392,7 +1392,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
case .voice: return cups.voice.enabled.forUser
case .calls: return cups.calls.enabled.forUser
}
case let .group(groupInfo):
case let .group(groupInfo, _):
let prefs = groupInfo.fullGroupPreferences
switch feature {
case .timedMessages: return prefs.timedMessages.on
@@ -1415,7 +1415,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
case let .direct(contact):
let pref = contact.mergedPreferences.timedMessages
return pref.enabled.forUser ? pref.userPreference.preference.ttl : nil
case let .group(groupInfo):
case let .group(groupInfo, _):
let pref = groupInfo.fullGroupPreferences.timedMessages
return pref.on ? pref.ttl : nil
default:
@@ -1440,7 +1440,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
} else {
return .other
}
case let .group(groupInfo):
case let .group(groupInfo, _):
if !groupInfo.fullGroupPreferences.voice.on(for: groupInfo.membership) {
return .groupOwnerCan
} else {
@@ -1471,7 +1471,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
return .other
}
}
public func groupChatScope() -> GroupChatScope? {
switch self {
case let .group(_, groupChatScope): groupChatScope?.toChatScope()
default: nil
}
}
public func ntfsEnabled(chatItem: ChatItem) -> Bool {
ntfsEnabled(chatItem.meta.userMention)
}
@@ -1487,7 +1494,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
public var chatSettings: ChatSettings? {
switch self {
case let .direct(contact): return contact.chatSettings
case let .group(groupInfo): return groupInfo.chatSettings
case let .group(groupInfo, _): return groupInfo.chatSettings
default: return nil
}
}
@@ -1503,7 +1510,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
public var chatTags: [Int64]? {
switch self {
case let .direct(contact): return contact.chatTags
case let .group(groupInfo): return groupInfo.chatTags
case let .group(groupInfo, _): return groupInfo.chatTags
default: return nil
}
}
@@ -1511,7 +1518,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
var createdAt: Date {
switch self {
case let .direct(contact): return contact.createdAt
case let .group(groupInfo): return groupInfo.createdAt
case let .group(groupInfo, _): return groupInfo.createdAt
case let .local(noteFolder): return noteFolder.createdAt
case let .contactRequest(contactRequest): return contactRequest.createdAt
case let .contactConnection(contactConnection): return contactConnection.createdAt
@@ -1522,7 +1529,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
public var updatedAt: Date {
switch self {
case let .direct(contact): return contact.updatedAt
case let .group(groupInfo): return groupInfo.updatedAt
case let .group(groupInfo, _): return groupInfo.updatedAt
case let .local(noteFolder): return noteFolder.updatedAt
case let .contactRequest(contactRequest): return contactRequest.updatedAt
case let .contactConnection(contactConnection): return contactConnection.updatedAt
@@ -1533,7 +1540,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
public var chatTs: Date {
switch self {
case let .direct(contact): return contact.chatTs ?? contact.updatedAt
case let .group(groupInfo): return groupInfo.chatTs ?? groupInfo.updatedAt
case let .group(groupInfo, _): return groupInfo.chatTs ?? groupInfo.updatedAt
case let .local(noteFolder): return noteFolder.chatTs
case let .contactRequest(contactRequest): return contactRequest.updatedAt
case let .contactConnection(contactConnection): return contactConnection.updatedAt
@@ -1549,7 +1556,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
} else {
ChatTTL.userDefault(globalTTL)
}
case let .group(groupInfo):
case let .group(groupInfo, _):
return if let ciTTL = groupInfo.chatItemTTL {
ChatTTL.chat(ChatItemTTL(ciTTL))
} else {
@@ -1569,7 +1576,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
public static var sampleData: ChatInfo.SampleData = SampleData(
direct: ChatInfo.direct(contact: Contact.sampleData),
group: ChatInfo.group(groupInfo: GroupInfo.sampleData),
group: ChatInfo.group(groupInfo: GroupInfo.sampleData, groupChatScope: nil),
local: ChatInfo.local(noteFolder: NoteFolder.sampleData),
contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData),
contactConnection: ChatInfo.contactConnection(contactConnection: PendingContactConnection.getSampleData())
@@ -1599,10 +1606,18 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike {
}
public struct ChatStats: Decodable, Hashable {
public init(unreadCount: Int = 0, unreadMentions: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) {
public init(
unreadCount: Int = 0,
unreadMentions: Int = 0,
reportsCount: Int = 0,
supportChatsUnreadCount: Int = 0,
minUnreadItemId: Int64 = 0,
unreadChat: Bool = false
) {
self.unreadCount = unreadCount
self.unreadMentions = unreadMentions
self.reportsCount = reportsCount
self.supportChatsUnreadCount = supportChatsUnreadCount
self.minUnreadItemId = minUnreadItemId
self.unreadChat = unreadChat
}
@@ -1611,11 +1626,39 @@ public struct ChatStats: Decodable, Hashable {
public var unreadMentions: Int = 0
// actual only via getChats() and getChat(.initial), otherwise, zero
public var reportsCount: Int = 0
// actual only via getChats() and getChat(.initial), otherwise, zero
public var supportChatsUnreadCount: Int = 0
public var minUnreadItemId: Int64 = 0
// actual only via getChats(), otherwise, false
public var unreadChat: Bool = false
}
public enum GroupChatScope: Decodable {
case memberSupport(groupMemberId_: Int64?)
}
public func sameChatScope(_ scope1: GroupChatScope, _ scope2: GroupChatScope) -> Bool {
switch (scope1, scope2) {
case let (.memberSupport(groupMemberId1_), .memberSupport(groupMemberId2_)):
return groupMemberId1_ == groupMemberId2_
}
}
public enum GroupChatScopeInfo: Decodable, Hashable {
case memberSupport(groupMember_: GroupMember?)
public func toChatScope() -> GroupChatScope {
switch self {
case let .memberSupport(groupMember_):
if let groupMember = groupMember_ {
return .memberSupport(groupMemberId_: groupMember.groupMemberId)
} else {
return .memberSupport(groupMemberId_: nil)
}
}
}
}
public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
public var contactId: Int64
var localDisplayName: ContactName
@@ -2010,6 +2053,10 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
return membership.memberRole >= .admin && membership.memberActive
}
public var canModerate: Bool {
return membership.memberRole >= .moderator && membership.memberActive
}
public static let sampleData = GroupInfo(
groupId: 1,
localDisplayName: "team",
@@ -2030,12 +2077,20 @@ public struct GroupRef: Decodable, Hashable {
}
public struct GroupProfile: Codable, NamedChat, Hashable {
public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) {
public init(
displayName: String,
fullName: String,
description: String? = nil,
image: String? = nil,
groupPreferences: GroupPreferences? = nil,
memberAdmission: GroupMemberAdmission? = nil
) {
self.displayName = displayName
self.fullName = fullName
self.description = description
self.image = image
self.groupPreferences = groupPreferences
self.memberAdmission = memberAdmission
}
public var displayName: String
@@ -2043,6 +2098,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable {
public var description: String?
public var image: String?
public var groupPreferences: GroupPreferences?
public var memberAdmission: GroupMemberAdmission?
public var localAlias: String { "" }
public static let sampleData = GroupProfile(
@@ -2051,6 +2107,34 @@ public struct GroupProfile: Codable, NamedChat, Hashable {
)
}
public struct GroupMemberAdmission: Codable, Hashable {
public var review: MemberCriteria?
public init(
review: MemberCriteria? = nil
) {
self.review = review
}
public static let sampleData = GroupMemberAdmission(
review: .all
)
}
public enum MemberCriteria: String, Codable, Identifiable, Hashable {
case all
public static var values: [MemberCriteria] { [.all] }
public var id: Self { self }
public var text: String {
switch self {
case .all: return NSLocalizedString("all", comment: "member criteria value")
}
}
}
public struct BusinessChatInfo: Decodable, Hashable {
public var chatType: BusinessChatType
public var businessId: String
@@ -2077,6 +2161,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
public var memberContactId: Int64?
public var memberContactProfileId: Int64
public var activeConn: Connection?
public var supportChat: GroupSupportChat?
public var memberChatVRange: VersionRange
public var id: String { "#\(groupId) @\(groupMemberId)" }
@@ -2148,6 +2233,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
case .memUnknown: return false
case .memInvited: return false
case .memPendingApproval: return true
case .memPendingReview: return true
case .memIntroduced: return false
case .memIntroInvited: return false
case .memAccepted: return false
@@ -2167,6 +2253,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
case .memUnknown: return false
case .memInvited: return false
case .memPendingApproval: return false
case .memPendingReview: return false
case .memIntroduced: return true
case .memIntroInvited: return true
case .memAccepted: return true
@@ -2177,6 +2264,14 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
}
}
public var memberPending: Bool {
switch memberStatus {
case .memPendingApproval: return true
case .memPendingReview: return true
default: return false
}
}
public func canBeRemoved(groupInfo: GroupInfo) -> Bool {
let userRole = groupInfo.membership.memberRole
return memberStatus != .memRemoved && memberStatus != .memLeft
@@ -2230,6 +2325,13 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
)
}
public struct GroupSupportChat: Codable, Hashable {
public var chatTs: Date
public var unread: Int
public var memberAttention: Int
public var mentions: Int
}
public struct GroupMemberSettings: Codable, Hashable {
public var showMessages: Bool
}
@@ -2299,6 +2401,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable {
case memUnknown = "unknown"
case memInvited = "invited"
case memPendingApproval = "pending_approval"
case memPendingReview = "pending_review"
case memIntroduced = "introduced"
case memIntroInvited = "intro-inv"
case memAccepted = "accepted"
@@ -2316,6 +2419,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable {
case .memUnknown: return "unknown status"
case .memInvited: return "invited"
case .memPendingApproval: return "pending approval"
case .memPendingReview: return "pending review"
case .memIntroduced: return "connecting (introduced)"
case .memIntroInvited: return "connecting (introduction invitation)"
case .memAccepted: return "connecting (accepted)"
@@ -2335,6 +2439,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable {
case .memUnknown: return "unknown"
case .memInvited: return "invited"
case .memPendingApproval: return "pending"
case .memPendingReview: return "review"
case .memIntroduced: return "connecting"
case .memIntroInvited: return "connecting"
case .memAccepted: return "connecting"
@@ -2589,12 +2694,14 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
case .userDeleted: nil
case .groupDeleted: nil
case .memberCreatedContact: nil
case .newMemberPendingReview: nil
default: .rcvGroupEvent
}
case let .sndGroupEvent(event):
switch event {
case .userRole: nil
case .userLeft: nil
case .userPendingReview: nil
default: .sndGroupEvent
}
default:
@@ -2627,6 +2734,8 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
switch rcvGroupEvent {
case .groupUpdated: return false
case .memberConnected: return false
case .memberAccepted: return false
case .userAccepted: return false
case .memberRole: return false
case .memberBlocked: return false
case .userRole: return true
@@ -2638,6 +2747,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
case .invitedViaGroupLink: return false
case .memberCreatedContact: return false
case .memberProfileUpdated: return false
case .newMemberPendingReview: return true
}
case .sndGroupEvent: return false
case .rcvConnEvent: return false
@@ -2706,12 +2816,12 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
public func memberToModerate(_ chatInfo: ChatInfo) -> (GroupInfo, GroupMember?)? {
switch (chatInfo, chatDir) {
case let (.group(groupInfo), .groupRcv(groupMember)):
case let (.group(groupInfo, _), .groupRcv(groupMember)):
let m = groupInfo.membership
return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil
? (groupInfo, groupMember)
: nil
case let (.group(groupInfo), .groupSnd):
case let (.group(groupInfo, _), .groupSnd):
let m = groupInfo.membership
return m.memberRole >= .admin ? (groupInfo, nil) : nil
default: return nil
@@ -3093,6 +3203,21 @@ public enum CIStatus: Decodable, Hashable {
}
}
// as in corresponds to SENT response from agent, opposed to `sent` which means snd status
public var isSent: Bool {
switch self {
case .sndNew: false
case .sndSent: true
case .sndRcvd: false
case .sndErrorAuth: true
case .sndError: true
case .sndWarning: true
case .rcvNew: false
case .rcvRead: false
case .invalid: false
}
}
public func statusIcon(_ metaColor: Color, _ paleMetaColor: Color, _ primaryColor: Color = .accentColor) -> (Image, Color)? {
switch self {
case .sndNew: nil
@@ -3146,6 +3271,17 @@ public enum CIStatus: Decodable, Hashable {
}
}
public func shouldKeepOldSndCIStatus(oldStatus: CIStatus, newStatus: CIStatus) -> Bool {
switch (oldStatus, newStatus) {
case (.sndRcvd, let new) where !new.isSndRcvd:
return true
case (let old, .sndNew) where old.isSent:
return true
default:
return false
}
}
public enum SndError: Decodable, Hashable {
case auth
case quota
@@ -4045,6 +4181,16 @@ extension MsgContent: Encodable {
}
}
public enum MsgContentTag: String {
case text
case link
case image
case video
case voice
case file
case report
}
public struct FormattedText: Decodable, Hashable {
public var text: String
public var format: Format?
@@ -4376,6 +4522,8 @@ public enum RcvDirectEvent: Decodable, Hashable {
public enum RcvGroupEvent: Decodable, Hashable {
case memberAdded(groupMemberId: Int64, profile: Profile)
case memberConnected
case memberAccepted(groupMemberId: Int64, profile: Profile)
case userAccepted
case memberLeft
case memberRole(groupMemberId: Int64, profile: Profile, role: GroupMemberRole)
case memberBlocked(groupMemberId: Int64, profile: Profile, blocked: Bool)
@@ -4387,12 +4535,16 @@ public enum RcvGroupEvent: Decodable, Hashable {
case invitedViaGroupLink
case memberCreatedContact
case memberProfileUpdated(fromProfile: Profile, toProfile: Profile)
case newMemberPendingReview
var text: String {
switch self {
case let .memberAdded(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("invited %@", comment: "rcv group event chat item"), profile.profileViewName)
case .memberConnected: return NSLocalizedString("member connected", comment: "rcv group event chat item")
case let .memberAccepted(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("accepted %@", comment: "rcv group event chat item"), profile.profileViewName)
case .userAccepted: return NSLocalizedString("accepted you", comment: "rcv group event chat item")
case .memberLeft: return NSLocalizedString("left", comment: "rcv group event chat item")
case let .memberRole(_, profile, role):
return String.localizedStringWithFormat(NSLocalizedString("changed role of %@ to %@", comment: "rcv group event chat item"), profile.profileViewName, role.text)
@@ -4412,6 +4564,7 @@ public enum RcvGroupEvent: Decodable, Hashable {
case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item")
case .memberCreatedContact: return NSLocalizedString("connected directly", comment: "rcv group event chat item")
case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile)
case .newMemberPendingReview: return NSLocalizedString("New member wants to join the group.", comment: "rcv group event chat item")
}
}
@@ -4436,6 +4589,7 @@ public enum SndGroupEvent: Decodable, Hashable {
case memberDeleted(groupMemberId: Int64, profile: Profile)
case userLeft
case groupUpdated(groupProfile: GroupProfile)
case userPendingReview
var text: String {
switch self {
@@ -4453,6 +4607,8 @@ public enum SndGroupEvent: Decodable, Hashable {
return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName)
case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item")
case .groupUpdated: return NSLocalizedString("group profile updated", comment: "snd group event chat item")
case .userPendingReview:
return NSLocalizedString("Please wait for group moderators to review your request to join the group.", comment: "snd group event chat item")
}
}
}
+3 -3
View File
@@ -16,7 +16,7 @@ public protocol ChatLike {
extension ChatLike {
public func groupFeatureEnabled(_ feature: GroupFeature) -> Bool {
if case let .group(groupInfo) = self.chatInfo {
if case let .group(groupInfo, _) = self.chatInfo {
let p = groupInfo.fullGroupPreferences
return switch feature {
case .timedMessages: p.timedMessages.on
@@ -83,7 +83,7 @@ public func foundChat(_ chat: ChatLike, _ searchStr: String) -> Bool {
private func canForwardToChat(_ cInfo: ChatInfo) -> Bool {
switch cInfo {
case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv
case let .group(groupInfo): groupInfo.sendMsgEnabled
case let .group(groupInfo, _): groupInfo.sendMsgEnabled
case let .local(noteFolder): noteFolder.sendMsgEnabled
case .contactRequest: false
case .contactConnection: false
@@ -94,7 +94,7 @@ private func canForwardToChat(_ cInfo: ChatInfo) -> Bool {
public func chatIconName(_ cInfo: ChatInfo) -> String {
switch cInfo {
case .direct: "person.crop.circle.fill"
case let .group(groupInfo):
case let .group(groupInfo, _):
switch groupInfo.businessChat?.chatType {
case .none: "person.2.circle.fill"
case .business: "briefcase.circle.fill"
+1 -1
View File
@@ -62,7 +62,7 @@ public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact,
public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem, _ badgeCount: Int) -> UNMutableNotificationContent {
let previewMode = ntfPreviewModeGroupDefault.get()
var title: String
if case let .group(groupInfo) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir {
if case let .group(groupInfo, _) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir {
title = groupMsgNtfTitle(groupInfo, groupMember, hideContent: previewMode == .hidden)
} else {
title = previewMode == .hidden ? contactHidden : "\(cInfo.chatViewName):"
@@ -3103,6 +3103,7 @@ sealed class CIStatus {
@Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
@Serializable @SerialName("invalid") class Invalid(val text: String): CIStatus()
// as in corresponds to SENT response from agent
fun isSent(): Boolean = when(this) {
is SndNew -> false
is SndSent -> true
@@ -165,7 +165,7 @@ fun SupportChatRow(member: GroupMember) {
fun memberStatus(): String {
return if (member.activeConn?.connDisabled == true) {
generalGetString(MR.strings.member_info_member_disabled)
} else if (member.activeConn?.connDisabled == true) {
} else if (member.activeConn?.connInactive == true) {
generalGetString(MR.strings.member_info_member_inactive)
} else if (member.memberPending) {
member.memberStatus.text