Compare commits

..

11 commits

Author SHA1 Message Date
spaced4ndy
3d22b738d8
core: fix change connection user (#5992)
* core: fix change connection user

* plans
2025-06-16 22:38:02 +01:00
Evgeny Poberezkin
c08189108e
6.3.6: ios 282, android 295, desktop 106 2025-06-14 20:12:19 +01:00
Evgeny Poberezkin
442d9afc4b
android: remove Contribute link from Android bundle 2025-06-14 19:26:46 +01:00
Evgeny Poberezkin
a593557c21
core: 6.3.6.0 (simplexmq 6.4.0.3.1) 2025-06-14 14:46:08 +01:00
Evgeny
07abe24e18
core: make decoding for short link data forward compatible (#5989)
* core: make decoding for short link data forward compatible

* simplexmq
2025-06-14 14:17:34 +01:00
Evgeny Poberezkin
5f6595dda9
6.3.5: ios 280, android 292, desktop 104 2025-06-09 09:32:32 +01:00
Evgeny Poberezkin
6fdd50efb9
core: 6.3.5.0 2025-06-08 18:28:26 +01:00
Evgeny
50dfda6c09
core: fix deletion queries for PostgreSQL client (#5969)
* core: fix deletion queries for PostgreSQL client

* disable test in posrgres

* plan
2025-06-08 18:27:42 +01:00
Evgeny Poberezkin
ea1a81fcac
core: 6.3.4.2 (simplexmq 6.4.0.3) 2025-06-06 12:26:46 +01:00
Evgeny
cf0639bf28
website: add Whonix to reviews (#5966) 2025-06-05 21:05:16 +01:00
Evgeny
7b362ff655
ui: label in compose when user cannot send messages (#5922)
* ui: label in compose when user cannot send messages

* gray buttons when user cannot send messages

* improve

* kotlin

* fix order

* fix alert

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2025-05-19 14:50:33 +00:00
129 changed files with 2930 additions and 7562 deletions

View file

@ -10,7 +10,7 @@
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
[<img src="./images/trail-of-bits.jpg" height="100">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) &nbsp;&nbsp;&nbsp; [<img src="./images/privacy-guides.jpg" height="80">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) &nbsp;&nbsp;&nbsp; [<img src="./images/kuketz-blog.jpg" height="80">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
[<img src="./images/trail-of-bits.jpg" height="80">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) &nbsp;&nbsp;&nbsp; [<img src="./images/privacy-guides.jpg" height="64">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) &nbsp;&nbsp;&nbsp; [<img src="./images/whonix-logo.jpg" height="64">](https://www.whonix.org/wiki/Chat#Recommendation) &nbsp;&nbsp;&nbsp; [<img src="./images/kuketz-blog.jpg" height="64">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
## Welcome to SimpleX Chat!
@ -110,6 +110,15 @@ After you connect, you can [verify connection security code](./blog/20230103-sim
Read about the app features and settings in the new [User guide](./docs/guide/README.md).
## Contribute
We would love to have you join the development! You can help us with:
- [share the color theme](./docs/THEMES.md) you use in Android app!
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started.
## Help translating SimpleX Chat
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
@ -141,15 +150,6 @@ Join our translators to help SimpleX grow!
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
## Contribute
We would love to have you join the development! You can help us with:
- [share the color theme](./docs/THEMES.md) you use in Android app!
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started.
## Please support us with your donations
Huge thank you to everybody who donated to SimpleX Chat!

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, 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 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 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, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode)
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, 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, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction)
case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction)
case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction)
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 apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64])
case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?)
case apiGetNtfToken
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
@ -68,7 +68,6 @@ 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)
@ -148,8 +147,8 @@ enum ChatCommand: ChatCmdProtocol {
case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus)
// WebRTC calls /
case apiGetNetworkStatuses
case apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?)
case apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64])
case apiChatRead(type: ChatType, id: Int64)
case apiChatItemsRead(type: ChatType, id: Int64, 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?)
@ -210,16 +209,15 @@ 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, 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):
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):
let msgs = encodeJSON(composedMessages)
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
return "/_send \(ref(type, id)) 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, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id)) \(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: ","))"
@ -228,17 +226,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, 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 .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 .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, scope, itemId, add, reaction): return "/_reaction \(ref(type, id, scope: scope)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(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, 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):
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):
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(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)"
@ -249,7 +247,6 @@ 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))"
@ -273,13 +270,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, scope: nil)) \(chatItemTTLStr(seconds: seconds))"
case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(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, scope: nil)) \(encodeJSON(chatSettings))"
case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(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)"
@ -311,8 +308,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, scope: nil)) \(chatDeleteMode.cmdString)"
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id, scope: nil))"
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 .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))"
@ -337,9 +334,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, 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 .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 .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)"
@ -424,7 +421,6 @@ 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"
@ -527,20 +523,8 @@ enum ChatCommand: ChatCmdProtocol {
}
}
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 ref(_ type: ChatType, _ id: Int64) -> String {
"\(type.rawValue)\(id)"
}
func joinedIds(_ ids: [Int64]) -> String {
@ -727,7 +711,6 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case contactDeleted(user: UserRef, contact: Contact)
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
case groupDeletedUser(user: UserRef, groupInfo: GroupInfo)
case itemsReadForChat(user: UserRef, chatInfo: ChatInfo)
case chatCleared(user: UserRef, chatInfo: ChatInfo)
case userProfileNoChange(user: User)
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary)
@ -766,7 +749,6 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case .contactDeleted: "contactDeleted"
case .contactConnectionDeleted: "contactConnectionDeleted"
case .groupDeletedUser: "groupDeletedUser"
case .itemsReadForChat: "itemsReadForChat"
case .chatCleared: "chatCleared"
case .userProfileNoChange: "userProfileNoChange"
case .userProfileUpdated: "userProfileUpdated"
@ -799,7 +781,6 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case let .contactDeleted(u, contact): return withUser(u, String(describing: contact))
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .itemsReadForChat(u, chatInfo): return withUser(u, String(describing: chatInfo))
case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo))
case .userProfileNoChange: return noDetails
case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile))
@ -850,7 +831,6 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool)
case leftMemberUser(user: UserRef, groupInfo: GroupInfo)
case groupMembers(user: UserRef, group: SimpleXChat.Group)
case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole)
case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool)
case groupUpdated(user: UserRef, toGroup: GroupInfo)
@ -899,7 +879,6 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case .userDeletedMembers: "userDeletedMembers"
case .leftMemberUser: "leftMemberUser"
case .groupMembers: "groupMembers"
case .memberAccepted: "memberAccepted"
case .membersRoleUser: "membersRoleUser"
case .membersBlockedForAllUser: "membersBlockedForAllUser"
case .groupUpdated: "groupUpdated"
@ -944,7 +923,6 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)")
case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .groupMembers(u, group): return withUser(u, String(describing: group))
case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)")
case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)")
case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
@ -1010,7 +988,6 @@ enum ChatEvent: Decodable, ChatAPIResult {
case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember)
case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact)
case joinedGroupMemberConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, 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 memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool)
case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, withMessages: Bool)
@ -1087,7 +1064,6 @@ enum ChatEvent: Decodable, ChatAPIResult {
case .groupLinkConnecting: "groupLinkConnecting"
case .businessLinkConnecting: "businessLinkConnecting"
case .joinedGroupMemberConnecting: "joinedGroupMemberConnecting"
case .memberAcceptedByOther: "memberAcceptedByOther"
case .memberRole: "memberRole"
case .memberBlockedForAll: "memberBlockedForAll"
case .deletedMemberUser: "deletedMemberUser"
@ -1168,7 +1144,6 @@ enum ChatEvent: Decodable, ChatAPIResult {
case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))")
case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))")
case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\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 .memberBlockedForAll(u, groupInfo, byMember, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nblocked: \(blocked)")
case let .deletedMemberUser(u, groupInfo, member, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nwithMessages: \(withMessages)")

View file

@ -52,26 +52,8 @@ 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(secondaryIMFilter: nil)
public var secondaryIMFilter: SecondaryItemsModelFilter?
public var preloadState = PreloadState()
static let shared = ItemsModel()
private let publisher = ObservableObjectPublisher()
private var bag = Set<AnyCancellable>()
var reversedChatItems: [ChatItem] = [] {
@ -95,20 +77,13 @@ class ItemsModel: ObservableObject {
chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id
}
init(secondaryIMFilter: SecondaryItemsModelFilter? = nil) {
self.secondaryIMFilter = secondaryIMFilter
init() {
publisher
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) {
let im = ItemsModel(secondaryIMFilter: chatFilter)
ChatModel.shared.secondaryIM = im
im.loadOpenChat(chatId, willNavigate: willNavigate)
}
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
navigationTimeoutTask?.cancel()
loadChatTask?.cancel()
@ -124,7 +99,7 @@ class ItemsModel: ObservableObject {
loadChatTask = Task {
await MainActor.run { self.isLoading = true }
// try? await Task.sleep(nanoseconds: 1000_000000)
await loadChat(chatId: chatId, im: self)
await loadChat(chatId: chatId)
if !Task.isCancelled {
await MainActor.run {
self.isLoading = false
@ -139,7 +114,7 @@ class ItemsModel: ObservableObject {
loadChatTask?.cancel()
loadChatTask = Task {
// try? await Task.sleep(nanoseconds: 1000_000000)
await loadChat(chatId: chatId, im: self, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
if !Task.isCancelled {
await MainActor.run {
if openAroundItemId == nil {
@ -149,34 +124,6 @@ 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 {
@ -340,6 +287,7 @@ 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
@ -388,10 +336,6 @@ 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
@Published var secondaryPendingInviteeChatOpened = false
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true
@ -449,7 +393,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
@ -502,11 +446,7 @@ final class ChatModel: ObservableObject {
func updateChatInfo(_ cInfo: ChatInfo) {
if let i = getChatIndex(cInfo.id) {
if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil {
chats[i].chatInfo = .group(groupInfo: groupInfo, groupChatScope: nil)
} else {
chats[i].chatInfo = cInfo
}
chats[i].chatInfo = cInfo
chats[i].created = Date.now
}
}
@ -528,7 +468,7 @@ final class ChatModel: ObservableObject {
}
func updateGroup(_ groupInfo: GroupInfo) {
updateChat(.group(groupInfo: groupInfo, groupChatScope: nil))
updateChat(.group(groupInfo: groupInfo))
}
private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) {
@ -571,112 +511,77 @@ final class ChatModel: ObservableObject {
// }
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// updates membersRequireAttention
updateChatInfo(cInfo)
// mark chat non deleted
if case let .direct(contact) = cInfo, contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
updateContact(updatedContact)
}
// update chat list
// update previews
if let i = getChatIndex(cInfo.id) {
// update preview
if cInfo.groupChatScope() == nil || cInfo.groupInfo?.membership.memberPending ?? false {
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 {
chats[i].chatItems = switch cInfo {
case .group:
if let currentPreviewItem = chats[i].chatItems.first {
if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs {
[cItem]
} else {
[currentPreviewItem]
}
default:
} else {
[cItem]
}
if case .rcvNew = cItem.meta.itemStatus {
unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0)
}
default:
[cItem]
}
if case .rcvNew = cItem.meta.itemStatus {
unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0)
}
// pop chat
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else {
if cInfo.groupChatScope() == nil {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
} else {
addChat(Chat(chatInfo: cInfo, chatItems: []))
}
addChat(Chat(chatInfo: cInfo, chatItems: [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
// add to current chat
if chatId == cInfo.id {
_ = _upsertChatItem(cInfo, cItem)
}
}
func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
// 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 {
// 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) {
chat.chatItems = [cItem]
}
} else {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
itemAdded = true
}
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
chat.chatItems = [cItem]
}
res = false
} else {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
res = true
}
// update current scope
if let ciIM = getCIItemsModel(cInfo, cItem) {
itemAdded = _upsertChatItem(ciIM, cInfo, cItem)
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
return itemAdded
// update current chat
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
}
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
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) {
_updateChatItem(at: i, with: cItem)
ChatItemDummyModel.shared.sendUpdate()
return false
} else {
ciIM.reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0)
ciIM.chatState.itemAdded((cItem.id, cItem.isRcvNew), hasLiveDummy ? 1 : 0)
ciIM.itemAdded = true
var ci = cItem
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
}
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.chatState.itemAdded((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0)
im.itemAdded = true
ChatItemDummyModel.shared.sendUpdate()
return true
}
@ -690,42 +595,40 @@ final class ChatModel: ObservableObject {
}
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
if let ciIM = getCIItemsModel(cInfo, cItem),
let i = getChatItemIndex(ciIM, cItem) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
withConditionalAnimation {
_updateChatItem(ciIM: ciIM, at: i, with: cItem)
_updateChatItem(at: i, with: cItem)
}
} else if let status = status {
chatItemStatuses.updateValue(status, forKey: cItem.id)
}
}
private func _updateChatItem(ciIM: ItemsModel, at i: Int, with cItem: ChatItem) {
ciIM.reversedChatItems[i] = cItem
ciIM.reversedChatItems[i].viewTimestamp = .now
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
im.reversedChatItems[i] = cItem
im.reversedChatItems[i].viewTimestamp = .now
}
func getChatItemIndex(_ ciIM: ItemsModel, _ cItem: ChatItem) -> Int? {
ciIM.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// 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()]
}
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 scope
if let ciIM = getCIItemsModel(cInfo, cItem) {
if let i = getChatItemIndex(ciIM, cItem) {
// remove from current chat
if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) {
withAnimation {
let item = ciIM.reversedChatItems.remove(at: i)
ciIM.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
let item = im.reversedChatItems.remove(at: i)
im.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
}
}
}
@ -741,7 +644,7 @@ final class ChatModel: ObservableObject {
if chatId == groupInfo.id {
for i in 0..<im.reversedChatItems.count {
if let updatedItem = removedUpdatedItem(im.reversedChatItems[i]) {
_updateChatItem(ciIM: im, at: i, with: updatedItem) // TODO [knocking] review: use getCIItemsModel?
_updateChatItem(at: i, with: updatedItem)
}
}
} else if let chat = getChat(groupInfo.id),
@ -833,7 +736,7 @@ final class ChatModel: ObservableObject {
im.reversedChatItems.first?.isLiveDummy == true
}
func markAllChatItemsRead(_ chatIM: ItemsModel, _ cInfo: ChatInfo) {
func markAllChatItemsRead(_ cInfo: ChatInfo) {
// update preview
_updateChat(cInfo.id) { chat in
self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
@ -844,7 +747,7 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id {
var i = 0
while i < im.reversedChatItems.count {
markChatItemRead_(chatIM, i)
markChatItemRead_(i)
i += 1
}
im.chatState.itemsRead(nil, im.reversedChatItems.reversed())
@ -869,26 +772,27 @@ final class ChatModel: ObservableObject {
}
// clear current chat
if chatId == cInfo.id {
chatItemStatuses = [:]
im.reversedChatItems = []
im.chatState.clear()
}
}
func markChatItemsRead(_ chatIM: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) {
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) {
if self.chatId == cInfo.id {
var unreadItemIds: Set<ChatItem.ID> = []
var i = 0
var ids = Set(itemIds)
while i < chatIM.reversedChatItems.count && !ids.isEmpty {
let item = chatIM.reversedChatItems[i]
while i < im.reversedChatItems.count && !ids.isEmpty {
let item = im.reversedChatItems[i]
if ids.contains(item.id) && item.isRcvNew {
markChatItemRead_(chatIM, i)
markChatItemRead_(i)
unreadItemIds.insert(item.id)
ids.remove(item.id)
}
i += 1
}
chatIM.chatState.itemsRead(unreadItemIds, chatIM.reversedChatItems.reversed())
im.chatState.itemsRead(unreadItemIds, im.reversedChatItems.reversed())
}
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead)
}
@ -984,13 +888,13 @@ final class ChatModel: ObservableObject {
}
}
private func markChatItemRead_(_ chatIM: ItemsModel, _ i: Int) {
let meta = chatIM.reversedChatItems[i].meta
private func markChatItemRead_(_ i: Int) {
let meta = im.reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus {
chatIM.reversedChatItems[i].meta.itemStatus = .rcvRead
chatIM.reversedChatItems[i].viewTimestamp = .now
im.reversedChatItems[i].meta.itemStatus = .rcvRead
im.reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
chatIM.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
}
}
}
@ -1069,7 +973,7 @@ final class ChatModel: ObservableObject {
var count = 0
var ns: [String] = []
if let ciCategory = chatItem.mergeCategory,
var i = getChatItemIndex(im, chatItem) { // TODO [knocking] review: use getCIItemsModel?
var i = getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
if ci.mergeCategory != ciCategory { break }
@ -1085,7 +989,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(im, ci) { // TODO [knocking] review: use getCIItemsModel?
if let i = getChatItemIndex(ci) {
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
} else {
(nil, nil)
@ -1196,7 +1100,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)
}
}
@ -1248,34 +1152,6 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
)
}
var userCanSend: Bool {
switch chatInfo {
case .direct: return true
case let .group(groupInfo, groupChatScope):
let m = groupInfo.membership
return (m.memberActive && m.memberRole >= .member && !m.memberPending) || groupChatScope != nil
case .local:
return true
default: return false
}
}
var userIsObserver: Bool {
switch chatInfo {
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
@ -1288,18 +1164,6 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
var supportUnreadCount: Int {
switch chatInfo {
case let .group(groupInfo, _):
if groupInfo.canModerate {
return groupInfo.membersRequireAttention
} else {
return groupInfo.membership.supportChat?.unread ?? 0
}
default: return 0
}
}
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}

View file

@ -344,54 +344,43 @@ func apiGetChatTagsAsync() async throws -> [ChatTag] {
let loadItemsPerPage = 50
func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag? = nil, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
let r: ChatResponse0 = try await chatSendCmd(.apiGetChat(chatId: chatId, scope: scope, contentTag: contentTag, pagination: pagination, search: search))
func apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
let r: ChatResponse0 = try await chatSendCmd(.apiGetChat(chatId: chatId, pagination: pagination, search: search))
if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) }
throw r.unexpected
}
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(chat: Chat, search: String = "", clearItems: Bool = true) async {
await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems)
}
func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
let m = ChatModel.shared
let im = ItemsModel.shared
await MainActor.run {
m.chatItemStatuses = [:]
if clearItems {
im.reversedChatItems = []
im.chatState.clear()
ItemsModel.shared.chatState.clear()
}
}
await apiLoadMessages(
chatId,
im,
( // pagination
openAroundItemId != nil
? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage)
: (
search == ""
? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)
)
),
search,
openAroundItemId,
{ 0...0 }
)
await apiLoadMessages(chatId, openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : (search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)), im.chatState, search, openAroundItemId, { 0...0 })
}
func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> ChatItemInfo {
let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, scope: scope, itemId: itemId))
func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId))
if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo }
throw r.unexpected
}
func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
let r: ChatResponse1 = try await chatSendCmd(.apiPlanForwardChatItems(fromChatType: type, fromChatId: id, fromScope: scope, itemIds: itemIds))
func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
let r: ChatResponse1 = try await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds))
if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) }
throw r.unexpected
}
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)
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)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
@ -423,8 +412,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws {
try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds))
}
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)
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)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
}
@ -501,8 +490,8 @@ private func createChatItemsErrorAlert(_ r: ChatError) {
)
}
func apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem {
let r: ChatResponse1 = try await chatSendCmd(.apiUpdateChatItem(type: type, id: id, scope: scope, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay)
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem {
let r: ChatResponse1 = try await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay)
switch r {
case let .chatItemUpdated(_, aChatItem): return aChatItem.chatItem
case let .chatItemNotChanged(_, aChatItem): return aChatItem.chatItem
@ -510,8 +499,8 @@ func apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId
}
}
func apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem {
let r: ChatResponse1 = try await chatSendCmd(.apiChatItemReaction(type: type, id: id, scope: scope, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem {
let r: ChatResponse1 = try await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem }
throw r.unexpected
}
@ -523,8 +512,8 @@ func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction)
throw r.unexpected
}
func apiDeleteChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChatItem(type: type, id: id, scope: scope, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
throw r.unexpected
}
@ -1248,14 +1237,12 @@ func apiRejectContactRequest(contactReqId: Int64) async throws {
throw r.unexpected
}
func apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) async throws {
try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: scope))
func apiChatRead(type: ChatType, id: Int64) async throws {
try await sendCommandOkResp(.apiChatRead(type: type, id: id))
}
func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ChatInfo {
let r: ChatResponse1 = try await chatSendCmd(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds))
if case let .itemsReadForChat(_, updatedChatInfo) = r { return updatedChatInfo }
throw r.unexpected
func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds))
}
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
@ -1558,13 +1545,13 @@ func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] {
throw r.unexpected
}
func markChatRead(_ im: ItemsModel, _ chat: Chat) async {
func markChatRead(_ chat: Chat) async {
do {
if chat.chatStats.unreadCount > 0 {
let cInfo = chat.chatInfo
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope())
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId)
await MainActor.run {
withAnimation { ChatModel.shared.markAllChatItemsRead(im, cInfo) }
withAnimation { ChatModel.shared.markAllChatItemsRead(cInfo) }
}
}
if chat.chatStats.unreadChat {
@ -1587,12 +1574,11 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
}
}
func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async {
func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async {
do {
let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds)
await MainActor.run {
ChatModel.shared.updateChatInfo(updatedChatInfo)
ChatModel.shared.markChatItemsRead(im, cInfo, itemIds, mentionsRead)
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
DispatchQueue.main.async {
ChatModel.shared.markChatItemsRead(cInfo, itemIds, mentionsRead)
}
} catch {
logger.error("apiChatItemsRead error: \(responseError(error))")
@ -1640,15 +1626,9 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
}
}
func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> (GroupInfo, GroupMember) {
let r: ChatResponse2 = try await chatSendCmd(.apiAcceptMember(groupId: groupId, groupMemberId: groupMemberId, memberRole: memberRole))
if case let .memberAccepted(_, groupInfo, member) = r { return (groupInfo, member) }
throw r.unexpected
}
func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> (GroupInfo, [GroupMember]) {
func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> [GroupMember] {
let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false)
if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) }
if case let .userDeletedMembers(_, _, members, withMessages) = r { return members }
throw r.unexpected
}
@ -2152,7 +2132,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
let cInfo = chatItem.chatInfo
let cItem = chatItem.chatItem
if !cItem.isDeletedContent && active(user) {
_ = await MainActor.run { m.upsertChatItem(cInfo, cItem) }
await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
}
if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus {
@ -2248,13 +2228,6 @@ func processReceivedMsg(_ res: ChatEvent) async {
_ = m.upsertGroupMember(groupInfo, member)
}
}
case let .memberAcceptedByOther(user, groupInfo, _, member):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
m.updateGroup(groupInfo)
}
}
case let .deletedMemberUser(user, groupInfo, member, withMessages): // TODO update user member
if active(user) {
await MainActor.run {
@ -2267,7 +2240,6 @@ func processReceivedMsg(_ res: ChatEvent) async {
case let .deletedMember(user, groupInfo, byMember, deletedMember, withMessages):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, deletedMember)
if withMessages {
m.removeMemberItems(deletedMember, byMember: byMember, groupInfo)
@ -2277,7 +2249,6 @@ func processReceivedMsg(_ res: ChatEvent) async {
case let .leftMember(user, groupInfo, member):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, member)
}
}
@ -2292,12 +2263,6 @@ func processReceivedMsg(_ res: ChatEvent) async {
await MainActor.run {
m.updateGroup(groupInfo)
}
if m.chatId == groupInfo.id,
case .memberSupport(nil) = m.secondaryIM?.groupScopeInfo {
await MainActor.run {
m.secondaryPendingInviteeChatOpened = false
}
}
}
case let .joinedGroupMember(user, groupInfo, member):
if active(user) {
@ -2584,7 +2549,7 @@ func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemID
return
}
let im = ItemsModel.shared
let cInfo = ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)
let cInfo = ChatInfo.group(groupInfo: groupInfo)
await MainActor.run {
m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count)
}

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, im: ItemsModel.shared, clearItems: false) }
Task { await loadChat(chat: chat, clearItems: false) }
}
if let ncr = chatModel.ntfContactRequest {
await MainActor.run { chatModel.ntfContactRequest = nil }

View file

@ -22,28 +22,11 @@ struct ChatInfoToolbar: View {
Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo)
Spacer().frame(width: 16)
}
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.supportUnreadCount > 0 {
Image(systemName: "flag.circle.fill")
.resizable()
.scaledToFit()
.frame(width: 14, height: 14)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, theme.colors.primary)
}
}
ChatInfoImage(
chat: chat,
size: imageSize,
color: Color(uiColor: .tertiaryLabel)
)
.padding(.trailing, 4)
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)

View file

@ -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, im: ItemsModel.shared, clearItems: true)
await loadChat(chat: chat, 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, im: ItemsModel.shared, clearItems: true)
await loadChat(chat: chat, 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, groupChatScope: nil))
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil))
ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo))
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo))
themes = themesFromChat(chat.wrappedValue)
}
}

View file

@ -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(im, chatItem) {
if var i = m.getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) {
if !icons.contains(f.icon) {
@ -108,7 +108,6 @@ 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)
}

View file

@ -278,7 +278,6 @@ 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),
@ -294,16 +293,16 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group {
ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
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), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: fileChatItemWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
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 })
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))

View file

@ -12,7 +12,7 @@ import SimpleXChat
struct CIImageView: View {
@EnvironmentObject var m: ChatModel
let chatItem: ChatItem
var scrollToItem: ((ChatItem.ID) -> Void)? = nil
var scrollToItemId: ((ChatItem.ID) -> Void)? = nil
var preview: UIImage?
let maxWidth: CGFloat
var imgWidth: CGFloat?
@ -26,7 +26,7 @@ struct CIImageView: View {
if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
.fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, scrollToItem: scrollToItem, image: uiImage, showView: $showFullScreenImage)
FullScreenMediaView(chatItem: chatItem, scrollToItemId: scrollToItemId, image: uiImage, showView: $showFullScreenImage)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))

View file

@ -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 {

View file

@ -435,7 +435,6 @@ 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),
@ -458,10 +457,10 @@ struct CIVoiceView_Previews: PreviewProvider {
duration: 30,
allowMenu: Binding.constant(true)
)
ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .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))
}
.previewLayout(.fixed(width: 360, height: 360))
}

View file

@ -77,7 +77,6 @@ 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),
@ -93,11 +92,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
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."), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWithQuote, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
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 })
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))

File diff suppressed because one or more lines are too long

View file

@ -14,7 +14,7 @@ import AVKit
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
@State var chatItem: ChatItem
var scrollToItem: ((ChatItem.ID) -> Void)?
var scrollToItemId: ((ChatItem.ID) -> Void)?
@State var image: UIImage?
@State var player: AVPlayer? = nil
@State var url: URL? = nil
@ -71,7 +71,7 @@ struct FullScreenMediaView: View {
let w = abs(t.width)
if t.height > 60 && t.height > w * 2 {
showView = false
scrollToItem?(chatItem.id)
scrollToItemId?(chatItem.id)
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0
scrolling = true

View file

@ -14,7 +14,6 @@ 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 {
@ -30,14 +29,14 @@ struct MarkedDeletedItemView: View {
var mergedMarkedDeletedText: LocalizedStringKey {
if !revealed,
let ciCategory = chatItem.mergeCategory,
var i = m.getChatItemIndex(im, chatItem) {
var i = m.getChatItemIndex(chatItem) {
var moderated = 0
var blocked = 0
var blockedByAdmin = 0
var deleted = 0
var moderatedBy: Set<String> = []
while i < im.reversedChatItems.count,
let ci = .some(im.reversedChatItems[i]),
while i < ItemsModel.shared.reversedChatItems.count,
let ci = .some(ItemsModel.shared.reversedChatItems[i]),
ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted {
@ -86,7 +85,6 @@ 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)
}

View file

@ -40,31 +40,25 @@ 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
var chatItem: ChatItem
var scrollToItem: (ChatItem.ID) -> Void
@Binding var scrollToItemId: ChatItem.ID?
var scrollToItemId: (ChatItem.ID) -> Void
var maxWidth: CGFloat = .infinity
@Binding var allowMenu: Bool
init(
chat: Chat,
im: ItemsModel,
chatItem: ChatItem,
scrollToItem: @escaping (ChatItem.ID) -> Void,
scrollToItemId: Binding<ChatItem.ID?> = .constant(nil),
scrollToItemId: @escaping (ChatItem.ID) -> Void,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
allowMenu: Binding<Bool> = .constant(false)
) {
self.chat = chat
self.im = im
self.chatItem = chatItem
self.scrollToItem = scrollToItem
_scrollToItemId = scrollToItemId
self.scrollToItemId = scrollToItemId
self.maxWidth = maxWidth
_allowMenu = allowMenu
}
@ -72,14 +66,14 @@ struct ChatItemView: View {
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
MarkedDeletedItemView(chat: chat, im: im, chatItem: chatItem)
MarkedDeletedItemView(chat: chat, 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, im: im, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
@ -107,10 +101,8 @@ struct ChatItemView: View {
}()
return FramedItemView(
chat: chat,
im: im,
chatItem: chatItem,
scrollToItem: scrollToItem,
scrollToItemId: $scrollToItemId,
scrollToItemId: scrollToItemId,
preview: preview,
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
@ -125,7 +117,6 @@ 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
@ -149,9 +140,7 @@ 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()
@ -160,7 +149,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, im: im, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
CIChatFeatureView(chat: chat, 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)
@ -192,13 +181,6 @@ 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)
@ -214,7 +196,7 @@ struct ChatItemContentView<Content: View>: View {
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, iconColor: iconColor)
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor)
}
private var mergedGroupEventText: Text? {
@ -274,17 +256,16 @@ 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, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getDeletedContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true)
ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true)
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)
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 70))
@ -294,12 +275,10 @@ 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)),
@ -307,12 +286,10 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
scrollToItem: { _ in },
scrollToItemId: Binding.constant(nil)
scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead),
@ -320,11 +297,10 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)
scrollToItemId: { _ in }
)
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)),
@ -332,12 +308,10 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
scrollToItem: { _ in },
scrollToItemId: Binding.constant(nil)
scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@ -345,12 +319,10 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
scrollToItem: { _ in },
scrollToItemId: Binding.constant(nil)
scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@ -358,8 +330,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
scrollToItem: { _ in },
scrollToItemId: Binding.constant(nil)
scrollToItemId: { _ in }
)
}
.environment(\.revealed, true)

View file

@ -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, scope: im.groupScopeInfo?.toChatScope(), contentTag: im.contentTag, pagination: pagination, search: search)
(chat, navInfo) = try await apiGetChat(chatId: chatId, pagination: pagination, search: search)
} catch let error {
logger.error("apiLoadMessages error: \(responseError(error))")
return
@ -38,31 +38,30 @@ func apiLoadMessages(
return
}
let unreadAfterItemId = im.chatState.unreadAfterItemId
let unreadAfterItemId = chatState.unreadAfterItemId
let oldItems = Array(im.reversedChatItems.reversed())
let oldItems = Array(ItemsModel.shared.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 im.secondaryIMFilter == nil && chatModel.getChat(chat.id) == nil {
if chatModel.getChat(chat.id) == nil {
chatModel.addChat(chat)
}
await MainActor.run {
im.reversedChatItems = chat.chatItems.reversed()
if im.secondaryIMFilter == nil {
chatModel.updateChatInfo(chat.chatInfo)
}
im.chatState.splits = newSplits
chatModel.chatItemStatuses.removeAll()
ItemsModel.shared.reversedChatItems = chat.chatItems.reversed()
chatModel.updateChatInfo(chat.chatInfo)
chatState.splits = newSplits
if !chat.chatItems.isEmpty {
im.chatState.unreadAfterItemId = chat.chatItems.last!.id
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
chatState.totalAfter = navInfo.afterTotal
chatState.unreadTotal = chat.chatStats.unreadCount
chatState.unreadAfter = navInfo.afterUnread
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
im.preloadState.clear()
PreloadState.shared.clear()
}
case let .before(paginationChatItemId, _):
newItems.append(contentsOf: oldItems)
@ -72,15 +71,15 @@ func apiLoadMessages(
let wasSize = newItems.count
let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination(
unreadAfterItemId, &newItems, newIds, im.chatState.splits, visibleItemIndexes
unreadAfterItemId, &newItems, newIds, 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 {
im.reversedChatItems = newReversed
im.chatState.splits = modifiedSplits.newSplits
im.chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems)
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = modifiedSplits.newSplits
chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems)
}
case let .after(paginationChatItemId, _):
newItems.append(contentsOf: oldItems)
@ -90,7 +89,7 @@ func apiLoadMessages(
let mappedItems = mapItemsToIds(chat.chatItems)
let newIds = mappedItems.0
let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination(
mappedItems.1, paginationChatItemId, &newItems, newIds, chat, im.chatState.splits
mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits
)
let indexToAdd = min(indexInCurrentItems + 1, newItems.count)
let indexToAddIsLast = indexToAdd == newItems.count
@ -98,19 +97,19 @@ func apiLoadMessages(
let new: [ChatItem] = newItems
let newReversed: [ChatItem] = newItems.reversed()
await MainActor.run {
im.reversedChatItems = newReversed
im.chatState.splits = newSplits
im.chatState.moveUnreadAfterItem(im.chatState.splits.first ?? new.last!.id, new)
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = newSplits
chatState.moveUnreadAfterItem(chatState.splits.first ?? new.last!.id, new)
// loading clear bottom area, updating number of unread items after the newest loaded item
if indexToAddIsLast {
im.chatState.unreadAfterNewestLoaded -= unreadInLoaded
chatState.unreadAfterNewestLoaded -= unreadInLoaded
}
}
case .around:
var newSplits: [Int64]
if openAroundItemId == nil {
newItems.append(contentsOf: oldItems)
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, im.chatState.splits, visibleItemIndexesNonReversed)
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
} else {
newSplits = []
}
@ -121,37 +120,33 @@ func apiLoadMessages(
let newReversed: [ChatItem] = newItems.reversed()
let orderedSplits = newSplits
await MainActor.run {
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
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
if let openAroundItemId {
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
}
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
ChatModel.shared.openAroundItemId = openAroundItemId
ChatModel.shared.chatId = chatId
} else {
// no need to set it, count will be wrong
// chatState.unreadAfterNewestLoaded = navInfo.afterUnread
}
im.preloadState.clear()
PreloadState.shared.clear()
}
case .last:
newItems.append(contentsOf: oldItems)
let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, im.chatState.splits)
let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits)
newItems.append(contentsOf: chat.chatItems)
let items = newItems
await MainActor.run {
im.reversedChatItems = items.reversed()
im.chatState.splits = newSplits
if im.secondaryIMFilter == nil {
chatModel.updateChatInfo(chat.chatInfo)
}
im.chatState.unreadAfterNewestLoaded = 0
ItemsModel.shared.reversedChatItems = items.reversed()
chatState.splits = newSplits
chatModel.updateChatInfo(chat.chatInfo)
chatState.unreadAfterNewestLoaded = 0
}
}
}

View file

@ -10,7 +10,6 @@ import SwiftUI
import SimpleXChat
struct MergedItems: Hashable, Equatable {
let im: ItemsModel
let items: [MergedItem]
let splits: [SplitRange]
// chat item id, index in list
@ -24,15 +23,15 @@ struct MergedItems: Hashable, Equatable {
hasher.combine("\(items.hashValue)")
}
static func create(_ im: ItemsModel, _ revealedItems: Set<Int64>) -> MergedItems {
if im.reversedChatItems.isEmpty {
return MergedItems(im: im, items: [], splits: [], indexInParentItems: [:])
static func create(_ items: [ChatItem], _ revealedItems: Set<Int64>, _ chatState: ActiveChatState) -> MergedItems {
if items.isEmpty {
return MergedItems(items: [], splits: [], indexInParentItems: [:])
}
let unreadCount = im.chatState.unreadTotal
let unreadCount = chatState.unreadTotal
let unreadAfterItemId = im.chatState.unreadAfterItemId
let itemSplits = im.chatState.splits
let unreadAfterItemId = chatState.unreadAfterItemId
let itemSplits = chatState.splits
var mergedItems: [MergedItem] = []
// Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
var splitRanges: [SplitRange] = []
@ -41,19 +40,19 @@ struct MergedItems: Hashable, Equatable {
var unclosedSplitIndex: Int? = nil
var unclosedSplitIndexInParent: Int? = nil
var visibleItemIndexInParent = -1
var unreadBefore = unreadCount - im.chatState.unreadAfterNewestLoaded
var unreadBefore = unreadCount - chatState.unreadAfterNewestLoaded
var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil
var lastRangeInReversedForMergedItems: BoxedValue<ClosedRange<Int>>? = nil
var recent: MergedItem? = 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
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
let category = item.mergeCategory
let itemIsSplit = itemSplits.contains(item.id)
if item.id == unreadAfterItemId {
unreadBefore = unreadCount - im.chatState.unreadAfter
unreadBefore = unreadCount - chatState.unreadAfter
}
if item.isRcvNew {
unreadBefore -= 1
@ -107,19 +106,18 @@ 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: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
}
unclosedSplitIndex = index
unclosedSplitIndexInParent = visibleItemIndexInParent
} else if index + 1 == im.reversedChatItems.count, let unclosedSplitIndex, let unclosedSplitIndexInParent {
} else if index + 1 == items.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: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
}
indexInParentItems[item.id] = visibleItemIndexInParent
index += 1
}
return MergedItems(
im: im,
items: mergedItems,
splits: splitRanges,
indexInParentItems: indexInParentItems
@ -129,6 +127,7 @@ 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
@ -435,7 +434,7 @@ class BoxedValue<T: Hashable>: Equatable, Hashable {
}
@MainActor
func visibleItemIndexesNonReversed(_ im: ItemsModel, _ listState: EndlessScrollView<MergedItem>.ListState, _ mergedItems: MergedItems) -> ClosedRange<Int> {
func visibleItemIndexesNonReversed(_ listState: EndlessScrollView<MergedItem>.ListState, _ mergedItems: MergedItems) -> ClosedRange<Int> {
let zero = 0 ... 0
let items = mergedItems.items
if items.isEmpty {
@ -446,12 +445,12 @@ func visibleItemIndexesNonReversed(_ im: ItemsModel, _ listState: EndlessScrollV
guard let newest, let oldest else {
return zero
}
let size = im.reversedChatItems.count
let size = ItemsModel.shared.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 im.reversedChatItems.reversed()
// visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed()
return range
}

View file

@ -9,7 +9,7 @@
import SwiftUI
import SimpleXChat
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat, _ im: ItemsModel) async {
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) async {
await MainActor.run {
loadingMoreItems.wrappedValue = true
loadingBottomItems.wrappedValue = true
@ -22,15 +22,27 @@ func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Bindin
}
return
}
await apiLoadMessages(chat.chatInfo.id, im, ChatPagination.last(count: 50))
await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState)
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,
@ -38,7 +50,7 @@ func preloadIfNeeded(
loadItems: @escaping (Bool, ChatPagination) async -> Bool,
loadLastItems: @escaping () async -> Void
) {
let state = im.preloadState
let state = PreloadState.shared
guard !listState.isScrolling && !listState.isAnimatedScrolling,
!state.preloading,
listState.totalItemsCount > 0
@ -51,7 +63,7 @@ func preloadIfNeeded(
Task {
defer { state.preloading = false }
var triedToLoad = true
await preloadItems(im, mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
triedToLoad = await loadItems(false, pagination)
return triedToLoad
}
@ -61,11 +73,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 && !im.lastItemsLoaded {
if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
await loadLastItems()
}
}
} else if listState.itemsCanCoverScreen && !im.lastItemsLoaded {
} else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
state.preloading = true
Task {
defer { state.preloading = false }
@ -75,7 +87,6 @@ func preloadIfNeeded(
}
func preloadItems(
_ im: ItemsModel,
_ mergedItems: MergedItems,
_ allowLoadMoreItems: Bool,
_ listState: EndlessScrollView<MergedItem>.ListState,
@ -94,7 +105,7 @@ async {
let splits = mergedItems.splits
let lastVisibleIndex = listState.lastVisibleItemIndex
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
let items: [ChatItem] = im.reversedChatItems.reversed()
let items: [ChatItem] = ItemsModel.shared.reversedChatItems.reversed()
if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining {
lastIndexToLoadFrom = items.count - 1
}
@ -111,7 +122,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 == im.reversedChatItems.count && firstItemIdWas == im.reversedChatItems.last?.id {
if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id {
ignoreLoadingRequests.wrappedValue = loadFromItemId
return false
}
@ -122,7 +133,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] = im.reversedChatItems
let reversedItems: [ChatItem] = ItemsModel.shared.reversedChatItems
if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex {
let index = split.indexRangeInReversed.lowerBound
if index >= 0 {

File diff suppressed because it is too large Load diff

View file

@ -323,11 +323,11 @@ 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
@Binding var selectedRange: NSRange
var disabledText: LocalizedStringKey? = nil
@State var linkUrl: URL? = nil
@State var hasSimplexLink: Bool = false
@ -356,20 +356,6 @@ 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()
@ -406,12 +392,12 @@ struct ComposeView: View {
Image(systemName: "paperclip")
.resizable()
}
.disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
.disabled(composeState.attachmentDisabled || !chat.chatInfo.sendMsgEnabled || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
.frame(width: 25, height: 25)
.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(
@ -456,32 +442,13 @@ struct ComposeView: View {
: theme.colors.primary
)
.padding(.trailing, 12)
.disabled(!chat.userCanSend)
.disabled(!chat.chatInfo.sendMsgEnabled)
if im.secondaryIMFilter == nil {
if chat.userIsPending {
Text("reviewed by admins")
.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."
)
}
}
if let disabledText {
Text(disabledText)
.italic()
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
}
}
}
@ -507,8 +474,8 @@ struct ComposeView: View {
hasSimplexLink = false
}
}
.onChange(of: chat.userCanSend) { canSend in
if !canSend {
.onChange(of: chat.chatInfo.sendMsgEnabled) { sendEnabled in
if !sendEnabled {
cancelCurrentVoiceRecording()
clearCurrentDraft()
clearState()
@ -972,7 +939,6 @@ 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
@ -1035,9 +1001,9 @@ struct ComposeView: View {
reportReason: reportReason,
reportText: msgText
) {
if showReportsInSupportChatAlertDefault.get() {
await MainActor.run {
showReportsInSupportChatAlert()
await MainActor.run {
for chatItem in chatItems {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
}
return chatItems.first
@ -1045,27 +1011,7 @@ struct ComposeView: View {
return nil
}
func showReportsInSupportChatAlert() {
showAlert(
NSLocalizedString("Report sent to moderators", comment: "alert title"),
message: NSLocalizedString("You can view your reports in Chat with admins.", comment: "alert message"),
actions: {[
UIAlertAction(
title: NSLocalizedString("Don't show again", comment: "alert action"),
style: .default,
handler: { _ in
showReportsInSupportChatAlertDefault.set(false)
}
),
UIAlertAction(
title: NSLocalizedString("Ok", comment: "alert action"),
style: .default
)
]}
)
}
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? {
await send(
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)],
@ -1080,7 +1026,6 @@ struct ComposeView: View {
: await apiSendMessages(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
scope: chat.chatInfo.groupChatScope(),
live: live,
ttl: ttl,
composedMessages: msgs
@ -1105,10 +1050,8 @@ 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
) {
@ -1329,14 +1272,12 @@ 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),
@ -1345,7 +1286,6 @@ struct ComposeView_Previews: PreviewProvider {
.environmentObject(ChatModel())
ComposeView(
chat: chat,
im: im,
composeState: $composeState,
keyboardVisible: Binding.constant(true),
keyboardHiddenDate: Binding.constant(Date.now),

View file

@ -1,99 +0,0 @@
//
// ContextPendingMemberActionsView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 02.05.2025.
// Copyright © 2025 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ContextPendingMemberActionsView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss
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, dismiss: dismiss)
}
ZStack {
Text("Accept")
.foregroundColor(theme.colors.primary)
}
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.onTapGesture {
showAcceptMemberAlert(groupInfo, member, dismiss: dismiss)
}
}
.frame(minHeight: 54)
.frame(maxWidth: .infinity)
.background(.thinMaterial)
}
}
func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) {
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, dismiss: dismiss)
}
),
UIAlertAction(
title: NSLocalizedString("Accept as observer", comment: "alert action"),
style: .default,
handler: { _ in
acceptMember(groupInfo, member, .observer, dismiss: dismiss)
}
),
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .default
)
]}
)
}
func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMemberRole, dismiss: DismissAction? = nil) {
Task {
do {
let (gInfo, acceptedMember) = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role)
await MainActor.run {
_ = ChatModel.shared.upsertGroupMember(groupInfo, acceptedMember)
ChatModel.shared.updateGroup(gInfo)
dismiss?()
}
} 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
)
}

View file

@ -15,6 +15,7 @@ struct SendMessageView: View {
@Binding var composeState: ComposeState
@Binding var selectedRange: NSRange
@EnvironmentObject var theme: AppTheme
@Environment(\.isEnabled) var isEnabled
var sendMessage: (Int?) -> Void
var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil
@ -255,6 +256,7 @@ struct SendMessageView: View {
}
private struct RecordVoiceMessageButton: View {
@Environment(\.isEnabled) var isEnabled
@EnvironmentObject var theme: AppTheme
var startVoiceMessageRecording: (() -> Void)?
var finishVoiceMessageRecording: (() -> Void)?
@ -263,11 +265,11 @@ struct SendMessageView: View {
@State private var pressed: TimeInterval? = nil
var body: some View {
Image(systemName: "mic.fill")
Image(systemName: isEnabled ? "mic.fill" : "mic")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.primary)
.foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
.opacity(holdingVMR ? 0.7 : 1)
.disabled(disabled)
.frame(width: 31, height: 31)
@ -352,7 +354,7 @@ struct SendMessageView: View {
Image(systemName: "bolt.fill")
.resizable()
.scaledToFit()
.foregroundColor(theme.colors.primary)
.foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
.frame(width: 20, height: 20)
}
.frame(width: 29, height: 29)

View file

@ -78,12 +78,6 @@ struct AddGroupMembersViewCommon: View {
let count = selectedContacts.count
Section {
if creatingGroup {
MemberAdmissionButton(
groupInfo: $groupInfo,
admission: groupInfo.groupProfile.memberAdmission_,
currentAdmission: groupInfo.groupProfile.memberAdmission_,
creatingGroup: true
)
GroupPreferencesButton(
groupInfo: $groupInfo,
preferences: groupInfo.fullGroupPreferences,

View file

@ -17,7 +17,6 @@ struct GroupChatInfoView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo
@Binding var scrollToItemId: ChatItem.ID?
var onSearch: () -> Void
@State var localAlias: String
@FocusState private var aliasTextFieldFocused: Bool
@ -88,25 +87,7 @@ struct GroupChatInfoView: View {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Section {
if groupInfo.canAddMembers && groupInfo.businessChat == nil {
groupLinkButton()
}
if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator {
memberSupportButton()
}
if groupInfo.canModerate {
GroupReportsChatNavLink(chat: chat, scrollToItemId: $scrollToItemId)
}
if groupInfo.membership.memberActive
&& (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) {
UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
}
} header: {
Text("")
}
Section {
if groupInfo.isOwner && groupInfo.businessChat == nil {
editGroupButton()
@ -115,6 +96,19 @@ 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: {
let label: LocalizedStringKey = (
groupInfo.businessChat == nil
@ -126,16 +120,6 @@ 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.")
@ -143,6 +127,9 @@ struct GroupChatInfoView: View {
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
if groupInfo.canAddMembers {
if groupInfo.businessChat == nil {
groupLinkButton()
}
if (chat.chatInfo.incognito) {
Label("Invite members", systemImage: "plus")
.foregroundColor(Color(uiColor: .tertiaryLabel))
@ -157,16 +144,9 @@ struct GroupChatInfoView: View {
let filteredMembers = s == ""
? members
: members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
MemberRowView(
chat: chat,
groupInfo: groupInfo,
groupMember: GMember(groupInfo.membership),
scrollToItemId: $scrollToItemId,
user: true,
alert: $alert
)
MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
ForEach(filteredMembers) { member in
MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, scrollToItemId: $scrollToItemId, alert: $alert)
MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, alert: $alert)
}
}
@ -175,7 +155,7 @@ struct GroupChatInfoView: View {
if groupInfo.canDelete {
deleteGroupButton()
}
if groupInfo.membership.memberCurrentOrPending {
if groupInfo.membership.memberCurrent {
leaveGroupButton()
}
}
@ -373,7 +353,6 @@ struct GroupChatInfoView: View {
var chat: Chat
var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember
@Binding var scrollToItemId: ChatItem.ID?
@EnvironmentObject var theme: AppTheme
var user: Bool = false
@Binding var alert: GroupChatInfoViewAlert?
@ -436,7 +415,7 @@ struct GroupChatInfoView: View {
}
private func memberInfoView() -> some View {
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember, scrollToItemId: $scrollToItemId)
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
.navigationBarHidden(false)
}
@ -544,89 +523,6 @@ struct GroupChatInfoView: View {
.navigationBarTitleDisplayMode(.large)
}
struct UserSupportChatNavLink: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var groupInfo: GroupInfo
@EnvironmentObject var chatModel: ChatModel
@Binding var scrollToItemId: ChatItem.ID?
@State private var navLinkActive = false
var body: some View {
let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil)
NavigationLink(isActive: $navLinkActive) {
SecondaryChatView(
chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
scrollToItemId: $scrollToItemId
)
} label: {
HStack {
Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag")
Spacer()
if chat.supportUnreadCount > 0 {
UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary)
}
}
}
.onChange(of: navLinkActive) { active in
if active {
ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo))
}
}
}
}
private func memberSupportButton() -> some View {
NavigationLink {
MemberSupportView(groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
.navigationBarTitle("Chats with members")
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large)
} label: {
HStack {
Label(
"Chats with members",
systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag"
)
Spacer()
if chat.supportUnreadCount > 0 {
UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary)
}
}
}
}
struct GroupReportsChatNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State private var navLinkActive = false
@ObservedObject var chat: Chat
@Binding var scrollToItemId: ChatItem.ID?
var body: some View {
NavigationLink(isActive: $navLinkActive) {
SecondaryChatView(chat: chat, scrollToItemId: $scrollToItemId)
} label: {
HStack {
Label {
Text("Member reports")
} icon: {
Image(systemName: chat.chatStats.reportsCount > 0 ? "flag.fill" : "flag").foregroundColor(.red)
}
Spacer()
if chat.chatStats.reportsCount > 0 {
UnreadBadge(count: chat.chatStats.reportsCount, color: .red)
}
}
}
.onChange(of: navLinkActive) { active in
if active {
ItemsModel.loadSecondaryChat(chat.id, chatFilter: .msgContentTagContext(contentTag: .report))
}
}
}
}
private func editGroupButton() -> some View {
NavigationLink {
GroupProfileView(
@ -787,36 +683,26 @@ struct GroupChatInfoView: View {
title: Text("Remove member?"),
message: Text(messageLabel),
primaryButton: .destructive(Text("Remove")) {
removeMember(groupInfo, mem)
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)
}
}
},
secondaryButton: .cancel()
)
}
}
func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) {
Task {
do {
let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
await MainActor.run {
ChatModel.shared.updateGroup(updatedGroupInfo)
updatedMembers.forEach { updatedMember in
_ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember)
}
dismiss?()
}
} 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!")
@ -910,7 +796,6 @@ struct GroupChatInfoView_Previews: PreviewProvider {
GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData),
scrollToItemId: Binding.constant(nil),
onSearch: {},
localAlias: ""
)

View file

@ -16,7 +16,6 @@ struct GroupMemberInfoView: View {
@State var groupInfo: GroupInfo
@ObservedObject var chat: Chat
@ObservedObject var groupMember: GMember
@Binding var scrollToItemId: ChatItem.ID?
var navigation: Bool = false
@State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil
@ -104,10 +103,6 @@ struct GroupMemberInfoView: View {
if member.memberActive {
Section {
if groupInfo.membership.memberRole >= .moderator
&& (member.memberRole < .moderator || member.supportChat != nil) {
MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId)
}
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
@ -283,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
}
}
@ -479,31 +474,6 @@ struct GroupMemberInfoView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
struct MemberInfoSupportChatNavLink: View {
@EnvironmentObject var theme: AppTheme
var groupInfo: GroupInfo
var member: GMember
@Binding var scrollToItemId: ChatItem.ID?
@State private var navLinkActive = false
var body: some View {
let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: member.wrapped)
NavigationLink(isActive: $navLinkActive) {
SecondaryChatView(
chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
scrollToItemId: $scrollToItemId
)
} label: {
Label("Chat with member", systemImage: "flag")
}
.onChange(of: navLinkActive) { active in
if active {
ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo))
}
}
}
}
private func verifyCodeButton(_ code: String) -> some View {
let member = groupMember.wrapped
return NavigationLink {
@ -640,11 +610,10 @@ struct GroupMemberInfoView: View {
primaryButton: .destructive(Text("Remove")) {
Task {
do {
let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
await MainActor.run {
chatModel.updateGroup(updatedGroupInfo)
updatedMembers.forEach { updatedMember in
_ = chatModel.upsertGroupMember(updatedGroupInfo, updatedMember)
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
}
dismiss()
}
@ -852,8 +821,7 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
GroupMemberInfoView(
groupInfo: GroupInfo.sampleData,
chat: Chat.sampleData,
groupMember: GMember.sampleData,
scrollToItemId: Binding.constant(nil)
groupMember: GMember.sampleData
)
}
}

View file

@ -17,7 +17,6 @@ let MAX_VISIBLE_MEMBER_ROWS: CGFloat = 4.8
struct GroupMentionsView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var im: ItemsModel
var groupInfo: GroupInfo
@Binding var composeState: ComposeState
@Binding var selectedRange: NSRange
@ -94,31 +93,12 @@ struct GroupMentionsView: View {
currentMessage = composeState.message
}
}
func contextMemberFilter(_ member: GroupMember) -> Bool {
switch im.secondaryIMFilter {
case nil:
return true
case let .groupChatScopeContext(groupScopeInfo):
switch (groupScopeInfo) {
case let .memberSupport(groupMember_):
if let scopeMember = groupMember_ {
return member.memberRole >= .moderator || member.groupMemberId == scopeMember.groupMemberId
} else {
return member.memberRole >= .moderator
}
}
case .msgContentTagContext:
return false
}
}
private func filteredMembers() -> [GMember] {
let s = mentionName.lowercased()
return sortedMembers.filter {
contextMemberFilter($0.wrapped)
&& (s.isEmpty || $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s))
}
return s.isEmpty
? sortedMembers
: sortedMembers.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
}
private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) {

View file

@ -30,14 +30,6 @@ struct GroupPreferencesView: View {
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
VStack {
List {
Section {
MemberAdmissionButton(
groupInfo: $groupInfo,
admission: groupInfo.groupProfile.memberAdmission_,
currentAdmission: groupInfo.groupProfile.memberAdmission_,
creatingGroup: creatingGroup
)
}
featureSection(.timedMessages, $preferences.timedMessages.enable)
featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role)
@ -148,66 +140,6 @@ 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: {
if creatingGroup {
Text("Set member admission")
} else {
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))")
}
}
}
}
struct GroupPreferencesView_Previews: PreviewProvider {
static var previews: some View {
GroupPreferencesView(

View file

@ -1,93 +0,0 @@
//
// 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 members", comment: "admission stage"),
NSLocalizedString("Review members before admitting (\"knocking\").", 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)
}
}
.frame(height: 36)
} 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: {}
)
}

View file

@ -1,44 +0,0 @@
//
// 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.chatViewName).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())
}

View file

@ -1,265 +0,0 @@
//
// 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
@Binding var scrollToItemId: ChatItem.ID?
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 chats with members")
.foregroundColor(.secondary)
} else {
List {
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 8)
ForEach(filteredMembersWithChats) { memberWithChat in
MemberSupportChatNavLink(
groupInfo: groupInfo,
memberWithChat: memberWithChat,
scrollToItemId: $scrollToItemId
)
}
}
}
}
struct MemberSupportChatNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State private var memberSupportChatNavLinkActive = false
var groupInfo: GroupInfo
var memberWithChat: GMember
@Binding var scrollToItemId: ChatItem.ID?
var body: some View {
ZStack {
let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: memberWithChat.wrapped)
Button {
ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) {
memberSupportChatNavLinkActive = true
}
} label: {
SupportChatRowView(groupMember: memberWithChat, groupInfo: groupInfo)
}
NavigationLink(isActive: $memberSupportChatNavLinkActive) {
SecondaryChatView(
chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
scrollToItemId: $scrollToItemId
)
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if memberWithChat.wrapped.memberPending {
Button {
showAcceptMemberAlert(groupInfo, memberWithChat.wrapped)
} label: {
Label("Accept", systemImage: "checkmark")
}
.tint(theme.colors.primary)
}
Button {
showRemoveMemberAlert(groupInfo, memberWithChat.wrapped)
} label: {
Label("Remove", systemImage: "trash")
}
.tint(.red)
}
}
}
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)
}
}
}
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, dismiss: DismissAction? = nil) {
showAlert(
title: NSLocalizedString("Remove member?", comment: "alert title"),
buttonTitle: "Remove",
buttonAction: { removeMember(groupInfo, member, dismiss: dismiss) },
cancelButton: true
)
}
#Preview {
MemberSupportView(
groupInfo: GroupInfo.sampleData,
scrollToItemId: Binding.constant(nil)
)
}

View file

@ -1,42 +0,0 @@
//
// 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
@Binding var scrollToItemId: ChatItem.ID?
var body: some View {
if let im = chatModel.secondaryIM {
ChatView(
chat: chat,
im: im,
mergedItems: BoxedValue(MergedItems.create(im, [])),
floatingButtonModel: FloatingButtonModel(im: im),
scrollToItemId: $scrollToItemId
)
.onDisappear {
chatModel.secondaryIM = nil
}
}
}
}
#Preview {
SecondaryChatView(
chat: Chat(
chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)),
chatItems: [],
chatStats: ChatStats()
),
scrollToItemId: Binding.constant(nil)
)
}

View file

