Updated on 2026-08-14
This commit is contained in:
parent
5a62376bfa
commit
f4fa13627c
15 changed files with 142 additions and 53 deletions
|
|
@ -6,18 +6,21 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.conditional
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
|
|
@ -34,6 +37,7 @@ data class SmallButtonConfig(
|
|||
val onClick: () -> Unit,
|
||||
val icon: TangemButtonIconPosition = TangemButtonIconPosition.None,
|
||||
val isEnabled: Boolean = true,
|
||||
val isLoading: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -68,7 +72,7 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier:
|
|||
label = "Update background color",
|
||||
)
|
||||
|
||||
Row(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(
|
||||
minWidth = TangemTheme.dimens.size46,
|
||||
|
|
@ -79,7 +83,7 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier:
|
|||
color = backgroundColor,
|
||||
shape = shape,
|
||||
)
|
||||
.clickable(enabled = config.isEnabled, onClick = config.onClick)
|
||||
.clickable(enabled = !config.isLoading && config.isEnabled, onClick = config.onClick)
|
||||
.padding(
|
||||
paddingValues = when (config.icon) {
|
||||
is TangemButtonIconPosition.None -> PaddingValues(
|
||||
|
|
@ -95,42 +99,54 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier:
|
|||
)
|
||||
},
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ContentContainer(
|
||||
iconPosition = config.icon,
|
||||
text = {
|
||||
val textColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
!config.isEnabled -> TangemTheme.colors.text.disabled
|
||||
isPrimary -> TangemTheme.colors.text.primary2
|
||||
else -> TangemTheme.colors.text.primary1
|
||||
},
|
||||
label = "Update text color",
|
||||
)
|
||||
if (config.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = TangemTheme.dimens.size2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier.size(TangemTheme.dimens.size16),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.conditional(config.isLoading) { alpha(0f) },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
ContentContainer(
|
||||
iconPosition = config.icon,
|
||||
text = {
|
||||
val textColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
!config.isEnabled -> TangemTheme.colors.text.disabled
|
||||
isPrimary -> TangemTheme.colors.text.primary2
|
||||
else -> TangemTheme.colors.text.primary1
|
||||
},
|
||||
label = "Update text color",
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing4),
|
||||
text = config.text.resolveReference(),
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
style = TangemTheme.typography.button,
|
||||
)
|
||||
},
|
||||
icon = { iconResId ->
|
||||
Icon(
|
||||
modifier = Modifier.size(TangemTheme.dimens.size16),
|
||||
painter = painterResource(id = iconResId),
|
||||
tint = if (config.isEnabled) {
|
||||
TangemTheme.colors.icon.secondary
|
||||
} else {
|
||||
TangemTheme.colors.icon.inactive
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing4),
|
||||
text = config.text.resolveReference(),
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
style = TangemTheme.typography.button,
|
||||
)
|
||||
},
|
||||
icon = { iconResId ->
|
||||
Icon(
|
||||
modifier = Modifier.size(TangemTheme.dimens.size16),
|
||||
painter = painterResource(id = iconResId),
|
||||
tint = if (config.isEnabled) {
|
||||
TangemTheme.colors.icon.secondary
|
||||
} else {
|
||||
TangemTheme.colors.icon.inactive
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +188,7 @@ private fun ButtonsSample() {
|
|||
)
|
||||
PrimarySmallButton(config = config)
|
||||
SecondarySmallButton(config = config.copy(text = TextReference.Str(value = "Add")))
|
||||
SecondarySmallButton(config = config.copy(text = TextReference.Str(value = "Add"), isLoading = true))
|
||||
SecondarySmallButton(
|
||||
config = config.copy(
|
||||
text = TextReference.Str(value = "Rating"),
|
||||
|
|
@ -191,5 +208,12 @@ private fun ButtonsSample() {
|
|||
isEnabled = false,
|
||||
),
|
||||
)
|
||||
SecondarySmallButton(
|
||||
config = config.copy(
|
||||
text = TextReference.Str(value = "Add token"),
|
||||
icon = TangemButtonIconPosition.Start(iconResId = R.drawable.ic_plus_24),
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -168,6 +168,14 @@ internal class DefaultAccountsCRUDRepository(
|
|||
userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse())
|
||||
}
|
||||
|
||||
override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option<Int> = option {
|
||||
val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId)
|
||||
|
||||
ensureNotNull(accountListResponse)
|
||||
|
||||
return accountListResponse.wallet.totalAccounts.toOption()
|
||||
}
|
||||
|
||||
override suspend fun getTotalActiveAccountsCountSync(userWalletId: UserWalletId): Option<Int> = option {
|
||||
val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId)
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ internal class DefaultMainAccountTokensMigration(
|
|||
},
|
||||
)
|
||||
|
||||
store.updateData { updatedResponse }
|
||||
|
||||
userTokensSaver.push(
|
||||
userWalletId = userWalletId,
|
||||
response = updatedResponse.toUserTokensResponse(),
|
||||
|
|
|
|||
|
|
@ -183,6 +183,8 @@ class DefaultMainAccountTokensMigrationTest {
|
|||
|
||||
accountsResponseStoreFlow.value = response
|
||||
|
||||
coEvery { accountsResponseStore.updateData(any()) } returns mockk()
|
||||
|
||||
// Act
|
||||
val actual = migration.migrate(userWalletId, derivationIndex)
|
||||
|
||||
|
|
@ -199,6 +201,7 @@ class DefaultMainAccountTokensMigrationTest {
|
|||
coVerifySequence {
|
||||
accountsResponseStoreFactory.create(userWalletId)
|
||||
accountsResponseStore.data
|
||||
accountsResponseStore.updateData(any())
|
||||
userTokensSaver.push(
|
||||
userWalletId = userWalletId,
|
||||
response = migratedResponse.toUserTokensResponse(),
|
||||
|
|
|
|||
|
|
@ -48,11 +48,11 @@ data class AccountList private constructor(
|
|||
*/
|
||||
operator fun plus(other: Account): Either<Error, AccountList> {
|
||||
val isNewAccount = this.accounts.none { it.accountId == other.accountId }
|
||||
val accounts = this.accounts.addOrReplace(other) { it.accountId == other.accountId }
|
||||
val accounts = this.accounts.toList().addOrReplace(other) { it.accountId == other.accountId }
|
||||
|
||||
return invoke(
|
||||
userWalletId = this.userWalletId,
|
||||
accounts = accounts,
|
||||
accounts = accounts.toSet(),
|
||||
totalAccounts = this.totalAccounts + if (isNewAccount) 1 else 0,
|
||||
sortType = this.sortType,
|
||||
groupType = this.groupType,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,13 @@ interface AccountsCRUDRepository {
|
|||
/** Synchronizes tokens for a specific [userWalletId] with remote data source */
|
||||
suspend fun syncTokens(userWalletId: UserWalletId)
|
||||
|
||||
/**
|
||||
* Retrieves the total count of active accounts associated with a specific user wallet excluding archived accounts
|
||||
*
|
||||
* @param userWalletId the unique identifier of the user wallet
|
||||
*/
|
||||
suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option<Int>
|
||||
|
||||
/**
|
||||
* Retrieves the total count of active accounts associated with a specific user wallet excluding archived accounts
|
||||
*
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class GetUnoccupiedAccountIndexUseCase(
|
|||
|
||||
private suspend fun Raise<Error>.getTotalAccountsCount(userWalletId: UserWalletId): Int {
|
||||
return catch(
|
||||
block = { crudRepository.getTotalActiveAccountsCountSync(userWalletId = userWalletId) },
|
||||
block = { crudRepository.getTotalAccountsCountSync(userWalletId = userWalletId) },
|
||||
catch = { raise(Error.DataOperationFailed(cause = it)) },
|
||||
)
|
||||
.getOrElse { raise(Error.DataNotFound) }
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ data class AccountId private constructor(
|
|||
private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") }
|
||||
private val hexRegex = Regex("^[a-fA-F0-9]{64}$")
|
||||
|
||||
fun forMainCryptoPortfolio(userWalletId: UserWalletId): AccountId {
|
||||
return forCryptoPortfolio(userWalletId = userWalletId, derivationIndex = DerivationIndex.Main)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique account identifier for a crypto portfolio
|
||||
*
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import com.tangem.domain.models.account.AccountId
|
|||
import com.tangem.features.account.ArchivedAccountListComponent
|
||||
import com.tangem.features.account.archived.entity.AccountArchivedUM
|
||||
import com.tangem.features.account.archived.entity.AccountArchivedUMBuilder
|
||||
import com.tangem.features.account.archived.entity.AccountArchivedUMBuilder.Companion.toggleProgress
|
||||
import com.tangem.features.account.createedit.error.AccountFeatureError
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.JobHolder
|
||||
|
|
@ -92,9 +93,12 @@ internal class ArchivedAccountListModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch {
|
||||
withContext(dispatchers.default) {
|
||||
_uiState.update { it.toggleProgress(accountId, isLoading = true) }
|
||||
val result = withContext(dispatchers.default) {
|
||||
recoverCryptoPortfolioUseCase(accountId)
|
||||
}
|
||||
_uiState.update { it.toggleProgress(accountId, isLoading = false) }
|
||||
result
|
||||
.onLeft(::handleRecoverError)
|
||||
.onRight {
|
||||
showSuccessRecoverMessage()
|
||||
|
|
|
|||
|
|
@ -24,5 +24,6 @@ internal data class ArchivedAccountUM(
|
|||
val accountIconUM: CryptoPortfolioIconUM,
|
||||
val tokensInfo: TextReference,
|
||||
val networksInfo: TextReference,
|
||||
val isLoading: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.pluralReference
|
|||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.domain.account.models.ArchivedAccount
|
||||
import com.tangem.domain.account.usecase.ArchivedAccountList
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -28,6 +29,7 @@ internal class AccountArchivedUMBuilder @Inject constructor() {
|
|||
accountId = accountId.value,
|
||||
accountName = name.toUM().value,
|
||||
accountIconUM = icon.toUM(),
|
||||
isLoading = false,
|
||||
tokensInfo = pluralReference(
|
||||
R.plurals.common_tokens_count,
|
||||
count = tokensCount,
|
||||
|
|
@ -52,4 +54,25 @@ internal class AccountArchivedUMBuilder @Inject constructor() {
|
|||
onRetryClick = { getArchivedAccounts() },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun AccountArchivedUM.toggleProgress(accountId: AccountId, isLoading: Boolean): AccountArchivedUM {
|
||||
return when (this) {
|
||||
is AccountArchivedUM.Error,
|
||||
is AccountArchivedUM.Loading,
|
||||
-> this
|
||||
|
||||
is AccountArchivedUM.Content -> copy(
|
||||
accounts = accounts.map { accountUM ->
|
||||
if (accountUM.accountId == accountId.value) {
|
||||
accountUM.copy(isLoading = isLoading)
|
||||
} else {
|
||||
accountUM
|
||||
}
|
||||
}.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod
|
|||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = item.onClick)
|
||||
.clickable(enabled = !item.isLoading, onClick = item.onClick)
|
||||
.padding(all = TangemTheme.dimens.spacing12),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
|
||||
|
|
@ -148,6 +148,7 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod
|
|||
SecondarySmallButton(
|
||||
config = SmallButtonConfig(
|
||||
text = resourceReference(R.string.account_archived_recover),
|
||||
isLoading = item.isLoading,
|
||||
onClick = item.onClick,
|
||||
),
|
||||
)
|
||||
|
|
@ -177,7 +178,7 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountA
|
|||
tokensInfo = stringReference("10 tokens"),
|
||||
networksInfo = stringReference("2 networks"),
|
||||
onClick = {},
|
||||
|
||||
isLoading = it % 2 == 0,
|
||||
)
|
||||
}.toImmutableList()
|
||||
val first = AccountArchivedUM.Content(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import androidx.compose.ui.Modifier
|
|||
import com.tangem.common.ui.account.AccountIconPreviewData
|
||||
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
|
||||
import com.tangem.common.ui.userwallet.state.UserWalletItemUM.ImageState
|
||||
import com.tangem.core.analytics.DummyAnalyticsEventHandler
|
||||
import com.tangem.core.decompose.navigation.DummyRouter
|
||||
import com.tangem.core.ui.components.block.model.BlockUM
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
|
|
@ -45,7 +44,6 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent {
|
|||
popBack = {},
|
||||
items = ItemsBuilder(
|
||||
router = DummyRouter(),
|
||||
analyticsEventHandler = DummyAnalyticsEventHandler(),
|
||||
).buildItems(
|
||||
userWallet = UserWallet.Hot(
|
||||
walletId = UserWalletId("011"),
|
||||
|
|
@ -62,6 +60,7 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent {
|
|||
forgetWallet = {},
|
||||
onLinkMoreCardsClick = {},
|
||||
onReferralClick = {},
|
||||
onManageTokensClick = {},
|
||||
isManageTokensAvailable = true,
|
||||
isNotificationsEnabled = true,
|
||||
onCheckedNotificationsChanged = {},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import arrow.core.getOrElse
|
|||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRoute.ManageTokens.Source
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.OnOffState.Off
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.OnOffState.On
|
||||
|
|
@ -26,6 +27,8 @@ import com.tangem.core.ui.message.bottomSheetMessage
|
|||
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.demo.IsDemoCardUseCase
|
||||
import com.tangem.domain.models.PortfolioId
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.scan.CardDTO
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
|
|
@ -220,6 +223,21 @@ internal class WalletSettingsModel @Inject constructor(
|
|||
}
|
||||
},
|
||||
onReferralClick = { onReferralClick(userWallet) },
|
||||
onManageTokensClick = {
|
||||
analyticsEventHandler.send(Settings.ButtonManageTokens)
|
||||
router.push(
|
||||
AppRoute.ManageTokens(
|
||||
source = Source.SETTINGS,
|
||||
portfolioId = if (accountsFeatureEnabled) {
|
||||
PortfolioId(
|
||||
accountId = AccountId.forMainCryptoPortfolio(userWalletId = userWallet.walletId),
|
||||
)
|
||||
} else {
|
||||
PortfolioId(userWalletId = userWallet.walletId)
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
isNotificationsEnabled = isNotificationsEnabled,
|
||||
isNotificationsPermissionGranted = isNotificationsPermissionGranted,
|
||||
onCheckedNotificationsChanged = ::onCheckedNotificationsChange,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
package com.tangem.feature.walletsettings.utils
|
||||
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRoute.ManageTokens.Source
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.core.ui.components.block.model.BlockUM
|
||||
import com.tangem.core.ui.components.label.entity.LabelStyle
|
||||
import com.tangem.core.ui.components.label.entity.LabelUM
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.models.PortfolioId
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.feature.walletsettings.analytics.Settings
|
||||
import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM
|
||||
import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM
|
||||
import com.tangem.feature.walletsettings.impl.R
|
||||
|
|
@ -24,7 +20,6 @@ import javax.inject.Inject
|
|||
@ModelScoped
|
||||
internal class ItemsBuilder @Inject constructor(
|
||||
private val router: Router,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
) {
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
|
|
@ -45,6 +40,7 @@ internal class ItemsBuilder @Inject constructor(
|
|||
forgetWallet: () -> Unit,
|
||||
onLinkMoreCardsClick: () -> Unit,
|
||||
onReferralClick: () -> Unit,
|
||||
onManageTokensClick: () -> Unit,
|
||||
onAccessCodeClick: () -> Unit,
|
||||
walletUpgradeDismissed: Boolean,
|
||||
onUpgradeWalletClick: () -> Unit,
|
||||
|
|
@ -69,6 +65,7 @@ internal class ItemsBuilder @Inject constructor(
|
|||
isManageTokensAvailable = isManageTokensAvailable,
|
||||
onLinkMoreCardsClick = onLinkMoreCardsClick,
|
||||
onReferralClick = onReferralClick,
|
||||
onManageTokensClick = onManageTokensClick,
|
||||
),
|
||||
)
|
||||
.addAll(
|
||||
|
|
@ -175,6 +172,7 @@ internal class ItemsBuilder @Inject constructor(
|
|||
isManageTokensAvailable: Boolean,
|
||||
onLinkMoreCardsClick: () -> Unit,
|
||||
onReferralClick: () -> Unit,
|
||||
onManageTokensClick: () -> Unit,
|
||||
) = WalletSettingsItemUM.WithItems(
|
||||
id = "card",
|
||||
description = resourceReference(R.string.settings_card_settings_footer),
|
||||
|
|
@ -204,10 +202,7 @@ internal class ItemsBuilder @Inject constructor(
|
|||
BlockUM(
|
||||
text = resourceReference(R.string.add_tokens_title),
|
||||
iconRes = R.drawable.ic_tether_24,
|
||||
onClick = {
|
||||
analyticsEventHandler.send(Settings.ButtonManageTokens)
|
||||
router.push(AppRoute.ManageTokens(Source.SETTINGS, PortfolioId(userWalletId)))
|
||||
},
|
||||
onClick = onManageTokensClick,
|
||||
).let(::add)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue