Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pinned items timeline implementation for the banner #3099

Merged
merged 8 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading