mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Further design updates to the timeline. (#524)
- Decryption banner on the timeline - Bubble colours - Background insets - Multiline composer corner radius - Read marker - GIF indicator - Reactions shape - Use RoomTimelineViewLabelStyle everywhere possible - Only set a line limit on reply quotes, allow all the lines for regular quotes. - Message label icons - Yes/No button casing - Make sender profiles immutable. - Enable copy link and invite buttons. - Fix layout loop detecting multiline composer text. - Add animation to the encryption banner.
This commit is contained in:
@@ -67,22 +67,19 @@ public struct ElementColors {
|
|||||||
|
|
||||||
// MARK: - Temp
|
// MARK: - Temp
|
||||||
|
|
||||||
private var tempActionBlack: UIColor { UIColor(red: 20 / 255, green: 20 / 255, blue: 20 / 255, alpha: 1.0) }
|
public var bubblesYou: Color {
|
||||||
|
|
||||||
public var tempActionBackground: Color {
|
|
||||||
Color(UIColor { collection in
|
Color(UIColor { collection in
|
||||||
collection.userInterfaceStyle == .light ? tempActionBlack : .white
|
// Note: Light colour doesn't currently match Figma.
|
||||||
|
collection.userInterfaceStyle == .light ? .element.systemGray5 : UIColor(red: 0.16, green: 0.18, blue: 0.21, alpha: 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public var tempActionForeground: Color {
|
public var bubblesNotYou: Color {
|
||||||
Color(UIColor { collection in
|
Color(UIColor { collection in
|
||||||
collection.userInterfaceStyle == .light ? .white : tempActionBlack
|
// Note: Light colour doesn't currently match Figma.
|
||||||
|
collection.userInterfaceStyle == .light ? .element.systemGray6 : .element.system
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public var tempActionBackgroundTint: Color { tempActionBackground.opacity(0.2) }
|
|
||||||
public var tempActionForegroundTint: Color { tempActionForeground.opacity(0.2) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: UIKit
|
// MARK: UIKit
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/* Used for testing */
|
/* Used for testing */
|
||||||
"untranslated" = "Untranslated";
|
"untranslated" = "Untranslated";
|
||||||
|
|
||||||
|
"ios_yes" = "Yes";
|
||||||
|
"ios_no" = "No";
|
||||||
"action_confirm" = "Confirm";
|
"action_confirm" = "Confirm";
|
||||||
"action_match" = "Match";
|
"action_match" = "Match";
|
||||||
|
|
||||||
@@ -24,9 +26,9 @@
|
|||||||
"room_timeline_syncing" = "Syncing";
|
"room_timeline_syncing" = "Syncing";
|
||||||
"room_timeline_unable_to_decrypt" = "Unable to decrypt";
|
"room_timeline_unable_to_decrypt" = "Unable to decrypt";
|
||||||
|
|
||||||
"room_timeline_context_menu_retry_decryption" = "Retry decryption";
|
|
||||||
|
|
||||||
"room_timeline_item_unsupported" = "Unsupported event";
|
"room_timeline_item_unsupported" = "Unsupported event";
|
||||||
|
"room_timeline_image_gif" = "GIF";
|
||||||
|
"room_timeline_read_marker_title" = "New";
|
||||||
|
|
||||||
"noticeRoomInviteAccepted" = "%1$@ accepted the invite";
|
"noticeRoomInviteAccepted" = "%1$@ accepted the invite";
|
||||||
"noticeRoomInviteAcceptedByYou" = "You accepted the invite";
|
"noticeRoomInviteAcceptedByYou" = "You accepted the invite";
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ extension ElementL10n {
|
|||||||
public static let ftueAuthCarouselWelcomeTitle = ElementL10n.tr("Untranslated", "ftue_auth_carousel_welcome_title")
|
public static let ftueAuthCarouselWelcomeTitle = ElementL10n.tr("Untranslated", "ftue_auth_carousel_welcome_title")
|
||||||
/// Enter your details
|
/// Enter your details
|
||||||
public static let ftueAuthSignInEnterDetails = ElementL10n.tr("Untranslated", "ftue_auth_sign_in_enter_details")
|
public static let ftueAuthSignInEnterDetails = ElementL10n.tr("Untranslated", "ftue_auth_sign_in_enter_details")
|
||||||
|
/// No
|
||||||
|
public static let iosNo = ElementL10n.tr("Untranslated", "ios_no")
|
||||||
|
/// Yes
|
||||||
|
public static let iosYes = ElementL10n.tr("Untranslated", "ios_yes")
|
||||||
/// Mobile
|
/// Mobile
|
||||||
public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device")
|
public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device")
|
||||||
/// Tablet
|
/// Tablet
|
||||||
@@ -94,14 +98,16 @@ extension ElementL10n {
|
|||||||
public static let roomDetailsTitle = ElementL10n.tr("Untranslated", "room_details_title")
|
public static let roomDetailsTitle = ElementL10n.tr("Untranslated", "room_details_title")
|
||||||
/// Failed loading messages
|
/// Failed loading messages
|
||||||
public static let roomTimelineBackpaginationFailure = ElementL10n.tr("Untranslated", "room_timeline_backpagination_failure")
|
public static let roomTimelineBackpaginationFailure = ElementL10n.tr("Untranslated", "room_timeline_backpagination_failure")
|
||||||
/// Retry decryption
|
|
||||||
public static let roomTimelineContextMenuRetryDecryption = ElementL10n.tr("Untranslated", "room_timeline_context_menu_retry_decryption")
|
|
||||||
/// Editing
|
/// Editing
|
||||||
public static let roomTimelineEditing = ElementL10n.tr("Untranslated", "room_timeline_editing")
|
public static let roomTimelineEditing = ElementL10n.tr("Untranslated", "room_timeline_editing")
|
||||||
|
/// GIF
|
||||||
|
public static let roomTimelineImageGif = ElementL10n.tr("Untranslated", "room_timeline_image_gif")
|
||||||
/// Unsupported event
|
/// Unsupported event
|
||||||
public static let roomTimelineItemUnsupported = ElementL10n.tr("Untranslated", "room_timeline_item_unsupported")
|
public static let roomTimelineItemUnsupported = ElementL10n.tr("Untranslated", "room_timeline_item_unsupported")
|
||||||
/// Failed creating the permalink
|
/// Failed creating the permalink
|
||||||
public static let roomTimelinePermalinkCreationFailure = ElementL10n.tr("Untranslated", "room_timeline_permalink_creation_failure")
|
public static let roomTimelinePermalinkCreationFailure = ElementL10n.tr("Untranslated", "room_timeline_permalink_creation_failure")
|
||||||
|
/// New
|
||||||
|
public static let roomTimelineReadMarkerTitle = ElementL10n.tr("Untranslated", "room_timeline_read_marker_title")
|
||||||
/// Replying to %@
|
/// Replying to %@
|
||||||
public static func roomTimelineReplyingTo(_ p1: Any) -> String {
|
public static func roomTimelineReplyingTo(_ p1: Any) -> String {
|
||||||
return ElementL10n.tr("Untranslated", "room_timeline_replying_to", String(describing: p1))
|
return ElementL10n.tr("Untranslated", "room_timeline_replying_to", String(describing: p1))
|
||||||
|
|||||||
@@ -94,7 +94,11 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
|||||||
attributedString.removeSubrange(range)
|
attributedString.removeSubrange(range)
|
||||||
}
|
}
|
||||||
|
|
||||||
return AttributedStringBuilderComponent(attributedString: attributedString, isBlockquote: value != nil)
|
let isBlockquote = value != nil
|
||||||
|
/// This is a temporary workaround until replies are retrieved from the SDK.
|
||||||
|
let isReply = isBlockquote && attributedString.characters.starts(with: "In reply to @")
|
||||||
|
|
||||||
|
return AttributedStringBuilderComponent(attributedString: attributedString, isBlockquote: isBlockquote, isReply: isReply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import Foundation
|
|||||||
struct AttributedStringBuilderComponent: Hashable {
|
struct AttributedStringBuilderComponent: Hashable {
|
||||||
let attributedString: AttributedString
|
let attributedString: AttributedString
|
||||||
let isBlockquote: Bool
|
let isBlockquote: Bool
|
||||||
|
let isReply: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol AttributedStringBuilderProtocol {
|
protocol AttributedStringBuilderProtocol {
|
||||||
|
|||||||
@@ -16,32 +16,26 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Small squared action button for settings screens
|
/// Small squared action button style for settings screens
|
||||||
struct SettingsActionButton: View {
|
struct SettingsActionButtonStyle: ButtonStyle {
|
||||||
// MARK: Public
|
|
||||||
|
|
||||||
let title: String
|
let title: String
|
||||||
let image: Image
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
// MARK: Private
|
|
||||||
|
|
||||||
@ScaledMetric private var menuIconSize = 54.0
|
@ScaledMetric private var menuIconSize = 54.0
|
||||||
|
|
||||||
// MARK: Views
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
VStack {
|
VStack {
|
||||||
image
|
configuration.label
|
||||||
.renderingMode(.template)
|
.buttonStyle(.plain)
|
||||||
.foregroundColor(.element.primaryContent)
|
.foregroundColor(.element.primaryContent)
|
||||||
.frame(width: menuIconSize, height: menuIconSize)
|
.frame(width: menuIconSize, height: menuIconSize)
|
||||||
.background(RoundedRectangle(cornerRadius: 16).fill(Color.element.background))
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(Color.element.background.opacity(configuration.isPressed ? 0.5 : 1))
|
||||||
|
}
|
||||||
|
|
||||||
Text(title)
|
Text(title)
|
||||||
.foregroundColor(.element.secondaryContent)
|
.foregroundColor(.element.secondaryContent)
|
||||||
.font(.element.subheadline)
|
.font(.element.subheadline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ struct ServerSelectionScreen: View {
|
|||||||
.readableFrame()
|
.readableFrame()
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
.background(Color.element.background, ignoresSafeAreaEdges: .all)
|
.background(Color.element.background.ignoresSafeArea())
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
.alert(item: $context.alertInfo) { $0.alert }
|
.alert(item: $context.alertInfo) { $0.alert }
|
||||||
.interactiveDismissDisabled()
|
.interactiveDismissDisabled()
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
|||||||
func presentCrashedLastRunAlert() {
|
func presentCrashedLastRunAlert() {
|
||||||
state.bindings.alertInfo = AlertInfo(id: UUID(),
|
state.bindings.alertInfo = AlertInfo(id: UUID(),
|
||||||
title: ElementL10n.sendBugReportAppCrashed,
|
title: ElementL10n.sendBugReportAppCrashed,
|
||||||
primaryButton: .init(title: ElementL10n.no, action: nil),
|
primaryButton: .init(title: ElementL10n.iosNo, action: nil),
|
||||||
secondaryButton: .init(title: ElementL10n.yes) { [weak self] in
|
secondaryButton: .init(title: ElementL10n.iosYes) { [weak self] in
|
||||||
self?.callback?(.presentFeedbackScreen)
|
self?.callback?(.presentFeedbackScreen)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ struct HomeScreen: View {
|
|||||||
userMenuButton
|
userMenuButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.element.background)
|
.background(Color.element.background.ignoresSafeArea())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ struct RoomDetailsViewState: BindableState {
|
|||||||
var title = ""
|
var title = ""
|
||||||
var topic: String?
|
var topic: String?
|
||||||
var avatarURL: URL?
|
var avatarURL: URL?
|
||||||
|
let permalink: URL?
|
||||||
var members: [RoomDetailsMember]
|
var members: [RoomDetailsMember]
|
||||||
|
|
||||||
var isLoadingMembers: Bool {
|
var isLoadingMembers: Bool {
|
||||||
@@ -58,7 +59,6 @@ enum RoomDetailsErrorType: Hashable {
|
|||||||
enum RoomDetailsViewAction {
|
enum RoomDetailsViewAction {
|
||||||
case processTapPeople
|
case processTapPeople
|
||||||
case copyRoomLink
|
case copyRoomLink
|
||||||
case inviteToRoom
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RoomDetailsMember: Identifiable, Equatable {
|
struct RoomDetailsMember: Identifiable, Equatable {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
|
|||||||
title: roomProxy.displayName ?? roomProxy.name ?? "Unknown Room",
|
title: roomProxy.displayName ?? roomProxy.name ?? "Unknown Room",
|
||||||
topic: roomProxy.topic,
|
topic: roomProxy.topic,
|
||||||
avatarURL: roomProxy.avatarURL,
|
avatarURL: roomProxy.avatarURL,
|
||||||
|
permalink: roomProxy.permalink,
|
||||||
members: [],
|
members: [],
|
||||||
bindings: .init()),
|
bindings: .init()),
|
||||||
imageProvider: mediaProvider)
|
imageProvider: mediaProvider)
|
||||||
@@ -59,19 +60,17 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
|
|||||||
callback?(.requestMemberDetailsPresentation(members))
|
callback?(.requestMemberDetailsPresentation(members))
|
||||||
case .copyRoomLink:
|
case .copyRoomLink:
|
||||||
copyRoomLink()
|
copyRoomLink()
|
||||||
case .inviteToRoom:
|
|
||||||
inviteToRoom()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private func copyRoomLink() {
|
private func copyRoomLink() {
|
||||||
// TODO: to be implemented
|
if let roomLink = state.permalink {
|
||||||
// ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.linkCopiedToClipboard))
|
UIPasteboard.general.url = roomLink
|
||||||
}
|
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.linkCopiedToClipboard))
|
||||||
|
} else {
|
||||||
private func inviteToRoom() {
|
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.unknownError))
|
||||||
// TODO: to be implemented
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,16 +62,20 @@ struct RoomDetailsScreen: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: uncomment this code to display copy link and invite buttons
|
if let permalink = context.viewState.permalink {
|
||||||
// HStack(spacing: 32) {
|
HStack(spacing: 32) {
|
||||||
// SettingsActionButton(title: ElementL10n.roomDetailsCopyLink, image: Image(systemName: "link")) {
|
Button { context.send(viewAction: .copyRoomLink) } label: {
|
||||||
// context.send(viewAction: .copyRoomLink)
|
Image(systemName: "link")
|
||||||
// }
|
}
|
||||||
// SettingsActionButton(title: ElementL10n.inviteUsersToRoomActionInvite.capitalized, image: Image(systemName: "square.and.arrow.up")) {
|
.buttonStyle(SettingsActionButtonStyle(title: ElementL10n.roomDetailsCopyLink))
|
||||||
// context.send(viewAction: .inviteToRoom)
|
|
||||||
// }
|
ShareLink(item: permalink) {
|
||||||
// }
|
Image(systemName: "square.and.arrow.up")
|
||||||
// .padding(.top, 32)
|
}
|
||||||
|
.buttonStyle(SettingsActionButtonStyle(title: ElementL10n.inviteUsersToRoomActionInvite.capitalized))
|
||||||
|
}
|
||||||
|
.padding(.top, 32)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ enum RoomScreenViewAction {
|
|||||||
case cancelEdit
|
case cancelEdit
|
||||||
/// Mark the entire room as read - this is heavy handed as a starting point for now.
|
/// Mark the entire room as read - this is heavy handed as a starting point for now.
|
||||||
case markRoomAsRead
|
case markRoomAsRead
|
||||||
|
case retryDecryption
|
||||||
case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction)
|
case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ struct RoomScreenViewState: BindableState {
|
|||||||
var items: [RoomTimelineViewProvider] = []
|
var items: [RoomTimelineViewProvider] = []
|
||||||
var canBackPaginate = true
|
var canBackPaginate = true
|
||||||
var isBackPaginating = false
|
var isBackPaginating = false
|
||||||
|
var showEncryptionBanner = false
|
||||||
var showLoading = false
|
var showLoading = false
|
||||||
var bindings: RoomScreenViewStateBindings
|
var bindings: RoomScreenViewStateBindings
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
private let timelineController: RoomTimelineControllerProtocol
|
private let timelineController: RoomTimelineControllerProtocol
|
||||||
private let timelineViewFactory: RoomTimelineViewFactoryProtocol
|
private let timelineViewFactory: RoomTimelineViewFactoryProtocol
|
||||||
|
|
||||||
|
// swiftlint:disable:next cyclomatic_complexity
|
||||||
init(timelineController: RoomTimelineControllerProtocol,
|
init(timelineController: RoomTimelineControllerProtocol,
|
||||||
timelineViewFactory: RoomTimelineViewFactoryProtocol,
|
timelineViewFactory: RoomTimelineViewFactoryProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
mediaProvider: MediaProviderProtocol,
|
||||||
@@ -66,6 +67,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
if self.state.isBackPaginating != isBackPaginating {
|
if self.state.isBackPaginating != isBackPaginating {
|
||||||
self.state.isBackPaginating = isBackPaginating
|
self.state.isBackPaginating = isBackPaginating
|
||||||
}
|
}
|
||||||
|
case .hasEncryptedItems(let hasEncryptedItems):
|
||||||
|
if self.state.showEncryptionBanner != hasEncryptedItems {
|
||||||
|
self.state.showEncryptionBanner = hasEncryptedItems
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -113,6 +118,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
state.bindings.composerText = ""
|
state.bindings.composerText = ""
|
||||||
case .markRoomAsRead:
|
case .markRoomAsRead:
|
||||||
await markRoomAsRead()
|
await markRoomAsRead()
|
||||||
|
case .retryDecryption:
|
||||||
|
retryDecryption()
|
||||||
case .contextMenuAction(let itemID, let action):
|
case .contextMenuAction(let itemID, let action):
|
||||||
processContentMenuAction(action, itemID: itemID)
|
processContentMenuAction(action, itemID: itemID)
|
||||||
}
|
}
|
||||||
@@ -182,6 +189,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retry decrypting any encrypted items in the timeline.
|
||||||
|
private func retryDecryption() {
|
||||||
|
Task {
|
||||||
|
let firstEncryptedItem = state.items.first { $0.sessionID != nil }
|
||||||
|
|
||||||
|
if let sessionID = firstEncryptedItem?.sessionID {
|
||||||
|
// Request for the first encrypted item to be decrypted as the SDK
|
||||||
|
// will continue to decrypt any following items automatically.
|
||||||
|
await timelineController.retryDecryption(for: sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func displayError(_ type: RoomScreenErrorType) {
|
private func displayError(_ type: RoomScreenErrorType) {
|
||||||
switch type {
|
switch type {
|
||||||
case .alert(let message):
|
case .alert(let message):
|
||||||
@@ -224,12 +244,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
actions.append(.redact)
|
actions.append(.redact)
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugActions: [TimelineItemContextMenuAction] = [.viewSource]
|
let debugActions: [TimelineItemContextMenuAction] = [.viewSource]
|
||||||
|
|
||||||
if let item = timelineItem as? EncryptedRoomTimelineItem,
|
|
||||||
case let .megolmV1AesSha2(sessionId) = item.encryptionType {
|
|
||||||
debugActions.append(.retryDecryption(sessionId: sessionId))
|
|
||||||
}
|
|
||||||
|
|
||||||
return .init(actions: actions, debugActions: debugActions)
|
return .init(actions: actions, debugActions: debugActions)
|
||||||
}
|
}
|
||||||
@@ -271,10 +286,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
let debugDescription = timelineController.debugDescription(for: item.id)
|
let debugDescription = timelineController.debugDescription(for: item.id)
|
||||||
MXLog.info(debugDescription)
|
MXLog.info(debugDescription)
|
||||||
state.bindings.debugInfo = .init(title: "Timeline item", content: debugDescription)
|
state.bindings.debugInfo = .init(title: "Timeline item", content: debugDescription)
|
||||||
case .retryDecryption(let sessionId):
|
|
||||||
Task {
|
|
||||||
await timelineController.retryDecryption(for: sessionId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if action.switchToDefaultComposer {
|
if action.switchToDefaultComposer {
|
||||||
@@ -291,3 +302,14 @@ extension RoomScreenViewModel {
|
|||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
roomName: "Preview room")
|
roomName: "Preview room")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension RoomTimelineViewProvider {
|
||||||
|
/// The item's session ID if it was unable to decrypt and uses megolm.
|
||||||
|
/// This will be nil for items that have already been decrypted.
|
||||||
|
var sessionID: String? {
|
||||||
|
guard case let .encrypted(item) = self,
|
||||||
|
case let .megolmV1AesSha2(sessionID) = item.encryptionType
|
||||||
|
else { return nil }
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ struct MessageComposer: View {
|
|||||||
@Binding var focused: Bool
|
@Binding var focused: Bool
|
||||||
let sendingDisabled: Bool
|
let sendingDisabled: Bool
|
||||||
let type: RoomScreenComposerMode
|
let type: RoomScreenComposerMode
|
||||||
|
@State private var isMultiline = false
|
||||||
|
|
||||||
let sendAction: () -> Void
|
let sendAction: () -> Void
|
||||||
let replyCancellationAction: () -> Void
|
let replyCancellationAction: () -> Void
|
||||||
@@ -34,10 +35,11 @@ struct MessageComposer: View {
|
|||||||
MessageComposerTextField(placeholder: ElementL10n.roomMessagePlaceholder,
|
MessageComposerTextField(placeholder: ElementL10n.roomMessagePlaceholder,
|
||||||
text: $text,
|
text: $text,
|
||||||
focused: $focused,
|
focused: $focused,
|
||||||
|
isMultiline: $isMultiline,
|
||||||
maxHeight: 300,
|
maxHeight: 300,
|
||||||
onEnterKeyHandler: sendAction)
|
onEnterKeyHandler: sendAction)
|
||||||
.tint(.element.brand)
|
.tint(.element.brand)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
sendAction()
|
sendAction()
|
||||||
@@ -55,7 +57,8 @@ struct MessageComposer: View {
|
|||||||
.disabled(sendingDisabled)
|
.disabled(sendingDisabled)
|
||||||
.animation(.elementDefault, value: sendingDisabled)
|
.animation(.elementDefault, value: sendingDisabled)
|
||||||
.keyboardShortcut(.return, modifiers: [.command])
|
.keyboardShortcut(.return, modifiers: [.command])
|
||||||
.padding(8)
|
.padding(.trailing, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading, 12.0)
|
.padding(.leading, 12.0)
|
||||||
@@ -67,8 +70,8 @@ struct MessageComposer: View {
|
|||||||
.stroke(Color.element.quinaryContent, lineWidth: 1)
|
.stroke(Color.element.quinaryContent, lineWidth: 1)
|
||||||
.opacity(focused ? 1 : 0)
|
.opacity(focused ? 1 : 0)
|
||||||
}
|
}
|
||||||
|
.animation(.easeOut(duration: 0.25), value: isMultiline)
|
||||||
}
|
}
|
||||||
.clipShape(roundedRectangle)
|
|
||||||
.animation(.elementDefault, value: type)
|
.animation(.elementDefault, value: type)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,9 +101,9 @@ struct MessageComposer: View {
|
|||||||
private var borderRadius: CGFloat {
|
private var borderRadius: CGFloat {
|
||||||
switch type {
|
switch type {
|
||||||
case .default:
|
case .default:
|
||||||
return 28.0
|
return isMultiline ? 8 : 28
|
||||||
case .reply, .edit:
|
case .reply, .edit:
|
||||||
return 12.0
|
return 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,29 +19,18 @@ import SwiftUI
|
|||||||
typealias OnEnterKeyHandler = () -> Void
|
typealias OnEnterKeyHandler = () -> Void
|
||||||
|
|
||||||
struct MessageComposerTextField: View {
|
struct MessageComposerTextField: View {
|
||||||
@Binding private var text: String
|
let placeholder: String
|
||||||
@Binding private var focused: Bool
|
@Binding var text: String
|
||||||
|
@Binding var focused: Bool
|
||||||
|
@Binding var isMultiline: Bool
|
||||||
|
|
||||||
private let placeholder: String
|
let maxHeight: CGFloat
|
||||||
private let maxHeight: CGFloat
|
let onEnterKeyHandler: OnEnterKeyHandler
|
||||||
private let onEnterKeyHandler: OnEnterKeyHandler
|
|
||||||
|
|
||||||
private var showingPlaceholder: Bool {
|
private var showingPlaceholder: Bool {
|
||||||
text.isEmpty
|
text.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
init(placeholder: String,
|
|
||||||
text: Binding<String>,
|
|
||||||
focused: Binding<Bool>,
|
|
||||||
maxHeight: CGFloat,
|
|
||||||
onEnterKeyHandler: @escaping OnEnterKeyHandler) {
|
|
||||||
self.placeholder = placeholder
|
|
||||||
_text = text
|
|
||||||
_focused = focused
|
|
||||||
self.maxHeight = maxHeight
|
|
||||||
self.onEnterKeyHandler = onEnterKeyHandler
|
|
||||||
}
|
|
||||||
|
|
||||||
private var placeholderColor: Color {
|
private var placeholderColor: Color {
|
||||||
.element.secondaryContent
|
.element.secondaryContent
|
||||||
}
|
}
|
||||||
@@ -49,6 +38,7 @@ struct MessageComposerTextField: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
UITextViewWrapper(text: $text,
|
UITextViewWrapper(text: $text,
|
||||||
focused: $focused,
|
focused: $focused,
|
||||||
|
isMultiline: $isMultiline,
|
||||||
maxHeight: maxHeight,
|
maxHeight: maxHeight,
|
||||||
onEnterKeyHandler: onEnterKeyHandler)
|
onEnterKeyHandler: onEnterKeyHandler)
|
||||||
.background(placeholderView, alignment: .topLeading)
|
.background(placeholderView, alignment: .topLeading)
|
||||||
@@ -68,18 +58,22 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
|
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
@Binding var focused: Bool
|
@Binding var focused: Bool
|
||||||
|
@Binding var isMultiline: Bool
|
||||||
|
|
||||||
let maxHeight: CGFloat
|
let maxHeight: CGFloat
|
||||||
|
|
||||||
let onEnterKeyHandler: OnEnterKeyHandler
|
let onEnterKeyHandler: OnEnterKeyHandler
|
||||||
|
|
||||||
|
private let font = UIFont.preferredFont(forTextStyle: .body)
|
||||||
|
|
||||||
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
|
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
|
||||||
let textView = TextViewWithKeyDetection()
|
let textView = TextViewWithKeyDetection()
|
||||||
|
textView.isMultiline = $isMultiline
|
||||||
textView.delegate = context.coordinator
|
textView.delegate = context.coordinator
|
||||||
textView.keyDelegate = context.coordinator
|
textView.keyDelegate = context.coordinator
|
||||||
textView.textColor = .element.primaryContent
|
textView.textColor = .element.primaryContent
|
||||||
textView.isEditable = true
|
textView.isEditable = true
|
||||||
textView.font = .preferredFont(forTextStyle: .body)
|
textView.font = font
|
||||||
textView.isSelectable = true
|
textView.isSelectable = true
|
||||||
textView.isUserInteractionEnabled = true
|
textView.isUserInteractionEnabled = true
|
||||||
textView.backgroundColor = UIColor.clear
|
textView.backgroundColor = UIColor.clear
|
||||||
@@ -99,6 +93,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
height: CGFloat.greatestFiniteMagnitude))
|
height: CGFloat.greatestFiniteMagnitude))
|
||||||
let width = proposal.width ?? newSize.width
|
let width = proposal.width ?? newSize.width
|
||||||
let height = min(maxHeight, newSize.height)
|
let height = min(maxHeight, newSize.height)
|
||||||
|
|
||||||
return CGSize(width: width, height: height)
|
return CGSize(width: width, height: height)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +135,10 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
|
|
||||||
private let onEnterKeyHandler: OnEnterKeyHandler
|
private let onEnterKeyHandler: OnEnterKeyHandler
|
||||||
|
|
||||||
init(text: Binding<String>, focused: Binding<Bool>, maxHeight: CGFloat, onEnterKeyHandler: @escaping OnEnterKeyHandler) {
|
init(text: Binding<String>,
|
||||||
|
focused: Binding<Bool>,
|
||||||
|
maxHeight: CGFloat,
|
||||||
|
onEnterKeyHandler: @escaping OnEnterKeyHandler) {
|
||||||
self.text = text
|
self.text = text
|
||||||
self.focused = focused
|
self.focused = focused
|
||||||
self.maxHeight = maxHeight
|
self.maxHeight = maxHeight
|
||||||
@@ -177,6 +175,8 @@ private protocol TextViewWithKeyDetectionDelegate: AnyObject {
|
|||||||
private class TextViewWithKeyDetection: UITextView {
|
private class TextViewWithKeyDetection: UITextView {
|
||||||
weak var keyDelegate: TextViewWithKeyDetectionDelegate?
|
weak var keyDelegate: TextViewWithKeyDetectionDelegate?
|
||||||
|
|
||||||
|
var isMultiline: Binding<Bool>?
|
||||||
|
|
||||||
override var keyCommands: [UIKeyCommand]? {
|
override var keyCommands: [UIKeyCommand]? {
|
||||||
[UIKeyCommand(input: "\r", modifierFlags: .shift, action: #selector(shiftEnterKeyPressed)),
|
[UIKeyCommand(input: "\r", modifierFlags: .shift, action: #selector(shiftEnterKeyPressed)),
|
||||||
UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(enterKeyPressed))]
|
UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(enterKeyPressed))]
|
||||||
@@ -189,6 +189,23 @@ private class TextViewWithKeyDetection: UITextView {
|
|||||||
@objc func enterKeyPressed(sender: UIKeyCommand) {
|
@objc func enterKeyPressed(sender: UIKeyCommand) {
|
||||||
keyDelegate?.enterKeyWasPressed(textView: self)
|
keyDelegate?.enterKeyWasPressed(textView: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
guard let isMultiline, let font else { return }
|
||||||
|
|
||||||
|
let numberOfLines = frame.height / font.lineHeight
|
||||||
|
if numberOfLines > 1.5 {
|
||||||
|
if !isMultiline.wrappedValue {
|
||||||
|
isMultiline.wrappedValue = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if isMultiline.wrappedValue {
|
||||||
|
isMultiline.wrappedValue = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MessageComposerTextField_Previews: PreviewProvider {
|
struct MessageComposerTextField_Previews: PreviewProvider {
|
||||||
@@ -203,14 +220,21 @@ struct MessageComposerTextField_Previews: PreviewProvider {
|
|||||||
struct PreviewWrapper: View {
|
struct PreviewWrapper: View {
|
||||||
@State var text: String
|
@State var text: String
|
||||||
@State var focused: Bool
|
@State var focused: Bool
|
||||||
|
@State var isMultiline: Bool
|
||||||
|
|
||||||
init(text: String) {
|
init(text: String) {
|
||||||
_text = .init(initialValue: text)
|
_text = .init(initialValue: text)
|
||||||
_focused = .init(initialValue: false)
|
_focused = .init(initialValue: false)
|
||||||
|
_isMultiline = .init(initialValue: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
MessageComposerTextField(placeholder: "Placeholder", text: $text, focused: $focused, maxHeight: 300, onEnterKeyHandler: { })
|
MessageComposerTextField(placeholder: "Placeholder",
|
||||||
|
text: $text,
|
||||||
|
focused: $focused,
|
||||||
|
isMultiline: $isMultiline,
|
||||||
|
maxHeight: 300,
|
||||||
|
onEnterKeyHandler: { })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ struct RoomScreen: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
timeline
|
timeline
|
||||||
.background(Color.element.background) // Kills the toolbar translucency.
|
.background(Color.element.background.ignoresSafeArea()) // Kills the toolbar translucency.
|
||||||
.safeAreaInset(edge: .bottom) { messageComposer }
|
.overlay(alignment: .top) { encryptionBanner } // Overlay for now, safeAreaInset breaks timeline scroll offset.
|
||||||
|
.animation(.spring(), value: context.viewState.showEncryptionBanner)
|
||||||
|
.safeAreaInset(edge: .bottom, spacing: 0) { messageComposer }
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
.toolbarRole(.editor) // Hide the back button title.
|
.toolbarRole(.editor) // Hide the back button title.
|
||||||
@@ -49,6 +51,44 @@ struct RoomScreen: View {
|
|||||||
.overlay(alignment: .bottomTrailing) { scrollToBottomButton }
|
.overlay(alignment: .bottomTrailing) { scrollToBottomButton }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var encryptionBanner: some View {
|
||||||
|
if context.viewState.showEncryptionBanner {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Label {
|
||||||
|
Text("Unable to decrypt all messages")
|
||||||
|
.foregroundColor(.element.primaryContent)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "lock.shield")
|
||||||
|
.foregroundColor(.element.background)
|
||||||
|
.padding(4)
|
||||||
|
.background(Color.element.tertiaryContent)
|
||||||
|
.cornerRadius(5)
|
||||||
|
}
|
||||||
|
.font(.element.bodyBold)
|
||||||
|
|
||||||
|
Text("Accessing your encrypted message history is not fully supported yet.")
|
||||||
|
.font(.element.subheadline)
|
||||||
|
.foregroundColor(.element.secondaryContent)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Button(ElementL10n.globalRetry) {
|
||||||
|
context.send(viewAction: .retryDecryption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.elementCapsuleProminent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.element.system)
|
||||||
|
}
|
||||||
|
.padding([.horizontal, .top], 16)
|
||||||
|
.background(Color.element.background)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var messageComposer: some View {
|
var messageComposer: some View {
|
||||||
MessageComposer(text: $context.composerText,
|
MessageComposer(text: $context.composerText,
|
||||||
focused: $context.composerFocused,
|
focused: $context.composerFocused,
|
||||||
@@ -110,7 +150,8 @@ struct RoomScreen_Previews: PreviewProvider {
|
|||||||
roomName: "Preview room")
|
roomName: "Preview room")
|
||||||
|
|
||||||
NavigationView {
|
NavigationView {
|
||||||
RoomScreen(context: viewModel.context)
|
RoomScreen(context: viewModel.context).encryptionBanner
|
||||||
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.bubbleStyle(inset: true,
|
.bubbleStyle(inset: true,
|
||||||
color: timelineItem.isOutgoing ? .element.systemGray5 : .element.systemGray6,
|
color: timelineItem.isOutgoing ? .element.bubblesYou : .element.bubblesNotYou,
|
||||||
cornerRadius: cornerRadius,
|
cornerRadius: cornerRadius,
|
||||||
corners: timelineItem.roundedCorners)
|
corners: timelineItem.roundedCorners)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,12 +51,16 @@ struct TimelineReactionButton: View {
|
|||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.background(
|
.background(
|
||||||
Capsule()
|
backgroundShape
|
||||||
.strokeBorder(reaction.isHighlighted ? Color.element.secondaryContent : .element.background, lineWidth: 2)
|
.strokeBorder(reaction.isHighlighted ? Color.element.secondaryContent : .element.background, lineWidth: 2)
|
||||||
.background(reaction.isHighlighted ? Color.element.accent.opacity(0.1) : .element.system, in: Capsule())
|
.background(reaction.isHighlighted ? Color.element.accent.opacity(0.1) : .element.system, in: backgroundShape)
|
||||||
)
|
)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var backgroundShape: some InsettableShape {
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TimelineReactionView_Previews: PreviewProvider {
|
struct TimelineReactionView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -21,23 +21,24 @@ struct EncryptedRoomTimelineView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineStyler(timelineItem: timelineItem) {
|
TimelineStyler(timelineItem: timelineItem) {
|
||||||
Label {
|
Label(timelineItem.text, systemImage: "lock.shield")
|
||||||
FormattedBodyText(text: timelineItem.text)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "lock.shield")
|
|
||||||
.foregroundColor(.element.secondaryContent)
|
|
||||||
}
|
|
||||||
.labelStyle(RoomTimelineViewLabelStyle())
|
.labelStyle(RoomTimelineViewLabelStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RoomTimelineViewLabelStyle: LabelStyle {
|
struct RoomTimelineViewLabelStyle: LabelStyle {
|
||||||
|
@Environment(\.timelineStyle) private var timelineStyle
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
HStack(spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
configuration.icon
|
configuration.icon
|
||||||
|
.foregroundColor(.element.secondaryContent)
|
||||||
configuration.title
|
configuration.title
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.element.primaryContent)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ struct FormattedBodyText: View {
|
|||||||
Text(component.attributedString.mergingAttributes(blockquoteAttributes))
|
Text(component.attributedString.mergingAttributes(blockquoteAttributes))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.foregroundColor(.element.tertiaryContent)
|
.foregroundColor(.element.tertiaryContent)
|
||||||
.lineLimit(3) // FIXME: Quotes vs replies
|
.lineLimit(component.isReply ? 3 : nil)
|
||||||
.padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
|
.padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
|
||||||
.clipped()
|
.clipped()
|
||||||
.background(Color.element.background)
|
.background(Color.element.background)
|
||||||
@@ -47,7 +47,7 @@ struct FormattedBodyText: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(component.attributedString)
|
Text(component.attributedString)
|
||||||
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0) // FIXME: Configurable
|
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.foregroundColor(.element.primaryContent)
|
.foregroundColor(.element.primaryContent)
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ struct FormattedBodyText: View {
|
|||||||
|
|
||||||
extension FormattedBodyText {
|
extension FormattedBodyText {
|
||||||
init(text: String) {
|
init(text: String) {
|
||||||
attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false)]
|
attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false, isReply: false)]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ struct ImageRoomTimelineView: View {
|
|||||||
TimelineStyler(timelineItem: timelineItem) {
|
TimelineStyler(timelineItem: timelineItem) {
|
||||||
LoadableImage(mediaSource: timelineItem.source,
|
LoadableImage(mediaSource: timelineItem.source,
|
||||||
blurhash: timelineItem.blurhash,
|
blurhash: timelineItem.blurhash,
|
||||||
imageProvider: context.imageProvider) {
|
imageProvider: context.imageProvider) { image in
|
||||||
|
image.overlay { overlay }
|
||||||
|
} placeholder: {
|
||||||
placeholder
|
placeholder
|
||||||
}
|
}
|
||||||
.frame(maxHeight: 300)
|
.frame(maxHeight: 300)
|
||||||
@@ -43,6 +45,20 @@ struct ImageRoomTimelineView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var overlay: some View {
|
||||||
|
if timelineItem.type == .gif {
|
||||||
|
Text(ElementL10n.roomTimelineImageGif)
|
||||||
|
.font(.element.bodyBold)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(.thinMaterial)
|
||||||
|
.cornerRadius(8)
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ImageRoomTimelineView_Previews: PreviewProvider {
|
struct ImageRoomTimelineView_Previews: PreviewProvider {
|
||||||
@@ -82,7 +98,8 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
|
|||||||
sender: .init(id: "Bob"),
|
sender: .init(id: "Bob"),
|
||||||
source: nil,
|
source: nil,
|
||||||
aspectRatio: 0.7,
|
aspectRatio: 0.7,
|
||||||
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
|
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW",
|
||||||
|
type: .gif))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,21 @@ struct NoticeRoomTimelineView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineStyler(timelineItem: timelineItem) {
|
TimelineStyler(timelineItem: timelineItem) {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
// Don't use RoomTimelineViewLabelStyle with FormattedBodyText as the formatted text
|
||||||
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
|
// adds additional padding so the spacing between the icon and text is inconsistent.
|
||||||
|
|
||||||
|
// Spacing: 6 = label spacing - formatted text padding
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Image(systemName: "info.bubble").padding(.top, 2.0)
|
||||||
|
.foregroundColor(.element.secondaryContent)
|
||||||
|
|
||||||
if let attributedComponents = timelineItem.attributedComponents {
|
if let attributedComponents = timelineItem.attributedComponents {
|
||||||
FormattedBodyText(attributedComponents: attributedComponents)
|
FormattedBodyText(attributedComponents: attributedComponents)
|
||||||
} else {
|
} else {
|
||||||
FormattedBodyText(text: timelineItem.text)
|
FormattedBodyText(text: timelineItem.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.leading, 4) // Trailing padding is provided by FormattedBodyText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,18 +21,47 @@ struct ReadMarkerRoomTimelineView: View {
|
|||||||
let timelineItem: ReadMarkerRoomTimelineItem
|
let timelineItem: ReadMarkerRoomTimelineItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
Spacer(minLength: 4.0)
|
Text(ElementL10n.roomTimelineReadMarkerTitle)
|
||||||
Divider()
|
.textCase(.uppercase)
|
||||||
.frame(maxWidth: .infinity)
|
.font(.element.caption2Bold)
|
||||||
.overlay(Color.element.accent)
|
.foregroundColor(.element.quaternaryContent)
|
||||||
|
Rectangle()
|
||||||
|
.frame(height: 0.5)
|
||||||
|
.foregroundColor(.element.quaternaryContent)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
|
struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
|
static let item = ReadMarkerRoomTimelineItem()
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let item = ReadMarkerRoomTimelineItem()
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
RoomTimelineViewProvider.separator(.init(text: "Today"))
|
||||||
|
RoomTimelineViewProvider.text(.init(id: "",
|
||||||
|
text: "This is another message",
|
||||||
|
timestamp: "",
|
||||||
|
groupState: .single,
|
||||||
|
isOutgoing: true,
|
||||||
|
isEditable: false,
|
||||||
|
sender: .init(id: "1", displayName: "Bob")))
|
||||||
|
|
||||||
ReadMarkerRoomTimelineView(timelineItem: item)
|
ReadMarkerRoomTimelineView(timelineItem: item)
|
||||||
|
|
||||||
|
RoomTimelineViewProvider.separator(.init(text: "Today"))
|
||||||
|
RoomTimelineViewProvider.text(.init(id: "",
|
||||||
|
text: "This is a message",
|
||||||
|
timestamp: "",
|
||||||
|
groupState: .single,
|
||||||
|
isOutgoing: false,
|
||||||
|
isEditable: false,
|
||||||
|
sender: .init(id: "", displayName: "Alice")))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ struct RedactedRoomTimelineView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineStyler(timelineItem: timelineItem) {
|
TimelineStyler(timelineItem: timelineItem) {
|
||||||
HStack {
|
Label(timelineItem.text, systemImage: "trash")
|
||||||
Image(systemName: "trash")
|
.labelStyle(RoomTimelineViewLabelStyle())
|
||||||
FormattedBodyText(text: timelineItem.text)
|
.imageScale(.small) // Smaller icon so that the bubble remains rounded on the outside.
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ struct StateRoomTimelineView: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(.element.secondaryContent)
|
.foregroundColor(.element.secondaryContent)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 36)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,17 +25,15 @@ struct UnsupportedRoomTimelineView: View {
|
|||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("\(timelineItem.text): \(timelineItem.eventType)")
|
Text("\(timelineItem.text): \(timelineItem.eventType)")
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.foregroundColor(.element.primaryContent)
|
|
||||||
|
|
||||||
Text(timelineItem.error)
|
Text(timelineItem.error)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.font(.element.footnote)
|
.font(.element.footnote)
|
||||||
.foregroundColor(.element.primaryContent)
|
|
||||||
}
|
}
|
||||||
} icon: {
|
} icon: {
|
||||||
Image(systemName: "exclamationmark.bubble")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
.foregroundColor(.element.secondaryContent)
|
|
||||||
}
|
}
|
||||||
|
.labelStyle(RoomTimelineViewLabelStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ enum TimelineItemContextMenuAction: Identifiable, Hashable {
|
|||||||
case redact
|
case redact
|
||||||
case reply
|
case reply
|
||||||
case viewSource
|
case viewSource
|
||||||
case retryDecryption(sessionId: String)
|
|
||||||
|
|
||||||
var id: Self { self }
|
var id: Self { self }
|
||||||
|
|
||||||
@@ -97,16 +96,12 @@ public struct TimelineItemContextMenu: View {
|
|||||||
}
|
}
|
||||||
case .redact:
|
case .redact:
|
||||||
Button(role: .destructive) { send(action) } label: {
|
Button(role: .destructive) { send(action) } label: {
|
||||||
Label(ElementL10n.messageActionItemRedact, systemImage: "trash")
|
Label(ElementL10n.actionRemove, systemImage: "trash")
|
||||||
}
|
}
|
||||||
case .viewSource:
|
case .viewSource:
|
||||||
Button { send(action) } label: {
|
Button { send(action) } label: {
|
||||||
Label(ElementL10n.viewSource, systemImage: "doc.text.below.ecg")
|
Label(ElementL10n.viewSource, systemImage: "doc.text.below.ecg")
|
||||||
}
|
}
|
||||||
case .retryDecryption:
|
|
||||||
Button { send(action) } label: {
|
|
||||||
Label(ElementL10n.roomTimelineContextMenuRetryDecryption, systemImage: "arrow.down.message")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ struct SessionVerificationScreen: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { toolbarContent }
|
.toolbar { toolbarContent }
|
||||||
}
|
}
|
||||||
.background(Color.element.background)
|
.background(Color.element.background.ignoresSafeArea())
|
||||||
.safeAreaInset(edge: .bottom) { actionButtons.padding() }
|
.safeAreaInset(edge: .bottom) { actionButtons.padding() }
|
||||||
}
|
}
|
||||||
.navigationViewStyle(.stack)
|
.navigationViewStyle(.stack)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ struct SettingsScreen: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(ElementL10n.settings)
|
.navigationTitle(ElementL10n.settings)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.background(backgroundColor, ignoresSafeAreaEdges: .all)
|
.background(backgroundColor.ignoresSafeArea())
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
doneButton
|
doneButton
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ protocol RoomProxyProtocol {
|
|||||||
|
|
||||||
var avatarURL: URL? { get }
|
var avatarURL: URL? { get }
|
||||||
|
|
||||||
|
var permalink: URL? { get }
|
||||||
|
|
||||||
func loadAvatarURLForUserId(_ userId: String) async -> Result<URL?, RoomProxyError>
|
func loadAvatarURLForUserId(_ userId: String) async -> Result<URL?, RoomProxyError>
|
||||||
|
|
||||||
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError>
|
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError>
|
||||||
@@ -75,6 +77,17 @@ protocol RoomProxyProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension RoomProxyProtocol {
|
extension RoomProxyProtocol {
|
||||||
|
var permalink: URL? {
|
||||||
|
if let canonicalAlias, let link = try? PermalinkBuilder.permalinkTo(roomAlias: canonicalAlias) {
|
||||||
|
return link
|
||||||
|
} else if let link = try? PermalinkBuilder.permalinkTo(roomIdentifier: id) {
|
||||||
|
return link
|
||||||
|
} else {
|
||||||
|
MXLog.error("Failed to build permalink for Room: \(id)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> {
|
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> {
|
||||||
await sendMessage(message, inReplyTo: nil)
|
await sendMessage(message, inReplyTo: nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ enum RoomTimelineItemFixtures {
|
|||||||
TextRoomTimelineItem(id: UUID().uuidString,
|
TextRoomTimelineItem(id: UUID().uuidString,
|
||||||
text: "",
|
text: "",
|
||||||
attributedComponents: [
|
attributedComponents: [
|
||||||
AttributedStringBuilderComponent(attributedString: "Hol' up", isBlockquote: false),
|
AttributedStringBuilderComponent(attributedString: "Hol' up", isBlockquote: false, isReply: false),
|
||||||
AttributedStringBuilderComponent(attributedString: "New home office set up!", isBlockquote: true),
|
AttributedStringBuilderComponent(attributedString: "New home office set up!", isBlockquote: true, isReply: false),
|
||||||
AttributedStringBuilderComponent(attributedString: "That's amazing! Congrats 🥳", isBlockquote: false)
|
AttributedStringBuilderComponent(attributedString: "That's amazing! Congrats 🥳", isBlockquote: false, isReply: false)
|
||||||
],
|
],
|
||||||
timestamp: "5 PM",
|
timestamp: "5 PM",
|
||||||
groupState: .single,
|
groupState: .single,
|
||||||
|
|||||||
@@ -218,11 +218,12 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
updateTimelineItems()
|
updateTimelineItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
// swiftlint:disable:next cyclomatic_complexity
|
// swiftlint:disable:next cyclomatic_complexity function_body_length
|
||||||
private func updateTimelineItems() {
|
private func updateTimelineItems() {
|
||||||
var newTimelineItems = [RoomTimelineItemProtocol]()
|
var newTimelineItems = [RoomTimelineItemProtocol]()
|
||||||
var canBackPaginate = true
|
var canBackPaginate = true
|
||||||
var isBackPaginating = false
|
var isBackPaginating = false
|
||||||
|
var hasEncryptedItems = false
|
||||||
|
|
||||||
var createdIdentifiers = [String: Bool]()
|
var createdIdentifiers = [String: Bool]()
|
||||||
|
|
||||||
@@ -243,6 +244,9 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
if createdIdentifiers[timelineItem.id] == nil {
|
if createdIdentifiers[timelineItem.id] == nil {
|
||||||
newTimelineItems.append(timelineItem)
|
newTimelineItems.append(timelineItem)
|
||||||
createdIdentifiers[timelineItem.id] = true
|
createdIdentifiers[timelineItem.id] = true
|
||||||
|
if timelineItem is EncryptedRoomTimelineItem {
|
||||||
|
hasEncryptedItems = true
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
MXLog.error("Found duplicated timeline item, ignoring")
|
MXLog.error("Found duplicated timeline item, ignoring")
|
||||||
}
|
}
|
||||||
@@ -280,6 +284,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
callbacks.send(.updatedTimelineItems)
|
callbacks.send(.updatedTimelineItems)
|
||||||
callbacks.send(.canBackPaginate(canBackPaginate))
|
callbacks.send(.canBackPaginate(canBackPaginate))
|
||||||
callbacks.send(.isBackPaginating(isBackPaginating))
|
callbacks.send(.isBackPaginating(isBackPaginating))
|
||||||
|
callbacks.send(.hasEncryptedItems(hasEncryptedItems))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func computeGroupState(for itemProxy: TimelineItemProxy,
|
private func computeGroupState(for itemProxy: TimelineItemProxy,
|
||||||
|
|||||||
+1
@@ -23,6 +23,7 @@ enum RoomTimelineControllerCallback {
|
|||||||
case updatedTimelineItem(_ itemId: String)
|
case updatedTimelineItem(_ itemId: String)
|
||||||
case canBackPaginate(Bool)
|
case canBackPaginate(Bool)
|
||||||
case isBackPaginating(Bool)
|
case isBackPaginating(Bool)
|
||||||
|
case hasEncryptedItems(Bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RoomTimelineControllerAction {
|
enum RoomTimelineControllerAction {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import Foundation
|
import Foundation
|
||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// A protocol that contains the base `m.room.message` event content properties.
|
/// A protocol that contains the base `m.room.message` event content properties.
|
||||||
/// The `CustomStringConvertible` conformance is to redact specific properties from the logs.
|
/// The `CustomStringConvertible` conformance is to redact specific properties from the logs.
|
||||||
@@ -95,6 +96,10 @@ extension MessageTimelineItem where Content == MatrixRustSDK.ImageMessageContent
|
|||||||
var blurhash: String? {
|
var blurhash: String? {
|
||||||
content.info?.blurhash
|
content.info?.blurhash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var type: UTType? {
|
||||||
|
content.info?.mimetype.flatMap { UTType(mimeType: $0) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MatrixRustSDK.VideoMessageContent: MessageContentProtocol { }
|
extension MatrixRustSDK.VideoMessageContent: MessageContentProtocol { }
|
||||||
|
|||||||
@@ -16,11 +16,14 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
#warning("This could be replaced by RoomMemberProxy if Rust includes a RoomMember.")
|
|
||||||
struct TimelineItemSender: Identifiable, Hashable {
|
struct TimelineItemSender: Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
|
let displayName: String?
|
||||||
|
let avatarURL: URL?
|
||||||
|
|
||||||
// Lazy loaded properties, displayName and avatarURL will be come lets.
|
init(id: String, displayName: String? = nil, avatarURL: URL? = nil) {
|
||||||
var displayName: String?
|
self.id = id
|
||||||
var avatarURL: URL?
|
self.displayName = displayName
|
||||||
|
self.avatarURL = avatarURL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol {
|
|||||||
var isOutgoing: Bool { get }
|
var isOutgoing: Bool { get }
|
||||||
var isEditable: Bool { get }
|
var isEditable: Bool { get }
|
||||||
|
|
||||||
var sender: TimelineItemSender { get set }
|
var sender: TimelineItemSender { get }
|
||||||
|
|
||||||
var properties: RoomTimelineItemProperties { get }
|
var properties: RoomTimelineItemProperties { get }
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
|
|||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
|
||||||
var sender: TimelineItemSender
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
var properties = RoomTimelineItemProperties()
|
var properties = RoomTimelineItemProperties()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha
|
|||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
|
||||||
var sender: TimelineItemSender
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
let source: MediaSourceProxy?
|
let source: MediaSourceProxy?
|
||||||
let thumbnailSource: MediaSourceProxy?
|
let thumbnailSource: MediaSourceProxy?
|
||||||
|
|||||||
+3
-1
@@ -15,6 +15,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable {
|
struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
@@ -24,7 +25,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
|
|||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
|
||||||
var sender: TimelineItemSender
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
let source: MediaSourceProxy?
|
let source: MediaSourceProxy?
|
||||||
var cachedFileURL: URL?
|
var cachedFileURL: URL?
|
||||||
@@ -33,6 +34,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
|
|||||||
var height: CGFloat?
|
var height: CGFloat?
|
||||||
var aspectRatio: CGFloat?
|
var aspectRatio: CGFloat?
|
||||||
var blurhash: String?
|
var blurhash: String?
|
||||||
|
var type: UTType?
|
||||||
|
|
||||||
var properties = RoomTimelineItemProperties()
|
var properties = RoomTimelineItemProperties()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Has
|
|||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
|
||||||
var sender: TimelineItemSender
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
var properties = RoomTimelineItemProperties()
|
var properties = RoomTimelineItemProperties()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha
|
|||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
|
||||||
var sender: TimelineItemSender
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
var properties = RoomTimelineItemProperties()
|
var properties = RoomTimelineItemProperties()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
|
|||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
|
||||||
var sender: TimelineItemSender
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
let duration: UInt64
|
let duration: UInt64
|
||||||
let source: MediaSourceProxy?
|
let source: MediaSourceProxy?
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable,
|
|||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
|
||||||
var sender: TimelineItemSender
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
var properties = RoomTimelineItemProperties()
|
var properties = RoomTimelineItemProperties()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, H
|
|||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
|
||||||
var sender: TimelineItemSender
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
var properties = RoomTimelineItemProperties()
|
var properties = RoomTimelineItemProperties()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ struct StateRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
|
|||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
|
||||||
var sender: TimelineItemSender
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
var properties = RoomTimelineItemProperties()
|
var properties = RoomTimelineItemProperties()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ struct StickerRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Ha
|
|||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
|
||||||
var sender: TimelineItemSender
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
let imageURL: URL
|
let imageURL: URL
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ struct UnsupportedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable
|
|||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
|
||||||
var sender: TimelineItemSender
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
var properties = RoomTimelineItemProperties()
|
var properties = RoomTimelineItemProperties()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
|||||||
height: message.height,
|
height: message.height,
|
||||||
aspectRatio: aspectRatio,
|
aspectRatio: aspectRatio,
|
||||||
blurhash: message.blurhash,
|
blurhash: message.blurhash,
|
||||||
|
type: message.type,
|
||||||
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
|
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
|
||||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||||
deliveryStatus: eventItemProxy.deliveryStatus))
|
deliveryStatus: eventItemProxy.deliveryStatus))
|
||||||
|
|||||||
@@ -314,6 +314,29 @@ class AttributedStringBuilderTests: XCTestCase {
|
|||||||
XCTAssertNotNil(foundBlockquoteAndLink, "Couldn't find blockquote or link")
|
XCTAssertNotNil(foundBlockquoteAndLink, "Couldn't find blockquote or link")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testReplyBlockquote() {
|
||||||
|
let htmlString = "<blockquote><a href=\"https://matrix.to/#/someroom/someevent\">In reply to</a> <a href=\"https://matrix.to/#/@user:matrix.org\">@user:matrix.org</a><br>The future is <code>swift run tools</code> 😎</blockquote>"
|
||||||
|
|
||||||
|
guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else {
|
||||||
|
XCTFail("Could not build the attributed string")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let coalescedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedString) else {
|
||||||
|
XCTFail("Could not build the attributed string components")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
XCTAssertEqual(coalescedComponents.count, 1)
|
||||||
|
|
||||||
|
guard let component = coalescedComponents.first else {
|
||||||
|
XCTFail("Could not get the first component")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(component.isBlockquote, "The reply quote should be a blockquote.")
|
||||||
|
XCTAssertTrue(component.isReply, "The reply quote should be detected as a reply")
|
||||||
|
}
|
||||||
|
|
||||||
func testMultipleGroupedBlockquotes() {
|
func testMultipleGroupedBlockquotes() {
|
||||||
let htmlString = """
|
let htmlString = """
|
||||||
<blockquote>First blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
|
<blockquote>First blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
|
||||||
@@ -355,7 +378,15 @@ class AttributedStringBuilderTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(attributedString.runs.count, 12)
|
XCTAssertEqual(attributedString.runs.count, 12)
|
||||||
|
|
||||||
XCTAssertEqual(attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedString)?.count, 6)
|
guard let coalescedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedString) else {
|
||||||
|
XCTFail("Could not build the attributed string components")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(coalescedComponents.count, 6)
|
||||||
|
for component in coalescedComponents where component.isReply {
|
||||||
|
XCTFail("None of the blockquotes should be detected as replies.")
|
||||||
|
}
|
||||||
|
|
||||||
var numberOfBlockquotes = 0
|
var numberOfBlockquotes = 0
|
||||||
for run in attributedString.runs where run.elementX.blockquote ?? false && run.link != nil {
|
for run in attributedString.runs where run.elementX.blockquote ?? false && run.link != nil {
|
||||||
|
|||||||
@@ -212,17 +212,20 @@ class LoggingTests: XCTestCase {
|
|||||||
let textAttributedString = "TextAttributed"
|
let textAttributedString = "TextAttributed"
|
||||||
let textMessage = TextRoomTimelineItem(id: "mytextmessage", text: "TextString",
|
let textMessage = TextRoomTimelineItem(id: "mytextmessage", text: "TextString",
|
||||||
attributedComponents: [.init(attributedString: AttributedString(textAttributedString),
|
attributedComponents: [.init(attributedString: AttributedString(textAttributedString),
|
||||||
isBlockquote: false)],
|
isBlockquote: false,
|
||||||
|
isReply: false)],
|
||||||
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
|
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
|
||||||
let noticeAttributedString = "NoticeAttributed"
|
let noticeAttributedString = "NoticeAttributed"
|
||||||
let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", text: "NoticeString",
|
let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", text: "NoticeString",
|
||||||
attributedComponents: [.init(attributedString: AttributedString(noticeAttributedString),
|
attributedComponents: [.init(attributedString: AttributedString(noticeAttributedString),
|
||||||
isBlockquote: false)],
|
isBlockquote: false,
|
||||||
|
isReply: false)],
|
||||||
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
|
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
|
||||||
let emoteAttributedString = "EmoteAttributed"
|
let emoteAttributedString = "EmoteAttributed"
|
||||||
let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", text: "EmoteString",
|
let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", text: "EmoteString",
|
||||||
attributedComponents: [.init(attributedString: AttributedString(emoteAttributedString),
|
attributedComponents: [.init(attributedString: AttributedString(emoteAttributedString),
|
||||||
isBlockquote: false)],
|
isBlockquote: false,
|
||||||
|
isReply: false)],
|
||||||
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
|
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
|
||||||
let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", text: "ImageString",
|
let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", text: "ImageString",
|
||||||
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false,
|
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
Update the designs for the timeline.
|
Finish the design review ready for a public TestFlight.
|
||||||
Reference in New Issue
Block a user