mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 13:37:11 +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_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.";
|
||||
|
@ -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 { }
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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 { }
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
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