1
0
Fork 1

Show custom emoji reactions and allow sending them

(cherry picked from commit 7a51666a8bfe56bae3299dc386cd778236143874)
This commit is contained in:
Stephen Paul Weber 2024-10-04 05:20:38 +02:00 committed by Arne
parent 293c21db2c
commit 699eb7eae9
6 changed files with 317 additions and 141 deletions

View file

@ -23,6 +23,7 @@ import android.text.StaticLayout;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.style.ImageSpan;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.Gravity;
@ -67,6 +68,8 @@ import com.caverock.androidsvg.SVG;
import de.monocles.chat.ConversationPage;
import de.monocles.chat.Util;
import de.monocles.chat.WebxdcPage;
import de.monocles.chat.BobTransfer;
import de.monocles.chat.GetThumbnailForCid;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.tabs.TabLayout;
@ -108,6 +111,7 @@ import java.util.stream.Collectors;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.function.Function;
import me.saket.bettermovementmethod.BetterLinkMovementMethod;
@ -309,6 +313,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
for (int i = messages.size() - 1; i >= 0; --i) {
final Message message = messages.get(i);
if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
if (asReaction(message) != null) continue;
if (message.isRead()) {
return first;
} else {
@ -324,6 +329,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
synchronized (this.messages) {
for (final Message message : Lists.reverse(this.messages)) {
if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
if (asReaction(message) != null) continue;
if (message.getStatus() == Message.STATUS_RECEIVED) {
final String serverMsgId = message.getServerMsgId();
if (serverMsgId != null && multi) {
@ -735,26 +741,59 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
thread.first = m;
}
}
final var reply = m.getReply();
if (reply != null && reply.getAttribute("id") != null) {
extraIds.add(reply.getAttribute("id"));
final var body = m.getBody(true).toString().replaceAll("\\s", "");
if (Emoticons.isEmoji(body)) {
reactions.put(reply.getAttribute("id"), new Reaction(body, true, m.getCounterpart(), m.getTrueCounterpart(), m.getOccupantId()));
iterator.remove();
}
final var asReaction = asReaction(m);
if (asReaction != null) {
reactions.put(asReaction.first, asReaction.second);
iterator.remove();
}
if (m.wasMergedIntoPrevious(xmppConnectionService) || (m.getSubject() != null && !m.isOOb() && (m.getRawBody() == null || m.getRawBody().length() == 0)) || (getLockThread() && !extraIds.contains(m.replyId()) && (mthread == null || !mthread.getContent().equals(getThread() == null ? "" : getThread().getContent())))) {
iterator.remove();
} else if (getLockThread() && mthread != null) {
final var reply = m.getReply();
if (reply != null && reply.getAttribute("id") != null) extraIds.add(reply.getAttribute("id"));
Element reactions = m.getReactionsEl();
if (reactions != null && reactions.getAttribute("id") != null) extraIds.add(reactions.getAttribute("id"));
}
}
}
public Reaction.Aggregated aggregatedReactionsFor(Message m) {
protected Pair<String, Reaction> asReaction(Message m) {
final var reply = m.getReply();
if (reply != null && reply.getAttribute("id") != null) {
final var body = m.getBody(true).toString().replaceAll("\\s", "");
if (Emoticons.isEmoji(body)) {
return new Pair<>(reply.getAttribute("id"), new Reaction(body, null, m.getStatus() <= Message.STATUS_RECEIVED, m.getCounterpart(), m.getTrueCounterpart(), m.getOccupantId()));
} else {
final var html = m.getHtml();
if (html == null) return null;
SpannableStringBuilder spannable = m.getSpannableBody(null, null, false);
ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
for (ImageSpan span : imageSpans) {
final int start = spannable.getSpanStart(span);
final int end = spannable.getSpanEnd(span);
spannable.delete(start, end);
}
if (imageSpans.length == 1 && spannable.toString().replaceAll("\\s", "").length() < 1) {
// Only one inline image, so it's a custom emoji by itself as a reply/reaction
final var source = imageSpans[0].getSource();
var shortcode = "";
final var img = html.findChild("img");
if (img != null) {
shortcode = img.getAttribute("alt").replaceAll("(^:)|(:$)", "");
}
if (source != null && source.length() > 0 && source.substring(0, 4).equals("cid:")) {
final Cid cid = BobTransfer.cid(Uri.parse(source));
return new Pair<>(reply.getAttribute("id"), new Reaction(shortcode, cid, m.getStatus() <= Message.STATUS_RECEIVED, m.getCounterpart(), m.getTrueCounterpart(), m.getOccupantId()));
}
}
}
}
return null;
}
public Reaction.Aggregated aggregatedReactionsFor(Message m, Function<Reaction, GetThumbnailForCid> thumbnailer) {
Set<Reaction> result = new HashSet<>();
if (getMode() == MODE_MULTI) {
result.addAll(reactions.get(m.getServerMsgId()));
@ -764,7 +803,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
result.addAll(reactions.get(m.getRemoteMsgId()));
}
result.addAll(m.getReactions());
return Reaction.aggregated(result);
return Reaction.aggregated(result, thumbnailer);
}
public Thread getThread(String id) {
@ -934,15 +973,17 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
public Message getLatestMessage() {
synchronized (this.messages) {
if (this.messages.size() == 0) {
Message message = new Message(this, "", Message.ENCRYPTION_NONE);
message.setType(Message.TYPE_STATUS);
message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
message.setTimeReceived(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
for(final Message message : Lists.reverse(this.messages)) {
if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
if (asReaction(message) != null) continue;
return message;
} else {
return this.messages.get(this.messages.size() - 1);
}
Message message = new Message(this, "", Message.ENCRYPTION_NONE);
message.setType(Message.TYPE_STATUS);
message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
message.setTimeReceived(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
return message;
}
}
@ -1534,6 +1575,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
int count = 0;
for(final Message message : Lists.reverse(this.messages)) {
if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
if (asReaction(message) != null) continue;
final boolean muted = xmppConnectionService != null && message.getStatus() == Message.STATUS_RECEIVED && getMode() == Conversation.MODE_MULTI && xmppConnectionService.isMucUserMuted(new MucOptions.User(null, getJid(), message.getOccupantId(), null, null));
if (muted) continue;
if (message.isRead()) {
@ -1553,6 +1595,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
synchronized (this.messages) {
for (Message message : messages) {
if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
if (asReaction(message) != null) continue;
if (message.getStatus() == Message.STATUS_RECEIVED) {
++count;
}
@ -2454,7 +2497,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
float maxColumnWidth = theOptions.stream().map((x) ->
StaticLayout.getDesiredWidth(x.toString(), paint)
StaticLayout.getDesiredWidth(x.toString(), paint)
).max(Float::compare).orElse(new Float(0.0));
if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
binding.radios.setNumColumns(theOptions.size());
@ -2547,22 +2590,22 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
final SVG icon = getItem(position).getIcon();
if (icon != null) {
final Element iconEl = getItem(position).getIconEl();
if (height < 1) {
v.measure(0, 0);
height = v.getMeasuredHeight();
}
if (height < 1) return v;
if (mediaSelector) {
final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
if (d != null) {
final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
}
v.setCompoundDrawables(null, d, null, null);
} else {
v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
}
final Element iconEl = getItem(position).getIconEl();
if (height < 1) {
v.measure(0, 0);
height = v.getMeasuredHeight();
}
if (height < 1) return v;
if (mediaSelector) {
final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
if (d != null) {
final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
}
v.setCompoundDrawables(null, d, null, null);
} else {
v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
}
}
return v;
@ -2650,9 +2693,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
final SVG defaultIcon = defaultOption.getIcon();
if (defaultIcon != null) {
DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
int height = (int)(display.heightPixels*display.density/4);
binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
int height = (int)(display.heightPixels*display.density/4);
binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
}
binding.defaultButton.setText(defaultOption.toString());
@ -2962,10 +3005,10 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
viewType = TYPE_CHECKBOX_FIELD;
}
} else if (
range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
"xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
"xs:decimal".equals(datatype) || "xs:double".equals(datatype)
)
range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
"xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
"xs:decimal".equals(datatype) || "xs:double".equals(datatype)
)
) {
// has a range and is numeric, use a slider
viewType = TYPE_SLIDER_FIELD;
@ -3196,8 +3239,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
eu.siacs.conversations.xmpp.forms.Field fillableField = null;
for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
fillableField = range == null ? field : null;
fillableFieldCount++;
}
@ -3286,8 +3329,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
Data dataForm = null;
if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
if (mNode.equals("jabber:iq:register") &&
xmppConnectionService.getPreferences().contains("onboarding_action") &&
dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
xmppConnectionService.getPreferences().contains("onboarding_action") &&
dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
@ -3376,8 +3419,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
if (el.getName().equals("item")) {
for (Element subel : el.getChildren()) {
if (subel.getAttribute("var").equals(reportedField.getVar())) {
itemField = subel;
break;
itemField = subel;
break;
}
}
}
@ -3538,11 +3581,11 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
String formType = responseElement == null ? null : responseElement.getAttribute("type");
if (!action.equals("cancel") &&
!action.equals("prev") &&
responseElement != null &&
responseElement.getName().equals("x") &&
responseElement.getNamespace().equals("jabber:x:data") &&
formType != null && formType.equals("form")) {
!action.equals("prev") &&
responseElement != null &&
responseElement.getName().equals("x") &&
responseElement.getNamespace().equals("jabber:x:data") &&
formType != null && formType.equals("form")) {
Data form = Data.parse(responseElement);
eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
@ -3621,9 +3664,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
float tableHeaderWidth = reported.stream().reduce(
0f,
(total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
(a, b) -> a + b
0f,
(total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
(a, b) -> a + b
);
spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
@ -3715,11 +3758,11 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
}
private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
} else {
return xmppConnectionService.getFileBackend().drawSVG(svg, size);
}
if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
} else {
return xmppConnectionService.getFileBackend().drawSVG(svg, size);
}
}
private Drawable getDrawableForUrl(final String url) {
@ -3835,8 +3878,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
final var packet = new Iq(Iq.Type.SET);
packet.setTo(response.getFrom());
final Element form = packet
.addChild("query", "http://jabber.org/protocol/muc#owner")
.addChild("x", "jabber:x:data");
.addChild("query", "http://jabber.org/protocol/muc#owner")
.addChild("x", "jabber:x:data");
form.setAttribute("type", "cancel");
xmppConnectionService.sendIqPacket(getAccount(), packet, null);
return true;
@ -3849,14 +3892,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
String formType = responseElement == null ? null : responseElement.getAttribute("type");
if (responseElement != null &&
responseElement.getName().equals("x") &&
responseElement.getNamespace().equals("jabber:x:data") &&
formType != null && formType.equals("form")) {
responseElement.getName().equals("x") &&
responseElement.getNamespace().equals("jabber:x:data") &&
formType != null && formType.equals("form")) {
responseElement.setAttribute("type", "submit");
packet
.addChild("query", "http://jabber.org/protocol/muc#owner")
.addChild(responseElement);
.addChild("query", "http://jabber.org/protocol/muc#owner")
.addChild(responseElement);
}
executing = true;

View file

@ -1091,10 +1091,14 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
}
public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
return getSpannableBody(thumbnailer, fallbackImg, true);
}
public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg, final boolean includeReplyTo) {
SpannableStringBuilder spannableBody;
final Element html = getHtml();
if (html == null || Build.VERSION.SDK_INT < 24) {
spannableBody = new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody(getInReplyTo() != null)).trim());
spannableBody = new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody(includeReplyTo && getInReplyTo() != null)).trim());
spannableBody.setSpan(PLAIN_TEXT_SPAN, 0, spannableBody.length(), 0); // Let adapter know it can do more formatting
} else {
SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
@ -1143,7 +1147,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
spannableBody = (SpannableStringBuilder) spannable.subSequence(0, i+1);
}
if (getInReplyTo() != null && getModerated() == null) {
if (includeReplyTo && getInReplyTo() != null && getModerated() == null) {
// Don't show quote if it's the message right before us
if (prev() != null && prev().getUuid().equals(getInReplyTo().getUuid())) return spannableBody;

View file

@ -2,6 +2,9 @@ package eu.siacs.conversations.entities;
import androidx.annotation.NonNull;
import de.monocles.chat.EmojiSearch;
import de.monocles.chat.GetThumbnailForCid;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.collect.Collections2;
@ -18,6 +21,8 @@ import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import io.ipfs.cid.Cid;
import eu.siacs.conversations.xmpp.Jid;
import java.io.IOException;
@ -28,6 +33,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
public class Reaction {
@ -43,7 +49,7 @@ public class Reaction {
private static final Gson GSON;
static {
GSON = new GsonBuilder().registerTypeAdapter(Jid.class, new JidTypeAdapter()).create();
GSON = new GsonBuilder().registerTypeAdapter(Jid.class, new JidTypeAdapter()).registerTypeAdapter(Cid.class, new CidTypeAdapter()).create();
}
public final String reaction;
@ -51,14 +57,17 @@ public class Reaction {
public final Jid from;
public final Jid trueJid;
public final String occupantId;
public final Cid cid;
public Reaction(
final String reaction,
final Cid cid,
boolean received,
final Jid from,
final Jid trueJid,
final String occupantId) {
this.reaction = reaction;
this.cid = cid;
this.received = received;
this.from = from;
this.trueJid = trueJid;
@ -91,7 +100,7 @@ public class Reaction {
builder.addAll(existing);
builder.addAll(
Collections2.transform(
reactions, r -> new Reaction(r, received, from, trueJid, occupantId)));
reactions, r -> new Reaction(r, null, received, from, trueJid, occupantId)));
return builder.build();
}
@ -106,7 +115,7 @@ public class Reaction {
builder.addAll(Collections2.filter(existing, e -> !occupantId.equals(e.occupantId)));
builder.addAll(
Collections2.transform(
reactions, r -> new Reaction(r, received, from, trueJid, occupantId)));
reactions, r -> new Reaction(r, null, received, from, trueJid, occupantId)));
return builder.build();
}
@ -114,7 +123,8 @@ public class Reaction {
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
.add("reaction", reaction)
.add("reaction", cid == null ? reaction : null)
.add("cid", cid)
.add("received", received)
.add("from", from)
.add("trueJid", trueJid)
@ -142,7 +152,7 @@ public class Reaction {
Collections2.filter(existing, e -> !from.asBareJid().equals(e.from.asBareJid())));
builder.addAll(
Collections2.transform(
reactions, r -> new Reaction(r, received, from, null, null)));
reactions, r -> new Reaction(r, null, received, from, null, null)));
return builder.build();
}
@ -169,31 +179,58 @@ public class Reaction {
}
}
private static class CidTypeAdapter extends TypeAdapter<Cid> {
@Override
public void write(final JsonWriter out, final Cid value) throws IOException {
if (value == null) {
out.nullValue();
} else {
out.value(value.toString());
}
}
@Override
public Cid read(final JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
} else if (in.peek() == JsonToken.STRING) {
final String value = in.nextString();
return Cid.decode(value);
}
throw new IOException("Unexpected token");
}
}
public static Aggregated aggregated(final Collection<Reaction> reactions) {
final Map<String, Integer> aggregatedReactions =
return aggregated(reactions, (r) -> null);
}
public static Aggregated aggregated(final Collection<Reaction> reactions, Function<Reaction, GetThumbnailForCid> thumbnailer) {
final Map<EmojiSearch.Emoji, Integer> aggregatedReactions =
Maps.transformValues(
Multimaps.index(reactions, r -> r.reaction).asMap(), Collection::size);
final List<Map.Entry<String, Integer>> sortedList =
Multimaps.index(reactions, r -> r.cid == null ? new EmojiSearch.Emoji(r.reaction, 0) : new EmojiSearch.CustomEmoji(r.reaction, r.cid.toString(), thumbnailer.apply(r).getThumbnail(r.cid), null)).asMap(), Collection::size);
final List<Map.Entry<EmojiSearch.Emoji, Integer>> sortedList =
Ordering.from(
Comparator.comparingInt(
(Map.Entry<String, Integer> o) -> o.getValue()))
(Map.Entry<EmojiSearch.Emoji, Integer> o) -> o.getValue()))
.reverse()
.immutableSortedCopy(aggregatedReactions.entrySet());
return new Aggregated(
sortedList,
ImmutableSet.copyOf(
Collections2.transform(
Collections2.filter(reactions, r -> !r.received),
Collections2.filter(reactions, r -> r.cid == null && !r.received),
r -> r.reaction)));
}
public static final class Aggregated {
public final List<Map.Entry<String, Integer>> reactions;
public final List<Map.Entry<EmojiSearch.Emoji, Integer>> reactions;
public final Set<String> ourReactions;
private Aggregated(
final List<Map.Entry<String, Integer>> reactions, Set<String> ourReactions) {
final List<Map.Entry<EmojiSearch.Emoji, Integer>> reactions, Set<String> ourReactions) {
this.reactions = reactions;
this.ourReactions = ourReactions;
}

View file

@ -4,6 +4,8 @@ import android.view.View;
import android.view.ViewGroup;
import android.util.TypedValue;
import de.monocles.chat.EmojiSearch;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.color.MaterialColors;
@ -15,7 +17,6 @@ import eu.siacs.conversations.entities.Reaction;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
@ -25,15 +26,16 @@ public class BindingAdapters {
final ChipGroup chipGroup,
final Reaction.Aggregated reactions,
final Consumer<Collection<String>> onModifiedReactions,
final Consumer<EmojiSearch.CustomEmoji> onCustomReaction,
final Runnable addReaction) {
setReactions(chipGroup, reactions, true, onModifiedReactions, addReaction);
setReactions(chipGroup, reactions, true, onModifiedReactions, onCustomReaction, addReaction);
}
public static void setReactionsOnSent(
final ChipGroup chipGroup,
final Reaction.Aggregated reactions,
final Consumer<Collection<String>> onModifiedReactions) {
setReactions(chipGroup, reactions, false, onModifiedReactions, null);
setReactions(chipGroup, reactions, false, onModifiedReactions, null, null);
}
private static void setReactions(
@ -41,34 +43,29 @@ public class BindingAdapters {
final Reaction.Aggregated aggregated,
final boolean onReceived,
final Consumer<Collection<String>> onModifiedReactions,
final Consumer<EmojiSearch.CustomEmoji> onCustomReaction,
final Runnable addReaction) {
final var context = chipGroup.getContext();
final var size = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 35, context.getResources().getDisplayMetrics());
final var corner = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 35, context.getResources().getDisplayMetrics());
final var layoutParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, size);
final List<Map.Entry<String, Integer>> reactions = aggregated.reactions;
final List<Map.Entry<EmojiSearch.Emoji, Integer>> reactions = aggregated.reactions;
if (reactions == null || reactions.isEmpty()) {
chipGroup.setVisibility(View.GONE);
} else {
chipGroup.removeAllViews();
chipGroup.setVisibility(View.VISIBLE);
for (final Map.Entry<String, Integer> reaction : reactions) {
for (final var reaction : reactions) {
final var emoji = reaction.getKey();
final var count = reaction.getValue();
final Chip chip = new Chip(chipGroup.getContext());
//chip.setEnsureMinTouchTargetSize(false);
chip.setChipMinHeight(size-32.0f);
chip.ensureAccessibleTouchTarget(size);
chip.setChipStartPadding(0.0f);
chip.setChipEndPadding(0.0f);
chip.setChipCornerRadius(corner);
chip.setLayoutParams(layoutParams);
if (count == 1) {
chip.setText(emoji);
} else {
chip.setText(String.format(Locale.ENGLISH, "%s %d", emoji, count));
}
final boolean oneOfOurs = aggregated.ourReactions.contains(emoji);
chip.setChipCornerRadius(corner);
emoji.setupChip(chip, count);
final boolean oneOfOurs = aggregated.ourReactions.contains(emoji.toString());
// received = surface; sent = surface high matches bubbles
if (oneOfOurs) {
chip.setChipBackgroundColor(
@ -82,6 +79,8 @@ public class BindingAdapters {
context,
com.google.android.material.R.attr.colorSurfaceContainerLow));
}
chip.setTextEndPadding(0.0f);
chip.setTextStartPadding(0.0f);
chip.setOnClickListener(
v -> {
if (oneOfOurs) {
@ -89,13 +88,17 @@ public class BindingAdapters {
ImmutableSet.copyOf(
Collections2.filter(
aggregated.ourReactions,
r -> !r.equals(emoji))));
r -> !r.equals(emoji.toString()))));
} else {
onModifiedReactions.accept(
if (emoji instanceof EmojiSearch.CustomEmoji) {
onCustomReaction.accept((EmojiSearch.CustomEmoji) emoji);
} else {
onModifiedReactions.accept(
new ImmutableSet.Builder<String>()
.addAll(aggregated.ourReactions)
.add(emoji)
.add(emoji.toString())
.build());
}
}
});
chipGroup.addView(chip);
@ -113,11 +116,11 @@ public class BindingAdapters {
// com.google.android.material.R.attr.colorTertiary));
chip.setChipBackgroundColor(
MaterialColors.getColorStateListOrNull(
chipGroup.getContext(),
context,
com.google.android.material.R.attr.colorSurfaceContainerLow));
chip.setChipIconTint(
MaterialColors.getColorStateListOrNull(
chipGroup.getContext(),
context,
com.google.android.material.R.attr.colorOnSurface));
//chip.setEnsureMinTouchTargetSize(false);
chip.setTextEndPadding(0.0f);

View file

@ -67,6 +67,17 @@ import de.monocles.chat.MessageTextActionModeCallback;
import de.monocles.chat.Util;
import de.monocles.chat.WebxdcPage;
import de.monocles.chat.WebxdcUpdate;
import de.monocles.chat.BobTransfer;
import de.monocles.chat.EmojiSearch;
import de.monocles.chat.GetThumbnailForCid;
import de.monocles.chat.MessageTextActionModeCallback;
import de.monocles.chat.SwipeDetector;
import de.monocles.chat.Util;
import de.monocles.chat.WebxdcPage;
import de.monocles.chat.WebxdcUpdate;
import androidx.emoji2.emojipicker.EmojiViewItem;
import androidx.emoji2.emojipicker.RecentEmojiProvider;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.chip.ChipGroup;
@ -91,6 +102,7 @@ import java.util.List;
import java.util.Map;
import java.util.Locale;
import java.util.UUID;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -112,6 +124,7 @@ import eu.siacs.conversations.entities.DownloadableFile;
import eu.siacs.conversations.entities.Message.FileParams;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.MucOptions;
import eu.siacs.conversations.entities.Reaction;
import eu.siacs.conversations.entities.Roster;
import eu.siacs.conversations.entities.RtpSessionStatus;
import eu.siacs.conversations.entities.Transferable;
@ -296,9 +309,9 @@ public class MessageAdapter extends ArrayAdapter<Message> {
fileSize = params.size != null ? UIHelper.filesizeToString(params.size) : null;
if (message.getStatus() == Message.STATUS_SEND_FAILED
|| (transferable != null
&& (transferable.getStatus() == Transferable.STATUS_FAILED
|| transferable.getStatus()
== Transferable.STATUS_CANCELLED))) {
&& (transferable.getStatus() == Transferable.STATUS_FAILED
|| transferable.getStatus()
== Transferable.STATUS_CANCELLED))) {
error = true;
} else {
error = message.getStatus() == Message.STATUS_SEND_FAILED;
@ -498,10 +511,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
if (makeEdits && end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
body.insert(end, "\n");
body.setSpan(
new DividerSpan(false),
end,
end + ("\n".equals(body.subSequence(end + 1, end + 2).toString()) ? 2 : 1),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
new DividerSpan(false),
end,
end + ("\n".equals(body.subSequence(end + 1, end + 2).toString()) ? 2 : 1),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
final DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
@ -593,27 +606,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
private SpannableStringBuilder getSpannableBody(final Message message) {
Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), R.drawable.ic_photo_24dp, null);
return message.getMergedBody((cid) -> {
try {
DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
if (f == null || !f.canRead()) {
if (!message.trusted() && !message.getConversation().canInferPresence()) return null;
try {
new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
} catch (final NoSuchAlgorithmException | URISyntaxException e) { }
return null;
}
Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
if (d == null) {
new ThumbnailTask().execute(f);
}
return d;
} catch (final IOException e) {
return null;
}
}, fallbackImg);
return message.getMergedBody(new Thumbnailer(message), fallbackImg);
}
private void displayTextMessage(
@ -903,8 +896,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
if (lastUpdate != null && (lastUpdate.getSummary() != null || lastUpdate.getDocument() != null)) {
viewHolder.messageBody.setVisibility(View.VISIBLE);
viewHolder.messageBody.setText(
(lastUpdate.getDocument() == null ? "" : lastUpdate.getDocument() + "\n") +
(lastUpdate.getSummary() == null ? "" : lastUpdate.getSummary())
(lastUpdate.getDocument() == null ? "" : lastUpdate.getDocument() + "\n") +
(lastUpdate.getSummary() == null ? "" : lastUpdate.getSummary())
);
}
}
@ -1447,7 +1440,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
if (event.getAction() == MotionEvent.ACTION_UP) {
if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
MessageAdapter.this.mOnMessageBoxClickedListener
.onContactPictureClicked(message);
.onContactPictureClicked(message);
}
}
@ -1583,6 +1576,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
setTextColor(viewHolder.messageBody, bubbleColor);
viewHolder.messageBody.setLinkTextColor(bubbleToOnSurfaceColor(viewHolder.messageBody, bubbleColor));
final Function<Reaction, GetThumbnailForCid> reactionThumbnailer = (r) -> new Thumbnailer(conversation.getAccount(), r, conversation.canInferPresence());
if (type == RECEIVED) {
if (!muted && commands != null && conversation instanceof Conversation) {
CommandButtonAdapter adapter = new CommandButtonAdapter(activity);
@ -1616,16 +1610,20 @@ public class MessageAdapter extends ArrayAdapter<Message> {
CryptoHelper.encryptionTypeToText(message.getEncryption()));
}
}
final var aggregatedReactions = conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message, reactionThumbnailer) : message.getAggregatedReactions();
BindingAdapters.setReactionsOnReceived(
viewHolder.reactions,
conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message) : message.getAggregatedReactions(),
aggregatedReactions,
reactions -> sendReactions(message, reactions),
emoji -> sendCustomReaction(message, emoji),
() -> addReaction(message));
} else if (type == SENT) {
final var aggregatedReactions = conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message, reactionThumbnailer) : message.getAggregatedReactions();
BindingAdapters.setReactionsOnReceived(
viewHolder.reactions,
conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message) : message.getAggregatedReactions(),
aggregatedReactions,
reactions -> sendReactions(message, reactions),
emoji -> sendCustomReaction(message, emoji),
() -> addReaction(message));
}
@ -1701,6 +1699,13 @@ public class MessageAdapter extends ArrayAdapter<Message> {
Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
}
private void sendCustomReaction(final Message inReplyTo, final EmojiSearch.CustomEmoji emoji) {
final var message = inReplyTo.reply();
message.appendBody(emoji.toInsert());
Message.configurePrivateMessage(message);
new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
}
private void addReaction(final Message message) {
activity.addReaction(message, reactions -> activity.xmppConnectionService.sendReactions(message,reactions));
}
@ -1728,8 +1733,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
public void openDownloadable(Message message) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
&& ContextCompat.checkSelfPermission(
activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ConversationFragment.registerPendingMessage(activity, message);
ActivityCompat.requestPermissions(
activity,
@ -1915,6 +1920,47 @@ public class MessageAdapter extends ArrayAdapter<Message> {
protected ChipGroup reactions;
}
class Thumbnailer implements GetThumbnailForCid {
final Account account;
final boolean canFetch;
final Jid counterpart;
public Thumbnailer(final Message message) {
account = message.getConversation().getAccount();
canFetch = message.trusted() || message.getConversation().canInferPresence();
counterpart = message.getCounterpart();
}
public Thumbnailer(final Account account, final Reaction reaction, final boolean allowFetch) {
canFetch = allowFetch;
counterpart = reaction.from;
this.account = account;
}
@Override
public Drawable getThumbnail(Cid cid) {
try {
DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
if (f == null || !f.canRead()) {
if (!canFetch) return null;
try {
new BobTransfer(BobTransfer.uri(cid), account, counterpart, activity.xmppConnectionService).start();
} catch (final NoSuchAlgorithmException | URISyntaxException e) { }
return null;
}
Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
if (d == null) {
new ThumbnailTask().execute(f);
}
return d;
} catch (final IOException e) {
return null;
}
}
}
class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {
@Override
protected Drawable[] doInBackground(DownloadableFile... params) {

View file

@ -14,9 +14,13 @@ import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.chip.Chip;
import com.google.android.material.color.MaterialColors;
import com.google.common.collect.Lists;
import com.google.common.io.CharStreams;
import io.ipfs.cid.Cid;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.Comparable;
@ -24,6 +28,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.PriorityQueue;
import java.util.Set;
import java.util.TreeSet;
@ -39,6 +44,7 @@ import org.json.JSONObject;
import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.EmojiSearchRowBinding;
import eu.siacs.conversations.entities.DownloadableFile;
import eu.siacs.conversations.utils.ReplacingSerialSingleThreadExecutor;
public class EmojiSearch {
@ -156,6 +162,19 @@ public class EmojiSearch {
return new SpannableStringBuilder(unicode);
}
public void setupChip(Chip chip, int count) {
if (count < 2) {
chip.setText(unicode);
} else {
chip.setText(String.format(Locale.ENGLISH, "%s %d", unicode, count));
}
}
@Override
public String toString() {
return unicode;
}
public String uniquePart() {
return unicode;
}
@ -173,10 +192,15 @@ public class EmojiSearch {
return uniquePart().equals(((Emoji) o).uniquePart());
}
@Override
public int hashCode() {
return uniquePart().hashCode();
}
}
public static class CustomEmoji extends Emoji {
protected final String source;
public final String source;
protected final Drawable icon;
public CustomEmoji(final String shortcode, final String source, final Drawable icon, final String tag) {
@ -185,21 +209,40 @@ public class EmojiSearch {
if (tag != null) tags.add(tag);
this.source = source;
this.icon = icon;
if (icon == null) {
throw new IllegalArgumentException("icon must not be null");
}
}
public SpannableStringBuilder toInsert() {
SpannableStringBuilder builder = new SpannableStringBuilder(":" + shortcodes.get(0) + ":");
builder.setSpan(new InlineImageSpan(icon, source), 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableStringBuilder builder = new SpannableStringBuilder(toString());
if (icon != null) builder.setSpan(new InlineImageSpan(icon, source), 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return builder;
}
public void setupChip(Chip chip, int count) {
if (icon == null) {
chip.setChipIconResource(R.drawable.ic_photo_24dp);
chip.setChipIconTint(
MaterialColors.getColorStateListOrNull(
chip.getContext(),
com.google.android.material.R.attr.colorOnSurface));
} else {
SpannableStringBuilder builder = new SpannableStringBuilder("😇"); // needs to be same size as an emoji
if (icon != null) builder.setSpan(new InlineImageSpan(icon, source), 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
chip.setText(builder); // We cannot use icon because it is a hardware bitmap
}
if (count > 1) {
chip.append(String.format(Locale.ENGLISH, " %d", count));
}
}
@Override
public String uniquePart() {
return source;
}
@Override
public String toString() {
return ":" + shortcodes.get(0) + ":";
}
}
public class EmojiSearchAdapter extends ListAdapter<Emoji, EmojiSearchAdapter.ViewHolder> {