mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-25 20:44:38 +00:00
Merge branch 'master' into ep/member-acceptance
This commit is contained in:
@@ -97,6 +97,22 @@ class ItemsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) {
|
||||
navigationTimeoutTask?.cancel()
|
||||
loadChatTask?.cancel()
|
||||
loadChatTask = Task {
|
||||
// try? await Task.sleep(nanoseconds: 1000_000000)
|
||||
await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
|
||||
if !Task.isCancelled {
|
||||
await MainActor.run {
|
||||
if openAroundItemId == nil {
|
||||
ChatModel.shared.chatId = chatId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ChatTagsModel: ObservableObject {
|
||||
@@ -259,6 +275,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var deletedChats: Set<String> = []
|
||||
// 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] = []
|
||||
|
||||
@@ -338,7 +338,7 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||
await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems)
|
||||
}
|
||||
|
||||
func loadChat(chatId: ChatId, search: String = "", 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
|
||||
m.chatItemStatuses = [:]
|
||||
@@ -348,7 +348,7 @@ func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) asyn
|
||||
ItemsModel.shared.chatItemsChangesListener.cleared()
|
||||
}
|
||||
}
|
||||
await apiLoadMessages(chatId, search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage), im.chatState, search, { 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, itemId: Int64) async throws -> ChatItemInfo {
|
||||
@@ -1588,21 +1588,21 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
|
||||
}
|
||||
}
|
||||
|
||||
func apiRemoveMember(_ groupId: Int64, _ memberId: Int64) async throws -> GroupMember {
|
||||
let r = await chatSendCmd(.apiRemoveMember(groupId: groupId, memberId: memberId), bgTask: false)
|
||||
if case let .userDeletedMember(_, _, member) = r { return member }
|
||||
func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64]) async throws -> [GroupMember] {
|
||||
let r = await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds), bgTask: false)
|
||||
if case let .userDeletedMembers(_, _, members) = r { return members }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiMemberRole(_ groupId: Int64, _ memberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
|
||||
let r = await chatSendCmd(.apiMemberRole(groupId: groupId, memberId: memberId, memberRole: memberRole), bgTask: false)
|
||||
if case let .memberRoleUser(_, _, member, _, _) = r { return member }
|
||||
func apiMembersRole(_ groupId: Int64, _ memberIds: [Int64], _ memberRole: GroupMemberRole) async throws -> [GroupMember] {
|
||||
let r = await chatSendCmd(.apiMembersRole(groupId: groupId, memberIds: memberIds, memberRole: memberRole), bgTask: false)
|
||||
if case let .membersRoleUser(_, _, members, _) = r { return members }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiBlockMemberForAll(_ groupId: Int64, _ memberId: Int64, _ blocked: Bool) async throws -> GroupMember {
|
||||
let r = await chatSendCmd(.apiBlockMemberForAll(groupId: groupId, memberId: memberId, blocked: blocked), bgTask: false)
|
||||
if case let .memberBlockedForAllUser(_, _, member, _) = r { return member }
|
||||
func apiBlockMembersForAll(_ groupId: Int64, _ memberIds: [Int64], _ blocked: Bool) async throws -> [GroupMember] {
|
||||
let r = await chatSendCmd(.apiBlockMembersForAll(groupId: groupId, memberIds: memberIds, blocked: blocked), bgTask: false)
|
||||
if case let .membersBlockedForAllUser(_, _, members, _) = r { return members }
|
||||
throw r
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ func apiLoadMessages(
|
||||
_ pagination: ChatPagination,
|
||||
_ chatState: ActiveChatState,
|
||||
_ search: String = "",
|
||||
_ openAroundItemId: ChatItem.ID? = nil,
|
||||
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int> = { 0 ... 0 }
|
||||
) async {
|
||||
let chat: Chat
|
||||
@@ -32,7 +33,8 @@ func apiLoadMessages(
|
||||
// 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
|
||||
let paginationIsInitial = switch pagination { case .initial: true; default: false }
|
||||
let paginationIsLast = switch pagination { case .last: true; default: false }
|
||||
if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) || Task.isCancelled {
|
||||
// When openAroundItemId is provided, chatId can be different too
|
||||
if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast && openAroundItemId == nil) || Task.isCancelled {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,8 +104,13 @@ func apiLoadMessages(
|
||||
}
|
||||
}
|
||||
case .around:
|
||||
newItems.append(contentsOf: oldItems)
|
||||
let newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
|
||||
let newSplits: [Int64]
|
||||
if openAroundItemId == nil {
|
||||
newItems.append(contentsOf: oldItems)
|
||||
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
|
||||
} else {
|
||||
newSplits = []
|
||||
}
|
||||
// currently, items will always be added on top, which is index 0
|
||||
newItems.insert(contentsOf: chat.chatItems, at: 0)
|
||||
let newReversed: [ChatItem] = newItems.reversed()
|
||||
@@ -114,8 +121,15 @@ func apiLoadMessages(
|
||||
chatState.totalAfter = navInfo.afterTotal
|
||||
chatState.unreadTotal = chat.chatStats.unreadCount
|
||||
chatState.unreadAfter = navInfo.afterUnread
|
||||
// no need to set it, count will be wrong
|
||||
// unreadAfterNewestLoaded.value = navInfo.afterUnread
|
||||
|
||||
if let openAroundItemId {
|
||||
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
|
||||
}
|
||||
}
|
||||
case .last:
|
||||
newItems.append(contentsOf: oldItems)
|
||||
|
||||
@@ -46,7 +46,7 @@ func preloadIfNeeded(
|
||||
loadItems: @escaping (Bool, ChatPagination) async -> Bool
|
||||
) {
|
||||
let state = PreloadState.shared
|
||||
guard !listState.isScrolling,
|
||||
guard !listState.isScrolling && !listState.isAnimatedScrolling,
|
||||
state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count,
|
||||
!state.preloading,
|
||||
listState.totalItemsCount > 0
|
||||
|
||||
@@ -29,6 +29,7 @@ struct ChatView: View {
|
||||
@State private var composeState = ComposeState()
|
||||
@State private var selectedRange = NSRange()
|
||||
@State private var keyboardVisible = false
|
||||
@State private var keyboardHiddenDate = Date.now
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var customUserProfile: Profile?
|
||||
@State private var connectionCode: String?
|
||||
@@ -37,7 +38,7 @@ struct ChatView: View {
|
||||
@State private var requestedTopScroll = false
|
||||
@State private var loadingBottomItems = false
|
||||
@State private var requestedBottomScroll = false
|
||||
@State private var searchMode = false
|
||||
@State private var showSearch = false
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
// opening GroupMemberInfoView on member icon
|
||||
@@ -54,11 +55,8 @@ struct ChatView: View {
|
||||
@State private var allowLoadMoreItems: Bool = false
|
||||
@State private var ignoreLoadingRequests: Int64? = nil
|
||||
@State private var animatedScrollingInProgress: Bool = false
|
||||
@State private var updateMergedItemsTask: Task<Void, Never>? = nil
|
||||
@State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel()
|
||||
|
||||
private let useItemsUpdateTask = false
|
||||
|
||||
@State private var scrollView: EndlessScrollView<MergedItem> = EndlessScrollView(frame: .zero)
|
||||
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
@@ -101,6 +99,7 @@ struct ChatView: View {
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: $keyboardVisible,
|
||||
keyboardHiddenDate: $keyboardHiddenDate,
|
||||
selectedRange: $selectedRange
|
||||
)
|
||||
.disabled(!cInfo.sendMsgEnabled)
|
||||
@@ -131,7 +130,7 @@ struct ChatView: View {
|
||||
}
|
||||
.safeAreaInset(edge: .top) {
|
||||
VStack(spacing: .zero) {
|
||||
if searchMode { searchToolbar() }
|
||||
if showSearch { searchToolbar() }
|
||||
Divider()
|
||||
}
|
||||
.background(ToolbarMaterial.material(toolbarMaterial))
|
||||
@@ -232,15 +231,45 @@ struct ChatView: View {
|
||||
scrollView.listState.onUpdateListener = onChatItemsUpdated
|
||||
initChatView()
|
||||
theme = buildTheme()
|
||||
if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) {
|
||||
closeSearch()
|
||||
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
|
||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
|
||||
if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] {
|
||||
scrollView.scrollToItem(index)
|
||||
} else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) {
|
||||
scrollView.scrollToItem(unreadIndex)
|
||||
} else {
|
||||
scrollView.scrollToBottom()
|
||||
}
|
||||
if chatModel.openAroundItemId != nil {
|
||||
chatModel.openAroundItemId = nil
|
||||
}
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.openAroundItemId) { openAround in
|
||||
if let openAround {
|
||||
closeSearch()
|
||||
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
|
||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
chatModel.openAroundItemId = nil
|
||||
|
||||
if let index = mergedItems.boxedValue.indexInParentItems[openAround] {
|
||||
scrollView.scrollToItem(index)
|
||||
}
|
||||
|
||||
// this may already being loading because of changed chat id (see .onChange(of: chat.id)
|
||||
if !loadingBottomItems {
|
||||
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
||||
allowLoadMoreItems = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
allowLoadMoreItems = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
VideoPlayerView.players.removeAll()
|
||||
stopAudioPlayer()
|
||||
@@ -429,9 +458,13 @@ struct ChatView: View {
|
||||
index = mergedItems.boxedValue.indexInParentItems[itemId]
|
||||
}
|
||||
if let index {
|
||||
await MainActor.run { animatedScrollingInProgress = true }
|
||||
await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index))
|
||||
await MainActor.run { animatedScrollingInProgress = false }
|
||||
closeKeyboardAndRun {
|
||||
Task {
|
||||
await MainActor.run { animatedScrollingInProgress = true }
|
||||
await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index))
|
||||
await MainActor.run { animatedScrollingInProgress = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("Error scrolling to item: \(error)")
|
||||
@@ -460,9 +493,7 @@ struct ChatView: View {
|
||||
.cornerRadius(10.0)
|
||||
|
||||
Button ("Cancel") {
|
||||
searchText = ""
|
||||
searchMode = false
|
||||
searchFocussed = false
|
||||
closeSearch()
|
||||
Task { await loadChat(chat: chat) }
|
||||
}
|
||||
}
|
||||
@@ -517,7 +548,9 @@ struct ChatView: View {
|
||||
showChatInfoSheet: $showChatInfoSheet,
|
||||
revealedItems: $revealedItems,
|
||||
selectedChatItems: $selectedChatItems,
|
||||
forwardedChatItems: $forwardedChatItems
|
||||
forwardedChatItems: $forwardedChatItems,
|
||||
searchText: $searchText,
|
||||
closeKeyboardAndRun: closeKeyboardAndRun
|
||||
)
|
||||
// crashes on Cell size calculation without this line
|
||||
.environmentObject(ChatModel.shared)
|
||||
@@ -535,25 +568,10 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: im.reversedChatItems) { items in
|
||||
updateMergedItemsTask?.cancel()
|
||||
if useItemsUpdateTask {
|
||||
updateMergedItemsTask = Task {
|
||||
let items = MergedItems.create(items, revealedItems, im.chatState)
|
||||
if Task.isCancelled {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
mergedItems.boxedValue = items
|
||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState)
|
||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
}
|
||||
mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState)
|
||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
}
|
||||
.onChange(of: revealedItems) { revealed in
|
||||
updateMergedItemsTask?.cancel()
|
||||
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState)
|
||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
}
|
||||
@@ -567,6 +585,7 @@ struct ChatView: View {
|
||||
.padding(.vertical, -100)
|
||||
.onTapGesture { hideKeyboard() }
|
||||
.onChange(of: searchText) { s in
|
||||
guard showSearch else { return }
|
||||
Task {
|
||||
await loadChat(chat: chat, search: s)
|
||||
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
|
||||
@@ -880,11 +899,29 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private func focusSearch() {
|
||||
searchMode = true
|
||||
showSearch = true
|
||||
searchFocussed = true
|
||||
searchText = ""
|
||||
}
|
||||
|
||||
private func closeSearch() {
|
||||
showSearch = false
|
||||
searchText = ""
|
||||
searchFocussed = false
|
||||
}
|
||||
|
||||
private func closeKeyboardAndRun(_ action: @escaping () -> Void) {
|
||||
var delay: TimeInterval = 0
|
||||
if keyboardVisible || keyboardHiddenDate.timeIntervalSinceNow >= -1 || showSearch {
|
||||
delay = 0.5
|
||||
closeSearch()
|
||||
hideKeyboard()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
private func addMembersButton() -> some View {
|
||||
Button {
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
@@ -1079,6 +1116,7 @@ struct ChatView: View {
|
||||
pagination,
|
||||
im.chatState,
|
||||
searchText,
|
||||
nil,
|
||||
{ visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) }
|
||||
)
|
||||
return true
|
||||
@@ -1136,10 +1174,14 @@ struct ChatView: View {
|
||||
@State private var showChatItemInfoSheet: Bool = false
|
||||
@State private var chatItemInfo: ChatItemInfo?
|
||||
@State private var msgWidth: CGFloat = 0
|
||||
@State private var touchInProgress: Bool = false
|
||||
|
||||
@Binding var selectedChatItems: Set<Int64>?
|
||||
@Binding var forwardedChatItems: [ChatItem]
|
||||
|
||||
@Binding var searchText: String
|
||||
var closeKeyboardAndRun: (@escaping () -> Void) -> Void
|
||||
|
||||
@State private var allowMenu: Bool = true
|
||||
@State private var markedRead = false
|
||||
@State private var markReadTask: Task<Void, Never>? = nil
|
||||
@@ -1257,6 +1299,16 @@ struct ChatView: View {
|
||||
markedRead = false
|
||||
}
|
||||
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||
// skip updating struct on touch if no need to show GoTo button
|
||||
.if(touchInProgress || searchIsNotBlank || (chatItem.meta.itemForwarded != nil && chatItem.meta.itemForwarded != .unknown)) {
|
||||
// long press listener steals taps from top-level listener, so repeating it's logic here as well
|
||||
$0.onTapGesture {
|
||||
hideKeyboard()
|
||||
}
|
||||
.onLongPressGesture(minimumDuration: .infinity, perform: {}, onPressingChanged: { pressing in
|
||||
touchInProgress = pressing
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func unreadItemIds(_ range: ClosedRange<Int>) -> ([ChatItem.ID], Int) {
|
||||
@@ -1290,6 +1342,11 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var searchIsNotBlank: Bool {
|
||||
get {
|
||||
searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct MemberLayout: Layout {
|
||||
@@ -1459,18 +1516,26 @@ struct ChatView: View {
|
||||
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
VStack(alignment: alignment.horizontal, spacing: 3) {
|
||||
ChatItemView(
|
||||
chat: chat,
|
||||
chatItem: ci,
|
||||
scrollToItemId: scrollToItemId,
|
||||
maxWidth: maxWidth,
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
.environment(\.revealed, revealed)
|
||||
.environment(\.showTimestamp, itemSeparation.timestamp)
|
||||
.modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed)))
|
||||
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
|
||||
.accessibilityLabel("")
|
||||
HStack {
|
||||
if ci.chatDir.sent {
|
||||
goToItemButton(true)
|
||||
}
|
||||
ChatItemView(
|
||||
chat: chat,
|
||||
chatItem: ci,
|
||||
scrollToItemId: scrollToItemId,
|
||||
maxWidth: maxWidth,
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
.environment(\.revealed, revealed)
|
||||
.environment(\.showTimestamp, itemSeparation.timestamp)
|
||||
.modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed)))
|
||||
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
|
||||
.accessibilityLabel("")
|
||||
if !ci.chatDir.sent {
|
||||
goToItemButton(false)
|
||||
}
|
||||
}
|
||||
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
||||
chatItemReactions(ci)
|
||||
.padding(.bottom, 4)
|
||||
@@ -2133,6 +2198,37 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func goToItemInnerButton(_ alignStart: Bool, _ image: String, touchInProgress: Bool, _ onClick: @escaping () -> Void) -> some View {
|
||||
Button {
|
||||
onClick()
|
||||
} label: {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.frame(width: 13, height: 13)
|
||||
.padding([alignStart ? .trailing : .leading], 10)
|
||||
.tint(theme.colors.secondary.opacity(touchInProgress ? 1.0 : 0.4))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func goToItemButton(_ alignStart: Bool) -> some View {
|
||||
let chatTypeApiIdMsgId = chatItem.meta.itemForwarded?.chatTypeApiIdMsgId
|
||||
if searchIsNotBlank {
|
||||
goToItemInnerButton(alignStart, "magnifyingglass", touchInProgress: touchInProgress) {
|
||||
closeKeyboardAndRun {
|
||||
ItemsModel.shared.loadOpenChatNoWait(chat.id, chatItem.id)
|
||||
}
|
||||
}
|
||||
} else if let chatTypeApiIdMsgId {
|
||||
goToItemInnerButton(alignStart, "arrow.right", touchInProgress: touchInProgress) {
|
||||
closeKeyboardAndRun {
|
||||
let (chatType, apiId, msgId) = chatTypeApiIdMsgId
|
||||
ItemsModel.shared.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SelectedChatItem: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var ciId: Int64
|
||||
|
||||
@@ -325,6 +325,7 @@ struct ComposeView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var keyboardVisible: Bool
|
||||
@Binding var keyboardHiddenDate: Date
|
||||
@Binding var selectedRange: NSRange
|
||||
|
||||
@State var linkUrl: URL? = nil
|
||||
@@ -434,6 +435,7 @@ struct ComposeView: View {
|
||||
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
|
||||
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
|
||||
keyboardVisible: $keyboardVisible,
|
||||
keyboardHiddenDate: $keyboardHiddenDate,
|
||||
sendButtonColor: chat.chatInfo.incognito
|
||||
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
||||
: theme.colors.primary
|
||||
@@ -1280,6 +1282,7 @@ struct ComposeView_Previews: PreviewProvider {
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: Binding.constant(true),
|
||||
keyboardHiddenDate: Binding.constant(Date.now),
|
||||
selectedRange: $selectedRange
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
@@ -1287,6 +1290,7 @@ struct ComposeView_Previews: PreviewProvider {
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: Binding.constant(true),
|
||||
keyboardHiddenDate: Binding.constant(Date.now),
|
||||
selectedRange: $selectedRange
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
|
||||
@@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
@Binding var disableEditing: Bool
|
||||
@Binding var height: CGFloat
|
||||
@Binding var focused: Bool
|
||||
@Binding var lastUnfocusedDate: Date
|
||||
@Binding var placeholder: String?
|
||||
@Binding var selectedRange: NSRange
|
||||
let onImagesAdded: ([UploadContent]) -> Void
|
||||
@@ -42,7 +43,12 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
onImagesAdded(images)
|
||||
}
|
||||
}
|
||||
field.setOnFocusChangedListener { focused = $0 }
|
||||
field.setOnFocusChangedListener {
|
||||
focused = $0
|
||||
if !focused {
|
||||
lastUnfocusedDate = .now
|
||||
}
|
||||
}
|
||||
field.delegate = field
|
||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||
field.setPlaceholderView()
|
||||
@@ -266,6 +272,7 @@ struct NativeTextEditor_Previews: PreviewProvider{
|
||||
disableEditing: Binding.constant(false),
|
||||
height: Binding.constant(100),
|
||||
focused: Binding.constant(false),
|
||||
lastUnfocusedDate: Binding.constant(.now),
|
||||
placeholder: Binding.constant("Placeholder"),
|
||||
selectedRange: Binding.constant(NSRange(location: 0, length: 0)),
|
||||
onImagesAdded: { _ in }
|
||||
|
||||
@@ -32,6 +32,7 @@ struct SendMessageView: View {
|
||||
@State private var holdingVMR = false
|
||||
@Namespace var namespace
|
||||
@Binding var keyboardVisible: Bool
|
||||
@Binding var keyboardHiddenDate: Date
|
||||
var sendButtonColor = Color.accentColor
|
||||
@State private var teHeight: CGFloat = NativeTextEditor.minHeight
|
||||
@State private var teFont: Font = .body
|
||||
@@ -61,6 +62,7 @@ struct SendMessageView: View {
|
||||
disableEditing: $composeState.inProgress,
|
||||
height: $teHeight,
|
||||
focused: $keyboardVisible,
|
||||
lastUnfocusedDate: $keyboardHiddenDate,
|
||||
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
|
||||
selectedRange: $selectedRange,
|
||||
onImagesAdded: onMediaAdded
|
||||
@@ -441,7 +443,8 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
selectedRange: $selectedRange,
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: Binding.constant(true),
|
||||
keyboardHiddenDate: Binding.constant(Date.now)
|
||||
)
|
||||
}
|
||||
VStack {
|
||||
@@ -452,7 +455,8 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
selectedRange: $selectedRangeEditing,
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: Binding.constant(true),
|
||||
keyboardHiddenDate: Binding.constant(Date.now)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,8 +171,10 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||
visibleItems.last?.index ?? 0
|
||||
}
|
||||
|
||||
/// Whether there is scroll to item in progress or not
|
||||
/// Whether there is a non-animated scroll to item in progress or not
|
||||
var isScrolling: Bool = false
|
||||
/// Whether there is an animated scroll to item in progress or not
|
||||
var isAnimatedScrolling: Bool = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@@ -349,11 +351,11 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||
if // there is auto scroll in progress and the first item has a higher offset than bottom part
|
||||
// of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to
|
||||
// re-make the first visible item
|
||||
(listState.isScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) ||
|
||||
(listState.isAnimatedScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) ||
|
||||
// the fist visible item previously is hidden now, remove it and move on
|
||||
!isVisible(vis.view) {
|
||||
let newIndex: Int
|
||||
if listState.isScrolling {
|
||||
if listState.isAnimatedScrolling {
|
||||
// skip many items to make the scrolling take less time
|
||||
var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0
|
||||
// if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled
|
||||
@@ -471,7 +473,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||
}
|
||||
|
||||
func scrollToItem(_ index: Int, top: Bool = true) {
|
||||
if index >= listState.items.count || listState.isScrolling {
|
||||
if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling {
|
||||
return
|
||||
}
|
||||
if bounds.height == 0 {
|
||||
@@ -498,7 +500,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
|
||||
|
||||
var stepSlowdownMultiplier: CGFloat = 1
|
||||
while true {
|
||||
while i < 200 {
|
||||
let up = index > listState.firstVisibleItemIndex
|
||||
if upPrev != up {
|
||||
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
|
||||
@@ -522,18 +524,22 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||
break
|
||||
}
|
||||
contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
|
||||
adaptItems(listState.items, false)
|
||||
snapToContent(animated: false)
|
||||
i += 1
|
||||
}
|
||||
adaptItems(listState.items, false)
|
||||
snapToContent(animated: false)
|
||||
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
|
||||
}
|
||||
|
||||
func scrollToItemAnimated(_ index: Int, top: Bool = true) async {
|
||||
if index >= listState.items.count || listState.isScrolling {
|
||||
if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling {
|
||||
return
|
||||
}
|
||||
listState.isScrolling = true
|
||||
listState.isAnimatedScrolling = true
|
||||
defer {
|
||||
listState.isScrolling = false
|
||||
listState.isAnimatedScrolling = false
|
||||
}
|
||||
var adjustedOffset = self.contentOffset.y
|
||||
var i = 0
|
||||
@@ -543,7 +549,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
|
||||
|
||||
var stepSlowdownMultiplier: CGFloat = 1
|
||||
while true {
|
||||
while i < 200 {
|
||||
let up = index > listState.firstVisibleItemIndex
|
||||
if upPrev != up {
|
||||
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
|
||||
|
||||
@@ -681,12 +681,14 @@ struct GroupChatInfoView: View {
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
|
||||
let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
|
||||
await MainActor.run {
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember in
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiRemoveMember error: \(responseError(error))")
|
||||
logger.error("apiRemoveMembers error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error removing member")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
|
||||
@@ -610,13 +610,15 @@ struct GroupMemberInfoView: View {
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
|
||||
let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
|
||||
await MainActor.run {
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember in
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiRemoveMember error: \(responseError(error))")
|
||||
logger.error("apiRemoveMembers error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error removing member")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
@@ -641,14 +643,16 @@ struct GroupMemberInfoView: View {
|
||||
primaryButton: .default(Text("Change")) {
|
||||
Task {
|
||||
do {
|
||||
let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole)
|
||||
let updatedMembers = try await apiMembersRole(groupInfo.groupId, [mem.groupMemberId], newRole)
|
||||
await MainActor.run {
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember in
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
|
||||
} catch let error {
|
||||
newRole = mem.memberRole
|
||||
logger.error("apiMemberRole error: \(responseError(error))")
|
||||
logger.error("apiMembersRole error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing role")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
@@ -800,12 +804,14 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||
func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) {
|
||||
Task {
|
||||
do {
|
||||
let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked)
|
||||
let updatedMembers = try await apiBlockMembersForAll(gInfo.groupId, [member.groupMemberId], blocked)
|
||||
await MainActor.run {
|
||||
_ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember in
|
||||
_ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiBlockMemberForAll error: \(responseError(error))")
|
||||
logger.error("apiBlockMembersForAll error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ struct TerminalView: View {
|
||||
@State var composeState: ComposeState = ComposeState()
|
||||
@State var selectedRange = NSRange()
|
||||
@State private var keyboardVisible = false
|
||||
@State private var keyboardHiddenDate = Date.now
|
||||
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||
@State private var terminalItem: TerminalItem?
|
||||
@State private var scrolled = false
|
||||
@@ -101,7 +102,8 @@ struct TerminalView: View {
|
||||
sendMessage: { _ in consoleSendMessage() },
|
||||
showVoiceMessageButton: false,
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
keyboardVisible: $keyboardVisible,
|
||||
keyboardHiddenDate: $keyboardHiddenDate
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
@@ -71,9 +71,9 @@ public enum ChatCommand {
|
||||
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
|
||||
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
|
||||
case apiJoinGroup(groupId: Int64)
|
||||
case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole)
|
||||
case apiBlockMemberForAll(groupId: Int64, memberId: Int64, blocked: Bool)
|
||||
case apiRemoveMember(groupId: Int64, memberId: Int64)
|
||||
case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole)
|
||||
case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool)
|
||||
case apiRemoveMembers(groupId: Int64, memberIds: [Int64])
|
||||
case apiLeaveGroup(groupId: Int64)
|
||||
case apiListMembers(groupId: Int64)
|
||||
case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile)
|
||||
@@ -250,9 +250,9 @@ public enum ChatCommand {
|
||||
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 .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)"
|
||||
case let .apiBlockMemberForAll(groupId, memberId, blocked): return "/_block #\(groupId) \(memberId) blocked=\(onOff(blocked))"
|
||||
case let .apiRemoveMember(groupId, memberId): return "/_remove #\(groupId) \(memberId)"
|
||||
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): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ","))"
|
||||
case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)"
|
||||
case let .apiListMembers(groupId): return "/_members #\(groupId)"
|
||||
case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))"
|
||||
@@ -424,9 +424,9 @@ public enum ChatCommand {
|
||||
case .apiNewGroup: return "apiNewGroup"
|
||||
case .apiAddMember: return "apiAddMember"
|
||||
case .apiJoinGroup: return "apiJoinGroup"
|
||||
case .apiMemberRole: return "apiMemberRole"
|
||||
case .apiBlockMemberForAll: return "apiBlockMemberForAll"
|
||||
case .apiRemoveMember: return "apiRemoveMember"
|
||||
case .apiMembersRole: return "apiMembersRole"
|
||||
case .apiBlockMembersForAll: return "apiBlockMembersForAll"
|
||||
case .apiRemoveMembers: return "apiRemoveMembers"
|
||||
case .apiLeaveGroup: return "apiLeaveGroup"
|
||||
case .apiListMembers: return "apiListMembers"
|
||||
case .apiUpdateGroupProfile: return "apiUpdateGroupProfile"
|
||||
@@ -681,16 +681,16 @@ public enum ChatResponse: Decodable, Error {
|
||||
case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
|
||||
case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember)
|
||||
case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact)
|
||||
case userDeletedMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
|
||||
case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember])
|
||||
case leftMemberUser(user: UserRef, groupInfo: GroupInfo)
|
||||
case groupMembers(user: UserRef, group: Group)
|
||||
case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole)
|
||||
case groupDeletedUser(user: UserRef, groupInfo: GroupInfo)
|
||||
case joinedGroupMemberConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember)
|
||||
case memberRole(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole)
|
||||
case memberRoleUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole)
|
||||
case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole)
|
||||
case memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool)
|
||||
case memberBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, blocked: Bool)
|
||||
case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool)
|
||||
case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
|
||||
case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember)
|
||||
case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
|
||||
@@ -861,16 +861,16 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .userAcceptedGroupSent: return "userAcceptedGroupSent"
|
||||
case .groupLinkConnecting: return "groupLinkConnecting"
|
||||
case .businessLinkConnecting: return "businessLinkConnecting"
|
||||
case .userDeletedMember: return "userDeletedMember"
|
||||
case .userDeletedMembers: return "userDeletedMembers"
|
||||
case .leftMemberUser: return "leftMemberUser"
|
||||
case .groupMembers: return "groupMembers"
|
||||
case .receivedGroupInvitation: return "receivedGroupInvitation"
|
||||
case .groupDeletedUser: return "groupDeletedUser"
|
||||
case .joinedGroupMemberConnecting: return "joinedGroupMemberConnecting"
|
||||
case .memberRole: return "memberRole"
|
||||
case .memberRoleUser: return "memberRoleUser"
|
||||
case .membersRoleUser: return "membersRoleUser"
|
||||
case .memberBlockedForAll: return "memberBlockedForAll"
|
||||
case .memberBlockedForAllUser: return "memberBlockedForAllUser"
|
||||
case .membersBlockedForAllUser: return "membersBlockedForAllUser"
|
||||
case .deletedMemberUser: return "deletedMemberUser"
|
||||
case .deletedMember: return "deletedMember"
|
||||
case .leftMember: return "leftMember"
|
||||
@@ -1048,16 +1048,16 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
|
||||
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 .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
|
||||
case let .userDeletedMembers(u, groupInfo, members): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)")
|
||||
case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
|
||||
case let .groupMembers(u, group): return withUser(u, String(describing: group))
|
||||
case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)")
|
||||
case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
|
||||
case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)")
|
||||
case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)")
|
||||
case let .memberRoleUser(u, groupInfo, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)")
|
||||
case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)")
|
||||
case let .memberBlockedForAll(u, groupInfo, byMember, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nblocked: \(blocked)")
|
||||
case let .memberBlockedForAllUser(u, groupInfo, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nblocked: \(blocked)")
|
||||
case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)")
|
||||
case let .deletedMemberUser(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
|
||||
case let .deletedMember(u, groupInfo, byMember, deletedMember): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)")
|
||||
case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
|
||||
|
||||
@@ -3261,6 +3261,20 @@ public enum CIForwardedFrom: Decodable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
public var chatTypeApiIdMsgId: (ChatType, Int64, ChatItem.ID?)? {
|
||||
switch self {
|
||||
case .unknown: nil
|
||||
case let .contact(_, _, contactId, msgId):
|
||||
if let contactId {
|
||||
(ChatType.direct, contactId, msgId)
|
||||
} else { nil }
|
||||
case let .group(_, _, groupId, msgId):
|
||||
if let groupId {
|
||||
(ChatType.group, groupId, msgId)
|
||||
} else { nil }
|
||||
}
|
||||
}
|
||||
|
||||
public func text(_ chatType: ChatType) -> LocalizedStringKey {
|
||||
chatType == .local
|
||||
? (chatName == "" ? "saved" : "saved from \(chatName)")
|
||||
|
||||
+25
-4
@@ -30,10 +30,31 @@ internal class Cryptor: CryptorInterface {
|
||||
}
|
||||
return null
|
||||
}
|
||||
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
val spec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
|
||||
return runCatching { String(cipher.doFinal(data))}.onFailure { Log.e(TAG, "doFinal: ${it.stackTraceToString()}") }.getOrNull()
|
||||
|
||||
try {
|
||||
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
val spec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
|
||||
return String(cipher.doFinal(data))
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "cipher.init: ${e.stackTraceToString()}")
|
||||
val randomPassphrase = appPreferences.initialRandomDBPassphrase.get()
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.error_reading_passphrase),
|
||||
text = generalGetString(if (randomPassphrase) {
|
||||
MR.strings.restore_passphrase_can_not_be_read_desc
|
||||
} else {
|
||||
MR.strings.restore_passphrase_can_not_be_read_enter_manually_desc
|
||||
}
|
||||
)
|
||||
.plus("\n\n").plus(e.stackTraceToString())
|
||||
)
|
||||
if (randomPassphrase) {
|
||||
// do not allow to override initial random passphrase in case of such error
|
||||
throw e
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
|
||||
|
||||
@@ -65,6 +65,7 @@ object ChatModel {
|
||||
|
||||
// current chat
|
||||
val chatId = mutableStateOf<String?>(null)
|
||||
val openAroundItemId: MutableState<Long?> = mutableStateOf(null)
|
||||
val chatsContext = ChatsContext(null)
|
||||
val reportsChatsContext = ChatsContext(MsgContentTag.Report)
|
||||
// declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions
|
||||
@@ -3111,6 +3112,13 @@ sealed class CIForwardedFrom {
|
||||
is Group -> chatName
|
||||
}
|
||||
|
||||
val chatTypeApiIdMsgId: Triple<ChatType, Long, Long?>?
|
||||
get() = when (this) {
|
||||
Unknown -> null
|
||||
is Contact -> if (contactId != null) Triple(ChatType.Direct, contactId, chatItemId) else null
|
||||
is Group -> if (groupId != null) Triple(ChatType.Group, groupId, chatItemId) else null
|
||||
}
|
||||
|
||||
fun text(chatType: ChatType): String =
|
||||
if (chatType == ChatType.Local) {
|
||||
if (chatName.isEmpty()) {
|
||||
|
||||
+37
-33
@@ -1995,34 +1995,34 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiRemoveMember(rh: Long?, groupId: Long, memberId: Long): GroupMember? =
|
||||
when (val r = sendCmd(rh, CC.ApiRemoveMember(groupId, memberId))) {
|
||||
is CR.UserDeletedMember -> r.member
|
||||
suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List<Long>): List<GroupMember>? =
|
||||
when (val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds))) {
|
||||
is CR.UserDeletedMembers -> r.members
|
||||
else -> {
|
||||
if (!(networkErrorAlert(r))) {
|
||||
apiErrorAlert("apiRemoveMember", generalGetString(MR.strings.error_removing_member), r)
|
||||
apiErrorAlert("apiRemoveMembers", generalGetString(MR.strings.error_removing_member), r)
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiMemberRole(rh: Long?, groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember =
|
||||
when (val r = sendCmd(rh, CC.ApiMemberRole(groupId, memberId, memberRole))) {
|
||||
is CR.MemberRoleUser -> r.member
|
||||
suspend fun apiMembersRole(rh: Long?, groupId: Long, memberIds: List<Long>, memberRole: GroupMemberRole): List<GroupMember> =
|
||||
when (val r = sendCmd(rh, CC.ApiMembersRole(groupId, memberIds, memberRole))) {
|
||||
is CR.MembersRoleUser -> r.members
|
||||
else -> {
|
||||
if (!(networkErrorAlert(r))) {
|
||||
apiErrorAlert("apiMemberRole", generalGetString(MR.strings.error_changing_role), r)
|
||||
apiErrorAlert("apiMembersRole", generalGetString(MR.strings.error_changing_role), r)
|
||||
}
|
||||
throw Exception("failed to change member role: ${r.responseType} ${r.details}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiBlockMemberForAll(rh: Long?, groupId: Long, memberId: Long, blocked: Boolean): GroupMember =
|
||||
when (val r = sendCmd(rh, CC.ApiBlockMemberForAll(groupId, memberId, blocked))) {
|
||||
is CR.MemberBlockedForAllUser -> r.member
|
||||
suspend fun apiBlockMembersForAll(rh: Long?, groupId: Long, memberIds: List<Long>, blocked: Boolean): List<GroupMember> =
|
||||
when (val r = sendCmd(rh, CC.ApiBlockMembersForAll(groupId, memberIds, blocked))) {
|
||||
is CR.MembersBlockedForAllUser -> r.members
|
||||
else -> {
|
||||
if (!(networkErrorAlert(r))) {
|
||||
apiErrorAlert("apiBlockMemberForAll", generalGetString(MR.strings.error_blocking_member_for_all), r)
|
||||
apiErrorAlert("apiBlockMembersForAll", generalGetString(MR.strings.error_blocking_member_for_all), r)
|
||||
}
|
||||
throw Exception("failed to block member for all: ${r.responseType} ${r.details}")
|
||||
}
|
||||
@@ -2723,13 +2723,17 @@ object ChatController {
|
||||
upsertGroupMember(rhId, r.groupInfo, r.member)
|
||||
}
|
||||
}
|
||||
is CR.MemberRoleUser ->
|
||||
is CR.MembersRoleUser ->
|
||||
if (active(r.user)) {
|
||||
withChats {
|
||||
upsertGroupMember(rhId, r.groupInfo, r.member)
|
||||
r.members.forEach { member ->
|
||||
upsertGroupMember(rhId, r.groupInfo, member)
|
||||
}
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
upsertGroupMember(rhId, r.groupInfo, r.member)
|
||||
r.members.forEach { member ->
|
||||
upsertGroupMember(rhId, r.groupInfo, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
is CR.MemberBlockedForAll ->
|
||||
@@ -3406,9 +3410,9 @@ sealed class 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 ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC()
|
||||
class ApiBlockMemberForAll(val groupId: Long, val memberId: Long, val blocked: Boolean): CC()
|
||||
class ApiRemoveMember(val groupId: Long, val memberId: Long): 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>): CC()
|
||||
class ApiLeaveGroup(val groupId: Long): CC()
|
||||
class ApiListMembers(val groupId: Long): CC()
|
||||
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
|
||||
@@ -3591,9 +3595,9 @@ sealed class CC {
|
||||
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
|
||||
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
|
||||
is ApiJoinGroup -> "/_join #$groupId"
|
||||
is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}"
|
||||
is ApiBlockMemberForAll -> "/_block #$groupId $memberId blocked=${onOff(blocked)}"
|
||||
is ApiRemoveMember -> "/_remove #$groupId $memberId"
|
||||
is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}"
|
||||
is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}"
|
||||
is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")}"
|
||||
is ApiLeaveGroup -> "/_leave #$groupId"
|
||||
is ApiListMembers -> "/_members #$groupId"
|
||||
is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}"
|
||||
@@ -3754,9 +3758,9 @@ sealed class CC {
|
||||
is ApiNewGroup -> "apiNewGroup"
|
||||
is ApiAddMember -> "apiAddMember"
|
||||
is ApiJoinGroup -> "apiJoinGroup"
|
||||
is ApiMemberRole -> "apiMemberRole"
|
||||
is ApiBlockMemberForAll -> "apiBlockMemberForAll"
|
||||
is ApiRemoveMember -> "apiRemoveMember"
|
||||
is ApiMembersRole -> "apiMembersRole"
|
||||
is ApiBlockMembersForAll -> "apiBlockMembersForAll"
|
||||
is ApiRemoveMembers -> "apiRemoveMembers"
|
||||
is ApiLeaveGroup -> "apiLeaveGroup"
|
||||
is ApiListMembers -> "apiListMembers"
|
||||
is ApiUpdateGroupProfile -> "apiUpdateGroupProfile"
|
||||
@@ -5801,16 +5805,16 @@ sealed class CR {
|
||||
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR()
|
||||
@Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR()
|
||||
@Serializable @SerialName("businessLinkConnecting") class BusinessLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val fromContact: Contact): CR()
|
||||
@Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("userDeletedMembers") class UserDeletedMembers(val user: UserRef, val groupInfo: GroupInfo, val members: List<GroupMember>): CR()
|
||||
@Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): 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("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR()
|
||||
@Serializable @SerialName("memberRoleUser") class MemberRoleUser(val user: UserRef, val groupInfo: GroupInfo, 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()
|
||||
@Serializable @SerialName("memberBlockedForAllUser") class MemberBlockedForAllUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val blocked: Boolean): CR()
|
||||
@Serializable @SerialName("membersBlockedForAllUser") class MembersBlockedForAllUser(val user: UserRef, val groupInfo: GroupInfo, val members: List<GroupMember>, val blocked: Boolean): CR()
|
||||
@Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR()
|
||||
@Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@@ -5986,16 +5990,16 @@ sealed class CR {
|
||||
is UserAcceptedGroupSent -> "userAcceptedGroupSent"
|
||||
is GroupLinkConnecting -> "groupLinkConnecting"
|
||||
is BusinessLinkConnecting -> "businessLinkConnecting"
|
||||
is UserDeletedMember -> "userDeletedMember"
|
||||
is UserDeletedMembers -> "userDeletedMembers"
|
||||
is LeftMemberUser -> "leftMemberUser"
|
||||
is GroupMembers -> "groupMembers"
|
||||
is ReceivedGroupInvitation -> "receivedGroupInvitation"
|
||||
is GroupDeletedUser -> "groupDeletedUser"
|
||||
is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting"
|
||||
is MemberRole -> "memberRole"
|
||||
is MemberRoleUser -> "memberRoleUser"
|
||||
is MembersRoleUser -> "membersRoleUser"
|
||||
is MemberBlockedForAll -> "memberBlockedForAll"
|
||||
is MemberBlockedForAllUser -> "memberBlockedForAllUser"
|
||||
is MembersBlockedForAllUser -> "membersBlockedForAllUser"
|
||||
is DeletedMemberUser -> "deletedMemberUser"
|
||||
is DeletedMember -> "deletedMember"
|
||||
is LeftMember -> "leftMember"
|
||||
@@ -6164,16 +6168,16 @@ sealed class CR {
|
||||
is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
|
||||
is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember")
|
||||
is BusinessLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nfromContact: $fromContact")
|
||||
is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
|
||||
is UserDeletedMembers -> withUser(user, "groupInfo: $groupInfo\nmembers: $members")
|
||||
is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo))
|
||||
is GroupMembers -> withUser(user, json.encodeToString(group))
|
||||
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 MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole")
|
||||
is MemberRoleUser -> withUser(user, "groupInfo: $groupInfo\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")
|
||||
is MemberBlockedForAllUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nblocked: $blocked")
|
||||
is MembersBlockedForAllUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\nblocked: $blocked")
|
||||
is DeletedMemberUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
|
||||
is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember")
|
||||
is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
|
||||
|
||||
+2
-1
@@ -1398,7 +1398,8 @@ private suspend fun afterSetChatTTL(rhId: Long?, chatInfo: ChatInfo, progressInd
|
||||
chat,
|
||||
navInfo,
|
||||
contentTag = null,
|
||||
pagination = pagination
|
||||
pagination = pagination,
|
||||
openAroundItemId = null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "apiGetChat error: ${e.stackTraceToString()}")
|
||||
|
||||
+22
-7
@@ -28,13 +28,15 @@ suspend fun apiLoadMessages(
|
||||
contentTag: MsgContentTag?,
|
||||
pagination: ChatPagination,
|
||||
search: String = "",
|
||||
openAroundItemId: Long? = null,
|
||||
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
|
||||
) = coroutineScope {
|
||||
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, 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
|
||||
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last)
|
||||
/** 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)
|
||||
|| !isActive) return@coroutineScope
|
||||
processLoadedChat(chat, navInfo, contentTag, pagination, visibleItemIndexesNonReversed)
|
||||
processLoadedChat(chat, navInfo, contentTag, pagination, openAroundItemId, visibleItemIndexesNonReversed)
|
||||
}
|
||||
|
||||
suspend fun processLoadedChat(
|
||||
@@ -42,6 +44,7 @@ suspend fun processLoadedChat(
|
||||
navInfo: NavigationInfo,
|
||||
contentTag: MsgContentTag?,
|
||||
pagination: ChatPagination,
|
||||
openAroundItemId: Long?,
|
||||
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
|
||||
) {
|
||||
val chatState = chatModel.chatStateForContent(contentTag)
|
||||
@@ -67,7 +70,7 @@ suspend fun processLoadedChat(
|
||||
withChats(contentTag) {
|
||||
chatItemStatuses.clear()
|
||||
chatItems.replaceAll(chat.chatItems)
|
||||
chatModel.chatId.value = chat.chatInfo.id
|
||||
chatModel.chatId.value = chat.id
|
||||
splits.value = newSplits
|
||||
if (chat.chatItems.isNotEmpty()) {
|
||||
unreadAfterItemId.value = chat.chatItems.last().id
|
||||
@@ -119,10 +122,15 @@ suspend fun processLoadedChat(
|
||||
}
|
||||
}
|
||||
is ChatPagination.Around -> {
|
||||
newItems.addAll(oldItems)
|
||||
val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)
|
||||
val newSplits = if (openAroundItemId == null) {
|
||||
newItems.addAll(oldItems)
|
||||
removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
// currently, items will always be added on top, which is index 0
|
||||
newItems.addAll(0, chat.chatItems)
|
||||
|
||||
withChats(contentTag) {
|
||||
chatItems.replaceAll(newItems)
|
||||
splits.value = listOf(chat.chatItems.last().id) + newSplits
|
||||
@@ -130,8 +138,15 @@ suspend fun processLoadedChat(
|
||||
totalAfter.value = navInfo.afterTotal
|
||||
unreadTotal.value = chat.chatStats.unreadCount
|
||||
unreadAfter.value = navInfo.afterUnread
|
||||
// no need to set it, count will be wrong
|
||||
// unreadAfterNewestLoaded.value = navInfo.afterUnread
|
||||
|
||||
if (openAroundItemId != null) {
|
||||
unreadAfterNewestLoaded.value = navInfo.afterUnread
|
||||
chatModel.openAroundItemId.value = openAroundItemId
|
||||
chatModel.chatId.value = chat.id
|
||||
} else {
|
||||
// no need to set it, count will be wrong
|
||||
// unreadAfterNewestLoaded.value = navInfo.afterUnread
|
||||
}
|
||||
}
|
||||
}
|
||||
is ChatPagination.Last -> {
|
||||
|
||||
+49
-13
@@ -133,9 +133,12 @@ fun ChatView(
|
||||
|
||||
SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) {
|
||||
val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value ->
|
||||
if (searchText.value == value) return@onSearchValueChanged
|
||||
val c = chatModel.getChat(chatInfo.id) ?: return@onSearchValueChanged
|
||||
if (chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged
|
||||
val sameText = searchText.value == value
|
||||
// showSearch can be false with empty text when it was closed manually after clicking on message from search to load .around it
|
||||
// (required on Android to have this check to prevent call to search with old text)
|
||||
val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && contentTag == null
|
||||
val c = chatModel.getChat(chatInfo.id)
|
||||
if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged
|
||||
withBGApi {
|
||||
apiFindMessages(c, value, contentTag)
|
||||
searchText.value = value
|
||||
@@ -344,7 +347,7 @@ fun ChatView(
|
||||
val c = chatModel.getChat(chatId)
|
||||
if (chatModel.chatId.value != chatId) return@ChatLayout
|
||||
if (c != null) {
|
||||
apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, visibleItemIndexes)
|
||||
apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, null, visibleItemIndexes)
|
||||
}
|
||||
},
|
||||
deleteMessage = { itemId, mode ->
|
||||
@@ -602,6 +605,10 @@ fun ChatView(
|
||||
},
|
||||
changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) },
|
||||
onSearchValueChanged = onSearchValueChanged,
|
||||
closeSearch = {
|
||||
showSearch.value = false
|
||||
searchText.value = ""
|
||||
},
|
||||
onComposed,
|
||||
developerTools = chatModel.controller.appPrefs.developerTools.get(),
|
||||
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
|
||||
@@ -699,6 +706,7 @@ fun ChatLayout(
|
||||
markChatRead: () -> Unit,
|
||||
changeNtfsState: (MsgFilter, currentValue: MutableState<MsgFilter>) -> Unit,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
closeSearch: () -> Unit,
|
||||
onComposed: suspend (chatId: String) -> Unit,
|
||||
developerTools: Boolean,
|
||||
showViaProxy: Boolean,
|
||||
@@ -751,7 +759,7 @@ fun ChatLayout(
|
||||
useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports,
|
||||
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
|
||||
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
|
||||
setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy,
|
||||
setReaction, showItemDetails, markItemsRead, markChatRead, closeSearch, remember { { onComposed(it) } }, developerTools, showViaProxy,
|
||||
)
|
||||
}
|
||||
if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) {
|
||||
@@ -1160,11 +1168,13 @@ fun BoxScope.ChatItemsList(
|
||||
showItemDetails: (ChatInfo, ChatItem) -> Unit,
|
||||
markItemsRead: (List<Long>) -> Unit,
|
||||
markChatRead: () -> Unit,
|
||||
closeSearch: () -> Unit,
|
||||
onComposed: suspend (chatId: String) -> Unit,
|
||||
developerTools: Boolean,
|
||||
showViaProxy: Boolean
|
||||
) {
|
||||
val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } }
|
||||
val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } }
|
||||
val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf<Long>()) }
|
||||
val contentTag = LocalContentTag.current
|
||||
// not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state
|
||||
@@ -1177,15 +1187,29 @@ fun BoxScope.ChatItemsList(
|
||||
val reportsCount = reportsCount(chatInfo.id)
|
||||
val topPaddingToContent = topPaddingToContent(chatView = contentTag == null, contentTag == null && reportsCount > 0)
|
||||
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() })
|
||||
val numberOfBottomAppBars = numberOfBottomAppBars()
|
||||
/** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of
|
||||
* [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears
|
||||
* */
|
||||
val maxHeightForList = rememberUpdatedState(
|
||||
with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * 2).roundToPx() }
|
||||
with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * numberOfBottomAppBars).roundToPx() }
|
||||
)
|
||||
val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, saver = LazyListState.Saver) {
|
||||
val index = mergedItems.value.items.indexOfLast { it.hasUnread() }
|
||||
val resetListState = remember { mutableStateOf(false) }
|
||||
remember(chatModel.openAroundItemId.value) {
|
||||
if (chatModel.openAroundItemId.value != null) {
|
||||
closeSearch()
|
||||
resetListState.value = !resetListState.value
|
||||
}
|
||||
}
|
||||
val highlightedItems = remember { mutableStateOf(setOf<Long>()) }
|
||||
val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) {
|
||||
val openAroundItemId = chatModel.openAroundItemId.value
|
||||
val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() }
|
||||
val reportsState = reportsListState
|
||||
if (openAroundItemId != null) {
|
||||
highlightedItems.value += openAroundItemId
|
||||
chatModel.openAroundItemId.value = null
|
||||
}
|
||||
if (reportsState != null) {
|
||||
reportsListState = null
|
||||
reportsState
|
||||
@@ -1221,7 +1245,6 @@ fun BoxScope.ChatItemsList(
|
||||
|
||||
val remoteHostIdUpdated = rememberUpdatedState(remoteHostId)
|
||||
val chatInfoUpdated = rememberUpdatedState(chatInfo)
|
||||
val highlightedItems = remember { mutableStateOf(setOf<Long>()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollToItem: (Long) -> Unit = remember {
|
||||
// In group reports just set the itemId to scroll to so the main ChatView will handle scrolling
|
||||
@@ -1238,7 +1261,7 @@ fun BoxScope.ChatItemsList(
|
||||
scrollToItemId.value = null }
|
||||
}
|
||||
}
|
||||
LoadLastItems(loadingMoreItems, remoteHostId, chatInfo)
|
||||
LoadLastItems(loadingMoreItems, resetListState, remoteHostId, chatInfo)
|
||||
SmallScrollOnNewMessage(listState, reversedChatItems)
|
||||
val finishedInitialComposition = remember { mutableStateOf(false) }
|
||||
NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed)
|
||||
@@ -1296,7 +1319,7 @@ fun BoxScope.ChatItemsList(
|
||||
highlightedItems.value = setOf()
|
||||
}
|
||||
}
|
||||
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
|
||||
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1567,9 +1590,9 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadLastItems(loadingMoreItems: MutableState<Boolean>, remoteHostId: Long?, chatInfo: ChatInfo) {
|
||||
private fun LoadLastItems(loadingMoreItems: MutableState<Boolean>, resetListState: State<Boolean>, remoteHostId: Long?, chatInfo: ChatInfo) {
|
||||
val contentTag = LocalContentTag.current
|
||||
LaunchedEffect(remoteHostId, chatInfo.id) {
|
||||
LaunchedEffect(remoteHostId, chatInfo.id, resetListState.value) {
|
||||
try {
|
||||
loadingMoreItems.value = true
|
||||
if (chatModel.chatStateForContent(contentTag).totalAfter.value <= 0) return@LaunchedEffect
|
||||
@@ -1888,6 +1911,17 @@ fun topPaddingToContent(chatView: Boolean, additionalTopBar: Boolean = false): D
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun numberOfBottomAppBars(): Int {
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val chatBottomBar = remember { appPrefs.chatBottomBar.state }
|
||||
return if (oneHandUI.value && chatBottomBar.value) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FloatingDate(
|
||||
modifier: Modifier,
|
||||
@@ -2798,6 +2832,7 @@ fun PreviewChatLayout() {
|
||||
markChatRead = {},
|
||||
changeNtfsState = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
closeSearch = {},
|
||||
onComposed = {},
|
||||
developerTools = false,
|
||||
showViaProxy = false,
|
||||
@@ -2874,6 +2909,7 @@ fun PreviewGroupChatLayout() {
|
||||
markChatRead = {},
|
||||
changeNtfsState = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
closeSearch = {},
|
||||
onComposed = {},
|
||||
developerTools = false,
|
||||
showViaProxy = false,
|
||||
|
||||
+8
-4
@@ -213,13 +213,17 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe
|
||||
confirmText = generalGetString(MR.strings.remove_member_confirmation),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
val updatedMember = chatModel.controller.apiRemoveMember(rhId, groupInfo.groupId, mem.groupMemberId)
|
||||
if (updatedMember != null) {
|
||||
val updatedMembers = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, listOf(mem.groupMemberId))
|
||||
if (updatedMembers != null) {
|
||||
withChats {
|
||||
upsertGroupMember(rhId, groupInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember ->
|
||||
upsertGroupMember(rhId, groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
upsertGroupMember(rhId, groupInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember ->
|
||||
upsertGroupMember(rhId, groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-10
@@ -142,12 +142,16 @@ fun GroupMemberInfoView(
|
||||
}) {
|
||||
withBGApi {
|
||||
kotlin.runCatching {
|
||||
val mem = chatModel.controller.apiMemberRole(rhId, groupInfo.groupId, member.groupMemberId, it)
|
||||
val members = chatModel.controller.apiMembersRole(rhId, groupInfo.groupId, listOf(member.groupMemberId), it)
|
||||
withChats {
|
||||
upsertGroupMember(rhId, groupInfo, mem)
|
||||
members.forEach { member ->
|
||||
upsertGroupMember(rhId, groupInfo, member)
|
||||
}
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
upsertGroupMember(rhId, groupInfo, mem)
|
||||
members.forEach { member ->
|
||||
upsertGroupMember(rhId, groupInfo, member)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
newRole.value = prevValue
|
||||
@@ -257,13 +261,17 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c
|
||||
confirmText = generalGetString(MR.strings.remove_member_confirmation),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
val removedMember = chatModel.controller.apiRemoveMember(rhId, member.groupId, member.groupMemberId)
|
||||
if (removedMember != null) {
|
||||
val removedMembers = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId))
|
||||
if (removedMembers != null) {
|
||||
withChats {
|
||||
upsertGroupMember(rhId, groupInfo, removedMember)
|
||||
removedMembers.forEach { removedMember ->
|
||||
upsertGroupMember(rhId, groupInfo, removedMember)
|
||||
}
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
upsertGroupMember(rhId, groupInfo, removedMember)
|
||||
removedMembers.forEach { removedMember ->
|
||||
upsertGroupMember(rhId, groupInfo, removedMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
close?.invoke()
|
||||
@@ -804,12 +812,16 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) {
|
||||
|
||||
fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) {
|
||||
withBGApi {
|
||||
val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked)
|
||||
val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, listOf(member.groupMemberId), blocked)
|
||||
withChats {
|
||||
upsertGroupMember(rhId, gInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember ->
|
||||
upsertGroupMember(rhId, gInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
upsertGroupMember(rhId, gInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember ->
|
||||
upsertGroupMember(rhId, gInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+489
-424
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -202,7 +202,7 @@ fun FramedItemView(
|
||||
Column(
|
||||
Modifier
|
||||
.width(IntrinsicSize.Max)
|
||||
.padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp)
|
||||
.padding(start = if (!sent && tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp)
|
||||
) {
|
||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||
@Composable
|
||||
|
||||
+20
-2
@@ -210,8 +210,26 @@ suspend fun openGroupChat(rhId: Long?, groupId: Long, contentTag: MsgContentTag?
|
||||
|
||||
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, contentTag: MsgContentTag? = null) = openChat(rhId, chatInfo.chatType, chatInfo.apiId, contentTag)
|
||||
|
||||
private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long, contentTag: MsgContentTag? = null) =
|
||||
apiLoadMessages(rhId, chatType, apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
|
||||
suspend fun openChat(
|
||||
rhId: Long?,
|
||||
chatType: ChatType,
|
||||
apiId: Long,
|
||||
contentTag: MsgContentTag? = null,
|
||||
openAroundItemId: Long? = null
|
||||
) =
|
||||
apiLoadMessages(
|
||||
rhId,
|
||||
chatType,
|
||||
apiId,
|
||||
contentTag,
|
||||
if (openAroundItemId != null) {
|
||||
ChatPagination.Around(openAroundItemId, ChatPagination.INITIAL_COUNT)
|
||||
} else {
|
||||
ChatPagination.Initial(ChatPagination.INITIAL_COUNT)
|
||||
},
|
||||
"",
|
||||
openAroundItemId
|
||||
)
|
||||
|
||||
suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) {
|
||||
withChats(contentTag) {
|
||||
|
||||
@@ -1471,6 +1471,7 @@
|
||||
|
||||
<!-- DatabaseErrorView.kt -->
|
||||
<string name="wrong_passphrase">Wrong database passphrase</string>
|
||||
<string name="error_reading_passphrase">Error reading database passphrase</string>
|
||||
<string name="encrypted_database">Encrypted database</string>
|
||||
<string name="database_error">Database error</string>
|
||||
<string name="keychain_error">Keychain error</string>
|
||||
@@ -1492,7 +1493,9 @@
|
||||
<string name="restore_database_alert_desc">Please enter the previous password after restoring database backup. This action can not be undone.</string>
|
||||
<string name="restore_database_alert_confirm">Restore</string>
|
||||
<string name="database_restore_error">Restore database error</string>
|
||||
<string name="restore_passphrase_not_found_desc">Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please, contact developers.</string>
|
||||
<string name="restore_passphrase_not_found_desc">Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please contact developers.</string>
|
||||
<string name="restore_passphrase_can_not_be_read_desc">Passphrase in Keystore can\'t be read. This may have happened after system update incompatible with the app. If it\'s not the case, please contact developers.</string>
|
||||
<string name="restore_passphrase_can_not_be_read_enter_manually_desc">Passphrase in Keystore can\'t be read, please enter it manually. This may have happened after system update incompatible with the app. If it\'s not the case, please contact developers.</string>
|
||||
<string name="database_upgrade">Database upgrade</string>
|
||||
<string name="database_downgrade">Database downgrade</string>
|
||||
<string name="incompatible_database_version">Incompatible database version</string>
|
||||
|
||||
@@ -362,9 +362,9 @@ data ChatCommand
|
||||
| APIAddMember GroupId ContactId GroupMemberRole
|
||||
| APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter}
|
||||
| APIAcceptMember GroupId GroupMemberId GroupMemberRole
|
||||
| APIMemberRole GroupId GroupMemberId GroupMemberRole
|
||||
| APIBlockMemberForAll GroupId GroupMemberId Bool
|
||||
| APIRemoveMember GroupId GroupMemberId
|
||||
| APIMembersRole GroupId (NonEmpty GroupMemberId) GroupMemberRole
|
||||
| APIBlockMembersForAll GroupId (NonEmpty GroupMemberId) Bool
|
||||
| APIRemoveMembers GroupId (NonEmpty GroupMemberId)
|
||||
| APILeaveGroup GroupId
|
||||
| APIListMembers GroupId
|
||||
| APIUpdateGroupProfile GroupId GroupProfile
|
||||
@@ -669,7 +669,7 @@ data ChatResponse
|
||||
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
|
||||
| CRGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember}
|
||||
| CRBusinessLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, fromContact :: Contact}
|
||||
| CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
|
||||
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]}
|
||||
| CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]}
|
||||
| CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember}
|
||||
| CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus
|
||||
@@ -754,9 +754,9 @@ data ChatResponse
|
||||
| CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
|
||||
| CRJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember}
|
||||
| CRMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole}
|
||||
| CRMemberRoleUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole}
|
||||
| CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole}
|
||||
| CRMemberBlockedForAll {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, blocked :: Bool}
|
||||
| CRMemberBlockedForAllUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, blocked :: Bool}
|
||||
| CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool}
|
||||
| CRConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact}
|
||||
| CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember}
|
||||
| CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
|
||||
|
||||
@@ -2036,75 +2036,170 @@ processChatCommand' vr = \case
|
||||
introduceToGroup vr user gInfo m'
|
||||
pure $ CRJoinedGroupMember user gInfo m'
|
||||
_ -> throwChatError CEGroupMemberNotActive
|
||||
APIMemberRole groupId memberId memRole -> withUser $ \user -> do
|
||||
Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId
|
||||
if memberId == groupMemberId' membership
|
||||
then changeMemberRole user gInfo members membership $ SGEUserRole memRole
|
||||
else case find ((== memberId) . groupMemberId') members of
|
||||
Just m -> changeMemberRole user gInfo members m $ SGEMemberRole memberId (fromLocalProfile $ memberProfile m) memRole
|
||||
_ -> throwChatError CEGroupMemberNotFound
|
||||
APIMembersRole groupId memberIds newRole -> withUser $ \user ->
|
||||
withGroupLock "memberRole" groupId . procCmd $ do
|
||||
g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId
|
||||
when (selfSelected gInfo) $ throwChatError $ CECommandError "can't change role for self"
|
||||
let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin) = selectMembers members
|
||||
when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound
|
||||
when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $
|
||||
throwChatError $ CECommandError "can't change role of multiple members when admins selected, or new role is admin"
|
||||
assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole])
|
||||
(errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems
|
||||
(errs2, changed2, acis) <- changeRoleCurrentMems user g currentMems
|
||||
unless (null acis) $ toView $ CRNewChatItems user acis
|
||||
let errs = errs1 <> errs2
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole} -- same order is not guaranteed
|
||||
where
|
||||
changeMemberRole user gInfo members m gEvent = do
|
||||
let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m
|
||||
assertUserGroupRole gInfo $ maximum ([GRAdmin, mRole, memRole] :: [GroupMemberRole])
|
||||
withGroupLock "memberRole" groupId . procCmd $ do
|
||||
unless (mRole == memRole) $ do
|
||||
withFastStore' $ \db -> updateGroupMemberRole db user m memRole
|
||||
case mStatus of
|
||||
GSMemInvited -> do
|
||||
withFastStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case
|
||||
(Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq
|
||||
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
|
||||
_ -> do
|
||||
msg <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole
|
||||
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent gEvent)
|
||||
toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci]
|
||||
pure CRMemberRoleUser {user, groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole}
|
||||
APIBlockMemberForAll groupId memberId blocked -> withUser $ \user -> do
|
||||
Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId
|
||||
when (memberId == groupMemberId' membership) $ throwChatError $ CECommandError "can't block/unblock self"
|
||||
case splitMember memberId members of
|
||||
Nothing -> throwChatError $ CEException "expected to find a single blocked member"
|
||||
Just (bm, remainingMembers) -> do
|
||||
let GroupMember {memberId = bmMemberId, memberRole = bmRole, memberProfile = bmp} = bm
|
||||
-- TODO GRModerator when most users migrate
|
||||
assertUserGroupRole gInfo $ max GRAdmin bmRole
|
||||
when (blocked == blockedByAdmin bm) $ throwChatError $ CECommandError $ if blocked then "already blocked" else "already unblocked"
|
||||
withGroupLock "blockForAll" groupId . procCmd $ do
|
||||
let mrs = if blocked then MRSBlocked else MRSUnrestricted
|
||||
event = XGrpMemRestrict bmMemberId MemberRestrictions {restriction = mrs}
|
||||
msg <- sendGroupMessage' user gInfo remainingMembers event
|
||||
let ciContent = CISndGroupEvent $ SGEMemberBlocked memberId (fromLocalProfile bmp) blocked
|
||||
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg ciContent
|
||||
toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci]
|
||||
bm' <- withFastStore $ \db -> do
|
||||
liftIO $ updateGroupMemberBlocked db user groupId memberId mrs
|
||||
getGroupMember db vr user groupId memberId
|
||||
toggleNtf user bm' (not blocked)
|
||||
pure CRMemberBlockedForAllUser {user, groupInfo = gInfo, member = bm', blocked}
|
||||
selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds
|
||||
selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool)
|
||||
selectMembers = foldr' addMember ([], [], [], GRObserver, False)
|
||||
where
|
||||
addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin)
|
||||
| groupMemberId `elem` memberIds =
|
||||
let maxRole' = max maxRole memberRole
|
||||
anyAdmin' = anyAdmin || memberRole >= GRAdmin
|
||||
in
|
||||
if
|
||||
| memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin')
|
||||
| memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin')
|
||||
| otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin')
|
||||
| otherwise = (invited, current, unchanged, maxRole, anyAdmin)
|
||||
changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember])
|
||||
changeRoleInvitedMems user gInfo memsToChange = do
|
||||
-- not batched, as we need to send different invitations to different connections anyway
|
||||
mems_ <- forM memsToChange $ \m -> (Right <$> changeRole m) `catchChatError` (pure . Left)
|
||||
pure $ partitionEithers mems_
|
||||
where
|
||||
changeRole :: GroupMember -> CM GroupMember
|
||||
changeRole m@GroupMember {groupMemberId, memberContactId, localDisplayName = cName} = do
|
||||
withFastStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user groupMemberId)) >>= \case
|
||||
(Just ct, Just cReq) -> do
|
||||
sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = newRole} cReq
|
||||
withFastStore' $ \db -> updateGroupMemberRole db user m newRole
|
||||
pure (m :: GroupMember) {memberRole = newRole}
|
||||
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
|
||||
changeRoleCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem])
|
||||
changeRoleCurrentMems user (Group gInfo members) memsToChange = case L.nonEmpty memsToChange of
|
||||
Nothing -> pure ([], [], [])
|
||||
Just memsToChange' -> do
|
||||
let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange'
|
||||
(msgs_, _gsr) <- sendGroupMessages user gInfo members events
|
||||
let itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_)
|
||||
cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData Nothing False
|
||||
when (length cis_ /= length memsToChange) $ logError "changeRoleCurrentMems: memsToChange and cis_ length mismatch"
|
||||
(errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange)
|
||||
let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) $ rights cis_
|
||||
pure (errs, changed, acis)
|
||||
where
|
||||
sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c
|
||||
sndItemData GroupMember {groupMemberId, memberProfile} msg =
|
||||
let content = CISndGroupEvent $ SGEMemberRole groupMemberId (fromLocalProfile memberProfile) newRole
|
||||
ts = ciContentTexts content
|
||||
in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing
|
||||
updMember db m = do
|
||||
updateGroupMemberRole db user m newRole
|
||||
pure (m :: GroupMember) {memberRole = newRole}
|
||||
APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user ->
|
||||
withGroupLock "blockForAll" groupId . procCmd $ do
|
||||
Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId
|
||||
when (selfSelected gInfo) $ throwChatError $ CECommandError "can't block/unblock self"
|
||||
let (blockMems, remainingMems, maxRole, anyAdmin) = selectMembers members
|
||||
when (length blockMems /= length memberIds) $ throwChatError CEGroupMemberNotFound
|
||||
when (length memberIds > 1 && anyAdmin) $ throwChatError $ CECommandError "can't block/unblock multiple members when admins selected"
|
||||
assertUserGroupRole gInfo $ max GRModerator maxRole
|
||||
blockMembers user gInfo blockMems remainingMems
|
||||
where
|
||||
splitMember mId ms = case break ((== mId) . groupMemberId') ms of
|
||||
(_, []) -> Nothing
|
||||
(ms1, bm : ms2) -> Just (bm, ms1 <> ms2)
|
||||
APIRemoveMember groupId memberId -> withUser $ \user -> do
|
||||
Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId
|
||||
case find ((== memberId) . groupMemberId') members of
|
||||
Nothing -> throwChatError CEGroupMemberNotFound
|
||||
Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberProfile} -> do
|
||||
assertUserGroupRole gInfo $ max GRAdmin mRole
|
||||
withGroupLock "removeMember" groupId . procCmd $ do
|
||||
case mStatus of
|
||||
GSMemInvited -> do
|
||||
deleteMemberConnection user m
|
||||
withFastStore' $ \db -> deleteGroupMember db user m
|
||||
_ -> do
|
||||
msg <- sendGroupMessage user gInfo members $ XGrpMemDel mId
|
||||
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile))
|
||||
toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci]
|
||||
deleteMemberConnection' user m True
|
||||
-- undeleted "member connected" chat item will prevent deletion of member record
|
||||
deleteOrUpdateMemberRecord user m
|
||||
pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved}
|
||||
selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds
|
||||
selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool)
|
||||
selectMembers = foldr' addMember ([], [], GRObserver, False)
|
||||
where
|
||||
addMember m@GroupMember {groupMemberId, memberRole} (block, remaining, maxRole, anyAdmin)
|
||||
| groupMemberId `elem` memberIds =
|
||||
let maxRole' = max maxRole memberRole
|
||||
anyAdmin' = anyAdmin || memberRole >= GRAdmin
|
||||
in (m : block, remaining, maxRole', anyAdmin')
|
||||
| otherwise = (block, m : remaining, maxRole, anyAdmin)
|
||||
blockMembers :: User -> GroupInfo -> [GroupMember] -> [GroupMember] -> CM ChatResponse
|
||||
blockMembers user gInfo blockMems remainingMems = case L.nonEmpty blockMems of
|
||||
Nothing -> throwChatError $ CECommandError "no members to block/unblock"
|
||||
Just blockMems' -> do
|
||||
let mrs = if blockFlag then MRSBlocked else MRSUnrestricted
|
||||
events = L.map (\GroupMember {memberId} -> XGrpMemRestrict memberId MemberRestrictions {restriction = mrs}) blockMems'
|
||||
(msgs_, _gsr) <- sendGroupMessages user gInfo remainingMems events
|
||||
let itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_)
|
||||
cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData Nothing False
|
||||
when (length cis_ /= length blockMems) $ logError "blockMembers: blockMems and cis_ length mismatch"
|
||||
let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) $ rights cis_
|
||||
unless (null acis) $ toView $ CRNewChatItems user acis
|
||||
(errs, blocked) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updateGroupMemberBlocked db user gInfo mrs) blockMems)
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
-- TODO not batched - requires agent batch api
|
||||
forM_ blocked $ \m -> toggleNtf user m (not blockFlag)
|
||||
pure CRMembersBlockedForAllUser {user, groupInfo = gInfo, members = blocked, blocked = blockFlag}
|
||||
where
|
||||
sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c
|
||||
sndItemData GroupMember {groupMemberId, memberProfile} msg =
|
||||
let content = CISndGroupEvent $ SGEMemberBlocked groupMemberId (fromLocalProfile memberProfile) blockFlag
|
||||
ts = ciContentTexts content
|
||||
in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing
|
||||
APIRemoveMembers groupId memberIds -> withUser $ \user ->
|
||||
withGroupLock "removeMembers" groupId . procCmd $ do
|
||||
g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId
|
||||
let (invitedMems, currentMems, maxRole, anyAdmin) = selectMembers members
|
||||
when (length invitedMems + length currentMems /= length memberIds) $ throwChatError CEGroupMemberNotFound
|
||||
when (length memberIds > 1 && anyAdmin) $ throwChatError $ CECommandError "can't remove multiple members when admins selected"
|
||||
assertUserGroupRole gInfo $ max GRAdmin maxRole
|
||||
(errs1, deleted1) <- deleteInvitedMems user invitedMems
|
||||
(errs2, deleted2, acis) <- deleteCurrentMems user g currentMems
|
||||
unless (null acis) $ toView $ CRNewChatItems user acis
|
||||
let errs = errs1 <> errs2
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
pure $ CRUserDeletedMembers user gInfo (deleted1 <> deleted2) -- same order is not guaranteed
|
||||
where
|
||||
selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool)
|
||||
selectMembers = foldr' addMember ([], [], GRObserver, False)
|
||||
where
|
||||
addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, maxRole, anyAdmin)
|
||||
| groupMemberId `elem` memberIds =
|
||||
let maxRole' = max maxRole memberRole
|
||||
anyAdmin' = anyAdmin || memberRole >= GRAdmin
|
||||
in
|
||||
if memberStatus == GSMemInvited
|
||||
then (m : invited, current, maxRole', anyAdmin')
|
||||
else (invited, m : current, maxRole', anyAdmin')
|
||||
| otherwise = (invited, current, maxRole, anyAdmin)
|
||||
deleteInvitedMems :: User -> [GroupMember] -> CM ([ChatError], [GroupMember])
|
||||
deleteInvitedMems user memsToDelete = do
|
||||
deleteMembersConnections user memsToDelete
|
||||
lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete)
|
||||
where
|
||||
delMember db m = do
|
||||
deleteGroupMember db user m
|
||||
pure m {memberStatus = GSMemRemoved}
|
||||
deleteCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem])
|
||||
deleteCurrentMems user (Group gInfo members) memsToDelete = case L.nonEmpty memsToDelete of
|
||||
Nothing -> pure ([], [], [])
|
||||
Just memsToDelete' -> do
|
||||
let events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId) memsToDelete'
|
||||
(msgs_, _gsr) <- sendGroupMessages user gInfo members events
|
||||
let itemsData = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_)
|
||||
cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData Nothing False
|
||||
when (length cis_ /= length memsToDelete) $ logError "deleteCurrentMems: memsToDelete and cis_ length mismatch"
|
||||
deleteMembersConnections' user memsToDelete True
|
||||
(errs, deleted) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete)
|
||||
let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) $ rights cis_
|
||||
pure (errs, deleted, acis)
|
||||
where
|
||||
sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c
|
||||
sndItemData GroupMember {groupMemberId, memberProfile} msg =
|
||||
let content = CISndGroupEvent $ SGEMemberDeleted groupMemberId (fromLocalProfile memberProfile)
|
||||
ts = ciContentTexts content
|
||||
in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing
|
||||
delMember db m = do
|
||||
deleteOrUpdateMemberRecordIO db user m
|
||||
pure m {memberStatus = GSMemRemoved}
|
||||
APILeaveGroup groupId -> withUser $ \user@User {userId} -> do
|
||||
Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId
|
||||
filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo
|
||||
@@ -2127,18 +2222,14 @@ processChatCommand' vr = \case
|
||||
JoinGroup gName enableNtfs -> withUser $ \user -> do
|
||||
groupId <- withFastStore $ \db -> getGroupIdByName db user gName
|
||||
processChatCommand $ APIJoinGroup groupId enableNtfs
|
||||
MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMemberRole gId gMemberId memRole
|
||||
BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMemberForAll gId gMemberId blocked
|
||||
MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMembersRole gId [gMemberId] memRole
|
||||
BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMembersForAll gId [gMemberId] blocked
|
||||
RemoveMembers gName gMemberNames -> withUser $ \user -> do
|
||||
(gId, gMemberIds) <- withStore $ \db -> do
|
||||
gId <- getGroupIdByName db user gName
|
||||
gMemberIds <- forM gMemberNames $ getGroupMemberIdByName db user gId
|
||||
pure (gId, gMemberIds)
|
||||
rs <- forM (L.zip (L.fromList [1..]) gMemberIds) $ \(i, memId) -> do
|
||||
r <- processChatCommand (APIRemoveMember gId memId)
|
||||
when (i < length gMemberIds) $ toView r
|
||||
pure r
|
||||
pure $ L.last rs
|
||||
processChatCommand $ APIRemoveMembers gId gMemberIds
|
||||
LeaveGroup gName -> withUser $ \user -> do
|
||||
groupId <- withFastStore $ \db -> getGroupIdByName db user gName
|
||||
processChatCommand $ APILeaveGroup groupId
|
||||
@@ -3116,7 +3207,7 @@ processChatCommand' vr = \case
|
||||
(msgs_, gsr) <- sendGroupMessages user gInfo ms chatMsgEvents
|
||||
let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_)
|
||||
cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live
|
||||
when (length itemsData /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch"
|
||||
when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch"
|
||||
createMemberSndStatuses cis_ msgs_ gsr
|
||||
let r@(_, cis) = partitionEithers cis_
|
||||
processSendErrs user r
|
||||
@@ -3827,9 +3918,9 @@ chatCommandP =
|
||||
"/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole),
|
||||
"/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI
|
||||
"/_accept member #" *> (APIAcceptMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole),
|
||||
"/_member role #" *> (APIMemberRole <$> A.decimal <* A.space <*> A.decimal <*> memberRole),
|
||||
"/_block #" *> (APIBlockMemberForAll <$> A.decimal <* A.space <*> A.decimal <* A.space <* "blocked=" <*> onOffP),
|
||||
"/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal),
|
||||
"/_member role #" *> (APIMembersRole <$> A.decimal <*> _strP <*> memberRole),
|
||||
"/_block #" *> (APIBlockMembersForAll <$> A.decimal <*> _strP <* A.space <* "blocked=" <*> onOffP),
|
||||
"/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP),
|
||||
"/_leave #" *> (APILeaveGroup <$> A.decimal),
|
||||
"/_members #" *> (APIListMembers <$> A.decimal),
|
||||
"/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP),
|
||||
|
||||
@@ -1381,11 +1381,14 @@ deleteMemberConnection' user GroupMember {activeConn} waitDelivery = do
|
||||
withStore' $ \db -> updateConnectionStatus db conn ConnDeleted
|
||||
|
||||
deleteOrUpdateMemberRecord :: User -> GroupMember -> CM ()
|
||||
deleteOrUpdateMemberRecord user@User {userId} member =
|
||||
withStore' $ \db ->
|
||||
checkGroupMemberHasItems db user member >>= \case
|
||||
Just _ -> updateGroupMemberStatus db userId member GSMemRemoved
|
||||
Nothing -> deleteGroupMember db user member
|
||||
deleteOrUpdateMemberRecord user member =
|
||||
withStore' $ \db -> deleteOrUpdateMemberRecordIO db user member
|
||||
|
||||
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
|
||||
|
||||
sendDirectContactMessages :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage]
|
||||
sendDirectContactMessages user ct events = do
|
||||
|
||||
@@ -2506,7 +2506,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
|
||||
xGrpMemRestrict :: GroupInfo -> GroupMember -> MemberId -> MemberRestrictions -> RcvMessage -> UTCTime -> CM ()
|
||||
xGrpMemRestrict
|
||||
gInfo@GroupInfo {groupId, membership = GroupMember {memberId = membershipMemId}}
|
||||
gInfo@GroupInfo {membership = GroupMember {memberId = membershipMemId}}
|
||||
m@GroupMember {memberRole = senderRole}
|
||||
memId
|
||||
MemberRestrictions {restriction}
|
||||
@@ -2517,10 +2517,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
messageError "x.grp.mem.restrict: admin blocks you"
|
||||
| otherwise =
|
||||
withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case
|
||||
Right bm@GroupMember {groupMemberId = bmId, memberRole, memberProfile = bmp}
|
||||
Right bm@GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp}
|
||||
| blockedByAdmin == mrsBlocked restriction -> pure ()
|
||||
| senderRole < GRModerator || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions"
|
||||
| otherwise -> do
|
||||
bm' <- setMemberBlocked bmId
|
||||
bm' <- setMemberBlocked bm
|
||||
toggleNtf user bm' (not blocked)
|
||||
let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked
|
||||
ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs ciContent
|
||||
@@ -2528,14 +2529,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
toView CRMemberBlockedForAll {user, groupInfo = gInfo, byMember = m, member = bm, blocked}
|
||||
Left (SEGroupMemberNotFoundByMemberId _) -> do
|
||||
bm <- createUnknownMember gInfo memId
|
||||
bm' <- setMemberBlocked $ groupMemberId' bm
|
||||
bm' <- setMemberBlocked bm
|
||||
toView $ CRUnknownMemberBlocked user gInfo m bm'
|
||||
Left e -> throwError $ ChatErrorStore e
|
||||
where
|
||||
setMemberBlocked bmId =
|
||||
withStore $ \db -> do
|
||||
liftIO $ updateGroupMemberBlocked db user groupId bmId restriction
|
||||
getGroupMember db vr user groupId bmId
|
||||
setMemberBlocked bm = withStore' $ \db -> updateGroupMemberBlocked db user gInfo restriction bm
|
||||
blocked = mrsBlocked restriction
|
||||
|
||||
xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> CM ()
|
||||
|
||||
@@ -33,9 +33,9 @@ data RcvGroupEvent
|
||||
|
||||
data SndGroupEvent
|
||||
= SGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole}
|
||||
| SGEMemberBlocked {groupMemberId :: GroupMemberId, profile :: Profile, blocked :: Bool} -- CRMemberBlockedForAllUser
|
||||
| SGEMemberBlocked {groupMemberId :: GroupMemberId, profile :: Profile, blocked :: Bool} -- CRMembersBlockedForAllUser
|
||||
| SGEUserRole {role :: GroupMemberRole}
|
||||
| SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember
|
||||
| SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMembers
|
||||
| SGEUserLeft -- CRLeftMemberUser
|
||||
| SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated
|
||||
deriving (Show)
|
||||
|
||||
@@ -2077,8 +2077,8 @@ updateGroupMemberSettings db User {userId} gId gMemberId GroupMemberSettings {sh
|
||||
|]
|
||||
(BI showMessages, currentTs, userId, gId, gMemberId)
|
||||
|
||||
updateGroupMemberBlocked :: DB.Connection -> User -> GroupId -> GroupMemberId -> MemberRestrictionStatus -> IO ()
|
||||
updateGroupMemberBlocked db User {userId} gId gMemberId memberBlocked = do
|
||||
updateGroupMemberBlocked :: DB.Connection -> User -> GroupInfo -> MemberRestrictionStatus -> GroupMember -> IO GroupMember
|
||||
updateGroupMemberBlocked db User {userId} GroupInfo {groupId} mrs m@GroupMember {groupMemberId} = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
@@ -2087,7 +2087,8 @@ updateGroupMemberBlocked db User {userId} gId gMemberId memberBlocked = do
|
||||
SET member_restriction = ?, updated_at = ?
|
||||
WHERE user_id = ? AND group_id = ? AND group_member_id = ?
|
||||
|]
|
||||
(memberBlocked, currentTs, userId, gId, gMemberId)
|
||||
(mrs, currentTs, userId, groupId, groupMemberId)
|
||||
pure m {blockedByAdmin = mrsBlocked mrs}
|
||||
|
||||
getXGrpMemIntroContDirect :: DB.Connection -> User -> Contact -> IO (Maybe (Int64, XGrpMemIntroCont))
|
||||
getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do
|
||||
|
||||
+13
-13
@@ -220,7 +220,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."]
|
||||
CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."]
|
||||
CRBusinessLinkConnecting u g _ _ -> ttyUser u [ttyGroup' g <> ": joining the group..."]
|
||||
CRUserDeletedMember u g m -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"]
|
||||
CRUserDeletedMembers u g members -> case members of
|
||||
[m] -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"]
|
||||
mems' -> ttyUser u [ttyGroup' g <> ": you removed " <> sShow (length mems') <> " members from the group"]
|
||||
CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g
|
||||
CRUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um]
|
||||
CRUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um]
|
||||
@@ -301,9 +303,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRJoinedGroupMemberConnecting u g host m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"]
|
||||
CRConnectedToGroupMember u g m _ -> ttyUser u [ttyGroup' g <> ": " <> connectedMember m <> " is connected"]
|
||||
CRMemberRole u g by m r r' -> ttyUser u $ viewMemberRoleChanged g by m r r'
|
||||
CRMemberRoleUser u g m r r' -> ttyUser u $ viewMemberRoleUserChanged g m r r'
|
||||
CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r'
|
||||
CRMemberBlockedForAll u g by m blocked -> ttyUser u $ viewMemberBlockedForAll g by m blocked
|
||||
CRMemberBlockedForAllUser u g m blocked -> ttyUser u $ viewMemberBlockedForAllUser g m blocked
|
||||
CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked
|
||||
CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g
|
||||
CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"]
|
||||
CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"]
|
||||
@@ -1117,21 +1119,19 @@ viewMemberRoleChanged g@GroupInfo {membership} by m r r'
|
||||
memId = groupMemberId' m
|
||||
view s = [ttyGroup' g <> ": " <> ttyMember by <> " changed " <> s <> " from " <> showRole r <> " to " <> showRole r']
|
||||
|
||||
viewMemberRoleUserChanged :: GroupInfo -> GroupMember -> GroupMemberRole -> GroupMemberRole -> [StyledString]
|
||||
viewMemberRoleUserChanged g@GroupInfo {membership} m r r'
|
||||
| r == r' = [ttyGroup' g <> ": member role did not change"]
|
||||
| groupMemberId' membership == groupMemberId' m = view "your role"
|
||||
| otherwise = view $ "the role of " <> ttyMember m
|
||||
where
|
||||
view s = [ttyGroup' g <> ": you changed " <> s <> " from " <> showRole r <> " to " <> showRole r']
|
||||
viewMemberRoleUserChanged :: GroupInfo -> [GroupMember] -> GroupMemberRole -> [StyledString]
|
||||
viewMemberRoleUserChanged g members r = case members of
|
||||
[m] -> [ttyGroup' g <> ": you changed the role of " <> ttyMember m <> " to " <> showRole r]
|
||||
mems' -> [ttyGroup' g <> ": you changed the role of " <> sShow (length mems') <> " members to " <> showRole r]
|
||||
|
||||
viewMemberBlockedForAll :: GroupInfo -> GroupMember -> GroupMember -> Bool -> [StyledString]
|
||||
viewMemberBlockedForAll g by m blocked =
|
||||
[ttyGroup' g <> ": " <> ttyMember by <> " " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m]
|
||||
|
||||
viewMemberBlockedForAllUser :: GroupInfo -> GroupMember -> Bool -> [StyledString]
|
||||
viewMemberBlockedForAllUser g m blocked =
|
||||
[ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m]
|
||||
viewMembersBlockedForAllUser :: GroupInfo -> [GroupMember] -> Bool -> [StyledString]
|
||||
viewMembersBlockedForAllUser g members blocked = case members of
|
||||
[m] -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m]
|
||||
mems' -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> sShow (length mems') <> " members"]
|
||||
|
||||
showRole :: GroupMemberRole -> StyledString
|
||||
showRole = plain . strEncode
|
||||
|
||||
@@ -123,7 +123,7 @@ testDirectoryService ps =
|
||||
bob <# "SimpleX-Directory> You must grant directory service admin role to register the group"
|
||||
bob ##> "/mr PSA SimpleX-Directory admin"
|
||||
-- putStrLn "*** discover service joins group and creates the link for profile"
|
||||
bob <## "#PSA: you changed the role of SimpleX-Directory from member to admin"
|
||||
bob <## "#PSA: you changed the role of SimpleX-Directory to admin"
|
||||
bob <# "SimpleX-Directory> Joining the group PSA…"
|
||||
bob <## "#PSA: SimpleX-Directory joined the group"
|
||||
bob <# "SimpleX-Directory> Joined the group PSA, creating the link…"
|
||||
@@ -580,7 +580,7 @@ testDelistedRoleChanges ps =
|
||||
groupFoundN 3 cath "privacy"
|
||||
-- de-listed if service role changed
|
||||
bob ##> "/mr privacy SimpleX-Directory member"
|
||||
bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member"
|
||||
bob <## "#privacy: you changed the role of SimpleX-Directory to member"
|
||||
cath <## "#privacy: bob changed the role of SimpleX-Directory from admin to member"
|
||||
bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to member."
|
||||
bob <## ""
|
||||
@@ -589,7 +589,7 @@ testDelistedRoleChanges ps =
|
||||
groupNotFound cath "privacy"
|
||||
-- re-listed if service role changed back without profile changes
|
||||
cath ##> "/mr privacy SimpleX-Directory admin"
|
||||
cath <## "#privacy: you changed the role of SimpleX-Directory from member to admin"
|
||||
cath <## "#privacy: you changed the role of SimpleX-Directory to admin"
|
||||
bob <## "#privacy: cath changed the role of SimpleX-Directory from member to admin"
|
||||
bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin."
|
||||
bob <## ""
|
||||
@@ -598,7 +598,7 @@ testDelistedRoleChanges ps =
|
||||
groupFoundN 3 cath "privacy"
|
||||
-- de-listed if owner role changed
|
||||
cath ##> "/mr privacy bob admin"
|
||||
cath <## "#privacy: you changed the role of bob from owner to admin"
|
||||
cath <## "#privacy: you changed the role of bob to admin"
|
||||
bob <## "#privacy: cath changed your role from owner to admin"
|
||||
bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to admin."
|
||||
bob <## ""
|
||||
@@ -607,7 +607,7 @@ testDelistedRoleChanges ps =
|
||||
groupNotFound cath "privacy"
|
||||
-- re-listed if owner role changed back without profile changes
|
||||
cath ##> "/mr privacy bob owner"
|
||||
cath <## "#privacy: you changed the role of bob from admin to owner"
|
||||
cath <## "#privacy: you changed the role of bob to owner"
|
||||
bob <## "#privacy: cath changed your role from admin to owner"
|
||||
bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to owner."
|
||||
bob <## ""
|
||||
@@ -628,7 +628,7 @@ testNotDelistedMemberRoleChanged ps =
|
||||
cath <## "use @SimpleX-Directory <message> to send messages"
|
||||
groupFoundN 3 cath "privacy"
|
||||
bob ##> "/mr privacy cath member"
|
||||
bob <## "#privacy: you changed the role of cath from owner to member"
|
||||
bob <## "#privacy: you changed the role of cath to member"
|
||||
cath <## "#privacy: bob changed your role from owner to member"
|
||||
groupFoundN 3 cath "privacy"
|
||||
|
||||
@@ -642,11 +642,11 @@ testNotSentApprovalBadRoles ps =
|
||||
submitGroup bob "privacy" "Privacy"
|
||||
welcomeWithLink <- groupAccepted bob "privacy"
|
||||
bob ##> "/mr privacy SimpleX-Directory member"
|
||||
bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member"
|
||||
bob <## "#privacy: you changed the role of SimpleX-Directory to member"
|
||||
updateProfileWithLink bob "privacy" welcomeWithLink 1
|
||||
bob <# "SimpleX-Directory> You must grant directory service admin role to register the group"
|
||||
bob ##> "/mr privacy SimpleX-Directory admin"
|
||||
bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin"
|
||||
bob <## "#privacy: you changed the role of SimpleX-Directory to admin"
|
||||
bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin."
|
||||
bob <## ""
|
||||
bob <## "The group is submitted for approval."
|
||||
@@ -667,14 +667,14 @@ testNotApprovedBadRoles ps =
|
||||
updateProfileWithLink bob "privacy" welcomeWithLink 1
|
||||
notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1
|
||||
bob ##> "/mr privacy SimpleX-Directory member"
|
||||
bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member"
|
||||
bob <## "#privacy: you changed the role of SimpleX-Directory to member"
|
||||
let approve = "/approve 1:privacy 1"
|
||||
superUser #> ("@SimpleX-Directory " <> approve)
|
||||
superUser <# ("SimpleX-Directory> > " <> approve)
|
||||
superUser <## " Group is not approved: SimpleX-Directory is not an admin."
|
||||
groupNotFound cath "privacy"
|
||||
bob ##> "/mr privacy SimpleX-Directory admin"
|
||||
bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin"
|
||||
bob <## "#privacy: you changed the role of SimpleX-Directory to admin"
|
||||
bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin."
|
||||
bob <## ""
|
||||
bob <## "The group is submitted for approval."
|
||||
@@ -941,7 +941,7 @@ testListUserGroups ps =
|
||||
-- with de-listed group
|
||||
groupFound cath "anonymity"
|
||||
cath ##> "/mr anonymity SimpleX-Directory member"
|
||||
cath <## "#anonymity: you changed the role of SimpleX-Directory from admin to member"
|
||||
cath <## "#anonymity: you changed the role of SimpleX-Directory to member"
|
||||
cath <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (anonymity) is changed to member."
|
||||
cath <## ""
|
||||
cath <## "The group is no longer listed in the directory."
|
||||
|
||||
+100
-35
@@ -176,7 +176,8 @@ chatGroupTests = do
|
||||
it "messages are fully deleted" testBlockForAllFullDelete
|
||||
it "another admin can unblock" testBlockForAllAnotherAdminUnblocks
|
||||
it "member was blocked before joining group" testBlockForAllBeforeJoining
|
||||
it "can't repeat block, unblock" testBlockForAllCantRepeat
|
||||
it "repeat block, unblock" testBlockForAllRepeat
|
||||
it "block multiple members" testBlockForAllMultipleMembers
|
||||
describe "group member inactivity" $ do
|
||||
it "mark member inactive on reaching quota" testGroupMemberInactive
|
||||
describe "group member reports" $ do
|
||||
@@ -270,7 +271,7 @@ testGroupShared alice bob cath checkMessages = do
|
||||
-- test observer role
|
||||
alice ##> "/mr team bob observer"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of bob from admin to observer",
|
||||
[ alice <## "#team: you changed the role of bob to observer",
|
||||
bob <## "#team: alice changed your role from admin to observer",
|
||||
cath <## "#team: alice changed the role of bob from admin to observer"
|
||||
]
|
||||
@@ -285,7 +286,7 @@ testGroupShared alice bob cath checkMessages = do
|
||||
]
|
||||
alice ##> "/mr team bob admin"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of bob from observer to admin",
|
||||
[ alice <## "#team: you changed the role of bob to admin",
|
||||
bob <## "#team: alice changed your role from observer to admin",
|
||||
cath <## "#team: alice changed the role of bob from observer to admin"
|
||||
]
|
||||
@@ -1465,7 +1466,7 @@ testUpdateMemberRole =
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
addMember "team" alice bob GRAdmin
|
||||
alice ##> "/mr team bob member"
|
||||
alice <## "#team: you changed the role of bob from admin to member"
|
||||
alice <## "#team: you changed the role of bob to member"
|
||||
bob <## "#team: alice invites you to join the group as member"
|
||||
bob <## "use /j team to accept"
|
||||
bob ##> "/j team"
|
||||
@@ -1477,7 +1478,7 @@ testUpdateMemberRole =
|
||||
bob <## "#team: you have insufficient permissions for this action, the required role is admin"
|
||||
alice ##> "/mr team bob admin"
|
||||
concurrently_
|
||||
(alice <## "#team: you changed the role of bob from member to admin")
|
||||
(alice <## "#team: you changed the role of bob to admin")
|
||||
(bob <## "#team: alice changed your role from member to admin")
|
||||
bob ##> "/a team cath owner"
|
||||
bob <## "#team: you have insufficient permissions for this action, the required role is owner"
|
||||
@@ -1493,13 +1494,7 @@ testUpdateMemberRole =
|
||||
alice <## "#team: new member cath is connected"
|
||||
]
|
||||
alice ##> "/mr team alice admin"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed your role from owner to admin",
|
||||
bob <## "#team: alice changed the role from owner to admin",
|
||||
cath <## "#team: alice changed the role from owner to admin"
|
||||
]
|
||||
alice ##> "/d #team"
|
||||
alice <## "#team: you have insufficient permissions for this action, the required role is owner"
|
||||
alice <## "bad chat command: can't change role for self"
|
||||
|
||||
testGroupDescription :: HasCallStack => TestParams -> IO ()
|
||||
testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do
|
||||
@@ -1584,7 +1579,7 @@ testGroupModerate =
|
||||
-- disableFullDeletion3 "team" alice bob cath
|
||||
alice ##> "/mr team cath member"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of cath from admin to member",
|
||||
[ alice <## "#team: you changed the role of cath to member",
|
||||
bob <## "#team: alice changed the role of cath from admin to member",
|
||||
cath <## "#team: alice changed your role from admin to member"
|
||||
]
|
||||
@@ -1667,7 +1662,7 @@ testGroupModerateFullDelete =
|
||||
-- disableFullDeletion3 "team" alice bob cath
|
||||
alice ##> "/mr team cath member"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of cath from admin to member",
|
||||
[ alice <## "#team: you changed the role of cath to member",
|
||||
bob <## "#team: alice changed the role of cath from admin to member",
|
||||
cath <## "#team: alice changed your role from admin to member"
|
||||
]
|
||||
@@ -2696,7 +2691,7 @@ testGroupLinkMemberRole =
|
||||
bob <## "#team: you don't have permission to send messages"
|
||||
|
||||
alice ##> "/mr #team bob member"
|
||||
alice <## "#team: you changed the role of bob from observer to member"
|
||||
alice <## "#team: you changed the role of bob to member"
|
||||
bob <## "#team: alice changed your role from observer to member"
|
||||
|
||||
bob #> "#team hey now"
|
||||
@@ -2726,7 +2721,7 @@ testGroupLinkMemberRole =
|
||||
cath <## "#team: you don't have permission to send messages"
|
||||
|
||||
alice ##> "/mr #team cath admin"
|
||||
alice <## "#team: you changed the role of cath from observer to admin"
|
||||
alice <## "#team: you changed the role of cath to admin"
|
||||
cath <## "#team: alice changed your role from observer to admin"
|
||||
bob <## "#team: alice changed the role of cath from observer to admin"
|
||||
|
||||
@@ -2735,7 +2730,7 @@ testGroupLinkMemberRole =
|
||||
bob <# "#team cath> hey"
|
||||
|
||||
cath ##> "/mr #team bob admin"
|
||||
cath <## "#team: you changed the role of bob from member to admin"
|
||||
cath <## "#team: you changed the role of bob to admin"
|
||||
bob <## "#team: cath changed your role from member to admin"
|
||||
alice <## "#team: cath changed the role of bob from member to admin"
|
||||
|
||||
@@ -4197,14 +4192,14 @@ testGroupMsgForwardReport =
|
||||
|
||||
alice ##> "/mr team bob moderator"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of bob from admin to moderator",
|
||||
[ alice <## "#team: you changed the role of bob to moderator",
|
||||
bob <## "#team: alice changed your role from admin to moderator",
|
||||
cath <## "#team: alice changed the role of bob from admin to moderator"
|
||||
]
|
||||
|
||||
alice ##> "/mr team cath member"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of cath from admin to member",
|
||||
[ alice <## "#team: you changed the role of cath to member",
|
||||
bob <## "#team: alice changed the role of cath from admin to member",
|
||||
cath <## "#team: alice changed your role from admin to member"
|
||||
]
|
||||
@@ -4222,7 +4217,7 @@ testGroupMsgForwardReport =
|
||||
|
||||
alice ##> "/mr team bob member"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of bob from moderator to member",
|
||||
[ alice <## "#team: you changed the role of bob to member",
|
||||
bob <## "#team: alice changed your role from moderator to member",
|
||||
cath <## "#team: alice changed the role of bob from moderator to member"
|
||||
]
|
||||
@@ -4380,7 +4375,7 @@ testGroupMsgForwardChangeRole =
|
||||
setupGroupForwarding3 "team" alice bob cath
|
||||
|
||||
cath ##> "/mr #team bob member"
|
||||
cath <## "#team: you changed the role of bob from admin to member"
|
||||
cath <## "#team: you changed the role of bob to member"
|
||||
alice <## "#team: cath changed the role of bob from admin to member"
|
||||
bob <## "#team: cath changed your role from admin to member" -- TODO show as forwarded
|
||||
|
||||
@@ -6007,19 +6002,13 @@ testBlockForAllBeforeJoining =
|
||||
cc <## "#team: alice added dan (Daniel) to the group (connecting...)"
|
||||
cc <## "#team: new member dan is connected"
|
||||
|
||||
testBlockForAllCantRepeat :: HasCallStack => TestParams -> IO ()
|
||||
testBlockForAllCantRepeat =
|
||||
testBlockForAllRepeat :: HasCallStack => TestParams -> IO ()
|
||||
testBlockForAllRepeat =
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> do
|
||||
createGroup3 "team" alice bob cath
|
||||
-- disableFullDeletion3 "team" alice bob cath
|
||||
|
||||
alice ##> "/unblock for all #team bob"
|
||||
alice <## "bad chat command: already unblocked"
|
||||
|
||||
cath ##> "/unblock for all #team bob"
|
||||
cath <## "bad chat command: already unblocked"
|
||||
|
||||
bob #> "#team 1"
|
||||
[alice, cath] *<# "#team bob> 1"
|
||||
|
||||
@@ -6029,10 +6018,10 @@ testBlockForAllCantRepeat =
|
||||
bob <// 50000
|
||||
|
||||
alice ##> "/block for all #team bob"
|
||||
alice <## "bad chat command: already blocked"
|
||||
alice <## "#team: you blocked bob"
|
||||
|
||||
cath ##> "/block for all #team bob"
|
||||
cath <## "bad chat command: already blocked"
|
||||
cath <## "#team: you blocked bob"
|
||||
|
||||
bob #> "#team 2"
|
||||
alice <# "#team bob> 2 [blocked by admin] <muted>"
|
||||
@@ -6044,16 +6033,92 @@ testBlockForAllCantRepeat =
|
||||
bob <// 50000
|
||||
|
||||
alice ##> "/unblock for all #team bob"
|
||||
alice <## "bad chat command: already unblocked"
|
||||
alice <## "#team: you unblocked bob"
|
||||
|
||||
cath ##> "/unblock for all #team bob"
|
||||
cath <## "bad chat command: already unblocked"
|
||||
cath <## "#team: you unblocked bob"
|
||||
|
||||
bob #> "#team 3"
|
||||
[alice, cath] *<# "#team bob> 3"
|
||||
|
||||
bob #$> ("/_get chat #1 count=3", chat, [(1, "1"), (1, "2"), (1, "3")])
|
||||
|
||||
testBlockForAllMultipleMembers :: HasCallStack => TestParams -> IO ()
|
||||
testBlockForAllMultipleMembers =
|
||||
testChat4 aliceProfile bobProfile cathProfile danProfile $
|
||||
\alice bob cath dan -> do
|
||||
createGroup3 "team" alice bob cath
|
||||
|
||||
connectUsers alice dan
|
||||
addMember "team" alice dan GRMember
|
||||
dan ##> "/j team"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: dan joined the group",
|
||||
do
|
||||
dan <## "#team: you joined the group"
|
||||
dan
|
||||
<### [ "#team: member bob (Bob) is connected",
|
||||
"#team: member cath (Catherine) is connected"
|
||||
],
|
||||
do
|
||||
bob <## "#team: alice added dan (Daniel) to the group (connecting...)"
|
||||
bob <## "#team: new member dan is connected",
|
||||
do
|
||||
cath <## "#team: alice added dan (Daniel) to the group (connecting...)"
|
||||
cath <## "#team: new member dan is connected"
|
||||
]
|
||||
|
||||
-- lower roles to for batch block to be allowed (can't batch block if admins are selected)
|
||||
alice ##> "/mr team bob member"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of bob to member",
|
||||
bob <## "#team: alice changed your role from admin to member",
|
||||
cath <## "#team: alice changed the role of bob from admin to member",
|
||||
dan <## "#team: alice changed the role of bob from admin to member"
|
||||
]
|
||||
alice ##> "/mr team cath member"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of cath to member",
|
||||
bob <## "#team: alice changed the role of cath from admin to member",
|
||||
cath <## "#team: alice changed your role from admin to member",
|
||||
dan <## "#team: alice changed the role of cath from admin to member"
|
||||
]
|
||||
|
||||
bob #> "#team 1"
|
||||
[alice, cath, dan] *<# "#team bob> 1"
|
||||
|
||||
cath #> "#team 2"
|
||||
[alice, bob, dan] *<# "#team cath> 2"
|
||||
|
||||
alice ##> "/_block #1 2,3 blocked=on"
|
||||
alice <## "#team: you blocked 2 members"
|
||||
dan <## "#team: alice blocked bob"
|
||||
dan <## "#team: alice blocked cath"
|
||||
bob <// 50000
|
||||
cath <// 50000
|
||||
|
||||
-- bob and cath don't know they are blocked and receive each other's messages
|
||||
bob #> "#team 3"
|
||||
[alice, dan] *<# "#team bob> 3 [blocked by admin] <muted>"
|
||||
cath <# "#team bob> 3"
|
||||
|
||||
cath #> "#team 4"
|
||||
[alice, dan] *<# "#team cath> 4 [blocked by admin] <muted>"
|
||||
bob <# "#team cath> 4"
|
||||
|
||||
alice ##> "/_block #1 2,3 blocked=off"
|
||||
alice <## "#team: you unblocked 2 members"
|
||||
dan <## "#team: alice unblocked bob"
|
||||
dan <## "#team: alice unblocked cath"
|
||||
bob <// 50000
|
||||
cath <// 50000
|
||||
|
||||
bob #> "#team 5"
|
||||
[alice, cath, dan] *<# "#team bob> 5"
|
||||
|
||||
cath #> "#team 6"
|
||||
[alice, bob, dan] *<# "#team cath> 6"
|
||||
|
||||
testGroupMemberInactive :: HasCallStack => TestParams -> IO ()
|
||||
testGroupMemberInactive ps = do
|
||||
withSmpServer' serverCfg' $ do
|
||||
@@ -6132,13 +6197,13 @@ testGroupMemberReports =
|
||||
-- disableFullDeletion3 "jokes" alice bob cath
|
||||
alice ##> "/mr jokes bob moderator"
|
||||
concurrentlyN_
|
||||
[ alice <## "#jokes: you changed the role of bob from admin to moderator",
|
||||
[ alice <## "#jokes: you changed the role of bob to moderator",
|
||||
bob <## "#jokes: alice changed your role from admin to moderator",
|
||||
cath <## "#jokes: alice changed the role of bob from admin to moderator"
|
||||
]
|
||||
alice ##> "/mr jokes cath member"
|
||||
concurrentlyN_
|
||||
[ alice <## "#jokes: you changed the role of cath from admin to member",
|
||||
[ alice <## "#jokes: you changed the role of cath to member",
|
||||
bob <## "#jokes: alice changed the role of cath from admin to member",
|
||||
cath <## "#jokes: alice changed your role from admin to member"
|
||||
]
|
||||
|
||||
@@ -773,7 +773,7 @@ testBusinessUpdateProfiles = withTestOutput $ testChat4 businessProfile alicePro
|
||||
biz <# "#alisa alisa_1> hello again"
|
||||
-- customer can invite members too, if business allows
|
||||
biz ##> "/mr alisa alisa_1 admin"
|
||||
biz <## "#alisa: you changed the role of alisa_1 from member to admin"
|
||||
biz <## "#alisa: you changed the role of alisa_1 to admin"
|
||||
alice <## "#biz: biz_1 changed your role from member to admin"
|
||||
connectUsers alice bob
|
||||
alice ##> "/a #biz bob"
|
||||
|
||||
Reference in New Issue
Block a user