diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 99dbabf308..17dfe5e352 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -206,7 +206,7 @@ - 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) + ) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/manage/StickerManagementActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/manage/StickerManagementActivity.kt index b48974989d..892211795a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/manage/StickerManagementActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/manage/StickerManagementActivity.kt @@ -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)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/manage/StickerManagementBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/manage/StickerManagementBottomSheet.kt index 77cb8dcb9f..b3d59370c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/manage/StickerManagementBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/manage/StickerManagementBottomSheet.kt @@ -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)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewAction.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewAction.kt new file mode 100644 index 0000000000..5a7000aba4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewAction.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewActivity.java deleted file mode 100644 index 41afa4445e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewActivity.java +++ /dev/null @@ -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> 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 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; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewActivityV2.kt index 55f1eb4245..f726c77539 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewActivityV2.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewEvent.kt new file mode 100644 index 0000000000..0932924868 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewEvent.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewViewModelV2.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewViewModelV2.kt index c4264b4deb..ba7f8fc194 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewViewModelV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/preview/StickerPackPreviewViewModelV2.kt @@ -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(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 = internalUiState.asStateFlow() + private val internalActions: Channel = Channel(Channel.BUFFERED) + val actions: Flow = 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 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fb0b6ab17a..4c48392c6b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2789,7 +2789,7 @@ Delete - Remove %1$s sticker pack? + Remove sticker pack? Remove %1$s sticker packs? @@ -2832,7 +2832,14 @@ Search No results found for \'%1$s\' - + + Send via Signal + + Link + + Share + + Anyone with this link can install and share this sticker pack. Download %s sticker pack diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboardViewModel.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboardViewModel.kt new file mode 100644 index 0000000000..c2a3b30ab7 --- /dev/null +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboardViewModel.kt @@ -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, + initialTab: MediaKeyboardTab? = null +) : EventDrivenViewModel(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 = _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?) { + _state.update { it.copy(restrictedTo = tabs) } + } + + class Factory( + private val repository: MediaKeyboardRepository, + private val isEnteringText: StateFlow, + private val initialTab: MediaKeyboardTab? + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return MediaKeyboardViewModel(repository, isEnteringText, initialTab) as T + } + } +} diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/PinnedRailLayout.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/PinnedRailLayout.kt new file mode 100644 index 0000000000..9a1f4b0fcf --- /dev/null +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/PinnedRailLayout.kt @@ -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 = 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() + } + } + } +} diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/emoji/EmojiPageViewModel.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/emoji/EmojiPageViewModel.kt new file mode 100644 index 0000000000..631a64a123 --- /dev/null +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/emoji/EmojiPageViewModel.kt @@ -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, + private val onAction: (MediaKeyboardAction) -> Unit +) : EventDrivenViewModel(TAG, shouldLogEvents = false) { + + companion object { + private val TAG = Log.tag(EmojiPageViewModel::class) + } + + private val _state = MutableStateFlow(EmojiPageState()) + val state: StateFlow = _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 { + 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, + private val onAction: (MediaKeyboardAction) -> Unit + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return EmojiPageViewModel(repository, parentState, onAction) as T + } + } +} diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageViewModel.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageViewModel.kt new file mode 100644 index 0000000000..77a409f553 --- /dev/null +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageViewModel.kt @@ -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(TAG, shouldLogEvents = false) { + + companion object { + private val TAG = Log.tag(StickerPageViewModel::class) + } + + private val _state = MutableStateFlow(StickerPageState(allowAnimation = repository.allowStickerAnimation)) + val state: StateFlow = _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 create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return StickerPageViewModel(repository, onAction) as T + } + } +}