Add a typing-state property to Purple.Conversation

Mon, 19 Aug 2024 21:17:49 -0500

author
Gary Kramlich <grim@reaperworld.com>
date
Mon, 19 Aug 2024 21:17:49 -0500
changeset 42884
5a7d425c9d1b
parent 42883
51a0fe1e2500
child 42885
9b0cae94f406

Add a typing-state property to Purple.Conversation

This property tracks the typing state of the libpurple user in the
conversation. User interfaces are expected to set this property to typing
whenever the user's input could be considered part of the message.

When the state changes to typing, a timeout will be added to set the state to
paused. This timeout is reset each time the property is set to typing again.

If that timeout expires, a new timeout will be added to set the state to none.

When the conversation is created, the typing-state properties on the
Conversation and the ConversationMember of the libpurple user are bound so
user interfaces can treat that ConversationMember like any other.

The conversation properties unit test was updated to verify that the property
plumbing is in place. However, no other unit tests were created as the timeouts
would add too much time to the tests.

Testing Done:
Ran the unit tests and temporarily added some code to pidgin to set the typing state and verified the timeouts worked as intended.

Reviewed at https://reviews.imfreedom.org/r/3409/

libpurple/purpleconversation.c file | annotate | diff | comparison | revisions
libpurple/purpleconversation.h file | annotate | diff | comparison | revisions
libpurple/purpleprotocolconversation.c file | annotate | diff | comparison | revisions
libpurple/purpleprotocolconversation.h file | annotate | diff | comparison | revisions
libpurple/purpletyping.h file | annotate | diff | comparison | revisions
libpurple/tests/test_conversation.c file | annotate | diff | comparison | revisions
--- a/libpurple/purpleconversation.c	Fri Aug 16 03:15:34 2024 -0500
+++ b/libpurple/purpleconversation.c	Mon Aug 19 21:17:49 2024 -0500
@@ -65,6 +65,9 @@
 
 	GListStore *messages;
 	gboolean needs_attention;
+
+	PurpleTypingState typing_state;
+	guint typing_state_source;
 };
 
 enum {
@@ -94,6 +97,7 @@
 	PROP_MEMBERS,
 	PROP_MESSAGES,
 	PROP_NEEDS_ATTENTION,
+	PROP_TYPING_STATE,
 	N_PROPERTIES,
 };
 static GParamSpec *properties[N_PROPERTIES] = {NULL, };
@@ -187,9 +191,15 @@
 
 		if(PURPLE_IS_ACCOUNT(conversation->account)) {
 			PurpleContactInfo *info = NULL;
+			PurpleConversationMember *member = NULL;
 
 			info = purple_account_get_contact_info(account);
-			purple_conversation_add_member(conversation, info, FALSE, NULL);
+			member = purple_conversation_add_member(conversation, info, FALSE,
+			                                        NULL);
+
+			g_object_bind_property(conversation, "typing-state",
+			                       member, "typing-state",
+			                       G_BINDING_BIDIRECTIONAL | G_BINDING_SYNC_CREATE);
 
 			g_signal_connect_object(account, "notify::connected",
 			                        G_CALLBACK(purple_conversation_account_connected_cb),
@@ -329,6 +339,60 @@
 	}
 }
 
+/*
+ * purple_conversation_typing_state_typing_cb: (skip)
+ * @data: The conversation instance.
+ *
+ * If this callback manages to get called, it means the user has stopped typing
+ * and we need to change the typing state of the conversation to paused.
+ *
+ * There's some specific ordering we have to worry about because
+ * purple_conversation_set_typing_state will attempt to remove the source that
+ * called us even though we're going to exit cleanly after we call that
+ * function.
+ *
+ * To avoid this, we just set the typing_state_source to 0 which will make
+ * purple_conversation_set_typing_state not try to cancel the source.
+ */
+static gboolean
+purple_conversation_typing_state_typing_cb(gpointer data) {
+	PurpleConversation *conversation = data;
+
+	conversation->typing_state_source = 0;
+
+	purple_conversation_set_typing_state(conversation,
+	                                     PURPLE_TYPING_STATE_PAUSED);
+
+	return G_SOURCE_REMOVE;
+}
+
+/*
+ * purple_conversation_typing_state_paused_cb: (skip)
+ * @data: The conversation instance.
+ *
+ * If this callback manages to get called, it means the user has stopped typing
+ * some time ago, and we need to set the state to NONE.
+ *
+ * There's some specific ordering we have to worry about because
+ * purple_conversation_set_typing_state will attempt to remove the source that
+ * called us even though we're going to exit cleanly after we call that
+ * function.
+ *
+ * To avoid this, we just set the typing_state_source to 0 which will make
+ * purple_conversation_set_typing_state not try to cancel the source.
+ */
+static gboolean
+purple_conversation_typing_state_paused_cb(gpointer data) {
+	PurpleConversation *conversation = data;
+
+	conversation->typing_state_source = 0;
+
+	purple_conversation_set_typing_state(conversation,
+	                                     PURPLE_TYPING_STATE_NONE);
+
+	return G_SOURCE_REMOVE;
+}
+
 /**************************************************************************
  * GObject Implementation
  **************************************************************************/
@@ -410,6 +474,10 @@
 		purple_conversation_set_needs_attention(conversation,
 		                                        g_value_get_boolean(value));
 		break;
+	case PROP_TYPING_STATE:
+		purple_conversation_set_typing_state(conversation,
+		                                     g_value_get_enum(value));
+		break;
 	default:
 		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
 		break;
@@ -517,6 +585,10 @@
 		g_value_set_boolean(value,
 		                    purple_conversation_get_needs_attention(conversation));
 		break;
+	case PROP_TYPING_STATE:
+		g_value_set_enum(value,
+		                 purple_conversation_get_typing_state(conversation));
+		break;
 	default:
 		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
 		break;
@@ -559,6 +631,8 @@
 
 	purple_request_close_with_handle(conversation);
 
+	g_clear_handle_id(&conversation->typing_state_source, g_source_remove);
+
 	g_clear_object(&conversation->account);
 	g_clear_pointer(&conversation->id, g_free);
 	g_clear_object(&conversation->avatar);
@@ -980,6 +1054,38 @@
 		FALSE,
 		G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);
 
