| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2024-06-25 15:47:58 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2024-06-25 15:47:58 UTC |
| parent | a1e29028fd052cd8b301faf69c38e2513d862122 |
| snikket/Chat.hx | +88 | -31 |
| snikket/Client.hx | +74 | -26 |
| snikket/persistence/Sqlite.hx | +8 | -2 |
| snikket/persistence/browser.js | +4 | -0 |
diff --git a/snikket/Chat.hx b/snikket/Chat.hx index 6d8228a..a5822e8 100644 --- a/snikket/Chat.hx +++ b/snikket/Chat.hx @@ -51,15 +51,20 @@ abstract class Chat { private var extensions: Stanza; private var _unreadCount = 0; private var lastMessage: Null<ChatMessage>; + private var readUpToId: Null<String>; + @:allow(snikket) + private var readUpToBy: Null<String>; @:allow(snikket) - private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null) { + private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null) { this.client = client; this.stream = stream; this.persistence = persistence; this.chatId = chatId; this.uiState = uiState; this.extensions = extensions ?? new Stanza("extensions", { xmlns: "urn:xmpp:bookmarks:1" }); + this.readUpToId = readUpToId; + this.readUpToBy = readUpToBy; this.displayName = chatId; } @@ -189,8 +194,7 @@ abstract class Chat { An ID of the last message displayed to the user **/ public function readUpTo() { - final displayed = extensions.getChild("displayed", "urn:xmpp:chat-markers:0"); - return displayed?.attr?.get("id"); + return readUpToId; } /** @@ -377,6 +381,68 @@ abstract class Chat { public function videoTracks(): Array<MediaStreamTrack> { return jingleSessions.flatMap((session) -> session.videoTracks()); } + + @:allow(snikket) + private function markReadUpToId(upTo: String, upToBy: String, ?callback: ()->Void) { + if (upTo == null) return; + + readUpToId = upTo; + readUpToBy = upToBy; + persistence.storeChat(client.accountId(), this); + persistence.getMessages(client.accountId(), chatId, null, null, (messages) -> { + var i = messages.length; + while (--i >= 0) { + if (messages[i].serverId == readUpToId) break; + } + if (i > 0) _unreadCount = messages.length - (i + 1); + if (callback != null) callback(); + }); + } + + private function publishMds() { + stream.sendIq( + new Stanza("iq", { type: "set" }) + .tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) + .tag("publish", { node: "urn:xmpp:mds:displayed:0" }) + .tag("item", { id: chatId }) + .tag("displayed", { xmlns: "urn:xmpp:mds:displayed:0"}) + .tag("stanza-id", { xmlns: "urn:xmpp:sid:0", id: readUpTo(), by: readUpToBy }) + .up().up().up() + .tag("publish-options") + .tag("x", { xmlns: "jabber:x:data", type: "submit" }) + .tag("field", { "var": "FORM_TYPE", type: "hidden" }).textTag("value", "http://jabber.org/protocol/pubsub#publish-options").up() + .tag("field", { "var": "pubsub#persist_items" }).textTag("value", "true").up() + .tag("field", { "var": "pubsub#max_items" }).textTag("value", "max").up() + .tag("field", { "var": "pubsub#send_last_published_item" }).textTag("value", "never").up() + .tag("field", { "var": "pubsub#access_model" }).textTag("value", "whitelist").up() + .up().up(), + (response) -> { + if (response.attr.get("type") == "error") { + final preconditionError = response.getChild("error")?.getChild("precondition-not-met", "http://jabber.org/protocol/pubsub#errors"); + if (preconditionError != null) { + // publish options failed, so force them to be right, what a silly workflow + stream.sendIq( + new Stanza("iq", { type: "set" }) + .tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub#owner" }) + .tag("configure", { node: "urn:xmpp:mds:displayed:0" }) + .tag("x", { xmlns: "jabber:x:data", type: "submit" }) + .tag("field", { "var": "FORM_TYPE", type: "hidden" }).textTag("value", "http://jabber.org/protocol/pubsub#publish-options").up() + .tag("field", { "var": "pubsub#persist_items" }).textTag("value", "true").up() + .tag("field", { "var": "pubsub#max_items" }).textTag("value", "max").up() + .tag("field", { "var": "pubsub#send_last_published_item" }).textTag("value", "never").up() + .tag("field", { "var": "pubsub#access_model" }).textTag("value", "whitelist").up() + .up().up().up(), + (response) -> { + if (response.attr.get("type") == "result") { + publishMds(); + } + } + ); + } + } + } + ); + } } @:expose @@ -386,8 +452,8 @@ abstract class Chat { #end class DirectChat extends Chat { @:allow(snikket) - private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null) { - super(client, stream, persistence, chatId, uiState, extensions); + private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null) { + super(client, stream, persistence, chatId, uiState, extensions, readUpToId, readUpToBy); } @HaxeCBridge.noemit // on superclass as abstract @@ -523,7 +589,6 @@ class DirectChat extends Chat { public function markReadUpTo(message: ChatMessage) { if (readUpTo() == message.localId || readUpTo() == message.serverId) return; final upTo = message.localId ?? message.serverId; - _unreadCount = 0; // TODO if (upTo == null) return; // Can't mark as read with no id for (recipient in getParticipants()) { // TODO: extended addressing when relevant @@ -535,15 +600,10 @@ class DirectChat extends Chat { client.sendStanza(stanza); } - var displayed = extensions.getChild("displayed", "urn:xmpp:chat-markers:0"); - if (displayed == null) { - displayed = new Stanza("displayed", { xmlns: "urn:xmpp:chat-markers:0", id: upTo }); - extensions.addChild(displayed); - } else { - displayed.attr.set("id", upTo); - } - persistence.storeChat(client.accountId(), this); - client.trigger("chats/update", [this]); + markReadUpToId(message.serverId, message.serverIdBy, () -> { + publishMds(); + client.trigger("chats/update", [this]); + }); } @HaxeCBridge.noemit // on superclass as abstract @@ -581,8 +641,8 @@ class Channel extends Chat { private var inSync = true; @:allow(snikket) - private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions = null, ?disco: Caps) { - super(client, stream, persistence, chatId, uiState, extensions); + private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions = null, readUpToId = null, readUpToBy = null, ?disco: Caps) { + super(client, stream, persistence, chatId, uiState, extensions, readUpToId, readUpToBy); if (disco != null) this.disco = disco; } @@ -876,7 +936,6 @@ class Channel extends Chat { public function markReadUpTo(message: ChatMessage) { if (readUpTo() == message.serverId) return; final upTo = message.serverId; - _unreadCount = 0; // TODO if (upTo == null) return; // Can't mark as read with no id final stanza = new Stanza("message", { to: chatId, id: ID.long(), type: "groupchat" }) .tag("displayed", { xmlns: "urn:xmpp:chat-markers:0", id: upTo }).up(); @@ -885,16 +944,10 @@ class Channel extends Chat { } client.sendStanza(stanza); - var displayed = extensions.getChild("displayed", "urn:xmpp:chat-markers:0"); - if (displayed == null) { - displayed = new Stanza("displayed", { xmlns: "urn:xmpp:chat-markers:0", id: upTo }); - extensions.addChild(displayed); - } else { - displayed.attr.set("id", upTo); - } - persistence.storeChat(client.accountId(), this); - bookmark(); // TODO: what if not previously bookmarked? - client.trigger("chats/update", [this]); + markReadUpToId(upTo, message.serverIdBy, () -> { + publishMds(); + client.trigger("chats/update", [this]); + }); } @HaxeCBridge.noemit // on superclass as abstract @@ -1004,10 +1057,12 @@ class SerializedChat { public final displayName:Null<String>; public final uiState:String; public final extensions:String; + public final readUpToId:Null<String>; + public final readUpToBy:Null<String>; public final disco:Null<Caps>; public final klass:String; - public function new(chatId: String, trusted: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<String>, extensions: Null<String>, disco: Null<Caps>, klass: String) { + public function new(chatId: String, trusted: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<String>, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, disco: Null<Caps>, klass: String) { this.chatId = chatId; this.trusted = trusted; this.avatarSha1 = avatarSha1; @@ -1015,6 +1070,8 @@ class SerializedChat { this.displayName = displayName; this.uiState = uiState ?? "Open"; this.extensions = extensions ?? "<extensions xmlns='urn:app:bookmarks:1' />"; + this.readUpToId = readUpToId; + this.readUpToBy = readUpToBy; this.disco = disco; this.klass = klass; } @@ -1029,9 +1086,9 @@ class SerializedChat { final extensionsStanza = Stanza.fromXml(Xml.parse(extensions)); final chat = if (klass == "DirectChat") { - new DirectChat(client, stream, persistence, chatId, uiStateEnum, extensionsStanza); + new DirectChat(client, stream, persistence, chatId, uiStateEnum, extensionsStanza, readUpToId, readUpToBy); } else if (klass == "Channel") { - final channel = new Channel(client, stream, persistence, chatId, uiStateEnum, extensionsStanza); + final channel = new Channel(client, stream, persistence, chatId, uiStateEnum, extensionsStanza, readUpToId, readUpToBy); channel.disco = disco ?? new Caps("", [], ["http://jabber.org/protocol/muc"]); channel; } else { diff --git a/snikket/Client.hx b/snikket/Client.hx index 15fcaed..3adb2ca 100644 --- a/snikket/Client.hx +++ b/snikket/Client.hx @@ -56,6 +56,8 @@ class Client extends EventEmitter { "http://jabber.org/protocol/caps", "urn:xmpp:avatar:metadata+notify", "http://jabber.org/protocol/nick+notify", + "urn:xmpp:bookmarks:1+notify", + "urn:xmpp:mds:displayed:0+notify", "urn:xmpp:jingle-message:0", "urn:xmpp:jingle:1", "urn:xmpp:jingle:apps:dtls:0", @@ -225,6 +227,23 @@ class Client extends EventEmitter { updateDisplayName(pubsubEvent.getItems()[0].getChildText("nick", "http://jabber.org/protocol/nick")); } + if (pubsubEvent != null && pubsubEvent.getFrom() != null && JID.parse(pubsubEvent.getFrom()).asBare().asString() == accountId() && pubsubEvent.getNode() == "urn:xmpp:mds:displayed:0" && pubsubEvent.getItems().length > 0) { + for (item in pubsubEvent.getItems()) { + if (item.attr.get("id") != null) { + final upTo = item.getChild("displayed", "urn:xmpp:mds:displayed:0")?.getChild("stanza-id", "urn:xmpp:sid:0"); + final chat = getChat(item.attr.get("id")); + if (chat == null) { + startChatWith(item.attr.get("id"), (caps) -> Closed, (chat) -> chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by"))); + } else { + chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by"), () -> { + persistence.storeChat(accountId(), chat); + this.trigger("chats/update", [chat]); + }); + } + } + } + } + return EventUnhandled; // Allow others to get this event as well }); @@ -941,45 +960,74 @@ class Client extends EventEmitter { sendQuery(rosterGet); } + private function startChatWith(jid: String, handleCaps: (Caps)->UiState, handleChat: (Chat)->Void) { + final discoGet = new DiscoInfoGet(jid); + discoGet.onFinished(() -> { + final resultCaps = discoGet.getResult(); + if (resultCaps == null) { + final err = discoGet.responseStanza?.getChild("error")?.getChild(null, "urn:ietf:params:xml:ns:xmpp-stanzas"); + if (err == null || err?.name == "service-unavailable" || err?.name == "feature-not-implemented") { + final chat = getDirectChat(jid, false); + handleChat(chat); + persistence.storeChat(accountId(), chat); + } + } else { + persistence.storeCaps(resultCaps); + final uiState = handleCaps(resultCaps); + if (resultCaps.isChannel(jid)) { + final chat = new Channel(this, this.stream, this.persistence, jid, uiState, null, resultCaps); + handleChat(chat); + chats.unshift(chat); + persistence.storeChat(accountId(), chat); + } else { + final chat = getDirectChat(jid, false); + handleChat(chat); + persistence.storeChat(accountId(), chat); + } + } + }); + sendQuery(discoGet); + } + // This is called right before we're going to trigger for all chats anyway, so don't bother with single triggers private function bookmarksGet(callback: ()->Void) { + final mdsGet = new PubsubGet(null, "urn:xmpp:mds:displayed:0"); + mdsGet.onFinished(() -> { + for (item in mdsGet.getResult()) { + if (item.attr.get("id") != null) { + final upTo = item.getChild("displayed", "urn:xmpp:mds:displayed:0")?.getChild("stanza-id", "urn:xmpp:sid:0"); + final chat = getChat(item.attr.get("id")); + if (chat == null) { + startChatWith(item.attr.get("id"), (caps) -> Closed, (chat) -> chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by"))); + } else { + chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by")); + persistence.storeChat(accountId(), chat); + } + } + } + }); + sendQuery(mdsGet); + final pubsubGet = new PubsubGet(null, "urn:xmpp:bookmarks:1"); pubsubGet.onFinished(() -> { for (item in pubsubGet.getResult()) { if (item.attr.get("id") != null) { final chat = getChat(item.attr.get("id")); if (chat == null) { - final discoGet = new DiscoInfoGet(item.attr.get("id")); - discoGet.onFinished(() -> { - final resultCaps = discoGet.getResult(); - if (resultCaps == null) { - final err = discoGet.responseStanza?.getChild("error")?.getChild(null, "urn:ietf:params:xml:ns:xmpp-stanzas"); - if (err == null || err?.name == "service-unavailable" || err?.name == "feature-not-implemented") { - final chat = getDirectChat(item.attr.get("id"), false); - chat.updateFromBookmark(item); - persistence.storeChat(accountId(), chat); - } - } else { - persistence.storeCaps(resultCaps); - final identity = resultCaps.identities[0]; + startChatWith( + item.attr.get("id"), + (caps) -> { + final identity = caps.identities[0]; final conf = item.getChild("conference", "urn:xmpp:bookmarks:1"); if (conf.attr.get("name") == null) { conf.attr.set("name", identity?.name); } - if (resultCaps.isChannel(item.attr.get("id"))) { - final uiState = (conf.attr.get("autojoin") == "1" || conf.attr.get("autojoin") == "true") ? Open : Closed; - final chat = new Channel(this, this.stream, this.persistence, item.attr.get("id"), uiState, null, resultCaps); - chat.updateFromBookmark(item); - chats.unshift(chat); - persistence.storeChat(accountId(), chat); - } else { - final chat = getDirectChat(item.attr.get("id"), false); - chat.updateFromBookmark(item); - persistence.storeChat(accountId(), chat); - } + return (conf.attr.get("autojoin") == "1" || conf.attr.get("autojoin") == "true" || !caps.isChannel(item.attr.get("id"))) ? Open : Closed; + }, + (chat) -> { + chat.updateFromBookmark(item); } - }); - sendQuery(discoGet); + ); } else { chat.updateFromBookmark(item); persistence.storeChat(accountId(), chat); diff --git a/snikket/persistence/Sqlite.hx b/snikket/persistence/Sqlite.hx index e929542..579a84b 100644 --- a/snikket/persistence/Sqlite.hx +++ b/snikket/persistence/Sqlite.hx @@ -59,6 +59,8 @@ class Sqlite implements Persistence { fn TEXT, ui_state TEXT, extensions TEXT, + read_up_to_id TEXT, + read_up_to_by TEXT, class TEXT NOT NULL, PRIMARY KEY (account_id, chat_id) );"); @@ -125,6 +127,10 @@ class Sqlite implements Persistence { q.add(","); db.addValue(q, chat.extensions); q.add(","); + db.addValue(q, chat.readUpTo()); + q.add(","); + db.addValue(q, chat.readUpToBy); + q.add(","); db.addValue(q, Type.getClassName(Type.getClass(chat)).split(".").pop()); q.add(");"); db.request(q.toString()); @@ -135,12 +141,12 @@ class Sqlite implements Persistence { // TODO: presence // TODO: disco final q = new StringBuf(); - q.add("SELECT chat_id, trusted, avatar_sha1, fn, ui_state, extensions, class FROM chats WHERE account_id="); + q.add("SELECT chat_id, trusted, avatar_sha1, fn, ui_state, extensions, read_up_to_id, read_up_to_by, class FROM chats WHERE account_id="); db.addValue(q, accountId); final result = db.request(q.toString()); final chats = []; for (row in result) { - chats.push(new SerializedChat(row.chat_id, row.trusted, row.avatar_sha1, [], row.fn, row.ui_state, row.extensions, null, Reflect.field(row, "class"))); + chats.push(new SerializedChat(row.chat_id, row.trusted, row.avatar_sha1, [], row.fn, row.ui_state, row.extensions, row.read_up_to_id, row.read_up_to_by, null, Reflect.field(row, "class"))); } callback(chats); } diff --git a/snikket/persistence/browser.js b/snikket/persistence/browser.js index ba0075e..a18fcfa 100644 --- a/snikket/persistence/browser.js +++ b/snikket/persistence/browser.js @@ -186,6 +186,8 @@ const browser = (dbname) => { displayName: chat.displayName, uiState: chat.uiState, extensions: chat.extensions?.toString(), + readUpToId: chat.readUpToId, + readUpToBy: chat.readUpToBy, disco: chat.disco, class: chat instanceof snikket.DirectChat ? "DirectChat" : (chat instanceof snikket.Channel ? "Channel" : "Chat") }); @@ -207,6 +209,8 @@ const browser = (dbname) => { r.displayName, r.uiState, r.extensions, + r.readUpToId, + r.readUpToBy, r.disco, r.class )));