mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Autoplay the next voice message if it is the next timeline item. (#6089)
This commit is contained in:
@@ -1475,6 +1475,7 @@
|
|||||||
EC1A0E85CEE50BF0C64EEFA5 /* LabsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D9DF4F2DF3507F99B5B97B /* LabsScreenViewModel.swift */; };
|
EC1A0E85CEE50BF0C64EEFA5 /* LabsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D9DF4F2DF3507F99B5B97B /* LabsScreenViewModel.swift */; };
|
||||||
EC3320639828BED8B3E5F2C6 /* EncryptionResetScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5875F7C0A2398E9F134B1284 /* EncryptionResetScreenViewModel.swift */; };
|
EC3320639828BED8B3E5F2C6 /* EncryptionResetScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5875F7C0A2398E9F134B1284 /* EncryptionResetScreenViewModel.swift */; };
|
||||||
EC5B6D717560B9E275A0EA2F /* landscape_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 028CAFDBB60E2DBB24C3F621 /* landscape_test_image.jpg */; };
|
EC5B6D717560B9E275A0EA2F /* landscape_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 028CAFDBB60E2DBB24C3F621 /* landscape_test_image.jpg */; };
|
||||||
|
ECE4E03D17E309724013F825 /* TimelineInteractionHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53C65A839BC897F6C236E8A5 /* TimelineInteractionHandlerTests.swift */; };
|
||||||
ED2BEDA7B074542A4D217ADF /* StaticLocationData.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFB21A2FF41CF0E118790DD /* StaticLocationData.swift */; };
|
ED2BEDA7B074542A4D217ADF /* StaticLocationData.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFB21A2FF41CF0E118790DD /* StaticLocationData.swift */; };
|
||||||
ED3E91E6166E4923791ACA84 /* ResolveVerifiedUserSendFailureScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56852036214ABA9D7D305768 /* ResolveVerifiedUserSendFailureScreenViewModelProtocol.swift */; };
|
ED3E91E6166E4923791ACA84 /* ResolveVerifiedUserSendFailureScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56852036214ABA9D7D305768 /* ResolveVerifiedUserSendFailureScreenViewModelProtocol.swift */; };
|
||||||
ED635D7F00FA07E94D3CE1E8 /* PreviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B796D347E53631576F631C /* PreviewTests.swift */; };
|
ED635D7F00FA07E94D3CE1E8 /* PreviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B796D347E53631576F631C /* PreviewTests.swift */; };
|
||||||
@@ -2238,6 +2239,7 @@
|
|||||||
53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewAdapter.swift; sourceTree = "<group>"; };
|
53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewAdapter.swift; sourceTree = "<group>"; };
|
||||||
5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedRoomTimelineItem.swift; sourceTree = "<group>"; };
|
5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||||
536E72DCBEEC4A1FE66CFDCE /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
|
536E72DCBEEC4A1FE66CFDCE /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
|
||||||
|
53C65A839BC897F6C236E8A5 /* TimelineInteractionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineInteractionHandlerTests.swift; sourceTree = "<group>"; };
|
||||||
53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModel.swift; sourceTree = "<group>"; };
|
53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModel.swift; sourceTree = "<group>"; };
|
||||||
5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenViewModelTests.swift; sourceTree = "<group>"; };
|
5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
|
5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
|
||||||
@@ -5276,6 +5278,7 @@
|
|||||||
18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */,
|
18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */,
|
||||||
6DF438EAFC732D2D95D34BF6 /* StartChatViewModelTests.swift */,
|
6DF438EAFC732D2D95D34BF6 /* StartChatViewModelTests.swift */,
|
||||||
2CEBCB9676FCD1D0F13188DD /* StringTests.swift */,
|
2CEBCB9676FCD1D0F13188DD /* StringTests.swift */,
|
||||||
|
53C65A839BC897F6C236E8A5 /* TimelineInteractionHandlerTests.swift */,
|
||||||
9AA3AF94A06D319BB37E52DA /* TimelineItemFactoryTests.swift */,
|
9AA3AF94A06D319BB37E52DA /* TimelineItemFactoryTests.swift */,
|
||||||
C98C243A90369929D669873D /* TimelineItemMenuActionProviderTests.swift */,
|
C98C243A90369929D669873D /* TimelineItemMenuActionProviderTests.swift */,
|
||||||
ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */,
|
ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */,
|
||||||
@@ -8374,6 +8377,7 @@
|
|||||||
6189B4ABD535CE526FA1107B /* StartChatViewModelTests.swift in Sources */,
|
6189B4ABD535CE526FA1107B /* StartChatViewModelTests.swift in Sources */,
|
||||||
1FEC0A4EC6E6DF693C16B32A /* StringTests.swift in Sources */,
|
1FEC0A4EC6E6DF693C16B32A /* StringTests.swift in Sources */,
|
||||||
E75CE800B3E64D0F7F8E228D /* TemplateScreenViewModelTests.swift in Sources */,
|
E75CE800B3E64D0F7F8E228D /* TemplateScreenViewModelTests.swift in Sources */,
|
||||||
|
ECE4E03D17E309724013F825 /* TimelineInteractionHandlerTests.swift in Sources */,
|
||||||
0D4EB2ABAA5FE8CB10FDBCB8 /* TimelineItemFactoryTests.swift in Sources */,
|
0D4EB2ABAA5FE8CB10FDBCB8 /* TimelineItemFactoryTests.swift in Sources */,
|
||||||
0668BECCF11E8EE6936CDF16 /* TimelineItemMenuActionProviderTests.swift in Sources */,
|
0668BECCF11E8EE6936CDF16 /* TimelineItemMenuActionProviderTests.swift in Sources */,
|
||||||
C02DE5F62C81FB9E173C3D2F /* TimelineMediaPreviewDataSourceTests.swift in Sources */,
|
C02DE5F62C81FB9E173C3D2F /* TimelineMediaPreviewDataSourceTests.swift in Sources */,
|
||||||
|
|||||||
@@ -8081,6 +8081,37 @@ nonisolated class MediaPlayerProviderMock: MediaPlayerProviderProtocol, @uncheck
|
|||||||
detachAllStatesExceptReceivedInvocationsLock.withLock { detachAllStatesExceptUnderlyingReceivedInvocations.append(exception) }
|
detachAllStatesExceptReceivedInvocationsLock.withLock { detachAllStatesExceptUnderlyingReceivedInvocations.append(exception) }
|
||||||
await detachAllStatesExceptClosure?(exception)
|
await detachAllStatesExceptClosure?(exception)
|
||||||
}
|
}
|
||||||
|
//MARK: - play
|
||||||
|
|
||||||
|
private let playSoundEffectCallsCountLock = NSLock()
|
||||||
|
private nonisolated(unsafe) var playSoundEffectUnderlyingCallsCount = 0
|
||||||
|
var playSoundEffectCallsCount: Int {
|
||||||
|
get { playSoundEffectCallsCountLock.withLock { playSoundEffectUnderlyingCallsCount } }
|
||||||
|
set { playSoundEffectCallsCountLock.withLock { playSoundEffectUnderlyingCallsCount = newValue } }
|
||||||
|
}
|
||||||
|
var playSoundEffectCalled: Bool {
|
||||||
|
return playSoundEffectCallsCount > 0
|
||||||
|
}
|
||||||
|
private let playSoundEffectReceivedSoundEffectLock = NSLock()
|
||||||
|
private nonisolated(unsafe) var playSoundEffectUnderlyingReceivedSoundEffect: SoundEffect?
|
||||||
|
var playSoundEffectReceivedSoundEffect: SoundEffect? {
|
||||||
|
get { playSoundEffectReceivedSoundEffectLock.withLock { playSoundEffectUnderlyingReceivedSoundEffect } }
|
||||||
|
set { playSoundEffectReceivedSoundEffectLock.withLock { playSoundEffectUnderlyingReceivedSoundEffect = newValue } }
|
||||||
|
}
|
||||||
|
private let playSoundEffectReceivedInvocationsLock = NSLock()
|
||||||
|
private nonisolated(unsafe) var playSoundEffectUnderlyingReceivedInvocations: [SoundEffect] = []
|
||||||
|
var playSoundEffectReceivedInvocations: [SoundEffect] {
|
||||||
|
get { playSoundEffectReceivedInvocationsLock.withLock { playSoundEffectUnderlyingReceivedInvocations } }
|
||||||
|
set { playSoundEffectReceivedInvocationsLock.withLock { playSoundEffectUnderlyingReceivedInvocations = newValue } }
|
||||||
|
}
|
||||||
|
nonisolated(unsafe) var playSoundEffectClosure: ((SoundEffect) -> Void)?
|
||||||
|
|
||||||
|
func play(soundEffect: SoundEffect) {
|
||||||
|
playSoundEffectCallsCountLock.withLock { playSoundEffectUnderlyingCallsCount += 1 }
|
||||||
|
playSoundEffectReceivedSoundEffect = soundEffect
|
||||||
|
playSoundEffectReceivedInvocationsLock.withLock { playSoundEffectUnderlyingReceivedInvocations.append(soundEffect) }
|
||||||
|
playSoundEffectClosure?(soundEffect)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
nonisolated class MediaProviderMock: MediaProviderProtocol, @unchecked Sendable {
|
nonisolated class MediaProviderMock: MediaProviderProtocol, @unchecked Sendable {
|
||||||
|
|
||||||
|
|||||||
@@ -64,4 +64,18 @@ nonisolated extension Array where Element == RoomTimelineItemProtocol {
|
|||||||
func firstUsingStableID(_ id: TimelineItemIdentifier) -> Element? {
|
func firstUsingStableID(_ id: TimelineItemIdentifier) -> Element? {
|
||||||
first { $0.id.uniqueID == id.uniqueID }
|
first { $0.id.uniqueID == id.uniqueID }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The voice message that directly follows the item with the given ID, if there is one.
|
||||||
|
///
|
||||||
|
/// Decorations such as date separators and the read marker are ignored, but any other
|
||||||
|
/// kind of item in between means that no voice message directly follows.
|
||||||
|
func voiceMessageDirectlyFollowing(_ id: TimelineItemIdentifier) -> VoiceMessageRoomTimelineItem? {
|
||||||
|
guard let index = firstIndex(where: { $0.id.uniqueID == id.uniqueID }) else { return nil }
|
||||||
|
|
||||||
|
for item in self[index...].dropFirst() where !(item is DecorationTimelineItemProtocol) {
|
||||||
|
return item as? VoiceMessageRoomTimelineItem
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,16 @@ nonisolated extension URL {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The directory holding the sounds that iOS uses for its own interface.
|
||||||
|
static let systemSoundsDirectory: URL = {
|
||||||
|
let systemRoot = if let simulatorRoot = ProcessInfo.processInfo.environment["SIMULATOR_ROOT"] {
|
||||||
|
URL(filePath: simulatorRoot)
|
||||||
|
} else {
|
||||||
|
URL(filePath: "/")
|
||||||
|
}
|
||||||
|
return systemRoot.appending(components: "System", "Library", "Audio", "UISounds", directoryHint: .isDirectory)
|
||||||
|
}()
|
||||||
|
|
||||||
var globalProxy: String? {
|
var globalProxy: String? {
|
||||||
guard let proxySettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() else {
|
guard let proxySettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() else {
|
||||||
MXLog.error("Failed retrieving proxy settings")
|
MXLog.error("Failed retrieving proxy settings")
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ class TimelineInteractionHandler {
|
|||||||
|
|
||||||
private var resumeVoiceMessagePlaybackAfterScrubbing = false
|
private var resumeVoiceMessagePlaybackAfterScrubbing = false
|
||||||
|
|
||||||
|
/// The voice message playback that was last asked for, and whether the player has begun it.
|
||||||
|
private enum VoiceMessagePlayback {
|
||||||
|
case requested(TimelineItemIdentifier)
|
||||||
|
case playing(TimelineItemIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var voiceMessagePlayback: VoiceMessagePlayback?
|
||||||
|
private var audioPlayerActionsCancellable: AnyCancellable?
|
||||||
|
|
||||||
private var emojiPickerCancellable: AnyCancellable?
|
private var emojiPickerCancellable: AnyCancellable?
|
||||||
|
|
||||||
init(roomProxy: JoinedRoomProxyProtocol,
|
init(roomProxy: JoinedRoomProxyProtocol,
|
||||||
@@ -449,8 +458,19 @@ class TimelineInteractionHandler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loading a new message tears the player down, which cancels any autoplay that was pending for the previous one.
|
||||||
|
voiceMessagePlayback = .requested(itemID)
|
||||||
|
|
||||||
let audioPlayer = mediaPlayerProvider.player
|
let audioPlayer = mediaPlayerProvider.player
|
||||||
|
|
||||||
|
// Observe the player lazily so that the mocks used by the previews and the UI tests don't need one.
|
||||||
|
if audioPlayerActionsCancellable == nil {
|
||||||
|
audioPlayerActionsCancellable = audioPlayer.actions
|
||||||
|
.sink { [weak self] action in
|
||||||
|
Task { await self?.handleAudioPlayerAction(action) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stop any recording in progress
|
// Stop any recording in progress
|
||||||
if voiceMessageRecorder.isRecording {
|
if voiceMessageRecorder.isRecording {
|
||||||
await voiceMessageRecorder.stopRecording()
|
await voiceMessageRecorder.stopRecording()
|
||||||
@@ -493,6 +513,36 @@ class TimelineInteractionHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleAudioPlayerAction(_ action: AudioPlayerAction) async {
|
||||||
|
switch action {
|
||||||
|
case .didStartPlaying:
|
||||||
|
if case .requested(let itemID) = voiceMessagePlayback {
|
||||||
|
voiceMessagePlayback = .playing(itemID)
|
||||||
|
}
|
||||||
|
case .didFinishPlaying:
|
||||||
|
// Anything else means the player was torn down to play something different
|
||||||
|
// rather than reaching the end of the message it had started.
|
||||||
|
guard case .playing(let finishedItemID) = voiceMessagePlayback else { return }
|
||||||
|
voiceMessagePlayback = nil
|
||||||
|
await autoplayVoiceMessage(following: finishedItemID)
|
||||||
|
case .didStartLoading, .didFinishLoading, .didPausePlaying, .didStopPlaying, .didFailWithError:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays the voice message directly following the given one.
|
||||||
|
private func autoplayVoiceMessage(following finishedItemID: TimelineItemIdentifier) async {
|
||||||
|
// The playback may have been taken over by another player state, such as the recorder's preview.
|
||||||
|
guard audioPlayerState(for: finishedItemID)?.isAttached == true,
|
||||||
|
let nextVoiceMessage = timelineController.timelineItems.voiceMessageDirectlyFollowing(finishedItemID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
MXLog.info("Autoplaying the voice message following itemID \(finishedItemID)")
|
||||||
|
mediaPlayerProvider.play(soundEffect: .tink)
|
||||||
|
await playPauseAudio(for: nextVoiceMessage.id)
|
||||||
|
}
|
||||||
|
|
||||||
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async {
|
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async {
|
||||||
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else {
|
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -263,15 +263,7 @@ nonisolated struct NotificationToneManager: NotificationToneManagerProtocol {
|
|||||||
filename: "sound_01.caf")
|
filename: "sound_01.caf")
|
||||||
].sorted()
|
].sorted()
|
||||||
|
|
||||||
private static let systemLocation = {
|
private static let systemLocation = URL.systemSoundsDirectory
|
||||||
let systemRoot: URL
|
|
||||||
if let simulatorRoot = ProcessInfo.processInfo.environment["SIMULATOR_ROOT"] {
|
|
||||||
systemRoot = URL(filePath: simulatorRoot)
|
|
||||||
} else {
|
|
||||||
systemRoot = URL(filePath: "/")
|
|
||||||
}
|
|
||||||
return systemRoot.appending(components: "System", "Library", "Audio", "UISounds", directoryHint: .isDirectory)
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let bundledLocation: URL = {
|
private static let bundledLocation: URL = {
|
||||||
guard let url = Bundle.app.resourceURL else {
|
guard let url = Bundle.app.resourceURL else {
|
||||||
|
|||||||
@@ -179,16 +179,19 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func addObservers() {
|
private func addObservers() {
|
||||||
guard let internalAudioPlayer, let playerItem else {
|
// Deliberately not shadowing the properties, the observers compare against them to detect stale updates.
|
||||||
|
guard let currentAudioPlayer = internalAudioPlayer, let currentItem = playerItem else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
statusObserver = playerItem.observe(\.status, options: [.old, .new]) { [weak self] item, _ in
|
statusObserver = currentItem.observe(\.status, options: [.old, .new]) { [weak self] item, _ in
|
||||||
// KVO fires on an arbitrary thread, hop to the main actor with the values we need.
|
// KVO fires on an arbitrary thread, hop to the main actor with the values we need.
|
||||||
let status = item.status
|
let status = item.status
|
||||||
let error = item.error
|
let error = item.error
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
// Ignore an update that was queued before the content was replaced, it would leave
|
||||||
|
// the state out of sync with the item that is loaded now.
|
||||||
|
guard let self, item === playerItem else { return }
|
||||||
|
|
||||||
switch status {
|
switch status {
|
||||||
case .failed:
|
case .failed:
|
||||||
@@ -202,11 +205,11 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rateObserver = internalAudioPlayer.observe(\.rate, options: [.old, .new]) { [weak self] player, _ in
|
rateObserver = currentAudioPlayer.observe(\.rate, options: [.old, .new]) { [weak self] player, _ in
|
||||||
// KVO fires on an arbitrary thread, hop to the main actor with the values we need.
|
// KVO fires on an arbitrary thread, hop to the main actor with the values we need.
|
||||||
let rate = player.rate
|
let rate = player.rate
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self, player === internalAudioPlayer else { return }
|
||||||
|
|
||||||
if rate == 0 {
|
if rate == 0 {
|
||||||
if isStopped {
|
if isStopped {
|
||||||
@@ -220,7 +223,9 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationCenter.default.publisher(for: Notification.Name.AVPlayerItemDidPlayToEndTime)
|
NotificationCenter.default.publisher(for: Notification.Name.AVPlayerItemDidPlayToEndTime, object: currentItem)
|
||||||
|
// The notification isn't posted on the main thread, unlike the state updates it triggers.
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
setInternalState(.finishedPlaying)
|
setInternalState(.finishedPlaying)
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ class AudioPlayerState: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
private func subscribeToAudioPlayer(audioPlayer: AudioPlayerProtocol) {
|
private func subscribeToAudioPlayer(audioPlayer: AudioPlayerProtocol) {
|
||||||
audioPlayerSubscription = audioPlayer.actions
|
audioPlayerSubscription = audioPlayer.actions
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] action in
|
.sink { [weak self] action in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,11 +6,14 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class MediaPlayerProvider: MediaPlayerProviderProtocol {
|
class MediaPlayerProvider: MediaPlayerProviderProtocol {
|
||||||
private lazy var audioPlayer = AudioPlayer()
|
private lazy var audioPlayer = AudioPlayer()
|
||||||
private var audioPlayerStates: [String: AudioPlayerState] = [:]
|
private var audioPlayerStates: [String: AudioPlayerState] = [:]
|
||||||
|
/// Held onto for the duration of the playback, an unowned player is silent.
|
||||||
|
private var soundEffectPlayer: AVAudioPlayer?
|
||||||
|
|
||||||
var player: AudioPlayerProtocol {
|
var player: AudioPlayerProtocol {
|
||||||
audioPlayer
|
audioPlayer
|
||||||
@@ -55,6 +58,17 @@ class MediaPlayerProvider: MediaPlayerProviderProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Doesn't configure an audio session, so that it can be used alongside any existing player or session.
|
||||||
|
func play(soundEffect: SoundEffect) {
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(contentsOf: soundEffect.fileURL)
|
||||||
|
soundEffectPlayer = player
|
||||||
|
player.play()
|
||||||
|
} catch {
|
||||||
|
MXLog.error("Failed playing the sound effect: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private func audioPlayerStateID(for identifier: AudioPlayerStateIdentifier) -> String? {
|
private func audioPlayerStateID(for identifier: AudioPlayerStateIdentifier) -> String? {
|
||||||
|
|||||||
@@ -8,6 +8,17 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
enum SoundEffect {
|
||||||
|
/// A short, light blip.
|
||||||
|
case tink
|
||||||
|
|
||||||
|
var fileURL: URL {
|
||||||
|
switch self {
|
||||||
|
case .tink: URL.systemSoundsDirectory.appending(component: "Tink.caf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protocol MediaPlayerProviderProtocol {
|
protocol MediaPlayerProviderProtocol {
|
||||||
var player: AudioPlayerProtocol { get }
|
var player: AudioPlayerProtocol { get }
|
||||||
|
|
||||||
@@ -15,6 +26,10 @@ protocol MediaPlayerProviderProtocol {
|
|||||||
func register(audioPlayerState: AudioPlayerState)
|
func register(audioPlayerState: AudioPlayerState)
|
||||||
func unregister(audioPlayerState: AudioPlayerState)
|
func unregister(audioPlayerState: AudioPlayerState)
|
||||||
func detachAllStates(except exception: AudioPlayerState?) async
|
func detachAllStates(except exception: AudioPlayerState?) async
|
||||||
|
|
||||||
|
/// Plays a short sound on top of whatever is already playing. It goes through the app's own
|
||||||
|
/// audio session, so unlike the sounds built into iOS it is heard in silent mode too.
|
||||||
|
func play(soundEffect: SoundEffect)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourcery: AutoMockable
|
// sourcery: AutoMockable
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct ArrayTests {
|
struct ArrayTests {
|
||||||
@Test
|
@Test
|
||||||
func grouping() {
|
func grouping() {
|
||||||
@@ -33,4 +34,84 @@ struct ArrayTests {
|
|||||||
|
|
||||||
#expect([0, 0, 0, 1, 0, 2, 3, 0, 0].groupBy { $0 == 0 } == [[0, 0, 0], [1], [0], [2], [3], [0, 0]])
|
#expect([0, 0, 0, 1, 0, 2, 3, 0, 0].groupBy { $0 == 0 } == [[0, 0, 0], [1], [0], [2], [3], [0, 0]])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice message autoplay
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func voiceMessageFollowsVoiceMessage() {
|
||||||
|
let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"),
|
||||||
|
makeVoiceMessage(uniqueID: "voice2")]
|
||||||
|
|
||||||
|
#expect(items.voiceMessageDirectlyFollowing(items[0].id)?.id.uniqueID == .init("voice2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func decorationsAreIgnored() {
|
||||||
|
let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"),
|
||||||
|
SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init("separator")), timestamp: .mock),
|
||||||
|
ReadMarkerRoomTimelineItem(id: .virtual(uniqueID: .init("readMarker"))),
|
||||||
|
makeVoiceMessage(uniqueID: "voice2")]
|
||||||
|
|
||||||
|
#expect(items.voiceMessageDirectlyFollowing(items[0].id)?.id.uniqueID == .init("voice2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func otherMessagesBreakTheChain() {
|
||||||
|
let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"),
|
||||||
|
makeTextMessage(uniqueID: "text"),
|
||||||
|
makeVoiceMessage(uniqueID: "voice2")]
|
||||||
|
|
||||||
|
#expect(items.voiceMessageDirectlyFollowing(items[0].id) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func lastItemHasNoFollowingVoiceMessage() {
|
||||||
|
let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"),
|
||||||
|
SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init("separator")), timestamp: .mock)]
|
||||||
|
|
||||||
|
#expect(items.voiceMessageDirectlyFollowing(items[0].id) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func precedingVoiceMessageIsIgnored() {
|
||||||
|
let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"),
|
||||||
|
makeVoiceMessage(uniqueID: "voice2")]
|
||||||
|
|
||||||
|
#expect(items.voiceMessageDirectlyFollowing(items[1].id) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unknownItemHasNoFollowingVoiceMessage() {
|
||||||
|
let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"),
|
||||||
|
makeVoiceMessage(uniqueID: "voice2")]
|
||||||
|
|
||||||
|
#expect(items.voiceMessageDirectlyFollowing(.virtual(uniqueID: .init("unknown"))) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func makeVoiceMessage(uniqueID: String) -> VoiceMessageRoomTimelineItem {
|
||||||
|
VoiceMessageRoomTimelineItem(id: .virtual(uniqueID: .init(uniqueID)),
|
||||||
|
timestamp: .mock,
|
||||||
|
isOutgoing: false,
|
||||||
|
isEditable: false,
|
||||||
|
canBeRepliedTo: true,
|
||||||
|
sender: .init(id: "@sender:example.com"),
|
||||||
|
content: .init(filename: "audio.ogg",
|
||||||
|
duration: 10,
|
||||||
|
waveform: .mockWaveform,
|
||||||
|
source: try? MediaSourceProxy(url: .mockMXCAudio, mimeType: nil),
|
||||||
|
fileSize: nil,
|
||||||
|
contentType: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTextMessage(uniqueID: String) -> TextRoomTimelineItem {
|
||||||
|
TextRoomTimelineItem(id: .virtual(uniqueID: .init(uniqueID)),
|
||||||
|
timestamp: .mock,
|
||||||
|
isOutgoing: false,
|
||||||
|
isEditable: false,
|
||||||
|
canBeRepliedTo: true,
|
||||||
|
sender: .init(id: "@sender:example.com"),
|
||||||
|
content: .init(body: "Test message"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,23 @@ struct AudioPlayerStateTests {
|
|||||||
#expect(!audioPlayerState.showProgressIndicator)
|
#expect(!audioPlayerState.showProgressIndicator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
mutating func audioPlayerActionsDidFinishPlayingWhenDetachedStraightAway() async throws {
|
||||||
|
await audioPlayerState.updateState(progress: 0.4)
|
||||||
|
audioPlayerState.attachAudioPlayer(audioPlayerMock)
|
||||||
|
|
||||||
|
let deferred = deferFulfillment(audioPlayerState.$progress) { $0 == 0.0 }
|
||||||
|
|
||||||
|
// Detaching before the action has been handled is what happens when the following
|
||||||
|
// voice message takes the player over as soon as this one reaches its end.
|
||||||
|
audioPlayerActionsSubject.send(.didFinishPlaying)
|
||||||
|
audioPlayerState.detachAudioPlayer()
|
||||||
|
|
||||||
|
try await deferred.fulfill()
|
||||||
|
#expect(audioPlayerState.progress == 0.0)
|
||||||
|
#expect(!audioPlayerState.showProgressIndicator)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func setPlaybackSpeed() {
|
func setPlaybackSpeed() {
|
||||||
audioPlayerState.attachAudioPlayer(audioPlayerMock)
|
audioPlayerState.attachAudioPlayer(audioPlayerMock)
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//
|
||||||
|
// Copyright 2026 Element Creations 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
|
||||||
|
@testable import ElementX
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct TimelineInteractionHandlerTests {
|
||||||
|
private static let firstSourceURL = URL("mxc://matrix.org/voice1")
|
||||||
|
private static let secondSourceURL = URL("mxc://matrix.org/voice2")
|
||||||
|
|
||||||
|
private let audioPlayer: AudioPlayerMock
|
||||||
|
private let mediaPlayerProvider: MediaPlayerProviderMock
|
||||||
|
private let actionsSubject: PassthroughSubject<AudioPlayerAction, Never>
|
||||||
|
private let loadedSourceURLsSubject: PassthroughSubject<URL, Never>
|
||||||
|
|
||||||
|
private static let firstVoiceMessage = makeVoiceMessage(id: "voice1", sourceURL: firstSourceURL)
|
||||||
|
private static let secondVoiceMessage = makeVoiceMessage(id: "voice2", sourceURL: secondSourceURL)
|
||||||
|
private static let textMessage = TextRoomTimelineItem(id: .event(uniqueID: .init("text"), eventOrTransactionID: .eventID("text")),
|
||||||
|
timestamp: .mock,
|
||||||
|
isOutgoing: false,
|
||||||
|
isEditable: false,
|
||||||
|
canBeRepliedTo: true,
|
||||||
|
sender: .init(id: "@sender:example.com"),
|
||||||
|
content: .init(body: "Test message"))
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let player = AudioPlayerMock()
|
||||||
|
let actionsSubject = PassthroughSubject<AudioPlayerAction, Never>()
|
||||||
|
let loadedSourceURLsSubject = PassthroughSubject<URL, Never>()
|
||||||
|
|
||||||
|
player.actions = actionsSubject.eraseToAnyPublisher()
|
||||||
|
player.state = .stopped
|
||||||
|
player.currentTime = 0
|
||||||
|
player.duration = 10
|
||||||
|
player.loadSourceURLPlaybackURLAutoplayClosure = { sourceURL, _, _ in
|
||||||
|
player.sourceURL = sourceURL
|
||||||
|
player.state = .playing
|
||||||
|
loadedSourceURLsSubject.send(sourceURL)
|
||||||
|
}
|
||||||
|
player.playClosure = { player.state = .playing }
|
||||||
|
player.pauseClosure = { player.state = .paused }
|
||||||
|
player.stopClosure = { player.state = .stopped }
|
||||||
|
|
||||||
|
let states = [Self.firstVoiceMessage, Self.secondVoiceMessage]
|
||||||
|
.reduce(into: [TimelineItemIdentifier.UniqueID: AudioPlayerState]()) { states, item in
|
||||||
|
states[item.id.uniqueID] = AudioPlayerState(id: .timelineItemIdentifier(item.id),
|
||||||
|
title: L10n.commonVoiceMessage,
|
||||||
|
duration: item.content.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hands out one stable player state per voice message, as the real provider does.
|
||||||
|
let mediaPlayerProvider = MediaPlayerProviderMock()
|
||||||
|
mediaPlayerProvider.player = player
|
||||||
|
mediaPlayerProvider.playerStateForClosure = { identifier in
|
||||||
|
guard case .timelineItemIdentifier(let itemID) = identifier else { return nil }
|
||||||
|
return states[itemID.uniqueID]
|
||||||
|
}
|
||||||
|
|
||||||
|
audioPlayer = player
|
||||||
|
self.mediaPlayerProvider = mediaPlayerProvider
|
||||||
|
self.actionsSubject = actionsSubject
|
||||||
|
self.loadedSourceURLsSubject = loadedSourceURLsSubject
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice message autoplay
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func autoplaysTheFollowingVoiceMessage() async throws {
|
||||||
|
let handler = makeHandler(timelineItems: [Self.firstVoiceMessage, Self.secondVoiceMessage])
|
||||||
|
|
||||||
|
await handler.playPauseAudio(for: Self.firstVoiceMessage.id)
|
||||||
|
#expect(loadedSourceURLs == [Self.firstSourceURL])
|
||||||
|
|
||||||
|
let deferred = deferFulfillment(loadedSourceURLsSubject) { $0 == Self.secondSourceURL }
|
||||||
|
actionsSubject.send(.didStartPlaying)
|
||||||
|
actionsSubject.send(.didFinishPlaying)
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
#expect(loadedSourceURLs == [Self.firstSourceURL, Self.secondSourceURL])
|
||||||
|
#expect(mediaPlayerProvider.playSoundEffectReceivedSoundEffect == .tink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func doesNotAutoplayAcrossAnotherMessage() async {
|
||||||
|
let handler = makeHandler(timelineItems: [Self.firstVoiceMessage, Self.textMessage, Self.secondVoiceMessage])
|
||||||
|
|
||||||
|
await handler.playPauseAudio(for: Self.firstVoiceMessage.id)
|
||||||
|
actionsSubject.send(.didStartPlaying)
|
||||||
|
actionsSubject.send(.didFinishPlaying)
|
||||||
|
await allowPlayerActionsToBeHandled()
|
||||||
|
|
||||||
|
#expect(loadedSourceURLs == [Self.firstSourceURL])
|
||||||
|
#expect(!mediaPlayerProvider.playSoundEffectCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func doesNotAutoplayAfterTheLastVoiceMessage() async {
|
||||||
|
let handler = makeHandler(timelineItems: [Self.firstVoiceMessage])
|
||||||
|
|
||||||
|
await handler.playPauseAudio(for: Self.firstVoiceMessage.id)
|
||||||
|
actionsSubject.send(.didStartPlaying)
|
||||||
|
actionsSubject.send(.didFinishPlaying)
|
||||||
|
await allowPlayerActionsToBeHandled()
|
||||||
|
|
||||||
|
#expect(loadedSourceURLs == [Self.firstSourceURL])
|
||||||
|
#expect(!mediaPlayerProvider.playSoundEffectCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func doesNotAutoplayAfterPausing() async {
|
||||||
|
let handler = makeHandler(timelineItems: [Self.firstVoiceMessage, Self.secondVoiceMessage])
|
||||||
|
|
||||||
|
await handler.playPauseAudio(for: Self.firstVoiceMessage.id)
|
||||||
|
actionsSubject.send(.didStartPlaying)
|
||||||
|
await allowPlayerActionsToBeHandled()
|
||||||
|
|
||||||
|
await handler.playPauseAudio(for: Self.firstVoiceMessage.id)
|
||||||
|
#expect(audioPlayer.pauseCalled)
|
||||||
|
|
||||||
|
actionsSubject.send(.didFinishPlaying)
|
||||||
|
await allowPlayerActionsToBeHandled()
|
||||||
|
|
||||||
|
#expect(loadedSourceURLs == [Self.firstSourceURL])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func replayingAVoiceMessageDoesNotJumpToTheFollowingOne() async throws {
|
||||||
|
let handler = makeHandler(timelineItems: [Self.firstVoiceMessage, Self.secondVoiceMessage])
|
||||||
|
|
||||||
|
// Play the first message all the way through so that the second one takes over.
|
||||||
|
await handler.playPauseAudio(for: Self.firstVoiceMessage.id)
|
||||||
|
let autoplay = deferFulfillment(loadedSourceURLsSubject) { $0 == Self.secondSourceURL }
|
||||||
|
actionsSubject.send(.didStartPlaying)
|
||||||
|
actionsSubject.send(.didFinishPlaying)
|
||||||
|
try await autoplay.fulfill()
|
||||||
|
actionsSubject.send(.didStartPlaying)
|
||||||
|
await allowPlayerActionsToBeHandled()
|
||||||
|
|
||||||
|
// Loading tears the player down, which is where the spurious end of playback came from.
|
||||||
|
audioPlayer.loadSourceURLPlaybackURLAutoplayClosure = { [audioPlayer, actionsSubject, loadedSourceURLsSubject] sourceURL, _, _ in
|
||||||
|
actionsSubject.send(.didFinishPlaying)
|
||||||
|
audioPlayer.sourceURL = sourceURL
|
||||||
|
audioPlayer.state = .playing
|
||||||
|
loadedSourceURLsSubject.send(sourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
let replay = deferFulfillment(loadedSourceURLsSubject) { $0 == Self.firstSourceURL }
|
||||||
|
await handler.playPauseAudio(for: Self.firstVoiceMessage.id)
|
||||||
|
try await replay.fulfill()
|
||||||
|
await allowPlayerActionsToBeHandled()
|
||||||
|
|
||||||
|
#expect(loadedSourceURLs == [Self.firstSourceURL, Self.secondSourceURL, Self.firstSourceURL])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var loadedSourceURLs: [URL] {
|
||||||
|
audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedInvocations.map(\.sourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The handler reacts to the player's actions in detached tasks, this gives them a chance to run.
|
||||||
|
private func allowPlayerActionsToBeHandled() async {
|
||||||
|
for _ in 0..<10 {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeHandler(timelineItems: [RoomTimelineItemProtocol]) -> TimelineInteractionHandler {
|
||||||
|
let voiceMessageRecorder = VoiceMessageRecorderMock()
|
||||||
|
voiceMessageRecorder.isRecording = false
|
||||||
|
|
||||||
|
let voiceMessageMediaManager = VoiceMessageMediaManagerMock()
|
||||||
|
voiceMessageMediaManager.loadVoiceMessageFromSourceBodyReturnValue = URL("file:///voice-message.m4a")
|
||||||
|
|
||||||
|
let userSession = UserSessionMock(.init())
|
||||||
|
userSession.voiceMessageMediaManager = voiceMessageMediaManager
|
||||||
|
|
||||||
|
let appSettings = AppSettings.volatile()
|
||||||
|
|
||||||
|
return TimelineInteractionHandler(roomProxy: JoinedRoomProxyMock(.init()),
|
||||||
|
timelineController: TimelineControllerMock(.init(timelineItems: timelineItems)),
|
||||||
|
userSession: userSession,
|
||||||
|
mediaPlayerProvider: mediaPlayerProvider,
|
||||||
|
voiceMessageRecorder: voiceMessageRecorder,
|
||||||
|
userIndicatorController: UserIndicatorControllerMock(),
|
||||||
|
appMediator: AppMediatorMock(.init()),
|
||||||
|
appSettings: appSettings,
|
||||||
|
analyticsService: AnalyticsServiceMock(.init()),
|
||||||
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
||||||
|
linkMetadataProvider: LinkMetadataProvider(),
|
||||||
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeVoiceMessage(id: String, sourceURL: URL) -> VoiceMessageRoomTimelineItem {
|
||||||
|
VoiceMessageRoomTimelineItem(id: .event(uniqueID: .init(id), eventOrTransactionID: .eventID(id)),
|
||||||
|
timestamp: .mock,
|
||||||
|
isOutgoing: false,
|
||||||
|
isEditable: false,
|
||||||
|
canBeRepliedTo: true,
|
||||||
|
sender: .init(id: "@sender:example.com"),
|
||||||
|
content: .init(filename: "audio.ogg",
|
||||||
|
duration: 10,
|
||||||
|
waveform: .mockWaveform,
|
||||||
|
source: try? MediaSourceProxy(url: sourceURL, mimeType: "audio/ogg"),
|
||||||
|
fileSize: nil,
|
||||||
|
contentType: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user