Skip to content

Commit

Permalink
Add developer option to hide media in the timeline. (#3366)
Browse files Browse the repository at this point in the history
  • Loading branch information
pixlwave authored Oct 3, 2024
1 parent 4f29821 commit e6f4dd3
Show file tree
Hide file tree
Showing 24 changed files with 320 additions and 60 deletions.
5 changes: 3 additions & 2 deletions ElementX/Resources/Localizations/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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.";
Expand Down
5 changes: 5 additions & 0 deletions ElementX/Sources/Application/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -34,6 +35,7 @@ final class AppSettings {
case appAppearance
case sharePresence
case hideUnreadMessagesBadge
case hideTimelineMedia

case elementCallBaseURLOverride
case elementCallEncryptionEnabled
Expand Down Expand Up @@ -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 { }
9 changes: 4 additions & 5 deletions ElementX/Sources/Generated/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
227 changes: 208 additions & 19 deletions ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -79,13 +84,16 @@ 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?
private let transformer: (AnyView) -> TransformerView
private let placeholder: () -> PlaceholderView

@StateObject private var contentLoader: ContentLoader
@State private var loadManually = false

init(mediaSource: MediaSourceProxy,
mediaType: LoadableImageMediaType,
Expand All @@ -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 {
return
}

await contentLoader.load()
}

ZStack {
switch contentLoader.content {
case .image(let image):
switch (contentLoader.content, shouldRender) {
case (.image(let image), true):
transformer(
AnyView(Image(uiImage: image).resizable())
)
case .gifData:
case (.gifData, true):
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()))
case (.none, _), (_, false):
if let blurHashView {
if shouldRender {
transformer(blurHashView)
} else {
blurHashView
}
} else {
placeholder()
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()
}
.onDisappear {
guard contentLoader.content == nil else {
return
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit e6f4dd3

Please sign in to comment.