Animate between compact & empty states of unread filter
This commit is contained in:
parent
2687cc1d01
commit
1ad2f51b5f
@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0512145B2C5BCECF0021EEC9 /* CollectionDifference+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0512145A2C5BCECF0021EEC9 /* CollectionDifference+SSK.swift */; };
|
||||
0517B9782BFCFF12002CDE7D /* TSThreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0517B9772BFCFF12002CDE7D /* TSThreadTests.swift */; };
|
||||
052A33382C52BF410083D812 /* ChatListFilterActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 052A33372C52BF410083D812 /* ChatListFilterActions.swift */; };
|
||||
05412B3C2C22219E007AC9C7 /* InboxFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05412B3B2C22219E007AC9C7 /* InboxFilter.swift */; };
|
||||
@ -17,6 +18,7 @@
|
||||
0550A5E22C4035170072CC02 /* CLVViewInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0550A5E12C4035170072CC02 /* CLVViewInfo.swift */; };
|
||||
0550A5E42C4048CF0072CC02 /* ChatListFilterFooterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0550A5E32C4048CF0072CC02 /* ChatListFilterFooterCell.swift */; };
|
||||
05572BC42BFC0094006A72F1 /* DoubleTapToEditOnboardingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05572BC32BFC0094006A72F1 /* DoubleTapToEditOnboardingController.swift */; };
|
||||
05B411252C62845000A1EDBC /* ChatListInboxFilterSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05B411242C62845000A1EDBC /* ChatListInboxFilterSection.swift */; };
|
||||
0CE014267EDFBD2538E940A0 /* Pods_Signal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FF88FB580BC19B240EEB86A /* Pods_Signal.framework */; };
|
||||
1404D8B3276A353B0068E2F6 /* ChatListViewController+Multiselect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1404D8B2276A353A0068E2F6 /* ChatListViewController+Multiselect.swift */; };
|
||||
1466AB282817F7E7003B3D9F /* PluralAware.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 1466AB262817F7E7003B3D9F /* PluralAware.stringsdict */; };
|
||||
@ -2949,6 +2951,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0512145A2C5BCECF0021EEC9 /* CollectionDifference+SSK.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionDifference+SSK.swift"; sourceTree = "<group>"; };
|
||||
0517B9772BFCFF12002CDE7D /* TSThreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSThreadTests.swift; sourceTree = "<group>"; };
|
||||
052A33372C52BF410083D812 /* ChatListFilterActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListFilterActions.swift; sourceTree = "<group>"; };
|
||||
05412B3B2C22219E007AC9C7 /* InboxFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxFilter.swift; sourceTree = "<group>"; };
|
||||
@ -2959,6 +2962,7 @@
|
||||
0550A5E12C4035170072CC02 /* CLVViewInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLVViewInfo.swift; sourceTree = "<group>"; };
|
||||
0550A5E32C4048CF0072CC02 /* ChatListFilterFooterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListFilterFooterCell.swift; sourceTree = "<group>"; };
|
||||
05572BC32BFC0094006A72F1 /* DoubleTapToEditOnboardingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTapToEditOnboardingController.swift; sourceTree = "<group>"; };
|
||||
05B411242C62845000A1EDBC /* ChatListInboxFilterSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListInboxFilterSection.swift; sourceTree = "<group>"; };
|
||||
05E3A4DD8B4442530268AFC1 /* Pods-SignalShareExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalShareExtension.app store release.xcconfig"; path = "Target Support Files/Pods-SignalShareExtension/Pods-SignalShareExtension.app store release.xcconfig"; sourceTree = "<group>"; };
|
||||
0BADD293DAFC82BF3274F0F6 /* Pods_SignalTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1404D8B2276A353A0068E2F6 /* ChatListViewController+Multiselect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatListViewController+Multiselect.swift"; sourceTree = "<group>"; };
|
||||
@ -9114,6 +9118,7 @@
|
||||
052A33372C52BF410083D812 /* ChatListFilterActions.swift */,
|
||||
0550A5DF2C3ECB230072CC02 /* ChatListFilterButton.swift */,
|
||||
0550A5E32C4048CF0072CC02 /* ChatListFilterFooterCell.swift */,
|
||||
05B411242C62845000A1EDBC /* ChatListInboxFilterSection.swift */,
|
||||
50101FB32B08447000C648E4 /* ChatListProxyButtonCreator.swift */,
|
||||
50101FB12B083C8100C648E4 /* ChatListSettingsButtonState.swift */,
|
||||
34E95C26269F6095004807EC /* ChatListViewController+Actions.swift */,
|
||||
@ -11830,6 +11835,7 @@
|
||||
76387BEF28F4ED73002C7BA5 /* CaseIterable.swift */,
|
||||
661396AC28BE74DC00E0C4DF /* ChainedPromise.swift */,
|
||||
F9C5CB2A289453B200548EEE /* Collection+OWS.swift */,
|
||||
0512145A2C5BCECF0021EEC9 /* CollectionDifference+SSK.swift */,
|
||||
34A955AB271B521500B05242 /* CommonStrings.swift */,
|
||||
F962B389293F9F1F00765BD8 /* CRC32.swift */,
|
||||
F9C5CB22289453B200548EEE /* Currency.swift */,
|
||||
@ -13442,6 +13448,7 @@
|
||||
052A33382C52BF410083D812 /* ChatListFilterActions.swift in Sources */,
|
||||
0550A5E02C3ECB230072CC02 /* ChatListFilterButton.swift in Sources */,
|
||||
0550A5E42C4048CF0072CC02 /* ChatListFilterFooterCell.swift in Sources */,
|
||||
05B411252C62845000A1EDBC /* ChatListInboxFilterSection.swift in Sources */,
|
||||
50101FB42B08447000C648E4 /* ChatListProxyButtonCreator.swift in Sources */,
|
||||
50101FB22B083C8100C648E4 /* ChatListSettingsButtonState.swift in Sources */,
|
||||
34E95C27269F6096004807EC /* ChatListViewController+Actions.swift in Sources */,
|
||||
@ -14248,6 +14255,7 @@
|
||||
C1CF83D02B96C85E00CDC9C4 /* ChunkedOutputStreamTransform.swift in Sources */,
|
||||
728BFE522C5C59E5008F20F1 /* CipherContext.swift in Sources */,
|
||||
F9C5CDFC289453B400548EEE /* Collection+OWS.swift in Sources */,
|
||||
0512145B2C5BCECF0021EEC9 /* CollectionDifference+SSK.swift in Sources */,
|
||||
72345D1D2B9A1984000237B3 /* CommonStrings.swift in Sources */,
|
||||
66E1AD552B8D033E00C56B7B /* ConcreteTSResource.swift in Sources */,
|
||||
66D7B92F2B98ECB00005C98B /* ConcreteTSResourceReference.swift in Sources */,
|
||||
|
||||
@ -7,9 +7,25 @@ import SignalServiceKit
|
||||
|
||||
/// A snapshot combining both view database state used to render chat list table view rows.
|
||||
struct CLVRenderState {
|
||||
struct Section {
|
||||
struct Section: Hashable, Identifiable {
|
||||
var type: ChatListSectionType
|
||||
var id: ChatListSectionType { type }
|
||||
var threads: KeyPath<CLVRenderState, [TSThread]>?
|
||||
var value: AnyHashable?
|
||||
}
|
||||
|
||||
/// A type-erased representation of a row in a dynamic section of the chat
|
||||
/// list, to support automatic diffing of rows.
|
||||
struct RowItem: Hashable, Identifiable {
|
||||
var section: ChatListSectionType
|
||||
var id: AnyHashable
|
||||
var value: AnyHashable
|
||||
|
||||
init(section: ChatListSectionType, value: some Hashable & Identifiable) {
|
||||
self.section = section
|
||||
self.id = value.id
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
static var empty: CLVRenderState {
|
||||
@ -19,12 +35,15 @@ struct CLVRenderState {
|
||||
let viewInfo: CLVViewInfo
|
||||
let pinnedThreads: [TSThread]
|
||||
let unpinnedThreads: [TSThread]
|
||||
|
||||
private(set) var sections: [Section] = []
|
||||
private(set) var inboxFilterSection: ChatListInboxFilterSection?
|
||||
|
||||
init(viewInfo: CLVViewInfo, pinnedThreads: [TSThread], unpinnedThreads: [TSThread]) {
|
||||
self.viewInfo = viewInfo
|
||||
self.pinnedThreads = pinnedThreads
|
||||
self.unpinnedThreads = unpinnedThreads
|
||||
self.inboxFilterSection = ChatListInboxFilterSection(renderState: self)
|
||||
self.sections = ChatListSectionType.allCases.compactMap(makeSection(for:))
|
||||
}
|
||||
|
||||
@ -35,10 +54,12 @@ struct CLVRenderState {
|
||||
case .unpinned:
|
||||
return Section(type: sectionType, threads: \.unpinnedThreads)
|
||||
case .reminders where hasVisibleReminders,
|
||||
.archiveButton where hasArchivedThreadsRow,
|
||||
.inboxFilterFooter where viewInfo.inboxFilter != nil:
|
||||
.archiveButton where hasArchivedThreadsRow:
|
||||
return Section(type: sectionType)
|
||||
case .reminders, .archiveButton, .inboxFilterFooter:
|
||||
case .inboxFilterFooter:
|
||||
guard let inboxFilterSection else { return nil }
|
||||
return Section(type: sectionType, value: inboxFilterSection)
|
||||
case .reminders, .archiveButton:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -80,6 +101,35 @@ struct CLVRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// For chat list sections that support dynamic content, compute a
|
||||
/// collection difference of the rows in that section.
|
||||
func sectionDifference(for section: Section, from renderState: CLVRenderState) -> CollectionDifference<RowItem>? {
|
||||
switch section.type {
|
||||
case .inboxFilterFooter:
|
||||
let items = items(in: section) ?? []
|
||||
let oldValue = renderState.items(in: section) ?? []
|
||||
return items.difference(from: oldValue)
|
||||
|
||||
case .pinned, .unpinned, .reminders, .archiveButton:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func items(in section: Section) -> [RowItem]? {
|
||||
switch section.type {
|
||||
case .inboxFilterFooter:
|
||||
if let inboxFilterSection {
|
||||
return [RowItem(section: section.type, value: inboxFilterSection)]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
case .pinned, .unpinned, .reminders, .archiveButton:
|
||||
owsFailDebug("Section diffing not yet supported in section '\(section.type)'")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func sectionIndex(for sectionType: ChatListSectionType) -> Int? {
|
||||
sections.firstIndex(where: { $0.type == sectionType })
|
||||
}
|
||||
|
||||
@ -549,6 +549,12 @@ extension CLVTableDataSource: UITableViewDataSource {
|
||||
let filterFooterCell = tableView.dequeueReusableCell(ChatListFilterFooterCell.self, for: indexPath)
|
||||
filterFooterCell.primaryAction = .disableChatListFilter()
|
||||
cell = filterFooterCell
|
||||
guard let inboxFilterSection = renderState.inboxFilterSection else {
|
||||
owsFailDebug("Missing view model in inbox filter section")
|
||||
break
|
||||
}
|
||||
filterFooterCell.isExpanded = inboxFilterSection.isEmptyState
|
||||
filterFooterCell.message = inboxFilterSection.message
|
||||
}
|
||||
|
||||
cell.tintColor = .ows_accentBlue
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
//
|
||||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import SignalServiceKit
|
||||
|
||||
/// View model describing the current state of the inbox filter footer.
|
||||
struct ChatListInboxFilterSection: Hashable, Identifiable {
|
||||
let id = ChatListSectionType.inboxFilterFooter
|
||||
var isEmptyState: Bool
|
||||
|
||||
var message: String? {
|
||||
guard isEmptyState else { return nil }
|
||||
return OWSLocalizedString("CHAT_LIST_UNREAD_FILTER_NO_CHATS", comment: "Message displayed on chat list when Filter by Unread is enabled but no unread chats are available")
|
||||
}
|
||||
|
||||
init?(renderState: CLVRenderState) {
|
||||
guard renderState.viewInfo.inboxFilter != nil else { return nil }
|
||||
isEmptyState = renderState.visibleThreadCount == 0
|
||||
}
|
||||
}
|
||||
@ -106,10 +106,14 @@ extension ChatListViewController {
|
||||
fileprivate func applyRowChanges(_ rowChanges: [CLVRowChange], renderState: CLVRenderState, animated: Bool) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
let sectionDifference = renderState.sections.difference(from: tableDataSource.renderState.sections, by: { $0.type == $1.type })
|
||||
let isChangingFilter = tableDataSource.renderState.viewInfo.inboxFilter != renderState.viewInfo.inboxFilter
|
||||
let previousRenderState = tableDataSource.renderState
|
||||
tableDataSource.renderState = renderState
|
||||
|
||||
let sectionChanges = renderState.sections
|
||||
.difference(from: previousRenderState.sections)
|
||||
.batchedChanges()
|
||||
let isChangingFilter = previousRenderState.viewInfo.inboxFilter != renderState.viewInfo.inboxFilter
|
||||
|
||||
let tableView = self.tableView
|
||||
let threadViewModelCache = self.threadViewModelCache
|
||||
let cellContentCache = self.cellContentCache
|
||||
@ -131,25 +135,20 @@ extension ChatListViewController {
|
||||
}
|
||||
}
|
||||
|
||||
// Ss soon as structural changes are applied to the table we can not use our optimized update implementation
|
||||
// As soon as structural changes are applied to the table we can not use our optimized update implementation
|
||||
// anymore. All indexPaths are based on the old model (before any change was applied) and if we
|
||||
// animate move, insert and delete changes the indexPaths of the to be updated rows will differ.
|
||||
var useFallBackUpdateMechanism = false
|
||||
var removedSections = IndexSet()
|
||||
var insertedSections = IndexSet()
|
||||
|
||||
for change in sectionDifference {
|
||||
switch change {
|
||||
case let .insert(offset, element: _, associatedWith: _):
|
||||
insertedSections.insert(offset)
|
||||
case let .remove(offset, element: _, associatedWith: _):
|
||||
removedSections.insert(offset)
|
||||
}
|
||||
if !sectionChanges.removals.isEmpty {
|
||||
checkAndSetTableUpdates()
|
||||
tableView.deleteSections(sectionChanges.removals.offsets, with: .middle)
|
||||
useFallBackUpdateMechanism = true
|
||||
}
|
||||
|
||||
if !removedSections.isEmpty {
|
||||
if !sectionChanges.insertions.isEmpty {
|
||||
checkAndSetTableUpdates()
|
||||
tableView.deleteSections(removedSections, with: .middle)
|
||||
tableView.insertSections(sectionChanges.insertions.offsets, with: .fade)
|
||||
useFallBackUpdateMechanism = true
|
||||
}
|
||||
|
||||
@ -197,9 +196,41 @@ extension ChatListViewController {
|
||||
}
|
||||
}
|
||||
|
||||
if !insertedSections.isEmpty {
|
||||
if !sectionChanges.updates.isEmpty {
|
||||
checkAndSetTableUpdates()
|
||||
tableView.insertSections(insertedSections, with: .top)
|
||||
|
||||
for (_, sectionUpdate) in sectionChanges.updates {
|
||||
guard let rowChanges = renderState
|
||||
.sectionDifference(for: sectionUpdate.element, from: previousRenderState)?
|
||||
.batchedChanges()
|
||||
else { continue }
|
||||
|
||||
let sectionIndex = sectionUpdate.offset
|
||||
|
||||
if !rowChanges.removals.isEmpty {
|
||||
tableView.deleteRows(at: rowChanges.removals.indexPaths(in: sectionIndex), with: defaultRowAnimation)
|
||||
}
|
||||
|
||||
if !rowChanges.insertions.isEmpty {
|
||||
tableView.insertRows(at: rowChanges.insertions.indexPaths(in: sectionIndex), with: defaultRowAnimation)
|
||||
}
|
||||
|
||||
if !rowChanges.updates.isEmpty {
|
||||
if let previousSectionIndex = sectionUpdate.previousOffset, sectionIndex != previousSectionIndex {
|
||||
// If the section index has changed, there's a good
|
||||
// chance that the type of the cell has also changed
|
||||
// (i.e., the `reuseIdentifier` last associated with that
|
||||
// `indexPath`). This causes `reconfigureRows(at:)` to
|
||||
// raise an assertion.
|
||||
//
|
||||
// Whenever the type of cell may have changed, we have
|
||||
// to be conservative and reload instead of reconfiguring.
|
||||
tableView.reloadRows(at: rowChanges.updates.indexPaths(in: previousSectionIndex), with: defaultRowAnimation)
|
||||
} else {
|
||||
tableView.reconfigureRows(at: rowChanges.updates.indexPaths(in: sectionIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tableUpdatesPerformed {
|
||||
|
||||
@ -1048,6 +1048,9 @@
|
||||
/* Title for context menu action to enable Filter by Unread */
|
||||
"CHAT_LIST_UNREAD_FILTER_MENU_ACTION" = "Filter by Unread";
|
||||
|
||||
/* Message displayed on chat list when Filter by Unread is enabled but no unread chats are available */
|
||||
"CHAT_LIST_UNREAD_FILTER_NO_CHATS" = "No unread chats";
|
||||
|
||||
/* Title for the color & wallpaper settings view. */
|
||||
"COLOR_AND_WALLPAPER_SETTINGS_TITLE" = "Chat Color & Wallpaper";
|
||||
|
||||
|
||||
109
SignalServiceKit/Util/CollectionDifference+SSK.swift
Normal file
109
SignalServiceKit/Util/CollectionDifference+SSK.swift
Normal file
@ -0,0 +1,109 @@
|
||||
//
|
||||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension CollectionDifference where ChangeElement: Identifiable {
|
||||
/// Represents a batch of changes derived from a `CollectionDifferance`,
|
||||
/// suitable for applying to a `UITableView` or `UICollectionView`.
|
||||
public struct ChangeBatch: Collection, ExpressibleByDictionaryLiteral {
|
||||
public typealias Key = ChangeElement.ID
|
||||
public typealias Value = (offset: Int, element: ChangeElement, previousOffset: Int?)
|
||||
|
||||
private var storage: [Key: Value]
|
||||
|
||||
public init() {
|
||||
self.storage = [:]
|
||||
}
|
||||
|
||||
public var offsets: IndexSet {
|
||||
IndexSet(storage.values.lazy.map(\.offset))
|
||||
}
|
||||
|
||||
public func indexPaths(in section: Int) -> [IndexPath] {
|
||||
offsets.map { IndexPath(row: $0, section: section) }
|
||||
}
|
||||
|
||||
public subscript(key: Key) -> Value? {
|
||||
get { storage[key] }
|
||||
set { storage[key] = newValue }
|
||||
}
|
||||
|
||||
// MARK: ExpressibleByDictionaryLiteral
|
||||
|
||||
public init(dictionaryLiteral elements: (Key, Value)...) {
|
||||
self.storage = Dictionary(uniqueKeysWithValues: elements)
|
||||
}
|
||||
|
||||
// MARK: Collection
|
||||
|
||||
public typealias Index = Dictionary<Key, Value>.Index
|
||||
public typealias Element = Dictionary<Key, Value>.Element
|
||||
|
||||
public var isEmpty: Bool {
|
||||
storage.isEmpty
|
||||
}
|
||||
|
||||
public var startIndex: Index {
|
||||
storage.startIndex
|
||||
}
|
||||
|
||||
public var endIndex: Index {
|
||||
storage.endIndex
|
||||
}
|
||||
|
||||
public func index(after i: Index) -> Index {
|
||||
storage.index(after: i)
|
||||
}
|
||||
|
||||
public subscript(position: Index) -> Element {
|
||||
storage[position]
|
||||
}
|
||||
}
|
||||
|
||||
/// A combined set of batched changes derived from a `CollectionDifference`,
|
||||
/// suitable for applying to a `UITableView` or `UICollectionView`.
|
||||
public struct BatchedChanges {
|
||||
/// An ordered set of all removals in the `CollectionDifference`.
|
||||
public fileprivate(set) var removals = ChangeBatch()
|
||||
|
||||
/// An ordered set of all insertions in the `CollectionDifference`.
|
||||
public fileprivate(set) var insertions = ChangeBatch()
|
||||
|
||||
/// An ordered set of all updates in the `CollectionDifference`, i.e.,
|
||||
/// a combination of removal & insertion where the change element's `id`
|
||||
/// is the same, but the element's value has changed.
|
||||
public fileprivate(set) var updates = ChangeBatch()
|
||||
}
|
||||
|
||||
/// Iterate over each change in the `CollectionDifference`, combining
|
||||
/// changes of the same type (removal, insertion or update) into a
|
||||
/// corresponding `ChangeBatch`.
|
||||
///
|
||||
/// An **update** change is defined as a **remove** followed by an **insert**
|
||||
/// where the change elements’ `id` properties are equal. The original pair
|
||||
/// of remove & insert operations are replaced by a single update in the
|
||||
/// resulting `BatchedChanges`.
|
||||
public func batchedChanges() -> BatchedChanges {
|
||||
var changes = BatchedChanges()
|
||||
|
||||
for change in self {
|
||||
switch change {
|
||||
case let .remove(offset, element, associatedWith: _):
|
||||
changes.removals[element.id] = (offset, element, nil)
|
||||
|
||||
case let .insert(offset, element, associatedWith: _):
|
||||
if let removal = changes.removals[element.id] {
|
||||
changes.removals[element.id] = nil
|
||||
changes.updates[element.id] = (offset, element, previousOffset: removal.offset)
|
||||
} else {
|
||||
changes.insertions[element.id] = (offset, element, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user