mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 13:37:11 +00:00
Pinned items timeline implementation for the banner (#3099)
This commit is contained in:
parent
a11faeb131
commit
ff2c42d53b
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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])
|
||||
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
@ -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>()
|
||||
|
||||
|
@ -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: { })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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? {
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-en-GB.1.png
(Stored with Git LFS)
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-en-GB.1.png
(Stored with Git LFS)
Binary file not shown.
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-pseudo.1.png
(Stored with Git LFS)
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPad-pseudo.1.png
(Stored with Git LFS)
Binary file not shown.
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-en-GB.1.png
(Stored with Git LFS)
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-en-GB.1.png
(Stored with Git LFS)
Binary file not shown.
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-pseudo.1.png
(Stored with Git LFS)
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedItemsBannerView-iPhone-15-pseudo.1.png
(Stored with Git LFS)
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user