Merge branch 'develop' into feature/bma/fixSendQueueCrash
This commit is contained in:
commit
0261739fff
281 changed files with 1877 additions and 998 deletions
|
|
@ -147,9 +147,21 @@ interface MatrixRoom : Closeable {
|
|||
progressCallback: ProgressCallback?
|
||||
): Result<MediaUploadHandler>
|
||||
|
||||
suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
|
||||
suspend fun sendAudio(
|
||||
file: File,
|
||||
audioInfo: AudioInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler>
|
||||
|
||||
suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
|
||||
suspend fun sendFile(
|
||||
file: File,
|
||||
fileInfo: FileInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler>
|
||||
|
||||
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit>
|
||||
|
||||
|
|
|
|||
|
|
@ -59,10 +59,17 @@ interface Timeline : AutoCloseable {
|
|||
|
||||
suspend fun editMessage(
|
||||
eventOrTransactionId: EventOrTransactionId,
|
||||
body: String, htmlBody: String?,
|
||||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
): Result<Unit>
|
||||
|
||||
suspend fun editCaption(
|
||||
eventOrTransactionId: EventOrTransactionId,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
): Result<Unit>
|
||||
|
||||
suspend fun replyMessage(
|
||||
eventId: EventId,
|
||||
body: String,
|
||||
|
|
@ -91,9 +98,21 @@ interface Timeline : AutoCloseable {
|
|||
|
||||
suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit>
|
||||
|
||||
suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
|
||||
suspend fun sendAudio(
|
||||
file: File,
|
||||
audioInfo: AudioInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler>
|
||||
|
||||
suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
|
||||
suspend fun sendFile(
|
||||
file: File,
|
||||
fileInfo: FileInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler>
|
||||
|
||||
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit>
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ enum class Target(open val filter: String) {
|
|||
MATRIX_SDK_HTTP_CLIENT("matrix_sdk::http_client"),
|
||||
MATRIX_SDK_CLIENT("matrix_sdk::client"),
|
||||
MATRIX_SDK_OIDC("matrix_sdk::oidc"),
|
||||
MATRIX_SDK_SEND_QUEUE("matrix_sdk::send_queue"),
|
||||
MATRIX_SDK_SLIDING_SYNC("matrix_sdk::sliding_sync"),
|
||||
MATRIX_SDK_BASE_SLIDING_SYNC("matrix_sdk_base::sliding_sync"),
|
||||
MATRIX_SDK_UI_TIMELINE("matrix_sdk_ui::timeline"),
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ class RustMatrixClient(
|
|||
sessionCoroutineScope = sessionCoroutineScope,
|
||||
)
|
||||
|
||||
private val roomMembershipObserver = RoomMembershipObserver()
|
||||
private val roomFactory = RustRoomFactory(
|
||||
roomListService = roomListService,
|
||||
innerRoomListService = innerRoomListService,
|
||||
|
|
@ -193,6 +194,7 @@ class RustMatrixClient(
|
|||
roomSyncSubscriber = roomSyncSubscriber,
|
||||
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
|
||||
featureFlagService = featureFlagService,
|
||||
roomMembershipObserver = roomMembershipObserver,
|
||||
)
|
||||
|
||||
override val mediaLoader: MatrixMediaLoader = RustMediaLoader(
|
||||
|
|
@ -201,8 +203,6 @@ class RustMatrixClient(
|
|||
innerClient = innerClient,
|
||||
)
|
||||
|
||||
private val roomMembershipObserver = RoomMembershipObserver()
|
||||
|
||||
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
||||
|
||||
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
|
||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
|
|
@ -60,7 +61,6 @@ import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
|||
import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -92,7 +92,6 @@ import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateCh
|
|||
import org.matrix.rustcomponents.sdk.Room as InnerRoom
|
||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RustMatrixRoom(
|
||||
override val sessionId: SessionId,
|
||||
private val deviceId: DeviceId,
|
||||
|
|
@ -107,6 +106,7 @@ class RustMatrixRoom(
|
|||
private val roomSyncSubscriber: RoomSyncSubscriber,
|
||||
private val matrixRoomInfoMapper: MatrixRoomInfoMapper,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val roomMembershipObserver: RoomMembershipObserver,
|
||||
) : MatrixRoom {
|
||||
override val roomId = RoomId(innerRoom.id())
|
||||
|
||||
|
|
@ -376,6 +376,8 @@ class RustMatrixRoom(
|
|||
override suspend fun leave(): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerRoom.leave()
|
||||
}.onSuccess {
|
||||
roomMembershipObserver.notifyUserLeftRoom(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -467,12 +469,36 @@ class RustMatrixRoom(
|
|||
return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, caption, formattedCaption, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
return liveTimeline.sendAudio(file, audioInfo, progressCallback)
|
||||
override suspend fun sendAudio(
|
||||
file: File,
|
||||
audioInfo: AudioInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return liveTimeline.sendAudio(
|
||||
file = file,
|
||||
audioInfo = audioInfo,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
progressCallback = progressCallback,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
return liveTimeline.sendFile(file, fileInfo, progressCallback)
|
||||
override suspend fun sendFile(
|
||||
file: File,
|
||||
fileInfo: FileInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return liveTimeline.sendFile(
|
||||
file,
|
||||
fileInfo,
|
||||
caption,
|
||||
formattedCaption,
|
||||
progressCallback,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit> {
|
||||
|
|
|
|||
|
|
@ -10,15 +10,19 @@ package io.element.android.libraries.matrix.impl.room
|
|||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.room.PendingRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import org.matrix.rustcomponents.sdk.RoomPreview
|
||||
|
||||
class RustPendingRoom(
|
||||
override val sessionId: SessionId,
|
||||
override val roomId: RoomId,
|
||||
private val inner: RoomPreview,
|
||||
private val roomMembershipObserver: RoomMembershipObserver,
|
||||
) : PendingRoom {
|
||||
override suspend fun leave(): Result<Unit> = runCatching {
|
||||
inner.leave()
|
||||
}.onSuccess {
|
||||
roomMembershipObserver.notifyUserLeftRoom(roomId)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
|||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.PendingRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
|
||||
import io.element.android.libraries.matrix.impl.roomlist.fullRoomWithTimeline
|
||||
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
|
@ -51,8 +51,8 @@ class RustRoomFactory(
|
|||
private val roomSyncSubscriber: RoomSyncSubscriber,
|
||||
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val roomMembershipObserver: RoomMembershipObserver,
|
||||
) {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val dispatcher = dispatchers.io.limitedParallelism(1)
|
||||
private val mutex = Mutex()
|
||||
private var isDestroyed: Boolean = false
|
||||
|
|
@ -120,6 +120,7 @@ class RustRoomFactory(
|
|||
roomSyncSubscriber = roomSyncSubscriber,
|
||||
matrixRoomInfoMapper = matrixRoomInfoMapper,
|
||||
featureFlagService = featureFlagService,
|
||||
roomMembershipObserver = roomMembershipObserver,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -148,6 +149,7 @@ class RustRoomFactory(
|
|||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
inner = innerRoom,
|
||||
roomMembershipObserver = roomMembershipObserver,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -295,22 +295,40 @@ class RustTimeline(
|
|||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
): Result<Unit> =
|
||||
withContext(dispatcher) {
|
||||
runCatching<Unit> {
|
||||
val editedContent = EditedContent.RoomMessage(
|
||||
content = MessageEventContent.from(
|
||||
body = body,
|
||||
htmlBody = htmlBody,
|
||||
intentionalMentions = intentionalMentions
|
||||
),
|
||||
)
|
||||
inner.edit(
|
||||
newContent = editedContent,
|
||||
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
|
||||
)
|
||||
}
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching<Unit> {
|
||||
val editedContent = EditedContent.RoomMessage(
|
||||
content = MessageEventContent.from(
|
||||
body = body,
|
||||
htmlBody = htmlBody,
|
||||
intentionalMentions = intentionalMentions
|
||||
),
|
||||
)
|
||||
inner.edit(
|
||||
newContent = editedContent,
|
||||
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun editCaption(
|
||||
eventOrTransactionId: EventOrTransactionId,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatching<Unit> {
|
||||
val editedContent = EditedContent.MediaCaption(
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption?.let {
|
||||
FormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
)
|
||||
inner.edit(
|
||||
newContent = editedContent,
|
||||
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun replyMessage(
|
||||
eventId: EventId,
|
||||
|
|
@ -373,27 +391,44 @@ class RustTimeline(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
override suspend fun sendAudio(
|
||||
file: File,
|
||||
audioInfo: AudioInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
|
||||
return sendAttachment(listOf(file)) {
|
||||
inner.sendAudio(
|
||||
url = file.path,
|
||||
audioInfo = audioInfo.map(),
|
||||
// Maybe allow a caption in the future?
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption?.let {
|
||||
FormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
useSendQueue = useSendQueue,
|
||||
progressWatcher = progressCallback?.toProgressWatcher()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
override suspend fun sendFile(
|
||||
file: File,
|
||||
fileInfo: FileInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
|
||||
return sendAttachment(listOf(file)) {
|
||||
inner.sendFile(
|
||||
url = file.path,
|
||||
fileInfo = fileInfo.map(),
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption?.let {
|
||||
FormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
useSendQueue = useSendQueue,
|
||||
progressWatcher = progressCallback?.toProgressWatcher(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -96,6 +96,17 @@ class RustSessionVerificationService(
|
|||
|
||||
private var listener: SessionVerificationServiceListener? = null
|
||||
|
||||
init {
|
||||
// Instantiate the verification controller when possible, this is needed to get incoming verification requests
|
||||
sessionCoroutineScope.launch {
|
||||
// Needed to avoid crashes on unit tests due to the Rust SDK not being available
|
||||
tryOrNull {
|
||||
encryptionService.waitForE2eeInitializationTasks()
|
||||
initVerificationControllerIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setListener(listener: SessionVerificationServiceListener?) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,5 +32,6 @@ internal fun aRustRoomPreviewInfo(
|
|||
isHistoryWorldReadable = true,
|
||||
membership = membership,
|
||||
joinRule = joinRule,
|
||||
heroes = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,10 +92,10 @@ class FakeMatrixRoom(
|
|||
{ _, _, _, _, _, _ -> lambdaError() },
|
||||
private val sendVideoResult: (File, File?, VideoInfo, String?, String?, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
|
||||
{ _, _, _, _, _, _ -> lambdaError() },
|
||||
private val sendFileResult: (File, FileInfo, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
|
||||
{ _, _, _ -> lambdaError() },
|
||||
private val sendAudioResult: (File, AudioInfo, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
|
||||
{ _, _, _ -> lambdaError() },
|
||||
private val sendFileResult: (File, FileInfo, String?, String?, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
|
||||
{ _, _, _, _, _ -> lambdaError() },
|
||||
private val sendAudioResult: (File, AudioInfo, String?, String?, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
|
||||
{ _, _, _, _, _ -> lambdaError() },
|
||||
private val sendVoiceMessageResult: (File, AudioInfo, List<Float>, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
|
||||
{ _, _, _, _ -> lambdaError() },
|
||||
private val setNameResult: (String) -> Result<Unit> = { lambdaError() },
|
||||
|
|
@ -354,12 +354,16 @@ class FakeMatrixRoom(
|
|||
override suspend fun sendAudio(
|
||||
file: File,
|
||||
audioInfo: AudioInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?
|
||||
): Result<MediaUploadHandler> = simulateLongTask {
|
||||
simulateSendMediaProgress(progressCallback)
|
||||
sendAudioResult(
|
||||
file,
|
||||
audioInfo,
|
||||
caption,
|
||||
formattedCaption,
|
||||
progressCallback,
|
||||
)
|
||||
}
|
||||
|
|
@ -367,12 +371,16 @@ class FakeMatrixRoom(
|
|||
override suspend fun sendFile(
|
||||
file: File,
|
||||
fileInfo: FileInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?
|
||||
): Result<MediaUploadHandler> = simulateLongTask {
|
||||
simulateSendMediaProgress(progressCallback)
|
||||
sendFileResult(
|
||||
file,
|
||||
fileInfo,
|
||||
caption,
|
||||
formattedCaption,
|
||||
progressCallback,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,24 @@ class FakeTimeline(
|
|||
intentionalMentions
|
||||
)
|
||||
|
||||
var editCaptionLambda: (
|
||||
eventOrTransactionId: EventOrTransactionId,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
) -> Result<Unit> = { _, _, _ ->
|
||||
lambdaError()
|
||||
}
|
||||
|
||||
override suspend fun editCaption(
|
||||
eventOrTransactionId: EventOrTransactionId,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
): Result<Unit> = editCaptionLambda(
|
||||
eventOrTransactionId,
|
||||
caption,
|
||||
formattedCaption,
|
||||
)
|
||||
|
||||
var replyMessageLambda: (
|
||||
eventId: EventId,
|
||||
body: String,
|
||||
|
|
@ -173,36 +191,48 @@ class FakeTimeline(
|
|||
var sendAudioLambda: (
|
||||
file: File,
|
||||
audioInfo: AudioInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
) -> Result<MediaUploadHandler> = { _, _, _ ->
|
||||
) -> Result<MediaUploadHandler> = { _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
|
||||
override suspend fun sendAudio(
|
||||
file: File,
|
||||
audioInfo: AudioInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> = sendAudioLambda(
|
||||
file,
|
||||
audioInfo,
|
||||
caption,
|
||||
formattedCaption,
|
||||
progressCallback
|
||||
)
|
||||
|
||||
var sendFileLambda: (
|
||||
file: File,
|
||||
fileInfo: FileInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
) -> Result<MediaUploadHandler> = { _, _, _ ->
|
||||
) -> Result<MediaUploadHandler> = { _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
|
||||
override suspend fun sendFile(
|
||||
file: File,
|
||||
fileInfo: FileInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> = sendFileLambda(
|
||||
file,
|
||||
fileInfo,
|
||||
caption,
|
||||
formattedCaption,
|
||||
progressCallback
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,25 +13,23 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.core.content.FileProvider
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.mediapickers.api.ComposePickerLauncher
|
||||
import io.element.android.libraries.mediapickers.api.NoOpPickerLauncher
|
||||
import io.element.android.libraries.mediapickers.api.PickerLauncher
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.api.PickerType
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
|
||||
@Inject
|
||||
constructor() : this(false)
|
||||
|
||||
class DefaultPickerProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : PickerProvider {
|
||||
/**
|
||||
* Remembers and returns a [PickerLauncher] for a certain media/file [type].
|
||||
*/
|
||||
|
|
@ -40,7 +38,7 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
|
|||
type: PickerType<Input, Output>,
|
||||
onResult: (Output) -> Unit,
|
||||
): PickerLauncher<Input, Output> {
|
||||
return if (LocalInspectionMode.current || isInTest) {
|
||||
return if (LocalInspectionMode.current) {
|
||||
NoOpPickerLauncher { }
|
||||
} else {
|
||||
val contract = type.getContract()
|
||||
|
|
@ -56,7 +54,7 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
|
|||
@Composable
|
||||
override fun registerGalleryImagePicker(onResult: (Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
|
||||
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
|
||||
return if (LocalInspectionMode.current || isInTest) {
|
||||
return if (LocalInspectionMode.current) {
|
||||
NoOpPickerLauncher { onResult(null) }
|
||||
} else {
|
||||
rememberPickerLauncher(type = PickerType.Image) { uri -> onResult(uri) }
|
||||
|
|
@ -72,10 +70,9 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
|
|||
onResult: (uri: Uri?, mimeType: String?) -> Unit
|
||||
): PickerLauncher<PickVisualMediaRequest, Uri?> {
|
||||
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
|
||||
return if (LocalInspectionMode.current || isInTest) {
|
||||
return if (LocalInspectionMode.current) {
|
||||
NoOpPickerLauncher { onResult(null, null) }
|
||||
} else {
|
||||
val context = LocalContext.current
|
||||
rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri ->
|
||||
val mimeType = uri?.let { context.contentResolver.getType(it) }
|
||||
onResult(uri, mimeType)
|
||||
|
|
@ -93,7 +90,7 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
|
|||
onResult: (Uri?) -> Unit,
|
||||
): PickerLauncher<String, Uri?> {
|
||||
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
|
||||
return if (LocalInspectionMode.current || isInTest) {
|
||||
return if (LocalInspectionMode.current) {
|
||||
NoOpPickerLauncher { onResult(null) }
|
||||
} else {
|
||||
rememberPickerLauncher(type = PickerType.File(mimeType)) { uri -> onResult(uri) }
|
||||
|
|
@ -107,12 +104,11 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
|
|||
@Composable
|
||||
override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
|
||||
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
|
||||
return if (LocalInspectionMode.current || isInTest) {
|
||||
return if (LocalInspectionMode.current) {
|
||||
NoOpPickerLauncher { onResult(null) }
|
||||
} else {
|
||||
val context = LocalContext.current
|
||||
val tmpFile = remember { getTemporaryFile(context) }
|
||||
val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) }
|
||||
val tmpFile = remember { getTemporaryFile("photo.jpg") }
|
||||
val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) }
|
||||
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
|
||||
// Execute callback
|
||||
onResult(if (success) tmpFileUri else null)
|
||||
|
|
@ -127,12 +123,11 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
|
|||
@Composable
|
||||
override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
|
||||
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
|
||||
return if (LocalInspectionMode.current || isInTest) {
|
||||
return if (LocalInspectionMode.current) {
|
||||
NoOpPickerLauncher { onResult(null) }
|
||||
} else {
|
||||
val context = LocalContext.current
|
||||
val tmpFile = remember { getTemporaryFile(context) }
|
||||
val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) }
|
||||
val tmpFile = remember { getTemporaryFile("video.mp4") }
|
||||
val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) }
|
||||
rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success ->
|
||||
// Execute callback
|
||||
onResult(if (success) tmpFileUri else null)
|
||||
|
|
@ -141,15 +136,12 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
|
|||
}
|
||||
|
||||
private fun getTemporaryFile(
|
||||
context: Context,
|
||||
baseFolder: File = context.cacheDir,
|
||||
filename: String = UUID.randomUUID().toString(),
|
||||
filename: String,
|
||||
): File {
|
||||
return File(baseFolder, filename)
|
||||
return File(context.cacheDir, filename)
|
||||
}
|
||||
|
||||
private fun getTemporaryUri(
|
||||
context: Context,
|
||||
file: File,
|
||||
): Uri {
|
||||
val authority = "${context.packageName}.fileprovider"
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@ class MediaSender @Inject constructor(
|
|||
sendAudio(
|
||||
file = uploadInfo.file,
|
||||
audioInfo = uploadInfo.audioInfo,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
progressCallback = progressCallback
|
||||
)
|
||||
}
|
||||
|
|
@ -140,6 +142,8 @@ class MediaSender @Inject constructor(
|
|||
sendFile(
|
||||
file = uploadInfo.file,
|
||||
fileInfo = uploadInfo.fileInfo,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
progressCallback = progressCallback
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ class MediaSenderTest {
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `given a cancellation in the media upload when sending the job is cancelled`() = runTest(StandardTestDispatcher()) {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
|
|
|
|||
|
|
@ -47,6 +47,16 @@ internal fun ComposerModeView(
|
|||
when (composerMode) {
|
||||
is MessageComposerMode.Edit -> {
|
||||
EditingModeView(
|
||||
text = stringResource(CommonStrings.common_editing),
|
||||
modifier = modifier,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
)
|
||||
}
|
||||
is MessageComposerMode.EditCaption -> {
|
||||
EditingModeView(
|
||||
text = stringResource(
|
||||
if (composerMode.content.isEmpty()) CommonStrings.common_adding_caption else CommonStrings.common_editing_caption
|
||||
),
|
||||
modifier = modifier,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
)
|
||||
|
|
@ -65,6 +75,7 @@ internal fun ComposerModeView(
|
|||
@Composable
|
||||
private fun EditingModeView(
|
||||
onResetComposerMode: () -> Unit,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -76,14 +87,14 @@ private fun EditingModeView(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Edit(),
|
||||
contentDescription = stringResource(CommonStrings.common_editing),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.materialColors.secondary,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(CommonStrings.common_editing),
|
||||
text = text,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
textAlign = TextAlign.Start,
|
||||
color = ElementTheme.materialColors.secondary,
|
||||
|
|
|
|||
|
|
@ -121,19 +121,25 @@ fun TextComposer(
|
|||
}
|
||||
|
||||
val layoutModifier = modifier
|
||||
.fillMaxSize()
|
||||
.height(IntrinsicSize.Min)
|
||||
.fillMaxSize()
|
||||
.height(IntrinsicSize.Min)
|
||||
|
||||
val composerOptionsButton: @Composable () -> Unit = remember {
|
||||
val composerOptionsButton: @Composable () -> Unit = remember(composerMode) {
|
||||
@Composable {
|
||||
if (composerMode is MessageComposerMode.Attachment) {
|
||||
Spacer(modifier = Modifier.width(9.dp))
|
||||
} else {
|
||||
ComposerOptionsButton(
|
||||
modifier = Modifier
|
||||
.size(48.dp),
|
||||
onClick = onAddAttachment
|
||||
)
|
||||
when (composerMode) {
|
||||
is MessageComposerMode.Attachment -> {
|
||||
Spacer(modifier = Modifier.width(9.dp))
|
||||
}
|
||||
is MessageComposerMode.EditCaption -> {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
else -> {
|
||||
ComposerOptionsButton(
|
||||
modifier = Modifier
|
||||
.size(48.dp),
|
||||
onClick = onAddAttachment
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -331,8 +337,8 @@ private fun StandardLayout(
|
|||
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
|
||||
.size(48.dp),
|
||||
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
|
||||
.size(48.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
voiceDeleteButton()
|
||||
|
|
@ -342,8 +348,8 @@ private fun StandardLayout(
|
|||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp, top = 8.dp)
|
||||
.weight(1f)
|
||||
.padding(bottom = 8.dp, top = 8.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
voiceRecording()
|
||||
}
|
||||
|
|
@ -356,16 +362,16 @@ private fun StandardLayout(
|
|||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp, top = 8.dp)
|
||||
.weight(1f)
|
||||
.padding(bottom = 8.dp, top = 8.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
textInput()
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
|
||||
.size(48.dp),
|
||||
Modifier
|
||||
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
|
||||
.size(48.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
endButton()
|
||||
|
|
@ -387,8 +393,8 @@ private fun TextFormattingLayout(
|
|||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp)
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
textInput()
|
||||
}
|
||||
|
|
@ -432,11 +438,11 @@ private fun TextInputBox(
|
|||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(roundedCorners)
|
||||
.border(0.5.dp, borderColor, roundedCorners)
|
||||
.background(color = bgColor)
|
||||
.requiredHeightIn(min = 42.dp)
|
||||
.fillMaxSize(),
|
||||
.clip(roundedCorners)
|
||||
.border(0.5.dp, borderColor, roundedCorners)
|
||||
.background(color = bgColor)
|
||||
.requiredHeightIn(min = 42.dp)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
if (composerMode is MessageComposerMode.Special) {
|
||||
ComposerModeView(
|
||||
|
|
@ -447,9 +453,9 @@ private fun TextInputBox(
|
|||
val defaultTypography = ElementTheme.typography.fontBodyLgRegular
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp)
|
||||
// Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail
|
||||
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
|
||||
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp)
|
||||
// Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail
|
||||
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
// Placeholder
|
||||
|
|
@ -495,8 +501,8 @@ private fun TextInput(
|
|||
// This prevents it gaining focus and mutating the state.
|
||||
registerStateUpdates = !subcomposing,
|
||||
modifier = Modifier
|
||||
.padding(top = 6.dp, bottom = 6.dp)
|
||||
.fillMaxWidth(),
|
||||
.padding(top = 6.dp, bottom = 6.dp)
|
||||
.fillMaxWidth(),
|
||||
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
|
||||
resolveMentionDisplay = resolveMentionDisplay,
|
||||
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
|
||||
|
|
@ -573,6 +579,42 @@ internal fun TextComposerEditPreview() = ElementPreview {
|
|||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TextComposerEditCaptionPreview() = ElementPreview {
|
||||
PreviewColumn(
|
||||
items = aTextEditorStateRichList()
|
||||
) { _, textEditorState ->
|
||||
ATextComposer(
|
||||
state = textEditorState,
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = aMessageComposerModeEditCaption(
|
||||
// Set an existing caption so that the UI will be in edit caption mode
|
||||
content = "An existing caption",
|
||||
),
|
||||
enableVoiceMessages = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TextComposerAddCaptionPreview() = ElementPreview {
|
||||
PreviewColumn(
|
||||
items = aTextEditorStateRichList()
|
||||
) { _, textEditorState ->
|
||||
ATextComposer(
|
||||
state = textEditorState,
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = aMessageComposerModeEditCaption(
|
||||
// No caption so that the UI will be in add caption mode
|
||||
content = "",
|
||||
),
|
||||
enableVoiceMessages = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MarkdownTextComposerEditPreview() = ElementPreview {
|
||||
|
|
@ -717,6 +759,14 @@ fun aMessageComposerModeEdit(
|
|||
content = content
|
||||
)
|
||||
|
||||
fun aMessageComposerModeEditCaption(
|
||||
eventOrTransactionId: EventOrTransactionId = EventId("$1234").toEventOrTransactionId(),
|
||||
content: String,
|
||||
) = MessageComposerMode.EditCaption(
|
||||
eventOrTransactionId = eventOrTransactionId,
|
||||
content = content
|
||||
)
|
||||
|
||||
fun aMessageComposerModeReply(
|
||||
replyToDetails: InReplyToDetails,
|
||||
hideImage: Boolean = false,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
package io.element.android.libraries.textcomposer.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -17,7 +16,13 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.LinearGradientShader
|
||||
import androidx.compose.ui.graphics.RadialGradientShader
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
|
|
@ -31,6 +36,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTran
|
|||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* Send button for the message composer.
|
||||
* Figma: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1956-37575&node-type=frame&m=dev
|
||||
*/
|
||||
@Composable
|
||||
internal fun SendButton(
|
||||
canSendMessage: Boolean,
|
||||
|
|
@ -44,23 +53,29 @@ internal fun SendButton(
|
|||
onClick = onClick,
|
||||
enabled = canSendMessage,
|
||||
) {
|
||||
val iconVector = when (composerMode) {
|
||||
is MessageComposerMode.Edit -> CompoundIcons.Check()
|
||||
else -> CompoundIcons.Send()
|
||||
val iconVector = when {
|
||||
composerMode.isEditing -> CompoundIcons.Check()
|
||||
else -> CompoundIcons.SendSolid()
|
||||
}
|
||||
val iconStartPadding = when (composerMode) {
|
||||
is MessageComposerMode.Edit -> 0.dp
|
||||
val iconStartPadding = when {
|
||||
composerMode.isEditing -> 0.dp
|
||||
else -> 2.dp
|
||||
}
|
||||
val contentDescription = when (composerMode) {
|
||||
is MessageComposerMode.Edit -> stringResource(CommonStrings.action_edit)
|
||||
val contentDescription = when {
|
||||
composerMode.isEditing -> stringResource(CommonStrings.action_edit)
|
||||
else -> stringResource(CommonStrings.action_send)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(36.dp)
|
||||
.background(if (canSendMessage) ElementTheme.colors.iconAccentTertiary else Color.Transparent)
|
||||
.then(
|
||||
if (canSendMessage) {
|
||||
buttonBackgroundModifier()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
|
|
@ -68,13 +83,55 @@ internal fun SendButton(
|
|||
.align(Alignment.Center),
|
||||
imageVector = iconVector,
|
||||
contentDescription = contentDescription,
|
||||
// Exception here, we use Color.White instead of ElementTheme.colors.iconOnSolidPrimary
|
||||
tint = if (canSendMessage) Color.White else ElementTheme.colors.iconDisabled
|
||||
tint = if (canSendMessage) {
|
||||
if (ElementTheme.colors.isLight) {
|
||||
ElementTheme.colors.iconOnSolidPrimary
|
||||
} else {
|
||||
ElementTheme.colors.iconPrimary
|
||||
}
|
||||
} else {
|
||||
ElementTheme.colors.iconQuaternary
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buttonBackgroundModifier() = Modifier.drawWithCache {
|
||||
// We have a square button, so height == width.
|
||||
val height = size.height
|
||||
val verticalGradientBrush = ShaderBrush(
|
||||
LinearGradientShader(
|
||||
from = Offset(0f, 0f),
|
||||
to = Offset(0f, height),
|
||||
colors = listOf(
|
||||
Color(0xFF0BC491),
|
||||
Color(0xFF0467DD),
|
||||
)
|
||||
)
|
||||
)
|
||||
val radialGradientBrush = ShaderBrush(
|
||||
RadialGradientShader(
|
||||
center = Offset(height / 2f, height / 2f),
|
||||
radius = height / 2f,
|
||||
colors = listOf(
|
||||
Color(0xFF0BC491),
|
||||
Color(0xFF0467DD),
|
||||
)
|
||||
)
|
||||
)
|
||||
onDrawBehind {
|
||||
drawRect(
|
||||
brush = verticalGradientBrush,
|
||||
)
|
||||
drawRect(
|
||||
brush = radialGradientBrush,
|
||||
alpha = 0.4f,
|
||||
blendMode = BlendMode.Overlay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SendButtonPreview() = ElementPreview {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ sealed interface MessageComposerMode {
|
|||
val content: String
|
||||
) : Special
|
||||
|
||||
data class EditCaption(
|
||||
val eventOrTransactionId: EventOrTransactionId,
|
||||
val content: String
|
||||
) : Special
|
||||
|
||||
data class Reply(
|
||||
val replyToDetails: InReplyToDetails,
|
||||
val hideImage: Boolean,
|
||||
|
|
@ -34,16 +39,8 @@ sealed interface MessageComposerMode {
|
|||
val eventId: EventId = replyToDetails.eventId()
|
||||
}
|
||||
|
||||
val relatedEventId: EventId?
|
||||
get() = when (this) {
|
||||
is Normal,
|
||||
is Attachment -> null
|
||||
is Edit -> eventOrTransactionId.eventId
|
||||
is Reply -> eventId
|
||||
}
|
||||
|
||||
val isEditing: Boolean
|
||||
get() = this is Edit
|
||||
get() = this is Edit || this is EditCaption
|
||||
|
||||
val isReply: Boolean
|
||||
get() = this is Reply
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
<string name="a11y_voice_message_record">"Record voice message."</string>
|
||||
<string name="a11y_voice_message_stop_recording">"Stop recording"</string>
|
||||
<string name="action_accept">"Accept"</string>
|
||||
<string name="action_add_caption">"Add caption"</string>
|
||||
<string name="action_add_to_timeline">"Add to timeline"</string>
|
||||
<string name="action_back">"Back"</string>
|
||||
<string name="action_call">"Call"</string>
|
||||
|
|
@ -57,6 +58,7 @@
|
|||
<string name="action_discard">"Discard"</string>
|
||||
<string name="action_done">"Done"</string>
|
||||
<string name="action_edit">"Edit"</string>
|
||||
<string name="action_edit_caption">"Edit caption"</string>
|
||||
<string name="action_edit_poll">"Edit poll"</string>
|
||||
<string name="action_enable">"Enable"</string>
|
||||
<string name="action_end_poll">"End poll"</string>
|
||||
|
|
@ -91,6 +93,7 @@
|
|||
<string name="action_react">"React"</string>
|
||||
<string name="action_reject">"Reject"</string>
|
||||
<string name="action_remove">"Remove"</string>
|
||||
<string name="action_remove_caption">"Remove caption"</string>
|
||||
<string name="action_reply">"Reply"</string>
|
||||
<string name="action_reply_in_thread">"Reply in thread"</string>
|
||||
<string name="action_report_bug">"Report bug"</string>
|
||||
|
|
@ -123,6 +126,7 @@
|
|||
<string name="action_yes">"Yes"</string>
|
||||
<string name="common_about">"About"</string>
|
||||
<string name="common_acceptable_use_policy">"Acceptable use policy"</string>
|
||||
<string name="common_adding_caption">"Adding caption"</string>
|
||||
<string name="common_advanced_settings">"Advanced settings"</string>
|
||||
<string name="common_analytics">"Analytics"</string>
|
||||
<string name="common_appearance">"Appearance"</string>
|
||||
|
|
@ -143,6 +147,7 @@
|
|||
<string name="common_do_not_show_this_again">"Do not show this again"</string>
|
||||
<string name="common_edited_suffix">"(edited)"</string>
|
||||
<string name="common_editing">"Editing"</string>
|
||||
<string name="common_editing_caption">"Editing caption"</string>
|
||||
<string name="common_emote">"* %1$s %2$s"</string>
|
||||
<string name="common_encryption">"Encryption"</string>
|
||||
<string name="common_encryption_enabled">"Encryption enabled"</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue