Skip to content

Commit

Permalink
New media strategy
Browse files Browse the repository at this point in the history
Return ni:/// URIs (or, optionally, /.well-known/ni/ paths) for media.
Provide a helper to serve the .well-known paths from a service worker.

No more option to get media URIs from the persistence layer, instead
each layer will provide a fit-for-platform mechanism to map ni URIs to
data, file paths, whatever make sense in context. For web that's
Response object or service worker interception.

Avatars are now two-part: the possible-null ni URI and the never-null
placeholder. Display the placeholder while loading the real avatar data,
or if it is null or loading it fails for any reason.
  • Loading branch information
singpolyma committed Oct 11, 2024
1 parent 6a377c1 commit b483f8e
Show file tree
Hide file tree
Showing 13 changed files with 191 additions and 59 deletions.
3 changes: 3 additions & 0 deletions npm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ export import Chat = snikket.Chat;
export import ChatAttachment = snikket.ChatAttachment;
export import ChatMessage = snikket.ChatMessage;
export import Client = snikket.Client;
export import Config = snikket.Config;
export import DirectChat = snikket.DirectChat;
export import Hash = snikket.Hash;
export import Identicon = snikket.Identicon;
export import Identity = snikket.Identity;
export import Notification = snikket.Notification;
export import Participant = snikket.Participant;
export import SerializedChat = snikket.SerializedChat;
export import jingle = snikket.jingle;

Expand Down
43 changes: 19 additions & 24 deletions snikket/Chat.hx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package snikket;

