Release polls behind feature flag.

This commit is contained in:
Michelle Tang
2025-10-01 12:46:37 -04:00
parent 67a693107e
commit b8e4ffb5ae
84 changed files with 4164 additions and 102 deletions

View File

@@ -35,6 +35,7 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout.
private static final List<AttachmentKeyboardButton> DEFAULT_BUTTONS = Arrays.asList(
AttachmentKeyboardButton.GALLERY,
AttachmentKeyboardButton.FILE,
AttachmentKeyboardButton.POLL,
AttachmentKeyboardButton.CONTACT,
AttachmentKeyboardButton.LOCATION,
AttachmentKeyboardButton.PAYMENT

View File

@@ -11,7 +11,8 @@ public enum AttachmentKeyboardButton {
FILE(R.string.AttachmentKeyboard_file, R.drawable.symbol_file_24),
PAYMENT(R.string.AttachmentKeyboard_payment, R.drawable.symbol_payment_24),
CONTACT(R.string.AttachmentKeyboard_contact, R.drawable.symbol_person_circle_24),
LOCATION(R.string.AttachmentKeyboard_location, R.drawable.symbol_location_circle_24);
LOCATION(R.string.AttachmentKeyboard_location, R.drawable.symbol_location_circle_24),
POLL(R.string.AttachmentKeyboard_poll, R.drawable.symbol_poll_24);
private final int titleRes;
private final int iconRes;

View File

@@ -56,6 +56,7 @@ import androidx.annotation.ColorInt;
import androidx.annotation.DimenRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.compose.ui.platform.ComposeView;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.MediaItem;
@@ -73,7 +74,6 @@ import org.signal.core.util.BidiUtil;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.StringUtil;
import org.signal.core.util.logging.Log;
import org.signal.ringrtc.CallLinkRootKey;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
@@ -130,6 +130,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.polls.PollRecord;
import org.thoughtcrime.securesms.reactions.ReactionsConversationView;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -236,6 +237,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private Stub<Button> callToActionStub;
private Stub<GiftMessageView> giftViewStub;
private Stub<PaymentMessageView> paymentViewStub;
private Stub<ComposeView> pollView;
private @Nullable EventListener eventListener;
private @Nullable GestureDetector gestureDetector;
@@ -352,6 +354,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.quotedIndicator = findViewById(R.id.quoted_indicator);
this.paymentViewStub = new Stub<>(findViewById(R.id.payment_view_stub));
this.scheduledIndicator = findViewById(R.id.scheduled_indicator);
this.pollView = new Stub<>(findViewById(R.id.poll));
setOnClickListener(new ClickListener(null));
@@ -417,6 +420,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
setStoryReactionLabel(messageRecord);
setHasBeenQuoted(conversationMessage);
setHasBeenScheduled(conversationMessage);
setPoll(messageRecord);
if (audioViewStub.resolved()) {
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
@@ -561,6 +565,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
conversationMessage.getBottomButton() == null &&
!BidiUtil.hasMixedTextDirection(bodyText.getText()) &&
!messageRecord.isRemoteDelete() &&
!MessageRecordUtil.hasPoll(messageRecord) &&
bodyText.getLastLineWidth() > 0)
{
View dateView = footer.getDateView();
@@ -1001,6 +1006,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return MessageRecordUtil.hasQuote(messageRecord);
}
private boolean hasPoll(MessageRecord messageRecord) {
return MessageRecordUtil.hasPoll(messageRecord);
}
private boolean hasSharedContact(MessageRecord messageRecord) {
return MessageRecordUtil.hasSharedContact(messageRecord);
}
@@ -1054,6 +1063,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (messageRequestAccepted) {
linkifyMessageBody(styledText, batchSelected.isEmpty());
}
if (MessageRecordUtil.hasPoll(messageRecord)) {
styledText.setSpan(new StyleSpan(Typeface.BOLD), 0, styledText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
bodyText.setMaxWidth(readDimen(R.dimen.media_bubble_default_dimens));
}
styledText = SearchUtil.getHighlightedSpan(locale, STYLE_FACTORY, styledText, searchQuery, SearchUtil.STRICT);
if (hasExtraText(messageRecord)) {
@@ -1627,6 +1640,31 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void setPoll(@NonNull MessageRecord messageRecord) {
if (hasPoll(messageRecord) && !messageRecord.isRemoteDelete()) {
PollRecord poll = MessageRecordUtil.getPoll(messageRecord);
PollComponentKt.setContent(pollView.get(), poll, isOutgoing(), () -> {
if (eventListener != null && batchSelected.isEmpty()) {
eventListener.onViewResultsClicked(poll.getId());
} else {
passthroughClickListener.onClick(pollView.get());
}
return null;
}, (option, isChecked) -> {
if (eventListener != null && batchSelected.isEmpty()) {
eventListener.onToggleVote(poll, option, isChecked);
} else {
passthroughClickListener.onClick(pollView.get());
}
return null;
});
pollView.setVisibility(View.VISIBLE);
} else if (pollView != null && pollView.resolved()) {
pollView.setVisibility(View.GONE);
}
}
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
boolean startOfCluster = isStartOfMessageCluster(current, previous, isGroupThread);
if (hasQuote(messageRecord)) {

View File

@@ -138,6 +138,10 @@ public class ConversationMessage {
getBottomButton() == null;
}
public boolean isPoll() {
return MessageRecordUtil.isPoll(messageRecord);
}
public long getConversationTimestamp() {
if (originalMessage != null) {
return originalMessage.getDateSent();

View File

@@ -760,6 +760,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
items.add(new ActionItem(R.drawable.symbol_info_24, getResources().getString(R.string.conversation_selection__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
}
if (menuState.shouldShowPollTerminateAction()) {
items.add(new ActionItem(R.drawable.symbol_stop_24, getResources().getString(R.string.conversation_selection__menu_end_poll), () -> handleActionItemClicked(Action.END_POLL)));
}
backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
@@ -961,5 +965,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
PAYMENT_DETAILS,
VIEW_INFO,
DELETE,
END_POLL
}
}

View File

@@ -2,8 +2,12 @@ package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.Looper;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.method.LinkMovementMethod;
import android.util.AttributeSet;
import android.view.View;
@@ -17,6 +21,7 @@ import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
@@ -43,16 +48,18 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ProjectionList;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import java.util.Collection;
import java.util.Locale;
@@ -81,7 +88,6 @@ public final class ConversationUpdateItem extends FrameLayout
private Optional<MessageRecord> nextMessageRecord;
private MessageRecord messageRecord;
private boolean isMessageRequestAccepted;
private LiveData<SpannableString> displayBody;
private EventListener eventListener;
private final UpdateObserver updateObserver = new UpdateObserver();
@@ -91,6 +97,14 @@ public final class ConversationUpdateItem extends FrameLayout
private final RecipientObserverManager groupObserver = new RecipientObserverManager(presentOnChange);
private final GroupDataManager groupData = new GroupDataManager();
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable timerUpdateRunnable = new TimerUpdateRunnable();
private final MutableLiveData<SpannableString> displayBodyWithTimer = new MutableLiveData<>();
private int latestFrame;
private SpannableString displayBody;
private ExpirationTimer timer;
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
public ConversationUpdateItem(Context context) {
@@ -181,9 +195,10 @@ public final class ConversationUpdateItem extends FrameLayout
LiveData<SpannableString> spannableMessage = loading(liveUpdateMessage);
observeDisplayBody(lifecycleOwner, spannableMessage);
observeDisplayBodyWithTimer(lifecycleOwner);
present(conversationMessage, nextMessageRecord, conversationRecipient, isMessageRequestAccepted);
presentTimer(updateDescription);
presentBackground(shouldCollapse(messageRecord, previousMessageRecord),
shouldCollapse(messageRecord, nextMessageRecord),
hasWallpaper);
@@ -217,6 +232,8 @@ public final class ConversationUpdateItem extends FrameLayout
@Override
public void unbind() {
this.displayBodyWithTimer.removeObserver(updateObserver);
handler.removeCallbacks(timerUpdateRunnable);
}
@Override
@@ -389,20 +406,30 @@ public final class ConversationUpdateItem extends FrameLayout
return false;
}
private void observeDisplayBody(@NonNull LifecycleOwner lifecycleOwner, @Nullable LiveData<SpannableString> displayBody) {
if (this.displayBody != displayBody) {
if (this.displayBody != null) {
this.displayBody.removeObserver(updateObserver);
}
this.displayBody = displayBody;
if (this.displayBody != null) {
this.displayBody.observe(lifecycleOwner, updateObserver);
}
private void observeDisplayBody(@NonNull LifecycleOwner lifecycleOwner, @Nullable LiveData<SpannableString> message) {
if (message != null) {
message.observe(lifecycleOwner, it -> {
displayBody = it;
updateBodyWithTimer();
});
}
}
private void observeDisplayBodyWithTimer(@NonNull LifecycleOwner lifecycleOwner) {
this.displayBodyWithTimer.observe(lifecycleOwner, updateObserver);
}
private void updateBodyWithTimer() {
SpannableStringBuilder builder = new SpannableStringBuilder(displayBody);
if (latestFrame != 0) {
Drawable drawable = DrawableUtil.tint(getContext().getDrawable(latestFrame), ContextCompat.getColor(getContext(), R.color.signal_icon_tint_secondary));
SpanUtil.appendCenteredImageSpan(builder, drawable, 12, 12);
}
displayBodyWithTimer.setValue(new SpannableString(builder));
}
private void setBodyText(@Nullable CharSequence text) {
if (text == null) {
body.setVisibility(INVISIBLE);
@@ -644,6 +671,16 @@ public final class ConversationUpdateItem extends FrameLayout
passthroughClickListener.onClick(v);
}
});
} else if (MessageRecordUtil.hasPollTerminate(conversationMessage.getMessageRecord()) && conversationMessage.getMessageRecord().getMessageExtras().pollTerminate.messageId != -1) {
actionButton.setText(R.string.Poll__view_poll);
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null && MessageRecordUtil.hasPollTerminate(conversationMessage.getMessageRecord())) {
eventListener.onViewPollClicked(conversationMessage.getMessageRecord().getMessageExtras().pollTerminate.messageId);
} else {
passthroughClickListener.onClick(v);
}
});
} else {
actionButton.setVisibility(GONE);
actionButton.setOnClickListener(null);
@@ -747,6 +784,16 @@ public final class ConversationUpdateItem extends FrameLayout
(current.isChangeNumber() && candidate.isChangeNumber());
}
private void presentTimer(UpdateDescription updateDescription) {
if (updateDescription.hasExpiration() && messageRecord.getExpiresIn() > 0) {
timer = new ExpirationTimer(messageRecord.getTimestamp(), messageRecord.getExpiresIn());
handler.post(timerUpdateRunnable);
} else {
latestFrame = 0;
handler.removeCallbacks(timerUpdateRunnable);
}
}
@Override
public void setOnClickListener(View.OnClickListener l) {
super.setOnClickListener(new InternalClickListener(l));
@@ -763,6 +810,19 @@ public final class ConversationUpdateItem extends FrameLayout
}
}
private class TimerUpdateRunnable implements Runnable {
@Override
public void run() {
float progress = timer.calculateProgress();
latestFrame = ExpirationTimer.getFrame(progress);
updateBodyWithTimer();
if (progress < 1f) {
handler.postDelayed(this, timer.calculateAnimationDelay());
}
}
}
private final class UpdateObserver implements Observer<Spannable> {
@Override

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.conversation
import org.thoughtcrime.securesms.R
import java.util.concurrent.TimeUnit
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
/**
* Tracks which drawables to use for the expiring timer in disappearing messages.
*/
class ExpirationTimer(
val startedAt: Long,
val expiresIn: Long
) {
companion object {
private val frames = intArrayOf(
R.drawable.ic_timer_00_12,
R.drawable.ic_timer_05_12,
R.drawable.ic_timer_10_12,
R.drawable.ic_timer_15_12,
R.drawable.ic_timer_20_12,
R.drawable.ic_timer_25_12,
R.drawable.ic_timer_30_12,
R.drawable.ic_timer_35_12,
R.drawable.ic_timer_40_12,
R.drawable.ic_timer_45_12,
R.drawable.ic_timer_50_12,
R.drawable.ic_timer_55_12,
R.drawable.ic_timer_60_12
)
@JvmStatic
fun getFrame(progress: Float): Int {
val percentFull = 1 - progress
val frame = ceil(percentFull * (frames.size - 1)).toInt()
val adjustedFrame = max(0, min(frame, frames.size - 1))
return frames[adjustedFrame]
}
}
fun calculateProgress(): Float {
val progressed = System.currentTimeMillis() - startedAt
val percentComplete = progressed.toFloat() / expiresIn.toFloat()
return max(0f, min(percentComplete, 1f))
}
fun calculateAnimationDelay(): Long {
val progressed = System.currentTimeMillis() - startedAt
val remaining = expiresIn - progressed
return (if (remaining < TimeUnit.SECONDS.toMillis(30)) 50 else 1000).toLong()
}
}

View File

@@ -26,6 +26,7 @@ public final class MenuState {
private final boolean reactions;
private final boolean paymentDetails;
private final boolean edit;
private final boolean pollTerminate;
private MenuState(@NonNull Builder builder) {
forward = builder.forward;
@@ -38,6 +39,7 @@ public final class MenuState {
reactions = builder.reactions;
paymentDetails = builder.paymentDetails;
edit = builder.edit;
pollTerminate = builder.pollTerminate;
}
public boolean shouldShowForwardAction() {
@@ -80,23 +82,29 @@ public final class MenuState {
return edit;
}
public boolean shouldShowPollTerminateAction() {
return pollTerminate;
}
public static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
boolean isNonAdminInAnnouncementGroup)
{
Builder builder = new Builder();
boolean actionMessage = false;
boolean hasText = false;
boolean sharedContact = false;
boolean viewOnce = false;
boolean remoteDelete = false;
boolean hasInMemory = false;
boolean hasPendingMedia = false;
boolean mediaIsSelected = false;
boolean hasGift = false;
Builder builder = new Builder();
boolean actionMessage = false;
boolean hasText = false;
boolean sharedContact = false;
boolean viewOnce = false;
boolean remoteDelete = false;
boolean hasInMemory = false;
boolean hasPendingMedia = false;
boolean mediaIsSelected = false;
boolean hasGift = false;
boolean hasPayment = false;
boolean hasPoll = false;
boolean hasPollTerminate = false;
for (MultiselectPart part : selectedParts) {
MessageRecord messageRecord = part.getMessageRecord();
@@ -138,14 +146,24 @@ public final class MenuState {
if (messageRecord.isPaymentNotification() || messageRecord.isPaymentTombstone()) {
hasPayment = true;
}
if (MessageRecordUtil.hasPoll(messageRecord)) {
hasPoll = true;
}
if (MessageRecordUtil.hasPoll(messageRecord) && !MessageRecordUtil.getPoll(messageRecord).getHasEnded() && messageRecord.isOutgoing()) {
hasPollTerminate = true;
}
}
boolean shouldShowForwardAction = !actionMessage &&
!viewOnce &&
!remoteDelete &&
!hasPendingMedia &&
!hasGift &&
!hasPayment &&
boolean shouldShowForwardAction = !actionMessage &&
!viewOnce &&
!remoteDelete &&
!hasPendingMedia &&
!hasGift &&
!hasPayment &&
!hasPoll &&
!hasPollTerminate &&
selectedParts.size() <= MAX_FORWARDABLE_COUNT;
int uniqueRecords = selectedParts.stream()
@@ -159,7 +177,8 @@ public final class MenuState {
.shouldShowDetailsAction(false)
.shouldShowSaveAttachmentAction(false)
.shouldShowResendAction(false)
.shouldShowEdit(false);
.shouldShowEdit(false)
.shouldShowPollTerminate(false);
} else {
MultiselectPart multiSelectRecord = selectedParts.iterator().next();
@@ -182,13 +201,15 @@ public final class MenuState {
builder.shouldShowEdit(!actionMessage &&
hasText &&
!multiSelectRecord.getConversationMessage().getOriginalMessage().isFailed() &&
!hasPoll &&
MessageConstraintsUtil.isValidEditMessageSend(multiSelectRecord.getConversationMessage().getOriginalMessage(), System.currentTimeMillis()));
}
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment)
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment && !hasPoll)
.shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts))
.shouldShowReactions(!conversationRecipient.isReleaseNotes())
.shouldShowPaymentDetails(hasPayment)
.shouldShowPollTerminate(hasPollTerminate)
.build();
}
@@ -233,6 +254,7 @@ public final class MenuState {
private boolean reactions;
private boolean paymentDetails;
private boolean edit;
private boolean pollTerminate;
@NonNull Builder shouldShowForwardAction(boolean forward) {
this.forward = forward;
@@ -284,6 +306,11 @@ public final class MenuState {
return this;
}
@NonNull Builder shouldShowPollTerminate(boolean pollTerminate) {
this.pollTerminate = pollTerminate;
return this;
}
@NonNull
MenuState build() {
return new MenuState(this);

View File

@@ -0,0 +1,346 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.compose.RoundCheckbox
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.VibrateUtil
/**
* Allows us to utilize our composeView from Java code.
*/
fun setContent(
composeView: ComposeView,
poll: PollRecord,
isOutgoing: Boolean,
onViewVotes: () -> Unit,
onToggleVote: (PollOption, Boolean) -> Unit = { _, _ -> }
) {
composeView.setContent {
SignalTheme(
isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)
) {
Poll(
poll = poll,
onViewVotes = onViewVotes,
onToggleVote = onToggleVote,
pollColors = if (isOutgoing) PollColorsType.Outgoing.getColors() else PollColorsType.Incoming.getColors()
)
}
}
}
@Composable
private fun Poll(
poll: PollRecord,
onViewVotes: () -> Unit = {},
onToggleVote: (PollOption, Boolean) -> Unit = { _, _ -> },
pollColors: PollColors = PollColorsType.Incoming.getColors()
) {
val totalVotes = remember(poll.pollOptions) { poll.pollOptions.sumOf { it.voterIds.size } }
val caption = when {
poll.hasEnded -> R.string.Poll__final_results
poll.allowMultipleVotes -> R.string.Poll__select_multiple
else -> R.string.Poll__select_one
}
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(caption),
color = pollColors.caption,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 12.dp, bottom = 4.dp)
)
poll.pollOptions.forEach {
PollOption(it, totalVotes, poll.hasEnded, onToggleVote, pollColors)
}
Spacer(Modifier.size(16.dp))
if (totalVotes == 0) {
Text(
text = stringResource(R.string.Poll__no_votes),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.align(Alignment.CenterHorizontally).height(40.dp).wrapContentHeight(align = Alignment.CenterVertically),
textAlign = TextAlign.Center,
color = pollColors.text
)
} else {
Buttons.MediumTonal(
colors = ButtonDefaults.buttonColors(containerColor = pollColors.buttonBackground, contentColor = pollColors.button),
onClick = onViewVotes,
modifier = Modifier.align(Alignment.CenterHorizontally).height(40.dp)
) {
Text(stringResource(if (poll.hasEnded) R.string.Poll__view_results else R.string.Poll__view_votes))
}
}
Spacer(Modifier.size(4.dp))
}
}
@Composable
private fun PollOption(
option: PollOption,
totalVotes: Int,
hasEnded: Boolean,
onToggleVote: (PollOption, Boolean) -> Unit = { _, _ -> },
pollColors: PollColors
) {
val context = LocalContext.current
val haptics = LocalHapticFeedback.current
val progress = remember(option.voterIds.size, totalVotes) {
if (totalVotes > 0) (option.voterIds.size.toFloat() / totalVotes.toFloat()) else 0f
}
val progressValue by animateFloatAsState(targetValue = progress, animationSpec = tween(durationMillis = 250))
Row(
modifier = Modifier.padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
) {
if (!hasEnded) {
AnimatedContent(
targetState = option.isPending,
transitionSpec = {
val enterTransition = fadeIn(tween(delayMillis = 500, durationMillis = 500))
val exitTransition = fadeOut(tween(durationMillis = 500))
enterTransition.togetherWith(exitTransition)
.using(SizeTransform(clip = false))
}
) { inProgress ->
if (inProgress) {
CircularProgressIndicator(
modifier = Modifier.padding(top = 4.dp, end = 8.dp).size(24.dp),
strokeWidth = 1.5.dp,
color = pollColors.checkbox
)
} else {
RoundCheckbox(
checked = option.isSelected,
onCheckedChange = { checked ->
if (VibrateUtil.isHapticFeedbackEnabled(context)) {
haptics.performHapticFeedback(if (checked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff)
}
onToggleVote(option, checked)
},
modifier = Modifier.padding(top = 4.dp, end = 8.dp).height(24.dp),
outlineColor = pollColors.checkbox,
checkedColor = pollColors.checkboxBackground
)
}
}
}
Column {
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = option.text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(end = 24.dp).weight(1f),
color = pollColors.text
)
if (hasEnded && option.isSelected) {
RoundCheckbox(
checked = true,
onCheckedChange = {},
modifier = Modifier.padding(end = 4.dp),
size = 16.dp,
enabled = false,
checkedColor = pollColors.checkboxBackground
)
}
AnimatedContent(
targetState = option.voterIds.size
) { size ->
Text(
text = size.toString(),
color = pollColors.text,
style = MaterialTheme.typography.bodyMedium
)
}
}
Box(
modifier = Modifier.height(8.dp).padding(top = 4.dp).fillMaxWidth()
.background(
color = pollColors.progressBackground,
shape = RoundedCornerShape(18.dp)
)
) {
Box(
modifier = Modifier
.fillMaxWidth(progressValue)
.fillMaxHeight()
.background(
color = pollColors.progress,
shape = if (progress == 1f) RoundedCornerShape(18.dp) else RoundedCornerShape(topStart = 18.dp, bottomStart = 18.dp)
)
)
}
}
}
}
class PollColors(
val text: Color,
val caption: Color,
val progress: Color,
val progressBackground: Color,
val checkbox: Color,
val checkboxBackground: Color,
val button: Color,
val buttonBackground: Color
)
private sealed interface PollColorsType {
@Composable
fun getColors(): PollColors
data object Outgoing : PollColorsType {
@Composable
override fun getColors(): PollColors {
return PollColors(
text = colorResource(R.color.conversation_item_sent_text_primary_color),
caption = colorResource(R.color.conversation_item_sent_text_secondary_color),
progress = colorResource(R.color.conversation_item_sent_text_primary_color),
progressBackground = SignalTheme.colors.colorTransparent3,
checkbox = colorResource(R.color.conversation_item_sent_text_secondary_color),
checkboxBackground = colorResource(R.color.conversation_item_sent_text_primary_color),
button = MaterialTheme.colorScheme.primary,
buttonBackground = colorResource(R.color.conversation_item_sent_text_primary_color)
)
}
}
data object Incoming : PollColorsType {
@Composable
override fun getColors(): PollColors {
return PollColors(
text = MaterialTheme.colorScheme.onSurface,
caption = MaterialTheme.colorScheme.onSurfaceVariant,
progress = MaterialTheme.colorScheme.primary,
progressBackground = SignalTheme.colors.colorTransparentInverse3,
checkbox = MaterialTheme.colorScheme.outline,
checkboxBackground = MaterialTheme.colorScheme.primary,
button = MaterialTheme.colorScheme.onSurface,
buttonBackground = MaterialTheme.colorScheme.surface
)
}
}
}
@DayNightPreviews
@Composable
private fun PollPreview() {
Previews.Preview {
Poll(
PollRecord(
id = 1,
question = "How do you feel about compose previews?",
pollOptions = listOf(
PollOption(1, "yay", listOf(1), isSelected = true),
PollOption(2, "ok", listOf(1, 2)),
PollOption(3, "nay", listOf(2, 3, 4))
),
allowMultipleVotes = false,
hasEnded = false,
authorId = 1,
messageId = 1
)
)
}
}
@DayNightPreviews
@Composable
private fun EmptyPollPreview() {
Previews.Preview {
Poll(
PollRecord(
id = 1,
question = "How do you feel about multiple compose previews?",
pollOptions = listOf(
PollOption(1, "yay", emptyList()),
PollOption(2, "ok", emptyList(), isSelected = true),
PollOption(3, "nay", emptyList(), isSelected = true)
),
allowMultipleVotes = true,
hasEnded = false,
authorId = 1,
messageId = 1
)
)
}
}
@DayNightPreviews
@Composable
private fun FinishedPollPreview() {
Previews.Preview {
Poll(
PollRecord(
id = 1,
question = "How do you feel about finished compose previews?",
pollOptions = listOf(
PollOption(1, "yay", listOf(1)),
PollOption(2, "ok", emptyList(), isSelected = true),
PollOption(3, "nay", emptyList())
),
allowMultipleVotes = false,
hasEnded = true,
authorId = 1,
messageId = 1
)
)
}
}

View File

@@ -0,0 +1,234 @@
package org.thoughtcrime.securesms.conversation.clicklisteners
import android.os.Bundle
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
import org.thoughtcrime.securesms.conversation.clicklisteners.PollVotesFragment.Companion.MAX_INITIAL_VOTER_COUNT
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.util.viewModel
/**
* Fragment that shows the results for a given poll.
*/
class PollVotesFragment : ComposeDialogFragment() {
companion object {
const val MAX_INITIAL_VOTER_COUNT = 5
const val RESULT_KEY = "PollVotesFragment"
const val POLL_VOTES_FRAGMENT_TAG = "PollVotesFragment"
private val TAG = Log.tag(PollVotesFragment::class.java)
private const val ARG_POLL_ID = "poll_id"
fun create(pollId: Long, fragmentManager: FragmentManager) {
return PollVotesFragment().apply {
arguments = bundleOf(ARG_POLL_ID to pollId)
}.show(fragmentManager, POLL_VOTES_FRAGMENT_TAG)
}
}
private val viewModel: PollVotesViewModel by viewModel {
PollVotesViewModel(requireArguments().getLong(ARG_POLL_ID))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
@Composable
override fun DialogContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffolds.Settings(
title = stringResource(id = R.string.Poll__poll_results),
onNavigationClick = this::dismissAllowingStateLoss,
navigationIcon = ImageVector.vectorResource(id = R.drawable.symbol_x_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { paddingValues ->
if (state.poll == null) {
return@Settings
}
Surface(modifier = Modifier.padding(paddingValues)) {
Column {
PollResultsScreen(
state,
onEndPoll = {
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to true))
dismissAllowingStateLoss()
}
)
}
}
}
}
}
@Composable
private fun PollResultsScreen(
state: PollVotesState,
onEndPoll: () -> Unit = {},
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier
.fillMaxWidth()
.horizontalGutters(24.dp)
) {
item {
Spacer(Modifier.size(16.dp))
Text(
text = stringResource(R.string.Poll__question),
style = MaterialTheme.typography.titleSmall
)
TextField(
value = state.poll!!.question,
onValueChange = {},
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp).fillMaxWidth(),
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
enabled = false
)
}
items(state.pollOptions) { PollOptionSection(it) }
if (state.isAuthor && !state.poll!!.hasEnded) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onEndPoll)
.padding(vertical = 16.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
contentDescription = stringResource(R.string.Poll__end_poll),
tint = MaterialTheme.colorScheme.onSurface
)
Text(text = stringResource(id = R.string.Poll__end_poll), modifier = Modifier.padding(start = 24.dp), style = MaterialTheme.typography.bodyLarge)
}
}
}
}
}
@Composable
private fun PollOptionSection(
option: PollOptionModel
) {
var expand by remember { mutableStateOf(false) }
val context = LocalContext.current
Row(
modifier = Modifier.padding(vertical = 12.dp)
) {
Text(text = option.pollOption.text, modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleSmall)
Text(text = pluralStringResource(R.plurals.Poll__num_votes, option.voters.size, option.voters.size), style = MaterialTheme.typography.bodyLarge)
}
if (!expand && option.voters.size > MAX_INITIAL_VOTER_COUNT) {
option.voters.subList(0, MAX_INITIAL_VOTER_COUNT).forEach { recipient ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 12.dp)
) {
AvatarImage(recipient = recipient, modifier = Modifier.padding(end = 16.dp).size(40.dp))
Text(text = if (recipient.isSelf) stringResource(id = R.string.Recipient_you) else recipient.getShortDisplayName(context))
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 12.dp).clickable { expand = true }
) {
Image(
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_chevron_down_24),
contentDescription = stringResource(R.string.Poll__see_all),
modifier = Modifier.size(40.dp).background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape).padding(8.dp)
)
Text(text = stringResource(R.string.Poll__see_all), modifier = Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyLarge)
}
} else {
option.voters.forEach { recipient ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 12.dp)
) {
AvatarImage(recipient = recipient, modifier = Modifier.padding(end = 16.dp).size(40.dp))
Text(text = if (recipient.isSelf) stringResource(id = R.string.Recipient_you) else recipient.getShortDisplayName(context))
}
}
}
Spacer(Modifier.size(16.dp))
}
@DayNightPreviews
@Composable
private fun PollResultsScreenPreview() {
Previews.Preview {
PollResultsScreen(
state = PollVotesState(
PollRecord(
id = 1,
question = "How do you feel about finished compose previews?",
pollOptions = listOf(
PollOption(1, "Yay", listOf(1, 12, 3)),
PollOption(2, "Ok", listOf(2, 4), isSelected = true),
PollOption(3, "Nay", emptyList())
),
allowMultipleVotes = false,
hasEnded = true,
authorId = 1,
messageId = 1
),
isAuthor = true
)
)
}
}

