| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-05-22 01:13:01 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-05-25 02:56:00 UTC |
| parent | 63c2285baa577ec75aff10920685b79a62f430fc |
| Makefile | +1 | -1 |
| borogove/AvailableChatIterator.hx | +50 | -30 |
| borogove/Caps.hx | +41 | -26 |
| borogove/CapsRepo.hx | +12 | -4 |
| borogove/Chat.hx | +372 | -271 |
| borogove/ChatMessage.hx | +9 | -1 |
| borogove/Client.hx | +72 | -41 |
| borogove/Html.hx | +2 | -2 |
| borogove/{Participant.hx => Member.hx} | +36 | -13 |
| borogove/MemberUpdate.hx | +93 | -0 |
| borogove/Message.hx | +3 | -1 |
| borogove/Persistence.hx | +55 | -0 |
| borogove/Presence.hx | +30 | -0 |
| borogove/Role.hx | +2 | -2 |
| borogove/Util.hx | +4 | -0 |
| borogove/persistence/Dummy.hx | +25 | -0 |
| borogove/persistence/IDB.js | +210 | -14 |
| borogove/persistence/Sqlite.hx | +37 | -4 |
| npm/index.ts | +1 | -1 |
| test/TestAll.hx | +2 | -1 |
| test/TestCaps.hx | +141 | -1 |
| test/TestCapsRepo.hx | +34 | -0 |
| test/TestChat.hx | +83 | -50 |
| test/TestClient.hx | +118 | -0 |
| test/TestHtml.hx | +3 | -3 |
| test/{TestParticipant.hx => TestMember.hx} | +3 | -3 |
| test/TestMemberUpdate.hx | +114 | -0 |
| test/TestSortId.hx | +1 | -3 |
| test/idb.spec.ts | +558 | -4 |
diff --git a/Makefile b/Makefile index 08b3d92..4406508 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,7 @@ npm: npm/borogove-browser.js npm/borogove.js borogove/persistence/IDB.js borogov cd npm && npx tsc --esModuleInterop --lib esnext,dom --target esnext --preserveConstEnums --allowJs --checkJs -d persistence-browser.ts playwright/.cache/borogove.js: npm - cd npm && esbuild index.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_JID as JID, borogove_Stanza as Stanza, borogove_ReactionUpdate as ReactionUpdate }" --outfile=../$@ + cd npm && esbuild index.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_JID as JID, borogove_Stanza as Stanza, borogove_ReactionUpdate as ReactionUpdate, borogove_MemberUpdate as MemberUpdate }" --outfile=../$@ playwright/.cache/sqlite-wasm.js: npm cd npm && esbuild sqlite-wasm.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" --outfile=../$@ diff --git a/borogove/AvailableChatIterator.hx b/borogove/AvailableChatIterator.hx index 9860e93..b4b5571 100644 --- a/borogove/AvailableChatIterator.hx +++ b/borogove/AvailableChatIterator.hx @@ -8,6 +8,7 @@ import borogove.queries.JabberIqGatewayGet; import borogove.Util; using Lambda; using StringTools; +using borogove.Util; #if cpp import HaxeCBridge; @@ -19,6 +20,7 @@ import HaxeCBridge; @:build(HaxeSwiftBridge.expose()) @:HaxeSwiftBridge.asyncSequence(AvailableChat) #end +@:nullSafety(StrictThreaded) class AvailableChatIterator { /** The query that this iterator is returning results for @@ -27,8 +29,9 @@ class AvailableChatIterator { private final query: String; private final client: Client; private final persistence: Persistence; - private var results: Array<Promise<Null<AvailableChat>>> = []; - private var dedup: Map<String, Bool> = []; + private var results: Array<Promise<Array<AvailableChat>>> = []; + private var head: Array<AvailableChat> = []; + private var dedup: Map<String, Bool> = new Map(); @:allow(borogove) private function new(q: String, client: Client, persistence: Persistence) { @@ -65,45 +68,50 @@ class AvailableChatIterator { final jid = JID.parse(uriDecode(parts[0])); if (jid.isValid()) return check(jid); - return Promise.resolve(null); + return Promise.resolve([]); })); } final lowerQ = query.toLowerCase(); + final laterResults = []; for (chat in client.chats) { if (chat.chatId != client.accountId()) { if (chat.chatId.contains(lowerQ) || chat.getDisplayName().toLowerCase().contains(lowerQ) || Util.existsFast(chat.getTags(), t -> t.toLowerCase() == lowerQ)) { - final channel = Util.downcast(chat, Channel); - results.push(Promise.resolve(new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, channel == null || channel.disco == null ? new Caps("", [], [], []) : channel.disco))); - } - - for (p in chat.getParticipants()) { - final details = chat.getParticipantDetails(p); - if (details.chat != null && (details.chat.chatId.contains(lowerQ) || (details.chat.displayName ?? "").toLowerCase().contains(lowerQ))) { - results.push(Promise.resolve(details.chat)); + final caps = chat.getCaps(); + final available = Promise.resolve([new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, caps.hasNext() ? caps.next().value : CapsRepo.empty)]); + if (chat.isTrusted()) { + results.push(available); + } else { + laterResults.push(available); } } + + laterResults.push(chat.members().then(members -> + members.map(member -> member.chat).filterOutNulls(chat -> + chat != null && (chat.chatId.contains(lowerQ) || (chat.displayName ?? "").toLowerCase().contains(lowerQ)) + ) + )); } if (chat.isTrusted()) { - final resources:Map<String, Bool> = []; - for (resource in Caps.withIdentity(chat.getCaps(), "gateway", null)) { + final resources:StringMapNullableKey<Caps> = new StringMapNullableKey(); + for (resource => caps in Caps.withIdentity(chat.getCaps(), "gateway", null)) { // Sometimes gateway items also have id "gateway" for whatever reason - final identities = chat.getResourceCaps(resource)?.identities ?? []; + final identities = caps.identities; if ( (chat.chatId.indexOf("@") < 0 || identities.find(i -> i.category == "conference") == null) && identities.find(i -> i.category == "client") == null ) { - resources[resource] = true; + resources[resource] = caps; } } /* Gajim advertises this, so just go with identity instead - for (resource in Caps.withFeature(chat.getCaps(), "jabber:iq:gateway")) { + for (resource => caps in Caps.withFeature(chat.getCaps(), "jabber:iq:gateway")) { resources[resource] = true; }*/ - if (!client.sendAvailable && JID.parse(chat.chatId).isDomain()) { - resources[null] = true; + if (!resources.exists(null) && !client.sendAvailable && JID.parse(chat.chatId).isDomain()) { + resources[null] = CapsRepo.empty; } - for (resource in resources.keys()) { + for (resource => caps in resources) { final bareJid = JID.parse(chat.chatId); final fullJid = new JID(bareJid.node, bareJid.domain, bareJid.isDomain() && resource == "" ? null : resource); final jigGet = new JabberIqGatewayGet(fullJid.asString(), query); @@ -111,7 +119,6 @@ class AvailableChatIterator { jigGet.onFinished(() -> { final result = jigGet.getResult(); if (result == null) { - final caps = chat.getResourceCaps(resource); if (bareJid.isDomain() && caps.features.contains("jid\\20escaping")) { check(new JID(query, bareJid.domain)).then(resolve); } else if (bareJid.isDomain()) { @@ -119,7 +126,7 @@ class AvailableChatIterator { } } else { switch (result) { - case Left(error): resolve(null); + case Left(error): resolve([]); case Right(result): check(JID.parse(result)).then(resolve); } @@ -130,6 +137,11 @@ class AvailableChatIterator { } } } + + // Sort some results to the end + for (later in laterResults) { + results.push(later); + } } private function check(jid: JID) { @@ -140,16 +152,16 @@ class AvailableChatIterator { 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") { - resolve(new AvailableChat(jid.asString(), jid.node == null ? query : jid.node, jid.asString(), new Caps("", [], [], []))); + resolve([new AvailableChat(jid.asString(), jid.node == null ? query : jid.node, jid.asString(), CapsRepo.empty)]); } else { - resolve(null); + resolve([]); } } else { - client.capsRepo.add(resultCaps); + final caps = client.capsRepo.add(resultCaps); final identity = resultCaps.identities[0]; final displayName = identity?.name ?? query; final note = jid.asString() + (identity == null ? "" : " (" + identity.type + ")"); - resolve(new AvailableChat(jid.asString(), displayName, note, resultCaps)); + resolve([new AvailableChat(jid.asString(), displayName, note, caps)]); } }); client.sendQuery(discoGet); @@ -187,15 +199,23 @@ class AvailableChatIterator { #end private function internalNext(): Promise<Null<AvailableChat>> { - if (results.length < 1) return Promise.resolve(null); + if (head.length > 0) { + final available = head.shift(); + + if (available != null) { + if (dedup.exists(available.chatId)) return internalNext(); - return results.shift().then(available -> { - if (available == null || dedup[available.chatId]) { - return this.internalNext(); - } else { dedup[available.chatId] = true; return Promise.resolve(available); } + } + + final promise = results.shift(); + if (promise == null) return Promise.resolve(cast null); + + return promise.then(availables -> { + head = availables; + return internalNext(); }); } } diff --git a/borogove/Caps.hx b/borogove/Caps.hx index 8da2acd..81e8d88 100644 --- a/borogove/Caps.hx +++ b/borogove/Caps.hx @@ -13,6 +13,7 @@ import borogove.Util; // exposed for IDB.js, not really part of public API @:expose +@:nullSafety(StrictThreaded) class Caps { public final node: String; public final identities: ReadOnlyArray<Identity>; @@ -20,34 +21,42 @@ class Caps { public final data: ReadOnlyArray<DataForm>; private var _ver : Null<Hash> = null; - @:allow(borogove) - private static function withIdentity(caps:KeyValueIterator<String, Null<Caps>>, category:Null<String>, type:Null<String>):Array<String> { - final result = []; - for (cap in caps) { - if (cap.value != null) { - for (identity in cap.value.identities) { - if ((category == null || category == identity.category) && (type == null || type == identity.type)) { - result.push(cap.key); - } + private static function filter(caps:KeyValueIterator<Null<String>, Caps>, predicate:Caps->Bool):KeyValueIterator<Null<String>, Caps> { + var nextMatch:Null<{key:Null<String>, value:Caps}> = null; + + final findNext = () -> { + while (caps.hasNext()) { + var n = caps.next(); + if (predicate(n.value)) { + nextMatch = n; + return; } } - } - return result; + nextMatch = null; + }; + + findNext(); + + return { + hasNext: () -> nextMatch != null, + next: () -> { + final r = nextMatch; + if (r == null) throw "No more elements"; + + findNext(); + return r; + } + }; } @:allow(borogove) - private static function withFeature(caps:KeyValueIterator<String, Null<Caps>>, feature:String):Array<String> { - final result = []; - for (cap in caps) { - if (cap.value != null) { - for (feat in cap.value.features) { - if (feature == feat) { - result.push(cap.key); - } - } - } - } - return result; + private static function withIdentity(caps:KeyValueIterator<Null<String>, Caps>, category:Null<String>, type:Null<String>):KeyValueIterator<Null<String>, Caps> { + return filter(caps, (c) -> c.identities.exists((identity) -> (category == null || category == identity.category) && (type == null || type == identity.type))); + } + + @:allow(borogove) + private static function withFeature(caps:KeyValueIterator<Null<String>, Caps>, feature:String):KeyValueIterator<Null<String>, Caps> { + return filter(caps, (c) -> c.features.contains(feature)); } /** @@ -167,7 +176,9 @@ class Caps { s += feature + "<"; } for (form in data) { - s += form.field("FORM_TYPE").value[0] + "<"; + final formType = form.field("FORM_TYPE"); + s += formType == null ? "" : formType.value[0]; + s += "<"; final fields = form.fields; fields.sort((x, y) -> Reflect.compare(x.name, y.name)); for (field in fields) { @@ -188,8 +199,12 @@ class Caps { Get the raw XEP-0115 capability hash object for this capability set. **/ public function verRaw(): Hash { - if (_ver == null) _ver = computeVer(); - return _ver; + final ver = _ver; + if (ver != null) return ver; + + final newVer = computeVer(); + _ver = newVer; + return newVer; } /** diff --git a/borogove/CapsRepo.hx b/borogove/CapsRepo.hx index 09b1493..cf44532 100644 --- a/borogove/CapsRepo.hx +++ b/borogove/CapsRepo.hx @@ -4,6 +4,7 @@ import thenshim.Promise; @:nullSafety(StrictThreaded) class CapsRepo { + public static final empty = new Caps("", [], [], []); private final persistence: Persistence; private final cache: Map<String, Caps> = []; @@ -11,9 +12,16 @@ class CapsRepo { this.persistence = persistence; } - public function add(caps: Caps) { - persistence.storeCaps(caps); - cache[caps.ver()] = caps; + public function add(caps: Caps, storeOnMiss = true) { + final ver = caps.ver(); + final r = cache[ver]; + if (r == null) { + if (storeOnMiss) persistence.storeCaps(caps); + cache[ver] = caps; + return caps; + } + + return r; } public function getAsync(presence: Presence): Promise<Null<Caps>> { @@ -39,6 +47,6 @@ class CapsRepo { getAsync(presence); // Fetch and put in cache for later } - return new Caps("", [], [], []); + return empty; } } diff --git a/borogove/Chat.hx b/borogove/Chat.hx index fc71309..522ecdc 100644 --- a/borogove/Chat.hx +++ b/borogove/Chat.hx @@ -30,26 +30,30 @@ import HaxeCBridge; #end #if js -typedef StringMapNullableKey = Map<Null<String>, String>; +typedef StringMapNullableKey<T> = Map<Null<String>, T>; #else final nullSentinel = "65e2ca3a-a13e-490c-bfe6-9c6b4c8651d0"; @:forward -abstract StringMapNullableKey(haxe.ds.StringMap<String>) { +abstract StringMapNullableKey<T>(haxe.ds.StringMap<T>) { public inline function new() { this = new haxe.ds.StringMap(); } - public inline function set(k: Null<String>, v: String) { + @:arrayAccess public inline function set(k: Null<String>, v: T) { this.set(k == null ? nullSentinel : k, v); } - public inline function get(k: Null<String>) { - return this.get(k == null ? nullSentinel : k,); + @:arrayAccess public inline function get(k: Null<String>) { + return this.get(k == null ? nullSentinel : k); } public inline function remove(k: Null<String>) { - return this.remove(k == null ? nullSentinel : k,); + return this.remove(k == null ? nullSentinel : k); + } + + public inline function exists(k: Null<String>) { + return this.exists(k == null ? nullSentinel : k); } public inline function keyValueIterator() { @@ -104,14 +108,13 @@ enum abstract EncryptionMode(Int) { @:build(HaxeCBridge.expose()) @:build(HaxeSwiftBridge.expose()) #end -abstract class Chat { +abstract class Chat extends EventEmitter { private var client:Client; private var stream:GenericStream; private var persistence:Persistence; @:allow(borogove) private var avatarSha1:Null<BytesData> = null; - @:allow(borogove) - private var presence:Map<String, Presence> = []; + @:allow(borogove.SerializedChat) private var trusted:Bool = false; @:allow(borogove) public var status(default, null):Status = new Status("", ""); @@ -150,7 +153,7 @@ abstract class Chat { @:allow(borogove) private var readUpToBy: Null<String>; @:allow(borogove.persistence) - private final threads: StringMapNullableKey = new StringMapNullableKey(); + private final threads: StringMapNullableKey<String> = new StringMapNullableKey(); private var isTyping = false; private var typingThread: Null<String> = null; private var typingTimer: haxe.Timer = null; @@ -165,6 +168,7 @@ abstract class Chat { @:allow(borogove) private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBookmarked = false, isBlocked = false, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null, omemoContactDeviceIDs: Array<Int> = null) { + super(); if (chatId == null || chatId == "") { throw "chatId may not be empty"; } @@ -268,42 +272,63 @@ abstract class Chat { abstract public function bookmark():Void; /** - Get the list of IDs of participants in this Chat + Get a list of members in this Chat - @returns array of IDs + This list will often be a a complete list of everyone who has access to + the chat, but for larger chats may be incomplete. **/ - abstract public function getParticipants():Array<String>; + abstract public function members():Promise<Array<Member>>; + + /** + Get the details for some members in this Chat + + @param memberIds the IDs of the member to look up + **/ + abstract public function getMemberDetails(memberIds: Array<String>):Promise<Array<Null<Member>>>; /** - Roles the current user can assign to the target participant + Event fired when a member is updated, or when a new member is added + + @param handler takes one argument, an array of Member that were updated + @returns token for use with removeEventListener **/ - public function availableRoles(participantId: String):Array<Role> { - return []; + @:HaxeSwiftBridge.contextLifetime(handler, EventEmitter) + public function addMembersUpdatedListener(handler: (Array<Member>)->Void) { + return this.on("members/update", (data) -> { + handler(data); + return EventHandled; + }); } /** - Can the current user remove this role from the participant? + Roles the current user can assign to the target member **/ - public function canRemoveRole(participantId: String, role: Role):Bool { - return false; + public function availableRoles(member: Member):Array<Role> { + return []; } /** - Add a role to a participant + Can the current user remove this role from the member? **/ - public function addRole(participantId: String, role: Role) { } + public function canRemoveRole(member: Member, role: Role):Bool { + return false; + } /** - Remove a role from a participant + Add a role to a member + + @param member the member to update + @param role the role to add **/ - public function removeRole(participantId: String, role: Role) { } + public function addRole(member: Member, role: Role) { } /** - Get the details for one participant in this Chat + Remove a role from a member - @param participantId the ID of the participant to look up + @param member the member to update + @param role the role to remove **/ - abstract public function getParticipantDetails(participantId: String):Participant; + public function removeRole(member: Member, role: Role) { } /** Correct an already-send message by replacing it with a new one @@ -553,11 +578,6 @@ abstract class Chat { return notificationSettings == null || notificationSettings.reply; } - /** - An ID of the most recent message in this chat - **/ - abstract public function lastMessageId():Null<String>; - @:allow(borogove) private function updateFromBookmark(item: Stanza) { isBookmarked = true; @@ -638,8 +658,9 @@ abstract class Chat { } @:allow(borogove) - private function setLastMessage(message:Null<ChatMessage>) { + private function setLastMessage(message:Null<ChatMessage>): Promise<Any> { lastMessage = message; + return Promise.resolve(null); } /** @@ -658,14 +679,6 @@ abstract class Chat { public function getDisplayName() { if (this.displayName == chatId) { if (chatId == client.accountId()) return client.displayName(); - - final participants = getParticipants(); - if (participants.length > 2 && participants.length < 20) { - return participants.map(id -> { - final p = id == chatId ? null : getParticipantDetails(id); - p == null || p.isSelf ? null : p.displayName; - }).filter(fn -> fn != null).join(", "); - } } else if (uiState == Invited) { return '${displayName} (${chatId})'; } @@ -686,32 +699,10 @@ abstract class Chat { } @:allow(borogove) - private function setPresence(resource:String, presence:Presence) { - this.presence.set(resource, presence); - } - - @:allow(borogove) - private function removePresence(resource:String) { - presence.remove(resource); - } - - @:allow(borogove) - private function getCaps():KeyValueIterator<String, Caps> { - final iter = presence.keyValueIterator(); - return { - hasNext: iter.hasNext, - next: () -> { - final n = iter.next(); - return { key: n.key, value: client.capsRepo.get(n.value) }; - } - }; - } + abstract private function setPresence(resource:String, presence:Presence, noStore:Bool = false):Void; @:allow(borogove) - private function getResourceCaps(resource:String):Caps { - final p = presence[resource]; - return p == null ? new Caps("", [], [], []) : client.capsRepo.get(p); - } + abstract private function getCaps():KeyValueIterator<Null<String>, Caps>; @:allow(borogove) private function setAvatarSha1(sha1: BytesData) { @@ -866,7 +857,7 @@ abstract class Chat { Can the user send messages to this chat? **/ public function canSend() { - return Caps.withFeature(getCaps(), "urn:xmpp:noreply:0").length < 1; + return !Caps.withFeature(getCaps(), "urn:xmpp:noreply:0").hasNext(); } /** @@ -909,17 +900,17 @@ abstract class Chat { return chatId.indexOf("@") < 0 && hasCommands(); } - final bot = Caps.withIdentity(getCaps(), "client", "bot").length > 0; - final client = Caps.withIdentity(getCaps(), "client", null).length > 0; - final account = Caps.withIdentity(getCaps(), "account", null).length > 0; + final bot = Caps.withIdentity(getCaps(), "client", "bot").hasNext(); + final client = Caps.withIdentity(getCaps(), "client", null).hasNext(); + final account = Caps.withIdentity(getCaps(), "account", null).hasNext(); // Clients are not apps, we chat with them if ((client || account) && !bot) return false; - final noReply = Caps.withFeature(getCaps(), "urn:xmpp:noreply:0").length > 0; + final noReply = Caps.withFeature(getCaps(), "urn:xmpp:noreply:0").hasNext(); // A bot that doesn't want messages is an app if (bot && noReply) return hasCommands(); - final conference = Caps.withIdentity(getCaps(), "conference", null).length > 0; + final conference = Caps.withIdentity(getCaps(), "conference", null).hasNext(); // A MUC component is an app if (conference && chatId.indexOf("@") < 0) return hasCommands(); @@ -955,8 +946,8 @@ abstract class Chat { private function commandJids() { final jids = []; final jid = JID.parse(chatId); - for (resource in Caps.withFeature(getCaps(), "http://jabber.org/protocol/commands")) { - jids.push(resource == "" || resource == null ? jid : jid.withResource(resource)); + for (resource => caps in Caps.withFeature(getCaps(), "http://jabber.org/protocol/commands")) { + jids.push(resource == null ? jid : jid.withResource(resource)); } if (jids.length < 1 && jid.isDomain()) { jids.push(jid); @@ -965,11 +956,11 @@ abstract class Chat { } /** - The Participant that originally invited us to this Chat, if we were invited + The Member that originally invited us to this Chat, if we were invited **/ public function invitedBy() { final inviteEls = invites(); - if (inviteEls.length < 1) return null; + if (inviteEls.length < 1) return Promise.resolve(null); final inviteFrom = JID.parse(inviteEls[0].attr.get("from")); final bare = inviteFrom.asBare().asString(); @@ -977,11 +968,13 @@ abstract class Chat { if (maybeChannel != null) { final channel = maybeChannel.downcast(Channel); if (channel != null) { - return channel.getParticipantDetails(inviteFrom.asString()); + members() + .then(members -> members.filter(member -> member.displayName == inviteFrom.resource)) + .then(result -> result.length > 0 ? result[0] : null); } } - return (maybeChannel ?? client.getDirectChat(bare)).getParticipantDetails(bare); + return (maybeChannel ?? client.getDirectChat(bare)).getMemberDetails([bare]).then(result -> result[0] != null ? result[0] : null); } private function invites() { @@ -1004,7 +997,7 @@ abstract class Chat { } @:allow(borogove) - private function markReadUpToId(upTo: String, upToBy: String): Promise<Any> { + private function markReadUpToId(upTo: String, upToBy: String, recompute = true): Promise<Any> { if (upTo == null) return Promise.reject(null); if (readUpToId == upTo) { return Promise.reject(null); @@ -1012,8 +1005,8 @@ abstract class Chat { readUpToId = upTo; readUpToBy = upToBy; - persistence.storeChats(client.accountId(), [this]); - return recomputeUnread(); + if (recompute) persistence.storeChats(client.accountId(), [this]); + return recompute ? recomputeUnread() : Promise.resolve(null); } private function markReadUpToMessage(message: ChatMessage): Promise<Any> { @@ -1061,46 +1054,100 @@ abstract class Chat { @:build(HaxeSwiftBridge.expose()) #end class DirectChat extends Chat { + @:allow(borogove.Client) + private final presence: Map<String, Presence> = new Map(); + private var _fullCounterparts: Array<String>; + @:allow(borogove) private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBookmarked = false, isBlocked = false, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null, omemoContactDeviceIDs: Array<Int> = null) { super(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy, omemoContactDeviceIDs); + _fullCounterparts = counterparts(); outbox.start(); } - @HaxeCBridge.noemit // on superclass as abstract - public function getParticipants(): Array<String> { - final counters = counterparts(); - final ids: Map<String, Bool> = []; - if (counters.length < 2 && (lastMessage?.recipients?.length ?? 0) > 1) { - ids[lastMessage.senderId] = true; - for (id in lastMessage.recipients.map(r -> r.asString())) { - ids[id] = true; - } + @:allow(borogove) + private function setPresence(resource:String, presence:Presence, noStore = false) { + // We only store presence for 1:1 + if (_fullCounterparts.length > 1) return; + + if (presence.type == "unavailable") { + this.presence.remove(resource); } else { - ids[client.accountId()] = true; - for (id in counterparts()) { - ids[id] = true; + this.presence.set(resource, presence); + } + + if (noStore) return; + + getMemberDetails(_fullCounterparts) + .then(members -> persistence.storeMembers(client.accountId(), chatId, members).then(_ -> members)) + .then(members -> { + trigger("members/update", members); + client.trigger("chats/update", [this]); + return null; + }); + } + + @:allow(borogove) + private function getCaps():KeyValueIterator<Null<String>, Caps> { + final iter = presence.keyValueIterator(); + return { + hasNext: iter.hasNext, + next: () -> { + final n = iter.next(); + return { key: n.key, value: client.capsRepo.get(n.value) }; } + }; + } + + @:allow(borogove) + private function getResourceCaps(resource: Null<String>):Caps { + final p = presence[resource]; + return p == null ? CapsRepo.empty : client.capsRepo.get(p); + } + + override public function getDisplayName() { + final name = super.getDisplayName(); + if (name == chatId && _fullCounterparts.length > 1) { + final names = _fullCounterparts.map(id -> client.getDirectChat(id).getDisplayName()); + names.sort(Reflect.compare); + return names.join(", "); } - return { iterator: () -> ids.keys() }.array(); + + return name; } - private function counterparts() { - return chatId.split("\n"); + @HaxeCBridge.noemit // on superclass as abstract + public function members() { + return getMemberDetails(_fullCounterparts.concat([client.accountId()])); } @HaxeCBridge.noemit // on superclass as abstract - public function getParticipantDetails(participantId:String): Participant { - final chat = client.getDirectChat(participantId); - return new Participant( - chat.getDisplayName(), - chat.getPhoto(), - chat.getPlaceholder(), - chat.chatId == client.accountId(), - [], // No roles in direct chat - JID.parse(participantId), - new AvailableChat(participantId, chat.getDisplayName(), '${participantId} (via ${displayName})', new Caps("", [], [], [])) - ); + public function getMemberDetails(memberIds: Array<String>) { + return Promise.resolve(memberIds.map(id -> { + final chat = client.getDirectChat(id); + return new Member( + id, + chat.getDisplayName(), + chat.getPhoto(), + chat.chatId == client.accountId(), + [], // No roles in direct chat + JID.parse(id), + chat.presence, + new AvailableChat(id, chat.getDisplayName(), '${id} (via ${displayName})', CapsRepo.empty) + ); + })); + } + + override private function setLastMessage(message:Null<ChatMessage>): Promise<Any> { + // If last message at load time is a sent message, this may not include everyone + if (_fullCounterparts.length < 2 && (message?.recipients?.length ?? 0) > 1) { + _fullCounterparts = message.recipients.map(r -> r.asBare().asString()).filter(id -> id != client.accountId()); + } + return super.setLastMessage(message); + } + + private function counterparts() { + return chatId.split("\n"); } @HaxeCBridge.noemit // on superclass as abstract @@ -1177,8 +1224,9 @@ class DirectChat extends Chat { message.localId = toSendId; sendMessageStanza(message.build().asStanza(), outboxItem); if (lastMessage == null || corrected.canReplace(lastMessage)) { - setLastMessage(corrected); - client.trigger("chats/update", [this]); + setLastMessage(corrected).then(_ -> + client.trigger("chats/update", [this]) + ); } client.notifyMessageHandlers(corrected, CorrectionEvent); }); @@ -1203,9 +1251,10 @@ class DirectChat extends Chat { stanza.tag("active", { xmlns: "http://jabber.org/protocol/chatstates" }).up(); } sendMessageStanza(stanza, outboxItem); - setLastMessage(stored); - client.notifyMessageHandlers(stored, stored.versions.length > 1 ? CorrectionEvent : DeliveryEvent); - client.trigger("chats/update", [this]); + setLastMessage(stored).then(_ -> { + client.notifyMessageHandlers(stored, stored.versions.length > 1 ? CorrectionEvent : DeliveryEvent); + client.trigger("chats/update", [this]); + }); }); case ReactionUpdateStanza(update): persistence.storeReaction(client.accountId(), update).then((stored) -> { @@ -1287,11 +1336,6 @@ class DirectChat extends Chat { }); } - @HaxeCBridge.noemit // on superclass as abstract - public function lastMessageId() { - return lastMessage?.serverId ?? lastMessage?.localId; - } - @HaxeCBridge.noemit // on superclass as abstract public function markReadUpTo(message: ChatMessage) { // A PM is not actually part of the chat @@ -1391,8 +1435,14 @@ class Channel extends Chat { private var joinFailed = null; private var sync = null; private var forceLive = false; - private var _nickInUse = null; + @:allow(borogove) + private var self: Null<Member> = null; + @:allow(borogove) + private var mavUntil = null; + @:allow(borogove.SerializedChat) + private var membersForName: Null<Array<{id: String, displayName: String}>> = []; private var sortId = null; + private var lastMessageSenderName = null; @:allow(borogove) private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBookmarked = false, isBlocked = false, extensions = null, readUpToId = null, readUpToBy = null, ?disco: Caps) { @@ -1449,29 +1499,35 @@ class Channel extends Chat { } @:allow(borogove) - private function join() { + private function join(): Promise<Any> { if (uiState == Invited || uiState == Closed) { // Do not join - return; + return Promise.resolve(null); } - presence = []; // About to ask for a fresh set - _nickInUse = null; - outbox.pause(); - inSync = false; - client.trigger("chats/update", [this]); - final desiredFullJid = JID.parse(chatId).withResource(client.displayName()); - client.sendPresence( - desiredFullJid.asString(), - (stanza) -> { - stanza.tag("x", { xmlns: "http://jabber.org/protocol/muc" }); - if (disco.features.contains("urn:xmpp:mam:2")) stanza.tag("history", { maxchars: "0" }).up(); - // TODO: else since (last message we know about) - stanza.up(); - return stanza; - } - ); - persistence.syncPoint(client.accountId(), chatId).then((point) -> doSync(point)); + return persistence.clearMemberPresence(client.accountId(), chatId).then(_ -> { + self = null; + outbox.pause(); + inSync = false; + client.trigger("chats/update", [this]); + final desiredFullJid = JID.parse(chatId).withResource(client.displayName()); + client.sendPresence( + desiredFullJid.asString(), + (stanza) -> { + final mavAttr: haxe.DynamicAccess<String> = { xmlns: "urn:xmpp:muc:affiliations:1" }; + if (mavUntil != null) mavAttr.set("since", mavUntil); + stanza.tag("x", { xmlns: "http://jabber.org/protocol/muc" }) + .tag("mav", mavAttr).up(); + if (disco.features.contains("urn:xmpp:mam:2")) stanza.tag("history", { maxchars: "0" }).up(); + // TODO: else since (last message we know about) + stanza.up(); + return stanza; + } + ); + persistence.syncPoint(client.accountId(), chatId).then((point) -> doSync(point)); + + return null; + }); } private function selfPingSuccess() { @@ -1486,12 +1542,15 @@ class Channel extends Chat { } override public function getDisplayName() { - if (this.displayName == chatId) { + final name = super.getDisplayName(); + if (name == chatId) { final title = (info()?.field("muc#roomconfig_roomname")?.value ?? []).join("\n"); if (title != null && title != "") return title; + + if (membersForName != null && membersForName.length > 0 && membersForName.length < 20) return membersForName.map(m -> m.displayName).join(", "); } - return super.getDisplayName(); + return name; } /** @@ -1527,63 +1586,104 @@ class Channel extends Chat { override public function canInvite() { if (!isPrivate()) return true; - if (_nickInUse == null) return false; + if (self == null) return false; - final p = presence[_nickInUse]; - if (p == null) return false; + final it = self.presence.iterator(); + if (!it.hasNext()) return false; - if (p.mucUser.role == "moderator") return true; + if (it.next().mucUser.role == "moderator") return true; return false; } override public function canSend() { if (!super.canSend()) return false; - if (_nickInUse == null) return true; + if (self == null) return true; - final p = presence[_nickInUse]; - if (p == null) return true; + final it = self.presence.iterator(); + if (!it.hasNext()) return true; - return p.mucUser.role != "visitor"; + return it.next().mucUser.role != "visitor"; } override public function canModerate() { - if (_nickInUse == null) return false; + if (self == null || disco == null) return false; - final p = presence[_nickInUse]; - if (p == null) return false; + final it = self.presence.iterator(); + if (!it.hasNext()) return false; - return disco.features.contains("urn:xmpp:message-moderate:1") && p.mucUser.role == "moderator"; + return disco.features.contains("urn:xmpp:message-moderate:1") && it.next().mucUser.role == "moderator"; } @:allow(borogove) - override private function getCaps():KeyValueIterator<String, Caps> { - return ["" => disco].keyValueIterator(); + private function getCaps():KeyValueIterator<Null<String>, Caps> { + var hasNext = true; + return { + hasNext: () -> hasNext, + next: () -> { + hasNext = false; + return { key: null, value: disco }; + } + }; } @:allow(borogove) - override private function setPresence(resource:String, presence:Presence) { + private function setPresence(resource:String, presence:Presence, noStore = false) { final oneTen = presence?.mucUser?.statusCodes?.find((status) -> status == "110"); - if (oneTen != null) { - _nickInUse = resource; - outbox.start(); - } else if (resource == _nickInUse) { - _nickInUse = null; - outbox.pause(); + final member = buildMember(resource, presence); + if (presence.mucUser != null && oneTen == null && member.isSelf) { + // ejabberd sends presence updates for self without 110 + final mucUser: Stanza = presence.mucUser; + mucUser.tag("status", { code: "110" }).up(); + setPresence(resource, presence); + return; } - if (presence != null && presence.mucUser != null && oneTen == null) { - final existing = this.presence.get(resource); - if (existing != null && existing?.mucUser?.statusCodes?.find((status) -> status == "110") != null) { - final mucUser: Stanza = presence.mucUser; - mucUser.tag("status", { code: "110" }).up(); - setPresence(resource, presence); - return; + final occupantId = (presence : Stanza).getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id"); + if (!noStore && !(disco.features.contains("urn:xmpp:occupant-id:0") && member.id != chatId + "/" + occupantId)) { + (if (presence.type == "unavailable" && member.photoUri == null) { + final memberUpdates = MemberUpdate.extractUpdates(client.accountId(), this, presence); + getMemberDetails([member.id]).then(fetched -> + cast memberUpdates[0].applyTo(fetched[0]) + ); + } else { + Promise.resolve(member); + }).then(member -> { + persistence.storeMembers(client.accountId(), chatId, [member]).then(_ -> { + trigger("members/update", [member]); + }); + }); + } + final mucUser = (presence : Stanza).getChild("x", "http://jabber.org/protocol/muc#user"); + if (mucUser != null) { + final mav = mucUser.getChild("mav", "urn:xmpp:muc:affiliations:1"); + if (mav?.attr?.get("since") != null && mav?.attr?.get("since") != mavUntil) { + trace("MAV update with unknown previous version", mavUntil, presence); + } + if (mav?.attr?.get("until") != null && mavUntil != mav?.attr?.get("until")) { + mavUntil = mav?.attr?.get("until"); + persistence.storeChats(client.accountId(), [this]); + } + } + if (member.isSelf) { + if (presence.type == "unavailable") { + self = null; + outbox.pause(); + } else { + self = member; + outbox.start(); } + client.trigger("chats/update", [this]); + } + if (!member.isSelf && member.id != chatId && membersForName != null) { + membersForName = membersForName.filter(m -> m.id != member.id); + membersForName.push({ id: member.id, displayName: member.displayName }); + membersForName.sort((a, b) -> Reflect.compare(a.displayName, b.displayName)); + if (membersForName.length > 20) membersForName = null; + if (displayName == chatId) client.trigger("chats/update", [this]); } - super.setPresence(resource, presence); final tripleThree = presence?.mucUser?.statusCodes?.find((status) -> status == "333"); - if (oneTen != null && tripleThree != null) { - selfPing(true); + if (member.isSelf && tripleThree != null) { + haxe.Timer.delay(() -> selfPing(true), 5000); } } @@ -1677,19 +1777,22 @@ class Channel extends Chat { // Sort by time so that eg edits go into the past dedupedMessages.sort((x, y) -> Reflect.compare(x.timestamp, y.timestamp)); - final lastFromSync = dedupedMessages[dedupedMessages.length - 1]; - if (lastFromSync != null && (lastMessage == null || lastFromSync.sortId > lastMessage.sortId)) { - setLastMessage(lastFromSync); - client.sortChats(); - } - final readIndex = dedupedMessages.findLastIndex((m) -> m.serverId == readUpToId || !m.isIncoming()); if (readIndex < 0) { setUnreadCount(unreadCount() + dedupedMessages.length); } else { setUnreadCount(dedupedMessages.length - readIndex - 1); } - client.trigger("chats/update", [this]); + + final lastFromSync = dedupedMessages[dedupedMessages.length - 1]; + if (lastFromSync != null && (lastMessage == null || lastFromSync.sortId > lastMessage.sortId)) { + setLastMessage(lastFromSync).then(_ -> { + client.sortChats(); + client.trigger("chats/update", [this]); + }); + } else { + Promise.resolve(client.trigger("chats/update", [this])); + } } }); }); @@ -1734,8 +1837,7 @@ class Channel extends Chat { final discoGet = new DiscoInfoGet(chatId); discoGet.onFinished(() -> { if (discoGet.getResult() != null) { - disco = discoGet.getResult(); - client.capsRepo.add(discoGet.getResult()); + disco = client.capsRepo.add(discoGet.getResult()); persistence.storeChats(client.accountId(), [this]); } if (callback != null) callback(); @@ -1745,7 +1847,6 @@ class Channel extends Chat { avatarSha1 = hash.hash; persistence.hasMedia("sha-1", avatarSha1).then((has) -> { if (!has) { -trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1Hex); final vcardGet = new VcardTempGet(JID.parse(chatId)); vcardGet.onFinished(() -> { final vcard = vcardGet.getResult(); @@ -1765,14 +1866,14 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H override public function preview() { if (lastMessage == null) return super.preview(); - return getParticipantDetails(lastMessage.senderId).displayName + ": " + super.preview(); + return lastMessageSenderName + ": " + super.preview(); } @:allow(borogove) override private function livePresence() { if (forceLive) return true; - return _nickInUse != null; + return self != null; } override public function syncing() { @@ -1780,8 +1881,15 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H } override private function setLastMessage(message:Null<ChatMessage>) { - super.setLastMessage(message); - if (message != null && message.type == MessageChannel && (sortId == null || sortId < message.sortId)) sortId = message.sortId; + return super.setLastMessage(message).then(_ -> { + if (message != null && message.type == MessageChannel && (sortId == null || sortId < message.sortId)) sortId = message.sortId; + if (message == null) return Promise.resolve(null); + + return getMemberDetails([message.senderId]).then(sender -> { + lastMessageSenderName = sender[0] != null ? sender[0].displayName : message.senderMemberStub().displayName; + return null; + }); + }); } override public function canAudioCall():Bool { @@ -1793,7 +1901,7 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H } private function nickInUse() { - return _nickInUse ?? client.displayName(); + return self?.displayName ?? client.displayName(); } @:allow(borogove) @@ -1802,115 +1910,101 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H } @HaxeCBridge.noemit // on superclass as abstract - public function getParticipants() { - final jid = JID.parse(chatId); - return { iterator: () -> presence.keys() }.filter(resource -> resource != null).map((resource) -> new JID(jid.node, jid.domain, resource).asString()); + public function members() { + return persistence + .getMembers(client.accountId(), this, self == null ? false : self.roles.exists(r -> ["admin", "owner"].contains(r.id))) + .then(members -> members.filter(member -> member.id != chatId)); } @HaxeCBridge.noemit // on superclass as abstract - public function getParticipantDetails(participantId:String): Participant { - final jid = JID.parse(participantId); - final nick = jid.resource; - final ppresence = presence[nick]; - final roles = ppresence?.hats ?? []; - if (ppresence?.mucUser != null) { - final affRole = Role.forAffiliation(ppresence.mucUser.affiliation); + public function getMemberDetails(memberIds: Array<String>) { + if (memberIds.length == 1 && self != null && memberIds[0] == self.id) return Promise.resolve([cast self]); + + return persistence.getMemberDetails(client.accountId(), this, memberIds); + } + + private function buildMember(resource: String, presence: Presence): Member { + final oneTen = presence?.mucUser?.statusCodes?.find((status) -> status == "110"); + final jid = JID.parse(chatId).withResource(resource); + final nick = resource ?? getDisplayName(); + final roles = presence?.hats ?? []; + final occupantId = (presence : Stanza).getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id"); + final id = occupantId == null ? jid.asString() : chatId + "/" + occupantId; + if (presence?.mucUser != null) { + final affRole = Role.forAffiliation(presence.mucUser.affiliation); if (affRole != null) roles.unshift(affRole); } - if (participantId == getFullJid().asString()) { + if (oneTen != null || id == self?.id) { final chat = client.getDirectChat(client.accountId(), false); - return new Participant( - client.displayName(), + return new Member( + id, + nick, chat.getPhoto(), - chat.getPlaceholder(), true, roles, JID.parse(chat.chatId), - new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, new Caps("", [], [], [])) + [ nick => presence ], + new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, CapsRepo.empty) ); } else { - final placeholderUri = Color.defaultPhoto(participantId, nick == null ? " " : nick.charAt(0)); - final trueJid = ppresence?.mucUser?.jid?.asBare()?.asString(); - return new Participant( - nick ?? "", - ppresence?.avatarHash?.toUri(), - placeholderUri, + final trueJid = presence?.mucUser?.jid?.asBare()?.asString(); + return new Member( + id, + nick, + presence?.avatarHash?.toUri(), false, roles, trueJid == null ? jid : JID.parse(trueJid), - trueJid == null ? null : new AvailableChat(trueJid, nick ?? "", '$trueJid (via ${displayName})', new Caps("", [], [], [])) + [ nick => presence ], + trueJid == null ? null : new AvailableChat(trueJid, nick, '$trueJid (via ${displayName})', CapsRepo.empty) ); } } - override public function availableRoles(participantId: String):Array<Role> { - if (_nickInUse == null) return []; + override public function availableRoles(member: Member):Array<Role> { + if (self == null) return []; - final p = presence[_nickInUse]; - if (p?.mucUser == null) return []; - - // TODO: this should get their affiliation from the list not using presence - // once we are fetching the affiliation list - // That's probably true everywhere we use affiliation - final pjid = JID.parse(participantId); - final pnick = pjid.resource; - final ppresence = presence[pnick]; - if (ppresence?.mucUser == null) return []; - if (ppresence?.mucUser?.jid == null) return []; - - if (p.mucUser.affiliation == "owner") { - return ["owner", "admin", "member", "outcast"].filter(aff -> aff != ppresence.mucUser.affiliation).map(aff -> Role.forAffiliation(aff)); + if (self.roles.exists(r -> r.id == "owner")) { + return ["owner", "admin", "none", "outcast"].filter(aff -> !member.roles.exists(r -> r.id == aff)).map(aff -> Role.forAffiliation(aff)); } - if (p.mucUser.affiliation == "admin") { - if (ppresence.mucUser.affiliation == "owner") return []; + if (self.roles.exists(r -> r.id == "admin")) { + if (member.roles.exists(r -> r.id == "owner")) return []; - return ["member", "outcast"].filter(aff -> aff != ppresence.mucUser.affiliation).map(aff -> Role.forAffiliation(aff)); + return ["none", "outcast"].filter(aff -> !member.roles.exists(r -> r.id == aff)).map(aff -> Role.forAffiliation(aff)); } return []; } - override public function canRemoveRole(participantId: String, role: Role):Bool { - if (_nickInUse == null) return false; - - final p = presence[_nickInUse]; - if (p?.mucUser == null) return false; + override public function canRemoveRole(member: Member, role: Role):Bool { + if (self == null) return false; - final pjid = JID.parse(participantId); - final pnick = pjid.resource; - final ppresence = presence[pnick]; - if (ppresence?.mucUser == null) return false; - if (ppresence?.mucUser?.jid == null) return false; - - if (p.mucUser.affiliation == "owner") { - return ["owner", "admin", "member", "outcast"].contains(role.id); + if (self.roles.exists(r -> r.id == "owner")) { + return ["owner", "admin", "none", "outcast"].contains(role.id); } - if (p.mucUser.affiliation == "admin") { - if (ppresence.mucUser.affiliation == "owner") return false; + if (self.roles.exists(r -> r.id == "admin")) { + if (member.roles.exists(r -> r.id == "owner")) return false; - return ["admin", "member", "outcast"].contains(role.id); + return ["admin", "none", "outcast"].contains(role.id); } return false; } - override public function addRole(participantId: String, role: Role) { - final pjid = JID.parse(participantId); - final pnick = pjid.resource; - final ppresence = presence[pnick]; - if (ppresence?.mucUser?.jid == null) return; + override public function addRole(member: Member, role: Role) { + if (member.chat == null) return; // We don't know their jid final iq = new Stanza("iq", { type: "set", to: chatId }) .tag("query", { xmlns: "http://jabber.org/protocol/muc#admin" }) - .textTag("item", "", { affiliation: role.id, jid: ppresence.mucUser.jid.asBare().asString() }); + .textTag("item", "", { affiliation: role.id, jid: member.chat.chatId }); stream.sendIq(iq, (response) -> {}); } - override public function removeRole(participantId: String, role: Role) { - // For affiliation-backed roles we remove them by adding affiliation of none - addRole(participantId, new Role("none", "")); + override public function removeRole(member: Member, role: Role) { + // For affiliation-backed roles we remove them by adding affiliation of member, which we treat as "no role" + addRole(member, new Role("member", "")); } @HaxeCBridge.noemit // on superclass as abstract @@ -1977,8 +2071,9 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H message.sortId = client.nextSortId(); } } - message.senderId = stanza.attr.get("from"); // MUC always needs full JIDs - if (message.senderId == getFullJid().asString()) { + final occupantId = stanza.getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id"); + message.senderId = occupantId == null ? stanza.attr.get("from") : chatId + "/" + occupantId; // MUC always needs full JIDs + if (message.senderId == self?.id) { message.recipients = message.replyTo; message.direction = MessageSent; } @@ -1990,8 +2085,8 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H message.timestamp = message.timestamp ?? Date.format(std.Date.now()); message.direction = MessageSent; message.from = client.jid; - message.sender = getFullJid(); - message.replyTo = [message.sender]; + message.senderId = self?.id ?? getFullJid().asString(); // TODO: what if we aren't joined and self is null and this is an occupant id room? + message.replyTo = [getFullJid()]; message.to = JID.parse(chatId); message.recipients = [message.to]; if (message.localId == null) message.localId = ID.unique(); @@ -2012,8 +2107,9 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H sendMessageStanza(message.build().asStanza(), outboxItem); client.notifyMessageHandlers(corrected, CorrectionEvent); if (lastMessage == null || corrected.canReplace(lastMessage)) { - setLastMessage(corrected); - client.trigger("chats/update", [this]); + setLastMessage(corrected).then(_ -> + client.trigger("chats/update", [this]) + ); } }); } @@ -2027,7 +2123,10 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H final stanza = message.build().asStanza(); // Fake from as it will look on reflection for storage purposes stanza.attr.set("from", getFullJid().asString()); - final fromStanza = Message.fromStanza(stanza, client.jid).parsed; + final fromStanza = Message.fromStanza(stanza, client.jid, (builder, _) -> { + if (message.senderId != null) builder.senderId = message.senderId; + return builder; + }).parsed; stanza.attr.set("from", client.jid.asString()); switch (fromStanza) { case ChatMessageStanza(_): @@ -2039,9 +2138,10 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H final outboxItem = outbox.newItem(); client.storeMessageBuilder(message).then((stored) -> { sendMessageStanza(stanza, outboxItem); - setLastMessage(stored); - client.notifyMessageHandlers(stored, stored.versions.length > 1 ? CorrectionEvent : DeliveryEvent); - client.trigger("chats/update", [this]); + setLastMessage(stored).then(_ -> { + client.notifyMessageHandlers(stored, stored.versions.length > 1 ? CorrectionEvent : DeliveryEvent); + client.trigger("chats/update", [this]); + }); }); case ReactionUpdateStanza(update): persistence.storeReaction(client.accountId(), update).then((stored) -> { @@ -2073,11 +2173,11 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H final reactions = []; for (areaction => reacts in m.reactions) { if (areaction != reaction.key) { - final react = reacts.find(r -> r.senderId == getFullJid().asString()); + final react = reacts.find(r -> r.senderId == self?.id); if (react != null && !Std.isOfType(react, CustomEmojiReaction)) reactions.push(react); } } - final update = new ReactionUpdate(ID.unique(), m.serverId, m.chatId(), null, m.chatId(), getFullJid().asString(), Date.format(std.Date.now()), reactions, EmojiReactions); + final update = new ReactionUpdate(ID.unique(), m.serverId, m.chatId(), null, m.chatId(), self?.id, Date.format(std.Date.now()), reactions, EmojiReactions); final outboxItem = outbox.newItem(); persistence.storeReaction(client.accountId(), update).then((stored) -> { sendMessageStanza(update.asStanza(), outboxItem); @@ -2094,11 +2194,6 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H outboxItem.handle(() -> client.sendStanza(stanza)); } - @HaxeCBridge.noemit // on superclass as abstract - public function lastMessageId() { - return lastMessage?.serverId; - } - @HaxeCBridge.noemit // on superclass as abstract public function markReadUpTo(message: ChatMessage) { markReadUpToMessage(message).then(_ -> { @@ -2272,6 +2367,7 @@ class SerializedChat { public final isBookmarked:Bool; public final avatarSha1:Null<BytesData>; public final presence:Map<String, Presence>; + public final membersForName: Null<Array<{id: String, displayName: String}>>; public final displayName:Null<String>; public final uiState:UiState; public final isBlocked:Bool; @@ -2279,23 +2375,25 @@ class SerializedChat { public final extensions:String; public final readUpToId:Null<String>; public final readUpToBy:Null<String>; - public final threads: StringMapNullableKey = new StringMapNullableKey(); + public final threads: StringMapNullableKey<String> = new StringMapNullableKey(); public final disco:Null<Caps>; + public final mavUntil:Null<String>; public final omemoContactDeviceIDs: Array<Int>; - public final klass:String; public final notificationsFiltered: Null<Bool>; public final notifyMention: Bool; public final notifyReply: Bool; + public final klass:String; /** Create a serialized chat snapshot suitable for persistence. **/ - public function new(chatId: String, trusted: Bool, isBookmarked: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<UiState>, isBlocked: Null<Bool>, status: Status, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, notificationsFiltered: Null<Bool>, notifyMention: Bool, notifyReply: Bool, threads: StringMapNullableKey, disco: Null<Caps>, omemoContactDeviceIDs: Array<Int>, klass: String) { + public function new(chatId: String, trusted: Bool, isBookmarked: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, membersForName: Null<Array<{id: String, displayName: String}>>, displayName: Null<String>, uiState: Null<UiState>, isBlocked: Null<Bool>, status: Status, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, notificationsFiltered: Null<Bool>, notifyMention: Bool, notifyReply: Bool, threads: StringMapNullableKey<String>, disco: Null<Caps>, mavUntil: Null<String>, omemoContactDeviceIDs: Array<Int>, klass: String) { this.chatId = chatId; this.trusted = trusted; this.isBookmarked = isBookmarked; this.avatarSha1 = avatarSha1; this.presence = presence; + this.membersForName = membersForName; this.displayName = displayName; this.uiState = uiState ?? Open; this.isBlocked = isBlocked ?? false; @@ -2308,6 +2406,7 @@ class SerializedChat { this.notifyReply = notifyReply; this.threads = threads; this.disco = disco; + this.mavUntil = mavUntil; this.omemoContactDeviceIDs = omemoContactDeviceIDs; this.klass = klass; } @@ -2329,7 +2428,9 @@ class SerializedChat { new DirectChat(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensionsStanza, readUpToId, readUpToBy, omemoContactDeviceIDs); } else if (klass == "Channel") { final channel = new Channel(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensionsStanza, readUpToId, readUpToBy); - channel.disco = disco ?? new Caps("", [], ["http://jabber.org/protocol/muc"], []); + channel.membersForName = membersForName; + channel.mavUntil = mavUntil; + if (disco != null) channel.disco = client.capsRepo.add(disco, false); if (notificationsFiltered == null && !channel.isPrivate()) { mention = filterN = true; } @@ -2343,7 +2444,7 @@ class SerializedChat { chat.status = status; chat.setTrusted(trusted); for (resource => p in presence) { - chat.setPresence(resource, p); + chat.setPresence(resource, p, true); } for (threadId => subject in threads) { chat.setThreadSubject(threadId, subject); diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx index c84681a..3730064 100644 --- a/borogove/ChatMessage.hx +++ b/borogove/ChatMessage.hx @@ -411,7 +411,7 @@ class ChatMessage { @param sender optionally specify the full details of the sender **/ - public function body(sender: Null<Participant> = null):Html { + public function body(sender: Null<Member> = null):Html { return new Html(htmlBody(), sender); } @@ -440,6 +440,14 @@ class ChatMessage { return (!isIncoming() ? from?.asBare()?.asString() : to?.asBare()?.asString()) ?? throw "from or to is null"; } + /** + A basic Member for the sender, in case the full one can't be loaded + **/ + public function senderMemberStub():Member { + final displayName = (type == MessageChannel ? from.resource : from.asString()) ?? " "; + return new Member(senderId, displayName, null, !isIncoming(), [], from, new Map(), null); + } + /** Is this message the same as or a replacement for some other one? **/ diff --git a/borogove/Client.hx b/borogove/Client.hx index 113f365..3b5eada 100644 --- a/borogove/Client.hx +++ b/borogove/Client.hx @@ -358,12 +358,9 @@ class Client extends EventEmitter { if (presence.ver == null) { chat.setPresence(JID.parse(stanza.attr.get("from")).resource, stanza); - persistence.storeChats(this.accountId(), [chat]); - if (chat.livePresence()) this.trigger("chats/update", [chat]); } else { final handleCaps = (caps) -> { chat.setPresence(JID.parse(stanza.attr.get("from")).resource, stanza); - if (presence.mucUser == null || chat.livePresence()) persistence.storeChats(this.accountId(), [chat]); return chat; }; @@ -378,12 +375,12 @@ class Client extends EventEmitter { final chatsToUpdate: Map<String, Chat> = []; final handlers = pendingCaps.get(ver) ?? []; pendingCaps.remove(ver); - if (discoGet.getResult() != null) capsRepo.add(discoGet.getResult()); - for (handler in handlers) { - final c = handler(discoGet.getResult()); - if (c.livePresence()) chatsToUpdate.set(c.chatId, c); + if (discoGet.getResult() != null) { + final cachedCaps = capsRepo.add(discoGet.getResult()); + for (handler in handlers) { + final c = handler(cachedCaps); + } } - this.trigger("chats/update", Lambda.array({ iterator: () -> chatsToUpdate.iterator() })); }); sendQuery(discoGet); } else { @@ -429,9 +426,7 @@ class Client extends EventEmitter { trace("Presence for unknown JID: " + stanza.attr.get("from")); return EventUnhandled; } - // Maybe in the future record it as offine rather than removing it - chat.removePresence(JID.parse(stanza.attr.get("from")).resource); - persistence.storeChats(this.accountId(), [chat]); + chat.setPresence(JID.parse(stanza.attr.get("from")).resource, stanza); this.trigger("chats/update", [chat]); } @@ -494,18 +489,19 @@ class Client extends EventEmitter { if (chat != null) { final updateChat = (chatMessage: ChatMessage) -> { final eventType = chatMessage.versions.length > 1 ? CorrectionEvent : DeliveryEvent; - if (chat.lastMessage == null || eventType == DeliveryEvent || chatMessage.canReplace(chat.lastMessage)) { - chat.setLastMessage(chatMessage); - } - - notifyMessageHandlers(chatMessage, eventType); - - if (eventType == DeliveryEvent) { - chat.setUnreadCount(chatMessage.isIncoming() ? chat.unreadCount() + 1 : 0); - chatActivity(chat); - } else if (newChat != null) { - this.trigger("chats/update", [newChat]); - } + ((chat.lastMessage == null || eventType == DeliveryEvent || chatMessage.canReplace(chat.lastMessage)) ? + chat.setLastMessage(chatMessage) : + Promise.resolve(null) + ).then(_ -> { + notifyMessageHandlers(chatMessage, eventType); + + if (eventType == DeliveryEvent) { + chat.setUnreadCount(chatMessage.isIncoming() ? chat.unreadCount() + 1 : 0); + chatActivity(chat); + } else if (newChat != null) { + this.trigger("chats/update", [newChat]); + } + }); }; if (chatMessage.serverId == null) { updateChat(chatMessage); @@ -599,6 +595,7 @@ class Client extends EventEmitter { } #end + final chat = getChat(from.asBare().asString()); final chatState = stanza.getChild(null, "http://jabber.org/protocol/chatstates"); final userState = switch (chatState?.name) { case "active": UserState.Active; @@ -609,12 +606,39 @@ class Client extends EventEmitter { default: null; }; if (userState != null) { - final chat = getChat(from.asBare().asString()); - if (chat == null || !chat.getParticipantDetails(message.senderId).isSelf) { - this.trigger("chat-state/update", { message: message, userState: userState }); + if (chat == null) { + chat.getMemberDetails([message.senderId]).then(members -> { + if (members.length > 0 && members[0].isSelf) return; + + this.trigger("chat-state/update", { message: message, userState: userState }); + }); } } + final memberUpdates = MemberUpdate.extractUpdates(accountId(), chat, stanza); + if (memberUpdates.length > 0) { +trace("YYZZXX memberUpdates", chat.chatId, memberUpdates.length); + final channel = Util.downcast(chat, Channel); + final mucUser = stanza.getChild("x", "http://jabber.org/protocol/muc#user"); + var isFullList = false; + if (channel != null && mucUser != null) { + final mav = mucUser.getChild("mav", "urn:xmpp:muc:affiliations:1"); + if (mav != null && mav.attr.get("since") == null) { + isFullList = true; + } else if (mav != null && mav.attr.get("since") != channel.mavUntil) { + trace("MAV update with unknown previous version", channel.mavUntil, stanza); + } + if (channel.mavUntil != mav?.attr?.get("until")) { + channel.mavUntil = mav?.attr?.get("until"); + persistence.storeChats(accountId(), [channel]); + } + } + + persistence.storeMemberUpdates(accountId(), chat, memberUpdates, isFullList).then(members -> { + if (members.length > 0) chat.trigger("members/update", members); + }); + } + checkForReceipts(stanza); final pubsubEvent = PubsubEvent.fromStanza(stanza); @@ -760,6 +784,7 @@ class Client extends EventEmitter { return persistence.getChats(accountId()); }).then((protoChats) -> { + protoChats.sort((a, b) -> a.chatId == accountId() ? 1 : 0); var oneProtoChat = null; while ((oneProtoChat = protoChats.pop()) != null) { chats.push(oneProtoChat.toChat(this, stream, persistence)); @@ -921,6 +946,12 @@ class Client extends EventEmitter { return EventHandled; } + // We don't know what any presences are + persistence.clearMemberPresence(accountId(), null); + for (chat in chats) { + final directChat = Util.downcast(chat, DirectChat); + if (directChat != null) directChat.presence.clear(); + } discoverServices(new JID(null, jid.domain), (service, caps) -> { persistence.storeService(accountId(), service.jid.asString(), service.name, service.node, caps); @@ -943,10 +974,10 @@ class Client extends EventEmitter { persistence.getChatsUnreadDetails(accountId(), chats).then((details) -> { for (detail in details) { var chat = getChat(detail.chatId) ?? getDirectChat(detail.chatId, false); - final initialLastId = chat.lastMessageId(); + final initialLast = chat.lastMessage; if (detail.message != null) chat.setLastMessage(detail.message); chat.setUnreadCount(detail.unreadCount); - if (detail.unreadCount > 0 && initialLastId != chat.lastMessageId()) { + if (detail.unreadCount > 0 && initialLast != null && !initialLast.canReplace(chat.lastMessage)) { chatActivity(chat, false); } } @@ -1047,21 +1078,21 @@ class Client extends EventEmitter { for (chat in chats) { if (chat.isTrusted()) { - final resources:Map<String, Bool> = new Map(); - for (resource in Caps.withIdentity(chat.getCaps(), "gateway", null)) { + final resources:StringMapNullableKey<Caps> = new StringMapNullableKey(); + for (resource => caps in Caps.withIdentity(chat.getCaps(), "gateway", null)) { // Sometimes gateway items also have id "gateway" for whatever reason - final identities = chat.getResourceCaps(resource)?.identities ?? []; + final identities = caps.identities; if ( (chat.chatId.indexOf("@") < 0 || identities.find(i -> i.category == "conference") == null) && identities.find(i -> i.category == "client") == null ) { - resources[resource] = true; + resources[resource] = caps; } } - if (!sendAvailable && JID.parse(chat.chatId).isDomain()) { - resources[null] = true; + if (!resources.exists(null) && !sendAvailable && JID.parse(chat.chatId).isDomain()) { + resources[null] = CapsRepo.empty; } - for (resource in resources.keys()) { + for (resource => caps in resources) { final bareJid = JID.parse(chat.chatId); final fullJid = new JID(bareJid.node, bareJid.domain, bareJid.isDomain() && resource == "" ? null : resource); final jigGet = new JabberIqGatewayGet(fullJid.asString()); @@ -1069,7 +1100,7 @@ class Client extends EventEmitter { jigGet.onFinished(() -> { final result = jigGet.getResult(); if (result == null) { - final identity = chat.getResourceCaps(resource).identities[0]; + final identity = caps.identities[0]; if (identity == null) { resolve(""); } else { @@ -1709,7 +1740,7 @@ class Client extends EventEmitter { for (item in itemsGet.getResult() ?? []) { final infoGet = new DiscoInfoGet(item.jid.asString(), item.node); infoGet.onFinished(() -> { - callback(item, infoGet.getResult() ?? new Caps("", [], [], [])); + callback(item, infoGet.getResult() ?? CapsRepo.empty); }); sendQuery(infoGet); } @@ -1778,9 +1809,9 @@ class Client extends EventEmitter { this.trigger("chats/update", [chat]); } } else { - capsRepo.add(resultCaps); - if (resultCaps.isChannel(jid)) { - final chat = new Channel(this, this.stream, this.persistence, jid, uiState, false, false, null, resultCaps); + final cachedCaps = capsRepo.add(resultCaps); + if (cachedCaps.isChannel(jid)) { + final chat = new Channel(this, this.stream, this.persistence, jid, uiState, false, false, null, cachedCaps); chat.setupNotifications(); chats.unshift(chat); if (inSync && sendAvailable) chat.selfPing(false); @@ -1849,7 +1880,7 @@ class Client extends EventEmitter { if (chat == null) { startChatWith(item.attr.get("id"), _ -> Closed, (chat) -> chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by"))); } else { - chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by")).then(_ -> null, e -> e != null ? Promise.reject(e) : null); + chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by"), false).then(_ -> null, e -> e != null ? Promise.reject(e) : null); chatsToUpdate.push(chat); } } diff --git a/borogove/Html.hx b/borogove/Html.hx index 3ef6d0e..668c95d 100644 --- a/borogove/Html.hx +++ b/borogove/Html.hx @@ -42,10 +42,10 @@ class Html { @:allow(borogove) private final xml: ReadOnlyArray<Node>; - private final sender: Null<Participant>; + private final sender: Null<Member>; @:allow(borogove) - private function new(xml: Array<Node>, sender: Null<Participant>) { + private function new(xml: Array<Node>, sender: Null<Member>) { this.xml = xml; this.sender = sender; } diff --git a/borogove/Participant.hx b/borogove/Member.hx similarity index 63% rename from borogove/Participant.hx rename to borogove/Member.hx index 661eeae..dff9e17 100644 --- a/borogove/Participant.hx +++ b/borogove/Member.hx @@ -4,6 +4,7 @@ import thenshim.Promise; import haxe.ds.ReadOnlyArray; import borogove.Chat; +import borogove.Presence; import borogove.queries.PubsubGet; #if cpp @@ -16,14 +17,19 @@ import HaxeCBridge; @:build(HaxeCBridge.expose()) @:build(HaxeSwiftBridge.expose()) #end -class Participant { +class Member { /** - Display name to show for this participant + A unique id for this member + **/ + public final id: String; + + /** + Display name to show for this member **/ public final displayName: String; /** - Avatar URI for this participant, or null when none is known + Avatar URI for this member, or null when none is known **/ public final photoUri: Null<String>; @@ -33,38 +39,55 @@ class Participant { public final placeholderUri: String; /** - True when this participant is the connected account + True when this member is the connected account **/ public final isSelf: Bool; /** - Chat metadata for this participant when it is available as a direct Chat + Chat metadata for this member when it is available as a direct Chat **/ public final chat: Null<AvailableChat>; /** - Roles this participant has in the Chat + Roles this member has in the Chat **/ public final roles: ReadOnlyArray<Role>; + public final showPresence: ShowPresence; + + @:allow(borogove) + private var presence: Map<String, Presence>; + + @:allow(borogove.MemberUpdate) private final jid: JID; @:allow(borogove) - private function new(displayName: String, photoUri: Null<String>, placeholderUri: String, isSelf: Bool, roles: Array<Role>, jid: JID, chat: Null<AvailableChat>) { + private function new(id: String, displayName: String, photoUri: Null<String>, isSelf: Bool, roles: Array<Role>, jid: JID, presence: Map<String, Presence>, chat: Null<AvailableChat>) { + this.id = id; this.displayName = displayName; this.photoUri = photoUri; - this.placeholderUri = placeholderUri; + this.placeholderUri = Color.defaultPhoto(id, displayName.charAt(0).toUpperCase()); this.isSelf = isSelf; this.roles = roles; - this.chat = chat; this.jid = jid; + this.presence = presence; + this.chat = chat; + var lowestShow = Offline; + var highestPrio = -100000; + for (_ => p in presence) { + if (p.priority >= highestPrio) { + highestPrio = p.priority; + if ((lowestShow : Int) > (p.show : Int)) lowestShow = p.show; + } + } + showPresence = lowestShow; } /** - Load the participant's profile + Load the member's profile @param client connected client used to send the profile query - @returns Promise resolving to the participant profile + @returns Promise resolving to the member Profile **/ public function profile(client: Client): Promise<Profile> { return new Promise((resolve, reject) -> { @@ -83,10 +106,10 @@ class Participant { } /** - Load the participant's status + Load the member's status @param client connected client used to send the profile query - @returns Promise resolving to the participant status + @returns Promise resolving to the member Status **/ public function status(client: Client): Promise<Status> { return new Promise((resolve, reject) -> { diff --git a/borogove/MemberUpdate.hx b/borogove/MemberUpdate.hx new file mode 100644 index 0000000..70d532b --- /dev/null +++ b/borogove/MemberUpdate.hx @@ -0,0 +1,93 @@ +package borogove; + +import thenshim.Promise; +import haxe.ds.ReadOnlyArray; + +import borogove.Chat; +import borogove.queries.PubsubGet; + +#if cpp +import HaxeCBridge; +#end + +// Some updates are partial +// We encode the more low-level values here +@:nullSafety(Strict) +class MemberUpdate { + public final id: Null<String>; + public final jid: Null<JID>; + private final displayName: Null<String>; + private final isSelf: Bool; + private final affiliation: Null<Role>; // Null means "member" + private final presence: Map<String, Presence>; + + @:allow(borogove) + private function new(id: Null<String>, jid: Null<JID>, displayName: Null<String>, isSelf: Bool, affiliation: Null<Role>, presence: Map<String, Presence>) { + this.id = id; + this.jid = jid; + this.displayName = displayName; + this.isSelf = isSelf; + this.affiliation = affiliation; + this.presence = presence; + } + + public function applyTo(member: Null<Member>) { + if (id != null || jid != null) { + if (member != null && member?.id != id && member?.chat?.chatId != jid?.asString()) throw "Member does not match this update"; + } + if (member != null && isSelf != member?.isSelf) throw "Member does not match this update"; + final filteredRoles = affiliation?.id == "none" ? [] : (member?.roles ?? []).filter(r -> !["owner", "admin", "member", "none", "outcast"].contains(r.id)); + final mergedPresence = new Map(); + + for (resource => p in member?.presence ?? new Map()) { + mergedPresence.set(resource, p); + } + + for (resource => p in presence) { + mergedPresence.set(resource, p); + } + + return { + id: id ?? member?.id, + displayName: displayName ?? (member?.displayName == "" ? null : member?.displayName) ?? jid?.asString(), + photoUri: member?.photoUri, + isSelf: isSelf, + roles: filteredRoles.concat(affiliation == null ? [] : [affiliation]), + jid: member?.jid ?? jid, + presence: mergedPresence, + chat: jid == null ? null : new AvailableChat(jid.asBare().asString(), displayName, "", CapsRepo.empty) + }; + } + + static public function extractUpdates(accountId: String, chat: Chat, stanza: Stanza) { + final updates = []; + final mucUser = stanza.getChild("x", "http://jabber.org/protocol/muc#user"); + if (mucUser != null) { + final sOccupantId = stanza.getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id"); + final from = stanza.attr.get("from"); + final resource = stanza.name == "presence" && from != null ? JID.parse(from).resource : null; + final channel = Util.downcast(chat, Channel); + for (item in mucUser.allTags("item")) { + final jidS = item.attr.get("jid"); + final jid = jidS == null ? null : JID.parse(jidS).asBare(); + final occupantId: Null<String> = item.getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id") ?? sOccupantId; + final id = occupantId == null ? null : chat.chatId + "/" + occupantId; + final aff = item.attr.get("affiliation"); + if (aff == null) { + trace("No affiliation on affiliation update", stanza); + } else { + updates.push(new MemberUpdate( + id, + jid, + item.attr.get("nick") ?? resource, + jid?.asString() == accountId || channel?.self?.id == id, + Role.forAffiliation(aff), + stanza.name == "presence" ? [ resource ?? "" => stanza ] : new Map() + )); + } + } + } + + return updates; + } +} diff --git a/borogove/Message.hx b/borogove/Message.hx index 29f59ea..07bf735 100644 --- a/borogove/Message.hx +++ b/borogove/Message.hx @@ -88,7 +88,8 @@ class Message { if (msg.type == MessageChat && stanza.getChild("x", "http://jabber.org/protocol/muc#user") != null) { msg.type = MessageChannelPrivate; } - msg.senderId = (msg.type == MessageChannel || msg.type == MessageChannelPrivate ? msg.from : msg.from?.asBare())?.asString(); + final occupantId = stanza.getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id"); + msg.senderId = occupantId != null ? msg.chatId() + "/" + occupantId : (msg.type == MessageChannel || msg.type == MessageChannelPrivate ? msg.from : msg.from?.asBare())?.asString(); final localJidBare = localJid.asBare(); final domain = localJid.domain; final to = stanza.attr.get("to"); @@ -344,6 +345,7 @@ class Message { final replyToMessage = new ChatMessageBuilder(); replyToMessage.to = replyToJid == msg.senderId ? msg.to : msg.from; replyToMessage.from = replyToJid == null ? null : JID.parse(replyToJid); + // Note: in MUC this senderid is wrong and not an occupant-id replyToMessage.senderId = isGroupchat ? replyToMessage.from?.asString() : replyToMessage.from?.asBare()?.asString(); replyToMessage.replyId = replyToID; if ((msg.serverIdBy != null && msg.serverIdBy != localJid.asBare().asString()) || isGroupchat) { diff --git a/borogove/Persistence.hx b/borogove/Persistence.hx index e81d410..6695130 100644 --- a/borogove/Persistence.hx +++ b/borogove/Persistence.hx @@ -47,6 +47,61 @@ interface Persistence { @HaxeCBridge.noemit public function getChats(accountId: String): Promise<Array<SerializedChat>>; + /** + Persist some members for a chat + + @param accountId the account that owns the chat + @param chatId the chat whose members are being stored + @param members members to write to storage + @returns Promise resolving true when the write completes + **/ + @HaxeCBridge.noemit + public function storeMembers(accountId: String, chatId: String, members: Array<Member>): Promise<Bool>; + + /** + Apply one or more member updates to stored chat membership state + + @param accountId the account that owns the chat + @param chat the chat whose membership is being updated + @param updates incremental member updates to apply + @param isFullList true when updates represent the complete current affiliation list + @returns Promise resolving to the updated members that should be surfaced to callers + **/ + @HaxeCBridge.noemit + public function storeMemberUpdates(accountId: String, chat: Chat, updates: Array<MemberUpdate>, isFullList: Bool): Promise<Array<Member>>; + + /** + Clear cached member presence for one chat or an entire account + + @param accountId the account whose cached presence should be cleared + @param chatId the chat to clear, or null to clear every chat in the account + @returns Promise resolving true when the clear completes + **/ + @HaxeCBridge.noemit + public function clearMemberPresence(accountId: String, chatId: Null<String>): Promise<Bool>; + + /** + Load some members for a chat + + @param accountId the account that owns the chat + @param chat the chat to load members for + @param forModerator true to include moderator-only rows such as banned occupants + @returns Promise resolving to the members visible for the requested view + **/ + @HaxeCBridge.noemit + public function getMembers(accountId: String, chat: Chat, forModerator: Bool): Promise<Array<Member>>; + + /** + Load detailed member records by ID + + @param accountId the account that owns the member records + @param chat the chat context for any hydrated chat-specific metadata, or null when unavailable + @param ids member IDs to look up + @returns Promise resolving to an entry per requested ID, with null for unknown or incomplete members + **/ + @HaxeCBridge.noemit + public function getMemberDetails(accountId: String, chat: Null<Chat>, ids: Array<String>): Promise<Array<Null<Member>>>; + /** Load unread counters and most recent unread message per Chat diff --git a/borogove/Presence.hx b/borogove/Presence.hx index 9ab8b52..08206b6 100644 --- a/borogove/Presence.hx +++ b/borogove/Presence.hx @@ -4,6 +4,14 @@ import borogove.Hash; import borogove.MucUser; import borogove.Role; +enum abstract ShowPresence(Int) to Int { + var Online; + var Idle; + var DoNotDisturb; + var Standby; + var Offline; +} + @:nullSafety(StrictThreaded) @:forward(toString) @:expose @@ -13,6 +21,9 @@ abstract Presence(Stanza) from Stanza to Stanza { public var mucUser(get, never): Null<MucUser>; public var hats(get, never): Null<Array<Role>>; public var avatarHash(get, never): Null<Hash>; + public var type(get, never): Null<String>; + public var show(get, never): ShowPresence; + public var priority(get, never): Int; /** Create a presence stanza wrapper from caps, MUC metadata, and avatar hash. @@ -50,4 +61,23 @@ abstract Presence(Stanza) from Stanza to Stanza { final avatarSha1Hex = this.findText("{vcard-temp:x:update}x/photo#"); return avatarSha1Hex == null || avatarSha1Hex == "" ? null : Hash.fromHex("sha-1", avatarSha1Hex); } + + private inline function get_type() { + return this.attr.get("type"); + } + + private inline function get_show() { + if (type == "unavailable") return Offline; + + return switch (this.getChildText("show")) { + case "away": Idle; + case "dnd": DoNotDisturb; + case "xa": Standby; + default: Online; + }; + } + + private inline function get_priority() { + return Std.parseInt(this.getChildText("priority") ?? "0") ?? 0; + } } diff --git a/borogove/Role.hx b/borogove/Role.hx index cb20035..50307c3 100644 --- a/borogove/Role.hx +++ b/borogove/Role.hx @@ -10,7 +10,7 @@ import borogove.Color; #end class Role { // A role is the unification of XMPP affiliations and hats - // importantly, it is *not* and XMPP MUC role + // importantly, it is *not* an XMPP MUC role /** Unique id for the role @@ -32,9 +32,9 @@ class Role { private static function forAffiliation(aff: String) { final title = switch (aff) { case "outcast": "Banned"; - case "member": "Member"; case "admin": "Admin"; case "owner": "Owner"; + case "none": "Guest"; default: return null; } return new Role(aff, title); diff --git a/borogove/Util.hx b/borogove/Util.hx index bbe8d27..66e8ccb 100644 --- a/borogove/Util.hx +++ b/borogove/Util.hx @@ -193,6 +193,10 @@ class Util { o.writeBytes(b, 0, b.length); } + inline static public function filterOutNulls<A>(arr: Array<Null<A>>, f: (A -> Bool)): Array<A> { + return arr.filter((x) -> x != null && f(x)); + } + /** Convert a String index to a UnicodeString index **/ diff --git a/borogove/persistence/Dummy.hx b/borogove/persistence/Dummy.hx index 4fc55aa..e23f041 100644 --- a/borogove/persistence/Dummy.hx +++ b/borogove/persistence/Dummy.hx @@ -42,6 +42,31 @@ class Dummy implements Persistence { return Promise.resolve([]); } + @HaxeCBridge.noemit + public function storeMembers(accountId: String, chatId: String, chat: Array<Member>) { + return Promise.resolve(false); + } + + @HaxeCBridge.noemit + public function storeMemberUpdates(accountId: String, chat: Chat, updates: Array<MemberUpdate>, isFullList: Bool) { + return Promise.resolve([]); + } + + @HaxeCBridge.noemit + public function clearMemberPresence(accountId: String, chatId: Null<String>) { + return Promise.resolve(false); + } + + @HaxeCBridge.noemit + public function getMembers(accountId: String, chat: Chat, forModerator: Bool) { + return Promise.resolve([]); + } + + @HaxeCBridge.noemit + public function getMemberDetails(accountId: String, chat: Null<Chat>, ids: Array<String>) { + return Promise.resolve([]); + } + @HaxeCBridge.noemit public function storeMessages(accountId: String, messages: Array<ChatMessage>): Promise<Array<ChatMessage>> { return Promise.resolve(messages); diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js index 1463399..95cc2b5 100644 --- a/borogove/persistence/IDB.js +++ b/borogove/persistence/IDB.js @@ -3,6 +3,7 @@ // Importing internals not the public interface import { + borogove_AvailableChat, borogove_Caps, borogove_Channel, borogove_ChatMessageBuilder, @@ -10,6 +11,8 @@ import { borogove_DirectChat, borogove_Hash, borogove_Identity, + borogove_Member, + borogove_Role, borogove_JID, borogove_Presence, borogove_Reaction, @@ -152,6 +155,15 @@ export default async (dbname, media, tokenize, stemmer) => { if (!db.objectStoreNames.contains("chats")) { db.createObjectStore("chats", { keyPath: ["account", "chatId"] }); } + if (!db.objectStoreNames.contains("members")) { + const members = db.createObjectStore("members", { keyPath: ["account", "id"] }); + } + if (tx.objectStore("members").indexNames.contains("chats")) { + tx.objectStore("members").deleteIndex("chats"); + } + if (!tx.objectStore("members").indexNames.contains("chatsWithTrueJid")) { + tx.objectStore("members").createIndex("chatsWithTrueJid", ["account", "chatId", "isSelf", "chat"]); + } if (!db.objectStoreNames.contains("services")) { db.createObjectStore("services", { keyPath: ["account", "serviceId"] }); } @@ -192,14 +204,15 @@ export default async (dbname, media, tokenize, stemmer) => { dbOpenReq.onsuccess = (event) => { const db = event.target.result; const storeNames = [ - "messages", - "keyvaluepairs", "chats", - "services", - "reactions", + "keyvaluepairs", + "members", + "messages", "omemo_identities", "omemo_sessions", - "omemo_sessions_meta" + "omemo_sessions_meta", + "reactions", + "services", ]; for(const storeName of storeNames) { if(!db.objectStoreNames.contains(storeName)) { @@ -208,7 +221,7 @@ export default async (dbname, media, tokenize, stemmer) => { return; } } - const tx = db.transaction(["messages", "keyvaluepairs"], "readonly"); + const tx = db.transaction(["messages", "members", "keyvaluepairs"], "readonly"); const messagesIndexNames = tx.objectStore("messages").indexNames; const wantIndexNames = ["chatsBySortId", "accountsBySortId", "terms", "chats"]; for(const indexName of wantIndexNames) { @@ -219,6 +232,12 @@ export default async (dbname, media, tokenize, stemmer) => { } } + if (!tx.objectStore("members").indexNames.contains("chatsWithTrueJid")) { + db.close(); + openDb(db.version + 1).then(resolve, reject); + return; + } + (async () => { const kv = tx.objectStore("keyvaluepairs"); const ranMigrationAddSortIdAndTerms = await promisifyRequest(kv.get("__migrationAddSortIdAndTerms")); @@ -253,6 +272,19 @@ export default async (dbname, media, tokenize, stemmer) => { }); } + function hydrateMember(chat, raw) { + return new borogove_Member( + raw.id, + raw.displayName, + raw.photoUri, + raw.isSelf ? true : false, + raw.roles.map(role => new borogove_Role(role.id, role.title)), + raw.jid instanceof borogove_JID ? raw.jid : borogove_JID.parse(raw.jid), + new Map((raw.presence?.entries() ?? []).map(([k, p]) => [k, p instanceof borogove_Stanza ? p : borogove_Stanza.parse(p)])), + raw.chat ? new borogove_AvailableChat(raw.chat, raw.displayName, raw.chat + (chat ? " (via " + chat.getDisplayName() + ")" : ""), new borogove_Caps("", [], [], [])) : null + ); + } + function hydrateStringReaction(r, senderId, timestamp) { if (r.startsWith("ni://")){ return new borogove_CustomEmojiReaction(senderId, timestamp, "", r); @@ -409,6 +441,37 @@ export default async (dbname, media, tokenize, stemmer) => { return reactionsMap; } + async function chatPresenceAndMembersForName(account, store, rawChat) { + if (rawChat.class == "DirectChat") { + return [new Map(((await promisifyRequest(store.get([account, rawChat.chatId])))?.presence?.entries() ?? []).map(([k, p]) => [k, borogove_Stanza.parse(p)])), null]; + } + + const range = IDBKeyRange.bound([account, rawChat.chatId], [account, rawChat.chatId, []]); + const cursor = store.index("chatsWithTrueJid").openCursor(range, "prev"); + let membersForName = []; + let presence = new Map(); + while (true) { + const cresult = await promisifyRequest(cursor); + if (!cresult?.value) break; + + if (cresult.value.isSelf) { + presence = new Map((cresult.value.presence?.entries() ?? []).map(([k, p]) => [k, borogove_Stanza.parse(p)])); + } else if (!cresult.value.roles.find(r => ["none", "outcast"].includes(r.id)) && cresult.value.id !== rawChat.chatId) { + membersForName.push({ id: cresult.value.id, displayName: cresult.value.displayName }); + } + + // In big rooms we don't make a name from the members + if (membersForName.length > 20) { + membersForName = null; + break; + } + + cresult.continue(); + } + + return [presence, membersForName]; + }; + const obj = { syncPoint: async function(account, chatId) { const tx = db.transaction(["messages"], "readonly"); @@ -448,7 +511,6 @@ export default async (dbname, media, tokenize, stemmer) => { trusted: chat.trusted, isBookmarked: chat.isBookmarked, avatarSha1: chat.avatarSha1, - presence: new Map([...chat.presence.entries()].map(([k, p]) => [k, p.toString()])), status: chat.status, displayName: chat.displayName, uiState: chat.uiState, @@ -458,6 +520,7 @@ export default async (dbname, media, tokenize, stemmer) => { readUpToBy: chat.readUpToBy, notificationSettings: chat.notificationsFiltered() ? { mention: chat.notifyMention(), reply: chat.notifyReply() } : null, threads: chat.threads, + mavUntil: chat.mavUntil, disco: { ...chat.disco, data: chat.disco?.data?.map(d => d.toString()) }, omemoDevices: chat.omemoContactDeviceIDs, class: chat instanceof borogove_DirectChat ? "DirectChat" : (chat instanceof borogove_Channel ? "Channel" : "Chat") @@ -466,8 +529,9 @@ export default async (dbname, media, tokenize, stemmer) => { }, getChats: async function(account) { - const tx = db.transaction(["chats"], "readonly"); + const tx = db.transaction(["chats", "members"], "readonly"); const store = tx.objectStore("chats"); + const membersStore = tx.objectStore("members"); const range = IDBKeyRange.bound([account], [account, []]); const result = await promisifyRequest(store.getAll(range)); return await Promise.all(result.map(async (r) => new borogove_SerializedChat( @@ -475,12 +539,7 @@ export default async (dbname, media, tokenize, stemmer) => { r.trusted, r.isBookmarked, r.avatarSha1, - new Map(await Promise.all((r.presence instanceof Map ? [...r.presence.entries()] : Object.entries(r.presence)).map( - async ([k, p]) => [k, - typeof(p) === "string" ? borogove_Stanza.parse(p) : - borogove_Presence._new(p.caps && await this.getCaps(p.caps), p.mucUser && borogove_Stanza.parse(p.mucUser), p.avatarHash && borogove_Hash.fromUri(p.avatarHash)) - ] - ))), + ...await chatPresenceAndMembersForName(account, membersStore, r), r.displayName, r.uiState, r.isBlocked, @@ -498,11 +557,148 @@ export default async (dbname, media, tokenize, stemmer) => { r.disco.features || [], (r.disco.data || []).map(s => borogove_Stanza.parse(s)) ) : null, + r.mavUntil, r.omemoDevices || [], r.class ))); }, + async storeMembers(account, chatId, members) { + const tx = db.transaction(["members"], "readwrite"); + const store = tx.objectStore("members"); + + await Promise.all(members.map(member => promisifyRequest(store.put({ + account, + chatId, + id: member.id, + displayName: member.displayName, + photoUri: member.photoUri, + isSelf: member.isSelf ? 1 : 0, // Can't index on boolean + chat: member.chat?.chatId ?? "", + roles: member.roles, + presence: new Map([...member.presence.entries()].map(([k, p]) => [k, p.toString()])), + jid: member.jid.asString(), + })))); + + return true; + }, + + async storeMemberUpdates(account, chat, updates, isFullList) { + const tx = db.transaction(["members"], "readonly"); + const store = tx.objectStore("members"); + const updatesFor = new Set(); + + const pseudoMembers = await Promise.all(updates.map(async (update) => { + let member = null; + if (update.id) { + member = await promisifyRequest(store.get([account, update.id])); + } + if (update.jid && !member) { + member = await promisifyRequest(store.index("chatsWithTrueJid").get([account, chat.chatId, update.isSelf ? 1 : 0, update.jid.asString()])); + } + if (member?.id || update.id) updatesFor.add(update.id ?? member?.id); + return update.applyTo(member ? hydrateMember(chat, member) : null); + })); + + await this.storeMembers(account, chat.chatId, pseudoMembers.filter(m => m?.id)); + + if (isFullList) { + const txW = db.transaction(["members"], "readwrite"); + const storeW = txW.objectStore("members"); + const range = IDBKeyRange.bound([account, chat.chatId], [account, chat.chatId, []]); + const cursor = storeW.index("chatsWithTrueJid").openCursor(range); + while (true) { + const cresult = await promisifyRequest(cursor); + if (!cresult?.value) break; + + if (!updatesFor.has(cresult.value.id)) { + // No update for this member, and we have the full list + // So they are no longer an affiliated member + cresult.update({ ...cresult.value, roles: [] }); + } + + cresult.continue(); + } + } + + return pseudoMembers.filter(m => m?.id && m?.displayName && m?.jid).map(m => hydrateMember(chat, {...m, chat: chat?.chatId })); + }, + + async clearMemberPresence(account, chatId) { + const tx = db.transaction(["members"], "readwrite"); + const store = tx.objectStore("members"); + const range = IDBKeyRange.bound(chatId ? [account, chatId] : [account], chatId ? [account, chatId, []] : [account, []]); + const cursor = store.index("chatsWithTrueJid").openCursor(range); + while (true) { + const cresult = await promisifyRequest(cursor); + if (!cresult?.value) break; + + cresult.update({ ...cresult.value, presence: new Map() }); + + cresult.continue(); + } + + return true; + }, + + async getMembers(account, chat, forModerator) { + const roleSort = { owner: 4, admin: 3, none: 1, outcast: 0 }; + const tx = db.transaction(["members"], "readonly"); + const store = tx.objectStore("members"); + const range = IDBKeyRange.bound([account, chat.chatId], [account, chat.chatId, []]); + // getAll is much faster than openCursor, + // but if the room has more than 20k members this will miss people + // at which point we need paging + const allMembers = await promisifyRequest(store.index("chatsWithTrueJid").getAll(range, 20000)); + let clearedOffline = false; + let result = []; + let member = null; + while ((member = allMembers.pop())) { + if (!member.id || !member.displayName || !member.jid) continue; + if (!forModerator && member.roles.find(r => r.id == "outcast")) continue; + + const presenceKey = member.presence.keys().next()?.value; + const isOnline = presenceKey && !member.presence.get(presenceKey).includes('type="unavailable"'); + if (member.roles.find(r => r.id == "none") && !isOnline) continue; + + if (!forModerator && !clearedOffline && result.length >= 1000) { + result = result.filter(m => m.__isOnline); + clearedOffline = true; + } + + if (!forModerator && clearedOffline && !isOnline) continue; + + const hydrated = hydrateMember(chat, member); + hydrated.__isOnline = isOnline; + hydrated.__roleSort = hydrated.roles.length < 1 ? 2 : Math.max(...hydrated.roles.map(r => roleSort[r.id] ?? 2)); + hydrated.__sortKey = hydrated.roles.map(r => r.title).sort().join(" ") + " " + hydrated.displayName; + result.push(hydrated); + + if (!forModerator && result.length >= 2000) break; + } + + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", + }); + + return result.sort((x, y) => { + if (x.__roleSort !== y.__roleSort) return y.__roleSort - x.__roleSort; + return collator.compare(x.__sortKey, y.__sortKey); + }); + }, + + async getMemberDetails(account, chat, ids) { + const tx = db.transaction(["members"], "readonly"); + const store = tx.objectStore("members"); + return await Promise.all(ids.map(async (id) => { + const raw = await promisifyRequest(store.get([account, id])); + if (!raw?.id || !raw?.displayName || !raw?.jid) return null; + + return hydrateMember(chat, raw); + })); + }, + getChatUnreadDetails: async function(account, chat) { const tx = db.transaction(["messages"], "readonly"); const store = tx.objectStore("messages"); diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx index 507708c..909ad82 100644 --- a/borogove/persistence/Sqlite.hx +++ b/borogove/persistence/Sqlite.hx @@ -286,9 +286,9 @@ class Sqlite implements Persistence implements KeyValueStore { storeChatTimer = haxe.Timer.delay(() -> { final mapPresence = (chat: Chat) -> { final storePresence: DynamicAccess<String> = {}; - for (resource => presence in chat.presence) { + /* TODO for (resource => presence in chat.presence) { if (storePresence[resource ?? ""] == null) storePresence[resource ?? ""] = presence.toString(); - } + }*/ return storePresence; }; final q = new StringBuf(); @@ -328,6 +328,36 @@ class Sqlite implements Persistence implements KeyValueStore { }, 100); } + @HaxeCBridge.noemit + public function storeMembers(accountId: String, chatId: String, chat: Array<Member>) { + // TODO + return Promise.resolve(false); + } + + @HaxeCBridge.noemit + public function storeMemberUpdates(accountId: String, chat: Chat, updates: Array<MemberUpdate>, isFullList: Bool) { + // TODO + return Promise.resolve([]); + } + + @HaxeCBridge.noemit + public function clearMemberPresence(accountId: String, chatId: Null<String>) { + // TODO + return Promise.resolve(false); + } + + @HaxeCBridge.noemit + public function getMembers(accountId: String, chat: Chat, forModerator: Bool) { + // TODO + return Promise.resolve([]); + } + + @HaxeCBridge.noemit + public function getMemberDetails(accountId: String, chat: Null<Chat>, ids: Array<String>) { + // TODO + return Promise.resolve([]); + } + @HaxeCBridge.noemit public function searchMessages(accountId: String, chatId: Null<String>, q: String): Promise<Array<ChatMessage>> { var sql = "SELECT stanza, direction, type, status, status_text, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, sender_id, mam_id, mam_by, sort_id, sync_point FROM messages WHERE account_id=? AND stanza LIKE ?"; @@ -364,13 +394,16 @@ class Sqlite implements Persistence implements KeyValueStore { } final metaJson: { ?threads: Null<DynamicAccess<String>>, ?status: Null<{ emoji: String, text: String }> } = Json.parse(row.meta); - final threadsMap: StringMapNullableKey = new StringMapNullableKey(); + final threadsMap: StringMapNullableKey<String> = new StringMapNullableKey(); for (thread => subject in metaJson.threads ?? {}) { threadsMap.set(thread == "" ? null : thread, subject); } // FIXME: Empty OMEMO contact device ids hardcoded in next line - chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, presenceMap, row.fn, row.ui_state, row.blocked != 0, new Status(metaJson.status?.emoji ?? "", metaJson.status?.text ?? ""), row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, threadsMap, row.capsObj, [], Reflect.field(row, "class"))); + // TODO: memebersForName + // TODO: new presence storage + // TODO: mavUntil + chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, presenceMap, [], row.fn, row.ui_state, row.blocked != 0, new Status(metaJson.status?.emoji ?? "", metaJson.status?.text ?? ""), row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, threadsMap, row.capsObj, null, [], Reflect.field(row, "class"))); } return chats; }); diff --git a/npm/index.ts b/npm/index.ts index 418776c..bee6fb7 100644 --- a/npm/index.ts +++ b/npm/index.ts @@ -31,7 +31,7 @@ export { borogove_Identicon as Identicon, borogove_LinkMetadata as LinkMetadata, borogove_Notification as Notification, - borogove_Participant as Participant, + borogove_Member as Member, borogove_Profile as Profile, borogove_ProfileBuilder as ProfileBuilder, borogove_ProfileItem as ProfileItem, diff --git a/test/TestAll.hx b/test/TestAll.hx index c68f0a1..91750a2 100644 --- a/test/TestAll.hx +++ b/test/TestAll.hx @@ -21,7 +21,8 @@ class TestAll { new TestStanza(), new TestPresence(), new TestClient(), - new TestParticipant(), + new TestMember(), + new TestMemberUpdate(), new TestChat(), new TestSortId(), new TestXEP0393(), diff --git a/test/TestCaps.hx b/test/TestCaps.hx index f9291ac..7c0c371 100644 --- a/test/TestCaps.hx +++ b/test/TestCaps.hx @@ -8,7 +8,7 @@ import borogove.Hash; import borogove.Stanza; import borogove.queries.DiscoInfoGet; -@:access(borogove.Caps.hashInput) +@:access(borogove.Caps) @:access(borogove.Hash.sha256) class TestCaps extends utest.Test { final example = ' @@ -120,4 +120,144 @@ class TestCaps extends utest.Test { s.toString() ); } + + function stableKVOrderIterator<K, V>(data:Array<{key:K, value:V}>):KeyValueIterator<K, V> { + var i = 0; + return { + hasNext: () -> i < data.length, + next: () -> data[i++] + }; + } + + public function testWithIdentityCategory() { + final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []); + final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []); + final data = [{key: "r1", value: c1}, {key: "r2", value: c2}]; + + var iter = Caps.withIdentity(stableKVOrderIterator(data), "client", null); + Assert.isTrue(iter.hasNext()); + Assert.equals("r1", iter.next().key); + Assert.isFalse(iter.hasNext()); + } + + public function testWithIdentityType() { + final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []); + final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []); + final data = [{key: "r1", value: c1}, {key: "r2", value: c2}]; + + var iter = Caps.withIdentity(stableKVOrderIterator(data), null, "sms"); + Assert.isTrue(iter.hasNext()); + Assert.equals("r2", iter.next().key); + Assert.isFalse(iter.hasNext()); + } + + public function testWithIdentityBoth() { + final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []); + final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []); + final data = [{key: "r1", value: c1}, {key: "r2", value: c2}]; + + var iter = Caps.withIdentity(stableKVOrderIterator(data), "client", "pc"); + Assert.isTrue(iter.hasNext()); + Assert.equals("r1", iter.next().key); + Assert.isFalse(iter.hasNext()); + } + + public function testWithIdentityNoMatch() { + final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []); + final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []); + final data = [{key: "r1", value: c1}, {key: "r2", value: c2}]; + + var iter = Caps.withIdentity(stableKVOrderIterator(data), "client", "sms"); + Assert.isFalse(iter.hasNext()); + + iter = Caps.withIdentity(stableKVOrderIterator(data), "other", null); + Assert.isFalse(iter.hasNext()); + } + + public function testWithIdentityEmpty() { + var iter = Caps.withIdentity(stableKVOrderIterator([]), "client", null); + Assert.isFalse(iter.hasNext()); + } + + public function testWithIdentityMatchLast() { + final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []); + final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []); + final data = [{key: "r1", value: c1}, {key: "r2", value: c2}]; + + var iter = Caps.withIdentity(stableKVOrderIterator(data), "gateway", null); + Assert.isTrue(iter.hasNext()); + Assert.equals("r2", iter.next().key); + Assert.isFalse(iter.hasNext()); + } + + public function testWithIdentityMatchMiddle() { + final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []); + final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []); + final data = [{key: "r1", value: c2}, {key: "r2", value: c1}, {key: "r3", value: c2}]; + + var iter = Caps.withIdentity(stableKVOrderIterator(data), "client", null); + Assert.isTrue(iter.hasNext()); + Assert.equals("r2", iter.next().key); + Assert.isFalse(iter.hasNext()); + } + + public function testWithFeatureMatch() { + final c1 = new Caps("n1", [], ["f1", "f2"], []); + final c2 = new Caps("n2", [], ["f2", "f3"], []); + final data = [{key: "r1", value: c1}, {key: "r2", value: c2}]; + + var iter = Caps.withFeature(stableKVOrderIterator(data), "f1"); + Assert.isTrue(iter.hasNext()); + Assert.equals("r1", iter.next().key); + Assert.isFalse(iter.hasNext()); + } + + public function testWithFeatureMultiMatch() { + final c1 = new Caps("n1", [], ["f1", "f2"], []); + final c2 = new Caps("n2", [], ["f2", "f3"], []); + final data = [{key: "r1", value: c1}, {key: "r2", value: c2}]; + + var iter = Caps.withFeature(stableKVOrderIterator(data), "f2"); + Assert.isTrue(iter.hasNext()); + Assert.equals("r1", iter.next().key); + Assert.isTrue(iter.hasNext()); + Assert.equals("r2", iter.next().key); + Assert.isFalse(iter.hasNext()); + } + + public function testWithFeatureNoMatch() { + final c1 = new Caps("n1", [], ["f1", "f2"], []); + final c2 = new Caps("n2", [], ["f2", "f3"], []); + final data = [{key: "r1", value: c1}, {key: "r2", value: c2}]; + + var iter = Caps.withFeature(stableKVOrderIterator(data), "f4"); + Assert.isFalse(iter.hasNext()); + } + + public function testWithFeatureEmpty() { + var iter = Caps.withFeature(stableKVOrderIterator([]), "f1"); + Assert.isFalse(iter.hasNext()); + } + + public function testWithFeatureMatchLast() { + final c1 = new Caps("n1", [], ["f1", "f2"], []); + final c2 = new Caps("n2", [], ["f2", "f3"], []); + final data = [{key: "r1", value: c1}, {key: "r2", value: c2}]; + + var iter = Caps.withFeature(stableKVOrderIterator(data), "f3"); + Assert.isTrue(iter.hasNext()); + Assert.equals("r2", iter.next().key); + Assert.isFalse(iter.hasNext()); + } + + public function testWithFeatureMatchMiddle() { + final c1 = new Caps("n1", [], ["f1", "f2"], []); + final c2 = new Caps("n2", [], ["f2", "f3"], []); + final data = [{key: "r1", value: c2}, {key: "r2", value: c1}, {key: "r3", value: c2}]; + + var iter = Caps.withFeature(stableKVOrderIterator(data), "f1"); + Assert.isTrue(iter.hasNext()); + Assert.equals("r2", iter.next().key); + Assert.isFalse(iter.hasNext()); + } } diff --git a/test/TestCapsRepo.hx b/test/TestCapsRepo.hx index ffe083f..2d1472f 100644 --- a/test/TestCapsRepo.hx +++ b/test/TestCapsRepo.hx @@ -28,6 +28,30 @@ class CapsRepoMockPersistence extends Dummy { } class TestCapsRepo extends utest.Test { + public function testAddDeduplicatesAndReturnsCached() { + final persistence = new CapsRepoMockPersistence(); + final repo = new CapsRepo(persistence); + final caps1 = new Caps("node1", [], ["feat1"], []); + final caps2 = new Caps("node1", [], ["feat1"], []); + + final added1 = repo.add(caps1); + final added2 = repo.add(caps2); + + Assert.equals(caps1, added1); + Assert.equals(caps1, added2); + Assert.equals(1, persistence.storedCaps.length); + } + + public function testAddWithoutStoreOnMiss() { + final persistence = new CapsRepoMockPersistence(); + final repo = new CapsRepo(persistence); + final caps = new Caps("node1", [], ["feat1"], []); + + repo.add(caps, false); + + Assert.equals(0, persistence.storedCaps.length); + } + public function testAddAndGet(async: Async) { final persistence = new CapsRepoMockPersistence(); final repo = new CapsRepo(persistence); @@ -86,4 +110,14 @@ class TestCapsRepo extends utest.Test { async.done(); }, 1); } + + public function testGetSyncMissingReturnsEmpty() { + final persistence = new CapsRepoMockPersistence(); + final repo = new CapsRepo(persistence); + final presence = Stanza.parse('<presence><c xmlns="http://jabber.org/protocol/caps" node="node1" ver="missing"/></presence>'); + + final retrieved = repo.get(presence); + + Assert.equals(CapsRepo.empty, retrieved); + } } diff --git a/test/TestChat.hx b/test/TestChat.hx index c88e3c3..b197536 100644 --- a/test/TestChat.hx +++ b/test/TestChat.hx @@ -7,9 +7,13 @@ import borogove.ChatMessageBuilder; import borogove.Stanza; import borogove.JID; import borogove.persistence.Dummy; +import borogove.CapsRepo; import borogove.Chat.Channel; import borogove.Chat.AvailableChat; import borogove.Caps.Identity; +import borogove.Member; +import borogove.Role; +import thenshim.Promise; @:access(borogove) class TestChat extends utest.Test { @@ -265,18 +269,23 @@ class TestChat extends utest.Test { chat.disco = new borogove.Caps("", [], ["urn:xmpp:message-moderate:1", "http://jabber.org/protocol/muc"], []); Assert.isFalse(chat.canModerate()); - // Nick in use set - chat._nickInUse = "mynick"; + chat.self = new Member("me", "myself", null, true, [], JID.parse("test@example.com"), new Map(), null); Assert.isFalse(chat.canModerate()); // Presence set but not moderator - final p = new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "participant" }).up(), null); - chat.presence.set("mynick", p); + chat.self = new Member( + "me", "myself", null, true, [], JID.parse("test@example.com"), + ["myself" => new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "participant" }).up(), null)], + null + ); Assert.isFalse(chat.canModerate()); // Is moderator - final p2 = new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "moderator" }).up(), null); - chat.presence.set("mynick", p2); + chat.self = new Member( + "me", "myself", null, true, [], JID.parse("test@example.com"), + ["myself" => new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "moderator" }).up(), null)], + null + ); Assert.isTrue(chat.canModerate()); } @@ -352,51 +361,32 @@ class TestChat extends utest.Test { Assert.isTrue(builder.syncPoint, "Message SHOULD have syncPoint if inSync"); } - public function testGetParticipantDetailsWithRoles() { + public function testAvailableRoles() { final persistence = new Dummy(); final client = new Client("test@example.com", persistence); final chat = new Channel(client, client.stream, persistence, "channel@example.com"); + chat.self = new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null); - final stanza = Stanza.parse('<presence from="channel@example.com/other"> - <x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="admin" role="participant"/></x> - <hats xmlns="urn:xmpp:hats:0"> - <hat uri="http://example.com/custom" title="Custom Role"/> - </hats> - </presence>'); - chat.presence.set("other", stanza); - - final details = chat.getParticipantDetails("channel@example.com/other"); - Assert.equals(2, details.roles.length); - Assert.equals("admin", details.roles[0].id); - Assert.equals("Admin", details.roles[0].title); - Assert.equals("http://example.com/custom", details.roles[1].id); - Assert.equals("Custom Role", details.roles[1].title); + final roles = chat.availableRoles(new Member("other", "other", null, true, [], JID.parse("test@example.com"), new Map(), null)); + final ids = roles.map(r -> r.id); + Assert.contains("owner", ids); + Assert.contains("admin", ids); + Assert.contains("outcast", ids); + Assert.contains("none", ids); } - public function testAvailableRoles() { + public function testAvailableRolesForNone() { final persistence = new Dummy(); final client = new Client("test@example.com", persistence); final chat = new Channel(client, client.stream, persistence, "channel@example.com"); - chat._nickInUse = "me"; - - // I am owner - final myPresence = Stanza.parse('<presence from="channel@example.com/me"> - <x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="owner" role="moderator"/></x> - </presence>'); - chat.presence.set("me", myPresence); - - // Other is member - final otherPresence = Stanza.parse('<presence from="channel@example.com/other"> - <x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x> - </presence>'); - chat.presence.set("other", otherPresence); + chat.self = new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null); - final roles = chat.availableRoles("channel@example.com/other"); + final roles = chat.availableRoles(new Member("other", "other", null, true, [new Role("none", "")], JID.parse("test@example.com"), new Map(), null)); final ids = roles.map(r -> r.id); Assert.contains("owner", ids); Assert.contains("admin", ids); Assert.contains("outcast", ids); - Assert.isFalse(ids.contains("member"), "Should not include current role"); + Assert.isFalse(ids.contains("none")); } public function testAddRole(async: Async) { @@ -404,11 +394,6 @@ class TestChat extends utest.Test { final client = new Client("test@example.com", persistence); final chat = new Channel(client, client.stream, persistence, "channel@example.com"); - final otherPresence = Stanza.parse('<presence from="channel@example.com/other"> - <x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x> - </presence>'); - chat.presence.set("other", otherPresence); - client.stream.on("sendStanza", (stanza: Stanza) -> { if (stanza.name == "iq" && stanza.attr.get("type") == "set") { final query = stanza.getChild("query", "http://jabber.org/protocol/muc#admin"); @@ -423,7 +408,7 @@ class TestChat extends utest.Test { return EventUnhandled; }); - chat.addRole("channel@example.com/other", borogove.Role.forAffiliation("admin")); + chat.addRole(new Member("other", "other", null, true, [], JID.parse("test@example.com"), new Map(), new AvailableChat("other@example.com", "", "", CapsRepo.empty)), borogove.Role.forAffiliation("admin")); } public function testRemoveRole(async: Async) { @@ -431,17 +416,12 @@ class TestChat extends utest.Test { final client = new Client("test@example.com", persistence); final chat = new Channel(client, client.stream, persistence, "channel@example.com"); - final otherPresence = Stanza.parse('<presence from="channel@example.com/other"> - <x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x> - </presence>'); - chat.presence.set("other", otherPresence); - client.stream.on("sendStanza", (stanza: Stanza) -> { if (stanza.name == "iq" && stanza.attr.get("type") == "set") { final query = stanza.getChild("query", "http://jabber.org/protocol/muc#admin"); if (query != null) { final item = query.getChild("item"); - Assert.equals("none", item.attr.get("affiliation")); + Assert.equals("member", item.attr.get("affiliation")); Assert.equals("other@example.com", item.attr.get("jid")); async.done(); return EventHandled; @@ -450,6 +430,59 @@ class TestChat extends utest.Test { return EventUnhandled; }); - chat.removeRole("channel@example.com/other", borogove.Role.forAffiliation("member")); + chat.removeRole(new Member("other", "other", null, true, [new Role("admin", "")], JID.parse("test@example.com"), new Map(), new AvailableChat("other@example.com", "", "", CapsRepo.empty)), borogove.Role.forAffiliation("admin")); + } + + public function testDirectChatMembers(async: Async) { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, "alice@example.com\nbob@example.com"); + + chat.members().then(members -> { + Assert.equals(3, members.length); + Assert.equals("alice@example.com", members[0].id); + Assert.equals("bob@example.com", members[1].id); + Assert.equals("test@example.com", members[2].id); + Assert.isTrue(members[2].isSelf); + Assert.equals("alice@example.com (via alice@example.com\nbob@example.com)", members[0].chat.note); + async.done(); + }); + } + + public function testDirectChatMultiDisplayName() { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + client.getDirectChat("bob@example.com").displayName = "Bobby"; + client.getDirectChat("alice@example.com").displayName = "Alice"; + final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, "bob@example.com\nalice@example.com"); + + Assert.equals("Alice, Bobby", chat.getDisplayName()); + } + + public function testChannelMembersPassesModeratorFlag(async: Async) { + final persistence = new ChannelMembersPersistence(); + final client = new Client("test@example.com", persistence); + final chat = new Channel(client, client.stream, persistence, "channel@example.com"); + chat.self = new Member("me", "myself", null, true, [new Role("admin", "Admin")], JID.parse("test@example.com"), new Map(), null); + + chat.members().then(members -> { + Assert.isTrue(persistence.forModerator); + Assert.equals(1, members.length); + Assert.equals("other", members[0].id); + async.done(); + }); + } +} + +@:access(borogove) +class ChannelMembersPersistence extends Dummy { + public var forModerator = false; + + override public function getMembers(accountId: String, chat: borogove.Chat, forModerator: Bool) { + this.forModerator = forModerator; + return Promise.resolve([ + new Member(chat.chatId, "Room", null, false, [], JID.parse(chat.chatId), new Map(), null), + new Member("other", "Other", null, false, [], JID.parse("other@example.com"), new Map(), new AvailableChat("other@example.com", "", "", CapsRepo.empty)) + ]); } } diff --git a/test/TestClient.hx b/test/TestClient.hx index cfa08aa..6330ea4 100644 --- a/test/TestClient.hx +++ b/test/TestClient.hx @@ -9,8 +9,11 @@ import borogove.ChatMessage; import borogove.ChatMessageBuilder; import borogove.Client; import borogove.JID; +import borogove.Member; +import borogove.MemberUpdate; import borogove.Message; import borogove.ModerationAction; +import borogove.Role; import borogove.Stanza; import borogove.Status; import borogove.persistence.Dummy; @@ -513,6 +516,90 @@ class TestClient extends utest.Test { client.doSync((_) -> {}, null); } + + public function testMucAffiliationMessageStoresMemberUpdatesAndEmits(async: Async) { + final persistence = new MemberUpdateMockPersistence(); + final client = new Client("test@example.com", persistence); + final chat = new borogove.Chat.Channel(client, client.stream, persistence, "room@example.com"); + client.chats.push(chat); + var onPersistedEvent = null; + final afterPersistedEvent = new Promise((resolved, rejected) -> { onPersistedEvent = resolved; }); + + chat.addMembersUpdatedListener((members) -> { + if (persistence.lastUpdates.length > 0) { + Assert.equals(1, members.length); + Assert.equals("room@example.com/occ-1", members[0].id); + Assert.equals("alice@example.com", members[0].chat.chatId); + onPersistedEvent(null); + } + }); + + client.stream.onStanza(Stanza.parse('<message from="room@example.com" xmlns="jabber:client"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="admin" jid="alice@example.com" nick="Alice"> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="occ-1" /> + </item> + </x> + </message>')); + + afterPersistedEvent.then(_ -> { + Assert.equals(false, persistence.lastIsFullList); + Assert.equals("room@example.com", persistence.lastChatId); + Assert.equals(1, persistence.lastUpdates.length); + if (persistence.lastUpdates.length > 0) { + Assert.equals("room@example.com/occ-1", persistence.lastUpdates[0].id); + } + async.done(); + }); + } + + public function testMucAffiliationFullListSetsFlag(async: Async) { + final persistence = new MemberUpdateMockPersistence(); + final client = new Client("test@example.com", persistence); + final chat = new borogove.Chat.Channel(client, client.stream, persistence, "room@example.com"); + client.chats.push(chat); + + client.stream.onStanza(Stanza.parse('<message from="room@example.com" xmlns="jabber:client"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <mav xmlns="urn:xmpp:muc:affiliations:1" until="v2" /> + <item affiliation="admin" jid="alice@example.com" nick="Alice"> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="occ-1" /> + </item> + </x> + </message>')); + + haxe.Timer.delay(() -> { + Assert.equals(true, persistence.lastIsFullList); + Assert.equals("v2", chat.mavUntil); + async.done(); + }, 1); + } + + public function testConnectClearsMemberPresence(async: Async) { + final persistence = new MemberUpdateMockPersistence(); + final client = new Client("test@example.com", persistence); + + client.stream.on("connect", (data) -> { + client.stream.trigger("status/online", { jid: "test@example.com/resource" }); + return EventHandled; + }); + + client.stream.on("sendStanza", (stanza: Stanza) -> { + if (stanza.name == "iq") { + client.stream.onStanza(new Stanza("iq", { xmlns: "jabber:client", type: "error", id: stanza.attr.get("id") })); + } + return EventHandled; + }); + + client.addStatusOnlineListener(() -> { + Assert.equals(1, persistence.clearMemberPresenceCalls.length); + Assert.equals("test@example.com", persistence.clearMemberPresenceCalls[0].accountId); + Assert.isNull(persistence.clearMemberPresenceCalls[0].chatId); + async.done(); + }); + + client.start(); + } } @:access(borogove) @@ -553,3 +640,34 @@ class MessageMockPersistence extends Dummy { if (message.serverId != null) this.messages.set(message.serverId, message); } } + +@:access(borogove) +class MemberUpdateMockPersistence extends Dummy { + public var lastUpdates: Array<MemberUpdate> = []; + public var lastIsFullList: Null<Bool> = null; + public var lastChatId: Null<String> = null; + public var clearMemberPresenceCalls: Array<{ accountId: String, chatId: Null<String> }> = []; + + override public function storeMemberUpdates(accountId: String, chat: Chat, updates: Array<MemberUpdate>, isFullList: Bool) { + lastUpdates = updates; + lastIsFullList = isFullList; + lastChatId = chat.chatId; + return Promise.resolve([ + new Member( + "room@example.com/occ-1", + "Alice", + null, + false, + [Role.forAffiliation("admin")], + JID.parse("alice@example.com"), + new Map(), + new borogove.Chat.AvailableChat("alice@example.com", "Alice", "", borogove.CapsRepo.empty) + ) + ]); + } + + override public function clearMemberPresence(accountId: String, chatId: Null<String>) { + clearMemberPresenceCalls.push({ accountId: accountId, chatId: chatId }); + return Promise.resolve(true); + } +} diff --git a/test/TestHtml.hx b/test/TestHtml.hx index 837ee4c..07fa3aa 100644 --- a/test/TestHtml.hx +++ b/test/TestHtml.hx @@ -6,7 +6,7 @@ import utest.Async; import borogove.Html; import borogove.ChatMessageBuilder; import borogove.JID; -import borogove.Participant; +import borogove.Member; @:access(borogove) class TestHtml extends utest.Test { @@ -44,7 +44,7 @@ class TestHtml extends utest.Test { msg.sender = msg.from; msg.text = "/me says hello"; - final participant = new Participant("hatter", null, "", false, [], msg.from, null); + final participant = new Member("id1", "hatter", null, false, [], msg.from, new Map(), null); Assert.equals( "<div class=\"action\"><p>hatter says hello</p></div>", @@ -60,7 +60,7 @@ class TestHtml extends utest.Test { msg.sender = msg.from; msg.setBody(Html.fromString("/me says <div class='sup&2'><img src='hai'><br><p></p>")); - final participant = new Participant("hatter", null, "", false, [], msg.from, null); + final participant = new Member("id1", "hatter", null, false, [], msg.from, new Map(), null); Assert.equals( "<div class=\"action\">hatter says <div class=\"sup&2\"><img src=\"hai\" /><br /><p></p></div></div>", diff --git a/test/TestParticipant.hx b/test/TestMember.hx similarity index 88% rename from test/TestParticipant.hx rename to test/TestMember.hx index 60bfb82..afc7da7 100644 --- a/test/TestParticipant.hx +++ b/test/TestMember.hx @@ -3,17 +3,17 @@ package test; import utest.Assert; import utest.Async; import borogove.Client; -import borogove.Participant; +import borogove.Member; import borogove.JID; import borogove.Stanza; import borogove.persistence.Dummy; @:access(borogove) -class TestParticipant extends utest.Test { +class TestMember extends utest.Test { public function testStatus(async: Async) { final persistence = new Dummy(); final client = new Client("test@example.com", persistence); - final participant = new Participant("Friend", null, "", false, [], JID.parse("friend@example.com"), null); + final participant = new Member("id1", "Friend", null, false, [], JID.parse("friend@example.com"), new Map(), null); client.stream.on("sendStanza", (stanza: Stanza) -> { if (stanza.name == "iq" && stanza.attr.get("type") == "get") { diff --git a/test/TestMemberUpdate.hx b/test/TestMemberUpdate.hx new file mode 100644 index 0000000..f12bf7f --- /dev/null +++ b/test/TestMemberUpdate.hx @@ -0,0 +1,114 @@ +package test; + +import utest.Assert; + +import borogove.CapsRepo; +import borogove.Chat.AvailableChat; +import borogove.JID; +import borogove.Member; +import borogove.MemberUpdate; +import borogove.Presence; +import borogove.Role; +import borogove.Stanza; + +@:access(borogove) +class TestMemberUpdate extends utest.Test { + function role(id: String, title = "") { + return new Role(id, title == "" ? id : title); + } + + function member(?roles: Array<Role>, ?presence: Map<String, Presence>, ?isSelf = false) { + return new Member( + "room@example.com/occ-1", + "Alice", + null, + isSelf, + roles ?? [], + JID.parse("alice@example.com"), + presence ?? new Map(), + new AvailableChat("alice@example.com", "Alice", "", CapsRepo.empty) + ); + } + + public function testApplyToNull() { + final update = new MemberUpdate( + "room@example.com/occ-1", + JID.parse("alice@example.com"), + "Alice", + false, + Role.forAffiliation("admin"), + ["desk" => Stanza.parse('<presence><show>away</show></presence>')] + ); + final applied = update.applyTo(null); + + Assert.equals("room@example.com/occ-1", applied.id); + Assert.equals("Alice", applied.displayName); + Assert.equals("alice@example.com", applied.jid.asString()); + Assert.equals(1, applied.roles.length); + Assert.equals("admin", applied.roles[0].id); + Assert.notNull(applied.chat); + Assert.equals("alice@example.com", applied.chat.chatId); + Assert.notNull(applied.presence.get("desk")); + } + + public function testApplyToNoneKeepsNonAffiliationRoles() { + final update = new MemberUpdate( + "room@example.com/occ-1", + JID.parse("alice@example.com"), + null, + false, + Role.forAffiliation("none"), + new Map() + ); + final applied = update.applyTo(member([ + role("admin", "Admin"), + role("urn:xmpp:hats:test", "Tea Host") + ])); + + Assert.equals(1, applied.roles.length); + Assert.equals("none", applied.roles[0].id); + } + + public function testApplyToMergesPresence() { + final update = new MemberUpdate( + "room@example.com/occ-1", + JID.parse("alice@example.com"), + null, + false, + null, + ["mobile" => Stanza.parse('<presence><priority>5</priority></presence>')] + ); + final applied = update.applyTo(member([], [ + "desktop" => Stanza.parse('<presence><show>away</show></presence>') + ])); + + Assert.notNull(applied.presence.get("desktop")); + Assert.notNull(applied.presence.get("mobile")); + } + + public function testApplyToMismatchedIdThrows() { + final update = new MemberUpdate( + "room@example.com/occ-2", + JID.parse("mallory@example.com"), + null, + false, + null, + new Map() + ); + + Assert.raises(() -> update.applyTo(member()), String); + } + + public function testApplyToMismatchedSelfThrows() { + final update = new MemberUpdate( + "room@example.com/occ-1", + JID.parse("alice@example.com"), + null, + true, + null, + new Map() + ); + + Assert.raises(() -> update.applyTo(member([], new Map(), false)), String); + } +} diff --git a/test/TestSortId.hx b/test/TestSortId.hx index 39f58f0..91681a6 100644 --- a/test/TestSortId.hx +++ b/test/TestSortId.hx @@ -299,9 +299,7 @@ class TestSortId extends utest.Test { return EventHandled; }); - channel.join(); - - Promise.resolve(null).then(_ -> { + channel.join().then(_ -> { Assert.notNull(queryId); Assert.notNull(iqId); diff --git a/test/idb.spec.ts b/test/idb.spec.ts index dee0f72..150985a 100644 --- a/test/idb.spec.ts +++ b/test/idb.spec.ts @@ -1054,8 +1054,7 @@ test("storeChats and getChats with status", async ({ page }) => { const blob = new Blob([code], { type: "text/javascript" }); const borogove = await import(URL.createObjectURL(blob)); - const mediaStore = - await borogove.persistence.MediaStoreCache("snikket"); + const mediaStore = await borogove.persistence.MediaStoreCache("snikket"); const persistence = await borogove.persistence.IDB("snikket", mediaStore); const chat = Object.create(borogove.DirectChat.prototype); @@ -1096,12 +1095,567 @@ test("storeStreamManamagement and getStreamManagement", async ({ page }) => { const persistence = await borogove.persistence.IDB("snikket", mediaStore); await persistence.storeLogin("alice@example.com", "", "", null); // or updating with SM may not work - await persistence.storeStreamManagement("alice@example.com", new Uint8Array([1,2,0,4]).buffer, "ZZ"); + await persistence.storeStreamManagement( + "alice@example.com", + new Uint8Array([1, 2, 0, 4]).buffer, + "ZZ", + ); const result = await persistence.getStreamManagement("alice@example.com"); - return { smIsArrayBuffer: result.sm instanceof ArrayBuffer, smIsEq: result.sm ? indexedDB.cmp(result.sm, new Uint8Array([1,2,0,4]).buffer) : "null", sortId: result.sortId }; + return { + smIsArrayBuffer: result.sm instanceof ArrayBuffer, + smIsEq: result.sm + ? indexedDB.cmp(result.sm, new Uint8Array([1, 2, 0, 4]).buffer) + : "null", + sortId: result.sortId, + }; }, code); expect(result.smIsEq).toBe(0); expect(result.smIsArrayBuffer).toBe(true); expect(result.sortId).toBe("ZZ"); }); + +test("getMembers hydrates persisted member data", async ({ page }) => { + page.route("https://localhost/", (route) => + route.fulfill({ body: "<html></html>" }), + ); + const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + await page.goto("https://localhost/"); + const result = await page.evaluate(async (code) => { + const blob = new Blob([code], { type: "text/javascript" }); + const borogove = await import(URL.createObjectURL(blob)); + + const mediaStore = await borogove.persistence.MediaStoreCache("snikket"); + const persistence = await borogove.persistence.IDB("snikket", mediaStore); + const chat = Object.create(borogove.Channel.prototype); + chat.chatId = "room-members-1@example.com"; + chat.getDisplayName = () => "Tea Room"; + + const member = { + id: "room-members-1@example.com/occ-1", + displayName: "Alice", + photoUri: "photo:alice", + isSelf: false, + roles: [{ id: "admin", title: "Admin" }], + jid: borogove.JID.parse("alice@example.com"), + presence: new Map([ + [ + "laptop", + borogove.Stanza.parse("<presence><show>away</show></presence>"), + ], + ]), + chat: { chatId: "alice@example.com" }, + }; + + await persistence.storeMembers("alice@example.com", chat.chatId, [member]); + const [stored] = await persistence.getMembers( + "alice@example.com", + chat, + false, + ); + + return { + id: stored.id, + displayName: stored.displayName, + chatId: stored.chat?.chatId, + roleIds: stored.roles.map((r) => r.id), + presenceKeys: [...stored.presence.keys()], + showPresence: stored.showPresence, + }; + }, code); + + expect(result.id).toBe("room-members-1@example.com/occ-1"); + expect(result.displayName).toBe("Alice"); + expect(result.chatId).toBe("alice@example.com"); + expect(result.roleIds).toEqual(["admin"]); + expect(result.presenceKeys).toEqual(["laptop"]); + expect(result.showPresence).toBe(1); +}); + +test("storeMemberUpdates merges existing member data", async ({ page }) => { + page.route("https://localhost/", (route) => + route.fulfill({ body: "<html></html>" }), + ); + const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + await page.goto("https://localhost/"); + const result = await page.evaluate(async (code) => { + const blob = new Blob([code], { type: "text/javascript" }); + const borogove = await import(URL.createObjectURL(blob)); + + const mediaStore = await borogove.persistence.MediaStoreCache("snikket"); + const persistence = await borogove.persistence.IDB("snikket", mediaStore); + const chat = Object.create(borogove.Channel.prototype); + chat.chatId = "room-members-2@example.com"; + chat.getDisplayName = () => "Tea Room"; + + await persistence.storeMembers("alice@example.com", chat.chatId, [ + { + id: "room-members-2@example.com/occ-1", + displayName: "Alice", + photoUri: null, + isSelf: false, + roles: [ + { id: "admin", title: "Admin" }, + { id: "urn:xmpp:hats:test", title: "Tea Host" }, + ], + jid: borogove.JID.parse("alice@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "alice@example.com" }, + }, + ]); + + const updates = [ + new borogove.MemberUpdate( + "room-members-2@example.com/occ-1", + borogove.JID.parse("alice@example.com"), + "Alice Cooper", + false, + null, + new Map([["mobile", borogove.Stanza.parse("<presence />")]]), + ), + ]; + + const updated = await persistence.storeMemberUpdates( + "alice@example.com", + chat, + updates, + false, + ); + + return { + updatedRoleIds: updated[0].roles.map((r) => r.id), + updatedPresenceKeys: [...updated[0].presence.keys()].sort(), + updatedDisplayName: updated[0].displayName, + }; + }, code); + + expect(result.updatedRoleIds).toEqual(["urn:xmpp:hats:test"]); + expect(result.updatedPresenceKeys).toEqual(["desk", "mobile"]); + expect(result.updatedDisplayName).toBe("Alice Cooper"); +}); + +test("storeMemberUpdates clears omitted full-list affiliations", async ({ + page, +}) => { + page.route("https://localhost/", (route) => + route.fulfill({ body: "<html></html>" }), + ); + const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + await page.goto("https://localhost/"); + const result = await page.evaluate(async (code) => { + const blob = new Blob([code], { type: "text/javascript" }); + const borogove = await import(URL.createObjectURL(blob)); + + const mediaStore = await borogove.persistence.MediaStoreCache("snikket"); + const persistence = await borogove.persistence.IDB("snikket", mediaStore); + const chat = Object.create(borogove.Channel.prototype); + chat.chatId = "room-members-2b@example.com"; + chat.getDisplayName = () => "Tea Room"; + + await persistence.storeMembers("alice@example.com", chat.chatId, [ + { + id: "room-members-2b@example.com/occ-1", + displayName: "Alice", + photoUri: null, + isSelf: false, + roles: [{ id: "admin", title: "Admin" }], + jid: borogove.JID.parse("alice@example.com"), + presence: new Map(), + chat: { chatId: "alice@example.com" }, + }, + { + id: "room-members-2b@example.com/occ-2", + displayName: "Bob", + photoUri: null, + isSelf: false, + roles: [{ id: "owner", title: "Owner" }], + jid: borogove.JID.parse("bob@example.com"), + presence: new Map(), + chat: { chatId: "bob@example.com" }, + }, + ]); + + await persistence.storeMemberUpdates( + "alice@example.com", + chat, + [ + new borogove.MemberUpdate( + "room-members-2b@example.com/occ-1", + borogove.JID.parse("alice@example.com"), + "Alice", + false, + null, + new Map(), + ), + ], + true, + ); + const members = await persistence.getMembers( + "alice@example.com", + chat, + true, + ); + + return members.find((m) => m.id.endsWith("occ-2")).roles.map((r) => r.id); + }, code); + + expect(result).toEqual([]); +}); + +test("storeMemberUpdates matches existing member by true JID", async ({ + page, +}) => { + page.route("https://localhost/", (route) => + route.fulfill({ body: "<html></html>" }), + ); + const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + await page.goto("https://localhost/"); + const result = await page.evaluate(async (code) => { + const blob = new Blob([code], { type: "text/javascript" }); + const borogove = await import(URL.createObjectURL(blob)); + + const mediaStore = await borogove.persistence.MediaStoreCache("snikket"); + const persistence = await borogove.persistence.IDB("snikket", mediaStore); + const chat1 = Object.create(borogove.Channel.prototype); + chat1.chatId = "room-members-3@example.com"; + chat1.getDisplayName = () => "Tea Room"; + const chat2 = Object.create(borogove.Channel.prototype); + chat2.chatId = "room-members-4@example.com"; + chat2.getDisplayName = () => "Other Room"; + + await persistence.storeMembers("alice@example.com", chat1.chatId, [ + { + id: "room-members-3@example.com/occ-1", + displayName: "Alice", + photoUri: null, + isSelf: false, + roles: [{ id: "admin", title: "Admin" }], + jid: borogove.JID.parse("alice@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "alice@example.com" }, + }, + { + id: "room-members-4@example.com/occ-1", + displayName: "Bob", + photoUri: null, + isSelf: false, + roles: [{ id: "admin", title: "Admin" }], + jid: borogove.JID.parse("bob@example.com"), + presence: new Map([["phone", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "bob@example.com" }, + }, + ]); + + await persistence.storeMemberUpdates( + "alice@example.com", + chat1, + [ + new borogove.MemberUpdate( + null, + borogove.JID.parse("alice@example.com"), + "Alice Renamed", + false, + null, + new Map(), + ), + ], + false, + ); + const [chat1Member] = await persistence.getMemberDetails( + "alice@example.com", + chat1, + ["room-members-3@example.com/occ-1"], + ); + + return chat1Member.displayName; + }, code); + + expect(result).toBe("Alice Renamed"); +}); + +test("clearMemberPresence only clears the targeted chat", async ({ page }) => { + page.route("https://localhost/", (route) => + route.fulfill({ body: "<html></html>" }), + ); + const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + await page.goto("https://localhost/"); + const result = await page.evaluate(async (code) => { + const blob = new Blob([code], { type: "text/javascript" }); + const borogove = await import(URL.createObjectURL(blob)); + + const mediaStore = await borogove.persistence.MediaStoreCache("snikket"); + const persistence = await borogove.persistence.IDB("snikket", mediaStore); + const chat1 = Object.create(borogove.Channel.prototype); + chat1.chatId = "room-members-4a@example.com"; + chat1.getDisplayName = () => "Tea Room"; + const chat2 = Object.create(borogove.Channel.prototype); + chat2.chatId = "room-members-4b@example.com"; + chat2.getDisplayName = () => "Other Room"; + + await persistence.storeMembers("alice@example.com", chat1.chatId, [ + { + id: "room-members-4a@example.com/occ-1", + displayName: "Alice", + photoUri: null, + isSelf: false, + roles: [{ id: "admin", title: "Admin" }], + jid: borogove.JID.parse("alice@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "alice@example.com" }, + }, + ]); + await persistence.storeMembers("alice@example.com", chat2.chatId, [ + { + id: "room-members-4b@example.com/occ-1", + displayName: "Bob", + photoUri: null, + isSelf: false, + roles: [{ id: "admin", title: "Admin" }], + jid: borogove.JID.parse("bob@example.com"), + presence: new Map([["phone", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "bob@example.com" }, + }, + ]); + + await persistence.clearMemberPresence("alice@example.com", chat1.chatId); + const [chat1Member] = await persistence.getMemberDetails( + "alice@example.com", + chat1, + ["room-members-4a@example.com/occ-1"], + ); + const [chat2Member] = await persistence.getMemberDetails( + "alice@example.com", + chat2, + ["room-members-4b@example.com/occ-1"], + ); + + return { + chat1PresenceKeys: [...chat1Member.presence.keys()], + chat2PresenceKeys: [...chat2Member.presence.keys()], + }; + }, code); + + expect(result.chat1PresenceKeys).toEqual([]); + expect(result.chat2PresenceKeys).toEqual(["phone"]); +}); + +test("getMembers filters hidden rows for non-moderators", async ({ page }) => { + page.route("https://localhost/", (route) => + route.fulfill({ body: "<html></html>" }), + ); + const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + await page.goto("https://localhost/"); + const result = await page.evaluate(async (code) => { + const blob = new Blob([code], { type: "text/javascript" }); + const borogove = await import(URL.createObjectURL(blob)); + + const mediaStore = await borogove.persistence.MediaStoreCache("snikket"); + const persistence = await borogove.persistence.IDB("snikket", mediaStore); + const chat = Object.create(borogove.Channel.prototype); + chat.chatId = "room-members-5@example.com"; + chat.getDisplayName = () => "Tea Room"; + + await persistence.storeMembers("alice@example.com", chat.chatId, [ + { + id: "room-members-5@example.com/owner", + displayName: "Zulu", + photoUri: null, + isSelf: false, + roles: [{ id: "owner", title: "Owner" }], + jid: borogove.JID.parse("zulu@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "zulu@example.com" }, + }, + { + id: "room-members-5@example.com/outcast", + displayName: "Banned", + photoUri: null, + isSelf: false, + roles: [{ id: "outcast", title: "Banned" }], + jid: borogove.JID.parse("banned@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "banned@example.com" }, + }, + { + id: "room-members-5@example.com/guest-offline", + displayName: "Guest", + photoUri: null, + isSelf: false, + roles: [{ id: "none", title: "Guest" }], + jid: borogove.JID.parse("guest@example.com"), + presence: new Map([ + ["desk", borogove.Stanza.parse('<presence type="unavailable" />')], + ]), + chat: { chatId: "guest@example.com" }, + }, + { + id: "room-members-5@example.com/guest-offline2", + displayName: "Guest2", + photoUri: null, + isSelf: false, + roles: [{ id: "none", title: "Guest" }], + jid: borogove.JID.parse("guest2@example.com"), + presence: new Map(), + chat: { chatId: "guest2@example.com" }, + }, + { + id: "room-members-5@example.com/admin", + displayName: "Alpha", + photoUri: null, + isSelf: false, + roles: [{ id: "admin", title: "Admin" }], + jid: borogove.JID.parse("alpha@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "alpha@example.com" }, + }, + ]); + + const normal = await persistence.getMembers( + "alice@example.com", + chat, + false, + ); + + return normal.map((m) => m.displayName); + }, code); + + expect(result).toEqual(["Zulu", "Alpha"]); +}); + +test("getMembers includes moderator-visible rows", async ({ page }) => { + page.route("https://localhost/", (route) => + route.fulfill({ body: "<html></html>" }), + ); + const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + await page.goto("https://localhost/"); + const result = await page.evaluate(async (code) => { + const blob = new Blob([code], { type: "text/javascript" }); + const borogove = await import(URL.createObjectURL(blob)); + + const mediaStore = await borogove.persistence.MediaStoreCache("snikket"); + const persistence = await borogove.persistence.IDB("snikket", mediaStore); + const chat = Object.create(borogove.Channel.prototype); + chat.chatId = "room-members-6@example.com"; + chat.getDisplayName = () => "Tea Room"; + + await persistence.storeMembers("alice@example.com", chat.chatId, [ + { + id: "room-members-6@example.com/owner", + displayName: "Zulu", + photoUri: null, + isSelf: false, + roles: [{ id: "owner", title: "Owner" }], + jid: borogove.JID.parse("zulu@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "zulu@example.com" }, + }, + { + id: "room-members-6@example.com/outcast", + displayName: "Banned", + photoUri: null, + isSelf: false, + roles: [{ id: "outcast", title: "Banned" }], + jid: borogove.JID.parse("banned@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "banned@example.com" }, + }, + { + id: "room-members-6@example.com/guest-offline", + displayName: "Guest", + photoUri: null, + isSelf: false, + roles: [{ id: "none", title: "Guest" }], + jid: borogove.JID.parse("guest@example.com"), + presence: new Map([ + ["desk", borogove.Stanza.parse('<presence type="unavailable" />')], + ]), + chat: { chatId: "guest@example.com" }, + }, + { + id: "room-members-6@example.com/admin", + displayName: "Alpha", + photoUri: null, + isSelf: false, + roles: [{ id: "admin", title: "Admin" }], + jid: borogove.JID.parse("alpha@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "alpha@example.com" }, + }, + ]); + + const moderator = await persistence.getMembers( + "alice@example.com", + chat, + true, + ); + return moderator.map((m) => m.displayName); + }, code); + + expect(result).toEqual(["Zulu", "Alpha", "Banned"]); +}); + +test("getMemberDetails returns null for incomplete rows", async ({ page }) => { + page.route("https://localhost/", (route) => + route.fulfill({ body: "<html></html>" }), + ); + const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + await page.goto("https://localhost/"); + const result = await page.evaluate(async (code) => { + const blob = new Blob([code], { type: "text/javascript" }); + const borogove = await import(URL.createObjectURL(blob)); + + const mediaStore = await borogove.persistence.MediaStoreCache("snikket"); + const persistence = await borogove.persistence.IDB("snikket", mediaStore); + const chat = Object.create(borogove.Channel.prototype); + chat.chatId = "room-members-7@example.com"; + chat.getDisplayName = () => "Tea Room"; + + await persistence.storeMembers("alice@example.com", chat.chatId, [ + { + id: "room-members-7@example.com/admin", + displayName: "Alpha", + photoUri: null, + isSelf: false, + roles: [{ id: "admin", title: "Admin" }], + jid: borogove.JID.parse("alpha@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "alpha@example.com" }, + }, + ]); + + const tx = indexedDB.open("snikket"); + const db = await new Promise((resolve, reject) => { + tx.onsuccess = () => resolve(tx.result); + tx.onerror = () => reject(tx.error); + }); + const write = db.transaction(["members"], "readwrite"); + write.objectStore("members").put({ + account: "alice@example.com", + chatId: chat.chatId, + id: "room-members-7@example.com/incomplete", + displayName: "", + photoUri: null, + isSelf: 0, + chat: "", + roles: [], + presence: new Map(), + jid: "", + }); + await new Promise((resolve, reject) => { + write.oncomplete = () => resolve(null); + write.onerror = () => reject(write.error); + }); + + const details = await persistence.getMemberDetails( + "alice@example.com", + chat, + [ + "room-members-7@example.com/admin", + "room-members-7@example.com/incomplete", + ], + ); + return details.map((m) => (m ? m.displayName : null)); + }, code); + + expect(result).toEqual(["Alpha", null]); +});