@ -25,7 +25,7 @@ struct SelectedItemsTopToolbar: View {
struct SelectedItemsBottomToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let im: ItemsModel
let chatItems: [ChatItem]
@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 || im.secondaryIMFilter != nil ? theme.colors.secondary : .red)
.foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red)
}
.disabled(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil)
.disabled(!moderateEnabled || deleteCountProhibited)
.opacity(canModerate ? 1 : 0)
Spacer()
@ -88,24 +88,24 @@ struct SelectedItemsBottomToolbar: View {
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : theme.colors.primary)
.foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary)
}
.disabled(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil)
.disabled(!forwardEnabled || forwardCountProhibited)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
}
.onAppear {
recheckItems(chatInfo, im.reversedChatItems, selectedChatItems)
recheckItems(chatInfo, chatItems, selectedChatItems)
}
.onChange(of: chatInfo) { info in
recheckItems(info, im.reversedChatItems, selectedChatItems)
recheckItems(info, chatItems, selectedChatItems)
}
.onChange(of: im.reversedChatItems) { items in
.onChange(of: chatItems) { items in
recheckItems(chatInfo, items, selectedChatItems)
}
.onChange(of: selectedChatItems) { selected in
recheckItems(chatInfo, im.reversedChatItems, selected)
recheckItems(chatInfo, chatItems, 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
}

View file

@ -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)
@ -189,7 +189,7 @@ struct ChatListNavLink: View {
}
.swipeActions(edge: .trailing) {
tagChatButton(chat)
if (groupInfo.membership.memberCurrentOrPending) {
if (groupInfo.membership.memberCurrent) {
leaveGroupChatButton(groupInfo)
}
if groupInfo.canDelete {
@ -214,7 +214,7 @@ struct ChatListNavLink: View {
let showReportsButton = chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator
let showClearButton = !chat.chatItems.isEmpty
let showDeleteGroup = groupInfo.canDelete
let showLeaveGroup = groupInfo.membership.memberCurrentOrPending
let showLeaveGroup = groupInfo.membership.memberCurrent
let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0)
if showClearButton && totalNumberOfButtons <= 3 {
@ -276,7 +276,7 @@ struct ChatListNavLink: View {
@ViewBuilder private func markReadButton() -> some View {
if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat {
Button {
Task { await markChatRead(ItemsModel.shared, chat) }
Task { await markChatRead(chat) }
} label: {
SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI)
}
@ -482,10 +482,12 @@ struct ChatListNavLink: View {
.tint(theme.colors.primary)
}
.appSheet(isPresented: $showContactConnectionInfo) {
if case let .contactConnection(contactConnection) = chat.chatInfo {
ContactConnectionInfo(contactConnection: contactConnection)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
.modifier(ThemedBackground(grouped: true))
Group {
if case let .contactConnection(contactConnection) = chat.chatInfo {
ContactConnectionInfo(contactConnection: contactConnection)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
.modifier(ThemedBackground(grouped: true))
}
}
}
.contentShape(Rectangle())

View file

@ -148,7 +148,6 @@ struct ChatListView: View {
@State private var userPickerShown: Bool = false
@State private var sheet: SomeSheet<AnyView>? = nil
@StateObject private var chatTagsModel = ChatTagsModel.shared
@State private var scrollToItemId: ChatItem.ID? = nil
// iOS 15 is required it to show/hide toolbar while chat is hidden/visible
@State private var viewOnScreen = true
@ -447,14 +446,7 @@ struct ChatListView: View {
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
let im = ItemsModel.shared
ChatView(
chat: chat,
im: im,
mergedItems: BoxedValue(MergedItems.create(im, [])),
floatingButtonModel: FloatingButtonModel(im: im),
scrollToItemId: $scrollToItemId
)
ChatView(chat: chat)
}
}
@ -900,12 +892,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:

View file

@ -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)
@ -341,12 +341,11 @@ 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)
case .memAccepted: chatPreviewInfoText("connecting…")
case .memPendingReview, .memPendingApproval: chatPreviewInfoText("reviewed by admins")
default: EmptyView()
}
default: EmptyView()
@ -440,11 +439,7 @@ struct ChatPreviewView: View {
if progressByTimeout {
ProgressView()
} else if chat.chatStats.reportsCount > 0 {
flagIcon(size: size * 0.8, color: .red)
} else if chat.supportUnreadCount > 0 {
flagIcon(size: size * 0.8, color: theme.colors.primary)
} else if chat.chatInfo.groupInfo?.membership.memberPending ?? false {
flagIcon(size: size * 0.8, color: theme.colors.secondary)
groupReportsIcon(size: size * 0.8)
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
@ -490,12 +485,12 @@ struct ChatPreviewView: View {
}
}
func flagIcon(size: CGFloat, color: Color) -> some View {
func groupReportsIcon(size: CGFloat) -> some View {
Image(systemName: "flag")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundColor(color)
.foregroundColor(.red)
}
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {

View file

@ -124,7 +124,7 @@ struct UserPicker: View {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
if (u.unreadCount > 0) {
userUnreadBadge(u, theme: theme).offset(x: 4, y: -4)
UnreadBadge(userInfo: u).offset(x: 4, y: -4)
}
}
.padding(.trailing, 6)
@ -171,27 +171,19 @@ struct UserPicker: View {
}
}
@inline(__always)
func userUnreadBadge(_ userInfo: UserInfo, theme: AppTheme) -> some View {
UnreadBadge(
count: userInfo.unreadCount,
color: userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary
)
}
struct UnreadBadge: View {
var userInfo: UserInfo
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var count: Int
var color: Color
var body: some View {
let size = dynamicSize(userFont).chatInfoSize
unreadCountText(count)
unreadCountText(userInfo.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: size, minHeight: size)
.background(color)
.background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner)
}
}

View file

@ -33,7 +33,7 @@ extension View {
func appSheet<Content>(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
@ViewBuilder content: @escaping () -> Content
content: @escaping () -> Content
) -> some View where Content: View {
sheet(isPresented: isPresented, onDismiss: onDismiss) {
content().modifier(PrivacySensitive())
@ -43,7 +43,7 @@ extension View {
func appSheet<T, Content>(
item: Binding<T?>,
onDismiss: (() -> Void)? = nil,
@ViewBuilder content: @escaping (T) -> Content
content: @escaping (T) -> Content
) -> some View where T: Identifiable, Content: View {
sheet(item: item, onDismiss: onDismiss) { it in
content(it).modifier(PrivacySensitive())

View file

@ -66,8 +66,6 @@ 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)

View file

@ -193,7 +193,7 @@ struct AddGroupView: View {
Task {
await m.loadGroupMembers(gInfo)
}
let c = Chat(chatInfo: .group(groupInfo: gInfo, groupChatScope: nil), chatItems: [])
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
m.addChat(c)
withAnimation {
groupInfo = gInfo

View file

@ -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, im: ItemsModel.shared, chatItem: alice, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: alice, scrollToItemId: { _ in })
.modifier(ChatItemClipped(alice, tailVisible: true))
Spacer()
}
HStack {
Spacer()
ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: bob, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: bob, scrollToItemId: { _ in })
.modifier(ChatItemClipped(bob, tailVisible: true))
.frame(alignment: .trailing)
}

View file

@ -58,7 +58,6 @@ 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,7 +117,6 @@ 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,
@ -146,7 +144,6 @@ 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
]
@ -198,8 +195,6 @@ let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.sta
let showDeleteConversationNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE)
let showDeleteContactNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONTACT_NOTICE)
let showReportsInSupportChatAlertDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT)
/// after importing new database, this flag will be set and unset only after importing app settings in `initializeChat` */
let shouldImportAppSettingsDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOULD_IMPORT_APP_SETTINGS)
let currentThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME, withDefault: DefaultTheme.SYSTEM_THEME_NAME)

View file

@ -350,7 +350,7 @@ struct UserProfilesView: View {
Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground)
} else {
if userInfo.unreadCount > 0 {
userUnreadBadge(userInfo, theme: theme)
UnreadBadge(userInfo: userInfo)
}
if user.hidden {
Image(systemName: "lock").foregroundColor(theme.colors.secondary)

View file

@ -67,7 +67,6 @@ func apiSendMessages(
: SEChatCommand.apiSendMessages(
type: chatInfo.chatType,
id: chatInfo.apiId,
scope: chatInfo.groupChatScope(),
live: false,
ttl: nil,
composedMessages: composedMessages
@ -124,7 +123,7 @@ enum SEChatCommand: ChatCmdProtocol {
case apiSetEncryptLocalFiles(enable: Bool)
case apiGetChats(userId: Int64)
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
var cmdString: String {
switch self {
@ -140,27 +139,15 @@ enum SEChatCommand: ChatCmdProtocol {
case let .apiCreateChatItems(noteFolderId, composedMessages):
let msgs = encodeJSON(composedMessages)
return "/_create *\(noteFolderId) json \(msgs)"
case let .apiSendMessages(type, id, scope, live, ttl, composedMessages):
case let .apiSendMessages(type, id, live, ttl, composedMessages):
let msgs = encodeJSON(composedMessages)
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
}
}
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 ref(_ type: ChatType, _ id: Int64) -> String {
"\(type.rawValue)\(id)"
}
}

View file

@ -168,19 +168,14 @@
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 */; };
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@ -532,19 +527,14 @@
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>"; };
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@ -702,8 +692,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -788,8 +778,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.a */,
);
path = Libraries;
sourceTree = "<group>";
@ -1086,7 +1076,6 @@
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */,
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */,
6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */,
64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */,
);
path = ComposeMessage;
sourceTree = "<group>";
@ -1128,10 +1117,6 @@
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>";
@ -1446,10 +1431,8 @@
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 */,
@ -1484,7 +1467,6 @@
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 */,
@ -1527,7 +1509,6 @@
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 */,
@ -1606,7 +1587,6 @@
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 */,
@ -1991,7 +1971,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 282;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@ -2016,7 +1996,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES_THIN;
MARKETING_VERSION = 6.4;
MARKETING_VERSION = 6.3.6;
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
@ -2041,7 +2021,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 282;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@ -2066,7 +2046,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.4;
MARKETING_VERSION = 6.3.6;
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
@ -2083,11 +2063,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 282;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.4;
MARKETING_VERSION = 6.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -2103,11 +2083,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 282;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.4;
MARKETING_VERSION = 6.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -2128,7 +2108,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 282;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@ -2143,7 +2123,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.4;
MARKETING_VERSION = 6.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -2165,7 +2145,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 282;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@ -2180,7 +2160,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.4;
MARKETING_VERSION = 6.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -2202,7 +2182,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 282;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@ -2228,7 +2208,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.4;
MARKETING_VERSION = 6.3.6;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@ -2253,7 +2233,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 282;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@ -2279,7 +2259,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.4;
MARKETING_VERSION = 6.3.6;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@ -2304,7 +2284,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 282;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@ -2319,7 +2299,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.4;
MARKETING_VERSION = 6.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -2338,7 +2318,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 282;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@ -2353,7 +2333,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.4;
MARKETING_VERSION = 6.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;

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, groupChatScope: GroupChatScopeInfo?)
case group(groupInfo: GroupInfo)
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
@ -1333,11 +1333,24 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
}
}
public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? {
get {
switch self {
case let .direct(contact): return contact.userCantSendReason
case let .group(groupInfo): return groupInfo.userCantSendReason
case let .local(noteFolder): return noteFolder.userCantSendReason
case let .contactRequest(contactRequest): return contactRequest.userCantSendReason
case let .contactConnection(contactConnection): return contactConnection.userCantSendReason
case .invalidJSON: return ("can't send messages", nil)
}
}
}
public var sendMsgEnabled: Bool {
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 +1363,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 +1388,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 +1405,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 +1428,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 +1453,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,14 +1484,7 @@ 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)
}
@ -1494,7 +1500,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
}
}
@ -1510,7 +1516,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
}
}
@ -1518,7 +1524,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
@ -1529,7 +1535,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
@ -1540,7 +1546,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
@ -1556,7 +1562,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 {
@ -1576,7 +1582,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, groupChatScope: nil),
group: ChatInfo.group(groupInfo: GroupInfo.sampleData),
local: ChatInfo.local(noteFolder: NoteFolder.sampleData),
contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData),
contactConnection: ChatInfo.contactConnection(contactConnection: PendingContactConnection.getSampleData())
@ -1606,13 +1612,7 @@ 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, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) {
self.unreadCount = unreadCount
self.unreadMentions = unreadMentions
self.reportsCount = reportsCount
@ -1629,32 +1629,6 @@ public struct ChatStats: Decodable, Hashable {
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
@ -1681,15 +1655,16 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
public var ready: Bool { get { activeConn?.connStatus == .ready } }
public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } }
public var active: Bool { get { contactStatus == .active } }
public var sendMsgEnabled: Bool { get {
(
sndReady
&& active
&& !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false)
&& !(activeConn?.connDisabled ?? true)
)
|| nextSendGrpInv
} }
public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? {
// TODO [short links] this will have additional statuses for pending contact requests before they are accepted
if nextSendGrpInv { return nil }
if !active { return ("contact deleted", nil) }
if !sndReady { return ("contact not ready", nil) }
if activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false { return ("not synchronized", nil) }
if activeConn?.connDisabled ?? true { return ("contact disabled", nil) }
return nil
}
public var sendMsgEnabled: Bool { userCantSendReason == nil }
public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } }
public var displayName: String { localAlias == "" ? profile.displayName : localAlias }
public var fullName: String { get { profile.fullName } }
@ -1868,6 +1843,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable {
public var id: ChatId { get { "<@\(contactRequestId)" } }
public var apiId: Int64 { get { contactRequestId } }
var ready: Bool { get { true } }
public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { ("can't send messages", nil) }
public var sendMsgEnabled: Bool { get { false } }
public var displayName: String { get { profile.displayName } }
public var fullName: String { get { profile.fullName } }
@ -1900,6 +1876,7 @@ public struct PendingContactConnection: Decodable, NamedChat, Hashable {
public var id: ChatId { get { ":\(pccConnId)" } }
public var apiId: Int64 { get { pccConnId } }
var ready: Bool { get { false } }
public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { ("can't send messages", nil) }
public var sendMsgEnabled: Bool { get { false } }
var localDisplayName: String {
get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) }
@ -2025,12 +2002,24 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
var updatedAt: Date
var chatTs: Date?
public var uiThemes: ThemeModeOverrides?
public var membersRequireAttention: Int
public var id: ChatId { get { "#\(groupId)" } }
public var apiId: Int64 { get { groupId } }
public var ready: Bool { get { true } }
public var sendMsgEnabled: Bool { get { membership.memberActive } }
public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? {
return if membership.memberActive {
membership.memberRole == .observer ? ("you are observer", "Please contact group admin.") : nil
} else {
switch membership.memberStatus {
case .memRejected: ("request to join rejected", nil)
case .memGroupDeleted: ("group is deleted", nil)
case .memRemoved: ("removed from group", nil)
case .memLeft: ("you left", nil)
default: ("can't send messages", nil)
}
}
}
public var sendMsgEnabled: Bool { userCantSendReason == nil }
public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias }
public var fullName: String { get { groupProfile.fullName } }
public var image: String? { get { groupProfile.image } }
@ -2043,17 +2032,13 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
}
public var canDelete: Bool {
return membership.memberRole == .owner || !membership.memberCurrentOrPending
return membership.memberRole == .owner || !membership.memberCurrent
}
public var canAddMembers: Bool {
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",
@ -2063,7 +2048,6 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
chatSettings: ChatSettings.defaults,
createdAt: .now,
updatedAt: .now,
membersRequireAttention: 0,
chatTags: [],
localAlias: ""
)
@ -2075,20 +2059,12 @@ 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,
memberAdmission: GroupMemberAdmission? = nil
) {
public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) {
self.displayName = displayName
self.fullName = fullName
self.description = description
self.image = image
self.groupPreferences = groupPreferences
self.memberAdmission = memberAdmission
}
public var displayName: String
@ -2096,48 +2072,14 @@ 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 var memberAdmission_: GroupMemberAdmission {
get { self.memberAdmission ?? GroupMemberAdmission() }
set { memberAdmission = newValue }
}
public static let sampleData = GroupProfile(
displayName: "team",
fullName: "My Team"
)
}
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
@ -2164,7 +2106,6 @@ 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)" }
@ -2236,7 +2177,6 @@ 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
@ -2256,7 +2196,6 @@ 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
@ -2267,18 +2206,6 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
}
}
public var memberPending: Bool {
switch memberStatus {
case .memPendingApproval: return true
case .memPendingReview: return true
default: return false
}
}
public var memberCurrentOrPending: Bool {
memberCurrent || memberPending
}
public func canBeRemoved(groupInfo: GroupInfo) -> Bool {
let userRole = groupInfo.membership.memberRole
return memberStatus != .memRemoved && memberStatus != .memLeft
@ -2332,13 +2259,6 @@ 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
}
@ -2408,7 +2328,6 @@ 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"
@ -2426,7 +2345,6 @@ 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)"
@ -2446,7 +2364,6 @@ 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"
@ -2469,6 +2386,7 @@ public struct NoteFolder: Identifiable, Decodable, NamedChat, Hashable {
public var id: ChatId { get { "*\(noteFolderId)" } }
public var apiId: Int64 { get { noteFolderId } }
public var ready: Bool { get { true } }
public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { nil }
public var sendMsgEnabled: Bool { get { true } }
public var displayName: String { get { ChatInfo.privateNotesChatName } }
public var fullName: String { get { "" } }
@ -2729,15 +2647,12 @@ 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 .memberAccepted: nil
case .userPendingReview: nil
default: .sndGroupEvent
}
default:
@ -2770,8 +2685,6 @@ 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
@ -2783,7 +2696,6 @@ 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
@ -2852,12 +2764,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
@ -3239,21 +3151,6 @@ 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
@ -3307,17 +3204,6 @@ 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
@ -4217,16 +4103,6 @@ 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?
@ -4564,8 +4440,6 @@ 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)
@ -4577,16 +4451,12 @@ 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)
@ -4606,7 +4476,6 @@ 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")
}
}
@ -4631,8 +4500,6 @@ public enum SndGroupEvent: Decodable, Hashable {
case memberDeleted(groupMemberId: Int64, profile: Profile)
case userLeft
case groupUpdated(groupProfile: GroupProfile)
case memberAccepted(groupMemberId: Int64, profile: Profile)
case userPendingReview
var text: String {
switch self {
@ -4650,9 +4517,6 @@ 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 .memberAccepted: return NSLocalizedString("you accepted this member", 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")
}
}
}

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"

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):"

View file

@ -155,6 +155,7 @@ buildConfig {
buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"")
buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}")
buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"")
buildConfigField("Boolean", "ANDROID_BUNDLE", "${extra["android.bundle"]}")
}
}

View file

