mirror of
https://github.com/oxen-io/session-android.git
synced 2025-08-25 19:47:31 +00:00
Switch the conversation list to our own paging library.
This commit is contained in:

committed by
Alan Evans

parent
b7477d287b
commit
4b5f1d64e6
@@ -311,8 +311,6 @@ dependencies {
|
||||
implementation "androidx.camera:camera-view:1.0.0-alpha18"
|
||||
implementation "androidx.concurrent:concurrent-futures:1.0.0"
|
||||
implementation "androidx.autofill:autofill:1.0.0"
|
||||
implementation "androidx.paging:paging-common:2.1.2"
|
||||
implementation "androidx.paging:paging-runtime:2.1.2"
|
||||
implementation 'com.google.firebase:firebase-ml-vision:24.0.3'
|
||||
implementation 'com.google.firebase:firebase-ml-vision-face-model:20.0.1'
|
||||
|
||||
|
@@ -7,10 +7,12 @@ import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.paging.PagedListAdapter;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.DiffUtil;
|
||||
import androidx.recyclerview.widget.ListAdapter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.signal.paging.PagingController;
|
||||
import org.thoughtcrime.securesms.BindableConversationListItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
@@ -28,7 +30,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerView.ViewHolder> {
|
||||
class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.ViewHolder> {
|
||||
|
||||
private static final int TYPE_THREAD = 1;
|
||||
private static final int TYPE_ACTION = 2;
|
||||
@@ -46,6 +48,8 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
private boolean batchMode = false;
|
||||
private final Set<Long> typingSet = new HashSet<>();
|
||||
|
||||
private PagingController pagingController;
|
||||
|
||||
protected ConversationListAdapter(@NonNull GlideRequests glideRequests,
|
||||
@NonNull OnConversationClickListener onConversationClickListener)
|
||||
{
|
||||
@@ -156,6 +160,19 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Conversation getItem(int position) {
|
||||
if (pagingController != null) {
|
||||
pagingController.onDataNeededAroundIndex(position);
|
||||
}
|
||||
|
||||
return super.getItem(position);
|
||||
}
|
||||
|
||||
public void setPagingController(@Nullable PagingController pagingController) {
|
||||
this.pagingController = pagingController;
|
||||
}
|
||||
|
||||
void setTypingThreads(@NonNull Set<Long> typingThreadSet) {
|
||||
this.typingSet.clear();
|
||||
this.typingSet.addAll(typingThreadSet);
|
||||
|
@@ -7,110 +7,66 @@ import android.database.MergeCursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.paging.DataSource;
|
||||
import androidx.paging.PositionalDataSource;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.paging.PagedDataSource;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.tracing.Trace;
|
||||
import org.thoughtcrime.securesms.util.paging.Invalidator;
|
||||
import org.thoughtcrime.securesms.util.paging.SizeFixResult;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Trace
|
||||
abstract class ConversationListDataSource extends PositionalDataSource<Conversation> {
|
||||
|
||||
public static final Executor EXECUTOR = SignalExecutors.newFixedLifoThreadExecutor("signal-conversation-list", 1, 1);
|
||||
abstract class ConversationListDataSource implements PagedDataSource<Conversation> {
|
||||
|
||||
private static final String TAG = Log.tag(ConversationListDataSource.class);
|
||||
|
||||
protected final ThreadDatabase threadDatabase;
|
||||
|
||||
protected ConversationListDataSource(@NonNull Context context, @NonNull Invalidator invalidator) {
|
||||
protected ConversationListDataSource(@NonNull Context context) {
|
||||
this.threadDatabase = DatabaseFactory.getThreadDatabase(context);
|
||||
|
||||
DatabaseObserver.Observer observer = new DatabaseObserver.Observer() {
|
||||
@Override
|
||||
public void onChanged() {
|
||||
invalidate();
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(this);
|
||||
}
|
||||
};
|
||||
|
||||
invalidator.observe(() -> {
|
||||
invalidate();
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer);
|
||||
});
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(observer);
|
||||
}
|
||||
|
||||
private static ConversationListDataSource create(@NonNull Context context, @NonNull Invalidator invalidator, boolean isArchived) {
|
||||
if (!isArchived) return new UnarchivedConversationListDataSource(context, invalidator);
|
||||
else return new ArchivedConversationListDataSource(context, invalidator);
|
||||
public static ConversationListDataSource create(@NonNull Context context, boolean isArchived) {
|
||||
if (!isArchived) return new UnarchivedConversationListDataSource(context);
|
||||
else return new ArchivedConversationListDataSource(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback<Conversation> callback) {
|
||||
long start = System.currentTimeMillis();
|
||||
public int size() {
|
||||
return getTotalCount();
|
||||
}
|
||||
|
||||
List<Conversation> conversations = new ArrayList<>(params.requestedLoadSize);
|
||||
int totalCount = getTotalCount();
|
||||
int effectiveCount = params.requestedStartPosition;
|
||||
@Override
|
||||
public @NonNull List<Conversation> load(int start, int length, @NonNull CancellationSignal cancellationSignal) {
|
||||
Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), " + getClass().getSimpleName());
|
||||
|
||||
List<Conversation> conversations = new ArrayList<>(length);
|
||||
List<Recipient> recipients = new LinkedList<>();
|
||||
|
||||
try (ConversationReader reader = new ConversationReader(getCursor(params.requestedStartPosition, params.requestedLoadSize))) {
|
||||
try (ConversationReader reader = new ConversationReader(getCursor(start, length))) {
|
||||
ThreadRecord record;
|
||||
while ((record = reader.getNext()) != null && effectiveCount < totalCount && !isInvalid()) {
|
||||
conversations.add(new Conversation(record));
|
||||
recipients.add(record.getRecipient());
|
||||
effectiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationDependencies.getRecipientCache().addToCache(recipients);
|
||||
|
||||
if (!isInvalid()) {
|
||||
SizeFixResult<Conversation> result = SizeFixResult.ensureMultipleOfPageSize(conversations, params.requestedStartPosition, params.pageSize, totalCount);
|
||||
callback.onResult(result.getItems(), params.requestedStartPosition, result.getTotal());
|
||||
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", actualSize: " + result.getItems().size() + ", totalCount: " + result.getTotal() + ", class: " + getClass().getSimpleName());
|
||||
} else {
|
||||
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", totalCount: " + totalCount + ", class: " + getClass().getSimpleName() + " -- invalidated");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback<Conversation> callback) {
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
List<Conversation> conversations = new ArrayList<>(params.loadSize);
|
||||
List<Recipient> recipients = new LinkedList<>();
|
||||
|
||||
try (ConversationReader reader = new ConversationReader(getCursor(params.startPosition, params.loadSize))) {
|
||||
ThreadRecord record;
|
||||
while ((record = reader.getNext()) != null && !isInvalid()) {
|
||||
while ((record = reader.getNext()) != null && !cancellationSignal.isCanceled()) {
|
||||
conversations.add(new Conversation(record));
|
||||
recipients.add(record.getRecipient());
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.split("cursor");
|
||||
|
||||
ApplicationDependencies.getRecipientCache().addToCache(recipients);
|
||||
|
||||
callback.onResult(conversations);
|
||||
stopwatch.split("cache-recipients");
|
||||
|
||||
Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms | start: " + params.startPosition + ", size: " + params.loadSize + ", class: " + getClass().getSimpleName() + (isInvalid() ? " -- invalidated" : ""));
|
||||
stopwatch.stop(TAG);
|
||||
|
||||
return conversations;
|
||||
}
|
||||
|
||||
protected abstract int getTotalCount();
|
||||
@@ -118,8 +74,8 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
|
||||
|
||||
private static class ArchivedConversationListDataSource extends ConversationListDataSource {
|
||||
|
||||
ArchivedConversationListDataSource(@NonNull Context context, @NonNull Invalidator invalidator) {
|
||||
super(context, invalidator);
|
||||
ArchivedConversationListDataSource(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -141,8 +97,8 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
|
||||
private int archivedCount;
|
||||
private int unpinnedCount;
|
||||
|
||||
UnarchivedConversationListDataSource(@NonNull Context context, @NonNull Invalidator invalidator) {
|
||||
super(context, invalidator);
|
||||
UnarchivedConversationListDataSource(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -212,22 +168,4 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
|
||||
return archivedCount != 0;
|
||||
}
|
||||
}
|
||||
|
||||
static class Factory extends DataSource.Factory<Integer, Conversation> {
|
||||
|
||||
private final Context context;
|
||||
private final Invalidator invalidator;
|
||||
private final boolean isArchived;
|
||||
|
||||
public Factory(@NonNull Context context, @NonNull Invalidator invalidator, boolean isArchived) {
|
||||
this.context = context;
|
||||
this.invalidator = invalidator;
|
||||
this.isArchived = isArchived;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull DataSource<Integer, Conversation> create() {
|
||||
return ConversationListDataSource.create(context, invalidator, isArchived);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -242,8 +242,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
.execute();
|
||||
});
|
||||
|
||||
initializeListAdapters();
|
||||
initializeViewModel();
|
||||
initializeListAdapters();
|
||||
initializeTypingObserver();
|
||||
initializeSearchListener();
|
||||
|
||||
@@ -502,6 +502,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
return;
|
||||
}
|
||||
|
||||
if (adapter instanceof ConversationListAdapter) {
|
||||
((ConversationListAdapter) adapter).setPagingController(viewModel.getPagingController());
|
||||
}
|
||||
|
||||
list.setAdapter(adapter);
|
||||
|
||||
if (adapter == defaultAdapter) {
|
||||
@@ -820,13 +824,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1);
|
||||
}
|
||||
|
||||
private void onSubmitList(@NonNull ConversationListViewModel.ConversationList conversationList) {
|
||||
if (conversationList.getConversations().isDetached()) {
|
||||
return;
|
||||
}
|
||||
|
||||
defaultAdapter.submitList(conversationList.getConversations());
|
||||
|
||||
private void onSubmitList(@NonNull List<Conversation> conversationList) {
|
||||
defaultAdapter.submitList(conversationList);
|
||||
onPostSubmitList();
|
||||
}
|
||||
|
||||
|
@@ -6,15 +6,13 @@ import android.text.TextUtils;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.paging.DataSource;
|
||||
import androidx.paging.LivePagedListBuilder;
|
||||
import androidx.paging.PagedList;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.paging.PagedData;
|
||||
import org.signal.paging.PagingConfig;
|
||||
import org.signal.paging.PagingController;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
@@ -26,24 +24,27 @@ import org.thoughtcrime.securesms.megaphone.Megaphones;
|
||||
import org.thoughtcrime.securesms.search.SearchRepository;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.util.paging.Invalidator;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.List;
|
||||
|
||||
class ConversationListViewModel extends ViewModel {
|
||||
|
||||
private static final String TAG = Log.tag(ConversationListViewModel.class);
|
||||
|
||||
private final MutableLiveData<Megaphone> megaphone;
|
||||
private final MutableLiveData<SearchResult> searchResult;
|
||||
private final LiveData<ConversationList> conversationList;
|
||||
private final SearchRepository searchRepository;
|
||||
private final MegaphoneRepository megaphoneRepository;
|
||||
private final Debouncer debouncer;
|
||||
private final DatabaseObserver.Observer observer;
|
||||
private final Invalidator invalidator;
|
||||
private final MutableLiveData<Megaphone> megaphone;
|
||||
private final MutableLiveData<SearchResult> searchResult;
|
||||
private final PagedData<Conversation> pagedData;
|
||||
private final LiveData<Boolean> hasNoConversations;
|
||||
private final SearchRepository searchRepository;
|
||||
private final MegaphoneRepository megaphoneRepository;
|
||||
private final Debouncer debouncer;
|
||||
private final DatabaseObserver.Observer observer;
|
||||
private final Invalidator invalidator;
|
||||
|
||||
private String lastQuery;
|
||||
private int pinnedCount;
|
||||
|
||||
private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository, boolean isArchived) {
|
||||
this.megaphone = new MutableLiveData<>();
|
||||
@@ -52,49 +53,33 @@ class ConversationListViewModel extends ViewModel {
|
||||
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
|
||||
this.debouncer = new Debouncer(300);
|
||||
this.invalidator = new Invalidator();
|
||||
this.pagedData = PagedData.create(ConversationListDataSource.create(application, isArchived),
|
||||
new PagingConfig.Builder()
|
||||
.setPageSize(15)
|
||||
.setBufferPages(2)
|
||||
.build());
|
||||
this.observer = () -> {
|
||||
if (!TextUtils.isEmpty(getLastQuery())) {
|
||||
searchRepository.query(getLastQuery(), searchResult::postValue);
|
||||
}
|
||||
pagedData.getController().onDataInvalidated();
|
||||
};
|
||||
|
||||
DataSource.Factory<Integer, Conversation> factory = new ConversationListDataSource.Factory(application, invalidator, isArchived);
|
||||
PagedList.Config config = new PagedList.Config.Builder()
|
||||
.setPageSize(15)
|
||||
.setInitialLoadSizeHint(30)
|
||||
.setEnablePlaceholders(true)
|
||||
.build();
|
||||
this.hasNoConversations = LiveDataUtil.mapAsync(pagedData.getData(), conversations -> {
|
||||
pinnedCount = DatabaseFactory.getThreadDatabase(application).getPinnedConversationListCount();
|
||||
|
||||
LiveData<PagedList<Conversation>> conversationList = new LivePagedListBuilder<>(factory, config).setFetchExecutor(ConversationListDataSource.EXECUTOR)
|
||||
.setInitialLoadKey(0)
|
||||
.build();
|
||||
if (conversations.size() > 0) {
|
||||
return false;
|
||||
} else {
|
||||
return DatabaseFactory.getThreadDatabase(application).getArchivedConversationListCount() == 0;
|
||||
}
|
||||
});
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(observer);
|
||||
|
||||
this.conversationList = Transformations.switchMap(conversationList, conversation -> {
|
||||
if (conversation.getDataSource().isInvalid()) {
|
||||
Log.w(TAG, "Received an invalid conversation list. Ignoring.");
|
||||
return new MutableLiveData<>();
|
||||
}
|
||||
|
||||
MutableLiveData<ConversationList> updated = new MutableLiveData<>();
|
||||
|
||||
if (isArchived) {
|
||||
updated.postValue(new ConversationList(conversation, 0, 0));
|
||||
} else {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
int archiveCount = DatabaseFactory.getThreadDatabase(application).getArchivedConversationListCount();
|
||||
int pinnedCount = DatabaseFactory.getThreadDatabase(application).getPinnedConversationListCount();
|
||||
updated.postValue(new ConversationList(conversation, archiveCount, pinnedCount));
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
public LiveData<Boolean> hasNoConversations() {
|
||||
return Transformations.map(getConversationList(), ConversationList::isEmpty);
|
||||
return hasNoConversations;
|
||||
}
|
||||
|
||||
@NonNull LiveData<SearchResult> getSearchResult() {
|
||||
@@ -105,12 +90,16 @@ class ConversationListViewModel extends ViewModel {
|
||||
return megaphone;
|
||||
}
|
||||
|
||||
@NonNull LiveData<ConversationList> getConversationList() {
|
||||
return conversationList;
|
||||
@NonNull LiveData<List<Conversation>> getConversationList() {
|
||||
return pagedData.getData();
|
||||
}
|
||||
|
||||
@NonNull PagingController getPagingController() {
|
||||
return pagedData.getController();
|
||||
}
|
||||
|
||||
public int getPinnedCount() {
|
||||
return Objects.requireNonNull(getConversationList().getValue()).pinnedCount;
|
||||
return pinnedCount;
|
||||
}
|
||||
|
||||
void onVisible() {
|
||||
@@ -168,32 +157,4 @@ class ConversationListViewModel extends ViewModel {
|
||||
return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository(), isArchived));
|
||||
}
|
||||
}
|
||||
|
||||
final static class ConversationList {
|
||||
private final PagedList<Conversation> conversations;
|
||||
private final int archivedCount;
|
||||
private final int pinnedCount;
|
||||
|
||||
ConversationList(PagedList<Conversation> conversations, int archivedCount, int pinnedCount) {
|
||||
this.conversations = conversations;
|
||||
this.archivedCount = archivedCount;
|
||||
this.pinnedCount = pinnedCount;
|
||||
}
|
||||
|
||||
PagedList<Conversation> getConversations() {
|
||||
return conversations;
|
||||
}
|
||||
|
||||
int getArchivedCount() {
|
||||
return archivedCount;
|
||||
}
|
||||
|
||||
public int getPinnedCount() {
|
||||
return pinnedCount;
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return conversations.isEmpty() && archivedCount == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -65,7 +65,11 @@ public final class LiveDataUtil {
|
||||
MediatorLiveData<B> outputLiveData = new MediatorLiveData<>();
|
||||
Executor liveDataExecutor = new SerialMonoLifoExecutor(executor);
|
||||
|
||||
outputLiveData.addSource(source, currentValue -> liveDataExecutor.execute(() -> outputLiveData.postValue(backgroundFunction.apply(currentValue))));
|
||||
outputLiveData.addSource(source, currentValue -> {
|
||||
liveDataExecutor.execute(() -> {
|
||||
outputLiveData.postValue(backgroundFunction.apply(currentValue));
|
||||
});
|
||||
});
|
||||
|
||||
return outputLiveData;
|
||||
}
|
||||
|
@@ -55,7 +55,7 @@ public class UnarchivedConversationListDataSourceTest {
|
||||
when(DatabaseFactory.getThreadDatabase(any())).thenReturn(threadDatabase);
|
||||
when(ApplicationDependencies.getDatabaseObserver()).thenReturn(mock(DatabaseObserver.class));
|
||||
|
||||
testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(ApplicationProvider.getApplicationContext(), mock(Invalidator.class));
|
||||
testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(mock(Application.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@@ -156,12 +156,6 @@ dependencyVerification {
|
||||
['androidx.navigation:navigation-ui:2.1.0',
|
||||
'1ec0558d692982c5bcfcca6de5b5972723e6b4a9870aa7fc1eddf5e869f116ed'],
|
||||
|
||||
['androidx.paging:paging-common:2.1.2',
|
||||
'891dd24bad908d5d866d7d3545114ab2d26994847cd0200ac68477287c0710b5'],
|
||||
|
||||
['androidx.paging:paging-runtime:2.1.2',
|
||||
'4e81d8ab584a184e2781c6f0d50b6f00acd11741f759270e7c976ef3307d78a7'],
|
||||
|
||||
['androidx.preference:preference:1.0.0',
|
||||
'ea9fde25606eb456210ffe9f7e51048abd776b55a34c0cc6608282b5699122d1'],
|
||||
|
||||
|
Reference in New Issue
Block a user