Skip to content

Commit

Permalink
Merge pull request #5630 from vector-im/andy/5605_leave_room
Browse files Browse the repository at this point in the history
Update activity indicators on leaving room
  • Loading branch information
Anderas authored Feb 25, 2022
2 parents 00c5d5a + 6aacced commit c7985e5
Show file tree
Hide file tree
Showing 29 changed files with 603 additions and 228 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import Foundation

class ActivityPresenterSpy: ActivityPresentable {
class UserIndicatorPresenterSpy: UserIndicatorPresentable {
var intel = [String]()

func present() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,36 +17,36 @@
import Foundation
import XCTest

class ActivityCenterTests: XCTestCase {
var activities: [Activity]!
var center: ActivityCenter!
class UserIndicatorQueueTests: XCTestCase {
var indicators: [UserIndicator]!
var queue: UserIndicatorQueue!

override func setUp() {
activities = []
center = ActivityCenter()
indicators = []
queue = UserIndicatorQueue()
}

func makeRequest() -> ActivityRequest {
return ActivityRequest(
presenter: ActivityPresenterSpy(),
func makeRequest() -> UserIndicatorRequest {
return UserIndicatorRequest(
presenter: UserIndicatorPresenterSpy(),
dismissal: .manual
)
}

func testStartsActivityWhenAdded() {
let activity = center.add(makeRequest())
XCTAssertEqual(activity.state, .executing)
func testStartsIndicatorWhenAdded() {
let indicator = queue.add(makeRequest())
XCTAssertEqual(indicator.state, .executing)
}

func testSecondActivityIsPending() {
center.add(makeRequest()).store(in: &activities)
let activity = center.add(makeRequest())
XCTAssertEqual(activity.state, .pending)
func testSecondIndicatorIsPending() {
queue.add(makeRequest()).store(in: &indicators)
let indicator = queue.add(makeRequest())
XCTAssertEqual(indicator.state, .pending)
}

func testSecondActivityIsExecutingWhenFirstCompleted() {
let first = center.add(makeRequest())
let second = center.add(makeRequest())
func testSecondIndicatorIsExecutingWhenFirstCompleted() {
let first = queue.add(makeRequest())
let second = queue.add(makeRequest())

first.cancel()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,79 +17,79 @@
import Foundation
import XCTest

class ActivityTests: XCTestCase {
var presenter: ActivityPresenterSpy!
class UserIndicatorTests: XCTestCase {
var presenter: UserIndicatorPresenterSpy!

override func setUp() {
super.setUp()
presenter = ActivityPresenterSpy()
presenter = UserIndicatorPresenterSpy()
}

func makeActivity(dismissal: ActivityDismissal = .manual, callback: @escaping () -> Void = {}) -> Activity {
let request = ActivityRequest(
func makeIndicator(dismissal: UserIndicatorDismissal = .manual, callback: @escaping () -> Void = {}) -> UserIndicator {
let request = UserIndicatorRequest(
presenter: presenter,
dismissal: dismissal
)
return Activity(
return UserIndicator(
request: request,
completion: callback
)
}

// MARK: - State

func testNewActivityIsPending() {
let activity = makeActivity()
XCTAssertEqual(activity.state, .pending)
func testNewIndicatorIsPending() {
let indicator = makeIndicator()
XCTAssertEqual(indicator.state, .pending)
}

func testStartedActivityIsExecuting() {
let activity = makeActivity()
activity.start()
XCTAssertEqual(activity.state, .executing)
func testStartedIndicatorIsExecuting() {
let indicator = makeIndicator()
indicator.start()
XCTAssertEqual(indicator.state, .executing)
}

func testCancelledActivityIsCompleted() {
let activity = makeActivity()
activity.cancel()
XCTAssertEqual(activity.state, .completed)
func testCancelledIndicatorIsCompleted() {
let indicator = makeIndicator()
indicator.cancel()
XCTAssertEqual(indicator.state, .completed)
}

// MARK: - Presenter

func testStartingActivityPresentsUI() {
let activity = makeActivity()
activity.start()
func testStartingIndicatorPresentsUI() {
let indicator = makeIndicator()
indicator.start()
XCTAssertEqual(presenter.intel, ["present()"])
}

func testAllowStartingOnlyOnce() {
let activity = makeActivity()
activity.start()
let indicator = makeIndicator()
indicator.start()
presenter.intel = []

activity.start()
indicator.start()

XCTAssertEqual(presenter.intel, [])
}

func testCancellingActivityDismissesUI() {
let activity = makeActivity()
activity.start()
func testCancellingIndicatorDismissesUI() {
let indicator = makeIndicator()
indicator.start()
presenter.intel = []

activity.cancel()
indicator.cancel()

XCTAssertEqual(presenter.intel, ["dismiss()"])
}

func testAllowCancellingOnlyOnce() {
let activity = makeActivity()
activity.start()
activity.cancel()
let indicator = makeIndicator()
indicator.start()
indicator.cancel()
presenter.intel = []

activity.cancel()
indicator.cancel()

XCTAssertEqual(presenter.intel, [])
}
Expand All @@ -98,29 +98,29 @@ class ActivityTests: XCTestCase {

func testDismissAfterTimeout() {
let interval: TimeInterval = 0.01
let activity = makeActivity(dismissal: .timeout(interval))
let indicator = makeIndicator(dismissal: .timeout(interval))

activity.start()
indicator.start()

let exp = expectation(description: "")
DispatchQueue.main.asyncAfter(deadline: .now() + interval) {
exp.fulfill()
}
waitForExpectations(timeout: 1)

XCTAssertEqual(activity.state, .completed)
XCTAssertEqual(indicator.state, .completed)
}

// MARK: - Completion callback

func testTriggersCallbackWhenCompleted() {
var didComplete = false
let activity = makeActivity {
let indicator = makeIndicator {
didComplete = true
}
activity.start()
indicator.start()

activity.cancel()
indicator.cancel()

XCTAssertTrue(didComplete)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,35 @@
import Foundation
import UIKit

/// An `Activity` represents the state of a temporary visual indicator, such as activity indicator, success notification or an error message. It does not directly manage the UI, instead it delegates to a `presenter`
/// A `UserIndicator` represents the state of a temporary visual indicator, such as loading spinner, success notification or an error message. It does not directly manage the UI, instead it delegates to a `presenter`
/// whenever the UI should be shown or hidden.
///
/// More than one `Activity` may be requested by the system at the same time (e.g. global syncing vs local refresh),
/// and the `ActivityCenter` will ensure that only one activity is shown at a given time, putting the other in a pending queue.
/// More than one `UserIndicator` may be requested by the system at the same time (e.g. global syncing vs local refresh),
/// and the `UserIndicatorQueue` will ensure that only one indicator is shown at a given time, putting the other in a pending queue.
///
/// A client that requests an activity can specify a default timeout after which the activity is dismissed, or it has to be manually
/// A client that requests an indicator can specify a default timeout after which the indicator is dismissed, or it has to be manually
/// responsible for dismissing it via `cancel` method, or by deallocating itself.
public class Activity {
enum State {
public class UserIndicator {
public enum State {
case pending
case executing
case completed
}

private let request: ActivityRequest
private let request: UserIndicatorRequest
private let completion: () -> Void

private(set) var state: State
public private(set) var state: State

public init(request: ActivityRequest, completion: @escaping () -> Void) {
public init(request: UserIndicatorRequest, completion: @escaping () -> Void) {
self.request = request
self.completion = completion

state = .pending
}

deinit {
cancel()
complete()
}

internal func start() {
Expand All @@ -66,11 +66,11 @@ public class Activity {
}
}

/// Cancel the activity, triggering any dismissal action / animation
/// Cancel the indicator, triggering any dismissal action / animation
///
/// Note: clients can call this method directly, if they have access to the `Activity`.
/// Once cancelled, `ActivityCenter` will automatically start the next `Activity` in the queue.
func cancel() {
/// Note: clients can call this method directly, if they have access to the `UserIndicator`.
/// Once cancelled, `UserIndicatorQueue` will automatically start the next `UserIndicator` in the queue.
public func cancel() {
complete()
}

Expand All @@ -87,8 +87,16 @@ public class Activity {
}
}

public extension Activity {
func store<C>(in collection: inout C) where C: RangeReplaceableCollection, C.Element == Activity {
public extension UserIndicator {
func store<C>(in collection: inout C) where C: RangeReplaceableCollection, C.Element == UserIndicator {
collection.append(self)
}
}

public extension Collection where Element == UserIndicator {
func cancelAll() {
forEach {
$0.cancel()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@

import Foundation

/// Different ways in which an `Activity` can be dismissed
public enum ActivityDismissal {
/// The `Activity` will not manage the dismissal, but will expect the calling client to do so manually
/// Different ways in which a `UserIndicator` can be dismissed
public enum UserIndicatorDismissal {
/// The `UserIndicator` will not manage the dismissal, but will expect the calling client to do so manually
case manual
/// The `Activity` will be automatically dismissed after `TimeInterval`
/// The `UserIndicator` will be automatically dismissed after `TimeInterval`
case timeout(TimeInterval)
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@

import Foundation

/// A presenter associated with and called by an `Activity`, and responsible for the underlying view shown on the screen.
public protocol ActivityPresentable {
/// Called when the `Activity` is started (manually or by the `ActivityCenter`)
/// A presenter associated with and called by a `UserIndicator`, and responsible for the underlying view shown on the screen.
public protocol UserIndicatorPresentable {
/// Called when the `UserIndicator` is started (manually or by the `UserIndicatorQueue`)
func present()
/// Called when the `Activity` is manually cancelled or completed
/// Called when the `UserIndicator` is manually cancelled or completed
func dismiss()
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,39 +16,39 @@

import Foundation

/// A shared activity center with a single FIFO queue which will ensure only one activity is shown at a given time.
/// A FIFO queue which will ensure only one user indicator is shown at a given time.
///
/// `ActivityCenter` offers a `shared` center that can be used by any clients, but clients are also allowed
/// to create local `ActivityCenter` if the context requres multiple simultaneous activities.
public class ActivityCenter {
/// `UserIndicatorQueue` offers a `shared` queue that can be used by any clients app-wide, but clients are also allowed
/// to create local `UserIndicatorQueue` if the context requres multiple simultaneous indicators.
public class UserIndicatorQueue {
private class Weak<T: AnyObject> {
weak var element: T?
init(_ element: T) {
self.element = element
}
}

public static let shared = ActivityCenter()
private var queue = [Weak<Activity>]()
public static let shared = UserIndicatorQueue()
private var queue = [Weak<UserIndicator>]()

/// Add a new activity to the queue by providing a request.
/// Add a new indicator to the queue by providing a request.
///
/// The queue will start the activity right away, if there are no currently running activities,
/// otherwise the activity will be put on hold.
public func add(_ request: ActivityRequest) -> Activity {
let activity = Activity(request: request) { [weak self] in
/// The queue will start the indicator right away, if there are no currently running indicators,
/// otherwise the indicator will be put on hold.
public func add(_ request: UserIndicatorRequest) -> UserIndicator {
let indicator = UserIndicator(request: request) { [weak self] in
self?.startNextIfIdle()
}

queue.append(Weak(activity))
queue.append(Weak(indicator))
startNextIfIdle()
return activity
return indicator
}

private func startNextIfIdle() {
cleanup()
if let activity = queue.first?.element, activity.state == .pending {
activity.start()
if let indicator = queue.first?.element, indicator.state == .pending {
indicator.start()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@

import Foundation

/// A request used to create an underlying `Activity`, allowing clients to only specify the visual aspects of an activity.
public struct ActivityRequest {
internal let presenter: ActivityPresentable
internal let dismissal: ActivityDismissal
/// A request used to create an underlying `UserIndicator`, allowing clients to only specify the visual aspects of an indicator.
public struct UserIndicatorRequest {
internal let presenter: UserIndicatorPresentable
internal let dismissal: UserIndicatorDismissal

public init(presenter: ActivityPresentable, dismissal: ActivityDismissal) {
public init(presenter: UserIndicatorPresentable, dismissal: UserIndicatorDismissal) {
self.presenter = presenter
self.dismissal = dismissal
}
Expand Down
Loading

0 comments on commit c7985e5

Please sign in to comment.