Pinned items timeline implementation for the banner (#3099)

This commit is contained in:
Mauro 2024-08-05 14:09:05 +02:00 committed by GitHub
parent a11faeb131
commit ff2c42d53b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 336 additions and 83 deletions

View File

@ -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 {
@ -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 }
@ -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

View File

@ -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

View File

@ -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

View File

@ -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])

View File

@ -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<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
@ -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]
}
}

View File

@ -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<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>()

View File

@ -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: { })
}
}
}

View File

@ -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))
}

View File

@ -145,7 +145,7 @@ struct TimelineItemBubbledStylerView<Content: View>: 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

View File

@ -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,

View File

@ -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<String> {
get async {
await (try? room.roomInfo().pinnedEventIds) ?? []
guard let pinnedEventIDs = try? await room.roomInfo().pinnedEventIds else {
return []
}
return .init(pinnedEventIDs)
}
}

View File

@ -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<String> { 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()

View File

@ -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)
}
}

View File

@ -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? {

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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)