@ -42,7 +42,6 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import java.lang.reflect.Field
import java.net.URI
@ -51,11 +50,10 @@ import java.net.URI
actual fun PlatformTextField(
composeState: MutableState<ComposeState>,
sendMsgEnabled: Boolean,
disabledText: String?,
sendMsgButtonDisabled: Boolean,
textStyle: MutableState<TextStyle>,
showDeleteTextButton: MutableState<Boolean>,
userIsObserver: Boolean,
userIsPending: Boolean,
placeholder: String,
showVoiceButton: Boolean,
onMessageChange: (ComposeMessage) -> Unit,
@ -198,18 +196,16 @@ actual fun PlatformTextField(
showDeleteTextButton.value = it.lineCount >= 4 && !cs.inProgress
}
if (composeState.value.preview is ComposePreview.VoicePreview) {
ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding)
} else if (userIsPending) {
ComposeOverlay(MR.strings.reviewed_by_admins, textStyle, padding)
} else if (userIsObserver) {
ComposeOverlay(MR.strings.you_are_observer, textStyle, padding)
ComposeOverlay(generalGetString(MR.strings.voice_message_send_text), textStyle, padding)
} else if (disabledText != null) {
ComposeOverlay(disabledText, textStyle, padding)
}
}
@Composable
private fun ComposeOverlay(textId: StringResource, textStyle: MutableState<TextStyle>, padding: PaddingValues) {
private fun ComposeOverlay(text: String, textStyle: MutableState<TextStyle>, padding: PaddingValues) {
Text(
generalGetString(textId),
text,
Modifier.padding(padding),
color = MaterialTheme.colors.secondary,
style = textStyle.value.copy(fontStyle = FontStyle.Italic)

View file

@ -91,7 +91,7 @@ fun UserPickerUserBox(
ProfileImageForActiveCall(size = USER_PICKER_IMAGE_SIZE, image = userInfo.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
if (userInfo.unreadCount > 0 && !userInfo.user.activeUser) {
userUnreadBadge(userInfo.unreadCount, userInfo.user.showNtfs, false)
unreadBadge(userInfo.unreadCount, userInfo.user.showNtfs, false)
}
}
val user = userInfo.user

View file

@ -295,13 +295,14 @@ object ChatModel {
}
}
class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) {
class ChatsContext(val contentTag: MsgContentTag?) {
val chats = mutableStateOf(SnapshotStateList<Chat>())
/** if you modify the items by adding/removing them, use helpers methods like [addToChatItems], [removeLastChatItems], [removeAllAndNotify], [clearAndNotify] and so on.
* If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages].
* If you use api call to get the items, use just [add] instead of [addToChatItems].
* Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */
val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
// set listener here that will be notified on every add/delete of a chat item
val chatState = ActiveChatState()
@ -309,26 +310,6 @@ object ChatModel {
fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id }
private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId }
val contentTag: MsgContentTag? =
when (secondaryContextFilter) {
null -> null
is SecondaryContextFilter.GroupChatScopeContext -> null
is SecondaryContextFilter.MsgContentTagContext -> secondaryContextFilter.contentTag
}
val groupScopeInfo: GroupChatScopeInfo? =
when (secondaryContextFilter) {
null -> null
is SecondaryContextFilter.GroupChatScopeContext -> secondaryContextFilter.groupScopeInfo
is SecondaryContextFilter.MsgContentTagContext -> null
}
val isUserSupportChat: Boolean =
when (groupScopeInfo) {
null -> false
is GroupChatScopeInfo.MemberSupport -> groupScopeInfo.groupMember_ == null
}
suspend fun addChat(chat: Chat) {
chats.add(index = 0, chat)
popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = 0)
@ -361,8 +342,6 @@ object ChatModel {
)
)
}
} else if (currentCInfo is ChatInfo.Group && newCInfo is ChatInfo.Group && newCInfo.groupChatScope != null) {
newCInfo = newCInfo.copy(groupInfo = newCInfo.groupInfo, groupChatScope = null)
}
chats[i] = chats[i].copy(chatInfo = newCInfo)
}
@ -385,7 +364,7 @@ object ChatModel {
updateContact(rhId, updatedContact)
}
suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo, groupChatScope = null))
suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo))
private suspend fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) {
if (hasChat(rhId, cInfo.id)) {
@ -437,64 +416,55 @@ object ChatModel {
}
suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) {
// updates membersRequireAttention
updateChatInfo(rhId, cInfo)
// mark chat non deleted
if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) {
val updatedContact = cInfo.contact.copy(chatDeleted = false)
updateContact(rhId, updatedContact)
}
// update chat list
// update previews
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
if (i >= 0) {
chat = chatsContext.chats[i]
// update preview (for chat from main scope to show new items for invitee in pending status)
if (cInfo.groupChatScope() == null || cInfo.groupInfo_?.membership?.memberPending == true) {
val newPreviewItem = when (cInfo) {
is ChatInfo.Group -> {
val currentPreviewItem = chat.chatItems.firstOrNull()
if (currentPreviewItem != null) {
if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) {
cItem
} else {
currentPreviewItem
}
} else {
chat = chats[i]
val newPreviewItem = when (cInfo) {
is ChatInfo.Group -> {
val currentPreviewItem = chat.chatItems.firstOrNull()
if (currentPreviewItem != null) {
if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) {
cItem
} else {
currentPreviewItem
}
} else {
cItem
}
else -> cItem
}
val wasUnread = chat.unreadTag
chatsContext.chats[i] = chat.copy(
chatItems = arrayListOf(newPreviewItem),
chatStats =
if (cItem.meta.itemStatus is CIStatus.RcvNew) {
increaseUnreadCounter(rhId, currentUser.value!!)
chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions)
} else
chat.chatStats
)
updateChatTagReadInPrimaryContext(chatsContext.chats[i], wasUnread)
else -> cItem
}
// pop chat
val wasUnread = chat.unreadTag
chats[i] = chat.copy(
chatItems = arrayListOf(newPreviewItem),
chatStats =
if (cItem.meta.itemStatus is CIStatus.RcvNew) {
increaseUnreadCounter(rhId, currentUser.value!!)
chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions)
}
else
chat.chatStats
)
updateChatTagReadNoContentTag(chats[i], wasUnread)
if (appPlatform.isDesktop && cItem.chatDir.sent) {
reorderChat(chatsContext.chats[i], 0)
reorderChat(chats[i], 0)
} else {
popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i)
}
} else {
if (cInfo.groupChatScope() == null) {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
} else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = emptyList()))
}
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
}
// add to current scope
withContext(Dispatchers.Main) {
if (chatItemBelongsToScope(cInfo, cItem)) {
// add to current chat
if (chatId.value == cInfo.id) {
// Prevent situation when chat item already in the list received from backend
if (chatItems.value.none { it.id == cItem.id }) {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
@ -506,101 +476,84 @@ object ChatModel {
}
}
}
private fun chatItemBelongsToScope(cInfo: ChatInfo, cItem: ChatItem): Boolean =
when (secondaryContextFilter) {
null ->
chatId.value == cInfo.id && cInfo.groupChatScope() == null
is SecondaryContextFilter.GroupChatScopeContext -> {
val cInfoScope = cInfo.groupChatScope()
if (cInfoScope != null) {
chatId.value == cInfo.id && sameChatScope(cInfoScope, secondaryContextFilter.groupScopeInfo.toChatScope())
} else {
false
}
}
is SecondaryContextFilter.MsgContentTagContext ->
chatId.value == cInfo.id && cItem.isReport
}
suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean {
var itemAdded = false
// update chat list
if (cInfo.groupChatScope() == null) {
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
if (i >= 0) {
chat = chats[i]
val pItem = chat.chatItems.lastOrNull()
if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
if (pItem.isRcvNew && !cItem.isRcvNew) {
// status changed from New to Read, update counter
decreaseCounterInPrimaryContext(rhId, cInfo.id)
}
// update previews
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
val res: Boolean
if (i >= 0) {
chat = chats[i]
val pItem = chat.chatItems.lastOrNull()
if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
if (pItem.isRcvNew && !cItem.isRcvNew) {
// status changed from New to Read, update counter
decreaseCounterInChatNoContentTag(rhId, cInfo.id)
}
} else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
itemAdded = true
}
res = false
} else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
res = true
}
// update current scope
withContext(Dispatchers.Main) {
if (chatItemBelongsToScope(cInfo, cItem)) {
return withContext(Dispatchers.Main) {
// update current chat
if (chatId.value == cInfo.id) {
if (cItem.isDeletedContent || cItem.meta.itemDeleted != null) {
AudioPlayer.stop(cItem)
}
val items = chatItems.value
val itemIndex = items.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
val oldStatus = items[itemIndex].meta.itemStatus
val newStatus = cItem.meta.itemStatus
val ci = if (shouldKeepOldSndCIStatus(oldStatus, newStatus)) {
cItem.copy(meta = cItem.meta.copy(itemStatus = oldStatus))
items[itemIndex] = cItem
false
} else {
val status = chatItemStatuses.remove(cItem.id)
val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) {
cItem.copy(meta = cItem.meta.copy(itemStatus = status))
} else {
cItem
}
items[itemIndex] = ci
} else {
addToChatItems(cItem)
itemAdded = true
addToChatItems(ci)
true
}
} else {
res
}
}
return itemAdded
}
suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null, atIndex: Int? = null) {
withContext(Dispatchers.Main) {
if (chatItemBelongsToScope(cInfo, cItem)) {
if (chatId.value == cInfo.id) {
val items = chatItems.value
val itemIndex = atIndex ?: items.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
items[itemIndex] = cItem
}
} else if (status != null) {
chatItemStatuses[cItem.id] = status
}
}
}
fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) {
// update chat list
if (cInfo.groupChatScope() == null) {
if (cItem.isRcvNew) {
decreaseCounterInPrimaryContext(rhId, cInfo.id)
}
// update preview
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
if (i >= 0) {
chat = chats[i]
val pItem = chat.chatItems.lastOrNull()
if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy))
}
if (cItem.isRcvNew) {
decreaseCounterInChatNoContentTag(rhId, cInfo.id)
}
// update previews
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
if (i >= 0) {
chat = chats[i]
val pItem = chat.chatItems.lastOrNull()
if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy))
}
}
// remove from current scope
if (chatItemBelongsToScope(cInfo, cItem)) {
// remove from current chat
if (chatId.value == cInfo.id) {
chatItems.removeAllAndNotify {
// We delete taking into account meta.createdAt to make sure we will not be in situation when two items with the same id will be deleted
// (it can happen if already deleted chat item in backend still in the list and new one came with the same (re-used) chat item id)
@ -633,7 +586,7 @@ object ChatModel {
Log.d(TAG, "exiting removeMemberItems")
return
}
val cInfo = ChatInfo.Group(groupInfo, groupChatScope = null) // TODO [knocking] review
val cInfo = ChatInfo.Group(groupInfo)
if (chatId.value == groupInfo.id) {
for (i in 0 until chatItems.value.size) {
val updatedItem = removedUpdatedItem(chatItems.value[i])
@ -664,6 +617,7 @@ object ChatModel {
}
// clear current chat
if (chatId.value == cInfo.id) {
chatItemStatuses.clear()
chatItems.clearAndNotify()
}
}
@ -734,7 +688,7 @@ object ChatModel {
chats[chatIdx] = chat.copy(
chatStats = chat.chatStats.copy(unreadCount = unreadCount, unreadMentions = unreadMentions)
)
updateChatTagReadInPrimaryContext(chats[chatIdx], wasUnread)
updateChatTagReadNoContentTag(chats[chatIdx], wasUnread)
}
}
}
@ -775,9 +729,9 @@ object ChatModel {
return markedRead to mentionsMarkedRead
}
private fun decreaseCounterInPrimaryContext(rhId: Long?, chatId: ChatId) {
private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (secondaryContextFilter != null) return
if (contentTag != null) return
val chatIndex = getChatIndex(rhId, chatId)
if (chatIndex == -1) return
@ -791,7 +745,7 @@ object ChatModel {
unreadCount = unreadCount,
)
)
updateChatTagReadInPrimaryContext(chats[chatIndex], wasUnread)
updateChatTagReadNoContentTag(chats[chatIndex], wasUnread)
}
fun removeChat(rhId: Long?, id: String) {
@ -860,16 +814,16 @@ object ChatModel {
}
fun increaseUnreadCounter(rhId: Long?, user: UserLike) {
changeUnreadCounterInPrimaryContext(rhId, user, 1)
changeUnreadCounterNoContentTag(rhId, user, 1)
}
fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) {
changeUnreadCounterInPrimaryContext(rhId, user, -by)
changeUnreadCounterNoContentTag(rhId, user, -by)
}
private fun changeUnreadCounterInPrimaryContext(rhId: Long?, user: UserLike, by: Int) {
private fun changeUnreadCounterNoContentTag(rhId: Long?, user: UserLike, by: Int) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (secondaryContextFilter != null) return
if (contentTag != null) return
val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId }
if (i != -1) {
@ -877,9 +831,9 @@ object ChatModel {
}
}
fun updateChatTagReadInPrimaryContext(chat: Chat, wasUnread: Boolean) {
fun updateChatTagReadNoContentTag(chat: Chat, wasUnread: Boolean) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (secondaryContextFilter != null) return
if (contentTag != null) return
val tags = chat.chatInfo.chatTags ?: return
val nowUnread = chat.unreadTag
@ -889,21 +843,21 @@ object ChatModel {
unreadTags[tag] = (unreadTags[tag] ?: 0) + 1
}
} else if (!nowUnread && wasUnread) {
markChatTagReadInPrimaryContext_(chat, tags)
markChatTagReadNoContentTag_(chat, tags)
}
}
fun markChatTagRead(chat: Chat) {
if (chat.unreadTag) {
chat.chatInfo.chatTags?.let { tags ->
markChatTagReadInPrimaryContext_(chat, tags)
markChatTagReadNoContentTag_(chat, tags)
}
}
}
private fun markChatTagReadInPrimaryContext_(chat: Chat, tags: List<Long>) {
private fun markChatTagReadNoContentTag_(chat: Chat, tags: List<Long>) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (secondaryContextFilter != null) return
if (contentTag != null) return
for (tag in tags) {
val count = unreadTags[tag]
@ -935,12 +889,12 @@ object ChatModel {
val wasReportsCount = chat.chatStats.reportsCount
val nowReportsCount = chats[i].chatStats.reportsCount
val by = if (wasReportsCount == 0 && nowReportsCount > 0) 1 else if (wasReportsCount > 0 && nowReportsCount == 0) -1 else 0
changeGroupReportsTagInPrimaryContext(by)
changeGroupReportsTagNoContentTag(by)
}
}
private fun changeGroupReportsTagInPrimaryContext(by: Int = 0) {
if (by == 0 || secondaryContextFilter != null) return
private fun changeGroupReportsTagNoContentTag(by: Int = 0) {
if (by == 0 || contentTag != null) return
presetTags[PresetTagKind.GROUP_REPORTS] = kotlin.math.max(0, (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by)
clearActiveChatFilterIfNeeded()
}
@ -1145,28 +1099,6 @@ enum class ChatType(val type: String) {
ContactConnection(":");
}
sealed class GroupChatScope {
class MemberSupport(val groupMemberId_: Long?): GroupChatScope()
}
fun sameChatScope(scope1: GroupChatScope, scope2: GroupChatScope) =
scope1 is GroupChatScope.MemberSupport
&& scope2 is GroupChatScope.MemberSupport
&& scope1.groupMemberId_ == scope2.groupMemberId_
@Serializable
sealed class GroupChatScopeInfo {
@Serializable @SerialName("memberSupport") data class MemberSupport(val groupMember_: GroupMember?) : GroupChatScopeInfo()
fun toChatScope(): GroupChatScope =
when (this) {
is MemberSupport -> when (groupMember_) {
null -> GroupChatScope.MemberSupport(groupMemberId_ = null)
else -> GroupChatScope.MemberSupport(groupMemberId_ = groupMember_.groupMemberId)
}
}
}
@Serializable
data class User(
val remoteHostId: Long?,
@ -1272,6 +1204,7 @@ interface SomeChat {
val apiId: Long
val ready: Boolean
val chatDeleted: Boolean
val userCantSendReason: Pair<String, String?>?
val sendMsgEnabled: Boolean
val incognito: Boolean
fun featureEnabled(feature: ChatFeature): Boolean
@ -1296,19 +1229,6 @@ data class Chat(
else -> false
}
val userIsObserver: Boolean get() = when(chatInfo) {
is ChatInfo.Group -> {
val m = chatInfo.groupInfo.membership
m.memberActive && m.memberRole == GroupMemberRole.Observer
}
else -> false
}
val userIsPending: Boolean get() = when(chatInfo) {
is ChatInfo.Group -> chatInfo.groupInfo.membership.memberPending
else -> false
}
val unreadTag: Boolean get() = when (chatInfo.chatSettings?.enableNtfs) {
All -> chatStats.unreadChat || chatStats.unreadCount > 0
Mentions -> chatStats.unreadChat || chatStats.unreadMentions > 0
@ -1317,16 +1237,6 @@ data class Chat(
val id: String get() = chatInfo.id
val supportUnreadCount: Int get() = when (chatInfo) {
is ChatInfo.Group ->
if (chatInfo.groupInfo.canModerate) {
chatInfo.groupInfo.membersRequireAttention
} else {
chatInfo.groupInfo.membership.supportChat?.unread ?: 0
}
else -> 0
}
fun groupFeatureEnabled(feature: GroupFeature): Boolean =
if (chatInfo is ChatInfo.Group) {
chatInfo.groupInfo.groupFeatureEnabled(feature)
@ -1365,6 +1275,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contact.apiId
override val ready get() = contact.ready
override val chatDeleted get() = contact.chatDeleted
override val userCantSendReason get() = contact.userCantSendReason
override val sendMsgEnabled get() = contact.sendMsgEnabled
override val incognito get() = contact.incognito
override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature)
@ -1383,13 +1294,14 @@ sealed class ChatInfo: SomeChat, NamedChat {
}
@Serializable @SerialName("group")
data class Group(val groupInfo: GroupInfo, val groupChatScope: GroupChatScopeInfo?): ChatInfo() {
data class Group(val groupInfo: GroupInfo): ChatInfo() {
override val chatType get() = ChatType.Group
override val localDisplayName get() = groupInfo.localDisplayName
override val id get() = groupInfo.id
override val apiId get() = groupInfo.apiId
override val ready get() = groupInfo.ready
override val chatDeleted get() = groupInfo.chatDeleted
override val userCantSendReason get() = groupInfo.userCantSendReason
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
override val incognito get() = groupInfo.incognito
override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature)
@ -1402,7 +1314,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val localAlias get() = groupInfo.localAlias
companion object {
val sampleData = Group(GroupInfo.sampleData, groupChatScope = null)
val sampleData = Group(GroupInfo.sampleData)
}
}
@ -1414,6 +1326,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = noteFolder.apiId
override val ready get() = noteFolder.ready
override val chatDeleted get() = noteFolder.chatDeleted
override val userCantSendReason get() = noteFolder.userCantSendReason
override val sendMsgEnabled get() = noteFolder.sendMsgEnabled
override val incognito get() = noteFolder.incognito
override fun featureEnabled(feature: ChatFeature) = noteFolder.featureEnabled(feature)
@ -1438,6 +1351,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactRequest.apiId
override val ready get() = contactRequest.ready
override val chatDeleted get() = contactRequest.chatDeleted
override val userCantSendReason get() = contactRequest.userCantSendReason
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
override val incognito get() = contactRequest.incognito
override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature)
@ -1462,6 +1376,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactConnection.apiId
override val ready get() = contactConnection.ready
override val chatDeleted get() = contactConnection.chatDeleted
override val userCantSendReason get() = contactConnection.userCantSendReason
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
override val incognito get() = contactConnection.incognito
override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature)
@ -1491,6 +1406,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = "?$apiId"
override val ready get() = false
override val chatDeleted get() = false
override val userCantSendReason get() = generalGetString(MR.strings.cant_send_message_generic) to null
override val sendMsgEnabled get() = false
override val incognito get() = false
override fun featureEnabled(feature: ChatFeature) = false
@ -1506,11 +1422,6 @@ sealed class ChatInfo: SomeChat, NamedChat {
}
}
fun groupChatScope(): GroupChatScope? = when (this) {
is Group -> groupChatScope?.toChatScope()
else -> null
}
fun ntfsEnabled(ci: ChatItem): Boolean =
ntfsEnabled(ci.meta.userMention)
@ -1538,16 +1449,6 @@ sealed class ChatInfo: SomeChat, NamedChat {
is InvalidJSON -> updatedAt
}
val userCanSend: Boolean
get() = when (this) {
is ChatInfo.Direct -> true
is ChatInfo.Group ->
(groupInfo.membership.memberRole >= GroupMemberRole.Member && !groupInfo.membership.memberPending)
|| groupChatScope != null
is ChatInfo.Local -> true
else -> false
}
val chatTags: List<Long>?
get() = when (this) {
is Direct -> contact.chatTags
@ -1564,13 +1465,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
is Direct -> contact.activeConn == null && contact.profile.contactLink != null && contact.active
else -> false
}
val groupInfo_: GroupInfo?
get() = when (this) {
is Group -> groupInfo
else -> null
}
}
}
@Serializable
sealed class NetworkStatus {
@ -1624,13 +1519,17 @@ data class Contact(
override val ready get() = activeConn?.connStatus == ConnStatus.Ready
val sndReady get() = ready || activeConn?.connStatus == ConnStatus.SndReady
val active get() = contactStatus == ContactStatus.Active
override val sendMsgEnabled get() = (
sndReady
&& active
&& !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?: false)
&& !(activeConn?.connDisabled ?: true)
)
|| nextSendGrpInv
override val userCantSendReason: Pair<String, String?>?
get() {
// TODO [short links] this will have additional statuses for pending contact requests before they are accepted
if (nextSendGrpInv) return null
if (!active) return generalGetString(MR.strings.cant_send_message_contact_deleted) to null
if (!sndReady) return generalGetString(MR.strings.cant_send_message_contact_not_ready) to null
if (activeConn?.connectionStats?.ratchetSyncSendProhibited == true) return generalGetString(MR.strings.cant_send_message_contact_not_synchronized) to null
if (activeConn?.connDisabled == true) return generalGetString(MR.strings.cant_send_message_contact_disabled) to null
return null
}
override val sendMsgEnabled get() = userCantSendReason == null
val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent
override val incognito get() = contactConnIncognito
override fun featureEnabled(feature: ChatFeature) = when (feature) {
@ -1855,7 +1754,6 @@ data class GroupInfo (
override val updatedAt: Instant,
val chatTs: Instant?,
val uiThemes: ThemeModeOverrides? = null,
val membersRequireAttention: Int,
val chatTags: List<Long>,
val chatItemTTL: Long?,
override val localAlias: String,
@ -1865,7 +1763,23 @@ data class GroupInfo (
override val apiId get() = groupId
override val ready get() = membership.memberActive
override val chatDeleted get() = false
override val sendMsgEnabled get() = membership.memberActive
override val userCantSendReason: Pair<String, String?>? get() =
if (membership.memberActive) {
if (membership.memberRole == GroupMemberRole.Observer) {
generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc)
} else {
null
}
} else {
when (membership.memberStatus) {
GroupMemberStatus.MemRejected -> generalGetString(MR.strings.cant_send_message_rejected) to null
GroupMemberStatus.MemGroupDeleted -> generalGetString(MR.strings.cant_send_message_group_deleted) to null
GroupMemberStatus.MemRemoved -> generalGetString(MR.strings.cant_send_message_mem_removed) to null
GroupMemberStatus.MemLeft -> generalGetString(MR.strings.cant_send_message_you_left) to null
else -> generalGetString(MR.strings.cant_send_message_generic) to null
}
}
override val sendMsgEnabled get() = userCantSendReason == null
override val incognito get() = membership.memberIncognito
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
@ -1883,7 +1797,7 @@ data class GroupInfo (
get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent
val canDelete: Boolean
get() = membership.memberRole == GroupMemberRole.Owner || !membership.memberCurrentOrPending
get() = membership.memberRole == GroupMemberRole.Owner || !membership.memberCurrent
val canAddMembers: Boolean
get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive
@ -1918,7 +1832,6 @@ data class GroupInfo (
updatedAt = Clock.System.now(),
chatTs = Clock.System.now(),
uiThemes = null,
membersRequireAttention = 0,
chatTags = emptyList(),
localAlias = "",
chatItemTTL = null
@ -1936,8 +1849,7 @@ data class GroupProfile (
val description: String? = null,
override val image: String? = null,
override val localAlias: String = "",
val groupPreferences: GroupPreferences? = null,
val memberAdmission: GroupMemberAdmission? = null
val groupPreferences: GroupPreferences? = null
): NamedChat {
companion object {
val sampleData = GroupProfile(
@ -1947,27 +1859,6 @@ data class GroupProfile (
}
}
@Serializable
data class GroupMemberAdmission(
val review: MemberCriteria? = null,
) {
companion object {
val sampleData = GroupMemberAdmission(
review = null,
)
}
}
@Serializable
enum class MemberCriteria {
@SerialName("all") All;
val text: String
get() = when(this) {
MemberCriteria.All -> generalGetString(MR.strings.member_criteria_all)
}
}
@Serializable
data class BusinessChatInfo (
val chatType: BusinessChatType,
@ -1996,8 +1887,7 @@ data class GroupMember (
val memberProfile: LocalProfile,
val memberContactId: Long? = null,
val memberContactProfileId: Long,
var activeConn: Connection? = null,
val supportChat: GroupSupportChat? = null
var activeConn: Connection? = null
): NamedChat {
val id: String get() = "#$groupId @$groupMemberId"
val ready get() = activeConn?.connStatus == ConnStatus.Ready
@ -2056,7 +1946,6 @@ data class GroupMember (
GroupMemberStatus.MemUnknown -> false
GroupMemberStatus.MemInvited -> false
GroupMemberStatus.MemPendingApproval -> true
GroupMemberStatus.MemPendingReview -> true
GroupMemberStatus.MemIntroduced -> false
GroupMemberStatus.MemIntroInvited -> false
GroupMemberStatus.MemAccepted -> false
@ -2074,7 +1963,6 @@ data class GroupMember (
GroupMemberStatus.MemUnknown -> false
GroupMemberStatus.MemInvited -> false
GroupMemberStatus.MemPendingApproval -> false
GroupMemberStatus.MemPendingReview -> false
GroupMemberStatus.MemIntroduced -> true
GroupMemberStatus.MemIntroInvited -> true
GroupMemberStatus.MemAccepted -> true
@ -2084,15 +1972,6 @@ data class GroupMember (
GroupMemberStatus.MemCreator -> true
}
val memberPending: Boolean get() = when (this.memberStatus) {
GroupMemberStatus.MemPendingApproval -> true
GroupMemberStatus.MemPendingReview -> true
else -> false
}
val memberCurrentOrPending: Boolean get() =
memberCurrent || memberPending
fun canBeRemoved(groupInfo: GroupInfo): Boolean {
val userRole = groupInfo.membership.memberRole
return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft
@ -2133,14 +2012,6 @@ data class GroupMember (
}
}
@Serializable
class GroupSupportChat (
val chatTs: Instant,
val unread: Int,
val memberAttention: Int,
val mentions: Int
)
@Serializable
data class GroupMemberSettings(val showMessages: Boolean) {}
@ -2197,7 +2068,6 @@ enum class GroupMemberStatus {
@SerialName("unknown") MemUnknown,
@SerialName("invited") MemInvited,
@SerialName("pending_approval") MemPendingApproval,
@SerialName("pending_review") MemPendingReview,
@SerialName("introduced") MemIntroduced,
@SerialName("intro-inv") MemIntroInvited,
@SerialName("accepted") MemAccepted,
@ -2214,7 +2084,6 @@ enum class GroupMemberStatus {
MemUnknown -> generalGetString(MR.strings.group_member_status_unknown)
MemInvited -> generalGetString(MR.strings.group_member_status_invited)
MemPendingApproval -> generalGetString(MR.strings.group_member_status_pending_approval)
MemPendingReview -> generalGetString(MR.strings.group_member_status_pending_review)
MemIntroduced -> generalGetString(MR.strings.group_member_status_introduced)
MemIntroInvited -> generalGetString(MR.strings.group_member_status_intro_invitation)
MemAccepted -> generalGetString(MR.strings.group_member_status_accepted)
@ -2232,7 +2101,6 @@ enum class GroupMemberStatus {
MemUnknown -> generalGetString(MR.strings.group_member_status_unknown_short)
MemInvited -> generalGetString(MR.strings.group_member_status_invited)
MemPendingApproval -> generalGetString(MR.strings.group_member_status_pending_approval_short)
MemPendingReview -> generalGetString(MR.strings.group_member_status_pending_review_short)
MemIntroduced -> generalGetString(MR.strings.group_member_status_connecting)
MemIntroInvited -> generalGetString(MR.strings.group_member_status_connecting)
MemAccepted -> generalGetString(MR.strings.group_member_status_connecting)
@ -2287,6 +2155,7 @@ class NoteFolder(
override val apiId get() = noteFolderId
override val chatDeleted get() = false
override val ready get() = true
override val userCantSendReason: Pair<String, String?>? = null
override val sendMsgEnabled get() = true
override val incognito get() = false
override fun featureEnabled(feature: ChatFeature) = feature == ChatFeature.Voice
@ -2323,6 +2192,7 @@ class UserContactRequest (
override val apiId get() = contactRequestId
override val chatDeleted get() = false
override val ready get() = true
override val userCantSendReason = generalGetString(MR.strings.cant_send_message_generic) to null
override val sendMsgEnabled get() = false
override val incognito get() = false
override fun featureEnabled(feature: ChatFeature) = false
@ -2362,6 +2232,7 @@ class PendingContactConnection(
override val apiId get() = pccConnId
override val chatDeleted get() = false
override val ready get() = false
override val userCantSendReason = generalGetString(MR.strings.cant_send_message_generic) to null
override val sendMsgEnabled get() = false
override val incognito get() = customUserProfileId != null
override fun featureEnabled(feature: ChatFeature) = false
@ -2580,16 +2451,11 @@ data class ChatItem (
is CIContent.RcvGroupFeature,
is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.UserRole,
is RcvGroupEvent.UserDeleted,
is RcvGroupEvent.GroupDeleted,
is RcvGroupEvent.MemberCreatedContact,
is RcvGroupEvent.NewMemberPendingReview ->
null
is RcvGroupEvent.UserRole, is RcvGroupEvent.UserDeleted, is RcvGroupEvent.GroupDeleted, is RcvGroupEvent.MemberCreatedContact -> null
else -> CIMergeCategory.RcvGroupEvent
}
is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) {
is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.MemberAccepted, is SndGroupEvent.UserPendingReview -> null
is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft -> null
else -> CIMergeCategory.SndGroupEvent
}
else -> {
@ -2660,8 +2526,6 @@ data class ChatItem (
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.MemberAdded -> false
is RcvGroupEvent.MemberConnected -> false
is RcvGroupEvent.MemberAccepted -> false
is RcvGroupEvent.UserAccepted -> false
is RcvGroupEvent.MemberLeft -> false
is RcvGroupEvent.MemberRole -> false
is RcvGroupEvent.MemberBlocked -> false
@ -2673,7 +2537,6 @@ data class ChatItem (
is RcvGroupEvent.InvitedViaGroupLink -> false
is RcvGroupEvent.MemberCreatedContact -> false
is RcvGroupEvent.MemberProfileUpdated -> false
is RcvGroupEvent.NewMemberPendingReview -> true
}
is CIContent.SndGroupEventContent -> false
is CIContent.RcvConnEventContent -> false
@ -2855,11 +2718,6 @@ data class ChatItem (
}
}
sealed class SecondaryContextFilter {
class GroupChatScopeContext(val groupScopeInfo: GroupChatScopeInfo): SecondaryContextFilter()
class MsgContentTagContext(val contentTag: MsgContentTag): SecondaryContextFilter()
}
fun MutableState<SnapshotStateList<Chat>>.add(index: Int, elem: Chat) {
value = SnapshotStateList<Chat>().apply { addAll(value); add(index, elem) }
}
@ -3118,19 +2976,6 @@ 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
is SndRcvd -> false
is SndErrorAuth -> true
is CISSndError -> true
is SndWarning -> true
is RcvNew -> false
is RcvRead -> false
is Invalid -> false
}
fun statusIcon(
primaryColor: Color,
metaColor: Color = CurrentColors.value.colors.secondary,
@ -3170,13 +3015,6 @@ sealed class CIStatus {
}
}
fun shouldKeepOldSndCIStatus(oldStatus: CIStatus, newStatus: CIStatus): Boolean =
when {
oldStatus is CIStatus.SndRcvd && newStatus !is CIStatus.SndRcvd -> true
oldStatus.isSent() && newStatus is CIStatus.SndNew -> true
else -> false
}
@Serializable
sealed class SndError {
@Serializable @SerialName("auth") class Auth: SndError()
@ -4315,8 +4153,6 @@ sealed class RcvDirectEvent() {
sealed class RcvGroupEvent() {
@Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
@Serializable @SerialName("memberConnected") class MemberConnected(): RcvGroupEvent()
@Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
@Serializable @SerialName("userAccepted") class UserAccepted(): RcvGroupEvent()
@Serializable @SerialName("memberLeft") class MemberLeft(): RcvGroupEvent()
@Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): RcvGroupEvent()
@Serializable @SerialName("memberBlocked") class MemberBlocked(val groupMemberId: Long, val profile: Profile, val blocked: Boolean): RcvGroupEvent()
@ -4328,13 +4164,10 @@ sealed class RcvGroupEvent() {
@Serializable @SerialName("invitedViaGroupLink") class InvitedViaGroupLink(): RcvGroupEvent()
@Serializable @SerialName("memberCreatedContact") class MemberCreatedContact(): RcvGroupEvent()
@Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent()
@Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent()
val text: String get() = when (this) {
is MemberAdded -> String.format(generalGetString(MR.strings.rcv_group_event_member_added), profile.profileViewName)
is MemberConnected -> generalGetString(MR.strings.rcv_group_event_member_connected)
is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName)
is UserAccepted -> generalGetString(MR.strings.rcv_group_event_user_accepted)
is MemberLeft -> generalGetString(MR.strings.rcv_group_event_member_left)
is MemberRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_member_role), profile.profileViewName, role.text)
is MemberBlocked -> if (blocked) {
@ -4350,7 +4183,6 @@ sealed class RcvGroupEvent() {
is InvitedViaGroupLink -> generalGetString(MR.strings.rcv_group_event_invited_via_your_group_link)
is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact)
is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile)
is NewMemberPendingReview -> generalGetString(MR.strings.rcv_group_event_new_member_pending_review)
}
private fun profileUpdatedText(from: Profile, to: Profile): String =
@ -4375,8 +4207,6 @@ sealed class SndGroupEvent() {
@Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
@Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent()
@Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent()
@Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
@Serializable @SerialName("userPendingReview") class UserPendingReview(): SndGroupEvent()
val text: String get() = when (this) {
is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text)
@ -4389,8 +4219,6 @@ sealed class SndGroupEvent() {
is MemberDeleted -> String.format(generalGetString(MR.strings.snd_group_event_member_deleted), profile.profileViewName)
is UserLeft -> generalGetString(MR.strings.snd_group_event_user_left)
is GroupUpdated -> generalGetString(MR.strings.snd_group_event_group_profile_updated)
is MemberAccepted -> generalGetString(MR.strings.snd_group_event_member_accepted)
is UserPendingReview -> generalGetString(MR.strings.snd_group_event_user_pending_review)
}
}

View file

@ -163,7 +163,6 @@ class AppPreferences {
val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false)
val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false)
val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true)
val showReportsInSupportChatAlert = mkBoolPreference(SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, true)
val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
val appUpdateChannel = mkEnumPreference(SHARED_PREFS_APP_UPDATE_CHANNEL, AppUpdatesChannel.DISABLED) { AppUpdatesChannel.entries.firstOrNull { it.name == this } }
val appSkippedUpdate = mkStrPreference(SHARED_PREFS_APP_SKIPPED_UPDATE, "")
@ -246,7 +245,6 @@ class AppPreferences {
liveMessageAlertShown to false,
showHiddenProfilesNotice to true,
showMuteProfileAlert to true,
showReportsInSupportChatAlert to true,
showDeleteConversationNotice to true,
showDeleteContactNotice to true,
)
@ -417,7 +415,6 @@ class AppPreferences {
private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown"
private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown"
private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert"
private const val SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "ShowReportsInSupportChatAlert"
private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase"
private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase"
@ -885,8 +882,8 @@ object ChatController {
return null
}
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair<Chat, NavigationInfo>? {
val r = sendCmd(rh, CC.ApiGetChat(type, id, scope, contentTag, pagination, search))
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair<Chat, NavigationInfo>? {
val r = sendCmd(rh, CC.ApiGetChat(type, id, contentTag, pagination, search))
if (r is API.Result && r.res is CR.ApiChat) return if (rh == null) r.res.chat to r.res.navInfo else r.res.chat.copy(remoteHostId = rh) to r.res.navInfo
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
val e = (r as? API.Error)?.err
@ -920,8 +917,8 @@ object ChatController {
suspend fun apiReorderChatTags(rh: Long?, tagIds: List<Long>) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds))
suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, live: Boolean = false, ttl: Int? = null, composedMessages: List<ComposedMessage>): List<AChatItem>? {
val cmd = CC.ApiSendMessages(type, id, scope, live, ttl, composedMessages)
suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List<ComposedMessage>): List<AChatItem>? {
val cmd = CC.ApiSendMessages(type, id, live, ttl, composedMessages)
return processSendMessageCmd(rh, cmd)
}
@ -972,27 +969,27 @@ object ChatController {
return null
}
suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long): ChatItemInfo? {
val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, scope, itemId))
suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? {
val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))
if (r is API.Result && r.res is CR.ApiChatItemInfo) return r.res.chatItemInfo
apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r)
return null
}
suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List<Long>, ttl: Int?): List<ChatItem>? {
val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl)
suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemIds: List<Long>, ttl: Int?): List<ChatItem>? {
val cmd = CC.ApiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl)
return processSendMessageCmd(rh, cmd)?.map { it.chatItem }
}
suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, chatItemIds: List<Long>): CR.ForwardPlan? {
val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, fromScope, chatItemIds))
suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, chatItemIds: List<Long>): CR.ForwardPlan? {
val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, chatItemIds))
if (r is API.Result && r.res is CR.ForwardPlan) return r.res
apiErrorAlert("apiPlanForwardChatItems", generalGetString(MR.strings.error_forwarding_messages), r)
return null
}
suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long, updatedMessage: UpdatedMessage, live: Boolean = false): AChatItem? {
val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, scope, itemId, updatedMessage, live))
suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, updatedMessage: UpdatedMessage, live: Boolean = false): AChatItem? {
val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, updatedMessage, live))
when {
r is API.Result && r.res is CR.ChatItemUpdated -> return r.res.chatItem
r is API.Result && r.res is CR.ChatItemNotChanged -> return r.res.chatItem
@ -1014,8 +1011,8 @@ object ChatController {
return null
}
suspend fun apiChatItemReaction(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? {
val r = sendCmd(rh, CC.ApiChatItemReaction(type, id, scope, itemId, add, reaction))
suspend fun apiChatItemReaction(rh: Long?, type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? {
val r = sendCmd(rh, CC.ApiChatItemReaction(type, id, itemId, add, reaction))
if (r is API.Result && r.res is CR.ChatItemReaction) return r.res.reaction.chatReaction.chatItem
Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}")
return null
@ -1029,8 +1026,8 @@ object ChatController {
return null
}
suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List<Long>, mode: CIDeleteMode): List<ChatItemDeletion>? {
val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, scope, itemIds, mode))
suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, itemIds: List<Long>, mode: CIDeleteMode): List<ChatItemDeletion>? {
val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, itemIds, mode))
if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions
Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}")
return null
@ -1662,18 +1659,18 @@ object ChatController {
return null
}
suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?): Boolean {
val r = sendCmd(rh, CC.ApiChatRead(type, id, scope))
suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long): Boolean {
val r = sendCmd(rh, CC.ApiChatRead(type, id))
if (r.result is CR.CmdOk) return true
Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}")
return false
}
suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List<Long>): ChatInfo? {
val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, scope, itemIds))
if (r is API.Result && r.res is CR.ItemsReadForChat) return r.res.chatInfo
suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, itemIds: List<Long>): Boolean {
val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, itemIds))
if (r.result is CR.CmdOk) return true
Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}")
return null
return false
}
suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean {
@ -1902,18 +1899,9 @@ object ChatController {
}
}
suspend fun apiAcceptMember(rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole): Pair<GroupInfo, GroupMember>? {
val r = sendCmd(rh, CC.ApiAcceptMember(groupId, groupMemberId, memberRole))
if (r is API.Result && r.res is CR.MemberAccepted) return r.res.groupInfo to r.res.member
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiAcceptMember", generalGetString(MR.strings.error_accepting_member), r)
}
return null
}
suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List<Long>, withMessages: Boolean = false): Pair<GroupInfo, List<GroupMember>>? {
suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List<Long>, withMessages: Boolean = false): List<GroupMember>? {
val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages))
if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.groupInfo to r.res.members
if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.members
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiRemoveMembers", generalGetString(MR.strings.error_removing_member), r)
}
@ -2432,7 +2420,9 @@ object ChatController {
}
}
withContext(Dispatchers.Main) {
chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem)
if (cItem.isReport) {
chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem)
}
}
} else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) {
withContext(Dispatchers.Main) {
@ -2458,10 +2448,12 @@ object ChatController {
val cItem = chatItem.chatItem
if (!cItem.isDeletedContent && active(r.user)) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertChatItem(rhId, cInfo, cItem)
chatModel.chatsContext.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus)
}
withContext(Dispatchers.Main) {
chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, cItem)
if (cItem.isReport) {
chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus)
}
}
}
}
@ -2473,7 +2465,9 @@ object ChatController {
chatModel.chatsContext.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
}
withContext(Dispatchers.Main) {
chatModel.secondaryChatsContext.value?.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
if (r.reaction.chatReaction.chatItem.isReport) {
chatModel.secondaryChatsContext.value?.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
}
}
}
}
@ -2516,10 +2510,12 @@ object ChatController {
}
}
withContext(Dispatchers.Main) {
if (toChatItem == null) {
chatModel.secondaryChatsContext.value?.removeChatItem(rhId, cInfo, cItem)
} else {
chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, toChatItem.chatItem)
if (cItem.isReport) {
if (toChatItem == null) {
chatModel.secondaryChatsContext.value?.removeChatItem(rhId, cInfo, cItem)
} else {
chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, toChatItem.chatItem)
}
}
}
}
@ -2579,13 +2575,6 @@ object ChatController {
chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.MemberAcceptedByOther ->
if (active(r.user)) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member)
chatModel.chatsContext.updateGroup(rhId, r.groupInfo)
}
}
is CR.DeletedMemberUser -> // TODO update user member
if (active(r.user)) {
withContext(Dispatchers.Main) {
@ -2603,7 +2592,6 @@ object ChatController {
is CR.DeletedMember ->
if (active(r.user)) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, r.groupInfo)
chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.deletedMember)
if (r.withMessages) {
chatModel.chatsContext.removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo)
@ -2619,7 +2607,6 @@ object ChatController {
is CR.LeftMember ->
if (active(r.user)) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, r.groupInfo)
chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member)
}
withContext(Dispatchers.Main) {
@ -2668,16 +2655,6 @@ object ChatController {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, r.groupInfo)
}
if (
chatModel.chatId.value == r.groupInfo.id
&& ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)
&& chatModel.secondaryChatsContext.value?.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext
) {
withContext(Dispatchers.Main) {
chatModel.secondaryChatsContext.value = null
}
ModalManager.end.closeModals()
}
}
is CR.JoinedGroupMember ->
if (active(r.user)) {
@ -3039,11 +3016,11 @@ object ChatController {
if (activeUser(rh, user)) {
val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem
withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) }
withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem)
}
withContext(Dispatchers.Main) {
chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem)
if (cItem.isReport) {
chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem)
}
}
}
}
@ -3055,7 +3032,7 @@ object ChatController {
chatModel.users.addAll(users)
return
}
val cInfo = ChatInfo.Group(r.groupInfo, groupChatScope = null) // TODO [knocking] get scope from items?
val cInfo = ChatInfo.Group(r.groupInfo)
withContext(Dispatchers.Main) {
val chatsCtx = chatModel.chatsContext
r.chatItemIDs.forEach { itemId ->
@ -3110,11 +3087,11 @@ object ChatController {
if (!activeUser(rh, user)) {
notify()
} else {
val createdChat = withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem)
}
val createdChat = withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) }
withContext(Dispatchers.Main) {
chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem)
if (cItem.content.msgContent is MsgContent.MCReport) {
chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem)
}
}
if (createdChat) {
notify()
@ -3319,9 +3296,9 @@ sealed class CC {
class ApiGetSettings(val settings: AppSettings): CC()
class ApiGetChatTags(val userId: Long): CC()
class ApiGetChats(val userId: Long): CC()
class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long): CC()
class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val live: Boolean, val ttl: Int?, val composedMessages: List<ComposedMessage>): CC()
class ApiGetChat(val type: ChatType, val id: Long, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC()
class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List<ComposedMessage>): CC()
class ApiCreateChatTag(val tag: ChatTagData): CC()
class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List<Long>): CC()
class ApiDeleteChatTag(val tagId: Long): CC()
@ -3329,19 +3306,18 @@ sealed class CC {
class ApiReorderChatTags(val tagIds: List<Long>): CC()
class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List<ComposedMessage>): CC()
class ApiReportMessage(val groupId: Long, val chatItemId: Long, val reportReason: ReportReason, val reportText: String): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val updatedMessage: UpdatedMessage, val live: Boolean): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemIds: List<Long>, val mode: CIDeleteMode): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val updatedMessage: UpdatedMessage, val live: Boolean): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List<Long>, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List<Long>): CC()
class ApiArchiveReceivedReports(val groupId: Long): CC()
class ApiDeleteReceivedReports(val groupId: Long, val itemIds: List<Long>, val mode: CIDeleteMode): CC()
class ApiChatItemReaction(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
class ApiGetReactionMembers(val userId: Long, val groupId: Long, val itemId: Long, val reaction: MsgReaction): CC()
class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val chatItemIds: List<Long>): CC()
class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List<Long>, val ttl: Int?): CC()
class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val chatItemIds: List<Long>): CC()
class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemIds: List<Long>, val ttl: Int?): CC()
class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC()
class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC()
class ApiMembersRole(val groupId: Long, val memberIds: List<Long>, val memberRole: GroupMemberRole): CC()
class ApiBlockMembersForAll(val groupId: Long, val memberIds: List<Long>, val blocked: Boolean): CC()
class ApiRemoveMembers(val groupId: Long, val memberIds: List<Long>, val withMessages: Boolean): CC()
@ -3419,8 +3395,8 @@ sealed class CC {
class ApiGetNetworkStatuses(): CC()
class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC()
class ApiChatItemsRead(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemIds: List<Long>): CC()
class ApiChatRead(val type: ChatType, val id: Long): CC()
class ApiChatItemsRead(val type: ChatType, val id: Long, val itemIds: List<Long>): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long, val userApprovedRelays: Boolean, val encrypt: Boolean, val inline: Boolean?): CC()
class CancelFile(val fileId: Long): CC()
@ -3492,16 +3468,16 @@ sealed class CC {
} else {
" content=${contentTag.name.lowercase()}"
}
"/_get chat ${chatRef(type, id, scope)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
"/_get chat ${chatRef(type, id)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
}
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id, scope)} $itemId"
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId"
is ApiSendMessages -> {
val msgs = json.encodeToString(composedMessages)
val ttlStr = if (ttl != null) "$ttl" else "default"
"/_send ${chatRef(type, id, scope)} live=${onOff(live)} ttl=${ttlStr} json $msgs"
"/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json $msgs"
}
is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}"
is ApiSetChatTags -> "/_tags ${chatRef(type, id, scope = null)} ${tagIds.joinToString(",")}"
is ApiSetChatTags -> "/_tags ${chatRef(type, id)} ${tagIds.joinToString(",")}"
is ApiDeleteChatTag -> "/_delete tag $tagId"
is ApiUpdateChatTag -> "/_update tag $tagId ${json.encodeToString(tagData)}"
is ApiReorderChatTags -> "/_reorder tags ${tagIds.joinToString(",")}"
@ -3510,24 +3486,23 @@ sealed class CC {
"/_create *$noteFolderId json $msgs"
}
is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText"
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id, scope)} $itemId live=${onOff(live)} ${updatedMessage.cmdString}"
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id, scope)} ${itemIds.joinToString(",")} ${mode.deleteMode}"
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${updatedMessage.cmdString}"
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}"
is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}"
is ApiArchiveReceivedReports -> "/_archive reports #$groupId"
is ApiDeleteReceivedReports -> "/_delete reports #$groupId ${itemIds.joinToString(",")} ${mode.deleteMode}"
is ApiChatItemReaction -> "/_reaction ${chatRef(type, id, scope)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}"
is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}"
is ApiGetReactionMembers -> "/_reaction members $userId #$groupId $itemId ${json.encodeToString(reaction)}"
is ApiForwardChatItems -> {
val ttlStr = if (ttl != null) "$ttl" else "default"
"/_forward ${chatRef(toChatType, toChatId, toScope)} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}"
"/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} ${itemIds.joinToString(",")} ttl=${ttlStr}"
}
is ApiPlanForwardChatItems -> {
"/_forward plan ${chatRef(fromChatType, fromChatId, fromScope)} ${chatItemIds.joinToString(",")}"
"/_forward plan ${chatRef(fromChatType, fromChatId)} ${chatItemIds.joinToString(",")}"
}
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId"
is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}"
is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}"
is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}"
is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}"
@ -3551,13 +3526,13 @@ sealed class CC {
is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}"
is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}"
is APIGetChatItemTTL -> "/_ttl $userId"
is APISetChatTTL -> "/_ttl $userId ${chatRef(chatType, id, scope = null)} ${chatItemTTLStr(seconds)}"
is APISetChatTTL -> "/_ttl $userId ${chatRef(chatType, id)} ${chatItemTTLStr(seconds)}"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
is APIGetNetworkConfig -> "/network"
is APISetNetworkInfo -> "/_network info ${json.encodeToString(networkInfo)}"
is ReconnectServer -> "/reconnect $userId $server"
is ReconnectAllServers -> "/reconnect"
is APISetChatSettings -> "/_settings ${chatRef(type, id, scope = null)} ${json.encodeToString(chatSettings)}"
is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}"
is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}"
is APIContactInfo -> "/_info @$contactId"
is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId"
@ -3579,8 +3554,8 @@ sealed class CC {
is APIConnectPlan -> "/_connect plan $userId $connLink"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} ${connLink.connFullLink} ${connLink.connShortLink ?: ""}"
is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId"
is ApiDeleteChat -> "/_delete ${chatRef(type, id, scope = null)} ${chatDeleteMode.cmdString}"
is ApiClearChat -> "/_clear chat ${chatRef(type, id, scope = null)}"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)} ${chatDeleteMode.cmdString}"
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ApiListContacts -> "/_contacts $userId"
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
@ -3605,9 +3580,9 @@ sealed class CC {
is ApiEndCall -> "/_call end @${contact.apiId}"
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
is ApiGetNetworkStatuses -> "/_network_statuses"
is ApiChatRead -> "/_read chat ${chatRef(type, id, scope)}"
is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id, scope)} ${itemIds.joinToString(",")}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id, scope = null)} ${onOff(unreadChat)}"
is ApiChatRead -> "/_read chat ${chatRef(type, id)}"
is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id)} ${itemIds.joinToString(",")}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
is ReceiveFile ->
"/freceive $fileId" +
" approved_relays=${onOff(userApprovedRelays)}" +
@ -3691,7 +3666,6 @@ sealed class CC {
is ApiNewGroup -> "apiNewGroup"
is ApiAddMember -> "apiAddMember"
is ApiJoinGroup -> "apiJoinGroup"
is ApiAcceptMember -> "apiAcceptMember"
is ApiMembersRole -> "apiMembersRole"
is ApiBlockMembersForAll -> "apiBlockMembersForAll"
is ApiRemoveMembers -> "apiRemoveMembers"
@ -3828,13 +3802,7 @@ sealed class CC {
private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd)
companion object {
fun chatRef(chatType: ChatType, id: Long, scope: GroupChatScope?) = when (scope) {
null -> "${chatType.type}${id}"
is GroupChatScope.MemberSupport -> when (scope.groupMemberId_) {
null -> "${chatType.type}${id}(_support)"
else -> "${chatType.type}${id}(_support:${scope.groupMemberId_})"
}
}
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
}
}
@ -5796,7 +5764,6 @@ sealed class CR {
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactDeletedByContact") class ContactDeletedByContact(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("itemsReadForChat") class ItemsReadForChat(val user: UserRef, val chatInfo: ChatInfo): CR()
@Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR()
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR()
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR()
@ -5845,8 +5812,6 @@ sealed class CR {
@Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR()
@Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("memberAcceptedByOther") class MemberAcceptedByOther(val user: UserRef, val groupInfo: GroupInfo, val acceptingMember: GroupMember, val member: GroupMember): CR()
@Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR()
@Serializable @SerialName("membersRoleUser") class MembersRoleUser(val user: UserRef, val groupInfo: GroupInfo, val members: List<GroupMember>, val toRole: GroupMemberRole): CR()
@Serializable @SerialName("memberBlockedForAll") class MemberBlockedForAll(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val blocked: Boolean): CR()
@ -5977,7 +5942,6 @@ sealed class CR {
is ContactAlreadyExists -> "contactAlreadyExists"
is ContactDeleted -> "contactDeleted"
is ContactDeletedByContact -> "contactDeletedByContact"
is ItemsReadForChat -> "itemsReadForChat"
is ChatCleared -> "chatCleared"
is UserProfileNoChange -> "userProfileNoChange"
is UserProfileUpdated -> "userProfileUpdated"
@ -6023,8 +5987,6 @@ sealed class CR {
is ReceivedGroupInvitation -> "receivedGroupInvitation"
is GroupDeletedUser -> "groupDeletedUser"
is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting"
is MemberAccepted -> "memberAccepted"
is MemberAcceptedByOther -> "memberAcceptedByOther"
is MemberRole -> "memberRole"
is MembersRoleUser -> "membersRoleUser"
is MemberBlockedForAll -> "memberBlockedForAll"
@ -6148,7 +6110,6 @@ sealed class CR {
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
is ContactDeleted -> withUser(user, json.encodeToString(contact))
is ContactDeletedByContact -> withUser(user, json.encodeToString(contact))
is ItemsReadForChat -> withUser(user, json.encodeToString(chatInfo))
is ChatCleared -> withUser(user, json.encodeToString(chatInfo))
is UserProfileNoChange -> withUser(user, noDetails())
is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
@ -6194,8 +6155,6 @@ sealed class CR {
is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole")
is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo))
is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member")
is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
is MemberAcceptedByOther -> withUser(user, "groupInfo: $groupInfo\nacceptingMember: $acceptingMember\nmember: $member")
is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole")
is MembersRoleUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\ntoRole: $toRole")
is MemberBlockedForAll -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nblocked: $blocked")

View file

@ -12,11 +12,10 @@ import java.net.URI
expect fun PlatformTextField(
composeState: MutableState<ComposeState>,
sendMsgEnabled: Boolean,
disabledText: String?,
sendMsgButtonDisabled: Boolean,
textStyle: MutableState<TextStyle>,
showDeleteTextButton: MutableState<Boolean>,
userIsObserver: Boolean,
userIsPending: Boolean,
placeholder: String,
showVoiceButton: Boolean,
onMessageChange: (ComposeMessage) -> Unit,

View file

@ -99,13 +99,11 @@ fun TerminalLayout(
isDirectChat = false,
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
sendMsgEnabled = true,
userCantSendReason = null,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = false,
userIsObserver = false,
userIsPending = false,
userCanSend = true,
allowVoiceToContact = {},
placeholder = "",
sendMessage = { sendCommand() },

View file

@ -40,8 +40,9 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.group.ChatTTLOption
import chat.simplex.common.views.chat.group.ChatTTLSection
import chat.simplex.common.views.chatlist.updateChatSettings
import chat.simplex.common.views.database.*
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
@ -616,10 +617,7 @@ fun ChatInfoLayout(
}
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems)
SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer))
}
ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems)
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
val conn = contact.activeConn
@ -1386,7 +1384,7 @@ private fun setChatTTL(
private suspend fun afterSetChatTTL(chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatInfo: ChatInfo, progressIndicator: MutableState<Boolean>) {
try {
val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT)
val (chat, navInfo) = controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, scope = null, contentTag = null, pagination) ?: return
val (chat, navInfo) = controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, null, pagination) ?: return
if (chat.chatItems.isEmpty()) {
// replacing old chat with the same old chat but without items. Less intrusive way of clearing a preview
withContext(Dispatchers.Main) {

View file

@ -17,7 +17,7 @@ suspend fun apiLoadSingleMessage(
apiId: Long,
itemId: Long
): ChatItem? = coroutineScope {
val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), chatsCtx.contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null
val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null
chat.chatItems.firstOrNull()
}
@ -31,7 +31,7 @@ suspend fun apiLoadMessages(
openAroundItemId: Long? = null,
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
) = coroutineScope {
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), chatsCtx.contentTag, pagination, search) ?: return@coroutineScope
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.contentTag, pagination, search) ?: return@coroutineScope
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
/** When [openAroundItemId] is provided, chatId can be different too */
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null)
@ -54,7 +54,7 @@ suspend fun processLoadedChat(
when (pagination) {
is ChatPagination.Initial -> {
val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList()
if (chatsCtx.secondaryContextFilter == null) {
if (chatsCtx.contentTag == null) {
// update main chats, not content tagged
withContext(Dispatchers.Main) {
val oldChat = chatModel.chatsContext.getChat(chat.id)
@ -68,6 +68,7 @@ suspend fun processLoadedChat(
}
}
withContext(Dispatchers.Main) {
chatsCtx.chatItemStatuses.clear()
chatsCtx.chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.id
splits.value = newSplits

View file

@ -1,117 +0,0 @@
package chat.simplex.common.views.chat
import SectionItemView
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.chat.group.removeMemberDialog
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun ComposeContextPendingMemberActionsView(
rhId: Long?,
groupInfo: GroupInfo,
member: GroupMember
) {
Column(
Modifier
.height(60.dp)
.background(MaterialTheme.colors.surface)
) {
Divider()
Row(
Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Column(
Modifier
.fillMaxWidth()
.fillMaxHeight()
.weight(1F)
.clickable {
removeMemberDialog(rhId, groupInfo, member, chatModel, close = { ModalManager.end.closeModal() })
},
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(MR.strings.remove_pending_member_button), color = Color.Red)
}
Column(
Modifier
.fillMaxWidth()
.fillMaxHeight()
.weight(1F)
.clickable {
acceptMemberDialog(rhId, groupInfo, member, close = { ModalManager.end.closeModal() })
},
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(MR.strings.accept_pending_member_button), color = MaterialTheme.colors.primary)
}
}
}
}
fun acceptMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.accept_pending_member_alert_title),
text = generalGetString(MR.strings.accept_pending_member_alert_question),
buttons = {
Column {
// Accept as member
SectionItemView({
AlertManager.shared.hideAlert()
acceptMember(rhId, groupInfo, member, GroupMemberRole.Member, close)
}) {
Text(generalGetString(MR.strings.accept_pending_member_alert_confirmation_as_member), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
// Accept as observer
SectionItemView({
AlertManager.shared.hideAlert()
acceptMember(rhId, groupInfo, member, GroupMemberRole.Observer, close)
}) {
Text(generalGetString(MR.strings.accept_pending_member_alert_confirmation_as_observer), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
private fun acceptMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, role: GroupMemberRole, close: (() -> Unit)?) {
withBGApi {
val r = chatModel.controller.apiAcceptMember(rhId, groupInfo.groupId, member.groupMemberId, role)
if (r != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, r.second)
chatModel.chatsContext.updateGroup(rhId, r.first)
}
withContext(Dispatchers.Main) {
chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, r.second)
}
}
close?.invoke()
}
}

View file

@ -333,9 +333,7 @@ suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text:
@Composable
fun ComposeView(
rhId: Long?,
chatModel: ChatModel,
chatsCtx: ChatModel.ChatsContext,
chat: Chat,
composeState: MutableState<ComposeState>,
attachmentOption: MutableState<AttachmentOption?>,
@ -468,7 +466,6 @@ fun ComposeView(
rh = chat.remoteHostId,
type = cInfo.chatType,
id = cInfo.apiId,
scope = cInfo.groupChatScope(),
live = live,
ttl = ttl,
composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions))
@ -476,7 +473,7 @@ fun ComposeView(
if (!chatItems.isNullOrEmpty()) {
chatItems.forEach { aChatItem ->
withContext(Dispatchers.Main) {
chatsCtx.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem)
chatModel.chatsContext.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem)
}
}
return chatItems.first().chatItem
@ -501,17 +498,15 @@ fun ComposeView(
rh = rhId,
toChatType = chat.chatInfo.chatType,
toChatId = chat.chatInfo.apiId,
toScope = chat.chatInfo.groupChatScope(),
fromChatType = fromChatInfo.chatType,
fromChatId = fromChatInfo.apiId,
fromScope = fromChatInfo.groupChatScope(),
itemIds = forwardedItem.map { it.id },
ttl = ttl
)
withContext(Dispatchers.Main) {
chatItems?.forEach { chatItem ->
chatsCtx.addChatItem(rhId, chat.chatInfo, chatItem)
chatModel.chatsContext.addChatItem(rhId, chat.chatInfo, chatItem)
}
}
@ -568,21 +563,16 @@ fun ComposeView(
}
}
fun showReportsInSupportChatAlert() {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.report_sent_alert_title),
text = generalGetString(MR.strings.report_sent_alert_msg_view_in_support_chat),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.dont_show_again),
onDismiss = {
chatModel.controller.appPrefs.showReportsInSupportChatAlert.set(false)
},
)
}
suspend fun sendReport(reportReason: ReportReason, chatItemId: Long): List<ChatItem>? {
val cItems = chatModel.controller.apiReportMessage(chat.remoteHostId, chat.chatInfo.apiId, chatItemId, reportReason, msgText)
if (chatModel.controller.appPrefs.showReportsInSupportChatAlert.get()) showReportsInSupportChatAlert()
if (cItems != null) {
withContext(Dispatchers.Main) {
cItems.forEach { chatItem ->
chatModel.chatsContext.addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem)
}
}
}
return cItems?.map { it.chatItem }
}
@ -591,7 +581,7 @@ fun ComposeView(
val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc)
if (contact != null) {
withContext(Dispatchers.Main) {
chatsCtx.updateContact(chat.remoteHostId, contact)
chatModel.chatsContext.updateContact(chat.remoteHostId, contact)
}
}
}
@ -604,14 +594,13 @@ fun ComposeView(
rh = chat.remoteHostId,
type = cInfo.chatType,
id = cInfo.apiId,
scope = cInfo.groupChatScope(),
itemId = ei.meta.itemId,
updatedMessage = UpdatedMessage(updateMsgContent(oldMsgContent), cs.memberMentions),
live = live
)
if (updatedItem != null) {
withContext(Dispatchers.Main) {
chatsCtx.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem)
chatModel.chatsContext.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem)
}
}
return updatedItem?.chatItem
@ -902,7 +891,7 @@ fun ComposeView(
fun editPrevMessage() {
if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return
val lastEditable = chatsCtx.chatItems.value.findLast { it.meta.editable }
val lastEditable = chatModel.chatsContext.chatItems.value.findLast { it.meta.editable }
if (lastEditable != null) {
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews)
}
@ -1010,28 +999,11 @@ fun ComposeView(
chatModel.sharedContent.value = null
}
val userCanSend = rememberUpdatedState(chat.chatInfo.userCanSend)
val sendMsgEnabled = rememberUpdatedState(chat.chatInfo.sendMsgEnabled)
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
val userIsPending = rememberUpdatedState(chat.userIsPending)
val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason)
val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv)
Column {
if (
chat.chatInfo is ChatInfo.Group
&& chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext
&& chatsCtx.secondaryContextFilter.groupScopeInfo is GroupChatScopeInfo.MemberSupport
&& chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_ != null
&& chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_.memberPending
&& composeState.value.contextItem == ComposeContextItem.NoContextItem
&& composeState.value.preview == ComposePreview.NoPreview
) {
ComposeContextPendingMemberActionsView(
rhId = rhId,
groupInfo = chat.chatInfo.groupInfo,
member = chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_
)
}
if (nextSendGrpInv.value) {
ComposeContextInvitingContactMemberView()
}
@ -1083,7 +1055,6 @@ fun ComposeView(
val attachmentEnabled =
!composeState.value.attachmentDisabled
&& sendMsgEnabled.value
&& userCanSend.value
&& !isGroupAndProhibitedFiles
&& !nextSendGrpInv.value
IconButton(
@ -1129,8 +1100,8 @@ fun ComposeView(
}
}
LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) {
if (!chat.chatInfo.userCanSend) {
LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) {
if (!chat.chatInfo.sendMsgEnabled) {
clearCurrentDraft()
clearState()
}
@ -1186,14 +1157,12 @@ fun ComposeView(
chat.chatInfo is ChatInfo.Direct,
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
sendMsgEnabled = sendMsgEnabled.value,
userCantSendReason = userCantSendReason.value,
sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited),
nextSendGrpInv = nextSendGrpInv.value,
needToAllowVoiceToContact,
allowedVoiceByPrefs,
allowVoiceToContact = ::allowVoiceToContact,
userIsObserver = if (chatsCtx.secondaryContextFilter == null) userIsObserver.value else false,
userIsPending = if (chatsCtx.secondaryContextFilter == null) userIsPending.value else false,
userCanSend = userCanSend.value,
sendButtonColor = sendButtonColor,
timedMessageAllowed = timedMessageAllowed,
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,

View file

@ -77,7 +77,7 @@ fun SelectedItemsButtonsToolbar(
val forwardCountProhibited = remember { mutableStateOf(false) }
Box {
// It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty
ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() })
ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() })
Row(
Modifier
.matchParentSize()
@ -101,21 +101,21 @@ fun SelectedItemsButtonsToolbar(
)
}
IconButton({ moderateItems() }, Modifier.alpha(if (canModerate.value) 1f else 0f), enabled = moderateEnabled.value && !deleteCountProhibited.value && chatsCtx.secondaryContextFilter == null) {
IconButton({ moderateItems() }, Modifier.alpha(if (canModerate.value) 1f else 0f), enabled = moderateEnabled.value && !deleteCountProhibited.value) {
Icon(
painterResource(MR.images.ic_flag),
null,
Modifier.size(22.dp),
tint = if (!moderateEnabled.value || deleteCountProhibited.value || chatsCtx.secondaryContextFilter != null) MaterialTheme.colors.secondary else MaterialTheme.colors.error
tint = if (!moderateEnabled.value || deleteCountProhibited.value) MaterialTheme.colors.secondary else MaterialTheme.colors.error
)
}
IconButton({ forwardItems() }, enabled = forwardEnabled.value && !forwardCountProhibited.value && chatsCtx.secondaryContextFilter == null) {
IconButton({ forwardItems() }, enabled = forwardEnabled.value && !forwardCountProhibited.value) {
Icon(
painterResource(MR.images.ic_forward),
null,
Modifier.size(22.dp),
tint = if (!forwardEnabled.value || forwardCountProhibited.value || chatsCtx.secondaryContextFilter != null) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
tint = if (!forwardEnabled.value || forwardCountProhibited.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}

View file

@ -39,13 +39,11 @@ fun SendMsgView(
isDirectChat: Boolean,
liveMessageAlertShown: SharedPreference<Boolean>,
sendMsgEnabled: Boolean,
userCantSendReason: Pair<String, String?>?,
sendButtonEnabled: Boolean,
nextSendGrpInv: Boolean,
needToAllowVoiceToContact: Boolean,
allowedVoiceByPrefs: Boolean,
userIsObserver: Boolean,
userIsPending: Boolean,
userCanSend: Boolean,
sendButtonColor: Color = MaterialTheme.colors.primary,
allowVoiceToContact: () -> Unit,
timedMessageAllowed: Boolean = false,
@ -81,15 +79,14 @@ fun SendMsgView(
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
cs.endLiveDisabled ||
!sendButtonEnabled
val clicksOnTextFieldDisabled = !sendMsgEnabled || cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress
val clicksOnTextFieldDisabled = !sendMsgEnabled || cs.preview is ComposePreview.VoicePreview || cs.inProgress
PlatformTextField(
composeState,
sendMsgEnabled,
disabledText = userCantSendReason?.first,
sendMsgButtonDisabled,
textStyle,
showDeleteTextButton,
userIsObserver,
userIsPending,
if (clicksOnTextFieldDisabled) "" else placeholder,
showVoiceButton,
onMessageChange,
@ -102,16 +99,23 @@ fun SendMsgView(
}
}
if (clicksOnTextFieldDisabled) {
Box(
Modifier
.matchParentSize()
.clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.observer_cant_send_message_title),
text = generalGetString(MR.strings.observer_cant_send_message_desc)
)
})
)
if (userCantSendReason != null) {
Box(
Modifier
.matchParentSize()
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.cant_send_message_alert_title),
text = userCantSendReason.second
)
})
)
} else {
Box(
Modifier
.matchParentSize()
)
}
}
if (showDeleteTextButton.value) {
DeleteTextButton(composeState)
@ -135,11 +139,11 @@ fun SendMsgView(
Row(verticalAlignment = Alignment.CenterVertically) {
val stopRecOnNextClick = remember { mutableStateOf(false) }
when {
needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> {
DisallowedVoiceButton(userCanSend) {
needToAllowVoiceToContact || !allowedVoiceByPrefs -> {
DisallowedVoiceButton {
if (needToAllowVoiceToContact) {
showNeedToAllowVoiceAlert(allowVoiceToContact)
} else if (!allowedVoiceByPrefs) {
} else {
showDisabledVoiceAlert(isDirectChat)
}
}
@ -155,7 +159,7 @@ fun SendMsgView(
&& cs.contextItem is ComposeContextItem.NoContextItem
) {
Spacer(Modifier.width(12.dp))
StartLiveMessageButton(userCanSend) {
StartLiveMessageButton {
if (composeState.value.preview is ComposePreview.NoPreview) {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
}
@ -343,8 +347,8 @@ private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNex
}
@Composable
private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp), enabled = enabled) {
private fun DisallowedVoiceButton(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
painterResource(MR.images.ic_keyboard_voice),
stringResource(MR.strings.icon_descr_record_voice_message),
@ -460,14 +464,13 @@ private fun SendMsgButton(
}
@Composable
private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
private fun StartLiveMessageButton(onClick: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
val ripple = remember { ripple(bounded = false, radius = 24.dp) }
Box(
modifier = Modifier.requiredSize(36.dp)
.clickable(
onClick = onClick,
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = ripple
@ -477,7 +480,7 @@ private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
Icon(
BoltFilled,
stringResource(MR.strings.icon_descr_send_message),
tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
@ -576,13 +579,11 @@ fun PreviewSendMsgView() {
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
sendMsgEnabled = true,
userCantSendReason = null,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
userIsObserver = false,
userIsPending = false,
userCanSend = true,
allowVoiceToContact = {},
timedMessageAllowed = false,
placeholder = "",
@ -613,13 +614,11 @@ fun PreviewSendMsgViewEditing() {
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
sendMsgEnabled = true,
userCantSendReason = null,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
userIsObserver = false,
userIsPending = false,
userCanSend = true,
allowVoiceToContact = {},
timedMessageAllowed = false,
placeholder = "",
@ -650,13 +649,11 @@ fun PreviewSendMsgViewInProgress() {
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
sendMsgEnabled = true,
userCantSendReason = null,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
userIsObserver = false,
userIsPending = false,
userCanSend = true,
allowVoiceToContact = {},
timedMessageAllowed = false,
placeholder = "",

View file

@ -55,16 +55,6 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
GroupPreferencesView(chatModel, rhId, groupInfo.id, close)
}
},
openMemberAdmission = {
ModalManager.end.showCustomModal { close ->
MemberAdmissionView(
chat.simplex.common.platform.chatModel,
rhId,
groupInfo.id,
close
)
}
},
inviteMembers = {
allowModifyMembers = false
withLongRunningApi(slow = 120_000) {
@ -120,7 +110,6 @@ fun AddGroupMembersLayout(
allowModifyMembers: Boolean,
searchText: MutableState<TextFieldValue>,
openPreferences: () -> Unit,
openMemberAdmission: () -> Unit,
inviteMembers: () -> Unit,
clearSelection: () -> Unit,
addContact: (Long) -> Unit,
@ -155,7 +144,7 @@ fun AddGroupMembersLayout(
horizontalArrangement = Arrangement.Center
) {
ChatInfoToolbarTitle(
ChatInfo.Group(groupInfo, groupChatScope = null),
ChatInfo.Group(groupInfo),
imageSize = 60.dp,
iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight
)
@ -176,9 +165,6 @@ fun AddGroupMembersLayout(
} else {
SectionView {
if (creatingGroup) {
SectionItemView(openMemberAdmission) {
Text(stringResource(MR.strings.set_member_admission))
}
SectionItemView(openPreferences) {
Text(stringResource(MR.strings.set_group_preferences))
}
@ -390,7 +376,6 @@ fun PreviewAddGroupMembersLayout() {
allowModifyMembers = true,
searchText = remember { mutableStateOf(TextFieldValue("")) },
openPreferences = {},
openMemberAdmission = {},
inviteMembers = {},
clearSelection = {},
addContact = {},

View file

@ -74,9 +74,6 @@ fun ModalData.GroupChatInfoView(
val chatItemTTL = remember(groupInfo.id) { mutableStateOf(if (groupInfo.chatItemTTL != null) ChatItemTTL.fromSeconds(groupInfo.chatItemTTL) else null) }
val deletingItems = rememberSaveable(groupInfo.id) { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val activeSortedMembers = remember { chatModel.groupMembers }.value
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedByDescending { it.memberRole }
GroupChatInfoLayout(
chat,
@ -98,7 +95,9 @@ fun ModalData.GroupChatInfoView(
setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems)
},
activeSortedMembers = activeSortedMembers,
activeSortedMembers = remember { chatModel.groupMembers }.value
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedByDescending { it.memberRole },
developerTools,
onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) },
groupLink,
@ -127,7 +126,7 @@ fun ModalData.GroupChatInfoView(
}
ModalManager.end.showModalCloseable(true) { closeCurrent ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, closeCurrent) {
GroupMemberInfoView(rhId, groupInfo, mem, stats, code, chatModel, closeCurrent) {
closeCurrent()
close()
}
@ -141,17 +140,6 @@ fun ModalData.GroupChatInfoView(
addOrEditWelcomeMessage = {
ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, rhId, groupInfo, close) }
},
openMemberSupport = {
ModalManager.end.showCustomModal { close ->
MemberSupportView(
rhId,
chat,
groupInfo,
scrollToItemId,
close
)
}
},
openPreferences = {
ModalManager.end.showCustomModal { close ->
GroupPreferencesView(
@ -329,40 +317,6 @@ fun AddGroupMembersButton(
)
}
@Composable
fun UserSupportChatButton(
chat: Chat,
groupInfo: GroupInfo,
scrollToItemId: MutableState<Long?>
) {
val scope = rememberCoroutineScope()
SettingsActionItemWithContent(
painterResource(if (chat.supportUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag),
stringResource(MR.strings.button_support_chat),
click = {
val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null)
val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo)
scope.launch {
showMemberSupportChatView(
chatModel.chatId,
scrollToItemId = scrollToItemId,
supportChatInfo,
scopeInfo
)
}
},
iconColor = (if (chat.supportUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary),
) {
if (chat.supportUnreadCount > 0) {
UnreadBadge(
text = unreadCountStr(chat.supportUnreadCount),
backgroundColor = MaterialTheme.colors.primary
)
}
}
}
@Composable
fun ModalData.GroupChatInfoLayout(
chat: Chat,
@ -383,7 +337,6 @@ fun ModalData.GroupChatInfoLayout(
showMemberInfo: (GroupMember) -> Unit,
editGroupProfile: () -> Unit,
addOrEditWelcomeMessage: () -> Unit,
openMemberSupport: () -> Unit,
openPreferences: () -> Unit,
deleteGroup: () -> Unit,
clearChat: () -> Unit,
@ -469,40 +422,6 @@ fun ModalData.GroupChatInfoLayout(
SectionSpacer()
var anyTopSectionRowShow = false
SectionView {
if (groupInfo.canAddMembers && groupInfo.businessChat == null) {
anyTopSectionRowShow = true
if (groupLink == null) {
CreateGroupLinkButton(manageGroupLink)
} else {
GroupLinkButton(manageGroupLink)
}
}
if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
anyTopSectionRowShow = true
MemberSupportButton(chat, openMemberSupport)
}
if (groupInfo.canModerate) {
anyTopSectionRowShow = true
GroupReportsButton(chat) {
scope.launch {
showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo)
}
}
}
if (
groupInfo.membership.memberActive &&
(groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null)
) {
anyTopSectionRowShow = true
UserSupportChatButton(chat, groupInfo, scrollToItemId)
}
}
if (anyTopSectionRowShow) {
SectionDividerSpaced(maxBottomPadding = false)
}
SectionView {
if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) {
EditGroupProfileButton(editGroupProfile)
@ -512,17 +431,19 @@ fun ModalData.GroupChatInfoLayout(
}
val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences
GroupPreferencesButton(prefsTitleId, openPreferences)
}
val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs
SectionTextFooter(stringResource(footerId))
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
SectionView {
if (groupInfo.canModerate) {
GroupReportsButton {
scope.launch {
showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo)
}
}
}
if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
} else {
SendReceiptsOptionDisabled()
}
WallpaperButton {
ModalManager.end.showModal {
val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } }
@ -532,13 +453,23 @@ fun ModalData.GroupChatInfoLayout(
}
}
}
ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems)
SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer))
}
val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs
SectionTextFooter(stringResource(footerId))
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems)
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true)
SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) {
if (groupInfo.canAddMembers) {
if (groupInfo.businessChat == null) {
if (groupLink == null) {
CreateGroupLinkButton(manageGroupLink)
} else {
GroupLinkButton(manageGroupLink)
}
}
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
val addMembersTitleId = when (groupInfo.businessChat?.chatType) {
@ -550,7 +481,7 @@ fun ModalData.GroupChatInfoLayout(
}
if (activeSortedMembers.size > 8) {
SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) {
MemberListSearchRowView(searchText)
SearchRowView(searchText)
}
}
SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
@ -596,7 +527,7 @@ fun ModalData.GroupChatInfoLayout(
val titleId = if (groupInfo.businessChat == null) MR.strings.button_delete_group else MR.strings.button_delete_chat
DeleteGroupButton(titleId, deleteGroup)
}
if (groupInfo.membership.memberCurrentOrPending) {
if (groupInfo.membership.memberCurrent) {
val titleId = if (groupInfo.businessChat == null) MR.strings.button_leave_group else MR.strings.button_leave_chat
LeaveGroupButton(titleId, leaveGroup)
}
@ -700,14 +631,17 @@ private fun SelectedItemsCounterToolbarSetter(
}
@Composable
fun ChatTTLOption(chatItemTTL: State<ChatItemTTL?>, setChatItemTTL: (ChatItemTTL?) -> Unit, deletingItems: State<Boolean>) {
fun ChatTTLSection(chatItemTTL: State<ChatItemTTL?>, setChatItemTTL: (ChatItemTTL?) -> Unit, deletingItems: State<Boolean>) {
Box {
TtlOptions(
chatItemTTL,
enabled = remember { derivedStateOf { !deletingItems.value } },
onSelected = setChatItemTTL,
default = chatModel.chatItemTTL
)
SectionView {
TtlOptions(
chatItemTTL,
enabled = remember { derivedStateOf { !deletingItems.value } },
onSelected = setChatItemTTL,
default = chatModel.chatItemTTL
)
SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer))
}
if (deletingItems.value) {
Box(Modifier.matchParentSize()) {
ProgressIndicator()
@ -749,23 +683,6 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) {
}
}
@Composable
private fun MemberSupportButton(chat: Chat, onClick: () -> Unit) {
SettingsActionItemWithContent(
painterResource(if (chat.supportUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag),
stringResource(MR.strings.member_support),
click = onClick,
iconColor = (if (chat.supportUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary)
) {
if (chat.supportUnreadCount > 0) {
UnreadBadge(
text = unreadCountStr(chat.supportUnreadCount),
backgroundColor = MaterialTheme.colors.primary
)
}
}
}
@Composable
private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) {
SettingsActionItem(
@ -776,20 +693,12 @@ private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit)
}
@Composable
private fun GroupReportsButton(chat: Chat, onClick: () -> Unit) {
SettingsActionItemWithContent(
painterResource(if (chat.chatStats.reportsCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag),
private fun GroupReportsButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_flag),
stringResource(MR.strings.group_reports_member_reports),
click = onClick,
iconColor = (if (chat.chatStats.reportsCount > 0) Color.Red else MaterialTheme.colors.secondary)
) {
if (chat.chatStats.reportsCount > 0) {
UnreadBadge(
text = unreadCountStr(chat.chatStats.reportsCount),
backgroundColor = Color.Red
)
}
}
click = onClick
)
}
@Composable
@ -911,7 +820,7 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr
}
@Composable
fun MemberVerifiedShield() {
private fun MemberVerifiedShield() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary)
}
@ -1032,7 +941,7 @@ private fun DeleteGroupButton(titleId: StringResource, onClick: () -> Unit) {
}
@Composable
fun MemberListSearchRowView(
private fun SearchRowView(
searchText: MutableState<TextFieldValue> = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
) {
Box(Modifier.width(36.dp), contentAlignment = Alignment.Center) {
@ -1055,18 +964,16 @@ private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel)
fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, onSuccess: () -> Unit = {}) {
withBGApi {
val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds)
if (r != null) {
val (updatedGroupInfo, updatedMembers) = r
val updatedMembers = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds)
if (updatedMembers != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo)
updatedMembers.forEach { updatedMember ->
chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, updatedMember)
chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
withContext(Dispatchers.Main) {
updatedMembers.forEach { updatedMember ->
chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, updatedGroupInfo, updatedMember)
chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
onSuccess()
@ -1109,18 +1016,7 @@ fun PreviewGroupChatInfoLayout() {
selectedItems = remember { mutableStateOf(null) },
appBar = remember { mutableStateOf(null) },
scrollToItemId = remember { mutableStateOf(null) },
addMembers = {},
showMemberInfo = {},
editGroupProfile = {},
addOrEditWelcomeMessage = {},
openMemberSupport = {},
openPreferences = {},
deleteGroup = {},
clearChat = {},
leaveGroup = {},
manageGroupLink = {},
onSearchClicked = {},
deletingItems = remember { mutableStateOf(true) }
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, deletingItems = remember { mutableStateOf(true) }
)
}
}

View file

@ -45,7 +45,6 @@ fun GroupMemberInfoView(
rhId: Long?,
groupInfo: GroupInfo,
member: GroupMember,
scrollToItemId: MutableState<Long?>,
connectionStats: ConnectionStats?,
connectionCode: String?,
chatModel: ChatModel,
@ -80,7 +79,6 @@ fun GroupMemberInfoView(
rhId = rhId,
groupInfo,
member,
scrollToItemId,
connStats,
newRole,
developerTools,
@ -246,18 +244,16 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c
confirmText = generalGetString(MR.strings.remove_member_confirmation),
onConfirm = {
withBGApi {
val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId))
if (r != null) {
val (updatedGroupInfo, removedMembers) = r
val removedMembers = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId))
if (removedMembers != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo)
removedMembers.forEach { removedMember ->
chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, removedMember)
chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, removedMember)
}
}
withContext(Dispatchers.Main) {
removedMembers.forEach { removedMember ->
chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, updatedGroupInfo, removedMember)
chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, removedMember)
}
}
}
@ -273,7 +269,6 @@ fun GroupMemberInfoLayout(
rhId: Long?,
groupInfo: GroupInfo,
member: GroupMember,
scrollToItemId: MutableState<Long?>,
connStats: MutableState<ConnectionStats?>,
newRole: MutableState<GroupMemberRole>,
developerTools: Boolean,
@ -304,29 +299,6 @@ fun GroupMemberInfoLayout(
}
}
@Composable
fun SupportChatButton() {
val scope = rememberCoroutineScope()
SettingsActionItem(
painterResource(MR.images.ic_flag),
stringResource(MR.strings.button_support_chat_member),
click = {
val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = member)
val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo)
scope.launch {
showMemberSupportChatView(
chatModel.chatId,
scrollToItemId = scrollToItemId,
supportChatInfo,
scopeInfo
)
}
},
iconColor = MaterialTheme.colors.secondary,
)
}
@Composable
fun ModeratorDestructiveSection() {
val canBlockForAll = member.canBlockForAll(groupInfo)
@ -441,12 +413,6 @@ fun GroupMemberInfoLayout(
if (member.memberActive) {
SectionView {
if (
groupInfo.membership.memberRole >= GroupMemberRole.Moderator &&
(member.memberRole < GroupMemberRole.Moderator || member.supportChat != null)
) {
SupportChatButton()
}
if (connectionCode != null) {
VerifyCodeButton(member.verified, verifyClicked)
}
@ -912,7 +878,6 @@ fun PreviewGroupMemberInfoLayout() {
rhId = null,
groupInfo = GroupInfo.sampleData,
member = GroupMember.sampleData,
scrollToItemId = remember { mutableStateOf(null) },
connStats = remember { mutableStateOf(null) },
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,

View file

@ -44,7 +44,7 @@ fun SelectedItemsMembersToolbar(
) {
// It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty
Box(Modifier.alpha(0f)) {
ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() })
ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() })
}
Row(
Modifier

View file

@ -35,7 +35,6 @@ private val MAX_PICKER_HEIGHT = (PICKER_ROW_SIZE * 4) + (MEMBER_ROW_AVATAR_SIZE
@Composable
fun GroupMentions(
chatsCtx: ChatModel.ChatsContext,
rhId: Long?,
composeState: MutableState<ComposeState>,
composeViewFocusRequester: FocusRequester?,
@ -49,31 +48,12 @@ fun GroupMentions(
val mentionName = remember { mutableStateOf("") }
val mentionRange = remember { mutableStateOf<TextRange?>(null) }
val mentionMemberId = remember { mutableStateOf<String?>(null) }
fun contextMemberFilter(member: GroupMember): Boolean =
when (chatsCtx.secondaryContextFilter) {
null -> true
is SecondaryContextFilter.GroupChatScopeContext ->
when (chatsCtx.secondaryContextFilter.groupScopeInfo) {
is GroupChatScopeInfo.MemberSupport -> {
val scopeMember = chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_
if (scopeMember != null) {
member.memberRole >= GroupMemberRole.Moderator || member.groupMemberId == scopeMember.groupMemberId
} else {
member.memberRole >= GroupMemberRole.Moderator
}
}
}
is SecondaryContextFilter.MsgContentTagContext -> false
}
val filteredMembers = remember {
derivedStateOf {
val members = chatModel.groupMembers.value
.filter {
val status = it.memberStatus
status != GroupMemberStatus.MemLeft && status != GroupMemberStatus.MemRemoved && status != GroupMemberStatus.MemInvited
&& contextMemberFilter(it)
}
.sortedByDescending { it.memberRole }

View file

@ -17,9 +17,7 @@ import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon
import chat.simplex.common.model.*
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.*
private val featureRoles: List<Pair<GroupMemberRole?, String>> = listOf(
@ -73,16 +71,6 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () ->
preferences = currentPreferences
},
savePrefs = ::savePrefs,
openMemberAdmission = {
ModalManager.end.showCustomModal { close ->
MemberAdmissionView(
chatModel,
rhId,
chatId,
close
)
}
}
)
}
}
@ -95,15 +83,10 @@ private fun GroupPreferencesLayout(
applyPrefs: (FullGroupPreferences) -> Unit,
reset: () -> Unit,
savePrefs: () -> Unit,
openMemberAdmission: () -> Unit,
) {
ColumnWithScrollBar {
val titleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences
AppBarTitle(stringResource(titleId))
if (groupInfo.businessChat == null) {
MemberAdmissionButton(openMemberAdmission)
SectionDividerSpaced(maxBottomPadding = false)
}
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
val onTTLUpdated = { ttl: Int? ->
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl)))
@ -173,15 +156,6 @@ private fun GroupPreferencesLayout(
}
}
@Composable
private fun MemberAdmissionButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_toggle_on),
stringResource(MR.strings.member_admission),
click = onClick
)
}
@Composable
private fun FeatureSection(
feature: GroupFeature,

View file

@ -15,15 +15,7 @@ import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.*
@Composable
private fun GroupReportsView(
reportsChatsCtx: ChatModel.ChatsContext,
staleChatId: State<String?>,
scrollToItemId: MutableState<Long?>,
close: () -> Unit
) {
KeyChangeEffect(chatModel.chatId.value) {
close()
}
private fun GroupReportsView(reportsChatsCtx: ChatModel.ChatsContext, staleChatId: State<String?>, scrollToItemId: MutableState<Long?>) {
ChatView(reportsChatsCtx, staleChatId, scrollToItemId, onComposed = {})
}
@ -61,7 +53,7 @@ fun GroupReportsAppBar(
}
@Composable
fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) {
private fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) {
LaunchedEffect(Unit) {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
@ -77,13 +69,13 @@ fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) {
}
suspend fun showGroupReportsView(staleChatId: State<String?>, scrollToItemId: MutableState<Long?>, chatInfo: ChatInfo) {
val reportsChatsCtx = ChatModel.ChatsContext(secondaryContextFilter = SecondaryContextFilter.MsgContentTagContext(MsgContentTag.Report))
val reportsChatsCtx = ChatModel.ChatsContext(contentTag = MsgContentTag.Report)
openChat(secondaryChatsCtx = reportsChatsCtx, chatModel.remoteHostId(), chatInfo)
ModalManager.end.showCustomModal(true, id = ModalViewId.SECONDARY_CHAT) { close ->
ModalView({}, showAppBar = false) {
val chatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chatModel.chatId.value }?.chatInfo } }.value
if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) {
GroupReportsView(reportsChatsCtx, staleChatId, scrollToItemId, close)
GroupReportsView(reportsChatsCtx, staleChatId, scrollToItemId)
} else {
LaunchedEffect(Unit) {
close()

View file

@ -1,152 +0,0 @@
package chat.simplex.common.views.chat.group
import InfoRow
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.chatModel
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun MemberAdmissionView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit) {
val groupInfo = remember { derivedStateOf {
val ch = m.getChat(chatId)
val g = (ch?.chatInfo as? ChatInfo.Group)?.groupInfo
if (g == null || ch.remoteHostId != rhId) null else g
}}
val gInfo = groupInfo.value ?: return
var admission by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.groupProfile.memberAdmission) }
var currentAdmission by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(admission) }
fun saveAdmission(afterSave: () -> Unit = {}) {
withBGApi {
val gp = gInfo.groupProfile.copy(memberAdmission = admission)
val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp)
if (g != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, g)
currentAdmission = admission
}
}
afterSave()
}
}
ModalView(
close = {
if (admission == currentAdmission) close()
else showUnsavedChangesAlert({ saveAdmission(close) }, close)
},
) {
MemberAdmissionLayout(
admission,
currentAdmission,
gInfo,
applyAdmission = { admsn ->
admission = admsn
},
reset = {
admission = currentAdmission
},
saveAdmission = ::saveAdmission,
)
}
}
@Composable
private fun MemberAdmissionLayout(
admission: GroupMemberAdmission?,
currentAdmission: GroupMemberAdmission?,
groupInfo: GroupInfo,
applyAdmission: (GroupMemberAdmission) -> Unit,
reset: () -> Unit,
saveAdmission: () -> Unit,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.member_admission))
val review = remember(admission) { mutableStateOf(admission?.review) }
AdmissionSection(MR.strings.admission_stage_review, MR.strings.admission_stage_review_descr, review, groupInfo) { criteria ->
if (admission != null) {
applyAdmission(admission.copy(review = criteria))
} else {
applyAdmission(GroupMemberAdmission(review = criteria))
}
}
if (groupInfo.isOwner) {
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
ResetSaveButtons(
reset = reset,
save = saveAdmission,
disabled = admission == currentAdmission
)
}
SectionBottomSpacer()
}
}
private val memberCriterias: List<Pair<MemberCriteria?, String>> = listOf(
null to generalGetString(MR.strings.member_criteria_off),
MemberCriteria.All to generalGetString(MR.strings.member_criteria_all)
)
@Composable
private fun AdmissionSection(
admissionStageStrId: StringResource,
admissionStageDescrStrId: StringResource,
memberCriteria: State<MemberCriteria?>,
groupInfo: GroupInfo,
onSelected: (MemberCriteria?) -> Unit
) {
SectionView {
if (groupInfo.isOwner) {
ExposedDropDownSettingRow(
generalGetString(admissionStageStrId),
memberCriterias,
memberCriteria,
onSelected = { value ->
onSelected(value)
}
)
} else {
InfoRow(
stringResource(admissionStageStrId),
memberCriteria.value?.text ?: generalGetString(MR.strings.member_criteria_off)
)
}
}
SectionTextFooter(stringResource( admissionStageDescrStrId))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
SectionItemView(reset, disabled = disabled) {
Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
}
SectionItemView(save, disabled = disabled) {
Text(stringResource(MR.strings.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
}
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.save_admission_question),
confirmText = generalGetString(MR.strings.save_and_notify_group_members),
dismissText = generalGetString(MR.strings.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}

View file

@ -1,145 +0,0 @@
package chat.simplex.common.views.chat.group
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
private fun MemberSupportChatView(
chatInfo: ChatInfo,
memberSupportChatsCtx: ChatModel.ChatsContext,
staleChatId: State<String?>,
scrollToItemId: MutableState<Long?>
) {
KeyChangeEffect(chatModel.chatId.value) {
ModalManager.end.closeModals()
}
if (appPlatform.isAndroid) {
DisposableEffect(Unit) {
onDispose {
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatInfo.id }
if (
memberSupportChatsCtx.isUserSupportChat
&& chat?.chatInfo?.groupInfo_?.membership?.memberPending == true
) {
withBGApi {
chatModel.chatId.value = null
}
}
}
}
}
ChatView(memberSupportChatsCtx, staleChatId, scrollToItemId, onComposed = {})
}
@Composable
fun MemberSupportChatAppBar(
chatsCtx: ChatModel.ChatsContext,
scopeMember_: GroupMember?,
close: () -> Unit,
onSearchValueChanged: (String) -> Unit
) {
val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state }
val showSearch = rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch.value) {
close()
} else {
onSearchValueChanged("")
showSearch.value = false
}
}
BackHandler(onBack = onBackClicked)
if (scopeMember_ != null) {
DefaultAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { MemberSupportChatToolbarTitle(scopeMember_) },
onTitleClick = null,
onTop = !oneHandUI.value,
showSearch = showSearch.value,
onSearchValueChanged = onSearchValueChanged,
buttons = {
IconButton({ showSearch.value = true }) {
Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
}
}
)
} else {
DefaultAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
fixedTitleText = stringResource(MR.strings.support_chat),
onTitleClick = null,
onTop = !oneHandUI.value,
showSearch = showSearch.value,
onSearchValueChanged = onSearchValueChanged,
buttons = {
IconButton({ showSearch.value = true }) {
Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
}
}
)
}
ItemsReload(chatsCtx)
}
@Composable
fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
MemberProfileImage(size = imageSize * fontSizeSqrtMultiplier, member, iconColor)
Column(
Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.verified) {
MemberVerifiedShield()
}
Text(
member.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
if (member.fullName != "" && member.fullName != member.displayName && member.localAlias.isEmpty()) {
Text(
member.fullName,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
}
}
}
suspend fun showMemberSupportChatView(staleChatId: State<String?>, scrollToItemId: MutableState<Long?>, chatInfo: ChatInfo, scopeInfo: GroupChatScopeInfo) {
val memberSupportChatsCtx = ChatModel.ChatsContext(secondaryContextFilter = SecondaryContextFilter.GroupChatScopeContext(scopeInfo))
openChat(secondaryChatsCtx = memberSupportChatsCtx, chatModel.remoteHostId(), chatInfo)
ModalManager.end.showCustomModal(true, id = ModalViewId.SECONDARY_CHAT) { close ->
ModalView({}, showAppBar = false) {
if (chatInfo is ChatInfo.Group && chatInfo.groupChatScope != null) {
MemberSupportChatView(chatInfo, memberSupportChatsCtx, staleChatId, scrollToItemId)
} else {
LaunchedEffect(Unit) {
close()
}
}
}
}
}

View file

@ -1,282 +0,0 @@
package chat.simplex.common.views.chat.group
import SectionBottomSpacer
import SectionItemView
import SectionItemViewLongClickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.launch
@Composable
fun ModalData.MemberSupportView(
rhId: Long?,
chat: Chat,
groupInfo: GroupInfo,
scrollToItemId: MutableState<Long?>,
close: () -> Unit
) {
KeyChangeEffect(chatModel.chatId.value) {
ModalManager.end.closeModals()
}
LaunchedEffect(Unit) {
setGroupMembers(rhId, groupInfo, chatModel)
}
ModalView(
close = close,
endButtons = { RefreshMembersButton(rhId, groupInfo) }
) {
MemberSupportViewLayout(
chat,
groupInfo,
scrollToItemId
)
}
}
@Composable
fun RefreshMembersButton(
rhId: Long?,
groupInfo: GroupInfo
) {
IconButton(
onClick = {
withBGApi {
setGroupMembers(rhId, groupInfo, chatModel)
}
}
) {
Icon(
painterResource(MR.images.ic_refresh),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
}
@Composable
private fun ModalData.MemberSupportViewLayout(
chat: Chat,
groupInfo: GroupInfo,
scrollToItemId: MutableState<Long?>
) {
val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state }
val scope = rememberCoroutineScope()
val membersWithChats = remember { chatModel.groupMembers }.value
.filter { it.supportChat != null && it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedWith(
compareByDescending<GroupMember> { it.memberPending }
.thenByDescending { (it.supportChat?.mentions ?: 0) > 0 }
.thenByDescending { (it.supportChat?.memberAttention ?: 0) > 0 }
.thenByDescending { (it.supportChat?.unread ?: 0) > 0 }
.thenByDescending { it.supportChat?.chatTs }
)
val searchText = remember { stateGetOrPut("searchText") { TextFieldValue() } }
val filteredmembersWithChats = remember(membersWithChats) {
derivedStateOf {
val s = searchText.value.text.trim().lowercase()
if (s.isEmpty()) membersWithChats else membersWithChats.filter { m -> m.anyNameContains(s) }
}
}
LazyColumnWithScrollBar(
contentPadding =
PaddingValues(
top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp else topPaddingToContent(false)
)
) {
item {
AppBarTitle(stringResource(MR.strings.member_support))
}
if (membersWithChats.isEmpty()) {
item {
Box(Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.no_support_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
}
}
} else {
item {
SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) {
MemberListSearchRowView(searchText)
}
}
items(filteredmembersWithChats.value, key = { it.groupMemberId }) { member ->
Divider()
val showMenu = remember { mutableStateOf(false) }
SectionItemViewLongClickable(
click = {
val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = member)
val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo)
scope.launch {
showMemberSupportChatView(
chatModel.chatId,
scrollToItemId = scrollToItemId,
supportChatInfo,
scopeInfo
)
}
},
longClick = { showMenu.value = true },
minHeight = 54.dp,
padding = PaddingValues(horizontal = DEFAULT_PADDING)
) {
Box(contentAlignment = Alignment.CenterStart) {
DropDownMenuForSupportChat(chat.remoteHostId, member, groupInfo, showMenu)
SupportChatRow(member)
}
}
}
item {
Divider()
SectionBottomSpacer()
}
}
}
}
@Composable
fun SupportChatRow(member: GroupMember) {
fun memberStatus(): String {
return if (member.activeConn?.connDisabled == true) {
generalGetString(MR.strings.member_info_member_disabled)
} else if (member.activeConn?.connInactive == true) {
generalGetString(MR.strings.member_info_member_inactive)
} else if (member.memberPending) {
member.memberStatus.text
} else {
member.memberRole.text
}
}
@Composable
fun SupportChatUnreadIndicator(supportChat: GroupSupportChat) {
Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.sp.toDp())) {
if (supportChat.unread > 0 || supportChat.mentions > 0 || supportChat.memberAttention > 0) {
val unreadBadgeColor = when {
supportChat.mentions > 0 || supportChat.memberAttention > 0 -> MaterialTheme.colors.primaryVariant
else -> MaterialTheme.colors.secondary
}
if (supportChat.mentions == 1 && supportChat.unread == 1) {
Box(modifier = Modifier.offset(y = 2.sp.toDp()).size(15.sp.toDp()).background(unreadBadgeColor, shape = CircleShape), contentAlignment = Alignment.Center) {
Icon(
painterResource(MR.images.ic_alternate_email),
contentDescription = generalGetString(MR.strings.notifications),
tint = Color.White,
modifier = Modifier.size(9.sp.toDp())
)
}
} else {
if (supportChat.mentions > 0 && supportChat.unread > 1) {
Icon(
painterResource(MR.images.ic_alternate_email),
contentDescription = generalGetString(MR.strings.notifications),
tint = unreadBadgeColor,
modifier = Modifier.size(12.sp.toDp()).offset(y = 2.sp.toDp())
)
}
UnreadBadge(
text = unreadCountStr(supportChat.unread),
backgroundColor = unreadBadgeColor,
yOffset = 2.dp
)
}
}
}
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
Modifier.weight(1f).padding(top = MEMBER_ROW_VERTICAL_PADDING, end = DEFAULT_PADDING, bottom = MEMBER_ROW_VERTICAL_PADDING),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member)
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.verified) {
MemberVerifiedShield()
}
Text(
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
}
Text(
memberStatus(),
color = MaterialTheme.colors.secondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Row {
if (member.memberPending) {
Icon(
painterResource(MR.images.ic_flag_filled),
contentDescription = null,
Modifier.padding(end = 3.dp).size(16.dp),
tint = MaterialTheme.colors.primaryVariant
)
}
if (member.supportChat != null) {
SupportChatUnreadIndicator(member.supportChat)
}
}
}
}
@Composable
private fun DropDownMenuForSupportChat(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) {
DefaultDropdownMenu(showMenu) {
if (member.memberPending) {
ItemAction(stringResource(MR.strings.accept_pending_member_button), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, onClick = {
acceptMemberDialog(rhId, groupInfo, member)
showMenu.value = false
})
}
ItemAction(stringResource(MR.strings.remove_pending_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
removeMemberDialog(rhId, groupInfo, member, chatModel)
showMenu.value = false
})
// TODO [knocking] mark read, mark unread
// ItemAction(stringResource(MR.strings.mark_unread), painterResource(MR.images.ic_mark_chat_unread), onClick = {
// showMenu.value = false
// })
}
}