View File

@@ -0,0 +1,61 @@
package org.thoughtcrime.securesms.conversation.clicklisteners
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* View model for [PollVotesFragment] which allows you to see results for a given poll.
*/
class PollVotesViewModel(pollId: Long) : ViewModel() {
companion object {
private val TAG = Log.tag(PollVotesViewModel::class)
}
private val _state = MutableStateFlow(PollVotesState())
val state = _state.asStateFlow()
init {
loadPollInfo(pollId)
}
private fun loadPollInfo(pollId: Long) {
viewModelScope.launch(SignalDispatchers.IO) {
val poll = SignalDatabase.polls.getPollFromId(pollId)!!
_state.update {
it.copy(
poll = poll,
pollOptions = poll.pollOptions.map { option ->
PollOptionModel(
pollOption = option,
voters = Recipient.resolvedList(option.voterIds.map { voter -> RecipientId.from(voter) })
)
},
isAuthor = poll.authorId == Recipient.self().id.toLong()
)
}
}
}
}
data class PollVotesState(
val poll: PollRecord? = null,
val pollOptions: List<PollOptionModel> = emptyList(),
val isAuthor: Boolean = false
)
data class PollOptionModel(
val pollOption: PollOption,
val voters: List<Recipient> = emptyList()
)

View File