+	/**
+	 * PurpleConversation:typing-state:
+	 *
+	 * The [enum@TypingState] of the libpurple user in this conversation.
+	 *
+	 * When the property changes to `typing`, a timeout will be setup to change
+	 * the property to `paused` if the property hasn't been set to `typing`
+	 * again before the timeout expires.
+	 *
+	 * If the above timeout fires, the state will be changed to `paused`, and a
+	 * new timeout will be added that will reset the state to `none` if it
+	 * expires.
+	 *
+	 * This means that user interfaces should only ever need to set the state
+	 * to typing and should do so whenever the user types anything that could
+	 * be part of a message. Things like keyboard navigation and %commands
+	 * should not result in this property being changed.
+	 *
+	 * If the [class@Protocol] that this conversation belongs to implements
+	 * [iface@ProtocolConversation] and
+	 * [vfunc@ProtocolConversation.send_typing],
+	 * [vfunc@ProtocolConversation.send_typing] will be called when this
+	 * property is set even if the state hasn't changed.
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_TYPING_STATE] = g_param_spec_enum(
+		"typing-state", NULL, NULL,
+		PURPLE_TYPE_TYPING_STATE,
+		PURPLE_TYPING_STATE_NONE,
+		G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);
+
 	g_object_class_install_properties(obj_class, N_PROPERTIES, properties);
 
 	/**
@@ -1314,6 +1420,9 @@
 	g_return_if_fail(PURPLE_IS_CONVERSATION(conversation));
 	g_return_if_fail(PURPLE_IS_MESSAGE(message));
 
+	purple_conversation_set_typing_state(conversation,
+	                                     PURPLE_TYPING_STATE_NONE);
+
 	task = g_task_new(conversation, cancellable, callback, data);
 	g_task_set_source_tag(task, purple_conversation_send_message_async);
 	g_task_set_task_data(task, g_object_ref(message), g_object_unref);
@@ -1806,3 +1915,72 @@
 		                         properties[PROP_NEEDS_ATTENTION]);
 	}
 }
+
+PurpleTypingState
+purple_conversation_get_typing_state(PurpleConversation *conversation) {
+	g_return_val_if_fail(PURPLE_IS_CONVERSATION(conversation),
+	                     PURPLE_TYPING_STATE_NONE);
+
+	return conversation->typing_state;
+}
+
+void
+purple_conversation_set_typing_state(PurpleConversation *conversation,
+                                     PurpleTypingState typing_state)
+{
+	g_return_if_fail(PURPLE_IS_CONVERSATION(conversation));
+
+	/* Remove the old timeout because we have new activity. */
+	g_clear_handle_id(&conversation->typing_state_source, g_source_remove);
+
+	/* We set some default timeouts based on the state. If the new state is
+	 * TYPING, we use a 6 second timeout that will change the state to PAUSED.
+	 * When the state changes to PAUSED we will set a 30 second time that will
+	 * change the state to NONE.
+	 *
+	 * This allows the user interface to just tell libpurple when the user is
+	 * typing, and the rest happens automatically.
+	 */
+	if(typing_state == PURPLE_TYPING_STATE_TYPING) {
+		conversation->typing_state_source =
+			g_timeout_add_seconds(6,
+			                      purple_conversation_typing_state_typing_cb,
+			                      conversation);
+	} else if(typing_state == PURPLE_TYPING_STATE_PAUSED) {
+		conversation->typing_state_source =
+			g_timeout_add_seconds(30,
+			                      purple_conversation_typing_state_paused_cb,
+			                      conversation);
+	}
+
+	if(conversation->typing_state != typing_state) {
+		conversation->typing_state = typing_state;
+
+		g_object_notify_by_pspec(G_OBJECT(conversation),
+		                         properties[PROP_TYPING_STATE]);
+	}
+
+	/* Check if we have a protocol that implements
+	 * ProtocolConversation.send_typing and call it if it does.
+	 *
+	 * We do this after the notify above to make sure the user interface will
+	 * not be possibly blocked by the protocol.
+	 */
+	if(PURPLE_IS_ACCOUNT(conversation->account)) {
+		PurpleProtocol *protocol = NULL;
+
+		protocol = purple_account_get_protocol(conversation->account);
+		if(PURPLE_IS_PROTOCOL_CONVERSATION(protocol)) {
+			PurpleProtocolConversation *protocol_conversation = NULL;
+
+			protocol_conversation = PURPLE_PROTOCOL_CONVERSATION(protocol);
+
+			if(purple_protocol_conversation_implements_send_typing(protocol_conversation))
+			{
+				purple_protocol_conversation_send_typing(protocol_conversation,
+				                                         conversation,
+				                                         conversation->typing_state);
+			}
+		}
+	}
+}
--- a/libpurple/purpleconversation.h	Fri Aug 16 03:15:34 2024 -0500
+++ b/libpurple/purpleconversation.h	Mon Aug 19 21:17:49 2024 -0500
@@ -30,6 +30,7 @@
 #include <glib.h>
 #include <glib-object.h>
 
+#include "purpletyping.h"
 #include "purpleversion.h"
 
 /**
@@ -364,6 +365,8 @@
  * You will need to call [method@Conversation.send_message_finish] from
  * @callback.
  *
+ * This will also reset [property@Conversation:typing-state] to `none`.
+ *
  * Since: 3.0
  */
 PURPLE_AVAILABLE_IN_3_0
@@ -914,6 +917,35 @@
 PURPLE_AVAILABLE_IN_3_0
 void purple_conversation_set_needs_attention(PurpleConversation *conversation, gboolean needs_attention);
 
+/**
+ * purple_conversation_get_typing_state:
+ * @conversation: The instance.
+ *
+ * Gets the [enum@TypingState] for the libpurple user in @conversation.
+ *
+ * Returns: The typing state of the libpurple user.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleTypingState purple_conversation_get_typing_state(PurpleConversation *conversation);
+
+/**
+ * purple_conversation_set_typing_state:
+ * @conversation: The instance.
+ * @typing_state: The new state.
+ *
+ * Sets the [enum@TypingState] of the libpurple user in @conversation to
+ * @typing_state.
+ *
+ * This will also call [method@ProtocolConversation.send_typing] if it is
+ * implemented.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+void purple_conversation_set_typing_state(PurpleConversation *conversation, PurpleTypingState typing_state);
+
 G_END_DECLS
 
 #endif /* PURPLE_CONVERSATION_H */
