diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bf1568f..808ddb5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,9 +45,14 @@ jobs: haxelib --quiet install sha haxelib --quiet install thenshim haxelib --quiet install hxcpp - haxelib --quiet install hxtsdgen + haxelib --quiet git hxtsdgen https://github.com/singpolyma/hxtsdgen haxelib --quiet install utest + - name: NPM Dependencies + run: | + cd npm + npm i + - name: Tests run: make test @@ -62,12 +67,12 @@ jobs: libsnikket.so cpp/snikket.h + - name: NPM Tarball + run: tar -cjf npm.tar.gz npm/ + - name: JS Artifact uses: actions/upload-artifact@v4 with: - name: browser.js + name: npm.tar.gz path: | - browser.js - browser.haxe.d.ts - browser.haxe-enums.ts - + npm.tar.gz diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bed90d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +npm/package-lock.json +npm/*.d.ts +npm/snikket.js +npm/snikket-enums.ts +npm/snikket-enums.js +npm/index.js +node_modules diff --git a/Makefile b/Makefile index 59caa45..87de915 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,25 @@ HAXE_PATH=$$HOME/Software/haxe-4.3.1/hxnodejs/12,1,0/src -.PHONY: all test cpp/output.dso browser.js +.PHONY: all test cpp/output.dso npm/snikket.js -all: browser.js libsnikket.so +all: npm libsnikket.so test: haxe test.hxml -browser.js: - haxe browser.hxml - echo "var exports = {};" > browser.js - cat snikket/persistence/*.js >> browser.js - echo "export const { snikket } = exports;" >> browser.js +npm/snikket.js: + haxe js.hxml + sed -i 's/import { snikket }/import { snikket as enums }/' npm/snikket.d.ts + sed -i 's/snikket\.UiState/enums.UiState/g' npm/snikket.d.ts + sed -i 's/snikket\.MessageStatus/enums.MessageStatus/g' npm/snikket.d.ts + sed -i 's/snikket\.MessageDirection/enums.MessageDirection/g' npm/snikket.d.ts + sed -i '1ivar exports = {};' npm/snikket.js + echo "export const snikket = exports.snikket;" >> npm/snikket.js + cd npm && npx tsc --esModuleInterop --lib esnext,dom --target esnext --preserveConstEnums -d index.ts + sed -i '1iimport { snikket as enums } from "./snikket-enums";' npm/index.js + +npm: npm/snikket.js snikket/persistence/browser.js + cp snikket/persistence/browser.js npm cpp/output.dso: haxe cpp.hxml diff --git a/browser.hxml b/js.hxml similarity index 90% rename from browser.hxml rename to js.hxml index 7d2de8b..05d4971 100644 --- a/browser.hxml +++ b/js.hxml @@ -11,4 +11,4 @@ snikket.Push -D js-es=6 -D hxtsdgen_enums_ts -D hxtsdgen_namespaced ---js browser.haxe.js +--js npm/snikket.js diff --git a/npm/index.ts b/npm/index.ts new file mode 100644 index 0000000..5a964cb --- /dev/null +++ b/npm/index.ts @@ -0,0 +1,27 @@ +import browserp from "./browser"; +import { snikket as enums } from "./snikket-enums"; +import { snikket } from "./snikket"; + +// TODO: should we autogenerate this? +export import AvailableChat = snikket.AvailableChat; +export import Caps = snikket.Caps; +export import Channel = snikket.Channel; +export import Chat = snikket.Chat; +export import ChatAttachment = snikket.ChatAttachment; +export import ChatMessage = snikket.ChatMessage; +export import Client = snikket.Client; +export import DirectChat = snikket.DirectChat; +export import Identicon = snikket.Identicon; +export import Identity = snikket.Identity; +export import Notification = snikket.Notification; +export import SerializedChat = snikket.SerializedChat; +export import jingle = snikket.jingle; + +export import UiState = enums.UiState; +export import MessageStatus = enums.MessageStatus; +export import MessageDirection = enums.MessageDirection; + +export namespace persistence { + export import browser = browserp; + export import Dummy = snikket.persistence.Dummy; +} diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 0000000..7068fb9 --- /dev/null +++ b/npm/package.json @@ -0,0 +1,21 @@ +{ + "name": "snikket-sdk", + "version": "0.0.0", + "description": "Chat SDK", + "main": "index.js", + "files": [ + "*.js", + "*.ts" + ], + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "Apache-2.0", + "dependencies": { + "sasl-scram-sha-1": "github:singpolyma/js-sasl-scram-sha-1" + }, + "devDependencies": { + "typescript": "^5.4.5" + } +} diff --git a/snikket/Chat.hx b/snikket/Chat.hx index d417d96..4e938f0 100644 --- a/snikket/Chat.hx +++ b/snikket/Chat.hx @@ -23,15 +23,6 @@ enum abstract UiState(Int) { var Closed; // Archived } -#if js -@:expose("UiState") -class UiStateImpl { - static public final Pinned = UiState.Pinned; - static public final Open = UiState.Open; - static public final Closed = UiState.Closed; -} -#end - #if cpp @:build(HaxeCBridge.expose()) @:build(HaxeSwiftBridge.expose()) diff --git a/snikket/Message.hx b/snikket/Message.hx index bee5488..66f66d2 100644 --- a/snikket/Message.hx +++ b/snikket/Message.hx @@ -7,14 +7,6 @@ enum abstract MessageDirection(Int) { var MessageSent; } -#if js -@:expose("MessageDirection") -class MessageDirectionImpl { - static public final MessageReceived = MessageDirection.MessageReceived; - static public final MessageSent = MessageDirection.MessageSent; -} -#end - enum abstract MessageStatus(Int) { var MessagePending; // Message is waiting in client for sending var MessageDeliveredToServer; // Server acknowledged receipt of the message @@ -22,16 +14,6 @@ enum abstract MessageStatus(Int) { var MessageFailedToSend; // There was an error sending this message } -#if js -@:expose("MessageStatus") -class MessageStatusImpl { - static public final MessagePending = MessageStatus.MessagePending; - static public final MessageDeliveredToServer = MessageStatus.MessageDeliveredToServer; - static public final MessageDeliveredToDevice = MessageStatus.MessageDeliveredToDevice; - static public final MessageFailedToSend = MessageStatus.MessageFailedToSend; -} -#end - enum MessageStanza { ErrorMessageStanza(stanza: Stanza); ChatMessageStanza(message: ChatMessage); diff --git a/snikket/persistence/browser.js b/snikket/persistence/browser.js index e800db3..d4ac934 100644 --- a/snikket/persistence/browser.js +++ b/snikket/persistence/browser.js @@ -1,529 +1,534 @@ // This example persistence driver is written in JavaScript // so that SDK users can easily see how to write their own -exports.snikket.persistence = { - browser: (dbname) => { - var db = null; - function openDb(version) { - var dbOpenReq = indexedDB.open(dbname, version); - dbOpenReq.onerror = console.error; - dbOpenReq.onupgradeneeded = (event) => { - const upgradeDb = event.target.result; - if (!db.objectStoreNames.contains("messages")) { - const messages = upgradeDb.createObjectStore("messages", { keyPath: ["account", "serverId", "serverIdBy", "localId"] }); - messages.createIndex("chats", ["account", "chatId", "timestamp"]); - messages.createIndex("localId", ["account", "localId", "chatId"]); - messages.createIndex("accounts", ["account", "timestamp"]); - } - if (!db.objectStoreNames.contains("keyvaluepairs")) { - upgradeDb.createObjectStore("keyvaluepairs"); - } - if (!db.objectStoreNames.contains("chats")) { - upgradeDb.createObjectStore("chats", { keyPath: ["account", "chatId"] }); - } - if (!db.objectStoreNames.contains("services")) { - upgradeDb.createObjectStore("services", { keyPath: ["account", "serviceId"] }); - } - if (!db.objectStoreNames.contains("reactions")) { - const reactions = upgradeDb.createObjectStore("reactions", { keyPath: ["account", "chatId", "senderId", "updateId"] }); - reactions.createIndex("senders", ["account", "chatId", "messageId", "senderId", "timestamp"]); - } - }; - dbOpenReq.onsuccess = (event) => { - db = event.target.result; - if (!db.objectStoreNames.contains("messages") || !db.objectStoreNames.contains("keyvaluepairs") || !db.objectStoreNames.contains("chats") || !db.objectStoreNames.contains("services") || !db.objectStoreNames.contains("reactions")) { - db.close(); - openDb(db.version + 1); - return; - } - }; - } - openDb(); +import { snikket as enums } from "./snikket-enums"; +import { snikket } from "./snikket"; + +const browser = (dbname) => { + var db = null; + function openDb(version) { + var dbOpenReq = indexedDB.open(dbname, version); + dbOpenReq.onerror = console.error; + dbOpenReq.onupgradeneeded = (event) => { + const upgradeDb = event.target.result; + if (!db.objectStoreNames.contains("messages")) { + const messages = upgradeDb.createObjectStore("messages", { keyPath: ["account", "serverId", "serverIdBy", "localId"] }); + messages.createIndex("chats", ["account", "chatId", "timestamp"]); + messages.createIndex("localId", ["account", "localId", "chatId"]); + messages.createIndex("accounts", ["account", "timestamp"]); + } + if (!db.objectStoreNames.contains("keyvaluepairs")) { + upgradeDb.createObjectStore("keyvaluepairs"); + } + if (!db.objectStoreNames.contains("chats")) { + upgradeDb.createObjectStore("chats", { keyPath: ["account", "chatId"] }); + } + if (!db.objectStoreNames.contains("services")) { + upgradeDb.createObjectStore("services", { keyPath: ["account", "serviceId"] }); + } + if (!db.objectStoreNames.contains("reactions")) { + const reactions = upgradeDb.createObjectStore("reactions", { keyPath: ["account", "chatId", "senderId", "updateId"] }); + reactions.createIndex("senders", ["account", "chatId", "messageId", "senderId", "timestamp"]); + } + }; + dbOpenReq.onsuccess = (event) => { + db = event.target.result; + if (!db.objectStoreNames.contains("messages") || !db.objectStoreNames.contains("keyvaluepairs") || !db.objectStoreNames.contains("chats") || !db.objectStoreNames.contains("services") || !db.objectStoreNames.contains("reactions")) { + db.close(); + openDb(db.version + 1); + return; + } + }; + } + openDb(); - var cache = null; - caches.open(dbname).then((c) => cache = c); + var cache = null; + caches.open(dbname).then((c) => cache = c); - function mkNiUrl(hashAlgorithm, hashBytes) { - const b64url = btoa(Array.from(new Uint8Array(hashBytes), (x) => String.fromCodePoint(x)).join("")).replace(/\+/, "-").replace(/\//, "_").replace(/=/, ""); - return "/.well-known/ni/" + hashAlgorithm + "/" + b64url; - } + function mkNiUrl(hashAlgorithm, hashBytes) { + const b64url = btoa(Array.from(new Uint8Array(hashBytes), (x) => String.fromCodePoint(x)).join("")).replace(/\+/, "-").replace(/\//, "_").replace(/=/, ""); + return "/.well-known/ni/" + hashAlgorithm + "/" + b64url; + } - function promisifyRequest(request) { - return new Promise((resolve, reject) => { - request.oncomplete = request.onsuccess = () => resolve(request.result); - request.onabort = request.onerror = () => reject(request.error); - }); - } + function promisifyRequest(request) { + return new Promise((resolve, reject) => { + request.oncomplete = request.onsuccess = () => resolve(request.result); + request.onabort = request.onerror = () => reject(request.error); + }); + } - async function hydrateMessage(value) { - if (!value) return null; + async function hydrateMessage(value) { + if (!value) return null; + + const tx = db.transaction(["messages"], "readonly"); + const store = tx.objectStore("messages"); + let replyToMessage = value.replyToMessage && await hydrateMessage((await promisifyRequest(store.openCursor(IDBKeyRange.only(value.replyToMessage))))?.value); + + const message = new snikket.ChatMessage(); + message.localId = value.localId ? value.localId : null; + message.serverId = value.serverId ? value.serverId : null; + message.serverIdBy = value.serverIdBy ? value.serverIdBy : null; + message.syncPoint = !!value.syncPoint; + message.direction = value.direction; + message.status = value.status; + message.timestamp = value.timestamp && value.timestamp.toISOString(); + message.to = value.to && snikket.JID.parse(value.to); + message.from = value.from && snikket.JID.parse(value.from); + message.sender = value.sender && snikket.JID.parse(value.sender); + message.recipients = value.recipients.map((r) => snikket.JID.parse(r)); + message.replyTo = value.replyTo.map((r) => snikket.JID.parse(r)); + message.replyToMessage = replyToMessage; + message.threadId = value.threadId; + message.attachments = value.attachments; + message.reactions = value.reactions; + message.text = value.text; + message.lang = value.lang; + message.isGroupchat = value.isGroupchat || value.groupchat; + message.versions = await Promise.all((value.versions || []).map(hydrateMessage)); + message.payloads = (value.payloads || []).map(snikket.Stanza.parse); + return message; + } - const tx = db.transaction(["messages"], "readonly"); - const store = tx.objectStore("messages"); - let replyToMessage = value.replyToMessage && await hydrateMessage((await promisifyRequest(store.openCursor(IDBKeyRange.only(value.replyToMessage))))?.value); - - const message = new snikket.ChatMessage(); - message.localId = value.localId ? value.localId : null; - message.serverId = value.serverId ? value.serverId : null; - message.serverIdBy = value.serverIdBy ? value.serverIdBy : null; - message.syncPoint = !!value.syncPoint; - message.timestamp = value.timestamp && value.timestamp.toISOString(); - message.to = value.to && snikket.JID.parse(value.to); - message.from = value.from && snikket.JID.parse(value.from); - message.sender = value.sender && snikket.JID.parse(value.sender); - message.recipients = value.recipients.map((r) => snikket.JID.parse(r)); - message.replyTo = value.replyTo.map((r) => snikket.JID.parse(r)); - message.replyToMessage = replyToMessage; - message.threadId = value.threadId; - message.attachments = value.attachments; - message.reactions = value.reactions; - message.text = value.text; - message.lang = value.lang; - message.isGroupchat = value.isGroupchat || value.groupchat; - message.versions = await Promise.all((value.versions || []).map(hydrateMessage)); - message.payloads = (value.payloads || []).map(snikket.Stanza.parse); - return message; + function serializeMessage(account, message) { + return { + ...message, + serverId: message.serverId || "", + serverIdBy: message.serverIdBy || "", + localId: message.localId || "", + syncPoint: !!message.syncPoint, + account: account, + chatId: message.chatId(), + to: message.to?.asString(), + from: message.from?.asString(), + sender: message.sender?.asString(), + recipients: message.recipients.map((r) => r.asString()), + replyTo: message.replyTo.map((r) => r.asString()), + timestamp: new Date(message.timestamp), + replyToMessage: message.replyToMessage && [account, message.replyToMessage.serverId || "", message.replyToMessage.serverIdBy || "", message.replyToMessage.localId || ""], + versions: message.versions.map((m) => serializeMessage(account, m)), + payloads: message.payloads.map((p) => p.toString()), } + } - function serializeMessage(account, message) { - return { - ...message, - serverId: message.serverId || "", - serverIdBy: message.serverIdBy || "", - localId: message.localId || "", - syncPoint: !!message.syncPoint, - account: account, - chatId: message.chatId(), - to: message.to?.asString(), - from: message.from?.asString(), - sender: message.sender?.asString(), - recipients: message.recipients.map((r) => r.asString()), - replyTo: message.replyTo.map((r) => r.asString()), - timestamp: new Date(message.timestamp), - replyToMessage: message.replyToMessage && [account, message.replyToMessage.serverId || "", message.replyToMessage.serverIdBy || "", message.replyToMessage.localId || ""], - versions: message.versions.map((m) => serializeMessage(account, m)), - payloads: message.payloads.map((p) => p.toString()), + function correctMessage(account, message, result) { + // Newest (by timestamp) version wins for head + const newVersions = message.versions.length < 1 ? [message] : message.versions; + const storedVersions = result.value.versions || []; + // TODO: dedupe? There shouldn't be dupes... + const versions = (storedVersions.length < 1 ? [result.value] : storedVersions).concat(newVersions.map((nv) => serializeMessage(account, nv))).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + const head = {...versions[0]}; + // Can't change primary key + head.serverIdBy = result.value.serverIdBy; + head.serverId = result.value.serverId; + head.localId = result.value.localId; + head.timestamp = result.value.timestamp; // Edited version is not newer + head.versions = versions; + head.reactions = result.value.reactions; // Preserve these, edit doesn't touch them + result.update(head); + return head; + } + + function setReactions(reactionsMap, sender, reactions) { + for (const [reaction, senders] of reactionsMap) { + if (!reactions.includes(reaction) && senders.includes(sender)) { + if (senders.length === 1) { + reactionsMap.delete(reaction); + } else { + reactionsMap.set(reaction, senders.filter((asender) => asender != sender)); + } } } - - function correctMessage(account, message, result) { - // Newest (by timestamp) version wins for head - const newVersions = message.versions.length < 1 ? [message] : message.versions; - const storedVersions = result.value.versions || []; - // TODO: dedupe? There shouldn't be dupes... - const versions = (storedVersions.length < 1 ? [result.value] : storedVersions).concat(newVersions.map((nv) => serializeMessage(account, nv))).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); - const head = {...versions[0]}; - // Can't change primary key - head.serverIdBy = result.value.serverIdBy; - head.serverId = result.value.serverId; - head.localId = result.value.localId; - head.timestamp = result.value.timestamp; // Edited version is not newer - head.versions = versions; - head.reactions = result.value.reactions; // Preserve these, edit doesn't touch them - result.update(head); - return head; + for (const reaction of reactions) { + reactionsMap.set(reaction, [...new Set([...reactionsMap.get(reaction) || [], sender])].sort()); } + return reactionsMap; + } - function setReactions(reactionsMap, sender, reactions) { - for (const [reaction, senders] of reactionsMap) { - if (!reactions.includes(reaction) && senders.includes(sender)) { - if (senders.length === 1) { - reactionsMap.delete(reaction); - } else { - reactionsMap.set(reaction, senders.filter((asender) => asender != sender)); - } + return { + lastId: function(account, jid, callback) { + const tx = db.transaction(["messages"], "readonly"); + const store = tx.objectStore("messages"); + var cursor = null; + if (jid === null) { + cursor = store.index("accounts").openCursor( + IDBKeyRange.bound([account], [account, []]), + "prev" + ); + } else { + cursor = store.index("chats").openCursor( + IDBKeyRange.bound([account, jid], [account, jid, []]), + "prev" + ); + } + cursor.onsuccess = (event) => { + if (!event.target.result || (event.target.result.value.syncPoint && event.target.result.value.serverId && (jid || event.target.result.value.serverIdBy === account))) { + callback(event.target.result ? event.target.result.value.serverId : null); + } else { + event.target.result.continue(); } } - for (const reaction of reactions) { - reactionsMap.set(reaction, [...new Set([...reactionsMap.get(reaction) || [], sender])].sort()); + cursor.onerror = (event) => { + console.error(event); + callback(null); } - return reactionsMap; - } + }, - return { - lastId: function(account, jid, callback) { - const tx = db.transaction(["messages"], "readonly"); - const store = tx.objectStore("messages"); - var cursor = null; - if (jid === null) { - cursor = store.index("accounts").openCursor( - IDBKeyRange.bound([account], [account, []]), - "prev" - ); - } else { - cursor = store.index("chats").openCursor( - IDBKeyRange.bound([account, jid], [account, jid, []]), - "prev" - ); - } - cursor.onsuccess = (event) => { - if (!event.target.result || (event.target.result.value.syncPoint && event.target.result.value.serverId && (jid || event.target.result.value.serverIdBy === account))) { - callback(event.target.result ? event.target.result.value.serverId : null); - } else { - event.target.result.continue(); - } - } - cursor.onerror = (event) => { - console.error(event); - callback(null); - } - }, + storeChat: function(account, chat) { + const tx = db.transaction(["chats"], "readwrite"); + const store = tx.objectStore("chats"); - storeChat: function(account, chat) { - const tx = db.transaction(["chats"], "readwrite"); - const store = tx.objectStore("chats"); + store.put({ + account: account, + chatId: chat.chatId, + trusted: chat.trusted, + avatarSha1: chat.avatarSha1, + presence: new Map([...chat.presence.entries()].map(([k, p]) => [k, { caps: p.caps?.ver(), mucUser: p.mucUser?.toString() }])), + displayName: chat.displayName, + uiState: chat.uiState, + extensions: chat.extensions?.toString(), + disco: chat.disco, + class: chat instanceof snikket.DirectChat ? "DirectChat" : (chat instanceof snikket.Channel ? "Channel" : "Chat") + }); + }, - store.put({ - account: account, - chatId: chat.chatId, - trusted: chat.trusted, - avatarSha1: chat.avatarSha1, - presence: new Map([...chat.presence.entries()].map(([k, p]) => [k, { caps: p.caps?.ver(), mucUser: p.mucUser?.toString() }])), - displayName: chat.displayName, - uiState: chat.uiState, - extensions: chat.extensions?.toString(), - disco: chat.disco, - class: chat instanceof snikket.DirectChat ? "DirectChat" : (chat instanceof snikket.Channel ? "Channel" : "Chat") - }); - }, - - getChats: function(account, callback) { - (async () => { - const tx = db.transaction(["chats"], "readonly"); - const store = tx.objectStore("chats"); - const range = IDBKeyRange.bound([account], [account, []]); - const result = await promisifyRequest(store.getAll(range)); - return await Promise.all(result.map(async (r) => new snikket.SerializedChat( - r.chatId, - r.trusted, - r.avatarSha1, - new Map(await Promise.all((r.presence instanceof Map ? [...r.presence.entries()] : Object.entries(r.presence)).map( - async ([k, p]) => [k, new snikket.Presence(p.caps && await new Promise((resolve) => this.getCaps(p.caps, resolve)), p.mucUser && snikket.Stanza.parse(p.mucUser))] - ))), - r.displayName, - r.uiState, - r.extensions, - r.disco, - r.class - ))); - })().then(callback); - }, - - getChatsUnreadDetails: function(account, chatsArray, callback) { - const tx = db.transaction(["messages"], "readonly"); - const store = tx.objectStore("messages"); + getChats: function(account, callback) { + (async () => { + const tx = db.transaction(["chats"], "readonly"); + const store = tx.objectStore("chats"); + const range = IDBKeyRange.bound([account], [account, []]); + const result = await promisifyRequest(store.getAll(range)); + return await Promise.all(result.map(async (r) => new snikket.SerializedChat( + r.chatId, + r.trusted, + r.avatarSha1, + new Map(await Promise.all((r.presence instanceof Map ? [...r.presence.entries()] : Object.entries(r.presence)).map( + async ([k, p]) => [k, new snikket.Presence(p.caps && await new Promise((resolve) => this.getCaps(p.caps, resolve)), p.mucUser && snikket.Stanza.parse(p.mucUser))] + ))), + r.displayName, + r.uiState, + r.extensions, + r.disco, + r.class + ))); + })().then(callback); + }, + + getChatsUnreadDetails: function(account, chatsArray, callback) { + const tx = db.transaction(["messages"], "readonly"); + const store = tx.objectStore("messages"); - const cursor = store.index("accounts").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 < 40000) { - 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] = result[value.chatId].then((details) => { details.unreadCount++; return details; }); - } - } - } else { + const cursor = store.index("accounts").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 < 40000) { + rowCount++; + const value = event.target.result.value; + if (result[value.chatId]) { + if (!result[value.chatId].foundAll) { const readUpTo = chats[value.chatId]?.readUpTo(); - const haveRead = readUpTo === value.serverId || readUpTo === value.localId || value.direction == "MessageSent"; - result[value.chatId] = hydrateMessage(value).then((m) => ({ chatId: value.chatId, message: m, unreadCount: haveRead ? 0 : 1, foundAll: haveRead })); + if (readUpTo === value.serverId || readUpTo === value.localId || value.direction == enums.MessageDirection.MessageSent) { + result[value.chatId].foundAll = true; + } else { + result[value.chatId] = result[value.chatId].then((details) => { details.unreadCount++; return details; }); + } } - event.target.result.continue(); } else { - Promise.all(Object.values(result)).then(callback); + const readUpTo = chats[value.chatId]?.readUpTo(); + const haveRead = readUpTo === value.serverId || readUpTo === value.localId || value.direction == enums.MessageDirection.MessageSent; + result[value.chatId] = hydrateMessage(value).then((m) => ({ chatId: value.chatId, message: m, unreadCount: haveRead ? 0 : 1, foundAll: haveRead })); } + event.target.result.continue(); + } else { + Promise.all(Object.values(result)).then(callback); } - cursor.onerror = (event) => { - console.error(event); - callback([]); - } - }, + } + cursor.onerror = (event) => { + console.error(event); + callback([]); + } + }, - getMessage: function(account, chatId, serverId, localId, callback) { - const tx = db.transaction(["messages"], "readonly"); + getMessage: function(account, chatId, serverId, localId, callback) { + const tx = db.transaction(["messages"], "readonly"); + const store = tx.objectStore("messages"); + (async function() { + let result; + if (serverId) { + result = await promisifyRequest(store.openCursor(IDBKeyRange.bound([account, serverId], [account, serverId, []]))); + } else { + result = await promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, localId, chatId]))); + } + if (!result || !result.value) return null; + const message = result.value; + return await hydrateMessage(message); + })().then(callback); + }, + + storeReaction: function(account, update, callback) { + (async function() { + const tx = db.transaction(["messages", "reactions"], "readwrite"); const store = tx.objectStore("messages"); - (async function() { - let result; - if (serverId) { - result = await promisifyRequest(store.openCursor(IDBKeyRange.bound([account, serverId], [account, serverId, []]))); - } else { - result = await promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, localId, chatId]))); - } - if (!result || !result.value) return null; - const message = result.value; - return await hydrateMessage(message); - })().then(callback); - }, - - storeReaction: function(account, update, callback) { - (async function() { - const tx = db.transaction(["messages", "reactions"], "readwrite"); - const store = tx.objectStore("messages"); - const reactionStore = tx.objectStore("reactions"); - let result; - if (update.serverId) { - result = await promisifyRequest(store.openCursor(IDBKeyRange.bound([account, update.serverId], [account, update.serverId, []]))); - } else { - result = await promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, update.localId, update.chatId]))); - } - await promisifyRequest(reactionStore.put({...update, messageId: update.serverId || update.localId, timestamp: new Date(update.timestamp), account: account})); - if (!result || !result.value) { - return null; + const reactionStore = tx.objectStore("reactions"); + let result; + if (update.serverId) { + result = await promisifyRequest(store.openCursor(IDBKeyRange.bound([account, update.serverId], [account, update.serverId, []]))); + } else { + result = await promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, update.localId, update.chatId]))); + } + await promisifyRequest(reactionStore.put({...update, messageId: update.serverId || update.localId, timestamp: new Date(update.timestamp), account: account})); + if (!result || !result.value) { + return null; + } + const message = result.value; + const lastFromSender = promisifyRequest(reactionStore.index("senders").openCursor(IDBKeyRange.bound( + [account, update.chatId, update.serverId || update.localId, update.senderId], + [account, update.chatId, update.serverId || update.localId, update.senderId, []] + ), "prev")); + if (lastFromSender?.value && lastFromSender.value.timestamp > new Date(update.timestamp)) return; + setReactions(message.reactions, update.senderId, update.reactions); + store.put(message); + return await hydrateMessage(message); + })().then(callback); + }, + + storeMessage: function(account, message, callback) { + if (!message.chatId()) throw "Cannot store a message with no chatId"; + if (!message.serverId && !message.localId) throw "Cannot store a message with no id"; + if (!message.serverId && message.isIncoming()) throw "Cannot store an incoming message with no server id"; + if (message.serverId && !message.serverIdBy) throw "Cannot store a message with a server id and no by"; + new Promise((resolve) => + // Hydrate reply stubs + message.replyToMessage && !message.replyToMessage.serverIdBy ? this.getMessage(account, message.chatId(), message.replyToMessage?.serverId, message.replyToMessage?.localId, resolve) : resolve(message.replyToMessage) + ).then((replyToMessage) => { + message.replyToMessage = replyToMessage; + const tx = db.transaction(["messages", "reactions"], "readwrite"); + const store = tx.objectStore("messages"); + return promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, message.localId || [], message.chatId()]))).then((result) => { + if (result?.value && !message.isIncoming() && result?.value.direction === enums.MessageDirection.MessageSent) { + // Duplicate, we trust our own sent ids + return promisifyRequest(result.delete()); + } else if (result?.value && result.value.sender == message.senderId() && (message.versions.length > 0 || (result.value.versions || []).length > 0)) { + hydrateMessage(correctMessage(account, message, result)).then(callback); + return true; } - const message = result.value; - const lastFromSender = promisifyRequest(reactionStore.index("senders").openCursor(IDBKeyRange.bound( - [account, update.chatId, update.serverId || update.localId, update.senderId], - [account, update.chatId, update.serverId || update.localId, update.senderId, []] - ), "prev")); - if (lastFromSender?.value && lastFromSender.value.timestamp > new Date(update.timestamp)) return; - setReactions(message.reactions, update.senderId, update.reactions); - store.put(message); - return await hydrateMessage(message); - })().then(callback); - }, - - storeMessage: function(account, message, callback) { - if (!message.chatId()) throw "Cannot store a message with no chatId"; - if (!message.serverId && !message.localId) throw "Cannot store a message with no id"; - if (!message.serverId && message.isIncoming()) throw "Cannot store an incoming message with no server id"; - if (message.serverId && !message.serverIdBy) throw "Cannot store a message with a server id and no by"; - new Promise((resolve) => - // Hydrate reply stubs - message.replyToMessage && !message.replyToMessage.serverIdBy ? this.getMessage(account, message.chatId(), message.replyToMessage?.serverId, message.replyToMessage?.localId, resolve) : resolve(message.replyToMessage) - ).then((replyToMessage) => { - message.replyToMessage = replyToMessage; - const tx = db.transaction(["messages", "reactions"], "readwrite"); - const store = tx.objectStore("messages"); - return promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, message.localId || [], message.chatId()]))).then((result) => { - if (result?.value && !message.isIncoming() && result?.value.direction === "MessageSent") { - // Duplicate, we trust our own sent ids - return promisifyRequest(result.delete()); - } else if (result?.value && result.value.sender == message.senderId() && (message.versions.length > 0 || (result.value.versions || []).length > 0)) { - hydrateMessage(correctMessage(account, message, result)).then(callback); - return true; - } - }).then((done) => { - if (!done) { - // There may be reactions already if we are paging backwards - const cursor = tx.objectStore("reactions").index("senders").openCursor(IDBKeyRange.bound([account, message.chatId(), (message.isGroupchat ? message.serverId : message.localId) || ""], [account, message.chatId(), (message.isGroupchat ? message.serverId : message.localId) || "", []]), "prev"); - const reactions = new Map(); - const reactionTimes = new Map(); - cursor.onsuccess = (event) => { - if (event.target.result && event.target.result.value) { - const time = reactionTimes.get(event.target.result.senderId); - if (!time || time < event.target.result.value.timestamp) { - setReactions(reactions, event.target.result.value.senderId, event.target.result.value.reactions); - reactionTimes.set(event.target.result.value.senderId, event.target.result.value.timestamp); - } - event.target.result.continue(); - } else { - message.reactions = reactions; - store.put(serializeMessage(account, message)); - callback(message); + }).then((done) => { + if (!done) { + // There may be reactions already if we are paging backwards + const cursor = tx.objectStore("reactions").index("senders").openCursor(IDBKeyRange.bound([account, message.chatId(), (message.isGroupchat ? message.serverId : message.localId) || ""], [account, message.chatId(), (message.isGroupchat ? message.serverId : message.localId) || "", []]), "prev"); + const reactions = new Map(); + const reactionTimes = new Map(); + cursor.onsuccess = (event) => { + if (event.target.result && event.target.result.value) { + const time = reactionTimes.get(event.target.result.senderId); + if (!time || time < event.target.result.value.timestamp) { + setReactions(reactions, event.target.result.value.senderId, event.target.result.value.reactions); + reactionTimes.set(event.target.result.value.senderId, event.target.result.value.timestamp); } - }; - cursor.onerror = console.error; - } - }); - }); - }, - - updateMessageStatus: function(account, localId, status, callback) { - const tx = db.transaction(["messages"], "readwrite"); - const store = tx.objectStore("messages"); - promisifyRequest(store.index("localId").openCursor(IDBKeyRange.bound([account, localId], [account, localId, []]))).then((result) => { - if (result?.value && result.value.direction === "MessageSent" && result.value.status !== "MessageDeliveredToDevice") { - const newStatus = { ...result.value, status: status }; - result.update(newStatus); - hydrateMessage(newStatus).then(callback); + event.target.result.continue(); + } else { + message.reactions = reactions; + store.put(serializeMessage(account, message)); + callback(message); + } + }; + cursor.onerror = console.error; } }); - }, - - getMessages: function(account, chatId, beforeId, beforeTime, callback) { - const beforeDate = beforeTime ? new Date(beforeTime) : []; - const tx = db.transaction(["messages"], "readonly"); - const store = tx.objectStore("messages"); - const cursor = store.index("chats").openCursor( - IDBKeyRange.bound([account, chatId], [account, chatId, beforeDate]), - "prev" - ); - const result = []; - cursor.onsuccess = (event) => { - if (event.target.result && result.length < 50) { - const value = event.target.result.value; - if (value.serverId === beforeId || (value.timestamp && value.timestamp.getTime() === (beforeDate instanceof Date && beforeDate.getTime()))) { - event.target.result.continue(); - return; - } + }); + }, - result.unshift(hydrateMessage(value)); - event.target.result.continue(); - } else { - Promise.all(result).then(callback); - } - } - cursor.onerror = (event) => { - console.error(event); - callback([]); + updateMessageStatus: function(account, localId, status, callback) { + const tx = db.transaction(["messages"], "readwrite"); + const store = tx.objectStore("messages"); + promisifyRequest(store.index("localId").openCursor(IDBKeyRange.bound([account, localId], [account, localId, []]))).then((result) => { + if (result?.value && result.value.direction === enums.MessageDirection.MessageSent && result.value.status !== enums.MessageStatus.MessageDeliveredToDevice) { + const newStatus = { ...result.value, status: status }; + result.update(newStatus); + hydrateMessage(newStatus).then(callback); } - }, - - getMediaUri: function(hashAlgorithm, hash, callback) { - (async function() { - var niUrl; - if (hashAlgorithm == "sha-256") { - niUrl = mkNiUrl(hashAlgorithm, hash); - } else { - const tx = db.transaction(["keyvaluepairs"], "readonly"); - const store = tx.objectStore("keyvaluepairs"); - niUrl = await promisifyRequest(store.get(mkNiUrl(hashAlgorithm, hash))); - if (!niUrl) { - return null; - } - } + }); + }, - const response = await cache.match(niUrl); - if (response) { - // NOTE: the application needs to call URL.revokeObjectURL on this when done - return URL.createObjectURL(await response.blob()); + getMessages: function(account, chatId, beforeId, beforeTime, callback) { + const beforeDate = beforeTime ? new Date(beforeTime) : []; + const tx = db.transaction(["messages"], "readonly"); + const store = tx.objectStore("messages"); + const cursor = store.index("chats").openCursor( + IDBKeyRange.bound([account, chatId], [account, chatId, beforeDate]), + "prev" + ); + const result = []; + cursor.onsuccess = (event) => { + if (event.target.result && result.length < 50) { + const value = event.target.result.value; + if (value.serverId === beforeId || (value.timestamp && value.timestamp.getTime() === (beforeDate instanceof Date && beforeDate.getTime()))) { + event.target.result.continue(); + return; } - return null; - })().then(callback); - }, - - storeMedia: function(mime, buffer, callback) { - (async function() { - const sha256 = await crypto.subtle.digest("SHA-256", buffer); - const sha512 = await crypto.subtle.digest("SHA-512", buffer); - const sha1 = await crypto.subtle.digest("SHA-1", buffer); - const sha256NiUrl = mkNiUrl("sha-256", sha256); - await cache.put(sha256NiUrl, new Response(buffer, { headers: { "Content-Type": mime } })); - - const tx = db.transaction(["keyvaluepairs"], "readwrite"); - const store = tx.objectStore("keyvaluepairs"); - await promisifyRequest(store.put(sha256NiUrl, mkNiUrl("sha-1", sha1))); - await promisifyRequest(store.put(sha256NiUrl, mkNiUrl("sha-512", sha512))); - })().then(callback); - }, - - storeCaps: function(caps) { - const tx = db.transaction(["keyvaluepairs"], "readwrite"); - const store = tx.objectStore("keyvaluepairs"); - store.put(caps, "caps:" + caps.ver()).onerror = console.error; - }, + result.unshift(hydrateMessage(value)); + event.target.result.continue(); + } else { + Promise.all(result).then(callback); + } + } + cursor.onerror = (event) => { + console.error(event); + callback([]); + } + }, - getCaps: function(ver, callback) { - (async function() { + getMediaUri: function(hashAlgorithm, hash, callback) { + (async function() { + var niUrl; + if (hashAlgorithm == "sha-256") { + niUrl = mkNiUrl(hashAlgorithm, hash); + } else { const tx = db.transaction(["keyvaluepairs"], "readonly"); const store = tx.objectStore("keyvaluepairs"); - const raw = await promisifyRequest(store.get("caps:" + ver)); - if (raw) { - return (new snikket.Caps(raw.node, raw.identities.map((identity) => new snikket.Identity(identity.category, identity.type, identity.name)), raw.features)); + niUrl = await promisifyRequest(store.get(mkNiUrl(hashAlgorithm, hash))); + if (!niUrl) { + return null; } + } - return null; - })().then(callback); - }, - - storeLogin: function(login, clientId, displayName, token) { - const tx = db.transaction(["keyvaluepairs"], "readwrite"); - const store = tx.objectStore("keyvaluepairs"); - store.put(clientId, "login:clientId:" + login).onerror = console.error; - store.put(displayName, "fn:" + login).onerror = console.error; - if (token != null) { - store.put(token, "login:token:" + login).onerror = console.error; - store.put(0, "login:fastCount:" + login).onerror = console.error; + const response = await cache.match(niUrl); + if (response) { + // NOTE: the application needs to call URL.revokeObjectURL on this when done + return URL.createObjectURL(await response.blob()); } - }, - storeStreamManagement: function(account, id, outbound, inbound, outbound_q) { + return null; + })().then(callback); + }, + + storeMedia: function(mime, buffer, callback) { + (async function() { + const sha256 = await crypto.subtle.digest("SHA-256", buffer); + const sha512 = await crypto.subtle.digest("SHA-512", buffer); + const sha1 = await crypto.subtle.digest("SHA-1", buffer); + const sha256NiUrl = mkNiUrl("sha-256", sha256); + await cache.put(sha256NiUrl, new Response(buffer, { headers: { "Content-Type": mime } })); + const tx = db.transaction(["keyvaluepairs"], "readwrite"); const store = tx.objectStore("keyvaluepairs"); - store.put({ id: id, outbound: outbound, inbound: inbound, outbound_q }, "sm:" + account).onerror = console.error; - }, - - getStreamManagement: function(account, callback) { + await promisifyRequest(store.put(sha256NiUrl, mkNiUrl("sha-1", sha1))); + await promisifyRequest(store.put(sha256NiUrl, mkNiUrl("sha-512", sha512))); + })().then(callback); + }, + + storeCaps: function(caps) { + const tx = db.transaction(["keyvaluepairs"], "readwrite"); + const store = tx.objectStore("keyvaluepairs"); + store.put(caps, "caps:" + caps.ver()).onerror = console.error; + }, + + getCaps: function(ver, callback) { + (async function() { const tx = db.transaction(["keyvaluepairs"], "readonly"); const store = tx.objectStore("keyvaluepairs"); - promisifyRequest(store.get("sm:" + account)).then( - (v) => { - callback(v?.id, v?.outbound, v?.inbound, v?.outbound_q || []); - }, - (e) => { - console.error(e); - callback(null, -1, -1, []); - } - ); - }, + const raw = await promisifyRequest(store.get("caps:" + ver)); + if (raw) { + return (new snikket.Caps(raw.node, raw.identities.map((identity) => new snikket.Identity(identity.category, identity.type, identity.name)), raw.features)); + } - getLogin: function(login, callback) { - const tx = db.transaction(["keyvaluepairs"], "readwrite"); - const store = tx.objectStore("keyvaluepairs"); - Promise.all([ - promisifyRequest(store.get("login:clientId:" + login)), - promisifyRequest(store.get("login:token:" + login)), - promisifyRequest(store.get("login:fastCount:" + login)), - promisifyRequest(store.get("fn:" + login)), - ]).then((result) => { - if (result[1]) { - store.put((result[2] || 0) + 1, "login:fastCount:" + login).onerror = console.error; - } - callback(result[0], result[1], result[2] || 0, result[3]); - }).catch((e) => { + return null; + })().then(callback); + }, + + storeLogin: function(login, clientId, displayName, token) { + const tx = db.transaction(["keyvaluepairs"], "readwrite"); + const store = tx.objectStore("keyvaluepairs"); + store.put(clientId, "login:clientId:" + login).onerror = console.error; + store.put(displayName, "fn:" + login).onerror = console.error; + if (token != null) { + store.put(token, "login:token:" + login).onerror = console.error; + store.put(0, "login:fastCount:" + login).onerror = console.error; + } + }, + + storeStreamManagement: function(account, id, outbound, inbound, outbound_q) { + const tx = db.transaction(["keyvaluepairs"], "readwrite"); + const store = tx.objectStore("keyvaluepairs"); + store.put({ id: id, outbound: outbound, inbound: inbound, outbound_q }, "sm:" + account).onerror = console.error; + }, + + getStreamManagement: function(account, callback) { + const tx = db.transaction(["keyvaluepairs"], "readonly"); + const store = tx.objectStore("keyvaluepairs"); + promisifyRequest(store.get("sm:" + account)).then( + (v) => { + callback(v?.id, v?.outbound, v?.inbound, v?.outbound_q || []); + }, + (e) => { console.error(e); - callback(null, null, 0, null); - }); - }, + callback(null, -1, -1, []); + } + ); + }, + + getLogin: function(login, callback) { + const tx = db.transaction(["keyvaluepairs"], "readwrite"); + const store = tx.objectStore("keyvaluepairs"); + Promise.all([ + promisifyRequest(store.get("login:clientId:" + login)), + promisifyRequest(store.get("login:token:" + login)), + promisifyRequest(store.get("login:fastCount:" + login)), + promisifyRequest(store.get("fn:" + login)), + ]).then((result) => { + if (result[1]) { + store.put((result[2] || 0) + 1, "login:fastCount:" + login).onerror = console.error; + } + callback(result[0], result[1], result[2] || 0, result[3]); + }).catch((e) => { + console.error(e); + callback(null, null, 0, null); + }); + }, - storeService(account, serviceId, name, node, caps) { - this.storeCaps(caps); + storeService(account, serviceId, name, node, caps) { + this.storeCaps(caps); - const tx = db.transaction(["services"], "readwrite"); - const store = tx.objectStore("services"); + const tx = db.transaction(["services"], "readwrite"); + const store = tx.objectStore("services"); - store.put({ - account: account, - serviceId: serviceId, - name: name, - node: node, - caps: caps.ver(), - }); - }, - - findServicesWithFeature(account, feature, callback) { - const tx = db.transaction(["services"], "readonly"); - const store = tx.objectStore("services"); - - // Almost full scan shouldn't be too expensive, how many services are we aware of? - const cursor = store.openCursor(IDBKeyRange.bound([account], [account, []])); - const result = []; - cursor.onsuccess = (event) => { - if (event.target.result) { - const value = event.target.result.value; - result.push(new Promise((resolve) => this.getCaps(value.caps, (caps) => resolve({ ...value, caps: caps })))); - event.target.result.continue(); - } else { - Promise.all(result).then((items) => items.filter((item) => item.caps && item.caps.features.includes(feature))).then(callback); - } - } - cursor.onerror = (event) => { - console.error(event); - callback([]); + store.put({ + account: account, + serviceId: serviceId, + name: name, + node: node, + caps: caps.ver(), + }); + }, + + findServicesWithFeature(account, feature, callback) { + const tx = db.transaction(["services"], "readonly"); + const store = tx.objectStore("services"); + + // Almost full scan shouldn't be too expensive, how many services are we aware of? + const cursor = store.openCursor(IDBKeyRange.bound([account], [account, []])); + const result = []; + cursor.onsuccess = (event) => { + if (event.target.result) { + const value = event.target.result.value; + result.push(new Promise((resolve) => this.getCaps(value.caps, (caps) => resolve({ ...value, caps: caps })))); + event.target.result.continue(); + } else { + Promise.all(result).then((items) => items.filter((item) => item.caps && item.caps.features.includes(feature))).then(callback); } } + cursor.onerror = (event) => { + console.error(event); + callback([]); + } } } }; + +export default browser;