Add partial share and draft support to CFv2.

This commit is contained in:
Cody Henthorne
2023-05-31 14:12:33 -04:00
parent b9ae537706
commit 693aef5c04
25 changed files with 642 additions and 137 deletions

View File

@@ -191,6 +191,11 @@ public class ComposeText extends EmojiEditText {
setHintWithChecks(hint);
}
public void setDraftText(@Nullable CharSequence draftText) {
setText("");
append(draftText);
}
public void appendInvite(String invite) {
if (getText() == null) {
return;

View File

@@ -205,7 +205,7 @@ public class LinkPreviewView extends FrameLayout {
if (showThumbnail && linkPreview.getThumbnail().isPresent()) {
thumbnail.setVisibility(VISIBLE);
thumbnailState.applyState(thumbnail);
thumbnail.get().setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false);
thumbnail.get().setImageResource(glideRequests, new ImageSlide(linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false);
thumbnail.get().showDownloadText(false);
} else {
thumbnail.setVisibility(GONE);

View File

@@ -201,7 +201,7 @@ public final class TransferControlView extends FrameLayout {
private String getDownloadText(@NonNull List<Slide> slides) {
if (slides.size() == 1) {
return slides.get(0).getContentDescription();
return slides.get(0).getContentDescription(getContext());
} else {
int downloadCount = Stream.of(slides).reduce(0, (count, slide) -> slide.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_DONE ? count + 1 : count);
return getContext().getResources().getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount);

View File

@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mms.SlideFactory;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stickers.StickerLocator;
@@ -105,6 +106,9 @@ public class ConversationIntents {
private final RecipientId recipientId;
private final long threadId;
private final String draftText;
private final Uri draftMedia;
private final String draftContentType;
private final SlideFactory.MediaType draftMediaType;
private final ArrayList<Media> media;
private final StickerLocator stickerLocator;
private final boolean isBorderless;
@@ -124,6 +128,8 @@ public class ConversationIntents {
null,
null,
null,
null,
null,
false,
ThreadTable.DistributionTypes.DEFAULT,
-1,
@@ -137,6 +143,8 @@ public class ConversationIntents {
return new Args(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
arguments.getLong(EXTRA_THREAD_ID, -1),
arguments.getString(EXTRA_TEXT),
ConversationIntents.getIntentData(arguments),
ConversationIntents.getIntentType(arguments),
arguments.getParcelableArrayList(EXTRA_MEDIA),
arguments.getParcelable(EXTRA_STICKER),
arguments.getBoolean(EXTRA_BORDERLESS, false),
@@ -152,6 +160,8 @@ public class ConversationIntents {
private Args(@NonNull RecipientId recipientId,
long threadId,
@Nullable String draftText,
@Nullable Uri draftMedia,
@Nullable String draftContentType,
@Nullable ArrayList<Media> media,
@Nullable StickerLocator stickerLocator,
boolean isBorderless,
@@ -166,6 +176,8 @@ public class ConversationIntents {
this.recipientId = recipientId;
this.threadId = threadId;
this.draftText = draftText;
this.draftMedia = draftMedia;
this.draftContentType = draftContentType;
this.media = media;
this.stickerLocator = stickerLocator;
this.isBorderless = isBorderless;
@@ -176,6 +188,7 @@ public class ConversationIntents {
this.giftBadge = giftBadge;
this.shareDataTimestamp = shareDataTimestamp;
this.conversationScreenType = conversationScreenType;
this.draftMediaType = SlideFactory.MediaType.from(draftContentType);
}
public @NonNull RecipientId getRecipientId() {
@@ -190,6 +203,18 @@ public class ConversationIntents {
return draftText;
}
public @Nullable Uri getDraftMedia() {
return draftMedia;
}
public @Nullable String getDraftContentType() {
return draftContentType;
}
public @Nullable SlideFactory.MediaType getDraftMediaType() {
return draftMediaType;
}
public @Nullable ArrayList<Media> getMedia() {
return media;
}
@@ -237,6 +262,10 @@ public class ConversationIntents {
public @NonNull ConversationScreenType getConversationScreenType() {
return conversationScreenType;
}
public boolean canInitializeFromDatabase() {
return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null;
}
}
public final static class Builder {

View File

@@ -1127,7 +1127,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
mediaThumbnailStub.require().setVisibility(VISIBLE);
mediaThumbnailStub.require().setMinimumThumbnailWidth(readDimen(R.dimen.media_bubble_min_width_with_content));
mediaThumbnailStub.require().setMaximumThumbnailHeight(readDimen(R.dimen.media_bubble_max_height));
mediaThumbnailStub.require().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(context, linkPreview.getThumbnail().get())), showControls, false);
mediaThumbnailStub.require().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(linkPreview.getThumbnail().get())), showControls, false);
mediaThumbnailStub.require().setThumbnailClickListener(new LinkPreviewThumbnailClickListener());
mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener);
mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener);

View File

@@ -1,16 +1,22 @@
package org.thoughtcrime.securesms.conversation.drafts
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.text.Spannable
import android.text.SpannableString
import androidx.annotation.WorkerThread
import com.bumptech.glide.load.engine.DiskCacheStrategy
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.StreamUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.location.SignalPlace
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
import org.thoughtcrime.securesms.conversation.MessageStyler
@@ -27,28 +33,126 @@ import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
import org.thoughtcrime.securesms.mms.GifSlide
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.QuoteId
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideFactory
import org.thoughtcrime.securesms.mms.StickerSlide
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor
import org.thoughtcrime.securesms.util.hasTextSlide
import org.thoughtcrime.securesms.util.requireTextSlide
import java.io.IOException
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
class DraftRepository(
private val context: Context = ApplicationDependencies.getApplication(),
private val threadTable: ThreadTable = SignalDatabase.threads,
private val draftTable: DraftTable = SignalDatabase.drafts,
private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED)
private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED),
private val conversationArguments: ConversationIntents.Args? = null
) {
companion object {
val TAG = Log.tag(DraftRepository::class.java)
}
fun getShareOrDraftData(): Maybe<Pair<ShareOrDraftData, Drafts?>> {
return Maybe.fromCallable<Pair<ShareOrDraftData, Drafts?>> { getShareOrDraftDataInternal() }
.observeOn(Schedulers.io())
}
private fun getShareOrDraftDataInternal(): Pair<ShareOrDraftData, Drafts?>? {
val shareText = conversationArguments?.draftText
val shareMedia = conversationArguments?.draftMedia
val shareContentType = conversationArguments?.draftContentType
val shareMediaType = conversationArguments?.draftMediaType
val shareMediaList = conversationArguments?.media ?: emptyList()
val stickerLocator = conversationArguments?.stickerLocator
val borderless = conversationArguments?.isBorderless ?: false
if (stickerLocator != null && shareMedia != null) {
val slide = StickerSlide(context, shareMedia, 0, stickerLocator, shareContentType!!)
return ShareOrDraftData.SendSticker(slide) to null
}
if (shareMedia != null && shareContentType != null && borderless) {
val details = getKeyboardImageDetails(GlideApp.with(context), shareMedia)
if (details == null || !details.hasTransparency) {
return ShareOrDraftData.SetMedia(shareMedia, shareMediaType!!, null) to null
}
val slide: Slide? = if (MediaUtil.isGif(shareContentType)) {
GifSlide(context, shareMedia, 0, details.width, details.height, true, null)
} else if (MediaUtil.isImageType(shareContentType)) {
ImageSlide(context, shareMedia, shareContentType, 0, details.width, details.height, true, null, null)
} else {
Log.w(TAG, "Attempting to send unsupported non-image via keyboard share")
null
}
return if (slide != null) ShareOrDraftData.SendKeyboardImage(slide) to null else null
}
if (shareMediaList.isNotEmpty()) {
return ShareOrDraftData.StartSendMedia(shareMediaList, shareText) to null
}
if (shareMedia != null && shareMediaType != null) {
return ShareOrDraftData.SetMedia(shareMedia, shareMediaType, shareText) to null
}
if (shareText != null) {
return ShareOrDraftData.SetText(shareText) to null
}
if (conversationArguments?.canInitializeFromDatabase() == true) {
val (drafts, updatedText) = loadDraftsInternal(conversationArguments.threadId)
val draftText: CharSequence? = drafts.firstOrNull { it.type == DraftTable.Draft.TEXT }?.let { updatedText ?: it.value }
val location: SignalPlace? = drafts.firstOrNull { it.type == DraftTable.Draft.LOCATION }?.let { SignalPlace.deserialize(it.value) }
if (location != null) {
return ShareOrDraftData.SetLocation(location, draftText) to drafts
}
val audio: Uri? = drafts.firstOrNull { it.type == DraftTable.Draft.AUDIO }?.let { Uri.parse(it.value) }
if (audio != null) {
return ShareOrDraftData.SetMedia(audio, SlideFactory.MediaType.AUDIO, null) to drafts
}
val quote: ConversationMessage? = drafts.firstOrNull { it.type == DraftTable.Draft.QUOTE }?.let { loadDraftQuoteInternal(it.value) }
if (quote != null) {
return ShareOrDraftData.SetQuote(quote, draftText) to drafts
}
val messageEdit: ConversationMessage? = drafts.firstOrNull { it.type == DraftTable.Draft.MESSAGE_EDIT }?.let { loadDraftMessageEditInternal(it.value) }
if (messageEdit != null) {
return ShareOrDraftData.SetEditMessage(messageEdit) to drafts
}
if (draftText != null) {
return ShareOrDraftData.SetText(draftText) to drafts
}
}
// no share or draft
return null
}
fun deleteVoiceNoteDraftData(draft: DraftTable.Draft?) {
if (draft != null) {
SignalExecutors.BOUNDED.execute {
@@ -57,11 +161,13 @@ class DraftRepository(
}
}
fun saveDrafts(recipient: Recipient, threadId: Long, distributionType: Int, drafts: Drafts) {
fun saveDrafts(recipient: Recipient?, threadId: Long, distributionType: Int, drafts: Drafts) {
require(threadId != -1L || recipient != null)
saveDraftsExecutor.execute {
if (drafts.isNotEmpty()) {
val actualThreadId = if (threadId == -1L) {
threadTable.getOrCreateThreadIdFor(recipient, distributionType)
threadTable.getOrCreateThreadIdFor(recipient!!, distributionType)
} else {
threadId
}
@@ -79,65 +185,109 @@ class DraftRepository(
}
}
@Deprecated("Not needed for CFv2")
fun loadDrafts(threadId: Long): Single<DatabaseDraft> {
return Single.fromCallable {
val drafts: Drafts = draftTable.getDrafts(threadId)
val bodyRangesDraft: DraftTable.Draft? = drafts.getDraftOfType(DraftTable.Draft.BODY_RANGES)
val textDraft: DraftTable.Draft? = drafts.getDraftOfType(DraftTable.Draft.TEXT)
var updatedText: Spannable? = null
if (textDraft != null && bodyRangesDraft != null) {
val bodyRanges: BodyRangeList = BodyRangeList.parseFrom(Base64.decodeOrThrow(bodyRangesDraft.value))
val mentions: List<Mention> = MentionUtil.bodyRangeListToMentions(bodyRanges)
val updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, textDraft.value, mentions)
updatedText = SpannableString(updated.body)
MentionAnnotation.setMentionAnnotations(updatedText, updated.mentions)
MessageStyler.style(id = MessageStyler.DRAFT_ID, messageRanges = bodyRanges.adjustBodyRanges(updated.bodyAdjustments), span = updatedText, hideSpoilerText = false)
}
DatabaseDraft(drafts, updatedText)
loadDraftsInternal(threadId)
}.subscribeOn(Schedulers.io())
}
fun loadDraftQuote(serialized: String): Maybe<ConversationMessage> {
return Maybe.fromCallable {
val quoteId: QuoteId = QuoteId.deserialize(context, serialized) ?: return@fromCallable null
val messageRecord: MessageRecord = SignalDatabase.messages.getMessageFor(quoteId.id, quoteId.author)?.let {
if (it is MediaMmsMessageRecord) {
it.withAttachments(context, SignalDatabase.attachments.getAttachmentsForMessage(it.id))
} else {
it
}
} ?: return@fromCallable null
private fun loadDraftsInternal(threadId: Long): DatabaseDraft {
val drafts: Drafts = draftTable.getDrafts(threadId)
val bodyRangesDraft: DraftTable.Draft? = drafts.getDraftOfType(DraftTable.Draft.BODY_RANGES)
val textDraft: DraftTable.Draft? = drafts.getDraftOfType(DraftTable.Draft.TEXT)
var updatedText: Spannable? = null
val threadRecipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId))
ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, threadRecipient)
if (textDraft != null && bodyRangesDraft != null) {
val bodyRanges: BodyRangeList = BodyRangeList.parseFrom(Base64.decodeOrThrow(bodyRangesDraft.value))
val mentions: List<Mention> = MentionUtil.bodyRangeListToMentions(bodyRanges)
val updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, textDraft.value, mentions)
updatedText = SpannableString(updated.body)
MentionAnnotation.setMentionAnnotations(updatedText, updated.mentions)
MessageStyler.style(id = MessageStyler.DRAFT_ID, messageRanges = bodyRanges.adjustBodyRanges(updated.bodyAdjustments), span = updatedText, hideSpoilerText = false)
}
return DatabaseDraft(drafts, updatedText)
}
@Deprecated("Not needed for CFv2")
fun loadDraftQuote(serialized: String): Maybe<ConversationMessage> {
return Maybe.fromCallable { loadDraftQuoteInternal(serialized) }
}
private fun loadDraftQuoteInternal(serialized: String): ConversationMessage? {
val quoteId: QuoteId = QuoteId.deserialize(context, serialized) ?: return null
val messageRecord: MessageRecord = SignalDatabase.messages.getMessageFor(quoteId.id, quoteId.author)?.let {
if (it is MediaMmsMessageRecord) {
it.withAttachments(context, SignalDatabase.attachments.getAttachmentsForMessage(it.id))
} else {
it
}
} ?: return null
val threadRecipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId))
return ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, threadRecipient)
}
@Deprecated("Not needed for CFv2")
fun loadDraftMessageEdit(serialized: String): Maybe<ConversationMessage> {
return Maybe.fromCallable {
val messageId = MessageId.deserialize(serialized)
val messageRecord: MessageRecord = SignalDatabase.messages.getMessageRecordOrNull(messageId.id) ?: return@fromCallable null
val threadRecipient: Recipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId))
if (messageRecord.hasTextSlide()) {
val textSlide = messageRecord.requireTextSlide()
if (textSlide.uri != null) {
try {
PartAuthority.getAttachmentStream(context, textSlide.uri!!).use { stream ->
val body = StreamUtil.readFullyAsString(stream)
return@fromCallable ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body, threadRecipient)
}
} catch (e: IOException) {
Log.e(TAG, "Failed to load text slide", e)
return Maybe.fromCallable { loadDraftMessageEditInternal(serialized) }
}
private fun loadDraftMessageEditInternal(serialized: String): ConversationMessage? {
val messageId = MessageId.deserialize(serialized)
val messageRecord: MessageRecord = SignalDatabase.messages.getMessageRecordOrNull(messageId.id) ?: return null
val threadRecipient: Recipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId))
if (messageRecord.hasTextSlide()) {
val textSlide = messageRecord.requireTextSlide()
if (textSlide.uri != null) {
try {
PartAuthority.getAttachmentStream(context, textSlide.uri!!).use { stream ->
val body = StreamUtil.readFullyAsString(stream)
return ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body, threadRecipient)
}
} catch (e: IOException) {
Log.e(TAG, "Failed to load text slide", e)
}
}
ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, threadRecipient)
}
return ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, threadRecipient)
}
@WorkerThread
private fun getKeyboardImageDetails(glideRequests: GlideRequests, uri: Uri): KeyboardImageDetails? {
return try {
val bitmap: Bitmap = glideRequests.asBitmap()
.load(DecryptableUri(uri))
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.submit()
.get(1000, TimeUnit.MILLISECONDS)
val topLeft = bitmap.getPixel(0, 0)
KeyboardImageDetails(bitmap.width, bitmap.height, Color.alpha(topLeft) < 255)
} catch (e: InterruptedException) {
null
} catch (e: ExecutionException) {
null
} catch (e: TimeoutException) {
null
}
}
data class DatabaseDraft(val drafts: Drafts, val updatedText: CharSequence?)
sealed interface ShareOrDraftData {
data class SendSticker(val slide: Slide) : ShareOrDraftData
data class SendKeyboardImage(val slide: Slide) : ShareOrDraftData
data class StartSendMedia(val mediaList: List<Media>, val text: CharSequence?) : ShareOrDraftData
data class SetMedia(val media: Uri, val mediaType: SlideFactory.MediaType, val text: CharSequence?) : ShareOrDraftData
data class SetText(val text: CharSequence) : ShareOrDraftData
data class SetLocation(val location: SignalPlace, val draftText: CharSequence?) : ShareOrDraftData
data class SetQuote(val quote: ConversationMessage, val draftText: CharSequence?) : ShareOrDraftData
data class SetEditMessage(val messageEdit: ConversationMessage) : ShareOrDraftData
}
data class KeyboardImageDetails(val width: Int, val height: Int, val hasTransparency: Boolean)
}

View File

@@ -10,8 +10,10 @@ import org.thoughtcrime.securesms.recipients.RecipientId
* management pattern going forward for drafts.
*/
data class DraftState(
val recipientId: RecipientId = RecipientId.UNKNOWN,
@Deprecated("Not needed for CFv2")
val recipientId: RecipientId? = null,
val threadId: Long = -1,
@Deprecated("Not needed for CFv2")
val distributionType: Int = 0,
val textDraft: DraftTable.Draft? = null,
val bodyRangesDraft: DraftTable.Draft? = null,
@@ -36,7 +38,7 @@ data class DraftState(
}
}
fun copyAndSetDrafts(threadId: Long, drafts: Drafts): DraftState {
fun copyAndSetDrafts(threadId: Long = this.threadId, drafts: Drafts): DraftState {
return copy(
threadId = threadId,
textDraft = drafts.getDraftOfType(DraftTable.Draft.TEXT),

View File

@@ -25,10 +25,11 @@ import org.thoughtcrime.securesms.util.rx.RxStore
* management pattern going forward for drafts.
*/
class DraftViewModel @JvmOverloads constructor(
threadId: Long = -1,
private val repository: DraftRepository = DraftRepository()
) : ViewModel() {
private val store = RxStore(DraftState())
private val store = RxStore(DraftState(threadId = threadId))
val state: Flowable<DraftState> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
@@ -39,10 +40,12 @@ class DraftViewModel @JvmOverloads constructor(
store.dispose()
}
@Deprecated("Not needed for CFv2")
fun setThreadId(threadId: Long) {
store.update { it.copy(threadId = threadId) }
}
@Deprecated("Not needed for CFv2")
fun setDistributionType(distributionType: Int) {
store.update { it.copy(distributionType = distributionType) }
}
@@ -64,6 +67,7 @@ class DraftViewModel @JvmOverloads constructor(
}
}
@Deprecated("Not needed for CFv2")
fun onRecipientChanged(recipient: Recipient) {
store.update { it.copy(recipientId = recipient.id) }
}
@@ -130,16 +134,17 @@ class DraftViewModel @JvmOverloads constructor(
}
}
fun onSendComplete(threadId: Long) {
fun onSendComplete(threadId: Long = store.state.threadId) {
repository.deleteVoiceNoteDraftData(store.state.voiceNoteDraft)
store.update { saveDrafts(it.copyAndClearDrafts(threadId)) }
}
private fun saveDrafts(state: DraftState): DraftState {
repository.saveDrafts(Recipient.resolved(state.recipientId), state.threadId, state.distributionType, state.toDrafts())
repository.saveDrafts(state.recipientId?.let { Recipient.resolved(it) }, state.threadId, state.distributionType, state.toDrafts())
return state
}
@Deprecated("Not needed for CFv2")
fun loadDrafts(threadId: Long): Single<DraftRepository.DatabaseDraft> {
return repository
.loadDrafts(threadId)
@@ -149,17 +154,30 @@ class DraftViewModel @JvmOverloads constructor(
.observeOn(AndroidSchedulers.mainThread())
}
@Deprecated("Not needed for CFv2")
fun loadDraftQuote(serialized: String): Maybe<ConversationMessage> {
return repository.loadDraftQuote(serialized)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
@Deprecated("Not needed for CFv2")
fun loadDraftEditMessage(serialized: String): Maybe<ConversationMessage> {
return repository.loadDraftMessageEdit(serialized)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun loadShareOrDraftData(): Maybe<DraftRepository.ShareOrDraftData> {
return repository.getShareOrDraftData()
.doOnSuccess { (_, drafts) ->
if (drafts != null) {
store.update { saveDrafts(it.copyAndSetDrafts(drafts = drafts)) }
}
}
.map { (data, _) -> data }
.observeOn(AndroidSchedulers.mainThread())
}
}
private fun String.toTextDraft(): Draft? {

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.content.IntentCompat
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* This encapsulates the logic for interacting with other activities used throughout a conversation. The gist
* is to use this to launch the various activities and use the [Callbacks] to provide strongly-typed results.
* It is intended to replace the need for [android.app.Activity.onActivityResult].
*
* Note, not all activity results will live here but this should handle most of the basic cases. More advance
* usages like [AddToContactsContract] can be split out into their own [ActivityResultContract] implementations.
*/
class ConversationActivityResultContracts(fragment: Fragment, private val callbacks: Callbacks) {
private val contactShareLauncher = fragment.registerForActivityResult(ContactShareEditor) { contacts -> callbacks.onSendContacts(contacts) }
private val mediaSelectionLauncher = fragment.registerForActivityResult(MediaSelection) { result -> callbacks.onMediaSend(result) }
fun launchContactShareEditor(uri: Uri, chatColors: ChatColors) {
contactShareLauncher.launch(uri to chatColors)
}
fun launchMediaEditor(mediaList: List<Media>, recipientId: RecipientId, text: CharSequence?) {
mediaSelectionLauncher.launch(MediaSelectionInput(mediaList, recipientId, text))
}
private object MediaSelection : ActivityResultContract<MediaSelectionInput, MediaSendActivityResult>() {
override fun createIntent(context: Context, input: MediaSelectionInput): Intent {
val (media, recipientId, text) = input
return MediaSelectionActivity.editor(context, MessageSendType.SignalMessageSendType, media, recipientId, text)
}
override fun parseResult(resultCode: Int, intent: Intent?): MediaSendActivityResult {
return MediaSendActivityResult.fromData(intent!!)
}
}
private object ContactShareEditor : ActivityResultContract<Pair<Uri, ChatColors>, List<Contact>>() {
override fun createIntent(context: Context, input: Pair<Uri, ChatColors>): Intent {
val (uri, chatColors) = input
return ContactShareEditActivity.getIntent(context, listOf(uri), chatColors.asSingleColor())
}
override fun parseResult(resultCode: Int, intent: Intent?): List<Contact> {
return intent?.let { IntentCompat.getParcelableArrayListExtra(intent, ContactShareEditActivity.KEY_CONTACTS, Contact::class.java) } ?: emptyList()
}
}
private data class MediaSelectionInput(val media: List<Media>, val recipientId: RecipientId, val text: CharSequence?)
interface Callbacks {
fun onSendContacts(contacts: List<Contact>)
fun onMediaSend(result: MediaSendActivityResult)
}
}

View File

@@ -98,6 +98,7 @@ import org.thoughtcrime.securesms.components.ProgressCardDialogFragmentArgs
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.SendButton
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
@@ -126,11 +127,15 @@ import org.thoughtcrime.securesms.conversation.ConversationReactionOverlay.OnHid
import org.thoughtcrime.securesms.conversation.MarkReadHelper
import org.thoughtcrime.securesms.conversation.MenuState
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.MessageStyler.getStyling
import org.thoughtcrime.securesms.conversation.SelectedConversationModel
import org.thoughtcrime.securesms.conversation.ShowAdminsBottomSheetDialog
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.drafts.DraftRepository
import org.thoughtcrime.securesms.conversation.drafts.DraftRepository.ShareOrDraftData
import org.thoughtcrime.securesms.conversation.drafts.DraftViewModel
import org.thoughtcrime.securesms.conversation.mutiselect.ConversationItemAnimator
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
@@ -145,10 +150,13 @@ import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFra
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.database.model.StickerRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
@@ -177,13 +185,18 @@ import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory.create
import org.thoughtcrime.securesms.mediapreview.MediaPreviewV2Activity
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.mms.AttachmentManager
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.mms.SlideFactory
import org.thoughtcrime.securesms.notifications.v2.ConversationId
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity
import org.thoughtcrime.securesms.permissions.Permissions
@@ -208,10 +221,12 @@ import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.BubbleUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.DeleteDialog
import org.thoughtcrime.securesms.util.DrawableUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.SaveAttachmentUtil
import org.thoughtcrime.securesms.util.SignalLocalMetrics
@@ -232,6 +247,7 @@ import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil
import java.util.Locale
import java.util.Optional
import java.util.concurrent.ExecutionException
/**
@@ -284,14 +300,20 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
MessageRequestViewModel(args.threadId, conversationRecipientRepository, messageRequestRepository)
}
private val draftViewModel: DraftViewModel by viewModel {
DraftViewModel(threadId = args.threadId, repository = DraftRepository(conversationArguments = args))
}
private val conversationTooltips = ConversationTooltips(this)
private val colorizer = Colorizer()
private val textDraftSaveDebouncer = Debouncer(500)
private lateinit var conversationOptionsMenuProvider: ConversationOptionsMenu.Provider
private lateinit var layoutManager: LinearLayoutManager
private lateinit var markReadHelper: MarkReadHelper
private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler
private lateinit var addToContactsLauncher: ActivityResultLauncher<Intent>
private lateinit var conversationActivityResultContracts: ConversationActivityResultContracts
private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate
private lateinit var adapter: ConversationAdapterV2
private lateinit var recyclerViewColorizer: RecyclerViewColorizer
@@ -483,8 +505,6 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
addTextChangedListener(composeTextEventsListener)
setOnClickListener(composeTextEventsListener)
onFocusChangeListener = composeTextEventsListener
setMessageSendType(MessageSendType.SignalMessageSendType)
}
sendButton.apply {
@@ -553,6 +573,30 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
SwipeAvailabilityProvider(),
this::handleReplyToMessage
).attachToRecyclerView(binding.conversationItemRecycler)
draftViewModel.loadShareOrDraftData()
.subscribeBy {
when (it) {
is ShareOrDraftData.SendKeyboardImage -> sendMessageWithoutComposeInput(slide = it.slide, clearCompose = false)
is ShareOrDraftData.SendSticker -> sendMessageWithoutComposeInput(slide = it.slide, clearCompose = true)
is ShareOrDraftData.SetEditMessage -> inputPanel.enterEditMessageMode(GlideApp.with(this), it.messageEdit, true)
is ShareOrDraftData.SetLocation -> attachmentManager.setLocation(it.location, MediaConstraints.getPushMediaConstraints())
is ShareOrDraftData.SetMedia -> {
composeText.setDraftText(it.text)
setMedia(it.media, it.mediaType)
}
is ShareOrDraftData.SetQuote -> {
composeText.setDraftText(it.draftText)
handleReplyToMessage(it.quote)
}
is ShareOrDraftData.SetText -> composeText.setDraftText(it.text)
is ShareOrDraftData.StartSendMedia -> {
val recipientId = viewModel.recipientSnapshot?.id ?: return@subscribeBy
conversationActivityResultContracts.launchMediaEditor(it.mediaList, recipientId, it.text)
}
}
}
.addTo(disposables)
}
private fun presentInputReadyState(inputReadyState: InputReadyState) {
@@ -577,6 +621,8 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
} else {
disabledInputView.clear()
}
composeText.setMessageSendType(MessageSendType.SignalMessageSendType)
}
private fun presentIdentityRecordsState(identityRecordsState: IdentityRecordsState) {
@@ -599,6 +645,33 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
}
}
private fun setMedia(uri: Uri, mediaType: SlideFactory.MediaType, width: Int = 0, height: Int = 0, borderless: Boolean = false, videoGif: Boolean = false) {
val recipientId: RecipientId = viewModel.recipientSnapshot?.id ?: return
if (mediaType == SlideFactory.MediaType.VCARD) {
conversationActivityResultContracts.launchContactShareEditor(uri, viewModel.recipientSnapshot!!.chatColors)
} else if (mediaType == SlideFactory.MediaType.IMAGE || mediaType == SlideFactory.MediaType.GIF || mediaType == SlideFactory.MediaType.VIDEO) {
val mimeType = MediaUtil.getMimeType(requireContext(), uri) ?: mediaType.toFallbackMimeType()
val media = Media(
uri,
mimeType,
0,
width,
height,
0,
0,
borderless,
videoGif,
Optional.empty(),
Optional.empty(),
Optional.empty()
)
conversationActivityResultContracts.launchMediaEditor(listOf(media), recipientId, composeText.textTrimmed)
} else {
attachmentManager.setMedia(GlideApp.with(this), uri, mediaType, MediaConstraints.getPushMediaConstraints(), width, height)
}
}
private fun calculateCharactersRemaining() {
val messageBody: String = binding.conversationInputPanel.embeddedTextEditor.textTrimmed.toString()
val charactersLeftView: TextView = binding.conversationInputSpaceLeft
@@ -620,6 +693,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
private fun registerForResults() {
addToContactsLauncher = registerForActivityResult(AddToContactsContract()) {}
conversationActivityResultContracts = ConversationActivityResultContracts(this, ActivityResultCallbacks())
}
private fun onRecipientChanged(recipient: Recipient) {
@@ -881,26 +955,54 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
}
}
private fun sendMessage(
metricId: String? = null,
scheduledDate: Long = -1,
slideDeck: SlideDeck? = if (attachmentManager.isAttachmentPresent) attachmentManager.buildSlideDeck() else null
private fun sendMessageWithoutComposeInput(
slide: Slide? = null,
contacts: List<Contact> = emptyList(),
clearCompose: Boolean = true
) {
sendMessage(
slideDeck = slide?.let { SlideDeck().apply { addSlide(slide) } },
contacts = contacts,
clearCompose = clearCompose,
body = "",
mentions = emptyList(),
bodyRanges = null,
messageToEdit = null,
quote = null
)
}
private fun sendMessage(
body: String = composeText.editableText.toString(),
mentions: List<Mention> = composeText.mentions,
bodyRanges: BodyRangeList? = composeText.styling,
messageToEdit: MessageId? = inputPanel.editMessageId,
quote: QuoteModel? = inputPanel.quote.orNull(),
scheduledDate: Long = -1,
slideDeck: SlideDeck? = if (attachmentManager.isAttachmentPresent) attachmentManager.buildSlideDeck() else null,
contacts: List<Contact> = emptyList(),
clearCompose: Boolean = true
) {
val metricId = viewModel.recipientSnapshot?.let { if (it.isGroup == true) SignalLocalMetrics.GroupMessageSend.start() else SignalLocalMetrics.IndividualMessageSend.start() }
val send: Completable = viewModel.sendMessage(
metricId = metricId,
body = composeText.editableText.toString(),
body = body,
slideDeck = slideDeck,
scheduledDate = scheduledDate,
messageToEdit = inputPanel.editMessageId,
quote = inputPanel.quote.orNull(),
mentions = composeText.mentions,
bodyRanges = composeText.styling
messageToEdit = messageToEdit,
quote = quote,
mentions = mentions,
bodyRanges = bodyRanges,
contacts = contacts
)
disposables += send
.doOnSubscribe {
composeText.setText("")
inputPanel.clearQuote()
if (clearCompose) {
composeText.setText("")
inputPanel.clearQuote()
}
}
.subscribeBy(
onError = { t ->
@@ -929,7 +1031,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
// todo [cfv2] updateLinkPreviewState();
// todo [cfv2] draftViewModel.onSendComplete(threadId);
draftViewModel.onSendComplete()
inputPanel.exitEditMessageMode()
}
@@ -2067,6 +2169,23 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
}
// endregion Conversation Callbacks
//region Activity Results Callbacks
private inner class ActivityResultCallbacks : ConversationActivityResultContracts.Callbacks {
override fun onSendContacts(contacts: List<Contact>) {
sendMessageWithoutComposeInput(
contacts = contacts,
clearCompose = false
)
}
override fun onMediaSend(result: MediaSendActivityResult) {
// TODO [cfv2] media send
}
}
//endregion
private class LastSeenPositionUpdater(
val adapter: ConversationAdapterV2,
val layoutManager: LinearLayoutManager,
@@ -2282,13 +2401,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
private inner class SendButtonListener : View.OnClickListener, OnEditorActionListener {
override fun onClick(v: View) {
val metricId = if (viewModel.recipientSnapshot?.isGroup == true) {
SignalLocalMetrics.GroupMessageSend.start()
} else {
SignalLocalMetrics.IndividualMessageSend.start()
}
sendMessage(metricId)
sendMessage()
}
override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent): Boolean {
@@ -2312,6 +2425,9 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
ComposeText.CursorPositionChangedListener {
private var beforeLength = 0
private var previousText = ""
var typingStatusEnabled = true
override fun onKey(v: View, keyCode: Int, event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
@@ -2352,11 +2468,114 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
// todo [cfv2] linkPreviewViewModel.onTextChanged(requireContext(), composeText.getTextTrimmed().toString(), start, end);
}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) = Unit
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
handleSaveDraftOnTextChange(composeText.textTrimmed)
handleTypingIndicatorOnTextChange(s.toString())
}
private fun handleSaveDraftOnTextChange(text: CharSequence) {
textDraftSaveDebouncer.publish {
if (inputPanel.inEditMessageMode()) {
draftViewModel.setMessageEditDraft(inputPanel.editMessageId!!, text.toString(), MentionAnnotation.getMentionsFromAnnotations(text), getStyling(text))
} else {
draftViewModel.setTextDraft(text.toString(), MentionAnnotation.getMentionsFromAnnotations(text), getStyling(text))
}
}
}
private fun handleTypingIndicatorOnTextChange(text: String) {
val recipient = viewModel.recipientSnapshot
if (recipient == null || !typingStatusEnabled || recipient.isBlocked || recipient.isSelf) {
return
}
val typingStatusSender = ApplicationDependencies.getTypingStatusSender()
if (text.length == 0) {
typingStatusSender.onTypingStoppedWithNotify(args.threadId)
} else if (text.length < previousText.length && previousText.contains(text)) {
typingStatusSender.onTypingStopped(args.threadId)
} else {
typingStatusSender.onTypingStarted(args.threadId)
}
previousText = text
}
}
//endregion Compose + Send Callbacks
//region Input Panel Callbacks
private inner class InputPanelListener : InputPanel.Listener {
override fun onVoiceNoteDraftPlay(audioUri: Uri, progress: Double) {
// TODO [cfv2] Not yet implemented
}
override fun onVoiceNoteDraftSeekTo(audioUri: Uri, progress: Double) {
// TODO [cfv2] Not yet implemented
}
override fun onVoiceNoteDraftPause(audioUri: Uri) {
// TODO [cfv2] Not yet implemented
}
override fun onVoiceNoteDraftDelete(audioUri: Uri) {
// TODO [cfv2] Not yet implemented
}
override fun onRecorderStarted() {
// TODO [cfv2] Not yet implemented
}
override fun onRecorderLocked() {
// TODO [cfv2] Not yet implemented
}
override fun onRecorderFinished() {
// TODO [cfv2] Not yet implemented
}
override fun onRecorderCanceled(byUser: Boolean) {
// TODO [cfv2] Not yet implemented
}
override fun onRecorderPermissionRequired() {
// TODO [cfv2] Not yet implemented
}
override fun onEmojiToggle() {
// TODO [cfv2] Not yet implemented
}
override fun onLinkPreviewCanceled() {
// TODO [cfv2] Not yet implemented
}
override fun onStickerSuggestionSelected(sticker: StickerRecord) {
// TODO [cfv2] Not yet implemented
}
override fun onQuoteChanged(id: Long, author: RecipientId) {
draftViewModel.setQuoteDraft(id, author)
}
override fun onQuoteCleared() {
draftViewModel.clearQuoteDraft()
}
override fun onEnterEditMode() {
// TODO [cfv2] Not yet implemented
}
override fun onExitEditMode() {
updateToggleButtonState()
draftViewModel.deleteMessageEditDraft()
}
}
//endregion
//region Attachment + Media Keyboard
private inner class AttachmentManagerListener : AttachmentManager.AttachmentListener {
@@ -2365,7 +2584,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
}
override fun onLocationRemoved() {
// TODO [cfv2] implement
draftViewModel.clearLocationDraft()
}
}

View File

@@ -169,7 +169,8 @@ class ConversationRepository(
messageToEdit: MessageId?,
quote: QuoteModel?,
mentions: List<Mention>,
bodyRanges: BodyRangeList?
bodyRanges: BodyRangeList?,
contacts: List<Contact>
): Completable {
val sendCompletable = Completable.create { emitter ->
if (body.isEmpty() && slideDeck?.containsMediaSlide() != true) {
@@ -193,7 +194,8 @@ class ConversationRepository(
scheduledDate = scheduledDate,
outgoingQuote = quote,
messageToEdit = messageToEdit?.id ?: 0,
mentions = mentions
mentions = mentions,
sharedContacts = contacts
)
MessageSender.send(

View File

@@ -28,6 +28,7 @@ import org.signal.core.util.concurrent.subscribeWithSubject
import org.signal.core.util.orNull
import org.signal.paging.ProxyPagingController
import org.thoughtcrime.securesms.components.reminder.Reminder
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor
@@ -42,6 +43,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
@@ -117,11 +119,6 @@ class ConversationViewModel(
recipientSnapshot = it
}
disposables += recipientRepository
.conversationRecipient
.skip(1) // We can safely skip the first emission since this is used for updating the header on future changes
.subscribeBy { pagingController.onDataItemChanged(ConversationElementKey.threadHeader) }
disposables += repository.getConversationThreadState(threadId, requestedStartingPosition)
.subscribeBy(onSuccess = {
pagingController.set(it.items.controller)
@@ -153,6 +150,18 @@ class ConversationViewModel(
}
}.subscribeOn(Schedulers.io()).subscribe()
recipientRepository
.conversationRecipient
.filter { it.isRegistered }
.take(1)
.subscribeBy { RetrieveProfileJob.enqueue(it.id) }
.addTo(disposables)
disposables += recipientRepository
.conversationRecipient
.skip(1) // We can safely skip the first emission since this is used for updating the header on future changes
.subscribeBy { pagingController.onDataItemChanged(ConversationElementKey.threadHeader) }
disposables += scrollButtonStateStore.update(
repository.getMessageCounts(threadId)
) { counts, state ->
@@ -250,7 +259,8 @@ class ConversationViewModel(
messageToEdit: MessageId?,
quote: QuoteModel?,
mentions: List<Mention>,
bodyRanges: BodyRangeList?
bodyRanges: BodyRangeList?,
contacts: List<Contact>
): Completable {
return repository.sendMessage(
threadId = threadId,
@@ -262,7 +272,8 @@ class ConversationViewModel(
messageToEdit = messageToEdit,
quote = quote,
mentions = mentions,
bodyRanges = bodyRanges
bodyRanges = bodyRanges,
contacts = contacts
).observeOn(AndroidSchedulers.mainThread())
}

View File

@@ -57,15 +57,15 @@ public class AudioSlide extends Slide {
}
public AudioSlide(Context context, Uri uri, long dataSize, boolean voiceNote) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, null, null, null, null, voiceNote, false, false, false));
super(constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, null, null, null, null, voiceNote, false, false, false));
}
public AudioSlide(Context context, Uri uri, long dataSize, String contentType, boolean voiceNote) {
super(context, new UriAttachment(uri, contentType, AttachmentTable.TRANSFER_PROGRESS_STARTED, dataSize, 0, 0, null, null, voiceNote, false, false, false, null, null, null, null, null));
super(new UriAttachment(uri, contentType, AttachmentTable.TRANSFER_PROGRESS_STARTED, dataSize, 0, 0, null, null, voiceNote, false, false, false, null, null, null, null, null));
}
public AudioSlide(Context context, Attachment attachment) {
super(context, attachment);
super(attachment);
}
@Override
@@ -85,7 +85,7 @@ public class AudioSlide extends Slide {
@NonNull
@Override
public String getContentDescription() {
public String getContentDescription(Context context) {
return context.getString(R.string.Slide_audio);
}

View File

@@ -13,14 +13,14 @@ import org.thoughtcrime.securesms.util.StorageUtil;
public class DocumentSlide extends Slide {
public DocumentSlide(@NonNull Context context, @NonNull Attachment attachment) {
super(context, attachment);
super(attachment);
}
public DocumentSlide(@NonNull Context context, @NonNull Uri uri,
@NonNull String contentType, long size,
@Nullable String fileName)
{
super(context, constructAttachmentFromUri(context, uri, contentType, size, 0, 0, true, StorageUtil.getCleanFileName(fileName), null, null, null, null, false, false, false, false));
super(constructAttachmentFromUri(context, uri, contentType, size, 0, 0, true, StorageUtil.getCleanFileName(fileName), null, null, null, null, false, false, false, false));
}
@Override

View File

@@ -12,8 +12,8 @@ public class GifSlide extends ImageSlide {
private final boolean borderless;
public GifSlide(Context context, Attachment attachment) {
super(context, attachment);
public GifSlide(Attachment attachment) {
super(attachment);
this.borderless = attachment.isBorderless();
}
@@ -22,22 +22,22 @@ public class GifSlide extends ImageSlide {
}
public GifSlide(Context context, Uri uri, long size, int width, int height, boolean borderless, @Nullable String caption) {
super(context, constructAttachmentFromUri(context,
uri,
MediaUtil.IMAGE_GIF,
size,
width,
height,
true,
null,
caption,
null,
null,
null,
false,
borderless,
true,
false));
super(constructAttachmentFromUri(context,
uri,
MediaUtil.IMAGE_GIF,
size,
width,
height,
true,
null,
caption,
null,
null,
null,
false,
borderless,
true,
false));
this.borderless = borderless;
}

View File

@@ -38,8 +38,8 @@ public class ImageSlide extends Slide {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ImageSlide.class);
public ImageSlide(@NonNull Context context, @NonNull Attachment attachment) {
super(context, attachment);
public ImageSlide(@NonNull Attachment attachment) {
super(attachment);
this.borderless = attachment.isBorderless();
}
@@ -52,7 +52,7 @@ public class ImageSlide extends Slide {
}
public ImageSlide(Context context, Uri uri, String contentType, long size, int width, int height, boolean borderless, @Nullable String caption, @Nullable BlurHash blurHash, @Nullable TransformProperties transformProperties) {
super(context, constructAttachmentFromUri(context, uri, contentType, size, width, height, true, null, caption, null, blurHash, null, false, borderless, false, false, transformProperties));
super(constructAttachmentFromUri(context, uri, contentType, size, width, height, true, null, caption, null, blurHash, null, false, borderless, false, false, transformProperties));
this.borderless = borderless;
}
@@ -78,7 +78,7 @@ public class ImageSlide extends Slide {
@NonNull
@Override
public String getContentDescription() {
public String getContentDescription(Context context) {
return context.getString(R.string.Slide_image);
}
}

View File

@@ -10,12 +10,12 @@ import org.thoughtcrime.securesms.attachments.Attachment;
public class MmsSlide extends ImageSlide {
public MmsSlide(@NonNull Context context, @NonNull Attachment attachment) {
super(context, attachment);
super(attachment);
}
@NonNull
@Override
public String getContentDescription() {
public String getContentDescription(Context context) {
return "MMS";
}

View File

@@ -40,10 +40,8 @@ import java.util.Optional;
public abstract class Slide {
protected final Attachment attachment;
protected final Context context;
public Slide(@NonNull Context context, @NonNull Attachment attachment) {
this.context = context;
public Slide(@NonNull Attachment attachment) {
this.attachment = attachment;
}
@@ -122,7 +120,7 @@ public abstract class Slide {
return hasVideo() && attachment.isVideoGif();
}
public @NonNull String getContentDescription() { return ""; }
public @NonNull String getContentDescription(@NonNull Context context) { return ""; }
public @NonNull Attachment asAttachment() {
return attachment;

View File

@@ -21,13 +21,13 @@ public class StickerSlide extends Slide {
private final StickerLocator stickerLocator;
public StickerSlide(@NonNull Context context, @NonNull Attachment attachment) {
super(context, attachment);
public StickerSlide(@NonNull Attachment attachment) {
super(attachment);
this.stickerLocator = Objects.requireNonNull(attachment.getSticker());
}
public StickerSlide(Context context, Uri uri, long size, @NonNull StickerLocator stickerLocator, @NonNull String contentType) {
super(context, constructAttachmentFromUri(context, uri, contentType, size, WIDTH, HEIGHT, true, null, null, stickerLocator, null, null, false, false, false, false));
super(constructAttachmentFromUri(context, uri, contentType, size, WIDTH, HEIGHT, true, null, null, stickerLocator, null, null, false, false, false, false));
this.stickerLocator = Objects.requireNonNull(attachment.getSticker());
}
@@ -47,7 +47,7 @@ public class StickerSlide extends Slide {
}
@Override
public @NonNull String getContentDescription() {
public @NonNull String getContentDescription(Context context) {
return context.getString(R.string.Slide_sticker);
}

View File

@@ -13,10 +13,10 @@ import org.thoughtcrime.securesms.util.MediaUtil;
public class TextSlide extends Slide {
public TextSlide(@NonNull Context context, @NonNull Attachment attachment) {
super(context, attachment);
super(attachment);
}
public TextSlide(@NonNull Context context, @NonNull Uri uri, @Nullable String filename, long size) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.LONG_TEXT, size, 0, 0, true, filename, null, null, null, null, false, false, false, false));
super(constructAttachmentFromUri(context, uri, MediaUtil.LONG_TEXT, size, 0, 0, true, filename, null, null, null, null, false, false, false, false));
}
}

View File

@@ -38,15 +38,15 @@ public class VideoSlide extends Slide {
}
public VideoSlide(Context context, Uri uri, long dataSize, boolean gif, @Nullable String caption, @Nullable AttachmentTable.TransformProperties transformProperties) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, 0, 0, MediaUtil.hasVideoThumbnail(context, uri), null, caption, null, null, null, false, false, gif, false, transformProperties));
super(constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, 0, 0, MediaUtil.hasVideoThumbnail(context, uri), null, caption, null, null, null, false, false, gif, false, transformProperties));
}
public VideoSlide(Context context, Uri uri, long dataSize, boolean gif, int width, int height, @Nullable String caption, @Nullable AttachmentTable.TransformProperties transformProperties) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, width, height, MediaUtil.hasVideoThumbnail(context, uri), null, caption, null, null, null, false, false, gif, false, transformProperties));
super(constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, width, height, MediaUtil.hasVideoThumbnail(context, uri), null, caption, null, null, null, false, false, gif, false, transformProperties));
}
public VideoSlide(Context context, Attachment attachment) {
super(context, attachment);
super(attachment);
}
@Override
@@ -75,7 +75,7 @@ public class VideoSlide extends Slide {
}
@NonNull @Override
public String getContentDescription() {
public String getContentDescription(Context context) {
return context.getString(R.string.Slide_video);
}
}

View File

@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.util.MediaUtil;
public class ViewOnceSlide extends Slide {
public ViewOnceSlide(@NonNull Context context, @NonNull Attachment attachment) {
super(context, attachment);
super(attachment);
}
@Override

View File

@@ -116,7 +116,7 @@ class StoryLinkPreviewView @JvmOverloads constructor(
notImage.visible = false
val imageSlide: Slide? = linkPreview.thumbnail.map { ImageSlide(context, it) }.orElse(null)
val imageSlide: Slide? = linkPreview.thumbnail.map { ImageSlide(it) }.orElse(null)
if (imageSlide != null) {
if (loadThumbnail) {
future = image.setImageResource(

View File

@@ -92,12 +92,12 @@ public class MediaUtil {
public static @NonNull Slide getSlideForAttachment(Context context, Attachment attachment) {
if (attachment.isSticker()) {
return new StickerSlide(context, attachment);
return new StickerSlide(attachment);
}
switch (getSlideTypeFromContentType(attachment.getContentType())) {
case GIF : return new GifSlide(context, attachment);
case IMAGE : return new ImageSlide(context, attachment);
case GIF : return new GifSlide(attachment);
case IMAGE : return new ImageSlide(attachment);
case VIDEO : return new VideoSlide(context, attachment);
case AUDIO : return new AudioSlide(context, attachment);
case MMS : return new MmsSlide(context, attachment);