--- a/libpurple/purpleprotocolconversation.c	Fri Aug 16 03:15:34 2024 -0500
+++ b/libpurple/purpleprotocolconversation.c	Mon Aug 19 21:17:49 2024 -0500
@@ -450,6 +450,21 @@
 	return FALSE;
 }
 
+gboolean
+purple_protocol_conversation_implements_send_typing(PurpleProtocolConversation *protocol)
+{
+	PurpleProtocolConversationInterface *iface = NULL;
+
+	g_return_val_if_fail(PURPLE_IS_PROTOCOL_CONVERSATION(protocol), FALSE);
+
+	iface = PURPLE_PROTOCOL_CONVERSATION_GET_IFACE(protocol);
+	if(iface->send_typing != NULL) {
+		return TRUE;
+	}
+
+	return FALSE;
+}
+
 void
 purple_protocol_conversation_send_typing(PurpleProtocolConversation *protocol,
                                          PurpleConversation *conversation,
--- a/libpurple/purpleprotocolconversation.h	Fri Aug 16 03:15:34 2024 -0500
+++ b/libpurple/purpleprotocolconversation.h	Mon Aug 19 21:17:49 2024 -0500
@@ -402,6 +402,19 @@
 gboolean purple_protocol_conversation_set_avatar_finish(PurpleProtocolConversation *protocol, GAsyncResult *result, GError **error);
 
 /**
+ * purple_protocol_conversation_implements_send_typing:
+ * @protocol: The instance.
+ *
+ * Checks if @protocol implements [vfunc@ProtocolConversation.send_typing].
+ *
+ * Returns: %TRUE if everything is implemented, otherwise %FALSE.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_protocol_conversation_implements_send_typing(PurpleProtocolConversation *protocol);
+
+/**
  * purple_protocol_conversation_send_typing:
  * @protocol: The instance.
  * @conversation: The conversation.
--- a/libpurple/purpletyping.h	Fri Aug 16 03:15:34 2024 -0500
+++ b/libpurple/purpletyping.h	Mon Aug 19 21:17:49 2024 -0500
@@ -27,6 +27,8 @@
 #ifndef PURPLE_TYPING_H
 #define PURPLE_TYPING_H
 
+#include "purpleversion.h"
+
 /**
  * PurpleTypingState:
  * @PURPLE_TYPING_STATE_NONE: The user is not currently typing and has nothing
--- a/libpurple/tests/test_conversation.c	Fri Aug 16 03:15:34 2024 -0500
+++ b/libpurple/tests/test_conversation.c	Mon Aug 19 21:17:49 2024 -0500
@@ -53,6 +53,7 @@
 	PurpleConversation *conversation = NULL;
 	PurpleConversationType type = PURPLE_CONVERSATION_TYPE_UNSET;
 	PurpleTags *tags = NULL;
+	PurpleTypingState typing_state;
 	GDateTime *created_on = NULL;
 	GDateTime *created_on1 = NULL;
 	GDateTime *topic_updated = NULL;
@@ -105,6 +106,7 @@
 		"topic-author", topic_author,
 		"topic-updated", topic_updated,
 		"type", PURPLE_CONVERSATION_TYPE_THREAD,
+		"typing-state", PURPLE_TYPING_STATE_TYPING,
 		"user-nickname", "knick-knack",
 		NULL);
 
@@ -130,6 +132,7 @@
 		"topic-author", &topic_author1,
 		"topic-updated", &topic_updated1,
 		"type", &type,
+		"typing-state", &typing_state,
 		"user-nickname", &user_nickname,
 		NULL);
 
@@ -192,6 +195,8 @@
 
 	g_assert_cmpuint(type, ==, PURPLE_CONVERSATION_TYPE_THREAD);
 
+	g_assert_cmpuint(typing_state, ==, PURPLE_TYPING_STATE_TYPING);
+
 	g_assert_cmpstr(user_nickname, ==, "knick-knack");
 	g_clear_pointer(&user_nickname, g_free);
 

mercurial