mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
Added a pin icon (#3257)
This commit is contained in:
parent
ff5b22cecf
commit
ed67a29277
@ -440,7 +440,9 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updatePinnedEventIDs() async {
|
private func updatePinnedEventIDs() async {
|
||||||
state.pinnedEventIDs = await roomProxy.pinnedEventIDs
|
if appSettings.pinningEnabled {
|
||||||
|
state.pinnedEventIDs = await roomProxy.pinnedEventIDs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupDirectRoomSubscriptionsIfNeeded() {
|
private func setupDirectRoomSubscriptionsIfNeeded() {
|
||||||
|
@ -19,6 +19,13 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
|||||||
|
|
||||||
private var isEncryptedOneToOneRoom: Bool { context.viewState.isEncryptedOneToOneRoom }
|
private var isEncryptedOneToOneRoom: Bool { context.viewState.isEncryptedOneToOneRoom }
|
||||||
private var isFocussed: Bool { focussedEventID != nil && timelineItem.id.eventID == focussedEventID }
|
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.
|
/// The base padding applied to bubbles on either side.
|
||||||
///
|
///
|
||||||
@ -146,6 +153,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
|||||||
context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action))
|
context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.pinnedIndicator(isPinned: isPinned, isOutgoing: timelineItem.isOutgoing)
|
||||||
.padding(.top, messageBubbleTopPadding)
|
.padding(.top, messageBubbleTopPadding)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -309,10 +317,58 @@ private extension EdgeInsets {
|
|||||||
static var zero: Self = .init(around: 0)
|
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
|
// MARK: - Previews
|
||||||
|
|
||||||
struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview {
|
struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview {
|
||||||
static let viewModel = TimelineViewModel.mock
|
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 {
|
static var previews: some View {
|
||||||
mockTimeline
|
mockTimeline
|
||||||
@ -326,9 +382,12 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
|
|||||||
.previewDisplayName("Thread decorator")
|
.previewDisplayName("Thread decorator")
|
||||||
encryptionAuthenticity
|
encryptionAuthenticity
|
||||||
.previewDisplayName("Encryption Indicators")
|
.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 {
|
static var threads: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||||
@ -555,4 +614,96 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
|
|||||||
}
|
}
|
||||||
.environmentObject(viewModel.context)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPad-en-GB.Pinned-messages.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPad-en-GB.Pinned-messages.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPad-pseudo.Pinned-messages.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPad-pseudo.Pinned-messages.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPhone-15-en-GB.Pinned-messages.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPhone-15-en-GB.Pinned-messages.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPhone-15-pseudo.Pinned-messages.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_timelineItemBubbledStylerView-iPhone-15-pseudo.Pinned-messages.png
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -379,6 +379,7 @@ class TimelineViewModelTests: XCTestCase {
|
|||||||
// MARK: - Pins
|
// MARK: - Pins
|
||||||
|
|
||||||
func testPinnedEvents() async throws {
|
func testPinnedEvents() async throws {
|
||||||
|
ServiceLocator.shared.settings.pinningEnabled = true
|
||||||
let roomProxyMock = JoinedRoomProxyMock(.init(name: "",
|
let roomProxyMock = JoinedRoomProxyMock(.init(name: "",
|
||||||
pinnedEventIDs: .init(["test1"])))
|
pinnedEventIDs: .init(["test1"])))
|
||||||
let actionsSubject = PassthroughSubject<JoinedRoomProxyAction, Never>()
|
let actionsSubject = PassthroughSubject<JoinedRoomProxyAction, Never>()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user