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

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