View file

@ -86,7 +86,6 @@ fun ChatItemView(
joinGroup: (Long, () -> Unit) -> Unit,
acceptCall: (Contact) -> Unit,
scrollToItem: (Long) -> Unit,
scrollToItemId: MutableState<Long?>,
scrollToQuotedItemFromItem: (Long) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
openDirectChat: (Long) -> Unit,
@ -272,7 +271,6 @@ fun ChatItemView(
}
}
// improvement could be to track "forwarded from" scope and open it
@Composable
fun GoToItemButton(alignStart: Boolean, parentActivated: State<Boolean>) {
val chatTypeApiIdMsgId = cItem.meta.itemForwarded?.chatTypeApiIdMsgId
@ -326,7 +324,7 @@ fun ChatItemView(
) {
@Composable
fun framedItemView() {
FramedItemView(chatsCtx, cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToItemId, scrollToQuotedItemFromItem)
FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToQuotedItemFromItem)
}
fun deleteMessageQuestionText(): String {
@ -637,15 +635,6 @@ fun ChatItemView(
CIEventView(eventItemViewText(reversedChatItems))
}
@Composable fun PendingReviewEventItemView() {
Text(
buildAnnotatedString {
withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text) }
},
Modifier.padding(horizontal = 6.dp, vertical = 6.dp)
)
}
@Composable
fun DeletedItem() {
MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
@ -722,16 +711,12 @@ fun ChatItemView(
is CIContent.RcvGroupEventContent -> {
when (c.rcvGroupEvent) {
is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat)
is RcvGroupEvent.NewMemberPendingReview -> PendingReviewEventItemView()
else -> EventItemView()
}
MsgContentItemDropdownMenu()
}
is CIContent.SndGroupEventContent -> {
when (c.sndGroupEvent) {
is SndGroupEvent.UserPendingReview -> PendingReviewEventItemView()
else -> EventItemView()
}
EventItemView()
MsgContentItemDropdownMenu()
}
is CIContent.RcvConnEventContent -> {
@ -1437,7 +1422,7 @@ fun PreviewChatItemView(
chatItem: ChatItem = ChatItem.getSampleData(1, CIDirection.DirectSnd(), Clock.System.now(), "hello")
) {
ChatItemView(
chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null),
chatsCtx = ChatModel.ChatsContext(contentTag = null),
rhId = null,
ChatInfo.Direct.sampleData,
chatItem,
@ -1459,7 +1444,6 @@ fun PreviewChatItemView(
joinGroup = { _, _ -> },
acceptCall = { _ -> },
scrollToItem = {},
scrollToItemId = remember { mutableStateOf(null) },
scrollToQuotedItemFromItem = {},
acceptFeature = { _, _, _ -> },
openDirectChat = { _ -> },
@ -1488,7 +1472,7 @@ fun PreviewChatItemView(
fun PreviewChatItemViewDeletedContent() {
SimpleXTheme {
ChatItemView(
chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null),
chatsCtx = ChatModel.ChatsContext(contentTag = null),
rhId = null,
ChatInfo.Direct.sampleData,
ChatItem.getDeletedContentSampleData(),
@ -1510,7 +1494,6 @@ fun PreviewChatItemViewDeletedContent() {
joinGroup = { _, _ -> },
acceptCall = { _ -> },
scrollToItem = {},
scrollToItemId = remember { mutableStateOf(null) },
scrollToQuotedItemFromItem = {},
acceptFeature = { _, _, _ -> },
openDirectChat = { _ -> },

View file

@ -27,7 +27,6 @@ import kotlin.math.ceil
@Composable
fun FramedItemView(
chatsCtx: ChatModel.ChatsContext,
chatInfo: ChatInfo,
ci: ChatItem,
uriHandler: UriHandler? = null,
@ -40,7 +39,6 @@ fun FramedItemView(
receiveFile: (Long) -> Unit,
onLinkLongClick: (link: String) -> Unit = {},
scrollToItem: (Long) -> Unit = {},
scrollToItemId: MutableState<Long?>,
scrollToQuotedItemFromItem: (Long) -> Unit = {},
) {
val sent = ci.chatDir.sent
@ -255,11 +253,7 @@ fun FramedItemView(
onLongClick = { showMenu.value = true },
onClick = {
if (ci.quotedItem.itemId != null) {
if (ci.isReport && chatsCtx.secondaryContextFilter != null) {
scrollToItemId.value = ci.quotedItem.itemId
} else {
scrollToItem(ci.quotedItem.itemId)
}
scrollToItem(ci.quotedItem.itemId)
} else {
scrollToQuotedItemFromItem(ci.id)
}

View file

@ -236,6 +236,7 @@ suspend fun openChat(
suspend fun openLoadedChat(chat: Chat) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.chatItemStatuses.clear()
chatModel.chatsContext.chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id
chatModel.chatsContext.chatState.clear()
@ -304,7 +305,7 @@ fun GroupMenuItems(
}
}
GroupMemberStatus.MemAccepted -> {
if (groupInfo.membership.memberCurrentOrPending) {
if (groupInfo.membership.memberCurrent) {
LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu)
}
if (groupInfo.canDelete) {
@ -326,7 +327,7 @@ fun GroupMenuItems(
}
}
ClearChatAction(chat, showMenu)
if (groupInfo.membership.memberCurrentOrPending) {
if (groupInfo.membership.memberCurrent) {
LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu)
}
if (groupInfo.canDelete) {
@ -614,8 +615,7 @@ fun markChatRead(c: Chat) {
chatModel.controller.apiChatRead(
chat.remoteHostId,
chat.chatInfo.chatType,
chat.chatInfo.apiId,
chat.chatInfo.groupChatScope()
chat.chatInfo.apiId
)
chat = chatModel.getChat(chat.id) ?: return@withApi
}
@ -651,7 +651,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
if (success) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true)))
chatModel.chatsContext.updateChatTagReadInPrimaryContext(chat, wasUnread)
chatModel.chatsContext.updateChatTagReadNoContentTag(chat, wasUnread)
}
}
}
@ -886,7 +886,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch
ChatInfo.Direct(contact.copy(chatSettings = chatSettings))
}
is ChatInfo.Group -> with(chatInfo) {
ChatInfo.Group(groupInfo.copy(chatSettings = chatSettings), groupChatScope = null)
ChatInfo.Group(groupInfo.copy(chatSettings = chatSettings))
}
else -> null
}
@ -914,7 +914,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch
val updatedChat = chatModel.getChat(chatInfo.id)
if (updatedChat != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateChatTagReadInPrimaryContext(updatedChat, wasUnread)
chatModel.chatsContext.updateChatTagReadNoContentTag(updatedChat, wasUnread)
}
}
val current = currentState?.value

View file

@ -241,8 +241,6 @@ fun ChatPreviewView(
GroupMemberStatus.MemRejected -> Text(stringResource(MR.strings.group_preview_rejected))
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(currentUserProfileDisplayName, cInfo.groupInfo))
GroupMemberStatus.MemAccepted -> Text(stringResource(MR.strings.group_connection_pending), color = MaterialTheme.colors.secondary)
GroupMemberStatus.MemPendingReview, GroupMemberStatus.MemPendingApproval ->
Text(stringResource(MR.strings.reviewed_by_admins), color = MaterialTheme.colors.secondary)
else -> {}
}
else -> {}
@ -365,11 +363,7 @@ fun ChatPreviewView(
if (progressByTimeout) {
progressView()
} else if (chat.chatStats.reportsCount > 0) {
FlagIcon(color = MaterialTheme.colors.error)
} else if (chat.supportUnreadCount > 0) {
FlagIcon(color = MaterialTheme.colors.primary)
} else if (chat.chatInfo.groupInfo_?.membership?.memberPending == true) {
FlagIcon(color = MaterialTheme.colors.secondary)
GroupReportsIcon()
} else {
IncognitoIcon(chat.chatInfo.incognito)
}
@ -471,10 +465,17 @@ fun ChatPreviewView(
)
}
} else {
UnreadBadge(
text = if (n > 0) unreadCountStr(n) else "",
backgroundColor = if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant,
yOffset = 3.dp
Text(
if (n > 0) unreadCountStr(n) else "",
color = Color.White,
fontSize = 10.sp,
style = TextStyle(textAlign = TextAlign.Center),
modifier = Modifier
.offset(y = 3.sp.toDp())
.background(if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 2.sp.toDp())
.padding(vertical = 1.sp.toDp())
)
}
}
@ -547,11 +548,11 @@ fun IncognitoIcon(incognito: Boolean) {
}
@Composable
fun FlagIcon(color: Color) {
fun GroupReportsIcon() {
Icon(
painterResource(MR.images.ic_flag),
contentDescription = null,
tint = color,
tint = MaterialTheme.colors.error,
modifier = Modifier
.size(21.sp.toDp())
.offset(x = 2.sp.toDp())
@ -566,26 +567,6 @@ private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, g
stringResource(MR.strings.group_preview_you_are_invited)
}
@Composable
fun UnreadBadge(
text: String,
backgroundColor: Color,
yOffset: Dp? = null
) {
Text(
text,
color = Color.White,
fontSize = 10.sp,
style = TextStyle(textAlign = TextAlign.Center),
modifier = Modifier
.offset(y = yOffset ?: 0.dp)
.background(backgroundColor, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 2.sp.toDp())
.padding(vertical = 1.sp.toDp())
)
}
@Composable
fun unreadCountStr(n: Int): String {
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(MR.strings.thousand_abbreviation)

View file

@ -634,7 +634,7 @@ fun HostDisconnectButton(onClick: (() -> Unit)?) {
}
@Composable
fun BoxScope.userUnreadBadge(unreadCount: Int, userMuted: Boolean, hasPadding: Boolean) {
fun BoxScope.unreadBadge(unreadCount: Int, userMuted: Boolean, hasPadding: Boolean) {
Text(
if (unreadCount > 0) unreadCountStr(unreadCount) else "",
color = Color.White,

View file

@ -45,6 +45,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId = rhId, groupInfo)
chatModel.chatsContext.chatItems.clearAndNotify()
chatModel.chatsContext.chatItemStatuses.clear()
chatModel.chatId.value = groupInfo.id
}
setGroupMembers(rhId, groupInfo, chatModel)

View file

@ -21,6 +21,7 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.BuildConfigCommon
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
@ -127,7 +128,9 @@ fun SettingsLayout(
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_support)) {
ContributeItem(uriHandler)
if (!BuildConfigCommon.ANDROID_BUNDLE) {
ContributeItem(uriHandler)
}
RateAppItem(uriHandler)
StarOnGithubItem(uriHandler)
}

View file

@ -158,7 +158,6 @@
<string name="error_loading_details">Error loading details</string>
<string name="error_adding_members">Error adding member(s)</string>
<string name="error_joining_group">Error joining group</string>
<string name="error_accepting_member">Error accepting member</string>
<string name="cannot_receive_file">Cannot receive file</string>
<string name="sender_cancelled_file_transfer">Sender cancelled file transfer.</string>
<string name="file_not_approved_title">Unknown servers!</string>
@ -465,10 +464,6 @@
<string name="group_reports_active_one">1 report</string>
<string name="group_reports_active">%d reports</string>
<string name="group_reports_member_reports">Member reports</string>
<string name="group_new_support_messages">%d messages</string>
<string name="group_new_support_chats">%d chats with members</string>
<string name="group_new_support_chat_one">1 chat with a member</string>
<string name="group_new_support_chats_short">%d chat(s)</string>
<!-- ShareListView.kt -->
<string name="share_message">Share message…</string>
@ -492,9 +487,6 @@
<string name="image_decoding_exception_desc">The image cannot be decoded. Please, try a different image or contact developers.</string>
<string name="video_decoding_exception_desc">The video cannot be decoded. Please, try a different video or contact developers.</string>
<string name="you_are_observer">you are observer</string>
<string name="reviewed_by_admins">reviewed by admins</string>
<string name="observer_cant_send_message_title">You can\'t send messages!</string>
<string name="observer_cant_send_message_desc">Please contact group admin.</string>
<string name="files_and_media_prohibited">Files and media prohibited!</string>
<string name="only_owners_can_enable_files_and_media">Only group owners can enable files and media.</string>
<string name="compose_send_direct_message_to_connect">Send direct message to connect</string>
@ -513,8 +505,19 @@
<string name="report_compose_reason_header_community">Report violation: only group moderators will see it.</string>
<string name="report_compose_reason_header_illegal">Report content: only group moderators will see it.</string>
<string name="report_compose_reason_header_other">Report other: only group moderators will see it.</string>
<string name="report_sent_alert_title">Report sent to moderators</string>
<string name="report_sent_alert_msg_view_in_support_chat">You can view your reports in Chat with admins.</string>
<string name="cant_send_message_alert_title">You can\'t send messages!</string>
<string name="cant_send_message_contact_not_ready">contact not ready</string>
<string name="cant_send_message_contact_deleted">contact deleted</string>
<string name="cant_send_message_contact_not_synchronized">not synchronized</string>
<string name="cant_send_message_contact_disabled">contact disabled</string>
<string name="observer_cant_send_message_title">you are observer</string>
<string name="observer_cant_send_message_desc">Please contact group admin.</string>
<string name="cant_send_message_rejected">request to join rejected</string>
<string name="cant_send_message_group_deleted">group is deleted</string>
<string name="cant_send_message_mem_removed">removed from group</string>
<string name="cant_send_message_you_left">you left</string>
<string name="cant_send_message_generic">can\'t send messages</string>
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Image</string>
@ -1060,7 +1063,6 @@
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile.</string>
<string name="edit_image">Edit image</string>
<string name="delete_image">Delete image</string>
<string name="save_admission_question">Save admission settings?</string>
<string name="save_preferences_question">Save preferences?</string>
<string name="save_and_notify_contact">Save and notify contact</string>
<string name="save_and_notify_contacts">Save and notify contacts</string>
@ -1580,8 +1582,6 @@
<!-- Group event chat items -->
<string name="rcv_group_event_member_added">invited %1$s</string>
<string name="rcv_group_event_member_connected">connected</string>
<string name="rcv_group_event_member_accepted">accepted %1$s</string>
<string name="rcv_group_event_user_accepted">accepted you</string>
<string name="rcv_group_event_member_left">left</string>
<string name="rcv_group_event_changed_member_role">changed role of %s to %s</string>
<string name="rcv_group_event_member_blocked">blocked %s</string>
@ -1593,7 +1593,6 @@
<string name="rcv_group_event_updated_group_profile">updated group profile</string>
<string name="rcv_group_event_invited_via_your_group_link">invited via your group link</string>
<string name="rcv_group_event_member_created_contact">connected directly</string>
<string name="rcv_group_event_new_member_pending_review">New member wants to join the group.</string>
<string name="snd_group_event_changed_member_role">you changed role of %s to %s</string>
<string name="snd_group_event_changed_role_for_yourself">you changed role for yourself to %s</string>
<string name="snd_group_event_member_blocked">you blocked %s</string>
@ -1601,8 +1600,6 @@
<string name="snd_group_event_member_deleted">you removed %1$s</string>
<string name="snd_group_event_user_left">you left</string>
<string name="snd_group_event_group_profile_updated">group profile updated</string>
<string name="snd_group_event_member_accepted">you accepted this member</string>
<string name="snd_group_event_user_pending_review">Please wait for group moderators to review your request to join the group.</string>
<string name="rcv_group_event_1_member_connected">%s connected</string>
<string name="rcv_group_event_2_members_connected">%s and %s connected</string>
@ -1662,8 +1659,6 @@
<string name="group_member_status_invited">invited</string>
<string name="group_member_status_pending_approval">pending approval</string>
<string name="group_member_status_pending_approval_short">pending</string>
<string name="group_member_status_pending_review">pending review</string>
<string name="group_member_status_pending_review_short">review</string>
<string name="group_member_status_introduced">connecting (introduced)</string>
<string name="group_member_status_intro_invitation">connecting (introduction invitation)</string>
<string name="group_member_status_accepted">connecting (accepted)</string>
@ -1734,7 +1729,6 @@
<string name="send_receipts_disabled_alert_title">Receipts are disabled</string>
<string name="send_receipts_disabled_alert_msg">This group has over %1$d members, delivery receipts are not sent.</string>
<string name="action_button_add_members">Invite</string>
<string name="button_support_chat">Chat with admins</string>
<!-- Chat / Chat item info -->
<string name="section_title_for_console">FOR CONSOLE</string>
@ -1771,7 +1765,7 @@
<string name="button_remove_member_question">Remove member?</string>
<string name="button_remove_members_question">Remove members?</string>
<string name="button_remove_member">Remove member</string>
<string name="button_support_chat_member">Chat with member</string>
<string name="button_send_direct_message">Send direct message</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Member will be removed from group - this cannot be undone!</string>
<string name="members_will_be_removed_from_group_cannot_be_undone">Members will be removed from group - this cannot be undone!</string>
@ -2048,7 +2042,6 @@
<string name="contact_preferences">Contact preferences</string>
<string name="group_preferences">Group preferences</string>
<string name="set_group_preferences">Set group preferences</string>
<string name="set_member_admission">Set member admission</string>
<string name="your_preferences">Your preferences</string>
<string name="timed_messages">Disappearing messages</string>
<string name="direct_messages">Direct messages</string>
@ -2166,26 +2159,6 @@
<string name="feature_roles_owners">owners</string>
<string name="feature_enabled_for">Enabled for</string>
<!-- MemberAdmission.kt -->
<string name="member_admission">Member admission</string>
<string name="admission_stage_review">Review members</string>
<string name="admission_stage_review_descr">Review members before admitting ("knocking").</string>
<string name="member_criteria_off">off</string>
<string name="member_criteria_all">all</string>
<!-- MemberSupportView.kt -->
<string name="member_support">Chats with members</string>
<string name="no_support_chats">No chats with members</string>
<!-- MemberSupportChatView.kt -->
<string name="support_chat">Chat with admins</string>
<string name="remove_pending_member_button">Remove</string>
<string name="accept_pending_member_button">Accept</string>
<string name="accept_pending_member_alert_title">Accept member</string>
<string name="accept_pending_member_alert_question">Member will join the group, accept member?</string>
<string name="accept_pending_member_alert_confirmation_as_member">Accept as member</string>
<string name="accept_pending_member_alert_confirmation_as_observer">Accept as observer</string>
<!-- WhatsNewView.kt -->
<string name="whats_new">What\'s new</string>
<string name="new_in_version">New in %s</string>

View file

@ -44,11 +44,10 @@ import kotlin.text.substring
actual fun PlatformTextField(
composeState: MutableState<ComposeState>,
sendMsgEnabled: Boolean,
disabledText: String?,
sendMsgButtonDisabled: Boolean,
textStyle: MutableState<TextStyle>,
showDeleteTextButton: MutableState<Boolean>,
userIsObserver: Boolean,
userIsPending: Boolean,
placeholder: String,
showVoiceButton: Boolean,
onMessageChange: (ComposeMessage) -> Unit,
@ -204,18 +203,16 @@ actual fun PlatformTextField(
)
showDeleteTextButton.value = cs.message.text.split("\n").size >= 4 && !cs.inProgress
if (composeState.value.preview is ComposePreview.VoicePreview) {
ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding)
} else if (userIsPending) {
ComposeOverlay(MR.strings.reviewed_by_admins, textStyle, padding)
} else if (userIsObserver) {
ComposeOverlay(MR.strings.you_are_observer, textStyle, padding)
ComposeOverlay(generalGetString(MR.strings.voice_message_send_text), textStyle, padding)
} else if (disabledText != null) {
ComposeOverlay(disabledText, textStyle, padding)
}
}
@Composable
private fun ComposeOverlay(textId: StringResource, textStyle: MutableState<TextStyle>, padding: PaddingValues) {
private fun ComposeOverlay(text: String, textStyle: MutableState<TextStyle>, padding: PaddingValues) {
Text(
generalGetString(textId),
text,
Modifier.padding(padding),
color = MaterialTheme.colors.secondary,
style = textStyle.value.copy(fontStyle = FontStyle.Italic)

View file

@ -63,7 +63,7 @@ actual fun UserPickerUsersSection(
ProfileImage(size = 55.dp, image = user.profile.image, color = iconColor)
if (u.unreadCount > 0 && !user.activeUser) {
userUnreadBadge(u.unreadCount, user.showNtfs, true)
unreadBadge(u.unreadCount, user.showNtfs, true)
}
}

View file

@ -24,11 +24,13 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.4-beta.0
android.version_code=290
android.version_name=6.3.6
android.version_code=295
desktop.version_name=6.4-beta.0
desktop.version_code=102
android.bundle=false
desktop.version_name=6.3.6
desktop.version_code=106
kotlin.version=1.9.23
gradle.plugin.version=8.2.0

View file

@ -79,7 +79,7 @@ crDirectoryEvent_ = \case
CEvtJoinedGroupMember {groupInfo, member = m}
| pending m -> Just $ DEPendingMember groupInfo m
| otherwise -> Nothing
CEvtNewChatItems {chatItems = AChatItem _ _ (GroupChat g _scopeInfo) ci : _} -> case ci of
CEvtNewChatItems {chatItems = AChatItem _ _ (GroupChat g) ci : _} -> case ci of
ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent (MCText t)} | pending m -> Just $ DEPendingMemberMsg g m (chatItemId' ci) t
_ -> Nothing
CEvtMemberRole {groupInfo, member, toRole}

View file

@ -167,7 +167,7 @@ acceptMemberHook
when (useMemberFilter img $ rejectNames a) checkName
pure $
if
| useMemberFilter img (passCaptcha a) -> (GAPendingApproval, GRMember)
| useMemberFilter img (passCaptcha a) -> (GAPending, GRMember)
| useMemberFilter img (makeObserver a) -> (GAAccepted, GRObserver)
| otherwise -> (GAAccepted, memberRole)
where
@ -494,7 +494,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
[] -> textMsg
"" : _ -> textMsg
img : _ -> MCImage "" $ ImageData img
sendCaptcha mc = sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(quotedId, MCText noticeText), (Nothing, mc)]
sendCaptcha mc = sendComposedMessages_ cc (SRGroup groupId $ Just gmId) [(quotedId, MCText noticeText), (Nothing, mc)]
gmId = groupMemberId' m
approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO ()
@ -503,11 +503,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
let role = if useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_
gmId = groupMemberId' m
sendChatCmd cc (APIAcceptMember groupId gmId role) >>= \case
Right CRMemberAccepted {member} -> do
Right CRJoinedGroupMember {} -> do
atomically $ TM.delete gmId $ pendingCaptchas env
if memberStatus member == GSMemPendingReview
then logInfo $ "Member " <> viewName displayName <> " accepted and pending review, group " <> tshow groupId <> ":" <> viewGroupName g
else logInfo $ "Member " <> viewName displayName <> " accepted, group " <> tshow groupId <> ":" <> viewGroupName g
logInfo $ "Member " <> viewName displayName <> " accepted, group " <> tshow groupId <> ":" <> viewGroupName g
r -> logError $ "unexpected accept member response: " <> tshow r
dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO ()
@ -518,7 +516,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
Just PendingCaptcha {captchaText, sentAt, attempts}
| ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1
| matchCaptchaStr captchaText msgText -> do
sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just $ groupMemberId' m)) [(Just ciId, MCText $ "Correct, you joined the group " <> n)]
sendComposedMessages_ cc (SRGroup groupId $ Just $ groupMemberId' m) [(Just ciId, MCText $ "Correct, you joined the group " <> n)]
approvePendingMember a g m
| attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts
| otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts
@ -528,7 +526,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
a = groupMemberAcceptance g
rejectPendingMember rjctNotice = do
let gmId = groupMemberId' m
sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice]
sendComposedMessages cc (SRGroup groupId $ Just gmId) [MCText rjctNotice]
sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case
Right (CRUserDeletedMembers _ _ (_ : _) _) -> do
atomically $ TM.delete gmId $ pendingCaptchas env

View file

@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: f44ea0a6d8eec8abf4af177ebeb91629f7d89165
tag: 3d62a383d5dcae6529d6d866233857182bcb4d47
source-repository-package
type: git

View file

@ -1,38 +0,0 @@
# Group knocking
## Problem
In v6.3 release we added an option to "manually" approve members before introducing to group, based on decision made via `acceptMember` chat hook. Currently it's not supported in UI clients, and only used for directory service bot captcha challenge.
The goal of next improvement is to let:
- admins screen members before admitting to group, while not limiting communication with joining member to a single admin (and also removing the need for admin to be a highly available host of the group link);
- and group owners set up other arbitrary automated challenges or entry rules, while still being able to advertise groups in directory service.
## Solution
Group link host (further host), knowing group requires admin approval, would initially only introduce member pending approval to admins. Admins can connect with member for screening, meanwhile host would be forwarding messages as usual between connecting members. As a result of screening, pending member can either be removed, or approved by admins.
Upon acceptance, for further member connections to not depend on availability of admins, host should not only forward acceptance message, but also introduce remaining members to now accepted joining member. Respectively, admins' clients should not introduce members for approved members who are not their invitees.
For group owners to be able to set up alternative automated challenges, these are some possible alternatives:
- We could add a new role `Approver`, so that instead of adding all \[possibly human\] admins, host would initially introduce only approvers.
- It could be an orthogonal to role member setting (would require protocol extension).
- List of "approver" member IDs could be communicated to host client.
### Implementation details draft
Host needs to have knowledge whether to automatically accept, or only introduce admins/approvers.
```sql
ALTER TABLE group_profiles ADD COLUMN approval TEXT; -- comma separated member IDs; null - automatic introduction
-- or
ALTER TABLE group_profiles ADD COLUMN approval INTEGER; -- if based on `Approver` role
```
Alternatively, a different extension of protocol could be done in order to communicate group approval rule from owner to host outside of group profile (special messages).
Admins/approvers need to have separate conversation per pending member, requires adding scope to chat items.
Host to have specific processing of forwarded `XGrpLinkAcpt` - continue introduction of remaining members.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

BIN
images/whonix-logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

View file

@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."f44ea0a6d8eec8abf4af177ebeb91629f7d89165" = "1biq1kq33v7hnacbhllry9n5c6dmh9dyqnz8hc5abgsv1z38qb1a";
"https://github.com/simplex-chat/simplexmq.git"."3d62a383d5dcae6529d6d866233857182bcb4d47" = "133xm8jkim7agd6drwm3lbx1z7v8nf4l3asrm46ag3n2q201yfxc";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";

View file

@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.4.0.1
version: 6.3.6.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@ -105,7 +105,6 @@ library
Simplex.Chat.Store.Postgres.Migrations.M20241220_initial
Simplex.Chat.Store.Postgres.Migrations.M20250402_short_links
Simplex.Chat.Store.Postgres.Migrations.M20250512_member_admission
Simplex.Chat.Store.Postgres.Migrations.M20250513_group_scope
else
exposed-modules:
Simplex.Chat.Archive
@ -237,7 +236,6 @@ library
Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes
Simplex.Chat.Store.SQLite.Migrations.M20250402_short_links
Simplex.Chat.Store.SQLite.Migrations.M20250512_member_admission
Simplex.Chat.Store.SQLite.Migrations.M20250513_group_scope
other-modules:
Paths_simplex_chat
hs-source-dirs:

View file

@ -95,7 +95,7 @@ deleteMessage cc ct chatItemId = do
r -> putStrLn $ "unexpected delete message response: " <> show r
contactRef :: Contact -> ChatRef
contactRef ct = ChatRef CTDirect (contactId' ct) Nothing
contactRef = ChatRef CTDirect . contactId'
printLog :: ChatController -> ChatLogLevel -> String -> IO ()
printLog cc level s

View file

@ -365,8 +365,6 @@ data ChatCommand
| APIRemoveMembers {groupId :: GroupId, groupMemberIds :: Set GroupMemberId, withMessages :: Bool}
| APILeaveGroup GroupId
| APIListMembers GroupId
-- | APIDeleteGroupConversations GroupId (NonEmpty GroupConversationId)
-- | APIArchiveGroupConversations GroupId (NonEmpty GroupConversationId)
| APIUpdateGroupProfile GroupId GroupProfile
| APICreateGroupLink GroupId GroupMemberRole CreateShortLink
| APIGroupLinkMemberRole GroupId GroupMemberRole
@ -490,7 +488,6 @@ data ChatCommand
| DeleteGroup GroupName
| ClearGroup GroupName
| ListMembers GroupName
| ListMemberSupportChats GroupName
| APIListGroups UserId (Maybe ContactId) (Maybe String)
| ListGroups (Maybe ContactName) (Maybe String)
| UpdateGroupNames GroupName GroupProfile
@ -526,7 +523,6 @@ data ChatCommand
| SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed)
| SetGroupFeature AGroupFeatureNoRole GroupName GroupFeatureEnabled
| SetGroupFeatureRole AGroupFeatureRole GroupName GroupFeatureEnabled (Maybe GroupMemberRole)
| SetGroupMemberAdmissionReview GroupName (Maybe MemberCriteria)
| SetUserTimedMessages Bool -- UserId (not used in UI)
| SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled)
| SetGroupTimedMessages GroupName (Maybe Int)
@ -630,9 +626,9 @@ data ChatResponse
| CRGroupMemberInfo {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats}
| CRQueueInfo {user :: User, rcvMsgInfo :: Maybe RcvMsgInfo, queueInfo :: ServerQueueInfo}
| CRContactSwitchStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats}
| CEvtGroupMemberSwitchStarted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats}
| CRGroupMemberSwitchStarted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats}
| CRContactSwitchAborted {user :: User, contact :: Contact, connectionStats :: ConnectionStats}
| CEvtGroupMemberSwitchAborted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats}
| CRGroupMemberSwitchAborted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats}
| CRContactRatchetSyncStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats}
| CRGroupMemberRatchetSyncStarted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats}
| CRContactCode {user :: User, contact :: Contact, connectionCode :: Text}
@ -652,9 +648,6 @@ data ChatResponse
| CRWelcome {user :: User}
| CRGroupCreated {user :: User, groupInfo :: GroupInfo}
| CRGroupMembers {user :: User, group :: Group}
| CRMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]}
-- | CRGroupConversationsArchived {user :: User, groupInfo :: GroupInfo, archivedGroupConversations :: [GroupConversation]}
-- | CRGroupConversationsDeleted {user :: User, groupInfo :: GroupInfo, deletedGroupConversations :: [GroupConversation]}
| CRContactsList {user :: User, contacts :: [Contact]}
| CRUserContactLink {user :: User, contactLink :: UserContactLink}
| CRUserContactLinkUpdated {user :: User, contactLink :: UserContactLink}
@ -676,7 +669,6 @@ data ChatResponse
| CRSentConfirmation {user :: User, connection :: PendingContactConnection}
| CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile}
| CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile}
| CRItemsReadForChat {user :: User, chatInfo :: AChatInfo}
| CRContactDeleted {user :: User, contact :: Contact}
| CRChatCleared {user :: User, chatInfo :: AChatInfo}
| CRUserContactLinkCreated {user :: User, connLinkContact :: CreatedLinkContact}
@ -702,7 +694,6 @@ data ChatResponse
| CRContactPrefsUpdated {user :: User, fromContact :: Contact, toContact :: Contact}
| CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]}
| CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
| CRMemberAccepted {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
| CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole}
| CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool}
| CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember}
@ -811,7 +802,6 @@ data ChatEvent
| CEvtUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember}
| CEvtJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- there is the same command response
| CEvtJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember}
| CEvtMemberAcceptedByOther {user :: User, groupInfo :: GroupInfo, acceptingMember :: GroupMember, member :: GroupMember}
| CEvtMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole}
| CEvtMemberBlockedForAll {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, blocked :: Bool}
| CEvtConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact}
@ -909,15 +899,16 @@ logEventToFile = \case
_ -> False
_ -> False
-- (Maybe GroupMemberId) can later be changed to GroupSndScope = GSSAll | GSSAdmins | GSSMember GroupMemberId
data SendRef
= SRDirect ContactId
| SRGroup GroupId (Maybe GroupChatScope)
| SRGroup GroupId (Maybe GroupMemberId)
deriving (Eq, Show)
sendToChatRef :: SendRef -> ChatRef
sendToChatRef = \case
SRDirect cId -> ChatRef CTDirect cId Nothing
SRGroup gId scope -> ChatRef CTGroup gId scope
SRDirect cId -> ChatRef CTDirect cId
SRGroup gId _ -> ChatRef CTGroup gId
data ChatPagination
= CPLast Int

