diff --git a/libpurple/purpleconversation.h b/libpurple/purpleconversation.h
--- a/libpurple/purpleconversation.h
+++ b/libpurple/purpleconversation.h
@@ -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 */
# HG changeset patch
# Node ID 4210c9e5006987b8eed8da412fe94fad00206b8d
# Parent  dc1e61fb9f00a72565ca8ccffdc892cc42521d87
diff --git a/libpurple/purpleconversation.c b/libpurple/purpleconversation.c
--- a/libpurple/purpleconversation.c
+++ b/libpurple/purpleconversation.c
@@ -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);
+			}
+		}
+	}
+}
diff --git a/libpurple/purpleprotocolconversation.h b/libpurple/purpleprotocolconversation.h
--- a/libpurple/purpleprotocolconversation.h
+++ b/libpurple/purpleprotocolconversation.h
@@ -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.
diff --git a/libpurple/purpleprotocolconversation.c b/libpurple/purpleprotocolconversation.c
--- a/libpurple/purpleprotocolconversation.c
+++ b/libpurple/purpleprotocolconversation.c
@@ -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,
diff --git a/libpurple/purpletyping.h b/libpurple/purpletyping.h
--- a/libpurple/purpletyping.h
+++ b/libpurple/purpletyping.h
@@ -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
diff --git a/libpurple/tests/test_conversation.c b/libpurple/tests/test_conversation.c
--- a/libpurple/tests/test_conversation.c
+++ b/libpurple/tests/test_conversation.c
@@ -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);
 