@@ -37,6 +37,8 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
@@ -263,6 +265,8 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
override fun onReportSpamLearnMoreClicked() = Unit
override fun onMessageRequestAcceptOptionsClicked() = Unit
override fun onItemDoubleClick(item: MultiselectPart) = Unit
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit
override fun onViewResultsClicked(pollId: Long) = Unit
}
companion object {

View File

@@ -184,6 +184,7 @@ import org.thoughtcrime.securesms.conversation.ScheduledMessagesBottomSheet
import org.thoughtcrime.securesms.conversation.ScheduledMessagesRepository
import org.thoughtcrime.securesms.conversation.SelectedConversationModel
import org.thoughtcrime.securesms.conversation.ShowAdminsBottomSheetDialog
import org.thoughtcrime.securesms.conversation.clicklisteners.PollVotesFragment
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
@@ -282,6 +283,9 @@ import org.thoughtcrime.securesms.nicknames.NicknameActivity
import org.thoughtcrime.securesms.notifications.v2.ConversationId
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment
import org.thoughtcrime.securesms.providers.BlobProvider
@@ -333,6 +337,7 @@ import org.thoughtcrime.securesms.util.atMidnight
import org.thoughtcrime.securesms.util.atUTC
import org.thoughtcrime.securesms.util.doAfterNextLayout
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.getPoll
import org.thoughtcrime.securesms.util.getQuote
import org.thoughtcrime.securesms.util.getRecordQuoteType
import org.thoughtcrime.securesms.util.hasAudio
@@ -1951,6 +1956,16 @@ class ConversationFragment :
)
}
private fun sendPoll(recipient: Recipient, poll: Poll) {
val send = viewModel.sendPoll(recipient, poll)
disposables += send
.subscribeBy(
onComplete = { onSendComplete() },
onError = { Log.w(TAG, "Error received during poll send!", it) }
)
}
private fun sendMessage(
body: String = composeText.editableText.toString().trim(),
mentions: List<Mention> = composeText.mentions,
@@ -2554,6 +2569,21 @@ class ConversationFragment :
}
}
private fun handleEndPoll(pollId: Long?) {
if (pollId == null) {
Log.w(TAG, "Unable to find poll to end $pollId")
return
}
val endPoll = viewModel.endPoll(pollId)
disposables += endPoll
.subscribeBy(
// TODO(michelle): Error state when poll terminate fails
onError = { Log.w(TAG, "Error received during poll send!", it) }
)
}
private inner class SwipeAvailabilityProvider : ConversationItemSwipeCallback.SwipeAvailabilityProvider {
override fun isSwipeAvailable(conversationMessage: ConversationMessage): Boolean {
val recipient = viewModel.recipientSnapshot ?: return false
@@ -3051,6 +3081,32 @@ class ConversationFragment :
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
}
override fun onViewResultsClicked(pollId: Long) {
if (parentFragmentManager.findFragmentByTag(PollVotesFragment.POLL_VOTES_FRAGMENT_TAG) == null) {
PollVotesFragment.create(pollId, parentFragmentManager)
parentFragmentManager.setFragmentResultListener(PollVotesFragment.RESULT_KEY, requireActivity()) { _, bundle ->
val shouldEndPoll = bundle.getBoolean(PollVotesFragment.RESULT_KEY, false)
if (shouldEndPoll) {
handleEndPoll(pollId)
}
}
}
}
override fun onViewPollClicked(messageId: Long) {
disposables += viewModel
.moveToMessage(messageId)
.subscribeBy(
onSuccess = { moveToPosition(it) },
onError = { Toast.makeText(requireContext(), R.string.Poll__unable_poll, Toast.LENGTH_LONG).show() }
)
}
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
viewModel.toggleVote(poll, pollOption, isChecked)
}
override fun onJoinGroupCallClicked() {
val activity = activity ?: return
val recipient = viewModel.recipientSnapshot ?: return
@@ -3743,6 +3799,7 @@ class ConversationFragment :
ConversationReactionOverlay.Action.PAYMENT_DETAILS -> handleViewPaymentDetails(conversationMessage)
ConversationReactionOverlay.Action.VIEW_INFO -> handleDisplayDetails(conversationMessage)
ConversationReactionOverlay.Action.DELETE -> handleDeleteMessages(conversationMessage.multiselectCollection.toSet())
ConversationReactionOverlay.Action.END_POLL -> handleEndPoll(conversationMessage.messageRecord.getPoll()?.id)
}
}
}
@@ -4385,6 +4442,12 @@ class ConversationFragment :
toast(R.string.AttachmentManager_cant_open_media_selection, Toast.LENGTH_LONG)
}
}
AttachmentKeyboardButton.POLL -> {
CreatePollFragment.show(childFragmentManager)
childFragmentManager.setFragmentResultListener(CreatePollFragment.REQUEST_KEY, requireActivity()) { _, bundle ->
sendPoll(recipient, Poll.fromBundle(bundle))
}
}
}
} else if (media != null) {
conversationActivityResultContracts.launchMediaEditor(listOf(media), recipient.id, composeText.textTrimmed)

View File

@@ -61,6 +61,8 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.database.model.StickerRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob
import org.thoughtcrime.securesms.keyboard.KeyboardUtil
@@ -71,6 +73,7 @@ import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
@@ -82,9 +85,11 @@ import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.getPoll
import org.thoughtcrime.securesms.util.hasLinkPreview
import org.thoughtcrime.securesms.util.hasSharedContact
import org.thoughtcrime.securesms.util.hasTextSlide
import org.thoughtcrime.securesms.util.isPoll
import org.thoughtcrime.securesms.util.isViewOnceMessage
import org.thoughtcrime.securesms.util.requireTextSlide
import java.io.IOException
@@ -164,6 +169,59 @@ class ConversationRepository(
}.subscribeOn(Schedulers.io())
}
fun sendPoll(threadRecipient: Recipient, poll: Poll): Completable {
return Completable.create { emitter ->
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(threadRecipient)
val message = OutgoingMessage.pollMessage(
threadRecipient = threadRecipient,
sentTimeMillis = System.currentTimeMillis(),
expiresIn = threadRecipient.expiresInSeconds.seconds.inWholeMilliseconds,
poll = poll.copy(authorId = Recipient.self().id.toLong()),
question = poll.question
)
Log.i(TAG, "Sending poll create to " + message.threadRecipient.id + ", thread: " + threadId)
MessageSender.sendPollAction(
AppDependencies.application,
message,
threadId,
MessageSender.SendType.SIGNAL,
null,
{ emitter.onComplete() }
)
}.subscribeOn(Schedulers.io())
}
fun endPoll(pollId: Long): Completable {
return Completable.create { emitter ->
val poll = SignalDatabase.polls.getPollFromId(pollId)
val messageRecord = SignalDatabase.messages.getMessageRecord(poll!!.messageId)
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId)!!
val pollSentTimestamp = messageRecord.dateSent
val message = OutgoingMessage.pollTerminateMessage(
threadRecipient = threadRecipient,
sentTimeMillis = System.currentTimeMillis(),
expiresIn = threadRecipient.expiresInSeconds.seconds.inWholeMilliseconds,
messageExtras = MessageExtras(pollTerminate = PollTerminate(question = poll.question, messageId = poll.messageId, targetTimestamp = pollSentTimestamp))
)
Log.i(TAG, "Sending poll terminate to " + message.threadRecipient.id + ", thread: " + messageRecord.threadId)
MessageSender.sendPollAction(
AppDependencies.application,
message,
messageRecord.threadId,
MessageSender.SendType.SIGNAL,
null
) {
emitter.onComplete()
}
}.subscribeOn(Schedulers.io())
}
fun sendMessage(
threadId: Long,
threadRecipient: Recipient,
@@ -271,6 +329,13 @@ class ConversationRepository(
}.subscribeOn(Schedulers.io())
}
fun getMessagePosition(threadId: Long, messageId: Long): Single<Int> {
return Single.fromCallable {
val message = SignalDatabase.messages.getMessageRecord(messageId)
SignalDatabase.messages.getMessagePositionInConversation(threadId, message.dateReceived, message.fromRecipient.id)
}.subscribeOn(Schedulers.io())
}
fun getMessagePosition(threadId: Long, dateReceived: Long, authorId: RecipientId): Single<Int> {
return Single.fromCallable {
SignalDatabase.messages.getMessagePositionInConversation(threadId, dateReceived, authorId)
@@ -497,6 +562,11 @@ class ConversationRepository(
}
slideDeck to conversationMessage.getDisplayBody(context)
} else if (messageRecord.isPoll()) {
val poll = messageRecord.getPoll()!!
val slideDeck = SlideDeck()
slideDeck to SpannableStringBuilder().append(context.getString(R.string.Poll__poll_question, poll.question))
} else {
var slideDeck = if (messageRecord.isMms) {
(messageRecord as MmsMessageRecord).slideDeck

View File

@@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.paging.ProxyPagingController
import org.thoughtcrime.securesms.banner.Banner
@@ -60,6 +61,7 @@ import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
import org.thoughtcrime.securesms.conversation.v2.items.ChatColorsDrawable
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.IdentityRecord
@@ -72,6 +74,7 @@ import org.thoughtcrime.securesms.database.model.StickerRecord
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.PollVoteJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.keyboard.KeyboardUtil
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -81,6 +84,9 @@ import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
@@ -107,6 +113,10 @@ class ConversationViewModel(
private val scheduledMessagesRepository: ScheduledMessagesRepository
) : ViewModel() {
companion object {
private val TAG = Log.tag(ConversationViewModel::class.java)
}
private val disposables = CompositeDisposable()
private val scrollButtonStateStore = RxStore(ConversationScrollButtonState()).addTo(disposables)
@@ -415,6 +425,11 @@ class ConversationViewModel(
return repository.getNextMentionPosition(threadId)
}
fun moveToMessage(messageId: Long): Single<Int> {
return repository.getMessagePosition(threadId, messageId)
.observeOn(AndroidSchedulers.mainThread())
}
fun moveToMessage(dateReceived: Long, author: RecipientId): Single<Int> {
return repository.getMessagePosition(threadId, dateReceived, author)
.observeOn(AndroidSchedulers.mainThread())
@@ -494,6 +509,18 @@ class ConversationViewModel(
return reactions.firstOrNull { it.author == Recipient.self().id }
}
fun sendPoll(threadRecipient: Recipient, poll: Poll): Completable {
return repository
.sendPoll(threadRecipient, poll)
.observeOn(AndroidSchedulers.mainThread())
}
fun endPoll(pollId: Long): Completable {
return repository
.endPoll(pollId)
.observeOn(AndroidSchedulers.mainThread())
}
fun sendMessage(
metricId: String?,
threadRecipient: Recipient,
@@ -622,6 +649,27 @@ class ConversationViewModel(
}
}
fun toggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
val voteCount = if (isChecked) {
SignalDatabase.polls.insertVote(poll, pollOption)
} else {
SignalDatabase.polls.removeVote(poll, pollOption)
}
val pollVoteJob = PollVoteJob.create(
messageId = poll.messageId,
voteCount = voteCount,
isRemoval = !isChecked
)
if (pollVoteJob != null) {
AppDependencies.jobManager.add(pollVoteJob)
} else {
Log.w(TAG, "Unable to create poll vote job, ignoring.")
}
}
}
data class BackPressedState(
val isReactionDelegateShowing: Boolean = false,
val isSearchRequested: Boolean = false

View File

@@ -0,0 +1,291 @@
package org.thoughtcrime.securesms.conversation.v2
import android.app.Dialog
import android.os.Bundle
import android.view.WindowManager
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent
import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem
import org.signal.core.ui.compose.copied.androidx.compose.dragContainer
import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.util.ViewUtil
import kotlin.time.Duration.Companion.milliseconds
/**
* Fragment to create a poll
*/
class CreatePollFragment : ComposeDialogFragment() {
companion object {
private val TAG = Log.tag(CreatePollFragment::class)
const val MAX_CHARACTER_LENGTH = 100
const val MAX_OPTIONS = 10
const val MIN_OPTIONS = 2
const val REQUEST_KEY = "CreatePollFragment"
fun show(fragmentManager: FragmentManager) {
return CreatePollFragment().show(fragmentManager, null)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen_Poll) // TODO(michelle): Finalize animation
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
return dialog
}
@Composable
override fun DialogContent() {
Scaffolds.Settings(
title = stringResource(R.string.CreatePollFragment__new_poll),
onNavigationClick = {
dismissAllowingStateLoss()
},
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_x_24),
navigationContentDescription = stringResource(R.string.Material3SearchToolbar__close)
) { paddingValues ->
CreatePollScreen(
paddingValues = paddingValues,
onSend = { question, allowMultiple, options ->
ViewUtil.hideKeyboard(requireContext(), requireView())
setFragmentResult(REQUEST_KEY, Poll(question, allowMultiple, options).toBundle())
dismissAllowingStateLoss()
},
onShowErrorSnackbar = { hasQuestion, hasOptions ->
if (!hasQuestion && !hasOptions) {
Snackbar.make(requireView(), R.string.CreatePollFragment__add_question_option, Snackbar.LENGTH_LONG).show()
} else if (!hasQuestion) {
Snackbar.make(requireView(), R.string.CreatePollFragment__add_question, Snackbar.LENGTH_LONG).show()
} else {
Snackbar.make(requireView(), R.string.CreatePollFragment__add_option, Snackbar.LENGTH_LONG).show()
}
}
)
}
}
}
@OptIn(FlowPreview::class)
@Composable
private fun CreatePollScreen(
paddingValues: PaddingValues,
onSend: (String, Boolean, List<String>) -> Unit = { _, _, _ -> },
onShowErrorSnackbar: (Boolean, Boolean) -> Unit = { _, _ -> }
) {
// Parts of poll
var question by remember { mutableStateOf("") }
val options = remember { mutableStateListOf("", "") }
var allowMultiple by remember { mutableStateOf(false) }
var hasMinimumOptions by remember { mutableStateOf(false) }
val isEnabled = question.isNotBlank() && hasMinimumOptions
var focusedOption by remember { mutableStateOf(-1) }
// Drag and drop
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val isRtl = ViewUtil.isRtl(LocalContext.current)
val listState = rememberLazyListState()
val dragDropState = rememberDragDropState(listState, includeHeader = true, includeFooter = true, onEvent = { event ->
when (event) {
is DragAndDropEvent.OnItemMove -> {
val oldIndex = options[event.fromIndex]
options[event.fromIndex] = options[event.toIndex]
options[event.toIndex] = oldIndex
}
is DragAndDropEvent.OnItemDrop, is DragAndDropEvent.OnDragCancel -> Unit
}
})
LaunchedEffect(Unit) {
snapshotFlow { options.toList() }
.debounce(100.milliseconds)
.collect { currentOptions ->
val count = currentOptions.count { it.isNotBlank() }
if (count == currentOptions.size && currentOptions.size < CreatePollFragment.MAX_OPTIONS) {
options.add("")
}
hasMinimumOptions = count >= CreatePollFragment.MIN_OPTIONS
}
}
LaunchedEffect(focusedOption) {
val count = options.count { it.isNotBlank() }
if (count >= CreatePollFragment.MIN_OPTIONS) {
if (options.removeIf { it.isEmpty() }) {
options.add("")
}
}
}
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
LazyColumn(
modifier = Modifier
.fillMaxHeight()
.imePadding()
.dragContainer(
dragDropState = dragDropState,
leftDpOffset = if (isRtl) 0.dp else screenWidth - 56.dp,
rightDpOffset = if (isRtl) 56.dp else screenWidth
),
state = listState
) {
item {
DraggableItem(dragDropState, 0) {
Text(
text = stringResource(R.string.CreatePollFragment__question),
modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp),
style = MaterialTheme.typography.titleSmall
)
TextField(
value = question,
label = { Text(text = stringResource(R.string.CreatePollFragment__ask_a_question)) },
onValueChange = { question = it.substring(0, minOf(it.length, CreatePollFragment.MAX_CHARACTER_LENGTH)) },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.onFocusChanged { focusState -> if (focusState.isFocused) focusedOption = -1 }
)
Spacer(modifier = Modifier.size(32.dp))
Text(
text = stringResource(R.string.CreatePollFragment__options),
modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp),
style = MaterialTheme.typography.titleSmall
)
}
}
itemsIndexed(options) { index, option ->
DraggableItem(dragDropState, 1 + index) {
Box(modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 16.dp)) {
TextField(
value = option,
label = { Text(text = stringResource(R.string.CreatePollFragment__option_n, index + 1)) },
onValueChange = { options[index] = it.substring(0, minOf(it.length, CreatePollFragment.MAX_CHARACTER_LENGTH)) },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState -> if (focusState.isFocused) focusedOption = index },
trailingIcon = {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.drag_handle),
contentDescription = stringResource(R.string.CreatePollFragment__drag_handle),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
}
}
item {
DraggableItem(dragDropState, 1 + options.size) {
Dividers.Default()
Rows.ToggleRow(checked = allowMultiple, text = stringResource(R.string.CreatePollFragment__allow_multiple_votes), onCheckChanged = { allowMultiple = it })
Spacer(modifier = Modifier.size(60.dp))
}
}
}
Buttons.MediumTonal(
colors = ButtonDefaults.filledTonalButtonColors(
contentColor = if (isEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
containerColor = if (isEnabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
onClick = {
if (isEnabled) {
onSend(question, allowMultiple, options.filter { it.isNotBlank() })
} else {
onShowErrorSnackbar(question.isNotBlank(), hasMinimumOptions)
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 16.dp, end = 24.dp)
.imePadding()
) {
Text(text = stringResource(R.string.conversation_activity__send))
}
}
}
@DayNightPreviews
@Composable
fun CreatePollPreview() {
Previews.Preview {
CreatePollScreen(PaddingValues(0.dp))
}
}

View File

@@ -17,9 +17,11 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.database.model.withAttachments
import org.thoughtcrime.securesms.database.model.withCall
import org.thoughtcrime.securesms.database.model.withPayment
import org.thoughtcrime.securesms.database.model.withPoll
import org.thoughtcrime.securesms.database.model.withReactions
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.payments.Payment
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.util.UuidUtil
@@ -99,6 +101,10 @@ object MessageDataFetcher {
}
}
val pollsFuture = executor.submitTimed {
SignalDatabase.polls.getPollsForMessages(messageIds)
}
val mentionsResult = mentionsFuture.get()
val hasBeenQuotedResult = hasBeenQuotedFuture.get()
val reactionsResult = reactionsFuture.get()
@@ -106,6 +112,7 @@ object MessageDataFetcher {
val paymentsResult = paymentsFuture.get()
val callsResult = callsFuture.get()
val recipientsResult = recipientsFuture.get()
val pollsResult = pollsFuture.get()
val wallTimeMs = (System.nanoTime() - startTimeNanos).nanoseconds.toDouble(DurationUnit.MILLISECONDS)
@@ -119,6 +126,7 @@ object MessageDataFetcher {
attachments = attachmentsResult.result,
payments = paymentsResult.result,
calls = callsResult.result,
polls = pollsResult.result,
timeLog = "mentions: ${mentionsResult.duration}, is-quoted: ${hasBeenQuotedResult.duration}, reactions: ${reactionsResult.duration}, attachments: ${attachmentsResult.duration}, payments: ${paymentsResult.duration}, calls: ${callsResult.duration} >> cpuTime: ${cpuTimeMs.roundedString(2)}, wallTime: ${wallTimeMs.roundedString(2)}"
)
}
@@ -157,6 +165,10 @@ object MessageDataFetcher {
output.withCall(it)
} ?: output
output = data.polls[id]?.let {
output.withPoll(it)
} ?: output
return output
}
@@ -187,6 +199,7 @@ object MessageDataFetcher {
val attachments: Map<Long, List<DatabaseAttachment>>,
val payments: Map<Long, Payment>,
val calls: Map<Long, CallTable.Call>,
val polls: Map<Long, PollRecord>,
val timeLog: String
)
}

View File

@@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.permissions.PermissionCompat
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
import java.util.function.Predicate
/**
@@ -48,6 +49,7 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
private val lifecycleDisposable = LifecycleDisposable()
private val removePaymentFilter: Predicate<AttachmentKeyboardButton> = Predicate { button -> button != AttachmentKeyboardButton.PAYMENT }
private val removePollFilter: Predicate<AttachmentKeyboardButton> = Predicate { button -> button != AttachmentKeyboardButton.POLL }
@Suppress("ReplaceGetOrSet")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -72,7 +74,7 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
val snapshot = conversationViewModel.recipientSnapshot
if (snapshot != null) {
updatePaymentsAvailable(snapshot)
updateButtonsAvailable(snapshot)
}
conversationViewModel
@@ -80,7 +82,7 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
attachmentKeyboardView.setWallpaperEnabled(it.hasWallpaper)
updatePaymentsAvailable(it)
updateButtonsAvailable(it)
}
.addTo(lifecycleDisposable)
}
@@ -126,16 +128,19 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
.execute()
}
private fun updatePaymentsAvailable(recipient: Recipient) {
private fun updateButtonsAvailable(recipient: Recipient) {
val paymentsValues = SignalStore.payments
if (paymentsValues.paymentsAvailability.isSendAllowed &&
!recipient.isSelf &&
!recipient.isGroup &&
recipient.isRegistered
) {
attachmentKeyboardView.filterAttachmentKeyboardButtons(null)
} else {
val isPaymentsAvailable = paymentsValues.paymentsAvailability.isSendAllowed && !recipient.isSelf && !recipient.isGroup && recipient.isRegistered
val isPollsAvailable = recipient.isPushV2Group && RemoteConfig.polls
if (!isPaymentsAvailable && !isPollsAvailable) {
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePaymentFilter.and(removePollFilter))
} else if (!isPaymentsAvailable) {
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePaymentFilter)
} else if (!isPollsAvailable) (
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePollFilter)
) else {
attachmentKeyboardView.filterAttachmentKeyboardButtons(null)
}
}
}