mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 03:48:27 +00:00
Refactor and redesign Location Sharing Screen (#5155)
* refactored the static location screen to the location sharing screen # Conflicts: # UnitTests/Sources/LocationSharingScreenViewModelTests.swift # Conflicts: # ElementX.xcodeproj/project.pbxproj * implemented a custom pin with an overlayable view * implemented the render of the user when the location is sender instead of the pin type * removed description and body they are not used at all. * reimplemented single button for sharing this or user location + implemented an experimental way to update annotations * removed unnecessary @Suite description * implemented a way to display the alert on top of the sheet and added a spinner to the center user location button * fixed alerts strings * fixed a failing test * improved preview tests
This commit is contained in:
@@ -84,55 +84,38 @@ enum UserAvatarSizeOnScreen {
|
||||
case sendInviteConfirmation
|
||||
case sessionVerification
|
||||
case threadSummary
|
||||
case map
|
||||
|
||||
var value: CGFloat {
|
||||
switch self {
|
||||
case .chats, .spaces:
|
||||
return 32
|
||||
case .timeline:
|
||||
return 32
|
||||
case .readReceipt:
|
||||
return 16
|
||||
case .readReceiptSheet:
|
||||
return 32
|
||||
16
|
||||
case .spaceHeader:
|
||||
return 20
|
||||
case .completionSuggestions:
|
||||
return 32
|
||||
case .blockedUsers:
|
||||
return 32
|
||||
case .settings:
|
||||
return 52
|
||||
case .roomDetails:
|
||||
return 44
|
||||
case .roomMembersList:
|
||||
return 32
|
||||
case .roomChangeRoles:
|
||||
return 56
|
||||
case .startChat:
|
||||
return 36
|
||||
case .memberDetails:
|
||||
return 96
|
||||
case .inviteUsers:
|
||||
return 52
|
||||
case .editUserDetails:
|
||||
return 96
|
||||
case .dmDetails:
|
||||
return 75
|
||||
case .knockingUsersBannerStack:
|
||||
return 28
|
||||
case .knockingUserBanner:
|
||||
return 32
|
||||
case .knockingUserList:
|
||||
return 52
|
||||
case .mediaPreviewDetails:
|
||||
return 32
|
||||
case .sendInviteConfirmation:
|
||||
return 64
|
||||
case .sessionVerification:
|
||||
return 52
|
||||
20
|
||||
case .threadSummary:
|
||||
return 24
|
||||
24
|
||||
case .knockingUsersBannerStack:
|
||||
28
|
||||
case .chats, .spaces, .map,
|
||||
.timeline, .readReceiptSheet, .completionSuggestions,
|
||||
.blockedUsers, .roomMembersList, .knockingUserBanner,
|
||||
.mediaPreviewDetails:
|
||||
32
|
||||
case .startChat:
|
||||
36
|
||||
case .roomDetails:
|
||||
44
|
||||
case .inviteUsers, .knockingUserList, .sessionVerification,
|
||||
.settings:
|
||||
52
|
||||
case .roomChangeRoles:
|
||||
56
|
||||
case .sendInviteConfirmation:
|
||||
64
|
||||
case .dmDetails:
|
||||
75
|
||||
case .memberDetails, .editUserDetails:
|
||||
96
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,24 +143,22 @@ enum RoomAvatarSizeOnScreen {
|
||||
|
||||
var value: CGFloat {
|
||||
switch self {
|
||||
case .chats, .spaces, .spaceSettings:
|
||||
return 52
|
||||
case .notificationSettings:
|
||||
30
|
||||
case .timeline, .leaveSpace, .roomDirectorySearch,
|
||||
.completionSuggestions, .authorizedSpaces, .createRoomSelectSpace,
|
||||
.spaceFilters:
|
||||
return 32
|
||||
case .notificationSettings:
|
||||
return 30
|
||||
case .messageForwarding, .globalSearch, .roomSelection, .spaceAddRooms:
|
||||
return 36
|
||||
case .spaceAddRoomsSelected:
|
||||
return 52
|
||||
32
|
||||
case .messageForwarding, .globalSearch, .roomSelection,
|
||||
.spaceAddRooms:
|
||||
36
|
||||
case .chats, .spaces, .spaceSettings,
|
||||
.spaceAddRoomsSelected:
|
||||
52
|
||||
case .joinRoom, .spaceHeader:
|
||||
64
|
||||
case .details:
|
||||
return 96
|
||||
case .joinRoom:
|
||||
return 64
|
||||
case .spaceHeader:
|
||||
return 64
|
||||
96
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,23 +11,32 @@ import MapLibre
|
||||
import SwiftUI
|
||||
|
||||
final class LocationAnnotation: NSObject, MLNAnnotation {
|
||||
let coordinate: CLLocationCoordinate2D
|
||||
let id: String
|
||||
var coordinate: CLLocationCoordinate2D
|
||||
let anchorPoint: CGPoint
|
||||
let view: AnyView
|
||||
var view: AnyView
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(coordinate: CLLocationCoordinate2D,
|
||||
init(id: String = UUID().uuidString,
|
||||
coordinate: CLLocationCoordinate2D,
|
||||
anchorPoint: CGPoint = .init(x: 0.5, y: 0.5),
|
||||
@ViewBuilder label: () -> some View) {
|
||||
self.id = id
|
||||
self.coordinate = coordinate
|
||||
self.anchorPoint = anchorPoint
|
||||
view = AnyView(label())
|
||||
super.init()
|
||||
}
|
||||
|
||||
func updateView(@ViewBuilder label: () -> some View) {
|
||||
view = AnyView(label())
|
||||
}
|
||||
}
|
||||
|
||||
final class LocationAnnotationView: MLNUserLocationAnnotationView {
|
||||
private var hostingController: UIHostingController<AnyView>?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override init(annotation: MLNAnnotation?, reuseIdentifier: String?) {
|
||||
@@ -37,12 +46,21 @@ final class LocationAnnotationView: MLNUserLocationAnnotationView {
|
||||
|
||||
convenience init(annotation: LocationAnnotation) {
|
||||
self.init(annotation: annotation, reuseIdentifier: "\(Self.self)")
|
||||
let view: UIView = UIHostingController(rootView: annotation.view).view
|
||||
let hostingController = UIHostingController(rootView: annotation.view)
|
||||
self.hostingController = hostingController
|
||||
let view: UIView = hostingController.view
|
||||
view.backgroundColor = .clear
|
||||
view.anchorPoint = annotation.anchorPoint
|
||||
addSubview(view)
|
||||
view.bounds.size = view.intrinsicContentSize
|
||||
}
|
||||
|
||||
func updateContent(with view: AnyView) {
|
||||
hostingController?.rootView = view
|
||||
if let hostedView = hostingController?.view {
|
||||
hostedView.bounds.size = hostedView.intrinsicContentSize
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
|
||||
@@ -20,10 +20,10 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
/// The initial map center
|
||||
let mapCenter: CLLocationCoordinate2D
|
||||
|
||||
/// Map annotations
|
||||
let annotations: [LocationAnnotation]
|
||||
/// Map annotations keyed by a stable identifier (e.g. sender userID for user pins, UUID string for generic pins)
|
||||
let annotations: [String: LocationAnnotation]
|
||||
|
||||
init(zoomLevel: Double, initialZoomLevel: Double, mapCenter: CLLocationCoordinate2D, annotations: [LocationAnnotation] = []) {
|
||||
init(zoomLevel: Double, initialZoomLevel: Double, mapCenter: CLLocationCoordinate2D, annotations: [String: LocationAnnotation] = [:]) {
|
||||
self.zoomLevel = zoomLevel
|
||||
self.initialZoomLevel = initialZoomLevel
|
||||
self.mapCenter = mapCenter
|
||||
@@ -45,6 +45,7 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
@Binding var error: MapLibreError?
|
||||
/// Coordinate of the center of the map
|
||||
@Binding var mapCenterCoordinate: CLLocationCoordinate2D?
|
||||
@Binding var hasLoadedUserLocation: Bool
|
||||
@Binding var isLocationAuthorized: Bool?
|
||||
/// The radius of uncertainty for the location, measured in meters.
|
||||
@Binding var geolocationUncertainty: CLLocationAccuracy?
|
||||
@@ -70,6 +71,11 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
mapView.styleURL = dynamicMapURL
|
||||
}
|
||||
|
||||
// Update existing annotation views with fresh SwiftUI content.
|
||||
// This handles the case where the annotation's view data changes after
|
||||
// the annotation was initially placed (e.g. user avatar loads asynchronously).
|
||||
updateAnnotations(in: mapView)
|
||||
|
||||
showUserLocation(in: mapView)
|
||||
}
|
||||
|
||||
@@ -80,17 +86,52 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
// MARK: - Private
|
||||
|
||||
private func setupMap(mapView: MLNMapView, with options: Options) {
|
||||
mapView.addAnnotations(options.annotations)
|
||||
mapView.addAnnotations(Array(options.annotations.values))
|
||||
mapView.zoomLevel = options.annotations.isEmpty ? options.initialZoomLevel : options.zoomLevel
|
||||
mapView.centerCoordinate = options.mapCenter
|
||||
}
|
||||
|
||||
private func updateAnnotations(in mapView: MLNMapView) {
|
||||
let existingByID = Dictionary(uniqueKeysWithValues:
|
||||
(mapView.annotations ?? []).compactMap { $0 as? LocationAnnotation }.map { ($0.id, $0) })
|
||||
|
||||
let existingIDs = Set(existingByID.keys)
|
||||
let updatedIDs = Set(options.annotations.keys)
|
||||
|
||||
// Remove annotations that are no longer present
|
||||
let removedIDs = existingIDs.subtracting(updatedIDs)
|
||||
if !removedIDs.isEmpty {
|
||||
let toRemove = removedIDs.compactMap { existingByID[$0] }
|
||||
mapView.removeAnnotations(toRemove)
|
||||
}
|
||||
|
||||
// Add new annotations
|
||||
let addedIDs = updatedIDs.subtracting(existingIDs)
|
||||
if !addedIDs.isEmpty {
|
||||
let toAdd = addedIDs.compactMap { options.annotations[$0] }
|
||||
mapView.addAnnotations(toAdd)
|
||||
}
|
||||
|
||||
// Update existing annotations that are still present
|
||||
let keptIDs = existingIDs.intersection(updatedIDs)
|
||||
for id in keptIDs {
|
||||
guard let existingAnnotation = existingByID[id],
|
||||
let updatedAnnotation = options.annotations[id] else {
|
||||
continue
|
||||
}
|
||||
existingAnnotation.coordinate = updatedAnnotation.coordinate
|
||||
if let annotationView = mapView.view(for: existingAnnotation) as? LocationAnnotationView {
|
||||
annotationView.updateContent(with: updatedAnnotation.view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMapView() -> MLNMapView {
|
||||
let mapView = MLNMapView(frame: .zero, styleURL: mapURLBuilder.interactiveMapURL(for: colorScheme == .dark ? .dark : .light))
|
||||
mapView.logoViewPosition = .topLeft
|
||||
mapView.attributionButtonPosition = .topLeft
|
||||
mapView.attributionButtonMargins = .init(x: mapView.logoView.frame.maxX + 8, y: mapView.logoView.center.y / 2)
|
||||
mapView.tintColor = .black
|
||||
mapView.tintColor = .compound.iconAccentPrimary
|
||||
mapView.allowsRotating = false
|
||||
mapView.allowsTilting = false
|
||||
return mapView
|
||||
@@ -149,7 +190,8 @@ extension MapLibreMapView {
|
||||
|
||||
func mapView(_ mapView: MLNMapView, didUpdate userLocation: MLNUserLocation?) {
|
||||
guard let userLocation else { return }
|
||||
|
||||
mapLibreView.hasLoadedUserLocation = true
|
||||
|
||||
if previousUserLocation == nil, mapLibreView.options.annotations.isEmpty {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
mapView.setCenter(userLocation.coordinate, zoomLevel: self.mapLibreView.options.zoomLevel, animated: true)
|
||||
|
||||
@@ -18,12 +18,12 @@ final class ShareToMapsAppActivity: UIActivity {
|
||||
|
||||
private let type: MapsAppType
|
||||
private let location: CLLocationCoordinate2D
|
||||
private let locationDescription: String?
|
||||
private let senderName: String?
|
||||
|
||||
init(type: MapsAppType, location: CLLocationCoordinate2D, locationDescription: String?) {
|
||||
init(type: MapsAppType, location: CLLocationCoordinate2D, senderName: String?) {
|
||||
self.type = type
|
||||
self.location = location
|
||||
self.locationDescription = locationDescription
|
||||
self.senderName = senderName
|
||||
super.init()
|
||||
}
|
||||
|
||||
@@ -44,20 +44,20 @@ final class ShareToMapsAppActivity: UIActivity {
|
||||
}
|
||||
|
||||
override func prepare(withActivityItems activityItems: [Any]) {
|
||||
UIApplication.shared.open(type.activityURL(for: location, locationDescription: locationDescription), options: [:]) { [weak self] result in
|
||||
UIApplication.shared.open(type.activityURL(for: location, senderName: senderName), options: [:]) { [weak self] result in
|
||||
self?.activityDidFinish(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ShareToMapsAppActivity.MapsAppType {
|
||||
func activityURL(for location: CLLocationCoordinate2D, locationDescription: String?) -> URL {
|
||||
func activityURL(for location: CLLocationCoordinate2D, senderName: String?) -> URL {
|
||||
switch self {
|
||||
case .apple:
|
||||
var url: URL = "https://maps.apple.com/"
|
||||
url.append(queryItems: [
|
||||
.init(name: "ll", value: "\(location.latitude),\(location.longitude)"),
|
||||
.init(name: "q", value: locationDescription ?? "Pin")
|
||||
.init(name: "q", value: senderName ?? "Pin") // We need to provide a value or no marker is displayed.
|
||||
])
|
||||
return url
|
||||
case .google:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// Copyright 2026 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 SwiftUI
|
||||
|
||||
struct RowDivider: ViewModifier {
|
||||
@Environment(\.pixelLength) var pixelLength: CGFloat
|
||||
let alignment: Alignment
|
||||
let horizontalInsets: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(alignment: alignment) {
|
||||
Rectangle()
|
||||
.fill(Color.compound.borderDisabled)
|
||||
.frame(height: pixelLength)
|
||||
.padding(.trailing, -horizontalInsets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func rowDivider(alignment: Alignment = .bottom, horizontalInsets: CGFloat) -> some View {
|
||||
modifier(RowDivider(alignment: alignment, horizontalInsets: horizontalInsets))
|
||||
}
|
||||
}
|
||||
@@ -10,28 +10,84 @@ import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct LocationMarkerView: View {
|
||||
private let pinColor: Color = .compound.iconOnSolidPrimary
|
||||
private let pinInsets = EdgeInsets(top: 13, leading: 12, bottom: 15, trailing: 12)
|
||||
var userProfile: UserProfileProxy?
|
||||
var fillColor = Color.compound.bgCanvasDefault
|
||||
var strokeColor = Color.compound.iconSecondaryAlpha
|
||||
var dotColor = Color.compound.iconPrimary
|
||||
@ScaledMetric var size: CGFloat = 42
|
||||
var mediaProvider: MediaProviderProtocol?
|
||||
|
||||
private let circleCenter = CGPoint(x: 21, y: 21) // in SVG space
|
||||
private let circleRadius: CGFloat = 6 // in SVG space
|
||||
|
||||
var body: some View {
|
||||
CompoundIcon(\.locationPinSolid)
|
||||
.dynamicTypeSize(.large)
|
||||
.foregroundStyle(pinColor)
|
||||
.padding(pinInsets)
|
||||
.background {
|
||||
backgroundShape
|
||||
.shadow(color: .black.opacity(0.2), radius: 4.1129, x: 0, y: 4.93548)
|
||||
// Generated from the SVG
|
||||
Canvas { context, canvasSize in
|
||||
let scaleX = canvasSize.width / 42
|
||||
let scaleY = canvasSize.height / 50
|
||||
|
||||
let pinPath = Path { p in
|
||||
p.move(to: CGPoint(x: 21 * scaleX, y: 49 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 17.6875 * scaleX, y: 47.7581 * scaleY),
|
||||
control1: CGPoint(x: 19.25 * scaleX, y: 49 * scaleY),
|
||||
control2: CGPoint(x: 18.1458 * scaleX, y: 48.4825 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 5.28125 * scaleX, y: 33.6313 * scaleY),
|
||||
control1: CGPoint(x: 12.5833 * scaleX, y: 42.8525 * scaleY),
|
||||
control2: CGPoint(x: 8.41667 * scaleX, y: 38.1332 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 1 * scaleX, y: 21.3674 * scaleY),
|
||||
control1: CGPoint(x: 2.82292 * scaleX, y: 29.2846 * scaleY),
|
||||
control2: CGPoint(x: 1 * scaleX, y: 25.1863 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 21 * scaleX, y: 1 * scaleY),
|
||||
control1: CGPoint(x: 1 * scaleX, y: 10.2109 * scaleY),
|
||||
control2: CGPoint(x: 9.5 * scaleX, y: 1 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 41 * scaleX, y: 21.3674 * scaleY),
|
||||
control1: CGPoint(x: 32.5 * scaleX, y: 1 * scaleY),
|
||||
control2: CGPoint(x: 41 * scaleX, y: 10.2109 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 36.7188 * scaleX, y: 33.6313 * scaleY),
|
||||
control1: CGPoint(x: 41 * scaleX, y: 25.1863 * scaleY),
|
||||
control2: CGPoint(x: 39.1771 * scaleX, y: 29.2846 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 24.3125 * scaleX, y: 47.7581 * scaleY),
|
||||
control1: CGPoint(x: 33.5833 * scaleX, y: 38.1332 * scaleY),
|
||||
control2: CGPoint(x: 29.4167 * scaleX, y: 42.8525 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 21 * scaleX, y: 49 * scaleY),
|
||||
control1: CGPoint(x: 23.3333 * scaleX, y: 48.4825 * scaleY),
|
||||
control2: CGPoint(x: 21.5833 * scaleX, y: 49 * scaleY))
|
||||
p.closeSubpath()
|
||||
}
|
||||
.alignmentGuide(VerticalAlignment.center) { dimensions in
|
||||
dimensions[.bottom]
|
||||
|
||||
context.stroke(pinPath, with: .color(strokeColor), lineWidth: 2 * scaleX)
|
||||
context.fill(pinPath, with: .color(fillColor))
|
||||
|
||||
// Dot
|
||||
let dotPath = Path(ellipseIn: CGRect(x: (circleCenter.x - circleRadius) * scaleX,
|
||||
y: (circleCenter.y - circleRadius) * scaleY,
|
||||
width: circleRadius * 2 * scaleX,
|
||||
height: circleRadius * 2 * scaleY))
|
||||
context.fill(dotPath, with: .color(dotColor))
|
||||
|
||||
// Draw resolved symbol centered on the circle
|
||||
if userProfile != nil, let symbol = context.resolveSymbol(id: 0) {
|
||||
let center = CGPoint(x: circleCenter.x * scaleX,
|
||||
y: circleCenter.y * scaleY)
|
||||
context.draw(symbol, at: center, anchor: .center)
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundShape: some View {
|
||||
Image(asset: Asset.Images.locationMarkerShape)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.foregroundStyle(.compound.iconPrimary)
|
||||
} symbols: {
|
||||
if let userProfile {
|
||||
LoadableAvatarImage(url: userProfile.avatarURL,
|
||||
name: userProfile.displayName,
|
||||
contentID: userProfile.userID,
|
||||
avatarSize: .user(on: .map),
|
||||
mediaProvider: mediaProvider)
|
||||
.overlay {
|
||||
Circle().inset(by: 0.5).stroke(strokeColor)
|
||||
}
|
||||
.tag(0)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size * 50 / 42)
|
||||
.alignmentGuide(VerticalAlignment.center) { dimensions in
|
||||
dimensions[.bottom]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +95,19 @@ struct LocationMarkerView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 30) {
|
||||
LocationMarkerView()
|
||||
|
||||
|
||||
LocationMarkerView()
|
||||
.colorScheme(.dark)
|
||||
|
||||
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
|
||||
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
.colorScheme(.dark)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(red: 0.9, green: 0.85, blue: 0.8))
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct ToolbarButton: View {
|
||||
static let cancel = Role.cancel(title: L10n.actionCancel)
|
||||
static let done = Role.confirm(title: L10n.actionDone)
|
||||
static let save = Role.confirm(title: L10n.actionSave)
|
||||
static let close = Role.cancel(title: L10n.actionClose)
|
||||
|
||||
case cancel(title: String)
|
||||
case confirm(title: String)
|
||||
|
||||
@@ -83,6 +83,7 @@ enum TestablePreviewsDictionary {
|
||||
"LoadableImage_Previews" : LoadableImage_Previews.self,
|
||||
"LocationMarkerView_Previews" : LocationMarkerView_Previews.self,
|
||||
"LocationRoomTimelineView_Previews" : LocationRoomTimelineView_Previews.self,
|
||||
"LocationSharingScreen_Previews" : LocationSharingScreen_Previews.self,
|
||||
"LoginScreen_Previews" : LoginScreen_Previews.self,
|
||||
"LongPressWithFeedback_Previews" : LongPressWithFeedback_Previews.self,
|
||||
"ManageAuthorizedSpacesScreen_Previews" : ManageAuthorizedSpacesScreen_Previews.self,
|
||||
@@ -181,7 +182,6 @@ enum TestablePreviewsDictionary {
|
||||
"StackedAvatarsView_Previews" : StackedAvatarsView_Previews.self,
|
||||
"StartChatScreen_Previews" : StartChatScreen_Previews.self,
|
||||
"StateRoomTimelineView_Previews" : StateRoomTimelineView_Previews.self,
|
||||
"StaticLocationScreenViewer_Previews" : StaticLocationScreenViewer_Previews.self,
|
||||
"StickerRoomTimelineView_Previews" : StickerRoomTimelineView_Previews.self,
|
||||
"SwipeRightAction_Previews" : SwipeRightAction_Previews.self,
|
||||
"SwipeToReplyView_Previews" : SwipeToReplyView_Previews.self,
|
||||
|
||||
Reference in New Issue
Block a user