Add LinkPreview support to CFV2.

This commit is contained in:
Alex Hart
2023-06-15 13:43:32 -03:00
committed by Nicholas Tinsley
parent 3bdffed8c9
commit 2fbcc23451
10 changed files with 321 additions and 79 deletions

View File

@@ -190,6 +190,7 @@ import org.thoughtcrime.securesms.groups.v2.GroupBlockJoinRequestResult
import org.thoughtcrime.securesms.invites.InviteActions
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
import org.thoughtcrime.securesms.longmessage.LongMessageFragment
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
@@ -302,6 +303,12 @@ class ConversationFragment :
)
}
private val linkPreviewViewModel: LinkPreviewViewModelV2 by viewModel {
LinkPreviewViewModelV2(
enablePlaceholder = false
)
}
private val groupCallViewModel: ConversationGroupCallViewModel by viewModels(
factoryProducer = {
ConversationGroupCallViewModel.Factory(args.threadId, conversationRecipientRepository)
@@ -668,6 +675,7 @@ class ConversationFragment :
.addTo(disposables)
initializeSearch()
initializeLinkPreviews()
inputPanel.setListener(InputPanelListener())
}
@@ -1031,6 +1039,32 @@ class ConversationFragment :
}
}
private fun initializeLinkPreviews() {
linkPreviewViewModel.linkPreviewState
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { state ->
if (state.isLoading) {
inputPanel.setLinkPreviewLoading()
} else if (state.hasLinks() && !state.linkPreview.isPresent) {
inputPanel.setLinkPreviewNoPreview(state.error)
} else {
inputPanel.setLinkPreview(GlideApp.with(this), state.linkPreview)
}
updateToggleButtonState()
}
.addTo(disposables)
}
private fun updateLinkPreviewState() {
if (/* TODO [cfv2] -- viewModel.isPushAvailable && */ !attachmentManager.isAttachmentPresent && context != null) {
linkPreviewViewModel.onEnabled()
linkPreviewViewModel.onTextChanged(composeText.textTrimmed.toString(), composeText.selectionStart, composeText.selectionEnd)
} else {
linkPreviewViewModel.onUserCancel()
}
}
private fun updateToggleButtonState() {
val buttonToggle: AnimatingToggle = binding.conversationInputPanel.buttonToggle
val quickAttachment: HidingLinearLayout = binding.conversationInputPanel.quickAttachmentToggle
@@ -1065,7 +1099,7 @@ class ConversationFragment :
buttonToggle.display(sendButton)
quickAttachment.hide()
if (!attachmentManager.isAttachmentPresent) { // todo [cfv2] && !linkPreviewViewModel.hasLinkPreviewUi()) {
if (!attachmentManager.isAttachmentPresent && !linkPreviewViewModel.hasLinkPreviewUi) {
inlineAttachment.show()
} else {
inlineAttachment.hide()
@@ -1103,7 +1137,8 @@ class ConversationFragment :
mentions = emptyList(),
bodyRanges = null,
messageToEdit = null,
quote = null
quote = null,
linkPreviews = emptyList()
)
}
@@ -1116,7 +1151,8 @@ class ConversationFragment :
scheduledDate: Long = -1,
slideDeck: SlideDeck? = if (attachmentManager.isAttachmentPresent) attachmentManager.buildSlideDeck() else null,
contacts: List<Contact> = emptyList(),
clearCompose: Boolean = true
clearCompose: Boolean = true,
linkPreviews: List<LinkPreview> = linkPreviewViewModel.onSend()
) {
val metricId = viewModel.recipientSnapshot?.let { if (it.isGroup == true) SignalLocalMetrics.GroupMessageSend.start() else SignalLocalMetrics.IndividualMessageSend.start() }
@@ -1129,7 +1165,8 @@ class ConversationFragment :
quote = quote,
mentions = mentions,
bodyRanges = bodyRanges,
contacts = contacts
contacts = contacts,
linkPreviews = linkPreviews
)
disposables += send
@@ -1164,7 +1201,7 @@ class ConversationFragment :
scrollToPositionDelegate.resetScrollPosition()
attachmentManager.cleanup()
// todo [cfv2] updateLinkPreviewState();
updateLinkPreviewState()
draftViewModel.onSendComplete()
@@ -2712,7 +2749,7 @@ class ConversationFragment :
}
override fun onCursorPositionChanged(start: Int, end: Int) {
// todo [cfv2] linkPreviewViewModel.onTextChanged(requireContext(), composeText.getTextTrimmed().toString(), start, end);
linkPreviewViewModel.onTextChanged(composeText.textTrimmed.toString(), start, end)
}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
@@ -2809,7 +2846,7 @@ class ConversationFragment :
}
override fun onLinkPreviewCanceled() {
// TODO [cfv2] Not yet implemented
linkPreviewViewModel.onUserCancel()
}
override fun onStickerSuggestionSelected(sticker: StickerRecord) {
@@ -2845,7 +2882,9 @@ class ConversationFragment :
private inner class AttachmentManagerListener : AttachmentManager.AttachmentListener {
override fun onAttachmentChanged() {
// TODO [cfv2] implement
// TODO [cfv2] handleSecurityChange(viewModel.getConversationStateSnapshot().getSecurityInfo());
updateToggleButtonState()
updateLinkPreviewState()
}
override fun onLocationRemoved() {

View File

@@ -72,6 +72,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.OutgoingMessage
@@ -193,7 +194,8 @@ class ConversationRepository(
quote: QuoteModel?,
mentions: List<Mention>,
bodyRanges: BodyRangeList?,
contacts: List<Contact>
contacts: List<Contact>,
linkPreviews: List<LinkPreview>
): Completable {
val sendCompletable = Completable.create { emitter ->
if (body.isEmpty() && slideDeck?.containsMediaSlide() != true) {
@@ -218,7 +220,8 @@ class ConversationRepository(
outgoingQuote = quote,
messageToEdit = messageToEdit?.id ?: 0,
mentions = mentions,
sharedContacts = contacts
sharedContacts = contacts,
linkPreviews = linkPreviews
)
MessageSender.send(

View File

@@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.mms.GlideRequests
@@ -297,7 +298,8 @@ class ConversationViewModel(
quote: QuoteModel?,
mentions: List<Mention>,
bodyRanges: BodyRangeList?,
contacts: List<Contact>
contacts: List<Contact>,
linkPreviews: List<LinkPreview>
): Completable {
return repository.sendMessage(
threadId = threadId,
@@ -310,7 +312,8 @@ class ConversationViewModel(
quote = quote,
mentions = mentions,
bodyRanges = bodyRanges,
contacts = contacts
contacts = contacts,
linkPreviews = linkPreviews
).observeOn(AndroidSchedulers.mainThread())
}

View File

@@ -12,6 +12,7 @@ import androidx.core.util.Consumer;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.signal.core.util.Hex;
import org.signal.core.util.Result;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.InvalidMessageException;
@@ -62,6 +63,8 @@ import java.io.InputStream;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
import okhttp3.CacheControl;
import okhttp3.Call;
import okhttp3.OkHttpClient;
@@ -86,6 +89,28 @@ public class LinkPreviewRepository {
.build();
}
public @NonNull Single<Result<LinkPreview, Error>> getLinkPreview(@NonNull String url) {
return Single.<Result<LinkPreview, Error>>create(emitter -> {
RequestController controller = getLinkPreview(ApplicationDependencies.getApplication(),
url,
new Callback() {
@Override
public void onSuccess(@NonNull LinkPreview linkPreview) {
emitter.onSuccess(Result.success(linkPreview));
}
@Override
public void onError(@NonNull Error error) {
emitter.onSuccess(Result.failure(error));
}
});
if (controller != null) {
emitter.setCancellable(controller::cancel);
}
}).subscribeOn(Schedulers.io());
}
@Nullable RequestController getLinkPreview(@NonNull Context context,
@NonNull String url,
@NonNull Callback callback)

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.linkpreview;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Optional;
public class LinkPreviewState {
private final String activeUrlForError;
private final boolean isLoading;
private final boolean hasLinks;
private final Optional<LinkPreview> linkPreview;
private final LinkPreviewRepository.Error error;
private LinkPreviewState(@Nullable String activeUrlForError,
boolean isLoading,
boolean hasLinks,
Optional<LinkPreview> linkPreview,
@Nullable LinkPreviewRepository.Error error)
{
this.activeUrlForError = activeUrlForError;
this.isLoading = isLoading;
this.hasLinks = hasLinks;
this.linkPreview = linkPreview;
this.error = error;
}
public static LinkPreviewState forLoading() {
return new LinkPreviewState(null, true, false, Optional.empty(), null);
}
public static LinkPreviewState forPreview(@NonNull LinkPreview linkPreview) {
return new LinkPreviewState(null, false, true, Optional.of(linkPreview), null);
}
public static LinkPreviewState forLinksWithNoPreview(@Nullable String activeUrlForError, @NonNull LinkPreviewRepository.Error error) {
return new LinkPreviewState(activeUrlForError, false, true, Optional.empty(), error);
}
public static LinkPreviewState forNoLinks() {
return new LinkPreviewState(null, false, false, Optional.empty(), null);
}
public @Nullable String getActiveUrlForError() {
return activeUrlForError;
}
public boolean isLoading() {
return isLoading;
}
public boolean hasLinks() {
return hasLinks;
}
public Optional<LinkPreview> getLinkPreview() {
return linkPreview;
}
public @Nullable LinkPreviewRepository.Error getError() {
return error;
}
public boolean hasContent() {
return isLoading || hasLinks;
}
}

View File

@@ -255,7 +255,7 @@ public class LinkPreviewViewModel extends ViewModel {
}
if (enablePlaceholder) {
return state.linkPreview
return state.getLinkPreview()
.map(linkPreview -> LinkPreviewState.forLinksWithNoPreview(linkPreview.getUrl(), LinkPreviewRepository.Error.PREVIEW_NOT_AVAILABLE))
.orElse(state);
}
@@ -263,67 +263,6 @@ public class LinkPreviewViewModel extends ViewModel {
return LinkPreviewState.forNoLinks();
}
public static class LinkPreviewState {
private final String activeUrlForError;
private final boolean isLoading;
private final boolean hasLinks;
private final Optional<LinkPreview> linkPreview;
private final LinkPreviewRepository.Error error;
private LinkPreviewState(@Nullable String activeUrlForError,
boolean isLoading,
boolean hasLinks,
Optional<LinkPreview> linkPreview,
@Nullable LinkPreviewRepository.Error error)
{
this.activeUrlForError = activeUrlForError;
this.isLoading = isLoading;
this.hasLinks = hasLinks;
this.linkPreview = linkPreview;
this.error = error;
}
private static LinkPreviewState forLoading() {
return new LinkPreviewState(null, true, false, Optional.empty(), null);
}
private static LinkPreviewState forPreview(@NonNull LinkPreview linkPreview) {
return new LinkPreviewState(null, false, true, Optional.of(linkPreview), null);
}
private static LinkPreviewState forLinksWithNoPreview(@Nullable String activeUrlForError, @NonNull LinkPreviewRepository.Error error) {
return new LinkPreviewState(activeUrlForError, false, true, Optional.empty(), error);
}
private static LinkPreviewState forNoLinks() {
return new LinkPreviewState(null, false, false, Optional.empty(), null);
}
public @Nullable String getActiveUrlForError() {
return activeUrlForError;
}
public boolean isLoading() {
return isLoading;
}
public boolean hasLinks() {
return hasLinks;
}
public Optional<LinkPreview> getLinkPreview() {
return linkPreview;
}
public @Nullable LinkPreviewRepository.Error getError() {
return error;
}
boolean hasContent() {
return isLoading || hasLinks;
}
}
public static class Factory extends ViewModelProvider.NewInstanceFactory {
private final LinkPreviewRepository repository;

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.linkpreview
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.Result
import org.signal.core.util.isAbsent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.rx.RxStore
import java.util.Optional
/**
* Rewrite of [LinkPreviewViewModel] preferring Rx and Kotlin
*/
class LinkPreviewViewModelV2(
private val linkPreviewRepository: LinkPreviewRepository = LinkPreviewRepository(),
private val enablePlaceholder: Boolean
) : ViewModel() {
private var enabled = SignalStore.settings().isLinkPreviewsEnabled
private val linkPreviewStateStore = RxStore<LinkPreviewState>(LinkPreviewState.forNoLinks())
val linkPreviewState: Flowable<LinkPreviewState> = linkPreviewStateStore.stateFlowable
val hasLinkPreview: Boolean = linkPreviewStateStore.state.linkPreview.isPresent
val hasLinkPreviewUi: Boolean = linkPreviewStateStore.state.hasContent()
private var activeUrl: String? = null
private var activeRequest: Disposable = Disposable.disposed()
private var userCancelled: Boolean = false
private val debouncer: Debouncer = Debouncer(250)
override fun onCleared() {
activeRequest.dispose()
debouncer.clear()
}
fun onSend(): List<LinkPreview> {
val currentState = linkPreviewStateStore.state
onUserCancel()
return currentState.linkPreview.map { listOf(it) }.orElse(emptyList())
}
fun onTextChanged(text: String, cursorStart: Int, cursorEnd: Int) {
if (!enabled && !enablePlaceholder) {
return
}
debouncer.publish {
if (text.isEmpty()) {
userCancelled = false
}
if (userCancelled) {
return@publish
}
val link: Optional<Link> = LinkPreviewUtil.findValidPreviewUrls(text).findFirst()
activeRequest.dispose()
if (link.isAbsent() || !isCursorPositionValid(text, link.get(), cursorStart, cursorEnd)) {
activeUrl = null
setLinkPreviewState(LinkPreviewState.forNoLinks())
return@publish
}
setLinkPreviewState(LinkPreviewState.forLoading())
val activeUrl = link.get().url
this.activeUrl = activeUrl
activeRequest = if (enabled) {
performRequest(activeUrl)
} else {
createPlaceholder(activeUrl)
}
}
}
fun onEnabled() {
userCancelled = false
enabled = SignalStore.settings().isLinkPreviewsEnabled
}
fun onUserCancel() {
activeRequest.dispose()
userCancelled = true
activeUrl = null
debouncer.clear()
setLinkPreviewState(LinkPreviewState.forNoLinks())
}
private fun isCursorPositionValid(text: String, link: Link, cursorStart: Int, cursorEnd: Int): Boolean {
if (cursorStart != cursorEnd) {
return true
}
if (text.endsWith(link.url) && cursorStart == link.position + link.url.length) {
return true
}
return cursorStart < link.position || cursorStart > link.position + link.url.length
}
private fun createPlaceholder(url: String): Disposable {
return Single.just(url)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
if (!userCancelled) {
if (activeUrl != null && activeUrl == url) {
setLinkPreviewState(LinkPreviewState.forLinksWithNoPreview(url, LinkPreviewRepository.Error.PREVIEW_NOT_AVAILABLE))
} else {
setLinkPreviewState(LinkPreviewState.forNoLinks())
}
}
}
}
private fun performRequest(url: String): Disposable {
return linkPreviewRepository.getLinkPreview(url)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { result ->
if (!userCancelled) {
val linkPreviewState = when (result) {
is Result.Success -> if (activeUrl == result.success.url) LinkPreviewState.forPreview(result.success) else LinkPreviewState.forNoLinks()
is Result.Failure -> if (activeUrl != null) LinkPreviewState.forLinksWithNoPreview(activeUrl, result.failure) else LinkPreviewState.forNoLinks()
}
setLinkPreviewState(linkPreviewState)
}
}
}
private fun setLinkPreviewState(linkPreviewState: LinkPreviewState) {
linkPreviewStateStore.update { cleanseState(linkPreviewState) }
}
private fun cleanseState(linkPreviewState: LinkPreviewState): LinkPreviewState {
if (enabled) {
return linkPreviewState
}
if (enablePlaceholder) {
return linkPreviewState
.linkPreview
.map { LinkPreviewState.forLinksWithNoPreview(it.url, LinkPreviewRepository.Error.PREVIEW_NOT_AVAILABLE) }
.orElse(linkPreviewState)
}
return LinkPreviewState.forNoLinks()
}
}

View File

@@ -21,8 +21,8 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
import org.thoughtcrime.securesms.databinding.StoriesTextPostCreationFragmentBinding
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
import org.thoughtcrime.securesms.linkpreview.LinkPreviewState
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel.LinkPreviewState
import org.thoughtcrime.securesms.mediasend.CameraDisplay
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel

View File

@@ -12,8 +12,8 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ThumbnailView
import org.thoughtcrime.securesms.databinding.StoriesTextPostLinkPreviewBinding
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewState
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.Slide
@@ -73,7 +73,7 @@ class StoryLinkPreviewView @JvmOverloads constructor(
return future ?: SettableFuture(false)
}
fun bind(linkPreviewState: LinkPreviewViewModel.LinkPreviewState, hiddenVisibility: Int = View.INVISIBLE, useLargeThumbnail: Boolean) {
fun bind(linkPreviewState: LinkPreviewState, hiddenVisibility: Int = View.INVISIBLE, useLargeThumbnail: Boolean) {
val linkPreview: LinkPreview? = linkPreviewState.linkPreview.orElseGet {
linkPreviewState.activeUrlForError?.let {
LinkPreview(it, LinkPreviewUtil.getTopLevelDomain(it) ?: it, null, -1L, null)

View File

@@ -20,7 +20,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.fonts.TextFont
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.linkpreview.LinkPreviewState
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryScale
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryTextWatcher
@@ -157,7 +157,7 @@ class StoryTextPostView @JvmOverloads constructor(
linkPreviewView.setThumbnailDrawable(drawable, useLargeThumbnail)
}
fun bindLinkPreviewState(linkPreviewState: LinkPreviewViewModel.LinkPreviewState, hiddenVisibility: Int, useLargeThumbnail: Boolean) {
fun bindLinkPreviewState(linkPreviewState: LinkPreviewState, hiddenVisibility: Int, useLargeThumbnail: Boolean) {
linkPreviewView.bind(linkPreviewState, hiddenVisibility, useLargeThumbnail)
}