diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 03df560ce..819f96a1f 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -440,7 +440,9 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } private func updatePinnedEventIDs() async { - state.pinnedEventIDs = await roomProxy.pinnedEventIDs + if appSettings.pinningEnabled { + state.pinnedEventIDs = await roomProxy.pinnedEventIDs + } } private func setupDirectRoomSubscriptionsIfNeeded() { diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift index afdf3d931..32b33c57a 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift @@ -19,6 +19,13 @@ struct TimelineItemBubbledStylerView: View { private var isEncryptedOneToOneRoom: Bool { context.viewState.isEncryptedOneToOneRoom } private var isFocussed: Bool { focussedEventID != nil && timelineItem.id.eventID == focussedEventID } + private var isPinned: Bool { + guard !context.viewState.isPinnedEventsTimeline, + let eventID = timelineItem.id.eventID else { + return false + } + return context.viewState.pinnedEventIDs.contains(eventID) + } /// The base padding applied to bubbles on either side. /// @@ -146,6 +153,7 @@ struct TimelineItemBubbledStylerView: View { context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action)) } } + .pinnedIndicator(isPinned: isPinned, isOutgoing: timelineItem.isOutgoing) .padding(.top, messageBubbleTopPadding) } @@ -309,10 +317,58 @@ private extension EdgeInsets { static var zero: Self = .init(around: 0) } +private struct PinnedIndicatorViewModifier: ViewModifier { + let isPinned: Bool + let isOutgoing: Bool + + func body(content: Content) -> some View { + if isPinned { + HStack(alignment: .top, spacing: 8) { + if isOutgoing { + pinnedIndicator + } + content + .layoutPriority(1) + if !isOutgoing { + pinnedIndicator + } + } + } else { + content + } + } + + private var pinnedIndicator: some View { + CompoundIcon(\.pinSolid, size: .xSmall, relativeTo: .compound.bodyMD) + .foregroundStyle(Color.compound.iconTertiary) + } +} + +private extension View { + func pinnedIndicator(isPinned: Bool, isOutgoing: Bool) -> some View { + modifier(PinnedIndicatorViewModifier(isPinned: isPinned, isOutgoing: isOutgoing)) + } +} + // MARK: - Previews struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview { static let viewModel = TimelineViewModel.mock + static let viewModelWithPins: TimelineViewModel = { + var settings = AppSettings() + settings.pinningEnabled = true + let roomProxy = JoinedRoomProxyMock(.init(name: "Preview Room", pinnedEventIDs: [""])) + return TimelineViewModel(roomProxy: roomProxy, + focussedEventID: nil, + timelineController: MockRoomTimelineController(), + mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), + userIndicatorController: ServiceLocator.shared.userIndicatorController, + appMediator: AppMediatorMock.default, + appSettings: settings, + analyticsService: ServiceLocator.shared.analytics) + }() static var previews: some View { mockTimeline @@ -326,9 +382,12 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview .previewDisplayName("Thread decorator") encryptionAuthenticity .previewDisplayName("Encryption Indicators") + pinned + .previewDisplayName("Pinned messages") + .snapshotPreferences(delay: 1.0) } - // These akwats include a reply + // These always include a reply static var threads: some View { ScrollView { RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""), @@ -555,4 +614,96 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview } .environmentObject(viewModel.context) } + + static var pinned: some View { + ScrollView { + RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: "", eventID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: "whoever"), + content: .init(body: "A long message that should be on multiple lines."), + replyDetails: nil), + groupStyle: .single)) + + AudioRoomTimelineView(timelineItem: .init(id: .init(timelineID: "", eventID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: ""), + content: .init(body: "audio.ogg", + duration: 100, + waveform: EstimatedWaveform.mockWaveform, + source: nil, + contentType: nil), + replyDetails: nil)) + + FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: "", eventID: ""), + timestamp: "10:42", + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: ""), + content: .init(body: "File", + source: nil, + thumbnailSource: nil, + contentType: nil), + replyDetails: nil)) + ImageRoomTimelineView(timelineItem: .init(id: .init(timelineID: "", eventID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: true, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: ""), + content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil), + replyDetails: nil)) + LocationRoomTimelineView(timelineItem: .init(id: .init(timelineID: "", eventID: ""), + timestamp: "Now", + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: "Bob"), + content: .init(body: "Fallback geo uri description", + geoURI: .init(latitude: 41.902782, + longitude: 12.496366), + description: "Location description description description description description description description description"), + replyDetails: nil)) + LocationRoomTimelineView(timelineItem: .init(id: .init(timelineID: "", eventID: ""), + timestamp: "Now", + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: "Bob"), + content: .init(body: "Fallback geo uri description", + geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil), + replyDetails: nil)) + + VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: "", eventID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: ""), + content: .init(body: "audio.ogg", + duration: 100, + waveform: EstimatedWaveform.mockWaveform, + source: nil, + contentType: nil), + replyDetails: nil), + playerState: AudioPlayerState(id: .timelineItemIdentifier(.random), + title: L10n.commonVoiceMessage, + duration: 10, + waveform: EstimatedWaveform.mockWaveform)) + } + .environmentObject(viewModelWithPins.context) + } } diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPad-en-GB.Pinned-messages.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPad-en-GB.Pinned-messages.png new file mode 100644 index 000000000..21ffd7778 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPad-en-GB.Pinned-messages.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:844ecf4b4fec3222a52ffb7f9ff9d72d811b48c2db6d42818ef8eb49f7d58fe4 +size 736907 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPad-pseudo.Pinned-messages.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPad-pseudo.Pinned-messages.png new file mode 100644 index 000000000..21ffd7778 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPad-pseudo.Pinned-messages.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:844ecf4b4fec3222a52ffb7f9ff9d72d811b48c2db6d42818ef8eb49f7d58fe4 +size 736907 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPhone-15-en-GB.Pinned-messages.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPhone-15-en-GB.Pinned-messages.png new file mode 100644 index 000000000..d216e5cd1 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPhone-15-en-GB.Pinned-messages.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce287441bbdd630d0d7de7ec5b101002216e7528d77c81959526edf6a69e5718 +size 493180 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPhone-15-pseudo.Pinned-messages.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPhone-15-pseudo.Pinned-messages.png new file mode 100644 index 000000000..d216e5cd1 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPhone-15-pseudo.Pinned-messages.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce287441bbdd630d0d7de7ec5b101002216e7528d77c81959526edf6a69e5718 +size 493180 diff --git a/UnitTests/Sources/TimelineViewModelTests.swift b/UnitTests/Sources/TimelineViewModelTests.swift index adc3e3a47..c4434c8f2 100644 --- a/UnitTests/Sources/TimelineViewModelTests.swift +++ b/UnitTests/Sources/TimelineViewModelTests.swift @@ -379,6 +379,7 @@ class TimelineViewModelTests: XCTestCase { // MARK: - Pins func testPinnedEvents() async throws { + ServiceLocator.shared.settings.pinningEnabled = true let roomProxyMock = JoinedRoomProxyMock(.init(name: "", pinnedEventIDs: .init(["test1"]))) let actionsSubject = PassthroughSubject()