mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Live Location Sharing Banner (#5370)
* Live Location Sharing Banner # Conflicts: # ElementX.xcodeproj/project.pbxproj * updated the top banner modifier and the top banner views # Conflicts: # ElementX/Resources/Localizations/en-US.lproj/Localizable.strings # ElementX/Resources/Localizations/en.lproj/Localizable.strings * improved the stopping function from the timeline item * stop live location sharing before starting a new one. * added some tests for LiveLocationManager * pr suggestions
This commit is contained in:
@@ -192,6 +192,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentThreadList)
|
||||
case .displayThread(let threadRootEventID, let focussedEventID):
|
||||
actionsSubject.send(.presentThread(threadRootEventID: threadRootEventID, focussedEventID: focussedEventID))
|
||||
case .stopLiveLocationSharing:
|
||||
Task { [weak self] in await self?.timelineViewModel.stopLiveLocationSharing() }
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -21,6 +21,7 @@ enum RoomScreenViewModelAction: Equatable {
|
||||
case displayKnockRequests
|
||||
case displayRoom(roomID: String, via: [String])
|
||||
case displayMessageForwarding(MessageForwardingItem)
|
||||
case stopLiveLocationSharing
|
||||
}
|
||||
|
||||
enum RoomScreenViewAction {
|
||||
@@ -34,6 +35,7 @@ enum RoomScreenViewAction {
|
||||
case viewKnockRequests
|
||||
case displaySuccessorRoom
|
||||
case displayThreadList
|
||||
case tappedStopLiveLocation
|
||||
}
|
||||
|
||||
struct RoomScreenViewState: BindableState {
|
||||
@@ -48,6 +50,8 @@ struct RoomScreenViewState: BindableState {
|
||||
!pinnedEventsBannerState.isEmpty && lastScrollDirection != .top
|
||||
}
|
||||
|
||||
var isSharingLiveLocation = false
|
||||
|
||||
var canSendMessage = true
|
||||
|
||||
/// Whether or not starting a call is supported.
|
||||
|
||||
@@ -122,6 +122,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
actionsSubject.send(.displayRoom(roomID: successorID, via: Array(serverNames)))
|
||||
case .displayThreadList:
|
||||
actionsSubject.send(.displayThreadList)
|
||||
case .tappedStopLiveLocation:
|
||||
actionsSubject.send(.stopLiveLocationSharing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +174,15 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
appSettings.$knockingEnabled
|
||||
.weakAssign(to: \.state.isKnockingEnabled, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
appSettings.$liveLocationSharingEnabled
|
||||
.combineLatest(appSettings.$liveLocationSharingTimeoutDatesByRoomID)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isEnabled, timeoutDatesByRoomID in
|
||||
guard let self else { return }
|
||||
state.isSharingLiveLocation = isEnabled && timeoutDatesByRoomID.keys.contains(roomProxy.id)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
roomProxy.infoPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
||||
@@ -28,9 +28,6 @@ struct KnockRequestsBannerView: View {
|
||||
mainContent
|
||||
.padding(16)
|
||||
.background(.compound.bgCanvasDefaultLevel1, in: RoundedRectangle(cornerRadius: 12))
|
||||
.compositingGroup()
|
||||
.shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4)
|
||||
.padding(.bottom, 28)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@@ -202,6 +199,14 @@ struct KnockRequestsBannerView_Previews: PreviewProvider, TestablePreview {
|
||||
]
|
||||
|
||||
static var previews: some View {
|
||||
allPreviews
|
||||
.padding()
|
||||
.background(.gray)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
static var allPreviews: some View {
|
||||
KnockRequestsBannerView(requests: singleRequest) { } onAccept: { _ in } onViewAll: { }
|
||||
.previewDisplayName("Single Request")
|
||||
// swiftlint:disable:next trailing_closure
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct LiveLocationSharingBannerView: View {
|
||||
var onStop: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 9) {
|
||||
CompoundIcon(\.locationPinSolid, size: .medium, relativeTo: .compound.bodyMDSemibold)
|
||||
.foregroundColor(Color.compound.iconSuccessPrimary)
|
||||
.accessibilityHidden(true)
|
||||
Text(L10n.screenRoomLiveLocationBanner)
|
||||
.font(.compound.bodyMDSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
Spacer()
|
||||
Button(L10n.actionStop, role: .destructive, action: onStop)
|
||||
.buttonStyle(.compound(.primary, size: .small))
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, 15)
|
||||
.background(Color.compound.bgCanvasDefault)
|
||||
.overlay(alignment: .top) { Color.compound.separatorPrimary.frame(height: 1) }
|
||||
.overlay(alignment: .bottom) { Color.compound.separatorPrimary.frame(height: 1) }
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveLocationSharingBannerView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
LiveLocationSharingBannerView { }
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -23,9 +23,6 @@ struct PinnedItemsBannerView: View {
|
||||
.padding(.vertical, 16)
|
||||
.padding(.leading, 16)
|
||||
.background(Color.compound.bgCanvasDefault)
|
||||
.shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4)
|
||||
// To include the shadow in the size
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
private var mainButton: some View {
|
||||
@@ -111,5 +108,8 @@ struct PinnedItemsBannerView_Previews: PreviewProvider, TestablePreview {
|
||||
onMainButtonTap: { },
|
||||
onViewAllButtonTap: { })
|
||||
}
|
||||
.padding()
|
||||
.background(.gray)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,17 +35,27 @@ struct RoomScreen: View {
|
||||
}
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.topBanners([
|
||||
TopBannerItem(pinnedItemsBanner, isVisible: context.viewState.shouldShowPinnedEventsBanner && !isVoiceOverEnabled),
|
||||
// This can overlay on top of the pinnedItemsBanner
|
||||
TopBannerItem(knockRequestsBanner, isVisible: context.viewState.shouldSeeKnockRequests)
|
||||
TopBannerLayer(verticalBanners: [
|
||||
TopBannerItem(pinnedItemsBanner, isVisible: context.viewState.shouldShowPinnedEventsBanner && !isVoiceOverEnabled),
|
||||
TopBannerItem(liveLocationBanner, isVisible: context.viewState.isSharingLiveLocation && !isVoiceOverEnabled)
|
||||
]),
|
||||
// This can overlay on top of the stacked banners
|
||||
TopBannerLayer(knockRequestsBanner, isVisible: context.viewState.shouldSeeKnockRequests)
|
||||
], footer: dateBadge)
|
||||
.safeAreaInset(edge: .top) {
|
||||
// When VoiceOver is enabled, the table view isn't reversed and the scroll gestures
|
||||
// don't trigger meaning the banner never hides itself and so the .overlay layout
|
||||
// above permanently obscures the top of the timeline. So whenever VoiceOver is
|
||||
// enabled we use a safe area inset to vertically stack it above the timeline.
|
||||
if context.viewState.shouldShowPinnedEventsBanner, isVoiceOverEnabled {
|
||||
pinnedItemsBanner
|
||||
if context.viewState.shouldShowPinnedEventsBanner || context.viewState.isSharingLiveLocation, isVoiceOverEnabled {
|
||||
VStack(spacing: 0) {
|
||||
if context.viewState.shouldShowPinnedEventsBanner {
|
||||
pinnedItemsBanner
|
||||
}
|
||||
if context.viewState.isSharingLiveLocation {
|
||||
liveLocationBanner
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
@@ -76,6 +86,12 @@ struct RoomScreen: View {
|
||||
.sentryTrace("\(Self.self)")
|
||||
}
|
||||
|
||||
private var liveLocationBanner: some View {
|
||||
LiveLocationSharingBannerView {
|
||||
context.send(viewAction: .tappedStopLiveLocation)
|
||||
}
|
||||
}
|
||||
|
||||
private var pinnedItemsBanner: some View {
|
||||
PinnedItemsBannerView(state: context.viewState.pinnedEventsBannerState,
|
||||
onMainButtonTap: { context.send(viewAction: .tappedPinnedEventsBanner) },
|
||||
|
||||
@@ -70,7 +70,7 @@ enum TimelineViewAction {
|
||||
case handlePollAction(TimelineViewPollAction)
|
||||
case handleAudioPlayerAction(TimelineAudioPlayerAction)
|
||||
|
||||
case stopLiveLocationSharing
|
||||
case stopLiveLocationSharing(TimelineItemIdentifier)
|
||||
|
||||
/// Focus the timeline onto the specified event ID (switching to a detached timeline if needed).
|
||||
case focusOnEventID(String)
|
||||
@@ -143,6 +143,8 @@ struct TimelineViewState: BindableState {
|
||||
var mapTilerConfiguration: MapTilerConfiguration
|
||||
|
||||
var enableKeyShareOnInvite: Bool
|
||||
|
||||
var stoppedLiveLocationIDs: Set<TimelineItemIdentifier> = []
|
||||
|
||||
var bindings: TimelineViewStateBindings
|
||||
}
|
||||
|
||||
@@ -198,8 +198,9 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
handlePollAction(pollAction)
|
||||
case .handleAudioPlayerAction(let audioPlayerAction):
|
||||
handleAudioPlayerAction(audioPlayerAction)
|
||||
case .stopLiveLocationSharing:
|
||||
Task { await userSession.liveLocationManager.stopLiveLocation(roomID: state.roomID) }
|
||||
case .stopLiveLocationSharing(let id):
|
||||
state.stoppedLiveLocationIDs.insert(id)
|
||||
Task { await stopLiveLocationSharing() }
|
||||
case .focusOnEventID(let eventID):
|
||||
Task { await focusOnEvent(eventID: eventID) }
|
||||
case .focusLive:
|
||||
@@ -276,6 +277,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func stopLiveLocationSharing() async {
|
||||
await userSession.liveLocationManager.stopLiveLocation(roomID: roomProxy.id)
|
||||
}
|
||||
|
||||
func makeForwardingItem(for itemID: TimelineItemIdentifier) async -> MessageForwardingItem? {
|
||||
guard let content = await timelineController.messageEventContent(for: itemID) else { return nil }
|
||||
return .init(id: itemID, roomID: roomProxy.id, content: content)
|
||||
|
||||
@@ -18,7 +18,8 @@ protocol TimelineViewModelProtocol {
|
||||
func process(composerAction: ComposerToolbarViewModelAction)
|
||||
/// Updates the timeline to show and highlight the item with the corresponding event ID.
|
||||
func focusOnEvent(eventID: String) async
|
||||
|
||||
/// Stops the current live location sharing
|
||||
func stopLiveLocationSharing() async
|
||||
/// Handles getting the content to forward an item given its item ID.
|
||||
func makeForwardingItem(for itemID: TimelineItemIdentifier) async -> MessageForwardingItem?
|
||||
}
|
||||
|
||||
+3
-3
@@ -15,10 +15,10 @@ struct LiveLocationRoomTimelineView: View {
|
||||
let timelineItem: LiveLocationRoomTimelineItem
|
||||
private let currentDate: Date
|
||||
|
||||
init(currentDate: Date = .now, timelineItem: LiveLocationRoomTimelineItem) {
|
||||
init(currentDate: Date = .now, timelineItem: LiveLocationRoomTimelineItem, isStopped: Bool = false) {
|
||||
self.currentDate = currentDate
|
||||
self.timelineItem = timelineItem
|
||||
_hasExpired = State(initialValue: currentDate >= timelineItem.content.timeoutDate)
|
||||
_hasExpired = State(initialValue: isStopped || currentDate >= timelineItem.content.timeoutDate)
|
||||
}
|
||||
|
||||
/// A publisher that fires once when the timeoutDate is reached, setting `hasExpired` to true.
|
||||
@@ -191,7 +191,7 @@ struct LiveLocationRoomTimelineView: View {
|
||||
|
||||
private func stop() {
|
||||
hasExpired = true
|
||||
context?.send(viewAction: .stopLiveLocationSharing)
|
||||
context?.send(viewAction: .stopLiveLocationSharing(timelineItem.id))
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
Reference in New Issue
Block a user