mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Pin/Unpin Logic (#3084)
This commit is contained in:
@@ -172,8 +172,11 @@ class RoomScreenInteractionHandler {
|
||||
case .endPoll(let pollStartID):
|
||||
endPoll(pollStartID: pollStartID)
|
||||
case .pin:
|
||||
// TODO: Implement the pin action
|
||||
break
|
||||
guard let eventID = itemID.eventID else { return }
|
||||
Task { await timelineController.pin(eventID: eventID) }
|
||||
case .unpin:
|
||||
guard let eventID = itemID.eventID else { return }
|
||||
Task { await timelineController.unpin(eventID: eventID) }
|
||||
}
|
||||
|
||||
if action.switchToDefaultComposer {
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
import OrderedCollections
|
||||
import SwiftUI
|
||||
|
||||
enum RoomScreenViewModelAction {
|
||||
case displayRoomDetails
|
||||
@@ -140,7 +139,7 @@ enum RoomScreenViewAction {
|
||||
case hasSwitchedTimeline
|
||||
|
||||
case hasScrolled(direction: ScrollDirection)
|
||||
case nextPin
|
||||
case tappedPinBanner
|
||||
case viewAllPins
|
||||
}
|
||||
|
||||
@@ -172,20 +171,11 @@ struct RoomScreenViewState: BindableState {
|
||||
|
||||
var isPinningEnabled = false
|
||||
var lastScrollDirection: ScrollDirection?
|
||||
// These are just mocked items used for testing, their types might change
|
||||
let pinnedItems = [
|
||||
"Hello 1",
|
||||
"How are you 2",
|
||||
"I am fine 3",
|
||||
"Thank you 4"
|
||||
]
|
||||
var currentPinIndex = 0
|
||||
var shouldShowPinBanner: Bool {
|
||||
isPinningEnabled && !pinnedItems.isEmpty && lastScrollDirection != .top
|
||||
}
|
||||
|
||||
var selectedPinContent: AttributedString {
|
||||
.init(pinnedItems[currentPinIndex])
|
||||
var pinnedEventsState = PinnedEventsState()
|
||||
|
||||
var shouldShowPinBanner: Bool {
|
||||
isPinningEnabled && !pinnedEventsState.pinnedEventIDs.isEmpty && lastScrollDirection != .top
|
||||
}
|
||||
|
||||
var canJoinCall = false
|
||||
@@ -304,3 +294,41 @@ enum ScrollDirection: Equatable {
|
||||
case top
|
||||
case bottom
|
||||
}
|
||||
|
||||
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> = [] {
|
||||
didSet {
|
||||
if selectedPinEventID == nil, !pinnedEventIDs.isEmpty {
|
||||
selectedPinEventID = pinnedEventIDs.first
|
||||
} else if pinnedEventIDs.isEmpty {
|
||||
selectedPinEventID = nil
|
||||
} else if let selectedPinEventID, !pinnedEventIDs.contains(selectedPinEventID) {
|
||||
self.selectedPinEventID = pinnedEventIDs.first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selectedPinEventID: String?
|
||||
|
||||
var selectedPinIndex: Int {
|
||||
guard let selectedPinEventID else {
|
||||
return 0
|
||||
}
|
||||
return pinnedEventIDs.firstIndex(of: selectedPinEventID) ?? 0
|
||||
}
|
||||
|
||||
// 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 ?? "")
|
||||
}
|
||||
|
||||
mutating func nextPin() {
|
||||
guard !pinnedEventIDs.isEmpty else {
|
||||
return
|
||||
}
|
||||
let currentIndex = selectedPinIndex
|
||||
let nextIndex = (currentIndex + 1) % pinnedEventIDs.count
|
||||
selectedPinEventID = pinnedEventIDs[nextIndex]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +196,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
Task { state.timelineViewState.isSwitchingTimelines = false }
|
||||
case let .hasScrolled(direction):
|
||||
state.lastScrollDirection = direction
|
||||
case .nextPin:
|
||||
state.currentPinIndex = (state.currentPinIndex + 1) % state.pinnedItems.count
|
||||
case .tappedPinBanner:
|
||||
if let eventID = state.pinnedEventsState.selectedPinEventID {
|
||||
Task { await focusOnEvent(eventID: eventID) }
|
||||
}
|
||||
state.pinnedEventsState.nextPin()
|
||||
case .viewAllPins:
|
||||
// TODO: Implement
|
||||
break
|
||||
@@ -368,7 +371,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
|
||||
if state.isPinningEnabled,
|
||||
case let .success(value) = await roomProxy.canUser(userID: roomProxy.ownUserID, sendStateEvent: .roomPinnedEvents) {
|
||||
case let .success(value) = await roomProxy.canUserPinOrUnpin(userID: roomProxy.ownUserID) {
|
||||
state.canCurrentUserPin = value
|
||||
} else {
|
||||
state.canCurrentUserPin = false
|
||||
@@ -401,9 +404,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
roomProxy
|
||||
let roomInfoSubscription = roomProxy
|
||||
.actionsPublisher
|
||||
.filter { $0 == .roomInfoUpdate }
|
||||
|
||||
roomInfoSubscription
|
||||
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
@@ -413,6 +418,21 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else {
|
||||
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)
|
||||
for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
await state.pinnedEventsState.pinnedEventIDs = .init(roomProxy.pinnedEventIDs)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
appSettings.$sharePresence
|
||||
.weakAssign(to: \.state.showReadReceipts, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -62,6 +62,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
case toggleReaction(key: String)
|
||||
case endPoll(pollStartID: String)
|
||||
case pin
|
||||
case unpin
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
@@ -135,7 +136,9 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
case .endPoll:
|
||||
Label(L10n.actionEndPoll, icon: \.pollsEnd)
|
||||
case .pin:
|
||||
Label(L10n.Action.pin, icon: \.pin)
|
||||
Label(L10n.actionPin, icon: \.pin)
|
||||
case .unpin:
|
||||
Label(L10n.actionUnpin, icon: \.unpin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -21,6 +21,7 @@ struct TimelineItemMenuActionProvider {
|
||||
let canCurrentUserRedactSelf: Bool
|
||||
let canCurrentUserRedactOthers: Bool
|
||||
let canCurrentUserPin: Bool
|
||||
let pinnedEventIDs: Set<String>
|
||||
let isDM: Bool
|
||||
let isViewSourceEnabled: Bool
|
||||
|
||||
@@ -66,9 +67,8 @@ struct TimelineItemMenuActionProvider {
|
||||
actions.append(.forward(itemID: item.id))
|
||||
}
|
||||
|
||||
if canCurrentUserPin {
|
||||
// TODO: If the event is already pinned use the unpinned action
|
||||
actions.append(.pin)
|
||||
if canCurrentUserPin, let eventID = item.id.eventID {
|
||||
actions.append(pinnedEventIDs.contains(eventID) ? .unpin : .pin)
|
||||
}
|
||||
|
||||
if item.isEditable {
|
||||
|
||||
+8
-12
@@ -18,18 +18,16 @@ import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct PinnedItemsBannerView: View {
|
||||
let pinIndex: Int
|
||||
let pinsCount: Int
|
||||
let pinContent: AttributedString
|
||||
let pinnedEventsState: PinnedEventsState
|
||||
|
||||
let onMainButtonTap: () -> Void
|
||||
let onViewAllButtonTap: () -> Void
|
||||
|
||||
private var bannerIndicatorDescription: AttributedString {
|
||||
let index = pinIndex + 1
|
||||
let index = pinnedEventsState.selectedPinIndex + 1
|
||||
let boldPlaceholder = "{bold}"
|
||||
var finalString = AttributedString(L10n.Screen.Room.pinnedBannerIndicatorDescription(boldPlaceholder))
|
||||
var boldString = AttributedString(L10n.Screen.Room.pinnedBannerIndicator(index, pinsCount))
|
||||
var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder))
|
||||
var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, pinnedEventsState.pinnedEventIDs.count))
|
||||
boldString.bold()
|
||||
finalString.replace(boldPlaceholder, with: boldString)
|
||||
return finalString
|
||||
@@ -50,7 +48,7 @@ struct PinnedItemsBannerView: View {
|
||||
Button { onMainButtonTap() } label: {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 10) {
|
||||
PinnedItemsIndicatorView(pinIndex: pinIndex, pinsCount: pinsCount)
|
||||
PinnedItemsIndicatorView(pinIndex: pinnedEventsState.selectedPinIndex, pinsCount: pinnedEventsState.pinnedEventIDs.count)
|
||||
.accessibilityHidden(true)
|
||||
CompoundIcon(\.pinSolid, size: .small, relativeTo: .compound.bodyMD)
|
||||
.foregroundColor(Color.compound.iconSecondaryAlpha)
|
||||
@@ -65,7 +63,7 @@ struct PinnedItemsBannerView: View {
|
||||
|
||||
private var viewAllButton: some View {
|
||||
Button { onViewAllButtonTap() } label: {
|
||||
Text(L10n.Screen.Room.pinnedBannerViewAllButtonTitle)
|
||||
Text(L10n.screenRoomPinnedBannerViewAllButtonTitle)
|
||||
.font(.compound.bodyMDSemibold)
|
||||
.foregroundStyle(Color.compound.textPrimary)
|
||||
.padding(.horizontal, 16)
|
||||
@@ -79,7 +77,7 @@ struct PinnedItemsBannerView: View {
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textActionAccent)
|
||||
.lineLimit(1)
|
||||
Text(pinContent)
|
||||
Text(pinnedEventsState.selectedPinContent)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.lineLimit(1)
|
||||
@@ -89,9 +87,7 @@ struct PinnedItemsBannerView: View {
|
||||
|
||||
struct PinnedItemsBannerView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
PinnedItemsBannerView(pinIndex: 0,
|
||||
pinsCount: 3,
|
||||
pinContent: .init(stringLiteral: "Content"),
|
||||
PinnedItemsBannerView(pinnedEventsState: .init(pinnedEventIDs: ["Content", "NotShown1", "NotShown2"], selectedPinEventID: "Content"),
|
||||
onMainButtonTap: { },
|
||||
onViewAllButtonTap: { })
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ struct RoomScreen: View {
|
||||
canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf,
|
||||
canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers,
|
||||
canCurrentUserPin: context.viewState.canCurrentUserPin,
|
||||
pinnedEventIDs: context.viewState.pinnedEventsState.pinnedEventIDs.set,
|
||||
isDM: context.viewState.isEncryptedOneToOneRoom,
|
||||
isViewSourceEnabled: context.viewState.isViewSourceEnabled).makeActions()
|
||||
if let actions {
|
||||
@@ -109,10 +110,8 @@ struct RoomScreen: View {
|
||||
}
|
||||
|
||||
private var pinnedItemsBanner: some View {
|
||||
PinnedItemsBannerView(pinIndex: context.viewState.currentPinIndex,
|
||||
pinsCount: context.viewState.pinnedItems.count,
|
||||
pinContent: context.viewState.selectedPinContent,
|
||||
onMainButtonTap: { context.send(viewAction: .nextPin) },
|
||||
PinnedItemsBannerView(pinnedEventsState: context.viewState.pinnedEventsState,
|
||||
onMainButtonTap: { context.send(viewAction: .tappedPinBanner) },
|
||||
onViewAllButtonTap: { context.send(viewAction: .viewAllPins) })
|
||||
.transition(.move(edge: .top))
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf,
|
||||
canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers,
|
||||
canCurrentUserPin: context.viewState.canCurrentUserPin,
|
||||
pinnedEventIDs: context.viewState.pinnedEventsState.pinnedEventIDs.set,
|
||||
isDM: context.viewState.isEncryptedOneToOneRoom,
|
||||
isViewSourceEnabled: context.viewState.isViewSourceEnabled)
|
||||
TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in
|
||||
|
||||
Reference in New Issue
Block a user