diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 690bb2a215..0f34f65a06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -1049,7 +1049,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect Runnable deleteForEveryone = () -> { SignalExecutors.BOUNDED.execute(() -> { for (MessageRecord message : messageRecords) { - MessageSender.sendRemoteDelete(ApplicationDependencies.getApplication(), message.getId(), message.isMms()); + MessageSender.sendRemoteDelete(message.getId(), message.isMms()); } }); }; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index 90aed18ac0..b5f98ff35c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -10,6 +10,7 @@ import com.annimon.stream.Collectors; import com.annimon.stream.Stream; import com.google.protobuf.ByteString; +import org.signal.core.util.SetUtil; import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.util.Pair; 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.util.GroupUtil; import org.thoughtcrime.securesms.util.RecipientAccessList; -import org.signal.core.util.SetUtil; import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.api.crypto.ContentHint; @@ -65,7 +65,6 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; -import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Optional; @@ -337,8 +336,6 @@ public final class PushGroupSendJob extends PushSendJob { groupMessageBuilder.withBody(null); } } catch (NoSuchMessageException e) { - // The story has probably expired - // TODO [stories] check what should happen in this case throw new UndeliverableMessageException(e); } } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java index 80a56b2bef..4c39133660 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java @@ -239,8 +239,6 @@ public class PushMediaSendJob extends PushSendJob { mediaMessageBuilder.withBody(null); } } catch (NoSuchMessageException e) { - // The story has probably expired - // TODO [stories] check what should happen in this case throw new UndeliverableMessageException(e); } } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java index c9e5e5823e..d3195c9073 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -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(); db.markAsRemoteDelete(messageId); db.markAsSending(messageId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt index 311258eed6..949e73e82c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt @@ -45,7 +45,8 @@ object StoryContextMenu { val uri: Uri? = mediaMessageRecord?.slideDeck?.firstSlide?.uri val contentType: String? = mediaMessageRecord?.slideDeck?.firstSlide?.contentType 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 } @@ -176,7 +177,6 @@ object StoryContextMenu { } ) } else { - // TODO [stories] -- Final icon add( ActionItem(R.drawable.ic_check_circle_24, context.getString(R.string.StoriesLandingItem__unhide_story)) { callbacks.onUnhide() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsFragment.kt index 26b571c82d..22e0605bfe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsFragment.kt @@ -56,16 +56,6 @@ class PrivateStorySettingsFragment : DSLSettingsFragment( } 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) customPref( PrivateStoryItem.AddViewerModel( diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/ReplyBody.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/ReplyBody.kt new file mode 100644 index 0000000000..04255ca225 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/ReplyBody.kt @@ -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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt index 6e1d3fe8ce..9ff059b298 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt @@ -5,17 +5,17 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsColumns import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies -import org.thoughtcrime.securesms.recipients.Recipient -class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSource { +class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSource { override fun size(): Int { return SignalDatabase.mms.getNumberOfStoryReplies(parentStoryId) } - override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList { - val results: MutableList = ArrayList(length) + override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList { + val results: MutableList = ArrayList(length) SignalDatabase.mms.getStoryReplies(parentStoryId).use { cursor -> cursor.moveToPosition(start - 1) val reader = MmsDatabase.Reader(cursor) @@ -27,48 +27,21 @@ class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSour return results } - override fun load(key: StoryGroupReplyItemData.Key?): StoryGroupReplyItemData? { - throw UnsupportedOperationException() + override fun load(key: MessageId): ReplyBody { + 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 } - private fun readRowFromRecord(record: MmsMessageRecord): StoryGroupReplyItemData { + private fun readRowFromRecord(record: MmsMessageRecord): ReplyBody { return when { - record.isRemoteDelete -> readRemoteDeleteFromRecord(record) - MmsSmsColumns.Types.isStoryReaction(record.type) -> readReactionFromRecord(record) - else -> readTextFromRecord(record) - } - } - - 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( + record.isRemoteDelete -> ReplyBody.RemoteDelete(record) + MmsSmsColumns.Types.isStoryReaction(record.type) -> ReplyBody.Reaction(record) + else -> ReplyBody.Text( ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(ApplicationDependencies.getApplication(), record) ) - ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt index a21f680c3e..9f7b7689cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt @@ -6,12 +6,14 @@ import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.widget.Toast +import androidx.annotation.ColorInt import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehaviorHack 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.kotlin.subscribeBy 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.MentionsPickerViewModel 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.jobs.RetrieveProfileJob 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.RecipientId 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.StoryViewsAndRepliesPagerParent import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReactionBar @@ -64,6 +69,26 @@ class StoryGroupReplyFragment : ReactWithAnyEmojiBottomSheetDialogFragment.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( factoryProducer = { StoryGroupReplyViewModel.Factory(storyId, StoryGroupReplyRepository()) @@ -107,7 +132,7 @@ class StoryGroupReplyFragment : get() = requireArguments().getInt(ARG_GROUP_REPLY_START_POSITION, -1) private lateinit var recyclerView: RecyclerView - private lateinit var adapter: PagingMappingAdapter + private lateinit var adapter: PagingMappingAdapter private lateinit var dataObserver: RecyclerView.AdapterDataObserver private lateinit var composer: StoryReplyComposer @@ -131,7 +156,10 @@ class StoryGroupReplyFragment : val emptyNotice: View = requireView().findViewById(R.id.empty_notice) - adapter = PagingMappingAdapter() + adapter = PagingMappingAdapter().apply { + setPagingController(viewModel.pagingController) + } + val layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) recyclerView.layoutManager = layoutManager recyclerView.adapter = adapter @@ -142,37 +170,34 @@ class StoryGroupReplyFragment : onPageSelected(findListener()?.selectedChild ?: StoryViewsAndRepliesPagerParent.Child.REPLIES) - viewModel.state.observe(viewLifecycleOwner) { state -> - if (markReadHelper == null && state.threadId > 0L) { - if (isResumed) { - ApplicationDependencies.getMessageNotifier().setVisibleThread(ConversationId(state.threadId, storyId)) + var firstSubmit = true + + viewModel.state + .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) { - markReadHelper?.onViewsRevealed(System.currentTimeMillis()) + adapter.submitList(getConfiguration(state.replies).toMappingModelList()) { + 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() adapter.registerAdapterDataObserver(dataObserver) @@ -212,74 +237,51 @@ class StoryGroupReplyFragment : val lastVisibleItem = (recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() val adapterItem = adapter.getItem(lastVisibleItem) - if (adapterItem == null || adapterItem !is StoryGroupReplyItem.DataWrapper) { + if (adapterItem == null || adapterItem !is StoryGroupReplyItem.Model) { return } - markReadHelper?.onViewsRevealed(adapterItem.storyGroupReplyItemData.sentAtMillis) + markReadHelper?.onViewsRevealed(adapterItem.replyBody.sentAtMillis) } - private fun getConfiguration(pageData: List): DSLConfiguration { + private fun getConfiguration(pageData: List): DSLConfiguration { return configure { pageData.forEach { - when (it.replyBody) { - is StoryGroupReplyItemData.ReplyBody.Text -> { + when (it) { + is ReplyBody.Text -> { customPref( StoryGroupReplyItem.TextModel( - storyGroupReplyItemData = it, - text = it.replyBody, - nameColor = colorizer.getIncomingGroupSenderColor( - 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.") - } - } - }, + text = it, + nameColor = it.sender.getStoryGroupReplyColor(), + onCopyClick = { s -> onCopyClick(s) }, onMentionClick = { recipientId -> RecipientBottomSheetDialogFragment .create(recipientId, null) .show(childFragmentManager, null) - } + }, + onDeleteClick = { m -> onDeleteClick(m) }, + onTapForDetailsClick = { m -> onTapForDetailsClick(m) } ) ) } - is StoryGroupReplyItemData.ReplyBody.Reaction -> { + is ReplyBody.Reaction -> { customPref( StoryGroupReplyItem.ReactionModel( - storyGroupReplyItemData = it, - reaction = it.replyBody, - nameColor = colorizer.getIncomingGroupSenderColor( - requireContext(), - it.sender - ) + reaction = it, + nameColor = it.sender.getStoryGroupReplyColor(), + onCopyClick = { s -> onCopyClick(s) }, + onDeleteClick = { m -> onDeleteClick(m) }, + onTapForDetailsClick = { m -> onTapForDetailsClick(m) } ) ) } - is StoryGroupReplyItemData.ReplyBody.RemoteDelete -> { + is ReplyBody.RemoteDelete -> { customPref( StoryGroupReplyItem.RemoteDeleteModel( - storyGroupReplyItemData = it, - remoteDelete = it.replyBody, - nameColor = colorizer.getIncomingGroupSenderColor( - requireContext(), - 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.") - } - } - }, + remoteDelete = it, + nameColor = it.sender.getStoryGroupReplyColor(), + onDeleteClick = { m -> onDeleteClick(m) }, + onTapForDetailsClick = { m -> onTapForDetailsClick(m) } ) ) } @@ -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) { currentChild = child updateNestedScrolling() @@ -376,8 +409,7 @@ class StoryGroupReplyFragment : composer.closeEmojiSearch() } - override fun onReactWithAnyEmojiDialogDismissed() { - } + override fun onReactWithAnyEmojiDialogDismissed() = Unit override fun onReactWithAnyEmojiSelected(emoji: String) { 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) { lifecycleDisposable += StoryGroupReplySender.sendReply(requireContext(), storyId, body, mentions) .observeOn(AndroidSchedulers.mainThread()) @@ -505,7 +491,7 @@ class StoryGroupReplyFragment : } override fun onMessageResentAfterSafetyNumberChange() { - error("Should never get here.") + Log.i(TAG, "Message resent") } override fun onCanceled() { @@ -514,6 +500,37 @@ class StoryGroupReplyFragment : 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 { fun onStartDirectReply(recipientId: RecipientId) fun requestFullScreen(fullscreen: Boolean) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItem.kt index 9fd0eddeae..309d10f520 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItem.kt @@ -9,17 +9,23 @@ import android.text.style.ClickableSpan import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.annotation.CallSuper 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.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.AlertView import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.DeliveryStatusView import org.thoughtcrime.securesms.components.FromTextView import org.thoughtcrime.securesms.components.emoji.EmojiImageView import org.thoughtcrime.securesms.components.emoji.EmojiTextView import org.thoughtcrime.securesms.components.mention.MentionAnnotation import org.thoughtcrime.securesms.components.menu.ActionItem 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.RecipientId 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.MappingModel 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 java.util.Locale +typealias OnCopyClick = (CharSequence) -> Unit +typealias OnDeleteClick = (MessageRecord) -> Unit +typealias OnTapForDetailsClick = (MessageRecord) -> Unit + object StoryGroupReplyItem { private const val NAME_COLOR_CHANGED = 1 fun register(mappingAdapter: MappingAdapter) { 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(RemoteDeleteModel::class.java, LayoutFactory(::RemoteDeleteViewHolder, R.layout.stories_group_remote_delete_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_text_reply_item)) + } + + sealed class Model( + val replyBody: ReplyBody, + @ColorInt val nameColor: Int, + val onCopyClick: OnCopyClick?, + val onDeleteClick: OnDeleteClick, + val onTapForDetailsClick: OnTapForDetailsClick + ) : MappingModel { + + 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( - override val storyGroupReplyItemData: StoryGroupReplyItemData, - val text: StoryGroupReplyItemData.ReplyBody.Text, - @ColorInt val nameColor: Int, - val onCopyClick: (TextModel) -> Unit, - val onDeleteClick: (TextModel) -> Unit, - val onMentionClick: (RecipientId) -> Unit - ) : PreferenceModel(), DataWrapper { - override fun areItemsTheSame(newItem: TextModel): Boolean { - return storyGroupReplyItemData.sender == newItem.storyGroupReplyItemData.sender && - storyGroupReplyItemData.sentAtMillis == newItem.storyGroupReplyItemData.sentAtMillis - } - - 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, 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 - } - } - } + val text: ReplyBody.Text, + val onMentionClick: (RecipientId) -> Unit, + @ColorInt nameColor: Int, + onCopyClick: OnCopyClick, + onDeleteClick: OnDeleteClick, + onTapForDetailsClick: OnTapForDetailsClick + ) : Model( + replyBody = text, + nameColor = nameColor, + onCopyClick = onCopyClick, + onDeleteClick = onDeleteClick, + onTapForDetailsClick = onTapForDetailsClick + ) class ReactionModel( - override val storyGroupReplyItemData: StoryGroupReplyItemData, - val reaction: StoryGroupReplyItemData.ReplyBody.Reaction, - @ColorInt val nameColor: Int - ) : PreferenceModel(), DataWrapper { - override fun areItemsTheSame(newItem: ReactionModel): Boolean { - return storyGroupReplyItemData.sender == newItem.storyGroupReplyItemData.sender && - storyGroupReplyItemData.sentAtMillis == newItem.storyGroupReplyItemData.sentAtMillis - } + val reaction: ReplyBody.Reaction, + @ColorInt nameColor: Int, + onCopyClick: OnCopyClick, + onDeleteClick: OnDeleteClick, + onTapForDetailsClick: OnTapForDetailsClick + ) : Model( + replyBody = reaction, + nameColor = nameColor, + onCopyClick = onCopyClick, + onDeleteClick = onDeleteClick, + onTapForDetailsClick = onTapForDetailsClick + ) - override fun areContentsTheSame(newItem: ReactionModel): Boolean { - return storyGroupReplyItemData == newItem.storyGroupReplyItemData && - storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) && - nameColor == newItem.nameColor && - super.areContentsTheSame(newItem) - } + class RemoteDeleteModel( + val remoteDelete: ReplyBody.RemoteDelete, + @ColorInt nameColor: Int, + onDeleteClick: OnDeleteClick, + onTapForDetailsClick: OnTapForDetailsClick + ) : Model( + replyBody = remoteDelete, + nameColor = nameColor, + onCopyClick = null, + onDeleteClick = onDeleteClick, + onTapForDetailsClick = onTapForDetailsClick + ) - override fun getChangePayload(newItem: ReactionModel): Any? { - return if (nameColor != newItem.nameColor && - storyGroupReplyItemData == newItem.storyGroupReplyItemData && - storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) && - super.areContentsTheSame(newItem) - ) { - NAME_COLOR_CHANGED - } else { - null - } - } - } + private abstract class BaseViewHolder>(itemView: View) : MappingViewHolder(itemView) { + protected val bubble: View = findViewById(R.id.bubble) + protected val avatar: AvatarImageView = findViewById(R.id.avatar) + protected val name: FromTextView = findViewById(R.id.name) + protected val body: EmojiTextView = findViewById(R.id.body) + protected val date: TextView = findViewById(R.id.viewed_at) + protected val dateBelow: TextView = findViewById(R.id.viewed_at_below) + protected val status: DeliveryStatusView = findViewById(R.id.delivery_status) + protected val alertView: AlertView = findViewById(R.id.alert_view) + protected val reaction: EmojiImageView = itemView.findViewById(R.id.reaction) - interface DataWrapper { - val storyGroupReplyItemData: StoryGroupReplyItemData - } - - private abstract class BaseViewHolder(itemView: View) : MappingViewHolder(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(itemView) { - - override fun bind(model: TextModel) { + @CallSuper + override fun bind(model: T) { itemView.setOnLongClickListener { displayContextMenu(model) true @@ -171,29 +161,74 @@ object StoryGroupReplyItem { return } - AvatarUtil.loadIconIntoImageView(model.storyGroupReplyItemData.sender, avatar, DimensionUnit.DP.toPixels(28f).toInt()) - name.text = resolveName(context, model.storyGroupReplyItemData.sender) + AvatarUtil.loadIconIntoImageView(model.replyBody.sender, avatar, DimensionUnit.DP.toPixels(28f).toInt()) + 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() + 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(itemView) { + + override fun bind(model: TextModel) { + super.bind(model) body.movementMethod = LinkMovementMethod.getInstance() body.text = model.text.message.getDisplayBody(context).apply { 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) { @@ -211,59 +246,36 @@ object StoryGroupReplyItem { model.onMentionClick(mentionedRecipientId) } - override fun updateDrawState(ds: TextPaint) {} + override fun updateDrawState(ds: TextPaint) = Unit + } + } + + private class ReactionViewHolder(itemView: View) : BaseViewHolder(itemView) { + + init { + reaction.visible = true + bubble.visibility = View.INVISIBLE + itemView.padding(bottom = 0) + body.setText(R.string.StoryGroupReactionReplyItem__reacted_to_the_story) + body.updateLayoutParams { + 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(itemView) { - - override fun bind(model: RemoteDeleteModel) { - itemView.setOnLongClickListener { - 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(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) + init { + bubble.setBackgroundResource(R.drawable.rounded_outline_secondary_18) + body.setText(R.string.ThreadRecord_this_message_was_deleted) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItemData.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItemData.kt deleted file mode 100644 index 5acb4b66d0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItemData.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyRepository.kt index aca01125a7..b41b750757 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyRepository.kt @@ -1,14 +1,23 @@ 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.Single 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.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.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId class StoryGroupReplyRepository { @@ -19,38 +28,45 @@ class StoryGroupReplyRepository { }.subscribeOn(Schedulers.io()) } - fun getPagedReplies(parentStoryId: Long): Observable> { - return Observable.create> { emitter -> - fun refresh() { - emitter.onNext(PagedData.createForLiveData(StoryGroupReplyDataSource(parentStoryId), PagingConfig.Builder().build())) + fun getPagedReplies(parentStoryId: Long): Observable> { + return getThreadId(parentStoryId) + .toObservable() + .flatMap { threadId -> + Observable.create> { emitter -> + val pagedData: ObservablePagedData = PagedData.createForObservable(StoryGroupReplyDataSource(parentStoryId), PagingConfig.Builder().build()) + val controller: PagingController = 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 { - return Single.fromCallable { - SignalDatabase.mms.getMessageRecord(storyId).individualRecipient.id - }.subscribeOn(Schedulers.io()) + fun getNameColorsMap(storyId: Long, sessionMemberCache: MutableMap>): Observable> { + return Single.fromCallable { SignalDatabase.mms.getMessageRecord(storyId).individualRecipient.id } + .subscribeOn(Schedulers.io()) + .flatMapObservable { recipientId -> + Observable.create?> { emitter -> + val nameColorsMapLiveData = NameColors.getNameColorsMapLiveData(MutableLiveData(recipientId), sessionMemberCache) + val observer = Observer> { emitter.onNext(it) } + + ThreadUtil.postToMain { nameColorsMapLiveData.observeForever(observer) } + + emitter.setCancellable { ThreadUtil.postToMain { nameColorsMapLiveData.removeObserver(observer) } } + }.subscribeOn(Schedulers.io()) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyState.kt index ef66710fed..2bd975df88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyState.kt @@ -5,10 +5,12 @@ import org.thoughtcrime.securesms.recipients.RecipientId data class StoryGroupReplyState( val threadId: Long = 0L, - val noReplies: Boolean = true, + val replies: List = emptyList(), val nameColors: Map = emptyMap(), val loadState: LoadState = LoadState.INIT ) { + val noReplies: Boolean = replies.isEmpty() + enum class LoadState { INIT, READY diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyViewModel.kt index 75475ff414..81fd72e42d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyViewModel.kt @@ -1,57 +1,52 @@ 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.ViewModelProvider -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign -import org.signal.paging.LivePagedData -import org.signal.paging.PagingController +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.signal.paging.ProxyPagingController import org.thoughtcrime.securesms.conversation.colors.NameColors +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.groups.GroupId 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() { private val sessionMemberCache: MutableMap> = NameColors.createSessionMembersCache() - private val store = Store(StoryGroupReplyState()) + private val store = RxStore(StoryGroupReplyState()) private val disposables = CompositeDisposable() val stateSnapshot: StoryGroupReplyState = store.state - val state: LiveData = store.stateLiveData + val state: Flowable = store.stateFlowable - private val pagedData: MutableLiveData> = MutableLiveData() - - val pagingController: LiveData> - val pageData: LiveData> + val pagingController: ProxyPagingController = ProxyPagingController() init { disposables += repository.getThreadId(storyId).subscribe { threadId -> store.update { it.copy(threadId = threadId) } } - disposables += repository.getPagedReplies(storyId).subscribe { - pagedData.postValue(it) - } - - pagingController = Transformations.map(pagedData) { it.controller } - pageData = Transformations.switchMap(pagedData) { it.data } - store.update(pageData) { data, state -> - state.copy( - noReplies = data.isEmpty(), - loadState = StoryGroupReplyState.LoadState.READY - ) - } - - disposables += repository.getStoryOwner(storyId).observeOn(AndroidSchedulers.mainThread()).subscribe { recipientId -> - store.update(NameColors.getNameColorsMapLiveData(MutableLiveData(recipientId), sessionMemberCache)) { nameColors, state -> - state.copy(nameColors = nameColors) + disposables += repository.getPagedReplies(storyId) + .doOnNext { pagingController.set(it.controller) } + .flatMap { it.data } + .subscribeBy { data -> + store.update { state -> + state.copy( + replies = data, + loadState = StoryGroupReplyState.LoadState.READY + ) + } + } + + disposables += repository.getNameColorsMap(storyId, sessionMemberCache) + .subscribeBy { nameColors -> + store.update { state -> + state.copy(nameColors = nameColors) + } } - } } override fun onCleared() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt index 85aa8fd425..9d4c143b9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt @@ -8,7 +8,6 @@ import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask @@ -75,7 +74,7 @@ object DeleteDialog { private fun deleteForEveryone(messageRecords: Set, emitter: SingleEmitter) { SignalExecutors.BOUNDED.execute { messageRecords.forEach { message -> - MessageSender.sendRemoteDelete(ApplicationDependencies.getApplication(), message.id, message.isMms) + MessageSender.sendRemoteDelete(message.id, message.isMms) } emitter.onSuccess(false) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt index 517c160b2a..f8200a344e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt @@ -1,12 +1,24 @@ package org.thoughtcrime.securesms.util import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet var View.visible: Boolean get() { return this.visibility == View.VISIBLE } - set(value) { 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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/MappingViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/MappingViewHolder.java index fde4990cdd..2c1478309b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/MappingViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/MappingViewHolder.java @@ -21,7 +21,7 @@ public abstract class MappingViewHolder extends RecyclerView.ViewHolder { payload = new LinkedList<>(); } - public T findViewById(@IdRes int id) { + public final T findViewById(@IdRes int id) { return itemView.findViewById(id); } diff --git a/app/src/main/res/layout/stories_group_reaction_reply_item.xml b/app/src/main/res/layout/stories_group_reaction_reply_item.xml deleted file mode 100644 index a6ae50807d..0000000000 --- a/app/src/main/res/layout/stories_group_reaction_reply_item.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/stories_group_remote_delete_item.xml b/app/src/main/res/layout/stories_group_remote_delete_item.xml deleted file mode 100644 index 20ecec4328..0000000000 --- a/app/src/main/res/layout/stories_group_remote_delete_item.xml +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/stories_group_text_reply_item.xml b/app/src/main/res/layout/stories_group_text_reply_item.xml index 3a9f922269..0f2bcf860b 100644 --- a/app/src/main/res/layout/stories_group_text_reply_item.xml +++ b/app/src/main/res/layout/stories_group_text_reply_item.xml @@ -19,86 +19,156 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" /> - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + app:layout_constraintTop_toTopOf="parent" /> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d33207623..a6c1c8670f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4561,6 +4561,8 @@ Delete story? This story will be deleted for you and everyone who received it. + + Unable to save %1$d view diff --git a/paging/lib/src/main/java/org/signal/paging/FixedSizePagingController.java b/paging/lib/src/main/java/org/signal/paging/FixedSizePagingController.java index fc36fe813a..8bbe842f8a 100644 --- a/paging/lib/src/main/java/org/signal/paging/FixedSizePagingController.java +++ b/paging/lib/src/main/java/org/signal/paging/FixedSizePagingController.java @@ -21,7 +21,7 @@ import java.util.concurrent.Executor; */ class FixedSizePagingController implements PagingController { - 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 boolean DEBUG = false; @@ -182,10 +182,15 @@ class FixedSizePagingController implements PagingController { } @Override - public void onDataItemInserted(Key key, int position) { - if (DEBUG) Log.d(TAG, buildItemInsertedLog(key, position, "")); + public void onDataItemInserted(Key key, int inputPosition) { + if (DEBUG) Log.d(TAG, buildItemInsertedLog(key, inputPosition, "")); FETCH_EXECUTOR.execute(() -> { + int position = inputPosition; + if (position == POSITION_END) { + position = data.size(); + } + if (keyToPosition.containsKey(key)) { Log.w(TAG, "Notified of key " + key + " being inserted at " + position + ", but the item already exists!"); return; @@ -245,6 +250,6 @@ class FixedSizePagingController implements PagingController { } private String buildItemChangedLog(Key key, String message) { - return "[onDataItemInserted(" + key + "), size: " + loadState.size() + "] " + message; + return "[onDataItemChanged(" + key + "), size: " + loadState.size() + "] " + message; } } diff --git a/paging/lib/src/main/java/org/signal/paging/PagingController.java b/paging/lib/src/main/java/org/signal/paging/PagingController.java index e11020e60e..c2d31e22fc 100644 --- a/paging/lib/src/main/java/org/signal/paging/PagingController.java +++ b/paging/lib/src/main/java/org/signal/paging/PagingController.java @@ -2,6 +2,8 @@ package org.signal.paging; public interface PagingController { + int POSITION_END = -1; + void onDataNeededAroundIndex(int aroundIndex); void onDataInvalidated(); void onDataItemChanged(Key key);