Update sticker pack preview.

This commit is contained in:
Michelle Tang
2026-09-23 16:10:57 -04:00
parent f627988255
commit 442d5ec5af
15 changed files with 1126 additions and 521 deletions
+1 -1
View File
@@ -206,7 +206,7 @@
</activity>
<activity
android:name=".stickers.preview.StickerPackPreviewActivity"
android:name=".stickers.preview.StickerPackPreviewActivityV2"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="true"
android:launchMode="singleTask"
@@ -57,7 +57,6 @@ import androidx.annotation.MainThread
import androidx.annotation.StringRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.SearchView
import androidx.compose.runtime.getValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.ComposeView
@@ -267,6 +266,8 @@ import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.database.model.StickerPackId
import org.thoughtcrime.securesms.database.model.StickerPackKey
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.databinding.V2ConversationBackgroundBinding
import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding
@@ -356,7 +357,7 @@ import org.thoughtcrime.securesms.stickers.StickerEventListener
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent
import org.thoughtcrime.securesms.stickers.manage.StickerManagementScreen
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivity
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivityV2
import org.thoughtcrime.securesms.stories.StoryViewerArgs
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
import org.thoughtcrime.securesms.util.BubbleUtil
@@ -3693,7 +3694,7 @@ class ConversationFragment :
override fun onStickerClicked(stickerLocator: StickerLocator) {
context ?: return
startActivity(StickerPackPreviewActivity.getIntent(stickerLocator.packId, stickerLocator.packKey))
startActivity(StickerPackPreviewActivityV2.createIntent(StickerPackId(stickerLocator.packId), StickerPackKey(stickerLocator.packKey)))
}
override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) {
@@ -58,13 +58,15 @@ data class StickerPackParams(
companion object {
fun fromExternalUri(uri: Uri?): StickerPackParams? {
if (uri == null) return null
return (StickerUrl.parseActionUri(uri) ?: StickerUrl.parseShareLink(uri.toString()))
.map { parseResult ->
StickerPackParams(
id = StickerPackId(parseResult.first),
key = StickerPackKey(parseResult.second)
)
}.orNull()
val parseResult = StickerUrl.parseActionUri(uri).orNull()
?: StickerUrl.parseShareLink(uri.toString()).orNull()
?: return null
return StickerPackParams(
id = StickerPackId(parseResult.first),
key = StickerPackKey(parseResult.second)
)
}
}
}
@@ -26,7 +26,7 @@ import org.thoughtcrime.securesms.database.model.StickerPackId
import org.thoughtcrime.securesms.database.model.StickerPackKey
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.stickers.StickerUrl
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivity
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivityV2
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -101,6 +101,6 @@ class StickerManagementActivity : PassphraseRequiredActivity() {
}
private fun navigateToStickerPreview(packId: StickerPackId, packKey: StickerPackKey) {
startActivity(StickerPackPreviewActivity.getIntent(packId.value, packKey.value))
startActivity(StickerPackPreviewActivityV2.createIntent(packId, packKey))
}
}
@@ -37,7 +37,7 @@ import org.thoughtcrime.securesms.database.model.StickerPackId
import org.thoughtcrime.securesms.database.model.StickerPackKey
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.stickers.StickerUrl
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivity
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivityV2
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -153,6 +153,6 @@ class StickerManagementBottomSheet : ComposeBottomSheetDialogFragment() {
}
private fun navigateToStickerPreview(packId: StickerPackId, packKey: StickerPackKey) {
startActivity(StickerPackPreviewActivity.getIntent(packId.value, packKey.value))
startActivity(StickerPackPreviewActivityV2.createIntent(packId, packKey))
}
}
@@ -0,0 +1,18 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.stickers.preview
import org.thoughtcrime.securesms.database.model.StickerPackParams
/**
* Actions that happen in [StickerPackPreviewActivityV2]
*/
sealed interface StickerPackPreviewAction {
data class SendPack(val params: StickerPackParams) : StickerPackPreviewAction
data class ShareExternally(val params: StickerPackParams) : StickerPackPreviewAction
data object LinkCopied : StickerPackPreviewAction
data object PackUnavailable : StickerPackPreviewAction
}
@@ -1,287 +0,0 @@
package org.thoughtcrime.securesms.stickers.preview;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Point;
import android.os.Bundle;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.core.view.WindowInsetsCompat;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs;
import org.signal.glide.apng.ApngOptions;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.sharing.MultiShareArgs;
import org.thoughtcrime.securesms.stickers.StickerManifest;
import org.thoughtcrime.securesms.stickers.StickerManifest.Sticker;
import org.thoughtcrime.securesms.stickers.StickerRemoteUri;
import org.thoughtcrime.securesms.stickers.StickerRolloverTouchListener;
import org.thoughtcrime.securesms.stickers.StickerUrl;
import org.thoughtcrime.securesms.stickers.manage.StickerManagementRepository;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter;
import org.whispersystems.signalservice.api.util.OptionalUtil;
import java.util.Collections;
import java.util.Optional;
import kotlin.Pair;
/**
* Shows the contents of a pack and allows the user to install it (if not installed) or remove it
* (if installed). This is also the handler for sticker pack deep links.
*/
public final class StickerPackPreviewActivity extends PassphraseRequiredActivity
implements StickerRolloverTouchListener.RolloverEventListener,
StickerRolloverTouchListener.RolloverStickerRetriever,
StickerPackPreviewAdapter.EventListener
{
private static final String TAG = Log.tag(StickerPackPreviewActivity.class);
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private StickerPackPreviewViewModel viewModel;
private ImageView coverImage;
private TextView stickerTitle;
private TextView stickerAuthor;
private View installButton;
private View removeButton;
private RecyclerView stickerList;
private View shareButton;
private View shareButtonImage;
private StickerPackPreviewAdapter adapter;
private GridLayoutManager layoutManager;
private StickerRolloverTouchListener touchListener;
public static Intent getIntent(@NonNull String packId, @NonNull String packKey) {
Intent intent = new Intent(Intent.ACTION_VIEW, StickerUrl.createActionUri(packId, packKey));
intent.addCategory(Intent.CATEGORY_DEFAULT);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
return intent;
}
@Override
protected void onPreCreate() {
super.onPreCreate();
dynamicTheme.onCreate(this);
}
@Override
protected void onCreate(Bundle savedInstanceState, boolean ready) {
setContentView(R.layout.sticker_preview_activity);
Optional<Pair<String, String>> stickerParams = StickerUrl.parseExternalUri(getIntent().getData());
if (!stickerParams.isPresent()) {
Log.w(TAG, "Invalid URI!");
presentError();
return;
}
String packId = stickerParams.get().getFirst();
String packKey = stickerParams.get().getSecond();
initToolbar();
initView();
initViewModel(packId, packKey);
getSupportFragmentManager().setFragmentResultListener(MultiselectForwardFragment.RESULT_KEY, this, (requestKey, result) -> {
if (result.getBoolean(MultiselectForwardFragment.RESULT_SENT, false)) {
finish();
}
});
}
@Override
protected void onResume() {
super.onResume();
dynamicTheme.onResume(this);
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onScreenWidthChanged(getScreenWidth());
}
@Override
public void onStickerLongPress(@NonNull View view) {
if (touchListener != null) {
touchListener.enterHoverMode(stickerList, view);
}
}
@Override
public void onStickerPopupStarted() {
}
@Override
public void onStickerPopupEnded() {
}
@Override
public @Nullable Pair<Object, String> getStickerDataFromView(@NonNull View view) {
if (stickerList != null) {
StickerPackPreviewAdapter.StickerViewHolder holder = (StickerPackPreviewAdapter.StickerViewHolder) stickerList.getChildViewHolder(view);
if (holder != null) {
return new Pair<>(holder.getCurrentGlideModel(), holder.getCurrentEmoji());
}
}
return null;
}
private void initView() {
this.coverImage = findViewById(R.id.sticker_install_cover);
this.stickerTitle = findViewById(R.id.sticker_install_title);
this.stickerAuthor = findViewById(R.id.sticker_install_author);
this.installButton = findViewById(R.id.sticker_install_button);
this.removeButton = findViewById(R.id.sticker_install_remove_button);
this.stickerList = findViewById(R.id.sticker_install_list);
this.shareButton = findViewById(R.id.sticker_install_share_button);
this.shareButtonImage = findViewById(R.id.sticker_install_share_button_image);
SystemWindowInsetsSetter.attach(installButton, this, WindowInsetsCompat.Type.navigationBars(), SystemWindowInsetsSetter.ApplyMode.MARGIN);
SystemWindowInsetsSetter.attach(removeButton, this, WindowInsetsCompat.Type.navigationBars(), SystemWindowInsetsSetter.ApplyMode.MARGIN);
this.adapter = new StickerPackPreviewAdapter(Glide.with(this), this, true);
this.layoutManager = new GridLayoutManager(this, 2);
this.touchListener = new StickerRolloverTouchListener(this, Glide.with(this), this, this);
onScreenWidthChanged(getScreenWidth());
stickerList.setLayoutManager(layoutManager);
stickerList.addOnItemTouchListener(touchListener);
stickerList.setAdapter(adapter);
}
private void initToolbar() {
Toolbar toolbar = findViewById(R.id.sticker_install_toolbar);
SystemWindowInsetsSetter.attach(toolbar, this, WindowInsetsCompat.Type.statusBars());
setSupportActionBar(toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setTitle(R.string.StickerPackPreviewActivity_stickers);
toolbar.setNavigationOnClickListener(v -> onBackPressed());
}
private void initViewModel(@NonNull String packId, @NonNull String packKey) {
viewModel = new ViewModelProvider(this, new StickerPackPreviewViewModel.Factory(getApplication(),
new StickerPackPreviewRepository(),
StickerManagementRepository.INSTANCE)
).get(StickerPackPreviewViewModel.class);
viewModel.getStickerManifest(packId, packKey).observe(this, manifest -> {
if (manifest == null) return;
if (manifest.isPresent()) {
presentManifest(manifest.get().getManifest());
presentButton(manifest.get().isInstalled());
presentShareButton(manifest.get().isInstalled(), manifest.get().getManifest().getPackId(), manifest.get().getManifest().getPackKey());
} else {
presentError();
}
});
}
private void presentManifest(@NonNull StickerManifest manifest) {
stickerTitle.setText(manifest.getTitle().orElse(getString(R.string.StickerPackPreviewActivity_untitled)));
stickerAuthor.setText(manifest.getAuthor().orElse(getString(R.string.StickerPackPreviewActivity_unknown)));
adapter.setStickers(manifest.getStickers());
Sticker first = manifest.getStickers().isEmpty() ? null : manifest.getStickers().get(0);
Sticker cover = OptionalUtil.or(manifest.getCover(), Optional.ofNullable(first)).orElse(null);
if (cover != null) {
Object model = cover.getUri().isPresent() ? new DecryptableUri(cover.getUri().get())
: new StickerRemoteUri(cover.getPackId(), cover.getPackKey(), cover.getId());
Glide.with(this).load(model)
.transition(DrawableTransitionOptions.withCrossFade())
.fitCenter()
.set(ApngOptions.ANIMATE, true)
.into(coverImage);
} else {
coverImage.setImageDrawable(null);
}
}
private void presentButton(boolean installed) {
if (installed) {
removeButton.setVisibility(View.VISIBLE);
removeButton.setOnClickListener(v -> {
viewModel.onRemoveClicked();
finish();
});
installButton.setVisibility(View.GONE);
installButton.setOnClickListener(null);
} else {
installButton.setVisibility(View.VISIBLE);
installButton.setOnClickListener(v -> {
viewModel.onInstallClicked();
finish();
});
removeButton.setVisibility(View.GONE);
removeButton.setOnClickListener(null);
}
}
private void presentShareButton(boolean installed, @NonNull String packId, @NonNull String packKey) {
if (installed) {
shareButton.setVisibility(View.VISIBLE);
shareButtonImage.setVisibility(View.VISIBLE);
shareButton.setOnClickListener(v -> {
MultiselectForwardFragment.showBottomSheet(
getSupportFragmentManager(),
new MultiselectForwardFragmentArgs(
Collections.singletonList(new MultiShareArgs.Builder()
.withDraftText(StickerUrl.createShareLink(packId, packKey))
.build()),
R.string.MultiselectForwardFragment__share_with
)
);
});
} else {
shareButton.setVisibility(View.GONE);
shareButtonImage.setVisibility(View.GONE);
shareButton.setOnClickListener(null);
}
}
private void presentError() {
Toast.makeText(this, R.string.StickerPackPreviewActivity_failed_to_load_sticker_pack, Toast.LENGTH_SHORT).show();
finish();
}
private void onScreenWidthChanged(int screenWidth) {
if (layoutManager != null) {
int availableWidth = screenWidth - (2 * getResources().getDimensionPixelOffset(R.dimen.sticker_preview_gutter_size));
layoutManager.setSpanCount(availableWidth / getResources().getDimensionPixelOffset(R.dimen.sticker_preview_sticker_size));
}
}
private int getScreenWidth() {
Point size = new Point();
getWindowManager().getDefaultDisplay().getSize(size);
return size.x;
}
}
@@ -9,49 +9,68 @@ import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ButtonDefaults
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
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 androidx.lifecycle.lifecycleScope
import com.bumptech.glide.load.Key
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.IconButtons.IconButton
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.dismissWithAnimation
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.orNull
import org.signal.core.util.toOptional
@@ -65,13 +84,12 @@ import org.thoughtcrime.securesms.database.model.StickerPackId
import org.thoughtcrime.securesms.database.model.StickerPackKey
import org.thoughtcrime.securesms.database.model.StickerPackParams
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.stickers.BlessedPacks
import org.thoughtcrime.securesms.stickers.StickerManifest
import org.thoughtcrime.securesms.stickers.StickerPreviewDataFactory
import org.thoughtcrime.securesms.stickers.StickerRemoteUri
import org.thoughtcrime.securesms.stickers.StickerUrl
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewUiState.ContentState
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewUiState.NavTarget
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewUiState.UserPrompt
import org.thoughtcrime.securesms.util.viewModel
import java.text.NumberFormat
import kotlin.jvm.optionals.getOrElse
@@ -105,97 +123,163 @@ class StickerPackPreviewActivityV2 : PassphraseRequiredActivity() {
setContent {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
uiState.navTarget?.let(::navigateToTarget)
uiState.userMessage?.let(::showUserMessage)
CollectActions(viewModel.actions, ::handleAction)
SignalTheme {
StickerPackPreviewScreen(
uiState = uiState,
callbacks = remember {
object : StickerPackPreviewScreenCallbacks {
override fun onNavigateTo(target: NavTarget) = navigateToTarget(target)
override fun onForwardClick(params: StickerPackParams) = openShareSheet(params)
override fun onInstallClick(params: StickerPackParams) = viewModel.installStickerPack(params)
override fun onUninstallClick(params: StickerPackParams) = viewModel.uninstallStickerPack(params)
}
}
onEvent = viewModel::onEvent,
onNavigationClick = { onBackPressedDispatcher.onBackPressed() }
)
}
}
}
private fun navigateToTarget(target: NavTarget) {
lifecycleScope.launch {
when (target) {
is NavTarget.Up -> {
lifecycleScope.launch {
if (target.delay != null) delay(target.delay.inWholeMilliseconds)
onBackPressedDispatcher.onBackPressed()
}
}
private fun handleAction(action: StickerPackPreviewAction) {
when (action) {
is StickerPackPreviewAction.SendPack -> openPackShareSheet(action.params)
is StickerPackPreviewAction.ShareExternally -> openSystemShareSheet(action.params)
StickerPackPreviewAction.LinkCopied -> {
Toast.makeText(this, R.string.StickerManagement_copied, Toast.LENGTH_SHORT).show()
}
viewModel.setNavTargetConsumed()
}
}
private fun showUserMessage(type: StickerPackPreviewUiState.MessageType) {
when (type) {
StickerPackPreviewUiState.MessageType.STICKER_PACK_LOAD_FAILED -> {
StickerPackPreviewAction.PackUnavailable -> {
Toast.makeText(this, R.string.StickerPackPreviewActivity_failed_to_load_sticker_pack, Toast.LENGTH_SHORT).show()
onBackPressedDispatcher.onBackPressed()
}
}
viewModel.setUserMessageConsumed()
}
private fun openShareSheet(params: StickerPackParams) {
supportFragmentManager.setFragmentResultListener(MultiselectForwardFragment.RESULT_KEY, this) { _: String?, result: Bundle ->
if (result.getBoolean(MultiselectForwardFragment.RESULT_SENT, false)) {
navigateToTarget(NavTarget.Up())
}
}
private fun openPackShareSheet(params: StickerPackParams) {
MultiselectForwardFragment.showBottomSheet(
supportFragmentManager = supportFragmentManager,
multiselectForwardFragmentArgs = MultiselectForwardFragmentArgs(
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(StickerUrl.createShareLink(params.id.value, params.key.value))
.withDraftText(params.shareLink)
.build()
),
title = R.string.StickerManagement_share_sheet_title
)
)
}
}
interface StickerPackPreviewScreenCallbacks {
fun onNavigateTo(target: NavTarget)
fun onForwardClick(params: StickerPackParams)
fun onInstallClick(params: StickerPackParams)
fun onUninstallClick(params: StickerPackParams)
private fun openSystemShareSheet(params: StickerPackParams) {
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, params.shareLink)
}
object Empty : StickerPackPreviewScreenCallbacks {
override fun onNavigateTo(target: NavTarget) = Unit
override fun onForwardClick(params: StickerPackParams) = Unit
override fun onInstallClick(params: StickerPackParams) = Unit
override fun onUninstallClick(params: StickerPackParams) = Unit
startActivity(Intent.createChooser(shareIntent, null))
}
}
@Composable
private fun StickerPackPreviewScreen(
uiState: StickerPackPreviewUiState,
callbacks: StickerPackPreviewScreenCallbacks
onEvent: (StickerPackPreviewEvent) -> Unit,
onNavigationClick: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
stickerManifest = if (uiState.contentState is ContentState.HasData) uiState.contentState.stickerManifest else null,
onNavigateUp = { callbacks.onNavigateTo(NavTarget.Up()) },
onForwardClick = callbacks::onForwardClick
val gridState = rememberLazyGridState()
val showToolbarDetails by remember { derivedStateOf { gridState.firstVisibleItemIndex > 0 } }
val toolbarAlpha by animateFloatAsState(targetValue = if (showToolbarDetails) 1f else 0f, label = "toolbar-alpha")
val loadedState = uiState.contentState as? ContentState.HasData
val stickerManifest = loadedState?.stickerManifest
val untitledPackTitle = stringResource(R.string.StickerPackPreviewActivity_untitled)
val menuController = remember { DropdownMenus.MenuController() }
LaunchedEffect(showToolbarDetails) {
if (!showToolbarDetails) {
menuController.hide()
}
}
Scaffolds.Settings(
title = stickerManifest?.let { it.title.orNull() ?: untitledPackTitle } ?: "",
onNavigationClick = onNavigationClick,
navigationIcon = SignalIcons.ArrowStart.imageVector,
navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description),
titleContent = { _, toolbarTitle ->
ToolbarTitle(
title = toolbarTitle,
alpha = toolbarAlpha,
isBlessed = loadedState?.isBlessed ?: false
)
},
actions = {
if (loadedState != null && toolbarAlpha > 0f) {
IconButton(
onClick = { menuController.show() },
modifier = Modifier
.padding(horizontal = 8.dp)
.alpha(toolbarAlpha)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_more_vertical),
contentDescription = stringResource(R.string.StickerManagement_accessibility_open_top_bar_menu)
)
}
DropdownMenus.Menu(
controller = menuController,
offsetX = 24.dp,
offsetY = 0.dp
) {
DropdownMenus.ItemWithIcon(
menuController = menuController,
imageVector = SignalIcons.Forward.imageVector,
stringResId = R.string.StickerManagement_menu_send_pack,
onClick = { onEvent(StickerPackPreviewEvent.SendPackClicked) }
)
DropdownMenus.ItemWithIcon(
menuController = menuController,
imageVector = SignalIcons.Link.imageVector,
stringResId = R.string.StickerManagement_menu_link_pack,
onClick = { onEvent(StickerPackPreviewEvent.LinkPackClicked) }
)
if (loadedState.isPackInstalled) {
DropdownMenus.ItemWithIcon(
menuController = menuController,
imageVector = ImageVector.vectorResource(R.drawable.symbol_minus_circle_24),
stringResId = R.string.StickerManagement_menu_remove_pack,
onClick = { onEvent(StickerPackPreviewEvent.UninstallClicked) }
)
}
}
}
},
bottomBar = {
if (loadedState != null && !loadedState.isPackInstalled) {
InstallPackButton(
onClick = { onEvent(StickerPackPreviewEvent.InstallClicked) }
)
}
}
) { padding ->
if (uiState.userPrompt is UserPrompt.ConfirmRemovePack) {
Dialogs.SimpleAlertDialog(
title = pluralStringResource(R.plurals.StickerManagement_delete_n_packs_confirmation, 1, 1),
body = pluralStringResource(R.plurals.StickerManagement_delete_n_packs_confirmation_body, 1, 1),
confirm = stringResource(R.string.StickerManagement_menu_remove_pack),
confirmColor = MaterialTheme.colorScheme.error,
dismiss = stringResource(android.R.string.cancel),
onConfirm = { onEvent(StickerPackPreviewEvent.UninstallConfirmed) },
onDeny = { onEvent(StickerPackPreviewEvent.UninstallCanceled) },
onDismissRequest = { onEvent(StickerPackPreviewEvent.UninstallCanceled) }
)
}
if (uiState.userPrompt is UserPrompt.ShareStickerPack && stickerManifest != null) {
StickerPackShareSheet(
params = stickerManifest.params,
stickerManifest = stickerManifest,
onEvent = onEvent
)
}
when (uiState.contentState) {
is ContentState.Loading -> {
Box(
@@ -209,12 +293,10 @@ private fun StickerPackPreviewScreen(
}
is ContentState.HasData -> {
uiState.navTarget?.let(callbacks::onNavigateTo)
StickerPackPreviewContent(
contentState = uiState.contentState,
onInstallClick = callbacks::onInstallClick,
onUninstallClick = callbacks::onUninstallClick,
gridState = gridState,
onEvent = onEvent,
modifier = Modifier.padding(padding)
)
}
@@ -224,93 +306,116 @@ private fun StickerPackPreviewScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBar(
stickerManifest: StickerManifest?,
onNavigateUp: () -> Unit,
onForwardClick: (params: StickerPackParams) -> Unit
private fun ToolbarTitle(
title: String,
alpha: Float,
isBlessed: Boolean,
modifier: Modifier = Modifier
) {
Scaffolds.DefaultTopAppBar(
title = "", // TODO collapse title into top app bar on scroll
titleContent = { _, text -> Text(text = text, style = MaterialTheme.typography.titleLarge) },
navigationIcon = SignalIcons.ArrowStart.imageVector,
navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description),
onNavigationClick = onNavigateUp,
actions = {
if (stickerManifest != null) {
IconButton(
onClick = { onForwardClick(stickerManifest.params) },
modifier = Modifier.padding(horizontal = 8.dp)
) {
Icon(
imageVector = SignalIcons.Forward.imageVector,
contentDescription = stringResource(R.string.StickerManagement_menu_forward_pack)
)
}
}
}
)
}
@Composable
private fun StickerPackPreviewContent(
modifier: Modifier = Modifier,
contentState: ContentState.HasData,
onInstallClick: (params: StickerPackParams) -> Unit,
onUninstallClick: (params: StickerPackParams) -> Unit
) {
Column(
modifier = modifier.padding(top = 0.dp, bottom = 12.dp, start = 16.dp, end = 16.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.alpha(alpha)
) {
StickerPackInfo(
stickerManifest = contentState.stickerManifest,
modifier = Modifier.fillMaxWidth()
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 96.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
modifier = Modifier
.padding(top = 32.dp)
.weight(1f)
) {
items(
items = contentState.stickerManifest.stickers,
key = { it.id }
) { item ->
StickerImage(
sticker = item,
modifier = Modifier.size(96.dp)
)
}
}
val actionButtonModifier = Modifier
.width(412.dp)
.align(Alignment.CenterHorizontally)
.padding(top = 12.dp)
if (contentState.isPackInstalled) {
Buttons.LargeTonal(
content = { Text(text = stringResource(R.string.StickerManagement_menu_remove_pack)) },
onClick = { onUninstallClick(contentState.stickerManifest.params) },
colors = ButtonDefaults.filledTonalButtonColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = actionButtonModifier
)
} else {
Buttons.LargeTonal(
content = { Text(text = stringResource(R.string.StickerManagement_menu_install_pack)) },
onClick = { onInstallClick(contentState.stickerManifest.params) },
modifier = actionButtonModifier
if (isBlessed) {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_official_20),
contentDescription = stringResource(R.string.ConversationTitleView_verified),
modifier = Modifier
.padding(start = 4.dp)
.size(20.dp)
)
}
}
}
@Composable
private fun StickerPackInfo(
private fun StickerPackPreviewContent(
modifier: Modifier = Modifier,
contentState: ContentState.HasData,
gridState: LazyGridState,
onEvent: (StickerPackPreviewEvent) -> Unit
) {
LazyVerticalGrid(
state = gridState,
columns = GridCells.Adaptive(minSize = 96.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
contentPadding = PaddingValues(bottom = 12.dp, start = 16.dp, end = 16.dp),
modifier = modifier.fillMaxSize()
) {
item(span = { GridItemSpan(maxLineSpan) }) {
StickerPackTitle(
isBlessed = contentState.isBlessed,
stickerManifest = contentState.stickerManifest,
modifier = Modifier.fillMaxWidth()
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
StickerPackInfo(
stickerManifest = contentState.stickerManifest,
modifier = Modifier.fillMaxWidth()
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
StickerOptions(
contentState = contentState,
onEvent = onEvent,
modifier = Modifier.padding(bottom = 24.dp)
)
}
items(
items = contentState.stickerManifest.stickers,
key = { it.id }
) { item ->
StickerImage(
sticker = item,
modifier = Modifier.size(96.dp)
)
}
}
}
@Composable
private fun InstallPackButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = modifier
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Buttons.LargeTonal(
content = { Text(text = stringResource(R.string.StickerManagement__add_stickers)) },
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 412.dp)
)
}
}
}
@Composable
private fun StickerPackTitle(
isBlessed: Boolean,
modifier: Modifier = Modifier,
stickerManifest: StickerManifest
) {
@@ -327,34 +432,43 @@ private fun StickerPackInfo(
)
}
stickerManifest.title.orNull()?.let { title ->
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stickerManifest.title.orNull() ?: stringResource(R.string.StickerPackPreviewActivity_untitled),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
if (BlessedPacks.contains(stickerManifest.packId)) {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_official_20),
contentDescription = null,
modifier = Modifier
.padding(horizontal = 6.dp)
.size(24.dp)
)
}
if (isBlessed) {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_official_20),
contentDescription = null,
modifier = Modifier
.padding(start = 6.dp)
.size(24.dp)
)
}
}
}
}
@Composable
private fun StickerPackInfo(
modifier: Modifier = Modifier,
stickerManifest: StickerManifest
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stickerManifest.author
.filter { it.isNotBlank() }
.getOrElse { stringResource(R.string.StickerManagement_author_unknown) },
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp)
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
@@ -366,6 +480,149 @@ private fun StickerPackInfo(
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.size(12.dp))
}
}
@Composable
private fun StickerOptions(
contentState: ContentState.HasData,
onEvent: (StickerPackPreviewEvent) -> Unit,
modifier: Modifier = Modifier
) {
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Buttons.ActionButton(
onClick = { onEvent(StickerPackPreviewEvent.SendPackClicked) },
imageVector = SignalIcons.Forward.imageVector,
label = stringResource(R.string.StickerManagement_menu_send_pack)
)
Spacer(modifier = Modifier.width(16.dp))
Buttons.ActionButton(
onClick = { onEvent(StickerPackPreviewEvent.LinkPackClicked) },
imageVector = SignalIcons.Link.imageVector,
label = stringResource(R.string.StickerManagement_menu_link_pack)
)
if (contentState.isPackInstalled) {
Spacer(modifier = Modifier.width(16.dp))
Buttons.ActionButton(
onClick = { onEvent(StickerPackPreviewEvent.UninstallClicked) },
imageVector = ImageVector.vectorResource(R.drawable.symbol_minus_circle_24),
label = stringResource(R.string.StickerManagement_menu_remove_pack)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StickerPackShareSheet(
params: StickerPackParams,
stickerManifest: StickerManifest,
onEvent: (StickerPackPreviewEvent) -> Unit
) {
val sheetState = rememberModalBottomSheetState()
val scope = rememberCoroutineScope()
BottomSheets.BottomSheet(
sheetState = sheetState,
onDismissRequest = { onEvent(StickerPackPreviewEvent.ShareSheetDismissed) }
) {
StickerPackShareSheetContent(
params = params,
stickerManifest = stickerManifest,
onForwardClick = { sheetState.dismissWithAnimation(scope, onComplete = { onEvent(StickerPackPreviewEvent.SendPackClicked) }) },
onCopyClick = { sheetState.dismissWithAnimation(scope, onComplete = { onEvent(StickerPackPreviewEvent.CopyLinkClicked) }) },
onShareExternallyClick = { sheetState.dismissWithAnimation(scope, onComplete = { onEvent(StickerPackPreviewEvent.ShareExternallyClicked) }) }
)
}
}
@Composable
private fun StickerPackShareSheetContent(
params: StickerPackParams,
stickerManifest: StickerManifest,
onForwardClick: () -> Unit,
onCopyClick: () -> Unit,
onShareExternallyClick: () -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.StickerManagement__anyone),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp, horizontal = 32.dp)
)
Row(
modifier = Modifier
.padding(start = 24.dp, end = 24.dp, bottom = 16.dp)
.border(1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), shape = RoundedCornerShape(12.dp))
.padding(16.dp)
) {
stickerManifest.cover.orNull()?.let { cover ->
StickerImage(
sticker = cover,
modifier = Modifier.size(64.dp)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp)
) {
stickerManifest.title.orNull()?.let { title ->
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center
)
}
stickerManifest.author.orNull()?.let { author ->
Text(
text = author,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
}
Text(
text = params.shareLink,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Rows.TextRow(
text = stringResource(R.string.StickerManagement_menu_send_signal_pack),
icon = SignalIcons.Forward.imageVector,
onClick = onForwardClick
)
Rows.TextRow(
text = stringResource(R.string.StickerManagement_menu_copy_pack),
icon = SignalIcons.Copy.imageVector,
onClick = onCopyClick
)
Rows.TextRow(
text = stringResource(R.string.StickerManagement_menu_share_pack),
icon = SignalIcons.Share.imageVector,
onClick = onShareExternallyClick
)
}
}
@@ -412,10 +669,68 @@ private fun HasDataPreview() {
cover.toOptional(),
StickerPreviewDataFactory.manifestStickers(33)
),
isPackInstalled = false
isPackInstalled = false,
isBlessed = true
)
),
callbacks = StickerPackPreviewScreenCallbacks.Empty
onEvent = {},
onNavigationClick = {}
)
}
}
@DayNightPreviews
@Composable
private fun HasDataPreviewInstalled() {
Previews.Preview {
val cover = StickerManifest.Sticker(
"packId0",
"packKey0",
0,
"👍",
null
)
StickerPackPreviewScreen(
uiState = StickerPackPreviewUiState(
contentState = ContentState.HasData(
stickerManifest = StickerManifest(
cover.packId,
cover.packKey,
"Misbrands (The world's most hated IT stickers extended)".toOptional(),
"Sticker Pack Author".toOptional(),
cover.toOptional(),
StickerPreviewDataFactory.manifestStickers(33)
),
isPackInstalled = true,
isBlessed = true
)
),
onEvent = {},
onNavigationClick = {}
)
}
}
@DayNightPreviews
@Composable
private fun StickerPackShareSheetContentPreview() {
Previews.BottomSheetContentPreview {
val cover = StickerManifest.Sticker("packId0", "packKey0", 0, "👍", null)
StickerPackShareSheetContent(
params = StickerPackParams(StickerPackId(cover.packId), StickerPackKey(cover.packKey)),
stickerManifest = StickerManifest(
cover.packId,
cover.packKey,
"Misbrands".toOptional(),
"Sticker Pack Author".toOptional(),
cover.toOptional(),
StickerPreviewDataFactory.manifestStickers(33)
),
onForwardClick = {},
onCopyClick = {},
onShareExternallyClick = {}
)
}
}
@@ -430,3 +745,6 @@ private val StickerManifest.params: StickerPackParams
id = StickerPackId(packId),
key = StickerPackKey(packKey)
)
private val StickerPackParams.shareLink: String
get() = StickerUrl.createShareLink(id.value, key.value)
@@ -0,0 +1,22 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.stickers.preview
/**
* Events that happen in [StickerPackPreviewActivityV2]
*/
sealed interface StickerPackPreviewEvent {
data object Initialize : StickerPackPreviewEvent
data object InstallClicked : StickerPackPreviewEvent
data object UninstallClicked : StickerPackPreviewEvent
data object UninstallConfirmed : StickerPackPreviewEvent
data object UninstallCanceled : StickerPackPreviewEvent
data object SendPackClicked : StickerPackPreviewEvent
data object LinkPackClicked : StickerPackPreviewEvent
data object ShareSheetDismissed : StickerPackPreviewEvent
data object CopyLinkClicked : StickerPackPreviewEvent
data object ShareExternallyClicked : StickerPackPreviewEvent
}
@@ -5,68 +5,136 @@
package org.thoughtcrime.securesms.stickers.preview
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.database.model.StickerPackParams
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.stickers.BlessedPacks
import org.thoughtcrime.securesms.stickers.StickerManifest
import org.thoughtcrime.securesms.stickers.StickerUrl
import org.thoughtcrime.securesms.stickers.manage.StickerManagementRepository
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewUiState.ContentState
import kotlin.jvm.optionals.getOrElse
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewUiState.UserPrompt
import kotlin.coroutines.resume
class StickerPackPreviewViewModelV2(
params: StickerPackParams?
) : ViewModel() {
private val params: StickerPackParams?
) : EventDrivenViewModel<StickerPackPreviewEvent>(TAG) {
companion object {
private val TAG = Log.tag(StickerPackPreviewViewModelV2::class)
}
private val stickerPreviewRepo: StickerPackPreviewRepository = StickerPackPreviewRepository()
private val internalUiState = MutableStateFlow(StickerPackPreviewUiState(contentState = ContentState.Loading))
val uiState: StateFlow<StickerPackPreviewUiState> = internalUiState.asStateFlow()
private val internalActions: Channel<StickerPackPreviewAction> = Channel(Channel.BUFFERED)
val actions: Flow<StickerPackPreviewAction> = internalActions.receiveAsFlow()
init {
if (params != null) {
loadData(params)
} else {
showDataUnavailableState()
}
onEvent(StickerPackPreviewEvent.Initialize)
}
private fun loadData(params: StickerPackParams) {
stickerPreviewRepo.getStickerManifest(params.id.value, params.key.value) { result ->
val stickerManifest = result.map { it.manifest }.orNull()
if (stickerManifest != null) {
internalUiState.update {
it.copy(
contentState = ContentState.HasData(
stickerManifest = stickerManifest,
isPackInstalled = result.map { pack -> pack.isInstalled }.getOrElse { false }
)
)
override suspend fun processEvent(event: StickerPackPreviewEvent) {
if (params == null) {
Log.w(TAG, "No pack params to work with.")
showDataUnavailable()
return
}
when (event) {
StickerPackPreviewEvent.Initialize -> {
viewModelScope.launch { loadManifest(params) }
}
StickerPackPreviewEvent.InstallClicked -> {
if (!isPackInstalled()) {
updateInstalledState(true)
StickerManagementRepository.installStickerPack(packId = params.id, packKey = params.key, notify = true)
}
} else {
showDataUnavailableState()
}
StickerPackPreviewEvent.UninstallClicked -> showPrompt(UserPrompt.ConfirmRemovePack)
StickerPackPreviewEvent.UninstallConfirmed -> {
showPrompt(null)
if (isPackInstalled()) {
updateInstalledState(false)
StickerManagementRepository.uninstallStickerPacks(mapOf(params.id to params.key))
}
}
StickerPackPreviewEvent.UninstallCanceled -> showPrompt(null)
StickerPackPreviewEvent.SendPackClicked -> {
showPrompt(null)
internalActions.send(StickerPackPreviewAction.SendPack(params))
}
StickerPackPreviewEvent.LinkPackClicked -> showPrompt(UserPrompt.ShareStickerPack)
StickerPackPreviewEvent.ShareSheetDismissed -> showPrompt(null)
StickerPackPreviewEvent.CopyLinkClicked -> {
showPrompt(null)
Util.copyToClipboard(AppDependencies.application, StickerUrl.createShareLink(params.id.value, params.key.value))
internalActions.send(StickerPackPreviewAction.LinkCopied)
}
StickerPackPreviewEvent.ShareExternallyClicked -> {
showPrompt(null)
internalActions.send(StickerPackPreviewAction.ShareExternally(params))
}
}
}
fun installStickerPack(params: StickerPackParams) {
viewModelScope.launch {
StickerManagementRepository.installStickerPack(packId = params.id, packKey = params.key, notify = true)
updateInstalledState(true)
private suspend fun loadManifest(params: StickerPackParams) {
val loaded = fetchManifest(params)
if (loaded == null) {
showDataUnavailable()
return
}
internalUiState.update { it.copy(contentState = loaded) }
}
private suspend fun fetchManifest(params: StickerPackParams): ContentState.HasData? {
return suspendCancellableCoroutine { continuation ->
stickerPreviewRepo.getStickerManifest(params.id.value, params.key.value) { result ->
continuation.resume(
result.map { pack ->
ContentState.HasData(
stickerManifest = pack.manifest,
isPackInstalled = pack.isInstalled,
isBlessed = BlessedPacks.contains(params.id.value)
)
}.orNull()
)
}
}
}
fun uninstallStickerPack(params: StickerPackParams) {
viewModelScope.launch {
StickerManagementRepository.uninstallStickerPacks(mapOf(params.id to params.key))
updateInstalledState(false)
}
private fun showPrompt(prompt: UserPrompt?) {
internalUiState.update { it.copy(userPrompt = prompt) }
}
private fun isPackInstalled(): Boolean {
return (internalUiState.value.contentState as? ContentState.HasData)?.isPackInstalled == true
}
private fun updateInstalledState(isInstalled: Boolean) {
@@ -76,35 +144,26 @@ class StickerPackPreviewViewModelV2(
it.contentState.copy(isPackInstalled = isInstalled)
} else {
it.contentState
},
navTarget = StickerPackPreviewUiState.NavTarget.Up(delay = 500.milliseconds)
}
)
}
}
private fun showDataUnavailableState() {
private suspend fun showDataUnavailable() {
internalUiState.update {
it.copy(
contentState = ContentState.DataUnavailable,
userMessage = StickerPackPreviewUiState.MessageType.STICKER_PACK_LOAD_FAILED,
navTarget = StickerPackPreviewUiState.NavTarget.Up(delay = 1.seconds)
userPrompt = null
)
}
}
fun setNavTargetConsumed() {
internalUiState.update { it.copy(navTarget = null) }
}
fun setUserMessageConsumed() {
internalUiState.update { it.copy(userMessage = null) }
internalActions.send(StickerPackPreviewAction.PackUnavailable)
}
}
data class StickerPackPreviewUiState(
val contentState: ContentState,
val userMessage: MessageType? = null,
val navTarget: NavTarget? = null
val userPrompt: UserPrompt? = null
) {
sealed interface ContentState {
data object Loading : ContentState
@@ -112,15 +171,13 @@ data class StickerPackPreviewUiState(
data class HasData(
val stickerManifest: StickerManifest,
val isPackInstalled: Boolean
val isPackInstalled: Boolean,
val isBlessed: Boolean
) : ContentState
}
sealed interface NavTarget {
data class Up(val delay: Duration? = null) : NavTarget
}
enum class MessageType {
STICKER_PACK_LOAD_FAILED
sealed interface UserPrompt {
data object ConfirmRemovePack : UserPrompt
data object ShareStickerPack : UserPrompt
}
}
+9 -2
View File
@@ -2789,7 +2789,7 @@
<string name="StickerManagement_action_delete_selected">Delete</string>
<!-- Confirmation message shown before uninstalling sticker packs. The placeholder is the number of items to delete. -->
<plurals name="StickerManagement_delete_n_packs_confirmation">
<item quantity="one">Remove %1$s sticker pack?</item>
<item quantity="one">Remove sticker pack?</item>
<item quantity="other">Remove %1$s sticker packs?</item>
</plurals>
<!-- Body of the confirmation dialog shown before uninstalling sticker packs. -->
@@ -2832,7 +2832,14 @@
<string name="StickerManagement_search">Search</string>
<!-- Shown when no stickers match the search. The placeholder what was searched. -->
<string name="StickerManagement_search_no_results_s">No results found for \'%1$s\'</string>
<!-- Sticker pack button for forwarding a sticker link. -->
<string name="StickerManagement_menu_send_signal_pack">Send via Signal</string>
<!-- Sticker pack button that opens a bottom sheet with more share options. -->
<string name="StickerManagement_menu_link_pack">Link</string>
<!-- Sticker pack sheet option for sharing a sticker pack link using the system share sheet. -->
<string name="StickerManagement_menu_share_pack">Share</string>
<!-- Bottom sheet description for the share sticker sheet. -->
<string name="StickerManagement__anyone">Anyone with this link can install and share this sticker pack.</string>
<!-- Accessibility label for the button to download a sticker pack. The placeholder is the sticker pack name. -->
<string name="StickerManagement_accessibility_download_pack">Download %s sticker pack</string>
<!-- Accessibility label for the icon that is displayed after a sticker pack has been downloaded. The placeholder is the sticker pack name. -->
@@ -0,0 +1,94 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.mediakeyboard.data.MediaKeyboardRepository
/**
* @param isEnteringText Whether the search surface is up, from the scaffold that owns it.
* @param initialTab The tab to open on, or null to open on the first one offered.
*/
class MediaKeyboardViewModel(
private val repository: MediaKeyboardRepository,
isEnteringText: StateFlow<Boolean>,
initialTab: MediaKeyboardTab? = null
) : EventDrivenViewModel<MediaKeyboardScreenEvents>(TAG, shouldLogEvents = false) {
companion object {
private val TAG = Log.tag(MediaKeyboardViewModel::class)
}
private val _state = MutableStateFlow(
initialTab?.let { MediaKeyboardState(preferredTab = it) } ?: MediaKeyboardState()
)
val state: StateFlow<MediaKeyboardState> = _state.asStateFlow()
init {
onEvent(MediaKeyboardScreenEvents.Initialize)
isEnteringText
.onEach { entering -> _state.update { it.copy(searchActive = entering) } }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: MediaKeyboardScreenEvents) {
applyEvent(_state.value, event) { next ->
_state.update { current -> next.copy(searchActive = current.searchActive, restrictedTo = current.restrictedTo) }
}
}
@VisibleForTesting
suspend fun applyEvent(
state: MediaKeyboardState,
event: MediaKeyboardScreenEvents,
stateEmitter: (MediaKeyboardState) -> Unit
) {
when (event) {
is MediaKeyboardScreenEvents.Initialize -> {
val offered = repository.getAvailableTabs()
stateEmitter(state.copy(offeredTabs = MediaKeyboardTab.entries.filter { it in offered }, initialized = true))
}
is MediaKeyboardScreenEvents.TabSelected -> {
stateEmitter(state.copy(preferredTab = event.tab, searchQuery = ""))
}
is MediaKeyboardScreenEvents.SearchQueryChanged -> {
stateEmitter(state.copy(searchQuery = event.query))
}
}
}
/**
* Narrows the keyboard to [tabs], or null to offer everything again. Not an event: it is the
* host's standing configuration rather than something the user did, and applying it needs nothing
* the repository has to fetch.
*/
fun restrictTabs(tabs: Set<MediaKeyboardTab>?) {
_state.update { it.copy(restrictedTo = tabs) }
}
class Factory(
private val repository: MediaKeyboardRepository,
private val isEnteringText: StateFlow<Boolean>,
private val initialTab: MediaKeyboardTab?
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return MediaKeyboardViewModel(repository, isEnteringText, initialTab) as T
}
}
}
@@ -0,0 +1,113 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.screens
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
/**
* Whether the media keyboard is (or is settling to) full height. Bottom rails re-show themselves
* when it expands.
*/
val LocalMediaKeyboardExpanded: ProvidableCompositionLocal<Boolean> = compositionLocalOf { false }
private val SCROLL_DIRECTION_THRESHOLD = 4.dp
/**
* Lays out [content] with a [rail] pinned to the bottom of this layout.
*
* Like browser chrome, the rail slides away when the content is scrolled down and returns when it
* is scrolled back up or the keyboard grows to full height.
*/
@Composable
internal fun PinnedRailLayout(
rail: (@Composable () -> Unit)?,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val expanded = LocalMediaKeyboardExpanded.current
var measuredRailHeight by remember { mutableIntStateOf(0) }
var railHidden by remember { mutableStateOf(false) }
val scrollThresholdPx = with(density) { SCROLL_DIRECTION_THRESHOLD.toPx() }
val scrollConnection = remember(scrollThresholdPx) {
object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.UserInput) {
val delta = if (consumed.y != 0f) consumed.y else available.y
if (delta < -scrollThresholdPx) {
railHidden = true
} else if (delta > scrollThresholdPx) {
railHidden = false
}
}
return Offset.Zero
}
}
}
LaunchedEffect(expanded) {
if (expanded) {
railHidden = false
}
}
val railHeight = if (rail != null) measuredRailHeight else 0
val railHiddenFraction by animateFloatAsState(
targetValue = if (railHidden) 1f else 0f,
label = "railHiddenFraction"
)
Box(
modifier = modifier
.fillMaxSize()
.nestedScroll(scrollConnection)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = with(density) { (railHeight * (1f - railHiddenFraction)).toDp() })
) {
content()
}
if (rail != null) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.onSizeChanged { measuredRailHeight = it.height }
.graphicsLayer { translationY = railHeight * railHiddenFraction }
) {
rail()
}
}
}
}
@@ -0,0 +1,162 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.screens.emoji
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.mediakeyboard.MediaKeyboardAction
import org.signal.mediakeyboard.MediaKeyboardState
import org.signal.mediakeyboard.MediaKeyboardTab
import org.signal.mediakeyboard.data.EmojiCategoryPage
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
import org.signal.mediakeyboard.data.EmojiKeyboardRepository
class EmojiPageViewModel(
private val repository: EmojiKeyboardRepository,
private val parentState: StateFlow<MediaKeyboardState>,
private val onAction: (MediaKeyboardAction) -> Unit
) : EventDrivenViewModel<EmojiPageScreenEvents>(TAG, shouldLogEvents = false) {
companion object {
private val TAG = Log.tag(EmojiPageViewModel::class)
}
private val _state = MutableStateFlow(EmojiPageState())
val state: StateFlow<EmojiPageState> = _state.asStateFlow()
init {
onEvent(EmojiPageScreenEvents.Initialize)
parentState
.onEach { onEvent(EmojiPageScreenEvents.ParentStateChanged(it)) }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: EmojiPageScreenEvents) {
applyEvent(_state.value, event) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(
state: EmojiPageState,
event: EmojiPageScreenEvents,
stateEmitter: (EmojiPageState) -> Unit
) {
when (event) {
is EmojiPageScreenEvents.Initialize -> {
val pages = buildPages()
stateEmitter(
state.copy(
pages = pages,
preferredVariations = repository.getPreferredVariations(),
selectedCategory = state.selectedCategory ?: pages.firstOrNull()?.category
)
)
}
is EmojiPageScreenEvents.ParentStateChanged -> {
stateEmitter(applyParentState(state, event.parentState))
}
is EmojiPageScreenEvents.CategorySelected -> {
stateEmitter(state.copy(selectedCategory = event.category, scrollTarget = event.category))
}
is EmojiPageScreenEvents.VisibleCategoryChanged -> {
if (state.scrollTarget == null && state.selectedCategory != event.category) {
stateEmitter(state.copy(selectedCategory = event.category))
}
}
is EmojiPageScreenEvents.ScrollTargetConsumed -> {
stateEmitter(state.copy(scrollTarget = null))
}
is EmojiPageScreenEvents.EmojiClicked -> {
val display = state.displayEmoji(event.emoji)
repository.onEmojiUsed(display)
onAction(MediaKeyboardAction.EmojiSelected(display))
}
is EmojiPageScreenEvents.EmojiLongPressed -> {
if (event.emoji.hasVariations) {
stateEmitter(state.copy(variationSelector = EmojiPageState.VariationSelectorTarget(event.cellKey, event.emoji)))
}
}
is EmojiPageScreenEvents.VariationSelected -> {
repository.setPreferredVariation(event.emoji.canonical, event.variation)
repository.onEmojiUsed(event.variation)
onAction(MediaKeyboardAction.EmojiSelected(event.variation))
stateEmitter(
state.copy(
preferredVariations = repository.getPreferredVariations(),
variationSelector = null
)
)
}
is EmojiPageScreenEvents.VariationSelectorDismissed -> {
stateEmitter(state.copy(variationSelector = null))
}
}
}
private suspend fun applyParentState(state: EmojiPageState, parent: MediaKeyboardState): EmojiPageState {
val searching = parent.searchActive && parent.selectedTab == MediaKeyboardTab.EMOJI
return if (!searching) {
if (state.searchResults != null) {
state.copy(searchResults = null, searchQuery = "", pages = buildPages())
} else {
state
}
} else if (parent.searchQuery != state.searchQuery || state.searchResults == null) {
// Bail if a newer query has already arrived.
if (parent.searchQuery != parentState.value.searchQuery) {
state
} else {
val results = if (parent.searchQuery.isBlank()) {
repository.getRecentEmoji()
} else {
repository.search(parent.searchQuery)
}
state.copy(searchResults = results, searchQuery = parent.searchQuery)
}
} else {
state
}
}
private suspend fun buildPages(): List<EmojiCategoryPage> {
val recents = repository.getRecentEmoji()
return buildList {
if (recents.isNotEmpty()) {
add(EmojiCategoryPage(EmojiKeyboardCategory.RECENTS, recents))
}
addAll(repository.getEmojiPages())
}
}
class Factory(
private val repository: EmojiKeyboardRepository,
private val parentState: StateFlow<MediaKeyboardState>,
private val onAction: (MediaKeyboardAction) -> Unit
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return EmojiPageViewModel(repository, parentState, onAction) as T
}
}
}
@@ -0,0 +1,98 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.screens.sticker
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.mediakeyboard.MediaKeyboardAction
import org.signal.mediakeyboard.data.StickerKeyboardRepository
class StickerPageViewModel(
private val repository: StickerKeyboardRepository,
private val onAction: (MediaKeyboardAction) -> Unit
) : EventDrivenViewModel<StickerPageScreenEvents>(TAG, shouldLogEvents = false) {
companion object {
private val TAG = Log.tag(StickerPageViewModel::class)
}
private val _state = MutableStateFlow(StickerPageState(allowAnimation = repository.allowStickerAnimation))
val state: StateFlow<StickerPageState> = _state.asStateFlow()
init {
onEvent(StickerPageScreenEvents.Initialize)
repository.observeStickerPacks()
.onEach { onEvent(StickerPageScreenEvents.PacksUpdated(it)) }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: StickerPageScreenEvents) {
applyEvent(_state.value, event) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(
state: StickerPageState,
event: StickerPageScreenEvents,
stateEmitter: (StickerPageState) -> Unit
) {
when (event) {
is StickerPageScreenEvents.Initialize -> Unit
is StickerPageScreenEvents.PacksUpdated -> {
val selected = state.selectedPackId?.takeIf { id -> event.packs.any { it.id == id } }
?: event.packs.firstOrNull()?.id
stateEmitter(state.copy(packs = event.packs, selectedPackId = selected))
}
is StickerPageScreenEvents.PackSelected -> {
stateEmitter(state.copy(selectedPackId = event.packId, scrollTargetPackId = event.packId))
}
is StickerPageScreenEvents.VisiblePackChanged -> {
if (state.scrollTargetPackId == null && state.selectedPackId != event.packId) {
stateEmitter(state.copy(selectedPackId = event.packId))
}
}
is StickerPageScreenEvents.ScrollTargetConsumed -> {
stateEmitter(state.copy(scrollTargetPackId = null))
}
is StickerPageScreenEvents.StickerClicked -> {
repository.onStickerUsed(event.sticker)
onAction(MediaKeyboardAction.StickerSelected(event.sticker))
}
is StickerPageScreenEvents.SearchClicked -> {
onAction(MediaKeyboardAction.StickerSearchClicked)
}
is StickerPageScreenEvents.ViewStickerPackClicked -> {
onAction(MediaKeyboardAction.ViewStickerPackClicked(event.packId, event.packKey))
}
}
}
class Factory(
private val repository: StickerKeyboardRepository,
private val onAction: (MediaKeyboardAction) -> Unit
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return StickerPageViewModel(repository, onAction) as T
}
}
}