mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 09:20:19 +01:00
Release polls behind feature flag.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user