Add developer option to hide media in the timeline. (#3366)

This commit is contained in:
Doug 2024-10-03 17:59:39 +01:00 committed by GitHub
parent 4f29821306
commit e6f4dd33a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 325 additions and 65 deletions

View File

@ -41,6 +41,7 @@
"action_create" = "Create";
"action_create_a_room" = "Create a room";
"action_deactivate" = "Deactivate";
"action_deactivate_account" = "Deactivate account";
"action_decline" = "Decline";
"action_delete_poll" = "Delete Poll";
"action_disable" = "Disable";
@ -64,6 +65,7 @@
"action_leave" = "Leave";
"action_leave_conversation" = "Leave conversation";
"action_leave_room" = "Leave room";
"action_load_more" = "Load more";
"action_manage_account" = "Manage account";
"action_manage_devices" = "Manage devices";
"action_message" = "Message";
@ -93,6 +95,7 @@
"action_send_message" = "Send message";
"action_share" = "Share";
"action_share_link" = "Share link";
"action_show" = "Show";
"action_sign_in_again" = "Sign in again";
"action_signout" = "Sign out";
"action_signout_anyway" = "Sign out anyway";
@ -108,8 +111,6 @@
"action_view_in_timeline" = "View in timeline";
"action_view_source" = "View source";
"action_yes" = "Yes";
"action.load_more" = "Load more";
"action_deactivate_account" = "Deactivate account";
"banner_migrate_to_native_sliding_sync_action" = "Log Out & Upgrade";
"banner_migrate_to_native_sliding_sync_description" = "Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later.";
"banner_migrate_to_native_sliding_sync_force_logout_title" = "Your homeserver no longer supports the old protocol. Please log out and log back in to continue using the app.";

View File

@ -12,6 +12,7 @@ import SwiftUI
protocol CommonSettingsProtocol {
var logLevel: TracingConfiguration.LogLevel { get }
var enableOnlySignedDeviceIsolationMode: Bool { get }
var hideTimelineMedia: Bool { get }
}
/// Store Element specific app settings.
@ -34,6 +35,7 @@ final class AppSettings {
case appAppearance
case sharePresence
case hideUnreadMessagesBadge
case hideTimelineMedia
case elementCallBaseURLOverride
case elementCallEncryptionEnabled
@ -285,6 +287,9 @@ final class AppSettings {
/// Configuration to enable only signed device isolation mode for crypto. In this mode only devices signed by their owner will be considered in e2ee rooms.
@UserPreference(key: UserDefaultsKeys.enableOnlySignedDeviceIsolationMode, defaultValue: false, storageType: .userDefaults(store))
var enableOnlySignedDeviceIsolationMode
@UserPreference(key: UserDefaultsKeys.hideTimelineMedia, defaultValue: false, storageType: .userDefaults(store))
var hideTimelineMedia
}
extension AppSettings: CommonSettingsProtocol { }

View File

@ -164,6 +164,8 @@ internal enum L10n {
internal static var actionLeaveConversation: String { return L10n.tr("Localizable", "action_leave_conversation") }
/// Leave room
internal static var actionLeaveRoom: String { return L10n.tr("Localizable", "action_leave_room") }
/// Load more
internal static var actionLoadMore: String { return L10n.tr("Localizable", "action_load_more") }
/// Manage account
internal static var actionManageAccount: String { return L10n.tr("Localizable", "action_manage_account") }
/// Manage devices
@ -222,6 +224,8 @@ internal enum L10n {
internal static var actionShare: String { return L10n.tr("Localizable", "action_share") }
/// Share link
internal static var actionShareLink: String { return L10n.tr("Localizable", "action_share_link") }
/// Show
internal static var actionShow: String { return L10n.tr("Localizable", "action_show") }
/// Sign in again
internal static var actionSignInAgain: String { return L10n.tr("Localizable", "action_sign_in_again") }
/// Sign out
@ -2396,11 +2400,6 @@ internal enum L10n {
/// Check UnifiedPush
internal static var troubleshootNotificationsTestUnifiedPushTitle: String { return L10n.tr("Localizable", "troubleshoot_notifications_test_unified_push_title") }
internal enum Action {
/// Load more
internal static var loadMore: String { return L10n.tr("Localizable", "action.load_more") }
}
internal enum Banner {
internal enum SetUpRecovery {
/// Generate a new recovery key that can be used to restore your encrypted message history in case you lose access to your devices.

View File

@ -50,6 +50,7 @@ struct LoadableAvatarImage: View {
.frame(width: frameSize, height: frameSize)
.background(Color.compound.bgCanvasDefault)
.clipShape(Circle())
.environment(\.shouldAutomaticallyLoadImages, true) // We always load avatars.
}
@ViewBuilder

View File

@ -6,12 +6,17 @@
//
import Combine
import Compound
import Kingfisher
import SwiftUI
/// Used to configure animations
enum LoadableImageMediaType {
/// An avatar (can be displayed anywhere within the app).
case avatar
/// An image displayed in the timeline.
case timelineItem
/// Any other media (can be displayed anywhere within the app).
case generic
}
@ -79,6 +84,8 @@ struct LoadableImage<TransformerView: View, PlaceholderView: View>: View {
}
private struct LoadableImageContent<TransformerView: View, PlaceholderView: View>: View, ImageDataProvider {
@Environment(\.shouldAutomaticallyLoadImages) private var loadAutomatically
private let mediaSource: MediaSourceProxy
private let mediaType: LoadableImageMediaType
private let blurhash: String?
@ -86,6 +93,7 @@ private struct LoadableImageContent<TransformerView: View, PlaceholderView: View
private let placeholder: () -> PlaceholderView
@StateObject private var contentLoader: ContentLoader
@State private var loadManually = false
init(mediaSource: MediaSourceProxy,
mediaType: LoadableImageMediaType,
@ -104,36 +112,40 @@ private struct LoadableImageContent<TransformerView: View, PlaceholderView: View
_contentLoader = StateObject(wrappedValue: ContentLoader(mediaSource: mediaSource, size: size, mediaProvider: mediaProvider))
}
var shouldRender: Bool {
loadAutomatically || loadManually
}
var body: some View {
// Tried putting this in the body's .task but it randomly
// decides to not execute the request
let _ = Task {
guard contentLoader.content == nil else {
ZStack {
switch (contentLoader.content, shouldRender) {
case (.image(let image), true):
transformer(
AnyView(Image(uiImage: image).resizable())
)
case (.gifData, true):
transformer(AnyView(KFAnimatedImage(source: .provider(self))))
case (.none, _), (_, false):
if let blurHashView {
if shouldRender {
transformer(blurHashView)
} else {
blurHashView
}
} else {
placeholder().overlay { placeholderOverlay }
}
}
}
.animation(mediaType == .avatar ? .noAnimation : .elementDefault, value: contentLoader.content)
.animation(.elementDefault, value: loadManually)
.task(id: mediaSource.url.absoluteString + "\(shouldRender)") {
guard shouldRender, contentLoader.content == nil else {
return
}
await contentLoader.load()
}
ZStack {
switch contentLoader.content {
case .image(let image):
transformer(
AnyView(Image(uiImage: image).resizable())
)
case .gifData:
transformer(AnyView(KFAnimatedImage(source: .provider(self))))
case .none:
if let blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
transformer(AnyView(Image(uiImage: image).resizable()))
} else {
placeholder()
}
}
}
.animation(mediaType == .avatar ? .noAnimation : .elementDefault, value: contentLoader.content)
.onDisappear {
guard contentLoader.content == nil else {
return
@ -143,6 +155,66 @@ private struct LoadableImageContent<TransformerView: View, PlaceholderView: View
}
}
// Note: Returns `AnyView` as this is what `transformer` expects.
var blurHashView: AnyView? {
if let blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
return AnyView(Image(uiImage: image).resizable().overlay { blurHashOverlay })
} else {
return nil
}
}
// MARK: - Overlays
@ViewBuilder
var placeholderOverlay: some View {
switch mediaType {
case .avatar, .generic:
EmptyView()
case .timelineItem:
if shouldRender {
ProgressView(L10n.commonLoading)
.frame(maxWidth: .infinity)
} else {
loadManuallyButton
}
}
}
@ViewBuilder
var blurHashOverlay: some View {
if !shouldRender {
loadManuallyButton
}
}
var loadManuallyButton: some View {
ZStack {
Color.black.opacity(0.6)
.contentShape(.rect)
.onTapGesture { /* Empty gesture to block the itemTapped action */ }
// Don't use a real Button as it sometimes triggers simultaneously with the long press gesture.
Text(L10n.actionShow)
.font(.compound.bodyLGSemibold)
.foregroundStyle(.compound.textOnSolidPrimary)
.padding(.horizontal, 16)
.padding(.vertical, 4)
.overlay {
Capsule()
.stroke(lineWidth: 1)
.foregroundStyle(.compound.borderInteractiveSecondary)
}
.contentShape(.capsule)
.onTapGesture {
loadManually = true
}
.environment(\.colorScheme, .light)
}
}
// MARK: - ImageDataProvider
var cacheKey: String {
@ -222,3 +294,120 @@ private class ContentLoader: ObservableObject {
mediaSource.mimeType == "image/gif"
}
}
extension EnvironmentValues {
/// Whether or not images should be loaded inside `LoadableImage` without a user interaction.
@Entry var shouldAutomaticallyLoadImages = true
}
// MARK: - Previews
struct LoadableImage_Previews: PreviewProvider, TestablePreview {
static let mediaProvider = makeMediaProvider()
static let loadingMediaProvider = makeMediaProvider(isLoading: true)
static var previews: some View {
LazyVGrid(columns: [.init(.adaptive(minimum: 110, maximum: 110))], spacing: 24) {
LoadableImage(url: "mxc://wherever/1234",
mediaType: .timelineItem,
mediaProvider: mediaProvider,
placeholder: placeholder)
.layout(title: "Loaded")
LoadableImage(url: "mxc://wherever/2345",
mediaType: .timelineItem,
blurhash: "KpE4oyayR5|GbHb];3j@of",
mediaProvider: mediaProvider,
placeholder: placeholder)
.layout(title: "Hidden (blurhash)", hideTimelineMedia: true)
LoadableImage(url: "mxc://wherever/3456",
mediaType: .timelineItem,
mediaProvider: mediaProvider,
placeholder: placeholder)
.layout(title: "Hidden (placeholder)", hideTimelineMedia: true)
LoadableImage(url: "mxc://wherever/4567",
mediaType: .timelineItem,
blurhash: "KbLM^j]q$jT|EfR-3rtjXk",
mediaProvider: loadingMediaProvider,
placeholder: placeholder)
.layout(title: "Loading (blurhash)")
LoadableImage(url: "mxc://wherever/5678",
mediaType: .timelineItem,
mediaProvider: loadingMediaProvider,
placeholder: placeholder)
.layout(title: "Loading (placeholder)")
LoadableImage(url: "mxc://wherever/6789",
mediaType: .avatar,
mediaProvider: loadingMediaProvider,
placeholder: placeholder)
.layout(title: "Loading (avatar)")
LoadableImage(url: "mxc://wherever/345",
mediaType: .timelineItem,
blurhash: "KbLM^j]q$jT|EfR-3rtjXk",
mediaProvider: mediaProvider,
transformer: transformer,
placeholder: placeholder)
.layout(title: "Loaded (transformer)")
LoadableImage(url: "mxc://wherever/345",
mediaType: .timelineItem,
blurhash: "KbLM^j]q$jT|EfR-3rtjXk",
mediaProvider: loadingMediaProvider,
transformer: transformer,
placeholder: placeholder)
.layout(title: "Loading (transformer)")
LoadableImage(url: "mxc://wherever/234",
mediaType: .timelineItem,
blurhash: "KbLM^j]q$jT|EfR-3rtjXk",
mediaProvider: mediaProvider,
transformer: transformer,
placeholder: placeholder)
.layout(title: "Hidden (transformer)", hideTimelineMedia: true)
}
}
static func placeholder() -> some View { Color.compound._bgBubbleIncoming }
static func transformer(_ view: AnyView) -> some View {
view.overlay {
Image(systemSymbol: .playCircleFill)
.font(.largeTitle)
.foregroundStyle(.compound.iconAccentPrimary)
}
}
static func makeMediaProvider(isLoading: Bool = false) -> MediaProviderProtocol {
let mediaProvider = MediaProviderMock(configuration: .init())
if isLoading {
mediaProvider.imageFromSourceSizeClosure = { _, _ in nil }
mediaProvider.loadFileFromSourceBodyClosure = { _, _ in .failure(.failedRetrievingFile) }
mediaProvider.loadImageDataFromSourceClosure = { _ in .failure(.failedRetrievingImage) }
mediaProvider.loadImageFromSourceSizeClosure = { _, _ in .failure(.failedRetrievingImage) }
mediaProvider.loadThumbnailForSourceSourceSizeClosure = { _, _ in .failure(.failedRetrievingThumbnail) }
mediaProvider.loadImageRetryingOnReconnectionSizeClosure = { _, _ in
Task { throw MediaProviderError.failedRetrievingImage }
}
}
return mediaProvider
}
}
private extension View {
func layout(title: String, hideTimelineMedia: Bool = false) -> some View {
aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(alignment: .bottom) {
Text(title)
.font(.caption2)
.offset(y: 16)
.padding(.horizontal, -5)
}
.environment(\.shouldAutomaticallyLoadImages, !hideTimelineMedia)
}
}

View File

@ -91,7 +91,7 @@ struct RoomPollsHistoryScreen: View {
Button {
context.send(viewAction: .loadMore)
} label: {
Text(L10n.Action.loadMore)
Text(L10n.actionLoadMore)
.font(.compound.bodyLGSemibold)
.padding(.horizontal, 12)
}

View File

@ -42,6 +42,8 @@ struct RoomScreen: View {
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.environmentObject(timelineContext)
.environment(\.timelineContext, timelineContext)
// Make sure the reply header honours the hideTimelineMedia setting too.
.environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia)
}
.overlay(alignment: .top) {
Group {

View File

@ -44,9 +44,10 @@ protocol DeveloperOptionsProtocol: AnyObject {
var logLevel: TracingConfiguration.LogLevel { get set }
var slidingSyncDiscovery: AppSettings.SlidingSyncDiscovery { get set }
var hideUnreadMessagesBadge: Bool { get set }
var elementCallBaseURLOverride: URL? { get set }
var fuzzyRoomListSearchEnabled: Bool { get set }
var hideTimelineMedia: Bool { get set }
var enableOnlySignedDeviceIsolationMode: Bool { get set }
var elementCallBaseURLOverride: URL? { get set }
}
extension AppSettings: DeveloperOptionsProtocol { }

View File

@ -45,6 +45,12 @@ struct DeveloperOptionsScreen: View {
}
}
Section("Timeline") {
Toggle(isOn: $context.hideTimelineMedia) {
Text("Hide image & video previews")
}
}
Section {
Toggle(isOn: $context.enableOnlySignedDeviceIsolationMode) {
Text("Exclude not secure devices when sending/receiving messages")

View File

@ -97,6 +97,7 @@ struct TimelineViewState: BindableState {
var canCurrentUserRedactSelf = false
var canCurrentUserPin = false
var isViewSourceEnabled: Bool
var hideTimelineMedia: Bool
// The `pinnedEventIDs` are used only to determine if an item is already pinned or not.
// It's updated from the room info, so it's faster than using the timeline

View File

@ -125,6 +125,13 @@ class TimelineTableViewController: UIViewController {
}
}
var hideTimelineMedia = false {
didSet {
guard let snapshot = dataSource?.snapshot() else { return }
dataSource?.applySnapshotUsingReloadData(snapshot)
}
}
/// Used to hold an observable object that the typing indicator can use
let typingMembers = TypingMembersObservableObject(members: [])
@ -260,21 +267,19 @@ class TimelineTableViewController: UIViewController {
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
guard let self, let cell = cell as? TimelineItemCell else { return cell }
// A local reference to avoid capturing self in the cell configuration.
let coordinator = self.coordinator
let viewState = timelineItemsDictionary[id]
cell.item = viewState
guard let viewState else {
return cell
}
cell.contentConfiguration = UIHostingConfiguration {
cell.contentConfiguration = UIHostingConfiguration { [coordinator, hideTimelineMedia] in
RoomTimelineItemView(viewState: viewState)
.id(id)
.frame(maxWidth: .infinity, alignment: .leading)
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
.environment(\.timelineContext, coordinator.context)
.environment(\.shouldAutomaticallyLoadImages, !hideTimelineMedia)
}
.margins(.all, 0) // Margins are handled in the stylers
.minSize(height: 1)

View File

@ -78,6 +78,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
timelineViewState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
ownUserID: roomProxy.ownUserID,
isViewSourceEnabled: appSettings.viewSourceEnabled,
hideTimelineMedia: appSettings.hideTimelineMedia,
bindings: .init(reactionsCollapsed: [:])),
mediaProvider: mediaProvider)
@ -440,6 +441,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
appSettings.$viewSourceEnabled
.weakAssign(to: \.state.isViewSourceEnabled, on: self)
.store(in: &cancellables)
appSettings.$hideTimelineMedia
.weakAssign(to: \.state.hideTimelineMedia, on: self)
.store(in: &cancellables)
}
private func updatePinnedEventIDs() async {

View File

@ -15,6 +15,7 @@ struct ImageRoomTimelineView: View {
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
LoadableImage(mediaSource: source,
mediaType: .timelineItem,
blurhash: timelineItem.content.blurhash,
mediaProvider: context.mediaProvider) {
placeholder
@ -35,14 +36,9 @@ struct ImageRoomTimelineView: View {
}
var placeholder: some View {
ZStack {
Rectangle()
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
.opacity(0.3)
ProgressView(L10n.commonLoading)
.frame(maxWidth: .infinity)
}
Rectangle()
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
.opacity(0.3)
}
}

View File

@ -15,6 +15,7 @@ struct StickerRoomTimelineView: View {
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
LoadableImage(url: timelineItem.imageURL,
mediaType: .timelineItem,
blurhash: timelineItem.blurhash,
mediaProvider: context.mediaProvider) {
placeholder
@ -27,14 +28,9 @@ struct StickerRoomTimelineView: View {
}
private var placeholder: some View {
ZStack {
Rectangle()
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
.opacity(0.3)
ProgressView(L10n.commonLoading)
.frame(maxWidth: .infinity)
}
Rectangle()
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
.opacity(0.3)
}
}

View File

@ -26,6 +26,7 @@ struct VideoRoomTimelineView: View {
var thumbnail: some View {
if let thumbnailSource = timelineItem.content.thumbnailSource {
LoadableImage(mediaSource: thumbnailSource,
mediaType: .timelineItem,
blurhash: timelineItem.content.blurhash,
mediaProvider: context.mediaProvider) { imageView in
imageView
@ -47,14 +48,9 @@ struct VideoRoomTimelineView: View {
}
var placeholder: some View {
ZStack {
Rectangle()
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
.opacity(0.3)
ProgressView(L10n.commonLoading)
.frame(maxWidth: .infinity)
}
Rectangle()
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
.opacity(0.3)
}
}

View File

@ -60,6 +60,9 @@ struct TimelineView: UIViewControllerRepresentable {
if tableViewController.focussedEvent != context.viewState.timelineViewState.focussedEvent {
tableViewController.focussedEvent = context.viewState.timelineViewState.focussedEvent
}
if tableViewController.hideTimelineMedia != context.viewState.hideTimelineMedia {
tableViewController.hideTimelineMedia = context.viewState.hideTimelineMedia
}
if tableViewController.typingMembers.members != context.viewState.typingMembers {
tableViewController.setTypingMembers(context.viewState.typingMembers)

View File

@ -247,6 +247,40 @@ enum RoomTimelineItemFixtures {
senderDisplayName: index > 10 ? "Alice" : "Bob")
}
}
static var mediaChunk: [RoomTimelineItemProtocol] {
[
VideoRoomTimelineItem(id: .random,
timestamp: "10:47 am",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: ""),
content: .init(body: "video",
duration: 100,
source: .init(url: .picturesDirectory, mimeType: nil),
thumbnailSource: .init(url: .picturesDirectory, mimeType: nil),
width: 1920,
height: 1080,
aspectRatio: 1.78,
blurhash: "KtI~70X5V?yss9oyrYs:t6")),
ImageRoomTimelineItem(id: .random,
timestamp: "10:47 am",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: ""),
content: .init(body: "image",
source: .init(url: .picturesDirectory, mimeType: nil),
thumbnailSource: nil,
width: 5120,
height: 3412,
aspectRatio: 1.5,
blurhash: "KpE4oyayR5|GbHb];3j@of"))
]
}
}
private extension TextRoomTimelineItem {
@ -260,9 +294,7 @@ private extension TextRoomTimelineItem {
sender: .init(id: "", displayName: senderDisplayName),
content: .init(body: text))
}
}
private extension TextRoomTimelineItem {
func withReadReceipts(_ receipts: [ReadReceipt]) -> TextRoomTimelineItem {
var newSelf = self
newSelf.properties.orderedReadReceipts = receipts

View File

@ -11,6 +11,7 @@ import UserNotifications
struct NotificationContentBuilder {
let messageEventStringBuilder: RoomMessageEventStringBuilder
let settings: CommonSettingsProtocol
/// Process the given notification item proxy
/// - Parameters:
@ -100,6 +101,8 @@ struct NotificationContentBuilder {
let displayName = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName
notification.body = String(messageEventStringBuilder.buildAttributedString(for: messageType, senderDisplayName: displayName).characters)
guard !settings.hideTimelineMedia else { return notification }
switch messageType {
case .image(content: let content):
notification = await notification.addMediaAttachment(using: mediaProvider,

View File

@ -33,7 +33,8 @@ import UserNotifications
// database, logging, etc. are only ever setup once per *process*
private let settings: CommonSettingsProtocol = AppSettings()
private let notificationContentBuilder = NotificationContentBuilder(messageEventStringBuilder: RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(mentionBuilder: PlainMentionBuilder()), prefix: .none))
private let notificationContentBuilder = NotificationContentBuilder(messageEventStringBuilder: RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(mentionBuilder: PlainMentionBuilder()), prefix: .none),
settings: settings)
private let keychainController = KeychainController(service: .sessions,
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)

View File

@ -311,6 +311,12 @@ extension PreviewTests {
}
}
func test_loadableImage() {
for preview in LoadableImage_Previews._allPreviews {
assertSnapshots(matching: preview)
}
}
func test_locationMarkerView() {
for preview in LocationMarkerView_Previews._allPreviews {
assertSnapshots(matching: preview)

Binary file not shown.

Binary file not shown.