mirror of
https://github.com/revanced/revanced-integrations.git
synced 2025-01-07 10:35:49 +01:00
fix(YouTube - ReturnYouTubeDislike): Show estimated like count for videos with hidden likes (#684)
This commit is contained in:
parent
55c278dc08
commit
27d2b60444
@ -363,6 +363,23 @@ public class Utils {
|
|||||||
return isRightToLeftTextLayout;
|
return isRightToLeftTextLayout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return if the text contains at least 1 number character,
|
||||||
|
* including any unicode numbers such as Arabic.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
|
||||||
|
public static boolean containsNumber(@NonNull CharSequence text) {
|
||||||
|
for (int index = 0, length = text.length(); index < length;) {
|
||||||
|
final int codePoint = Character.codePointAt(text, index);
|
||||||
|
if (Character.isDigit(codePoint)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
index += Character.charCount(codePoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safe to call from any thread
|
* Safe to call from any thread
|
||||||
*/
|
*/
|
||||||
|
@ -225,7 +225,6 @@ public class ReturnYouTubeDislikePatch {
|
|||||||
return original;
|
return original;
|
||||||
}
|
}
|
||||||
|
|
||||||
final CharSequence replacement;
|
|
||||||
if (conversionContextString.contains("segmented_like_dislike_button.eml")) {
|
if (conversionContextString.contains("segmented_like_dislike_button.eml")) {
|
||||||
// Regular video.
|
// Regular video.
|
||||||
ReturnYouTubeDislike videoData = currentVideoData;
|
ReturnYouTubeDislike videoData = currentVideoData;
|
||||||
@ -235,46 +234,62 @@ public class ReturnYouTubeDislikePatch {
|
|||||||
if (!(original instanceof Spanned)) {
|
if (!(original instanceof Spanned)) {
|
||||||
original = new SpannableString(original);
|
original = new SpannableString(original);
|
||||||
}
|
}
|
||||||
replacement = videoData.getDislikesSpanForRegularVideo((Spanned) original,
|
return videoData.getDislikesSpanForRegularVideo((Spanned) original,
|
||||||
true, isRollingNumber);
|
true, isRollingNumber);
|
||||||
} else if (!isRollingNumber && conversionContextString.contains("|shorts_dislike_button.eml|")) {
|
|
||||||
// Litho Shorts player.
|
|
||||||
if (!Settings.RYD_SHORTS.get() || Settings.HIDE_SHORTS_DISLIKE_BUTTON.get()) {
|
|
||||||
// Must clear the current video here, otherwise if the user opens a regular video
|
|
||||||
// then opens a litho short (while keeping the regular video on screen), then closes the short,
|
|
||||||
// the original video may show the incorrect dislike value.
|
|
||||||
clearData();
|
|
||||||
return original;
|
|
||||||
}
|
|
||||||
ReturnYouTubeDislike videoData = lastLithoShortsVideoData;
|
|
||||||
if (videoData == null) {
|
|
||||||
// The Shorts litho video id filter did not detect the video id.
|
|
||||||
// This is normal in incognito mode, but otherwise is abnormal.
|
|
||||||
Logger.printDebug(() -> "Cannot modify Shorts litho span, data is null");
|
|
||||||
return original;
|
|
||||||
}
|
|
||||||
// Use the correct dislikes data after voting.
|
|
||||||
if (lithoShortsShouldUseCurrentData) {
|
|
||||||
lithoShortsShouldUseCurrentData = false;
|
|
||||||
videoData = currentVideoData;
|
|
||||||
if (videoData == null) {
|
|
||||||
Logger.printException(() -> "currentVideoData is null"); // Should never happen
|
|
||||||
return original;
|
|
||||||
}
|
|
||||||
Logger.printDebug(() -> "Using current video data for litho span");
|
|
||||||
}
|
|
||||||
replacement = videoData.getDislikeSpanForShort((Spanned) original);
|
|
||||||
} else {
|
|
||||||
return original;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return replacement;
|
if (isRollingNumber) {
|
||||||
|
return original; // No need to check for Shorts in the context.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversionContextString.contains("|shorts_dislike_button.eml")) {
|
||||||
|
return getShortsSpan(original, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversionContextString.contains("|shorts_like_button.eml")
|
||||||
|
&& !Utils.containsNumber(original)) {
|
||||||
|
Logger.printDebug(() -> "Replacing hidden likes count");
|
||||||
|
return getShortsSpan(original, false);
|
||||||
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Logger.printException(() -> "onLithoTextLoaded failure", ex);
|
Logger.printException(() -> "onLithoTextLoaded failure", ex);
|
||||||
}
|
}
|
||||||
return original;
|
return original;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static CharSequence getShortsSpan(@NonNull CharSequence original, boolean isDislikesSpan) {
|
||||||
|
// Litho Shorts player.
|
||||||
|
if (!Settings.RYD_SHORTS.get() || (isDislikesSpan && Settings.HIDE_SHORTS_DISLIKE_BUTTON.get())
|
||||||
|
|| (!isDislikesSpan && Settings.HIDE_SHORTS_LIKE_BUTTON.get())) {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReturnYouTubeDislike videoData = lastLithoShortsVideoData;
|
||||||
|
if (videoData == null) {
|
||||||
|
// The Shorts litho video id filter did not detect the video id.
|
||||||
|
// This is normal in incognito mode, but otherwise is abnormal.
|
||||||
|
Logger.printDebug(() -> "Cannot modify Shorts litho span, data is null");
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the correct dislikes data after voting.
|
||||||
|
if (lithoShortsShouldUseCurrentData) {
|
||||||
|
if (isDislikesSpan) {
|
||||||
|
lithoShortsShouldUseCurrentData = false;
|
||||||
|
}
|
||||||
|
videoData = currentVideoData;
|
||||||
|
if (videoData == null) {
|
||||||
|
Logger.printException(() -> "currentVideoData is null"); // Should never happen
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
Logger.printDebug(() -> "Using current video data for litho span");
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDislikesSpan
|
||||||
|
? videoData.getDislikeSpanForShort((Spanned) original)
|
||||||
|
: videoData.getLikeSpanForShort((Spanned) original);
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Rolling Number
|
// Rolling Number
|
||||||
//
|
//
|
||||||
@ -597,6 +612,7 @@ public class ReturnYouTubeDislikePatch {
|
|||||||
Logger.printDebug(() -> "Waiting for prefetch to complete: " + videoId);
|
Logger.printDebug(() -> "Waiting for prefetch to complete: " + videoId);
|
||||||
fetch.getFetchData(20000); // Any arbitrarily large max wait time.
|
fetch.getFetchData(20000); // Any arbitrarily large max wait time.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the fields after the fetch completes, so any concurrent calls will also wait.
|
// Set the fields after the fetch completes, so any concurrent calls will also wait.
|
||||||
lastPlayerResponseWasShort = videoIdIsShort;
|
lastPlayerResponseWasShort = videoIdIsShort;
|
||||||
lastPrefetchedVideoId = videoId;
|
lastPrefetchedVideoId = videoId;
|
||||||
@ -657,6 +673,7 @@ public class ReturnYouTubeDislikePatch {
|
|||||||
clearData();
|
clearData();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.printDebug(() -> "New litho Shorts video id: " + videoId);
|
Logger.printDebug(() -> "New litho Shorts video id: " + videoId);
|
||||||
ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId);
|
ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId);
|
||||||
videoData.setVideoIdIsShort(true);
|
videoData.setVideoIdIsShort(true);
|
||||||
|
@ -52,7 +52,7 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter {
|
|||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) {
|
public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) {
|
||||||
try {
|
try {
|
||||||
if (!isShortAndOpeningOrPlaying || !Settings.RYD_SHORTS.get()) {
|
if (!isShortAndOpeningOrPlaying || !Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
synchronized (lastVideoIds) {
|
synchronized (lastVideoIds) {
|
||||||
@ -68,21 +68,28 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter {
|
|||||||
private final ByteArrayFilterGroupList videoIdFilterGroup = new ByteArrayFilterGroupList();
|
private final ByteArrayFilterGroupList videoIdFilterGroup = new ByteArrayFilterGroupList();
|
||||||
|
|
||||||
public ReturnYouTubeDislikeFilterPatch() {
|
public ReturnYouTubeDislikeFilterPatch() {
|
||||||
|
// Likes always seems to load before the dislikes, but if this
|
||||||
|
// ever changes then both likes and dislikes need callbacks.
|
||||||
addPathCallbacks(
|
addPathCallbacks(
|
||||||
new StringFilterGroup(Settings.RYD_SHORTS, "|shorts_dislike_button.eml|")
|
new StringFilterGroup(null, "|shorts_like_button.eml")
|
||||||
);
|
);
|
||||||
// After the dislikes icon name is some binary data and then the video id for that specific short.
|
|
||||||
|
// After the likes icon name is some binary data and then the video id for that specific short.
|
||||||
videoIdFilterGroup.addAll(
|
videoIdFilterGroup.addAll(
|
||||||
// Video was previously disliked before video was opened.
|
// Video was previously liked before video was opened.
|
||||||
new ByteArrayFilterGroup(null, "ic_right_dislike_on_shadowed"),
|
new ByteArrayFilterGroup(null, "ic_right_like_on_shadowed"),
|
||||||
// Video was not already disliked.
|
// Video was not already liked.
|
||||||
new ByteArrayFilterGroup(null, "ic_right_dislike_off_shadowed")
|
new ByteArrayFilterGroup(null, "ic_right_like_off_shadowed")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
|
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
|
||||||
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
|
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
|
||||||
|
if (!Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray);
|
FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray);
|
||||||
if (result.isFiltered()) {
|
if (result.isFiltered()) {
|
||||||
String matchedVideoId = findVideoId(protobufBufferArray);
|
String matchedVideoId = findVideoId(protobufBufferArray);
|
||||||
|
@ -23,6 +23,9 @@ public class Requester {
|
|||||||
public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException {
|
public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException {
|
||||||
String url = apiUrl + route.getCompiledRoute();
|
String url = apiUrl + route.getCompiledRoute();
|
||||||
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
||||||
|
// Request data is in the URL parameters and no body is sent.
|
||||||
|
// The calling code must set a length if using a request body.
|
||||||
|
connection.setFixedLengthStreamingMode(0);
|
||||||
connection.setRequestMethod(route.getMethod().name());
|
connection.setRequestMethod(route.getMethod().name());
|
||||||
String agentString = System.getProperty("http.agent")
|
String agentString = System.getProperty("http.agent")
|
||||||
+ "; ReVanced/" + Utils.getAppVersionName()
|
+ "; ReVanced/" + Utils.getAppVersionName()
|
||||||
|
@ -10,6 +10,9 @@ import android.graphics.drawable.ShapeDrawable;
|
|||||||
import android.graphics.drawable.shapes.OvalShape;
|
import android.graphics.drawable.shapes.OvalShape;
|
||||||
import android.graphics.drawable.shapes.RectShape;
|
import android.graphics.drawable.shapes.RectShape;
|
||||||
import android.icu.text.CompactDecimalFormat;
|
import android.icu.text.CompactDecimalFormat;
|
||||||
|
import android.icu.text.DecimalFormat;
|
||||||
|
import android.icu.text.DecimalFormatSymbols;
|
||||||
|
import android.icu.text.NumberFormat;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.text.Spannable;
|
import android.text.Spannable;
|
||||||
import android.text.SpannableString;
|
import android.text.SpannableString;
|
||||||
@ -25,17 +28,11 @@ import androidx.annotation.GuardedBy;
|
|||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import java.text.NumberFormat;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.*;
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.Future;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.TimeoutException;
|
|
||||||
|
|
||||||
import app.revanced.integrations.shared.Logger;
|
import app.revanced.integrations.shared.Logger;
|
||||||
import app.revanced.integrations.shared.Utils;
|
import app.revanced.integrations.shared.Utils;
|
||||||
@ -223,32 +220,29 @@ public class ReturnYouTubeDislike {
|
|||||||
|
|
||||||
// Note: Some locales use right to left layout (Arabic, Hebrew, etc).
|
// Note: Some locales use right to left layout (Arabic, Hebrew, etc).
|
||||||
// If making changes to this code, change device settings to a RTL language and verify layout is correct.
|
// If making changes to this code, change device settings to a RTL language and verify layout is correct.
|
||||||
String oldLikesString = oldSpannable.toString();
|
CharSequence oldLikes = oldSpannable;
|
||||||
|
|
||||||
// YouTube creators can hide the like count on a video,
|
// YouTube creators can hide the like count on a video,
|
||||||
// and the like count appears as a device language specific string that says 'Like'.
|
// and the like count appears as a device language specific string that says 'Like'.
|
||||||
// Check if the string contains any numbers.
|
// Check if the string contains any numbers.
|
||||||
if (!stringContainsNumber(oldLikesString)) {
|
if (!Utils.containsNumber(oldLikes)) {
|
||||||
// Likes are hidden.
|
// Likes are hidden by video creator
|
||||||
// RYD does not provide usable data for these types of videos,
|
//
|
||||||
// and the API returns bogus data (zero likes and zero dislikes)
|
// RYD does not directly provide like data, but can use an estimated likes
|
||||||
// discussion about this: https://github.com/Anarios/return-youtube-dislike/discussions/530
|
// using the same scale factor RYD applied to the raw dislikes.
|
||||||
//
|
//
|
||||||
// example video: https://www.youtube.com/watch?v=UnrU5vxCHxw
|
// example video: https://www.youtube.com/watch?v=UnrU5vxCHxw
|
||||||
// RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw
|
// RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw
|
||||||
//
|
//
|
||||||
// Change the "Likes" string to show that likes and dislikes are hidden.
|
Logger.printDebug(() -> "Using estimated likes");
|
||||||
String hiddenMessageString = str("revanced_ryd_video_likes_hidden_by_video_owner");
|
oldLikes = formatDislikeCount(voteData.getLikeCount());
|
||||||
return newSpanUsingStylingOfAnotherSpan(oldSpannable, hiddenMessageString);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SpannableStringBuilder builder = new SpannableStringBuilder();
|
SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||||
final boolean compactLayout = Settings.RYD_COMPACT_LAYOUT.get();
|
final boolean compactLayout = Settings.RYD_COMPACT_LAYOUT.get();
|
||||||
|
|
||||||
if (!compactLayout) {
|
if (!compactLayout) {
|
||||||
String leftSeparatorString = Utils.isRightToLeftTextLayout()
|
String leftSeparatorString = getTextDirectionString();
|
||||||
? "\u200F" // u200F = right to left character
|
|
||||||
: "\u200E"; // u200E = left to right character
|
|
||||||
final Spannable leftSeparatorSpan;
|
final Spannable leftSeparatorSpan;
|
||||||
if (isRollingNumber) {
|
if (isRollingNumber) {
|
||||||
leftSeparatorSpan = new SpannableString(leftSeparatorString);
|
leftSeparatorSpan = new SpannableString(leftSeparatorString);
|
||||||
@ -267,7 +261,7 @@ public class ReturnYouTubeDislike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// likes
|
// likes
|
||||||
builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikesString));
|
builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikes));
|
||||||
|
|
||||||
// middle separator
|
// middle separator
|
||||||
String middleSeparatorString = compactLayout
|
String middleSeparatorString = compactLayout
|
||||||
@ -292,6 +286,12 @@ public class ReturnYouTubeDislike {
|
|||||||
return new SpannableString(builder);
|
return new SpannableString(builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static @NonNull String getTextDirectionString() {
|
||||||
|
return Utils.isRightToLeftTextLayout()
|
||||||
|
? "\u200F" // u200F = right to left character
|
||||||
|
: "\u200E"; // u200E = left to right character
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return If the text is likely for a previously created likes/dislikes segmented span.
|
* @return If the text is likely for a previously created likes/dislikes segmented span.
|
||||||
*/
|
*/
|
||||||
@ -299,20 +299,6 @@ public class ReturnYouTubeDislike {
|
|||||||
return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0;
|
return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Correctly handles any unicode numbers (such as Arabic numbers).
|
|
||||||
*
|
|
||||||
* @return if the string contains at least 1 number.
|
|
||||||
*/
|
|
||||||
private static boolean stringContainsNumber(@NonNull String text) {
|
|
||||||
for (int index = 0, length = text.length(); index < length; index++) {
|
|
||||||
if (Character.isDigit(text.codePointAt(index))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull Spanned two) {
|
private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull Spanned two) {
|
||||||
// Cannot use equals on the span, because many of the inner styling spans do not implement equals.
|
// Cannot use equals on the span, because many of the inner styling spans do not implement equals.
|
||||||
// Instead, compare the underlying text and the text color to handle when dark mode is changed.
|
// Instead, compare the underlying text and the text color to handle when dark mode is changed.
|
||||||
@ -334,6 +320,10 @@ public class ReturnYouTubeDislike {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SpannableString newSpannableWithLikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) {
|
||||||
|
return newSpanUsingStylingOfAnotherSpan(sourceStyling, formatDislikeCount(voteData.getLikeCount()));
|
||||||
|
}
|
||||||
|
|
||||||
private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) {
|
private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) {
|
||||||
return newSpanUsingStylingOfAnotherSpan(sourceStyling,
|
return newSpanUsingStylingOfAnotherSpan(sourceStyling,
|
||||||
Settings.RYD_DISLIKE_PERCENTAGE.get()
|
Settings.RYD_DISLIKE_PERCENTAGE.get()
|
||||||
@ -342,11 +332,16 @@ public class ReturnYouTubeDislike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned sourceStyle, @NonNull CharSequence newSpanText) {
|
private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned sourceStyle, @NonNull CharSequence newSpanText) {
|
||||||
|
if (sourceStyle == newSpanText && sourceStyle instanceof SpannableString) {
|
||||||
|
return (SpannableString) sourceStyle; // Nothing to do.
|
||||||
|
}
|
||||||
|
|
||||||
SpannableString destination = new SpannableString(newSpanText);
|
SpannableString destination = new SpannableString(newSpanText);
|
||||||
Object[] spans = sourceStyle.getSpans(0, sourceStyle.length(), Object.class);
|
Object[] spans = sourceStyle.getSpans(0, sourceStyle.length(), Object.class);
|
||||||
for (Object span : spans) {
|
for (Object span : spans) {
|
||||||
destination.setSpan(span, 0, destination.length(), sourceStyle.getSpanFlags(span));
|
destination.setSpan(span, 0, destination.length(), sourceStyle.getSpanFlags(span));
|
||||||
}
|
}
|
||||||
|
|
||||||
return destination;
|
return destination;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -354,13 +349,18 @@ public class ReturnYouTubeDislike {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
|
synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
|
||||||
if (dislikeCountFormatter == null) {
|
if (dislikeCountFormatter == null) {
|
||||||
// Note: Java number formatters will use the locale specific number characters.
|
|
||||||
// such as Arabic which formats "1.234" into "۱,۲۳٤"
|
|
||||||
// But YouTube disregards locale specific number characters
|
|
||||||
// and instead shows english number characters everywhere.
|
|
||||||
Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale;
|
Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale;
|
||||||
Logger.printDebug(() -> "Locale: " + locale);
|
|
||||||
dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT);
|
dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT);
|
||||||
|
|
||||||
|
// YouTube disregards locale specific number characters
|
||||||
|
// and instead shows english number characters everywhere.
|
||||||
|
// To use the same behavior, override the digit characters to use English
|
||||||
|
// so languages such as Arabic will show "1.234" instead of the native "۱,۲۳٤"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
|
||||||
|
symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings());
|
||||||
|
dislikeCountFormatter.setDecimalFormatSymbols(symbols);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return dislikeCountFormatter.format(dislikeCount);
|
return dislikeCountFormatter.format(dislikeCount);
|
||||||
}
|
}
|
||||||
@ -371,19 +371,31 @@ public class ReturnYouTubeDislike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String formatDislikePercentage(float dislikePercentage) {
|
private static String formatDislikePercentage(float dislikePercentage) {
|
||||||
synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
if (dislikePercentageFormatter == null) {
|
synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
|
||||||
Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale;
|
if (dislikePercentageFormatter == null) {
|
||||||
Logger.printDebug(() -> "Locale: " + locale);
|
Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale;
|
||||||
dislikePercentageFormatter = NumberFormat.getPercentInstance(locale);
|
dislikePercentageFormatter = NumberFormat.getPercentInstance(locale);
|
||||||
|
|
||||||
|
// Want to set the digit strings, and the simplest way is to cast to the implementation NumberFormat returns.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||||
|
&& dislikePercentageFormatter instanceof DecimalFormat) {
|
||||||
|
DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
|
||||||
|
symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings());
|
||||||
|
((DecimalFormat) dislikePercentageFormatter).setDecimalFormatSymbols(symbols);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dislikePercentage >= 0.01) { // at least 1%
|
||||||
|
dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points
|
||||||
|
} else {
|
||||||
|
dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision
|
||||||
|
}
|
||||||
|
return dislikePercentageFormatter.format(dislikePercentage);
|
||||||
}
|
}
|
||||||
if (dislikePercentage >= 0.01) { // at least 1%
|
|
||||||
dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points
|
|
||||||
} else {
|
|
||||||
dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision
|
|
||||||
}
|
|
||||||
return dislikePercentageFormatter.format(dislikePercentage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Will never be reached, as the oldest supported YouTube app requires Android N or greater.
|
||||||
|
return String.valueOf((int) (dislikePercentage * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
@ -484,7 +496,17 @@ public class ReturnYouTubeDislike {
|
|||||||
public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original,
|
public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original,
|
||||||
boolean isSegmentedButton,
|
boolean isSegmentedButton,
|
||||||
boolean isRollingNumber) {
|
boolean isRollingNumber) {
|
||||||
return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton, isRollingNumber,false);
|
return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton,
|
||||||
|
isRollingNumber, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a Shorts like Spannable is created.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public synchronized Spanned getLikeSpanForShort(@NonNull Spanned original) {
|
||||||
|
return waitForFetchAndUpdateReplacementSpan(original, false,
|
||||||
|
false, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -492,14 +514,16 @@ public class ReturnYouTubeDislike {
|
|||||||
*/
|
*/
|
||||||
@NonNull
|
@NonNull
|
||||||
public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) {
|
public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) {
|
||||||
return waitForFetchAndUpdateReplacementSpan(original, false, false, true);
|
return waitForFetchAndUpdateReplacementSpan(original, false,
|
||||||
|
false, true, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original,
|
private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original,
|
||||||
boolean isSegmentedButton,
|
boolean isSegmentedButton,
|
||||||
boolean isRollingNumber,
|
boolean isRollingNumber,
|
||||||
boolean spanIsForShort) {
|
boolean spanIsForShort,
|
||||||
|
boolean spanIsForLikes) {
|
||||||
try {
|
try {
|
||||||
RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH);
|
RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH);
|
||||||
if (votingData == null) {
|
if (votingData == null) {
|
||||||
@ -526,24 +550,17 @@ public class ReturnYouTubeDislike {
|
|||||||
return original;
|
return original;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (originalDislikeSpan != null && replacementLikeDislikeSpan != null) {
|
if (spanIsForLikes) {
|
||||||
if (spansHaveEqualTextAndColor(original, replacementLikeDislikeSpan)) {
|
// Scrolling Shorts does not cause the Spans to be reloaded,
|
||||||
Logger.printDebug(() -> "Ignoring previously created dislikes span of data: " + videoId);
|
// so there is no need to cache the likes for this situations.
|
||||||
return original;
|
Logger.printDebug(() -> "Creating likes span for: " + votingData.videoId);
|
||||||
}
|
return newSpannableWithLikes(original, votingData);
|
||||||
if (spansHaveEqualTextAndColor(original, originalDislikeSpan)) {
|
|
||||||
Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId);
|
|
||||||
return replacementLikeDislikeSpan;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (isSegmentedButton && isPreviouslyCreatedSegmentedSpan(original.toString())) {
|
|
||||||
// need to recreate using original, as original has prior outdated dislike values
|
if (originalDislikeSpan != null && replacementLikeDislikeSpan != null
|
||||||
if (originalDislikeSpan == null) {
|
&& spansHaveEqualTextAndColor(original, originalDislikeSpan)) {
|
||||||
// Should never happen.
|
Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId);
|
||||||
Logger.printDebug(() -> "Cannot add dislikes - original span is null. videoId: " + videoId);
|
return replacementLikeDislikeSpan;
|
||||||
return original;
|
|
||||||
}
|
|
||||||
original = originalDislikeSpan;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No replacement span exist, create it now.
|
// No replacement span exist, create it now.
|
||||||
@ -558,9 +575,10 @@ public class ReturnYouTubeDislike {
|
|||||||
|
|
||||||
return replacementLikeDislikeSpan;
|
return replacementLikeDislikeSpan;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception ex) {
|
||||||
Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", e); // should never happen
|
Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
return original;
|
return original;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,10 +3,13 @@ package app.revanced.integrations.youtube.returnyoutubedislike.requests;
|
|||||||
import static app.revanced.integrations.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote;
|
import static app.revanced.integrations.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import org.json.JSONException;
|
import org.json.JSONException;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import app.revanced.integrations.shared.Logger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReturnYouTubeDislike API estimated like/dislike/view counts.
|
* ReturnYouTubeDislike API estimated like/dislike/view counts.
|
||||||
*
|
*
|
||||||
@ -23,38 +26,65 @@ public final class RYDVoteData {
|
|||||||
public final long viewCount;
|
public final long viewCount;
|
||||||
|
|
||||||
private final long fetchedLikeCount;
|
private final long fetchedLikeCount;
|
||||||
private volatile long likeCount; // read/write from different threads
|
private volatile long likeCount; // Read/write from different threads.
|
||||||
|
/**
|
||||||
|
* Like count can be hidden by video creator, but RYD still tracks the number
|
||||||
|
* of like/dislikes it received thru it's browser extension and and API.
|
||||||
|
* The raw like/dislikes can be used to calculate a percentage.
|
||||||
|
*
|
||||||
|
* Raw values can be null, especially for older videos with little to no views.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private final Long fetchedRawLikeCount;
|
||||||
private volatile float likePercentage;
|
private volatile float likePercentage;
|
||||||
|
|
||||||
private final long fetchedDislikeCount;
|
private final long fetchedDislikeCount;
|
||||||
private volatile long dislikeCount; // read/write from different threads
|
private volatile long dislikeCount; // Read/write from different threads.
|
||||||
|
@Nullable
|
||||||
|
private final Long fetchedRawDislikeCount;
|
||||||
private volatile float dislikePercentage;
|
private volatile float dislikePercentage;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static Long getLongIfExist(JSONObject json, String key) throws JSONException {
|
||||||
|
return json.isNull(key)
|
||||||
|
? null
|
||||||
|
: json.getLong(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws JSONException if JSON parse error occurs, or if the values make no sense (ie: negative values)
|
* @throws JSONException if JSON parse error occurs, or if the values make no sense (ie: negative values)
|
||||||
*/
|
*/
|
||||||
public RYDVoteData(@NonNull JSONObject json) throws JSONException {
|
public RYDVoteData(@NonNull JSONObject json) throws JSONException {
|
||||||
videoId = json.getString("id");
|
videoId = json.getString("id");
|
||||||
viewCount = json.getLong("viewCount");
|
viewCount = json.getLong("viewCount");
|
||||||
|
|
||||||
fetchedLikeCount = json.getLong("likes");
|
fetchedLikeCount = json.getLong("likes");
|
||||||
|
fetchedRawLikeCount = getLongIfExist(json, "rawLikes");
|
||||||
|
|
||||||
fetchedDislikeCount = json.getLong("dislikes");
|
fetchedDislikeCount = json.getLong("dislikes");
|
||||||
|
fetchedRawDislikeCount = getLongIfExist(json, "rawDislikes");
|
||||||
|
|
||||||
if (viewCount < 0 || fetchedLikeCount < 0 || fetchedDislikeCount < 0) {
|
if (viewCount < 0 || fetchedLikeCount < 0 || fetchedDislikeCount < 0) {
|
||||||
throw new JSONException("Unexpected JSON values: " + json);
|
throw new JSONException("Unexpected JSON values: " + json);
|
||||||
}
|
}
|
||||||
likeCount = fetchedLikeCount;
|
likeCount = fetchedLikeCount;
|
||||||
dislikeCount = fetchedDislikeCount;
|
dislikeCount = fetchedDislikeCount;
|
||||||
updatePercentages();
|
|
||||||
|
updateUsingVote(Vote.LIKE_REMOVE); // Calculate percentages.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estimated like count
|
* Public like count of the video, as reported by YT when RYD last updated it's data.
|
||||||
|
*
|
||||||
|
* If the likes were hidden by the video creator, then this returns an
|
||||||
|
* estimated likes using the same extrapolation as the dislikes.
|
||||||
*/
|
*/
|
||||||
public long getLikeCount() {
|
public long getLikeCount() {
|
||||||
return likeCount;
|
return likeCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estimated dislike count
|
* Estimated total dislike count, extrapolated from the public like count using RYD data.
|
||||||
*/
|
*/
|
||||||
public long getDislikeCount() {
|
public long getDislikeCount() {
|
||||||
return dislikeCount;
|
return dislikeCount;
|
||||||
@ -79,28 +109,56 @@ public final class RYDVoteData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void updateUsingVote(Vote vote) {
|
public void updateUsingVote(Vote vote) {
|
||||||
|
final int likesToAdd, dislikesToAdd;
|
||||||
|
|
||||||
switch (vote) {
|
switch (vote) {
|
||||||
case LIKE:
|
case LIKE:
|
||||||
likeCount = fetchedLikeCount + 1;
|
likesToAdd = 1;
|
||||||
dislikeCount = fetchedDislikeCount;
|
dislikesToAdd = 0;
|
||||||
break;
|
break;
|
||||||
case DISLIKE:
|
case DISLIKE:
|
||||||
likeCount = fetchedLikeCount;
|
likesToAdd = 0;
|
||||||
dislikeCount = fetchedDislikeCount + 1;
|
dislikesToAdd = 1;
|
||||||
break;
|
break;
|
||||||
case LIKE_REMOVE:
|
case LIKE_REMOVE:
|
||||||
likeCount = fetchedLikeCount;
|
likesToAdd = 0;
|
||||||
dislikeCount = fetchedDislikeCount;
|
dislikesToAdd = 0;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new IllegalStateException();
|
throw new IllegalStateException();
|
||||||
}
|
}
|
||||||
updatePercentages();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updatePercentages() {
|
// If a video has no public likes but RYD has raw like data,
|
||||||
likePercentage = (likeCount == 0 ? 0 : (float) likeCount / (likeCount + dislikeCount));
|
// then use the raw data instead.
|
||||||
dislikePercentage = (dislikeCount == 0 ? 0 : (float) dislikeCount / (likeCount + dislikeCount));
|
final boolean videoHasNoPublicLikes = fetchedLikeCount == 0;
|
||||||
|
final boolean hasRawData = fetchedRawLikeCount != null && fetchedRawDislikeCount != null;
|
||||||
|
|
||||||
|
if (videoHasNoPublicLikes && hasRawData && fetchedRawDislikeCount > 0) {
|
||||||
|
// YT creator has hidden the likes count, and this is an older video that
|
||||||
|
// RYD does not provide estimated like counts.
|
||||||
|
//
|
||||||
|
// But we can calculate the public likes the same way RYD does for newer videos with hidden likes,
|
||||||
|
// by using the same raw to estimated scale factor applied to dislikes.
|
||||||
|
// This calculation exactly matches the public likes RYD provides for newer hidden videos.
|
||||||
|
final float estimatedRawScaleFactor = (float) fetchedDislikeCount / fetchedRawDislikeCount;
|
||||||
|
likeCount = (long) (estimatedRawScaleFactor * fetchedRawLikeCount) + likesToAdd;
|
||||||
|
Logger.printDebug(() -> "Using locally calculated estimated likes since RYD did not return an estimate");
|
||||||
|
} else {
|
||||||
|
likeCount = fetchedLikeCount + likesToAdd;
|
||||||
|
}
|
||||||
|
// RYD now always returns an estimated dislike count, even if the likes are hidden.
|
||||||
|
dislikeCount = fetchedDislikeCount + dislikesToAdd;
|
||||||
|
|
||||||
|
// Update percentages.
|
||||||
|
|
||||||
|
final float totalCount = likeCount + dislikeCount;
|
||||||
|
if (totalCount == 0) {
|
||||||
|
likePercentage = 0;
|
||||||
|
dislikePercentage = 0;
|
||||||
|
} else {
|
||||||
|
likePercentage = likeCount / totalCount;
|
||||||
|
dislikePercentage = dislikeCount / totalCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
|
@ -197,7 +197,7 @@ public class ReturnYouTubeDislikeApi {
|
|||||||
return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT;
|
return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT;
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are estimates.
|
@SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are only estimates.
|
||||||
private static void updateRateLimitAndStats(long timeNetworkCallStarted, boolean connectionError, boolean rateLimitHit) {
|
private static void updateRateLimitAndStats(long timeNetworkCallStarted, boolean connectionError, boolean rateLimitHit) {
|
||||||
if (connectionError && rateLimitHit) {
|
if (connectionError && rateLimitHit) {
|
||||||
throw new IllegalArgumentException();
|
throw new IllegalArgumentException();
|
||||||
@ -368,10 +368,12 @@ public class ReturnYouTubeDislikeApi {
|
|||||||
applyCommonPostRequestSettings(connection);
|
applyCommonPostRequestSettings(connection);
|
||||||
|
|
||||||
String jsonInputString = "{\"solution\": \"" + solution + "\"}";
|
String jsonInputString = "{\"solution\": \"" + solution + "\"}";
|
||||||
|
byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8);
|
||||||
|
connection.setFixedLengthStreamingMode(body.length);
|
||||||
try (OutputStream os = connection.getOutputStream()) {
|
try (OutputStream os = connection.getOutputStream()) {
|
||||||
byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8);
|
os.write(body);
|
||||||
os.write(input, 0, input.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final int responseCode = connection.getResponseCode();
|
final int responseCode = connection.getResponseCode();
|
||||||
if (checkIfRateLimitWasHit(responseCode)) {
|
if (checkIfRateLimitWasHit(responseCode)) {
|
||||||
connection.disconnect(); // disconnect, as no more connections will be made for a little while
|
connection.disconnect(); // disconnect, as no more connections will be made for a little while
|
||||||
@ -440,9 +442,10 @@ public class ReturnYouTubeDislikeApi {
|
|||||||
applyCommonPostRequestSettings(connection);
|
applyCommonPostRequestSettings(connection);
|
||||||
|
|
||||||
String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote.value + "\"}";
|
String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote.value + "\"}";
|
||||||
|
byte[] body = voteJsonString.getBytes(StandardCharsets.UTF_8);
|
||||||
|
connection.setFixedLengthStreamingMode(body.length);
|
||||||
try (OutputStream os = connection.getOutputStream()) {
|
try (OutputStream os = connection.getOutputStream()) {
|
||||||
byte[] input = voteJsonString.getBytes(StandardCharsets.UTF_8);
|
os.write(body);
|
||||||
os.write(input, 0, input.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final int responseCode = connection.getResponseCode();
|
final int responseCode = connection.getResponseCode();
|
||||||
@ -490,10 +493,12 @@ public class ReturnYouTubeDislikeApi {
|
|||||||
applyCommonPostRequestSettings(connection);
|
applyCommonPostRequestSettings(connection);
|
||||||
|
|
||||||
String jsonInputString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"solution\": \"" + solution + "\"}";
|
String jsonInputString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"solution\": \"" + solution + "\"}";
|
||||||
|
byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8);
|
||||||
|
connection.setFixedLengthStreamingMode(body.length);
|
||||||
try (OutputStream os = connection.getOutputStream()) {
|
try (OutputStream os = connection.getOutputStream()) {
|
||||||
byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8);
|
os.write(body);
|
||||||
os.write(input, 0, input.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final int responseCode = connection.getResponseCode();
|
final int responseCode = connection.getResponseCode();
|
||||||
if (checkIfRateLimitWasHit(responseCode)) {
|
if (checkIfRateLimitWasHit(responseCode)) {
|
||||||
connection.disconnect(); // disconnect, as no more connections will be made for a little while
|
connection.disconnect(); // disconnect, as no more connections will be made for a little while
|
||||||
|
Loading…
Reference in New Issue
Block a user