import haxe.io.Bytes;
import haxe.io.BytesData;
import snikket.Chat;
import snikket.ChatMessage;
Expand Down Expand Up @@ -169,9 +170,8 @@ abstract class Chat {
Get the details for one participant in this Chat
@param participantId the ID of the participant to look up
@param callback takes two arguments, the display name and the photo URI
**/
abstract public function getParticipantDetails(participantId:String, callback:(String, String)->Void):Void;
abstract public function getParticipantDetails(participantId: String):Participant;

/**
Correct an already-send message by replacing it with a new one
Expand Down Expand Up @@ -293,22 +293,18 @@ abstract class Chat {
}

/**
Get an image to represent this Chat
Get the URI image to represent this Chat, or null
**/
public function getPhoto(): Null<String> {
if (avatarSha1 == null || Bytes.ofData(avatarSha1).length < 1) return null;
return new Hash("sha-1", avatarSha1).toUri();
}

@param callback takes one argument, the URI to the image
/**
Get the URI to a placeholder image to represent this Chat
**/
public function getPhoto(callback:(String)->Void) {
if (avatarSha1 != null) {
persistence.getMediaUri("sha-1", avatarSha1, (uri) -> {
if (uri != null) {
callback(uri);
} else {
callback(Color.defaultPhoto(chatId, getDisplayName().charAt(0)));
}
});
} else {
callback(Color.defaultPhoto(chatId, getDisplayName().charAt(0)));
}
public function getPlaceholder(): String {
return Color.defaultPhoto(chatId, getDisplayName().charAt(0).toUpperCase());
}

/**
Expand Down Expand Up @@ -587,9 +583,9 @@ class DirectChat extends Chat {
}

@HaxeCBridge.noemit // on superclass as abstract
public function getParticipantDetails(participantId:String, callback:(String, String)->Void) {
public function getParticipantDetails(participantId:String): Participant {
final chat = client.getDirectChat(participantId);
chat.getPhoto((photoUri) -> callback(chat.getDisplayName(), photoUri));
return new Participant(chat.getDisplayName(), chat.getPhoto(), chat.getPlaceholder());
}

@HaxeCBridge.noemit // on superclass as abstract
Expand Down Expand Up @@ -993,15 +989,14 @@ class Channel extends Chat {
}

@HaxeCBridge.noemit // on superclass as abstract
public function getParticipantDetails(participantId:String, callback:(String, String)->Void) {
public function getParticipantDetails(participantId:String): Participant {
if (participantId == getFullJid().asString()) {
client.getDirectChat(client.accountId(), false).getPhoto((photoUri) -> {
callback(client.displayName(), photoUri);
});
final chat = client.getDirectChat(client.accountId(), false);
return new Participant(client.displayName(), chat.getPhoto(), chat.getPlaceholder());
} else {
final nick = JID.parse(participantId).resource;
final photoUri = Color.defaultPhoto(participantId, nick == null ? " " : nick.charAt(0));
callback(nick, photoUri);
final placeholderUri = Color.defaultPhoto(participantId, nick == null ? " " : nick.charAt(0));
return new Participant(client.displayName(), null, placeholderUri);
}
}

Expand Down
11 changes: 6 additions & 5 deletions snikket/ChatMessage.hx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import haxe.io.Bytes;
import haxe.io.BytesData;
import haxe.Exception;
using Lambda;
using StringTools;

#if cpp
import HaxeCBridge;
#end

import snikket.Hash;
import snikket.JID;
import snikket.Identicon;
import snikket.StringUtil;
Expand All @@ -28,16 +30,15 @@ class ChatAttachment {
public final mime: String;
public final size: Null<Int>;
public final uris: Array<String>;
@HaxeCBridge.noemit
public final hashes: Array<{algo:String, hash:BytesData}>;
public final hashes: Array<Hash>;

#if cpp
@:allow(snikket)
private
#else
public
#end
function new(name: Null<String>, mime: String, size: Null<Int>, uris: Array<String>, hashes: Array<{algo:String, hash:BytesData}>) {
function new(name: Null<String>, mime: String, size: Null<Int>, uris: Array<String>, hashes: Array<Hash>) {
this.name = name;
this.mime = mime;
this.size = size;
Expand Down Expand Up @@ -160,7 +161,7 @@ class ChatMessage {
var size = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/size#");
if (size == null) size = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/size#");
final hashes = ((sims.getChild("file", "urn:xmpp:jingle:apps:file-transfer:5") ?? sims.getChild("file", "urn:xmpp:jingle:apps:file-transfer:3"))
?.allTags("hash", "urn:xmpp:hashes:2") ?? []).map((hash) -> { algo: hash.attr.get("algo") ?? "", hash: Base64.decode(hash.getText()).getData() });
?.allTags("hash", "urn:xmpp:hashes:2") ?? []).map((hash) -> new Hash(hash.attr.get("algo") ?? "", Base64.decode(hash.getText()).getData()));
final sources = sims.getChild("sources");
final uris = (sources?.allTags("reference", "urn:xmpp:reference:0") ?? []).map((ref) -> ref.attr.get("uri") ?? "").filter((uri) -> uri != "");
if (uris.length > 0) attachments.push(new ChatAttachment(name, mime, size == null ? null : Std.parseInt(size), uris, hashes));
Expand Down Expand Up @@ -331,7 +332,7 @@ class ChatMessage {
stanza.textTag("media-type", attachment.mime);
if (attachment.size != null) stanza.textTag("size", Std.string(attachment.size));
for (hash in attachment.hashes) {
stanza.textTag("hash", Base64.encode(Bytes.ofData(hash.hash)), { xmlns: "urn:xmpp:hashes:2", algo: hash.algo });
stanza.textTag("hash", Base64.encode(Bytes.ofData(hash.hash)), { xmlns: "urn:xmpp:hashes:2", algo: hash.algorithm });
}
stanza.up();

Expand Down
20 changes: 10 additions & 10 deletions snikket/Client.hx
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,10 @@ class Client extends EventEmitter {
final chat = this.getDirectChat(JID.parse(pubsubEvent.getFrom()).asBare().asString(), false);
chat.setAvatarSha1(avatarSha1);
persistence.storeChat(accountId(), chat);
persistence.getMediaUri("sha-1", avatarSha1, (uri) -> {
if (uri == null) {
persistence.hasMedia("sha-1", avatarSha1, (has) -> {
if (has) {
this.trigger("chats/update", [chat]);
} else {
final pubsubGet = new PubsubGet(pubsubEvent.getFrom(), "urn:xmpp:avatar:data", avatarSha1Hex);
pubsubGet.onFinished(() -> {
final item = pubsubGet.getResult()[0];
Expand All @@ -270,8 +272,6 @@ class Client extends EventEmitter {
});
});
sendQuery(pubsubGet);
} else {
this.trigger("chats/update", [chat]);
}
});
}
Expand Down Expand Up @@ -434,8 +434,10 @@ class Client extends EventEmitter {
final avatarSha1 = Bytes.ofHex(avatarSha1Hex).getData();
chat.setAvatarSha1(avatarSha1);
persistence.storeChat(accountId(), chat);
persistence.getMediaUri("sha-1", avatarSha1, (uri) -> {
if (uri == null) {
persistence.hasMedia("sha-1", avatarSha1, (has) -> {
if (has) {
if (chat.livePresence()) this.trigger("chats/update", [chat]);
} else {
final vcardGet = new VcardTempGet(from);
vcardGet.onFinished(() -> {
final vcard = vcardGet.getResult();
Expand All @@ -445,8 +447,6 @@ class Client extends EventEmitter {
});
});
sendQuery(vcardGet);
} else {
if (chat.livePresence()) this.trigger("chats/update", [chat]);
}
});
}
Expand Down Expand Up @@ -648,15 +648,15 @@ class Client extends EventEmitter {
return tink.streams.Stream.Handled.Resume;
}).handle((o) -> switch o {
case Depleted:
prepareAttachmentFor(source, services, [{ algo: "sha-256", hash: sha256.digest().getData() }], callback);
prepareAttachmentFor(source, services, [new Hash("sha-256", sha256.digest().getData())], callback);
default:
trace("Error computing attachment hash", o);
callback(null);
});
});
}

private function prepareAttachmentFor(source: js.html.File, services: Array<{ serviceId: String }>, hashes: Array<{algo: String, hash: BytesData}>, callback: (Null<ChatAttachment>)->Void) {
private function prepareAttachmentFor(source: js.html.File, services: Array<{ serviceId: String }>, hashes: Array<Hash>, callback: (Null<ChatAttachment>)->Void) {
if (services.length < 1) {
callback(null);
return;
Expand Down
12 changes: 12 additions & 0 deletions snikket/Config.hx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package snikket;

@:expose
class Config {
/**
Produce /.well-known/ni/ paths instead of ni:/// URIs
for referencing media by hash.
This can be useful eg for intercepting with a Service Worker.
**/
public static var relativeHashUri = false;
}
70 changes: 70 additions & 0 deletions snikket/Hash.hx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package snikket;

import haxe.crypto.Base64;
import haxe.io.Bytes;
import haxe.io.BytesData;
using StringTools;

import snikket.Config;

#if cpp
import HaxeCBridge;
#end

@:expose
@:nullSafety(Strict)
#if cpp
@:build(HaxeCBridge.expose())
@:build(HaxeSwiftBridge.expose())
#end
class Hash {
public final algorithm: String;
@:allow(snikket)
private final hash: BytesData;

@:allow(snikket)
private function new(algorithm: String, hash: BytesData) {
this.algorithm = algorithm;
this.hash = hash;
}

public static function fromHex(algorithm: String, hash: String) {
return new Hash(algorithm, Bytes.ofHex(hash).getData());
}

public static function fromUri(uri: String): Null<Hash> {
if (uri.startsWith("cid:") && uri.endsWith("@bob.xmpp.org") && uri.contains("+")) {
final parts = uri.substr(4).split("@")[0].split("+");
final algo = parts[0] == "sha1" ? "sha-1" : parts[0];
return Hash.fromHex(algo, parts[1]);
} if (uri.startsWith("ni:///") && uri.contains(";")) {
final parts = uri.substring(6).split(';');
return new Hash(parts[0], Base64.urlDecode(parts[1]).getData());
} else if (uri.startsWith("/.well-known/ni/")) {
final parts = uri.substring(16).split('/');
return new Hash(parts[0], Base64.urlDecode(parts[1]).getData());
}

return null;
}

public function toUri() {
if (Config.relativeHashUri) {
return "/.well-known/ni/" + algorithm.urlEncode() + "/" + toBase64Url();
} else {
return "ni:///" + algorithm.urlEncode() + ";" + toBase64Url();
}
}

public function toHex() {
return Bytes.ofData(hash).toHex();
}

public function toBase64() {
return Base64.encode(Bytes.ofData(hash));
}

public function toBase64Url() {
return Base64.urlEncode(Bytes.ofData(hash));
}
}
24 changes: 24 additions & 0 deletions snikket/Participant.hx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package snikket;

#if cpp
import HaxeCBridge;
#end

@:expose
@:nullSafety(Strict)
#if cpp
@:build(HaxeCBridge.expose())
@:build(HaxeSwiftBridge.expose())
#end
class Participant {
public final displayName: String;
public final photoUri: Null<String>;
public final placeholderUri: String;

@:allow(snikket)
private function new(displayName: String, photoUri: Null<String>, placeholderUri: String) {
this.displayName = displayName;
this.photoUri = photoUri;
this.placeholderUri = placeholderUri;
}
}
2 changes: 1 addition & 1 deletion snikket/Persistence.hx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ interface Persistence {
public function getMessagesBefore(accountId: String, chatId: String, beforeId: Null<String>, beforeTime: Null<String>, callback: (messages:Array<ChatMessage>)->Void):Void;
public function getMessagesAfter(accountId: String, chatId: String, afterId: Null<String>, afterTime: Null<String>, callback: (messages:Array<ChatMessage>)->Void):Void;
public function getMessagesAround(accountId: String, chatId: String, aroundId: Null<String>, aroundTime: Null<String>, callback: (messages:Array<ChatMessage>)->Void):Void;
public function getMediaUri(hashAlgorithm:String, hash:BytesData, callback: (uri:Null<String>)->Void):Void;
public function hasMedia(hashAlgorithm:String, hash:BytesData, callback: (has:Bool)->Void):Void;
public function storeMedia(mime:String, bytes:BytesData, callback: ()->Void):Void;
public function storeCaps(caps:Caps):Void;
public function getCaps(ver:String, callback: (Null<Caps>)->Void):Void;
Expand Down
5 changes: 5 additions & 0 deletions snikket/persistence/Custom.hx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ class Custom implements Persistence {
backing.getMediaUri(hashAlgorithm, hash, callback);
}

@HaxeCBridge.noemit
public function hasMedia(hashAlgorithm:String, hash:BytesData, callback: (Bool)->Void) {
backing.hasMedia(hashAlgorithm, hash, callback);
}

@HaxeCBridge.noemit
public function storeMedia(mime:String, bd:BytesData, callback: ()->Void) {
backing.storeMedia(mime, bd, callback);
Expand Down
5 changes: 5 additions & 0 deletions snikket/persistence/Dummy.hx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ class Dummy implements Persistence {
callback(null);
}

@HaxeCBridge.noemit
public function hasMedia(hashAlgorithm:String, hash:BytesData, callback: (Bool)->Void) {
callback(false);
}

@HaxeCBridge.noemit
public function storeMedia(mime:String, bd:BytesData, callback: ()->Void) {
callback();
Expand Down
5 changes: 5 additions & 0 deletions snikket/persistence/Sqlite.hx
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,11 @@ class Sqlite implements Persistence {
}
}

@HaxeCBridge.noemit
public function hasMedia(hashAlgorithm:String, hash:BytesData, callback: (Bool)->Void) {
getMediaUri(hashAlgorithm, hash, (uri) -> callback(uri != null));
}

@HaxeCBridge.noemit
public function storeMedia(mime:String, bd:BytesData, callback: ()->Void) {
final bytes = Bytes.ofData(bd);
Expand Down
Loading

0 comments on commit b483f8e

Please sign in to comment.