Updated on 2026-08-14
This commit is contained in:
commit
dbe235560b
428 changed files with 10165 additions and 3907 deletions
|
|
@ -5,19 +5,14 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.essenty.lifecycle.subscribe
|
||||
import com.tangem.common.routing.RoutingFeatureToggle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.child
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.deeplink.DeepLinksRegistry
|
||||
import com.tangem.core.deeplink.global.BuyCurrencyDeepLink
|
||||
import com.tangem.core.deeplink.utils.registerDeepLinks
|
||||
import com.tangem.core.ui.components.NavigationBar3ButtonsScrim
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsModel
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreen
|
||||
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
|
||||
import com.tangem.features.onramp.OnrampFeatureToggles
|
||||
import com.tangem.features.tokendetails.TokenDetailsComponent
|
||||
import com.tangem.features.txhistory.component.TxHistoryComponent
|
||||
import dagger.assisted.Assisted
|
||||
|
|
@ -30,9 +25,6 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
|
|||
@Assisted params: TokenDetailsComponent.Params,
|
||||
tokenMarketBlockComponentFactory: TokenMarketBlockComponent.Factory,
|
||||
txHistoryComponentFactory: TxHistoryComponent.Factory,
|
||||
onrampFeatureToggles: OnrampFeatureToggles,
|
||||
routingFeatureToggle: RoutingFeatureToggle,
|
||||
deepLinksRegistry: DeepLinksRegistry,
|
||||
) : TokenDetailsComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: TokenDetailsModel = getOrCreateModel(params)
|
||||
|
|
@ -50,23 +42,6 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
|
|||
onPause = model::onPause,
|
||||
onResume = model::onResume,
|
||||
)
|
||||
|
||||
if (!routingFeatureToggle.isDeepLinkNavigationEnabled) {
|
||||
val deeplinks = buildList {
|
||||
if (!onrampFeatureToggles.isFeatureEnabled) {
|
||||
add(
|
||||
BuyCurrencyDeepLink(
|
||||
onReceive = model::onBuyCurrencyDeepLink,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
registerDeepLinks(
|
||||
registry = deepLinksRegistry,
|
||||
deepLinks = deeplinks,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val tokenMarketBlockComponent = params.currency.toTokenMarketParam()?.let { tokenMarketParams ->
|
||||
|
|
|
|||
|
|
@ -1,20 +1,29 @@
|
|||
package com.tangem.feature.tokendetails.deeplink
|
||||
|
||||
import arrow.core.getOrElse
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.deeplink.DeeplinkConst.DERIVATION_PATH_KEY
|
||||
import com.tangem.core.deeplink.DeeplinkConst.NETWORK_ID_KEY
|
||||
import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY
|
||||
import com.tangem.core.deeplink.DeeplinkConst.TRANSACTION_ID_KEY
|
||||
import com.tangem.core.deeplink.DeeplinkConst.TYPE_KEY
|
||||
import com.tangem.core.deeplink.DeeplinkConst.WALLET_ID_KEY
|
||||
import com.tangem.domain.common.util.cardTypesResolver
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.notifications.models.NotificationType
|
||||
import com.tangem.domain.tokens.FetchCurrencyStatusUseCase
|
||||
import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase
|
||||
import com.tangem.domain.tokens.*
|
||||
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
|
||||
import com.tangem.domain.wallets.models.isLocked
|
||||
import com.tangem.domain.wallets.models.isMultiCurrency
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.domain.wallets.usecase.SelectWalletUseCase
|
||||
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
|
||||
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
|
||||
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -28,10 +37,17 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
@Assisted private val queryParams: Map<String, String>,
|
||||
@Assisted private val isFromOnNewIntent: Boolean,
|
||||
private val appRouter: AppRouter,
|
||||
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
|
||||
private val selectWalletUseCase: SelectWalletUseCase,
|
||||
private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase,
|
||||
private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
|
||||
private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase,
|
||||
private val tokenDetailsDeepLinkActionTrigger: TokenDetailsDeepLinkActionTrigger,
|
||||
private val walletDeepLinkActionTrigger: WalletDeepLinkActionTrigger,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase,
|
||||
private val tokensFeatureToggles: TokensFeatureToggles,
|
||||
private val walletBalanceFetcher: WalletBalanceFetcher,
|
||||
private val fetchCardTokenListUseCase: FetchCardTokenListUseCase,
|
||||
) : TokenDetailsDeepLinkHandler {
|
||||
|
||||
init {
|
||||
|
|
@ -42,27 +58,23 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
val networkId = queryParams[NETWORK_ID_KEY]
|
||||
val tokenId = queryParams[TOKEN_ID_KEY]
|
||||
val type = NotificationType.getType(queryParams[TYPE_KEY])
|
||||
|
||||
// It is okay here, we are navigating from outside, and there is no other way to getting UserWallet
|
||||
val selectedUserWalletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId
|
||||
val walletId = queryParams[WALLET_ID_KEY]?.let(::UserWalletId) ?: selectedUserWalletId
|
||||
|
||||
// If selected user wallet is different than from deeplink - ignore deeplink
|
||||
// If selected user wallet is null - ignore deeplink
|
||||
if (walletId != selectedUserWalletId || selectedUserWalletId == null) {
|
||||
Timber.e("Error on getting user wallet")
|
||||
return
|
||||
}
|
||||
val transactionId = queryParams[TRANSACTION_ID_KEY]
|
||||
val walletId = queryParams[WALLET_ID_KEY]
|
||||
|
||||
scope.launch {
|
||||
val cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId = selectedUserWalletId).getOrElse {
|
||||
Timber.e("Error on getting crypto currency list")
|
||||
val userWalletId = walletId?.let(::UserWalletId)
|
||||
val userWallet = userWalletId?.let { getUserWalletUseCase(userWalletId) }?.getOrNull()
|
||||
// If wallet to select is null or locked, ignore deeplink
|
||||
if (userWallet == null || userWallet.isLocked) {
|
||||
Timber.e("Error on getting user wallet")
|
||||
return@launch
|
||||
}.firstOrNull {
|
||||
val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true)
|
||||
val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
|
||||
isNetwork && isCurrency
|
||||
}
|
||||
if (selectWalletUseCase(userWalletId).getOrNull() == null) {
|
||||
Timber.e("Error on selecting user wallet")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val cryptoCurrency = getCryptoCurrency(userWallet = userWallet, networkId = networkId, tokenId = tokenId)
|
||||
|
||||
if (cryptoCurrency == null) {
|
||||
Timber.e(
|
||||
|
|
@ -77,23 +89,78 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
|
||||
analyticsEventHandler.send(PushNotificationAnalyticEvents.NotificationOpened(type.name))
|
||||
|
||||
appRouter.push(
|
||||
AppRoute.CurrencyDetails(
|
||||
userWalletId = selectedUserWalletId,
|
||||
currency = cryptoCurrency,
|
||||
),
|
||||
)
|
||||
|
||||
if (isFromOnNewIntent) {
|
||||
fetchCurrencyStatusUseCase.invoke(
|
||||
userWalletId = selectedUserWalletId,
|
||||
id = cryptoCurrency.id,
|
||||
refresh = true,
|
||||
if (userWallet.isMultiCurrency) {
|
||||
appRouter.push(
|
||||
route = AppRoute.CurrencyDetails(
|
||||
userWalletId = userWallet.walletId,
|
||||
currency = cryptoCurrency,
|
||||
),
|
||||
onComplete = { walletDeepLinkActionTrigger.selectWallet(userWallet.walletId) },
|
||||
)
|
||||
} else {
|
||||
walletDeepLinkActionTrigger.selectWallet(userWallet.walletId)
|
||||
}
|
||||
|
||||
if (transactionId != null) {
|
||||
when (type) {
|
||||
NotificationType.SwapStatus,
|
||||
NotificationType.OnrampStatus,
|
||||
-> tokenDetailsDeepLinkActionTrigger.trigger(transactionId)
|
||||
NotificationType.Promo,
|
||||
NotificationType.IncomeTransactions,
|
||||
NotificationType.Unknown,
|
||||
-> Unit
|
||||
}
|
||||
}
|
||||
if (isFromOnNewIntent) fetchCurrency(userWallet, cryptoCurrency)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) {
|
||||
val isMultiCurrency = userWallet.isMultiCurrency
|
||||
// single-currency wallet with token (NODL)
|
||||
val isSingleWalletWithToken = userWallet is UserWallet.Cold &&
|
||||
userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
|
||||
when {
|
||||
isMultiCurrency -> fetchCurrencyStatusUseCase.invoke(
|
||||
userWalletId = userWallet.walletId,
|
||||
id = cryptoCurrency.id,
|
||||
refresh = true,
|
||||
)
|
||||
!isMultiCurrency && tokensFeatureToggles.isWalletBalanceFetcherEnabled ->
|
||||
walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId))
|
||||
// remove below after delete tokensFeatureToggles.isWalletBalanceFetcherEnabled
|
||||
!isMultiCurrency && userWallet is UserWallet.Cold && isSingleWalletWithToken ->
|
||||
fetchCardTokenListUseCase.invoke(
|
||||
userWalletId = userWallet.walletId,
|
||||
refresh = true,
|
||||
)
|
||||
!isMultiCurrency -> fetchCurrencyStatusUseCase.invoke(
|
||||
userWalletId = userWallet.walletId,
|
||||
refresh = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getCryptoCurrency(userWallet: UserWallet, networkId: String?, tokenId: String?) =
|
||||
if (userWallet.isMultiCurrency) {
|
||||
val derivationPath = queryParams[DERIVATION_PATH_KEY]
|
||||
|
||||
getCryptoCurrenciesUseCase(userWalletId = userWallet.walletId)
|
||||
.getOrNull()
|
||||
?.firstOrNull {
|
||||
val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true)
|
||||
val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
|
||||
|
||||
val isDefaultDerivation = it.network.derivationPath is Network.DerivationPath.Card
|
||||
val isCustomDerivation = derivationPath?.equals(it.network.derivationPath.value) == true
|
||||
val isCorrectDerivation = isDefaultDerivation || isCustomDerivation
|
||||
isNetwork && isCurrency && isCorrectDerivation
|
||||
}
|
||||
} else {
|
||||
getCryptoCurrencyUseCase(userWalletId = userWallet.walletId).getOrNull()
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : TokenDetailsDeepLinkHandler.Factory {
|
||||
override fun create(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
package com.tangem.feature.tokendetails.deeplink
|
||||
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
interface TokenDetailsDeepLinkActionTrigger {
|
||||
suspend fun trigger(txId: String)
|
||||
}
|
||||
|
||||
interface TokenDetailsDeepLinkActionListener {
|
||||
val tokenDetailsActionFlow: SharedFlow<String>
|
||||
}
|
||||
|
||||
@Singleton
|
||||
internal class DefaultTokenDetailsDeepLinkActionTrigger @Inject constructor() :
|
||||
TokenDetailsDeepLinkActionTrigger,
|
||||
TokenDetailsDeepLinkActionListener {
|
||||
|
||||
override val tokenDetailsActionFlow: SharedFlow<String>
|
||||
field = MutableSharedFlow<String>()
|
||||
|
||||
override suspend fun trigger(txId: String) {
|
||||
tokenDetailsActionFlow.emit(txId)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package com.tangem.feature.tokendetails.deeplink.di
|
||||
|
||||
import com.tangem.feature.tokendetails.deeplink.DefaultTokenDetailsDeepLinkActionTrigger
|
||||
import com.tangem.feature.tokendetails.deeplink.DefaultTokenDetailsDeepLinkHandler
|
||||
import com.tangem.feature.tokendetails.deeplink.TokenDetailsDeepLinkActionListener
|
||||
import com.tangem.feature.tokendetails.deeplink.TokenDetailsDeepLinkActionTrigger
|
||||
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
|
|
@ -17,4 +20,16 @@ internal interface TokenDetailsDeepLinkModule {
|
|||
fun bindWalletDeepLinkHandlerFactory(
|
||||
impl: DefaultTokenDetailsDeepLinkHandler.Factory,
|
||||
): TokenDetailsDeepLinkHandler.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindTokenDetailsDeepLinkActionTrigger(
|
||||
impl: DefaultTokenDetailsDeepLinkActionTrigger,
|
||||
): TokenDetailsDeepLinkActionTrigger
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindTokenDetailsDeepLinkActionListener(
|
||||
impl: DefaultTokenDetailsDeepLinkActionTrigger,
|
||||
): TokenDetailsDeepLinkActionListener
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ import com.tangem.domain.wallets.models.UserWalletId
|
|||
import com.tangem.domain.wallets.models.requireColdWallet
|
||||
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.feature.tokendetails.deeplink.TokenDetailsDeepLinkActionListener
|
||||
import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsCurrencyStatusAnalyticsSender
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender
|
||||
|
|
@ -80,7 +81,6 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBala
|
|||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express.ExpressStatusFactory
|
||||
import com.tangem.features.onramp.OnrampFeatureToggles
|
||||
import com.tangem.features.tokendetails.TokenDetailsComponent
|
||||
import com.tangem.features.tokendetails.impl.R
|
||||
import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter
|
||||
|
|
@ -127,7 +127,6 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
private val analyticsEventsHandler: AnalyticsEventHandler,
|
||||
private val vibratorHapticManager: VibratorHapticManager,
|
||||
private val clipboardManager: ClipboardManager,
|
||||
private val onrampFeatureToggles: OnrampFeatureToggles,
|
||||
private val shareManager: ShareManager,
|
||||
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
|
||||
private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter,
|
||||
|
|
@ -137,6 +136,7 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
getStakingIntegrationIdUseCase: GetStakingIntegrationIdUseCase,
|
||||
private val appRouter: AppRouter,
|
||||
private val router: InnerTokenDetailsRouter,
|
||||
private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener,
|
||||
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
|
||||
) : Model(), TokenDetailsClickIntents {
|
||||
|
||||
|
|
@ -157,6 +157,9 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null
|
||||
private var expressTxStatusTaskScheduler = SingleTaskScheduler<PersistentList<ExpressTransactionStateUM>>()
|
||||
|
||||
/** Transaction id to check for status */
|
||||
private val waitForFirstExpressStatusEmmit = MutableStateFlow(false)
|
||||
|
||||
private val stateFactory = TokenDetailsStateFactory(
|
||||
currentStateProvider = Provider { uiState.value },
|
||||
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
|
||||
|
|
@ -203,11 +206,7 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
initButtons()
|
||||
updateContent()
|
||||
handleBalanceHiding()
|
||||
}
|
||||
|
||||
fun onBuyCurrencyDeepLink() {
|
||||
val currency = cryptoCurrencyStatus?.currency ?: return
|
||||
analyticsEventsHandler.send(TokenScreenAnalyticsEvent.Bought(currency.symbol))
|
||||
checkForActionUpdates()
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
|
|
@ -335,6 +334,7 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
expressTxStatusTaskScheduler.cancelTask()
|
||||
expressStatusFactory.getExpressStatuses()
|
||||
.distinctUntilChanged()
|
||||
.onEach { waitForFirstExpressStatusEmmit.value = true }
|
||||
.onEach { expressTxs ->
|
||||
internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs(
|
||||
expressTxs = expressTxs,
|
||||
|
|
@ -516,30 +516,14 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
}
|
||||
|
||||
val status = cryptoCurrencyStatus ?: return
|
||||
if (onrampFeatureToggles.isFeatureEnabled) {
|
||||
modelScope.launch(dispatchers.main) {
|
||||
reduxStateHolder.dispatch(
|
||||
TradeCryptoAction.Buy(
|
||||
userWallet = userWallet,
|
||||
source = OnrampSource.TOKEN_DETAILS,
|
||||
cryptoCurrencyStatus = status,
|
||||
appCurrencyCode = selectedAppCurrencyFlow.value.code,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showErrorIfDemoModeOrElse {
|
||||
modelScope.launch(dispatchers.main) {
|
||||
reduxStateHolder.dispatch(
|
||||
TradeCryptoAction.Buy(
|
||||
userWallet = userWallet,
|
||||
source = OnrampSource.TOKEN_DETAILS,
|
||||
cryptoCurrencyStatus = status,
|
||||
appCurrencyCode = selectedAppCurrencyFlow.value.code,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
modelScope.launch {
|
||||
appRouter.push(
|
||||
AppRoute.Onramp(
|
||||
userWalletId = userWallet.walletId,
|
||||
currency = status.currency,
|
||||
source = OnrampSource.TOKEN_DETAILS,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -828,7 +812,8 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
}
|
||||
|
||||
override fun onExpressTransactionClick(txId: String) {
|
||||
val expressTxState = internalUiState.value.expressTxsToDisplay.first { it.info.txId == txId }
|
||||
val expressTxState = internalUiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId }
|
||||
?: return
|
||||
internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState)
|
||||
}
|
||||
|
||||
|
|
@ -1069,6 +1054,15 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun checkForActionUpdates() {
|
||||
combine(
|
||||
tokenDetailsDeepLinkActionListener.tokenDetailsActionFlow,
|
||||
waitForFirstExpressStatusEmmit.filter { it },
|
||||
) { transactionId, _ -> transactionId }
|
||||
.onEach(::onExpressTransactionClick)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue