@ -46,7 +46,6 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
@ -57,8 +56,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -106,6 +103,7 @@ import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.attachments.ScreenshotObserver
import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity
@ -175,12 +173,11 @@ import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.SimpleTextWatcher
import org.thoughtcrime.securesms.util.isScrolledToBottom
import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.util.toPx
import java.lang.ref.WeakReference
import java.time.Instant
import java.util.Date
import java.util.Locale
import java.util.concurrent.ExecutionException
import java.util.concurrent.atomic.AtomicBoolean
@ -191,8 +188,6 @@ import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = " ConversationActivityV2 "
@ -281,6 +276,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val isScrolledToBottom : Boolean
get ( ) = binding ?. conversationRecyclerView ?. isScrolledToBottom ?: true
private val isScrolledToWithin30dpOfBottom : Boolean
get ( ) = binding ?. conversationRecyclerView ?. isScrolledToWithin30dpOfBottom ?: true
private val layoutManager : LinearLayoutManager ?
get ( ) { return binding ?. conversationRecyclerView ?. layoutManager as LinearLayoutManager ? }
@ -336,6 +334,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
lifecycleCoroutineScope = lifecycleScope
)
adapter . visibleMessageViewDelegate = this
// Register an AdapterDataObserver to scroll us to the bottom of the RecyclerView if we're
// already near the the bottom and the data changes.
adapter . registerAdapterDataObserver ( ConversationAdapterDataObserver ( binding ?. conversationRecyclerView !! , adapter ) )
adapter
}
@ -352,6 +355,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private lateinit var reactionDelegate : ConversationReactionDelegate
private val reactWithAnyEmojiStartPage = - 1
// Properties for what message indices are visible previously & now, as well as the scroll state
private var previousLastVisibleRecyclerViewIndex : Int = RecyclerView . NO _POSITION
private var currentLastVisibleRecyclerViewIndex : Int = RecyclerView . NO _POSITION
private var recyclerScrollState : Int = RecyclerView . SCROLL _STATE _IDLE
// region Settings
companion object {
// Extras
@ -375,12 +383,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
super . onCreate ( savedInstanceState , isReady )
binding = ActivityConversationV2Binding . inflate ( layoutInflater )
setContentView ( binding !! . root )
// messageIdToScroll
messageToScrollTimestamp . set ( intent . getLongExtra ( SCROLL _MESSAGE _ID , - 1 ) )
messageToScrollAuthor . set ( intent . getParcelableExtra ( SCROLL _MESSAGE _AUTHOR ) )
val recipient = viewModel . recipient
val openGroup = recipient . let { viewModel . openGroup }
if ( recipient == null || ( recipient . is OpenGroup Recipient && openGroup == null ) ) {
if ( recipient == null || ( recipient . is Community Recipient && openGroup == null ) ) {
Toast . makeText ( this , " This thread has been deleted. " , Toast . LENGTH _LONG ) . show ( )
return finish ( )
}
@ -390,6 +399,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpLinkPreviewObserver ( )
restoreDraftIfNeeded ( )
setUpUiStateObserver ( )
binding !! . scrollToBottomButton . setOnClickListener {
val layoutManager = ( binding ?. conversationRecyclerView ?. layoutManager as ? LinearLayoutManager ) ?: return @setOnClickListener
val targetPosition = if ( reverseMessageList ) 0 else adapter . itemCount
@ -419,9 +429,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpBlockedBanner ( )
binding !! . searchBottomBar . setEventListener ( this )
updateSendAfterApprovalText ( )
showOrHideInputIfNeeded ( )
setUpMessageRequestsBar ( )
// Note: Do not `showOrHideInputIfNeeded` here - we'll never start this activity w/ the
// keyboard visible and have no need to immediately display it.
val weakActivity = WeakReference ( this )
lifecycleScope . launch ( Dispatchers . IO ) {
@ -563,17 +575,45 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding !! . conversationRecyclerView . addOnScrollListener ( object : RecyclerView . OnScrollListener ( ) {
override fun onScrolled ( recyclerView : RecyclerView , dx : Int , dy : Int ) {
if ( recyclerScrollState == RecyclerView . SCROLL _STATE _IDLE ) {
scrollToMostRecentMessageIfWeShould ( )
}
handleRecyclerViewScrolled ( )
}
override fun onScrollStateChanged ( recyclerView : RecyclerView , newState : Int ) {
recyclerScrollState = newState
}
} )
}
private fun scrollToMostRecentMessageIfWeShould ( ) {
// Grab an initial 'previous' last visible message..
if ( previousLastVisibleRecyclerViewIndex == RecyclerView . NO _POSITION ) {
previousLastVisibleRecyclerViewIndex = layoutManager ?. findLastVisibleItemPosition ( ) !!
}
binding !! . conversationRecyclerView . addOnLayoutChangeListener { _ , _ , _ , _ , _ , _ , _ , _ , _ ->
showScrollToBottomButtonIfApplicable ( )
// ..and grab the 'current' last visible message.
currentLastVisibleRecyclerViewIndex = layoutManager ?. findLastVisibleItemPosition ( ) !!
// If the current last visible message index is less than the previous one (i.e. we've
// lost visibility of one or more messages due to showing the IME keyboard) AND we're
// at the bottom of the message feed..
val atBottomAndTrueLastNoLongerVisible = currentLastVisibleRecyclerViewIndex !! <= previousLastVisibleRecyclerViewIndex !! && ! binding ?. scrollToBottomButton ?. isVisible !!
// ..OR we're at the last message or have received a new message..
val atLastOrReceivedNewMessage = currentLastVisibleRecyclerViewIndex == ( adapter . itemCount - 1 )
// ..then scroll the recycler view to the last message on resize. Note: We cannot just call
// scroll/smoothScroll - we have to `post` it or nothing happens!
if ( atBottomAndTrueLastNoLongerVisible || atLastOrReceivedNewMessage ) {
binding ?. conversationRecyclerView ?. post {
binding ?. conversationRecyclerView ?. smoothScrollToPosition ( adapter . itemCount )
}
}
// Update our previous last visible view index to the current one
previousLastVisibleRecyclerViewIndex = currentLastVisibleRecyclerViewIndex
}
// called from onCreate
@ -760,13 +800,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// of the first unread message in the middle of the screen
if ( isFirstLoad && ! reverseMessageList ) {
layoutManager ?. scrollToPositionWithOffset ( lastSeenItemPosition , ( ( layoutManager ?. height ?: 0 ) / 2 ) )
if ( shouldHighlight ) { highlightViewAtPosition ( lastSeenItemPosition ) }
return lastSeenItemPosition
}
if ( lastSeenItemPosition <= 3 ) { return lastSeenItemPosition }
binding ?. conversationRecyclerView ?. scrollToPosition ( lastSeenItemPosition )
return lastSeenItemPosition
}
@ -931,11 +970,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
view . glide = glide
view . onCandidateSelected = { handleMentionSelected ( it ) }
additionalContentContainer . addView ( view )
val candidates = MentionsManager . getMentionCandidates ( query , viewModel . threadId , recipient . is OpenGroup Recipient)
val candidates = MentionsManager . getMentionCandidates ( query , viewModel . threadId , recipient . is Community Recipient)
this . mentionCandidatesView = view
view . show ( candidates , viewModel . threadId )
} else {
val candidates = MentionsManager . getMentionCandidates ( query , viewModel . threadId , recipient . is OpenGroup Recipient)
val candidates = MentionsManager . getMentionCandidates ( query , viewModel . threadId , recipient . is Community Recipient)
this . mentionCandidatesView !! . setMentionCandidates ( candidates )
}
isShowingMentionCandidatesView = true
@ -1040,8 +1079,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun handleRecyclerViewScrolled ( ) {
val binding = binding ?: return
// Note: The typing indicate is whether the other person / other people are typing - it has
// nothing to do with the IME keyboard state.
val wasTypingIndicatorVisibleBefore = binding . typingIndicatorViewContainer . isVisible
binding . typingIndicatorViewContainer . isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
showScrollToBottomButtonIfApplicable ( )
val maybeTargetVisiblePosition = if ( reverseMessageList ) layoutManager ?. findFirstVisibleItemPosition ( ) else layoutManager ?. findLastVisibleItemPosition ( )
val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView . NO _POSITION
@ -1069,6 +1112,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val blindedRecipient = viewModel . blindedRecipient
val binding = binding ?: return
val openGroup = viewModel . openGroup
val ( textResource , insertParam ) = when {
recipient . isLocalNumber -> R . string . activity _conversation _empty _state _note _to _self to null
openGroup != null && ! openGroup . canWrite -> R . string . activity _conversation _empty _state _read _only to recipient . toShortString ( )
@ -1148,7 +1192,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
override fun copyOpenGroupUrl ( thread : Recipient ) {
if ( ! thread . is OpenGroup Recipient) { return }
if ( ! thread . is Community Recipient) { return }
val threadId = threadDb . getThreadIdIfExistsFor ( thread ) ?: return
val openGroup = lokiThreadDb . getOpenGroupChat ( threadId ) ?: return
@ -1286,6 +1330,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
sendEmojiRemoval ( emoji , messageRecord )
} else {
sendEmojiReaction ( emoji , messageRecord )
RecentEmojiPageModel . onCodePointSelected ( emoji ) // Save to recently used reaction emojis
}
}
@ -1312,7 +1357,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else originalMessage . individualRecipient . address
// Send it
reactionMessage . reaction = Reaction . from ( originalMessage . timestamp , originalAuthor . serialize ( ) , emoji , true )
if ( recipient . is OpenGroup Recipient) {
if ( recipient . is Community Recipient) {
val messageServerId = lokiMessageDb . getServerID ( originalMessage . id , ! originalMessage . isMms ) ?: return
viewModel . openGroup ?. let {
OpenGroupApi . addReaction ( it . room , it . server , messageServerId , emoji )
@ -1336,7 +1381,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else originalMessage . individualRecipient . address
message . reaction = Reaction . from ( originalMessage . timestamp , originalAuthor . serialize ( ) , emoji , false )
if ( recipient . is OpenGroup Recipient) {
if ( recipient . is Community Recipient) {
val messageServerId = lokiMessageDb . getServerID ( originalMessage . id , ! originalMessage . isMms ) ?: return
viewModel . openGroup ?. let {
OpenGroupApi . deleteReaction ( it . room , it . server , messageServerId , emoji )
@ -1733,7 +1778,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
sendAttachments ( slideDeck . asAttachments ( ) , body )
}
INVITE _CONTACTS -> {
if ( viewModel . recipient ?. is OpenGroup Recipient != true ) { return }
if ( viewModel . recipient ?. is Community Recipient != true ) { return }
val extras = intent ?. extras ?: return
if ( !in tent . hasExtra ( selectedContactsKey ) ) { return }
val selectedContacts = extras . getStringArray ( selectedContactsKey ) !!
@ -1799,19 +1844,61 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
handleLongPress ( messages . first ( ) , 0 ) //TODO: begin selection mode
}
// The option to "Delete just for me" or "Delete for everyone"
private fun showDeleteOrDeleteForEveryoneInCommunityUI ( messages : Set < MessageRecord > ) {
val bottomSheet = DeleteOptionsBottomSheet ( )
bottomSheet . recipient = viewModel . recipient !!
bottomSheet . onDeleteForMeTapped = {
messages . forEach ( viewModel :: deleteLocally )
bottomSheet . dismiss ( )
endActionMode ( )
}
bottomSheet . onDeleteForEveryoneTapped = {
messages . forEach ( viewModel :: deleteForEveryone )
bottomSheet . dismiss ( )
endActionMode ( )
}
bottomSheet . onCancelTapped = {
bottomSheet . dismiss ( )
endActionMode ( )
}
bottomSheet . show ( supportFragmentManager , bottomSheet . tag )
}
private fun showDeleteLocallyUI ( messages : Set < MessageRecord > ) {
val messageCount = 1
showSessionDialog {
title ( resources . getQuantityString ( R . plurals . ConversationFragment _delete _selected _messages , messageCount , messageCount ) )
text ( resources . getQuantityString ( R . plurals . ConversationFragment _this _will _permanently _delete _all _n _selected _messages , messageCount , messageCount ) )
button ( R . string . delete ) { messages . forEach ( viewModel :: deleteLocally ) ; endActionMode ( ) }
cancelButton ( :: endActionMode )
}
}
// Note: The messages in the provided set may be a single message, or multiple if there are a
// group of selected messages.
override fun deleteMessages ( messages : Set < MessageRecord > ) {
val recipient = viewModel . recipient ?: return
val recipient = viewModel . recipient
if ( recipient == null ) {
Log . w ( " ConversationActivityV2 " , " Asked to delete messages but could not obtain viewModel recipient - aborting. " )
return
}
val allSentByCurrentUser = messages . all { it . isOutgoing }
val allHasHash = messages . all { lokiMessageDb . getMessageServerHash ( it . id , it . isMms ) != null }
if ( recipient . isOpenGroupRecipient ) {
val messageCount = 1
// If the recipient is a community OR a Note-to-Self then we delete the message for everyone
if ( recipient . isCommunityRecipient || recipient . isLocalNumber ) {
val messageCount = 1 // Only used for plurals string
showSessionDialog {
title ( resources . getQuantityString ( R . plurals . ConversationFragment _delete _selected _messages , messageCount , messageCount ) )
text ( resources . getQuantityString ( R . plurals . ConversationFragment _this _will _permanently _delete _all _n _selected _messages , messageCount , messageCount ) )
button ( R . string . delete ) { messages . forEach ( viewModel :: deleteForEveryone ) ; endActionMode ( ) }
button ( R . string . delete ) {
messages . forEach ( viewModel :: deleteForEveryone ) ; endActionMode ( )
}
cancelButton { endActionMode ( ) }
}
// Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone
} else if ( allSentByCurrentUser && allHasHash ) {
val bottomSheet = DeleteOptionsBottomSheet ( )
bottomSheet . recipient = recipient
@ -1830,13 +1917,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
endActionMode ( )
}
bottomSheet . show ( supportFragmentManager , bottomSheet . tag )
} else {
}
else // Finally, if this is a closed group and you are deleting someone else's message(s) then we can only delete locally.
{
val messageCount = 1
showSessionDialog {
title ( resources . getQuantityString ( R . plurals . ConversationFragment _delete _selected _messages , messageCount , messageCount ) )
text ( resources . getQuantityString ( R . plurals . ConversationFragment _this _will _permanently _delete _all _n _selected _messages , messageCount , messageCount ) )
button ( R . string . delete ) { messages . forEach ( viewModel :: deleteLocally ) ; endActionMode ( ) }
button ( R . string . delete ) {
messages . forEach ( viewModel :: deleteLocally ) ; endActionMode ( )
}
cancelButton ( :: endActionMode )
}
}
@ -1855,7 +1945,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showSessionDialog {
title ( R . string . ConversationFragment _ban _selected _user )
text ( " This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there. " )
button ( R . string . ban ) { viewModel . banAndDeleteAll ( messages . first ( ) .individualRecipient ); endActionMode ( ) }
button ( R . string . ban ) { viewModel . banAndDeleteAll ( messages . first ( ) ); endActionMode ( ) }
cancelButton ( :: endActionMode )
}
}
@ -1937,7 +2027,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val message = messages . first ( ) as MmsMessageRecord
// Do not allow the user to download a file attachment before it has finished downloading
// TODO: Localise the msg in this toast!
if ( message . isMediaPending ) {
Toast . makeText ( this , resources . getString ( R . string . conversation _activity _ _wait _until _attachment _has _finished _downloading ) , Toast . LENGTH _LONG ) . show ( )
return
@ -2107,4 +2196,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
// AdapterDataObserver implementation to scroll us to the bottom of the ConversationRecyclerView
// when we're already near the bottom and we send or receive a message.
inner class ConversationAdapterDataObserver ( val recyclerView : ConversationRecyclerView , val adapter : ConversationAdapter ) : RecyclerView . AdapterDataObserver ( ) {
override fun onChanged ( ) {
super . onChanged ( )
if ( recyclerView . isScrolledToWithin30dpOfBottom ) {
// Note: The adapter itemCount is zero based - so calling this with the itemCount in
// a non-zero based manner scrolls us to the bottom of the last message (including
// to the bottom of long messages as required by Jira SES-789 / GitHub 1364).
recyclerView . scrollToPosition ( adapter . itemCount )
}
}
}
}