Reimplement main activity toolbars in compose.

This commit is contained in:
Alex Hart
2025-03-28 14:34:04 -03:00
committed by Greyson Parrelli
parent 5f7ce0d96d
commit f1985cf506
24 changed files with 1405 additions and 680 deletions

View File

@@ -96,6 +96,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
AppStartup.getInstance().onCriticalRenderEventStart()
super.onCreate(savedInstanceState, ready)
conversationListTabsViewModel
setContent {
val navState = rememberFragmentState()
@@ -147,7 +148,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
lifecycleDisposable.bindTo(this)
mediaController = VoiceNoteMediaController(this, true)
conversationListTabsViewModel
handleDeepLinkIntent(intent)
CachedInflater.from(this).clear()

View File

@@ -47,7 +47,11 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
}
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper? {
return Material3OnScrollHelper(requireActivity(), scrollShadow, viewLifecycleOwner)
return Material3OnScrollHelper(
activity = requireActivity(),
views = listOf(scrollShadow),
lifecycleOwner = viewLifecycleOwner
)
}
override fun bindAdapter(adapter: MappingAdapter) {

View File

@@ -3,9 +3,6 @@ package org.thoughtcrime.securesms.calls.log
import android.annotation.SuppressLint
import android.content.res.Resources
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
@@ -14,7 +11,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.SharedElementCallback
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -38,13 +34,10 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick
import org.thoughtcrime.securesms.conversation.SignalBottomActionBarController
@@ -57,8 +50,9 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.SearchBinder
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
@@ -82,7 +76,6 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
private var filterViewOffsetChangeListener: AppBarLayout.OnOffsetChangedListener? = null
private val viewModel: CallLogViewModel by activityViewModels()
private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind) {
binding.recyclerCoordinatorAppBar.removeOnOffsetChangedListener(filterViewOffsetChangeListener)
}
@@ -95,35 +88,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
private lateinit var signalBottomActionBarController: SignalBottomActionBarController
private val viewModel: CallLogViewModel by activityViewModels()
private val tabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
private val menuProvider = object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.calls_tab_menu, menu)
}
override fun onPrepareMenu(menu: Menu) {
val isFiltered = viewModel.filterSnapshot == CallLogFilter.MISSED
menu.findItem(R.id.action_clear_missed_call_filter).isVisible = isFiltered
menu.findItem(R.id.action_filter_missed_calls).isVisible = !isFiltered
menu.findItem(R.id.action_clear_call_history).isVisible = !viewModel.isEmpty
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_clear_call_history -> clearCallHistory()
R.id.action_settings -> startActivity(AppSettingsActivity.home(requireContext()))
R.id.action_notification_profile -> NotificationProfileSelectionFragment.show(parentFragmentManager)
R.id.action_filter_missed_calls -> filterMissedCalls()
R.id.action_clear_missed_call_filter -> onClearFilterClicked()
}
return true
}
}
private val mainToolbarViewModel: MainToolbarViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)
initializeSharedElementTransition()
viewLifecycleOwner.lifecycle.addObserver(conversationUpdateTick)
@@ -131,6 +100,15 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
val callLogAdapter = CallLogAdapter(this)
disposables.bindTo(viewLifecycleOwner)
disposables += mainToolbarViewModel.getCallLogEventsFlowable().subscribeBy {
when (it) {
MainToolbarViewModel.Event.CallLog.ApplyFilter -> filterMissedCalls()
MainToolbarViewModel.Event.CallLog.ClearFilter -> onClearFilterClicked()
MainToolbarViewModel.Event.CallLog.ClearHistory -> clearCallHistory()
}
}
callLogAdapter.setPagingController(viewModel.controller)
callLogAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
@@ -163,7 +141,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
.subscribe { (selected, totalCount) ->
if (selected.isNotEmpty(totalCount)) {
callLogActionMode.setCount(selected.count(totalCount))
} else {
} else if (callLogActionMode.isInActionMode()) {
callLogActionMode.end()
}
}
@@ -268,19 +246,16 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
private fun initializeSearchAction() {
val searchBinder = requireListener<SearchBinder>()
searchBinder.getSearchAction().setOnClickListener {
searchBinder.onSearchOpened()
searchBinder.getSearchToolbar().get().setSearchInputHint(R.string.SearchToolbar_search)
searchBinder.getSearchToolbar().get().listener = object : Material3SearchToolbar.Listener {
override fun onSearchTextChange(text: String) {
viewModel.setSearchQuery(text.trim())
}
override fun onSearchClosed() {
disposables += mainToolbarViewModel.getSearchEventsFlowable().subscribeBy {
when (it) {
MainToolbarViewModel.Event.Search.Close -> {
viewModel.setSearchQuery("")
searchBinder.onSearchClosed()
}
MainToolbarViewModel.Event.Search.Open -> {
mainToolbarViewModel.setSearchHint(R.string.SearchToolbar_search)
}
is MainToolbarViewModel.Event.Search.Query -> {
viewModel.setSearchQuery(it.query.trim())
}
}
}
@@ -294,6 +269,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
when (state) {
FilterPullState.CLOSING -> {
viewModel.setFilter(CallLogFilter.ALL)
mainToolbarViewModel.setCallLogFilter(CallLogFilter.ALL)
binding.recycler.doAfterNextLayout {
scrollToPositionDelegate.resetScrollPosition()
}
@@ -302,6 +278,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
FilterPullState.OPENING -> {
ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight)
viewModel.setFilter(CallLogFilter.MISSED)
mainToolbarViewModel.setCallLogFilter(CallLogFilter.MISSED)
}
FilterPullState.OPEN_APEX -> if (source === ConversationFilterSource.DRAG) {
@@ -438,16 +415,15 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
private fun closeSearchIfOpen(): Boolean {
if (isSearchOpen()) {
requireListener<SearchBinder>().getSearchToolbar().get().collapse()
requireListener<SearchBinder>().onSearchClosed()
mainToolbarViewModel.setToolbarMode(MainToolbarMode.FULL)
tabsViewModel.onSearchClosed()
return true
}
return false
}
private fun isSearchVisible(): Boolean {
return requireListener<SearchBinder>().getSearchToolbar().resolved() &&
requireListener<SearchBinder>().getSearchToolbar().get().visibility == View.VISIBLE
return mainToolbarViewModel.state.value.mode == MainToolbarMode.SEARCH
}
private fun performDeletion(count: Int, callLogStagedDeletion: CallLogStagedDeletion) {

View File

@@ -83,7 +83,11 @@ abstract class DSLSettingsFragment(
return null
}
return Material3OnScrollHelper(requireActivity(), toolbar, viewLifecycleOwner)
return Material3OnScrollHelper(
activity = requireActivity(),
views = listOf(toolbar),
lifecycleOwner = viewLifecycleOwner
)
}
open fun onToolbarNavigationClicked() {

View File

@@ -25,37 +25,42 @@ enum class BadgeImageSize(val sizeCode: Int) {
BADGE_112(5)
}
@Composable
fun BadgeImageSmall(
badge: Badge?,
modifier: Modifier = Modifier
) {
BadgeImage(badge, BadgeImageSize.SMALL, modifier)
}
@Composable
fun BadgeImageMedium(
badge: Badge?,
modifier: Modifier
modifier: Modifier = Modifier
) {
if (LocalInspectionMode.current) {
Box(modifier = modifier.background(color = Color.Black, shape = CircleShape))
} else {
AndroidView(
factory = {
BadgeImageView(it, BadgeImageSize.MEDIUM)
},
update = {
it.setBadge(badge)
},
modifier = modifier
)
}
BadgeImage(badge, BadgeImageSize.MEDIUM, modifier)
}
@Composable
fun BadgeImage112(
badge: Badge?,
modifier: Modifier = Modifier
) {
BadgeImage(badge, BadgeImageSize.BADGE_112, modifier)
}
@Composable
private fun BadgeImage(
badge: Badge?,
size: BadgeImageSize,
modifier: Modifier = Modifier
) {
if (LocalInspectionMode.current) {
Box(modifier = modifier.background(color = Color.Black, shape = CircleShape))
} else {
AndroidView(
factory = {
BadgeImageView(it, BadgeImageSize.BADGE_112)
BadgeImageView(it, size)
},
update = {
it.setBadge(badge)

View File

@@ -111,7 +111,7 @@ class DonateToSignalFragment :
}
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper {
return object : Material3OnScrollHelper(requireActivity(), toolbar!!, viewLifecycleOwner) {
return object : Material3OnScrollHelper(activity = requireActivity(), views = listOf(toolbar!!), lifecycleOwner = viewLifecycleOwner) {
override val activeColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground)
override val inactiveColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground)
}

View File

@@ -149,7 +149,7 @@ class ManageDonationsFragment :
}
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper {
return object : Material3OnScrollHelper(requireActivity(), toolbar!!, viewLifecycleOwner) {
return object : Material3OnScrollHelper(activity = requireActivity(), views = listOf(toolbar!!), lifecycleOwner = viewLifecycleOwner) {
override val activeColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground)
override val inactiveColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground)
}

View File

@@ -220,7 +220,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper {
return object : Material3OnScrollHelper(requireActivity(), toolbar!!, viewLifecycleOwner) {
return object : Material3OnScrollHelper(activity = requireActivity(), views = listOf(toolbar!!), lifecycleOwner = viewLifecycleOwner) {
override val inactiveColorSet = ColorSet(
toolbarColorRes = R.color.signal_colorBackground_0,
statusBarColorRes = R.color.signal_colorBackground

View File

@@ -87,7 +87,7 @@ public class ContactShareEditActivity extends PassphraseRequiredActivity impleme
Toolbar toolbar = findViewById(R.id.toolbar);
toolbar.setNavigationOnClickListener(unused -> onBackPressed());
Material3OnScrollHelper onScrollHelper = new Material3OnScrollHelper(this, Collections.singletonList(toolbar), Collections.emptyList(), this);
Material3OnScrollHelper onScrollHelper = Material3OnScrollHelper.create(this, toolbar);
onScrollHelper.attach(contactList);
ContactShareEditAdapter contactAdapter = new ContactShareEditAdapter(Glide.with(this), dynamicLanguage.getCurrentLocale(), this);

View File

@@ -1,8 +1,8 @@
package org.thoughtcrime.securesms.conversation.v2
import android.app.Activity
import android.view.View
import androidx.annotation.ColorRes
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.LifecycleOwner
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
@@ -12,7 +12,7 @@ import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
* Scroll helper to manage the color state of the top bar and status bar.
*/
class ConversationToolbarOnScrollHelper(
activity: Activity,
activity: FragmentActivity,
toolbarBackground: View,
private val wallpaperProvider: () -> ChatWallpaper?,
lifecycleOwner: LifecycleOwner

View File

@@ -27,8 +27,6 @@ import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.annotation.WorkerThread;
import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.Toolbar;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.snackbar.Snackbar;
@@ -51,7 +49,6 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
private Stub<View> emptyState;
private PulsingFloatingActionButton fab;
private PulsingFloatingActionButton cameraFab;
private Stub<Toolbar> toolbar;
public static ConversationListArchiveFragment newInstance() {
return new ConversationListArchiveFragment();
@@ -65,8 +62,6 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
toolbar = requireCallback().getBasicToolbar();
super.onViewCreated(view, savedInstanceState);
coordinator = view.findViewById(R.id.coordinator);
@@ -76,9 +71,6 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
cameraFab = view.findViewById(R.id.camera_fab);
foldersList = view.findViewById(R.id.chat_folder_list);
toolbar.get().setNavigationOnClickListener(v -> NavHostFragment.findNavController(this).popBackStack());
toolbar.get().setTitle(R.string.AndroidManifest_archived_conversations);
fab.hide();
cameraFab.hide();
foldersList.setVisibility(View.GONE);
@@ -98,11 +90,6 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
return true;
}
@Override
protected @NonNull Toolbar getToolbar(@NonNull View rootView) {
return toolbar.get();
}
@Override
protected @StringRes int getArchivedSnackbarTitleRes() {
return R.plurals.ConversationListFragment_moved_conversations_to_inbox;

View File

@@ -36,7 +36,6 @@ import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
@@ -54,8 +53,6 @@ import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.Toolbar;
import androidx.appcompat.widget.TooltipCompat;
import androidx.compose.ui.platform.ComposeView;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.content.ContextCompat;
@@ -87,7 +84,6 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.MainActivity;
import org.thoughtcrime.securesms.MainFragment;
import org.thoughtcrime.securesms.MainNavigator;
import org.thoughtcrime.securesms.MuteDialog;
@@ -112,7 +108,6 @@ import org.thoughtcrime.securesms.banner.banners.ServiceOutageBanner;
import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner;
import org.thoughtcrime.securesms.banner.banners.UsernameOutOfSyncBanner;
import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog;
import org.thoughtcrime.securesms.components.Material3SearchToolbar;
import org.thoughtcrime.securesms.components.RatingManager;
import org.thoughtcrime.securesms.components.SignalProgressDialog;
import org.thoughtcrime.securesms.components.menu.ActionItem;
@@ -121,7 +116,6 @@ import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord;
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment;
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
@@ -145,14 +139,13 @@ import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.jobs.MultiDeviceAttachmentBackfillMissingJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceAttachmentBackfillUpdateJob;
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob;
import org.thoughtcrime.securesms.keyvalue.AccountValues;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
import org.thoughtcrime.securesms.main.MainToolbarMode;
import org.thoughtcrime.securesms.main.MainToolbarViewModel;
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder;
import org.thoughtcrime.securesms.main.SearchBinder;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity;
import org.thoughtcrime.securesms.megaphone.Megaphone;
@@ -166,7 +159,6 @@ import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab;
@@ -255,6 +247,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private Stopwatch startupStopwatch;
private ConversationListTabsViewModel conversationListTabsViewModel;
private ContactSearchMediator contactSearchMediator;
private MainToolbarViewModel mainToolbarViewModel;
private BannerManager bannerManager;
@@ -276,8 +269,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
setHasOptionsMenu(true);
startupStopwatch = new Stopwatch("startup");
startupStopwatch = new Stopwatch("startup");
mainToolbarViewModel = new ViewModelProvider(getActivity()).get(MainToolbarViewModel.class);
}
@Override
@@ -353,10 +346,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
switch (state) {
case CLOSING:
viewModel.setFiltered(false, source);
mainToolbarViewModel.setChatFilter(ConversationFilter.OFF);
break;
case OPENING:
ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight);
viewModel.setFiltered(true, source);
mainToolbarViewModel.setChatFilter(ConversationFilter.UNREAD);
break;
case OPEN_APEX:
if (source == ConversationFilterSource.DRAG) {
@@ -437,8 +432,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
RatingManager.showRatingDialogIfNecessary(requireContext());
TooltipCompat.setTooltipText(requireCallback().getSearchAction(), getText(R.string.SearchToolbar_search_for_conversations_contacts_and_messages));
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
@@ -516,11 +509,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
super.onResume();
initializeSearchListener();
initializeFilterListener();
EventBus.getDefault().register(this);
itemAnimator.disable();
SpoilerAnnotation.resetRevealedSpoilers();
if ((!requireCallback().getSearchToolbar().resolved() || !(requireCallback().getSearchToolbar().get().getVisibility() == View.VISIBLE)) && list.getAdapter() != defaultAdapter) {
if (mainToolbarViewModel.getState().getValue().getMode() != MainToolbarMode.SEARCH && list.getAdapter() != defaultAdapter) {
setAdapter(defaultAdapter);
}
@@ -586,7 +580,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onPause() {
super.onPause();
requireCallback().getSearchAction().setOnClickListener(null);
fab.stopPulse();
cameraFab.stopPulse();
EventBus.getDefault().unregister(this);
@@ -598,58 +591,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
AppForegroundObserver.removeListener(appForegroundObserver);
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
menu.clear();
inflater.inflate(R.menu.text_secure_normal, menu);
}
@Override
public void onPrepareOptionsMenu(Menu menu) {
menu.findItem(R.id.menu_clear_passphrase).setVisible(!SignalStore.settings().getPassphraseDisabled());
ConversationFilterRequest request = viewModel.getConversationFilterRequest();
boolean isChatFilterEnabled = request != null && request.getFilter() == ConversationFilter.UNREAD;
menu.findItem(R.id.menu_filter_unread_chats).setVisible(!isChatFilterEnabled);
menu.findItem(R.id.menu_clear_unread_filter).setVisible(isChatFilterEnabled);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
super.onOptionsItemSelected(item);
int itemId = item.getItemId();
if (itemId == R.id.menu_new_group) {
handleCreateGroup();
return true;
} else if (itemId == R.id.menu_settings) {
handleDisplaySettings();
return true;
} else if (itemId == R.id.menu_clear_passphrase) {
handleClearPassphrase();
return true;
} else if (itemId == R.id.menu_mark_all_read) {
handleMarkAllRead();
return true;
} else if (itemId == R.id.menu_invite) {
handleInvite();
return true;
} else if (itemId == R.id.menu_notification_profile) {
handleNotificationProfile();
return true;
} else if (itemId == R.id.menu_filter_unread_chats) {
handleFilterUnreadChats();
return true;
} else if (itemId == R.id.menu_clear_unread_filter) {
onClearFilterClick();
return true;
} else {
return false;
}
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
@@ -718,14 +659,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
private boolean isSearchVisible() {
return (requireCallback().getSearchToolbar().resolved() && requireCallback().getSearchToolbar().get().getVisibility() == View.VISIBLE);
return mainToolbarViewModel.getState().getValue().getMode() == MainToolbarMode.SEARCH;
}
private boolean closeSearchIfOpen() {
if (isSearchOpen()) {
setAdapter(defaultAdapter);
requireCallback().getSearchToolbar().get().collapse();
requireCallback().onSearchClosed();
mainToolbarViewModel.setToolbarMode(MainToolbarMode.FULL);
conversationListTabsViewModel.onSearchClosed();
return true;
}
@@ -839,49 +780,33 @@ public class ConversationListFragment extends MainFragment implements ActionMode
})
);
requireCallback().getSearchAction().setOnClickListener(v -> {
fadeOutButtonsAndMegaphone(250);
requireCallback().onSearchOpened();
requireCallback().getSearchToolbar().get().setListener(new Material3SearchToolbar.Listener() {
@Override
public void onSearchTextChange(String text) {
String trimmed = text.trim();
contactSearchMediator.onFilterChanged(trimmed);
if (trimmed.length() > 0) {
if (activeAdapter != searchAdapter && list != null) {
setAdapter(searchAdapter);
}
} else {
if (activeAdapter != defaultAdapter) {
if (list != null) {
setAdapter(defaultAdapter);
}
}
lifecycleDisposable.add(
mainToolbarViewModel.getSearchEventsFlowable().subscribe(event -> {
if (event instanceof MainToolbarViewModel.Event.Search.Open) {
onSearchOpen();
} else if (event instanceof MainToolbarViewModel.Event.Search.Close) {
onSearchClose();
} else if (event instanceof MainToolbarViewModel.Event.Search.Query) {
onSearchQueryUpdated(((MainToolbarViewModel.Event.Search.Query) event).getQuery());
}
}
})
);
}
@Override
public void onSearchClosed() {
if (list != null) {
setAdapter(defaultAdapter);
private void initializeFilterListener() {
lifecycleDisposable.add(
mainToolbarViewModel.getChatEventsFlowable().subscribe(event -> {
if (event instanceof MainToolbarViewModel.Event.Chats.ApplyFilter) {
handleFilterUnreadChats();
} else if (event instanceof MainToolbarViewModel.Event.Chats.ClearFilter) {
onClearFilterClick();
}
requireCallback().onSearchClosed();
fadeInButtonsAndMegaphone(250);
}
});
updateSearchToolbarHint(Objects.requireNonNull(viewModel.getConversationFilterRequest()));
});
})
);
}
private void updateSearchToolbarHint(@NonNull ConversationFilterRequest conversationFilterRequest) {
Stub<Material3SearchToolbar> searchToolbar = requireCallback().getSearchToolbar();
if (!searchToolbar.resolved()) {
return;
}
searchToolbar.get().setSearchInputHint(
mainToolbarViewModel.setSearchHint(
conversationFilterRequest.getFilter() == ConversationFilter.OFF ? R.string.SearchToolbar_search : R.string.SearchToolbar_search_unread_chats
);
}
@@ -1058,10 +983,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
startupStopwatch.stop(TAG);
mediaControllerOwner.getVoiceNoteMediaController().finishPostpone();
if (getParentFragment() != null) {
requireCallback().getSearchToolbar().get();
}
Context context = getContext();
if (context != null) {
FrameLayout parent = new FrameLayout(context);
@@ -1128,31 +1049,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
viewModel.onMegaphoneVisible(megaphone);
}
private void handleCreateGroup() {
getNavigator().goToGroupCreation();
}
private void handleDisplaySettings() {
getNavigator().goToAppSettings();
}
private void handleClearPassphrase() {
Intent intent = new Intent(requireActivity(), KeyCachingService.class);
intent.setAction(KeyCachingService.CLEAR_KEY_ACTION);
requireActivity().startService(intent);
}
private void handleMarkAllRead() {
Context context = requireContext();
SignalExecutors.BOUNDED.execute(() -> {
List<MarkedMessageInfo> messageIds = SignalDatabase.threads().setAllThreadsRead();
AppDependencies.getMessageNotifier().updateNotification(context);
MarkReadReceiver.process(messageIds);
});
}
private void handleMarkAsRead(@NonNull Collection<Long> ids) {
Context context = requireContext();
Stopwatch stopwatch = new Stopwatch("mark-read");
@@ -1178,23 +1074,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
private void handleMarkAsUnread(@NonNull Collection<Long> ids) {
Context context = requireContext();
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
SignalDatabase.threads().setForcedUnread(ids);
StorageSyncHelper.scheduleSyncForDataChange();
return null;
}, none -> {
endActionModeIfActive();
});
}
private void handleInvite() {
getNavigator().goToInvite();
}
private void handleNotificationProfile() {
NotificationProfileSelectionFragment.show(getParentFragmentManager());
}, none -> endActionModeIfActive());
}
private void handleFilterUnreadChats() {
@@ -1592,7 +1476,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
private void showAddToFolderBottomSheet(Set<Conversation> conversations) {
List<Long> threadIds = new ArrayList<>();
List<Long> threadIds = new ArrayList<>();
List<Integer> threadTypes = new ArrayList<>();
for (Conversation conversation : conversations) {
@@ -1608,8 +1492,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private int getThreadType(Conversation conversation) {
boolean isIndividual = conversation.getThreadRecord().getRecipient().isIndividual();
boolean isGroup = conversation.getThreadRecord().getRecipient().isPushGroup();
int type;
boolean isGroup = conversation.getThreadRecord().getRecipient().isPushGroup();
int type;
if (isIndividual) {
type = AddToFolderBottomSheet.ThreadType.INDIVIDUAL.getValue();
} else if (isGroup) {
@@ -1689,10 +1573,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
return ((Callback) getParentFragment().getParentFragment());
}
protected Toolbar getToolbar(@NonNull View rootView) {
return requireCallback().getToolbar();
}
protected @PluralsRes int getArchivedSnackbarTitleRes() {
return R.plurals.ConversationListFragment_conversations_archived;
}
@@ -1815,7 +1695,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@Override
public void onReadAll(@NonNull ChatFolderRecord chatFolder) {
if (chatFolder.getFolderType() == ChatFolderRecord.FolderType.ALL) {
handleMarkAllRead();
mainToolbarViewModel.markAllMessagesRead();
} else {
viewModel.markChatFolderRead(chatFolder);
}
@@ -1831,6 +1711,35 @@ public class ConversationListFragment extends MainFragment implements ActionMode
startActivity(AppSettingsActivity.chatFolders(requireContext()));
}
private void onSearchOpen() {
fadeOutButtonsAndMegaphone(250);
}
private void onSearchClose() {
if (list != null) {
setAdapter(defaultAdapter);
}
fadeInButtonsAndMegaphone(250);
}
private void onSearchQueryUpdated(@NonNull String query) {
String trimmed = query.trim();
contactSearchMediator.onFilterChanged(trimmed);
if (!trimmed.isEmpty()) {
if (activeAdapter != searchAdapter && list != null) {
setAdapter(searchAdapter);
}
} else {
if (activeAdapter != defaultAdapter) {
if (list != null) {
setAdapter(defaultAdapter);
}
}
}
}
private class ArchiveListenerCallback extends ItemTouchHelper.SimpleCallback {
private static final long SWIPE_ANIMATION_DURATION = 175;
@@ -2083,13 +1992,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
}
public interface Callback extends Material3OnScrollHelperBinder, SearchBinder {
@NonNull Toolbar getToolbar();
@NonNull View getUnreadPaymentsDot();
@NonNull Stub<Toolbar> getBasicToolbar();
public interface Callback extends Material3OnScrollHelperBinder {
void updateProxyStatus(@NonNull WebSocketConnectionState state);
void onMultiSelectStarted();

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import kotlin.math.sqrt
/**
* Circle Reveal Modifiers found here:
* https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8
*
* A modifier that clips the composable content using a circular shape. The radius of the circle
* will be determined by the [transitionProgress].
*
* The values of the progress should be between 0 and 1.
*
* By default, the circle is centered in the content, but custom positions may be specified using
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).
*/
fun Modifier.circularReveal(
transitionProgress: State<Float>,
revealFrom: Offset = Offset(0.5f, 0.5f)
): Modifier {
return drawWithCache {
val path = Path()
val center = revealFrom.mapTo(size)
val radius = calculateRadius(revealFrom, size)
path.addOval(Rect(center, radius * transitionProgress.value))
onDrawWithContent {
clipPath(path) { this@onDrawWithContent.drawContent() }
}
}
}
private fun Offset.mapTo(size: Size): Offset {
return Offset(x * size.width, y * size.height)
}
private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) {
val x = (if (x > 0.5f) x else 1 - x) * size.width
val y = (if (y > 0.5f) y else 1 - y) * size.height
sqrt(x * x + y * y)
}

View File

@@ -1,53 +1,51 @@
package org.thoughtcrime.securesms.main
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.core.view.ViewCompat
import androidx.core.view.children
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.findNavController
import androidx.navigation.fragment.FragmentNavigatorExtras
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.InviteActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.components.TooltipPopup
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsState
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
import org.thoughtcrime.securesms.util.AvatarUtil
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.TopToastPopup
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.runHideAnimation
import org.thoughtcrime.securesms.util.runRevealAnimation
import org.thoughtcrime.securesms.util.views.Stub
import org.thoughtcrime.securesms.util.visible
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_fragment), ConversationListFragment.Callback, Material3OnScrollHelperBinder, CallLogFragment.Callback {
@@ -59,16 +57,6 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
private val conversationListTabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
private val disposables: LifecycleDisposable = LifecycleDisposable()
private lateinit var _toolbarBackground: View
private lateinit var _toolbar: Toolbar
private lateinit var _basicToolbar: Stub<Toolbar>
private lateinit var notificationProfileStatus: ImageView
private lateinit var proxyStatus: ImageView
private lateinit var _searchToolbar: Stub<Material3SearchToolbar>
private lateinit var _searchAction: ImageView
private lateinit var _unreadPaymentsDot: View
private lateinit var _backupsFailedDot: View
private var previousTopToastPopup: TopToastPopup? = null
private val destinationChangedListener = DestinationChangedListener()
@@ -79,28 +67,111 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
}
}
private val toolbarCallback = object : MainToolbarCallback {
override fun onNewGroupClick() {
startActivity(CreateGroupActivity.newIntent(requireActivity()))
}
override fun onClearPassphraseClick() {
val intent = Intent(requireActivity(), KeyCachingService::class.java)
intent.setAction(KeyCachingService.CLEAR_KEY_ACTION)
requireActivity().startService(intent)
}
override fun onMarkReadClick() {
toolbarViewModel.markAllMessagesRead()
}
override fun onInviteFriendsClick() {
val intent = Intent(requireContext(), InviteActivity::class.java)
startActivity(intent)
}
override fun onFilterUnreadChatsClick() {
toolbarViewModel.setChatFilter(ConversationFilter.UNREAD)
}
override fun onClearUnreadChatsFilterClick() {
toolbarViewModel.setChatFilter(ConversationFilter.OFF)
}
override fun onSettingsClick() {
openSettings.launch(AppSettingsActivity.home(requireContext()))
}
override fun onNotificationProfileClick() {
NotificationProfileSelectionFragment.show(parentFragmentManager)
}
override fun onProxyClick() {
startActivity(AppSettingsActivity.proxy(requireContext()))
}
override fun onSearchClick() {
conversationListTabsViewModel.onSearchOpened()
toolbarViewModel.setToolbarMode(MainToolbarMode.SEARCH)
toolbarViewModel.emitEvent(MainToolbarViewModel.Event.Search.Open)
}
override fun onClearCallHistoryClick() {
toolbarViewModel.clearCallHistory()
}
override fun onFilterMissedCallsClick() {
toolbarViewModel.setCallLogFilter(CallLogFilter.MISSED)
}
override fun onClearCallFilterClick() {
toolbarViewModel.setCallLogFilter(CallLogFilter.ALL)
}
override fun onStoryPrivacyClick() {
startActivity(StorySettingsActivity.getIntent(requireContext()))
}
override fun onCloseSearchClick() {
conversationListTabsViewModel.onSearchClosed()
toolbarViewModel.setToolbarMode(MainToolbarMode.FULL)
toolbarViewModel.emitEvent(MainToolbarViewModel.Event.Search.Close)
}
override fun onCloseArchiveClick() {
getChildNavController().popBackStack()
}
override fun onSearchQueryUpdated(query: String) {
toolbarViewModel.setSearchQuery(query)
}
override fun onNotificationProfileTooltipDismissed() {
SignalStore.notificationProfile.hasSeenTooltip = true
toolbarViewModel.setShowNotificationProfilesTooltip(false)
}
}
private val toolbarViewModel: MainToolbarViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
disposables.bindTo(viewLifecycleOwner)
_toolbarBackground = view.findViewById(R.id.toolbar_background)
_toolbar = view.findViewById(R.id.toolbar)
_basicToolbar = Stub(view.findViewById(R.id.toolbar_basic_stub))
notificationProfileStatus = view.findViewById(R.id.conversation_list_notification_profile_status)
proxyStatus = view.findViewById(R.id.conversation_list_proxy_status)
_searchAction = view.findViewById(R.id.search_action)
_searchToolbar = Stub(view.findViewById(R.id.search_toolbar))
_unreadPaymentsDot = view.findViewById(R.id.unread_payments_indicator)
_backupsFailedDot = view.findViewById(R.id.backups_failed_indicator)
val toolbarContainer = view.findViewById<ComposeView>(R.id.toolbar_container)
toolbarContainer.setContent {
val state by toolbarViewModel.state.collectAsStateWithLifecycle()
notificationProfileStatus.setOnClickListener { handleNotificationProfile() }
proxyStatus.setOnClickListener { onProxyStatusClicked() }
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)) {
MainToolbar(
state = state,
callback = toolbarCallback
)
}
}
initializeSettingsTouchTarget()
(requireActivity() as AppCompatActivity).setSupportActionBar(_toolbar)
UnreadPaymentsLiveData().observe(viewLifecycleOwner) { unread ->
toolbarViewModel.setHasUnreadPayments(unread.isPresent)
}
disposables += conversationListTabsViewModel.state.subscribeBy { state ->
val controller: NavController = requireView().findViewById<View>(R.id.fragment_container).findNavController()
val controller: NavController = getChildNavController()
when (controller.currentDestination?.id) {
R.id.conversationListFragment -> goToStateFromConversationList(state, controller)
R.id.conversationListArchiveFragment -> Unit
@@ -114,6 +185,10 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
}
}
private fun getChildNavController(): NavController {
return requireView().findViewById<View>(R.id.fragment_container).findNavController()
}
private fun goToStateFromConversationList(state: ConversationListTabsState, navController: NavController) {
if (state.tab == ConversationListTab.CHATS) {
return
@@ -168,13 +243,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
override fun onResume() {
super.onResume()
SimpleTask.run(viewLifecycleOwner.lifecycle, { Recipient.self() }, ::initializeProfileIcon)
_backupsFailedDot.alpha = if (BackupRepository.shouldDisplayBackupFailedIndicator() || BackupRepository.shouldDisplayBackupAlreadyRedeemedIndicator()) {
1f
} else {
0f
}
toolbarViewModel.refresh()
requireView()
.findViewById<View>(R.id.fragment_container)
@@ -195,40 +264,23 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
}
private fun presentToolbarForConversationListFragment() {
if (_basicToolbar.resolved() && _basicToolbar.get().visible) {
_toolbar.runRevealAnimation(R.anim.slide_from_start)
}
_toolbar.visible = true
_searchAction.visible = true
if (_basicToolbar.resolved() && _basicToolbar.get().visible) {
_basicToolbar.get().runHideAnimation(R.anim.slide_to_end)
}
toolbarViewModel.setToolbarMode(MainToolbarMode.FULL, destination = MainNavigationDestination.CHATS)
}
private fun presentToolbarForConversationListArchiveFragment() {
_toolbar.runHideAnimation(R.anim.slide_to_start)
_basicToolbar.get().runRevealAnimation(R.anim.slide_from_end)
toolbarViewModel.setToolbarMode(MainToolbarMode.BASIC, destination = MainNavigationDestination.CHATS)
}
private fun presentToolbarForStoriesLandingFragment() {
_toolbar.visible = true
_searchAction.visible = true
if (_basicToolbar.resolved()) {
_basicToolbar.get().visible = false
}
toolbarViewModel.setToolbarMode(MainToolbarMode.FULL, destination = MainNavigationDestination.STORIES)
}
private fun presentToolbarForCallLogFragment() {
presentToolbarForConversationListFragment()
toolbarViewModel.setToolbarMode(MainToolbarMode.FULL, destination = MainNavigationDestination.CALLS)
}
private fun presentToolbarForMultiselect() {
_toolbar.visible = false
if (_basicToolbar.resolved()) {
_basicToolbar.get().visible = false
}
toolbarViewModel.setToolbarMode(MainToolbarMode.NONE)
}
override fun onDestroyView() {
@@ -236,36 +288,6 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
super.onDestroyView()
}
override fun getToolbar(): Toolbar {
return _toolbar
}
override fun getSearchAction(): ImageView {
return _searchAction
}
override fun getSearchToolbar(): Stub<Material3SearchToolbar> {
return _searchToolbar
}
override fun getUnreadPaymentsDot(): View {
return _unreadPaymentsDot
}
override fun getBasicToolbar(): Stub<Toolbar> {
return _basicToolbar
}
override fun onSearchOpened() {
conversationListTabsViewModel.onSearchOpened()
_searchToolbar.get().clearText()
_searchToolbar.get().display(_searchAction.x + (_searchAction.width / 2.0f), _searchAction.y + (_searchAction.height / 2.0f))
}
override fun onSearchClosed() {
conversationListTabsViewModel.onSearchClosed()
}
override fun onMultiSelectStarted() {
presentToolbarForMultiselect()
conversationListTabsViewModel.onMultiSelectStarted()
@@ -280,43 +302,18 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
conversationListTabsViewModel.onMultiSelectFinished()
}
private fun initializeProfileIcon(recipient: Recipient) {
Log.d(TAG, "Initializing profile icon")
val icon = requireView().findViewById<ImageView>(R.id.toolbar_icon)
val imageView: BadgeImageView = requireView().findViewById(R.id.toolbar_badge)
imageView.setBadgeFromRecipient(recipient)
AvatarUtil.loadIconIntoImageView(recipient, icon, resources.getDimensionPixelSize(R.dimen.toolbar_avatar_size))
}
private fun initializeSettingsTouchTarget() {
val touchArea = requireView().findViewById<View>(R.id.toolbar_settings_touch_area)
touchArea.setOnClickListener {
BackupRepository.markBackupFailedIndicatorClicked()
openSettings.launch(AppSettingsActivity.home(requireContext()))
}
}
private fun handleNotificationProfile() {
NotificationProfileSelectionFragment.show(parentFragmentManager)
}
private fun onProxyStatusClicked() {
startActivity(AppSettingsActivity.proxy(requireContext()))
}
override fun updateProxyStatus(state: WebSocketConnectionState) {
if (SignalStore.proxy.isProxyEnabled) {
proxyStatus.visibility = View.VISIBLE
when (state) {
WebSocketConnectionState.CONNECTING, WebSocketConnectionState.DISCONNECTING, WebSocketConnectionState.DISCONNECTED -> proxyStatus.setImageResource(R.drawable.ic_proxy_connecting_24)
WebSocketConnectionState.CONNECTED -> proxyStatus.setImageResource(R.drawable.ic_proxy_connected_24)
WebSocketConnectionState.AUTHENTICATION_FAILED,
WebSocketConnectionState.REMOTE_DEPRECATED,
WebSocketConnectionState.FAILED -> proxyStatus.setImageResource(R.drawable.ic_proxy_failed_24)
else -> proxyStatus.visibility = View.GONE
val proxyState: MainToolbarState.ProxyState = when (state) {
WebSocketConnectionState.CONNECTING, WebSocketConnectionState.DISCONNECTING, WebSocketConnectionState.DISCONNECTED -> MainToolbarState.ProxyState.CONNECTING
WebSocketConnectionState.CONNECTED -> MainToolbarState.ProxyState.CONNECTED
WebSocketConnectionState.AUTHENTICATION_FAILED, WebSocketConnectionState.FAILED, WebSocketConnectionState.REMOTE_DEPRECATED -> MainToolbarState.ProxyState.FAILED
else -> MainToolbarState.ProxyState.NONE
}
toolbarViewModel.setProxyState(proxyState = proxyState)
} else {
proxyStatus.visibility = View.GONE
toolbarViewModel.setProxyState(proxyState = MainToolbarState.ProxyState.NONE)
}
}
@@ -346,30 +343,16 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
}
}, 500L)
}
notificationProfileStatus.visibility = View.VISIBLE
toolbarViewModel.setNotificationProfileEnabled(true)
} else {
notificationProfileStatus.visibility = View.GONE
toolbarViewModel.setNotificationProfileEnabled(false)
}
if (!SignalStore.notificationProfile.hasSeenTooltip && Util.hasItems(notificationProfiles)) {
val target: View? = findOverflowMenuButton(_toolbar)
if (target != null) {
TooltipPopup.forTarget(target)
.setText(R.string.ConversationListFragment__turn_your_notification_profile_on_or_off_here)
.setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.signal_button_primary))
.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_button_primary_text))
.setOnDismissListener { SignalStore.notificationProfile.hasSeenTooltip = true }
.show(TooltipPopup.POSITION_BELOW)
} else {
Log.w(TAG, "Unable to find overflow menu to show Notification Profile tooltip")
}
toolbarViewModel.setShowNotificationProfilesTooltip(true)
}
}
private fun findOverflowMenuButton(viewGroup: Toolbar): View? {
return viewGroup.children.find { it is ActionMenuView }
}
private fun presentToolbarForDestination(destination: NavDestination) {
when (destination.id) {
R.id.conversationListFragment -> {
@@ -402,18 +385,24 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
override fun bindScrollHelper(recyclerView: RecyclerView) {
Material3OnScrollHelper(
requireActivity(),
listOf(_toolbarBackground),
listOf(_searchToolbar),
viewLifecycleOwner
activity = requireActivity(),
views = listOf(),
viewStubs = listOf(),
onSetToolbarColor = {
toolbarViewModel.setToolbarColor(it)
},
lifecycleOwner = viewLifecycleOwner
).attach(recyclerView)
}
override fun bindScrollHelper(recyclerView: RecyclerView, chatFolders: RecyclerView, setChatFolder: (Int) -> Unit) {
Material3OnScrollHelper(
activity = requireActivity(),
views = listOf(_toolbarBackground, chatFolders),
viewStubs = listOf(_searchToolbar),
views = listOf(chatFolders),
viewStubs = listOf(),
onSetToolbarColor = {
toolbarViewModel.setToolbarColor(it)
},
lifecycleOwner = viewLifecycleOwner,
setChatFolderColor = setChatFolder
).attach(recyclerView)

View File

@@ -0,0 +1,704 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.TextFields
import org.signal.core.ui.compose.Tooltips
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageSmall
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.recipients.Recipient
interface MainToolbarCallback {
fun onNewGroupClick()
fun onClearPassphraseClick()
fun onMarkReadClick()
fun onInviteFriendsClick()
fun onFilterUnreadChatsClick()
fun onClearUnreadChatsFilterClick()
fun onSettingsClick()
fun onNotificationProfileClick()
fun onProxyClick()
fun onSearchClick()
fun onClearCallHistoryClick()
fun onFilterMissedCallsClick()
fun onClearCallFilterClick()
fun onStoryPrivacyClick()
fun onCloseSearchClick()
fun onCloseArchiveClick()
fun onSearchQueryUpdated(query: String)
fun onNotificationProfileTooltipDismissed()
object Empty : MainToolbarCallback {
override fun onNewGroupClick() = Unit
override fun onClearPassphraseClick() = Unit
override fun onMarkReadClick() = Unit
override fun onInviteFriendsClick() = Unit
override fun onFilterUnreadChatsClick() = Unit
override fun onClearUnreadChatsFilterClick() = Unit
override fun onSettingsClick() = Unit
override fun onNotificationProfileClick() = Unit
override fun onProxyClick() = Unit
override fun onSearchClick() = Unit
override fun onClearCallHistoryClick() = Unit
override fun onFilterMissedCallsClick() = Unit
override fun onClearCallFilterClick() = Unit
override fun onStoryPrivacyClick() = Unit
override fun onCloseSearchClick() = Unit
override fun onCloseArchiveClick() = Unit
override fun onSearchQueryUpdated(query: String) = Unit
override fun onNotificationProfileTooltipDismissed() = Unit
}
}
enum class MainToolbarMode {
NONE,
FULL,
BASIC,
SEARCH
}
data class MainToolbarState(
val toolbarColor: Color? = null,
val self: Recipient = Recipient.self(),
val mode: MainToolbarMode = MainToolbarMode.FULL,
val destination: MainNavigationDestination = MainNavigationDestination.CHATS,
val chatFilter: ConversationFilter = ConversationFilter.OFF,
val callFilter: CallLogFilter = CallLogFilter.ALL,
val hasUnreadPayments: Boolean = false,
val hasFailedBackups: Boolean = false,
val hasEnabledNotificationProfile: Boolean = false,
val showNotificationProfilesTooltip: Boolean = false,
val hasPassphrase: Boolean = false,
val proxyState: ProxyState = ProxyState.NONE,
@StringRes val searchHint: Int = R.string.SearchToolbar_search,
val searchQuery: String = ""
) {
enum class ProxyState(@DrawableRes val icon: Int) {
NONE(-1),
CONNECTING(R.drawable.ic_proxy_connecting_24),
CONNECTED(R.drawable.ic_proxy_connected_24),
FAILED(R.drawable.ic_proxy_failed_24)
}
}
@Composable
fun MainToolbar(
state: MainToolbarState,
callback: MainToolbarCallback
) {
if (state.mode == MainToolbarMode.NONE) {
return
}
Crossfade(
targetState = state.mode != MainToolbarMode.BASIC
) { targetState ->
when (targetState) {
true -> Box {
var revealOffset by remember { mutableStateOf(Offset.Zero) }
BoxWithConstraints {
val maxWidth = with(LocalDensity.current) {
maxWidth.toPx()
}
PrimaryToolbar(state, callback) {
revealOffset = Offset(it / maxWidth, 0.5f)
}
AnimatedVisibility(
visible = state.mode == MainToolbarMode.SEARCH,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
val visibility = transition.animateFloat(
transitionSpec = { tween(durationMillis = 400, easing = LinearOutSlowInEasing) },
label = "Visibility"
) { state ->
if (state == EnterExitState.Visible) 1f else 0f
}
SearchToolbar(
state = state,
callback = callback,
modifier = Modifier.circularReveal(visibility, revealOffset)
)
}
}
}
false -> ArchiveToolbar(state, callback)
}
}
}
@Composable
private fun SearchToolbar(
state: MainToolbarState,
callback: MainToolbarCallback,
modifier: Modifier = Modifier
) {
val focusRequester = remember { FocusRequester() }
TextFields.TextField(
value = state.searchQuery,
onValueChange = callback::onSearchQueryUpdated,
leadingIcon = {
IconButtons.IconButton(
onClick = callback::onCloseSearchClick
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_arrow_left_24),
contentDescription = null
)
}
},
contentPadding = PaddingValues(0.dp),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
errorContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
textStyle = MaterialTheme.typography.bodyLarge,
shape = RoundedCornerShape(50),
singleLine = true,
placeholder = {
Text(text = stringResource(state.searchHint))
},
modifier = modifier
.background(color = state.toolbarColor ?: MaterialTheme.colorScheme.surface)
.height(dimensionResource(R.dimen.signal_m3_toolbar_height))
.padding(horizontal = 16.dp, vertical = 10.dp)
.fillMaxWidth()
.focusRequester(focusRequester)
)
LaunchedEffect(state.mode) {
if (state.mode == MainToolbarMode.SEARCH) {
focusRequester.requestFocus()
} else {
focusRequester.freeFocus()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ArchiveToolbar(
state: MainToolbarState,
callback: MainToolbarCallback
) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = state.toolbarColor ?: MaterialTheme.colorScheme.surface
),
navigationIcon = {
IconButtons.IconButton(onClick = {
callback.onCloseArchiveClick()
}) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_arrow_left_24),
contentDescription = stringResource(R.string.CallScreenTopBar__go_back)
)
}
},
title = {
Text(text = stringResource(R.string.AndroidManifest_archived_conversations))
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PrimaryToolbar(
state: MainToolbarState,
callback: MainToolbarCallback,
onSearchButtonPositioned: (Float) -> Unit
) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = state.toolbarColor ?: MaterialTheme.colorScheme.surface
),
navigationIcon = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(start = 28.dp, end = 26.dp)
.fillMaxHeight()
) {
AvatarImage(
recipient = state.self,
modifier = Modifier
.clip(CircleShape)
.clickable(
onClick = callback::onSettingsClick,
onClickLabel = stringResource(R.string.conversation_list_settings_shortcut)
)
.size(28.dp)
)
BadgeImageSmall(
badge = state.self.featuredBadge,
modifier = Modifier
.padding(start = 14.dp, top = 16.dp)
.size(16.dp)
)
HeadsUpIndicator(
state = state,
modifier = Modifier.padding(start = 20.dp, bottom = 20.dp)
)
}
},
title = {
Text(
text = stringResource(R.string.app_name)
)
},
actions = {
NotificationProfileAction(state, callback)
ProxyAction(state, callback)
IconButtons.IconButton(
onClick = callback::onSearchClick,
modifier = Modifier.onPlaced {
onSearchButtonPositioned(it.positionInWindow().x + (it.size.width / 2f))
}
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_search_24),
contentDescription = stringResource(R.string.conversation_list_search_description)
)
}
val controller = remember { DropdownMenus.MenuController() }
val dismiss = remember(controller) { { controller.hide() } }
TooltipOverflowButton(
onOverflowClick = { controller.show() },
isTooltipVisible = state.showNotificationProfilesTooltip,
onDismiss = { callback.onNotificationProfileTooltipDismissed() }
)
DropdownMenus.Menu(
controller = controller
) {
when (state.destination) {
MainNavigationDestination.CHATS -> ChatDropdownItems(state, callback, dismiss)
MainNavigationDestination.CALLS -> CallDropdownItems(state.callFilter, callback, dismiss)
MainNavigationDestination.STORIES -> StoryDropDownItems(callback, dismiss)
}
}
}
)
}
@Composable
private fun TooltipOverflowButton(
onOverflowClick: () -> Unit,
onDismiss: () -> Unit,
isTooltipVisible: Boolean
) {
Tooltips.PlainBelowAnchor(
onDismiss = onDismiss,
isTooltipVisible = isTooltipVisible,
tooltipContent = {
Text(text = stringResource(R.string.ConversationListFragment__turn_your_notification_profile_on_or_off_here))
},
anchorContent = {
IconButtons.IconButton(
onClick = onOverflowClick
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_more_vertical),
contentDescription = null
)
}
}
)
}
@Composable
private fun NotificationProfileAction(
state: MainToolbarState,
callback: MainToolbarCallback
) {
if (state.hasEnabledNotificationProfile) {
IconButtons.IconButton(
onClick = callback::onNotificationProfileClick
) {
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_notification_profile_active),
contentDescription = null
)
}
}
}
@Composable
private fun ProxyAction(
state: MainToolbarState,
callback: MainToolbarCallback
) {
if (state.proxyState != MainToolbarState.ProxyState.NONE) {
IconButtons.IconButton(
onClick = callback::onProxyClick
) {
Image(
imageVector = ImageVector.vectorResource(state.proxyState.icon),
contentDescription = null
)
}
}
}
@Composable
private fun HeadsUpIndicator(state: MainToolbarState, modifier: Modifier = Modifier) {
if (!state.hasUnreadPayments && !state.hasFailedBackups) {
return
}
val color = if (state.hasFailedBackups) {
Color(0xFFFFCC00)
} else {
MaterialTheme.colorScheme.primary
}
Box(
modifier = modifier
.size(13.dp)
.background(color = color, shape = CircleShape)
) {
// Intentionally empty
}
}
@Composable
private fun StoryDropDownItems(callback: MainToolbarCallback, onOptionSelected: () -> Unit) {
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.StoriesLandingFragment__story_privacy),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onStoryPrivacyClick()
onOptionSelected()
}
)
}
@Composable
private fun CallDropdownItems(callFilter: CallLogFilter, callback: MainToolbarCallback, onOptionSelected: () -> Unit) {
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.CallLogFragment__clear_call_history),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onClearCallHistoryClick()
onOptionSelected()
}
)
if (callFilter == CallLogFilter.ALL) {
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.CallLogFragment__filter_missed_calls),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onFilterMissedCallsClick()
onOptionSelected()
}
)
} else {
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.CallLogFragment__clear_filter),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onClearCallFilterClick()
onOptionSelected()
}
)
}
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.text_secure_normal__menu_settings),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onSettingsClick()
onOptionSelected()
}
)
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.ConversationListFragment__notification_profile),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onNotificationProfileClick()
onOptionSelected()
}
)
}
@Composable
private fun ChatDropdownItems(state: MainToolbarState, callback: MainToolbarCallback, onOptionSelected: () -> Unit) {
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.text_secure_normal__menu_new_group),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onNewGroupClick()
onOptionSelected()
}
)
if (state.hasPassphrase) {
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.text_secure_normal__menu_clear_passphrase),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onNewGroupClick()
onOptionSelected()
}
)
}
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.text_secure_normal__mark_all_as_read),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onMarkReadClick()
onOptionSelected()
}
)
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.text_secure_normal__invite_friends),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onInviteFriendsClick()
onOptionSelected()
}
)
if (state.chatFilter == ConversationFilter.OFF) {
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.text_secure_normal__filter_unread_chats),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onFilterUnreadChatsClick()
onOptionSelected()
}
)
} else {
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.text_secure_normal__clear_unread_filter),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onClearUnreadChatsFilterClick()
onOptionSelected()
}
)
}
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.text_secure_normal__menu_settings),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onSettingsClick()
onOptionSelected()
}
)
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.ConversationListFragment__notification_profile),
style = MaterialTheme.typography.bodyLarge
)
},
onClick = {
callback.onNotificationProfileClick()
onOptionSelected()
}
)
}
@Preview
@SignalPreview
@Composable
private fun FullMainToolbarPreview() {
Previews.Preview {
var mode by remember { mutableStateOf(MainToolbarMode.FULL) }
MainToolbar(
state = MainToolbarState(
self = Recipient(isResolving = false),
mode = mode,
destination = MainNavigationDestination.CHATS,
hasEnabledNotificationProfile = true,
proxyState = MainToolbarState.ProxyState.CONNECTED,
hasFailedBackups = true
),
callback = object : MainToolbarCallback by MainToolbarCallback.Empty {
override fun onSearchClick() {
mode = MainToolbarMode.SEARCH
}
override fun onCloseSearchClick() {
mode = MainToolbarMode.FULL
}
}
)
}
}
@SignalPreview
@Composable
private fun SearchToolbarPreview() {
Previews.Preview {
SearchToolbar(
state = MainToolbarState(
self = Recipient(isResolving = false, isSelf = true),
searchQuery = "Test query"
),
callback = MainToolbarCallback.Empty
)
}
}
@SignalPreview
@Composable
private fun ArchiveToolbarPreview() {
Previews.Preview {
ArchiveToolbar(
state = MainToolbarState(),
callback = MainToolbarCallback.Empty
)
}
}
@SignalPreview
@Composable
private fun TooltipOverflowButtonPreview() {
Previews.Preview {
TooltipOverflowButton(
onOverflowClick = {},
onDismiss = {},
isTooltipVisible = true
)
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.notifications.MarkReadReceiver
object MainToolbarRepository {
/**
* Mark all unread messages in the local database as read.
*/
fun markAllMessagesRead() {
SignalExecutors.BOUNDED.execute {
val messageIds = SignalDatabase.threads.setAllThreadsRead()
AppDependencies.messageNotifier.updateNotification(AppDependencies.application)
MarkReadReceiver.process(messageIds)
}
}
}

View File

@@ -0,0 +1,168 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.reactivex.rxjava3.core.Flowable
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlowable
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
class MainToolbarViewModel : ViewModel() {
private val internalStateFlow = MutableStateFlow(MainToolbarState())
private val internalEvents = MutableSharedFlow<Event>()
val state: StateFlow<MainToolbarState> = internalStateFlow
fun refresh() {
internalStateFlow.update {
it.copy(
self = Recipient.self(),
hasFailedBackups = BackupRepository.shouldDisplayBackupFailedIndicator() || BackupRepository.shouldDisplayBackupAlreadyRedeemedIndicator(),
hasPassphrase = !SignalStore.settings.passphraseDisabled
)
}
}
fun emitEvent(event: Event) {
viewModelScope.launch {
internalEvents.emit(event)
}
}
fun setToolbarColor(@ColorInt color: Int) {
internalStateFlow.update {
it.copy(toolbarColor = Color(color))
}
}
fun setSearchQuery(query: String) {
internalStateFlow.update {
it.copy(searchQuery = query)
}
viewModelScope.launch {
internalEvents.emit(Event.Search.Query(query))
}
}
@JvmOverloads
fun setToolbarMode(mode: MainToolbarMode, destination: MainNavigationDestination? = null) {
internalStateFlow.update {
it.copy(mode = mode, destination = destination ?: it.destination, searchQuery = "")
}
}
fun setProxyState(proxyState: MainToolbarState.ProxyState) {
internalStateFlow.update {
it.copy(proxyState = proxyState)
}
}
fun setNotificationProfileEnabled(hasEnabledNotificationProfile: Boolean) {
internalStateFlow.update {
it.copy(hasEnabledNotificationProfile = hasEnabledNotificationProfile)
}
}
fun setShowNotificationProfilesTooltip(showNotificationProfilesTooltip: Boolean) {
internalStateFlow.update {
it.copy(showNotificationProfilesTooltip = showNotificationProfilesTooltip)
}
}
fun setHasUnreadPayments(hasUnreadPayments: Boolean) {
internalStateFlow.update {
it.copy(hasUnreadPayments = hasUnreadPayments)
}
}
fun setChatFilter(conversationFilter: ConversationFilter) {
internalStateFlow.update {
it.copy(chatFilter = conversationFilter)
}
viewModelScope.launch {
when (conversationFilter) {
ConversationFilter.UNREAD -> internalEvents.emit(Event.Chats.ApplyFilter)
else -> internalEvents.emit(Event.Chats.ClearFilter)
}
}
}
fun setCallLogFilter(callLogFilter: CallLogFilter) {
internalStateFlow.update {
it.copy(callFilter = callLogFilter)
}
viewModelScope.launch {
when (callLogFilter) {
CallLogFilter.MISSED -> internalEvents.emit(Event.CallLog.ApplyFilter)
else -> internalEvents.emit(Event.CallLog.ClearFilter)
}
}
}
fun getSearchEventsFlowable(): Flowable<Event.Search> {
return internalEvents.filterIsInstance(Event.Search::class).asFlowable()
}
fun getCallLogEventsFlowable(): Flowable<Event.CallLog> {
return internalEvents.filterIsInstance(Event.CallLog::class).asFlowable()
}
fun getChatEventsFlowable(): Flowable<Event.Chats> {
return internalEvents.filterIsInstance(Event.Chats::class).asFlowable()
}
fun clearCallHistory() {
viewModelScope.launch {
internalEvents.emit(Event.CallLog.ClearHistory)
}
}
fun markAllMessagesRead() {
MainToolbarRepository.markAllMessagesRead()
}
fun setSearchHint(@StringRes hint: Int) {
internalStateFlow.update {
it.copy(searchHint = hint)
}
}
sealed interface Event {
sealed interface Search : Event {
data object Open : Search
data object Close : Search
data class Query(val query: String) : Search
}
sealed interface Chats : Event {
data object ApplyFilter : Chats
data object ClearFilter : Chats
}
sealed interface CallLog : Event {
data object ApplyFilter : CallLog
data object ClearFilter : CallLog
data object ClearHistory : CallLog
}
}
}

View File

@@ -1,15 +0,0 @@
package org.thoughtcrime.securesms.main
import android.widget.ImageView
import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.util.views.Stub
interface SearchBinder {
fun getSearchAction(): ImageView
fun getSearchToolbar(): Stub<Material3SearchToolbar>
fun onSearchOpened()
fun onSearchClosed()
}

View File

@@ -84,7 +84,7 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
list.adapter = adapter
list.itemAnimator = null
Material3OnScrollHelper(requireActivity(), toolbarShadow, viewLifecycleOwner).attach(list)
Material3OnScrollHelper(activity = requireActivity(), views = listOf(toolbarShadow), lifecycleOwner = viewLifecycleOwner).attach(list)
}
private fun initializeViewModel() {

View File

@@ -4,9 +4,6 @@ import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
@@ -14,6 +11,7 @@ import androidx.compose.ui.platform.ComposeView
import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.SharedElementCallback
import androidx.core.view.ViewCompat
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@@ -31,7 +29,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.BannerManager
import org.thoughtcrime.securesms.banner.banners.DeprecatedBuildBanner
import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner
import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
@@ -42,8 +39,9 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.SearchBinder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.permissions.Permissions
@@ -53,7 +51,6 @@ import org.thoughtcrime.securesms.stories.StoryViewerArgs
import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
import org.thoughtcrime.securesms.stories.my.MyStoriesActivity
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
@@ -87,19 +84,10 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
)
private val tabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
private val mainToolbarViewModel: MainToolbarViewModel by activityViewModels()
private lateinit var adapter: MappingAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
menu.clear()
inflater.inflate(R.menu.story_landing_menu, menu)
}
override fun onResume() {
super.onResume()
viewModel.isTransitioningToAnotherScreen = false
@@ -109,25 +97,17 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
AppDependencies.expireStoriesManager.scheduleIfNecessary()
}
override fun onPause() {
super.onPause()
requireListener<SearchBinder>().getSearchAction().setOnClickListener(null)
}
private fun initializeSearchAction() {
val searchBinder = requireListener<SearchBinder>()
searchBinder.getSearchAction().setOnClickListener {
searchBinder.onSearchOpened()
searchBinder.getSearchToolbar().get().setSearchInputHint(R.string.SearchToolbar_search)
searchBinder.getSearchToolbar().get().listener = object : Material3SearchToolbar.Listener {
override fun onSearchTextChange(text: String) {
viewModel.setSearchQuery(text.trim())
}
override fun onSearchClosed() {
lifecycleDisposable += mainToolbarViewModel.getSearchEventsFlowable().subscribeBy {
when (it) {
MainToolbarViewModel.Event.Search.Close -> {
viewModel.setSearchQuery("")
searchBinder.onSearchClosed()
}
MainToolbarViewModel.Event.Search.Open -> {
mainToolbarViewModel.setSearchHint(R.string.SearchToolbar_search)
}
is MainToolbarViewModel.Event.Search.Query -> {
viewModel.setSearchQuery(it.query.trim())
}
}
}
@@ -410,15 +390,6 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return if (item.itemId == R.id.action_settings) {
startActivityIfAble(StorySettingsActivity.getIntent(requireContext()))
true
} else {
false
}
}
@Suppress("OVERRIDE_DEPRECATION")
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
@@ -438,19 +409,15 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
}
private fun isSearchVisible(): Boolean {
return requreSearchBinder().getSearchToolbar().resolved() && requreSearchBinder().getSearchToolbar().get().getVisibility() == View.VISIBLE
return mainToolbarViewModel.state.value.mode == MainToolbarMode.SEARCH
}
private fun closeSearchIfOpen(): Boolean {
if (isSearchOpen()) {
requreSearchBinder().getSearchToolbar().get().collapse()
requreSearchBinder().onSearchClosed()
mainToolbarViewModel.setToolbarMode(MainToolbarMode.FULL)
tabsViewModel.onSearchClosed()
return true
}
return false
}
private fun requreSearchBinder(): SearchBinder {
return requireListener()
}
}

View File

@@ -77,7 +77,7 @@ class CreateStoryWithViewersFragment : DSLSettingsFragment(
}
Material3OnScrollHelper(
context = requireContext(),
activity = requireActivity(),
setStatusBarColor = { requireListener<Callback>().setStatusBarColor(it) },
getStatusBarColor = { requireListener<Callback>().getStatusBarColor() },
views = listOf(binding.toolbar),

View File

@@ -1,13 +1,12 @@
package org.thoughtcrime.securesms.util
import android.animation.ValueAnimator
import android.app.Activity
import android.content.Context
import android.view.View
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.widget.NestedScrollView
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
@@ -22,40 +21,25 @@ import org.thoughtcrime.securesms.util.views.Stub
* for other purposes.
*/
open class Material3OnScrollHelper(
private val context: Context,
private val setStatusBarColor: (Int) -> Unit,
private val getStatusBarColor: () -> Int,
private val activity: FragmentActivity,
private val setStatusBarColor: (Int) -> Unit = { WindowUtil.setStatusBarColor(activity.window, it) },
getStatusBarColor: () -> Int = { WindowUtil.getStatusBarColor(activity.window) },
private val setChatFolderColor: (Int) -> Unit = {},
private val views: List<View>,
private val onSetToolbarColor: (Int) -> Unit = {},
private val views: List<View> = emptyList(),
private val viewStubs: List<Stub<out View>> = emptyList(),
lifecycleOwner: LifecycleOwner
) {
constructor(activity: Activity, view: View, lifecycleOwner: LifecycleOwner) : this(activity = activity, views = listOf(view), lifecycleOwner = lifecycleOwner)
constructor(activity: Activity, views: List<View>, viewStubs: List<Stub<out View>> = emptyList(), lifecycleOwner: LifecycleOwner) : this(
activity = activity,
views = views,
viewStubs = viewStubs,
lifecycleOwner = lifecycleOwner,
setChatFolderColor = {}
)
constructor(
activity: Activity,
views: List<View>,
viewStubs: List<Stub<out View>> = emptyList(),
lifecycleOwner: LifecycleOwner,
setChatFolderColor: (Int) -> Unit = {}
) : this(
context = activity,
setStatusBarColor = { WindowUtil.setStatusBarColor(activity.window, it) },
getStatusBarColor = { WindowUtil.getStatusBarColor(activity.window) },
setChatFolderColor = setChatFolderColor,
views = views,
viewStubs = viewStubs,
lifecycleOwner = lifecycleOwner
)
companion object {
/**
* Override for our single java usage.
*/
@JvmStatic
fun create(activity: FragmentActivity, toolbar: View): Material3OnScrollHelper {
return Material3OnScrollHelper(activity = activity, views = listOf(toolbar), lifecycleOwner = activity)
}
}
open val activeColorSet: ColorSet = ColorSet(
toolbarColorRes = R.color.signal_colorSurface2,
@@ -116,9 +100,9 @@ open class Material3OnScrollHelper(
animator?.cancel()
val colorSet = if (active == true) activeColorSet else inactiveColorSet
setToolbarColor(ContextCompat.getColor(context, colorSet.toolbarColorRes))
setStatusBarColor(ContextCompat.getColor(context, colorSet.statusBarColorRes))
setChatFolderColor(ContextCompat.getColor(context, colorSet.chatFolderColorRes))
setToolbarColor(ContextCompat.getColor(activity, colorSet.toolbarColorRes))
setStatusBarColor(ContextCompat.getColor(activity, colorSet.statusBarColorRes))
setChatFolderColor(ContextCompat.getColor(activity, colorSet.chatFolderColorRes))
}
private fun updateActiveState(isActive: Boolean) {
@@ -139,12 +123,12 @@ open class Material3OnScrollHelper(
val endColorSet = if (isActive) activeColorSet else inactiveColorSet
if (hadActiveState) {
val startToolbarColor = ContextCompat.getColor(context, startColorSet.toolbarColorRes)
val endToolbarColor = ContextCompat.getColor(context, endColorSet.toolbarColorRes)
val startStatusBarColor = ContextCompat.getColor(context, startColorSet.statusBarColorRes)
val endStatusBarColor = ContextCompat.getColor(context, endColorSet.statusBarColorRes)
val startChatFolderColor = ContextCompat.getColor(context, startColorSet.chatFolderColorRes)
val endChatFolderColor = ContextCompat.getColor(context, endColorSet.chatFolderColorRes)
val startToolbarColor = ContextCompat.getColor(activity, startColorSet.toolbarColorRes)
val endToolbarColor = ContextCompat.getColor(activity, endColorSet.toolbarColorRes)
val startStatusBarColor = ContextCompat.getColor(activity, startColorSet.statusBarColorRes)
val endStatusBarColor = ContextCompat.getColor(activity, endColorSet.statusBarColorRes)
val startChatFolderColor = ContextCompat.getColor(activity, startColorSet.chatFolderColorRes)
val endChatFolderColor = ContextCompat.getColor(activity, endColorSet.chatFolderColorRes)
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 200
@@ -164,6 +148,7 @@ open class Material3OnScrollHelper(
private fun setToolbarColor(@ColorInt color: Int) {
views.forEach { it.setBackgroundColor(color) }
viewStubs.filter { it.resolved() }.forEach { it.get().setBackgroundColor(color) }
onSetToolbarColor(color)
}
private inner class OnScrollListener : RecyclerView.OnScrollListener(), AppBarLayout.OnOffsetChangedListener, NestedScrollView.OnScrollChangeListener {

View File

@@ -1,177 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:orientation="vertical"
tools:viewBindingIgnore="true">
<View
android:id="@+id/toolbar_background"
<androidx.compose.ui.platform.ComposeView
android:id="@+id/toolbar_container"
android:layout_width="match_parent"
android:layout_height="@dimen/signal_m3_toolbar_height"
android:background="@color/signal_colorBackground"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.util.views.DarkOverflowToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="@dimen/signal_m3_toolbar_height"
android:minHeight="@dimen/signal_m3_toolbar_height"
android:theme="?attr/actionBarStyle"
android:visibility="gone"
app:contentInsetStart="0dp"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/toolbar_icon"
android:layout_width="@dimen/toolbar_avatar_size"
android:layout_height="@dimen/toolbar_avatar_size"
android:layout_alignParentStart="true"
android:layout_marginStart="@dimen/toolbar_avatar_margin"
android:contentDescription="@string/conversation_list_settings_shortcut"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/toolbar_badge"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="14dp"
android:layout_marginTop="16dp"
app:badge_size="small"
app:layout_constraintStart_toStartOf="@id/toolbar_icon"
app:layout_constraintTop_toTopOf="@id/toolbar_icon" />
<View
android:id="@+id/toolbar_settings_touch_area"
android:layout_width="48dp"
android:layout_height="48dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/toolbar_icon"
app:layout_constraintStart_toStartOf="@id/toolbar_icon"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/unread_payments_indicator"
android:layout_width="13dp"
android:layout_height="13dp"
android:layout_marginStart="20dp"
android:layout_marginBottom="20dp"
android:alpha="0"
android:background="@drawable/unread_count_background"
app:layout_constraintBottom_toBottomOf="@id/toolbar_icon"
app:layout_constraintStart_toStartOf="@id/toolbar_icon"
tools:alpha="1" />
<View
android:id="@+id/backups_failed_indicator"
android:layout_width="13dp"
android:layout_height="13dp"
android:layout_marginStart="20dp"
android:layout_marginBottom="20dp"
android:alpha="0"
android:background="@drawable/backups_failed_background"
app:layout_constraintBottom_toBottomOf="@id/toolbar_icon"
app:layout_constraintStart_toStartOf="@id/toolbar_icon"
tools:alpha="1" />
<TextView
android:id="@+id/conversation_list_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:text="@string/app_name"
android:textAlignment="viewStart"
android:textAppearance="@style/Signal.Text.TitleLarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/conversation_list_notification_profile_status"
app:layout_constraintStart_toEndOf="@id/toolbar_icon"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/conversation_list_notification_profile_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:paddingHorizontal="3dp"
android:paddingVertical="11dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/conversation_list_proxy_status"
app:layout_constraintStart_toEndOf="@id/conversation_list_title"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_notification_profile_active"
tools:visibility="visible" />
<ImageView
android:id="@+id/conversation_list_proxy_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:padding="12dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/search_action"
app:layout_constraintStart_toEndOf="@id/conversation_list_notification_profile_status"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_proxy_connected_24"
tools:visibility="visible" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/search_action"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?actionBarItemBackground"
android:contentDescription="@string/conversation_list_search_description"
android:padding="12dp"
android:tint="@color/signal_icon_tint_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/symbol_search_24" />
</androidx.constraintlayout.widget.ConstraintLayout>
</org.thoughtcrime.securesms.util.views.DarkOverflowToolbar>
<ViewStub
android:id="@+id/toolbar_basic_stub"
android:layout_width="match_parent"
android:layout_height="@dimen/signal_m3_toolbar_height"
android:inflatedId="@+id/toolbar_basic"
android:layout="@layout/conversation_list_archive_toolbar"
android:minHeight="@dimen/signal_m3_toolbar_height"
app:layout_constraintTop_toTopOf="parent" />
<ViewStub
android:id="@+id/search_toolbar"
android:layout_width="match_parent"
android:layout_height="@dimen/signal_m3_toolbar_height"
android:layout="@layout/conversation_list_search_toolbar"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/toolbar_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="toolbar,toolbar_basic,toolbar_background" />
android:layout_height="@dimen/signal_m3_toolbar_height" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar_barrier"
android:layout_weight="1"
app:navGraph="@navigation/main_activity_list" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View File

@@ -0,0 +1,121 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.PopupPositionProvider
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNot
object Tooltips {
/**
* Renders a tooltip below the anchor content regardless of space, aligning the end edge of each.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlainBelowAnchor(
onDismiss: () -> Unit,
containerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
isTooltipVisible: Boolean,
tooltipContent: @Composable () -> Unit,
anchorContent: @Composable () -> Unit
) {
val tooltipState = rememberTooltipState(
initialIsVisible = isTooltipVisible,
isPersistent = true
)
val caretSize = with(LocalDensity.current) {
TooltipDefaults.caretSize.toSize()
}
TooltipBox(
positionProvider = PositionBelowAnchor,
state = tooltipState,
tooltip = {
PlainTooltip(
shape = TooltipDefaults.plainTooltipContainerShape,
containerColor = containerColor,
contentColor = contentColor,
modifier = Modifier.drawCaret { anchorLayoutCoordinates ->
val path = if (anchorLayoutCoordinates != null) {
val anchorBounds = anchorLayoutCoordinates.boundsInWindow()
val anchorMid = (anchorBounds.right - anchorBounds.left) / 2
val position = Offset(size.width - anchorMid, 0f)
Path().apply {
moveTo(x = position.x, y = position.y)
lineTo(x = position.x + caretSize.width / 2, y = position.y)
lineTo(x = position.x, y = position.y - caretSize.height)
lineTo(x = position.x - caretSize.width / 2, y = position.y)
close()
}
} else {
Path()
}
onDrawWithContent {
drawContent()
drawPath(path = path, color = containerColor)
}
}
) {
tooltipContent()
}
}
) {
anchorContent()
}
LaunchedEffect(isTooltipVisible) {
if (isTooltipVisible) {
tooltipState.show()
} else {
tooltipState.dismiss()
}
}
LaunchedEffect(tooltipState) {
snapshotFlow { tooltipState.isVisible }
.drop(1)
.filterNot { it }
.collect { onDismiss() }
}
}
private object PositionBelowAnchor : PopupPositionProvider {
override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset {
val x = if (layoutDirection == LayoutDirection.Ltr) {
anchorBounds.right - popupContentSize.width
} else {
anchorBounds.left
}
return IntOffset(x, anchorBounds.bottom)
}
}
}