Merge branch 'master' into ep/member-acceptance

This commit is contained in:
Evgeny Poberezkin
2025-02-28 20:38:11 +00:00
36 changed files with 1282 additions and 765 deletions
+17
View File
@@ -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] = []
+11 -11
View File
@@ -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
+139 -43
View File
@@ -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))")
}
}
}
+3 -1
View File
@@ -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)
}
+18 -18
View File
@@ -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)")
+14
View File
@@ -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)")
@@ -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()) {
@@ -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")
@@ -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()}")
@@ -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 -> {
@@ -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,
@@ -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)
}
}
}
}
@@ -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)
}
}
}
}
@@ -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
@@ -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>
+6 -6
View File
@@ -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}
+169 -78
View File
@@ -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),
+8 -5
View File
@@ -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
+6 -8
View File
@@ -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)
+4 -3
View File
@@ -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
View File
@@ -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
+11 -11
View File
@@ -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
View File
@@ -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"
]
+1 -1
View File
@@ -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"