kopia lustrzana https://github.com/ryukoposting/Signal-Android
Add sending and error states for story group replies.
rodzic
a29bc1da8c
commit
2a91c67c51
|
@ -1049,7 +1049,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||||
Runnable deleteForEveryone = () -> {
|
Runnable deleteForEveryone = () -> {
|
||||||
SignalExecutors.BOUNDED.execute(() -> {
|
SignalExecutors.BOUNDED.execute(() -> {
|
||||||
for (MessageRecord message : messageRecords) {
|
for (MessageRecord message : messageRecords) {
|
||||||
MessageSender.sendRemoteDelete(ApplicationDependencies.getApplication(), message.getId(), message.isMms());
|
MessageSender.sendRemoteDelete(message.getId(), message.isMms());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,6 +10,7 @@ import com.annimon.stream.Collectors;
|
||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
import com.google.protobuf.ByteString;
|
import com.google.protobuf.ByteString;
|
||||||
|
|
||||||
|
import org.signal.core.util.SetUtil;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.signal.libsignal.protocol.util.Pair;
|
import org.signal.libsignal.protocol.util.Pair;
|
||||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||||
|
@ -45,7 +46,6 @@ import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||||
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
|
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
|
||||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||||
import org.thoughtcrime.securesms.util.RecipientAccessList;
|
import org.thoughtcrime.securesms.util.RecipientAccessList;
|
||||||
import org.signal.core.util.SetUtil;
|
|
||||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.whispersystems.signalservice.api.crypto.ContentHint;
|
import org.whispersystems.signalservice.api.crypto.ContentHint;
|
||||||
|
@ -65,7 +65,6 @@ import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
@ -337,8 +336,6 @@ public final class PushGroupSendJob extends PushSendJob {
|
||||||
groupMessageBuilder.withBody(null);
|
groupMessageBuilder.withBody(null);
|
||||||
}
|
}
|
||||||
} catch (NoSuchMessageException e) {
|
} catch (NoSuchMessageException e) {
|
||||||
// The story has probably expired
|
|
||||||
// TODO [stories] check what should happen in this case
|
|
||||||
throw new UndeliverableMessageException(e);
|
throw new UndeliverableMessageException(e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -239,8 +239,6 @@ public class PushMediaSendJob extends PushSendJob {
|
||||||
mediaMessageBuilder.withBody(null);
|
mediaMessageBuilder.withBody(null);
|
||||||
}
|
}
|
||||||
} catch (NoSuchMessageException e) {
|
} catch (NoSuchMessageException e) {
|
||||||
// The story has probably expired
|
|
||||||
// TODO [stories] check what should happen in this case
|
|
||||||
throw new UndeliverableMessageException(e);
|
throw new UndeliverableMessageException(e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -469,7 +469,7 @@ public class MessageSender {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void sendRemoteDelete(@NonNull Context context, long messageId, boolean isMms) {
|
public static void sendRemoteDelete(long messageId, boolean isMms) {
|
||||||
MessageDatabase db = isMms ? SignalDatabase.mms() : SignalDatabase.sms();
|
MessageDatabase db = isMms ? SignalDatabase.mms() : SignalDatabase.sms();
|
||||||
db.markAsRemoteDelete(messageId);
|
db.markAsRemoteDelete(messageId);
|
||||||
db.markAsSending(messageId);
|
db.markAsSending(messageId);
|
||||||
|
|
|
@ -45,7 +45,8 @@ object StoryContextMenu {
|
||||||
val uri: Uri? = mediaMessageRecord?.slideDeck?.firstSlide?.uri
|
val uri: Uri? = mediaMessageRecord?.slideDeck?.firstSlide?.uri
|
||||||
val contentType: String? = mediaMessageRecord?.slideDeck?.firstSlide?.contentType
|
val contentType: String? = mediaMessageRecord?.slideDeck?.firstSlide?.contentType
|
||||||
if (uri == null || contentType == null) {
|
if (uri == null || contentType == null) {
|
||||||
// TODO [stories] Toast that we can't save this media
|
Log.w(TAG, "Unable to save story media uri: $uri contentType: $contentType")
|
||||||
|
Toast.makeText(context, R.string.MyStories__unable_to_save, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -176,7 +177,6 @@ object StoryContextMenu {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// TODO [stories] -- Final icon
|
|
||||||
add(
|
add(
|
||||||
ActionItem(R.drawable.ic_check_circle_24, context.getString(R.string.StoriesLandingItem__unhide_story)) {
|
ActionItem(R.drawable.ic_check_circle_24, context.getString(R.string.StoriesLandingItem__unhide_story)) {
|
||||||
callbacks.onUnhide()
|
callbacks.onUnhide()
|
||||||
|
|
|
@ -56,16 +56,6 @@ class PrivateStorySettingsFragment : DSLSettingsFragment(
|
||||||
}
|
}
|
||||||
|
|
||||||
return configure {
|
return configure {
|
||||||
customPref(
|
|
||||||
PrivateStoryItem.Model(
|
|
||||||
privateStoryItemData = state.privateStory,
|
|
||||||
onClick = {
|
|
||||||
// TODO [stories] -- is this even clickable?
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dividerPref()
|
|
||||||
sectionHeaderPref(R.string.MyStorySettingsFragment__who_can_see_this_story)
|
sectionHeaderPref(R.string.MyStorySettingsFragment__who_can_see_this_story)
|
||||||
customPref(
|
customPref(
|
||||||
PrivateStoryItem.AddViewerModel(
|
PrivateStoryItem.AddViewerModel(
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
package org.thoughtcrime.securesms.stories.viewer.reply.group
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||||
|
import org.thoughtcrime.securesms.database.model.MessageId
|
||||||
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
|
||||||
|
sealed class ReplyBody(val messageRecord: MessageRecord) {
|
||||||
|
|
||||||
|
val key: MessageId = MessageId(messageRecord.id, true)
|
||||||
|
val sender: Recipient = if (messageRecord.isOutgoing) Recipient.self() else messageRecord.individualRecipient.resolve()
|
||||||
|
val sentAtMillis: Long = messageRecord.dateSent
|
||||||
|
|
||||||
|
open fun hasSameContent(other: ReplyBody): Boolean {
|
||||||
|
return key == other.key &&
|
||||||
|
sender.hasSameContent(other.sender) &&
|
||||||
|
sentAtMillis == other.sentAtMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
class Text(val message: ConversationMessage) : ReplyBody(message.messageRecord) {
|
||||||
|
override fun hasSameContent(other: ReplyBody): Boolean {
|
||||||
|
return super.hasSameContent(other) &&
|
||||||
|
(other as? Text)?.let { messageRecord.body == other.messageRecord.body } ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Reaction(messageRecord: MessageRecord) : ReplyBody(messageRecord) {
|
||||||
|
val emoji: CharSequence = messageRecord.body
|
||||||
|
|
||||||
|
override fun hasSameContent(other: ReplyBody): Boolean {
|
||||||
|
return super.hasSameContent(other) &&
|
||||||
|
(other as? Reaction)?.let { emoji == other.emoji } ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteDelete(messageRecord: MessageRecord) : ReplyBody(messageRecord)
|
||||||
|
}
|
|
@ -5,17 +5,17 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||||
import org.thoughtcrime.securesms.database.MmsSmsColumns
|
import org.thoughtcrime.securesms.database.MmsSmsColumns
|
||||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.model.MessageId
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
|
||||||
|
|
||||||
class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSource<StoryGroupReplyItemData.Key, StoryGroupReplyItemData> {
|
class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSource<MessageId, ReplyBody> {
|
||||||
override fun size(): Int {
|
override fun size(): Int {
|
||||||
return SignalDatabase.mms.getNumberOfStoryReplies(parentStoryId)
|
return SignalDatabase.mms.getNumberOfStoryReplies(parentStoryId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<StoryGroupReplyItemData> {
|
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ReplyBody> {
|
||||||
val results: MutableList<StoryGroupReplyItemData> = ArrayList(length)
|
val results: MutableList<ReplyBody> = ArrayList(length)
|
||||||
SignalDatabase.mms.getStoryReplies(parentStoryId).use { cursor ->
|
SignalDatabase.mms.getStoryReplies(parentStoryId).use { cursor ->
|
||||||
cursor.moveToPosition(start - 1)
|
cursor.moveToPosition(start - 1)
|
||||||
val reader = MmsDatabase.Reader(cursor)
|
val reader = MmsDatabase.Reader(cursor)
|
||||||
|
@ -27,48 +27,21 @@ class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSour
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun load(key: StoryGroupReplyItemData.Key?): StoryGroupReplyItemData? {
|
override fun load(key: MessageId): ReplyBody {
|
||||||
throw UnsupportedOperationException()
|
return readRowFromRecord(SignalDatabase.mms.getMessageRecord(key.id) as MmsMessageRecord)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getKey(data: StoryGroupReplyItemData): StoryGroupReplyItemData.Key {
|
override fun getKey(data: ReplyBody): MessageId {
|
||||||
return data.key
|
return data.key
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readRowFromRecord(record: MmsMessageRecord): StoryGroupReplyItemData {
|
private fun readRowFromRecord(record: MmsMessageRecord): ReplyBody {
|
||||||
return when {
|
return when {
|
||||||
record.isRemoteDelete -> readRemoteDeleteFromRecord(record)
|
record.isRemoteDelete -> ReplyBody.RemoteDelete(record)
|
||||||
MmsSmsColumns.Types.isStoryReaction(record.type) -> readReactionFromRecord(record)
|
MmsSmsColumns.Types.isStoryReaction(record.type) -> ReplyBody.Reaction(record)
|
||||||
else -> readTextFromRecord(record)
|
else -> ReplyBody.Text(
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readRemoteDeleteFromRecord(record: MmsMessageRecord): StoryGroupReplyItemData {
|
|
||||||
return StoryGroupReplyItemData(
|
|
||||||
key = StoryGroupReplyItemData.Key.RemoteDelete(record.id),
|
|
||||||
sender = if (record.isOutgoing) Recipient.self() else record.individualRecipient.resolve(),
|
|
||||||
sentAtMillis = record.dateSent,
|
|
||||||
replyBody = StoryGroupReplyItemData.ReplyBody.RemoteDelete(record)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readReactionFromRecord(record: MmsMessageRecord): StoryGroupReplyItemData {
|
|
||||||
return StoryGroupReplyItemData(
|
|
||||||
key = StoryGroupReplyItemData.Key.Reaction(record.id),
|
|
||||||
sender = if (record.isOutgoing) Recipient.self() else record.individualRecipient.resolve(),
|
|
||||||
sentAtMillis = record.dateSent,
|
|
||||||
replyBody = StoryGroupReplyItemData.ReplyBody.Reaction(record.body)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readTextFromRecord(record: MmsMessageRecord): StoryGroupReplyItemData {
|
|
||||||
return StoryGroupReplyItemData(
|
|
||||||
key = StoryGroupReplyItemData.Key.Text(record.id),
|
|
||||||
sender = if (record.isOutgoing) Recipient.self() else record.individualRecipient.resolve(),
|
|
||||||
sentAtMillis = record.dateSent,
|
|
||||||
replyBody = StoryGroupReplyItemData.ReplyBody.Text(
|
|
||||||
ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(ApplicationDependencies.getApplication(), record)
|
ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(ApplicationDependencies.getApplication(), record)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,12 +6,14 @@ import android.view.KeyEvent
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehaviorHack
|
import com.google.android.material.bottomsheet.BottomSheetBehaviorHack
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||||
import org.signal.core.util.concurrent.SignalExecutors
|
import org.signal.core.util.concurrent.SignalExecutors
|
||||||
|
@ -28,6 +30,8 @@ import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
|
||||||
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerFragment
|
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerFragment
|
||||||
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel
|
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel
|
||||||
import org.thoughtcrime.securesms.database.model.Mention
|
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.dependencies.ApplicationDependencies
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
|
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
|
||||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage
|
import org.thoughtcrime.securesms.keyboard.KeyboardPage
|
||||||
|
@ -39,6 +43,7 @@ import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDial
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
|
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
|
||||||
|
import org.thoughtcrime.securesms.sms.MessageSender
|
||||||
import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerChild
|
import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerChild
|
||||||
import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerParent
|
import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerParent
|
||||||
import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReactionBar
|
import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReactionBar
|
||||||
|
@ -64,6 +69,26 @@ class StoryGroupReplyFragment :
|
||||||
ReactWithAnyEmojiBottomSheetDialogFragment.Callback,
|
ReactWithAnyEmojiBottomSheetDialogFragment.Callback,
|
||||||
SafetyNumberChangeDialog.Callback {
|
SafetyNumberChangeDialog.Callback {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = Log.tag(StoryGroupReplyFragment::class.java)
|
||||||
|
|
||||||
|
private const val ARG_STORY_ID = "arg.story.id"
|
||||||
|
private const val ARG_GROUP_RECIPIENT_ID = "arg.group.recipient.id"
|
||||||
|
private const val ARG_IS_FROM_NOTIFICATION = "is_from_notification"
|
||||||
|
private const val ARG_GROUP_REPLY_START_POSITION = "group_reply_start_position"
|
||||||
|
|
||||||
|
fun create(storyId: Long, groupRecipientId: RecipientId, isFromNotification: Boolean, groupReplyStartPosition: Int): Fragment {
|
||||||
|
return StoryGroupReplyFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putLong(ARG_STORY_ID, storyId)
|
||||||
|
putParcelable(ARG_GROUP_RECIPIENT_ID, groupRecipientId)
|
||||||
|
putBoolean(ARG_IS_FROM_NOTIFICATION, isFromNotification)
|
||||||
|
putInt(ARG_GROUP_REPLY_START_POSITION, groupReplyStartPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val viewModel: StoryGroupReplyViewModel by viewModels(
|
private val viewModel: StoryGroupReplyViewModel by viewModels(
|
||||||
factoryProducer = {
|
factoryProducer = {
|
||||||
StoryGroupReplyViewModel.Factory(storyId, StoryGroupReplyRepository())
|
StoryGroupReplyViewModel.Factory(storyId, StoryGroupReplyRepository())
|
||||||
|
@ -107,7 +132,7 @@ class StoryGroupReplyFragment :
|
||||||
get() = requireArguments().getInt(ARG_GROUP_REPLY_START_POSITION, -1)
|
get() = requireArguments().getInt(ARG_GROUP_REPLY_START_POSITION, -1)
|
||||||
|
|
||||||
private lateinit var recyclerView: RecyclerView
|
private lateinit var recyclerView: RecyclerView
|
||||||
private lateinit var adapter: PagingMappingAdapter<StoryGroupReplyItemData.Key>
|
private lateinit var adapter: PagingMappingAdapter<MessageId>
|
||||||
private lateinit var dataObserver: RecyclerView.AdapterDataObserver
|
private lateinit var dataObserver: RecyclerView.AdapterDataObserver
|
||||||
private lateinit var composer: StoryReplyComposer
|
private lateinit var composer: StoryReplyComposer
|
||||||
|
|
||||||
|
@ -131,7 +156,10 @@ class StoryGroupReplyFragment :
|
||||||
|
|
||||||
val emptyNotice: View = requireView().findViewById(R.id.empty_notice)
|
val emptyNotice: View = requireView().findViewById(R.id.empty_notice)
|
||||||
|
|
||||||
adapter = PagingMappingAdapter<StoryGroupReplyItemData.Key>()
|
adapter = PagingMappingAdapter<MessageId>().apply {
|
||||||
|
setPagingController(viewModel.pagingController)
|
||||||
|
}
|
||||||
|
|
||||||
val layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
|
val layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
|
||||||
recyclerView.layoutManager = layoutManager
|
recyclerView.layoutManager = layoutManager
|
||||||
recyclerView.adapter = adapter
|
recyclerView.adapter = adapter
|
||||||
|
@ -142,37 +170,34 @@ class StoryGroupReplyFragment :
|
||||||
|
|
||||||
onPageSelected(findListener<StoryViewsAndRepliesPagerParent>()?.selectedChild ?: StoryViewsAndRepliesPagerParent.Child.REPLIES)
|
onPageSelected(findListener<StoryViewsAndRepliesPagerParent>()?.selectedChild ?: StoryViewsAndRepliesPagerParent.Child.REPLIES)
|
||||||
|
|
||||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
var firstSubmit = true
|
||||||
if (markReadHelper == null && state.threadId > 0L) {
|
|
||||||
if (isResumed) {
|
viewModel.state
|
||||||
ApplicationDependencies.getMessageNotifier().setVisibleThread(ConversationId(state.threadId, storyId))
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribeBy { state ->
|
||||||
|
if (markReadHelper == null && state.threadId > 0L) {
|
||||||
|
if (isResumed) {
|
||||||
|
ApplicationDependencies.getMessageNotifier().setVisibleThread(ConversationId(state.threadId, storyId))
|
||||||
|
}
|
||||||
|
|
||||||
|
markReadHelper = MarkReadHelper(ConversationId(state.threadId, storyId), requireContext(), viewLifecycleOwner)
|
||||||
|
|
||||||
|
if (isFromNotification) {
|
||||||
|
markReadHelper?.onViewsRevealed(System.currentTimeMillis())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
markReadHelper = MarkReadHelper(ConversationId(state.threadId, storyId), requireContext(), viewLifecycleOwner)
|
emptyNotice.visible = state.noReplies && state.loadState == StoryGroupReplyState.LoadState.READY
|
||||||
|
colorizer.onNameColorsChanged(state.nameColors)
|
||||||
|
|
||||||
if (isFromNotification) {
|
adapter.submitList(getConfiguration(state.replies).toMappingModelList()) {
|
||||||
markReadHelper?.onViewsRevealed(System.currentTimeMillis())
|
if (firstSubmit && (groupReplyStartPosition >= 0 && adapter.hasItem(groupReplyStartPosition))) {
|
||||||
|
firstSubmit = false
|
||||||
|
recyclerView.post { recyclerView.scrollToPosition(groupReplyStartPosition) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emptyNotice.visible = state.noReplies && state.loadState == StoryGroupReplyState.LoadState.READY
|
|
||||||
colorizer.onNameColorsChanged(state.nameColors)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.pagingController.observe(viewLifecycleOwner) { controller ->
|
|
||||||
adapter.setPagingController(controller)
|
|
||||||
}
|
|
||||||
|
|
||||||
var consumed = false
|
|
||||||
viewModel.pageData.observe(viewLifecycleOwner) { pageData ->
|
|
||||||
adapter.submitList(getConfiguration(pageData).toMappingModelList()) {
|
|
||||||
if (!consumed && (groupReplyStartPosition >= 0 && adapter.hasItem(groupReplyStartPosition))) {
|
|
||||||
consumed = true
|
|
||||||
recyclerView.post { recyclerView.scrollToPosition(groupReplyStartPosition) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dataObserver = GroupDataObserver()
|
dataObserver = GroupDataObserver()
|
||||||
adapter.registerAdapterDataObserver(dataObserver)
|
adapter.registerAdapterDataObserver(dataObserver)
|
||||||
|
|
||||||
|
@ -212,74 +237,51 @@ class StoryGroupReplyFragment :
|
||||||
|
|
||||||
val lastVisibleItem = (recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
|
val lastVisibleItem = (recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
|
||||||
val adapterItem = adapter.getItem(lastVisibleItem)
|
val adapterItem = adapter.getItem(lastVisibleItem)
|
||||||
if (adapterItem == null || adapterItem !is StoryGroupReplyItem.DataWrapper) {
|
if (adapterItem == null || adapterItem !is StoryGroupReplyItem.Model) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
markReadHelper?.onViewsRevealed(adapterItem.storyGroupReplyItemData.sentAtMillis)
|
markReadHelper?.onViewsRevealed(adapterItem.replyBody.sentAtMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getConfiguration(pageData: List<StoryGroupReplyItemData>): DSLConfiguration {
|
private fun getConfiguration(pageData: List<ReplyBody>): DSLConfiguration {
|
||||||
return configure {
|
return configure {
|
||||||
pageData.forEach {
|
pageData.forEach {
|
||||||
when (it.replyBody) {
|
when (it) {
|
||||||
is StoryGroupReplyItemData.ReplyBody.Text -> {
|
is ReplyBody.Text -> {
|
||||||
customPref(
|
customPref(
|
||||||
StoryGroupReplyItem.TextModel(
|
StoryGroupReplyItem.TextModel(
|
||||||
storyGroupReplyItemData = it,
|
text = it,
|
||||||
text = it.replyBody,
|
nameColor = it.sender.getStoryGroupReplyColor(),
|
||||||
nameColor = colorizer.getIncomingGroupSenderColor(
|
onCopyClick = { s -> onCopyClick(s) },
|
||||||
requireContext(),
|
|
||||||
it.sender
|
|
||||||
),
|
|
||||||
onCopyClick = { model ->
|
|
||||||
val clipData = ClipData.newPlainText(requireContext().getString(R.string.app_name), model.text.message.getDisplayBody(requireContext()))
|
|
||||||
ServiceUtil.getClipboardManager(requireContext()).setPrimaryClip(clipData)
|
|
||||||
Toast.makeText(requireContext(), R.string.StoryGroupReplyFragment__copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
|
||||||
},
|
|
||||||
onDeleteClick = { model ->
|
|
||||||
lifecycleDisposable += DeleteDialog.show(requireActivity(), setOf(model.text.message.messageRecord)).subscribe { result ->
|
|
||||||
if (result) {
|
|
||||||
throw AssertionError("We should never end up deleting a Group Thread like this.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMentionClick = { recipientId ->
|
onMentionClick = { recipientId ->
|
||||||
RecipientBottomSheetDialogFragment
|
RecipientBottomSheetDialogFragment
|
||||||
.create(recipientId, null)
|
.create(recipientId, null)
|
||||||
.show(childFragmentManager, null)
|
.show(childFragmentManager, null)
|
||||||
}
|
},
|
||||||
|
onDeleteClick = { m -> onDeleteClick(m) },
|
||||||
|
onTapForDetailsClick = { m -> onTapForDetailsClick(m) }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is StoryGroupReplyItemData.ReplyBody.Reaction -> {
|
is ReplyBody.Reaction -> {
|
||||||
customPref(
|
customPref(
|
||||||
StoryGroupReplyItem.ReactionModel(
|
StoryGroupReplyItem.ReactionModel(
|
||||||
storyGroupReplyItemData = it,
|
reaction = it,
|
||||||
reaction = it.replyBody,
|
nameColor = it.sender.getStoryGroupReplyColor(),
|
||||||
nameColor = colorizer.getIncomingGroupSenderColor(
|
onCopyClick = { s -> onCopyClick(s) },
|
||||||
requireContext(),
|
onDeleteClick = { m -> onDeleteClick(m) },
|
||||||
it.sender
|
onTapForDetailsClick = { m -> onTapForDetailsClick(m) }
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is StoryGroupReplyItemData.ReplyBody.RemoteDelete -> {
|
is ReplyBody.RemoteDelete -> {
|
||||||
customPref(
|
customPref(
|
||||||
StoryGroupReplyItem.RemoteDeleteModel(
|
StoryGroupReplyItem.RemoteDeleteModel(
|
||||||
storyGroupReplyItemData = it,
|
remoteDelete = it,
|
||||||
remoteDelete = it.replyBody,
|
nameColor = it.sender.getStoryGroupReplyColor(),
|
||||||
nameColor = colorizer.getIncomingGroupSenderColor(
|
onDeleteClick = { m -> onDeleteClick(m) },
|
||||||
requireContext(),
|
onTapForDetailsClick = { m -> onTapForDetailsClick(m) }
|
||||||
it.sender
|
|
||||||
),
|
|
||||||
onDeleteClick = { model ->
|
|
||||||
lifecycleDisposable += DeleteDialog.show(requireActivity(), setOf(model.remoteDelete.messageRecord)).subscribe { didDeleteThread ->
|
|
||||||
if (didDeleteThread) {
|
|
||||||
throw AssertionError("We should never end up deleting a Group Thread like this.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -288,6 +290,37 @@ class StoryGroupReplyFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onCopyClick(textToCopy: CharSequence) {
|
||||||
|
val clipData = ClipData.newPlainText(requireContext().getString(R.string.app_name), textToCopy)
|
||||||
|
ServiceUtil.getClipboardManager(requireContext()).setPrimaryClip(clipData)
|
||||||
|
Toast.makeText(requireContext(), R.string.StoryGroupReplyFragment__copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDeleteClick(messageRecord: MessageRecord) {
|
||||||
|
lifecycleDisposable += DeleteDialog.show(requireActivity(), setOf(messageRecord)).subscribe { didDeleteThread ->
|
||||||
|
if (didDeleteThread) {
|
||||||
|
throw AssertionError("We should never end up deleting a Group Thread like this.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTapForDetailsClick(messageRecord: MessageRecord) {
|
||||||
|
if (messageRecord.isRemoteDelete) {
|
||||||
|
// TODO [cody] Android doesn't support resending remote deletes yet
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageRecord.isIdentityMismatchFailure) {
|
||||||
|
SafetyNumberChangeDialog.show(requireContext(), childFragmentManager, messageRecord)
|
||||||
|
} else if (messageRecord.hasFailedWithNetworkFailures()) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setMessage(R.string.conversation_activity__message_could_not_be_sent)
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.setPositiveButton(R.string.conversation_activity__send) { _, _ -> SignalExecutors.BOUNDED.execute { MessageSender.resend(requireContext(), messageRecord) } }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPageSelected(child: StoryViewsAndRepliesPagerParent.Child) {
|
override fun onPageSelected(child: StoryViewsAndRepliesPagerParent.Child) {
|
||||||
currentChild = child
|
currentChild = child
|
||||||
updateNestedScrolling()
|
updateNestedScrolling()
|
||||||
|
@ -376,8 +409,7 @@ class StoryGroupReplyFragment :
|
||||||
composer.closeEmojiSearch()
|
composer.closeEmojiSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReactWithAnyEmojiDialogDismissed() {
|
override fun onReactWithAnyEmojiDialogDismissed() = Unit
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReactWithAnyEmojiSelected(emoji: String) {
|
override fun onReactWithAnyEmojiSelected(emoji: String) {
|
||||||
sendReaction(emoji)
|
sendReaction(emoji)
|
||||||
|
@ -427,52 +459,6 @@ class StoryGroupReplyFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class GroupReplyScrollObserver : RecyclerView.OnScrollListener() {
|
|
||||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
||||||
postMarkAsReadRequest()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
|
||||||
postMarkAsReadRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class GroupDataObserver : RecyclerView.AdapterDataObserver() {
|
|
||||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
|
||||||
if (itemCount == 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val item = adapter.getItem(positionStart)
|
|
||||||
if (positionStart == adapter.itemCount - 1 && item is StoryGroupReplyItem.DataWrapper) {
|
|
||||||
val isOutgoing = item.storyGroupReplyItemData.sender == Recipient.self()
|
|
||||||
if (isOutgoing || (!isOutgoing && !recyclerView.canScrollVertically(1))) {
|
|
||||||
recyclerView.post { recyclerView.scrollToPosition(positionStart) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = Log.tag(StoryGroupReplyFragment::class.java)
|
|
||||||
|
|
||||||
private const val ARG_STORY_ID = "arg.story.id"
|
|
||||||
private const val ARG_GROUP_RECIPIENT_ID = "arg.group.recipient.id"
|
|
||||||
private const val ARG_IS_FROM_NOTIFICATION = "is_from_notification"
|
|
||||||
private const val ARG_GROUP_REPLY_START_POSITION = "group_reply_start_position"
|
|
||||||
|
|
||||||
fun create(storyId: Long, groupRecipientId: RecipientId, isFromNotification: Boolean, groupReplyStartPosition: Int): Fragment {
|
|
||||||
return StoryGroupReplyFragment().apply {
|
|
||||||
arguments = Bundle().apply {
|
|
||||||
putLong(ARG_STORY_ID, storyId)
|
|
||||||
putParcelable(ARG_GROUP_RECIPIENT_ID, groupRecipientId)
|
|
||||||
putBoolean(ARG_IS_FROM_NOTIFICATION, isFromNotification)
|
|
||||||
putInt(ARG_GROUP_REPLY_START_POSITION, groupReplyStartPosition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun performSend(body: CharSequence, mentions: List<Mention>) {
|
private fun performSend(body: CharSequence, mentions: List<Mention>) {
|
||||||
lifecycleDisposable += StoryGroupReplySender.sendReply(requireContext(), storyId, body, mentions)
|
lifecycleDisposable += StoryGroupReplySender.sendReply(requireContext(), storyId, body, mentions)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
@ -505,7 +491,7 @@ class StoryGroupReplyFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessageResentAfterSafetyNumberChange() {
|
override fun onMessageResentAfterSafetyNumberChange() {
|
||||||
error("Should never get here.")
|
Log.i(TAG, "Message resent")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCanceled() {
|
override fun onCanceled() {
|
||||||
|
@ -514,6 +500,37 @@ class StoryGroupReplyFragment :
|
||||||
resendReaction = null
|
resendReaction = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ColorInt
|
||||||
|
private fun Recipient.getStoryGroupReplyColor(): Int {
|
||||||
|
return colorizer.getIncomingGroupSenderColor(requireContext(), this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class GroupReplyScrollObserver : RecyclerView.OnScrollListener() {
|
||||||
|
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||||
|
postMarkAsReadRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||||
|
postMarkAsReadRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class GroupDataObserver : RecyclerView.AdapterDataObserver() {
|
||||||
|
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||||
|
if (itemCount == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val item = adapter.getItem(positionStart)
|
||||||
|
if (positionStart == adapter.itemCount - 1 && item is StoryGroupReplyItem.Model) {
|
||||||
|
val isOutgoing = item.replyBody.sender == Recipient.self()
|
||||||
|
if (isOutgoing || (!isOutgoing && !recyclerView.canScrollVertically(1))) {
|
||||||
|
recyclerView.post { recyclerView.scrollToPosition(positionStart) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface Callback {
|
interface Callback {
|
||||||
fun onStartDirectReply(recipientId: RecipientId)
|
fun onStartDirectReply(recipientId: RecipientId)
|
||||||
fun requestFullScreen(fullscreen: Boolean)
|
fun requestFullScreen(fullscreen: Boolean)
|
||||||
|
|
|
@ -9,17 +9,23 @@ import android.text.style.ClickableSpan
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.constraintlayout.widget.ConstraintSet
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
import org.signal.core.util.DimensionUnit
|
import org.signal.core.util.DimensionUnit
|
||||||
import org.thoughtcrime.securesms.R
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.components.AlertView
|
||||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||||
|
import org.thoughtcrime.securesms.components.DeliveryStatusView
|
||||||
import org.thoughtcrime.securesms.components.FromTextView
|
import org.thoughtcrime.securesms.components.FromTextView
|
||||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
|
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
|
||||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
|
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
|
||||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
import org.thoughtcrime.securesms.util.AvatarUtil
|
import org.thoughtcrime.securesms.util.AvatarUtil
|
||||||
|
@ -29,138 +35,122 @@ import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||||
|
import org.thoughtcrime.securesms.util.changeConstraints
|
||||||
|
import org.thoughtcrime.securesms.util.padding
|
||||||
import org.thoughtcrime.securesms.util.visible
|
import org.thoughtcrime.securesms.util.visible
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
typealias OnCopyClick = (CharSequence) -> Unit
|
||||||
|
typealias OnDeleteClick = (MessageRecord) -> Unit
|
||||||
|
typealias OnTapForDetailsClick = (MessageRecord) -> Unit
|
||||||
|
|
||||||
object StoryGroupReplyItem {
|
object StoryGroupReplyItem {
|
||||||
|
|
||||||
private const val NAME_COLOR_CHANGED = 1
|
private const val NAME_COLOR_CHANGED = 1
|
||||||
|
|
||||||
fun register(mappingAdapter: MappingAdapter) {
|
fun register(mappingAdapter: MappingAdapter) {
|
||||||
mappingAdapter.registerFactory(TextModel::class.java, LayoutFactory(::TextViewHolder, R.layout.stories_group_text_reply_item))
|
mappingAdapter.registerFactory(TextModel::class.java, LayoutFactory(::TextViewHolder, R.layout.stories_group_text_reply_item))
|
||||||
mappingAdapter.registerFactory(ReactionModel::class.java, LayoutFactory(::ReactionViewHolder, R.layout.stories_group_reaction_reply_item))
|
mappingAdapter.registerFactory(ReactionModel::class.java, LayoutFactory(::ReactionViewHolder, R.layout.stories_group_text_reply_item))
|
||||||
mappingAdapter.registerFactory(RemoteDeleteModel::class.java, LayoutFactory(::RemoteDeleteViewHolder, R.layout.stories_group_remote_delete_item))
|
mappingAdapter.registerFactory(RemoteDeleteModel::class.java, LayoutFactory(::RemoteDeleteViewHolder, R.layout.stories_group_text_reply_item))
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Model<T : Any>(
|
||||||
|
val replyBody: ReplyBody,
|
||||||
|
@ColorInt val nameColor: Int,
|
||||||
|
val onCopyClick: OnCopyClick?,
|
||||||
|
val onDeleteClick: OnDeleteClick,
|
||||||
|
val onTapForDetailsClick: OnTapForDetailsClick
|
||||||
|
) : MappingModel<T> {
|
||||||
|
|
||||||
|
val messageRecord: MessageRecord = replyBody.messageRecord
|
||||||
|
val isPending: Boolean = messageRecord.isPending
|
||||||
|
val isFailure: Boolean = messageRecord.isFailed
|
||||||
|
val sentAtMillis: Long = replyBody.sentAtMillis
|
||||||
|
|
||||||
|
override fun areItemsTheSame(newItem: T): Boolean {
|
||||||
|
val other = newItem as Model<*>
|
||||||
|
return replyBody.sender == other.replyBody.sender &&
|
||||||
|
replyBody.sentAtMillis == other.replyBody.sentAtMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(newItem: T): Boolean {
|
||||||
|
val other = newItem as Model<*>
|
||||||
|
return areNonPayloadPropertiesTheSame(other) &&
|
||||||
|
nameColor == other.nameColor
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(newItem: T): Any? {
|
||||||
|
val other = newItem as Model<*>
|
||||||
|
return if (nameColor != other.nameColor && areNonPayloadPropertiesTheSame(other)) {
|
||||||
|
NAME_COLOR_CHANGED
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun areNonPayloadPropertiesTheSame(newItem: Model<*>): Boolean {
|
||||||
|
return replyBody.hasSameContent(newItem.replyBody) &&
|
||||||
|
isPending == newItem.isPending &&
|
||||||
|
isFailure == newItem.isFailure &&
|
||||||
|
sentAtMillis == newItem.sentAtMillis
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class TextModel(
|
class TextModel(
|
||||||
override val storyGroupReplyItemData: StoryGroupReplyItemData,
|
val text: ReplyBody.Text,
|
||||||
val text: StoryGroupReplyItemData.ReplyBody.Text,
|
val onMentionClick: (RecipientId) -> Unit,
|
||||||
@ColorInt val nameColor: Int,
|
@ColorInt nameColor: Int,
|
||||||
val onCopyClick: (TextModel) -> Unit,
|
onCopyClick: OnCopyClick,
|
||||||
val onDeleteClick: (TextModel) -> Unit,
|
onDeleteClick: OnDeleteClick,
|
||||||
val onMentionClick: (RecipientId) -> Unit
|
onTapForDetailsClick: OnTapForDetailsClick
|
||||||
) : PreferenceModel<TextModel>(), DataWrapper {
|
) : Model<TextModel>(
|
||||||
override fun areItemsTheSame(newItem: TextModel): Boolean {
|
replyBody = text,
|
||||||
return storyGroupReplyItemData.sender == newItem.storyGroupReplyItemData.sender &&
|
nameColor = nameColor,
|
||||||
storyGroupReplyItemData.sentAtMillis == newItem.storyGroupReplyItemData.sentAtMillis
|
onCopyClick = onCopyClick,
|
||||||
}
|
onDeleteClick = onDeleteClick,
|
||||||
|
onTapForDetailsClick = onTapForDetailsClick
|
||||||
override fun areContentsTheSame(newItem: TextModel): Boolean {
|
)
|
||||||
return storyGroupReplyItemData == newItem.storyGroupReplyItemData &&
|
|
||||||
storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) &&
|
|
||||||
nameColor == newItem.nameColor &&
|
|
||||||
super.areContentsTheSame(newItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(newItem: TextModel): Any? {
|
|
||||||
return if (nameColor != newItem.nameColor &&
|
|
||||||
storyGroupReplyItemData == newItem.storyGroupReplyItemData &&
|
|
||||||
storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) &&
|
|
||||||
super.areContentsTheSame(newItem)
|
|
||||||
) {
|
|
||||||
NAME_COLOR_CHANGED
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RemoteDeleteModel(
|
|
||||||
override val storyGroupReplyItemData: StoryGroupReplyItemData,
|
|
||||||
val remoteDelete: StoryGroupReplyItemData.ReplyBody.RemoteDelete,
|
|
||||||
val onDeleteClick: (RemoteDeleteModel) -> Unit,
|
|
||||||
@ColorInt val nameColor: Int
|
|
||||||
) : MappingModel<RemoteDeleteModel>, DataWrapper {
|
|
||||||
override fun areItemsTheSame(newItem: RemoteDeleteModel): Boolean {
|
|
||||||
return storyGroupReplyItemData.sender == newItem.storyGroupReplyItemData.sender &&
|
|
||||||
storyGroupReplyItemData.sentAtMillis == newItem.storyGroupReplyItemData.sentAtMillis
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(newItem: RemoteDeleteModel): Boolean {
|
|
||||||
return storyGroupReplyItemData == newItem.storyGroupReplyItemData &&
|
|
||||||
storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) &&
|
|
||||||
nameColor == newItem.nameColor
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(newItem: RemoteDeleteModel): Any? {
|
|
||||||
return if (nameColor != newItem.nameColor &&
|
|
||||||
storyGroupReplyItemData == newItem.storyGroupReplyItemData &&
|
|
||||||
storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender)
|
|
||||||
) {
|
|
||||||
NAME_COLOR_CHANGED
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ReactionModel(
|
class ReactionModel(
|
||||||
override val storyGroupReplyItemData: StoryGroupReplyItemData,
|
val reaction: ReplyBody.Reaction,
|
||||||
val reaction: StoryGroupReplyItemData.ReplyBody.Reaction,
|
@ColorInt nameColor: Int,
|
||||||
@ColorInt val nameColor: Int
|
onCopyClick: OnCopyClick,
|
||||||
) : PreferenceModel<ReactionModel>(), DataWrapper {
|
onDeleteClick: OnDeleteClick,
|
||||||
override fun areItemsTheSame(newItem: ReactionModel): Boolean {
|
onTapForDetailsClick: OnTapForDetailsClick
|
||||||
return storyGroupReplyItemData.sender == newItem.storyGroupReplyItemData.sender &&
|
) : Model<ReactionModel>(
|
||||||
storyGroupReplyItemData.sentAtMillis == newItem.storyGroupReplyItemData.sentAtMillis
|
replyBody = reaction,
|
||||||
}
|
nameColor = nameColor,
|
||||||
|
onCopyClick = onCopyClick,
|
||||||
|
onDeleteClick = onDeleteClick,
|
||||||
|
onTapForDetailsClick = onTapForDetailsClick
|
||||||
|
)
|
||||||
|
|
||||||
override fun areContentsTheSame(newItem: ReactionModel): Boolean {
|
class RemoteDeleteModel(
|
||||||
return storyGroupReplyItemData == newItem.storyGroupReplyItemData &&
|
val remoteDelete: ReplyBody.RemoteDelete,
|
||||||
storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) &&
|
@ColorInt nameColor: Int,
|
||||||
nameColor == newItem.nameColor &&
|
onDeleteClick: OnDeleteClick,
|
||||||
super.areContentsTheSame(newItem)
|
onTapForDetailsClick: OnTapForDetailsClick
|
||||||
}
|
) : Model<RemoteDeleteModel>(
|
||||||
|
replyBody = remoteDelete,
|
||||||
|
nameColor = nameColor,
|
||||||
|
onCopyClick = null,
|
||||||
|
onDeleteClick = onDeleteClick,
|
||||||
|
onTapForDetailsClick = onTapForDetailsClick
|
||||||
|
)
|
||||||
|
|
||||||
override fun getChangePayload(newItem: ReactionModel): Any? {
|
private abstract class BaseViewHolder<T : Model<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||||
return if (nameColor != newItem.nameColor &&
|
protected val bubble: View = findViewById(R.id.bubble)
|
||||||
storyGroupReplyItemData == newItem.storyGroupReplyItemData &&
|
protected val avatar: AvatarImageView = findViewById(R.id.avatar)
|
||||||
storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) &&
|
protected val name: FromTextView = findViewById(R.id.name)
|
||||||
super.areContentsTheSame(newItem)
|
protected val body: EmojiTextView = findViewById(R.id.body)
|
||||||
) {
|
protected val date: TextView = findViewById(R.id.viewed_at)
|
||||||
NAME_COLOR_CHANGED
|
protected val dateBelow: TextView = findViewById(R.id.viewed_at_below)
|
||||||
} else {
|
protected val status: DeliveryStatusView = findViewById(R.id.delivery_status)
|
||||||
null
|
protected val alertView: AlertView = findViewById(R.id.alert_view)
|
||||||
}
|
protected val reaction: EmojiImageView = itemView.findViewById(R.id.reaction)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataWrapper {
|
@CallSuper
|
||||||
val storyGroupReplyItemData: StoryGroupReplyItemData
|
override fun bind(model: T) {
|
||||||
}
|
|
||||||
|
|
||||||
private abstract class BaseViewHolder<T>(itemView: View) : MappingViewHolder<T>(itemView) {
|
|
||||||
protected val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
|
|
||||||
protected val name: FromTextView = itemView.findViewById(R.id.name)
|
|
||||||
protected val body: EmojiTextView = itemView.findViewById(R.id.body)
|
|
||||||
protected val date: TextView = itemView.findViewById(R.id.viewed_at)
|
|
||||||
protected val dateBelow: TextView = itemView.findViewById(R.id.viewed_at_below)
|
|
||||||
|
|
||||||
init {
|
|
||||||
body.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
|
||||||
if (body.lastLineWidth + date.measuredWidth > ViewUtil.dpToPx(242)) {
|
|
||||||
date.visible = false
|
|
||||||
dateBelow.visible = true
|
|
||||||
} else {
|
|
||||||
dateBelow.visible = false
|
|
||||||
date.visible = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TextViewHolder(itemView: View) : BaseViewHolder<TextModel>(itemView) {
|
|
||||||
|
|
||||||
override fun bind(model: TextModel) {
|
|
||||||
itemView.setOnLongClickListener {
|
itemView.setOnLongClickListener {
|
||||||
displayContextMenu(model)
|
displayContextMenu(model)
|
||||||
true
|
true
|
||||||
|
@ -171,29 +161,74 @@ object StoryGroupReplyItem {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
AvatarUtil.loadIconIntoImageView(model.storyGroupReplyItemData.sender, avatar, DimensionUnit.DP.toPixels(28f).toInt())
|
AvatarUtil.loadIconIntoImageView(model.replyBody.sender, avatar, DimensionUnit.DP.toPixels(28f).toInt())
|
||||||
name.text = resolveName(context, model.storyGroupReplyItemData.sender)
|
name.text = resolveName(context, model.replyBody.sender)
|
||||||
|
|
||||||
|
if (model.isPending) {
|
||||||
|
status.setPending()
|
||||||
|
} else {
|
||||||
|
status.setNone()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.isFailure) {
|
||||||
|
alertView.setFailed()
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
model.onTapForDetailsClick(model.messageRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
date.setText(R.string.ConversationItem_error_not_sent_tap_for_details)
|
||||||
|
dateBelow.setText(R.string.ConversationItem_error_not_sent_tap_for_details)
|
||||||
|
} else {
|
||||||
|
alertView.setNone()
|
||||||
|
itemView.setOnClickListener(null)
|
||||||
|
|
||||||
|
val dateText = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.sentAtMillis)
|
||||||
|
date.text = dateText
|
||||||
|
dateBelow.text = dateText
|
||||||
|
}
|
||||||
|
|
||||||
|
itemView.post {
|
||||||
|
if (alertView.visible || body.lastLineWidth + date.measuredWidth > ViewUtil.dpToPx(242)) {
|
||||||
|
date.visible = false
|
||||||
|
dateBelow.visible = true
|
||||||
|
} else {
|
||||||
|
dateBelow.visible = false
|
||||||
|
date.visible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun displayContextMenu(model: Model<*>) {
|
||||||
|
itemView.isSelected = true
|
||||||
|
|
||||||
|
val actions = mutableListOf<ActionItem>()
|
||||||
|
if (model.onCopyClick != null) {
|
||||||
|
actions += ActionItem(R.drawable.ic_copy_24_solid_tinted, context.getString(R.string.StoryGroupReplyItem__copy)) {
|
||||||
|
val toCopy: CharSequence = when (model) {
|
||||||
|
is TextModel -> model.text.message.getDisplayBody(context)
|
||||||
|
else -> model.messageRecord.getDisplayBody(context)
|
||||||
|
}
|
||||||
|
model.onCopyClick.invoke(toCopy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions += ActionItem(R.drawable.ic_trash_24_solid_tinted, context.getString(R.string.StoryGroupReplyItem__delete)) { model.onDeleteClick(model.messageRecord) }
|
||||||
|
|
||||||
|
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
|
||||||
|
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
|
||||||
|
.onDismiss { itemView.isSelected = false }
|
||||||
|
.show(actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TextViewHolder(itemView: View) : BaseViewHolder<TextModel>(itemView) {
|
||||||
|
|
||||||
|
override fun bind(model: TextModel) {
|
||||||
|
super.bind(model)
|
||||||
|
|
||||||
body.movementMethod = LinkMovementMethod.getInstance()
|
body.movementMethod = LinkMovementMethod.getInstance()
|
||||||
body.text = model.text.message.getDisplayBody(context).apply {
|
body.text = model.text.message.getDisplayBody(context).apply {
|
||||||
linkifyBody(model, this)
|
linkifyBody(model, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
date.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.storyGroupReplyItemData.sentAtMillis)
|
|
||||||
dateBelow.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.storyGroupReplyItemData.sentAtMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun displayContextMenu(model: TextModel) {
|
|
||||||
itemView.isSelected = true
|
|
||||||
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
|
|
||||||
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
|
|
||||||
.onDismiss { itemView.isSelected = false }
|
|
||||||
.show(
|
|
||||||
listOf(
|
|
||||||
ActionItem(R.drawable.ic_copy_24_solid_tinted, context.getString(R.string.StoryGroupReplyItem__copy)) { model.onCopyClick(model) },
|
|
||||||
ActionItem(R.drawable.ic_trash_24_solid_tinted, context.getString(R.string.StoryGroupReplyItem__delete)) { model.onDeleteClick(model) }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun linkifyBody(model: TextModel, body: Spannable) {
|
private fun linkifyBody(model: TextModel, body: Spannable) {
|
||||||
|
@ -211,59 +246,36 @@ object StoryGroupReplyItem {
|
||||||
model.onMentionClick(mentionedRecipientId)
|
model.onMentionClick(mentionedRecipientId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateDrawState(ds: TextPaint) {}
|
override fun updateDrawState(ds: TextPaint) = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ReactionViewHolder(itemView: View) : BaseViewHolder<ReactionModel>(itemView) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
reaction.visible = true
|
||||||
|
bubble.visibility = View.INVISIBLE
|
||||||
|
itemView.padding(bottom = 0)
|
||||||
|
body.setText(R.string.StoryGroupReactionReplyItem__reacted_to_the_story)
|
||||||
|
body.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
|
marginEnd = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
(itemView as ConstraintLayout).changeConstraints {
|
||||||
|
connect(avatar.id, ConstraintSet.BOTTOM, body.id, ConstraintSet.BOTTOM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(model: ReactionModel) {
|
||||||
|
super.bind(model)
|
||||||
|
reaction.setImageEmoji(model.reaction.emoji)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class RemoteDeleteViewHolder(itemView: View) : BaseViewHolder<RemoteDeleteModel>(itemView) {
|
private class RemoteDeleteViewHolder(itemView: View) : BaseViewHolder<RemoteDeleteModel>(itemView) {
|
||||||
|
init {
|
||||||
override fun bind(model: RemoteDeleteModel) {
|
bubble.setBackgroundResource(R.drawable.rounded_outline_secondary_18)
|
||||||
itemView.setOnLongClickListener {
|
body.setText(R.string.ThreadRecord_this_message_was_deleted)
|
||||||
displayContextMenu(model)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
name.setTextColor(model.nameColor)
|
|
||||||
if (payload.contains(NAME_COLOR_CHANGED)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
AvatarUtil.loadIconIntoImageView(model.storyGroupReplyItemData.sender, avatar, DimensionUnit.DP.toPixels(28f).toInt())
|
|
||||||
name.text = resolveName(context, model.storyGroupReplyItemData.sender)
|
|
||||||
|
|
||||||
date.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.storyGroupReplyItemData.sentAtMillis)
|
|
||||||
dateBelow.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.storyGroupReplyItemData.sentAtMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun displayContextMenu(model: RemoteDeleteModel) {
|
|
||||||
itemView.isSelected = true
|
|
||||||
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
|
|
||||||
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
|
|
||||||
.onDismiss { itemView.isSelected = false }
|
|
||||||
.show(
|
|
||||||
listOf(
|
|
||||||
ActionItem(R.drawable.ic_trash_24_solid_tinted, context.getString(R.string.StoryGroupReplyItem__delete)) { model.onDeleteClick(model) }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ReactionViewHolder(itemView: View) : MappingViewHolder<ReactionModel>(itemView) {
|
|
||||||
private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
|
|
||||||
private val name: FromTextView = itemView.findViewById(R.id.name)
|
|
||||||
private val reaction: EmojiImageView = itemView.findViewById(R.id.reaction)
|
|
||||||
private val date: TextView = itemView.findViewById(R.id.viewed_at)
|
|
||||||
|
|
||||||
override fun bind(model: ReactionModel) {
|
|
||||||
name.setTextColor(model.nameColor)
|
|
||||||
if (payload.contains(NAME_COLOR_CHANGED)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
AvatarUtil.loadIconIntoImageView(model.storyGroupReplyItemData.sender, avatar, DimensionUnit.DP.toPixels(28f).toInt())
|
|
||||||
name.text = resolveName(context, model.storyGroupReplyItemData.sender)
|
|
||||||
reaction.setImageEmoji(model.reaction.emoji)
|
|
||||||
date.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.storyGroupReplyItemData.sentAtMillis)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
package org.thoughtcrime.securesms.stories.viewer.reply.group
|
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
|
||||||
|
|
||||||
data class StoryGroupReplyItemData(
|
|
||||||
val key: Key,
|
|
||||||
val sender: Recipient,
|
|
||||||
val sentAtMillis: Long,
|
|
||||||
val replyBody: ReplyBody
|
|
||||||
) {
|
|
||||||
sealed class ReplyBody {
|
|
||||||
data class Text(val message: ConversationMessage) : ReplyBody()
|
|
||||||
data class Reaction(val emoji: CharSequence) : ReplyBody()
|
|
||||||
data class RemoteDelete(val messageRecord: MessageRecord) : ReplyBody()
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class Key {
|
|
||||||
data class Text(val messageId: Long) : Key()
|
|
||||||
data class Reaction(val reactionId: Long) : Key()
|
|
||||||
data class RemoteDelete(val messageId: Long) : Key()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +1,23 @@
|
||||||
package org.thoughtcrime.securesms.stories.viewer.reply.group
|
package org.thoughtcrime.securesms.stories.viewer.reply.group
|
||||||
|
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
import io.reactivex.rxjava3.core.Observable
|
import io.reactivex.rxjava3.core.Observable
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
import org.signal.paging.LivePagedData
|
import org.signal.core.util.ThreadUtil
|
||||||
|
import org.signal.paging.ObservablePagedData
|
||||||
import org.signal.paging.PagedData
|
import org.signal.paging.PagedData
|
||||||
import org.signal.paging.PagingConfig
|
import org.signal.paging.PagingConfig
|
||||||
|
import org.signal.paging.PagingController
|
||||||
|
import org.thoughtcrime.securesms.conversation.colors.NameColor
|
||||||
|
import org.thoughtcrime.securesms.conversation.colors.NameColors
|
||||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.model.MessageId
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupId
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
|
|
||||||
class StoryGroupReplyRepository {
|
class StoryGroupReplyRepository {
|
||||||
|
@ -19,38 +28,45 @@ class StoryGroupReplyRepository {
|
||||||
}.subscribeOn(Schedulers.io())
|
}.subscribeOn(Schedulers.io())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getPagedReplies(parentStoryId: Long): Observable<LivePagedData<StoryGroupReplyItemData.Key, StoryGroupReplyItemData>> {
|
fun getPagedReplies(parentStoryId: Long): Observable<ObservablePagedData<MessageId, ReplyBody>> {
|
||||||
return Observable.create<LivePagedData<StoryGroupReplyItemData.Key, StoryGroupReplyItemData>> { emitter ->
|
return getThreadId(parentStoryId)
|
||||||
fun refresh() {
|
.toObservable()
|
||||||
emitter.onNext(PagedData.createForLiveData(StoryGroupReplyDataSource(parentStoryId), PagingConfig.Builder().build()))
|
.flatMap { threadId ->
|
||||||
|
Observable.create<ObservablePagedData<MessageId, ReplyBody>> { emitter ->
|
||||||
|
val pagedData: ObservablePagedData<MessageId, ReplyBody> = PagedData.createForObservable(StoryGroupReplyDataSource(parentStoryId), PagingConfig.Builder().build())
|
||||||
|
val controller: PagingController<MessageId> = pagedData.controller
|
||||||
|
|
||||||
|
val updateObserver = DatabaseObserver.MessageObserver { controller.onDataItemChanged(it) }
|
||||||
|
val insertObserver = DatabaseObserver.MessageObserver { controller.onDataItemInserted(it, PagingController.POSITION_END) }
|
||||||
|
val conversationObserver = DatabaseObserver.Observer { controller.onDataInvalidated() }
|
||||||
|
|
||||||
|
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(updateObserver)
|
||||||
|
ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(threadId, insertObserver)
|
||||||
|
ApplicationDependencies.getDatabaseObserver().registerConversationObserver(threadId, conversationObserver)
|
||||||
|
|
||||||
|
emitter.setCancellable {
|
||||||
|
ApplicationDependencies.getDatabaseObserver().unregisterObserver(updateObserver)
|
||||||
|
ApplicationDependencies.getDatabaseObserver().unregisterObserver(insertObserver)
|
||||||
|
ApplicationDependencies.getDatabaseObserver().unregisterObserver(conversationObserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.onNext(pagedData)
|
||||||
|
}.subscribeOn(Schedulers.io())
|
||||||
}
|
}
|
||||||
|
|
||||||
val observer = DatabaseObserver.Observer {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
val messageObserver = DatabaseObserver.MessageObserver {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
val threadId = SignalDatabase.mms.getThreadIdForMessage(parentStoryId)
|
|
||||||
|
|
||||||
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageObserver)
|
|
||||||
ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(threadId, messageObserver)
|
|
||||||
ApplicationDependencies.getDatabaseObserver().registerConversationObserver(threadId, observer)
|
|
||||||
|
|
||||||
emitter.setCancellable {
|
|
||||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
|
|
||||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver)
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh()
|
|
||||||
}.subscribeOn(Schedulers.io())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getStoryOwner(storyId: Long): Single<RecipientId> {
|
fun getNameColorsMap(storyId: Long, sessionMemberCache: MutableMap<GroupId, Set<Recipient>>): Observable<Map<RecipientId, NameColor>> {
|
||||||
return Single.fromCallable {
|
return Single.fromCallable { SignalDatabase.mms.getMessageRecord(storyId).individualRecipient.id }
|
||||||
SignalDatabase.mms.getMessageRecord(storyId).individualRecipient.id
|
.subscribeOn(Schedulers.io())
|
||||||
}.subscribeOn(Schedulers.io())
|
.flatMapObservable { recipientId ->
|
||||||
|
Observable.create<Map<RecipientId, NameColor>?> { emitter ->
|
||||||
|
val nameColorsMapLiveData = NameColors.getNameColorsMapLiveData(MutableLiveData(recipientId), sessionMemberCache)
|
||||||
|
val observer = Observer<Map<RecipientId, NameColor>> { emitter.onNext(it) }
|
||||||
|
|
||||||
|
ThreadUtil.postToMain { nameColorsMapLiveData.observeForever(observer) }
|
||||||
|
|
||||||
|
emitter.setCancellable { ThreadUtil.postToMain { nameColorsMapLiveData.removeObserver(observer) } }
|
||||||
|
}.subscribeOn(Schedulers.io())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,10 +5,12 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
|
|
||||||
data class StoryGroupReplyState(
|
data class StoryGroupReplyState(
|
||||||
val threadId: Long = 0L,
|
val threadId: Long = 0L,
|
||||||
val noReplies: Boolean = true,
|
val replies: List<ReplyBody> = emptyList(),
|
||||||
val nameColors: Map<RecipientId, NameColor> = emptyMap(),
|
val nameColors: Map<RecipientId, NameColor> = emptyMap(),
|
||||||
val loadState: LoadState = LoadState.INIT
|
val loadState: LoadState = LoadState.INIT
|
||||||
) {
|
) {
|
||||||
|
val noReplies: Boolean = replies.isEmpty()
|
||||||
|
|
||||||
enum class LoadState {
|
enum class LoadState {
|
||||||
INIT,
|
INIT,
|
||||||
READY
|
READY
|
||||||
|
|
|
@ -1,57 +1,52 @@
|
||||||
package org.thoughtcrime.securesms.stories.viewer.reply.group
|
package org.thoughtcrime.securesms.stories.viewer.reply.group
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.Transformations
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||||
import org.signal.paging.LivePagedData
|
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||||
import org.signal.paging.PagingController
|
import org.signal.paging.ProxyPagingController
|
||||||
import org.thoughtcrime.securesms.conversation.colors.NameColors
|
import org.thoughtcrime.securesms.conversation.colors.NameColors
|
||||||
|
import org.thoughtcrime.securesms.database.model.MessageId
|
||||||
import org.thoughtcrime.securesms.groups.GroupId
|
import org.thoughtcrime.securesms.groups.GroupId
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.util.livedata.Store
|
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||||
|
|
||||||
class StoryGroupReplyViewModel(storyId: Long, repository: StoryGroupReplyRepository) : ViewModel() {
|
class StoryGroupReplyViewModel(storyId: Long, repository: StoryGroupReplyRepository) : ViewModel() {
|
||||||
|
|
||||||
private val sessionMemberCache: MutableMap<GroupId, Set<Recipient>> = NameColors.createSessionMembersCache()
|
private val sessionMemberCache: MutableMap<GroupId, Set<Recipient>> = NameColors.createSessionMembersCache()
|
||||||
private val store = Store(StoryGroupReplyState())
|
private val store = RxStore(StoryGroupReplyState())
|
||||||
private val disposables = CompositeDisposable()
|
private val disposables = CompositeDisposable()
|
||||||
|
|
||||||
val stateSnapshot: StoryGroupReplyState = store.state
|
val stateSnapshot: StoryGroupReplyState = store.state
|
||||||
val state: LiveData<StoryGroupReplyState> = store.stateLiveData
|
val state: Flowable<StoryGroupReplyState> = store.stateFlowable
|
||||||
|
|
||||||
private val pagedData: MutableLiveData<LivePagedData<StoryGroupReplyItemData.Key, StoryGroupReplyItemData>> = MutableLiveData()
|
val pagingController: ProxyPagingController<MessageId> = ProxyPagingController()
|
||||||
|
|
||||||
val pagingController: LiveData<PagingController<StoryGroupReplyItemData.Key>>
|
|
||||||
val pageData: LiveData<List<StoryGroupReplyItemData>>
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
disposables += repository.getThreadId(storyId).subscribe { threadId ->
|
disposables += repository.getThreadId(storyId).subscribe { threadId ->
|
||||||
store.update { it.copy(threadId = threadId) }
|
store.update { it.copy(threadId = threadId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
disposables += repository.getPagedReplies(storyId).subscribe {
|
disposables += repository.getPagedReplies(storyId)
|
||||||
pagedData.postValue(it)
|
.doOnNext { pagingController.set(it.controller) }
|
||||||
}
|
.flatMap { it.data }
|
||||||
|
.subscribeBy { data ->
|
||||||
pagingController = Transformations.map(pagedData) { it.controller }
|
store.update { state ->
|
||||||
pageData = Transformations.switchMap(pagedData) { it.data }
|
state.copy(
|
||||||
store.update(pageData) { data, state ->
|
replies = data,
|
||||||
state.copy(
|
loadState = StoryGroupReplyState.LoadState.READY
|
||||||
noReplies = data.isEmpty(),
|
)
|
||||||
loadState = StoryGroupReplyState.LoadState.READY
|
}
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
disposables += repository.getNameColorsMap(storyId, sessionMemberCache)
|
||||||
disposables += repository.getStoryOwner(storyId).observeOn(AndroidSchedulers.mainThread()).subscribe { recipientId ->
|
.subscribeBy { nameColors ->
|
||||||
store.update(NameColors.getNameColorsMapLiveData(MutableLiveData(recipientId), sessionMemberCache)) { nameColors, state ->
|
store.update { state ->
|
||||||
state.copy(nameColors = nameColors)
|
state.copy(nameColors = nameColors)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
|
|
|
@ -8,7 +8,6 @@ import org.signal.core.util.concurrent.SignalExecutors
|
||||||
import org.thoughtcrime.securesms.R
|
import org.thoughtcrime.securesms.R
|
||||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.sms.MessageSender
|
import org.thoughtcrime.securesms.sms.MessageSender
|
||||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask
|
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask
|
||||||
|
@ -75,7 +74,7 @@ object DeleteDialog {
|
||||||
private fun deleteForEveryone(messageRecords: Set<MessageRecord>, emitter: SingleEmitter<Boolean>) {
|
private fun deleteForEveryone(messageRecords: Set<MessageRecord>, emitter: SingleEmitter<Boolean>) {
|
||||||
SignalExecutors.BOUNDED.execute {
|
SignalExecutors.BOUNDED.execute {
|
||||||
messageRecords.forEach { message ->
|
messageRecords.forEach { message ->
|
||||||
MessageSender.sendRemoteDelete(ApplicationDependencies.getApplication(), message.id, message.isMms)
|
MessageSender.sendRemoteDelete(message.id, message.isMms)
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter.onSuccess(false)
|
emitter.onSuccess(false)
|
||||||
|
|
|
@ -1,12 +1,24 @@
|
||||||
package org.thoughtcrime.securesms.util
|
package org.thoughtcrime.securesms.util
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.constraintlayout.widget.ConstraintSet
|
||||||
|
|
||||||
var View.visible: Boolean
|
var View.visible: Boolean
|
||||||
get() {
|
get() {
|
||||||
return this.visibility == View.VISIBLE
|
return this.visibility == View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
set(value) {
|
set(value) {
|
||||||
this.visibility = if (value) View.VISIBLE else View.GONE
|
this.visibility = if (value) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun View.padding(left: Int = paddingLeft, top: Int = paddingTop, right: Int = paddingRight, bottom: Int = paddingBottom) {
|
||||||
|
setPadding(left, top, right, bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ConstraintLayout.changeConstraints(change: ConstraintSet.() -> Unit) {
|
||||||
|
val set = ConstraintSet()
|
||||||
|
set.clone(this)
|
||||||
|
set.change()
|
||||||
|
set.applyTo(this)
|
||||||
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ public abstract class MappingViewHolder<Model> extends RecyclerView.ViewHolder {
|
||||||
payload = new LinkedList<>();
|
payload = new LinkedList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public <T extends View> T findViewById(@IdRes int id) {
|
public final <T extends View> T findViewById(@IdRes int id) {
|
||||||
return itemView.findViewById(id);
|
return itemView.findViewById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,70 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginHorizontal="16dp"
|
|
||||||
android:layout_marginBottom="12dp">
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.AvatarImageView
|
|
||||||
android:id="@+id/avatar"
|
|
||||||
android:layout_width="28dp"
|
|
||||||
android:layout_height="28dp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@id/body"
|
|
||||||
app:layout_constraintStart_toStartOf="parent" />
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.FromTextView
|
|
||||||
android:id="@+id/name"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:layout_marginTop="7dp"
|
|
||||||
android:textAppearance="@style/TextAppearance.Signal.Subtitle.Bold"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/reaction"
|
|
||||||
app:layout_constraintHorizontal_bias="0"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/avatar"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
tools:text="Miles Morales" />
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
|
||||||
android:id="@+id/body"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:layout_marginBottom="7dp"
|
|
||||||
android:text="@string/StoryGroupReactionReplyItem__reacted_to_the_story"
|
|
||||||
android:textAppearance="@style/Signal.Text.Body"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/reaction"
|
|
||||||
app:layout_constraintHorizontal_bias="0"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/avatar"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/name" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/viewed_at"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="6dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:layout_marginBottom="5dp"
|
|
||||||
android:textAppearance="@style/Signal.Text.Caption"
|
|
||||||
android:textColor="@color/signal_inverse_transparent_60"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/reaction"
|
|
||||||
app:layout_constraintHorizontal_bias="0"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/body"
|
|
||||||
tools:text="15m" />
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
|
|
||||||
android:id="@+id/reaction"
|
|
||||||
android:layout_width="28dp"
|
|
||||||
android:layout_height="28dp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,105 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginHorizontal="8dp"
|
|
||||||
android:background="@drawable/selectable_list_item_background"
|
|
||||||
android:clipToPadding="false"
|
|
||||||
android:paddingHorizontal="8dp"
|
|
||||||
android:paddingTop="6dp"
|
|
||||||
android:paddingBottom="6dp">
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.AvatarImageView
|
|
||||||
android:id="@+id/avatar"
|
|
||||||
android:layout_width="28dp"
|
|
||||||
android:layout_height="28dp"
|
|
||||||
app:fallbackImageSize="small"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent" />
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:id="@+id/bubble"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:background="@drawable/rounded_outline_secondary_18"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintHorizontal_bias="0"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/avatar"
|
|
||||||
app:layout_constraintTop_toTopOf="parent">
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.FromTextView
|
|
||||||
android:id="@+id/name"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="12dp"
|
|
||||||
android:layout_marginTop="7dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:textAppearance="@style/TextAppearance.Signal.Subtitle.Bold"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintHorizontal_bias="0"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
tools:text="Miles Morales" />
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
|
||||||
android:id="@+id/body"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="12dp"
|
|
||||||
android:layout_marginTop="1dp"
|
|
||||||
android:layout_marginEnd="20dp"
|
|
||||||
android:layout_marginBottom="1dp"
|
|
||||||
android:text="@string/ThreadRecord_this_message_was_deleted"
|
|
||||||
android:textAppearance="@style/Signal.Text.Body"
|
|
||||||
android:textStyle="italic"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/viewed_at_below"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintHorizontal_bias="0"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/name"
|
|
||||||
app:layout_goneMarginBottom="7dp"
|
|
||||||
app:measureLastLine="true" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/viewed_at"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="6dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:layout_marginBottom="5dp"
|
|
||||||
android:textAppearance="@style/Signal.Text.Caption"
|
|
||||||
android:textColor="@color/transparent_white_60"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintHorizontal_bias="1"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/body"
|
|
||||||
tools:text="15m"
|
|
||||||
tools:textColor="@color/signal_text_secondary"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/viewed_at_below"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="6dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:layout_marginBottom="5dp"
|
|
||||||
android:textAppearance="@style/Signal.Text.Caption"
|
|
||||||
android:textColor="@color/transparent_white_60"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@id/bubble"
|
|
||||||
app:layout_constraintEnd_toEndOf="@id/bubble"
|
|
||||||
tools:text="15m"
|
|
||||||
tools:textColor="@color/signal_text_secondary" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -19,86 +19,156 @@
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent" />
|
app:layout_constraintStart_toStartOf="parent" />
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/bubble_start_barrier"
|
||||||
|
android:layout_width="1dp"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
app:barrierDirection="end"
|
||||||
|
app:barrierMargin="8dp"
|
||||||
|
app:constraint_referenced_ids="avatar" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/bubble_end_barrier"
|
||||||
|
android:layout_width="1dp"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
app:barrierDirection="end"
|
||||||
|
app:barrierMargin="12dp"
|
||||||
|
app:constraint_referenced_ids="viewed_at,viewed_at_below,delivery_status,name,body" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/message_content_end_barrier"
|
||||||
|
android:layout_width="1dp"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
app:barrierDirection="start"
|
||||||
|
app:barrierMargin="-8dp"
|
||||||
|
app:constraint_referenced_ids="alert_view" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/viewed_at_end_barrier"
|
||||||
|
android:layout_width="1dp"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
app:barrierDirection="end"
|
||||||
|
app:barrierMargin="0dp"
|
||||||
|
app:constraint_referenced_ids="viewed_at,viewed_at_below" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/viewed_at_bottom_barrier"
|
||||||
|
android:layout_width="1dp"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
app:barrierDirection="bottom"
|
||||||
|
app:barrierMargin="0dp"
|
||||||
|
app:constraint_referenced_ids="viewed_at,viewed_at_below" />
|
||||||
|
|
||||||
|
<View
|
||||||
android:id="@+id/bubble"
|
android:id="@+id/bubble"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:background="@drawable/rounded_rectangle_secondary_18"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="@+id/bubble_end_barrier"
|
||||||
|
app:layout_constraintStart_toStartOf="@+id/bubble_start_barrier"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<org.thoughtcrime.securesms.components.FromTextView
|
||||||
|
android:id="@+id/name"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginTop="7dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Signal.Subtitle.Bold"
|
||||||
|
app:layout_constrainedWidth="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="@+id/message_content_end_barrier"
|
||||||
|
app:layout_constraintHorizontal_bias="0"
|
||||||
|
app:layout_constraintStart_toStartOf="@+id/bubble_start_barrier"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="Miles Morales" />
|
||||||
|
|
||||||
|
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||||
|
android:id="@+id/body"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginTop="1dp"
|
||||||
|
android:layout_marginEnd="20dp"
|
||||||
|
android:layout_marginBottom="1dp"
|
||||||
|
android:textAppearance="@style/Signal.Text.Body"
|
||||||
|
app:layout_constrainedWidth="true"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/viewed_at_below"
|
||||||
|
app:layout_constraintEnd_toEndOf="@+id/message_content_end_barrier"
|
||||||
|
app:layout_constraintHorizontal_bias="0"
|
||||||
|
app:layout_constraintStart_toStartOf="@+id/bubble_start_barrier"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/name"
|
||||||
|
app:layout_goneMarginBottom="7dp"
|
||||||
|
app:measureLastLine="true"
|
||||||
|
tools:text="This is a very long message that is supposed to properly wrap when it hits the end." />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/viewed_at"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:layout_marginBottom="5dp"
|
||||||
|
android:textAppearance="@style/Signal.Text.Caption"
|
||||||
|
android:textColor="@color/transparent_white_60"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="1"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/body"
|
||||||
|
tools:text="15m"
|
||||||
|
tools:textColor="@color/signal_text_secondary"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/viewed_at_below"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginBottom="5dp"
|
||||||
|
android:textAppearance="@style/Signal.Text.Caption"
|
||||||
|
android:textColor="@color/transparent_white_60"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constrainedWidth="true"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="@+id/message_content_end_barrier"
|
||||||
|
app:layout_constraintHorizontal_bias="1"
|
||||||
|
app:layout_constraintStart_toStartOf="@+id/bubble_start_barrier"
|
||||||
|
tools:text="Not sent, tap for details"
|
||||||
|
tools:textColor="@color/signal_text_secondary" />
|
||||||
|
|
||||||
|
<org.thoughtcrime.securesms.components.DeliveryStatusView
|
||||||
|
android:id="@+id/delivery_status"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:layout_marginBottom="5dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/viewed_at_bottom_barrier"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/viewed_at_end_barrier" />
|
||||||
|
|
||||||
|
<org.thoughtcrime.securesms.components.AlertView
|
||||||
|
android:id="@+id/alert_view"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
android:background="@drawable/rounded_rectangle_secondary_18"
|
android:layout_marginEnd="8dp"
|
||||||
app:layout_constrainedWidth="true"
|
android:visibility="gone"
|
||||||
|
app:layout_goneMarginEnd="0dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/reaction"
|
||||||
|
app:layout_constraintHorizontal_bias="0"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/bubble_end_barrier"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
|
||||||
|
android:id="@+id/reaction"
|
||||||
|
android:layout_width="28dp"
|
||||||
|
android:layout_height="28dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintHorizontal_bias="0"
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
app:layout_constraintStart_toEndOf="@id/avatar"
|
|
||||||
app:layout_constraintTop_toTopOf="parent">
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.FromTextView
|
|
||||||
android:id="@+id/name"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="12dp"
|
|
||||||
android:layout_marginTop="7dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:textAppearance="@style/TextAppearance.Signal.Subtitle.Bold"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintHorizontal_bias="0"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
tools:text="Miles Morales" />
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
|
||||||
android:id="@+id/body"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="12dp"
|
|
||||||
android:layout_marginTop="1dp"
|
|
||||||
android:layout_marginEnd="20dp"
|
|
||||||
android:layout_marginBottom="1dp"
|
|
||||||
android:textAppearance="@style/Signal.Text.Body"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/viewed_at_below"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintHorizontal_bias="0"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/name"
|
|
||||||
app:layout_goneMarginBottom="7dp"
|
|
||||||
app:measureLastLine="true"
|
|
||||||
tools:text="This is a very long message that is supposed to properly wrap when it hits the end." />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/viewed_at"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="6dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:layout_marginBottom="5dp"
|
|
||||||
android:textAppearance="@style/Signal.Text.Caption"
|
|
||||||
android:textColor="@color/transparent_white_60"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintHorizontal_bias="1"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/body"
|
|
||||||
tools:text="15m"
|
|
||||||
tools:textColor="@color/signal_text_secondary"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/viewed_at_below"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="6dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:layout_marginBottom="5dp"
|
|
||||||
android:textAppearance="@style/Signal.Text.Caption"
|
|
||||||
android:textColor="@color/transparent_white_60"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@id/bubble"
|
|
||||||
app:layout_constraintEnd_toEndOf="@id/bubble"
|
|
||||||
tools:text="15m"
|
|
||||||
tools:textColor="@color/signal_text_secondary" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -4561,6 +4561,8 @@
|
||||||
<string name="MyStories__delete_story">Delete story?</string>
|
<string name="MyStories__delete_story">Delete story?</string>
|
||||||
<!-- Message of dialog to confirm deletion of story -->
|
<!-- Message of dialog to confirm deletion of story -->
|
||||||
<string name="MyStories__this_story_will_be_deleted">This story will be deleted for you and everyone who received it.</string>
|
<string name="MyStories__this_story_will_be_deleted">This story will be deleted for you and everyone who received it.</string>
|
||||||
|
<!-- Toast shown when story media cannot be saved -->
|
||||||
|
<string name="MyStories__unable_to_save">Unable to save</string>
|
||||||
<!-- Displayed at bottom of story viewer when current item has views -->
|
<!-- Displayed at bottom of story viewer when current item has views -->
|
||||||
<plurals name="StoryViewerFragment__d_views">
|
<plurals name="StoryViewerFragment__d_views">
|
||||||
<item quantity="one">%1$d view</item>
|
<item quantity="one">%1$d view</item>
|
||||||
|
|
|
@ -21,7 +21,7 @@ import java.util.concurrent.Executor;
|
||||||
*/
|
*/
|
||||||
class FixedSizePagingController<Key, Data> implements PagingController<Key> {
|
class FixedSizePagingController<Key, Data> implements PagingController<Key> {
|
||||||
|
|
||||||
private static final String TAG = FixedSizePagingController.class.getSimpleName();
|
private static final String TAG = Log.tag(FixedSizePagingController.class);
|
||||||
|
|
||||||
private static final Executor FETCH_EXECUTOR = SignalExecutors.newCachedSingleThreadExecutor("signal-FixedSizePagingController");
|
private static final Executor FETCH_EXECUTOR = SignalExecutors.newCachedSingleThreadExecutor("signal-FixedSizePagingController");
|
||||||
private static final boolean DEBUG = false;
|
private static final boolean DEBUG = false;
|
||||||
|
@ -182,10 +182,15 @@ class FixedSizePagingController<Key, Data> implements PagingController<Key> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDataItemInserted(Key key, int position) {
|
public void onDataItemInserted(Key key, int inputPosition) {
|
||||||
if (DEBUG) Log.d(TAG, buildItemInsertedLog(key, position, ""));
|
if (DEBUG) Log.d(TAG, buildItemInsertedLog(key, inputPosition, ""));
|
||||||
|
|
||||||
FETCH_EXECUTOR.execute(() -> {
|
FETCH_EXECUTOR.execute(() -> {
|
||||||
|
int position = inputPosition;
|
||||||
|
if (position == POSITION_END) {
|
||||||
|
position = data.size();
|
||||||
|
}
|
||||||
|
|
||||||
if (keyToPosition.containsKey(key)) {
|
if (keyToPosition.containsKey(key)) {
|
||||||
Log.w(TAG, "Notified of key " + key + " being inserted at " + position + ", but the item already exists!");
|
Log.w(TAG, "Notified of key " + key + " being inserted at " + position + ", but the item already exists!");
|
||||||
return;
|
return;
|
||||||
|
@ -245,6 +250,6 @@ class FixedSizePagingController<Key, Data> implements PagingController<Key> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildItemChangedLog(Key key, String message) {
|
private String buildItemChangedLog(Key key, String message) {
|
||||||
return "[onDataItemInserted(" + key + "), size: " + loadState.size() + "] " + message;
|
return "[onDataItemChanged(" + key + "), size: " + loadState.size() + "] " + message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@ package org.signal.paging;
|
||||||
|
|
||||||
|
|
||||||
public interface PagingController<Key> {
|
public interface PagingController<Key> {
|
||||||
|
int POSITION_END = -1;
|
||||||
|
|
||||||
void onDataNeededAroundIndex(int aroundIndex);
|
void onDataNeededAroundIndex(int aroundIndex);
|
||||||
void onDataInvalidated();
|
void onDataInvalidated();
|
||||||
void onDataItemChanged(Key key);
|
void onDataItemChanged(Key key);
|
||||||
|
|
Ładowanie…
Reference in New Issue