| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2024-03-13 16:06:05 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2024-03-13 16:06:05 UTC |
| parent | ef1b60b690b529414623949e533f0d94c78ef65e |
| snikket/Chat.hx | +156 | -32 |
| snikket/ChatMessage.hx | +75 | -7 |
| snikket/Client.hx | +215 | -130 |
diff --git a/snikket/Chat.hx b/snikket/Chat.hx index fbe71c1..366d7b1 100644 --- a/snikket/Chat.hx +++ b/snikket/Chat.hx @@ -43,11 +43,18 @@ abstract class Chat { private var avatarSha1:Null<BytesData> = null; private var presence:Map<String, Presence> = []; private var trusted:Bool = false; + /** + ID of this Chat + **/ public var chatId(default, null):String; @:allow(snikket) private var jingleSessions: Map<String, snikket.jingle.Session> = []; private var displayName:String; - public var uiState = Open; + /** + Current state of this chat + **/ + @:allow(snikket) + public var uiState(default, null): UiState = Open; @:allow(snikket) private var extensions: Stanza; private var _unreadCount = 0; @@ -67,31 +74,99 @@ abstract class Chat { @:allow(snikket) abstract private function prepareIncomingMessage(message:ChatMessage, stanza:Stanza):ChatMessage; - abstract public function correctMessage(localId:String, message:ChatMessage):Void; + /** + Fetch a page of messages before some point + + @param beforeId id of the message to look before + @param beforeTime timestamp of the message to look before, + String in format YYYY-MM-DDThh:mm:ss[.sss]+00:00 + @param handler takes one argument, an array of ChatMessage that are found + **/ + abstract public function getMessages(beforeId:Null<String>, beforeTime:Null<String>, handler:(Array<ChatMessage>)->Void):Void; + /** + Send a ChatMessage to this Chat + + @param message the ChatMessage to send + **/ abstract public function sendMessage(message:ChatMessage):Void; - abstract public function removeReaction(m:ChatMessage, reaction:String):Void; + /** + Signals that all messages up to and including this one have probably + been displayed to the user - abstract public function getMessages(beforeId:Null<String>, beforeTime:Null<String>, handler:(Array<ChatMessage>)->Void):Void; + @param message the ChatMessage most recently displayed + **/ + abstract public function markReadUpTo(message: ChatMessage):Void; + + /** + Save this Chat on the server + **/ + abstract public function bookmark():Void; + /** + Get the list of IDs of participants in this Chat + + @returns array of IDs + **/ abstract public function getParticipants():Array<String>; + /** + Get the details for one participant in this Chat + + @param participantId the ID of the participant to look up + @param callback takes two arguments, the display name and the photo URI + **/ abstract public function getParticipantDetails(participantId:String, callback:(String, String)->Void):Void; - abstract public function bookmark():Void; + /** + Correct an already-send message by replacing it with a new one - abstract public function close():Void; + @param localId the localId of the message to correct + must be the localId of the first version ever sent, not a subsequent correction + @param message the new ChatMessage to replace it with + **/ + abstract public function correctMessage(localId:String, message:ChatMessage):Void; - abstract public function markReadUpTo(message: ChatMessage):Void; + /** + Add new reaction to a message in this Chat + + @param m ChatMessage to react to + @param reaction emoji of the reaction + **/ + public function addReaction(m:ChatMessage, reaction:String) { + final toSend = m.reply(); + toSend.text = reaction; + sendMessage(toSend); + } + + /** + Remove an already-sent reaction from a message + + @param m ChatMessage to remove the reaction from + @param reaction the emoji to remove + **/ + abstract public function removeReaction(m:ChatMessage, reaction:String):Void; + + /** + Archive this chat + **/ + abstract public function close():Void; + /** + An ID of the most recent message in this chat + **/ abstract public function lastMessageId():Null<String>; + /** + The timestamp of the most recent message in this chat + **/ public function lastMessageTimestamp():Null<String> { return lastMessage?.timestamp; } - public function updateFromBookmark(item: Stanza) { + @:allow(snikket) + private function updateFromBookmark(item: Stanza) { final conf = item.getChild("conference", "urn:xmpp:bookmarks:1"); final fn = conf.attr.get("name"); if (fn != null) setDisplayName(fn); @@ -99,6 +174,11 @@ abstract class Chat { extensions = conf.getChild("extensions") ?? new Stanza("extensions", { xmlns: "urn:xmpp:bookmarks:1" }); } + /** + Get an image to represent this Chat + + @param callback takes one argument, the URI to the image + **/ public function getPhoto(callback:(String)->Void) { if (avatarSha1 != null) { persistence.getMediaUri("sha-1", avatarSha1, (uri) -> { @@ -113,11 +193,17 @@ abstract class Chat { } } + /** + An ID of the last message displayed to the user + **/ public function readUpTo() { final displayed = extensions.getChild("displayed", "urn:xmpp:chat-markers:0"); return displayed?.attr?.get("id"); } + /** + The number of message that have not yet been displayed to the user + **/ public function unreadCount() { return _unreadCount; } @@ -127,6 +213,9 @@ abstract class Chat { _unreadCount = count; } + /** + A preview of the chat, such as the most recent message body + **/ public function preview() { return lastMessage?.text ?? ""; } @@ -136,10 +225,14 @@ abstract class Chat { lastMessage = message; } - public function setDisplayName(fn:String) { + @:allow(snikket) + private function setDisplayName(fn:String) { this.displayName = fn; } + /** + The display name of this Chat + **/ public function getDisplayName() { return this.displayName; } @@ -187,10 +280,14 @@ abstract class Chat { this.avatarSha1 = sha1; } - public function setTrusted(trusted:Bool) { + @:allow(snikket) + private function setTrusted(trusted:Bool) { this.trusted = trusted; } + /** + Is this a chat with an entity we trust to see our online status? + **/ public function isTrusted():Bool { return this.trusted; } @@ -200,6 +297,9 @@ abstract class Chat { return false; } + /** + Can audio calls be started in this Chat? + **/ public function canAudioCall():Bool { for (resource => p in presence) { if (p.caps?.features?.contains("urn:xmpp:jingle:apps:rtp:audio") ?? false) return true; @@ -208,6 +308,9 @@ abstract class Chat { return false; } + /** + Can video calls be started in this Chat? + **/ public function canVideoCall():Bool { for (resource => p in presence) { if (p.caps?.features?.contains("urn:xmpp:jingle:apps:rtp:video") ?? false) return true; @@ -216,6 +319,12 @@ abstract class Chat { return false; } + /** + Start a new call in this Chat + + @param audio do we want audio in this call + @param video do we want video in this call + **/ public function startCall(audio: Bool, video: Bool) { final session = new OutgoingProposedSession(client, JID.parse(chatId)); jingleSessions.set(session.sid, session); @@ -228,12 +337,18 @@ abstract class Chat { jingleSessions.iterator().next().addMedia(streams); } + /** + Accept any incoming calls in this Chat + **/ public function acceptCall() { for (session in jingleSessions) { session.accept(); } } + /** + Hangup or reject any calls in this chat + **/ public function hangup() { for (session in jingleSessions) { session.hangup(); @@ -241,6 +356,9 @@ abstract class Chat { } } + /** + The current status of a call in this chat + **/ public function callStatus() { for (session in jingleSessions) { return session.callStatus(); @@ -249,6 +367,9 @@ abstract class Chat { return "none"; } + /** + A DTMFSender for a call in this chat, or NULL + **/ public function dtmf() { for (session in jingleSessions) { final dtmf = session.dtmf(); @@ -258,28 +379,12 @@ abstract class Chat { return null; } + /** + All video tracks in all active calls in this chat + **/ public function videoTracks(): Array<MediaStreamTrack> { return jingleSessions.flatMap((session) -> session.videoTracks()); } - - public function onMessage(handler:ChatMessage->Void):Void { - this.stream.on("message", function(event) { - final stanza:Stanza = event.stanza; - final from = JID.parse(stanza.attr.get("from")); - if (from.asBare() != JID.parse(this.chatId)) return EventUnhandled; - - final chatMessage = ChatMessage.fromStanza(stanza, client.jid); - if (chatMessage != null) handler(chatMessage); - - return EventUnhandled; // Allow others to get this event as well - }); - } - - public function addReaction(m:ChatMessage, reaction:String) { - final toSend = m.reply(); - toSend.text = reaction; - sendMessage(toSend); - } } @:expose @@ -287,6 +392,11 @@ abstract class Chat { @:build(HaxeCBridge.expose()) #end class DirectChat extends Chat { + @:allow(snikket) + private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null) { + super(client, stream, persistence, chatId, uiState, extensions); + } + @HaxeCBridge.noemit // on superclass as abstract public function getParticipants(): Array<String> { return chatId.split("\n"); @@ -343,6 +453,7 @@ class DirectChat extends Chat { return message; } + @HaxeCBridge.noemit // on superclass as abstract public function correctMessage(localId:String, message:ChatMessage) { final toSend = message.clone(); message = prepareOutgoingMessage(message); @@ -481,7 +592,8 @@ class Channel extends Chat { if (disco != null) this.disco = disco; } - public function selfPing(shouldRefreshDisco = true) { + @:allow(snikket) + private function selfPing(shouldRefreshDisco = true) { if (uiState == Closed){ client.sendPresence( getFullJid().asString(), @@ -582,7 +694,8 @@ class Channel extends Chat { sync.fetchNext(); } - public function refreshDisco(?callback: ()->Void) { + @:allow(snikket) + private function refreshDisco(?callback: ()->Void) { final discoGet = new DiscoInfoGet(chatId); discoGet.onFinished(() -> { if (discoGet.getResult() != null) { @@ -595,7 +708,6 @@ class Channel extends Chat { client.sendQuery(discoGet); } - override public function preview() { if (lastMessage == null) return super.preview(); return lastMessage.sender.resource + ": " + super.preview(); @@ -858,12 +970,24 @@ class Channel extends Chat { @:build(HaxeCBridge.expose()) #end class AvailableChat { + /** + The ID of the Chat this search result represents + **/ public final chatId: String; + /** + The display name of this search result + **/ public final displayName: Null<String>; + /** + A human-readable note associated with this search result + **/ public final note: String; @:allow(snikket) private final caps: Caps; + /** + Is this search result a channel? + **/ public function isChannel() { return caps.isChannel(chatId); } diff --git a/snikket/ChatMessage.hx b/snikket/ChatMessage.hx index ac558cf..6bc4bda 100644 --- a/snikket/ChatMessage.hx +++ b/snikket/ChatMessage.hx @@ -39,12 +39,24 @@ class ChatAttachment { @:build(HaxeCBridge.expose()) #end class ChatMessage { + /** + The ID as set by the creator of this message + **/ public var localId (default, set) : Null<String> = null; + /** + The ID as set by the authoritative server + **/ public var serverId (default, set) : Null<String> = null; + /** + The ID of the server which set the serverId + **/ public var serverIdBy : Null<String> = null; @:allow(snikket) private var syncPoint : Bool = false; + /** + The timestamp of this message, in format YYYY-MM-DDThh:mm:ss[.sss]+00:00 + **/ public var timestamp (default, set) : Null<String> = null; @:allow(snikket) @@ -58,28 +70,60 @@ class ChatMessage { @:allow(snikket) private var replyTo: Array<JID> = []; + /** + Message this one is in reply to, or NULL + **/ public var replyToMessage: Null<ChatMessage> = null; + /** + ID of the thread this message is in, or NULL + **/ public var threadId: Null<String> = null; + /** + Array of attachments to this message + **/ public var attachments: Array<ChatAttachment> = []; + /** + Map of reactions to this message + **/ public var reactions: Map<String, Array<String>> = []; + /** + Body text of this message or NULL + **/ public var text: Null<String> = null; + /** + Language code for the body text + **/ public var lang: Null<String> = null; + /** + Is this a Group Chat message? + + If the message is in the context of a Channel but this is false, + then it is a private message + **/ public var isGroupchat: Bool = false; // Only really useful for distinguishing whispers + /** + Direction of this message + **/ public var direction: MessageDirection = MessageReceived; + /** + Status of this message + **/ public var status: MessageStatus = MessagePending; + /** + Array of past versions of this message, if it has been edited + **/ public var versions: Array<ChatMessage> = []; @:allow(snikket) private var payloads: Array<Stanza> = []; + /** + @returns a new blank ChatMessage + **/ public function new() { } - public function setText(t: String) { - text = t; - } - @:allow(snikket) private static function fromStanza(stanza:Stanza, localJid:JID):Null<ChatMessage> { switch Message.fromStanza(stanza, localJid) { @@ -106,6 +150,9 @@ class ChatMessage { if (uris.length > 0) attachments.push(new ChatAttachment(name, mime, size == null ? null : Std.parseInt(size), uris, hashes)); } + /** + Create a new ChatMessage in reply to this one + **/ public function reply() { final m = new ChatMessage(); m.isGroupchat = isGroupchat; @@ -114,24 +161,27 @@ class ChatMessage { return m; } - public function set_localId(localId:String):String { + private function set_localId(localId:Null<String>) { if(this.localId != null) { throw new Exception("Message already has a localId set"); } return this.localId = localId; } - public function set_serverId(serverId:String):String { + private function set_serverId(serverId:Null<String>) { if(this.serverId != null && this.serverId != serverId) { throw new Exception("Message already has a serverId set"); } return this.serverId = serverId; } - public function set_timestamp(timestamp:String):String { + private function set_timestamp(timestamp:Null<String>) { return this.timestamp = timestamp; } + /** + Get HTML version of the message body + **/ public function html():String { final codepoints = StringUtil.codepointArray(text ?? ""); // TODO: not every app will implement every feature. How should the app tell us what fallbacks to handle? @@ -146,6 +196,9 @@ class ChatMessage { return payloads.find((p) -> p.attr.get("xmlns") == "urn:xmpp:styling:0" && p.name == "unstyled") == null ? XEP0393.parse(body).map((s) -> s.toString()).join("") : StringTools.htmlEscape(body); } + /** + The ID of the Chat this message is associated with + **/ public function chatId():String { if (isIncoming()) { return replyTo.map((r) -> r.asBare().asString()).join("\n"); @@ -154,18 +207,30 @@ class ChatMessage { } } + /** + The ID of the sender of this message + **/ public function senderId():String { return sender?.asString() ?? throw "sender is null"; } + /** + The ID of the account associated with this message + **/ public function account():String { return (!isIncoming() ? from?.asBare()?.asString() : to?.asBare()?.asString()) ?? throw "from or to is null"; } + /** + Is this an incoming message? + **/ public function isIncoming():Bool { return direction == MessageReceived; } + /** + The URI of an icon for the thread associated with this message, or NULL + **/ public function threadIcon() { return threadId == null ? null : Identicon.svg(threadId); } @@ -272,6 +337,9 @@ class ChatMessage { return stanza; } + /** + Duplicate this ChatMessage + **/ public function clone() { final cls:Class<ChatMessage> = untyped Type.getClass(this); final inst = Type.createEmptyInstance(cls); diff --git a/snikket/Client.hx b/snikket/Client.hx index 09a9ec3..83022ae 100644 --- a/snikket/Client.hx +++ b/snikket/Client.hx @@ -39,7 +39,8 @@ import HaxeCBridge; class Client extends EventEmitter { private var stream:GenericStream; private var chatMessageHandlers: Array<(ChatMessage)->Void> = []; - public var jid(default,null):JID; + @:allow(snikket) + private var jid(default,null):JID; private var chats: Array<Chat> = []; private var persistence: Persistence; private final caps = new Caps( @@ -377,40 +378,8 @@ class Client extends EventEmitter { } /** - Get the account ID for this Client - - @returns account id + Start this client running and trying to connect to the server **/ - public function accountId() { - return jid.asBare().asString(); - } - - public function displayName() { - return _displayName; - } - - public function setDisplayName(fn: String) { - if (fn == null || fn == "" || fn == displayName()) return; - - stream.sendIq( - new Stanza("iq", { type: "set" }) - .tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) - .tag("publish", { node: "http://jabber.org/protocol/nick" }) - .tag("item") - .textTag("nick", fn, { xmlns: "http://jabber.org/protocol/nick" }) - .up().up().up(), - (response) -> { } - ); - } - - private function updateDisplayName(fn: String) { - if (fn == null || fn == "" || fn == displayName()) return false; - _displayName = fn; - persistence.storeLogin(jid.asBare().asString(), stream.clientId ?? jid.resource, fn, null); - pingAllChannels(); - return true; - } - public function start() { persistence.getLogin(accountId(), (clientId, token, fastCount, displayName) -> { persistence.getStreamManagement(accountId(), (smId, smOut, smIn, smOutQ) -> { @@ -450,65 +419,58 @@ class Client extends EventEmitter { }); } + /** + Sets the password to be used in response to the password needed event - public function addPasswordNeededListener(handler:String->Void) { - this.on("auth/password-needed", (data) -> { - handler(data.accountId); - return EventHandled; - }); + @param password + **/ + public function usePassword(password: String):Void { + this.stream.trigger("auth/password", { password: password, requestToken: fastMechanism }); } - public function addStatusOnlineListener(handler:()->Void):Void { - this.on("status/online", (data) -> { - handler(); - return EventHandled; - }); - } + /** + Get the account ID for this Client - public function addChatMessageListener(handler:ChatMessage->Void):Void { - chatMessageHandlers.push(handler); + @returns account id + **/ + public function accountId() { + return jid.asBare().asString(); } - public function addChatsUpdatedListener(handler:Array<Chat>->Void):Void { - this.on("chats/update", (data) -> { - handler(data); - return EventHandled; - }); - } + /** + Get the current display name for this account - public function addCallRingListener(handler:(Session,String)->Void):Void { - this.on("call/ring", (data) -> { - handler(data.session, data.chatId); - return EventHandled; - }); + @returns display name or NULL + **/ + public function displayName() { + return _displayName; } - public function addCallRetractListener(handler:(String)->Void):Void { - this.on("call/retract", (data) -> { - handler(data.chatId); - return EventHandled; - }); - } + /** + Set the current display name for this account on the server - public function addCallRingingListener(handler:(String)->Void):Void { - this.on("call/ringing", (data) -> { - handler(data.chatId); - return EventHandled; - }); - } + @param display name to set (ignored if empty or NULL) + **/ + public function setDisplayName(displayName: String) { + if (displayName == null || displayName == "" || displayName == this.displayName()) return; - public function addCallMediaListener(handler:(Session,Bool,Bool)->Void):Void { - this.on("call/media", (data) -> { - handler(data.session, data.audio, data.video); - return EventHandled; - }); + stream.sendIq( + new Stanza("iq", { type: "set" }) + .tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) + .tag("publish", { node: "http://jabber.org/protocol/nick" }) + .tag("item") + .textTag("nick", displayName, { xmlns: "http://jabber.org/protocol/nick" }) + .up().up().up(), + (response) -> { } + ); } - public function addCallTrackListener(handler:(String,MediaStreamTrack,Array<MediaStream>)->Void):Void { - this.on("call/track", (data) -> { - handler(data.chatId, data.track, data.streams); - return EventHandled; - }); + private function updateDisplayName(fn: String) { + if (fn == null || fn == "" || fn == displayName()) return false; + _displayName = fn; + persistence.storeLogin(jid.asBare().asString(), stream.clientId ?? jid.resource, fn, null); + pingAllChannels(); + return true; } private function onConnected(data) { // Fired on connect or reconnect @@ -555,10 +517,6 @@ class Client extends EventEmitter { return EventHandled; } - public function usePassword(password: String):Void { - this.stream.trigger("auth/password", { password: password, requestToken: fastMechanism }); - } - #if js public function prepareAttachment(source: js.html.File, callback: (Null<ChatAttachment>)->Void) { // TODO: abstract with filename, mime, and ability to convert to tink.io.Source persistence.findServicesWithFeature(accountId(), "urn:xmpp:http:upload:0", (services) -> { @@ -601,57 +559,18 @@ class Client extends EventEmitter { #end /** - @returns array of chats, sorted by last activity - */ + @returns array of open chats, sorted by last activity + **/ public function getChats():Array<Chat> { return chats.filter((chat) -> chat.uiState != Closed); } - public function startChat(availableChat: AvailableChat):Chat { - final existingChat = getChat(availableChat.chatId); - if (existingChat != null) { - final channel = Std.downcast(existingChat, Channel); - if (channel == null && availableChat.isChannel()) { - chats = chats.filter((chat) -> chat.chatId != availableChat.chatId); - } else { - if (existingChat.uiState == Closed) existingChat.uiState = Open; - channel?.selfPing(); - this.trigger("chats/update", [existingChat]); - return existingChat; - } - } - - final chat = if (availableChat.isChannel()) { - final channel = new Channel(this, this.stream, this.persistence, availableChat.chatId, Open, null, availableChat.caps); - chats.unshift(channel); - channel.selfPing(false); - channel; - } else { - getDirectChat(availableChat.chatId, false); - } - if (availableChat.displayName != null) chat.setDisplayName(availableChat.displayName); - persistence.storeChat(accountId(), chat); - this.trigger("chats/update", [chat]); - return chat; - } - - public function getChat(chatId:String):Null<Chat> { - return chats.find((chat) -> chat.chatId == chatId); - } - - public function getDirectChat(chatId:String, triggerIfNew:Bool = true):DirectChat { - for (chat in chats) { - if (Std.isOfType(chat, DirectChat) && chat.chatId == chatId) { - return Std.downcast(chat, DirectChat); - } - } - final chat = new DirectChat(this, this.stream, this.persistence, chatId); - persistence.storeChat(accountId(), chat); - chats.unshift(chat); - if (triggerIfNew) this.trigger("chats/update", [chat]); - return chat; - } + /** + Search for chats the user can start or join + @param q the search query to use + @param callback takes two arguments, the query that was used and the array of results + **/ public function findAvailableChats(q:String, callback:(String, Array<AvailableChat>) -> Void) { var results = []; final query = StringTools.trim(q); @@ -714,6 +633,62 @@ class Client extends EventEmitter { } } + /** + Start or join a chat from the search results + + @returns the chat that was started + **/ + public function startChat(availableChat: AvailableChat):Chat { + final existingChat = getChat(availableChat.chatId); + if (existingChat != null) { + final channel = Std.downcast(existingChat, Channel); + if (channel == null && availableChat.isChannel()) { + chats = chats.filter((chat) -> chat.chatId != availableChat.chatId); + } else { + if (existingChat.uiState == Closed) existingChat.uiState = Open; + channel?.selfPing(); + this.trigger("chats/update", [existingChat]); + return existingChat; + } + } + + final chat = if (availableChat.isChannel()) { + final channel = new Channel(this, this.stream, this.persistence, availableChat.chatId, Open, null, availableChat.caps); + chats.unshift(channel); + channel.selfPing(false); + channel; + } else { + getDirectChat(availableChat.chatId, false); + } + if (availableChat.displayName != null) chat.setDisplayName(availableChat.displayName); + persistence.storeChat(accountId(), chat); + this.trigger("chats/update", [chat]); + return chat; + } + + /** + Find a chat by id + + @returns the chat if known, or NULL + **/ + public function getChat(chatId:String):Null<Chat> { + return chats.find((chat) -> chat.chatId == chatId); + } + + @:allow(snikket) + private function getDirectChat(chatId:String, triggerIfNew:Bool = true):DirectChat { + for (chat in chats) { + if (Std.isOfType(chat, DirectChat) && chat.chatId == chatId) { + return Std.downcast(chat, DirectChat); + } + } + final chat = new DirectChat(this, this.stream, this.persistence, chatId); + persistence.storeChat(accountId(), chat); + chats.unshift(chat); + if (triggerIfNew) this.trigger("chats/update", [chat]); + return chat; + } + #if js public function subscribePush(reg: js.html.ServiceWorkerRegistration, push_service: String, vapid_key: { publicKey: js.html.CryptoKey, privateKey: js.html.CryptoKey}) { js.Browser.window.crypto.subtle.exportKey("raw", vapid_key.publicKey).then((vapid_public_raw) -> { @@ -748,6 +723,116 @@ class Client extends EventEmitter { } #end + /** + Event fired when client needs a password for authentication + + @param handler takes one argument, the Client that needs a password + **/ + public function addPasswordNeededListener(handler:Client->Void) { + this.on("auth/password-needed", (data) -> { + handler(this); + return EventHandled; + }); + } + + /** + Event fired when client is connected and fully synchronized + + @param handler takes no arguments + **/ + public function addStatusOnlineListener(handler:()->Void):Void { + this.on("status/online", (data) -> { + handler(); + return EventHandled; + }); + } + + /** + Event fired when a new ChatMessage comes in on any Chat + Also fires when status of a ChatMessage changes, + when a ChatMessage is edited, or when a reaction is added + + @param handler takes one argument, the ChatMessage + **/ + public function addChatMessageListener(handler:ChatMessage->Void):Void { + chatMessageHandlers.push(handler); + } + + /** + Event fired when a Chat's metadata is updated, or when a new Chat is added + + @param handler takes one argument, an array of Chats that were updated + **/ + public function addChatsUpdatedListener(handler:Array<Chat>->Void):Void { + this.on("chats/update", (data) -> { + handler(data); + return EventHandled; + }); + } + + /** + Event fired when a new call comes in + + @param handler takes two arguments, the call Session and the associated Chat ID + **/ + public function addCallRingListener(handler:(Session,String)->Void):Void { + this.on("call/ring", (data) -> { + handler(data.session, data.chatId); + return EventHandled; + }); + } + + /** + Event fired when a call is retracted or hung up + + @param handler takes one argument, the associated Chat ID + **/ + public function addCallRetractListener(handler:(String)->Void):Void { + this.on("call/retract", (data) -> { + handler(data.chatId); + return EventHandled; + }); + } + + /** + Event fired when an outgoing call starts ringing + + @param handler takes one argument, the associated Chat ID + **/ + public function addCallRingingListener(handler:(String)->Void):Void { + this.on("call/ringing", (data) -> { + handler(data.chatId); + return EventHandled; + }); + } + + /** + Event fired when a call is asking for media to send + + @param handler takes three arguments, the call Session, + a boolean indicating if audio is desired, + and a boolean indicating if video is desired + **/ + public function addCallMediaListener(handler:(Session,Bool,Bool)->Void):Void { + this.on("call/media", (data) -> { + handler(data.session, data.audio, data.video); + return EventHandled; + }); + } + + /** + Event fired when call has a new MediaStreamTrack to play + + @param handler takes three arguments, the associated Chat ID, + the new MediaStreamTrack, and an array of any associated MediaStreams + **/ + public function addCallTrackListener(handler:(String,MediaStreamTrack,Array<MediaStream>)->Void):Void { + this.on("call/track", (data) -> { + handler(data.chatId, data.track, data.streams); + return EventHandled; + }); + } + @:allow(snikket) private function chatActivity(chat: Chat, trigger = true) { if (chat.uiState == Closed) {