git » sdk » commit f21484b

Reorder and document all public API

author Stephen Paul Weber
2024-03-13 16:06:05 UTC
committer Stephen Paul Weber
2024-03-13 16:06:05 UTC
parent ef1b60b690b529414623949e533f0d94c78ef65e

Reorder and document all public API

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) {