Add emoji reacts support (#889)
* feat: Add emoji reacts support * Remove message multi-selection * Add emoji reaction model * Add emoji reaction panel * Blur reacts panel background * Show emoji keyboard * Add emoji sprites * Update reaction proto * Emoji database updates * Emoji database refactor * Emoji reaction persistence * Optimize reactions retrieval * Fix emoji group query * Display emojis * Fix emoji persistence * Cleanup * Persistence refactor * Add reactions bottom sheet * Cleanup * Ui tweaks * React with any emoji * Show emoji react notifications * Remove reaction * Show reactions modal on long press * Click to react (+1) with an emoji * Click to react with an emoji * Enable emoji expand/collapse * fix: some compile issues from merge conflicts * fix: compile issues merging quote and media message UI * fix: xml IDs and adding in legacy is selected for future inclusion * Fix view constraints * Fix merge issue * Add message selection option in conversation context menu * Add sogs emoji integration * Handle sogs emoji reactions * Enable sending/deleting sogs emojis * fix: improve the visible message layout * fix: add file IDs to request parameters for message send (#940) * Fix open group polling from seqno instead of last hash (#939) * fix: reset seqno to get recent messages from open groups * build: upgrade build numbers * fix: actually run the migration * Using StringBuilder to construct request url * Fix reaction filter * fix: is_mms added in second projection query * Update default emojis * fix: include legacy and new open groups in server ID tracking (#941) * feat: add hidden moderator and admin roles, separated as they may be used independently in future (#942) * Cleanup * Fix view constraints * Add reactions capability check * Fix reactions alignment * Ui fixes * Display reactions list * feat: add formatted count strings * fix: account for negatives and add tests * Migrate old official open group locations for polling and adding (#932) * feat: adding in first part of open group migrations and tests for migration logic / helpers * feat: test code and migration logic for open groups in the case of no conflicts * feat: add in extra test cases and refactor code for migrator * refactor: migrate open group join URLs and references to server in adding new open groups to catch legacy and re-write it * refactor: joining open groups using OpenGroupUrlParser.kt now * fix: add in compile issues for renamed OpenGroupApi.kt from OpenGroupV2 * fix: prevent duplicates of http/https for new open group DNS and prevent adding new groups based on public key * fix: room and server swapped parameters * fix: replace default server for config messages * fix: actually using public key to de-dupe didn't work for rooms * build: bump version code and name * Display reactions list on open groups for moderators * Ui tweaks * Ui tweaks for moderation * Refactor * fix: compile issue * fix: de-duping joined queries in the get X from cursor * Restore import * fix: colouring the reaction overlay scrubber * fix: highlight colour, show reaction count if 1 or above * Cleanup * fix: light mode accent * fix: light / dark mode themeing in reactions dialog fragment * Emoji notification blinded id check * fix: show reaction list correctly and pass isUserModerator to bind methods * fix: remove unnecessary places for the moderator * fix: X button for removing own react not showing up properly * feat: add clear all header view * fix: migrate the clear all to the correct location * fix: use display instead of base * Truncate emoji sender ids * feat: add notify thread function in thread db * Notify threads on reaction received * fix: design fixes for the reaction list * fix: emoji reactions bottom sheet dialog UI designs * feat: add unsupported emoji reaction * fix: crash and doing vector properly * Fix reaction database queries * Fix background open group adder job * Show new open group reactions * Fetch a maximum of 5 reactors * Handle open group reactions polling conflicts * Add count to user reaction * Show number of additional reactors * fix: unreads set same as the unread query * fix: design changes * fix: update dependency to improve flexboxlayout behaviour, design consistencies * Add select message icon and update long press menu items order and wording * Fix crash on reactors dialog * fix: colours and backgrounds to match designs * fix: add header in recipient item * fix: margins * fix: alignments and layout issues for emoji reactions view * feat: add overflow previews and logic for overflow * Dim action bar * Add emoji search * Search index fix * Set count for 1:1 and closed group reactions when inserting in local database * Use on screen toolbar to allow overlaying * Show/hide scroll to bottom button * feat: add extended properties so it doesn't collapse on re-bind * Cleanup * feat: prevent keeping extended on rebinding if we get a new message ID * fix: long press works on devices now, fix release lint issue and crash for emoji search DBs from emoji builds * Display message timestamp * Fix modal items alignment * fix: sort order and emoji count in compareTo * Scale down really large messages to fit * Prevent closed group crash * Fix reaction author Co-authored-by: charles <charles@oxen.io> Co-authored-by: jubb <hjubb@users.noreply.github.com>
@ -90,10 +90,7 @@ dependencies {
|
||||
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
|
||||
}
|
||||
implementation 'com.annimon:stream:1.1.8'
|
||||
implementation ('com.takisoft.fix:colorpicker:0.9.1') {
|
||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||
exclude group: 'com.android.support', module: 'recyclerview-v7'
|
||||
}
|
||||
implementation 'com.takisoft.fix:colorpicker:1.0.1'
|
||||
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
|
||||
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
|
||||
implementation 'org.signal:android-database-sqlcipher:3.5.9-S3'
|
||||
@ -159,8 +156,8 @@ dependencies {
|
||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 292
|
||||
def canonicalVersionName = "1.14.0"
|
||||
def canonicalVersionCode = 294
|
||||
def canonicalVersionName = "1.14.2"
|
||||
|
||||
def postFixSize = 10
|
||||
def abiPostFix = ['armeabi-v7a' : 1,
|
||||
|
@ -221,7 +221,7 @@
|
||||
android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2"
|
||||
android:screenOrientation="portrait"
|
||||
android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity"
|
||||
android:theme="@style/Theme.Session.DayNight.FlatActionBar">
|
||||
android:theme="@style/Theme.Session.DayNight.NoActionBar">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
|
||||
|
BIN
app/src/main/assets/emoji/Activity.webp
Normal file
After Width: | Height: | Size: 88 KiB |
BIN
app/src/main/assets/emoji/Flags_0.webp
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
app/src/main/assets/emoji/Flags_1.webp
Normal file
After Width: | Height: | Size: 44 KiB |
BIN
app/src/main/assets/emoji/Foods.webp
Normal file
After Width: | Height: | Size: 125 KiB |
BIN
app/src/main/assets/emoji/Nature.webp
Normal file
After Width: | Height: | Size: 166 KiB |
BIN
app/src/main/assets/emoji/Objects.webp
Normal file
After Width: | Height: | Size: 238 KiB |
BIN
app/src/main/assets/emoji/People_0.webp
Normal file
After Width: | Height: | Size: 176 KiB |
BIN
app/src/main/assets/emoji/People_1.webp
Normal file
After Width: | Height: | Size: 99 KiB |
BIN
app/src/main/assets/emoji/People_2.webp
Normal file
After Width: | Height: | Size: 150 KiB |
BIN
app/src/main/assets/emoji/People_3.webp
Normal file
After Width: | Height: | Size: 149 KiB |
BIN
app/src/main/assets/emoji/People_4.webp
Normal file
After Width: | Height: | Size: 150 KiB |
BIN
app/src/main/assets/emoji/People_5.webp
Normal file
After Width: | Height: | Size: 148 KiB |
BIN
app/src/main/assets/emoji/People_6.webp
Normal file
After Width: | Height: | Size: 142 KiB |
BIN
app/src/main/assets/emoji/People_7.webp
Normal file
After Width: | Height: | Size: 174 KiB |
BIN
app/src/main/assets/emoji/People_8.webp
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
app/src/main/assets/emoji/People_9.webp
Normal file
After Width: | Height: | Size: 81 KiB |
BIN
app/src/main/assets/emoji/Places.webp
Normal file
After Width: | Height: | Size: 200 KiB |
BIN
app/src/main/assets/emoji/Symbols.webp
Normal file
After Width: | Height: | Size: 91 KiB |
1
app/src/main/assets/emoji/emoji_data.json
Normal file
1
app/src/main/assets/emoji/emoji_search_index.json
Normal file
@ -47,17 +47,22 @@ import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.WindowDebouncer;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.session.libsession.utilities.dynamiclanguage.LocaleParser;
|
||||
import org.session.libsignal.utilities.JsonUtil;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.utilities.ThreadUtils;
|
||||
import org.signal.aesgcmprovider.AesGcmProvider;
|
||||
import org.thoughtcrime.securesms.components.TypingStatusSender;
|
||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.JobDatabase;
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
|
||||
import org.thoughtcrime.securesms.database.Storage;
|
||||
import org.thoughtcrime.securesms.database.model.EmojiSearchData;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager;
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupMigrator;
|
||||
import org.thoughtcrime.securesms.home.HomeActivity;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer;
|
||||
@ -89,12 +94,16 @@ import org.webrtc.voiceengine.WebRtcAudioManager;
|
||||
import org.webrtc.voiceengine.WebRtcAudioUtils;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.Security;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Timer;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
@ -191,6 +200,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
storage,
|
||||
messageDataProvider,
|
||||
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this));
|
||||
// migrate session open group data
|
||||
OpenGroupMigrator.migrate(getDatabaseComponent());
|
||||
// end migration
|
||||
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
|
||||
Log.i(TAG, "onCreate()");
|
||||
startKovenant();
|
||||
@ -220,6 +232,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
initializeWebRtc();
|
||||
initializeBlobProvider();
|
||||
resubmitProfilePictureIfNeeded();
|
||||
loadEmojiSearchIndexIfNeeded();
|
||||
EmojiSource.refresh();
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -489,6 +503,20 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
});
|
||||
}
|
||||
|
||||
private void loadEmojiSearchIndexIfNeeded() {
|
||||
Executors.newSingleThreadExecutor().execute(() -> {
|
||||
EmojiSearchDatabase emojiSearchDb = getDatabaseComponent().emojiSearchDatabase();
|
||||
if (emojiSearchDb.query("face", 1).isEmpty()) {
|
||||
try (InputStream inputStream = getAssets().open("emoji/emoji_search_index.json")) {
|
||||
List<EmojiSearchData> searchIndex = Arrays.asList(JsonUtil.fromJson(inputStream, EmojiSearchData[].class));
|
||||
emojiSearchDb.setSearchIndex(searchIndex);
|
||||
} catch (IOException e) {
|
||||
Log.e("Loki", "Failed to load emoji search index");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void clearAllData(boolean isMigratingToV2KeyPair) {
|
||||
String token = TextSecurePreferences.getFCMToken(this);
|
||||
if (token != null && !token.isEmpty()) {
|
||||
|
@ -0,0 +1,17 @@
|
||||
package org.thoughtcrime.securesms.animation;
|
||||
|
||||
|
||||
import android.animation.Animator;
|
||||
|
||||
public abstract class AnimationCompleteListener implements Animator.AnimatorListener {
|
||||
@Override
|
||||
public final void onAnimationStart(Animator animation) {}
|
||||
|
||||
@Override
|
||||
public abstract void onAnimationEnd(Animator animation);
|
||||
|
||||
@Override
|
||||
public final void onAnimationCancel(Animator animation) {}
|
||||
@Override
|
||||
public final void onAnimationRepeat(Animator animation) {}
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
package org.thoughtcrime.securesms.animation;
|
||||
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.animation.Animation;
|
||||
import android.view.animation.Transformation;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class ResizeAnimation extends Animation {
|
||||
|
||||
private final View target;
|
||||
private final int targetWidthPx;
|
||||
private final int targetHeightPx;
|
||||
|
||||
private int startWidth;
|
||||
private int startHeight;
|
||||
|
||||
public ResizeAnimation(@NonNull View target, int targetWidthPx, int targetHeightPx) {
|
||||
this.target = target;
|
||||
this.targetWidthPx = targetWidthPx;
|
||||
this.targetHeightPx = targetHeightPx;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void applyTransformation(float interpolatedTime, Transformation t) {
|
||||
int newWidth = (int) (startWidth + (targetWidthPx - startWidth) * interpolatedTime);
|
||||
int newHeight = (int) (startHeight + (targetHeightPx - startHeight) * interpolatedTime);
|
||||
|
||||
ViewGroup.LayoutParams params = target.getLayoutParams();
|
||||
|
||||
params.width = newWidth;
|
||||
params.height = newHeight;
|
||||
|
||||
target.setLayoutParams(params);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize(int width, int height, int parentWidth, int parentHeight) {
|
||||
super.initialize(width, height, parentWidth, parentHeight);
|
||||
|
||||
this.startWidth = width;
|
||||
this.startHeight = height;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean willChangeBounds() {
|
||||
return true;
|
||||
}
|
||||
}
|
@ -1,21 +1,30 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.AttrRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.v2.Util;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class CompositeEmojiPageModel implements EmojiPageModel {
|
||||
@AttrRes private final int iconAttr;
|
||||
@NonNull private final EmojiPageModel[] models;
|
||||
@AttrRes private final int iconAttr;
|
||||
@NonNull private final List<EmojiPageModel> models;
|
||||
|
||||
public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull EmojiPageModel... models) {
|
||||
public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull List<EmojiPageModel> models) {
|
||||
this.iconAttr = iconAttr;
|
||||
this.models = models;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKey() {
|
||||
return Util.hasItems(models) ? models.get(0).getKey() : "";
|
||||
}
|
||||
|
||||
public int getIconAttr() {
|
||||
return iconAttr;
|
||||
}
|
||||
@ -44,7 +53,7 @@ public class CompositeEmojiPageModel implements EmojiPageModel {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable String getSprite() {
|
||||
public @Nullable Uri getSpriteUri() {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,27 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class Emoji {
|
||||
|
||||
private final List<String> variations;
|
||||
private final List<String> rawVariations;
|
||||
|
||||
public Emoji(String... variations) {
|
||||
this.variations = Arrays.asList(variations);
|
||||
this(Arrays.asList(variations), Collections.emptyList());
|
||||
}
|
||||
|
||||
public Emoji(List<String> variations) {
|
||||
this(variations, Collections.emptyList());
|
||||
}
|
||||
|
||||
public Emoji(List<String> variations, List<String> rawVariations) {
|
||||
this.variations = variations;
|
||||
this.rawVariations = rawVariations;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
@ -18,4 +31,15 @@ public class Emoji {
|
||||
public List<String> getVariations() {
|
||||
return variations;
|
||||
}
|
||||
|
||||
public boolean hasMultipleVariations() {
|
||||
return variations.size() > 1;
|
||||
}
|
||||
|
||||
public @Nullable String getRawVariation(int variationIndex) {
|
||||
if (rawVariations != null && variationIndex >= 0 && variationIndex < rawVariations.size()) {
|
||||
return rawVariations.get(variationIndex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,23 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatEditText;
|
||||
import android.text.InputFilter;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatEditText;
|
||||
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
|
||||
public class EmojiEditText extends AppCompatEditText {
|
||||
private static final String TAG = EmojiEditText.class.getSimpleName();
|
||||
private static final String TAG = Log.tag(EmojiEditText.class);
|
||||
|
||||
public EmojiEditText(Context context) {
|
||||
this(context, null);
|
||||
@ -26,8 +29,14 @@ public class EmojiEditText extends AppCompatEditText {
|
||||
|
||||
public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
if (!TextSecurePreferences.isSystemEmojiPreferred(getContext())) {
|
||||
setFilters(appendEmojiFilter(this.getFilters()));
|
||||
|
||||
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
|
||||
boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
|
||||
boolean jumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
|
||||
a.recycle();
|
||||
|
||||
if (!isInEditMode() && forceCustom) {
|
||||
setFilters(appendEmojiFilter(this.getFilters(), jumboEmoji));
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,7 +54,7 @@ public class EmojiEditText extends AppCompatEditText {
|
||||
else super.invalidateDrawable(drawable);
|
||||
}
|
||||
|
||||
private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters) {
|
||||
private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters, boolean jumboEmoji) {
|
||||
InputFilter[] result;
|
||||
|
||||
if (originalFilters != null) {
|
||||
@ -55,7 +64,7 @@ public class EmojiEditText extends AppCompatEditText {
|
||||
result = new InputFilter[1];
|
||||
}
|
||||
|
||||
result[0] = new EmojiFilter(this);
|
||||
result[0] = new EmojiFilter(this, jumboEmoji);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.view.KeyEvent;
|
||||
|
||||
public interface EmojiEventListener {
|
||||
void onEmojiSelected(String emoji);
|
||||
|
||||
void onKeyEvent(KeyEvent keyEvent);
|
||||
}
|
@ -8,9 +8,11 @@ import android.widget.TextView;
|
||||
|
||||
public class EmojiFilter implements InputFilter {
|
||||
private TextView view;
|
||||
private boolean jumboEmoji;
|
||||
|
||||
public EmojiFilter(TextView view) {
|
||||
this.view = view;
|
||||
public EmojiFilter(TextView view, boolean jumboEmoji) {
|
||||
this.view = view;
|
||||
this.jumboEmoji = jumboEmoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -19,7 +21,7 @@ public class EmojiFilter implements InputFilter {
|
||||
char[] v = new char[end - start];
|
||||
TextUtils.getChars(source, start, end, v, 0);
|
||||
|
||||
Spannable emojified = EmojiProvider.getInstance(view.getContext()).emojify(new String(v), view);
|
||||
Spannable emojified = EmojiProvider.emojify(new String(v), view, jumboEmoji);
|
||||
|
||||
if (source instanceof Spanned && emojified != null) {
|
||||
TextUtils.copySpansFrom((Spanned) source, start, end, null, emojified, 0);
|
||||
|
@ -0,0 +1,46 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class EmojiImageView extends AppCompatImageView {
|
||||
|
||||
private final boolean forceJumboEmoji;
|
||||
|
||||
public EmojiImageView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public EmojiImageView(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public EmojiImageView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiImageView, 0, 0);
|
||||
forceJumboEmoji = a.getBoolean(R.styleable.EmojiImageView_forceJumbo, false);
|
||||
a.recycle();
|
||||
}
|
||||
|
||||
public void setImageEmoji(CharSequence emoji) {
|
||||
if (isInEditMode()) {
|
||||
setImageResource(R.drawable.ic_emoji);
|
||||
} else {
|
||||
Drawable emojiDrawable = EmojiProvider.getEmojiDrawable(getContext(), emoji);
|
||||
if (emojiDrawable == null) {
|
||||
// fallback
|
||||
setImageResource(R.drawable.ic_outline_disabled_by_default_24);
|
||||
} else {
|
||||
setImageDrawable(emojiDrawable);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package org.thoughtcrime.securesms.components.emoji
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiModel
|
||||
import org.thoughtcrime.securesms.conversation.v2.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.InsetItemDecoration
|
||||
|
||||
private val EDGE_LENGTH: Int = ViewUtil.dpToPx(6)
|
||||
private val HORIZONTAL_INSET: Int = ViewUtil.dpToPx(6)
|
||||
private val EMOJI_VERTICAL_INSET: Int = ViewUtil.dpToPx(5)
|
||||
private val HEADER_VERTICAL_INSET: Int = ViewUtil.dpToPx(8)
|
||||
|
||||
/**
|
||||
* Use super class to add insets to the emojis and use the [onDrawOver] to draw the variation
|
||||
* hint if the emoji has more than one variation.
|
||||
*/
|
||||
class EmojiItemDecoration(private val allowVariations: Boolean, private val variationsDrawable: Drawable) : InsetItemDecoration(SetInset()) {
|
||||
|
||||
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
super.onDrawOver(canvas, parent, state)
|
||||
|
||||
val adapter: EmojiPageViewGridAdapter? = parent.adapter as? EmojiPageViewGridAdapter
|
||||
if (allowVariations && adapter != null) {
|
||||
for (i in 0 until parent.childCount) {
|
||||
val child: View = parent.getChildAt(i)
|
||||
val position: Int = parent.getChildAdapterPosition(child)
|
||||
if (position >= 0 && position <= adapter.itemCount) {
|
||||
val model = adapter.currentList[position]
|
||||
if (model is EmojiModel && model.emoji.hasMultipleVariations()) {
|
||||
variationsDrawable.setBounds(child.right, child.bottom - EDGE_LENGTH, child.right + EDGE_LENGTH, child.bottom)
|
||||
variationsDrawable.draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SetInset : InsetItemDecoration.SetInset() {
|
||||
override fun setInset(outRect: Rect, view: View, parent: RecyclerView) {
|
||||
val isHeader = view.javaClass == AppCompatTextView::class.java
|
||||
|
||||
outRect.left = HORIZONTAL_INSET
|
||||
outRect.right = HORIZONTAL_INSET
|
||||
outRect.top = if (isHeader) HEADER_VERTICAL_INSET else EMOJI_VERTICAL_INSET
|
||||
outRect.bottom = if (isHeader) 0 else EMOJI_VERTICAL_INSET
|
||||
}
|
||||
}
|
||||
}
|
@ -136,8 +136,7 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
|
||||
|
||||
@Override
|
||||
public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) {
|
||||
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener);
|
||||
page.setModel(pages.get(position));
|
||||
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener, false);
|
||||
container.addView(page);
|
||||
return page;
|
||||
}
|
||||
@ -160,8 +159,4 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
|
||||
}
|
||||
}
|
||||
|
||||
public interface EmojiEventListener {
|
||||
void onEmojiSelected(String emoji);
|
||||
void onKeyEvent(KeyEvent keyEvent);
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,15 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.Nullable;
|
||||
import java.util.List;
|
||||
|
||||
public interface EmojiPageModel {
|
||||
String getKey();
|
||||
int getIconAttr();
|
||||
List<String> getEmoji();
|
||||
List<Emoji> getDisplayEmoji();
|
||||
boolean hasSpriteMap();
|
||||
String getSprite();
|
||||
@Nullable Uri getSpriteUri();
|
||||
boolean isDynamic();
|
||||
}
|
||||
|
@ -1,60 +1,136 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiHeader;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiNoResultsModel;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
|
||||
import org.thoughtcrime.securesms.conversation.v2.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class EmojiPageView extends FrameLayout implements VariationSelectorListener {
|
||||
private static final String TAG = EmojiPageView.class.getSimpleName();
|
||||
|
||||
private EmojiPageModel model;
|
||||
private EmojiPageViewGridAdapter adapter;
|
||||
private RecyclerView recyclerView;
|
||||
private GridLayoutManager layoutManager;
|
||||
public class EmojiPageView extends RecyclerView implements VariationSelectorListener {
|
||||
private AdapterFactory adapterFactory;
|
||||
private LinearLayoutManager layoutManager;
|
||||
private RecyclerView.OnItemTouchListener scrollDisabler;
|
||||
private VariationSelectorListener variationSelectorListener;
|
||||
private EmojiVariationSelectorPopup popup;
|
||||
|
||||
public EmojiPageView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public EmojiPageView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public EmojiPageView(@NonNull Context context,
|
||||
@NonNull EmojiKeyboardProvider.EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener)
|
||||
@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations)
|
||||
{
|
||||
super(context);
|
||||
final View view = LayoutInflater.from(getContext()).inflate(R.layout.emoji_grid_layout, this, true);
|
||||
initialize(emojiSelectionListener, variationSelectorListener, allowVariations);
|
||||
}
|
||||
|
||||
public EmojiPageView(@NonNull Context context,
|
||||
@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations,
|
||||
@NonNull LinearLayoutManager layoutManager,
|
||||
@LayoutRes int displayEmojiLayoutResId,
|
||||
@LayoutRes int displayEmoticonLayoutResId)
|
||||
{
|
||||
super(context);
|
||||
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, layoutManager, displayEmojiLayoutResId, displayEmoticonLayoutResId);
|
||||
}
|
||||
|
||||
public void initialize(@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations)
|
||||
{
|
||||
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, new GridLayoutManager(getContext(), 8), R.layout.emoji_display_item_grid, R.layout.emoji_text_display_item_grid);
|
||||
}
|
||||
|
||||
public void initialize(@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations,
|
||||
@NonNull LinearLayoutManager layoutManager,
|
||||
@LayoutRes int displayEmojiLayoutResId,
|
||||
@LayoutRes int displayEmoticonLayoutResId)
|
||||
{
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
|
||||
recyclerView = view.findViewById(R.id.emoji);
|
||||
layoutManager = new GridLayoutManager(context, 8);
|
||||
scrollDisabler = new ScrollDisabler();
|
||||
popup = new EmojiVariationSelectorPopup(context, emojiSelectionListener);
|
||||
adapter = new EmojiPageViewGridAdapter(EmojiProvider.getInstance(context),
|
||||
popup,
|
||||
emojiSelectionListener,
|
||||
this);
|
||||
this.layoutManager = layoutManager;
|
||||
this.scrollDisabler = new ScrollDisabler();
|
||||
this.popup = new EmojiVariationSelectorPopup(getContext(), emojiSelectionListener);
|
||||
this.adapterFactory = () -> new EmojiPageViewGridAdapter(popup,
|
||||
emojiSelectionListener,
|
||||
this,
|
||||
allowVariations,
|
||||
displayEmojiLayoutResId,
|
||||
displayEmoticonLayoutResId);
|
||||
|
||||
recyclerView.setLayoutManager(layoutManager);
|
||||
recyclerView.setAdapter(adapter);
|
||||
if (this.layoutManager instanceof GridLayoutManager) {
|
||||
GridLayoutManager gridLayout = (GridLayoutManager) this.layoutManager;
|
||||
gridLayout.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
|
||||
@Override
|
||||
public int getSpanSize(int position) {
|
||||
if (getAdapter() != null) {
|
||||
Optional<MappingModel<?>> model = getAdapter().getModel(position);
|
||||
if (model.isPresent() && (model.get() instanceof EmojiHeader || model.get() instanceof EmojiNoResultsModel)) {
|
||||
return gridLayout.getSpanCount();
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setLayoutManager(layoutManager);
|
||||
|
||||
Drawable drawable = DrawableUtil.tint(ContextUtil.requireDrawable(getContext(), R.drawable.triangle_bottom_right_corner), ContextCompat.getColor(getContext(), R.color.signal_button_secondary_text_disabled));
|
||||
addItemDecoration(new EmojiItemDecoration(allowVariations, drawable));
|
||||
}
|
||||
|
||||
public void presentForEmojiKeyboard() {
|
||||
setPadding(getPaddingLeft(),
|
||||
getPaddingTop(),
|
||||
getPaddingRight(),
|
||||
getPaddingBottom() + ViewUtil.dpToPx(56));
|
||||
|
||||
setClipToPadding(false);
|
||||
}
|
||||
|
||||
public void onSelected() {
|
||||
if (model.isDynamic() && adapter != null) {
|
||||
adapter.notifyDataSetChanged();
|
||||
if (getAdapter() != null) {
|
||||
getAdapter().notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void setModel(EmojiPageModel model) {
|
||||
this.model = model;
|
||||
adapter.setEmoji(model.getDisplayEmoji());
|
||||
public void setList(@NonNull List<MappingModel<?>> list, @Nullable Runnable commitCallback) {
|
||||
EmojiPageViewGridAdapter adapter = adapterFactory.create();
|
||||
setAdapter(adapter);
|
||||
adapter.submitList(list, commitCallback);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -66,16 +142,21 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
|
||||
layoutManager.setSpanCount(Math.max(w / idealWidth, 1));
|
||||
if (layoutManager instanceof GridLayoutManager) {
|
||||
int viewWidth = w - getPaddingStart() - getPaddingEnd();
|
||||
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
|
||||
int spanCount = Math.max(viewWidth / idealWidth, 1);
|
||||
|
||||
((GridLayoutManager) layoutManager).setSpanCount(spanCount);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVariationSelectorStateChanged(boolean open) {
|
||||
if (open) {
|
||||
recyclerView.addOnItemTouchListener(scrollDisabler);
|
||||
addOnItemTouchListener(scrollDisabler);
|
||||
} else {
|
||||
post(() -> recyclerView.removeOnItemTouchListener(scrollDisabler));
|
||||
post(() -> removeOnItemTouchListener(scrollDisabler));
|
||||
}
|
||||
|
||||
if (variationSelectorListener != null) {
|
||||
@ -83,6 +164,32 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
|
||||
}
|
||||
}
|
||||
|
||||
public void setRecyclerNestedScrollingEnabled(boolean enabled) {
|
||||
setNestedScrollingEnabled(enabled);
|
||||
}
|
||||
|
||||
public void smoothScrollToPositionTop(int position) {
|
||||
int currentPosition = layoutManager.findFirstCompletelyVisibleItemPosition();
|
||||
boolean shortTrip = Math.abs(currentPosition - position) < 475;
|
||||
|
||||
if (shortTrip) {
|
||||
RecyclerView.SmoothScroller smoothScroller = new LinearSmoothScroller(getContext()) {
|
||||
@Override
|
||||
protected int getVerticalSnapPreference() {
|
||||
return LinearSmoothScroller.SNAP_TO_START;
|
||||
}
|
||||
};
|
||||
smoothScroller.setTargetPosition(position);
|
||||
layoutManager.startSmoothScroll(smoothScroller);
|
||||
} else {
|
||||
layoutManager.scrollToPositionWithOffset(position, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable EmojiPageViewGridAdapter getAdapter() {
|
||||
return (EmojiPageViewGridAdapter) super.getAdapter();
|
||||
}
|
||||
|
||||
private static class ScrollDisabler implements RecyclerView.OnItemTouchListener {
|
||||
@Override
|
||||
public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) {
|
||||
@ -95,4 +202,8 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
|
||||
@Override
|
||||
public void onRequestDisallowInterceptTouchEvent(boolean b) { }
|
||||
}
|
||||
|
||||
private interface AdapterFactory {
|
||||
EmojiPageViewGridAdapter create();
|
||||
}
|
||||
}
|
||||
|
@ -1,94 +1,40 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.PopupWindow;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWindow.OnDismissListener {
|
||||
|
||||
public class EmojiPageViewGridAdapter extends RecyclerView.Adapter<EmojiPageViewGridAdapter.EmojiViewHolder> implements PopupWindow.OnDismissListener {
|
||||
private final VariationSelectorListener variationSelectorListener;
|
||||
|
||||
private final List<Emoji> emojiList;
|
||||
private final EmojiProvider emojiProvider;
|
||||
private final EmojiVariationSelectorPopup popup;
|
||||
private final VariationSelectorListener variationSelectorListener;
|
||||
private final EmojiEventListener emojiEventListener;
|
||||
|
||||
public EmojiPageViewGridAdapter(@NonNull EmojiProvider emojiProvider,
|
||||
@NonNull EmojiVariationSelectorPopup popup,
|
||||
public EmojiPageViewGridAdapter(@NonNull EmojiVariationSelectorPopup popup,
|
||||
@NonNull EmojiEventListener emojiEventListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener)
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations,
|
||||
@LayoutRes int displayEmojiLayoutResId,
|
||||
@LayoutRes int displayEmoticonLayoutResId)
|
||||
{
|
||||
this.emojiList = new ArrayList<>();
|
||||
this.emojiProvider = emojiProvider;
|
||||
this.popup = popup;
|
||||
this.emojiEventListener = emojiEventListener;
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
|
||||
popup.setOnDismissListener(this);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public EmojiViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
|
||||
return new EmojiViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.emoji_display_item, viewGroup, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull EmojiViewHolder viewHolder, int i) {
|
||||
Emoji emoji = emojiList.get(i);
|
||||
|
||||
Drawable drawable = emojiProvider.getEmojiDrawable(emoji.getValue());
|
||||
|
||||
if (drawable != null) {
|
||||
viewHolder.textView.setVisibility(View.GONE);
|
||||
viewHolder.imageView.setVisibility(View.VISIBLE);
|
||||
|
||||
viewHolder.imageView.setImageDrawable(drawable);
|
||||
} else {
|
||||
viewHolder.textView.setVisibility(View.VISIBLE);
|
||||
viewHolder.imageView.setVisibility(View.GONE);
|
||||
|
||||
viewHolder.textView.setEmoji(emoji.getValue());
|
||||
}
|
||||
|
||||
viewHolder.itemView.setOnClickListener(v -> {
|
||||
emojiEventListener.onEmojiSelected(emoji.getValue());
|
||||
});
|
||||
|
||||
if (emoji.getVariations().size() > 1) {
|
||||
viewHolder.itemView.setOnLongClickListener(v -> {
|
||||
popup.dismiss();
|
||||
popup.setVariations(emoji.getVariations());
|
||||
popup.showAsDropDown(viewHolder.itemView, 0, -(2 * viewHolder.itemView.getHeight()));
|
||||
variationSelectorListener.onVariationSelectorStateChanged(true);
|
||||
return true;
|
||||
});
|
||||
viewHolder.hintCorner.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
viewHolder.itemView.setOnLongClickListener(null);
|
||||
viewHolder.hintCorner.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return emojiList.size();
|
||||
}
|
||||
|
||||
public void setEmoji(@NonNull List<Emoji> emojiList) {
|
||||
this.emojiList.clear();
|
||||
this.emojiList.addAll(emojiList);
|
||||
notifyDataSetChanged();
|
||||
registerFactory(EmojiHeader.class, new LayoutFactory<>(EmojiHeaderViewHolder::new, R.layout.emoji_grid_header));
|
||||
registerFactory(EmojiModel.class, new LayoutFactory<>(v -> new EmojiViewHolder(v, emojiEventListener, variationSelectorListener, popup, allowVariations), displayEmojiLayoutResId));
|
||||
registerFactory(EmojiTextModel.class, new LayoutFactory<>(v -> new EmojiTextViewHolder(v, emojiEventListener), displayEmoticonLayoutResId));
|
||||
registerFactory(EmojiNoResultsModel.class, new LayoutFactory<>(MappingViewHolder.SimpleViewHolder::new, R.layout.emoji_grid_no_results));
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -96,18 +42,196 @@ public class EmojiPageViewGridAdapter extends RecyclerView.Adapter<EmojiPageView
|
||||
variationSelectorListener.onVariationSelectorStateChanged(false);
|
||||
}
|
||||
|
||||
static class EmojiViewHolder extends RecyclerView.ViewHolder {
|
||||
public static class EmojiHeader implements MappingModel<EmojiHeader>, HasKey {
|
||||
|
||||
private final ImageView imageView;
|
||||
private final AsciiEmojiView textView;
|
||||
private final ImageView hintCorner;
|
||||
private final String key;
|
||||
private final int title;
|
||||
|
||||
public EmojiViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
this.imageView = itemView.findViewById(R.id.emoji_image);
|
||||
this.textView = itemView.findViewById(R.id.emoji_text);
|
||||
this.hintCorner = itemView.findViewById(R.id.emoji_variation_hint);
|
||||
public EmojiHeader(@NonNull String key, int title) {
|
||||
this.key = key;
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull EmojiHeader newItem) {
|
||||
return title == newItem.title;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull EmojiHeader newItem) {
|
||||
return areItemsTheSame(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
static class EmojiHeaderViewHolder extends MappingViewHolder<EmojiHeader> {
|
||||
|
||||
private final TextView title;
|
||||
|
||||
public EmojiHeaderViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
title = findViewById(R.id.emoji_grid_header_title);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull EmojiHeader model) {
|
||||
title.setText(model.title);
|
||||
}
|
||||
}
|
||||
|
||||
public static class EmojiModel implements MappingModel<EmojiModel>, HasKey {
|
||||
|
||||
private final String key;
|
||||
private final Emoji emoji;
|
||||
|
||||
public EmojiModel(@NonNull String key, @NonNull Emoji emoji) {
|
||||
this.key = key;
|
||||
this.emoji = emoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public @NonNull Emoji getEmoji() {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull EmojiModel newItem) {
|
||||
return newItem.emoji.getValue().equals(emoji.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull EmojiModel newItem) {
|
||||
return areItemsTheSame(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
static class EmojiViewHolder extends MappingViewHolder<EmojiModel> {
|
||||
|
||||
private final EmojiVariationSelectorPopup popup;
|
||||
private final VariationSelectorListener variationSelectorListener;
|
||||
private final EmojiEventListener emojiEventListener;
|
||||
private final boolean allowVariations;
|
||||
|
||||
private final ImageView imageView;
|
||||
|
||||
public EmojiViewHolder(@NonNull View itemView,
|
||||
@NonNull EmojiEventListener emojiEventListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
@NonNull EmojiVariationSelectorPopup popup,
|
||||
boolean allowVariations)
|
||||
{
|
||||
super(itemView);
|
||||
|
||||
this.popup = popup;
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
this.emojiEventListener = emojiEventListener;
|
||||
this.allowVariations = allowVariations;
|
||||
|
||||
this.imageView = itemView.findViewById(R.id.emoji_image);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull EmojiModel model) {
|
||||
final Drawable drawable = EmojiProvider.getEmojiDrawable(imageView.getContext(), model.emoji.getValue());
|
||||
|
||||
if (drawable != null) {
|
||||
imageView.setVisibility(View.VISIBLE);
|
||||
imageView.setImageDrawable(drawable);
|
||||
}
|
||||
|
||||
itemView.setOnClickListener(v -> {
|
||||
emojiEventListener.onEmojiSelected(model.emoji.getValue());
|
||||
});
|
||||
|
||||
if (allowVariations && model.emoji.hasMultipleVariations()) {
|
||||
itemView.setOnLongClickListener(v -> {
|
||||
popup.dismiss();
|
||||
popup.setVariations(model.emoji.getVariations());
|
||||
popup.showAsDropDown(itemView, 0, -(2 * itemView.getHeight()));
|
||||
variationSelectorListener.onVariationSelectorStateChanged(true);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
itemView.setOnLongClickListener(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class EmojiTextModel implements MappingModel<EmojiTextModel>, HasKey {
|
||||
private final String key;
|
||||
private final Emoji emoji;
|
||||
|
||||
public EmojiTextModel(@NonNull String key, @NonNull Emoji emoji) {
|
||||
this.key = key;
|
||||
this.emoji = emoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public @NonNull Emoji getEmoji() {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull EmojiTextModel newItem) {
|
||||
return newItem.emoji.getValue().equals(emoji.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull EmojiTextModel newItem) {
|
||||
return areItemsTheSame(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
static class EmojiTextViewHolder extends MappingViewHolder<EmojiTextModel> {
|
||||
|
||||
private final EmojiEventListener emojiEventListener;
|
||||
private final AsciiEmojiView textView;
|
||||
|
||||
public EmojiTextViewHolder(@NonNull View itemView,
|
||||
@NonNull EmojiEventListener emojiEventListener)
|
||||
{
|
||||
super(itemView);
|
||||
|
||||
this.emojiEventListener = emojiEventListener;
|
||||
this.textView = itemView.findViewById(R.id.emoji_text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull EmojiTextModel model) {
|
||||
textView.setEmoji(model.emoji.getValue());
|
||||
|
||||
itemView.setOnClickListener(v -> {
|
||||
emojiEventListener.onEmojiSelected(model.emoji.getValue());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static class EmojiNoResultsModel implements MappingModel<EmojiNoResultsModel> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull EmojiNoResultsModel newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull EmojiNoResultsModel newItem) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public interface HasKey {
|
||||
@NonNull String getKey();
|
||||
}
|
||||
|
||||
public interface VariationSelectorListener {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import static org.session.libsession.utilities.Util.runOnMain;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
@ -9,145 +10,156 @@ import android.graphics.Paint;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build.VERSION;
|
||||
import android.os.Build.VERSION_CODES;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.TextView;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiPageBitmap;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiTree;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.session.libsession.utilities.FutureTaskListener;
|
||||
import org.session.libsession.utilities.Util;
|
||||
import org.session.libsignal.utilities.Pair;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiPageCache;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
class EmojiProvider {
|
||||
public class EmojiProvider {
|
||||
|
||||
private static final String TAG = EmojiProvider.class.getSimpleName();
|
||||
private static volatile EmojiProvider instance = null;
|
||||
private static final Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG);
|
||||
private static final String TAG = Log.tag(EmojiProvider.class);
|
||||
private static final Paint PAINT = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG);
|
||||
|
||||
private final EmojiTree emojiTree = new EmojiTree();
|
||||
|
||||
private static final int EMOJI_RAW_HEIGHT = 64;
|
||||
private static final int EMOJI_RAW_WIDTH = 64;
|
||||
private static final int EMOJI_VERT_PAD = 0;
|
||||
private static final int EMOJI_PER_ROW = 32;
|
||||
|
||||
private final float decodeScale;
|
||||
private final float verticalPad;
|
||||
|
||||
public static EmojiProvider getInstance(Context context) {
|
||||
if (instance == null) {
|
||||
synchronized (EmojiProvider.class) {
|
||||
if (instance == null) {
|
||||
instance = new EmojiProvider(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private EmojiProvider(Context context) {
|
||||
this.decodeScale = Math.min(1f, context.getResources().getDimension(R.dimen.emoji_drawer_size) / EMOJI_RAW_HEIGHT);
|
||||
this.verticalPad = EMOJI_VERT_PAD * this.decodeScale;
|
||||
|
||||
for (EmojiPageModel page : EmojiPages.DATA_PAGES) {
|
||||
if (page.hasSpriteMap()) {
|
||||
EmojiPageBitmap pageBitmap = new EmojiPageBitmap(context, page, decodeScale);
|
||||
|
||||
List<String> emojis = page.getEmoji();
|
||||
for (int i = 0; i < emojis.size(); i++) {
|
||||
emojiTree.add(emojis.get(i), new EmojiDrawInfo(pageBitmap, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (Pair<String,String> obsolete : EmojiPages.OBSOLETE) {
|
||||
emojiTree.add(obsolete.first(), emojiTree.getEmoji(obsolete.second(), 0, obsolete.second().length()));
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable EmojiParser.CandidateList getCandidates(@Nullable CharSequence text) {
|
||||
public static @Nullable EmojiParser.CandidateList getCandidates(@Nullable CharSequence text) {
|
||||
if (text == null) return null;
|
||||
return new EmojiParser(emojiTree).findCandidates(text);
|
||||
return new EmojiParser(EmojiSource.getLatest().getEmojiTree()).findCandidates(text);
|
||||
}
|
||||
|
||||
@Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv) {
|
||||
return emojify(getCandidates(text), text, tv);
|
||||
static @Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv, boolean jumboEmoji) {
|
||||
if (tv.isInEditMode()) {
|
||||
return null;
|
||||
} else {
|
||||
return emojify(getCandidates(text), text, tv, jumboEmoji);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches,
|
||||
@Nullable CharSequence text,
|
||||
@NonNull TextView tv) {
|
||||
if (matches == null || text == null) return null;
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder(text);
|
||||
static @Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches,
|
||||
@Nullable CharSequence text,
|
||||
@NonNull TextView tv,
|
||||
boolean jumboEmoji)
|
||||
{
|
||||
if (matches == null || text == null || tv.isInEditMode()) return null;
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder(text);
|
||||
|
||||
for (EmojiParser.Candidate candidate : matches) {
|
||||
Drawable drawable = getEmojiDrawable(candidate.getDrawInfo());
|
||||
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout, jumboEmoji);
|
||||
|
||||
if (drawable != null) {
|
||||
builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@Nullable Drawable getEmojiDrawable(CharSequence emoji) {
|
||||
EmojiDrawInfo drawInfo = emojiTree.getEmoji(emoji, 0, emoji.length());
|
||||
return getEmojiDrawable(drawInfo);
|
||||
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji) {
|
||||
return getEmojiDrawable(context, emoji, false);
|
||||
}
|
||||
|
||||
private @Nullable Drawable getEmojiDrawable(@Nullable EmojiDrawInfo drawInfo) {
|
||||
if (drawInfo == null) {
|
||||
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji, boolean jumboEmoji) {
|
||||
if (TextUtils.isEmpty(emoji)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final EmojiDrawable drawable = new EmojiDrawable(drawInfo, decodeScale);
|
||||
drawInfo.getPage().get().addListener(new FutureTaskListener<Bitmap>() {
|
||||
@Override public void onSuccess(final Bitmap result) {
|
||||
Util.runOnMain(() -> drawable.setBitmap(result));
|
||||
}
|
||||
EmojiDrawInfo drawInfo = EmojiSource.getLatest().getEmojiTree().getEmoji(emoji, 0, emoji.length());
|
||||
return getEmojiDrawable(context, drawInfo, null, jumboEmoji);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an EmojiDrawable from the Page Cache
|
||||
*
|
||||
* @param context Context object used in reading and writing from disk
|
||||
* @param drawInfo Information about the emoji being displayed
|
||||
* @param onEmojiLoaded Runnable which will trigger when an emoji is loaded from disk
|
||||
*/
|
||||
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, @Nullable Runnable onEmojiLoaded, boolean jumboEmoji) {
|
||||
if (drawInfo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final int lowMemoryDecodeScale = Util.isLowMemory(context) ? 2 : 1;
|
||||
final EmojiSource source = EmojiSource.getLatest();
|
||||
final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo, lowMemoryDecodeScale);
|
||||
final AtomicBoolean jumboLoaded = new AtomicBoolean(false);
|
||||
|
||||
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
|
||||
|
||||
if (loadResult instanceof EmojiPageCache.LoadResult.Immediate) {
|
||||
runOnMain(() -> drawable.setBitmap(((EmojiPageCache.LoadResult.Immediate) loadResult).getBitmap()));
|
||||
} else if (loadResult instanceof EmojiPageCache.LoadResult.Async) {
|
||||
((EmojiPageCache.LoadResult.Async) loadResult).getTask().addListener(new FutureTaskListener<Bitmap>() {
|
||||
@Override
|
||||
public void onSuccess(Bitmap result) {
|
||||
runOnMain(() -> {
|
||||
if (!jumboLoaded.get()) {
|
||||
drawable.setBitmap(result);
|
||||
if (onEmojiLoaded != null) {
|
||||
onEmojiLoaded.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException exception) {
|
||||
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
|
||||
}
|
||||
|
||||
@Override public void onFailure(ExecutionException error) {
|
||||
Log.w(TAG, error);
|
||||
}
|
||||
});
|
||||
return drawable;
|
||||
}
|
||||
|
||||
class EmojiDrawable extends Drawable {
|
||||
private final EmojiDrawInfo info;
|
||||
private Bitmap bmp;
|
||||
private float intrinsicWidth;
|
||||
private float intrinsicHeight;
|
||||
static final class EmojiDrawable extends Drawable {
|
||||
private final float intrinsicWidth;
|
||||
private final float intrinsicHeight;
|
||||
private final Rect emojiBounds;
|
||||
|
||||
private Bitmap bmp;
|
||||
private boolean isSingleBitmap;
|
||||
|
||||
@Override
|
||||
public int getIntrinsicWidth() {
|
||||
return (int)intrinsicWidth;
|
||||
return (int) intrinsicWidth;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getIntrinsicHeight() {
|
||||
return (int)intrinsicHeight;
|
||||
return (int) intrinsicHeight;
|
||||
}
|
||||
|
||||
EmojiDrawable(EmojiDrawInfo info, float decodeScale) {
|
||||
this.info = info;
|
||||
this.intrinsicWidth = EMOJI_RAW_WIDTH * decodeScale;
|
||||
this.intrinsicHeight = EMOJI_RAW_HEIGHT * decodeScale;
|
||||
EmojiDrawable(@NonNull EmojiSource source, @NonNull EmojiDrawInfo info, int lowMemoryDecodeScale) {
|
||||
this.intrinsicWidth = (source.getMetrics().getRawWidth() * source.getDecodeScale()) / lowMemoryDecodeScale;
|
||||
this.intrinsicHeight = (source.getMetrics().getRawHeight() * source.getDecodeScale()) / lowMemoryDecodeScale;
|
||||
|
||||
final int glyphWidth = (int) (intrinsicWidth);
|
||||
final int glyphHeight = (int) (intrinsicHeight);
|
||||
final int index = info.getIndex();
|
||||
final int emojiPerRow = source.getMetrics().getPerRow();
|
||||
final int xStart = (index % emojiPerRow) * glyphWidth;
|
||||
final int yStart = (index / emojiPerRow) * glyphHeight;
|
||||
|
||||
this.emojiBounds = new Rect(xStart + 1,
|
||||
yStart + 1,
|
||||
xStart + glyphWidth - 1,
|
||||
yStart + glyphHeight - 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -156,22 +168,23 @@ class EmojiProvider {
|
||||
return;
|
||||
}
|
||||
|
||||
final int row = info.getIndex() / EMOJI_PER_ROW;
|
||||
final int row_index = info.getIndex() % EMOJI_PER_ROW;
|
||||
|
||||
canvas.drawBitmap(bmp,
|
||||
new Rect((int)(row_index * intrinsicWidth),
|
||||
(int)(row * intrinsicHeight + row * verticalPad)+1,
|
||||
(int)(((row_index + 1) * intrinsicWidth)-1),
|
||||
(int)((row + 1) * intrinsicHeight + row * verticalPad)-1),
|
||||
getBounds(),
|
||||
paint);
|
||||
isSingleBitmap ? null : emojiBounds,
|
||||
getBounds(),
|
||||
PAINT);
|
||||
}
|
||||
|
||||
@TargetApi(VERSION_CODES.HONEYCOMB_MR1)
|
||||
public void setBitmap(Bitmap bitmap) {
|
||||
Util.assertMainThread();
|
||||
if (VERSION.SDK_INT < VERSION_CODES.HONEYCOMB_MR1 || bmp == null || !bmp.sameAs(bitmap)) {
|
||||
setBitmap(bitmap, false);
|
||||
}
|
||||
|
||||
public void setSingleBitmap(Bitmap bitmap) {
|
||||
setBitmap(bitmap, true);
|
||||
}
|
||||
|
||||
private void setBitmap(Bitmap bitmap, boolean isSingleBitmap) {
|
||||
this.isSingleBitmap = isSingleBitmap;
|
||||
if (bmp == null || !bmp.sameAs(bitmap)) {
|
||||
bmp = bitmap;
|
||||
invalidateSelf();
|
||||
}
|
||||
|
@ -1,41 +1,56 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Paint.FontMetricsInt;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class EmojiSpan extends AnimatingImageSpan {
|
||||
|
||||
private final float SHIFT_FACTOR = 1.5f;
|
||||
|
||||
private final int size;
|
||||
private final FontMetricsInt fm;
|
||||
private int size;
|
||||
private FontMetricsInt fontMetrics;
|
||||
|
||||
public EmojiSpan(@NonNull Drawable drawable, @NonNull TextView tv) {
|
||||
super(drawable, tv);
|
||||
fm = tv.getPaint().getFontMetricsInt();
|
||||
size = fm != null ? Math.abs(fm.descent) + Math.abs(fm.ascent)
|
||||
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
|
||||
fontMetrics = tv.getPaint().getFontMetricsInt();
|
||||
size = fontMetrics != null ? Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent)
|
||||
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
|
||||
getDrawable().setBounds(0, 0, size, size);
|
||||
}
|
||||
|
||||
public EmojiSpan(@NonNull Context context, @NonNull Drawable drawable, @NonNull Paint paint) {
|
||||
super(drawable, null);
|
||||
fontMetrics = paint.getFontMetricsInt();
|
||||
size = fontMetrics != null ? Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent)
|
||||
: context.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
|
||||
|
||||
getDrawable().setBounds(0, 0, size, size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, FontMetricsInt fm) {
|
||||
if (fm != null && this.fm != null) {
|
||||
fm.ascent = this.fm.ascent;
|
||||
fm.descent = this.fm.descent;
|
||||
fm.top = this.fm.top;
|
||||
fm.bottom = this.fm.bottom;
|
||||
fm.leading = this.fm.leading;
|
||||
return size;
|
||||
if (fm != null && this.fontMetrics != null) {
|
||||
fm.ascent = this.fontMetrics.ascent;
|
||||
fm.descent = this.fontMetrics.descent;
|
||||
fm.top = this.fontMetrics.top;
|
||||
fm.bottom = this.fontMetrics.bottom;
|
||||
fm.leading = this.fontMetrics.leading;
|
||||
} else {
|
||||
return super.getSize(paint, text, start, end, fm);
|
||||
this.fontMetrics = paint.getFontMetricsInt();
|
||||
this.size = Math.abs(this.fontMetrics.descent) + Math.abs(this.fontMetrics.ascent);
|
||||
|
||||
getDrawable().setBounds(0, 0, size, size);
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -43,6 +58,7 @@ public class EmojiSpan extends AnimatingImageSpan {
|
||||
int height = bottom - top;
|
||||
int centeringMargin = (height - size) / 2;
|
||||
int adjustedMargin = (int) (centeringMargin * SHIFT_FACTOR);
|
||||
int adjustedBottom = bottom - adjustedMargin;
|
||||
super.draw(canvas, text, start, end, x, top, y, bottom - adjustedMargin, paint);
|
||||
}
|
||||
}
|
||||
|
@ -49,8 +49,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
|
||||
@Override public void setText(@Nullable CharSequence text, BufferType type) {
|
||||
EmojiProvider provider = EmojiProvider.getInstance(getContext());
|
||||
EmojiParser.CandidateList candidates = provider.getCandidates(text);
|
||||
EmojiParser.CandidateList candidates = EmojiProvider.getCandidates(text);
|
||||
|
||||
if (scaleEmojis && candidates != null && candidates.allEmojis) {
|
||||
int emojis = candidates.size();
|
||||
@ -82,7 +81,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
ellipsizeAnyTextForMaxLength();
|
||||
}
|
||||
} else {
|
||||
CharSequence emojified = provider.emojify(candidates, text, this);
|
||||
CharSequence emojified = EmojiProvider.emojify(candidates, text, this, false);
|
||||
super.setText(new SpannableStringBuilder(emojified).append(Optional.fromNullable(overflowText).or("")), BufferType.SPANNABLE);
|
||||
|
||||
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
|
||||
@ -107,12 +106,12 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
SpannableStringBuilder newContent = new SpannableStringBuilder();
|
||||
newContent.append(getText().subSequence(0, maxLength)).append(ELLIPSIS).append(Optional.fromNullable(overflowText).or(""));
|
||||
|
||||
EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent);
|
||||
EmojiParser.CandidateList newCandidates = EmojiProvider.getCandidates(newContent);
|
||||
|
||||
if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) {
|
||||
super.setText(newContent, BufferType.NORMAL);
|
||||
} else {
|
||||
CharSequence emojified = EmojiProvider.getInstance(getContext()).emojify(newCandidates, newContent, this);
|
||||
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, false);
|
||||
super.setText(emojified, BufferType.SPANNABLE);
|
||||
}
|
||||
}
|
||||
@ -141,8 +140,8 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
.append(ellipsized.subSequence(0, ellipsized.length()))
|
||||
.append(Optional.fromNullable(overflowText).or(""));
|
||||
|
||||
EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent);
|
||||
CharSequence emojified = EmojiProvider.getInstance(getContext()).emojify(newCandidates, newContent, this);
|
||||
EmojiParser.CandidateList newCandidates = EmojiProvider.getCandidates(newContent);
|
||||
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, false);
|
||||
|
||||
super.setText(emojified, BufferType.SPANNABLE);
|
||||
}
|
||||
|
@ -0,0 +1,54 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.emoji.ObsoleteEmoji;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class EmojiUtil {
|
||||
private static final Pattern EMOJI_PATTERN = Pattern.compile("^(?:(?:[\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9-\u21aa\u231a-\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\u24c2\u25aa-\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614-\u2615\u2618\u261d\u2620\u2622-\u2623\u2626\u262a\u262e-\u262f\u2638-\u263a\u2648-\u2653\u2660\u2663\u2665-\u2666\u2668\u267b\u267f\u2692-\u2694\u2696-\u2697\u2699\u269b-\u269c\u26a0-\u26a1\u26aa-\u26ab\u26b0-\u26b1\u26bd-\u26be\u26c4-\u26c5\u26c8\u26ce-\u26cf\u26d1\u26d3-\u26d4\u26e9-\u26ea\u26f0-\u26f5\u26f7-\u26fa\u26fd\u2702\u2705\u2708-\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2728\u2733-\u2734\u2744\u2747\u274c\u274e\u2753-\u2755\u2757\u2763-\u2764\u2795-\u2797\u27a1\u27b0\u27bf\u2934-\u2935\u2b05-\u2b07\u2b1b-\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299\ud83c\udc04\ud83c\udccf\ud83c\udd70-\ud83c\udd71\ud83c\udd7e-\ud83c\udd7f\ud83c\udd8e\ud83c\udd91-\ud83c\udd9a\ud83c\ude01-\ud83c\ude02\ud83c\ude1a\ud83c\ude2f\ud83c\ude32-\ud83c\ude3a\ud83c\ude50-\ud83c\ude51\u200d\ud83c\udf00-\ud83d\uddff\ud83d\ude00-\ud83d\ude4f\ud83d\ude80-\ud83d\udeff\ud83e\udd00-\ud83e\uddff\udb40\udc20-\udb40\udc7f]|\u200d[\u2640\u2642]|[\ud83c\udde6-\ud83c\uddff]{2}|.[\u20e0\u20e3\ufe0f]+)+)+$");
|
||||
private static final String EMOJI_REGEX = "[^\\p{L}\\p{M}\\p{N}\\p{P}\\p{Z}\\p{Cf}\\p{Cs}\\s]";
|
||||
|
||||
private EmojiUtil() {}
|
||||
|
||||
/**
|
||||
* This will return all ways we know of expressing a singular emoji. This is to aid in search,
|
||||
* where some platforms may send an emoji we've locally marked as 'obsolete'.
|
||||
*/
|
||||
public static @NonNull Set<String> getAllRepresentations(@NonNull String emoji) {
|
||||
Set<String> out = new HashSet<>();
|
||||
|
||||
out.add(emoji);
|
||||
|
||||
for (ObsoleteEmoji obsoleteEmoji : EmojiSource.getLatest().getObsolete()) {
|
||||
if (obsoleteEmoji.getObsolete().equals(emoji)) {
|
||||
out.add(obsoleteEmoji.getReplaceWith());
|
||||
} else if (obsoleteEmoji.getReplaceWith().equals(emoji)) {
|
||||
out.add(obsoleteEmoji.getObsolete());
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* When provided an emoji that is a skin variation of another, this will return the default yellow
|
||||
* version. This is to aid in search, so using a variation will still find all emojis tagged with
|
||||
* the default version.
|
||||
*
|
||||
* If the emoji has no skin variations, this function will return the original emoji.
|
||||
*/
|
||||
public static @NonNull String getCanonicalRepresentation(@NonNull String emoji) {
|
||||
String canonical = EmojiSource.getLatest().getVariationsToCanonical().get(emoji);
|
||||
return canonical != null ? canonical : emoji;
|
||||
}
|
||||
|
||||
public static boolean isCanonicallyEqual(@NonNull String left, @NonNull String right) {
|
||||
return getCanonicalRepresentation(left).equals(getCanonicalRepresentation(right));
|
||||
}
|
||||
|
||||
}
|
@ -8,8 +8,6 @@ import android.widget.PopupWindow;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
@ -37,7 +35,7 @@ public class EmojiVariationSelectorPopup extends PopupWindow {
|
||||
|
||||
for (String variation : variations) {
|
||||
ImageView imageView = (ImageView) LayoutInflater.from(context).inflate(R.layout.emoji_variation_selector_item, list, false);
|
||||
imageView.setImageDrawable(EmojiProvider.getInstance(context).getEmojiDrawable(variation));
|
||||
imageView.setImageDrawable(EmojiProvider.getEmojiDrawable(context, variation));
|
||||
imageView.setOnClickListener(v -> {
|
||||
listener.onEmojiSelected(variation);
|
||||
dismiss();
|
||||
|
@ -2,30 +2,36 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.preference.PreferenceManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.fasterxml.jackson.databind.type.CollectionType;
|
||||
import com.fasterxml.jackson.databind.type.TypeFactory;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
|
||||
import org.session.libsignal.utilities.JsonUtil;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Arrays;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
|
||||
private static final String EMOJI_LRU_PREFERENCE = "pref_recent_emoji2";
|
||||
private static final int EMOJI_LRU_SIZE = 50;
|
||||
public static final String KEY = "Recents";
|
||||
public static final List<String> DEFAULT_REACTIONS_LIST =
|
||||
Arrays.asList("\ud83d\ude02", "\ud83e\udd70", "\ud83d\ude22", "\ud83d\ude21", "\ud83d\ude2e", "\ud83d\ude08");
|
||||
|
||||
private final SharedPreferences prefs;
|
||||
private final LinkedHashSet<String> recentlyUsed;
|
||||
@ -47,14 +53,28 @@ public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override public int getIconAttr() {
|
||||
return R.attr.emoji_category_recent;
|
||||
}
|
||||
|
||||
@Override public List<String> getEmoji() {
|
||||
List<String> emoji = new ArrayList<>(recentlyUsed);
|
||||
Collections.reverse(emoji);
|
||||
return emoji;
|
||||
List<String> recent = new ArrayList<>(recentlyUsed);
|
||||
List<String> out = new ArrayList<>(DEFAULT_REACTIONS_LIST.size());
|
||||
|
||||
for (int i = 0; i < DEFAULT_REACTIONS_LIST.size(); i++) {
|
||||
if (recent.size() > i) {
|
||||
out.add(recent.get(i));
|
||||
} else {
|
||||
out.add(DEFAULT_REACTIONS_LIST.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override public List<Emoji> getDisplayEmoji() {
|
||||
@ -65,7 +85,9 @@ public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public String getSprite() {
|
||||
@Nullable
|
||||
@Override
|
||||
public Uri getSpriteUri() {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -1,38 +1,40 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import androidx.annotation.AttrRes;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiCategory;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class StaticEmojiPageModel implements EmojiPageModel {
|
||||
@AttrRes private final int iconAttr;
|
||||
@NonNull private final List<Emoji> emoji;
|
||||
@Nullable private final String sprite;
|
||||
private final @NonNull EmojiCategory category;
|
||||
private final @NonNull List<Emoji> emoji;
|
||||
private final @Nullable Uri sprite;
|
||||
|
||||
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull String[] strings, @Nullable String sprite) {
|
||||
List<Emoji> emoji = new ArrayList<>(strings.length);
|
||||
for (String s : strings) {
|
||||
emoji.add(new Emoji(s));
|
||||
}
|
||||
public StaticEmojiPageModel(@NonNull EmojiCategory category, @NonNull String[] strings, @Nullable Uri sprite) {
|
||||
this(category, Arrays.stream(strings).map(s -> new Emoji(Collections.singletonList(s))).collect(Collectors.toList()), sprite);
|
||||
}
|
||||
|
||||
this.iconAttr = iconAttr;
|
||||
this.emoji = emoji;
|
||||
public StaticEmojiPageModel(@NonNull EmojiCategory category, @NonNull List<Emoji> emoji, @Nullable Uri sprite) {
|
||||
this.category = category;
|
||||
this.emoji = Collections.unmodifiableList(emoji);
|
||||
this.sprite = sprite;
|
||||
}
|
||||
|
||||
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull Emoji[] emoji, @Nullable String sprite) {
|
||||
this.iconAttr = iconAttr;
|
||||
this.emoji = Arrays.asList(emoji);
|
||||
this.sprite = sprite;
|
||||
@Override
|
||||
public String getKey() {
|
||||
return category.getKey();
|
||||
}
|
||||
|
||||
public int getIconAttr() {
|
||||
return iconAttr;
|
||||
return category.getIcon();
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -55,7 +57,7 @@ public class StaticEmojiPageModel implements EmojiPageModel {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable String getSprite() {
|
||||
public @Nullable Uri getSpriteUri() {
|
||||
return sprite;
|
||||
}
|
||||
|
||||
|
@ -1,31 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.emoji.parsing;
|
||||
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class EmojiDrawInfo {
|
||||
|
||||
private final EmojiPageBitmap page;
|
||||
private final int index;
|
||||
|
||||
public EmojiDrawInfo(final @NonNull EmojiPageBitmap page, final int index) {
|
||||
this.page = page;
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
public @NonNull EmojiPageBitmap getPage() {
|
||||
return page;
|
||||
}
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return "DrawInfo{" +
|
||||
"page=" + page +
|
||||
", index=" + index +
|
||||
'}';
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.components.emoji.parsing
|
||||
|
||||
import org.thoughtcrime.securesms.emoji.EmojiPage
|
||||
|
||||
data class EmojiDrawInfo(val page: EmojiPage, val index: Int, private val emoji: String, val rawEmoji: String?, val jumboSheet: String?)
|
@ -48,7 +48,7 @@ public class EmojiPageBitmap {
|
||||
} else {
|
||||
Callable<Bitmap> callable = () -> {
|
||||
try {
|
||||
Log.i(TAG, "loading page " + model.getSprite());
|
||||
Log.i(TAG, "loading page " + model.getSpriteUri().toString());
|
||||
return loadPage();
|
||||
} catch (IOException ioe) {
|
||||
Log.w(TAG, ioe);
|
||||
@ -76,7 +76,7 @@ public class EmojiPageBitmap {
|
||||
|
||||
float scale = decodeScale;
|
||||
AssetManager assetManager = context.getAssets();
|
||||
InputStream assetStream = assetManager.open(model.getSprite());
|
||||
InputStream assetStream = assetManager.open(model.getSpriteUri().toString());
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||
|
||||
if (org.thoughtcrime.securesms.util.Util.isLowMemory(context)) {
|
||||
@ -85,7 +85,7 @@ public class EmojiPageBitmap {
|
||||
scale = decodeScale * 2;
|
||||
}
|
||||
|
||||
Stopwatch stopwatch = new Stopwatch(model.getSprite());
|
||||
Stopwatch stopwatch = new Stopwatch(model.getSpriteUri().toString());
|
||||
Bitmap bitmap = BitmapFactory.decodeStream(assetStream, null, options);
|
||||
stopwatch.split("decode");
|
||||
|
||||
@ -94,7 +94,7 @@ public class EmojiPageBitmap {
|
||||
stopwatch.stop(TAG);
|
||||
|
||||
bitmapReference = new SoftReference<>(scaledBitmap);
|
||||
Log.i(TAG, "onPageLoaded(" + model.getSprite() + ") originalByteCount: " + bitmap.getByteCount()
|
||||
Log.i(TAG, "onPageLoaded(" + model.getSpriteUri().toString() + ") originalByteCount: " + bitmap.getByteCount()
|
||||
+ " scaledByteCount: " + scaledBitmap.getByteCount()
|
||||
+ " scaledSize: " + scaledBitmap.getWidth() + "x" + scaledBitmap.getHeight());
|
||||
return scaledBitmap;
|
||||
@ -102,6 +102,6 @@ public class EmojiPageBitmap {
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return model.getSprite();
|
||||
return model.getSpriteUri().toString();
|
||||
}
|
||||
}
|
||||
|
@ -74,10 +74,10 @@ public class EmojiTree {
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable EmojiDrawInfo getEmoji(CharSequence unicode, int startPosition, int endPostiion) {
|
||||
public @Nullable EmojiDrawInfo getEmoji(CharSequence unicode, int startPosition, int endPosition) {
|
||||
EmojiTreeNode tree = root;
|
||||
|
||||
for (int i=startPosition; i<endPostiion; i++) {
|
||||
for (int i=startPosition; i<endPosition; i++) {
|
||||
char character = unicode.charAt(i);
|
||||
|
||||
if (!tree.hasChild(character)) {
|
||||
@ -88,7 +88,7 @@ public class EmojiTree {
|
||||
}
|
||||
|
||||
if (tree.getEmoji() != null) return tree.getEmoji();
|
||||
else if (unicode.charAt(endPostiion-1) != TERMINATOR && tree.hasChild(TERMINATOR)) return tree.getChild(TERMINATOR).getEmoji();
|
||||
else if (unicode.charAt(endPosition-1) != TERMINATOR && tree.hasChild(TERMINATOR)) return tree.getChild(TERMINATOR).getEmoji();
|
||||
else return null;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,12 @@
|
||||
package org.thoughtcrime.securesms.components.menu
|
||||
|
||||
import androidx.annotation.AttrRes
|
||||
|
||||
/**
|
||||
* Represents an action to be rendered
|
||||
*/
|
||||
data class ActionItem(
|
||||
@AttrRes val iconRes: Int,
|
||||
val title: CharSequence,
|
||||
val action: Runnable
|
||||
)
|
@ -0,0 +1,94 @@
|
||||
package org.thoughtcrime.securesms.components.menu
|
||||
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
|
||||
/**
|
||||
* Handles the setup and display of actions shown in a context menu.
|
||||
*/
|
||||
class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
|
||||
|
||||
private val mappingAdapter = MappingAdapter().apply {
|
||||
registerFactory(DisplayItem::class.java, LayoutFactory({ ItemViewHolder(it, onItemClick) }, R.layout.context_menu_item))
|
||||
}
|
||||
|
||||
init {
|
||||
recyclerView.apply {
|
||||
adapter = mappingAdapter
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
itemAnimator = null
|
||||
}
|
||||
}
|
||||
|
||||
fun setItems(items: List<ActionItem>) {
|
||||
mappingAdapter.submitList(items.toAdapterItems())
|
||||
}
|
||||
|
||||
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> {
|
||||
return this.mapIndexed { index, item ->
|
||||
val displayType: DisplayType = when {
|
||||
this.size == 1 -> DisplayType.ONLY
|
||||
index == 0 -> DisplayType.TOP
|
||||
index == this.size - 1 -> DisplayType.BOTTOM
|
||||
else -> DisplayType.MIDDLE
|
||||
}
|
||||
|
||||
DisplayItem(item, displayType)
|
||||
}
|
||||
}
|
||||
|
||||
private data class DisplayItem(
|
||||
val item: ActionItem,
|
||||
val displayType: DisplayType
|
||||
) : MappingModel<DisplayItem> {
|
||||
override fun areItemsTheSame(newItem: DisplayItem): Boolean {
|
||||
return this == newItem
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: DisplayItem): Boolean {
|
||||
return this == newItem
|
||||
}
|
||||
}
|
||||
|
||||
private enum class DisplayType {
|
||||
TOP, BOTTOM, MIDDLE, ONLY
|
||||
}
|
||||
|
||||
private class ItemViewHolder(
|
||||
itemView: View,
|
||||
private val onItemClick: () -> Unit,
|
||||
) : MappingViewHolder<DisplayItem>(itemView) {
|
||||
val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon)
|
||||
val title: TextView = itemView.findViewById(R.id.context_menu_item_title)
|
||||
|
||||
override fun bind(model: DisplayItem) {
|
||||
if (model.item.iconRes > 0) {
|
||||
val typedValue = TypedValue()
|
||||
context.theme.resolveAttribute(model.item.iconRes, typedValue, true)
|
||||
icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
|
||||
}
|
||||
title.text = model.item.title
|
||||
itemView.setOnClickListener {
|
||||
model.item.action.run()
|
||||
onItemClick()
|
||||
}
|
||||
|
||||
when (model.displayType) {
|
||||
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_top)
|
||||
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_bottom)
|
||||
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_middle)
|
||||
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_only)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -34,6 +34,7 @@ import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.DimenRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.drawToBitmap
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
@ -47,18 +48,20 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ActivityConversationV2ActionBarBinding
|
||||
import network.loki.messenger.databinding.ActivityConversationV2Binding
|
||||
import network.loki.messenger.databinding.ViewVisibleMessageBinding
|
||||
import nl.komponents.kovenant.ui.successUi
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.session.libsession.messaging.mentions.MentionsManager
|
||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
|
||||
import org.session.libsession.messaging.messages.visible.Reaction
|
||||
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
||||
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
|
||||
@ -68,6 +71,7 @@ import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.Address.Companion.fromSerialized
|
||||
import org.session.libsession.utilities.GroupUtil
|
||||
import org.session.libsession.utilities.MediaTypes
|
||||
import org.session.libsession.utilities.Stub
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.concurrent.SimpleTask
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
@ -82,6 +86,8 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.audio.AudioRecorder
|
||||
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
|
||||
import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog
|
||||
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
|
||||
import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
|
||||
@ -92,9 +98,8 @@ import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCand
|
||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
|
||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.VoiceMessageViewDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
|
||||
import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
|
||||
@ -110,13 +115,17 @@ import org.thoughtcrime.securesms.database.LokiMessageDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase
|
||||
import org.thoughtcrime.securesms.database.ReactionDatabase
|
||||
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.giph.ui.GiphyActivity
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
|
||||
@ -132,6 +141,8 @@ import org.thoughtcrime.securesms.mms.Slide
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
|
||||
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
|
||||
import org.thoughtcrime.securesms.util.ActivityDispatcher
|
||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
@ -154,12 +165,12 @@ import kotlin.math.sqrt
|
||||
// price we pay is a bit of back and forth between the input bar and the conversation activity.
|
||||
@AndroidEntryPoint
|
||||
class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate,
|
||||
InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher,
|
||||
ConversationActionModeCallbackDelegate, VisibleMessageContentViewDelegate, RecipientModifiedListener,
|
||||
SearchBottomBar.EventListener, VoiceMessageViewDelegate, LoaderManager.LoaderCallbacks<Cursor> {
|
||||
InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher,
|
||||
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
|
||||
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>,
|
||||
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback {
|
||||
|
||||
private var binding: ActivityConversationV2Binding? = null
|
||||
private var actionBarBinding: ActivityConversationV2ActionBarBinding? = null
|
||||
|
||||
@Inject lateinit var textSecurePreferences: TextSecurePreferences
|
||||
@Inject lateinit var threadDb: ThreadDatabase
|
||||
@ -172,6 +183,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
@Inject lateinit var mmsDb: MmsDatabase
|
||||
@Inject lateinit var lokiMessageDb: LokiMessageDatabase
|
||||
@Inject lateinit var storage: Storage
|
||||
@Inject lateinit var reactionDb: ReactionDatabase
|
||||
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
|
||||
|
||||
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
|
||||
@ -203,7 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
} ?: finish()
|
||||
}
|
||||
viewModelFactory.create(threadId)
|
||||
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
|
||||
}
|
||||
private var actionMode: ActionMode? = null
|
||||
private var unreadCount = 0
|
||||
@ -252,21 +264,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
onItemPress = { message, position, view, event ->
|
||||
handlePress(message, position, view, event)
|
||||
},
|
||||
onItemSwipeToReply = { message, position ->
|
||||
handleSwipeToReply(message, position)
|
||||
onItemSwipeToReply = { message, _ ->
|
||||
handleSwipeToReply(message)
|
||||
},
|
||||
onItemLongPress = { message, position ->
|
||||
handleLongPress(message, position)
|
||||
onItemLongPress = { message, position, view ->
|
||||
if (!isMessageRequestThread() &&
|
||||
(viewModel.openGroup == null || Capability.REACTIONS.name.lowercase() in viewModel.serverCapabilities)
|
||||
) {
|
||||
showEmojiPicker(message, view)
|
||||
} else {
|
||||
handleLongPress(message, position)
|
||||
}
|
||||
},
|
||||
glide,
|
||||
onDeselect = { message, position ->
|
||||
actionMode?.let {
|
||||
onDeselect(message, position, it)
|
||||
}
|
||||
},
|
||||
glide = glide,
|
||||
lifecycleCoroutineScope = lifecycleScope
|
||||
)
|
||||
adapter.visibleMessageContentViewDelegate = this
|
||||
adapter.visibleMessageViewDelegate = this
|
||||
adapter
|
||||
}
|
||||
|
||||
@ -279,6 +297,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
private val messageToScrollTimestamp = AtomicLong(-1)
|
||||
private val messageToScrollAuthor = AtomicReference<Address?>(null)
|
||||
|
||||
private lateinit var reactionDelegate: ConversationReactionDelegate
|
||||
private val reactWithAnyEmojiStartPage = -1
|
||||
|
||||
// region Settings
|
||||
companion object {
|
||||
// Extras
|
||||
@ -294,8 +315,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
const val PICK_FROM_LIBRARY = 12
|
||||
const val INVITE_CONTACTS = 124
|
||||
|
||||
//flag
|
||||
const val IS_UNSEND_REQUESTS_ENABLED = true
|
||||
}
|
||||
// endregion
|
||||
|
||||
@ -339,14 +358,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
showOrHideInputIfNeeded()
|
||||
setUpMessageRequestsBar()
|
||||
viewModel.recipient?.let { recipient ->
|
||||
if (recipient.isOpenGroupRecipient) {
|
||||
val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId)
|
||||
if (openGroup == null) {
|
||||
Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show()
|
||||
return finish()
|
||||
}
|
||||
if (recipient.isOpenGroupRecipient && viewModel.openGroup == null) {
|
||||
Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show()
|
||||
return finish()
|
||||
}
|
||||
}
|
||||
|
||||
val reactionOverlayStub: Stub<ConversationReactionOverlay> =
|
||||
ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub)
|
||||
reactionDelegate = ConversationReactionDelegate(reactionOverlayStub)
|
||||
reactionDelegate.setOnReactionSelectedListener(this)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@ -413,22 +434,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
|
||||
// called from onCreate
|
||||
private fun setUpToolBar() {
|
||||
setSupportActionBar(binding?.toolbar)
|
||||
val actionBar = supportActionBar ?: return
|
||||
actionBarBinding = ActivityConversationV2ActionBarBinding.inflate(layoutInflater)
|
||||
actionBar.title = ""
|
||||
actionBar.customView = actionBarBinding!!.root
|
||||
actionBar.setDisplayShowCustomEnabled(true)
|
||||
actionBarBinding!!.conversationTitleView.text = viewModel.recipient?.toShortString()
|
||||
actionBar.setDisplayHomeAsUpEnabled(true)
|
||||
actionBar.setHomeButtonEnabled(true)
|
||||
binding!!.toolbarContent.conversationTitleView.text = viewModel.recipient?.toShortString()
|
||||
@DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) {
|
||||
R.dimen.medium_profile_picture_size
|
||||
} else {
|
||||
R.dimen.small_profile_picture_size
|
||||
}
|
||||
val size = resources.getDimension(sizeID).roundToInt()
|
||||
actionBarBinding!!.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size)
|
||||
actionBarBinding!!.profilePictureView.root.glide = glide
|
||||
binding!!.toolbarContent.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size)
|
||||
binding!!.toolbarContent.profilePictureView.root.glide = glide
|
||||
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this)
|
||||
val profilePictureView = actionBarBinding!!.profilePictureView.root
|
||||
val profilePictureView = binding!!.toolbarContent.profilePictureView.root
|
||||
viewModel.recipient?.let { recipient ->
|
||||
profilePictureView.update(recipient)
|
||||
}
|
||||
@ -529,8 +550,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
|
||||
private fun getLatestOpenGroupInfoIfNeeded() {
|
||||
val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) ?: return
|
||||
OpenGroupApi.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() }
|
||||
viewModel.openGroup?.let {
|
||||
OpenGroupApi.getMemberCount(it.room, it.server).successUi { updateSubtitle() }
|
||||
}
|
||||
}
|
||||
|
||||
// called from onCreate
|
||||
@ -609,7 +631,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
tearDownRecipientObserver()
|
||||
super.onDestroy()
|
||||
binding = null
|
||||
actionBarBinding = null
|
||||
// actionBarBinding = null
|
||||
}
|
||||
// endregion
|
||||
|
||||
@ -625,9 +647,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
updateSubtitle()
|
||||
showOrHideInputIfNeeded()
|
||||
if (recipient != null) {
|
||||
actionBarBinding?.profilePictureView?.root?.update(recipient)
|
||||
binding?.toolbarContent?.profilePictureView?.root?.update(recipient)
|
||||
}
|
||||
actionBarBinding?.conversationTitleView?.text = recipient?.toShortString()
|
||||
binding?.toolbarContent?.conversationTitleView?.text = recipient?.toShortString()
|
||||
}
|
||||
}
|
||||
|
||||
@ -865,12 +887,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
|
||||
binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
|
||||
binding.typingIndicatorViewContainer.isVisible
|
||||
binding.scrollToBottomButton.isVisible = !isScrolledToBottom && adapter.itemCount > 0
|
||||
showOrHidScrollToBottomButton()
|
||||
val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1
|
||||
unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0)
|
||||
updateUnreadCountIndicator()
|
||||
}
|
||||
|
||||
private fun showOrHidScrollToBottomButton(show: Boolean = true) {
|
||||
binding?.scrollToBottomButton?.isVisible = show && !isScrolledToBottom && adapter.itemCount > 0
|
||||
}
|
||||
|
||||
private fun updateUnreadCountIndicator() {
|
||||
val binding = binding ?: return
|
||||
val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+"
|
||||
@ -882,7 +908,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
|
||||
private fun updateSubtitle() {
|
||||
val actionBarBinding = actionBarBinding ?: return
|
||||
val actionBarBinding = binding?.toolbarContent ?: return
|
||||
val recipient = viewModel.recipient ?: return
|
||||
actionBarBinding.muteIconImageView.isVisible = recipient.isMuted
|
||||
actionBarBinding.conversationSubtitleView.isVisible = true
|
||||
@ -893,11 +919,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever)
|
||||
}
|
||||
} else if (recipient.isGroupRecipient) {
|
||||
val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId)
|
||||
if (openGroup != null) {
|
||||
viewModel.openGroup?.let { openGroup ->
|
||||
val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0
|
||||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
|
||||
} else {
|
||||
} ?: run {
|
||||
actionBarBinding.conversationSubtitleView.isVisible = false
|
||||
}
|
||||
} else {
|
||||
@ -942,7 +967,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
|
||||
// `position` is the adapter position; not the visual position
|
||||
private fun handleSwipeToReply(message: MessageRecord, position: Int) {
|
||||
private fun handleSwipeToReply(message: MessageRecord) {
|
||||
val recipient = viewModel.recipient ?: return
|
||||
binding?.inputBar?.draftQuote(recipient, message, glide)
|
||||
}
|
||||
@ -966,6 +991,164 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
}
|
||||
|
||||
private fun showEmojiPicker(message: MessageRecord, visibleMessageView: VisibleMessageView) {
|
||||
val messageContentBitmap = try {
|
||||
visibleMessageView.messageContentView.drawToBitmap()
|
||||
} catch (e: Exception) {
|
||||
Log.e("Loki", "Failed to show emoji picker", e)
|
||||
return
|
||||
}
|
||||
ViewUtil.hideKeyboard(this, visibleMessageView);
|
||||
binding?.reactionsShade?.isVisible = true
|
||||
showOrHidScrollToBottomButton(false)
|
||||
binding?.conversationRecyclerView?.suppressLayout(true)
|
||||
reactionDelegate.setOnActionSelectedListener(ReactionsToolbarListener(message))
|
||||
reactionDelegate.setOnHideListener(object: ConversationReactionOverlay.OnHideListener {
|
||||
override fun startHide() {
|
||||
binding?.reactionsShade?.let {
|
||||
ViewUtil.fadeOut(it, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE)
|
||||
}
|
||||
showOrHidScrollToBottomButton(true)
|
||||
}
|
||||
|
||||
override fun onHide() {
|
||||
binding?.conversationRecyclerView?.suppressLayout(false)
|
||||
|
||||
WindowUtil.setLightStatusBarFromTheme(this@ConversationActivityV2);
|
||||
WindowUtil.setLightNavigationBarFromTheme(this@ConversationActivityV2);
|
||||
}
|
||||
|
||||
})
|
||||
val contentBounds = Rect()
|
||||
visibleMessageView.messageContentView.getGlobalVisibleRect(contentBounds)
|
||||
val selectedConversationModel = SelectedConversationModel(
|
||||
messageContentBitmap,
|
||||
contentBounds.left.toFloat(),
|
||||
contentBounds.top.toFloat(),
|
||||
visibleMessageView.messageContentView.width,
|
||||
message.isOutgoing,
|
||||
visibleMessageView.messageContentView
|
||||
)
|
||||
reactionDelegate.show(this, message, selectedConversationModel)
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
return reactionDelegate.applyTouchEvent(ev) || super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
override fun onReactionSelected(messageRecord: MessageRecord, emoji: String) {
|
||||
reactionDelegate.hide()
|
||||
val oldRecord = messageRecord.reactions.find { it.author == textSecurePreferences.getLocalNumber() }
|
||||
if (oldRecord != null && oldRecord.emoji == emoji) {
|
||||
sendEmojiRemoval(emoji, messageRecord)
|
||||
} else {
|
||||
sendEmojiReaction(emoji, messageRecord)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendEmojiReaction(emoji: String, originalMessage: MessageRecord) {
|
||||
// Create the message
|
||||
val recipient = viewModel.recipient ?: return
|
||||
val reactionMessage = VisibleMessage()
|
||||
val emojiTimestamp = System.currentTimeMillis()
|
||||
reactionMessage.sentTimestamp = emojiTimestamp
|
||||
val author = textSecurePreferences.getLocalNumber()!!
|
||||
// Put the message in the database
|
||||
val reaction = ReactionRecord(
|
||||
messageId = originalMessage.id,
|
||||
isMms = originalMessage.isMms,
|
||||
author = author,
|
||||
emoji = emoji,
|
||||
count = 1,
|
||||
dateSent = emojiTimestamp,
|
||||
dateReceived = emojiTimestamp
|
||||
)
|
||||
reactionDb.addReaction(MessageId(originalMessage.id, originalMessage.isMms), reaction)
|
||||
// Send it
|
||||
reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalMessage.recipient.address.serialize(), emoji, true)
|
||||
if (recipient.isOpenGroupRecipient) {
|
||||
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
|
||||
viewModel.openGroup?.let {
|
||||
OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji)
|
||||
}
|
||||
} else {
|
||||
MessageSender.send(reactionMessage, recipient.address)
|
||||
}
|
||||
LoaderManager.getInstance(this).restartLoader(0, null, this)
|
||||
}
|
||||
|
||||
private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) {
|
||||
val recipient = viewModel.recipient ?: return
|
||||
val message = VisibleMessage()
|
||||
val emojiTimestamp = System.currentTimeMillis()
|
||||
message.sentTimestamp = emojiTimestamp
|
||||
val author = textSecurePreferences.getLocalNumber()!!
|
||||
reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author)
|
||||
message.reaction = Reaction.from(originalMessage.timestamp, author, emoji, false)
|
||||
if (recipient.isOpenGroupRecipient) {
|
||||
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
|
||||
viewModel.openGroup?.let {
|
||||
OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji)
|
||||
}
|
||||
} else {
|
||||
MessageSender.send(message, recipient.address)
|
||||
}
|
||||
LoaderManager.getInstance(this).restartLoader(0, null, this)
|
||||
}
|
||||
|
||||
override fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) {
|
||||
val oldRecord = messageRecord.reactions.find { record -> record.author == textSecurePreferences.getLocalNumber() }
|
||||
|
||||
if (oldRecord != null && hasAddedCustomEmoji) {
|
||||
reactionDelegate.hide()
|
||||
sendEmojiRemoval(oldRecord.emoji, messageRecord)
|
||||
} else {
|
||||
reactionDelegate.hideForReactWithAny()
|
||||
|
||||
ReactWithAnyEmojiDialogFragment
|
||||
.createForMessageRecord(messageRecord, reactWithAnyEmojiStartPage)
|
||||
.show(supportFragmentManager, "BOTTOM");
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReactWithAnyEmojiDialogDismissed() {
|
||||
reactionDelegate.hide()
|
||||
}
|
||||
|
||||
override fun onReactWithAnyEmojiSelected(emoji: String, messageId: MessageId) {
|
||||
reactionDelegate.hide()
|
||||
val message = if (messageId.mms) {
|
||||
mmsDb.getMessageRecord(messageId.id)
|
||||
} else {
|
||||
smsDb.getMessageRecord(messageId.id)
|
||||
}
|
||||
val oldRecord = reactionDb.getReactions(messageId).find { it.author == textSecurePreferences.getLocalNumber() }
|
||||
if (oldRecord?.emoji == emoji) {
|
||||
sendEmojiRemoval(emoji, message)
|
||||
} else {
|
||||
sendEmojiReaction(emoji, message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemoveReaction(emoji: String, messageId: MessageId) {
|
||||
val message = if (messageId.mms) {
|
||||
mmsDb.getMessageRecord(messageId.id)
|
||||
} else {
|
||||
smsDb.getMessageRecord(messageId.id)
|
||||
}
|
||||
sendEmojiRemoval(emoji, message)
|
||||
}
|
||||
|
||||
override fun onClearAll(emoji: String, messageId: MessageId) {
|
||||
reactionDb.deleteEmojiReactions(emoji, messageId)
|
||||
viewModel.openGroup?.let { openGroup ->
|
||||
lokiMessageDb.getServerID(messageId.id, !messageId.mms)?.let { serverId ->
|
||||
OpenGroupApi.deleteAllReactions(openGroup.room, openGroup.server, serverId, emoji)
|
||||
}
|
||||
}
|
||||
threadDb.notifyThreadUpdated(viewModel.threadId)
|
||||
}
|
||||
|
||||
override fun onMicrophoneButtonMove(event: MotionEvent) {
|
||||
val rawX = event.rawX
|
||||
val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return
|
||||
@ -1047,6 +1230,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
|
||||
}
|
||||
|
||||
override fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) {
|
||||
val message = if (messageId.mms) {
|
||||
mmsDb.getMessageRecord(messageId.id)
|
||||
} else {
|
||||
smsDb.getMessageRecord(messageId.id)
|
||||
}
|
||||
if (userWasSender) {
|
||||
sendEmojiRemoval(emoji, message)
|
||||
} else {
|
||||
sendEmojiReaction(emoji, message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReactionLongClicked(messageId: MessageId) {
|
||||
if (viewModel.recipient?.isGroupRecipient == true) {
|
||||
val isUserModerator = viewModel.openGroup?.let { openGroup ->
|
||||
val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false
|
||||
OpenGroupManager.isUserModerator(this, openGroup.id, userPublicKey, viewModel.blindedPublicKey)
|
||||
} ?: false
|
||||
val fragment = ReactionsDialogFragment.create(messageId, isUserModerator)
|
||||
fragment.show(supportFragmentManager, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int) {
|
||||
if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return }
|
||||
val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return
|
||||
@ -1303,34 +1510,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask)
|
||||
}
|
||||
|
||||
// Remove this after the unsend request is enabled
|
||||
fun deleteMessagesWithoutUnsendRequest(messages: Set<MessageRecord>) {
|
||||
val messageCount = messages.size
|
||||
val builder = AlertDialog.Builder(this)
|
||||
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
|
||||
builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
|
||||
builder.setCancelable(true)
|
||||
builder.setPositiveButton(R.string.delete) { _, _ ->
|
||||
viewModel.deleteMessagesWithoutUnsendRequest(messages)
|
||||
endActionMode()
|
||||
}
|
||||
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
endActionMode()
|
||||
}
|
||||
builder.show()
|
||||
override fun selectMessages(messages: Set<MessageRecord>) {
|
||||
handleLongPress(messages.first(), 0) //TODO: begin selection mode
|
||||
}
|
||||
|
||||
override fun deleteMessages(messages: Set<MessageRecord>) {
|
||||
val recipient = viewModel.recipient ?: return
|
||||
if (!IS_UNSEND_REQUESTS_ENABLED) {
|
||||
deleteMessagesWithoutUnsendRequest(messages)
|
||||
return
|
||||
}
|
||||
val allSentByCurrentUser = messages.all { it.isOutgoing }
|
||||
val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null }
|
||||
if (recipient.isOpenGroupRecipient) {
|
||||
val messageCount = messages.size
|
||||
val messageCount = 1
|
||||
val builder = AlertDialog.Builder(this)
|
||||
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
|
||||
builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
|
||||
@ -1369,7 +1558,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
bottomSheet.show(supportFragmentManager, bottomSheet.tag)
|
||||
} else {
|
||||
val messageCount = messages.size
|
||||
val messageCount = 1
|
||||
val builder = AlertDialog.Builder(this)
|
||||
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
|
||||
builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
|
||||
@ -1466,9 +1655,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
|
||||
override fun showMessageDetail(messages: Set<MessageRecord>) {
|
||||
val message = messages.first()
|
||||
val intent = Intent(this, MessageDetailActivity::class.java)
|
||||
intent.putExtra(MessageDetailActivity.MESSAGE_TIMESTAMP, message.timestamp)
|
||||
intent.putExtra(MessageDetailActivity.MESSAGE_TIMESTAMP, messages.first().timestamp)
|
||||
push(intent)
|
||||
endActionMode()
|
||||
}
|
||||
@ -1549,8 +1737,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
if (result == null) return@Observer
|
||||
if (result.getResults().isNotEmpty()) {
|
||||
result.getResults()[result.position]?.let {
|
||||
jumpToMessage(it.messageRecipient.address, it.receivedTimestampMs,
|
||||
{ searchViewModel.onMissingResult() })
|
||||
jumpToMessage(it.messageRecipient.address, it.receivedTimestampMs) {
|
||||
searchViewModel.onMissingResult() }
|
||||
}
|
||||
}
|
||||
binding?.searchBottomBar?.setData(result.position, result.getResults().size)
|
||||
@ -1600,4 +1788,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
inner class ReactionsToolbarListener constructor(val message: MessageRecord) : OnActionSelectedListener {
|
||||
|
||||
override fun onActionSelected(action: ConversationReactionOverlay.Action) {
|
||||
val selectedItems = setOf(message)
|
||||
when (action) {
|
||||
ConversationReactionOverlay.Action.REPLY -> reply(selectedItems)
|
||||
ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems)
|
||||
ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems)
|
||||
ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems)
|
||||
ConversationReactionOverlay.Action.VIEW_INFO -> showMessageDetail(selectedItems)
|
||||
ConversationReactionOverlay.Action.SELECT -> selectMessages(selectedItems)
|
||||
ConversationReactionOverlay.Action.DELETE -> deleteMessages(selectedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -24,23 +24,30 @@ import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewVisibleMessageBinding
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
|
||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||
|
||||
class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit,
|
||||
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int) -> Unit,
|
||||
private val glide: GlideRequests, private val onDeselect: (MessageRecord, Int) -> Unit, lifecycleCoroutineScope: LifecycleCoroutineScope)
|
||||
class ConversationAdapter(
|
||||
context: Context,
|
||||
cursor: Cursor,
|
||||
private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit,
|
||||
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
|
||||
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
|
||||
private val onDeselect: (MessageRecord, Int) -> Unit,
|
||||
private val glide: GlideRequests,
|
||||
lifecycleCoroutineScope: LifecycleCoroutineScope
|
||||
)
|
||||
: CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
|
||||
private val messageDB by lazy { DatabaseComponent.get(context).mmsSmsDatabase() }
|
||||
private val contactDB by lazy { DatabaseComponent.get(context).sessionContactDatabase() }
|
||||
var selectedItems = mutableSetOf<MessageRecord>()
|
||||
private var searchQuery: String? = null
|
||||
var visibleMessageContentViewDelegate: VisibleMessageContentViewDelegate? = null
|
||||
var visibleMessageViewDelegate: VisibleMessageViewDelegate? = null
|
||||
|
||||
private val updateQueue = Channel<String>(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
private val contactCache = SparseArray<Contact>(100)
|
||||
@ -99,7 +106,6 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr
|
||||
val messageBefore = getMessageBefore(position, cursor)
|
||||
when (viewHolder) {
|
||||
is VisibleMessageViewHolder -> {
|
||||
val view = viewHolder.view
|
||||
val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView
|
||||
val isSelected = selectedItems.contains(message)
|
||||
visibleMessageView.snIsSelected = isSelected
|
||||
@ -114,17 +120,16 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr
|
||||
}
|
||||
val contact = contactCache[senderIdHash]
|
||||
|
||||
visibleMessageView.bind(message, messageBefore, getMessageAfter(position, cursor), glide, searchQuery, contact, senderId)
|
||||
visibleMessageView.bind(message, messageBefore, getMessageAfter(position, cursor), glide, searchQuery, contact, senderId, visibleMessageViewDelegate)
|
||||
if (!message.isDeleted) {
|
||||
visibleMessageView.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, visibleMessageView, event) }
|
||||
visibleMessageView.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) }
|
||||
visibleMessageView.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition) }
|
||||
visibleMessageView.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition, visibleMessageView) }
|
||||
} else {
|
||||
visibleMessageView.onPress = null
|
||||
visibleMessageView.onSwipeToReply = null
|
||||
visibleMessageView.onLongPress = null
|
||||
}
|
||||
visibleMessageView.contentViewDelegate = visibleMessageContentViewDelegate
|
||||
}
|
||||
is ControlMessageViewHolder -> {
|
||||
viewHolder.view.bind(message, messageBefore)
|
||||
@ -149,6 +154,11 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSelection(message: MessageRecord, position: Int) {
|
||||
if (selectedItems.contains(message)) selectedItems.remove(message) else selectedItems.add(message)
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
||||
override fun onItemViewRecycled(viewHolder: ViewHolder?) {
|
||||
when (viewHolder) {
|
||||
is VisibleMessageViewHolder -> viewHolder.view.findViewById<VisibleMessageView>(R.id.visibleMessageView).recycle()
|
||||
@ -196,11 +206,6 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSelection(message: MessageRecord, position: Int) {
|
||||
if (selectedItems.contains(message)) selectedItems.remove(message) else selectedItems.add(message)
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
||||
fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? {
|
||||
val cursor = this.cursor
|
||||
if (lastSeenTimestamp <= 0L || cursor == null || !isActiveCursor) return null
|
||||
|
@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.PopupWindow
|
||||
import androidx.core.content.ContextCompat
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.ContextMenuList
|
||||
|
||||
/**
|
||||
* The context menu shown after long pressing a message in ConversationActivity.
|
||||
*/
|
||||
class ConversationContextMenu(private val anchor: View, items: List<ActionItem>) : PopupWindow(
|
||||
LayoutInflater.from(anchor.context).inflate(R.layout.context_menu, null),
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
) {
|
||||
|
||||
val context: Context = anchor.context
|
||||
|
||||
private val contextMenuList = ContextMenuList(
|
||||
recyclerView = contentView.findViewById(R.id.context_menu_list),
|
||||
onItemClick = { dismiss() },
|
||||
)
|
||||
|
||||
init {
|
||||
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.context_menu_background))
|
||||
animationStyle = R.style.ConversationContextMenuAnimation
|
||||
|
||||
isFocusable = false
|
||||
isOutsideTouchable = true
|
||||
|
||||
elevation = 20f
|
||||
|
||||
setTouchInterceptor { _, event ->
|
||||
event.action == MotionEvent.ACTION_OUTSIDE
|
||||
}
|
||||
|
||||
contextMenuList.setItems(items)
|
||||
|
||||
contentView.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
}
|
||||
|
||||
fun getMaxWidth(): Int = contentView.measuredWidth
|
||||
fun getMaxHeight(): Int = contentView.measuredHeight
|
||||
|
||||
fun show(offsetX: Int, offsetY: Int) {
|
||||
showAsDropDown(anchor, offsetX, offsetY, Gravity.TOP or Gravity.START)
|
||||
}
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.PointF
|
||||
import android.view.MotionEvent
|
||||
import org.session.libsession.utilities.Stub
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnHideListener
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
|
||||
/**
|
||||
* Delegate class that mimics the ConversationReactionOverlay public API
|
||||
*
|
||||
* This allows us to properly stub out the ConversationReactionOverlay View class while still
|
||||
* respecting listeners and other positional information that can be set BEFORE we want to actually
|
||||
* resolve the view.
|
||||
*/
|
||||
internal class ConversationReactionDelegate(private val overlayStub: Stub<ConversationReactionOverlay>) {
|
||||
private val lastSeenDownPoint = PointF()
|
||||
private var onReactionSelectedListener: OnReactionSelectedListener? = null
|
||||
private var onActionSelectedListener: OnActionSelectedListener? = null
|
||||
private var onHideListener: OnHideListener? = null
|
||||
val isShowing: Boolean
|
||||
get() = overlayStub.resolved() && overlayStub.get().isShowing
|
||||
|
||||
fun show(
|
||||
activity: Activity,
|
||||
messageRecord: MessageRecord,
|
||||
selectedConversationModel: SelectedConversationModel
|
||||
) {
|
||||
resolveOverlay().show(
|
||||
activity,
|
||||
messageRecord,
|
||||
lastSeenDownPoint,
|
||||
selectedConversationModel
|
||||
)
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
overlayStub.get().hide()
|
||||
}
|
||||
|
||||
fun hideForReactWithAny() {
|
||||
overlayStub.get().hideForReactWithAny()
|
||||
}
|
||||
|
||||
fun setOnReactionSelectedListener(onReactionSelectedListener: OnReactionSelectedListener) {
|
||||
this.onReactionSelectedListener = onReactionSelectedListener
|
||||
if (overlayStub.resolved()) {
|
||||
overlayStub.get().setOnReactionSelectedListener(onReactionSelectedListener)
|
||||
}
|
||||
}
|
||||
|
||||
fun setOnActionSelectedListener(onActionSelectedListener: OnActionSelectedListener) {
|
||||
this.onActionSelectedListener = onActionSelectedListener
|
||||
if (overlayStub.resolved()) {
|
||||
overlayStub.get().setOnActionSelectedListener(onActionSelectedListener)
|
||||
}
|
||||
}
|
||||
|
||||
fun setOnHideListener(onHideListener: OnHideListener) {
|
||||
this.onHideListener = onHideListener
|
||||
if (overlayStub.resolved()) {
|
||||
overlayStub.get().setOnHideListener(onHideListener)
|
||||
}
|
||||
}
|
||||
|
||||
val messageRecord: MessageRecord
|
||||
get() {
|
||||
check(overlayStub.resolved()) { "Cannot call getMessageRecord right now." }
|
||||
return overlayStub.get().messageRecord
|
||||
}
|
||||
|
||||
fun applyTouchEvent(motionEvent: MotionEvent): Boolean {
|
||||
return if (!overlayStub.resolved() || !overlayStub.get().isShowing) {
|
||||
if (motionEvent.action == MotionEvent.ACTION_DOWN) {
|
||||
lastSeenDownPoint[motionEvent.x] = motionEvent.y
|
||||
}
|
||||
false
|
||||
} else {
|
||||
overlayStub.get().applyTouchEvent(motionEvent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveOverlay(): ConversationReactionOverlay {
|
||||
val overlay = overlayStub.get()
|
||||
overlay.requestFitSystemWindows()
|
||||
overlay.setOnHideListener(onHideListener)
|
||||
overlay.setOnActionSelectedListener(onActionSelectedListener)
|
||||
overlay.setOnReactionSelectedListener(onReactionSelectedListener)
|
||||
return overlay
|
||||
}
|
||||
}
|
@ -0,0 +1,882 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorSet;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.HapticFeedbackConstants;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.view.animation.Interpolator;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.ThemeUtil;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.util.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import kotlin.Unit;
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
public static final float LONG_PRESS_SCALE_FACTOR = 0.95f;
|
||||
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
|
||||
|
||||
private final Rect emojiViewGlobalRect = new Rect();
|
||||
private final Rect emojiStripViewBounds = new Rect();
|
||||
private float segmentSize;
|
||||
|
||||
private final Boundary horizontalEmojiBoundary = new Boundary();
|
||||
private final Boundary verticalScrubBoundary = new Boundary();
|
||||
private final PointF deadzoneTouchPoint = new PointF();
|
||||
|
||||
private Activity activity;
|
||||
private MessageRecord messageRecord;
|
||||
private SelectedConversationModel selectedConversationModel;
|
||||
private OverlayState overlayState = OverlayState.HIDDEN;
|
||||
private RecentEmojiPageModel recentEmojiPageModel;
|
||||
|
||||
private boolean downIsOurs;
|
||||
private int selected = -1;
|
||||
private int customEmojiIndex;
|
||||
private int originalStatusBarColor;
|
||||
private int originalNavigationBarColor;
|
||||
|
||||
private View dropdownAnchor;
|
||||
private LinearLayout conversationItem;
|
||||
private View backgroundView;
|
||||
private ConstraintLayout foregroundView;
|
||||
private EmojiImageView[] emojiViews;
|
||||
|
||||
private ConversationContextMenu contextMenu;
|
||||
|
||||
private float touchDownDeadZoneSize;
|
||||
private float distanceFromTouchDownPointToBottomOfScrubberDeadZone;
|
||||
private int scrubberWidth;
|
||||
private int selectedVerticalTranslation;
|
||||
private int scrubberHorizontalMargin;
|
||||
private int animationEmojiStartDelayFactor;
|
||||
private int statusBarHeight;
|
||||
|
||||
private OnReactionSelectedListener onReactionSelectedListener;
|
||||
private OnActionSelectedListener onActionSelectedListener;
|
||||
private OnHideListener onHideListener;
|
||||
|
||||
private AnimatorSet revealAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideAnimatorSet = new AnimatorSet();
|
||||
|
||||
public ConversationReactionOverlay(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
|
||||
dropdownAnchor = findViewById(R.id.dropdown_anchor);
|
||||
conversationItem = findViewById(R.id.conversation_item);
|
||||
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
|
||||
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
|
||||
|
||||
emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1),
|
||||
findViewById(R.id.reaction_2),
|
||||
findViewById(R.id.reaction_3),
|
||||
findViewById(R.id.reaction_4),
|
||||
findViewById(R.id.reaction_5),
|
||||
findViewById(R.id.reaction_6),
|
||||
findViewById(R.id.reaction_7) };
|
||||
|
||||
customEmojiIndex = emojiViews.length - 1;
|
||||
|
||||
distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom);
|
||||
|
||||
touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size);
|
||||
scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width);
|
||||
selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
|
||||
scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
|
||||
|
||||
animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor);
|
||||
|
||||
initAnimators();
|
||||
}
|
||||
|
||||
public void show(@NonNull Activity activity,
|
||||
@NonNull MessageRecord messageRecord,
|
||||
@NonNull PointF lastSeenDownPoint,
|
||||
@NonNull SelectedConversationModel selectedConversationModel)
|
||||
{
|
||||
if (overlayState != OverlayState.HIDDEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageRecord = messageRecord;
|
||||
this.selectedConversationModel = selectedConversationModel;
|
||||
overlayState = OverlayState.UNINITAILIZED;
|
||||
selected = -1;
|
||||
recentEmojiPageModel = new RecentEmojiPageModel(activity);
|
||||
|
||||
setupSelectedEmoji();
|
||||
|
||||
View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground);
|
||||
statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight();
|
||||
|
||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
||||
|
||||
View conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble);
|
||||
conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
|
||||
conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
|
||||
TextView conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
|
||||
conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp()));
|
||||
|
||||
updateConversationTimestamp(messageRecord);
|
||||
|
||||
boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this);
|
||||
|
||||
conversationItem.setScaleX(LONG_PRESS_SCALE_FACTOR);
|
||||
conversationItem.setScaleY(LONG_PRESS_SCALE_FACTOR);
|
||||
|
||||
setVisibility(View.INVISIBLE);
|
||||
|
||||
this.activity = activity;
|
||||
updateSystemUiOnShow(activity);
|
||||
|
||||
ViewKt.doOnLayout(this, v -> {
|
||||
showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void updateConversationTimestamp(MessageRecord message) {
|
||||
View bubble = conversationItem.findViewById(R.id.conversation_item_bubble);
|
||||
View timestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
|
||||
conversationItem.removeAllViewsInLayout();
|
||||
conversationItem.addView(message.isOutgoing() ? timestamp : bubble);
|
||||
conversationItem.addView(message.isOutgoing() ? bubble : timestamp);
|
||||
conversationItem.requestLayout();
|
||||
}
|
||||
|
||||
private void showAfterLayout(@NonNull MessageRecord messageRecord,
|
||||
@NonNull PointF lastSeenDownPoint,
|
||||
boolean isMessageOnLeft) {
|
||||
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord));
|
||||
|
||||
float itemX = isMessageOnLeft ? scrubberHorizontalMargin :
|
||||
selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth();
|
||||
conversationItem.setX(itemX);
|
||||
conversationItem.setY(selectedConversationModel.getBubbleY() - statusBarHeight);
|
||||
|
||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
||||
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
|
||||
|
||||
int overlayHeight = getHeight();
|
||||
int bubbleWidth = selectedConversationModel.getBubbleWidth();
|
||||
|
||||
float endX = itemX;
|
||||
float endY = conversationItem.getY();
|
||||
float endApparentTop = endY;
|
||||
float endScale = 1f;
|
||||
|
||||
float menuPadding = DimensionUnit.DP.toPixels(12f);
|
||||
float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f);
|
||||
int reactionBarHeight = backgroundView.getHeight();
|
||||
|
||||
float reactionBarBackgroundY;
|
||||
|
||||
if (isWideLayout) {
|
||||
boolean everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.getHeight() < overlayHeight;
|
||||
if (everythingFitsVertically) {
|
||||
boolean reactionBarFitsAboveItem = conversationItem.getY() > reactionBarHeight + menuPadding + reactionBarTopPadding;
|
||||
|
||||
if (reactionBarFitsAboveItem) {
|
||||
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
|
||||
} else {
|
||||
endY = reactionBarHeight + menuPadding + reactionBarTopPadding;
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding;
|
||||
|
||||
endScale = spaceAvailableForItem / conversationItem.getHeight();
|
||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
||||
endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
float reactionBarOffset = DimensionUnit.DP.toPixels(48);
|
||||
float spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0);
|
||||
boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight;
|
||||
|
||||
if (everythingFitsVertically) {
|
||||
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
|
||||
boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight;
|
||||
|
||||
if (menuFitsBelowItem) {
|
||||
if (conversationItem.getY() < 0) {
|
||||
endY = 0;
|
||||
}
|
||||
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
|
||||
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
||||
|
||||
if (reactionBarBackgroundY <= reactionBarTopPadding) {
|
||||
endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
|
||||
|
||||
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
|
||||
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
||||
}
|
||||
|
||||
endApparentTop = endY;
|
||||
} else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) {
|
||||
float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar;
|
||||
|
||||
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
|
||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
||||
endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
||||
|
||||
float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale);
|
||||
reactionBarBackgroundY = reactionBarTopPadding;//getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
||||
endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
||||
} else {
|
||||
contextMenu.setHeight(contextMenu.getMaxHeight() / 2);
|
||||
|
||||
int menuHeight = contextMenu.getHeight();
|
||||
boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight;
|
||||
|
||||
if (fitsVertically) {
|
||||
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
|
||||
boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight;
|
||||
|
||||
if (menuFitsBelowItem) {
|
||||
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
|
||||
|
||||
if (reactionBarBackgroundY < reactionBarTopPadding) {
|
||||
endY = reactionBarTopPadding + reactionBarHeight + menuPadding;
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight();
|
||||
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
|
||||
}
|
||||
endApparentTop = endY;
|
||||
} else {
|
||||
float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding;
|
||||
|
||||
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
|
||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
||||
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding;
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight);
|
||||
|
||||
hideAnimatorSet.end();
|
||||
setVisibility(View.VISIBLE);
|
||||
|
||||
float scrubberX;
|
||||
if (isMessageOnLeft) {
|
||||
scrubberX = scrubberHorizontalMargin;
|
||||
} else {
|
||||
scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin;
|
||||
}
|
||||
|
||||
foregroundView.setX(scrubberX);
|
||||
foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f);
|
||||
|
||||
backgroundView.setX(scrubberX);
|
||||
backgroundView.setY(reactionBarBackgroundY);
|
||||
|
||||
verticalScrubBoundary.update(reactionBarBackgroundY,
|
||||
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
|
||||
|
||||
updateBoundsOnLayoutChanged();
|
||||
|
||||
revealAnimatorSet.start();
|
||||
|
||||
if (isWideLayout) {
|
||||
float scrubberRight = scrubberX + scrubberWidth;
|
||||
float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding;
|
||||
contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight()));
|
||||
} else {
|
||||
float contentX = isMessageOnLeft ? scrubberHorizontalMargin : selectedConversationModel.getBubbleX();
|
||||
float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth;
|
||||
|
||||
float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale);
|
||||
contextMenu.show((int) offsetX, (int) (menuTop + menuPadding));
|
||||
}
|
||||
|
||||
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
||||
|
||||
conversationItem.animate()
|
||||
.x(endX)
|
||||
.y(endY)
|
||||
.scaleX(endScale)
|
||||
.scaleY(endScale)
|
||||
.setDuration(revealDuration);
|
||||
}
|
||||
|
||||
private float getReactionBarOffsetForTouch(float itemY,
|
||||
float contextMenuTop,
|
||||
float contextMenuPadding,
|
||||
float reactionBarOffset,
|
||||
int reactionBarHeight,
|
||||
float spaceNeededBetweenTopOfScreenAndTopOfReactionBar,
|
||||
float messageTop)
|
||||
{
|
||||
float adjustedTouchY = itemY - statusBarHeight;
|
||||
float reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop);
|
||||
|
||||
float spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop);
|
||||
|
||||
if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150)) {
|
||||
float offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding;
|
||||
reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding;
|
||||
}
|
||||
|
||||
return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar);
|
||||
}
|
||||
|
||||
private void updateSystemUiOnShow(@NonNull Activity activity) {
|
||||
Window window = activity.getWindow();
|
||||
int barColor = ContextCompat.getColor(getContext(), R.color.reactions_screen_dark_shade_color);
|
||||
|
||||
originalStatusBarColor = window.getStatusBarColor();
|
||||
WindowUtil.setStatusBarColor(window, barColor);
|
||||
|
||||
originalNavigationBarColor = window.getNavigationBarColor();
|
||||
WindowUtil.setNavigationBarColor(window, barColor);
|
||||
|
||||
if (!ThemeUtil.isDarkTheme(getContext())) {
|
||||
WindowUtil.clearLightStatusBar(window);
|
||||
WindowUtil.clearLightNavigationBar(window);
|
||||
}
|
||||
}
|
||||
|
||||
public void hide() {
|
||||
hideInternal(onHideListener);
|
||||
}
|
||||
|
||||
public void hideForReactWithAny() {
|
||||
hideInternal(onHideListener);
|
||||
}
|
||||
|
||||
private void hideInternal(@Nullable OnHideListener onHideListener) {
|
||||
overlayState = OverlayState.HIDDEN;
|
||||
|
||||
AnimatorSet animatorSet = newHideAnimatorSet();
|
||||
hideAnimatorSet = animatorSet;
|
||||
|
||||
revealAnimatorSet.end();
|
||||
animatorSet.start();
|
||||
|
||||
if (onHideListener != null) {
|
||||
onHideListener.startHide();
|
||||
}
|
||||
|
||||
if (selectedConversationModel.getFocusedView() != null) {
|
||||
ViewUtil.focusAndShowKeyboard(selectedConversationModel.getFocusedView());
|
||||
}
|
||||
|
||||
animatorSet.addListener(new AnimationCompleteListener() {
|
||||
@Override public void onAnimationEnd(Animator animation) {
|
||||
animatorSet.removeListener(this);
|
||||
|
||||
if (onHideListener != null) {
|
||||
onHideListener.onHide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (contextMenu != null) {
|
||||
contextMenu.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isShowing() {
|
||||
return overlayState != OverlayState.HIDDEN;
|
||||
}
|
||||
|
||||
public @NonNull MessageRecord getMessageRecord() {
|
||||
return messageRecord;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onLayout(boolean changed, int l, int t, int r, int b) {
|
||||
super.onLayout(changed, l, t, r, b);
|
||||
|
||||
updateBoundsOnLayoutChanged();
|
||||
}
|
||||
|
||||
private void updateBoundsOnLayoutChanged() {
|
||||
backgroundView.getGlobalVisibleRect(emojiStripViewBounds);
|
||||
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect);
|
||||
emojiStripViewBounds.left = getStart(emojiViewGlobalRect);
|
||||
emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect);
|
||||
emojiStripViewBounds.right = getEnd(emojiViewGlobalRect);
|
||||
|
||||
segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length;
|
||||
}
|
||||
|
||||
private int getStart(@NonNull Rect rect) {
|
||||
if (ViewUtil.isLtr(this)) {
|
||||
return rect.left;
|
||||
} else {
|
||||
return rect.right;
|
||||
}
|
||||
}
|
||||
|
||||
private int getEnd(@NonNull Rect rect) {
|
||||
if (ViewUtil.isLtr(this)) {
|
||||
return rect.right;
|
||||
} else {
|
||||
return rect.left;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
|
||||
if (!isShowing()) {
|
||||
throw new IllegalStateException("Touch events should only be propagated to this method if we are displaying the scrubber.");
|
||||
}
|
||||
|
||||
if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (overlayState == OverlayState.UNINITAILIZED) {
|
||||
downIsOurs = false;
|
||||
|
||||
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
|
||||
|
||||
overlayState = OverlayState.DEADZONE;
|
||||
}
|
||||
|
||||
if (overlayState == OverlayState.DEADZONE) {
|
||||
float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getX());
|
||||
float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getY());
|
||||
|
||||
if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) {
|
||||
overlayState = OverlayState.SCRUB;
|
||||
} else {
|
||||
if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
|
||||
overlayState = OverlayState.TAP;
|
||||
|
||||
if (downIsOurs) {
|
||||
handleUpEvent();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return MotionEvent.ACTION_MOVE == motionEvent.getAction();
|
||||
}
|
||||
}
|
||||
|
||||
switch (motionEvent.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
selected = getSelectedIndexViaDownEvent(motionEvent);
|
||||
|
||||
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
|
||||
overlayState = OverlayState.DEADZONE;
|
||||
downIsOurs = true;
|
||||
return true;
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
selected = getSelectedIndexViaMoveEvent(motionEvent);
|
||||
return true;
|
||||
case MotionEvent.ACTION_UP:
|
||||
handleUpEvent();
|
||||
return downIsOurs;
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
hide();
|
||||
return downIsOurs;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void setupSelectedEmoji() {
|
||||
final List<String> emojis = recentEmojiPageModel.getEmoji();
|
||||
|
||||
for (int i = 0; i < emojiViews.length; i++) {
|
||||
final EmojiImageView view = emojiViews[i];
|
||||
|
||||
view.setScaleX(1.0f);
|
||||
view.setScaleY(1.0f);
|
||||
view.setTranslationY(0);
|
||||
|
||||
boolean isAtCustomIndex = i == customEmojiIndex;
|
||||
|
||||
if (isAtCustomIndex) {
|
||||
view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_baseline_add_24));
|
||||
view.setTag(null);
|
||||
} else {
|
||||
view.setImageEmoji(emojis.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) {
|
||||
return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom));
|
||||
}
|
||||
|
||||
private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) {
|
||||
return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary);
|
||||
}
|
||||
|
||||
private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) {
|
||||
int selected = -1;
|
||||
|
||||
if (backgroundView.getVisibility() != View.VISIBLE) {
|
||||
return selected;
|
||||
}
|
||||
|
||||
for (int i = 0; i < emojiViews.length; i++) {
|
||||
final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left;
|
||||
horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize);
|
||||
|
||||
if (horizontalEmojiBoundary.contains(motionEvent.getX()) && boundary.contains(motionEvent.getY())) {
|
||||
selected = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.selected != -1 && this.selected != selected) {
|
||||
shrinkView(emojiViews[this.selected]);
|
||||
}
|
||||
|
||||
if (this.selected != selected && selected != -1) {
|
||||
growView(emojiViews[selected]);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
private void growView(@NonNull View view) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
|
||||
view.animate()
|
||||
.scaleY(1.5f)
|
||||
.scaleX(1.5f)
|
||||
.translationY(-selectedVerticalTranslation)
|
||||
.setDuration(200)
|
||||
.setInterpolator(INTERPOLATOR)
|
||||
.start();
|
||||
}
|
||||
|
||||
private void shrinkView(@NonNull View view) {
|
||||
view.animate()
|
||||
.scaleX(1.0f)
|
||||
.scaleY(1.0f)
|
||||
.translationY(0)
|
||||
.setDuration(200)
|
||||
.setInterpolator(INTERPOLATOR)
|
||||
.start();
|
||||
}
|
||||
|
||||
private void handleUpEvent() {
|
||||
if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) {
|
||||
if (selected == customEmojiIndex) {
|
||||
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null);
|
||||
} else {
|
||||
onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.getEmoji().get(selected));
|
||||
}
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) {
|
||||
this.onReactionSelectedListener = onReactionSelectedListener;
|
||||
}
|
||||
|
||||
public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) {
|
||||
this.onActionSelectedListener = onActionSelectedListener;
|
||||
}
|
||||
|
||||
public void setOnHideListener(@Nullable OnHideListener onHideListener) {
|
||||
this.onHideListener = onHideListener;
|
||||
}
|
||||
|
||||
private @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) {
|
||||
return Stream.of(messageRecord.getReactions())
|
||||
.filter(record -> record.getAuthor().equals(TextSecurePreferences.getLocalNumber(getContext())))
|
||||
.findFirst()
|
||||
.map(ReactionRecord::getEmoji)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private @NonNull List<ActionItem> getMenuActionItems(@NonNull MessageRecord message) {
|
||||
List<ActionItem> items = new ArrayList<>();
|
||||
|
||||
// Prepare
|
||||
boolean containsControlMessage = message.isUpdate();
|
||||
boolean hasText = !message.getBody().isEmpty();
|
||||
OpenGroup openGroup = DatabaseComponent.get(getContext()).lokiThreadDatabase().getOpenGroupChat(message.getThreadId());
|
||||
Recipient recipient = DatabaseComponent.get(getContext()).threadDatabase().getRecipientForThreadId(message.getThreadId());
|
||||
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
|
||||
// Select message
|
||||
items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT)));
|
||||
// Reply
|
||||
if (!message.isPending() && !message.isFailed()) {
|
||||
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
|
||||
}
|
||||
// Copy message text
|
||||
if (!containsControlMessage && hasText) {
|
||||
items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.copy), () -> handleActionItemClicked(Action.COPY_MESSAGE)));
|
||||
}
|
||||
// Copy Session ID
|
||||
if (recipient.isGroupRecipient() && !recipient.isOpenGroupRecipient() && !message.getRecipient().getAddress().toString().equals(userPublicKey)) {
|
||||
items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.activity_conversation_menu_copy_session_id), () -> handleActionItemClicked(Action.COPY_SESSION_ID)));
|
||||
}
|
||||
// Delete message
|
||||
if (ConversationMenuItemHelper.userCanDeleteSelectedItems(getContext(), message, openGroup, userPublicKey)) {
|
||||
items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete), () -> handleActionItemClicked(Action.DELETE)));
|
||||
}
|
||||
// Ban user
|
||||
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey)) {
|
||||
items.add(new ActionItem(0, getContext().getResources().getString(R.string.conversation_context__menu_ban_user), () -> handleActionItemClicked(Action.BAN_USER)));
|
||||
}
|
||||
// Ban and delete all
|
||||
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey)) {
|
||||
items.add(new ActionItem(0, getContext().getResources().getString(R.string.conversation_context__menu_ban_and_delete_all), () -> handleActionItemClicked(Action.BAN_AND_DELETE_ALL)));
|
||||
}
|
||||
// Message detail
|
||||
if (message.isFailed()) {
|
||||
items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
|
||||
}
|
||||
// Resend
|
||||
if (message.isFailed()) {
|
||||
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
|
||||
}
|
||||
// Save media
|
||||
if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) {
|
||||
items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD)));
|
||||
}
|
||||
|
||||
backgroundView.setVisibility(View.VISIBLE);
|
||||
foregroundView.setVisibility(View.VISIBLE);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private void handleActionItemClicked(@NonNull Action action) {
|
||||
hideInternal(new OnHideListener() {
|
||||
@Override public void startHide() {
|
||||
if (onHideListener != null) {
|
||||
onHideListener.startHide();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void onHide() {
|
||||
if (onHideListener != null) {
|
||||
onHideListener.onHide();
|
||||
}
|
||||
|
||||
if (onActionSelectedListener != null) {
|
||||
onActionSelectedListener.onActionSelected(action);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initAnimators() {
|
||||
|
||||
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
||||
int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset);
|
||||
|
||||
List<Animator> reveals = Stream.of(emojiViews)
|
||||
.mapIndexed((idx, v) -> {
|
||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal);
|
||||
anim.setTarget(v);
|
||||
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
|
||||
return anim;
|
||||
})
|
||||
.toList();
|
||||
|
||||
Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
|
||||
backgroundRevealAnim.setTarget(backgroundView);
|
||||
backgroundRevealAnim.setDuration(revealDuration);
|
||||
backgroundRevealAnim.setStartDelay(revealOffset);
|
||||
reveals.add(backgroundRevealAnim);
|
||||
|
||||
revealAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
revealAnimatorSet.playTogether(reveals);
|
||||
}
|
||||
|
||||
private @NonNull AnimatorSet newHideAnimatorSet() {
|
||||
AnimatorSet set = new AnimatorSet();
|
||||
|
||||
set.addListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
set.setInterpolator(INTERPOLATOR);
|
||||
|
||||
set.playTogether(newHideAnimators());
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
private @NonNull List<Animator> newHideAnimators() {
|
||||
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
|
||||
|
||||
List<Animator> animators = new ArrayList<>(Stream.of(emojiViews)
|
||||
.mapIndexed((idx, v) -> {
|
||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
|
||||
anim.setTarget(v);
|
||||
return anim;
|
||||
})
|
||||
.toList());
|
||||
|
||||
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
backgroundHideAnim.setTarget(backgroundView);
|
||||
backgroundHideAnim.setDuration(duration);
|
||||
animators.add(backgroundHideAnim);
|
||||
|
||||
ObjectAnimator itemScaleXAnim = new ObjectAnimator();
|
||||
itemScaleXAnim.setProperty(View.SCALE_X);
|
||||
itemScaleXAnim.setFloatValues(1f);
|
||||
itemScaleXAnim.setTarget(conversationItem);
|
||||
itemScaleXAnim.setDuration(duration);
|
||||
animators.add(itemScaleXAnim);
|
||||
|
||||
ObjectAnimator itemScaleYAnim = new ObjectAnimator();
|
||||
itemScaleYAnim.setProperty(View.SCALE_Y);
|
||||
itemScaleYAnim.setFloatValues(1f);
|
||||
itemScaleYAnim.setTarget(conversationItem);
|
||||
itemScaleYAnim.setDuration(duration);
|
||||
animators.add(itemScaleYAnim);
|
||||
|
||||
ObjectAnimator itemXAnim = new ObjectAnimator();
|
||||
itemXAnim.setProperty(View.X);
|
||||
itemXAnim.setFloatValues(selectedConversationModel.getBubbleX());
|
||||
itemXAnim.setTarget(conversationItem);
|
||||
itemXAnim.setDuration(duration);
|
||||
animators.add(itemXAnim);
|
||||
|
||||
ObjectAnimator itemYAnim = new ObjectAnimator();
|
||||
itemYAnim.setProperty(View.Y);
|
||||
itemYAnim.setFloatValues(selectedConversationModel.getBubbleY() - statusBarHeight);
|
||||
itemYAnim.setTarget(conversationItem);
|
||||
itemYAnim.setDuration(duration);
|
||||
animators.add(itemYAnim);
|
||||
|
||||
if (activity != null) {
|
||||
ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor);
|
||||
statusBarAnim.setDuration(duration);
|
||||
statusBarAnim.addUpdateListener(animation -> {
|
||||
WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
|
||||
});
|
||||
animators.add(statusBarAnim);
|
||||
|
||||
ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor);
|
||||
navigationBarAnim.setDuration(duration);
|
||||
navigationBarAnim.addUpdateListener(animation -> {
|
||||
WindowUtil.setNavigationBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
|
||||
});
|
||||
animators.add(navigationBarAnim);
|
||||
}
|
||||
|
||||
return animators;
|
||||
}
|
||||
|
||||
public interface OnHideListener {
|
||||
void startHide();
|
||||
void onHide();
|
||||
}
|
||||
|
||||
public interface OnReactionSelectedListener {
|
||||
void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji);
|
||||
void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji);
|
||||
}
|
||||
|
||||
public interface OnActionSelectedListener {
|
||||
void onActionSelected(@NonNull Action action);
|
||||
}
|
||||
|
||||
private static class Boundary {
|
||||
private float min;
|
||||
private float max;
|
||||
|
||||
Boundary() {}
|
||||
|
||||
Boundary(float min, float max) {
|
||||
update(min, max);
|
||||
}
|
||||
|
||||
private void update(float min, float max) {
|
||||
this.min = min;
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
public boolean contains(float value) {
|
||||
if (min < max) {
|
||||
return this.min < value && this.max > value;
|
||||
} else {
|
||||
return this.min > value && this.max < value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum OverlayState {
|
||||
HIDDEN,
|
||||
UNINITAILIZED,
|
||||
DEADZONE,
|
||||
SCRUB,
|
||||
TAP
|
||||
}
|
||||
|
||||
public enum Action {
|
||||
REPLY,
|
||||
RESEND,
|
||||
DOWNLOAD,
|
||||
COPY_MESSAGE,
|
||||
COPY_SESSION_ID,
|
||||
VIEW_INFO,
|
||||
SELECT,
|
||||
DELETE,
|
||||
BAN_USER,
|
||||
BAN_AND_DELETE_ALL,
|
||||
}
|
||||
}
|
@ -3,21 +3,29 @@ package org.thoughtcrime.securesms.conversation.v2
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.goterl.lazysodium.utils.KeyPair
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||
import java.util.UUID
|
||||
|
||||
class ConversationViewModel(
|
||||
val threadId: Long,
|
||||
private val repository: ConversationRepository
|
||||
val edKeyPair: KeyPair?,
|
||||
private val repository: ConversationRepository,
|
||||
private val storage: Storage
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ConversationUiState())
|
||||
@ -26,6 +34,18 @@ class ConversationViewModel(
|
||||
val recipient: Recipient?
|
||||
get() = repository.maybeGetRecipientForThreadId(threadId)
|
||||
|
||||
val openGroup: OpenGroup?
|
||||
get() = storage.getOpenGroup(threadId)
|
||||
|
||||
val serverCapabilities: List<String>
|
||||
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
|
||||
|
||||
val blindedPublicKey: String?
|
||||
get() = if (openGroup == null || edKeyPair == null) null else {
|
||||
SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes
|
||||
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
|
||||
}
|
||||
|
||||
init {
|
||||
_uiState.update {
|
||||
it.copy(isOxenHostedOpenGroup = repository.isOxenHostedOpenGroup(threadId))
|
||||
@ -137,17 +157,19 @@ class ConversationViewModel(
|
||||
|
||||
@dagger.assisted.AssistedFactory
|
||||
interface AssistedFactory {
|
||||
fun create(threadId: Long): Factory
|
||||
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class Factory @AssistedInject constructor(
|
||||
@Assisted private val threadId: Long,
|
||||
private val repository: ConversationRepository
|
||||
@Assisted private val edKeyPair: KeyPair?,
|
||||
private val repository: ConversationRepository,
|
||||
private val storage: Storage
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return ConversationViewModel(threadId, repository) as T
|
||||
return ConversationViewModel(threadId, edKeyPair, repository, storage) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,73 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2;
|
||||
|
||||
import android.content.res.Resources;
|
||||
|
||||
import androidx.annotation.Dimension;
|
||||
import androidx.annotation.Px;
|
||||
|
||||
/**
|
||||
* Core utility for converting different dimensional values.
|
||||
*/
|
||||
public enum DimensionUnit {
|
||||
PIXELS {
|
||||
@Override
|
||||
@Px
|
||||
public float toPixels(@Px float pixels) {
|
||||
return pixels;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Dimension(unit = Dimension.DP)
|
||||
public float toDp(@Px float pixels) {
|
||||
return pixels / Resources.getSystem().getDisplayMetrics().density;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Dimension(unit = Dimension.SP)
|
||||
public float toSp(@Px float pixels) {
|
||||
return pixels / Resources.getSystem().getDisplayMetrics().scaledDensity;
|
||||
}
|
||||
},
|
||||
DP {
|
||||
@Override
|
||||
@Px
|
||||
public float toPixels(@Dimension(unit = Dimension.DP) float dp) {
|
||||
return dp * Resources.getSystem().getDisplayMetrics().density;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Dimension(unit = Dimension.DP)
|
||||
public float toDp(@Dimension(unit = Dimension.DP) float dp) {
|
||||
return dp;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Dimension(unit = Dimension.SP)
|
||||
public float toSp(@Dimension(unit = Dimension.DP) float dp) {
|
||||
return PIXELS.toSp(toPixels(dp));
|
||||
}
|
||||
},
|
||||
SP {
|
||||
@Override
|
||||
@Px
|
||||
public float toPixels(@Dimension(unit = Dimension.SP) float sp) {
|
||||
return sp * Resources.getSystem().getDisplayMetrics().scaledDensity;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Dimension(unit = Dimension.DP)
|
||||
public float toDp(@Dimension(unit = Dimension.SP) float sp) {
|
||||
return PIXELS.toDp(toPixels(sp));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Dimension(unit = Dimension.SP)
|
||||
public float toSp(@Dimension(unit = Dimension.SP) float sp) {
|
||||
return sp;
|
||||
}
|
||||
};
|
||||
|
||||
public abstract float toPixels(float value);
|
||||
public abstract float toDp(float value);
|
||||
public abstract float toSp(float value);
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Disable animations for changes to same item
|
||||
*/
|
||||
public class NoCrossfadeChangeDefaultAnimator extends DefaultItemAnimator {
|
||||
@Override
|
||||
public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, int fromX, int fromY, int toX, int toY) {
|
||||
if (oldHolder == newHolder) {
|
||||
if (oldHolder != null) {
|
||||
dispatchChangeFinished(oldHolder, true);
|
||||
}
|
||||
} else {
|
||||
if (oldHolder != null) {
|
||||
dispatchChangeFinished(oldHolder, true);
|
||||
}
|
||||
if (newHolder != null) {
|
||||
dispatchChangeFinished(newHolder, false);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, @NonNull List<Object> payloads) {
|
||||
return true;
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
* Contains information on a single selected conversation item. This is used when transitioning
|
||||
* between selected and unselected states.
|
||||
*/
|
||||
data class SelectedConversationModel(
|
||||
val bitmap: Bitmap,
|
||||
val bubbleX: Float,
|
||||
val bubbleY: Float,
|
||||
val bubbleWidth: Int,
|
||||
val isOutgoing: Boolean,
|
||||
val focusedView: View?,
|
||||
)
|
@ -0,0 +1,381 @@
|
||||
/*
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.conversation.v2;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.ActivityManager;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.graphics.Typeface;
|
||||
import android.net.Uri;
|
||||
import android.os.Build.VERSION;
|
||||
import android.os.Build.VERSION_CODES;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.StyleSpan;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.mms.pdu_alt.CharacterSets;
|
||||
import com.google.android.mms.pdu_alt.EncodedStringValue;
|
||||
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.components.ComposeText;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class Util {
|
||||
private static final String TAG = Log.tag(Util.class);
|
||||
|
||||
private static final long BUILD_LIFESPAN = TimeUnit.DAYS.toMillis(90);
|
||||
|
||||
public static <T> List<T> asList(T... elements) {
|
||||
List<T> result = new LinkedList<>();
|
||||
Collections.addAll(result, elements);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String join(String[] list, String delimiter) {
|
||||
return join(Arrays.asList(list), delimiter);
|
||||
}
|
||||
|
||||
public static <T> String join(Collection<T> list, String delimiter) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
int i = 0;
|
||||
|
||||
for (T item : list) {
|
||||
result.append(item);
|
||||
|
||||
if (++i < list.size())
|
||||
result.append(delimiter);
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
public static String join(long[] list, String delimeter) {
|
||||
List<Long> boxed = new ArrayList<>(list.length);
|
||||
|
||||
for (int i = 0; i < list.length; i++) {
|
||||
boxed.add(list[i]);
|
||||
}
|
||||
|
||||
return join(boxed, delimeter);
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
public static @NonNull <E> List<E> join(@NonNull List<E>... lists) {
|
||||
int totalSize = Stream.of(lists).reduce(0, (sum, list) -> sum + list.size());
|
||||
List<E> joined = new ArrayList<>(totalSize);
|
||||
|
||||
for (List<E> list : lists) {
|
||||
joined.addAll(list);
|
||||
}
|
||||
|
||||
return joined;
|
||||
}
|
||||
|
||||
public static String join(List<Long> list, String delimeter) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
for (int j = 0; j < list.size(); j++) {
|
||||
if (j != 0) sb.append(delimeter);
|
||||
sb.append(list.get(j));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String rightPad(String value, int length) {
|
||||
if (value.length() >= length) {
|
||||
return value;
|
||||
}
|
||||
|
||||
StringBuilder out = new StringBuilder(value);
|
||||
while (out.length() < length) {
|
||||
out.append(" ");
|
||||
}
|
||||
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
public static boolean isEmpty(EncodedStringValue[] value) {
|
||||
return value == null || value.length == 0;
|
||||
}
|
||||
|
||||
public static boolean isEmpty(ComposeText value) {
|
||||
return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed());
|
||||
}
|
||||
|
||||
public static boolean isEmpty(Collection<?> collection) {
|
||||
return collection == null || collection.isEmpty();
|
||||
}
|
||||
|
||||
public static boolean isEmpty(@Nullable CharSequence charSequence) {
|
||||
return charSequence == null || charSequence.length() == 0;
|
||||
}
|
||||
|
||||
public static boolean hasItems(@Nullable Collection<?> collection) {
|
||||
return collection != null && !collection.isEmpty();
|
||||
}
|
||||
|
||||
public static <K, V> V getOrDefault(@NonNull Map<K, V> map, K key, V defaultValue) {
|
||||
return map.containsKey(key) ? map.get(key) : defaultValue;
|
||||
}
|
||||
|
||||
public static String getFirstNonEmpty(String... values) {
|
||||
for (String value : values) {
|
||||
if (!Util.isEmpty(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public static @NonNull String emptyIfNull(@Nullable String value) {
|
||||
return value != null ? value : "";
|
||||
}
|
||||
|
||||
public static @NonNull CharSequence emptyIfNull(@Nullable CharSequence value) {
|
||||
return value != null ? value : "";
|
||||
}
|
||||
|
||||
public static CharSequence getBoldedString(String value) {
|
||||
SpannableString spanned = new SpannableString(value);
|
||||
spanned.setSpan(new StyleSpan(Typeface.BOLD), 0,
|
||||
spanned.length(),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
return spanned;
|
||||
}
|
||||
|
||||
public static @NonNull String toIsoString(byte[] bytes) {
|
||||
try {
|
||||
return new String(bytes, CharacterSets.MIMENAME_ISO_8859_1);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new AssertionError("ISO_8859_1 must be supported!");
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] toIsoBytes(String isoString) {
|
||||
try {
|
||||
return isoString.getBytes(CharacterSets.MIMENAME_ISO_8859_1);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new AssertionError("ISO_8859_1 must be supported!");
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] toUtf8Bytes(String utf8String) {
|
||||
try {
|
||||
return utf8String.getBytes(CharacterSets.MIMENAME_UTF_8);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new AssertionError("UTF_8 must be supported!");
|
||||
}
|
||||
}
|
||||
|
||||
public static void wait(Object lock, long timeout) {
|
||||
try {
|
||||
lock.wait(timeout);
|
||||
} catch (InterruptedException ie) {
|
||||
throw new AssertionError(ie);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<String> split(String source, String delimiter) {
|
||||
List<String> results = new LinkedList<>();
|
||||
|
||||
if (TextUtils.isEmpty(source)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
String[] elements = source.split(delimiter);
|
||||
Collections.addAll(results, elements);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public static byte[][] split(byte[] input, int firstLength, int secondLength) {
|
||||
byte[][] parts = new byte[2][];
|
||||
|
||||
parts[0] = new byte[firstLength];
|
||||
System.arraycopy(input, 0, parts[0], 0, firstLength);
|
||||
|
||||
parts[1] = new byte[secondLength];
|
||||
System.arraycopy(input, firstLength, parts[1], 0, secondLength);
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
public static byte[] combine(byte[]... elements) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
for (byte[] element : elements) {
|
||||
baos.write(element);
|
||||
}
|
||||
|
||||
return baos.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] trim(byte[] input, int length) {
|
||||
byte[] result = new byte[length];
|
||||
System.arraycopy(input, 0, result, 0, result.length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static byte[] getSecretBytes(int size) {
|
||||
return getSecretBytes(new SecureRandom(), size);
|
||||
}
|
||||
|
||||
public static byte[] getSecretBytes(@NonNull SecureRandom secureRandom, int size) {
|
||||
byte[] secret = new byte[size];
|
||||
secureRandom.nextBytes(secret);
|
||||
return secret;
|
||||
}
|
||||
|
||||
public static <T> T getRandomElement(T[] elements) {
|
||||
return elements[new SecureRandom().nextInt(elements.length)];
|
||||
}
|
||||
|
||||
public static <T> T getRandomElement(List<T> elements) {
|
||||
return elements.get(new SecureRandom().nextInt(elements.size()));
|
||||
}
|
||||
|
||||
public static boolean equals(@Nullable Object a, @Nullable Object b) {
|
||||
return a == b || (a != null && a.equals(b));
|
||||
}
|
||||
|
||||
public static int hashCode(@Nullable Object... objects) {
|
||||
return Arrays.hashCode(objects);
|
||||
}
|
||||
|
||||
public static @Nullable Uri uri(@Nullable String uri) {
|
||||
if (uri == null) return null;
|
||||
else return Uri.parse(uri);
|
||||
}
|
||||
|
||||
@TargetApi(VERSION_CODES.KITKAT)
|
||||
public static boolean isLowMemory(Context context) {
|
||||
ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
|
||||
|
||||
return (VERSION.SDK_INT >= VERSION_CODES.KITKAT && activityManager.isLowRamDevice()) ||
|
||||
activityManager.getLargeMemoryClass() <= 64;
|
||||
}
|
||||
|
||||
public static int clamp(int value, int min, int max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
public static long clamp(long value, long min, long max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
public static float clamp(float value, float min, float max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns half of the difference between the given length, and the length when scaled by the
|
||||
* given scale.
|
||||
*/
|
||||
public static float halfOffsetFromScale(int length, float scale) {
|
||||
float scaledLength = length * scale;
|
||||
return (length - scaledLength) / 2;
|
||||
}
|
||||
|
||||
public static @Nullable String readTextFromClipboard(@NonNull Context context) {
|
||||
{
|
||||
ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
|
||||
if (clipboardManager.hasPrimaryClip() && clipboardManager.getPrimaryClip().getItemCount() > 0) {
|
||||
return clipboardManager.getPrimaryClip().getItemAt(0).getText().toString();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void writeTextToClipboard(@NonNull Context context, @NonNull String text) {
|
||||
writeTextToClipboard(context, context.getString(R.string.app_name), text);
|
||||
}
|
||||
|
||||
public static void writeTextToClipboard(@NonNull Context context, @NonNull String label, @NonNull String text) {
|
||||
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clip = ClipData.newPlainText(label, text);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
}
|
||||
|
||||
public static int toIntExact(long value) {
|
||||
if ((int)value != value) {
|
||||
throw new ArithmeticException("integer overflow");
|
||||
}
|
||||
return (int)value;
|
||||
}
|
||||
|
||||
public static boolean isEquals(@Nullable Long first, long second) {
|
||||
return first != null && first == second;
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
public static <T> List<T> concatenatedList(Collection <T>... items) {
|
||||
final List<T> concat = new ArrayList<>(Stream.of(items).reduce(0, (sum, list) -> sum + list.size()));
|
||||
|
||||
for (Collection<T> list : items) {
|
||||
concat.addAll(list);
|
||||
}
|
||||
|
||||
return concat;
|
||||
}
|
||||
|
||||
public static boolean isLong(String value) {
|
||||
try {
|
||||
Long.parseLong(value);
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static int parseInt(String integer, int defaultValue) {
|
||||
try {
|
||||
return Integer.parseInt(integer);
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Copyright (C) 2015 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.conversation.v2;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.res.Resources;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewStub;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.animation.AlphaAnimation;
|
||||
import android.view.animation.Animation;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.view.ContextThemeWrapper;
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
|
||||
import org.session.libsession.utilities.ServiceUtil;
|
||||
import org.session.libsession.utilities.Stub;
|
||||
import org.session.libsignal.utilities.ListenableFuture;
|
||||
import org.session.libsignal.utilities.SettableFuture;
|
||||
|
||||
public final class ViewUtil {
|
||||
|
||||
private ViewUtil() {
|
||||
}
|
||||
|
||||
public static void focusAndMoveCursorToEndAndOpenKeyboard(@NonNull EditText input) {
|
||||
int numberLength = input.getText().length();
|
||||
input.setSelection(numberLength, numberLength);
|
||||
|
||||
focusAndShowKeyboard(input);
|
||||
}
|
||||
|
||||
public static void focusAndShowKeyboard(@NonNull View view) {
|
||||
view.requestFocus();
|
||||
if (view.hasWindowFocus()) {
|
||||
showTheKeyboardNow(view);
|
||||
} else {
|
||||
view.getViewTreeObserver().addOnWindowFocusChangeListener(new ViewTreeObserver.OnWindowFocusChangeListener() {
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
if (hasFocus) {
|
||||
showTheKeyboardNow(view);
|
||||
view.getViewTreeObserver().removeOnWindowFocusChangeListener(this);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void showTheKeyboardNow(@NonNull View view) {
|
||||
if (view.isFocused()) {
|
||||
view.post(() -> {
|
||||
InputMethodManager inputMethodManager = ServiceUtil.getInputMethodManager(view.getContext());
|
||||
inputMethodManager.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T extends View> T inflateStub(@NonNull View parent, @IdRes int stubId) {
|
||||
return (T)((ViewStub)parent.findViewById(stubId)).inflate();
|
||||
}
|
||||
|
||||
public static <T extends View> Stub<T> findStubById(@NonNull Activity parent, @IdRes int resId) {
|
||||
return new Stub<>(parent.findViewById(resId));
|
||||
}
|
||||
|
||||
public static <T extends View> Stub<T> findStubById(@NonNull View parent, @IdRes int resId) {
|
||||
return new Stub<>(parent.findViewById(resId));
|
||||
}
|
||||
|
||||
private static Animation getAlphaAnimation(float from, float to, int duration) {
|
||||
final Animation anim = new AlphaAnimation(from, to);
|
||||
anim.setInterpolator(new FastOutSlowInInterpolator());
|
||||
anim.setDuration(duration);
|
||||
return anim;
|
||||
}
|
||||
|
||||
public static void fadeIn(final @NonNull View view, final int duration) {
|
||||
animateIn(view, getAlphaAnimation(0f, 1f, duration));
|
||||
}
|
||||
|
||||
public static ListenableFuture<Boolean> fadeOut(final @NonNull View view, final int duration) {
|
||||
return fadeOut(view, duration, View.GONE);
|
||||
}
|
||||
|
||||
public static ListenableFuture<Boolean> fadeOut(@NonNull View view, int duration, int visibility) {
|
||||
return animateOut(view, getAlphaAnimation(1f, 0f, duration), visibility);
|
||||
}
|
||||
|
||||
public static ListenableFuture<Boolean> animateOut(final @NonNull View view, final @NonNull Animation animation) {
|
||||
return animateOut(view, animation, View.GONE);
|
||||
}
|
||||
|
||||
public static ListenableFuture<Boolean> animateOut(final @NonNull View view, final @NonNull Animation animation, final int visibility) {
|
||||
final SettableFuture future = new SettableFuture();
|
||||
if (view.getVisibility() == visibility) {
|
||||
future.set(true);
|
||||
} else {
|
||||
view.clearAnimation();
|
||||
animation.reset();
|
||||
animation.setStartTime(0);
|
||||
animation.setAnimationListener(new Animation.AnimationListener() {
|
||||
@Override
|
||||
public void onAnimationStart(Animation animation) {}
|
||||
|
||||
@Override
|
||||
public void onAnimationRepeat(Animation animation) {}
|
||||
|
||||
@Override
|
||||
public void onAnimationEnd(Animation animation) {
|
||||
view.setVisibility(visibility);
|
||||
future.set(true);
|
||||
}
|
||||
});
|
||||
view.startAnimation(animation);
|
||||
}
|
||||
return future;
|
||||
}
|
||||
|
||||
public static void animateIn(final @NonNull View view, final @NonNull Animation animation) {
|
||||
if (view.getVisibility() == View.VISIBLE) return;
|
||||
|
||||
view.clearAnimation();
|
||||
animation.reset();
|
||||
animation.setStartTime(0);
|
||||
view.setVisibility(View.VISIBLE);
|
||||
view.startAnimation(animation);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T extends View> T inflate(@NonNull LayoutInflater inflater,
|
||||
@NonNull ViewGroup parent,
|
||||
@LayoutRes int layoutResId)
|
||||
{
|
||||
return (T)(inflater.inflate(layoutResId, parent, false));
|
||||
}
|
||||
|
||||
@SuppressLint("RtlHardcoded")
|
||||
public static void setTextViewGravityStart(final @NonNull TextView textView, @NonNull Context context) {
|
||||
if (isRtl(context)) {
|
||||
textView.setGravity(Gravity.RIGHT);
|
||||
} else {
|
||||
textView.setGravity(Gravity.LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
public static void mirrorIfRtl(View view, Context context) {
|
||||
if (isRtl(context)) {
|
||||
view.setScaleX(-1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isLtr(@NonNull View view) {
|
||||
return isLtr(view.getContext());
|
||||
}
|
||||
|
||||
public static boolean isLtr(@NonNull Context context) {
|
||||
return context.getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_LTR;
|
||||
}
|
||||
|
||||
public static boolean isRtl(@NonNull View view) {
|
||||
return isRtl(view.getContext());
|
||||
}
|
||||
|
||||
public static boolean isRtl(@NonNull Context context) {
|
||||
return context.getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
|
||||
}
|
||||
|
||||
public static float pxToDp(float px) {
|
||||
return px / Resources.getSystem().getDisplayMetrics().density;
|
||||
}
|
||||
|
||||
public static int dpToPx(Context context, int dp) {
|
||||
return (int)((dp * context.getResources().getDisplayMetrics().density) + 0.5);
|
||||
}
|
||||
|
||||
public static int dpToPx(int dp) {
|
||||
return Math.round(dp * Resources.getSystem().getDisplayMetrics().density);
|
||||
}
|
||||
|
||||
public static int dpToSp(int dp) {
|
||||
return (int) (dpToPx(dp) / Resources.getSystem().getDisplayMetrics().scaledDensity);
|
||||
}
|
||||
|
||||
public static int spToPx(float sp) {
|
||||
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, Resources.getSystem().getDisplayMetrics());
|
||||
}
|
||||
|
||||
public static void updateLayoutParams(@NonNull View view, int width, int height) {
|
||||
view.getLayoutParams().width = width;
|
||||
view.getLayoutParams().height = height;
|
||||
view.requestLayout();
|
||||
}
|
||||
|
||||
public static void updateLayoutParamsIfNonNull(@Nullable View view, int width, int height) {
|
||||
if (view != null) {
|
||||
updateLayoutParams(view, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
public static void setVisibilityIfNonNull(@Nullable View view, int visibility) {
|
||||
if (view != null) {
|
||||
view.setVisibility(visibility);
|
||||
}
|
||||
}
|
||||
|
||||
public static int getLeftMargin(@NonNull View view) {
|
||||
if (isLtr(view)) {
|
||||
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin;
|
||||
}
|
||||
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin;
|
||||
}
|
||||
|
||||
public static int getRightMargin(@NonNull View view) {
|
||||
if (isLtr(view)) {
|
||||
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin;
|
||||
}
|
||||
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin;
|
||||
}
|
||||
|
||||
public static int getTopMargin(@NonNull View view) {
|
||||
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin;
|
||||
}
|
||||
|
||||
public static void setLeftMargin(@NonNull View view, int margin) {
|
||||
if (isLtr(view)) {
|
||||
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin;
|
||||
} else {
|
||||
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin;
|
||||
}
|
||||
view.forceLayout();
|
||||
view.requestLayout();
|
||||
}
|
||||
|
||||
public static void setRightMargin(@NonNull View view, int margin) {
|
||||
if (isLtr(view)) {
|
||||
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin;
|
||||
} else {
|
||||
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin;
|
||||
}
|
||||
view.forceLayout();
|
||||
view.requestLayout();
|
||||
}
|
||||
|
||||
public static void setTopMargin(@NonNull View view, int margin) {
|
||||
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin = margin;
|
||||
view.requestLayout();
|
||||
}
|
||||
|
||||
public static void setBottomMargin(@NonNull View view, int margin) {
|
||||
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).bottomMargin = margin;
|
||||
view.requestLayout();
|
||||
}
|
||||
|
||||
public static int getWidth(@NonNull View view) {
|
||||
return view.getLayoutParams().width;
|
||||
}
|
||||
|
||||
public static void setPaddingTop(@NonNull View view, int padding) {
|
||||
view.setPadding(view.getPaddingLeft(), padding, view.getPaddingRight(), view.getPaddingBottom());
|
||||
}
|
||||
|
||||
public static void setPaddingBottom(@NonNull View view, int padding) {
|
||||
view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), view.getPaddingRight(), padding);
|
||||
}
|
||||
|
||||
public static void setPadding(@NonNull View view, int padding) {
|
||||
view.setPadding(padding, padding, padding, padding);
|
||||
}
|
||||
|
||||
public static void setPaddingStart(@NonNull View view, int padding) {
|
||||
if (isLtr(view)) {
|
||||
view.setPadding(padding, view.getPaddingTop(), view.getPaddingRight(), view.getPaddingBottom());
|
||||
} else {
|
||||
view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), padding, view.getPaddingBottom());
|
||||
}
|
||||
}
|
||||
|
||||
public static void setPaddingEnd(@NonNull View view, int padding) {
|
||||
if (isLtr(view)) {
|
||||
view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), padding, view.getPaddingBottom());
|
||||
} else {
|
||||
view.setPadding(padding, view.getPaddingTop(), view.getPaddingRight(), view.getPaddingBottom());
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isPointInsideView(@NonNull View view, float x, float y) {
|
||||
int[] location = new int[2];
|
||||
|
||||
view.getLocationOnScreen(location);
|
||||
|
||||
int viewX = location[0];
|
||||
int viewY = location[1];
|
||||
|
||||
return x > viewX && x < viewX + view.getWidth() &&
|
||||
y > viewY && y < viewY + view.getHeight();
|
||||
}
|
||||
|
||||
public static int getStatusBarHeight(@NonNull View view) {
|
||||
int result = 0;
|
||||
int resourceId = view.getResources().getIdentifier("status_bar_height", "dimen", "android");
|
||||
if (resourceId > 0) {
|
||||
result = view.getResources().getDimensionPixelSize(resourceId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static int getNavigationBarHeight(@NonNull View view) {
|
||||
int result = 0;
|
||||
int resourceId = view.getResources().getIdentifier("navigation_bar_height", "dimen", "android");
|
||||
if (resourceId > 0) {
|
||||
result = view.getResources().getDimensionPixelSize(resourceId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void hideKeyboard(@NonNull Context context, @NonNull View view) {
|
||||
InputMethodManager inputManager = (InputMethodManager) context.getSystemService(Activity.INPUT_METHOD_SERVICE);
|
||||
inputManager.hideSoftInputFromWindow(view.getWindowToken(), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables a view and all child views recursively.
|
||||
*/
|
||||
public static void setEnabledRecursive(@NonNull View view, boolean enabled) {
|
||||
view.setEnabled(enabled);
|
||||
if (view instanceof ViewGroup) {
|
||||
ViewGroup viewGroup = (ViewGroup) view;
|
||||
for (int i = 0; i < viewGroup.getChildCount(); i++) {
|
||||
setEnabledRecursive(viewGroup.getChildAt(i), enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static @Nullable Lifecycle getActivityLifecycle(@NonNull View view) {
|
||||
return getActivityLifecycle(view.getContext());
|
||||
}
|
||||
|
||||
private static @Nullable Lifecycle getActivityLifecycle(@Nullable Context context) {
|
||||
if (context instanceof ContextThemeWrapper) {
|
||||
return getActivityLifecycle(((ContextThemeWrapper) context).getBaseContext());
|
||||
}
|
||||
|
||||
if (context instanceof AppCompatActivity) {
|
||||
return ((AppCompatActivity) context).getLifecycle();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Build;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.session.libsession.utilities.ThemeUtil;
|
||||
|
||||
public final class WindowUtil {
|
||||
|
||||
private WindowUtil() {
|
||||
}
|
||||
|
||||
public static void setLightNavigationBarFromTheme(@NonNull Activity activity) {
|
||||
if (Build.VERSION.SDK_INT < 27) return;
|
||||
|
||||
final boolean isLightNavigationBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightNavigationBar);
|
||||
|
||||
if (isLightNavigationBar) setLightNavigationBar(activity.getWindow());
|
||||
else clearLightNavigationBar(activity.getWindow());
|
||||
}
|
||||
|
||||
public static void clearLightNavigationBar(@NonNull Window window) {
|
||||
if (Build.VERSION.SDK_INT < 27) return;
|
||||
|
||||
clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
|
||||
}
|
||||
|
||||
public static void setLightNavigationBar(@NonNull Window window) {
|
||||
if (Build.VERSION.SDK_INT < 27) return;
|
||||
|
||||
setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
|
||||
}
|
||||
|
||||
public static void setNavigationBarColor(@NonNull Window window, @ColorInt int color) {
|
||||
if (Build.VERSION.SDK_INT < 21) return;
|
||||
|
||||
window.setNavigationBarColor(color);
|
||||
}
|
||||
|
||||
public static void setLightStatusBarFromTheme(@NonNull Activity activity) {
|
||||
if (Build.VERSION.SDK_INT < 23) return;
|
||||
|
||||
final boolean isLightStatusBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightStatusBar);
|
||||
|
||||
if (isLightStatusBar) setLightStatusBar(activity.getWindow());
|
||||
else clearLightStatusBar(activity.getWindow());
|
||||
}
|
||||
|
||||
public static void clearLightStatusBar(@NonNull Window window) {
|
||||
if (Build.VERSION.SDK_INT < 23) return;
|
||||
|
||||
clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
||||
}
|
||||
|
||||
public static void setLightStatusBar(@NonNull Window window) {
|
||||
if (Build.VERSION.SDK_INT < 23) return;
|
||||
|
||||
setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
||||
}
|
||||
|
||||
public static void setStatusBarColor(@NonNull Window window, @ColorInt int color) {
|
||||
if (Build.VERSION.SDK_INT < 21) return;
|
||||
|
||||
window.setStatusBarColor(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sort of roundabout way of determining if the status bar is present by seeing if there's a
|
||||
* vertical window offset.
|
||||
*/
|
||||
public static boolean isStatusBarPresent(@NonNull Window window) {
|
||||
Rect rectangle = new Rect();
|
||||
window.getDecorView().getWindowVisibleDisplayFrame(rectangle);
|
||||
return rectangle.top > 0;
|
||||
}
|
||||
|
||||
private static void clearSystemUiFlags(@NonNull Window window, int flags) {
|
||||
View view = window.getDecorView();
|
||||
int uiFlags = view.getSystemUiVisibility();
|
||||
|
||||
uiFlags &= ~flags;
|
||||
view.setSystemUiVisibility(uiFlags);
|
||||
}
|
||||
|
||||
private static void setSystemUiFlags(@NonNull Window window, int flags) {
|
||||
View view = window.getDecorView();
|
||||
int uiFlags = view.getSystemUiVisibility();
|
||||
|
||||
uiFlags |= flags;
|
||||
view.setSystemUiVisibility(uiFlags);
|
||||
}
|
||||
}
|
@ -40,7 +40,7 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : B
|
||||
ThreadUtils.queue {
|
||||
try {
|
||||
OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity)
|
||||
MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(url)
|
||||
MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server)
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show()
|
||||
|
@ -10,7 +10,6 @@ import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
@ -43,14 +42,6 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
||||
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
|
||||
fun userCanDeleteSelectedItems(): Boolean {
|
||||
val allSentByCurrentUser = selectedItems.all { it.isOutgoing }
|
||||
|
||||
// Remove this after the unsend request is enabled
|
||||
if (!ConversationActivityV2.IS_UNSEND_REQUESTS_ENABLED) {
|
||||
if (openGroup == null) { return true }
|
||||
if (allSentByCurrentUser) { return true }
|
||||
return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey)
|
||||
}
|
||||
|
||||
val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing }
|
||||
if (openGroup == null) { return allSentByCurrentUser || allReceivedByCurrentUser }
|
||||
if (allSentByCurrentUser) { return true }
|
||||
@ -115,6 +106,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
||||
|
||||
interface ConversationActionModeCallbackDelegate {
|
||||
|
||||
fun selectMessages(messages: Set<MessageRecord>)
|
||||
fun deleteMessages(messages: Set<MessageRecord>)
|
||||
fun banUser(messages: Set<MessageRecord>)
|
||||
fun banAndDeleteAll(messages: Set<MessageRecord>)
|
||||
|
@ -0,0 +1,24 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.menus
|
||||
|
||||
import android.content.Context
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
|
||||
object ConversationMenuItemHelper {
|
||||
|
||||
@JvmStatic
|
||||
fun userCanDeleteSelectedItems(context: Context, message: MessageRecord, openGroup: OpenGroup?, userPublicKey: String): Boolean {
|
||||
if (openGroup == null) return message.isOutgoing || !message.isOutgoing
|
||||
if (message.isOutgoing) return true
|
||||
return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun userCanBanSelectedUsers(context: Context, message: MessageRecord, openGroup: OpenGroup?, userPublicKey: String): Boolean {
|
||||
if (openGroup == null) return false
|
||||
if (message.isOutgoing) return false // Users can't ban themselves
|
||||
return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,343 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.messages;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.HapticFeedbackConstants;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.Group;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.google.android.flexbox.FlexboxLayout;
|
||||
import com.google.android.flexbox.JustifyContent;
|
||||
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
|
||||
import org.thoughtcrime.securesms.conversation.v2.ViewUtil;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.util.NumberUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class EmojiReactionsView extends LinearLayout implements View.OnTouchListener {
|
||||
|
||||
// Normally 6dp, but we have 1dp left+right margin on the pills themselves
|
||||
private final int OUTER_MARGIN = ViewUtil.dpToPx(2);
|
||||
private static final int DEFAULT_THRESHOLD = 5;
|
||||
|
||||
private List<ReactionRecord> records;
|
||||
private long messageId;
|
||||
private ViewGroup container;
|
||||
private Group showLess;
|
||||
private VisibleMessageViewDelegate delegate;
|
||||
private Handler gestureHandler = new Handler(Looper.getMainLooper());
|
||||
private Runnable pressCallback;
|
||||
private Runnable longPressCallback;
|
||||
private long onDownTimestamp = 0;
|
||||
private static long longPressDurationThreshold = 250;
|
||||
private static long maxDoubleTapInterval = 200;
|
||||
private boolean extended = false;
|
||||
|
||||
public EmojiReactionsView(Context context) {
|
||||
super(context);
|
||||
init(null);
|
||||
}
|
||||
|
||||
public EmojiReactionsView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init(attrs);
|
||||
}
|
||||
|
||||
private void init(@Nullable AttributeSet attrs) {
|
||||
inflate(getContext(), R.layout.view_emoji_reactions, this);
|
||||
|
||||
this.container = findViewById(R.id.layout_emoji_container);
|
||||
this.showLess = findViewById(R.id.group_show_less);
|
||||
|
||||
records = new ArrayList<>();
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0);
|
||||
typedArray.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
this.records.clear();
|
||||
container.removeAllViews();
|
||||
}
|
||||
|
||||
public void setReactions(long messageId, @NonNull List<ReactionRecord> records, boolean outgoing, VisibleMessageViewDelegate delegate) {
|
||||
this.delegate = delegate;
|
||||
if (records.equals(this.records)) {
|
||||
return;
|
||||
}
|
||||
|
||||
FlexboxLayout containerLayout = (FlexboxLayout) this.container;
|
||||
containerLayout.setJustifyContent(outgoing ? JustifyContent.FLEX_END : JustifyContent.FLEX_START);
|
||||
this.records.clear();
|
||||
this.records.addAll(records);
|
||||
if (this.messageId != messageId) {
|
||||
extended = false;
|
||||
}
|
||||
this.messageId = messageId;
|
||||
|
||||
displayReactions(extended ? Integer.MAX_VALUE : DEFAULT_THRESHOLD);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
if (v.getTag() == null) return false;
|
||||
|
||||
Reaction reaction = (Reaction) v.getTag();
|
||||
int action = event.getAction();
|
||||
if (action == MotionEvent.ACTION_DOWN) onDown(new MessageId(reaction.messageId, reaction.isMms));
|
||||
else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback();
|
||||
else if (action == MotionEvent.ACTION_UP) onUp(reaction);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void displayReactions(int threshold) {
|
||||
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
|
||||
List<Reaction> reactions = buildSortedReactionsList(records, userPublicKey, threshold);
|
||||
|
||||
container.removeAllViews();
|
||||
LinearLayout overflowContainer = new LinearLayout(getContext());
|
||||
overflowContainer.setOrientation(LinearLayout.HORIZONTAL);
|
||||
int innerPadding = ViewUtil.dpToPx(4);
|
||||
overflowContainer.setPaddingRelative(innerPadding,innerPadding,innerPadding,innerPadding);
|
||||
|
||||
for (Reaction reaction : reactions) {
|
||||
if (container.getChildCount() + 1 >= DEFAULT_THRESHOLD && threshold != Integer.MAX_VALUE && reactions.size() > threshold) {
|
||||
if (overflowContainer.getParent() == null) {
|
||||
container.addView(overflowContainer);
|
||||
ViewGroup.LayoutParams overflowParams = overflowContainer.getLayoutParams();
|
||||
overflowParams.height = ViewUtil.dpToPx(26);
|
||||
overflowContainer.setLayoutParams(overflowParams);
|
||||
overflowContainer.setBackground(ContextCompat.getDrawable(getContext(), R.drawable.reaction_pill_dialog_background));
|
||||
}
|
||||
View pill = buildPill(getContext(), this, reaction, true);
|
||||
pill.setOnClickListener(v -> {
|
||||
extended = true;
|
||||
displayReactions(Integer.MAX_VALUE);
|
||||
});
|
||||
pill.findViewById(R.id.reactions_pill_count).setVisibility(View.GONE);
|
||||
pill.findViewById(R.id.reactions_pill_spacer).setVisibility(View.GONE);
|
||||
overflowContainer.addView(pill);
|
||||
} else {
|
||||
View pill = buildPill(getContext(), this, reaction, false);
|
||||
pill.setTag(reaction);
|
||||
pill.setOnTouchListener(this);
|
||||
container.addView(pill);
|
||||
int pixelSize = ViewUtil.dpToPx(1);
|
||||
MarginLayoutParams params = (MarginLayoutParams) pill.getLayoutParams();
|
||||
params.setMargins(pixelSize, 0, pixelSize, 0);
|
||||
pill.setLayoutParams(params);
|
||||
}
|
||||
}
|
||||
|
||||
int overflowChildren = overflowContainer.getChildCount();
|
||||
int negativeMargin = ViewUtil.dpToPx(-8);
|
||||
for (int i = 0; i < overflowChildren; i++) {
|
||||
View child = overflowContainer.getChildAt(i);
|
||||
MarginLayoutParams childParams = (MarginLayoutParams) child.getLayoutParams();
|
||||
if ((i == 0 && overflowChildren > 1) || i + 1 < overflowChildren) {
|
||||
// if first and there is more than one child, or we are not the last child then set negative right margin
|
||||
childParams.setMargins(0,0, negativeMargin, 0);
|
||||
child.setLayoutParams(childParams);
|
||||
}
|
||||
}
|
||||
|
||||
if (threshold == Integer.MAX_VALUE) {
|
||||
showLess.setVisibility(VISIBLE);
|
||||
for (int id : showLess.getReferencedIds()) {
|
||||
findViewById(id).setOnClickListener(view -> {
|
||||
extended = false;
|
||||
displayReactions(DEFAULT_THRESHOLD);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showLess.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void onReactionClicked(Reaction reaction) {
|
||||
if (reaction.messageId != 0) {
|
||||
MessageId messageId = new MessageId(reaction.messageId, reaction.isMms);
|
||||
delegate.onReactionClicked(reaction.emoji, messageId, reaction.userWasSender);
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull List<Reaction> buildSortedReactionsList(@NonNull List<ReactionRecord> records, String userPublicKey, int threshold) {
|
||||
Map<String, Reaction> counters = new LinkedHashMap<>();
|
||||
|
||||
for (ReactionRecord record : records) {
|
||||
String baseEmoji = EmojiUtil.getCanonicalRepresentation(record.getEmoji());
|
||||
Reaction info = counters.get(baseEmoji);
|
||||
|
||||
if (info == null) {
|
||||
info = new Reaction(record.getMessageId(), record.isMms(), record.getEmoji(), record.getCount(), record.getSortId(), record.getDateReceived(), userPublicKey.equals(record.getAuthor()));
|
||||
} else {
|
||||
info.update(record.getEmoji(), record.getCount(), record.getDateReceived(), userPublicKey.equals(record.getAuthor()));
|
||||
}
|
||||
|
||||
counters.put(baseEmoji, info);
|
||||
}
|
||||
|
||||
List<Reaction> reactions = new ArrayList<>(counters.values());
|
||||
|
||||
Collections.sort(reactions, Collections.reverseOrder());
|
||||
|
||||
if (reactions.size() >= threshold + 2 && threshold != Integer.MAX_VALUE) {
|
||||
List<Reaction> shortened = new ArrayList<>(threshold + 2);
|
||||
shortened.addAll(reactions.subList(0, threshold + 2));
|
||||
return shortened;
|
||||
} else {
|
||||
return reactions;
|
||||
}
|
||||
}
|
||||
|
||||
private static View buildPill(@NonNull Context context, @NonNull ViewGroup parent, @NonNull Reaction reaction, boolean isCompact) {
|
||||
View root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false);
|
||||
EmojiImageView emojiView = root.findViewById(R.id.reactions_pill_emoji);
|
||||
TextView countView = root.findViewById(R.id.reactions_pill_count);
|
||||
View spacer = root.findViewById(R.id.reactions_pill_spacer);
|
||||
|
||||
if (isCompact) {
|
||||
root.setPaddingRelative(1,1,1,1);
|
||||
ViewGroup.LayoutParams layoutParams = root.getLayoutParams();
|
||||
layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
root.setLayoutParams(layoutParams);
|
||||
}
|
||||
|
||||
if (reaction.emoji != null) {
|
||||
emojiView.setImageEmoji(reaction.emoji);
|
||||
|
||||
if (reaction.count >= 1) {
|
||||
countView.setText(NumberUtil.getFormattedNumber(reaction.count));
|
||||
} else {
|
||||
countView.setVisibility(GONE);
|
||||
spacer.setVisibility(GONE);
|
||||
}
|
||||
} else {
|
||||
emojiView.setVisibility(GONE);
|
||||
spacer.setVisibility(GONE);
|
||||
countView.setText(context.getString(R.string.ReactionsConversationView_plus, reaction.count));
|
||||
}
|
||||
|
||||
if (reaction.userWasSender && !isCompact) {
|
||||
root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected));
|
||||
countView.setTextColor(ContextCompat.getColor(context, R.color.reactions_pill_selected_text_color));
|
||||
} else {
|
||||
if (!isCompact) {
|
||||
root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background));
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private void onDown(MessageId messageId) {
|
||||
removeLongPressCallback();
|
||||
Runnable newLongPressCallback = () -> {
|
||||
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
|
||||
if (delegate != null) {
|
||||
delegate.onReactionLongClicked(messageId);
|
||||
}
|
||||
};
|
||||
this.longPressCallback = newLongPressCallback;
|
||||
gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold);
|
||||
onDownTimestamp = new Date().getTime();
|
||||
}
|
||||
|
||||
private void removeLongPressCallback() {
|
||||
if (longPressCallback != null) {
|
||||
gestureHandler.removeCallbacks(longPressCallback);
|
||||
}
|
||||
}
|
||||
|
||||
private void onUp(Reaction reaction) {
|
||||
if ((new Date().getTime() - onDownTimestamp) < longPressDurationThreshold) {
|
||||
removeLongPressCallback();
|
||||
if (pressCallback != null) {
|
||||
gestureHandler.removeCallbacks(pressCallback);
|
||||
this.pressCallback = null;
|
||||
} else {
|
||||
Runnable newPressCallback = () -> {
|
||||
onReactionClicked(reaction);
|
||||
pressCallback = null;
|
||||
};
|
||||
this.pressCallback = newPressCallback;
|
||||
gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class Reaction implements Comparable<Reaction> {
|
||||
private final long messageId;
|
||||
private final boolean isMms;
|
||||
private String emoji;
|
||||
private long count;
|
||||
private long sortIndex;
|
||||
private long lastSeen;
|
||||
private boolean userWasSender;
|
||||
|
||||
Reaction(long messageId, boolean isMms, @Nullable String emoji, long count, long sortIndex, long lastSeen, boolean userWasSender) {
|
||||
this.messageId = messageId;
|
||||
this.isMms = isMms;
|
||||
this.emoji = emoji;
|
||||
this.count = count;
|
||||
this.sortIndex = sortIndex;
|
||||
this.lastSeen = lastSeen;
|
||||
this.userWasSender = userWasSender;
|
||||
}
|
||||
|
||||
void update(@NonNull String emoji, long count, long lastSeen, boolean userWasSender) {
|
||||
if (!this.userWasSender) {
|
||||
if (userWasSender || lastSeen > this.lastSeen) {
|
||||
this.emoji = emoji;
|
||||
}
|
||||
}
|
||||
|
||||
this.count = this.count + count;
|
||||
this.lastSeen = Math.max(this.lastSeen, lastSeen);
|
||||
this.userWasSender = this.userWasSender || userWasSender;
|
||||
}
|
||||
|
||||
@NonNull Reaction merge(@NonNull Reaction other) {
|
||||
this.count = this.count + other.count;
|
||||
this.lastSeen = Math.max(this.lastSeen, other.lastSeen);
|
||||
this.userWasSender = this.userWasSender || other.userWasSender;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(Reaction rhs) {
|
||||
Reaction lhs = this;
|
||||
if (lhs.count == rhs.count ) {
|
||||
return Long.compare(lhs.sortIndex, rhs.sortIndex);
|
||||
} else {
|
||||
return Long.compare(lhs.count, rhs.count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -53,7 +53,7 @@ class VisibleMessageContentView : LinearLayout {
|
||||
private lateinit var binding: ViewVisibleMessageContentBinding
|
||||
var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
|
||||
var onContentDoubleTap: (() -> Unit)? = null
|
||||
var delegate: VisibleMessageContentViewDelegate? = null
|
||||
var delegate: VisibleMessageViewDelegate? = null
|
||||
var indexInAdapter: Int = -1
|
||||
|
||||
// region Lifecycle
|
||||
@ -87,13 +87,13 @@ class VisibleMessageContentView : LinearLayout {
|
||||
|
||||
if (message.isDeleted) {
|
||||
binding.deletedMessageView.root.isVisible = true
|
||||
binding.deletedMessageView.root.bind(message, VisibleMessageContentView.getTextColor(context,message))
|
||||
binding.deletedMessageView.root.bind(message, getTextColor(context, message))
|
||||
return
|
||||
} else {
|
||||
binding.deletedMessageView.root.isVisible = false
|
||||
}
|
||||
// clear the
|
||||
binding.bodyTextView.text = ""
|
||||
binding.bodyTextView.text = null
|
||||
|
||||
|
||||
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
|
||||
@ -149,64 +149,70 @@ class VisibleMessageContentView : LinearLayout {
|
||||
}
|
||||
}
|
||||
|
||||
if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) {
|
||||
binding.linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
|
||||
onContentClick.add { event -> binding.linkPreviewView.calculateHit(event) }
|
||||
// Body text view is inside the link preview for layout convenience
|
||||
} else if (message is MmsMessageRecord && message.slideDeck.audioSlide != null) {
|
||||
hideBody = true
|
||||
// Audio attachment
|
||||
if (contactIsTrusted || message.isOutgoing) {
|
||||
binding.voiceMessageView.root.indexInAdapter = indexInAdapter
|
||||
binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
|
||||
binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
|
||||
// We have to use onContentClick (rather than a click listener directly on the voice
|
||||
// message view) so as to not interfere with all the other gestures.
|
||||
onContentClick.add { binding.voiceMessageView.root.togglePlayback() }
|
||||
onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() }
|
||||
} else {
|
||||
// TODO: move this out to its own area
|
||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message))
|
||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
||||
when {
|
||||
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
|
||||
binding.linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
|
||||
onContentClick.add { event -> binding.linkPreviewView.calculateHit(event) }
|
||||
// Body text view is inside the link preview for layout convenience
|
||||
}
|
||||
} else if (message is MmsMessageRecord && message.slideDeck.documentSlide != null) {
|
||||
hideBody = true
|
||||
// Document attachment
|
||||
if (contactIsTrusted || message.isOutgoing) {
|
||||
binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
|
||||
} else {
|
||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message))
|
||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
||||
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
|
||||
hideBody = true
|
||||
// Audio attachment
|
||||
if (contactIsTrusted || message.isOutgoing) {
|
||||
binding.voiceMessageView.root.indexInAdapter = indexInAdapter
|
||||
binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
|
||||
binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
|
||||
// We have to use onContentClick (rather than a click listener directly on the voice
|
||||
// message view) so as to not interfere with all the other gestures.
|
||||
onContentClick.add { binding.voiceMessageView.root.togglePlayback() }
|
||||
onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() }
|
||||
} else {
|
||||
// TODO: move this out to its own area
|
||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message))
|
||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
||||
}
|
||||
}
|
||||
} else if (message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty()) {
|
||||
/*
|
||||
* Images / Video attachment
|
||||
*/
|
||||
if (contactIsTrusted || message.isOutgoing) {
|
||||
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
|
||||
// bind after add view because views are inflated and calculated during bind
|
||||
binding.albumThumbnailView.bind(
|
||||
message is MmsMessageRecord && message.slideDeck.documentSlide != null -> {
|
||||
hideBody = true
|
||||
// Document attachment
|
||||
if (contactIsTrusted || message.isOutgoing) {
|
||||
binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
|
||||
} else {
|
||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message))
|
||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
||||
}
|
||||
}
|
||||
message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty() -> {
|
||||
/*
|
||||
* Images / Video attachment
|
||||
*/
|
||||
if (contactIsTrusted || message.isOutgoing) {
|
||||
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
|
||||
// bind after add view because views are inflated and calculated during bind
|
||||
binding.albumThumbnailView.bind(
|
||||
glideRequests = glide,
|
||||
message = message,
|
||||
isStart = isStartOfMessageCluster,
|
||||
isEnd = isEndOfMessageCluster
|
||||
)
|
||||
val layoutParams = binding.albumThumbnailView.layoutParams as ConstraintLayout.LayoutParams
|
||||
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
binding.albumThumbnailView.layoutParams = layoutParams
|
||||
onContentClick.add { event ->
|
||||
binding.albumThumbnailView.calculateHitObject(event, message, thread)
|
||||
)
|
||||
val layoutParams = binding.albumThumbnailView.layoutParams as ConstraintLayout.LayoutParams
|
||||
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
binding.albumThumbnailView.layoutParams = layoutParams
|
||||
onContentClick.add { event ->
|
||||
binding.albumThumbnailView.calculateHitObject(event, message, thread)
|
||||
}
|
||||
} else {
|
||||
hideBody = true
|
||||
binding.albumThumbnailView.clearViews()
|
||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message))
|
||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
||||
}
|
||||
} else {
|
||||
hideBody = true
|
||||
binding.albumThumbnailView.clearViews()
|
||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message))
|
||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
||||
}
|
||||
} else if (message.isOpenGroupInvitation) {
|
||||
hideBody = true
|
||||
binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
|
||||
onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() }
|
||||
message.isOpenGroupInvitation -> {
|
||||
hideBody = true
|
||||
binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
|
||||
onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() }
|
||||
}
|
||||
}
|
||||
|
||||
binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody
|
||||
@ -312,8 +318,3 @@ class VisibleMessageContentView : LinearLayout {
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
interface VisibleMessageContentViewDelegate {
|
||||
|
||||
fun scrollToMessageIfPossible(timestamp: Long)
|
||||
}
|
@ -26,12 +26,14 @@ import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewVisibleMessageBinding
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.contacts.Contact.ContactContext
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.ViewUtil
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.ThreadUtils
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase
|
||||
@ -59,6 +61,7 @@ class VisibleMessageView : LinearLayout {
|
||||
|
||||
@Inject lateinit var threadDb: ThreadDatabase
|
||||
@Inject lateinit var lokiThreadDb: LokiThreadDatabase
|
||||
@Inject lateinit var lokiApiDb: LokiAPIDatabase
|
||||
@Inject lateinit var mmsSmsDb: MmsSmsDatabase
|
||||
@Inject lateinit var smsDb: SmsDatabase
|
||||
@Inject lateinit var mmsDb: MmsDatabase
|
||||
@ -83,7 +86,7 @@ class VisibleMessageView : LinearLayout {
|
||||
var onPress: ((event: MotionEvent) -> Unit)? = null
|
||||
var onSwipeToReply: (() -> Unit)? = null
|
||||
var onLongPress: (() -> Unit)? = null
|
||||
var contentViewDelegate: VisibleMessageContentViewDelegate? = null
|
||||
val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView }
|
||||
|
||||
companion object {
|
||||
const val swipeToReplyThreshold = 64.0f // dp
|
||||
@ -105,14 +108,21 @@ class VisibleMessageView : LinearLayout {
|
||||
private fun initialize() {
|
||||
isHapticFeedbackEnabled = true
|
||||
setWillNotDraw(false)
|
||||
binding.expirationTimerViewContainer.disableClipping()
|
||||
binding.messageInnerContainer.disableClipping()
|
||||
binding.messageContentView.disableClipping()
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Updating
|
||||
fun bind(message: MessageRecord, previous: MessageRecord?, next: MessageRecord?,
|
||||
glide: GlideRequests, searchQuery: String?, contact: Contact?, senderSessionID: String,
|
||||
fun bind(
|
||||
message: MessageRecord,
|
||||
previous: MessageRecord?,
|
||||
next: MessageRecord?,
|
||||
glide: GlideRequests,
|
||||
searchQuery: String?,
|
||||
contact: Contact?,
|
||||
senderSessionID: String,
|
||||
delegate: VisibleMessageViewDelegate?,
|
||||
) {
|
||||
val threadID = message.threadId
|
||||
val thread = threadDb.getRecipientForThreadId(threadID) ?: return
|
||||
@ -132,9 +142,9 @@ class VisibleMessageView : LinearLayout {
|
||||
else ViewUtil.dpToPx(context,2)
|
||||
|
||||
if (binding.profilePictureView.root.visibility == View.GONE) {
|
||||
val expirationParams = binding.expirationTimerViewContainer.layoutParams as MarginLayoutParams
|
||||
val expirationParams = binding.messageInnerContainer.layoutParams as MarginLayoutParams
|
||||
expirationParams.bottomMargin = bottomMargin
|
||||
binding.expirationTimerViewContainer.layoutParams = expirationParams
|
||||
binding.messageInnerContainer.layoutParams = expirationParams
|
||||
} else {
|
||||
val avatarLayoutParams = binding.profilePictureView.root.layoutParams as MarginLayoutParams
|
||||
avatarLayoutParams.bottomMargin = bottomMargin
|
||||
@ -198,7 +208,20 @@ class VisibleMessageView : LinearLayout {
|
||||
}
|
||||
// Expiration timer
|
||||
updateExpirationTimer(message)
|
||||
// Calculate max message bubble width
|
||||
// Emoji Reactions
|
||||
val emojiLayoutParams = binding.emojiReactionsView.layoutParams as ConstraintLayout.LayoutParams
|
||||
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
binding.emojiReactionsView.layoutParams = emojiLayoutParams
|
||||
val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) }
|
||||
if (message.reactions.isNotEmpty() &&
|
||||
(capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase()))
|
||||
) {
|
||||
binding.emojiReactionsView.setReactions(message.id, message.reactions, message.isOutgoing, delegate)
|
||||
binding.emojiReactionsView.isVisible = true
|
||||
} else {
|
||||
binding.emojiReactionsView.isVisible = false
|
||||
}
|
||||
|
||||
// Populate content view
|
||||
binding.messageContentView.indexInAdapter = indexInAdapter
|
||||
binding.messageContentView.bind(
|
||||
@ -210,7 +233,7 @@ class VisibleMessageView : LinearLayout {
|
||||
searchQuery,
|
||||
message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false)
|
||||
)
|
||||
binding.messageContentView.delegate = contentViewDelegate
|
||||
binding.messageContentView.delegate = delegate
|
||||
onDoubleTap = { binding.messageContentView.onContentDoubleTap?.invoke() }
|
||||
}
|
||||
|
||||
@ -245,7 +268,7 @@ class VisibleMessageView : LinearLayout {
|
||||
}
|
||||
|
||||
private fun updateExpirationTimer(message: MessageRecord) {
|
||||
val container = binding.expirationTimerViewContainer
|
||||
val container = binding.messageInnerContainer
|
||||
val content = binding.messageContentView
|
||||
val expiration = binding.expirationTimerView
|
||||
val spacing = binding.messageContentSpacing
|
||||
@ -297,8 +320,8 @@ class VisibleMessageView : LinearLayout {
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing)
|
||||
val iconSize = toPx(24, context.resources)
|
||||
val left = binding.expirationTimerViewContainer.left + binding.messageContentView.right + spacing
|
||||
val top = height - (binding.expirationTimerViewContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2)
|
||||
val left = binding.messageInnerContainer.left + binding.messageContentView.right + spacing
|
||||
val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2)
|
||||
val right = left + iconSize
|
||||
val bottom = top + iconSize
|
||||
swipeToReplyIconRect.left = left
|
||||
@ -388,7 +411,7 @@ class VisibleMessageView : LinearLayout {
|
||||
} else {
|
||||
val newPressCallback = Runnable { onPress(event) }
|
||||
this.pressCallback = newPressCallback
|
||||
gestureHandler.postDelayed(newPressCallback, VisibleMessageView.maxDoubleTapInterval)
|
||||
gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval)
|
||||
}
|
||||
}
|
||||
resetPosition()
|
||||
|
@ -0,0 +1,15 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.messages
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
|
||||
interface VisibleMessageViewDelegate {
|
||||
|
||||
fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int)
|
||||
|
||||
fun scrollToMessageIfPossible(timestamp: Long)
|
||||
|
||||
fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean)
|
||||
|
||||
fun onReactionLongClicked(messageId: MessageId)
|
||||
|
||||
}
|
@ -35,7 +35,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
|
||||
private var progress = 0.0
|
||||
private var duration = 0L
|
||||
private var player: AudioSlidePlayer? = null
|
||||
var delegate: VoiceMessageViewDelegate? = null
|
||||
var delegate: VisibleMessageViewDelegate? = null
|
||||
var indexInAdapter = -1
|
||||
|
||||
// region Lifecycle
|
||||
@ -141,8 +141,3 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
interface VoiceMessageViewDelegate {
|
||||
|
||||
fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int)
|
||||
}
|
||||
|
@ -74,6 +74,8 @@ import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
@ -674,7 +676,7 @@ public class AttachmentDatabase extends Database {
|
||||
return new LinkedList<>();
|
||||
}
|
||||
|
||||
List<DatabaseAttachment> result = new LinkedList<>();
|
||||
Set<DatabaseAttachment> result = new TreeSet<>((o1, o2) -> o1.getAttachmentId().equals(o2.getAttachmentId()) ? 0 : 1);
|
||||
JSONArray array = new JSONArray(cursor.getString(cursor.getColumnIndexOrThrow(ATTACHMENT_JSON_ALIAS)));
|
||||
|
||||
for (int i=0;i<array.length();i++) {
|
||||
@ -703,7 +705,7 @@ public class AttachmentDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return new ArrayList<>(result);
|
||||
} else {
|
||||
int urlIndex = cursor.getColumnIndex(URL);
|
||||
return Collections.singletonList(new DatabaseAttachment(new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)),
|
||||
|
@ -0,0 +1,101 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.database.model.EmojiSearchData
|
||||
import org.thoughtcrime.securesms.util.CursorUtil
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Contains all info necessary for full-text search of emoji tags.
|
||||
*/
|
||||
class EmojiSearchDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
|
||||
|
||||
companion object {
|
||||
const val TABLE_NAME = "emoji_search"
|
||||
const val LABEL = "label"
|
||||
const val EMOJI = "emoji"
|
||||
const val CREATE_EMOJI_SEARCH_TABLE_COMMAND = "CREATE VIRTUAL TABLE $TABLE_NAME USING fts5($LABEL, $EMOJI UNINDEXED)"
|
||||
}
|
||||
|
||||
/**
|
||||
* @param query A search query. Doesn't need any special formatted -- it'll be sanitized.
|
||||
* @return A list of emoji that are related to the search term, ordered by relevance.
|
||||
*/
|
||||
fun query(originalQuery: String, originalLimit: Int): List<String> {
|
||||
val query: String = originalQuery.trim()
|
||||
|
||||
if (query.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val limit: Int = max(originalLimit, 100)
|
||||
val entries = mutableListOf<Entry>()
|
||||
|
||||
readableDatabase.query(TABLE_NAME, arrayOf(LABEL, EMOJI), "$LABEL LIKE ?", arrayOf("%$query%"), null, null, null, "$limit")
|
||||
.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
entries += Entry(
|
||||
label = CursorUtil.requireString(cursor, LABEL),
|
||||
emoji = CursorUtil.requireString(cursor, EMOJI)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
.sortedWith { lhs, rhs ->
|
||||
similarityScore(query, lhs.label) - similarityScore(query, rhs.label)
|
||||
}
|
||||
.distinctBy { it.emoji }
|
||||
.take(originalLimit)
|
||||
.map { it.emoji }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the content of the current search index and replaces it with the new one.
|
||||
*/
|
||||
fun setSearchIndex(searchIndex: List<EmojiSearchData>) {
|
||||
writableDatabase.beginTransaction()
|
||||
writableDatabase.delete(TABLE_NAME, null, null)
|
||||
|
||||
for (searchData in searchIndex) {
|
||||
for (label in searchData.tags) {
|
||||
val values = contentValuesOf(
|
||||
LABEL to label,
|
||||
EMOJI to searchData.emoji
|
||||
)
|
||||
writableDatabase.insert(TABLE_NAME, null, values)
|
||||
}
|
||||
}
|
||||
writableDatabase.setTransactionSuccessful()
|
||||
writableDatabase.endTransaction()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks how "similar" a match is to the original search term.
|
||||
* A lower score means more similar, with 0 being a perfect match.
|
||||
*
|
||||
* We know that the `searchTerm` must be a substring of the `match`.
|
||||
* We determine similarity by how many letters appear before or after the `searchTerm` in the `match`.
|
||||
* We give letters that come before the term a bigger weight than those that come after as a way to prefer matches that are prefixed by the `searchTerm`.
|
||||
*/
|
||||
private fun similarityScore(searchTerm: String, match: String): Int {
|
||||
if (searchTerm == match) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val startIndex = match.indexOf(searchTerm)
|
||||
|
||||
val prefixCount = startIndex
|
||||
val suffixCount = match.length - (startIndex + searchTerm.length)
|
||||
|
||||
val prefixRankWeight = 1.5f
|
||||
val suffixRankWeight = 1f
|
||||
|
||||
return ((prefixCount * prefixRankWeight) + (suffixCount * suffixRankWeight)).roundToInt()
|
||||
}
|
||||
|
||||
private data class Entry(val label: String, val emoji: String)
|
||||
}
|
@ -14,6 +14,7 @@ import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.GroupRecord;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
@ -441,7 +442,15 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
|
||||
}
|
||||
}
|
||||
|
||||
public static class Reader implements Closeable {
|
||||
public void migrateEncodedGroup(@NotNull String legacyEncodedGroupId, @NotNull String newEncodedGroupId) {
|
||||
String query = GROUP_ID+" = ?";
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(GROUP_ID, newEncodedGroupId);
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.update(TABLE_NAME, contentValues, query, new String[]{legacyEncodedGroupId});
|
||||
}
|
||||
|
||||
public static class Reader implements Closeable {
|
||||
|
||||
private final Cursor cursor;
|
||||
|
||||
|
@ -59,9 +59,9 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
private val token = "token"
|
||||
@JvmStatic val createOpenGroupAuthTokenTableCommand = "CREATE TABLE $openGroupAuthTokenTable ($server TEXT PRIMARY KEY, $token TEXT);"
|
||||
// Last message server IDs
|
||||
private val lastMessageServerIDTable = "loki_api_last_message_server_id_cache"
|
||||
private const val lastMessageServerIDTable = "loki_api_last_message_server_id_cache"
|
||||
private val lastMessageServerIDTableIndex = "loki_api_last_message_server_id_cache_index"
|
||||
private val lastMessageServerID = "last_message_server_id"
|
||||
private const val lastMessageServerID = "last_message_server_id"
|
||||
@JvmStatic val createLastMessageServerIDTableCommand = "CREATE TABLE $lastMessageServerIDTable ($lastMessageServerIDTableIndex STRING PRIMARY KEY, $lastMessageServerID INTEGER DEFAULT 0);"
|
||||
// Last deletion server IDs
|
||||
private val lastDeletionServerIDTable = "loki_api_last_deletion_server_id_cache"
|
||||
@ -153,6 +153,9 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
"$requestSignature STRING NULLABLE DEFAULT NULL, $authorizationSignature STRING NULLABLE DEFAULT NULL, PRIMARY KEY ($masterPublicKey, $slavePublicKey));"
|
||||
private val sessionRequestTimestampCache = "session_request_timestamp_cache"
|
||||
@JvmStatic val createSessionRequestTimestampCacheCommand = "CREATE TABLE $sessionRequestTimestampCache ($publicKey STRING PRIMARY KEY, $timestamp STRING);"
|
||||
|
||||
const val RESET_SEQ_NO = "UPDATE $lastMessageServerIDTable SET $lastMessageServerID = 0;"
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
@ -377,18 +380,29 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
database.delete(lastDeletionServerIDTable, "$lastDeletionServerIDTableIndex = ?", wrap(index))
|
||||
}
|
||||
|
||||
fun removeLastDeletionServerID(group: Long, server: String) {
|
||||
override fun migrateLegacyOpenGroup(legacyServerId: String, newServerId: String) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val index = "$server.$group"
|
||||
database.delete(lastDeletionServerIDTable,"$lastDeletionServerIDTableIndex = ?", wrap(index))
|
||||
}
|
||||
|
||||
fun getUserCount(group: Long, server: String): Int? {
|
||||
val database = databaseHelper.readableDatabase
|
||||
val index = "$server.$group"
|
||||
return database.get(userCountTable, "$publicChatID = ?", wrap(index)) { cursor ->
|
||||
cursor.getInt(userCount)
|
||||
}?.toInt()
|
||||
database.beginTransaction()
|
||||
val authRow = wrap(mapOf(server to newServerId))
|
||||
database.update(openGroupAuthTokenTable, authRow, "$server = ?", wrap(legacyServerId))
|
||||
val lastMessageRow = wrap(mapOf(lastMessageServerIDTableIndex to newServerId))
|
||||
database.update(lastMessageServerIDTable, lastMessageRow,
|
||||
"$lastMessageServerIDTableIndex = ?", wrap(legacyServerId))
|
||||
val lastDeletionRow = wrap(mapOf(lastDeletionServerIDTableIndex to newServerId))
|
||||
database.update(
|
||||
lastDeletionServerIDTable, lastDeletionRow,
|
||||
"$lastDeletionServerIDTableIndex = ?", wrap(legacyServerId))
|
||||
val userCountRow = wrap(mapOf(publicChatID to newServerId))
|
||||
database.update(
|
||||
userCountTable, userCountRow,
|
||||
"$publicChatID = ?", wrap(legacyServerId)
|
||||
)
|
||||
val publicKeyRow = wrap(mapOf(server to newServerId))
|
||||
database.update(
|
||||
openGroupPublicKeyTable, publicKeyRow,
|
||||
"$server = ?", wrap(legacyServerId)
|
||||
)
|
||||
database.endTransaction()
|
||||
}
|
||||
|
||||
fun getUserCount(room: String, server: String): Int? {
|
||||
@ -399,13 +413,6 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
}?.toInt()
|
||||
}
|
||||
|
||||
override fun setUserCount(group: Long, server: String, newValue: Int) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val index = "$server.$group"
|
||||
val row = wrap(mapOf( publicChatID to index, Companion.userCount to newValue.toString() ))
|
||||
database.insertOrUpdate(userCountTable, row, "$publicChatID = ?", wrap(index))
|
||||
}
|
||||
|
||||
override fun setUserCount(room: String, server: String, newValue: Int) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val index = "$server.$room"
|
||||
|
@ -177,4 +177,12 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
||||
val database = databaseHelper.writableDatabase
|
||||
database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
|
||||
}
|
||||
|
||||
fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val contentValues = ContentValues(1)
|
||||
contentValues.put(threadID, newThreadId)
|
||||
database.update(messageThreadMappingTable, contentValues, "$threadID = ?", arrayOf(legacyThreadId.toString()))
|
||||
}
|
||||
|
||||
}
|
@ -7,15 +7,16 @@ import android.text.TextUtils;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.Document;
|
||||
import org.session.libsession.utilities.IdentityKeyMismatch;
|
||||
import org.session.libsession.utilities.IdentityKeyMismatchList;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.crypto.IdentityKey;
|
||||
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsignal.utilities.JsonUtil;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
@ -44,6 +45,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
|
||||
public abstract void updateThreadId(long fromId, long toId);
|
||||
|
||||
public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException;
|
||||
|
||||
public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) {
|
||||
try {
|
||||
addToDocument(messageId, MISMATCHED_IDENTITIES,
|
||||
@ -64,6 +67,30 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
}
|
||||
}
|
||||
|
||||
void updateReactionsUnread(SQLiteDatabase db, long messageId, boolean hasReactions, boolean isRemoval) {
|
||||
try {
|
||||
MessageRecord message = getMessageRecord(messageId);
|
||||
ContentValues values = new ContentValues();
|
||||
|
||||
if (!hasReactions) {
|
||||
values.put(REACTIONS_UNREAD, 0);
|
||||
} else if (!isRemoval) {
|
||||
values.put(REACTIONS_UNREAD, 1);
|
||||
}
|
||||
|
||||
if (message.isOutgoing() && hasReactions) {
|
||||
values.put(NOTIFIED, 0);
|
||||
}
|
||||
|
||||
if (values.size() > 0) {
|
||||
db.update(getTableName(), values, ID_WHERE, SqlUtil.buildArgs(messageId));
|
||||
}
|
||||
notifyConversationListeners(message.getThreadId());
|
||||
} catch (NoSuchMessageException e) {
|
||||
Log.w(TAG, "Failed to find message " + messageId);
|
||||
}
|
||||
}
|
||||
|
||||
protected <D extends Document<I>, I> void removeFromDocument(long messageId, String column, I object, Class<D> clazz) throws IOException {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.beginTransaction();
|
||||
@ -159,6 +186,15 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
}
|
||||
}
|
||||
|
||||
public void migrateThreadId(long oldThreadId, long newThreadId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
String where = THREAD_ID+" = ?";
|
||||
String[] args = new String[]{oldThreadId+""};
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(THREAD_ID, newThreadId);
|
||||
db.update(getTableName(), contentValues, where, args);
|
||||
}
|
||||
|
||||
public static class SyncMessageId {
|
||||
|
||||
private final Address address;
|
||||
|
@ -55,8 +55,6 @@ import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.ThreadUtils.queue
|
||||
import org.session.libsignal.utilities.guava.Optional
|
||||
import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase.InsertListener
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
@ -267,9 +265,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
private fun rawQuery(where: String, arguments: Array<String>?): Cursor {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.rawQuery(
|
||||
"SELECT " + MMS_PROJECTION.joinToString(",")+
|
||||
" FROM " + TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME +
|
||||
" ON (" + TABLE_NAME + "." + ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" +
|
||||
"SELECT " + MMS_PROJECTION.joinToString(",") + " FROM " + TABLE_NAME +
|
||||
" LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + " ON (" + TABLE_NAME + "." + ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" +
|
||||
" LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + " ON (" + TABLE_NAME + "." + ID + " = " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 1)" +
|
||||
" WHERE " + where + " GROUP BY " + TABLE_NAME + "." + ID, arguments
|
||||
)
|
||||
}
|
||||
@ -401,7 +399,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
|
||||
fun setMessagesRead(threadId: Long): List<MarkedMessageInfo> {
|
||||
return setMessagesRead(
|
||||
THREAD_ID + " = ? AND " + READ + " = 0",
|
||||
THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)",
|
||||
arrayOf(threadId.toString())
|
||||
)
|
||||
}
|
||||
@ -440,6 +438,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
}
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(READ, 1)
|
||||
contentValues.put(REACTIONS_UNREAD, 0)
|
||||
database.update(TABLE_NAME, contentValues, where, arguments)
|
||||
database.setTransactionSuccessful()
|
||||
} finally {
|
||||
@ -1006,6 +1005,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
notifyConversationListListeners()
|
||||
}
|
||||
|
||||
@Throws(NoSuchMessageException::class)
|
||||
override fun getMessageRecord(messageId: Long): MessageRecord {
|
||||
rawQuery(RAW_ID_WHERE, arrayOf("$messageId")).use { cursor ->
|
||||
return Reader(cursor).next ?: throw NoSuchMessageException("No message for ID: $messageId")
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteThread(threadId: Long) {
|
||||
deleteThreads(setOf(threadId))
|
||||
}
|
||||
@ -1266,7 +1272,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
message.outgoingQuote!!.missing,
|
||||
SlideDeck(context, message.outgoingQuote!!.attachments!!)
|
||||
) else null,
|
||||
message.sharedContacts, message.linkPreviews, false
|
||||
message.sharedContacts, message.linkPreviews, listOf(), false
|
||||
)
|
||||
}
|
||||
|
||||
@ -1391,12 +1397,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
.toList()
|
||||
)
|
||||
val quote = getQuote(cursor)
|
||||
val reactions = get(context).reactionDatabase().getReactions(cursor)
|
||||
return MediaMmsMessageRecord(
|
||||
id, recipient, recipient,
|
||||
addressDeviceId, dateSent, dateReceived, deliveryReceiptCount,
|
||||
threadId, body, slideDeck!!, partCount, box, mismatches,
|
||||
networkFailures, subscriptionId, expiresIn, expireStarted,
|
||||
readReceiptCount, quote, contacts, previews, unidentified
|
||||
readReceiptCount, quote, contacts, previews, reactions, unidentified
|
||||
)
|
||||
}
|
||||
|
||||
@ -1571,9 +1578,23 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
"'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " +
|
||||
"'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " +
|
||||
"'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID +
|
||||
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS
|
||||
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
|
||||
"json_group_array(json_object(" +
|
||||
"'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " +
|
||||
"'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " +
|
||||
"'" + ReactionDatabase.IS_MMS + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + ", " +
|
||||
"'" + ReactionDatabase.AUTHOR_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.AUTHOR_ID + ", " +
|
||||
"'" + ReactionDatabase.EMOJI + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.EMOJI + ", " +
|
||||
"'" + ReactionDatabase.SERVER_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SERVER_ID + ", " +
|
||||
"'" + ReactionDatabase.COUNT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.COUNT + ", " +
|
||||
"'" + ReactionDatabase.SORT_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SORT_ID + ", " +
|
||||
"'" + ReactionDatabase.DATE_SENT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + ", " +
|
||||
"'" + ReactionDatabase.DATE_RECEIVED + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_RECEIVED +
|
||||
")) AS " + ReactionDatabase.REACTION_JSON_ALIAS
|
||||
)
|
||||
private const val RAW_ID_WHERE: String = "$TABLE_NAME._id = ?"
|
||||
const val createMessageRequestResponseCommand: String = "ALTER TABLE $TABLE_NAME ADD COLUMN $MESSAGE_REQUEST_RESPONSE INTEGER DEFAULT 0;"
|
||||
const val CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $MESSAGE_REQUEST_RESPONSE INTEGER DEFAULT 0;"
|
||||
const val CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_UNREAD INTEGER DEFAULT 0;"
|
||||
const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;"
|
||||
}
|
||||
}
|
@ -21,6 +21,8 @@ public interface MmsSmsColumns {
|
||||
public static final String NOTIFIED = "notified";
|
||||
public static final String UNIDENTIFIED = "unidentified";
|
||||
public static final String MESSAGE_REQUEST_RESPONSE = "message_request_response";
|
||||
public static final String REACTIONS_UNREAD = "reactions_unread";
|
||||
public static final String REACTIONS_LAST_SEEN = "reactions_last_seen";
|
||||
|
||||
public static class Types {
|
||||
protected static final long TOTAL_MASK = 0xFFFFFFFF;
|
||||
|
@ -74,7 +74,8 @@ public class MmsSmsDatabase extends Database {
|
||||
MmsDatabase.QUOTE_MISSING,
|
||||
MmsDatabase.QUOTE_ATTACHMENT,
|
||||
MmsDatabase.SHARED_CONTACTS,
|
||||
MmsDatabase.LINK_PREVIEWS};
|
||||
MmsDatabase.LINK_PREVIEWS,
|
||||
ReactionDatabase.REACTION_JSON_ALIAS};
|
||||
|
||||
public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
@ -145,7 +146,7 @@ public class MmsSmsDatabase extends Database {
|
||||
|
||||
public Cursor getUnread() {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " ASC";
|
||||
String selection = MmsSmsColumns.READ + " = 0 AND " + MmsSmsColumns.NOTIFIED + " = 0";
|
||||
String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0";
|
||||
|
||||
return queryTables(PROJECTION, selection, order, null);
|
||||
}
|
||||
@ -219,6 +220,18 @@ public class MmsSmsDatabase extends Database {
|
||||
}
|
||||
|
||||
private Cursor queryTables(String[] projection, String selection, String order, String limit) {
|
||||
String reactionsColumn = "json_group_array(json_object(" +
|
||||
"'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " +
|
||||
"'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " +
|
||||
"'" + ReactionDatabase.IS_MMS + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + ", " +
|
||||
"'" + ReactionDatabase.AUTHOR_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.AUTHOR_ID + ", " +
|
||||
"'" + ReactionDatabase.EMOJI + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.EMOJI + ", " +
|
||||
"'" + ReactionDatabase.SERVER_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SERVER_ID + ", " +
|
||||
"'" + ReactionDatabase.COUNT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.COUNT + ", " +
|
||||
"'" + ReactionDatabase.SORT_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SORT_ID + ", " +
|
||||
"'" + ReactionDatabase.DATE_SENT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + ", " +
|
||||
"'" + ReactionDatabase.DATE_RECEIVED + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_RECEIVED +
|
||||
")) AS " + ReactionDatabase.REACTION_JSON_ALIAS;
|
||||
String[] mmsProjection = {MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
|
||||
MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
|
||||
MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AS " + MmsSmsColumns.ID,
|
||||
@ -248,6 +261,7 @@ public class MmsSmsDatabase extends Database {
|
||||
"'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " +
|
||||
"'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID +
|
||||
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
|
||||
reactionsColumn,
|
||||
SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID,
|
||||
SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE,
|
||||
MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT,
|
||||
@ -274,6 +288,7 @@ public class MmsSmsDatabase extends Database {
|
||||
+ " || '::' || " + SmsDatabase.DATE_SENT
|
||||
+ " AS " + MmsSmsColumns.UNIQUE_ROW_ID,
|
||||
"NULL AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
|
||||
reactionsColumn,
|
||||
SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID,
|
||||
SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE,
|
||||
MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT,
|
||||
@ -299,10 +314,14 @@ public class MmsSmsDatabase extends Database {
|
||||
mmsQueryBuilder.setDistinct(true);
|
||||
smsQueryBuilder.setDistinct(true);
|
||||
|
||||
smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME);
|
||||
mmsQueryBuilder.setTables(MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " +
|
||||
AttachmentDatabase.TABLE_NAME +
|
||||
" ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID);
|
||||
smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME +
|
||||
" LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME +
|
||||
" ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0");
|
||||
mmsQueryBuilder.setTables(MmsDatabase.TABLE_NAME +
|
||||
" LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME +
|
||||
" ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID +
|
||||
" LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME +
|
||||
" ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 1");
|
||||
|
||||
|
||||
Set<String> mmsColumnsPresent = new HashSet<>();
|
||||
@ -362,6 +381,16 @@ public class MmsSmsDatabase extends Database {
|
||||
mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT);
|
||||
mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS);
|
||||
mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS);
|
||||
mmsColumnsPresent.add(ReactionDatabase.MESSAGE_ID);
|
||||
mmsColumnsPresent.add(ReactionDatabase.IS_MMS);
|
||||
mmsColumnsPresent.add(ReactionDatabase.AUTHOR_ID);
|
||||
mmsColumnsPresent.add(ReactionDatabase.EMOJI);
|
||||
mmsColumnsPresent.add(ReactionDatabase.SERVER_ID);
|
||||
mmsColumnsPresent.add(ReactionDatabase.COUNT);
|
||||
mmsColumnsPresent.add(ReactionDatabase.SORT_ID);
|
||||
mmsColumnsPresent.add(ReactionDatabase.DATE_SENT);
|
||||
mmsColumnsPresent.add(ReactionDatabase.DATE_RECEIVED);
|
||||
mmsColumnsPresent.add(ReactionDatabase.REACTION_JSON_ALIAS);
|
||||
|
||||
Set<String> smsColumnsPresent = new HashSet<>();
|
||||
smsColumnsPresent.add(MmsSmsColumns.ID);
|
||||
@ -383,11 +412,22 @@ public class MmsSmsDatabase extends Database {
|
||||
smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED);
|
||||
smsColumnsPresent.add(SmsDatabase.STATUS);
|
||||
smsColumnsPresent.add(SmsDatabase.UNIDENTIFIED);
|
||||
smsColumnsPresent.add(ReactionDatabase.ROW_ID);
|
||||
smsColumnsPresent.add(ReactionDatabase.MESSAGE_ID);
|
||||
smsColumnsPresent.add(ReactionDatabase.IS_MMS);
|
||||
smsColumnsPresent.add(ReactionDatabase.AUTHOR_ID);
|
||||
smsColumnsPresent.add(ReactionDatabase.EMOJI);
|
||||
smsColumnsPresent.add(ReactionDatabase.SERVER_ID);
|
||||
smsColumnsPresent.add(ReactionDatabase.COUNT);
|
||||
smsColumnsPresent.add(ReactionDatabase.SORT_ID);
|
||||
smsColumnsPresent.add(ReactionDatabase.DATE_SENT);
|
||||
smsColumnsPresent.add(ReactionDatabase.DATE_RECEIVED);
|
||||
smsColumnsPresent.add(ReactionDatabase.REACTION_JSON_ALIAS);
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 4, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null);
|
||||
String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 5, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null);
|
||||
@SuppressWarnings("deprecation")
|
||||
String smsSubQuery = smsQueryBuilder.buildUnionSubQuery(TRANSPORT, smsProjection, smsColumnsPresent, 4, SMS_TRANSPORT, selection, null, null, null);
|
||||
String smsSubQuery = smsQueryBuilder.buildUnionSubQuery(TRANSPORT, smsProjection, smsColumnsPresent, 5, SMS_TRANSPORT, selection, null, SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID, null);
|
||||
|
||||
SQLiteQueryBuilder unionQueryBuilder = new SQLiteQueryBuilder();
|
||||
String unionQuery = unionQueryBuilder.buildUnionQuery(new String[] {smsSubQuery, mmsSubQuery}, order, limit);
|
||||
|
@ -0,0 +1,258 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.session.libsignal.utilities.JsonUtil.SaneJSONObject
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.util.CursorUtil
|
||||
|
||||
/**
|
||||
* Store reactions on messages.
|
||||
*/
|
||||
class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
|
||||
|
||||
companion object {
|
||||
const val TABLE_NAME = "reaction"
|
||||
const val REACTION_JSON_ALIAS = "reaction_json"
|
||||
const val ROW_ID = "reaction_id"
|
||||
const val MESSAGE_ID = "message_id"
|
||||
const val IS_MMS = "is_mms"
|
||||
const val AUTHOR_ID = "author_id"
|
||||
const val SERVER_ID = "server_id"
|
||||
const val COUNT = "count"
|
||||
const val SORT_ID = "sort_id"
|
||||
const val EMOJI = "emoji"
|
||||
const val DATE_SENT = "reaction_date_sent"
|
||||
const val DATE_RECEIVED = "reaction_date_received"
|
||||
|
||||
@JvmField
|
||||
val CREATE_REACTION_TABLE_COMMAND = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ROW_ID INTEGER PRIMARY KEY,
|
||||
$MESSAGE_ID INTEGER NOT NULL,
|
||||
$IS_MMS INTEGER NOT NULL,
|
||||
$AUTHOR_ID INTEGER NOT NULL REFERENCES ${RecipientDatabase.TABLE_NAME} (${RecipientDatabase.ID}) ON DELETE CASCADE,
|
||||
$EMOJI TEXT NOT NULL,
|
||||
$SERVER_ID TEXT NOT NULL,
|
||||
$COUNT INTEGER NOT NULL,
|
||||
$SORT_ID INTEGER NOT NULL,
|
||||
$DATE_SENT INTEGER NOT NULL,
|
||||
$DATE_RECEIVED INTEGER NOT NULL,
|
||||
UNIQUE($MESSAGE_ID, $IS_MMS, $EMOJI, $AUTHOR_ID) ON CONFLICT REPLACE
|
||||
)
|
||||
""".trimIndent()
|
||||
|
||||
@JvmField
|
||||
val CREATE_REACTION_TRIGGERS = arrayOf(
|
||||
"""
|
||||
CREATE TRIGGER reactions_sms_delete AFTER DELETE ON ${SmsDatabase.TABLE_NAME}
|
||||
BEGIN
|
||||
DELETE FROM $TABLE_NAME WHERE $MESSAGE_ID = old.${MmsSmsColumns.ID} AND $IS_MMS = 0;
|
||||
END
|
||||
""",
|
||||
"""
|
||||
CREATE TRIGGER reactions_mms_delete AFTER DELETE ON ${MmsDatabase.TABLE_NAME}
|
||||
BEGIN
|
||||
DELETE FROM $TABLE_NAME WHERE $MESSAGE_ID = old.${MmsSmsColumns.ID} AND $IS_MMS = 1;
|
||||
END
|
||||
"""
|
||||
)
|
||||
|
||||
private fun readReaction(cursor: Cursor): ReactionRecord {
|
||||
return ReactionRecord(
|
||||
messageId = CursorUtil.requireLong(cursor, MESSAGE_ID),
|
||||
isMms = CursorUtil.requireInt(cursor, IS_MMS) == 1,
|
||||
emoji = CursorUtil.requireString(cursor, EMOJI),
|
||||
author = CursorUtil.requireString(cursor, AUTHOR_ID),
|
||||
serverId = CursorUtil.requireString(cursor, SERVER_ID),
|
||||
count = CursorUtil.requireLong(cursor, COUNT),
|
||||
sortId = CursorUtil.requireLong(cursor, SORT_ID),
|
||||
dateSent = CursorUtil.requireLong(cursor, DATE_SENT),
|
||||
dateReceived = CursorUtil.requireLong(cursor, DATE_RECEIVED)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getReactions(messageId: MessageId): List<ReactionRecord> {
|
||||
val query = "$MESSAGE_ID = ? AND $IS_MMS = ? ORDER BY $SORT_ID"
|
||||
val args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}")
|
||||
|
||||
val reactions: MutableList<ReactionRecord> = mutableListOf()
|
||||
|
||||
readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
reactions += readReaction(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
return reactions
|
||||
}
|
||||
|
||||
fun addReaction(messageId: MessageId, reaction: ReactionRecord) {
|
||||
|
||||
writableDatabase.beginTransaction()
|
||||
try {
|
||||
val values = ContentValues().apply {
|
||||
put(MESSAGE_ID, messageId.id)
|
||||
put(IS_MMS, if (messageId.mms) 1 else 0)
|
||||
put(EMOJI, reaction.emoji)
|
||||
put(AUTHOR_ID, reaction.author)
|
||||
put(SERVER_ID, reaction.serverId)
|
||||
put(COUNT, reaction.count)
|
||||
put(SORT_ID, reaction.sortId)
|
||||
put(DATE_SENT, reaction.dateSent)
|
||||
put(DATE_RECEIVED, reaction.dateReceived)
|
||||
}
|
||||
|
||||
writableDatabase.insert(TABLE_NAME, null, values)
|
||||
|
||||
if (messageId.mms) {
|
||||
DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), false)
|
||||
} else {
|
||||
DatabaseComponent.get(context).smsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), false)
|
||||
}
|
||||
|
||||
writableDatabase.setTransactionSuccessful()
|
||||
} finally {
|
||||
writableDatabase.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteReaction(emoji: String, messageId: MessageId, author: String) {
|
||||
deleteReactions(
|
||||
messageId = messageId,
|
||||
query = "$MESSAGE_ID = ? AND $IS_MMS = ? AND $EMOJI = ? AND $AUTHOR_ID = ?",
|
||||
args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}", emoji, author)
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteEmojiReactions(emoji: String, messageId: MessageId) {
|
||||
deleteReactions(
|
||||
messageId = messageId,
|
||||
query = "$MESSAGE_ID = ? AND $IS_MMS = ? AND $EMOJI = ?",
|
||||
args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}", emoji)
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteMessageReactions(messageId: MessageId) {
|
||||
deleteReactions(
|
||||
messageId = messageId,
|
||||
query = "$MESSAGE_ID = ? AND $IS_MMS = ?",
|
||||
args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}")
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteReactions(messageId: MessageId, query: String, args: Array<String>) {
|
||||
writableDatabase.beginTransaction()
|
||||
try {
|
||||
writableDatabase.delete(TABLE_NAME, query, args)
|
||||
|
||||
if (messageId.mms) {
|
||||
DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), true)
|
||||
} else {
|
||||
DatabaseComponent.get(context).smsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), true)
|
||||
}
|
||||
|
||||
writableDatabase.setTransactionSuccessful()
|
||||
} finally {
|
||||
writableDatabase.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasReactions(messageId: MessageId): Boolean {
|
||||
val query = "$MESSAGE_ID = ? AND $IS_MMS = ?"
|
||||
val args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}")
|
||||
|
||||
readableDatabase.query(TABLE_NAME, arrayOf(MESSAGE_ID), query, args, null, null, null).use { cursor ->
|
||||
return cursor.moveToFirst()
|
||||
}
|
||||
}
|
||||
|
||||
fun getReactions(cursor: Cursor): List<ReactionRecord> {
|
||||
return try {
|
||||
if (cursor.getColumnIndex(REACTION_JSON_ALIAS) != -1) {
|
||||
if (cursor.isNull(cursor.getColumnIndexOrThrow(REACTION_JSON_ALIAS))) {
|
||||
return listOf()
|
||||
}
|
||||
val result = mutableSetOf<ReactionRecord>()
|
||||
val array = JSONArray(cursor.getString(cursor.getColumnIndexOrThrow(REACTION_JSON_ALIAS)))
|
||||
for (i in 0 until array.length()) {
|
||||
val `object` = SaneJSONObject(array.getJSONObject(i))
|
||||
if (!`object`.isNull(ROW_ID)) {
|
||||
result.add(
|
||||
ReactionRecord(
|
||||
`object`.getLong(ROW_ID),
|
||||
`object`.getLong(MESSAGE_ID),
|
||||
`object`.getInt(IS_MMS) == 1,
|
||||
`object`.getString(AUTHOR_ID),
|
||||
`object`.getString(EMOJI),
|
||||
`object`.getString(SERVER_ID),
|
||||
`object`.getLong(COUNT),
|
||||
`object`.getLong(SORT_ID),
|
||||
`object`.getLong(DATE_SENT),
|
||||
`object`.getLong(DATE_RECEIVED)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
result.sortedBy { it.dateSent }
|
||||
} else {
|
||||
listOf(
|
||||
ReactionRecord(
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_ID)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(IS_MMS)) == 1,
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(AUTHOR_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(EMOJI)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(SERVER_ID)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(COUNT)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(SORT_ID)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(DATE_SENT)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(DATE_RECEIVED))
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: JSONException) {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getReactionFor(timestamp: Long, sender: String): ReactionRecord? {
|
||||
val query = "$DATE_SENT = ? AND $AUTHOR_ID = ?"
|
||||
val args = arrayOf("$timestamp", sender)
|
||||
|
||||
readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor ->
|
||||
return if (cursor.moveToFirst()) readReaction(cursor) else null
|
||||
}
|
||||
}
|
||||
|
||||
fun updateReaction(reaction: ReactionRecord) {
|
||||
writableDatabase.beginTransaction()
|
||||
try {
|
||||
val values = ContentValues().apply {
|
||||
put(EMOJI, reaction.emoji)
|
||||
put(AUTHOR_ID, reaction.author)
|
||||
put(SERVER_ID, reaction.serverId)
|
||||
put(COUNT, reaction.count)
|
||||
put(SORT_ID, reaction.sortId)
|
||||
put(DATE_SENT, reaction.dateSent)
|
||||
put(DATE_RECEIVED, reaction.dateReceived)
|
||||
}
|
||||
|
||||
val query = "$ROW_ID = ?"
|
||||
val args = arrayOf("${reaction.id}")
|
||||
writableDatabase.update(TABLE_NAME, values, query, args)
|
||||
|
||||
writableDatabase.setTransactionSuccessful()
|
||||
} finally {
|
||||
writableDatabase.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -34,7 +34,7 @@ public class RecipientDatabase extends Database {
|
||||
private static final String TAG = RecipientDatabase.class.getSimpleName();
|
||||
|
||||
static final String TABLE_NAME = "recipient_preferences";
|
||||
private static final String ID = "_id";
|
||||
static final String ID = "_id";
|
||||
public static final String ADDRESS = "recipient_ids";
|
||||
static final String BLOCK = "block";
|
||||
static final String APPROVED = "approved";
|
||||
|
@ -23,6 +23,9 @@ import android.database.Cursor;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
@ -43,11 +46,13 @@ import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.utilities.guava.Optional;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -98,9 +103,24 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
PROTOCOL, READ, STATUS, TYPE,
|
||||
REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT,
|
||||
MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED,
|
||||
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED
|
||||
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED,
|
||||
"json_group_array(json_object(" +
|
||||
"'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " +
|
||||
"'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " +
|
||||
"'" + ReactionDatabase.IS_MMS + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + ", " +
|
||||
"'" + ReactionDatabase.AUTHOR_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.AUTHOR_ID + ", " +
|
||||
"'" + ReactionDatabase.EMOJI + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.EMOJI + ", " +
|
||||
"'" + ReactionDatabase.SERVER_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SERVER_ID + ", " +
|
||||
"'" + ReactionDatabase.COUNT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.COUNT + ", " +
|
||||
"'" + ReactionDatabase.SORT_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SORT_ID + ", " +
|
||||
"'" + ReactionDatabase.DATE_SENT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + ", " +
|
||||
"'" + ReactionDatabase.DATE_RECEIVED + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_RECEIVED +
|
||||
")) AS " + ReactionDatabase.REACTION_JSON_ALIAS
|
||||
};
|
||||
|
||||
public static String CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " +
|
||||
"ADD COLUMN " + REACTIONS_UNREAD + " INTEGER DEFAULT 0;";
|
||||
|
||||
private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache();
|
||||
private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache();
|
||||
|
||||
@ -294,7 +314,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
}
|
||||
|
||||
public List<MarkedMessageInfo> setMessagesRead(long threadId) {
|
||||
return setMessagesRead(THREAD_ID + " = ? AND " + READ + " = 0", new String[] {String.valueOf(threadId)});
|
||||
return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", new String[] {String.valueOf(threadId)});
|
||||
}
|
||||
|
||||
public List<MarkedMessageInfo> setAllMessagesRead() {
|
||||
@ -321,6 +341,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(READ, 1);
|
||||
contentValues.put(REACTIONS_UNREAD, 0);
|
||||
|
||||
database.update(TABLE_NAME, contentValues, where, arguments);
|
||||
database.setTransactionSuccessful();
|
||||
@ -533,15 +554,21 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
return database.rawQuery("SELECT " + Util.join(MESSAGE_PROJECTION, ",") +
|
||||
" FROM " + SmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME +
|
||||
" ON (" + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " = " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0)" +
|
||||
" WHERE " + where + " GROUP BY " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID, arguments);
|
||||
}
|
||||
|
||||
public Cursor getExpirationStartedMessages() {
|
||||
String where = EXPIRE_STARTED + " > 0";
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
return db.query(TABLE_NAME, MESSAGE_PROJECTION, where, null, null, null, null);
|
||||
return rawQuery(where, null);
|
||||
}
|
||||
|
||||
public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[]{messageId + ""}, null, null, null);
|
||||
Cursor cursor = rawQuery(ID_WHERE, new String[]{messageId + ""});
|
||||
Reader reader = new Reader(cursor);
|
||||
SmsMessageRecord record = reader.getNext();
|
||||
|
||||
@ -580,6 +607,11 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException {
|
||||
return getMessage(messageId);
|
||||
}
|
||||
|
||||
private boolean isDuplicate(IncomingTextMessage message, long threadId) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?",
|
||||
@ -704,7 +736,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
0, message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(),
|
||||
threadId, 0, new LinkedList<IdentityKeyMismatch>(),
|
||||
message.getExpiresIn(),
|
||||
System.currentTimeMillis(), 0, false);
|
||||
System.currentTimeMillis(), 0, false, Collections.emptyList());
|
||||
}
|
||||
}
|
||||
|
||||
@ -752,12 +784,13 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
|
||||
List<IdentityKeyMismatch> mismatches = getMismatches(mismatchDocument);
|
||||
Recipient recipient = Recipient.from(context, address, true);
|
||||
List<ReactionRecord> reactions = DatabaseComponent.get(context).reactionDatabase().getReactions(cursor);
|
||||
|
||||
return new SmsMessageRecord(messageId, body, recipient,
|
||||
recipient,
|
||||
dateSent, dateReceived, deliveryReceiptCount, type,
|
||||
threadId, status, mismatches,
|
||||
expiresIn, expireStarted, readReceiptCount, unidentified);
|
||||
expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
|
||||
}
|
||||
|
||||
private List<IdentityKeyMismatch> getMismatches(String document) {
|
||||
|
@ -12,6 +12,7 @@ import org.session.libsession.messaging.jobs.Job
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
import org.session.libsession.messaging.jobs.MessageReceiveJob
|
||||
import org.session.libsession.messaging.jobs.MessageSendJob
|
||||
import org.session.libsession.messaging.messages.Message
|
||||
import org.session.libsession.messaging.messages.control.ConfigurationMessage
|
||||
import org.session.libsession.messaging.messages.control.MessageRequestResponse
|
||||
import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage
|
||||
@ -22,6 +23,7 @@ import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessag
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
|
||||
import org.session.libsession.messaging.messages.visible.Attachment
|
||||
import org.session.libsession.messaging.messages.visible.Reaction
|
||||
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||
import org.session.libsession.messaging.open_groups.GroupMember
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
@ -49,6 +51,8 @@ import org.session.libsignal.utilities.KeyHelper
|
||||
import org.session.libsignal.utilities.guava.Optional
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
@ -567,9 +571,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
OpenGroupManager.addOpenGroup(urlAsString, context)
|
||||
}
|
||||
|
||||
override fun onOpenGroupAdded(urlAsString: String) {
|
||||
val server = OpenGroup.getServer(urlAsString)
|
||||
OpenGroupManager.restartPollerForServer(server.toString().removeSuffix("/"))
|
||||
override fun onOpenGroupAdded(server: String) {
|
||||
OpenGroupManager.restartPollerForServer(server.removeSuffix("/"))
|
||||
}
|
||||
|
||||
override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean {
|
||||
@ -888,4 +891,57 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
db.addBlindedIdMapping(mapping)
|
||||
return mapping
|
||||
}
|
||||
|
||||
override fun addReaction(reaction: Reaction) {
|
||||
val timestamp = reaction.timestamp
|
||||
val localId = reaction.localId
|
||||
val isMms = reaction.isMms
|
||||
val messageId = if (localId != null && localId > 0 && isMms != null) {
|
||||
MessageId(localId, isMms)
|
||||
} else if (timestamp != null && timestamp > 0) {
|
||||
val messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageForTimestamp(timestamp) ?: return
|
||||
MessageId(messageRecord.id, messageRecord.isMms)
|
||||
} else return
|
||||
DatabaseComponent.get(context).reactionDatabase().addReaction(
|
||||
messageId,
|
||||
ReactionRecord(
|
||||
messageId = messageId.id,
|
||||
isMms = messageId.mms,
|
||||
author = reaction.publicKey!!,
|
||||
emoji = reaction.emoji!!,
|
||||
serverId = reaction.serverId!!,
|
||||
count = reaction.count!!,
|
||||
sortId = reaction.index!!,
|
||||
dateSent = reaction.dateSent!!,
|
||||
dateReceived = reaction.dateReceived!!
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun removeReaction(emoji: String, messageTimestamp: Long, author: String) {
|
||||
val messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageForTimestamp(messageTimestamp) ?: return
|
||||
val messageId = MessageId(messageRecord.id, messageRecord.isMms)
|
||||
DatabaseComponent.get(context).reactionDatabase().deleteReaction(emoji, messageId, author)
|
||||
}
|
||||
|
||||
override fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) {
|
||||
val database = DatabaseComponent.get(context).reactionDatabase()
|
||||
var reaction = database.getReactionFor(message.sentTimestamp!!, sender) ?: return
|
||||
if (openGroupSentTimestamp != -1L) {
|
||||
addReceivedMessageTimestamp(openGroupSentTimestamp)
|
||||
reaction = reaction.copy(dateSent = openGroupSentTimestamp)
|
||||
}
|
||||
message.serverHash?.let {
|
||||
reaction = reaction.copy(serverId = it)
|
||||
}
|
||||
message.openGroupServerMessageID?.let {
|
||||
reaction = reaction.copy(serverId = "$it")
|
||||
}
|
||||
database.updateReaction(reaction)
|
||||
}
|
||||
|
||||
override fun deleteReactions(messageId: Long, mms: Boolean) {
|
||||
DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms))
|
||||
}
|
||||
|
||||
}
|
@ -34,6 +34,7 @@ import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.Contact;
|
||||
import org.session.libsession.utilities.DelimiterUtil;
|
||||
@ -56,12 +57,15 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupMigrator;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
||||
import org.thoughtcrime.securesms.util.SessionMetaProtocol;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@ -765,6 +769,88 @@ public class ThreadDatabase extends Database {
|
||||
return query;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public List<ThreadRecord> getHttpOxenOpenGroups() {
|
||||
String where = TABLE_NAME+"."+ADDRESS+" LIKE ?";
|
||||
String selection = OpenGroupMigrator.HTTP_PREFIX+OpenGroupMigrator.OPEN_GET_SESSION_TRAILING_DOT_ENCODED +"%";
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = createQuery(where, 0);
|
||||
Cursor cursor = db.rawQuery(query, new String[]{selection});
|
||||
|
||||
if (cursor == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<ThreadRecord> threads = new ArrayList<>();
|
||||
try {
|
||||
Reader reader = readerFor(cursor);
|
||||
ThreadRecord record;
|
||||
while ((record = reader.getNext()) != null) {
|
||||
threads.add(record);
|
||||
}
|
||||
} finally {
|
||||
cursor.close();
|
||||
}
|
||||
return threads;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public List<ThreadRecord> getLegacyOxenOpenGroups() {
|
||||
String where = TABLE_NAME+"."+ADDRESS+" LIKE ?";
|
||||
String selection = OpenGroupMigrator.LEGACY_GROUP_ENCODED_ID+"%";
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = createQuery(where, 0);
|
||||
Cursor cursor = db.rawQuery(query, new String[]{selection});
|
||||
|
||||
if (cursor == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<ThreadRecord> threads = new ArrayList<>();
|
||||
try {
|
||||
Reader reader = readerFor(cursor);
|
||||
ThreadRecord record;
|
||||
while ((record = reader.getNext()) != null) {
|
||||
threads.add(record);
|
||||
}
|
||||
} finally {
|
||||
cursor.close();
|
||||
}
|
||||
return threads;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public List<ThreadRecord> getHttpsOxenOpenGroups() {
|
||||
String where = TABLE_NAME+"."+ADDRESS+" LIKE ?";
|
||||
String selection = OpenGroupMigrator.NEW_GROUP_ENCODED_ID+"%";
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = createQuery(where, 0);
|
||||
Cursor cursor = db.rawQuery(query, new String[]{selection});
|
||||
if (cursor == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<ThreadRecord> threads = new ArrayList<>();
|
||||
try {
|
||||
Reader reader = readerFor(cursor);
|
||||
ThreadRecord record;
|
||||
while ((record = reader.getNext()) != null) {
|
||||
threads.add(record);
|
||||
}
|
||||
} finally {
|
||||
cursor.close();
|
||||
}
|
||||
return threads;
|
||||
}
|
||||
|
||||
public void migrateEncodedGroup(long threadId, @NotNull String newEncodedGroupId) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(ADDRESS, newEncodedGroupId);
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
|
||||
}
|
||||
|
||||
public void notifyThreadUpdated(long threadId) {
|
||||
notifyConversationListeners(threadId);
|
||||
}
|
||||
|
||||
public interface ProgressListener {
|
||||
void onProgress(int complete, int total);
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.crypto.DatabaseSecret;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase;
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase;
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupMemberDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
||||
@ -27,6 +28,7 @@ import org.thoughtcrime.securesms.database.LokiThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.LokiUserDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.PushDatabase;
|
||||
import org.thoughtcrime.securesms.database.ReactionDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.SearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.SessionContactDatabase;
|
||||
@ -70,9 +72,12 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
private static final int lokiV33 = 54;
|
||||
private static final int lokiV34 = 55;
|
||||
private static final int lokiV35 = 56;
|
||||
private static final int lokiV36 = 57;
|
||||
private static final int lokiV37 = 58;
|
||||
private static final int lokiV38 = 59;
|
||||
|
||||
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
|
||||
private static final int DATABASE_VERSION = lokiV35;
|
||||
private static final int DATABASE_VERSION = lokiV38;
|
||||
private static final String DATABASE_NAME = "signal.db";
|
||||
|
||||
private final Context context;
|
||||
@ -158,7 +163,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
db.execSQL(GroupDatabase.getCreateUpdatedTimestampCommand());
|
||||
db.execSQL(RecipientDatabase.getCreateApprovedCommand());
|
||||
db.execSQL(RecipientDatabase.getCreateApprovedMeCommand());
|
||||
db.execSQL(MmsDatabase.createMessageRequestResponseCommand);
|
||||
db.execSQL(MmsDatabase.CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND);
|
||||
db.execSQL(MmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND);
|
||||
db.execSQL(SmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND);
|
||||
db.execSQL(MmsDatabase.CREATE_REACTIONS_LAST_SEEN_COMMAND);
|
||||
db.execSQL(LokiAPIDatabase.CREATE_FORK_INFO_TABLE_COMMAND);
|
||||
db.execSQL(LokiAPIDatabase.CREATE_DEFAULT_FORK_INFO_COMMAND);
|
||||
db.execSQL(LokiAPIDatabase.UPDATE_HASHES_INCLUDE_NAMESPACE_COMMAND);
|
||||
@ -169,6 +177,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
db.execSQL(LokiAPIDatabase.DROP_LEGACY_RECEIVED_HASHES);
|
||||
db.execSQL(BlindedIdMappingDatabase.CREATE_BLINDED_ID_MAPPING_TABLE_COMMAND);
|
||||
db.execSQL(GroupMemberDatabase.CREATE_GROUP_MEMBER_TABLE_COMMAND);
|
||||
db.execSQL(LokiAPIDatabase.RESET_SEQ_NO); // probably not needed but consistent with all migrations
|
||||
db.execSQL(EmojiSearchDatabase.CREATE_EMOJI_SEARCH_TABLE_COMMAND);
|
||||
db.execSQL(ReactionDatabase.CREATE_REACTION_TABLE_COMMAND);
|
||||
|
||||
executeStatements(db, SmsDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, MmsDatabase.CREATE_INDEXS);
|
||||
@ -177,6 +188,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
executeStatements(db, DraftDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, GroupDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);
|
||||
|
||||
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -355,7 +368,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
db.execSQL(RecipientDatabase.getCreateApprovedCommand());
|
||||
db.execSQL(RecipientDatabase.getCreateApprovedMeCommand());
|
||||
db.execSQL(RecipientDatabase.getUpdateApprovedCommand());
|
||||
db.execSQL(MmsDatabase.createMessageRequestResponseCommand);
|
||||
db.execSQL(MmsDatabase.CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND);
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV32) {
|
||||
@ -385,6 +398,22 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
db.execSQL(GroupMemberDatabase.CREATE_GROUP_MEMBER_TABLE_COMMAND);
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV36) {
|
||||
db.execSQL(LokiAPIDatabase.RESET_SEQ_NO);
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV37) {
|
||||
db.execSQL(MmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND);
|
||||
db.execSQL(SmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND);
|
||||
db.execSQL(MmsDatabase.CREATE_REACTIONS_LAST_SEEN_COMMAND);
|
||||
db.execSQL(ReactionDatabase.CREATE_REACTION_TABLE_COMMAND);
|
||||
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV38) {
|
||||
db.execSQL(EmojiSearchDatabase.CREATE_EMOJI_SEARCH_TABLE_COMMAND);
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
|
@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ties together an emoji with it's associated search tags.
|
||||
*/
|
||||
public final class EmojiSearchData {
|
||||
@JsonProperty
|
||||
private String emoji;
|
||||
|
||||
@JsonProperty
|
||||
private List<String> tags;
|
||||
|
||||
public EmojiSearchData() {}
|
||||
|
||||
public @NonNull String getEmoji() {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
public @NonNull List<String> getTags() {
|
||||
return tags;
|
||||
}
|
||||
}
|
@ -18,8 +18,10 @@ package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.SpannableString;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview;
|
||||
import org.session.libsession.utilities.Contact;
|
||||
import org.session.libsession.utilities.IdentityKeyMismatch;
|
||||
@ -28,7 +30,9 @@ import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase.Status;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
/**
|
||||
@ -43,21 +47,22 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
||||
private final int partCount;
|
||||
|
||||
public MediaMmsMessageRecord(long id, Recipient conversationRecipient,
|
||||
Recipient individualRecipient, int recipientDeviceId,
|
||||
long dateSent, long dateReceived, int deliveryReceiptCount,
|
||||
long threadId, String body,
|
||||
@NonNull SlideDeck slideDeck,
|
||||
int partCount, long mailbox,
|
||||
List<IdentityKeyMismatch> mismatches,
|
||||
List<NetworkFailure> failures, int subscriptionId,
|
||||
long expiresIn, long expireStarted, int readReceiptCount,
|
||||
@Nullable Quote quote, @NonNull List<Contact> contacts,
|
||||
@NonNull List<LinkPreview> linkPreviews, boolean unidentified)
|
||||
Recipient individualRecipient, int recipientDeviceId,
|
||||
long dateSent, long dateReceived, int deliveryReceiptCount,
|
||||
long threadId, String body,
|
||||
@NonNull SlideDeck slideDeck,
|
||||
int partCount, long mailbox,
|
||||
List<IdentityKeyMismatch> mismatches,
|
||||
List<NetworkFailure> failures, int subscriptionId,
|
||||
long expiresIn, long expireStarted, int readReceiptCount,
|
||||
@Nullable Quote quote, @NonNull List<Contact> contacts,
|
||||
@NonNull List<LinkPreview> linkPreviews,
|
||||
@NonNull List<ReactionRecord> reactions, boolean unidentified)
|
||||
{
|
||||
super(id, body, conversationRecipient, individualRecipient, dateSent,
|
||||
dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
|
||||
expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts,
|
||||
linkPreviews, unidentified);
|
||||
linkPreviews, unidentified, reactions);
|
||||
this.partCount = partCount;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.database.model
|
||||
|
||||
/**
|
||||
* Represents a pair of values that can be used to find a message. Because we have two tables,
|
||||
* that means this has both the primary key and a boolean indicating which table it's in.
|
||||
*/
|
||||
data class MessageId(
|
||||
val id: Long,
|
||||
@get:JvmName("isMms") val mms: Boolean
|
||||
) {
|
||||
fun serialize(): String {
|
||||
return "$id|$mms"
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Returns null for invalid IDs. Useful when pulling a possibly-unset ID from a database, or something like that.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun fromNullable(id: Long, mms: Boolean): MessageId? {
|
||||
return if (id > 0) {
|
||||
MessageId(id, mms)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun deserialize(serialized: String): MessageId {
|
||||
val parts: List<String> = serialized.split("|")
|
||||
return MessageId(parts[0].toLong(), parts[1].toBoolean())
|
||||
}
|
||||
}
|
||||
}
|
@ -50,6 +50,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
private final long expireStarted;
|
||||
private final boolean unidentified;
|
||||
public final long id;
|
||||
private final List<ReactionRecord> reactions;
|
||||
|
||||
public abstract boolean isMms();
|
||||
public abstract boolean isMmsNotification();
|
||||
@ -61,7 +62,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
List<IdentityKeyMismatch> mismatches,
|
||||
List<NetworkFailure> networkFailures,
|
||||
long expiresIn, long expireStarted,
|
||||
int readReceiptCount, boolean unidentified)
|
||||
int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions)
|
||||
{
|
||||
super(body, conversationRecipient, dateSent, dateReceived,
|
||||
threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount);
|
||||
@ -72,6 +73,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
this.expiresIn = expiresIn;
|
||||
this.expireStarted = expireStarted;
|
||||
this.unidentified = unidentified;
|
||||
this.reactions = reactions;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
@ -147,4 +149,9 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
public int hashCode() {
|
||||
return (int)getId();
|
||||
}
|
||||
|
||||
public @NonNull List<ReactionRecord> getReactions() {
|
||||
return reactions;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -25,9 +25,9 @@ public abstract class MmsMessageRecord extends MessageRecord {
|
||||
List<NetworkFailure> networkFailures, long expiresIn,
|
||||
long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount,
|
||||
@Nullable Quote quote, @NonNull List<Contact> contacts,
|
||||
@NonNull List<LinkPreview> linkPreviews, boolean unidentified)
|
||||
@NonNull List<LinkPreview> linkPreviews, boolean unidentified, List<ReactionRecord> reactions)
|
||||
{
|
||||
super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, unidentified);
|
||||
super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
|
||||
this.slideDeck = slideDeck;
|
||||
this.quote = quote;
|
||||
this.contacts.addAll(contacts);
|
||||
|
@ -16,17 +16,18 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import static java.util.Collections.emptyList;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.SpannableString;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import org.session.libsession.utilities.IdentityKeyMismatch;
|
||||
import org.session.libsession.utilities.NetworkFailure;
|
||||
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase.Status;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
/**
|
||||
@ -53,8 +54,8 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord {
|
||||
{
|
||||
super(id, "", conversationRecipient, individualRecipient,
|
||||
dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
|
||||
new LinkedList<IdentityKeyMismatch>(), new LinkedList<NetworkFailure>(),
|
||||
0, 0, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false);
|
||||
emptyList(), emptyList(),
|
||||
0, 0, slideDeck, readReceiptCount, null, emptyList(), emptyList(), false, emptyList());
|
||||
|
||||
this.contentLocation = contentLocation;
|
||||
this.messageSize = messageSize;
|
||||
|
@ -0,0 +1,14 @@
|
||||
package org.thoughtcrime.securesms.database.model
|
||||
|
||||
data class ReactionRecord(
|
||||
val id: Long = 0,
|
||||
val messageId: Long,
|
||||
val isMms: Boolean,
|
||||
val author: String,
|
||||
val emoji: String,
|
||||
val serverId: String = "",
|
||||
val count: Long = 0,
|
||||
val sortId: Long = 0,
|
||||
val dateSent: Long = 0,
|
||||
val dateReceived: Long = 0
|
||||
)
|
@ -43,12 +43,12 @@ public class SmsMessageRecord extends MessageRecord {
|
||||
long type, long threadId,
|
||||
int status, List<IdentityKeyMismatch> mismatches,
|
||||
long expiresIn, long expireStarted,
|
||||
int readReceiptCount, boolean unidentified)
|
||||
int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions)
|
||||
{
|
||||
super(id, body, recipient, individualRecipient,
|
||||
dateSent, dateReceived, threadId, status, deliveryReceiptCount, type,
|
||||
mismatches, new LinkedList<>(),
|
||||
expiresIn, expireStarted, readReceiptCount, unidentified);
|
||||
expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
|
||||
}
|
||||
|
||||
public long getType() {
|
||||
|
@ -40,6 +40,8 @@ interface DatabaseComponent {
|
||||
fun lokiBackupFilesDatabase(): LokiBackupFilesDatabase
|
||||
fun sessionJobDatabase(): SessionJobDatabase
|
||||
fun sessionContactDatabase(): SessionContactDatabase
|
||||
fun reactionDatabase(): ReactionDatabase
|
||||
fun emojiSearchDatabase(): EmojiSearchDatabase
|
||||
fun storage(): Storage
|
||||
fun attachmentProvider(): MessageDataProvider
|
||||
fun blindedIdMappingDatabase(): BlindedIdMappingDatabase
|
||||
|
@ -125,6 +125,14 @@ object DatabaseModule {
|
||||
@Singleton
|
||||
fun provideGroupMemberDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = GroupMemberDatabase(context, openHelper)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideReactionDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ReactionDatabase(context, openHelper)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideEmojiSearchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = EmojiSearchDatabase(context, openHelper)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper)
|
||||
|
@ -0,0 +1,47 @@
|
||||
package org.thoughtcrime.securesms.emoji
|
||||
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StringRes
|
||||
import network.loki.messenger.R
|
||||
|
||||
/**
|
||||
* All the different Emoji categories the app is aware of in the order we want to display them.
|
||||
*/
|
||||
enum class EmojiCategory(val priority: Int, val key: String, @AttrRes val icon: Int) {
|
||||
PEOPLE(0, "People", R.attr.emoji_category_people),
|
||||
NATURE(1, "Nature", R.attr.emoji_category_nature),
|
||||
FOODS(2, "Foods", R.attr.emoji_category_foods),
|
||||
ACTIVITY(3, "Activity", R.attr.emoji_category_activity),
|
||||
PLACES(4, "Places", R.attr.emoji_category_places),
|
||||
OBJECTS(5, "Objects", R.attr.emoji_category_objects),
|
||||
SYMBOLS(6, "Symbols", R.attr.emoji_category_symbol),
|
||||
FLAGS(7, "Flags", R.attr.emoji_category_flags),
|
||||
EMOTICONS(8, "Emoticons", R.attr.emoji_category_emoticons);
|
||||
|
||||
@StringRes
|
||||
fun getCategoryLabel(): Int {
|
||||
return getCategoryLabel(icon)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun forKey(key: String) = values().first { it.key == key }
|
||||
|
||||
@JvmStatic
|
||||
@StringRes
|
||||
fun getCategoryLabel(@AttrRes iconAttr: Int): Int {
|
||||
return when (iconAttr) {
|
||||
R.attr.emoji_category_people -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__smileys_and_people
|
||||
R.attr.emoji_category_nature -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__nature
|
||||
R.attr.emoji_category_foods -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__food
|
||||
R.attr.emoji_category_activity -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__activities
|
||||
R.attr.emoji_category_places -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__places
|
||||
R.attr.emoji_category_objects -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__objects
|
||||
R.attr.emoji_category_symbol -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__symbols
|
||||
R.attr.emoji_category_flags -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__flags
|
||||
R.attr.emoji_category_emoticons -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__emoticons
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,138 @@
|
||||
package org.thoughtcrime.securesms.emoji
|
||||
|
||||
import android.net.Uri
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.session.libsignal.utilities.Hex
|
||||
import org.thoughtcrime.securesms.components.emoji.CompositeEmojiPageModel
|
||||
import org.thoughtcrime.securesms.components.emoji.Emoji
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
|
||||
import org.thoughtcrime.securesms.components.emoji.StaticEmojiPageModel
|
||||
import java.io.InputStream
|
||||
import java.nio.charset.Charset
|
||||
|
||||
typealias UriFactory = (sprite: String, format: String) -> Uri
|
||||
|
||||
/**
|
||||
* Takes an emoji_data.json file data and parses it into an EmojiSource
|
||||
*/
|
||||
object EmojiJsonParser {
|
||||
private val OBJECT_MAPPER = ObjectMapper()
|
||||
private const val ESTIMATED_EMOJI_COUNT = 3500
|
||||
|
||||
@JvmStatic
|
||||
fun verify(body: InputStream) {
|
||||
parse(body) { _, _ -> Uri.EMPTY }.getOrThrow()
|
||||
}
|
||||
|
||||
fun parse(body: InputStream, uriFactory: UriFactory): Result<ParsedEmojiData> {
|
||||
return try {
|
||||
Result.success(buildEmojiSourceFromNode(OBJECT_MAPPER.readTree(body), uriFactory))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildEmojiSourceFromNode(node: JsonNode, uriFactory: UriFactory): ParsedEmojiData {
|
||||
val format: String = node["format"].textValue()
|
||||
val obsolete: List<ObsoleteEmoji> = node["obsolete"].toObseleteList()
|
||||
val dataPages: List<EmojiPageModel> = getDataPages(format, node["emoji"], uriFactory)
|
||||
val jumboPages: Map<String, String> = getJumboPages(node["jumbomoji"])
|
||||
val displayPages: List<EmojiPageModel> = mergeToDisplayPages(dataPages)
|
||||
val metrics: EmojiMetrics = node["metrics"].toEmojiMetrics()
|
||||
val densities: List<String> = node["densities"].toDensityList()
|
||||
|
||||
return ParsedEmojiData(metrics, densities, format, displayPages, dataPages, jumboPages, obsolete)
|
||||
}
|
||||
|
||||
private fun getDataPages(format: String, emoji: JsonNode, uriFactory: UriFactory): List<EmojiPageModel> {
|
||||
return emoji.fields()
|
||||
.asSequence()
|
||||
.sortedWith { lhs, rhs ->
|
||||
val lhsCategory = EmojiCategory.forKey(lhs.key.asCategoryKey())
|
||||
val rhsCategory = EmojiCategory.forKey(rhs.key.asCategoryKey())
|
||||
val comp = lhsCategory.priority.compareTo(rhsCategory.priority)
|
||||
|
||||
if (comp == 0) {
|
||||
val lhsIndex = lhs.key.getPageIndex()
|
||||
val rhsIndex = rhs.key.getPageIndex()
|
||||
|
||||
lhsIndex.compareTo(rhsIndex)
|
||||
} else {
|
||||
comp
|
||||
}
|
||||
}
|
||||
.map { createPage(it.key, format, it.value, uriFactory) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun getJumboPages(jumbo: JsonNode?): Map<String, String> {
|
||||
if (jumbo != null) {
|
||||
return jumbo.fields()
|
||||
.asSequence()
|
||||
.map { (page: String, node: JsonNode) ->
|
||||
node.associate { it.textValue() to page }
|
||||
}
|
||||
.flatMap { it.entries }
|
||||
.associateTo(HashMap(ESTIMATED_EMOJI_COUNT)) { it.key to it.value }
|
||||
}
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
private fun createPage(pageName: String, format: String, page: JsonNode, uriFactory: UriFactory): EmojiPageModel {
|
||||
val category = EmojiCategory.forKey(pageName.asCategoryKey())
|
||||
val pageList = page.mapIndexed { i, data ->
|
||||
if (data.size() == 0) {
|
||||
throw IllegalStateException("Page index $pageName.$i had no data")
|
||||
} else {
|
||||
val variations: MutableList<String> = mutableListOf()
|
||||
val rawVariations: MutableList<String> = mutableListOf()
|
||||
data.forEach {
|
||||
variations += it.textValue().encodeAsUtf16()
|
||||
rawVariations += it.textValue()
|
||||
}
|
||||
|
||||
Emoji(variations, rawVariations)
|
||||
}
|
||||
}
|
||||
|
||||
return StaticEmojiPageModel(category, pageList, uriFactory(pageName, format))
|
||||
}
|
||||
|
||||
private fun mergeToDisplayPages(dataPages: List<EmojiPageModel>): List<EmojiPageModel> {
|
||||
return dataPages.groupBy { it.iconAttr }
|
||||
.map { (icon, pages) -> if (pages.size <= 1) pages.first() else CompositeEmojiPageModel(icon, pages) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonNode?.toObseleteList(): List<ObsoleteEmoji> {
|
||||
return if (this == null) {
|
||||
listOf()
|
||||
} else {
|
||||
map { node ->
|
||||
ObsoleteEmoji(node["obsoleted"].textValue().encodeAsUtf16(), node["replace_with"].textValue().encodeAsUtf16())
|
||||
}.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonNode.toEmojiMetrics(): EmojiMetrics {
|
||||
return EmojiMetrics(this["raw_width"].asInt(), this["raw_height"].asInt(), this["per_row"].asInt())
|
||||
}
|
||||
|
||||
private fun JsonNode.toDensityList(): List<String> {
|
||||
return map { it.textValue() }
|
||||
}
|
||||
|
||||
private fun String.encodeAsUtf16() = String(Hex.fromStringCondensed(this), Charset.forName("UTF-16"))
|
||||
private fun String.asCategoryKey() = replace("(_\\d+)*$".toRegex(), "")
|
||||
private fun String.getPageIndex() = "^.*_(\\d+)+$".toRegex().find(this)?.let { it.groupValues[1] }?.toInt() ?: throw IllegalStateException("No index.")
|
||||
|
||||
data class ParsedEmojiData(
|
||||
override val metrics: EmojiMetrics,
|
||||
override val densities: List<String>,
|
||||
override val format: String,
|
||||
override val displayPages: List<EmojiPageModel>,
|
||||
override val dataPages: List<EmojiPageModel>,
|
||||
override val jumboPages: Map<String, String>,
|
||||
override val obsolete: List<ObsoleteEmoji>
|
||||
) : EmojiData
|
@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.emoji
|
||||
|
||||
import android.net.Uri
|
||||
import com.bumptech.glide.load.Key
|
||||
import java.security.MessageDigest
|
||||
|
||||
typealias EmojiPageFactory = (Uri) -> EmojiPage
|
||||
|
||||
sealed class EmojiPage(open val uri: Uri) : Key {
|
||||
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||
messageDigest.update("EmojiPage".encodeToByteArray())
|
||||
messageDigest.update(uri.toString().encodeToByteArray())
|
||||
}
|
||||
|
||||
data class Asset(override val uri: Uri) : EmojiPage(uri)
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
package org.thoughtcrime.securesms.emoji
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.session.libsession.utilities.ListenableFutureTask
|
||||
import org.session.libsession.utilities.SoftHashMap
|
||||
import org.session.libsession.utilities.concurrent.SimpleTask
|
||||
import org.session.libsignal.utilities.Log
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
object EmojiPageCache {
|
||||
|
||||
private val TAG = Log.tag(EmojiPageCache::class.java)
|
||||
|
||||
private val cache: SoftHashMap<EmojiPageRequest, Bitmap> = SoftHashMap()
|
||||
private val tasks: HashMap<EmojiPageRequest, ListenableFutureTask<Bitmap>> = hashMapOf()
|
||||
|
||||
@MainThread
|
||||
fun load(context: Context, emojiPage: EmojiPage, inSampleSize: Int): LoadResult {
|
||||
val applicationContext = context.applicationContext
|
||||
val emojiPageRequest = EmojiPageRequest(emojiPage, inSampleSize)
|
||||
val bitmap: Bitmap? = cache[emojiPageRequest]
|
||||
val task: ListenableFutureTask<Bitmap>? = tasks[emojiPageRequest]
|
||||
|
||||
return when {
|
||||
bitmap != null -> LoadResult.Immediate(bitmap)
|
||||
task != null -> LoadResult.Async(task)
|
||||
else -> {
|
||||
val newTask = ListenableFutureTask<Bitmap> {
|
||||
try {
|
||||
Log.i(TAG, "Loading page $emojiPageRequest")
|
||||
loadInternal(applicationContext, emojiPageRequest)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
tasks[emojiPageRequest] = newTask
|
||||
|
||||
SimpleTask.run(newTask::run) {
|
||||
try {
|
||||
val newBitmap: Bitmap? = newTask.get()
|
||||
if (newBitmap == null) {
|
||||
Log.w(TAG, "Failed to load emoji bitmap for request $emojiPageRequest")
|
||||
} else {
|
||||
cache[emojiPageRequest] = newBitmap
|
||||
}
|
||||
} finally {
|
||||
tasks.remove(emojiPageRequest)
|
||||
}
|
||||
}
|
||||
|
||||
LoadResult.Async(newTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun loadInternal(context: Context, emojiPageRequest: EmojiPageRequest): Bitmap? {
|
||||
val inputStream: InputStream = when (emojiPageRequest.emojiPage) {
|
||||
is EmojiPage.Asset -> context.assets.open(emojiPageRequest.emojiPage.uri.toString().replace("file:///android_asset/", ""))
|
||||
}
|
||||
|
||||
val bitmapOptions = BitmapFactory.Options()
|
||||
bitmapOptions.inSampleSize = emojiPageRequest.inSampleSize
|
||||
|
||||
return inputStream.use { BitmapFactory.decodeStream(it, null, bitmapOptions) }
|
||||
}
|
||||
|
||||
private data class EmojiPageRequest(val emojiPage: EmojiPage, val inSampleSize: Int)
|
||||
|
||||
sealed class LoadResult {
|
||||
data class Immediate(val bitmap: Bitmap) : LoadResult()
|
||||
data class Async(val task: ListenableFutureTask<Bitmap>) : LoadResult()
|
||||
}
|
||||
}
|
@ -0,0 +1,161 @@
|
||||
package org.thoughtcrime.securesms.emoji
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.components.emoji.Emoji
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
|
||||
import org.thoughtcrime.securesms.components.emoji.StaticEmojiPageModel
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiTree
|
||||
import org.thoughtcrime.securesms.util.ScreenDensity
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* The entry point for the application to request Emoji data for custom emojis.
|
||||
*/
|
||||
class EmojiSource(
|
||||
val decodeScale: Float,
|
||||
private val emojiData: EmojiData,
|
||||
private val emojiPageFactory: EmojiPageFactory
|
||||
) : EmojiData by emojiData {
|
||||
|
||||
val variationsToCanonical: Map<String, String> by lazy {
|
||||
val map = mutableMapOf<String, String>()
|
||||
|
||||
for (page: EmojiPageModel in dataPages) {
|
||||
for (emoji: Emoji in page.displayEmoji) {
|
||||
for (variation: String in emoji.variations) {
|
||||
map[variation] = emoji.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
val canonicalToVariations: Map<String, List<String>> by lazy {
|
||||
val map = mutableMapOf<String, List<String>>()
|
||||
|
||||
for (page: EmojiPageModel in dataPages) {
|
||||
for (emoji: Emoji in page.displayEmoji) {
|
||||
map[emoji.value] = emoji.variations
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
val maxEmojiLength: Int by lazy {
|
||||
dataPages.map { it.emoji.map(String::length) }
|
||||
.flatten()
|
||||
.maxOrZero()
|
||||
}
|
||||
|
||||
val emojiTree: EmojiTree by lazy {
|
||||
val tree = EmojiTree()
|
||||
|
||||
dataPages
|
||||
.filter { it.spriteUri != null }
|
||||
.forEach { page ->
|
||||
val emojiPage = emojiPageFactory(page.spriteUri!!)
|
||||
|
||||
var overallIndex = 0
|
||||
page.displayEmoji.forEach { emoji: Emoji ->
|
||||
emoji.variations.forEachIndexed { variationIndex, variation ->
|
||||
val raw = emoji.getRawVariation(variationIndex)
|
||||
tree.add(variation, EmojiDrawInfo(emojiPage, overallIndex++, variation, raw, jumboPages[raw]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
obsolete.forEach {
|
||||
tree.add(it.obsolete, tree.getEmoji(it.replaceWith, 0, it.replaceWith.length))
|
||||
}
|
||||
|
||||
tree
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val emojiSource = AtomicReference<EmojiSource>()
|
||||
private val emojiLatch = CountDownLatch(1)
|
||||
|
||||
@JvmStatic
|
||||
val latest: EmojiSource
|
||||
get() {
|
||||
emojiLatch.await()
|
||||
return emojiSource.get()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
fun refresh() {
|
||||
emojiSource.set(getEmojiSource())
|
||||
emojiLatch.countDown()
|
||||
}
|
||||
|
||||
private fun getEmojiSource(): EmojiSource {
|
||||
return loadAssetBasedEmojis()
|
||||
}
|
||||
|
||||
private fun loadAssetBasedEmojis(): EmojiSource {
|
||||
val context = MessagingModuleConfiguration.shared.context
|
||||
val emojiData: InputStream = ApplicationContext.getInstance(context).assets.open("emoji/emoji_data.json")
|
||||
|
||||
emojiData.use {
|
||||
val parsedData: ParsedEmojiData = EmojiJsonParser.parse(it, ::getAssetsUri).getOrThrow()
|
||||
return EmojiSource(
|
||||
ScreenDensity.xhdpiRelativeDensityScaleFactor("xhdpi"),
|
||||
parsedData.copy(
|
||||
displayPages = parsedData.displayPages + PAGE_EMOTICONS,
|
||||
dataPages = parsedData.dataPages + PAGE_EMOTICONS
|
||||
)
|
||||
) { uri: Uri -> EmojiPage.Asset(uri) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Int>.maxOrZero(): Int = maxOrNull() ?: 0
|
||||
|
||||
interface EmojiData {
|
||||
val metrics: EmojiMetrics
|
||||
val densities: List<String>
|
||||
val format: String
|
||||
val displayPages: List<EmojiPageModel>
|
||||
val dataPages: List<EmojiPageModel>
|
||||
val jumboPages: Map<String, String>
|
||||
val obsolete: List<ObsoleteEmoji>
|
||||
}
|
||||
|
||||
data class ObsoleteEmoji(val obsolete: String, val replaceWith: String)
|
||||
|
||||
data class EmojiMetrics(val rawHeight: Int, val rawWidth: Int, val perRow: Int)
|
||||
|
||||
private fun getAssetsUri(name: String, format: String): Uri = Uri.parse("file:///android_asset/emoji/$name.$format")
|
||||
|
||||
private val PAGE_EMOTICONS: EmojiPageModel = StaticEmojiPageModel(
|
||||
EmojiCategory.EMOTICONS,
|
||||
arrayOf(
|
||||
":-)", ";-)", "(-:", ":->", ":-D", "\\o/",
|
||||
":-P", "B-)", ":-$", ":-*", "O:-)", "=-O",
|
||||
"O_O", "O_o", "o_O", ":O", ":-!", ":-x",
|
||||
":-|", ":-\\", ":-(", ":'(", ":-[", ">:-(",
|
||||
"^.^", "^_^", "\\(\u02c6\u02da\u02c6)/",
|
||||
"\u30fd(\u00b0\u25c7\u00b0 )\u30ce", "\u00af\\(\u00b0_o)/\u00af",
|
||||
"\u00af\\_(\u30c4)_/\u00af", "(\u00ac_\u00ac)",
|
||||
"(>_<)", "(\u2565\ufe4f\u2565)", "(\u261e\uff9f\u30ee\uff9f)\u261e",
|
||||
"\u261c(\uff9f\u30ee\uff9f\u261c)", "\u261c(\u2312\u25bd\u2312)\u261e",
|
||||
"(\u256f\u00b0\u25a1\u00b0)\u256f\ufe35", "\u253b\u2501\u253b",
|
||||
"\u252c\u2500\u252c", "\u30ce(\u00b0\u2013\u00b0\u30ce)",
|
||||
"(^._.^)\uff89", "\u0e05^\u2022\ufecc\u2022^\u0e05",
|
||||
"\u0295\u2022\u1d25\u2022\u0294", "(\u2022_\u2022)",
|
||||
" \u25a0-\u25a0\u00ac <(\u2022_\u2022) ", "(\u25a0_\u25a0\u00ac)",
|
||||
"\u01aa(\u0693\u05f2)\u200e\u01aa\u200b\u200b"
|
||||
),
|
||||
null
|
||||
)
|