File diff suppressed because it is too large Load diff

View file

@ -190,13 +190,13 @@ toggleNtf m ntfOn =
forM_ (memberConnId m) $ \connId ->
withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` eToView
prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup))
prepareGroupMsg db user g@GroupInfo {membership} msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of
prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup))
prepareGroupMsg db user g@GroupInfo {membership} mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of
(Nothing, Nothing) ->
let mc' = MCSimple $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope
let mc' = MCSimple $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live)
in pure (XMsgNew mc', Nothing)
(Nothing, Just _) ->
let mc' = MCForward $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope
let mc' = MCForward $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live)
in pure (XMsgNew mc', Nothing)
(Just quotedItemId, Nothing) -> do
CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <-
@ -206,7 +206,7 @@ prepareGroupMsg db user g@GroupInfo {membership} msgScope mc mentions quotedItem
qmc = quoteContent mc origQmc file
(qmc', ft', _) = updatedMentionNames qmc formattedText quoteMentions
quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft'}
mc' = MCQuote QuotedMsg {msgRef, content = qmc'} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope)
mc' = MCQuote QuotedMsg {msgRef, content = qmc'} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live))
pure (XMsgNew mc', Just quotedItem)
(Just _, Just _) -> throwError SEInvalidQuote
where
@ -453,8 +453,8 @@ deleteDirectCIs user ct items = do
deleteDirectChatItem db user ct ci
pure $ contactDeletion md ct ci Nothing
deleteGroupCIs :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [CChatItem 'CTGroup] -> Maybe GroupMember -> UTCTime -> CM [ChatItemDeletion]
deleteGroupCIs user gInfo chatScopeInfo items byGroupMember_ deletedTs = do
deleteGroupCIs :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Maybe GroupMember -> UTCTime -> CM [ChatItemDeletion]
deleteGroupCIs user gInfo items byGroupMember_ deletedTs = do
let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items
deleteCIFiles user ciFilesInfo
(errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items)
@ -466,7 +466,7 @@ deleteGroupCIs user gInfo chatScopeInfo items byGroupMember_ deletedTs = do
ci' <- case byGroupMember_ of
Just m -> Just <$> updateGroupChatItemModerated db user gInfo ci m deletedTs
Nothing -> Nothing <$ deleteGroupChatItem db user gInfo ci
pure $ groupDeletion md gInfo chatScopeInfo ci ci'
pure $ groupDeletion md gInfo ci ci'
deleteGroupMemberCIs :: MsgDirectionI d => User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> CM ()
deleteGroupMemberCIs user gInfo member byGroupMember msgDir = do
@ -517,8 +517,8 @@ markDirectCIsDeleted user ct items deletedTs = do
ci' <- markDirectChatItemDeleted db user ct ci deletedTs
pure $ contactDeletion md ct ci (Just ci')
markGroupCIsDeleted :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [CChatItem 'CTGroup] -> Maybe GroupMember -> UTCTime -> CM [ChatItemDeletion]
markGroupCIsDeleted user gInfo chatScopeInfo items byGroupMember_ deletedTs = do
markGroupCIsDeleted :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Maybe GroupMember -> UTCTime -> CM [ChatItemDeletion]
markGroupCIsDeleted user gInfo items byGroupMember_ deletedTs = do
let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items
cancelFilesInProgress user ciFilesInfo
(errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (markDeleted db) items)
@ -528,7 +528,7 @@ markGroupCIsDeleted user gInfo chatScopeInfo items byGroupMember_ deletedTs = do
where
markDeleted db (CChatItem md ci) = do
ci' <- markGroupChatItemDeleted db user gInfo ci byGroupMember_ deletedTs
pure $ groupDeletion md gInfo chatScopeInfo ci (Just ci')
pure $ groupDeletion md gInfo ci (Just ci')
markGroupMemberCIsDeleted :: User -> GroupInfo -> GroupMember -> GroupMember -> CM ()
markGroupMemberCIsDeleted user gInfo member byGroupMember = do
@ -548,10 +548,10 @@ markGroupMemberCIsDeleted_ db user gInfo member byGroupMember deletedTs = do
markMemberCIsDeleted db user gInfo member byGroupMember deletedTs
pure fs
groupDeletion :: MsgDirectionI d => SMsgDirection d -> GroupInfo -> Maybe GroupChatScopeInfo -> ChatItem 'CTGroup d -> Maybe (ChatItem 'CTGroup d) -> ChatItemDeletion
groupDeletion md g chatScopeInfo ci ci' = ChatItemDeletion (gItem ci) (gItem <$> ci')
groupDeletion :: MsgDirectionI d => SMsgDirection d -> GroupInfo -> ChatItem 'CTGroup d -> Maybe (ChatItem 'CTGroup d) -> ChatItemDeletion
groupDeletion md g ci ci' = ChatItemDeletion (gItem ci) (gItem <$> ci')
where
gItem = AChatItem SCTGroup md (GroupChat g chatScopeInfo)
gItem = AChatItem SCTGroup md (GroupChat g)
contactDeletion :: MsgDirectionI d => SMsgDirection d -> Contact -> ChatItem 'CTDirect d -> Maybe (ChatItem 'CTDirect d) -> ChatItemDeletion
contactDeletion md ct ci ci' = ChatItemDeletion (ctItem ci) (ctItem <$> ci')
@ -565,7 +565,7 @@ updateCallItemStatus user ct@Contact {contactId} Call {chatItemId} receivedStatu
timed_ <- callTimed ct aciContent
updateDirectChatItemView user ct chatItemId aciContent False False timed_ msgId_
forM_ (timed_ >>= timedDeleteAt') $
startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId)
startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId)
updateDirectChatItemView :: User -> Contact -> ChatItemId -> ACIContent -> Bool -> Bool -> Maybe CITimed -> Maybe MessageId -> CM ()
updateDirectChatItemView user ct chatItemId (ACIContent msgDir ciContent) edited live timed_ msgId_ = do
@ -668,10 +668,10 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI
_ -> do
chatRef <- withStore $ \db -> getChatRefByFileId db user fileId
case (chatRef, grpMemberId) of
(ChatRef CTDirect contactId _, Nothing) -> do
(ChatRef CTDirect contactId, Nothing) -> do
ct <- withStore $ \db -> getContact db vr user contactId
acceptFile CFCreateConnFileInvDirect $ \msg -> void $ sendDirectContactMessage user ct msg
(ChatRef CTGroup groupId _, Just memId) -> do
(ChatRef CTGroup groupId, Just memId) -> do
GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user groupId memId
case activeConn of
Just conn -> do
@ -880,7 +880,7 @@ acceptGroupJoinRequestAsync
gLinkMemRole
incognitoProfile = do
gVar <- asks random
let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted
let initialStatus = acceptanceToStatus gAccepted
(groupMemberId, memberId) <- withStore $ \db -> do
liftIO $ deleteContactRequestRec db user ucr
createJoiningMember db gVar user gInfo ucr gLinkMemRole initialStatus
@ -965,7 +965,7 @@ acceptBusinessJoinRequestAsync
let chatV = vr `peerConnChatVersion` cReqChatVRange
connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV
withStore' $ \db -> createJoiningMemberConnection db user connIds chatV ucr groupMemberId subMode
let cd = CDGroupSnd gInfo Nothing
let cd = CDGroupSnd gInfo
createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing
createGroupFeatureItems user cd CISndGroupFeature gInfo
pure gInfo
@ -981,41 +981,13 @@ profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$>
NewIncognito p -> p
ExistingIncognito lp -> fromLocalProfile lp
introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM ()
introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do
forM_ (memberConn m) $ \mConn -> do
let msg =
if (maxVersion (memberChatVRange m) >= groupKnockingVersion)
then XGrpLinkAcpt GAPendingReview memberRole memberId
else XMsgNew $ MCSimple $ extMsgContent (MCText pendingReviewMessage) Nothing
void $ sendDirectMemberMessage mConn msg groupId
modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo
let rcpModMs = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs
introduceMember vr user gInfo m rcpModMs (Just $ MSMember $ memberId' m)
introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM ()
introduceToAll vr user gInfo m = do
introduceToGroup :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM ()
introduceToGroup _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active"
introduceToGroup vr user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn = Just conn} = do
members <- withStore' $ \db -> getGroupMembers db vr user gInfo
let recipients = filter memberCurrent members
introduceMember vr user gInfo m recipients Nothing
introduceToRemaining :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM ()
introduceToRemaining vr user gInfo m = do
(members, introducedGMIds) <-
withStore' $ \db -> (,) <$> getGroupMembers db vr user gInfo <*> getIntroducedGroupMemberIds db m
let recipients = filter (introduceMemP introducedGMIds) members
introduceMember vr user gInfo m recipients Nothing
where
introduceMemP introducedGMIds mem =
memberCurrent mem
&& groupMemberId' mem `notElem` introducedGMIds
&& groupMemberId' mem /= groupMemberId' m
introduceMember :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM ()
introduceMember _ _ _ GroupMember {activeConn = Nothing} _ _ = throwChatError $ CEInternalError "member connection not active"
introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do
void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo m) msgScope
sendIntroductions introduceToMembers
void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m
sendIntroductions members
when (groupFeatureAllowed SGFHistory gInfo) sendHistory
where
sendIntroductions members = do
intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m
@ -1031,7 +1003,7 @@ introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Ju
memberIntro reMember =
let mInfo = memberInfo reMember
mRestrictions = memberRestrictions reMember
in XGrpMemIntro mInfo mRestrictions
in XGrpMemIntro mInfo mRestrictions
shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro]
shuffleIntros intros = do
let (admins, others) = partition isAdmin intros
@ -1044,19 +1016,15 @@ introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Ju
processIntro intro@GroupMemberIntro {introId} = do
void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId
withStore' $ \db -> updateIntroStatus db introId GMIntroSent
sendHistory :: User -> GroupInfo -> GroupMember -> CM ()
sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active"
sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn = Just conn} =
when (m `supportsVersion` batchSendVersion) $ do
(errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo m 100)
(errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items
let errors = map ChatErrorStore errs <> errs'
unless (null errors) $ toView $ CEvtChatErrors errors
let events' = maybe (concat events) (\x -> concat events <> [x]) descrEvent_
forM_ (L.nonEmpty events') $ \events'' ->
sendGroupMemberMessages user conn events'' groupId
where
sendHistory =
when (m `supportsVersion` batchSendVersion) $ do
(errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo m 100)
(errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items
let errors = map ChatErrorStore errs <> errs'
unless (null errors) $ toView $ CEvtChatErrors errors
let events' = maybe (concat events) (\x -> concat events <> [x]) descrEvent_
forM_ (L.nonEmpty events') $ \events'' ->
sendGroupMemberMessages user conn events'' groupId
descrEvent_ :: Maybe (ChatMsgEvent 'Json)
descrEvent_
| m `supportsVersion` groupHistoryIncludeWelcomeVersion = do
@ -1102,7 +1070,7 @@ sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn
| fileDescrComplete =
let fInvDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False}
fInv = xftpFileInvitation fileName fileSize fInvDescr
in Just (fInv, fileDescrText)
in Just (fInv, fileDescrText)
| otherwise = Nothing
processContentItem :: GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [ChatMsgEvent 'Json]
processContentItem sender ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ =
@ -1114,8 +1082,7 @@ sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn
fInv_ = fst <$> fInvDescr_
(mc', _, mentions') = updatedMentionNames mc formattedText mentions
mentions'' = M.map (\CIMention {memberId} -> MsgMention {memberId}) mentions'
-- TODO [knocking] send history to other scopes too?
(chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False
(chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False
let senderVRange = memberChatVRange' sender
xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent}
fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of
@ -1180,7 +1147,7 @@ startTimedItemThread user itemRef deleteAt = do
atomically $ writeTVar threadTVar (Just tId)
deleteTimedItem :: User -> (ChatRef, ChatItemId) -> UTCTime -> CM ()
deleteTimedItem user (ChatRef cType chatId scope, itemId) deleteAt = do
deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do
ts <- liftIO getCurrentTime
liftIO $ threadDelay' $ diffToMicroseconds $ diffUTCTime deleteAt ts
lift waitChatStartedAndActivated
@ -1193,8 +1160,7 @@ deleteTimedItem user (ChatRef cType chatId scope, itemId) deleteAt = do
CTGroup -> do
(gInfo, ci) <- withStore $ \db -> (,) <$> getGroupInfo db vr user chatId <*> getGroupChatItem db user chatId itemId
deletedTs <- liftIO getCurrentTime
chatScopeInfo <- mapM (getChatScopeInfo vr user) scope
deletions <- deleteGroupCIs user gInfo chatScopeInfo [ci] Nothing deletedTs
deletions <- deleteGroupCIs user gInfo [ci] Nothing deletedTs
toView $ CEvtChatItemsDeleted user deletions True True
_ -> eToView $ ChatError $ CEInternalError "bad deleteTimedItem cType"
@ -1310,103 +1276,6 @@ parseChatMessage conn s = do
errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s)
{-# INLINE parseChatMessage #-}
getChatScopeInfo :: VersionRangeChat -> User -> GroupChatScope -> CM GroupChatScopeInfo
getChatScopeInfo vr user = \case
GCSMemberSupport Nothing -> pure $ GCSIMemberSupport Nothing
GCSMemberSupport (Just gmId) -> do
supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId
pure $ GCSIMemberSupport (Just supportMem)
-- TODO [knocking] refactor to GroupChatScope -> "a" function, "a" is some new type? Or possibly split to get scope/get recipients steps
getGroupRecipients :: VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScope -> VersionChat -> CM (Maybe GroupChatScopeInfo, [GroupMember])
getGroupRecipients vr user gInfo@GroupInfo {membership} scope modsCompatVersion = case scope of
Nothing -> do
unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member"
ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo
let recipients = filter memberCurrent ms
pure (Nothing, recipients)
Just (GCSMemberSupport Nothing) -> do
modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo
let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs
when (null rcpModMs') $ throwChatError $ CECommandError "no admins support this message"
let scopeInfo = GCSIMemberSupport Nothing
pure (Just scopeInfo, rcpModMs')
Just (GCSMemberSupport (Just gmId)) -> do
unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member"
supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId
unless (memberCurrentOrPending supportMem) $ throwChatError $ CECommandError "support member not current or pending"
let scopeInfo = GCSIMemberSupport (Just supportMem)
if memberStatus supportMem == GSMemPendingApproval
then pure (Just scopeInfo, [supportMem])
else do
modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo
let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs
pure (Just scopeInfo, [supportMem] <> rcpModMs')
where
compatible GroupMember {activeConn, memberChatVRange} =
maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= modsCompatVersion
mkLocalGroupChatScope :: GroupInfo -> CM (GroupInfo, Maybe GroupChatScopeInfo)
mkLocalGroupChatScope gInfo@GroupInfo {membership}
| memberPending membership = do
(gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo
pure (gInfo', Just scopeInfo)
| otherwise =
pure (gInfo, Nothing)
mkGroupChatScope :: GroupInfo -> GroupMember -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo)
mkGroupChatScope gInfo@GroupInfo {membership} m
| memberPending membership = do
(gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo
pure (gInfo', m, Just scopeInfo)
| memberPending m = do
(m', scopeInfo) <- mkMemberSupportChatInfo m
pure (gInfo, m', Just scopeInfo)
| otherwise =
pure (gInfo, m, Nothing)
mkGetMessageChatScope :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo)
mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m msgScope_ =
mkGroupChatScope gInfo m >>= \case
groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope
(_, _, Nothing) -> case msgScope_ of
Nothing -> pure (gInfo, m, Nothing)
Just (MSMember mId)
| sameMemberId mId membership -> do
(gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo
pure (gInfo', m, Just scopeInfo)
| otherwise -> do
referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId
-- TODO [knocking] return patched _referredMember' too?
(_referredMember', scopeInfo) <- mkMemberSupportChatInfo referredMember
pure (gInfo, m, Just scopeInfo)
mkGroupSupportChatInfo :: GroupInfo -> CM (GroupInfo, GroupChatScopeInfo)
mkGroupSupportChatInfo gInfo@GroupInfo {membership} =
case supportChat membership of
Nothing -> do
chatTs <- liftIO getCurrentTime
withStore' $ \db -> setSupportChatTs db (groupMemberId' membership) chatTs
let gInfo' = gInfo {membership = membership {supportChat = Just $ GroupSupportChat chatTs 0 0 0 Nothing}}
scopeInfo = GCSIMemberSupport {groupMember_ = Nothing}
pure (gInfo', scopeInfo)
Just _supportChat ->
let scopeInfo = GCSIMemberSupport {groupMember_ = Nothing}
in pure (gInfo, scopeInfo)
mkMemberSupportChatInfo :: GroupMember -> CM (GroupMember, GroupChatScopeInfo)
mkMemberSupportChatInfo m@GroupMember {groupMemberId, supportChat} =
case supportChat of
Nothing -> do
chatTs <- liftIO getCurrentTime
withStore' $ \db -> setSupportChatTs db groupMemberId chatTs
let m' = m {supportChat = Just $ GroupSupportChat chatTs 0 0 0 Nothing}
scopeInfo = GCSIMemberSupport {groupMember_ = Just m'}
pure (m', scopeInfo)
Just _supportChat ->
let scopeInfo = GCSIMemberSupport {groupMember_ = Just m}
in pure (m, scopeInfo)
sendFileChunk :: User -> SndFileTransfer -> CM ()
sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} =
unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ do
@ -1564,20 +1433,15 @@ deleteMemberConnection' GroupMember {activeConn} waitDelivery = do
deleteAgentConnectionAsync' (aConnId conn) waitDelivery
withStore' $ \db -> updateConnectionStatus db conn ConnDeleted
deleteOrUpdateMemberRecord :: User -> GroupInfo -> GroupMember -> CM GroupInfo
deleteOrUpdateMemberRecord user gInfo member =
withStore' $ \db -> deleteOrUpdateMemberRecordIO db user gInfo member
deleteOrUpdateMemberRecord :: User -> GroupMember -> CM ()
deleteOrUpdateMemberRecord user member =
withStore' $ \db -> deleteOrUpdateMemberRecordIO db user member
deleteOrUpdateMemberRecordIO :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO GroupInfo
deleteOrUpdateMemberRecordIO db user@User {userId} gInfo member = do
gInfo' <-
if gmRequiresAttention member
then decreaseGroupMembersRequireAttention db user gInfo
else pure gInfo
deleteOrUpdateMemberRecordIO :: DB.Connection -> User -> GroupMember -> IO ()
deleteOrUpdateMemberRecordIO db user@User {userId} member =
checkGroupMemberHasItems db user member >>= \case
Just _ -> updateGroupMemberStatus db userId member GSMemRemoved
Nothing -> deleteGroupMember db user member
pure gInfo'
sendDirectContactMessages :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage]
sendDirectContactMessages user ct events = do
@ -1764,9 +1628,9 @@ deliverMessagesB msgReqs = do
where
updatePQ = updateConnPQSndEnabled db connId pqSndEnabled'
sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage
sendGroupMessage user gInfo gcScope members chatMsgEvent = do
sendGroupMessages user gInfo gcScope members (chatMsgEvent :| []) >>= \case
sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage
sendGroupMessage user gInfo members chatMsgEvent = do
sendGroupMessages user gInfo members (chatMsgEvent :| []) >>= \case
((Right msg) :| [], _) -> pure msg
_ -> throwChatError $ CEInternalError "sendGroupMessage: expected 1 message"
@ -1776,9 +1640,9 @@ sendGroupMessage' user gInfo members chatMsgEvent =
((Right msg) :| [], _) -> pure msg
_ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message"
sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)
sendGroupMessages user gInfo scope members events = do
-- TODO [knocking] send current profile to pending member after approval?
sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)
sendGroupMessages user gInfo members events = do
-- TODO [knocking] when sending to all, send profile update to pending approval members too, then filter for next step?
when shouldSendProfileUpdate $
sendProfileUpdate `catchChatError` eToView
sendGroupMessages_ user gInfo members events
@ -1786,7 +1650,6 @@ sendGroupMessages user gInfo scope members events = do
User {profile = p, userMemberProfileUpdatedAt} = user
GroupInfo {userMemberProfileSentAt} = gInfo
shouldSendProfileUpdate
| isJust scope = False -- why not sending profile updates to scopes?
| incognitoMembership gInfo = False
| otherwise =
case (userMemberProfileSentAt, userMemberProfileUpdatedAt) of
@ -1807,13 +1670,16 @@ data GroupSndResult = GroupSndResult
}
sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)
sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do
sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do
let idsEvts = L.map (GroupId groupId,) events
sndMsgs_ <- lift $ createSndMessages idsEvts
recipientMembers' <- liftIO $ shuffleMembers recipientMembers
-- TODO [knocking] Possibly we need to pass GroupSndScope through all functions to here to avoid ad-hoc filtering.
recipientMembers <- case members of
[m] | memberStatus m == GSMemPendingApproval -> pure [m]
_ -> liftIO $ shuffleMembers (filter memberCurrent members)
let msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events}
(toSendSeparate, toSendBatched, toPending, forwarded, _, dups) =
foldr' (addMember recipientMembers') ([], [], [], [], S.empty, 0 :: Int) recipientMembers'
foldr' addMember ([], [], [], [], S.empty, 0 :: Int) recipientMembers
when (dups /= 0) $ logError $ "sendGroupMessages_: " <> tshow dups <> " duplicate members"
-- TODO PQ either somehow ensure that group members connections cannot have pqSupport/pqEncryption or pass Off's here
-- Deliver to toSend members
@ -1835,7 +1701,7 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do
liftM2 (<>) (shuffle adminMs) (shuffle otherMs)
where
isAdmin GroupMember {memberRole} = memberRole >= GRAdmin
addMember members m acc@(toSendSeparate, toSendBatched, pending, forwarded, !mIds, !dups) =
addMember m acc@(toSendSeparate, toSendBatched, pending, forwarded, !mIds, !dups) =
case memberSendAction gInfo events members m of
Just a
| mId `S.member` mIds -> (toSendSeparate, toSendBatched, pending, forwarded, mIds, dups + 1)
@ -2012,7 +1878,7 @@ saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothi
saveSndChatItem' :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIFile 'MDSnd) -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDSnd)
saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed live = do
let itemTexts = ciContentTexts content
saveSndChatItems user cd [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case
saveSndChatItems user cd Nothing [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case
[Right ci] -> pure ci
_ -> throwChatError $ CEInternalError "saveSndChatItem': expected 1 item"
@ -2031,43 +1897,43 @@ saveSndChatItems ::
ChatTypeI c =>
User ->
ChatDirection c 'MDSnd ->
Maybe NotInHistory ->
[Either ChatError (NewSndChatItemData c)] ->
Maybe CITimed ->
Bool ->
CM [Either ChatError (ChatItem c 'MDSnd)]
saveSndChatItems user cd itemsData itemTimed live = do
saveSndChatItems user cd notInHistory_ itemsData itemTimed live = do
createdAt <- liftIO getCurrentTime
vr <- chatVersionRange
when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $
void $ withStore' (\db -> updateChatTsStats db vr user cd createdAt Nothing)
withStore' (\db -> updateChatTs db user cd createdAt)
lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData)
where
createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd))
createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do
ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live createdAt
ciId <- createNewSndChatItem db user cd notInHistory_ msg content quotedItem itemForwarded itemTimed live createdAt
forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt
let ci = mkChatItem_ cd ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False createdAt Nothing createdAt
Right <$> case cd of
CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions
CDGroupSnd g | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions
_ -> pure ci
saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv, ChatInfo c)
saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv)
saveRcvChatItemNoParse user cd msg brokerTs = saveRcvChatItem user cd msg brokerTs . ciContentNoParse
saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv, ChatInfo c)
saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv)
saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content =
saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty
saveRcvChatItem' user cd Nothing msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty
ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe MarkdownList))
ciContentNoParse content = (content, (ciContentToText content, Nothing))
saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c)
saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do
saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> Maybe NotInHistory -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv)
saveRcvChatItem' user cd notInHistory_ msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do
createdAt <- liftIO getCurrentTime
vr <- chatVersionRange
withStore' $ \db -> do
when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTs db user cd createdAt
(mentions' :: Map MemberName CIMention, userMention) <- case cd of
CDGroupRcv g@GroupInfo {membership} _scope _m -> do
CDGroupRcv g@GroupInfo {membership} _ -> do
mentions' <- getRcvCIMentions db user g ft_ mentions
let userReply = case cmToQuotedMsg chatMsgEvent of
Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership
@ -2075,23 +1941,12 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared
userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions'
in pure (mentions', userMention')
CDDirectRcv _ -> pure (M.empty, False)
cInfo' <- if (ciRequiresAttention content || contactChatDeleted cd)
then updateChatTsStats db vr user cd createdAt (memberChatStats userMention)
else pure $ toChatInfo cd
(ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt
(ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd notInHistory_ msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt
forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt
let ci = mkChatItem_ cd ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention brokerTs forwardedByMember createdAt
ci' <- case cd of
CDGroupRcv g _scope _m | not (null mentions') -> createGroupCIMentions db g ci mentions'
case cd of
CDGroupRcv g _ | not (null mentions') -> createGroupCIMentions db g ci mentions'
_ -> pure ci
pure (ci', cInfo')
where
memberChatStats :: Bool -> Maybe (Int, MemberAttention, Int)
memberChatStats userMention = case cd of
CDGroupRcv _g (Just scope) m -> do
let unread = fromEnum $ ciCreateStatus content == CISRcvNew
in Just (unread, memberAttentionChange unread (Just brokerTs) m scope, fromEnum userMention)
_ -> Nothing
-- TODO [mentions] optimize by avoiding unnecessary parsing
mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d
@ -2307,39 +2162,19 @@ createInternalItemsForChats ::
createInternalItemsForChats user itemTs_ dirsCIContents = do
createdAt <- liftIO getCurrentTime
let itemTs = fromMaybe createdAt itemTs_
vr <- chatVersionRange'
void . withStoreBatch' $ \db -> map (uncurry $ updateChat db vr createdAt) dirsCIContents
void . withStoreBatch' $ \db -> map (uncurry $ updateChat db createdAt) dirsCIContents
withStoreBatch' $ \db -> concatMap (uncurry $ createACIs db itemTs createdAt) dirsCIContents
where
updateChat :: DB.Connection -> VersionRangeChat -> UTCTime -> ChatDirection c d -> [CIContent d] -> IO ()
updateChat db vr createdAt cd contents
| any ciRequiresAttention contents || contactChatDeleted cd = void $ updateChatTsStats db vr user cd createdAt memberChatStats
updateChat :: DB.Connection -> UTCTime -> ChatDirection c d -> [CIContent d] -> IO ()
updateChat db createdAt cd contents
| any ciRequiresAttention contents || contactChatDeleted cd = updateChatTs db user cd createdAt
| otherwise = pure ()
where
memberChatStats :: Maybe (Int, MemberAttention, Int)
memberChatStats = case cd of
CDGroupRcv _g (Just scope) m -> do
let unread = length $ filter ciRequiresAttention contents
in Just (unread, memberAttentionChange unread itemTs_ m scope, 0)
_ -> Nothing
createACIs :: DB.Connection -> UTCTime -> UTCTime -> ChatDirection c d -> [CIContent d] -> [IO AChatItem]
createACIs db itemTs createdAt cd = map $ \content -> do
ciId <- createNewChatItemNoMsg db user cd content itemTs createdAt
let ci = mkChatItem cd ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt
pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci
memberAttentionChange :: Int -> (Maybe UTCTime) -> GroupMember -> GroupChatScopeInfo -> MemberAttention
memberAttentionChange unread brokerTs_ rcvMem = \case
GCSIMemberSupport (Just suppMem)
| groupMemberId' suppMem == groupMemberId' rcvMem -> MAInc unread brokerTs_
| msgIsNewerThanLastUnanswered -> MAReset
| otherwise -> MAInc 0 Nothing
where
msgIsNewerThanLastUnanswered = case (supportChat suppMem >>= lastMsgFromMemberTs, brokerTs_) of
(Just lastMsgTs, Just brokerTs) -> lastMsgTs < brokerTs
_ -> False
GCSIMemberSupport Nothing -> MAInc 0 Nothing
createLocalChatItems ::
User ->
ChatDirection 'CTLocal 'MDSnd ->
@ -2347,15 +2182,14 @@ createLocalChatItems ::
UTCTime ->
CM [ChatItem 'CTLocal 'MDSnd]
createLocalChatItems user cd itemsData createdAt = do
vr <- chatVersionRange
void $ withStore' $ \db -> updateChatTsStats db vr user cd createdAt Nothing
withStore' $ \db -> updateChatTs db user cd createdAt
(errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList itemsData)
unless (null errs) $ toView $ CEvtChatErrors errs
pure items
where
createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd)
createItem db (content, ciFile, itemForwarded, ts) = do
ciId <- createNewChatItem_ db user cd Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False createdAt Nothing createdAt
ciId <- createNewChatItem_ db user cd Nothing Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False createdAt Nothing createdAt
forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt
pure $ mkChatItem_ cd ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False createdAt Nothing createdAt

File diff suppressed because it is too large Load diff

View file

@ -61,25 +61,6 @@ import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>))
data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection
deriving (Eq, Show, Ord)
data GroupChatScope
= GCSMemberSupport {groupMemberId_ :: Maybe GroupMemberId} -- Nothing means own conversation with support
deriving (Eq, Show, Ord)
data GroupChatScopeTag
= GCSTMemberSupport_
deriving (Eq, Show)
instance FromField GroupChatScopeTag where fromField = fromTextField_ textDecode
instance ToField GroupChatScopeTag where toField = toField . textEncode
instance TextEncoding GroupChatScopeTag where
textDecode = \case
"member_support" -> Just GCSTMemberSupport_
_ -> Nothing
textEncode = \case
GCSTMemberSupport_ -> "member_support"
data ChatName = ChatName {chatType :: ChatType, chatName :: Text}
deriving (Show)
@ -94,52 +75,44 @@ chatTypeStr = \case
chatNameStr :: ChatName -> String
chatNameStr (ChatName cType name) = T.unpack $ chatTypeStr cType <> if T.any isSpace name then "'" <> name <> "'" else name
data ChatRef = ChatRef ChatType Int64 (Maybe GroupChatScope)
data ChatRef = ChatRef ChatType Int64
deriving (Eq, Show, Ord)
data ChatInfo (c :: ChatType) where
DirectChat :: Contact -> ChatInfo 'CTDirect
GroupChat :: GroupInfo -> Maybe GroupChatScopeInfo -> ChatInfo 'CTGroup
GroupChat :: GroupInfo -> ChatInfo 'CTGroup
LocalChat :: NoteFolder -> ChatInfo 'CTLocal
ContactRequest :: UserContactRequest -> ChatInfo 'CTContactRequest
ContactConnection :: PendingContactConnection -> ChatInfo 'CTContactConnection
deriving instance Show (ChatInfo c)
data GroupChatScopeInfo
= GCSIMemberSupport {groupMember_ :: Maybe GroupMember}
deriving (Show)
toChatScope :: GroupChatScopeInfo -> GroupChatScope
toChatScope = \case
GCSIMemberSupport {groupMember_} -> GCSMemberSupport $ groupMemberId' <$> groupMember_
toMsgScope :: GroupInfo -> GroupChatScopeInfo -> MsgScope
toMsgScope GroupInfo {membership} = \case
GCSIMemberSupport {groupMember_} -> MSMember $ memberId' $ fromMaybe membership groupMember_
chatInfoChatTs :: ChatInfo c -> Maybe UTCTime
chatInfoChatTs = \case
DirectChat Contact {chatTs} -> chatTs
GroupChat GroupInfo {chatTs} -> chatTs
_ -> Nothing
chatInfoToRef :: ChatInfo c -> ChatRef
chatInfoToRef = \case
DirectChat Contact {contactId} -> ChatRef CTDirect contactId Nothing
GroupChat GroupInfo {groupId} scopeInfo -> ChatRef CTGroup groupId (toChatScope <$> scopeInfo)
LocalChat NoteFolder {noteFolderId} -> ChatRef CTLocal noteFolderId Nothing
ContactRequest UserContactRequest {contactRequestId} -> ChatRef CTContactRequest contactRequestId Nothing
ContactConnection PendingContactConnection {pccConnId} -> ChatRef CTContactConnection pccConnId Nothing
DirectChat Contact {contactId} -> ChatRef CTDirect contactId
GroupChat GroupInfo {groupId} -> ChatRef CTGroup groupId
LocalChat NoteFolder {noteFolderId} -> ChatRef CTLocal noteFolderId
ContactRequest UserContactRequest {contactRequestId} -> ChatRef CTContactRequest contactRequestId
ContactConnection PendingContactConnection {pccConnId} -> ChatRef CTContactConnection pccConnId
chatInfoMembership :: ChatInfo c -> Maybe GroupMember
chatInfoMembership = \case
GroupChat GroupInfo {membership} _scopeInfo -> Just membership
GroupChat GroupInfo {membership} -> Just membership
_ -> Nothing
data JSONChatInfo
= JCInfoDirect {contact :: Contact}
| JCInfoGroup {groupInfo :: GroupInfo, groupChatScope :: Maybe GroupChatScopeInfo}
| JCInfoGroup {groupInfo :: GroupInfo}
| JCInfoLocal {noteFolder :: NoteFolder}
| JCInfoContactRequest {contactRequest :: UserContactRequest}
| JCInfoContactConnection {contactConnection :: PendingContactConnection}
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GCSI") ''GroupChatScopeInfo)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCInfo") ''JSONChatInfo)
instance ChatTypeI c => FromJSON (ChatInfo c) where
@ -152,7 +125,7 @@ instance ToJSON (ChatInfo c) where
jsonChatInfo :: ChatInfo c -> JSONChatInfo
jsonChatInfo = \case
DirectChat c -> JCInfoDirect c
GroupChat g s -> JCInfoGroup g s
GroupChat g -> JCInfoGroup g
LocalChat l -> JCInfoLocal l
ContactRequest g -> JCInfoContactRequest g
ContactConnection c -> JCInfoContactConnection c
@ -164,7 +137,7 @@ deriving instance Show AChatInfo
jsonAChatInfo :: JSONChatInfo -> AChatInfo
jsonAChatInfo = \case
JCInfoDirect c -> AChatInfo SCTDirect $ DirectChat c
JCInfoGroup g s -> AChatInfo SCTGroup $ GroupChat g s
JCInfoGroup g -> AChatInfo SCTGroup $ GroupChat g
JCInfoLocal l -> AChatInfo SCTLocal $ LocalChat l
JCInfoContactRequest g -> AChatInfo SCTContactRequest $ ContactRequest g
JCInfoContactConnection c -> AChatInfo SCTContactConnection $ ContactConnection c
@ -190,6 +163,8 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem
}
deriving (Show)
data NotInHistory = NotInHistory
data CIMention = CIMention
{ memberId :: MemberId,
-- member record can be created later than the mention is received
@ -292,8 +267,8 @@ ciReactionAllowed ChatItem {content} = isJust $ ciMsgContent content
data ChatDirection (c :: ChatType) (d :: MsgDirection) where
CDDirectSnd :: Contact -> ChatDirection 'CTDirect 'MDSnd
CDDirectRcv :: Contact -> ChatDirection 'CTDirect 'MDRcv
CDGroupSnd :: GroupInfo -> Maybe GroupChatScopeInfo -> ChatDirection 'CTGroup 'MDSnd
CDGroupRcv :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv
CDGroupSnd :: GroupInfo -> ChatDirection 'CTGroup 'MDSnd
CDGroupRcv :: GroupInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv
CDLocalSnd :: NoteFolder -> ChatDirection 'CTLocal 'MDSnd
CDLocalRcv :: NoteFolder -> ChatDirection 'CTLocal 'MDRcv
@ -301,8 +276,8 @@ toCIDirection :: ChatDirection c d -> CIDirection c d
toCIDirection = \case
CDDirectSnd _ -> CIDirectSnd
CDDirectRcv _ -> CIDirectRcv
CDGroupSnd _ _ -> CIGroupSnd
CDGroupRcv _ _ m -> CIGroupRcv m
CDGroupSnd _ -> CIGroupSnd
CDGroupRcv _ m -> CIGroupRcv m
CDLocalSnd _ -> CILocalSnd
CDLocalRcv _ -> CILocalRcv
@ -310,8 +285,8 @@ toChatInfo :: ChatDirection c d -> ChatInfo c
toChatInfo = \case
CDDirectSnd c -> DirectChat c
CDDirectRcv c -> DirectChat c
CDGroupSnd g s -> GroupChat g s
CDGroupRcv g s _ -> GroupChat g s
CDGroupSnd g -> GroupChat g
CDGroupRcv g _ -> GroupChat g
CDLocalSnd l -> LocalChat l
CDLocalRcv l -> LocalChat l
@ -1399,8 +1374,6 @@ instance MsgDirectionI d => ToJSON (CIFile d) where
toJSON = $(JQ.mkToJSON defaultJSON ''CIFile)
toEncoding = $(JQ.mkToEncoding defaultJSON ''CIFile)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GCS") ''GroupChatScope)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIDirection)
instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIDirection c d) where

View file

@ -206,8 +206,6 @@ ciRequiresAttention content = case msgDirection @d of
CIRcvGroupEvent rge -> case rge of
RGEMemberAdded {} -> False
RGEMemberConnected -> False
RGEMemberAccepted {} -> False
RGEUserAccepted -> False
RGEMemberLeft -> False
RGEMemberRole {} -> False
RGEMemberBlocked {} -> False
@ -219,7 +217,6 @@ ciRequiresAttention content = case msgDirection @d of
RGEInvitedViaGroupLink -> False
RGEMemberCreatedContact -> False
RGEMemberProfileUpdated {} -> False
RGENewMemberPendingReview -> True
CIRcvConnEvent _ -> True
CIRcvChatFeature {} -> False
CIRcvChatPreference {} -> False
@ -320,8 +317,6 @@ rcvGroupEventToText :: RcvGroupEvent -> Text
rcvGroupEventToText = \case
RGEMemberAdded _ p -> "added " <> profileToText p
RGEMemberConnected -> "connected"
RGEMemberAccepted _ p -> "accepted " <> profileToText p
RGEUserAccepted -> "accepted you"
RGEMemberLeft -> "left"
RGEMemberRole _ p r -> "changed role of " <> profileToText p <> " to " <> safeDecodeUtf8 (strEncode r)
RGEMemberBlocked _ p blocked -> (if blocked then "blocked" else "unblocked") <> " " <> profileToText p
@ -333,7 +328,6 @@ rcvGroupEventToText = \case
RGEInvitedViaGroupLink -> "invited via your group link"
RGEMemberCreatedContact -> "started direct connection with you"
RGEMemberProfileUpdated {} -> "updated profile"
RGENewMemberPendingReview -> "new member wants to join the group"
sndGroupEventToText :: SndGroupEvent -> Text
sndGroupEventToText = \case
@ -343,18 +337,6 @@ sndGroupEventToText = \case
SGEMemberDeleted _ p -> "removed " <> profileToText p
SGEUserLeft -> "left"
SGEGroupUpdated _ -> "group profile updated"
SGEMemberAccepted _ _p -> "you accepted this member"
SGEUserPendingReview -> "please wait for group moderators to review your request to join the group"
-- used to send to members with old version
pendingReviewMessage :: Text
pendingReviewMessage =
"Please wait for group moderators to review your request to join the group."
-- used to send to members with old version
acceptedToGroupMessage :: Text
acceptedToGroupMessage =
"You are accepted to the group."
rcvConnEventToText :: RcvConnEvent -> Text
rcvConnEventToText = \case

View file

@ -14,9 +14,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption)
data RcvGroupEvent
= RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting
| RGEMemberConnected -- CEvtUserJoinedGroup, CRJoinedGroupMember, CEvtConnectedToGroupMember
| RGEMemberAccepted {groupMemberId :: GroupMemberId, profile :: Profile}
| RGEUserAccepted
| RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember
| RGEMemberLeft -- CRLeftMember
| RGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole}
| RGEMemberBlocked {groupMemberId :: GroupMemberId, profile :: Profile, blocked :: Bool} -- CRMemberBlockedForAll
@ -31,7 +29,6 @@ data RcvGroupEvent
| RGEInvitedViaGroupLink -- CRSentGroupInvitationViaLink
| RGEMemberCreatedContact -- CRNewMemberContactReceivedInv
| RGEMemberProfileUpdated {fromProfile :: Profile, toProfile :: Profile} -- CRGroupMemberUpdated
| RGENewMemberPendingReview
deriving (Show)
data SndGroupEvent
@ -41,8 +38,6 @@ data SndGroupEvent
| SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMembers
| SGEUserLeft -- CRLeftMemberUser
| SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated
| SGEMemberAccepted {groupMemberId :: GroupMemberId, profile :: Profile}
| SGEUserPendingReview
deriving (Show)
data RcvConnEvent

Some files were not shown because too many files have changed in this diff Show more