Delete the temporary file only when the user explicitly cancel the upload.
This commit is contained in:
parent
22bb8796ef
commit
73b156371c
7 changed files with 88 additions and 38 deletions
|
|
@ -12,5 +12,6 @@ import androidx.compose.runtime.Immutable
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface AttachmentsPreviewEvents {
|
sealed interface AttachmentsPreviewEvents {
|
||||||
data object SendAttachment : AttachmentsPreviewEvents
|
data object SendAttachment : AttachmentsPreviewEvents
|
||||||
|
data object Cancel : AttachmentsPreviewEvents
|
||||||
data object ClearSendState : AttachmentsPreviewEvents
|
data object ClearSendState : AttachmentsPreviewEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,14 @@ class AttachmentsPreviewNode @AssistedInject constructor(
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
|
||||||
private val presenter = presenterFactory.create(inputs.attachment)
|
private val onDoneListener = OnDoneListener {
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(
|
||||||
|
attachment = inputs.attachment,
|
||||||
|
onDoneListener = onDoneListener,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
|
|
@ -39,7 +46,6 @@ class AttachmentsPreviewNode @AssistedInject constructor(
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
AttachmentsPreviewView(
|
AttachmentsPreviewView(
|
||||||
state = state,
|
state = state,
|
||||||
onDismiss = this::navigateUp,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
package io.element.android.features.messages.impl.attachments.preview
|
package io.element.android.features.messages.impl.attachments.preview
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -36,13 +35,17 @@ import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
@Assisted private val attachment: Attachment,
|
@Assisted private val attachment: Attachment,
|
||||||
|
@Assisted private val onDoneListener: OnDoneListener,
|
||||||
private val mediaSender: MediaSender,
|
private val mediaSender: MediaSender,
|
||||||
private val permalinkBuilder: PermalinkBuilder,
|
private val permalinkBuilder: PermalinkBuilder,
|
||||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||||
) : Presenter<AttachmentsPreviewState> {
|
) : Presenter<AttachmentsPreviewState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(attachment: Attachment): AttachmentsPreviewPresenter
|
fun create(
|
||||||
|
attachment: Attachment,
|
||||||
|
onDoneListener: OnDoneListener,
|
||||||
|
): AttachmentsPreviewPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -60,20 +63,6 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
// Delete the temporary file when the composable is disposed, in case it was not sent
|
|
||||||
if (sendActionState.value == SendActionState.Idle) {
|
|
||||||
// Attachment has not been sent, maybe delete it
|
|
||||||
when (attachment) {
|
|
||||||
is Attachment.Media -> {
|
|
||||||
temporaryUriDeleter.delete(attachment.localMedia.uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
|
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
|
||||||
when (attachmentsPreviewEvents) {
|
when (attachmentsPreviewEvents) {
|
||||||
is AttachmentsPreviewEvents.SendAttachment -> {
|
is AttachmentsPreviewEvents.SendAttachment -> {
|
||||||
|
|
@ -85,6 +74,9 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
sendActionState = sendActionState,
|
sendActionState = sendActionState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
AttachmentsPreviewEvents.Cancel -> {
|
||||||
|
coroutineScope.cancel(attachment)
|
||||||
|
}
|
||||||
AttachmentsPreviewEvents.ClearSendState -> {
|
AttachmentsPreviewEvents.ClearSendState -> {
|
||||||
ongoingSendAttachmentJob.value?.let {
|
ongoingSendAttachmentJob.value?.let {
|
||||||
it.cancel()
|
it.cancel()
|
||||||
|
|
@ -119,6 +111,18 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.cancel(
|
||||||
|
attachment: Attachment,
|
||||||
|
) = launch {
|
||||||
|
// Delete the temporary file
|
||||||
|
when (attachment) {
|
||||||
|
is Attachment.Media -> {
|
||||||
|
temporaryUriDeleter.delete(attachment.localMedia.uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDoneListener()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun sendMedia(
|
private suspend fun sendMedia(
|
||||||
mediaAttachment: Attachment.Media,
|
mediaAttachment: Attachment.Media,
|
||||||
caption: String?,
|
caption: String?,
|
||||||
|
|
@ -141,7 +145,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
sendActionState.value = SendActionState.Done
|
onDoneListener()
|
||||||
},
|
},
|
||||||
onFailure = { error ->
|
onFailure = { error ->
|
||||||
Timber.e(error, "Failed to send attachment")
|
Timber.e(error, "Failed to send attachment")
|
||||||
|
|
|
||||||
|
|
@ -36,5 +36,4 @@ sealed interface SendActionState {
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Failure(val error: Throwable) : SendActionState
|
data class Failure(val error: Throwable) : SendActionState
|
||||||
data object Done : SendActionState
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.attachments.preview
|
package io.element.android.features.messages.impl.attachments.preview
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
|
@ -17,9 +18,6 @@ import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -50,22 +48,22 @@ import me.saket.telephoto.zoomable.rememberZoomableState
|
||||||
@Composable
|
@Composable
|
||||||
fun AttachmentsPreviewView(
|
fun AttachmentsPreviewView(
|
||||||
state: AttachmentsPreviewState,
|
state: AttachmentsPreviewState,
|
||||||
onDismiss: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun postSendAttachment() {
|
fun postSendAttachment() {
|
||||||
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun postCancel() {
|
||||||
|
state.eventSink(AttachmentsPreviewEvents.Cancel)
|
||||||
|
}
|
||||||
|
|
||||||
fun postClearSendState() {
|
fun postClearSendState() {
|
||||||
state.eventSink(AttachmentsPreviewEvents.ClearSendState)
|
state.eventSink(AttachmentsPreviewEvents.ClearSendState)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.sendActionState is SendActionState.Done) {
|
BackHandler(enabled = state.sendActionState !is SendActionState.Sending) {
|
||||||
val latestOnDismiss by rememberUpdatedState(onDismiss)
|
postCancel()
|
||||||
LaunchedEffect(state.sendActionState) {
|
|
||||||
latestOnDismiss()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -75,7 +73,7 @@ fun AttachmentsPreviewView(
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
BackButton(
|
BackButton(
|
||||||
imageVector = CompoundIcons.Close(),
|
imageVector = CompoundIcons.Close(),
|
||||||
onClick = onDismiss,
|
onClick = ::postCancel,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
title = {},
|
title = {},
|
||||||
|
|
@ -202,6 +200,5 @@ private fun AttachmentsPreviewBottomActions(
|
||||||
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
|
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
|
||||||
AttachmentsPreviewView(
|
AttachmentsPreviewView(
|
||||||
state = state,
|
state = state,
|
||||||
onDismiss = {},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.attachments.preview
|
||||||
|
|
||||||
|
fun interface OnDoneListener {
|
||||||
|
operator fun invoke()
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
|
||||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener
|
||||||
import io.element.android.features.messages.impl.attachments.preview.SendActionState
|
import io.element.android.features.messages.impl.attachments.preview.SendActionState
|
||||||
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
|
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
|
||||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
|
|
@ -42,6 +43,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -69,7 +71,11 @@ class AttachmentsPreviewPresenterTest {
|
||||||
),
|
),
|
||||||
sendFileResult = sendFileResult,
|
sendFileResult = sendFileResult,
|
||||||
)
|
)
|
||||||
val presenter = createAttachmentsPreviewPresenter(room = room)
|
val onDoneListener = lambdaRecorder<Unit> { }
|
||||||
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
|
room = room,
|
||||||
|
onDoneListener = { onDoneListener() },
|
||||||
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -80,9 +86,28 @@ class AttachmentsPreviewPresenterTest {
|
||||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f))
|
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f))
|
||||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f))
|
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f))
|
||||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
|
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
|
||||||
val successState = awaitItem()
|
advanceUntilIdle()
|
||||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
|
||||||
sendFileResult.assertions().isCalledOnce()
|
sendFileResult.assertions().isCalledOnce()
|
||||||
|
onDoneListener.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - cancel scenario`() = runTest {
|
||||||
|
val onDoneListener = lambdaRecorder<Unit> { }
|
||||||
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
onDoneListener = { onDoneListener() },
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||||
|
initialState.eventSink(AttachmentsPreviewEvents.Cancel)
|
||||||
|
deleteCallback.assertions().isCalledOnce()
|
||||||
|
onDoneListener.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,9 +123,11 @@ class AttachmentsPreviewPresenterTest {
|
||||||
val room = FakeMatrixRoom(
|
val room = FakeMatrixRoom(
|
||||||
sendImageResult = sendImageResult,
|
sendImageResult = sendImageResult,
|
||||||
)
|
)
|
||||||
|
val onDoneListener = lambdaRecorder<Unit> { }
|
||||||
val presenter = createAttachmentsPreviewPresenter(
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
room = room,
|
room = room,
|
||||||
mediaPreProcessor = mediaPreProcessor,
|
mediaPreProcessor = mediaPreProcessor,
|
||||||
|
onDoneListener = { onDoneListener() },
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -110,8 +137,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
initialState.textEditorState.setMarkdown(A_CAPTION)
|
initialState.textEditorState.setMarkdown(A_CAPTION)
|
||||||
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
||||||
val successState = awaitItem()
|
advanceUntilIdle()
|
||||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
|
||||||
sendImageResult.assertions().isCalledOnce().with(
|
sendImageResult.assertions().isCalledOnce().with(
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
|
|
@ -120,6 +146,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
|
onDoneListener.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,9 +162,11 @@ class AttachmentsPreviewPresenterTest {
|
||||||
val room = FakeMatrixRoom(
|
val room = FakeMatrixRoom(
|
||||||
sendVideoResult = sendVideoResult,
|
sendVideoResult = sendVideoResult,
|
||||||
)
|
)
|
||||||
|
val onDoneListener = lambdaRecorder<Unit> { }
|
||||||
val presenter = createAttachmentsPreviewPresenter(
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
room = room,
|
room = room,
|
||||||
mediaPreProcessor = mediaPreProcessor,
|
mediaPreProcessor = mediaPreProcessor,
|
||||||
|
onDoneListener = { onDoneListener() },
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -147,8 +176,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
initialState.textEditorState.setMarkdown(A_CAPTION)
|
initialState.textEditorState.setMarkdown(A_CAPTION)
|
||||||
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
||||||
val successState = awaitItem()
|
advanceUntilIdle()
|
||||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
|
||||||
sendVideoResult.assertions().isCalledOnce().with(
|
sendVideoResult.assertions().isCalledOnce().with(
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
|
|
@ -157,6 +185,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
|
onDoneListener.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,9 +239,11 @@ class AttachmentsPreviewPresenterTest {
|
||||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||||
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
||||||
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||||
|
onDoneListener: OnDoneListener = OnDoneListener {},
|
||||||
): AttachmentsPreviewPresenter {
|
): AttachmentsPreviewPresenter {
|
||||||
return AttachmentsPreviewPresenter(
|
return AttachmentsPreviewPresenter(
|
||||||
attachment = aMediaAttachment(localMedia),
|
attachment = aMediaAttachment(localMedia),
|
||||||
|
onDoneListener = onDoneListener,
|
||||||
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
|
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
|
||||||
permalinkBuilder = permalinkBuilder,
|
permalinkBuilder = permalinkBuilder,
|
||||||
temporaryUriDeleter = temporaryUriDeleter,
|
temporaryUriDeleter = temporaryUriDeleter,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue