Add ability to open a chat incognito.

This commit is contained in:
Greyson Parrelli
2026-03-18 09:16:19 -04:00
committed by Michelle Tang
parent db5cced91b
commit b62b5ea8ef
18 changed files with 118 additions and 12 deletions

View File

@@ -41,9 +41,14 @@ public class MainNavigator {
}
public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition) {
goToConversation(recipientId, threadId, distributionType, startingPosition, false);
}
public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition, boolean incognito) {
Disposable disposable = ConversationIntents.createBuilder(activity, recipientId, threadId)
.map(builder -> builder.withDistributionType(distributionType)
.withStartingPosition(startingPosition)
.asIncognito(incognito)
.toConversationArgs())
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Chats.Conversation(args)));

View File

@@ -8,4 +8,5 @@ package org.thoughtcrime.securesms.components.settings.app.labs
sealed interface LabsSettingsEvents {
data class ToggleIndividualChatPlaintextExport(val enabled: Boolean) : LabsSettingsEvents
data class ToggleStoryArchive(val enabled: Boolean) : LabsSettingsEvents
data class ToggleIncognito(val enabled: Boolean) : LabsSettingsEvents
}

View File

@@ -106,6 +106,15 @@ private fun LabsSettingsContent(
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleStoryArchive(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.incognito,
text = "Incognito Mode",
label = "Adds an option to long-press a conversation to open it in incognito mode. Messages will not be marked as read and no read receipts will be sent.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleIncognito(it)) }
)
}
}
}
}

View File

@@ -10,5 +10,6 @@ import androidx.compose.runtime.Immutable
@Immutable
data class LabsSettingsState(
val individualChatPlaintextExport: Boolean = false,
val storyArchive: Boolean = false
val storyArchive: Boolean = false,
val incognito: Boolean = false
)

View File

@@ -25,13 +25,18 @@ class LabsSettingsViewModel : ViewModel() {
SignalStore.labs.storyArchive = event.enabled
_state.value = _state.value.copy(storyArchive = event.enabled)
}
is LabsSettingsEvents.ToggleIncognito -> {
SignalStore.labs.incognito = event.enabled
_state.value = _state.value.copy(incognito = event.enabled)
}
}
}
private fun loadState(): LabsSettingsState {
return LabsSettingsState(
individualChatPlaintextExport = SignalStore.labs.individualChatPlaintextExport,
storyArchive = SignalStore.labs.storyArchive
storyArchive = SignalStore.labs.storyArchive,
incognito = SignalStore.labs.incognito
)
}
}

View File

@@ -37,7 +37,8 @@ data class ConversationArgs(
val isWithSearchOpen: Boolean,
val giftBadge: Badge?,
val shareDataTimestamp: Long,
val conversationScreenType: ConversationScreenType
val conversationScreenType: ConversationScreenType,
val isIncognito: Boolean = false
) : Parcelable {
@IgnoredOnParcel
val draftMediaType: SlideFactory.MediaType? = SlideFactory.MediaType.from(draftContentType)

View File

@@ -47,6 +47,7 @@ public class ConversationIntents {
private static final String EXTRA_GIFT_BADGE = "gift_badge";
private static final String EXTRA_SHARE_DATA_TIMESTAMP = "share_data_timestamp";
private static final String EXTRA_CONVERSATION_TYPE = "conversation_type";
private static final String EXTRA_INCOGNITO = "incognito";
private static final String INTENT_DATA = "intent_data";
private static final String INTENT_TYPE = "intent_type";
@@ -152,7 +153,8 @@ public class ConversationIntents {
false,
null,
-1L,
ConversationScreenType.BUBBLE);
ConversationScreenType.BUBBLE,
false);
}
return new ConversationArgs(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
@@ -169,7 +171,8 @@ public class ConversationIntents {
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
arguments.getParcelable(EXTRA_GIFT_BADGE),
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)),
arguments.getBoolean(EXTRA_INCOGNITO, false));
}
public final static class Builder {
@@ -191,6 +194,7 @@ public class ConversationIntents {
private boolean withSearchOpen;
private Badge giftBadge;
private long shareDataTimestamp = -1L;
private boolean incognito;
private Builder(@NonNull Context context,
@NonNull Class<? extends Activity> conversationActivityClass,
@@ -218,6 +222,7 @@ public class ConversationIntents {
withSearchOpen = args.isWithSearchOpen();
giftBadge = args.getGiftBadge();
shareDataTimestamp = args.getShareDataTimestamp();
incognito = args.isIncognito();
return this;
}
@@ -282,6 +287,11 @@ public class ConversationIntents {
return this;
}
public @NonNull Builder asIncognito(boolean incognito) {
this.incognito = incognito;
return this;
}
public @NonNull ConversationArgs toConversationArgs() {
return new ConversationArgs(
recipientId,
@@ -298,7 +308,8 @@ public class ConversationIntents {
withSearchOpen,
giftBadge,
shareDataTimestamp,
conversationScreenType
conversationScreenType,
incognito
);
}
@@ -329,6 +340,7 @@ public class ConversationIntents {
intent.putExtra(EXTRA_GIFT_BADGE, giftBadge);
intent.putExtra(EXTRA_SHARE_DATA_TIMESTAMP, shareDataTimestamp);
intent.putExtra(EXTRA_CONVERSATION_TYPE, conversationScreenType.code);
intent.putExtra(EXTRA_INCOGNITO, incognito);
if (draftText != null) {
intent.putExtra(EXTRA_TEXT, draftText);

View File

@@ -42,18 +42,24 @@ public class MarkReadHelper {
private final ConversationId conversationId;
private final Context context;
private final LifecycleOwner lifecycleOwner;
private final boolean incognito;
private final Debouncer debouncer = new Debouncer(DEBOUNCE_TIMEOUT);
private long latestTimestamp;
private boolean ignoreViewReveals = false;
public MarkReadHelper(@NonNull ConversationId conversationId, @NonNull Context context, @NonNull LifecycleOwner lifecycleOwner) {
this(conversationId, context, lifecycleOwner, false);
}
public MarkReadHelper(@NonNull ConversationId conversationId, @NonNull Context context, @NonNull LifecycleOwner lifecycleOwner, boolean incognito) {
this.conversationId = conversationId;
this.context = context.getApplicationContext();
this.lifecycleOwner = lifecycleOwner;
this.incognito = incognito;
}
public void onViewsRevealed(long timestamp) {
if (timestamp <= latestTimestamp || lifecycleOwner.getLifecycle().getCurrentState() != Lifecycle.State.RESUMED || ignoreViewReveals) {
if (incognito || timestamp <= latestTimestamp || lifecycleOwner.getLifecycle().getCurrentState() != Lifecycle.State.RESUMED || ignoreViewReveals) {
return;
}

View File

@@ -659,7 +659,7 @@ class ConversationFragment :
FullscreenHelper(requireActivity()).showSystemUI()
}
markReadHelper = MarkReadHelper(ConversationId.forConversation(args.threadId), requireContext(), viewLifecycleOwner)
markReadHelper = MarkReadHelper(ConversationId.forConversation(args.threadId), requireContext(), viewLifecycleOwner, args.isIncognito)
markReadHelper.ignoreViewReveals()
attachmentManager = AttachmentManager(requireContext(), requireView(), AttachmentManagerListener())
@@ -670,7 +670,8 @@ class ConversationFragment :
requireActivity(),
binding.toolbarBackground,
viewModel::wallpaperSnapshot,
viewLifecycleOwner
viewLifecycleOwner,
incognito = args.isIncognito
)
conversationToolbarOnScrollHelper.attach(binding.conversationItemRecycler)
presentWallpaper(args.wallpaper)
@@ -1482,6 +1483,7 @@ class ConversationFragment :
var inputDisabled = true
when {
inputReadyState.isClientExpired || inputReadyState.isUnauthorized -> disabledInputView.showAsExpiredOrUnauthorized(inputReadyState.isClientExpired, inputReadyState.isUnauthorized)
args.isIncognito -> disabledInputView.showAsIncognito()
!inputReadyState.messageRequestState.isAccepted -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState)
inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember()
inputReadyState.isRequestingMember == true -> disabledInputView.showAsRequestingMember()

View File

@@ -16,7 +16,8 @@ class ConversationToolbarOnScrollHelper(
activity: FragmentActivity,
toolbarBackground: View,
private val wallpaperProvider: () -> ChatWallpaper?,
lifecycleOwner: LifecycleOwner
lifecycleOwner: LifecycleOwner,
private val incognito: Boolean = false
) : Material3OnScrollHelper(
activity = activity,
views = listOf(toolbarBackground),
@@ -24,10 +25,10 @@ class ConversationToolbarOnScrollHelper(
setStatusBarColor = {}
) {
override val activeColorSet: ColorSet
get() = ColorSet(getActiveToolbarColor(wallpaperProvider() != null))
get() = if (incognito) ColorSet(R.color.conversation_toolbar_color_incognito) else ColorSet(getActiveToolbarColor(wallpaperProvider() != null))
override val inactiveColorSet: ColorSet
get() = ColorSet(getInactiveToolbarColor(wallpaperProvider() != null))
get() = if (incognito) ColorSet(R.color.conversation_toolbar_color_incognito) else ColorSet(getInactiveToolbarColor(wallpaperProvider() != null))
@ColorRes
private fun getActiveToolbarColor(hasWallpaper: Boolean): Int {

View File

@@ -48,6 +48,7 @@ class DisabledInputView @JvmOverloads constructor(
private var announcementGroupOnly: TextView? = null
private var inviteToSignal: View? = null
private var releaseNoteChannel: View? = null
private var incognitoView: View? = null
private var currentView: View? = null
@@ -76,6 +77,13 @@ class DisabledInputView @JvmOverloads constructor(
)
}
fun showAsIncognito() {
incognitoView = show(
existingView = incognitoView,
create = { inflater.inflate(R.layout.conversation_incognito_mode, this, false) }
)
}
fun showAsMessageRequest(recipient: Recipient, messageRequestState: MessageRequestState) {
messageRequestView = show(
existingView = messageRequestView,
@@ -210,6 +218,7 @@ class DisabledInputView @JvmOverloads constructor(
noLongerAMember = null
requestingGroup = null
announcementGroupOnly = null
incognitoView = null
}
private fun <V : View> show(existingView: V?, create: () -> V, bind: V.() -> Unit = {}): V {

View File

@@ -1234,6 +1234,22 @@ public class ConversationListFragment extends MainFragment implements Conversati
});
}
private void handleOpenIncognito(@NonNull Conversation conversation) {
long threadId = conversation.getThreadRecord().getThreadId();
Recipient recipient = conversation.getThreadRecord().getRecipient();
int distributionType = conversation.getThreadRecord().getDistributionType();
SimpleTask.run(getLifecycle(), () -> {
ChatWallpaper wallpaper = recipient.resolve().getWallpaper();
if (wallpaper != null && !wallpaper.prefetch(requireContext(), 250)) {
Log.w(TAG, "Failed to prefetch wallpaper.");
}
return null;
}, (nothing) -> {
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1, true);
});
}
private void startActionModeIfNotActive() {
if (!mainToolbarViewModel.isInActionMode()) {
startActionMode();
@@ -1327,6 +1343,10 @@ public class ConversationListFragment extends MainFragment implements Conversati
} else {
items.add(new ActionItem(R.drawable.symbol_bell_slash_24, getResources().getString(R.string.ConversationListFragment_mute), () -> handleMute(Collections.singleton(conversation))));
}
if (SignalStore.labs().getIncognito()) {
items.add(new ActionItem(R.drawable.symbol_view_once_24, "Open Incognito", () -> handleOpenIncognito(conversation)));
}
}
if (!isFromSearch) {

View File

@@ -6,6 +6,7 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(
companion object {
const val INDIVIDUAL_CHAT_PLAINTEXT_EXPORT: String = "labs.individual_chat_plaintext_export"
const val STORY_ARCHIVE: String = "labs.story_archive"
const val INCOGNITO: String = "labs.incognito"
}
public override fun onFirstEverAppLaunch() = Unit
@@ -16,6 +17,8 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(
var storyArchive by booleanValue(STORY_ARCHIVE, true).falseForExternalUsers()
var incognito by booleanValue(INCOGNITO, true).falseForExternalUsers()
private fun SignalStoreValueDelegate<Boolean>.falseForExternalUsers(): SignalStoreValueDelegate<Boolean> {
return this.map { actualValue -> RemoteConfig.internalUser && actualValue }
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/conversation_toolbar_color_incognito" />
<corners android:radius="16dp" />
</shape>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/incognito_pill_background"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp"
android:text="@string/DisabledInputView__incognito_mode"
android:textColor="@color/signal_colorOnSurface"
android:textSize="14sp" />
</FrameLayout>

View File

@@ -5,6 +5,7 @@
<color name="conversation_toolbar_color">@color/signal_colorSurface</color>
<color name="conversation_toolbar_color_wallpaper">@color/signal_colorTransparentInverse5</color>
<color name="conversation_toolbar_color_wallpaper_scrolled">@color/signal_colorTransparentInverse5</color>
<color name="conversation_toolbar_color_incognito">#4A2D73</color>
<color name="signal_accent_primary">@color/core_ultramarine_light</color>
<color name="signal_inverse_primary">@color/core_white</color>

View File

@@ -3,6 +3,7 @@
<color name="conversation_toolbar_color">@color/signal_colorSurface</color>
<color name="conversation_toolbar_color_wallpaper">@color/signal_colorTransparent5</color>
<color name="conversation_toolbar_color_wallpaper_scrolled">@color/signal_colorTransparent5</color>
<color name="conversation_toolbar_color_incognito">#C4A8E0</color>
<color name="signal_accent_primary">@color/core_ultramarine</color>

View File

@@ -518,6 +518,7 @@
<string name="ConversationActivity_attachment_exceeds_size_limits">Attachment exceeds size limits for the type of message you\'re sending.</string>
<string name="ConversationActivity_unable_to_record_audio">Unable to record audio!</string>
<string name="ConversationActivity_you_cant_send_messages_to_this_group">You can\'t send messages to this group because you\'re no longer a member.</string>
<string name="DisabledInputView__incognito_mode">Incognito mode</string>
<string name="ConversationActivity_only_s_can_send_messages">Only %1$s can send messages.</string>
<string name="ConversationActivity_admins">admins</string>
<string name="ConversationActivity_message_an_admin">Message an admin</string>