Skip to content

Commit

Permalink
Pinned items timeline implementation for the banner (#3099)
Browse files Browse the repository at this point in the history
  • Loading branch information
Velin92 authored Aug 5, 2024
1 parent a11faeb commit ff2c42d
Show file tree
Hide file tree
Showing 23 changed files with 335 additions and 82 deletions.
23 changes: 20 additions & 3 deletions ElementX/Sources/Mocks/Generated/GeneratedMocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8320,7 +8320,7 @@ class RoomProxyMock: RoomProxyProtocol {
return pinnedEventIDsCallsCount > 0
}

var pinnedEventIDs: [String] {
var pinnedEventIDs: Set<String> {
get async {
pinnedEventIDsCallsCount += 1
if let pinnedEventIDsClosure = pinnedEventIDsClosure {
Expand All @@ -8330,8 +8330,8 @@ class RoomProxyMock: RoomProxyProtocol {
}
}
}
var underlyingPinnedEventIDs: [String]!
var pinnedEventIDsClosure: (() async -> [String])?
var underlyingPinnedEventIDs: Set<String>!
var pinnedEventIDsClosure: (() async -> Set<String>)?
var membership: Membership {
get { return underlyingMembership }
set(value) { underlyingMembership = value }
Expand Down Expand Up @@ -8403,6 +8403,23 @@ class RoomProxyMock: RoomProxyProtocol {
set(value) { underlyingTimeline = value }
}
var underlyingTimeline: TimelineProxyProtocol!
var pinnedEventsTimelineCallsCount = 0
var pinnedEventsTimelineCalled: Bool {
return pinnedEventsTimelineCallsCount > 0
}

var pinnedEventsTimeline: TimelineProxyProtocol? {
get async {
pinnedEventsTimelineCallsCount += 1
if let pinnedEventsTimelineClosure = pinnedEventsTimelineClosure {
return await pinnedEventsTimelineClosure()
} else {
return underlyingPinnedEventsTimeline
}
}
}
var underlyingPinnedEventsTimeline: TimelineProxyProtocol?
var pinnedEventsTimelineClosure: (() async -> TimelineProxyProtocol?)?

//MARK: - subscribeForUpdates

Expand Down
111 changes: 111 additions & 0 deletions ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10989,6 +10989,42 @@ open class RoomSDKMock: MatrixRustSDK.Room {
try await clearComposerDraftClosure?()
}

//MARK: - clearPinnedEventsCache

var clearPinnedEventsCacheUnderlyingCallsCount = 0
open var clearPinnedEventsCacheCallsCount: Int {
get {
if Thread.isMainThread {
return clearPinnedEventsCacheUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = clearPinnedEventsCacheUnderlyingCallsCount
}

return returnValue!
}
}
set {
if Thread.isMainThread {
clearPinnedEventsCacheUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
clearPinnedEventsCacheUnderlyingCallsCount = newValue
}
}
}
}
open var clearPinnedEventsCacheCalled: Bool {
return clearPinnedEventsCacheCallsCount > 0
}
open var clearPinnedEventsCacheClosure: (() async -> Void)?

open override func clearPinnedEventsCache() async {
clearPinnedEventsCacheCallsCount += 1
await clearPinnedEventsCacheClosure?()
}

//MARK: - discardRoomKey

open var discardRoomKeyThrowableError: Error?
Expand Down Expand Up @@ -13005,6 +13041,81 @@ open class RoomSDKMock: MatrixRustSDK.Room {
}
}

//MARK: - pinnedEventsTimeline

open var pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadThrowableError: Error?
var pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadUnderlyingCallsCount = 0
open var pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadCallsCount: Int {
get {
if Thread.isMainThread {
return pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadUnderlyingCallsCount
}

return returnValue!
}
}
set {
if Thread.isMainThread {
pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadUnderlyingCallsCount = newValue
}
}
}
}
open var pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadCalled: Bool {
return pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadCallsCount > 0
}
open var pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadReceivedArguments: (internalIdPrefix: String?, maxEventsToLoad: UInt16)?
open var pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadReceivedInvocations: [(internalIdPrefix: String?, maxEventsToLoad: UInt16)] = []

var pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadUnderlyingReturnValue: Timeline!
open var pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadReturnValue: Timeline! {
get {
if Thread.isMainThread {
return pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadUnderlyingReturnValue
} else {
var returnValue: Timeline? = nil
DispatchQueue.main.sync {
returnValue = pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadUnderlyingReturnValue
}

return returnValue!
}
}
set {
if Thread.isMainThread {
pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadUnderlyingReturnValue = newValue
}
}
}
}
open var pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadClosure: ((String?, UInt16) async throws -> Timeline)?

open override func pinnedEventsTimeline(internalIdPrefix: String?, maxEventsToLoad: UInt16) async throws -> Timeline {
if let error = pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadThrowableError {
throw error
}
pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadCallsCount += 1
pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadReceivedArguments = (internalIdPrefix: internalIdPrefix, maxEventsToLoad: maxEventsToLoad)
DispatchQueue.main.async {
self.pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadReceivedInvocations.append((internalIdPrefix: internalIdPrefix, maxEventsToLoad: maxEventsToLoad))
}
if let pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadClosure = pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadClosure {
return try await pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadClosure(internalIdPrefix, maxEventsToLoad)
} else {
return pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadReturnValue
}
}

//MARK: - rawName

var rawNameUnderlyingCallsCount = 0
Expand Down
2 changes: 1 addition & 1 deletion ElementX/Sources/Mocks/RoomProxyMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ struct RoomProxyMockConfiguration {
var isEncrypted = true
var hasOngoingCall = true
var canonicalAlias: String?
var pinnedEventIDs: [String] = []
var pinnedEventIDs: Set<String> = []

var timelineStartReached = false

Expand Down
5 changes: 5 additions & 0 deletions ElementX/Sources/Other/Extensions/AttributedString.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
import Foundation

extension AttributedString {
// faster than doing `String(characters)`: https://forums.swift.org/t/attributedstring-to-string/61667
var string: String {
String(characters[...])
}

var formattedComponents: [AttributedStringBuilderComponent] {
runs[\.blockquote].map { value, range in
var attributedString = AttributedString(self[range])
Expand Down
54 changes: 36 additions & 18 deletions ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ enum RoomScreenViewAction {
case hasSwitchedTimeline

case hasScrolled(direction: ScrollDirection)
case tappedPinBanner
case tappedPinnedEventsBanner
case viewAllPins
}

Expand Down Expand Up @@ -172,10 +172,14 @@ struct RoomScreenViewState: BindableState {
var isPinningEnabled = false
var lastScrollDirection: ScrollDirection?

// The `pinnedEventIDs` are used only to determine if an item is already pinned or not.
// It's updated from the room info, so it's faster than using the timeline
var pinnedEventIDs: Set<String> = []
// This is used to control the banner
var pinnedEventsState = PinnedEventsState()

var shouldShowPinBanner: Bool {
isPinningEnabled && !pinnedEventsState.pinnedEventIDs.isEmpty && lastScrollDirection != .top
var shouldShowPinnedEventsBanner: Bool {
isPinningEnabled && !pinnedEventsState.pinnedEventContents.isEmpty && lastScrollDirection != .top
}

var canJoinCall = false
Expand Down Expand Up @@ -296,39 +300,53 @@ enum ScrollDirection: Equatable {
}

struct PinnedEventsState: Equatable {
// For now these will only contain and show the event IDs, but in the future they will also contain the content
var pinnedEventIDs: OrderedSet<String> = [] {
var pinnedEventContents: OrderedDictionary<String, AttributedString> = [:] {
didSet {
if selectedPinEventID == nil, !pinnedEventIDs.isEmpty {
selectedPinEventID = pinnedEventIDs.first
} else if pinnedEventIDs.isEmpty {
if selectedPinEventID == nil, !pinnedEventContents.keys.isEmpty {
selectedPinEventID = pinnedEventContents.keys.last
} else if pinnedEventContents.isEmpty {
selectedPinEventID = nil
} else if let selectedPinEventID, !pinnedEventIDs.contains(selectedPinEventID) {
self.selectedPinEventID = pinnedEventIDs.first
} else if let selectedPinEventID, !pinnedEventContents.keys.set.contains(selectedPinEventID) {
self.selectedPinEventID = pinnedEventContents.firstNonNil { $0.key }
}
}
}

var selectedPinEventID: String?
private(set) var selectedPinEventID: String?

var selectedPinIndex: Int {
let defaultValue = pinnedEventContents.isEmpty ? 0 : pinnedEventContents.count - 1
guard let selectedPinEventID else {
return 0
return defaultValue
}
return pinnedEventIDs.firstIndex(of: selectedPinEventID) ?? 0
return pinnedEventContents.keys.firstIndex(of: selectedPinEventID) ?? defaultValue
}

// For now we show the event ID as the content, but is just until we have a way to get the real content
var selectedPinContent: AttributedString {
.init(selectedPinEventID ?? "")
guard let selectedPinEventID,
var content = pinnedEventContents[selectedPinEventID] else {
return AttributedString()
}
content.font = .compound.bodyMD
return content
}

var bannerIndicatorDescription: AttributedString {
let index = selectedPinIndex + 1
let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder))
var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, pinnedEventContents.count))
boldString.bold()
finalString.replace(boldPlaceholder, with: boldString)
return finalString
}

mutating func nextPin() {
guard !pinnedEventIDs.isEmpty else {
guard !pinnedEventContents.isEmpty else {
return
}
let currentIndex = selectedPinIndex
let nextIndex = (currentIndex + 1) % pinnedEventIDs.count
selectedPinEventID = pinnedEventIDs[nextIndex]
let nextIndex = (currentIndex + 1) % pinnedEventContents.count
selectedPinEventID = pinnedEventContents.keys[nextIndex]
}
}
40 changes: 37 additions & 3 deletions ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let pinnedEventStringBuilder: RoomEventStringBuilder

private let roomScreenInteractionHandler: RoomScreenInteractionHandler

Expand Down Expand Up @@ -66,6 +67,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
self.analyticsService = analyticsService
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID)

let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)

Expand Down Expand Up @@ -124,6 +126,23 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.canJoinCall = permission
}
}

Task {
guard let pinnedEventsTimelineProvider = await roomProxy.pinnedEventsTimeline?.timelineProvider else {
return
}

buildPinnedEventContent(timelineItems: pinnedEventsTimelineProvider.itemProxies)

pinnedEventsTimelineProvider.updatePublisher
// When pinning or unpinning an item, the timeline might return empty for a short while, so we need to debounce it to prevent weird UI behaviours like the banner disappearing
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.sink { [weak self] updatedItems, _ in
guard let self else { return }
buildPinnedEventContent(timelineItems: updatedItems)
}
.store(in: &cancellables)
}
}

// MARK: - Public
Expand Down Expand Up @@ -196,7 +215,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task { state.timelineViewState.isSwitchingTimelines = false }
case let .hasScrolled(direction):
state.lastScrollDirection = direction
case .tappedPinBanner:
case .tappedPinnedEventsBanner:
if let eventID = state.pinnedEventsState.selectedPinEventID {
Task { await focusOnEvent(eventID: eventID) }
}
Expand Down Expand Up @@ -423,12 +442,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return
}
// If the subscription has sent a value before the Task has started it might be lost, so before entering the loop we always do an update.
await state.pinnedEventsState.pinnedEventIDs = .init(roomProxy.pinnedEventIDs)
await state.pinnedEventIDs = roomProxy.pinnedEventIDs
for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
guard !Task.isCancelled else {
return
}
await state.pinnedEventsState.pinnedEventIDs = .init(roomProxy.pinnedEventIDs)
await state.pinnedEventIDs = roomProxy.pinnedEventIDs
}
}
.store(in: &cancellables)
Expand Down Expand Up @@ -635,6 +654,21 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol

// MARK: - Timeline Item Building

private func buildPinnedEventContent(timelineItems: [TimelineItemProxy]) {
var pinnedEventContents = OrderedDictionary<String, AttributedString>()

for item in timelineItems {
// Only remote events are pinned
if case let .event(event) = item,
let eventID = event.id.eventID {
pinnedEventContents.updateValue(pinnedEventStringBuilder.buildAttributedString(for: event) ?? AttributedString(L10n.commonUnsupportedEvent),
forKey: eventID)
}
}

state.pinnedEventsState.pinnedEventContents = pinnedEventContents
}

private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()

Expand Down
Loading

0 comments on commit ff2c42d

Please sign in to comment.