Files
letro-ios/ElementX/Sources/Screens/Timeline/TimelineModels.swift
Florian 5f724941ee Voice message: variable play back speed (#5121)
* Implement voice message variable playback speed

* Move playback speed string to Untranslated

https://github.com/element-hq/element-x-ios/pull/5121#discussion_r2822631371

* Address review feedback for voice message playback speed

- Persist voice message playback speed as an enum in AppSettings instead of storing an index.
- Update playback speed cycling to derive from enum allCases and safely fall back to `.default` if the stored value cannot be resolved.
- Apply runtime speed updates in AudioPlayer only when the player is in the `.playing` state.
- Keep MediaPlayerProviderTests formatting/indentation style intact while retaining mock playback speed setup.
- Regenerate preview snapshots for:
  - PlaybackSpeedButton
  - VoiceMessageRoomPlaybackView
  - VoiceMessageRoomTimelineView

* Move VoiceMessagePlaybackSpeed outside AppSettings, remove speedRatio

* Stabilize PlaybackSpeedButton width

* Sync voice-message speed label

- Add voiceMessagePlaybackSpeed to TimelineViewState and bind it from appSettings.$voiceMessagePlaybackSpeed.
- Pass that timeline-level speed into VoiceMessageRoomPlaybackView and use it for PlaybackSpeedButton, so labels update consistently across items.
- Use @EnvironmentObject in VoiceMessageRoomTimelineContent so the view re-renders when timeline context state changes.
- In WaveformInteractionModifier, add .allowsHitTesting(showCursor) to the cursor interaction view so hidden pre-playback cursor hit area does not steal taps from the speed button.
2026-02-19 22:40:31 +01:00

317 lines
12 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import MatrixRustSDK
import OrderedCollections
import SwiftUI
enum TimelineViewModelAction {
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
case displayCameraPicker
case displayMediaPicker
case displayDocumentPicker
case displayLocationPicker
case displayPollForm(mode: PollFormMode)
case displayMediaUploadPreviewScreen(mediaURLs: [URL])
case displaySenderDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayMediaPreview(TimelineMediaPreviewViewModel)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case displayThread(itemID: TimelineItemIdentifier)
case composer(action: TimelineComposerAction)
case hasScrolled(direction: ScrollDirection)
case viewInRoomTimeline(eventID: String, threadRootEventID: String?)
case displayRoom(roomID: String, via: [String])
case displayMediaDetails(item: EventBasedMessageTimelineItemProtocol)
}
enum TimelineViewPollAction {
case selectOption(pollStartID: String, optionID: String)
case end(pollStartID: String)
case edit(pollStartID: String, poll: Poll)
}
enum TimelineAudioPlayerAction {
case playPause(itemID: TimelineItemIdentifier)
case seek(itemID: TimelineItemIdentifier, progress: Double)
case changePlaybackSpeed(itemID: TimelineItemIdentifier)
}
enum TimelineViewAction {
case itemAppeared(itemID: TimelineItemIdentifier)
case itemDisappeared(itemID: TimelineItemIdentifier)
case mediaTapped(itemID: TimelineItemIdentifier)
case itemSendInfoTapped(itemID: TimelineItemIdentifier)
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
case paginateBackwards
case paginateForwards
case scrollToBottom
case displayTimelineItemMenu(itemID: TimelineItemIdentifier)
case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)
case tappedOnSenderDetails(sender: TimelineItemSender)
case displayReactionSummary(itemID: TimelineItemIdentifier, key: String)
case displayEmojiPicker(itemID: TimelineItemIdentifier)
case displayReadReceipts(itemID: TimelineItemIdentifier)
case displayThread(itemID: TimelineItemIdentifier)
case handlePasteOrDrop(providers: [NSItemProvider])
case handlePollAction(TimelineViewPollAction)
case handleAudioPlayerAction(TimelineAudioPlayerAction)
/// Focus the timeline onto the specified event ID (switching to a detached timeline if needed).
case focusOnEventID(String)
/// Switch back to a live timeline (from a detached one).
case focusLive
/// The timeline scrolled to reveal the focussed item.
case scrolledToFocussedItem
/// The table view has loaded the first items for a new timeline.
case hasSwitchedTimeline
case hasScrolled(direction: ScrollDirection)
case displayPredecessorRoom
}
enum TimelineComposerAction {
case setMode(mode: ComposerMode)
case setText(plainText: String, htmlText: String?)
case setFocus
case removeFocus
case clear
}
struct TimelineViewState: BindableState {
let timelineKind: TimelineKind
var roomID: String
var members: [String: RoomMemberState] = [:]
var typingMembers: [String] = []
var showLoading = false
var showReadReceipts = false
var isDirectOneToOneRoom = false
var timelineState: TimelineState // check the doc before changing this
var ownUserID: String
var canCurrentUserSendMessage = false
var canCurrentUserRedactOthers = false
var canCurrentUserRedactSelf = false
var canCurrentUserPin = false
var canCurrentUserKick = false
var canCurrentUserBan = false
var hideTimelineMedia: Bool
var isViewSourceEnabled: Bool
var areThreadsEnabled: Bool
var linkPreviewsEnabled: Bool
var voiceMessagePlaybackSpeed = VoiceMessagePlaybackSpeed.default.rawValue
let hasPredecessor: 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
var pinnedEventIDs: Set<String> = []
/// A closure providing the associated audio player state for an item in the timeline.
var audioPlayerStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> AudioPlayerState?)?
/// A closure that updates the associated pill context
var pillContextUpdater: (@MainActor (PillContext) -> Void)?
/// A closure that returns the associated room name give its id
var roomNameForIDResolver: (@MainActor (String) -> String?)?
/// A closure that returns the associated room name give its alias
var roomNameForAliasResolver: (@MainActor (String) -> String?)?
var emojiProvider: EmojiProviderProtocol
var linkMetadataProvider: LinkMetadataProviderProtocol?
var mapTilerConfiguration: MapTilerConfiguration
var enableKeyShareOnInvite: Bool
var bindings: TimelineViewStateBindings
}
struct TimelineViewStateBindings {
var isScrolledToBottom = true
/// The state of wether reactions listed on the timeline are expanded/collapsed.
/// Key is itemID, value is the collapsed state.
var reactionsCollapsed: [TimelineItemIdentifier: Bool]
var alertInfo: AlertInfo<TimelineAlertInfoType>?
var debugInfo: TimelineItemDebugInfo?
var actionMenuInfo: TimelineItemActionMenuInfo?
var reactionSummaryInfo: ReactionSummaryInfo?
var readReceiptsSummaryInfo: ReadReceiptSummaryInfo?
var manageMemberViewModel: ManageRoomMemberSheetViewModel?
var showTranslation = false
var textToBeTranslated: String?
}
struct TimelineItemActionMenuInfo: Equatable, Identifiable {
static func == (lhs: TimelineItemActionMenuInfo, rhs: TimelineItemActionMenuInfo) -> Bool {
lhs.id == rhs.id
}
let item: EventBasedTimelineItemProtocol
var id: TimelineItemIdentifier {
item.id
}
}
struct ReactionSummaryInfo: Identifiable {
let reactions: [AggregatedReaction]
let selectedKey: String
var id: String {
selectedKey
}
}
struct ReadReceiptSummaryInfo: Identifiable {
let orderedReceipts: [ReadReceipt]
let id: TimelineItemIdentifier
}
enum TimelineAlertInfoType: Hashable {
case audioRecodingPermissionError
case pollEndConfirmation(String)
case sendingFailed
case encryptionAuthenticity(String)
case encryptionForwarder(String)
case inviteAgain
case unableToInvite
case unknown
}
struct RoomMemberState {
let displayName: String?
let avatarURL: URL?
}
/// Used as the state for the TimelineView, to avoid having the context continuously refresh the list of items on each small change.
/// Is also nice to have this as a wrapper for any state that is directly connected to the timeline.
struct TimelineState {
var isLive = true
var paginationState = TimelinePaginationState.initial
/// The room is in the process of loading items from a new timeline (switching to/from a detached timeline).
var isSwitchingTimelines = false
struct FocussedEvent: Equatable {
enum Appearance {
/// The event should be shown using an animated scroll.
case animated
/// The event should be shown immediately, without any animation.
case immediate
/// The event has already been shown.
case hasAppeared
}
/// The ID of the event.
let eventID: String
/// How the event should be shown, or whether it has already appeared.
var appearance: Appearance
}
/// A focussed event that was navigated to via a permalink.
var focussedEvent: FocussedEvent?
/// These can be removed when we have full swiftUI and moved as @State values in the view
var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
var itemsDictionary = OrderedDictionary<TimelineItemIdentifier.UniqueID, RoomTimelineItemViewState>()
var uniqueIDs: [TimelineItemIdentifier.UniqueID] {
itemsDictionary.keys.elements
}
var itemViewStates: [RoomTimelineItemViewState] {
itemsDictionary.values.elements
}
func hasLoadedItem(with eventID: String) -> Bool {
itemViewStates.contains { $0.identifier.eventID == eventID }
}
}
enum ScrollDirection: Equatable {
case top
case bottom
}
extension TimelineViewState {
/// The string shown as the message preview.
///
/// This converts the formatted body to a plain string to remove formatting
/// and render with a consistent font size. This conversion is done to avoid
/// showing markdown characters in the preview for messages with formatting.
func buildMessagePreview(formattedBody: AttributedString?, plainBody: String) -> String {
guard let formattedBody,
let attributedString = try? NSMutableAttributedString(formattedBody, including: \.elementX) else {
return plainBody
}
let range = NSRange(location: 0, length: attributedString.length)
attributedString.enumerateAttributes(in: range) { attributes, range, _ in
if let userID = attributes[.MatrixUserID] as? String {
if let displayName = members[userID]?.displayName {
attributedString.replaceCharacters(in: range, with: "@\(displayName)")
} else {
attributedString.replaceCharacters(in: range, with: userID)
}
}
if attributes[.MatrixAllUsersMention] as? Bool == true {
attributedString.replaceCharacters(in: range, with: PillUtilities.atRoom)
}
if let roomAlias = attributes[.MatrixRoomAlias] as? String {
let roomName = roomNameForAliasResolver?(roomAlias)
attributedString.replaceCharacters(in: range, with: PillUtilities.roomPillDisplayText(roomName: roomName, rawRoomText: roomAlias))
}
if let roomID = attributes[.MatrixRoomID] as? String {
let roomName = roomNameForIDResolver?(roomID)
attributedString.replaceCharacters(in: range, with: PillUtilities.roomPillDisplayText(roomName: roomName, rawRoomText: roomID))
}
if let eventOnRoomID = attributes[.MatrixEventOnRoomID] as? EventOnRoomIDAttribute.Value {
let roomID = eventOnRoomID.roomID
let roomName = roomNameForIDResolver?(roomID)
attributedString.replaceCharacters(in: range, with: PillUtilities.eventPillDisplayText(roomName: roomName, rawRoomText: roomID))
}
if let eventOnRoomAlias = attributes[.MatrixEventOnRoomAlias] as? EventOnRoomAliasAttribute.Value {
let roomAlias = eventOnRoomAlias.alias
let roomName = roomNameForAliasResolver?(roomAlias)
attributedString.replaceCharacters(in: range, with: PillUtilities.eventPillDisplayText(roomName: roomName, rawRoomText: eventOnRoomAlias.alias))
}
}
return attributedString.string
}
}