Add sending and error states for story group replies.

fork-5.53.8
Cody Henthorne 2022-05-24 15:17:29 -04:00 zatwierdzone przez Alex Hart
rodzic a29bc1da8c
commit 2a91c67c51
23 zmienionych plików z 635 dodań i 707 usunięć

Wyświetl plik

@ -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());
} }
}); });
}; };

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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);

Wyświetl plik

@ -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()

Wyświetl plik

@ -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(

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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)
) )
) }
} }
} }

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)
} }
} }

Wyświetl plik

@ -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()
}
}

Wyświetl plik

@ -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())
}
} }
} }

Wyświetl plik

@ -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

Wyświetl plik

@ -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() {

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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);
} }

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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;
} }
} }

Wyświetl plik

@ -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);