git » sdk » commit f287e7b

Handle read/unread for each chat

author Stephen Paul Weber
2023-11-01 15:57:33 UTC
committer Stephen Paul Weber
2023-11-08 21:16:35 UTC
parent 305477dad3cd470edad1b80a9beb4fc7a25f7f19

Handle read/unread for each chat

Store it in bookmark (at least for MUC for now) and in local storage,
sync it with open/closed state (to re-open a chat with a new message).

xmpp/Chat.hx +91 -0
xmpp/Client.hx +47 -22
xmpp/Persistence.hx +1 -0
xmpp/persistence/browser.js +41 -0

diff --git a/xmpp/Chat.hx b/xmpp/Chat.hx
index c14ffe1..1593ce5 100644
--- a/xmpp/Chat.hx
+++ b/xmpp/Chat.hx
@@ -31,6 +31,8 @@ abstract class Chat {
 	private var displayName:String;
 	public var uiState = Open;
 	private var extensions: Stanza;
+	private var _unreadCount = 0;
+	private var lastMessage: Null<ChatMessage>;
 
 	public function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null) {
 		this.client = client;
@@ -56,6 +58,14 @@ abstract class Chat {
 
 	abstract public function close():Void;
 
+	abstract public function markReadUpTo(message: ChatMessage):Void;
+
+	abstract public function lastMessageId():Null<String>;
+
+	public function lastMessageTimestamp():Null<String> {
+		return lastMessage?.timestamp;
+	}
+
 	public function updateFromBookmark(item: Stanza) {
 		final conf = item.getChild("conference", "urn:xmpp:bookmarks:1");
 		final fn = conf.attr.get("name");
@@ -68,6 +78,27 @@ abstract class Chat {
 		callback(Color.defaultPhoto(chatId, getDisplayName().charAt(0)));
 	}
 
+	public function readUpTo() {
+		final displayed = extensions.getChild("displayed", "urn:xmpp:chat-markers:0");
+		return displayed?.attr?.get("id");
+	}
+
+	public function unreadCount() {
+		return _unreadCount;
+	}
+
+	public function setUnreadCount(count:Int) {
+		_unreadCount = count;
+	}
+
+	public function preview() {
+		return lastMessage?.text ?? "";
+	}
+
+	public function setLastMessage(message:Null<ChatMessage>) {
+		lastMessage = message;
+	}
+
 	public function setDisplayName(fn:String) {
 		this.displayName = fn;
 	}
@@ -242,6 +273,37 @@ class DirectChat extends Chat {
 			message.to = recipient;
 			client.sendStanza(message.asStanza());
 		}
+		setLastMessage(message);
+		client.trigger("chats/update", [this]);
+	}
+
+	public function lastMessageId() {
+		return lastMessage?.localId ?? lastMessage?.serverId;
+	}
+
+	public function markReadUpTo(message: ChatMessage) {
+		if (readUpTo() == message.localId || readUpTo() == message.serverId) return;
+		final upTo = message.localId ?? message.serverId;
+		_unreadCount = 0; // TODO
+		for (recipient in getParticipants()) {
+			// TODO: extended addressing when relevant
+			final stanza = new Stanza("message", { to: recipient, id: ID.long() })
+				.tag("displayed", { xmlns: "urn:xmpp:chat-markers:0", id: upTo }).up();
+			if (message.threadId != null) {
+				stanza.textTag("thread", message.threadId);
+			}
+			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]);
 	}
 
 	public function bookmark() {
@@ -406,6 +468,35 @@ class Channel extends Chat {
 		message.recipients = [message.to];
 		persistence.storeMessage(client.accountId(), message);
 		client.sendStanza(message.asStanza("groupchat"));
+		setLastMessage(message);
+		client.trigger("chats/update", [this]);
+	}
+
+	public function lastMessageId() {
+		return lastMessage?.serverId;
+	}
+
+	public function markReadUpTo(message: ChatMessage) {
+		if (readUpTo() == message.serverId) return;
+		final upTo = message.serverId;
+		_unreadCount = 0;
+		final stanza = new Stanza("message", { to: chatId, id: ID.long() })
+			.tag("displayed", { xmlns: "urn:xmpp:chat-markers:0", id: upTo }).up();
+		if (message.threadId != null) {
+			stanza.textTag("thread", message.threadId);
+		}
+		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]);
 	}
 
 	public function bookmark() {
diff --git a/xmpp/Client.hx b/xmpp/Client.hx
index c16a852..0b8388f 100644
--- a/xmpp/Client.hx
+++ b/xmpp/Client.hx
@@ -65,15 +65,25 @@ class Client extends xmpp.EventEmitter {
 			for (protoChat in protoChats) {
 				chats.push(protoChat.toChat(this, stream, persistence));
 			}
-			this.trigger("chats/update", chats);
-
-			persistence.getLogin(jid, (login) -> {
-				if (login.token == null) {
-					stream.on("auth/password-needed", (data)->this.trigger("auth/password-needed", { jid: this.jid }));
-				} else {
-					stream.on("auth/password-needed", (data)->this.stream.trigger("auth/password", { password: login.token }));
+			persistence.getChatsUnreadDetails(accountId(), chats, (details) -> {
+				for (detail in details) {
+					var chat = getChat(detail.chatId);
+					if (chat != null) {
+						chat.setLastMessage(detail.message);
+						chat.setUnreadCount(detail.unreadCount);
+					}
 				}
-				stream.connect(login.clientId == null ? jid : jid + "/" + login.clientId);
+				chats.sort((a, b) -> -Reflect.compare(a.lastMessageTimestamp() ?? "0", b.lastMessageTimestamp() ?? "0"));
+				this.trigger("chats/update", chats);
+
+				persistence.getLogin(jid, (login) -> {
+					if (login.token == null) {
+						stream.on("auth/password-needed", (data)->this.trigger("auth/password-needed", { jid: this.jid }));
+					} else {
+						stream.on("auth/password-needed", (data)->this.stream.trigger("auth/password", { password: login.token }));
+					}
+					stream.connect(login.clientId == null ? jid : jid + "/" + login.clientId);
+				});
 			});
 		});
 	}
@@ -140,8 +150,11 @@ class Client extends xmpp.EventEmitter {
 				var chat = getChat(chatMessage.chatId());
 				if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId());
 				if (chat != null) {
-					chatActivity(chat);
 					chatMessage = chat.prepareIncomingMessage(chatMessage, stanza);
+					chat.setLastMessage(chatMessage);
+					chat.setUnreadCount(chatMessage.isIncoming() ? chat.unreadCount() + 1 : 0);
+					if (chatMessage.serverId != null) persistence.storeMessage(accountId(), chatMessage);
+					chatActivity(chat);
 					for (handler in chatMessageHandlers) {
 						handler(chatMessage);
 					}
@@ -315,18 +328,32 @@ class Client extends xmpp.EventEmitter {
 		});
 
 		// Enable carbons
-		stream.sendStanza(
+		sendStanza(
 			new Stanza("iq", { type: "set", id: ID.short() })
 				.tag("enable", { xmlns: "urn:xmpp:carbons:2" })
 				.up()
 		);
 
 		rosterGet();
-		bookmarksGet();
-		sync(() -> {
-			// Set self to online
-			sendPresence();
-			this.trigger("status/online", {});
+		bookmarksGet(() -> {
+			sync(() -> {
+				persistence.getChatsUnreadDetails(accountId(), chats, (details) -> {
+					for (detail in details) {
+						var chat = getChat(detail.chatId) ?? getDirectChat(detail.chatId, false);
+						final initialLastId = chat.lastMessageId();
+						chat.setLastMessage(detail.message);
+						chat.setUnreadCount(detail.unreadCount);
+						if (detail.unreadCount > 0 && initialLastId != chat.lastMessageId()) {
+							chatActivity(chat, false);
+						}
+					}
+					chats.sort((a, b) -> -Reflect.compare(a.lastMessageTimestamp() ?? "0", b.lastMessageTimestamp() ?? "0"));
+					this.trigger("chats/update", chats);
+					// Set self to online
+					sendPresence();
+					this.trigger("status/online", {});
+				});
+			});
 		});
 
 		return EventHandled;
@@ -444,7 +471,7 @@ class Client extends xmpp.EventEmitter {
 		}
 	}
 
-	public function chatActivity(chat: Chat) {
+	public function chatActivity(chat: Chat, trigger = true) {
 		if (chat.uiState == Closed) {
 			chat.uiState = Open;
 			persistence.storeChat(accountId(), chat);
@@ -453,7 +480,7 @@ class Client extends xmpp.EventEmitter {
 		if (idx > 0) {
 			chats.splice(idx, 1);
 			chats.unshift(chat);
-			this.trigger("chats/update", [chat]);
+			if (trigger) this.trigger("chats/update", [chat]);
 		}
 	}
 
@@ -545,7 +572,8 @@ class Client extends xmpp.EventEmitter {
 		sendQuery(rosterGet);
 	}
 
-	private function bookmarksGet() {
+	// 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 pubsubGet = new PubsubGet(null, "urn:xmpp:bookmarks:1");
 		pubsubGet.onFinished(() -> {
 			for (item in pubsubGet.getResult()) {
@@ -561,7 +589,6 @@ class Client extends xmpp.EventEmitter {
 									final chat = getDirectChat(item.attr.get("id"), false);
 									chat.updateFromBookmark(item);
 									persistence.storeChat(accountId(), chat);
-									this.trigger("chats/update", [chat]);
 								}
 							} else {
 								persistence.storeCaps(resultCaps);
@@ -576,12 +603,10 @@ class Client extends xmpp.EventEmitter {
 									chat.updateFromBookmark(item);
 									chats.unshift(chat);
 									persistence.storeChat(accountId(), chat);
-									this.trigger("chats/update", [chat]);
 								} else {
 									final chat = getDirectChat(item.attr.get("id"), false);
 									chat.updateFromBookmark(item);
 									persistence.storeChat(accountId(), chat);
-									this.trigger("chats/update", [chat]);
 								}
 							}
 						});
@@ -589,10 +614,10 @@ class Client extends xmpp.EventEmitter {
 					} else {
 						chat.updateFromBookmark(item);
 						persistence.storeChat(accountId(), chat);
-						this.trigger("chats/update", [chat]);
 					}
 				}
 			}
+			callback();
 		});
 		sendQuery(pubsubGet);
 	}
diff --git a/xmpp/Persistence.hx b/xmpp/Persistence.hx
index 3ef9b06..8dc9d14 100644
--- a/xmpp/Persistence.hx
+++ b/xmpp/Persistence.hx
@@ -8,6 +8,7 @@ abstract class Persistence {
 	abstract public function lastId(accountId: String, chatId: Null<String>, callback:(serverId:Null<String>)->Void):Void;
 	abstract public function storeChat(accountId: String, chat: Chat):Void;
 	abstract public function getChats(accountId: String, callback: (chats:Array<SerializedChat>)->Void):Void;
+	abstract public function getChatsUnreadDetails(accountId: String, chats: Array<Chat>, callback: (details:Array<{ chatId: String, message: ChatMessage, unreadCount: Int }>)->Void):Void;
 	abstract public function storeMessage(accountId: String, message: ChatMessage):Void;
 	abstract public function getMessages(accountId: String, chatId: String, beforeId: Null<String>, beforeTime: Null<String>, callback: (messages:Array<ChatMessage>)->Void):Void;
 	abstract public function getMediaUri(hashAlgorithm:String, hash:BytesData, callback: (uri:Null<String>)->Void):Void;
diff --git a/xmpp/persistence/browser.js b/xmpp/persistence/browser.js
index 90bd6e9..d26443b 100644
--- a/xmpp/persistence/browser.js
+++ b/xmpp/persistence/browser.js
@@ -129,6 +129,47 @@ exports.xmpp.persistence = {
 				))));
 			},
 
