Skip to content

Commit

Permalink
Iniital bob-custom-emoji support
Browse files Browse the repository at this point in the history
Store and use XHTML-IM, replacing any BoB-URIs with ni:/// URIs (or
optionally /.well-known/ni/ paths). Fetch content for bob items when
message first comes in.

Open questions: how to trigger retry for failed fetch?
TODO: don't fetch from untrusted JID and thus reveal presence.
Question: how to trigger retry for fetch after permissions of JID
change?
Question: how to let UI know we have the image now and it can try to
fetch the data again/redraw?
  • Loading branch information
singpolyma committed Oct 11, 2024
1 parent b483f8e commit a4a243c
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 0 deletions.
3 changes: 3 additions & 0 deletions snikket/Chat.hx
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,9 @@ class Channel extends Chat {
for (m in messageList.messages) {
switch (m) {
case ChatMessageStanza(message):
for (hash in message.inlineHashReferences()) {
client.fetchMediaByHash([hash], [message.from]);
}
promises.push(new thenshim.Promise((resolve, reject) -> {
persistence.storeMessage(client.accountId(), message, resolve);
}));
Expand Down
43 changes: 43 additions & 0 deletions snikket/ChatMessage.hx
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,53 @@ class ChatMessage {
Reflect.setField(this, "localId", null);
}

@:allow(snikket)
private function inlineHashReferences(): Array<Hash> {
final result = [];
final htmlBody = payloads.find((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html")?.getChild("body", "http://www.w3.org/1999/xhtml");
if (htmlBody != null) {
htmlBody.traverse(child -> {
if (child.name == "img") {
final src = child.attr.get("src");
if (src != null) {
final hash = Hash.fromUri(src);
if (hash != null) {
final x:Hash = hash;
result.push(x);
}
}
return true;
}
return false;
});
}

return result;
}

/**
Get HTML version of the message body
WARNING: this is possibly untrusted HTML. You must parse or sanitize appropriately!
**/
public function html():String {
final htmlBody = payloads.find((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html")?.getChild("body", "http://www.w3.org/1999/xhtml");
if (htmlBody != null) {
return htmlBody.getChildren().map(el -> el.traverse(child -> {
if (child.name == "img") {
final src = child.attr.get("src");
if (src != null) {
final hash = Hash.fromUri(src);
if (hash != null) {
child.attr.set("src", hash.toUri());
}
}
return true;
}
return false;
}).serialize()).join("");
}

final codepoints = StringUtil.codepointArray(text ?? "");
// TODO: not every app will implement every feature. How should the app tell us what fallbacks to handle?
final fallbacks: Array<{start: Int, end: Int}> = cast payloads.filter(
Expand Down
35 changes: 35 additions & 0 deletions snikket/Client.hx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import snikket.EventHandler;
import snikket.PubsubEvent;
import snikket.Stream;
import snikket.jingle.Session;
import snikket.queries.BoB;
import snikket.queries.DiscoInfoGet;
import snikket.queries.DiscoItemsGet;
import snikket.queries.ExtDiscoGet;
Expand Down Expand Up @@ -200,6 +201,9 @@ class Client extends EventEmitter {
final message = Message.fromStanza(stanza, this.jid);
switch (message.parsed) {
case ChatMessageStanza(chatMessage):
for (hash in chatMessage.inlineHashReferences()) {
fetchMediaByHash([hash], [chatMessage.from]);
}
var chat = getChat(chatMessage.chatId());
if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId());
if (chat != null) {
Expand Down Expand Up @@ -1011,6 +1015,37 @@ class Client extends EventEmitter {
stream.sendStanza(new Stanza("inactive", { xmlns: "urn:xmpp:csi:0" }));
}

@:allow(snikket)
private function fetchMediaByHash(hashes: Array<Hash>, counterparts: Array<JID>) {
// TODO: only for counterparts who can infer our presence
// So MUCs, roster entires, anyone we've sent a message to in the past (from this client?)
if (hashes.length < 1 || counterparts.length < 1) return thenshim.Promise.reject("no counterparts left");
return fetchMediaByHashOneCounterpart(hashes, counterparts[0]).then(x -> x, (_) -> fetchMediaByHash(hashes, counterparts.slice(1)));
}

private function fetchMediaByHashOneCounterpart(hashes: Array<Hash>, counterpart: JID) {
if (hashes.length < 1) return thenshim.Promise.reject("no hashes left");

return new thenshim.Promise((resolve, reject) ->
persistence.hasMedia(hashes[0].algorithm, hashes[0].hash, resolve)
).then (has -> {
if (has) return thenshim.Promise.resolve(null);

return new thenshim.Promise((resolve, reject) -> {
final q = BoB.forHash(counterpart.asString(), hashes[0]);
q.onFinished(() -> {
final r = q.getResult();
if (r == null) {
reject("bad or no result from BoB query");
} else {
persistence.storeMedia(r.type, r.bytes.getData(), () -> resolve(null));
}
});
sendQuery(q);
}).then(x -> x, (_) -> fetchMediaByHashOneCounterpart(hashes.slice(1), counterpart));
});
}

@:allow(snikket)
private function chatActivity(chat: Chat, trigger = true) {
if (chat.uiState == Closed) {
Expand Down
5 changes: 5 additions & 0 deletions snikket/Message.hx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ class Message {
msg.payloads.push(unstyled);
}

final html = stanza.getChild("html", "http://jabber.org/protocol/xhtml-im");
if (html != null) {
msg.payloads.push(html);
}

final reply = stanza.getChild("reply", "urn:xmpp:reply:0");
if (reply != null) {
final replyToJid = reply.attr.get("to");
Expand Down
21 changes: 21 additions & 0 deletions snikket/Stanza.hx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ typedef NodeList = Array<Node>;
private interface NodeInterface {
public function serialize():String;
public function clone():NodeInterface;
public function traverse(f: (Stanza)->Bool):NodeInterface;
}

class TextNode implements NodeInterface {
Expand All @@ -32,6 +33,10 @@ class TextNode implements NodeInterface {
public function clone():TextNode {
return new TextNode(this.content);
}

public function traverse(f: (Stanza)->Bool) {
return this;
}
}

@:expose
Expand Down Expand Up @@ -206,6 +211,13 @@ class Stanza implements NodeInterface {
return allTags()[0];
}

public function getChildren():Array<NodeInterface> {
return children.map(child -> switch(child) {
case Element(el): el;
case CData(text): text;
});
}

public function getChild(?name:Null<String>, ?xmlns:Null<String>):Null<Stanza> {
var ourXmlns = this.attr.get("xmlns");
/*
Expand Down Expand Up @@ -294,6 +306,15 @@ class Stanza implements NodeInterface {
case _: null;
};
}

public function traverse(f: (Stanza)->Bool) {
if (!f(this)) {
for (child in allTags()) {
child.traverse(f);
}
}
return this;
}
}

enum IqRequestType {
Expand Down

0 comments on commit a4a243c

Please sign in to comment.