mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Memory management (#503)
* Correctly tear down the user session on signing out * Fix session verification <-> user session retain cycle, visible range debouncer leak * Manually clean up coordinators retained within SwiftUI's NavigationStacks * Slightly refactor the timeline content menu builder and prevent it from retaining the view model. Cleanup now unnecessarily optional RoomScreenCoordinator instance vars * Move coordinator dismissal logic to the navigation modules
This commit is contained in:
@@ -25,15 +25,11 @@ struct RoomScreenCoordinatorParameters {
|
||||
}
|
||||
|
||||
final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
private var parameters: RoomScreenCoordinatorParameters?
|
||||
private var parameters: RoomScreenCoordinatorParameters
|
||||
|
||||
private var viewModel: RoomScreenViewModelProtocol?
|
||||
private var viewModel: RoomScreenViewModelProtocol
|
||||
private var navigationStackCoordinator: NavigationStackCoordinator {
|
||||
guard let parameters else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
return parameters.navigationStackCoordinator
|
||||
parameters.navigationStackCoordinator
|
||||
}
|
||||
|
||||
init(parameters: RoomScreenCoordinatorParameters) {
|
||||
@@ -49,7 +45,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
viewModel?.callback = { [weak self] action in
|
||||
viewModel.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
@@ -64,18 +60,11 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
viewModel?.context.send(viewAction: .markRoomAsRead)
|
||||
viewModel?.stop()
|
||||
viewModel = nil
|
||||
parameters = nil
|
||||
viewModel.context.send(viewAction: .markRoomAsRead)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
guard let context = viewModel?.context else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
return AnyView(RoomScreen(context: context))
|
||||
AnyView(RoomScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
@@ -91,14 +80,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
private func displayEmojiPickerScreen(for itemId: String) {
|
||||
guard let emojiProvider = parameters?.emojiProvider,
|
||||
let timelineController = parameters?.timelineController else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let emojiPickerNavigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: emojiProvider,
|
||||
let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: parameters.emojiProvider,
|
||||
itemId: itemId)
|
||||
let coordinator = EmojiPickerScreenCoordinator(parameters: params)
|
||||
coordinator.callback = { [weak self] action in
|
||||
@@ -107,7 +91,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
MXLog.debug("Selected \(emoji) for \(itemId)")
|
||||
self?.navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
Task {
|
||||
await timelineController.sendReaction(emoji, to: itemId)
|
||||
await self?.parameters.timelineController.sendReaction(emoji, to: itemId)
|
||||
}
|
||||
case .dismiss:
|
||||
self?.navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
@@ -121,14 +105,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
private func displayRoomDetails() {
|
||||
guard let roomProxy = parameters?.roomProxy,
|
||||
let mediaProvider = parameters?.mediaProvider else {
|
||||
return
|
||||
}
|
||||
|
||||
let params = RoomDetailsCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
|
||||
roomProxy: roomProxy,
|
||||
mediaProvider: mediaProvider)
|
||||
roomProxy: parameters.roomProxy,
|
||||
mediaProvider: parameters.mediaProvider)
|
||||
let coordinator = RoomDetailsCoordinator(parameters: params)
|
||||
coordinator.callback = { [weak self] _ in
|
||||
self?.navigationStackCoordinator.pop()
|
||||
|
||||
@@ -44,6 +44,7 @@ enum RoomScreenViewAction {
|
||||
case cancelEdit
|
||||
/// Mark the entire room as read - this is heavy handed as a starting point for now.
|
||||
case markRoomAsRead
|
||||
case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction)
|
||||
}
|
||||
|
||||
struct RoomScreenViewState: BindableState {
|
||||
@@ -56,7 +57,7 @@ struct RoomScreenViewState: BindableState {
|
||||
var showLoading = false
|
||||
var bindings: RoomScreenViewStateBindings
|
||||
|
||||
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
|
||||
var contextMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemContextMenuActions?)?
|
||||
|
||||
var composerMode: RoomScreenComposerMode = .default
|
||||
|
||||
|
||||
@@ -28,9 +28,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
private let timelineController: RoomTimelineControllerProtocol
|
||||
private let timelineViewFactory: RoomTimelineViewFactoryProtocol
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
|
||||
init(timelineController: RoomTimelineControllerProtocol,
|
||||
timelineViewFactory: RoomTimelineViewFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
@@ -72,7 +70,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
state.contextMenuBuilder = buildContextMenuForItemId(_:)
|
||||
state.contextMenuActionProvider = { [weak self] itemId -> TimelineItemContextMenuActions? in
|
||||
guard let self else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.contextMenuActionsForItemId(itemId)
|
||||
}
|
||||
|
||||
buildTimelineViews()
|
||||
}
|
||||
@@ -109,13 +113,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
state.bindings.composerText = ""
|
||||
case .markRoomAsRead:
|
||||
await markRoomAsRead()
|
||||
case .contextMenuAction(let itemID, let action):
|
||||
processContentMenuAction(action, itemID: itemID)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
cancellables.removeAll()
|
||||
state.contextMenuBuilder = nil
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@@ -197,22 +198,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
// MARK: ContextMenus
|
||||
|
||||
private func buildContextMenuForItemId(_ itemId: String) -> TimelineItemContextMenu {
|
||||
TimelineItemContextMenu(contextMenuActions: contextMenuActionsForItemId(itemId)) { [weak self] action in
|
||||
self?.processContentMenuAction(action, itemId: itemId)
|
||||
}
|
||||
}
|
||||
|
||||
private func contextMenuActionsForItemId(_ itemId: String) -> TimelineItemContextMenuActions {
|
||||
private func contextMenuActionsForItemId(_ itemId: String) -> TimelineItemContextMenuActions? {
|
||||
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemId }),
|
||||
let item = timelineItem as? EventBasedTimelineItemProtocol else {
|
||||
// Don't show a context menu for non-event based items.
|
||||
return .empty
|
||||
return nil
|
||||
}
|
||||
|
||||
if timelineItem is StateRoomTimelineItem {
|
||||
// Don't show a context menu for state events.
|
||||
return .empty
|
||||
return nil
|
||||
}
|
||||
|
||||
var actions: [TimelineItemContextMenuAction] = [
|
||||
@@ -238,8 +233,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
private func processContentMenuAction(_ action: TimelineItemContextMenuAction, itemId: String) {
|
||||
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemId }),
|
||||
private func processContentMenuAction(_ action: TimelineItemContextMenuAction, itemID: String) {
|
||||
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemID }),
|
||||
let item = timelineItem as? EventBasedTimelineItemProtocol else {
|
||||
return
|
||||
}
|
||||
@@ -265,7 +260,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
case .redact:
|
||||
Task {
|
||||
await timelineController.redact(itemId)
|
||||
await timelineController.redact(itemID)
|
||||
}
|
||||
case .reply:
|
||||
state.bindings.composerFocused = true
|
||||
|
||||
@@ -20,6 +20,4 @@ import Foundation
|
||||
protocol RoomScreenViewModelProtocol {
|
||||
var callback: ((RoomScreenViewModelAction) -> Void)? { get set }
|
||||
var context: RoomScreenViewModelType.Context { get }
|
||||
|
||||
func stop()
|
||||
}
|
||||
|
||||
@@ -17,11 +17,17 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineItemContextMenuActions {
|
||||
static var empty: TimelineItemContextMenuActions { .init(actions: [], debugActions: []) }
|
||||
|
||||
let actions: [TimelineItemContextMenuAction]
|
||||
let debugActions: [TimelineItemContextMenuAction]
|
||||
var isEmpty: Bool { actions.isEmpty && debugActions.isEmpty }
|
||||
|
||||
init?(actions: [TimelineItemContextMenuAction], debugActions: [TimelineItemContextMenuAction]) {
|
||||
if actions.isEmpty, debugActions.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.actions = actions
|
||||
self.debugActions = debugActions
|
||||
}
|
||||
}
|
||||
|
||||
enum TimelineItemContextMenuAction: Identifiable, Hashable {
|
||||
@@ -52,16 +58,11 @@ public struct TimelineItemContextMenu: View {
|
||||
let callback: (TimelineItemContextMenuAction) -> Void
|
||||
|
||||
public var body: some View {
|
||||
if contextMenuActions.isEmpty {
|
||||
// When there are no actions make sure then menu isn't shown.
|
||||
EmptyView()
|
||||
} else {
|
||||
viewsForActions(contextMenuActions.actions)
|
||||
Menu {
|
||||
viewsForActions(contextMenuActions.debugActions)
|
||||
} label: {
|
||||
Label("Developer", systemImage: "hammer")
|
||||
}
|
||||
viewsForActions(contextMenuActions.actions)
|
||||
Menu {
|
||||
viewsForActions(contextMenuActions.debugActions)
|
||||
} label: {
|
||||
Label("Developer", systemImage: "hammer")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ class TimelineTableViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
|
||||
var contextMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemContextMenuActions?)?
|
||||
|
||||
@Binding private var scrollToBottomButtonVisible: Bool
|
||||
|
||||
@@ -189,7 +189,7 @@ class TimelineTableViewController: UIViewController {
|
||||
// A local reference to avoid capturing self in the cell configuration.
|
||||
let coordinator = self.coordinator
|
||||
let opacity = self.opacity(for: timelineItem)
|
||||
let contextMenuBuilder = self.contextMenuBuilder
|
||||
let contextMenuActionProvider = self.contextMenuActionProvider
|
||||
|
||||
cell.item = timelineItem
|
||||
cell.contentConfiguration = UIHostingConfiguration {
|
||||
@@ -198,7 +198,11 @@ class TimelineTableViewController: UIViewController {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.opacity(opacity)
|
||||
.contextMenu {
|
||||
contextMenuBuilder?(timelineItem.id)
|
||||
contextMenuActionProvider?(timelineItem.id).map { actions in
|
||||
TimelineItemContextMenu(contextMenuActions: actions) { action in
|
||||
coordinator.send(viewAction: .contextMenuAction(itemID: timelineItem.id, action: action))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
coordinator.send(viewAction: .itemAppeared(id: timelineItem.id))
|
||||
|
||||
@@ -70,7 +70,7 @@ struct TimelineView: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
// Doesn't have an equatable conformance :(
|
||||
tableViewController.contextMenuBuilder = context.viewState.contextMenuBuilder
|
||||
tableViewController.contextMenuActionProvider = context.viewState.contextMenuActionProvider
|
||||
}
|
||||
|
||||
func send(viewAction: RoomScreenViewAction) {
|
||||
|
||||
Reference in New Issue
Block a user