feat: add emoji pack management screens
Add screens under ui/screen/loggedIn/emojipacks/ mirroring the bookmarkgroups layout: - list/ListOfEmojiPacksScreen plus EmojiPackItem for the pack feed, with a "My Emoji List" row at the top that surfaces kind 10030 selection count. - list/metadata/EmojiPackMetadataScreen(+ViewModel) for create/edit form. - display/EmojiPackScreen(+ViewModel) rendering the emoji grid, FAB to add, long-press to delete. AddEmojiDialog validates the shortcode live against EmojiUrlTag.isValidShortcode. - membershipManagement/EmojiPackSelectionScreen providing a single toggle for the user's kind 10030 selection. New routes EmojiPacks, EmojiPackView, EmojiPackMetadataEdit, and EmojiPackSelection wired through AppNavigation. A Manage Emoji Packs row is added to the drawer. English-only strings added to strings.xml.
This commit is contained in:
@@ -99,6 +99,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPackScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.ListOfEmojiPacksScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata.EmojiPackMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.EmojiPackSelectionScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
|
||||
@@ -257,6 +261,11 @@ fun BuildNavigation(
|
||||
composableFromBottomArgs<Route.PostBookmarkManagement> { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.ArticleBookmarkManagement> { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.EmojiPacks> { ListOfEmojiPacksScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.EmojiPackView> { EmojiPackScreen(it.dTag, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.EmojiPackMetadataEdit> { EmojiPackMetadataScreen(it.dTag, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.EmojiPackSelection> { EmojiPackSelectionScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
|
||||
composableFromBottomArgs<Route.QRDisplay> { ShowQRScreen(it.pubkey, accountViewModel, nav) }
|
||||
|
||||
composableFromBottomArgs<Route.ManualZapSplitPayment> { PayViaIntentScreen(it.paymentId, accountViewModel, nav) }
|
||||
|
||||
+9
@@ -57,6 +57,7 @@ import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.outlined.AccountBalanceWallet
|
||||
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.Drafts
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material.icons.outlined.GroupAdd
|
||||
import androidx.compose.material.icons.outlined.Language
|
||||
import androidx.compose.material.icons.outlined.MilitaryTech
|
||||
@@ -562,6 +563,14 @@ fun ListContent(
|
||||
route = Route.BookmarkGroups,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.manage_emoji_packs,
|
||||
icon = Icons.Outlined.EmojiEmotions,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.EmojiPacks,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.interest_sets_title,
|
||||
icon = Icons.Outlined.Tag,
|
||||
|
||||
@@ -147,6 +147,28 @@ sealed class Route {
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object EmojiPacks : Route()
|
||||
|
||||
@Serializable data class EmojiPackView(
|
||||
val dTag: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class EmojiPackMetadataEdit(
|
||||
val dTag: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class EmojiPackSelection(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object WebBookmarks : Route()
|
||||
|
||||
@Serializable object Drafts : Route()
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
|
||||
@Composable
|
||||
fun AddEmojiDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (EmojiUrlTag) -> Unit,
|
||||
) {
|
||||
var shortcode by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf("") }
|
||||
var packAddressText by remember { mutableStateOf("") }
|
||||
|
||||
val shortcodeValid by remember {
|
||||
derivedStateOf {
|
||||
shortcode.isNotBlank() && EmojiUrlTag.isValidShortcode(shortcode)
|
||||
}
|
||||
}
|
||||
|
||||
val shortcodeShowError by remember {
|
||||
derivedStateOf {
|
||||
shortcode.isNotBlank() && !EmojiUrlTag.isValidShortcode(shortcode)
|
||||
}
|
||||
}
|
||||
|
||||
val canConfirm by remember {
|
||||
derivedStateOf {
|
||||
shortcodeValid && url.isNotBlank()
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringRes(R.string.emoji_add_dialog_title)) },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Top,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = shortcode,
|
||||
onValueChange = { shortcode = it.trim(':').trim() },
|
||||
label = { Text(stringRes(R.string.emoji_shortcode_label)) },
|
||||
isError = shortcodeShowError,
|
||||
supportingText = {
|
||||
if (shortcodeShowError) {
|
||||
Text(stringRes(R.string.emoji_shortcode_invalid))
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(DoubleVertSpacer)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text(stringRes(R.string.emoji_url_label)) },
|
||||
)
|
||||
Spacer(DoubleVertSpacer)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = packAddressText,
|
||||
onValueChange = { packAddressText = it },
|
||||
label = { Text(stringRes(R.string.emoji_pack_address_label)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
enabled = canConfirm,
|
||||
onClick = {
|
||||
val parsedAddress = packAddressText.trim().takeIf { it.isNotEmpty() }?.let { Address.parse(it) }
|
||||
onConfirm(EmojiUrlTag(code = shortcode, url = url.trim(), emojiSet = parsedAddress))
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.add))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text(stringRes(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
|
||||
@Composable
|
||||
fun EmojiPackScreen(
|
||||
packIdentifier: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val viewModel: EmojiPackViewModel =
|
||||
viewModel(
|
||||
factory = EmojiPackViewModel.Initializer(accountViewModel.account, packIdentifier),
|
||||
)
|
||||
EmojiPackScreenView(viewModel, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EmojiPackScreenView(
|
||||
viewModel: EmojiPackViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val pack by viewModel.selectedPackFlow.collectAsStateWithLifecycle()
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var isAddingPrivate by remember { mutableStateOf(false) }
|
||||
var pendingDelete by remember { mutableStateOf<EmojiDeleteTarget?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ShorterTopAppBar(
|
||||
title = {
|
||||
pack?.let {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = it.title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
it.description?.let { description ->
|
||||
Text(
|
||||
text = description,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(nav::popBack) {
|
||||
ArrowBackIcon()
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(text = stringRes(R.string.add_emoji_fab)) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Add,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
isAddingPrivate = false
|
||||
showAddDialog = true
|
||||
},
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
top = padding.calculateTopPadding(),
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding),
|
||||
) {
|
||||
pack?.let { currentPack ->
|
||||
EmojiGrid(
|
||||
pack = currentPack,
|
||||
onLongPress = { emoji, isPrivate -> pendingDelete = EmojiDeleteTarget(emoji, isPrivate) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddDialog) {
|
||||
AddEmojiDialog(
|
||||
onDismiss = { showAddDialog = false },
|
||||
onConfirm = { tag ->
|
||||
accountViewModel.launchSigner {
|
||||
viewModel.addEmoji(tag, isAddingPrivate)
|
||||
}
|
||||
showAddDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingDelete?.let { target ->
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.emoji_remove_dialog_title, target.emoji.code),
|
||||
onDismiss = { pendingDelete = null },
|
||||
) {
|
||||
M3ActionSection {
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.Add,
|
||||
text = stringRes(R.string.quick_action_delete),
|
||||
isDestructive = true,
|
||||
) {
|
||||
accountViewModel.launchSigner {
|
||||
viewModel.removeEmoji(target.emoji.code, target.isPrivate)
|
||||
}
|
||||
pendingDelete = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class EmojiDeleteTarget(
|
||||
val emoji: EmojiUrlTag,
|
||||
val isPrivate: Boolean,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun EmojiGrid(
|
||||
pack: OwnedEmojiPack,
|
||||
onLongPress: (EmojiUrlTag, Boolean) -> Unit,
|
||||
) {
|
||||
val allEmojis =
|
||||
remember(pack) {
|
||||
pack.publicEmojis.map { it to false } + pack.privateEmojis.map { it to true }
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 56.dp),
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout
|
||||
.PaddingValues(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(allEmojis, key = { (emoji, isPrivate) -> "${emoji.code}-${if (isPrivate) "priv" else "pub"}" }) { (emoji, isPrivate) ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { onLongPress(emoji, isPrivate) },
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = emoji.url,
|
||||
contentDescription = emoji.code,
|
||||
modifier = Size35Modifier,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@Stable
|
||||
class EmojiPackViewModel(
|
||||
val account: Account,
|
||||
val packIdentifier: String,
|
||||
) : ViewModel() {
|
||||
val selectedPackFlow =
|
||||
account.ownedEmojiPacks
|
||||
.getOwnedEmojiPackFlow(packIdentifier)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(2500), null)
|
||||
|
||||
suspend fun addEmoji(
|
||||
emoji: EmojiUrlTag,
|
||||
isPrivate: Boolean,
|
||||
) {
|
||||
account.addEmojiToOwnedPack(packIdentifier, emoji, isPrivate)
|
||||
}
|
||||
|
||||
suspend fun removeEmoji(
|
||||
shortcode: String,
|
||||
isPrivate: Boolean,
|
||||
) {
|
||||
account.removeEmojiFromOwnedPack(packIdentifier, shortcode, isPrivate)
|
||||
}
|
||||
|
||||
suspend fun deletePack() {
|
||||
account.deleteOwnedEmojiPack(packIdentifier)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class Initializer(
|
||||
val account: Account,
|
||||
val packIdentifier: String,
|
||||
) : ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = EmojiPackViewModel(account, packIdentifier) as T
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
|
||||
@Composable
|
||||
fun EmojiPackItem(
|
||||
modifier: Modifier = Modifier,
|
||||
pack: OwnedEmojiPack,
|
||||
onClick: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(pack.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Column(
|
||||
modifier = NoSoTinyBorders,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
EmojiPackOptionsButton(
|
||||
onEdit = onEdit,
|
||||
onDelete = onDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
pack.description?.let {
|
||||
Text(
|
||||
it,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
Spacer(StdVertSpacer)
|
||||
EmojiPackPreviewThumbnails(pack)
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (!pack.image.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = pack.image,
|
||||
contentDescription = pack.title,
|
||||
modifier = Size40Modifier,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.EmojiEmotions,
|
||||
contentDescription = null,
|
||||
modifier = Size40Modifier,
|
||||
)
|
||||
}
|
||||
Spacer(StdVertSpacer)
|
||||
Text(
|
||||
text = stringRes(R.string.emoji_pack_count, pack.totalEmojis),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiPackPreviewThumbnails(pack: OwnedEmojiPack) {
|
||||
val first = remember(pack) { (pack.publicEmojis + pack.privateEmojis).take(6) }
|
||||
if (first.isEmpty()) return
|
||||
Row(
|
||||
horizontalArrangement = SpacedBy2dp,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
first.forEach { emoji ->
|
||||
Box(
|
||||
modifier = Size40Modifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = emoji.url,
|
||||
contentDescription = emoji.code,
|
||||
modifier = Size40Modifier,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiPackOptionsButton(
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val isMenuOpen = remember { mutableStateOf(false) }
|
||||
|
||||
ClickableBox(
|
||||
onClick = { isMenuOpen.value = true },
|
||||
) {
|
||||
VerticalDotsIcon()
|
||||
}
|
||||
|
||||
if (isMenuOpen.value) {
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.emoji_pack_actions_dialog_title),
|
||||
onDismiss = { isMenuOpen.value = false },
|
||||
) {
|
||||
M3ActionSection {
|
||||
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_emoji_pack)) {
|
||||
onEdit()
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
M3ActionSection {
|
||||
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) {
|
||||
onDelete()
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@Composable
|
||||
fun ListOfEmojiPacksScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
ListOfEmojiPacksFeed(
|
||||
listSource = accountViewModel.account.ownedEmojiPacks.listFeedFlow,
|
||||
selectedPacksFlow = accountViewModel.account.emoji.flow,
|
||||
openMyEmojiList = {
|
||||
// kind 10030 is a user's selection of packs. No dedicated screen yet; open pack edit instead.
|
||||
},
|
||||
addEmojiPack = { nav.nav(Route.EmojiPackMetadataEdit()) },
|
||||
openEmojiPack = { pack -> nav.nav(Route.EmojiPackView(pack.identifier)) },
|
||||
editEmojiPack = { pack -> nav.nav(Route.EmojiPackMetadataEdit(pack.identifier)) },
|
||||
deleteEmojiPack = { pack ->
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.deleteOwnedEmojiPack(pack.identifier)
|
||||
}
|
||||
},
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListOfEmojiPacksFeed(
|
||||
listSource: StateFlow<List<OwnedEmojiPack>>,
|
||||
selectedPacksFlow: StateFlow<List<StateFlow<NoteState>>?>,
|
||||
openMyEmojiList: () -> Unit,
|
||||
addEmojiPack: () -> Unit,
|
||||
openEmojiPack: (OwnedEmojiPack) -> Unit,
|
||||
editEmojiPack: (OwnedEmojiPack) -> Unit,
|
||||
deleteEmojiPack: (OwnedEmojiPack) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(caption = stringRes(R.string.emoji_packs_title), nav::popBack)
|
||||
},
|
||||
floatingActionButton = {
|
||||
EmojiPackFab(onAddPack = addEmojiPack)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
bottom = paddingValues.calculateBottomPadding(),
|
||||
).fillMaxHeight(),
|
||||
) {
|
||||
ListOfEmojiPacksFeedView(
|
||||
listSource = listSource,
|
||||
selectedPacksFlow = selectedPacksFlow,
|
||||
openMyEmojiList = openMyEmojiList,
|
||||
openItem = openEmojiPack,
|
||||
editItem = editEmojiPack,
|
||||
deleteItem = deleteEmojiPack,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListOfEmojiPacksFeedView(
|
||||
listSource: StateFlow<List<OwnedEmojiPack>>,
|
||||
selectedPacksFlow: StateFlow<List<StateFlow<NoteState>>?>,
|
||||
openMyEmojiList: () -> Unit,
|
||||
openItem: (OwnedEmojiPack) -> Unit,
|
||||
editItem: (OwnedEmojiPack) -> Unit,
|
||||
deleteItem: (OwnedEmojiPack) -> Unit,
|
||||
) {
|
||||
val feedState by listSource.collectAsStateWithLifecycle()
|
||||
val selectedPacks by selectedPacksFlow.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
state = rememberLazyListState(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
item {
|
||||
MyEmojiListRow(
|
||||
selectedPackCount = selectedPacks?.size ?: 0,
|
||||
onClick = openMyEmojiList,
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
|
||||
if (feedState.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringRes(R.string.no_emoji_packs),
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
feedState,
|
||||
key = { _: Int, item: OwnedEmojiPack -> item.identifier },
|
||||
) { _, pack ->
|
||||
EmojiPackItem(
|
||||
modifier = Modifier.fillMaxSize().animateItem(),
|
||||
pack = pack,
|
||||
onClick = { openItem(pack) },
|
||||
onEdit = { editItem(pack) },
|
||||
onDelete = { deleteItem(pack) },
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MyEmojiListRow(
|
||||
selectedPackCount: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringRes(R.string.my_emoji_list_title),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = stringRes(R.string.my_emoji_list_explainer),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.EmojiEmotions,
|
||||
contentDescription = null,
|
||||
modifier = Size40Modifier,
|
||||
)
|
||||
Spacer(StdVertSpacer)
|
||||
Text(text = stringRes(R.string.emoji_pack_count, selectedPackCount))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmojiPackFab(onAddPack: () -> Unit) {
|
||||
ExtendedFloatingActionButton(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.new_emoji_pack))
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.EmojiEmotions,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = onAddPack,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
|
||||
@Composable
|
||||
fun EmojiPackMetadataScreen(
|
||||
packIdentifier: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val viewModel: EmojiPackMetadataViewModel = viewModel()
|
||||
viewModel.init(accountViewModel)
|
||||
|
||||
if (packIdentifier != null) {
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.load(packIdentifier)
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.new()
|
||||
}
|
||||
}
|
||||
|
||||
EmojiPackMetadataScaffold(viewModel, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EmojiPackMetadataScaffold(
|
||||
viewModel: EmojiPackMetadataViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
EmojiPackMetadataTopBar(
|
||||
viewModel = viewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = 10.dp,
|
||||
end = 10.dp,
|
||||
top = pad.calculateTopPadding(),
|
||||
bottom = pad.calculateBottomPadding(),
|
||||
).consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
) {
|
||||
item {
|
||||
PackName(viewModel)
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
PackImage(viewModel)
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
PackDescription(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiPackMetadataTopBar(
|
||||
viewModel: EmojiPackMetadataViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (viewModel.isNewPack) {
|
||||
CreatingTopBar(
|
||||
titleRes = R.string.new_emoji_pack,
|
||||
isActive = viewModel::canPost,
|
||||
onCancel = {
|
||||
viewModel.clear()
|
||||
nav.popBack()
|
||||
},
|
||||
onPost = {
|
||||
try {
|
||||
viewModel.createOrUpdate()
|
||||
nav.popBack()
|
||||
} catch (e: SignerExceptions.ReadOnlyException) {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_be_able_to_sign_events,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
SavingTopBar(
|
||||
titleRes = R.string.edit_emoji_pack,
|
||||
isActive = viewModel::canPost,
|
||||
onCancel = {
|
||||
viewModel.clear()
|
||||
nav.popBack()
|
||||
},
|
||||
onPost = {
|
||||
try {
|
||||
viewModel.createOrUpdate()
|
||||
nav.popBack()
|
||||
} catch (e: SignerExceptions.ReadOnlyException) {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_be_able_to_sign_events,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PackName(viewModel: EmojiPackMetadataViewModel) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.emoji_pack_name_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = viewModel.name.value,
|
||||
onValueChange = { viewModel.name.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.emoji_pack_name_label),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PackImage(viewModel: EmojiPackMetadataViewModel) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.emoji_pack_image_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = viewModel.picture.value,
|
||||
onValueChange = { viewModel.picture.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "https://example.com/cover.jpg",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PackDescription(viewModel: EmojiPackMetadataViewModel) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.emoji_pack_description_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = viewModel.description.value,
|
||||
onValueChange = { viewModel.description.value = it },
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
minLines = 3,
|
||||
)
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Stable
|
||||
class EmojiPackMetadataViewModel : ViewModel() {
|
||||
private lateinit var accountViewModel: AccountViewModel
|
||||
private lateinit var account: Account
|
||||
|
||||
var pack by mutableStateOf<OwnedEmojiPack?>(null)
|
||||
val isNewPack by derivedStateOf { pack == null }
|
||||
|
||||
val name = mutableStateOf(TextFieldValue())
|
||||
val picture = mutableStateOf(TextFieldValue())
|
||||
val description = mutableStateOf(TextFieldValue())
|
||||
|
||||
val canPost by derivedStateOf {
|
||||
name.value.text.isNotBlank()
|
||||
}
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
}
|
||||
|
||||
fun new() {
|
||||
pack = null
|
||||
clear()
|
||||
}
|
||||
|
||||
fun load(dTag: String) {
|
||||
val existing = account.ownedEmojiPacks.getPack(dTag)
|
||||
pack = existing
|
||||
name.value = TextFieldValue(existing?.title ?: "")
|
||||
picture.value = TextFieldValue(existing?.image ?: "")
|
||||
description.value = TextFieldValue(existing?.description ?: "")
|
||||
}
|
||||
|
||||
fun createOrUpdate() {
|
||||
accountViewModel.launchSigner {
|
||||
val currentPack = pack
|
||||
if (currentPack == null) {
|
||||
account.createOwnedEmojiPack(
|
||||
title = name.value.text,
|
||||
description = description.value.text,
|
||||
image = picture.value.text,
|
||||
)
|
||||
} else {
|
||||
account.updateOwnedEmojiPackMetadata(
|
||||
dTag = currentPack.identifier,
|
||||
newTitle = name.value.text,
|
||||
newDescription = description.value.text,
|
||||
newImage = picture.value.text,
|
||||
)
|
||||
}
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
name.value = TextFieldValue()
|
||||
picture.value = TextFieldValue()
|
||||
description.value = TextFieldValue()
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.recalculateWindowInsets
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BookmarkAdd
|
||||
import androidx.compose.material.icons.filled.BookmarkRemove
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
|
||||
@Composable
|
||||
fun EmojiPackSelectionScreen(
|
||||
packAddress: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadAddressableNote(address = packAddress, accountViewModel = accountViewModel) { note ->
|
||||
note?.let {
|
||||
EmojiPackSelectionView(
|
||||
modifier = Modifier.fillMaxSize().recalculateWindowInsets(),
|
||||
note = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiPackSelectionView(
|
||||
modifier: Modifier = Modifier,
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopBarWithBackButton(caption = stringRes(R.string.emoji_pack_management_title), nav::popBack)
|
||||
},
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(
|
||||
top = contentPadding.calculateTopPadding(),
|
||||
bottom = contentPadding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(contentPadding)
|
||||
.imePadding(),
|
||||
) {
|
||||
EmojiPackSelectionBody(note, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiPackSelectionBody(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
item {
|
||||
LoadAddressableNote(
|
||||
address = accountViewModel.account.emoji.getEmojiPackSelectionAddress(),
|
||||
accountViewModel = accountViewModel,
|
||||
) { selectionNote ->
|
||||
selectionNote?.let {
|
||||
val hasAddedThis by observeNoteAndMap(it, accountViewModel) { currentNote ->
|
||||
currentNote.event?.isTaggedAddressableNote(note.idHex) == true
|
||||
}
|
||||
|
||||
EmojiPackSelectionItem(
|
||||
isIncluded = hasAddedThis,
|
||||
packTitle = (note.event as? EmojiPackEvent)?.titleOrName() ?: note.dTag(),
|
||||
onAdd = {
|
||||
accountViewModel.addEmojiPack(note)
|
||||
},
|
||||
onRemove = {
|
||||
accountViewModel.removeEmojiPack(note)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiPackSelectionItem(
|
||||
isIncluded: Boolean,
|
||||
packTitle: String,
|
||||
onAdd: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = { if (isIncluded) onRemove() else onAdd() }),
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringRes(R.string.my_emoji_list_title),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text =
|
||||
if (isIncluded) {
|
||||
stringRes(R.string.emoji_pack_is_in_list, packTitle)
|
||||
} else {
|
||||
stringRes(R.string.emoji_pack_is_not_in_list, packTitle)
|
||||
},
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.EmojiEmotions,
|
||||
contentDescription = null,
|
||||
modifier = Size40Modifier,
|
||||
)
|
||||
Spacer(StdVertSpacer)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { if (isIncluded) onRemove() else onAdd() },
|
||||
modifier =
|
||||
Modifier
|
||||
.background(
|
||||
color =
|
||||
if (isIncluded) {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
shape = RoundedCornerShape(percent = 80),
|
||||
),
|
||||
) {
|
||||
if (isIncluded) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.BookmarkRemove,
|
||||
contentDescription = stringRes(R.string.remove_from_emoji_list),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.BookmarkAdd,
|
||||
contentDescription = stringRes(R.string.add_to_emoji_list),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2372,4 +2372,30 @@
|
||||
<string name="ai_tone_more_direct">More Direct</string>
|
||||
<string name="ai_tone_punchy">Punchy</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
|
||||
<!-- Emoji packs -->
|
||||
<string name="emoji_packs_title">Emoji Packs</string>
|
||||
<string name="emoji_pack_management_title">Add to Emoji List</string>
|
||||
<string name="add_to_emoji_list">Add to my emoji list</string>
|
||||
<string name="remove_from_emoji_list">Remove from my emoji list</string>
|
||||
<string name="new_emoji_pack">New emoji pack</string>
|
||||
<string name="edit_emoji_pack">Edit emoji pack</string>
|
||||
<string name="emoji_shortcode_label">Shortcode (e.g. :smile:)</string>
|
||||
<string name="emoji_shortcode_invalid">Only letters, numbers, hyphens, and underscores</string>
|
||||
<string name="emoji_url_label">Image URL</string>
|
||||
<string name="emoji_pack_address_label">Pack address (optional)</string>
|
||||
<string name="emoji_pack_name_label">Pack name</string>
|
||||
<string name="emoji_pack_description_label">Description (optional)</string>
|
||||
<string name="emoji_pack_image_label">Cover image URL (optional)</string>
|
||||
<string name="no_emoji_packs">You don\'t have any emoji packs yet</string>
|
||||
<string name="emoji_pack_count">%1$d emojis</string>
|
||||
<string name="my_emoji_list_title">My Emoji List</string>
|
||||
<string name="my_emoji_list_explainer">Emoji packs you\'ve added to your selection (NIP-51 kind 10030)</string>
|
||||
<string name="add_emoji_fab">Add emoji</string>
|
||||
<string name="emoji_add_dialog_title">Add custom emoji</string>
|
||||
<string name="emoji_remove_dialog_title">Remove :%1$s:?</string>
|
||||
<string name="emoji_pack_is_in_list">\"%1$s\" is in your emoji list</string>
|
||||
<string name="emoji_pack_is_not_in_list">\"%1$s\" is not in your emoji list</string>
|
||||
<string name="emoji_pack_actions_dialog_title">Emoji pack actions</string>
|
||||
<string name="manage_emoji_packs">Manage Emoji Packs</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user