From ff2c42d53bf07423761b4ac5de4cdf370ee08ed8 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Mon, 5 Aug 2024 14:09:05 +0200 Subject: [PATCH] Pinned items timeline implementation for the banner (#3099) --- .../Mocks/Generated/GeneratedMocks.swift | 23 +++- .../Mocks/Generated/SDKGeneratedMocks.swift | 111 ++++++++++++++++++ ElementX/Sources/Mocks/RoomProxyMock.swift | 2 +- .../Other/Extensions/AttributedString.swift | 5 + .../Screens/RoomScreen/RoomScreenModels.swift | 54 ++++++--- .../RoomScreen/RoomScreenViewModel.swift | 40 ++++++- .../PinnedItemsBannerView.swift | 43 ++++--- .../Screens/RoomScreen/View/RoomScreen.swift | 8 +- .../Style/TimelineItemBubbledStylerView.swift | 2 +- .../Sources/Services/Client/ClientProxy.swift | 5 +- .../Sources/Services/Room/RoomProxy.swift | 25 +++- .../Services/Room/RoomProxyProtocol.swift | 4 +- .../RoomSummary/RoomEventStringBuilder.swift | 15 ++- .../RoomMessageEventStringBuilder.swift | 55 +++++---- .../RoomTimelineController.swift | 5 +- .../RoomTimelineControllerFactory.swift | 1 + .../UITests/UITestsAppCoordinator.swift | 1 + NSE/Sources/NotificationContentBuilder.swift | 2 +- .../NotificationServiceExtension.swift | 2 +- ...est_pinnedItemsBannerView-iPad-en-GB.1.png | 4 +- ...st_pinnedItemsBannerView-iPad-pseudo.1.png | 4 +- ...innedItemsBannerView-iPhone-15-en-GB.1.png | 4 +- ...nnedItemsBannerView-iPhone-15-pseudo.1.png | 4 +- 23 files changed, 336 insertions(+), 83 deletions(-) diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 8dad86fca..a53fe7e2f 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -8320,7 +8320,7 @@ class RoomProxyMock: RoomProxyProtocol { return pinnedEventIDsCallsCount > 0 } - var pinnedEventIDs: [String] { + var pinnedEventIDs: Set { get async { pinnedEventIDsCallsCount += 1 if let pinnedEventIDsClosure = pinnedEventIDsClosure { @@ -8330,8 +8330,8 @@ class RoomProxyMock: RoomProxyProtocol { } } } - var underlyingPinnedEventIDs: [String]! - var pinnedEventIDsClosure: (() async -> [String])? + var underlyingPinnedEventIDs: Set! + var pinnedEventIDsClosure: (() async -> Set)? var membership: Membership { get { return underlyingMembership } set(value) { underlyingMembership = value } @@ -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 diff --git a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift index 5142c895a..056491d7f 100644 --- a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift @@ -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? @@ -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 diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index decb0df18..b82e7daa2 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -29,7 +29,7 @@ struct RoomProxyMockConfiguration { var isEncrypted = true var hasOngoingCall = true var canonicalAlias: String? - var pinnedEventIDs: [String] = [] + var pinnedEventIDs: Set = [] var timelineStartReached = false diff --git a/ElementX/Sources/Other/Extensions/AttributedString.swift b/ElementX/Sources/Other/Extensions/AttributedString.swift index efd51b9e2..dbe7fd742 100644 --- a/ElementX/Sources/Other/Extensions/AttributedString.swift +++ b/ElementX/Sources/Other/Extensions/AttributedString.swift @@ -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]) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 1897d8f8d..77d9bcc47 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -139,7 +139,7 @@ enum RoomScreenViewAction { case hasSwitchedTimeline case hasScrolled(direction: ScrollDirection) - case tappedPinBanner + case tappedPinnedEventsBanner case viewAllPins } @@ -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 = [] + // 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 @@ -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 = [] { + var pinnedEventContents: OrderedDictionary = [:] { 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] } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 4d32e659a..2698452c2 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -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 @@ -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) @@ -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 @@ -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) } } @@ -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) @@ -635,6 +654,21 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol // MARK: - Timeline Item Building + private func buildPinnedEventContent(timelineItems: [TimelineItemProxy]) { + var pinnedEventContents = OrderedDictionary() + + 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() diff --git a/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift b/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift index 506ebdd9a..f022b46f0 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift @@ -23,16 +23,6 @@ struct PinnedItemsBannerView: View { let onMainButtonTap: () -> Void let onViewAllButtonTap: () -> Void - private var bannerIndicatorDescription: AttributedString { - let index = pinnedEventsState.selectedPinIndex + 1 - let boldPlaceholder = "{bold}" - var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder)) - var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, pinnedEventsState.pinnedEventIDs.count)) - boldString.bold() - finalString.replace(boldPlaceholder, with: boldString) - return finalString - } - var body: some View { HStack(spacing: 0) { mainButton @@ -48,7 +38,7 @@ struct PinnedItemsBannerView: View { Button { onMainButtonTap() } label: { HStack(spacing: 0) { HStack(spacing: 10) { - PinnedItemsIndicatorView(pinIndex: pinnedEventsState.selectedPinIndex, pinsCount: pinnedEventsState.pinnedEventIDs.count) + PinnedItemsIndicatorView(pinIndex: pinnedEventsState.selectedPinIndex, pinsCount: pinnedEventsState.pinnedEventContents.count) .accessibilityHidden(true) CompoundIcon(\.pinSolid, size: .small, relativeTo: .compound.bodyMD) .foregroundColor(Color.compound.iconSecondaryAlpha) @@ -73,7 +63,7 @@ struct PinnedItemsBannerView: View { private var content: some View { VStack(alignment: .leading, spacing: 0) { - Text(bannerIndicatorDescription) + Text(pinnedEventsState.bannerIndicatorDescription) .font(.compound.bodySM) .foregroundColor(.compound.textActionAccent) .lineLimit(1) @@ -86,9 +76,32 @@ struct PinnedItemsBannerView: View { } struct PinnedItemsBannerView_Previews: PreviewProvider, TestablePreview { + static var attributedContent: AttributedString { + var boldPart = AttributedString("Image:") + boldPart.bold() + var final = boldPart + " content.png" + // This should be ignored when presented + final.font = .headline + return final + } + static var previews: some View { - PinnedItemsBannerView(pinnedEventsState: .init(pinnedEventIDs: ["Content", "NotShown1", "NotShown2"], selectedPinEventID: "Content"), - onMainButtonTap: { }, - onViewAllButtonTap: { }) + VStack(spacing: 20) { + PinnedItemsBannerView(pinnedEventsState: .init(pinnedEventContents: ["1": "Content", + "2": "2", + "3": "3"], + selectedPinEventID: "1"), + onMainButtonTap: { }, + onViewAllButtonTap: { }) + PinnedItemsBannerView(pinnedEventsState: .init(pinnedEventContents: ["1": "Very very very very long content here", + "2": "2"], + selectedPinEventID: "1"), + onMainButtonTap: { }, + onViewAllButtonTap: { }) + PinnedItemsBannerView(pinnedEventsState: .init(pinnedEventContents: ["1": attributedContent], + selectedPinEventID: "1"), + onMainButtonTap: { }, + onViewAllButtonTap: { }) + } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 45ece1897..6d0227d26 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -50,11 +50,11 @@ struct RoomScreen: View { } .overlay(alignment: .top) { Group { - if context.viewState.shouldShowPinBanner { + if context.viewState.shouldShowPinnedEventsBanner { pinnedItemsBanner } } - .animation(.elementDefault, value: context.viewState.shouldShowPinBanner) + .animation(.elementDefault, value: context.viewState.shouldShowPinnedEventsBanner) } .navigationTitle(L10n.screenRoomTitle) // Hidden but used for back button text. .navigationBarTitleDisplayMode(.inline) @@ -69,7 +69,7 @@ struct RoomScreen: View { canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf, canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers, canCurrentUserPin: context.viewState.canCurrentUserPin, - pinnedEventIDs: context.viewState.pinnedEventsState.pinnedEventIDs.set, + pinnedEventIDs: context.viewState.pinnedEventIDs, isDM: context.viewState.isEncryptedOneToOneRoom, isViewSourceEnabled: context.viewState.isViewSourceEnabled).makeActions() if let actions { @@ -111,7 +111,7 @@ struct RoomScreen: View { private var pinnedItemsBanner: some View { PinnedItemsBannerView(pinnedEventsState: context.viewState.pinnedEventsState, - onMainButtonTap: { context.send(viewAction: .tappedPinBanner) }, + onMainButtonTap: { context.send(viewAction: .tappedPinnedEventsBanner) }, onViewAllButtonTap: { context.send(viewAction: .viewAllPins) }) .transition(.move(edge: .top)) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 06ddbd509..cf5ddb862 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -145,7 +145,7 @@ struct TimelineItemBubbledStylerView: View { canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf, canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers, canCurrentUserPin: context.viewState.canCurrentUserPin, - pinnedEventIDs: context.viewState.pinnedEventsState.pinnedEventIDs.set, + pinnedEventIDs: context.viewState.pinnedEventIDs, isDM: context.viewState.isEncryptedOneToOneRoom, isViewSourceEnabled: context.viewState.isViewSourceEnabled) TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 95f063c6e..e03090332 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -759,10 +759,11 @@ class ClientProxy: ClientProxyProtocol { let roomListService = syncService.roomListService() let roomMessageEventStringBuilder = RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(cacheKey: "roomList", - mentionBuilder: PlainMentionBuilder())) + mentionBuilder: PlainMentionBuilder()), prefix: .senderName) let eventStringBuilder = RoomEventStringBuilder(stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID, shouldDisambiguateDisplayNames: false), messageEventStringBuilder: roomMessageEventStringBuilder, - shouldDisambiguateDisplayNames: false) + shouldDisambiguateDisplayNames: false, + shouldPrefixSenderName: true) roomSummaryProvider = RoomSummaryProvider(roomListService: roomListService, eventStringBuilder: eventStringBuilder, diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 34d435ea1..7067d175f 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -23,6 +23,24 @@ class RoomProxy: RoomProxyProtocol { private let roomListItem: RoomListItemProtocol private let room: RoomProtocol let timeline: TimelineProxyProtocol + private var innerPinnedEventsTimeline: TimelineProxyProtocol? + var pinnedEventsTimeline: TimelineProxyProtocol? { + get async { + if let innerPinnedEventsTimeline { + return innerPinnedEventsTimeline + } else { + do { + let timeline = try await TimelineProxy(timeline: room.pinnedEventsTimeline(internalIdPrefix: nil, maxEventsToLoad: 100), isLive: false) + await timeline.subscribeForUpdates() + innerPinnedEventsTimeline = timeline + return timeline + } catch { + MXLog.error("Failed creating pinned events timeline with error: \(error)") + return nil + } + } + } + } // periphery:ignore - required for instance retention in the rust codebase private var roomInfoObservationToken: TaskHandle? @@ -92,9 +110,12 @@ class RoomProxy: RoomProxyProtocol { } } - var pinnedEventIDs: [String] { + var pinnedEventIDs: Set { get async { - await (try? room.roomInfo().pinnedEventIds) ?? [] + guard let pinnedEventIDs = try? await room.roomInfo().pinnedEventIds else { + return [] + } + return .init(pinnedEventIDs) } } diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index ec161ed68..df42c9241 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -38,7 +38,7 @@ protocol RoomProxyProtocol { var isSpace: Bool { get } var isEncrypted: Bool { get } var isFavourite: Bool { get async } - var pinnedEventIDs: [String] { get async } + var pinnedEventIDs: Set { get async } var membership: Membership { get } var inviter: RoomMemberProxyProtocol? { get async } var hasOngoingCall: Bool { get } @@ -66,6 +66,8 @@ protocol RoomProxyProtocol { var timeline: TimelineProxyProtocol { get } + var pinnedEventsTimeline: TimelineProxyProtocol? { get async } + func subscribeForUpdates() async func subscribeToRoomInfoUpdates() diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift index 3878f83bc..4b6e3878e 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift @@ -21,6 +21,7 @@ struct RoomEventStringBuilder { let stateEventStringBuilder: RoomStateEventStringBuilder let messageEventStringBuilder: RoomMessageEventStringBuilder let shouldDisambiguateDisplayNames: Bool + let shouldPrefixSenderName: Bool func buildAttributedString(for eventItemProxy: EventTimelineItemProxy) -> AttributedString? { let sender = eventItemProxy.sender @@ -50,7 +51,7 @@ struct RoomEventStringBuilder { } let messageType = messageContent.msgtype() - return messageEventStringBuilder.buildAttributedString(for: messageType, senderDisplayName: displayName, prefixWithSenderName: true) + return messageEventStringBuilder.buildAttributedString(for: messageType, senderDisplayName: displayName) case .state(_, let state): return stateEventStringBuilder .buildString(for: state, sender: sender, isOutgoing: isOutgoing) @@ -78,6 +79,9 @@ struct RoomEventStringBuilder { } private func prefix(_ eventSummary: String, with senderDisplayName: String) -> AttributedString { + guard shouldPrefixSenderName else { + return AttributedString(eventSummary) + } let attributedEventSummary = AttributedString(eventSummary.trimmingCharacters(in: .whitespacesAndNewlines)) var attributedSenderDisplayName = AttributedString(senderDisplayName) @@ -86,4 +90,13 @@ struct RoomEventStringBuilder { // Don't include the message body in the markdown otherwise it makes tappable links. return attributedSenderDisplayName + ": " + attributedEventSummary } + + static func pinnedEventStringBuilder(userID: String) -> Self { + RoomEventStringBuilder(stateEventStringBuilder: .init(userID: userID, + shouldDisambiguateDisplayNames: false), + messageEventStringBuilder: .init(attributedStringBuilder: AttributedStringBuilder(cacheKey: "pinnedEvents", mentionBuilder: PlainMentionBuilder()), + prefix: .mediaType), + shouldDisambiguateDisplayNames: false, + shouldPrefixSenderName: false) + } } diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift index 86ba1481d..90ae99304 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift @@ -18,10 +18,17 @@ import Foundation import MatrixRustSDK struct RoomMessageEventStringBuilder { - let attributedStringBuilder: AttributedStringBuilderProtocol + enum Prefix { + case senderName + case mediaType + case none + } - func buildAttributedString(for messageType: MessageType, senderDisplayName: String, prefixWithSenderName: Bool) -> AttributedString { - let message: String + let attributedStringBuilder: AttributedStringBuilderProtocol + let prefix: Prefix + + func buildAttributedString(for messageType: MessageType, senderDisplayName: String) -> AttributedString { + let message: AttributedString switch messageType { // Message types that don't need a prefix. case .emote(content: let content): @@ -33,46 +40,54 @@ struct RoomMessageEventStringBuilder { // Message types that should be prefixed with the sender's name. case .audio(content: let content): let isVoiceMessage = content.voice != nil - message = isVoiceMessage ? L10n.commonVoiceMessage : L10n.commonAudio + var content = AttributedString(isVoiceMessage ? L10n.commonVoiceMessage : L10n.commonAudio) + if prefix == .mediaType { + content.bold() + } + message = content case .image(let content): - message = "\(L10n.commonImage) - \(content.body)" + message = prefix == .mediaType ? prefix(AttributedString(content.body), with: L10n.commonImage) : AttributedString("\(L10n.commonImage) - \(content.body)") case .video(let content): - message = "\(L10n.commonVideo) - \(content.body)" + message = prefix == .mediaType ? prefix(AttributedString(content.body), with: L10n.commonVideo) : AttributedString("\(L10n.commonVideo) - \(content.body)") case .file(let content): - message = "\(L10n.commonFile) - \(content.body)" + message = prefix == .mediaType ? prefix(AttributedString(content.body), with: L10n.commonFile) : AttributedString("\(L10n.commonFile) - \(content.body)") case .location: - message = L10n.commonSharedLocation + var content = AttributedString(L10n.commonSharedLocation) + if prefix == .mediaType { + content.bold() + } + message = content case .notice(content: let content): if let attributedMessage = attributedMessageFrom(formattedBody: content.formatted) { - message = String(attributedMessage.characters) + message = attributedMessage } else { - message = content.body + message = AttributedString(content.body) } case .text(content: let content): if let attributedMessage = attributedMessageFrom(formattedBody: content.formatted) { - message = String(attributedMessage.characters) + message = attributedMessage } else { - message = content.body + message = AttributedString(content.body) } case .other(_, let body): - message = body + message = AttributedString(body) } - if prefixWithSenderName { + if prefix == .senderName { return prefix(message, with: senderDisplayName) } else { - return AttributedString(message) + return message } } - private func prefix(_ eventSummary: String, with senderDisplayName: String) -> AttributedString { - let attributedEventSummary = AttributedString(eventSummary.trimmingCharacters(in: .whitespacesAndNewlines)) + private func prefix(_ eventSummary: AttributedString, with textToBold: String) -> AttributedString { + let attributedEventSummary = AttributedString(eventSummary.string.trimmingCharacters(in: .whitespacesAndNewlines)) - var attributedSenderDisplayName = AttributedString(senderDisplayName) - attributedSenderDisplayName.bold() + var attributedPrefix = AttributedString(textToBold + ":") + attributedPrefix.bold() // Don't include the message body in the markdown otherwise it makes tappable links. - return attributedSenderDisplayName + ": " + attributedEventSummary + return attributedPrefix + " " + attributedEventSummary } private func attributedMessageFrom(formattedBody: FormattedBody?) -> AttributedString? { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index 8db6d0c3b..ad727a623 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -42,16 +42,17 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } init(roomProxy: RoomProxyProtocol, + timelineProxy: TimelineProxyProtocol, initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol, appSettings: AppSettings) { self.roomProxy = roomProxy - liveTimelineProvider = roomProxy.timeline.timelineProvider + liveTimelineProvider = timelineProxy.timelineProvider self.timelineItemFactory = timelineItemFactory self.appSettings = appSettings serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility) - activeTimeline = roomProxy.timeline + activeTimeline = timelineProxy activeTimelineProvider = liveTimelineProvider NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil) diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift index 2dbfff198..182fe417d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift @@ -21,6 +21,7 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol) -> RoomTimelineControllerProtocol { RoomTimelineController(roomProxy: roomProxy, + timelineProxy: roomProxy.timeline, initialFocussedEventID: initialFocussedEventID, timelineItemFactory: timelineItemFactory, appSettings: ServiceLocator.shared.settings) diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index e138a5084..81eb82863 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -634,6 +634,7 @@ class MockScreen: Identifiable { ServiceLocator.shared.settings.migratedAccounts[clientProxy.userID] = true let timelineController = RoomTimelineController(roomProxy: roomProxy, + timelineProxy: roomProxy.timeline, initialFocussedEventID: nil, timelineItemFactory: RoomTimelineItemFactory(userID: "@alice:matrix.org", encryptionAuthenticityEnabled: true, diff --git a/NSE/Sources/NotificationContentBuilder.swift b/NSE/Sources/NotificationContentBuilder.swift index 90d7083e7..569d90852 100644 --- a/NSE/Sources/NotificationContentBuilder.swift +++ b/NSE/Sources/NotificationContentBuilder.swift @@ -103,7 +103,7 @@ struct NotificationContentBuilder { var notification = try await processCommonRoomMessage(notificationItem: notificationItem, mediaProvider: mediaProvider) let displayName = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName - let message = String(messageEventStringBuilder.buildAttributedString(for: messageType, senderDisplayName: displayName, prefixWithSenderName: false).characters) + let message = String(messageEventStringBuilder.buildAttributedString(for: messageType, senderDisplayName: displayName).characters) notification.body = notificationItem.hasMention ? L10n.notificationMentionedYouBody(message) : message diff --git a/NSE/Sources/NotificationServiceExtension.swift b/NSE/Sources/NotificationServiceExtension.swift index 172c464e7..2164ff6ee 100644 --- a/NSE/Sources/NotificationServiceExtension.swift +++ b/NSE/Sources/NotificationServiceExtension.swift @@ -42,7 +42,7 @@ import UserNotifications // database, logging, etc. are only ever setup once per *process* private let settings: NSESettingsProtocol = AppSettings() -private let notificationContentBuilder = NotificationContentBuilder(messageEventStringBuilder: RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(mentionBuilder: PlainMentionBuilder()))) +private let notificationContentBuilder = NotificationContentBuilder(messageEventStringBuilder: RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(mentionBuilder: PlainMentionBuilder()), prefix: .none)) private let keychainController = KeychainController(service: .sessions, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier) diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-en-GB.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-en-GB.1.png index 98e556874..92c1d53b3 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-en-GB.1.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-en-GB.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:08962f7405eacac00d0fbc410e806820202105b03558fbc962ab4d5ba53f0293 -size 85241 +oid sha256:5eb75e371a78b17fad61215006b871310665bf8061772985d047facbccc5bae7 +size 129176 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-pseudo.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-pseudo.1.png index 4990d784f..7019c67c6 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-pseudo.1.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-pseudo.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9cb3d02aa9af3d6d2fcd8d5d1195ab8c7cb9dcb1a7544fe5eceb468638770009 -size 94063 +oid sha256:745d8fdea278dc6ab55f39501384ecc5efc3899db233c52c797a8542f4885adc +size 154132 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-en-GB.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-en-GB.1.png index 7c0822bd4..0aeb4f05d 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-en-GB.1.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-en-GB.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22228167b8b988f91baac100e980d41039a3b942be219cdfa7d8a9bfd8947610 -size 42906 +oid sha256:82dc33b9de9b497388b91304afe2623e4fa5c9d5aff246a87b68c4d948a63a37 +size 76715 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-pseudo.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-pseudo.1.png index cf9afef3b..51383825d 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-pseudo.1.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-pseudo.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:116c3b0f009cbcb102fa574605577da8d2d68f95d33746f778b99afb19cec872 -size 46568 +oid sha256:48df9215b99d69572bf3c4395e4b13d0b34012691b98bba75c73fc35c3b0b6fb +size 85989