mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
Add developer option to hide media in the timeline. (#3366)
This commit is contained in:
parent
4f29821306
commit
e6f4dd33a0
@ -41,6 +41,7 @@
|
|||||||
"action_create" = "Create";
|
"action_create" = "Create";
|
||||||
"action_create_a_room" = "Create a room";
|
"action_create_a_room" = "Create a room";
|
||||||
"action_deactivate" = "Deactivate";
|
"action_deactivate" = "Deactivate";
|
||||||
|
"action_deactivate_account" = "Deactivate account";
|
||||||
"action_decline" = "Decline";
|
"action_decline" = "Decline";
|
||||||
"action_delete_poll" = "Delete Poll";
|
"action_delete_poll" = "Delete Poll";
|
||||||
"action_disable" = "Disable";
|
"action_disable" = "Disable";
|
||||||
@ -64,6 +65,7 @@
|
|||||||
"action_leave" = "Leave";
|
"action_leave" = "Leave";
|
||||||
"action_leave_conversation" = "Leave conversation";
|
"action_leave_conversation" = "Leave conversation";
|
||||||
"action_leave_room" = "Leave room";
|
"action_leave_room" = "Leave room";
|
||||||
|
"action_load_more" = "Load more";
|
||||||
"action_manage_account" = "Manage account";
|
"action_manage_account" = "Manage account";
|
||||||
"action_manage_devices" = "Manage devices";
|
"action_manage_devices" = "Manage devices";
|
||||||
"action_message" = "Message";
|
"action_message" = "Message";
|
||||||
@ -93,6 +95,7 @@
|
|||||||
"action_send_message" = "Send message";
|
"action_send_message" = "Send message";
|
||||||
"action_share" = "Share";
|
"action_share" = "Share";
|
||||||
"action_share_link" = "Share link";
|
"action_share_link" = "Share link";
|
||||||
|
"action_show" = "Show";
|
||||||
"action_sign_in_again" = "Sign in again";
|
"action_sign_in_again" = "Sign in again";
|
||||||
"action_signout" = "Sign out";
|
"action_signout" = "Sign out";
|
||||||
"action_signout_anyway" = "Sign out anyway";
|
"action_signout_anyway" = "Sign out anyway";
|
||||||
@ -108,8 +111,6 @@
|
|||||||
"action_view_in_timeline" = "View in timeline";
|
"action_view_in_timeline" = "View in timeline";
|
||||||
"action_view_source" = "View source";
|
"action_view_source" = "View source";
|
||||||
"action_yes" = "Yes";
|
"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_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_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.";
|
"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.";
|
||||||
|
@ -12,6 +12,7 @@ import SwiftUI
|
|||||||
protocol CommonSettingsProtocol {
|
protocol CommonSettingsProtocol {
|
||||||
var logLevel: TracingConfiguration.LogLevel { get }
|
var logLevel: TracingConfiguration.LogLevel { get }
|
||||||
var enableOnlySignedDeviceIsolationMode: Bool { get }
|
var enableOnlySignedDeviceIsolationMode: Bool { get }
|
||||||
|
var hideTimelineMedia: Bool { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store Element specific app settings.
|
/// Store Element specific app settings.
|
||||||
@ -34,6 +35,7 @@ final class AppSettings {
|
|||||||
case appAppearance
|
case appAppearance
|
||||||
case sharePresence
|
case sharePresence
|
||||||
case hideUnreadMessagesBadge
|
case hideUnreadMessagesBadge
|
||||||
|
case hideTimelineMedia
|
||||||
|
|
||||||
case elementCallBaseURLOverride
|
case elementCallBaseURLOverride
|
||||||
case elementCallEncryptionEnabled
|
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.
|
/// 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))
|
@UserPreference(key: UserDefaultsKeys.enableOnlySignedDeviceIsolationMode, defaultValue: false, storageType: .userDefaults(store))
|
||||||
var enableOnlySignedDeviceIsolationMode
|
var enableOnlySignedDeviceIsolationMode
|
||||||
|
|
||||||
|
@UserPreference(key: UserDefaultsKeys.hideTimelineMedia, defaultValue: false, storageType: .userDefaults(store))
|
||||||
|
var hideTimelineMedia
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AppSettings: CommonSettingsProtocol { }
|
extension AppSettings: CommonSettingsProtocol { }
|
||||||
|
@ -164,6 +164,8 @@ internal enum L10n {
|
|||||||
internal static var actionLeaveConversation: String { return L10n.tr("Localizable", "action_leave_conversation") }
|
internal static var actionLeaveConversation: String { return L10n.tr("Localizable", "action_leave_conversation") }
|
||||||
/// Leave room
|
/// Leave room
|
||||||
internal static var actionLeaveRoom: String { return L10n.tr("Localizable", "action_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
|
/// Manage account
|
||||||
internal static var actionManageAccount: String { return L10n.tr("Localizable", "action_manage_account") }
|
internal static var actionManageAccount: String { return L10n.tr("Localizable", "action_manage_account") }
|
||||||
/// Manage devices
|
/// Manage devices
|
||||||
@ -222,6 +224,8 @@ internal enum L10n {
|
|||||||
internal static var actionShare: String { return L10n.tr("Localizable", "action_share") }
|
internal static var actionShare: String { return L10n.tr("Localizable", "action_share") }
|
||||||
/// Share link
|
/// Share link
|
||||||
internal static var actionShareLink: String { return L10n.tr("Localizable", "action_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
|
/// Sign in again
|
||||||
internal static var actionSignInAgain: String { return L10n.tr("Localizable", "action_sign_in_again") }
|
internal static var actionSignInAgain: String { return L10n.tr("Localizable", "action_sign_in_again") }
|
||||||
/// Sign out
|
/// Sign out
|
||||||
@ -2396,11 +2400,6 @@ internal enum L10n {
|
|||||||
/// Check UnifiedPush
|
/// Check UnifiedPush
|
||||||
internal static var troubleshootNotificationsTestUnifiedPushTitle: String { return L10n.tr("Localizable", "troubleshoot_notifications_test_unified_push_title") }
|
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 Banner {
|
||||||
internal enum SetUpRecovery {
|
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.
|
/// Generate a new recovery key that can be used to restore your encrypted message history in case you lose access to your devices.
|
||||||
|
@ -50,6 +50,7 @@ struct LoadableAvatarImage: View {
|
|||||||
.frame(width: frameSize, height: frameSize)
|
.frame(width: frameSize, height: frameSize)
|
||||||
.background(Color.compound.bgCanvasDefault)
|
.background(Color.compound.bgCanvasDefault)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
|
.environment(\.shouldAutomaticallyLoadImages, true) // We always load avatars.
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
@ -6,12 +6,17 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import Compound
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Used to configure animations
|
/// Used to configure animations
|
||||||
enum LoadableImageMediaType {
|
enum LoadableImageMediaType {
|
||||||
|
/// An avatar (can be displayed anywhere within the app).
|
||||||
case avatar
|
case avatar
|
||||||
|
/// An image displayed in the timeline.
|
||||||
|
case timelineItem
|
||||||
|
/// Any other media (can be displayed anywhere within the app).
|
||||||
case generic
|
case generic
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +84,8 @@ struct LoadableImage<TransformerView: View, PlaceholderView: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct LoadableImageContent<TransformerView: View, PlaceholderView: View>: View, ImageDataProvider {
|
private struct LoadableImageContent<TransformerView: View, PlaceholderView: View>: View, ImageDataProvider {
|
||||||
|
@Environment(\.shouldAutomaticallyLoadImages) private var loadAutomatically
|
||||||
|
|
||||||
private let mediaSource: MediaSourceProxy
|
private let mediaSource: MediaSourceProxy
|
||||||
private let mediaType: LoadableImageMediaType
|
private let mediaType: LoadableImageMediaType
|
||||||
private let blurhash: String?
|
private let blurhash: String?
|
||||||
@ -86,6 +93,7 @@ private struct LoadableImageContent<TransformerView: View, PlaceholderView: View
|
|||||||
private let placeholder: () -> PlaceholderView
|
private let placeholder: () -> PlaceholderView
|
||||||
|
|
||||||
@StateObject private var contentLoader: ContentLoader
|
@StateObject private var contentLoader: ContentLoader
|
||||||
|
@State private var loadManually = false
|
||||||
|
|
||||||
init(mediaSource: MediaSourceProxy,
|
init(mediaSource: MediaSourceProxy,
|
||||||
mediaType: LoadableImageMediaType,
|
mediaType: LoadableImageMediaType,
|
||||||
@ -104,36 +112,40 @@ private struct LoadableImageContent<TransformerView: View, PlaceholderView: View
|
|||||||
_contentLoader = StateObject(wrappedValue: ContentLoader(mediaSource: mediaSource, size: size, mediaProvider: mediaProvider))
|
_contentLoader = StateObject(wrappedValue: ContentLoader(mediaSource: mediaSource, size: size, mediaProvider: mediaProvider))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var shouldRender: Bool {
|
||||||
|
loadAutomatically || loadManually
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// Tried putting this in the body's .task but it randomly
|
ZStack {
|
||||||
// decides to not execute the request
|
switch (contentLoader.content, shouldRender) {
|
||||||
let _ = Task {
|
case (.image(let image), true):
|
||||||
guard contentLoader.content == nil else {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await contentLoader.load()
|
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 {
|
.onDisappear {
|
||||||
guard contentLoader.content == nil else {
|
guard contentLoader.content == nil else {
|
||||||
return
|
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
|
// MARK: - ImageDataProvider
|
||||||
|
|
||||||
var cacheKey: String {
|
var cacheKey: String {
|
||||||
@ -222,3 +294,120 @@ private class ContentLoader: ObservableObject {
|
|||||||
mediaSource.mimeType == "image/gif"
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -91,7 +91,7 @@ struct RoomPollsHistoryScreen: View {
|
|||||||
Button {
|
Button {
|
||||||
context.send(viewAction: .loadMore)
|
context.send(viewAction: .loadMore)
|
||||||
} label: {
|
} label: {
|
||||||
Text(L10n.Action.loadMore)
|
Text(L10n.actionLoadMore)
|
||||||
.font(.compound.bodyLGSemibold)
|
.font(.compound.bodyLGSemibold)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
|
@ -42,6 +42,8 @@ struct RoomScreen: View {
|
|||||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||||
.environmentObject(timelineContext)
|
.environmentObject(timelineContext)
|
||||||
.environment(\.timelineContext, timelineContext)
|
.environment(\.timelineContext, timelineContext)
|
||||||
|
// Make sure the reply header honours the hideTimelineMedia setting too.
|
||||||
|
.environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia)
|
||||||
}
|
}
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
Group {
|
Group {
|
||||||
|
@ -44,9 +44,10 @@ protocol DeveloperOptionsProtocol: AnyObject {
|
|||||||
var logLevel: TracingConfiguration.LogLevel { get set }
|
var logLevel: TracingConfiguration.LogLevel { get set }
|
||||||
var slidingSyncDiscovery: AppSettings.SlidingSyncDiscovery { get set }
|
var slidingSyncDiscovery: AppSettings.SlidingSyncDiscovery { get set }
|
||||||
var hideUnreadMessagesBadge: Bool { get set }
|
var hideUnreadMessagesBadge: Bool { get set }
|
||||||
var elementCallBaseURLOverride: URL? { get set }
|
|
||||||
var fuzzyRoomListSearchEnabled: Bool { get set }
|
var fuzzyRoomListSearchEnabled: Bool { get set }
|
||||||
|
var hideTimelineMedia: Bool { get set }
|
||||||
var enableOnlySignedDeviceIsolationMode: Bool { get set }
|
var enableOnlySignedDeviceIsolationMode: Bool { get set }
|
||||||
|
var elementCallBaseURLOverride: URL? { get set }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AppSettings: DeveloperOptionsProtocol { }
|
extension AppSettings: DeveloperOptionsProtocol { }
|
||||||
|
@ -45,6 +45,12 @@ struct DeveloperOptionsScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Timeline") {
|
||||||
|
Toggle(isOn: $context.hideTimelineMedia) {
|
||||||
|
Text("Hide image & video previews")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: $context.enableOnlySignedDeviceIsolationMode) {
|
Toggle(isOn: $context.enableOnlySignedDeviceIsolationMode) {
|
||||||
Text("Exclude not secure devices when sending/receiving messages")
|
Text("Exclude not secure devices when sending/receiving messages")
|
||||||
|
@ -97,6 +97,7 @@ struct TimelineViewState: BindableState {
|
|||||||
var canCurrentUserRedactSelf = false
|
var canCurrentUserRedactSelf = false
|
||||||
var canCurrentUserPin = false
|
var canCurrentUserPin = false
|
||||||
var isViewSourceEnabled: Bool
|
var isViewSourceEnabled: Bool
|
||||||
|
var hideTimelineMedia: Bool
|
||||||
|
|
||||||
// The `pinnedEventIDs` are used only to determine if an item is already pinned or not.
|
// 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
|
// It's updated from the room info, so it's faster than using the timeline
|
||||||
|
@ -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
|
/// Used to hold an observable object that the typing indicator can use
|
||||||
let typingMembers = TypingMembersObservableObject(members: [])
|
let typingMembers = TypingMembersObservableObject(members: [])
|
||||||
|
|
||||||
@ -260,21 +267,19 @@ class TimelineTableViewController: UIViewController {
|
|||||||
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
|
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
|
||||||
guard let self, let cell = cell as? TimelineItemCell else { return cell }
|
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]
|
let viewState = timelineItemsDictionary[id]
|
||||||
cell.item = viewState
|
cell.item = viewState
|
||||||
guard let viewState else {
|
guard let viewState else {
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
cell.contentConfiguration = UIHostingConfiguration {
|
cell.contentConfiguration = UIHostingConfiguration { [coordinator, hideTimelineMedia] in
|
||||||
RoomTimelineItemView(viewState: viewState)
|
RoomTimelineItemView(viewState: viewState)
|
||||||
.id(id)
|
.id(id)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
|
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
|
||||||
.environment(\.timelineContext, coordinator.context)
|
.environment(\.timelineContext, coordinator.context)
|
||||||
|
.environment(\.shouldAutomaticallyLoadImages, !hideTimelineMedia)
|
||||||
}
|
}
|
||||||
.margins(.all, 0) // Margins are handled in the stylers
|
.margins(.all, 0) // Margins are handled in the stylers
|
||||||
.minSize(height: 1)
|
.minSize(height: 1)
|
||||||
|
@ -78,6 +78,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
|||||||
timelineViewState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
|
timelineViewState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
|
||||||
ownUserID: roomProxy.ownUserID,
|
ownUserID: roomProxy.ownUserID,
|
||||||
isViewSourceEnabled: appSettings.viewSourceEnabled,
|
isViewSourceEnabled: appSettings.viewSourceEnabled,
|
||||||
|
hideTimelineMedia: appSettings.hideTimelineMedia,
|
||||||
bindings: .init(reactionsCollapsed: [:])),
|
bindings: .init(reactionsCollapsed: [:])),
|
||||||
mediaProvider: mediaProvider)
|
mediaProvider: mediaProvider)
|
||||||
|
|
||||||
@ -440,6 +441,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
|||||||
appSettings.$viewSourceEnabled
|
appSettings.$viewSourceEnabled
|
||||||
.weakAssign(to: \.state.isViewSourceEnabled, on: self)
|
.weakAssign(to: \.state.isViewSourceEnabled, on: self)
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
appSettings.$hideTimelineMedia
|
||||||
|
.weakAssign(to: \.state.hideTimelineMedia, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updatePinnedEventIDs() async {
|
private func updatePinnedEventIDs() async {
|
||||||
|
@ -15,6 +15,7 @@ struct ImageRoomTimelineView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineStyler(timelineItem: timelineItem) {
|
TimelineStyler(timelineItem: timelineItem) {
|
||||||
LoadableImage(mediaSource: source,
|
LoadableImage(mediaSource: source,
|
||||||
|
mediaType: .timelineItem,
|
||||||
blurhash: timelineItem.content.blurhash,
|
blurhash: timelineItem.content.blurhash,
|
||||||
mediaProvider: context.mediaProvider) {
|
mediaProvider: context.mediaProvider) {
|
||||||
placeholder
|
placeholder
|
||||||
@ -35,14 +36,9 @@ struct ImageRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var placeholder: some View {
|
var placeholder: some View {
|
||||||
ZStack {
|
Rectangle()
|
||||||
Rectangle()
|
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
|
||||||
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
|
.opacity(0.3)
|
||||||
.opacity(0.3)
|
|
||||||
|
|
||||||
ProgressView(L10n.commonLoading)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ struct StickerRoomTimelineView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineStyler(timelineItem: timelineItem) {
|
TimelineStyler(timelineItem: timelineItem) {
|
||||||
LoadableImage(url: timelineItem.imageURL,
|
LoadableImage(url: timelineItem.imageURL,
|
||||||
|
mediaType: .timelineItem,
|
||||||
blurhash: timelineItem.blurhash,
|
blurhash: timelineItem.blurhash,
|
||||||
mediaProvider: context.mediaProvider) {
|
mediaProvider: context.mediaProvider) {
|
||||||
placeholder
|
placeholder
|
||||||
@ -27,14 +28,9 @@ struct StickerRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var placeholder: some View {
|
private var placeholder: some View {
|
||||||
ZStack {
|
Rectangle()
|
||||||
Rectangle()
|
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
|
||||||
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
|
.opacity(0.3)
|
||||||
.opacity(0.3)
|
|
||||||
|
|
||||||
ProgressView(L10n.commonLoading)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ struct VideoRoomTimelineView: View {
|
|||||||
var thumbnail: some View {
|
var thumbnail: some View {
|
||||||
if let thumbnailSource = timelineItem.content.thumbnailSource {
|
if let thumbnailSource = timelineItem.content.thumbnailSource {
|
||||||
LoadableImage(mediaSource: thumbnailSource,
|
LoadableImage(mediaSource: thumbnailSource,
|
||||||
|
mediaType: .timelineItem,
|
||||||
blurhash: timelineItem.content.blurhash,
|
blurhash: timelineItem.content.blurhash,
|
||||||
mediaProvider: context.mediaProvider) { imageView in
|
mediaProvider: context.mediaProvider) { imageView in
|
||||||
imageView
|
imageView
|
||||||
@ -47,14 +48,9 @@ struct VideoRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var placeholder: some View {
|
var placeholder: some View {
|
||||||
ZStack {
|
Rectangle()
|
||||||
Rectangle()
|
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
|
||||||
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
|
.opacity(0.3)
|
||||||
.opacity(0.3)
|
|
||||||
|
|
||||||
ProgressView(L10n.commonLoading)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,6 +60,9 @@ struct TimelineView: UIViewControllerRepresentable {
|
|||||||
if tableViewController.focussedEvent != context.viewState.timelineViewState.focussedEvent {
|
if tableViewController.focussedEvent != context.viewState.timelineViewState.focussedEvent {
|
||||||
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 {
|
if tableViewController.typingMembers.members != context.viewState.typingMembers {
|
||||||
tableViewController.setTypingMembers(context.viewState.typingMembers)
|
tableViewController.setTypingMembers(context.viewState.typingMembers)
|
||||||
|
@ -247,6 +247,40 @@ enum RoomTimelineItemFixtures {
|
|||||||
senderDisplayName: index > 10 ? "Alice" : "Bob")
|
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 {
|
private extension TextRoomTimelineItem {
|
||||||
@ -260,9 +294,7 @@ private extension TextRoomTimelineItem {
|
|||||||
sender: .init(id: "", displayName: senderDisplayName),
|
sender: .init(id: "", displayName: senderDisplayName),
|
||||||
content: .init(body: text))
|
content: .init(body: text))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private extension TextRoomTimelineItem {
|
|
||||||
func withReadReceipts(_ receipts: [ReadReceipt]) -> TextRoomTimelineItem {
|
func withReadReceipts(_ receipts: [ReadReceipt]) -> TextRoomTimelineItem {
|
||||||
var newSelf = self
|
var newSelf = self
|
||||||
newSelf.properties.orderedReadReceipts = receipts
|
newSelf.properties.orderedReadReceipts = receipts
|
||||||
|
@ -11,6 +11,7 @@ import UserNotifications
|
|||||||
|
|
||||||
struct NotificationContentBuilder {
|
struct NotificationContentBuilder {
|
||||||
let messageEventStringBuilder: RoomMessageEventStringBuilder
|
let messageEventStringBuilder: RoomMessageEventStringBuilder
|
||||||
|
let settings: CommonSettingsProtocol
|
||||||
|
|
||||||
/// Process the given notification item proxy
|
/// Process the given notification item proxy
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@ -100,6 +101,8 @@ struct NotificationContentBuilder {
|
|||||||
let displayName = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName
|
let displayName = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName
|
||||||
notification.body = String(messageEventStringBuilder.buildAttributedString(for: messageType, senderDisplayName: displayName).characters)
|
notification.body = String(messageEventStringBuilder.buildAttributedString(for: messageType, senderDisplayName: displayName).characters)
|
||||||
|
|
||||||
|
guard !settings.hideTimelineMedia else { return notification }
|
||||||
|
|
||||||
switch messageType {
|
switch messageType {
|
||||||
case .image(content: let content):
|
case .image(content: let content):
|
||||||
notification = await notification.addMediaAttachment(using: mediaProvider,
|
notification = await notification.addMediaAttachment(using: mediaProvider,
|
||||||
|
@ -33,7 +33,8 @@ import UserNotifications
|
|||||||
// database, logging, etc. are only ever setup once per *process*
|
// database, logging, etc. are only ever setup once per *process*
|
||||||
|
|
||||||
private let settings: CommonSettingsProtocol = AppSettings()
|
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,
|
private let keychainController = KeychainController(service: .sessions,
|
||||||
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
|
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
|
||||||
|
|
||||||
|
@ -311,6 +311,12 @@ extension PreviewTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_loadableImage() {
|
||||||
|
for preview in LoadableImage_Previews._allPreviews {
|
||||||
|
assertSnapshots(matching: preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func test_locationMarkerView() {
|
func test_locationMarkerView() {
|
||||||
for preview in LocationMarkerView_Previews._allPreviews {
|
for preview in LocationMarkerView_Previews._allPreviews {
|
||||||
assertSnapshots(matching: preview)
|
assertSnapshots(matching: preview)
|
||||||
|
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_loadableImage-iPad-en-GB.1.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_loadableImage-iPad-en-GB.1.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_loadableImage-iPad-pseudo.1.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_loadableImage-iPad-pseudo.1.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_loadableImage-iPhone-16-en-GB.1.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_loadableImage-iPhone-16-en-GB.1.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_loadableImage-iPhone-16-pseudo.1.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_loadableImage-iPhone-16-pseudo.1.png
(Stored with Git LFS)
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user