+			getChatsUnreadDetails: function(account, chatsArray, callback) {
+				const tx = db.transaction(["messages"], "readonly");
+				const store = tx.objectStore("messages");
+
+				const cursor = store.index("chats").openCursor(
+					IDBKeyRange.bound([account], [account, [], []]),
+					"prev"
+				);
+				const chats = {};
+				chatsArray.forEach((chat) => chats[chat.chatId] = chat);
+				const result = {};
+				var rowCount = 0;
+				cursor.onsuccess = (event) => {
+					if (event.target.result && rowCount < 1000) {
+						rowCount++;
+						const value = event.target.result.value;
+						if (result[value.chatId]) {
+							if (!result[value.chatId].foundAll) {
+								const readUpTo = chats[value.chatId]?.readUpTo();
+								if (readUpTo === value.serverId || readUpTo === value.localId || value.direction == "MessageSent") {
+									result[value.chatId].foundAll = true;
+								} else {
+									result[value.chatId].unreadCount++;
+								}
+							}
+						} else {
+							const readUpTo = chats[value.chatId]?.readUpTo();
+							const haveRead = readUpTo === value.serverId || readUpTo === value.localId || value.direction == "MessageSent";
+							result[value.chatId] = { chatId: value.chatId, message: hydrateMessage(value), unreadCount: haveRead ? 0 : 1, foundAll: haveRead };
+						}
+						event.target.result.continue();
+					} else {
+						callback(Object.values(result));
+					}
+				}
+				cursor.onerror = (event) => {
+					console.error(event);
+					callback([]);
+				}
+			},
+
 			storeMessage: function(account, message) {
 				const tx = db.transaction(["messages"], "readwrite");
 				const store = tx.objectStore("messages");