# HG changeset patch
# Node ID fbc7efa3f68072ad93048387eccfc3160abef361
# Parent  ed22ea598bec10768b02ce3973794ec7421ce04b
diff --git a/libpurple/meson.build b/libpurple/meson.build
--- a/libpurple/meson.build
+++ b/libpurple/meson.build
@@ -25,6 +25,7 @@
 	'purpleconversation.c',
 	'purpleconversationmanager.c',
 	'purpleconversationmember.c',
+	'purpleconversationmembers.c',
 	'purplecreateconversationdetails.c',
 	'purplecredentialmanager.c',
 	'purplecredentialprovider.c',
@@ -112,6 +113,7 @@
 	'purpleconversation.h',
 	'purpleconversationmanager.h',
 	'purpleconversationmember.h',
+	'purpleconversationmembers.h',
 	'purplecreateconversationdetails.h',
 	'purplecredentialmanager.h',
 	'purplecredentialprovider.h',
diff --git a/libpurple/purpleconversationmembers.h b/libpurple/purpleconversationmembers.h
new file mode 100644
--- /dev/null
+++ b/libpurple/purpleconversationmembers.h
@@ -0,0 +1,159 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * Purple is the legal property of its developers, whose names are too numerous
+ * to list here. Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#if !defined(PURPLE_GLOBAL_HEADER_INSIDE) && !defined(PURPLE_COMPILATION)
+# error "only <purple.h> may be included directly"
+#endif
+
+#ifndef PURPLE_CONVERSATION_MEMBERS_H
+#define PURPLE_CONVERSATION_MEMBERS_H
+
+#include <glib.h>
+#include <gio/gio.h>
+
+#include "purpleconversationmember.h"
+#include "purpleversion.h"
+
+G_BEGIN_DECLS
+
+#define PURPLE_TYPE_CONVERSATION_MEMBERS (purple_conversation_members_get_type())
+
+/**
+ * PurpleConversationMembers:
+ *
+ * A collection of [class@ConversationMember]s.
+ *
+ * This collection will notify the properties of each
+ * [class@ConversationMember] and emit the
+ * [signal@Gio.ListModel::items-changed] signal when any of them changes.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+G_DECLARE_FINAL_TYPE(PurpleConversationMembers, purple_conversation_members,
+                     PURPLE, CONVERSATION_MEMBERS, GObject)
+
+/**
+ * purple_conversation_members_add_member:
+ * @members: The instance.
+ * @info: The [class@ContactInfo] of the person joining.
+ * @announce: Whether this addition should be announced or not.
+ * @message: (nullable): An optional message to be used with @announce.
+ *
+ * Looks for an existing [class@ConversationMember] for @info in
+ * @members and returns it if found. If not, a new [class@ConversationMember]
+ * is created.
+ *
+ * > This method is intended to be called by a protocol plugin to directly
+ * > manage the membership state of the @conversation.
+ *
+ * This will also emit the [signal@ConversationMembers::member-added] signal if
+ * an existing member was not found.
+ *
+ * The @announce and @message parameters will be used when emitting the
+ * [signal@ConversationMembers::member-added] signal. Announce and message are
+ * meant for protocols to more properly define their behavior. For example, on
+ * IRC you will typically be told when a user joins a chat but on Twitch this
+ * isn't announced.
+ *
+ * Returns: (transfer none): The [class@ConversationMember] that was created or
+ *          found.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleConversationMember *purple_conversation_members_add_member(PurpleConversationMembers *members, PurpleContactInfo *info, gboolean announce, const char *message);
+
+/**
+ * purple_conversation_members_find_member:
+ * @members: The instance.
+ * @info: A [class@ContactInfo instance]
+ *
+ * Finds the [class@ConversationMember] for @info if they are in @members.
+ *
+ * Returns: (transfer none) (nullable): The [class@ConversationMember]
+ *          if found or %NULL.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleConversationMember *purple_conversation_members_find_member(PurpleConversationMembers *members, PurpleContactInfo *info);
+
+/**
+ * purple_conversation_members_has_member:
+ * @members: The instance.
+ * @info: The [class@ContactInfo] to look for.
+ * @position: (out) (optional): A return address for the position of the member
+ *            in the collection.
+ *
+ * Checks if @info is in @members.
+ *
+ * If @info is found, and @position is not %NULL, it will be set to the
+ * position of @info in @members.
+ *
+ * Returns: %TRUE if @info is in @members otherwise %FALSE.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_conversation_members_has_member(PurpleConversationMembers *members, PurpleContactInfo *info, guint *position);
+
+/**
+ * purple_conversation_members_new:
+ *
+ * Creates a new instance.
+ *
+ * Returns: (transfer full): The new instance.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleConversationMembers *purple_conversation_members_new(void);
+
+/**
+ * purple_conversation_members_remove_member:
+ * @members: The instance.
+ * @info: The [class@ContactInfo] of the person leaving.
+ * @announce: Whether or not this removal should be announced.
+ * @message: (nullable): An optional message for the announcement.
+ *
+ * Attempts to remove @info from the @members.
+ *
+ * If found, @info is removed and the
+ * [signal@ConversationMembers::member-removed] signal is emitted with
+ * @announce and @message as parameters.
+ *
+ * > This method is intended to be called by a protocol plugin to directly
+ * > manage the membership state of the @conversation.
+ *
+ * Returns: %TRUE if @member was found and removed from @members, otherwise
+ *          %FALSE.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_conversation_members_remove_member(PurpleConversationMembers *members, PurpleContactInfo *info, gboolean announce, const char *message);
+
+G_END_DECLS
+
+#endif /* PURPLE_CONVERSATION_MEMBERS_H */
+
diff --git a/libpurple/purpleconversationmembers.c b/libpurple/purpleconversationmembers.c
new file mode 100644
--- /dev/null
+++ b/libpurple/purpleconversationmembers.c
@@ -0,0 +1,360 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * Purple is the legal property of its developers, whose names are too numerous
+ * to list here. Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "purpleconversationmembers.h"
+
+struct _PurpleConversationMembers {
+	GObject parent;
+
+	GPtrArray *members;
+};
+
+enum {
+	PROP_0,
+	PROP_ITEM_TYPE,
+	PROP_N_ITEMS,
+	N_PROPERTIES,
+};
+static GParamSpec *properties[N_PROPERTIES] = {NULL, };
+
+enum {
+	SIG_MEMBER_ADDED,
+	SIG_MEMBER_REMOVED,
+	N_SIGNALS,
+};
+static guint signals[N_SIGNALS] = {0, };
+
+/******************************************************************************
+ * Callbacks
+ *****************************************************************************/
+static gboolean
+purple_conversation_members_check_member_equal(gconstpointer a, gconstpointer b)
+{
+	PurpleConversationMember *member_a = (PurpleConversationMember *)a;
+	PurpleConversationMember *member_b = (PurpleConversationMember *)b;
+	PurpleContactInfo *info_a = NULL;
+	PurpleContactInfo *info_b = NULL;
+
+	info_a = purple_conversation_member_get_contact_info(member_a);
+	info_b = purple_conversation_member_get_contact_info(member_b);
+
+	return (purple_contact_info_compare(info_a, info_b) == 0);
+}
+
+/******************************************************************************
+ * Callbacks
+ *****************************************************************************/
+static void
+purple_conversation_members_member_changed_cb(GObject *self,
+                                              G_GNUC_UNUSED GParamSpec *pspec,
+                                              gpointer data)
+{
+	PurpleConversationMember *member = PURPLE_CONVERSATION_MEMBER(self);
+	PurpleConversationMembers *members = data;
+	gboolean found = FALSE;
+	guint position = 0;
+
+	found = g_ptr_array_find_with_equal_func(members->members, member,
+	                                         purple_conversation_members_check_member_equal,
+	                                         &position);
+
+	if(found) {
+		g_list_model_items_changed(G_LIST_MODEL(members), position, 0, 0);
+	}
+}
+
+/******************************************************************************
+ * GListModel Implementation
+ *****************************************************************************/
+static GType
+purple_conversation_members_get_item_type(G_GNUC_UNUSED GListModel *list) {
+	return PURPLE_TYPE_CONVERSATION_MEMBER;
+}
+
+static guint
+purple_conversation_members_get_n_items(GListModel *list) {
+	PurpleConversationMembers *members = PURPLE_CONVERSATION_MEMBERS(list);
+
+	return members->members->len;
+}
+
+static gpointer
+purple_conversation_members_get_item(GListModel *list, guint position) {
+	PurpleConversationMembers *members = PURPLE_CONVERSATION_MEMBERS(list);
+	PurpleConversationMember *member = NULL;
+
+	if(position < members->members->len) {
+		member = g_ptr_array_index(members->members, position);
+		g_object_ref(member);
+	}
+
+	return member;
+}
+
+static void
+purple_conversation_members_list_model_init(GListModelInterface *iface) {
+	iface->get_item_type = purple_conversation_members_get_item_type;
+	iface->get_n_items = purple_conversation_members_get_n_items;
+	iface->get_item = purple_conversation_members_get_item;
+}
+
+/******************************************************************************
+ * GObject Implementation
+ *****************************************************************************/
+G_DEFINE_FINAL_TYPE_WITH_CODE(
+	PurpleConversationMembers,
+	purple_conversation_members,
+	G_TYPE_OBJECT,
+	G_IMPLEMENT_INTERFACE(G_TYPE_LIST_MODEL,
+	                      purple_conversation_members_list_model_init))
+
+static void
+purple_conversation_members_finalize(GObject *obj) {
+	PurpleConversationMembers *members = PURPLE_CONVERSATION_MEMBERS(obj);
+
+	g_clear_pointer(&members->members, g_ptr_array_unref);
+
+	G_OBJECT_CLASS(purple_conversation_members_parent_class)->finalize(obj);
+}
+
+static void
+purple_conversation_members_get_property(GObject *obj, guint param_id,
+                                         GValue *value, GParamSpec *pspec)
+{
+	switch(param_id) {
+	case PROP_ITEM_TYPE:
+		g_value_set_gtype(value,
+		                  g_list_model_get_item_type(G_LIST_MODEL(obj)));
+		break;
+	case PROP_N_ITEMS:
+		g_value_set_uint(value, g_list_model_get_n_items(G_LIST_MODEL(obj)));
+		break;
+	default:
+		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
+		break;
+	}
+}
+
+static void
+purple_conversation_members_init(PurpleConversationMembers *members) {
+	/* We allocate 2 as dms have at least 2 members. */
+	members->members = g_ptr_array_new_full(2, g_object_unref);
+}
+
+static void
+purple_conversation_members_class_init(PurpleConversationMembersClass *klass) {
+	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+
+	obj_class->finalize = purple_conversation_members_finalize;
+	obj_class->get_property = purple_conversation_members_get_property;
+
+	/**
+	 * PurpleConversationMembers:item-type:
+	 *
+	 * The type of items. See [iface@Gio.ListModel.get_item_type].
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_ITEM_TYPE] = g_param_spec_gtype(
+		"item-type", NULL, NULL,
+		G_TYPE_OBJECT,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * PurpleConversationMembers:n-items:
+	 *
+	 * The number of items. See [iface@Gio.ListModel.get_n_items].
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_N_ITEMS] = g_param_spec_uint(
+		"n-items", NULL, NULL,
+		0, G_MAXUINT, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	g_object_class_install_properties(obj_class, N_PROPERTIES, properties);
+
+	/**
+	 * PurpleConversationMembers::member-added:
+	 * @members: The instance.
+	 * @member: The [class@Purple.ConversationMember] instance.
+	 * @announce: Whether or not this addition should be announced.
+	 * @message: (nullable): An optional message to use in the announcement.
+	 *
+	 * Emitted when a new member is added to this collection.
+	 *
+	 * Since: 3.0
+	 */
+	signals[SIG_MEMBER_ADDED] = g_signal_new_class_handler(
+		"member-added",
+		G_OBJECT_CLASS_TYPE(klass),
+		G_SIGNAL_RUN_LAST,
+		NULL,
+		NULL,
+		NULL,
+		NULL,
+		G_TYPE_NONE,
+		3,
+		PURPLE_TYPE_CONVERSATION_MEMBER,
+		G_TYPE_BOOLEAN,
+		G_TYPE_STRING);
+
+	/**
+	 * PurpleConversationMembers::member-removed:
+	 * @members: The instance.
+	 * @member: The [class@Purple.ConversationMember] instance.
+	 * @announce: Whether or not this removal should be announced.
+	 * @message: (nullable): An optional message to use in the announcement.
+	 *
+	 * Emitted when member is removed from this collection.
+	 *
+	 * Since: 3.0
+	 */
+	signals[SIG_MEMBER_REMOVED] = g_signal_new_class_handler(
+		"member-removed",
+		G_OBJECT_CLASS_TYPE(klass),
+		G_SIGNAL_RUN_LAST,
+		NULL,
+		NULL,
+		NULL,
+		NULL,
+		G_TYPE_NONE,
+		3,
+		PURPLE_TYPE_CONVERSATION_MEMBER,
+		G_TYPE_BOOLEAN,
+		G_TYPE_STRING);
+}
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+PurpleConversationMember *
+purple_conversation_members_add_member(PurpleConversationMembers *members,
+                                       PurpleContactInfo *info,
+                                       gboolean announce,
+                                       const char *message)
+{
+	PurpleConversationMember *member = NULL;
+
+	g_return_val_if_fail(PURPLE_IS_CONVERSATION_MEMBERS(members), NULL);
+	g_return_val_if_fail(PURPLE_IS_CONTACT_INFO(info), NULL);
+
+	member = purple_conversation_members_find_member(members, info);
+	if(PURPLE_IS_CONVERSATION_MEMBER(member)) {
+		return member;
+	}
+
+	member = purple_conversation_member_new(info);
+	g_ptr_array_add(members->members, member);
+
+	/* Add a callback for notify::name-for-display and typing-state on info. */
+	g_signal_connect_object(member, "notify::name-for-display",
+	                        G_CALLBACK(purple_conversation_members_member_changed_cb),
+	                        members, G_CONNECT_DEFAULT);
+	g_signal_connect_object(member, "notify::typing-state",
+	                        G_CALLBACK(purple_conversation_members_member_changed_cb),
+	                        members, G_CONNECT_DEFAULT);
+
+	g_signal_emit(members, signals[SIG_MEMBER_ADDED], 0, member, announce,
+	              message);
+	g_list_model_items_changed(G_LIST_MODEL(members),
+	                           members->members->len - 1, 0, 1);
+
+	return member;
+}
+
+PurpleConversationMember *
+purple_conversation_members_find_member(PurpleConversationMembers *members,
+                                        PurpleContactInfo *info)
+{
+	PurpleConversationMember *member = NULL;
+	guint position = 0;
+
+	g_return_val_if_fail(PURPLE_IS_CONVERSATION_MEMBERS(members), NULL);
+	g_return_val_if_fail(PURPLE_IS_CONTACT_INFO(info), NULL);
+
+	if(purple_conversation_members_has_member(members, info, &position)) {
+		member = g_ptr_array_index(members->members, position);
+	}
+
+	return member;
+}
+
+gboolean
+purple_conversation_members_has_member(PurpleConversationMembers *members,
+                                       PurpleContactInfo *info,
+                                       guint *position)
+{
+	PurpleConversationMember *needle = NULL;
+	gboolean found = FALSE;
+
+	g_return_val_if_fail(PURPLE_IS_CONVERSATION_MEMBERS(members), FALSE);
+	g_return_val_if_fail(PURPLE_IS_CONTACT_INFO(info), FALSE);
+
+	needle = purple_conversation_member_new(info);
+	found = g_ptr_array_find_with_equal_func(members->members,
+	                                         needle,
+	                                         purple_conversation_members_check_member_equal,
+	                                         position);
+
+	g_clear_object(&needle);
+
+	return found;
+}
+
+PurpleConversationMembers *
+purple_conversation_members_new(void) {
+	return g_object_new(PURPLE_TYPE_CONVERSATION_MEMBERS, NULL);
+}
+
+gboolean
+purple_conversation_members_remove_member(PurpleConversationMembers *members,
+                                          PurpleContactInfo *info,
+                                          gboolean announce,
+                                          const char *message)
+{
+	PurpleConversationMember *member = NULL;
+	guint position = 0;
+
+	g_return_val_if_fail(PURPLE_IS_CONVERSATION_MEMBERS(members), FALSE);
+	g_return_val_if_fail(PURPLE_IS_CONTACT_INFO(info), FALSE);
+
+	if(!purple_conversation_members_has_member(members, info, &position)) {
+		return FALSE;
+	}
+
+	member = g_ptr_array_steal_index(members->members, position);
+
+	/* Remove our signal handlers for the member. */
+	g_signal_handlers_disconnect_by_func(member,
+	                                     purple_conversation_members_member_changed_cb,
+	                                     members);
+
+
+	g_signal_emit(members, signals[SIG_MEMBER_REMOVED], 0, member,
+	              announce, message);
+	g_list_model_items_changed(G_LIST_MODEL(members), position, 1, 0);
+
+	g_clear_object(&member);
+
+	return TRUE;
+}
diff --git a/libpurple/tests/meson.build b/libpurple/tests/meson.build
--- a/libpurple/tests/meson.build
+++ b/libpurple/tests/meson.build
@@ -11,6 +11,7 @@
     'conversation',
     'conversation_manager',
     'conversation_member',
+    'conversation_members',
     'credential_manager',
     'credential_provider',
     'file_transfer',
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
@@ -405,120 +405,6 @@
 }
 
 /******************************************************************************
- * Membership tests and helpers
- *****************************************************************************/
-static void
-test_purple_conversation_membership_signal_cb(PurpleConversation *conversation,
-                                              PurpleConversationMember *member,
-                                              gboolean announce,
-                                              const char *message,
-                                              gpointer data)
-{
-	/* We use int's for called to make sure it was only called once. */
-	gint *called = data;
-
-	g_assert_true(PURPLE_IS_CONVERSATION(conversation));
-	g_assert_true(PURPLE_IS_CONVERSATION_MEMBER(member));
-	g_assert_true(announce);
-	g_assert_cmpstr(message, ==, "announcement message");
-
-	*called = *called + 1;
-}
-
-static void
-test_purple_conversation_members_add_remove(void) {
-	PurpleAccount *account = NULL;
-	PurpleContactInfo *info = NULL;
-	PurpleContactInfo *member_info = NULL;
-	PurpleConversation *conversation = NULL;
-	PurpleConversationManager *conversation_manager = NULL;
-	PurpleConversationMember *member = NULL;
-	PurpleConversationMember *member1 = NULL;
-	PurpleConversationMember *test_member = NULL;
-	gboolean removed = FALSE;
-	gint added_called = 0;
-	gint removed_called = 0;
-
-	/* Create our instances. The id is just a uuid 4 to help us avoid a
-	 * g_warning.
-	 */
-	info = purple_contact_info_new("745c50ba-1189-48d9-827c-051783026c96");
-	account = purple_account_new("test", "test");
-	conversation = g_object_new(
-		PURPLE_TYPE_CONVERSATION,
-		"account", account,
-		"name", "test-conversation",
-		NULL);
-
-	/* Make sure the account got added as a member. */
-	test_member = purple_conversation_find_member(conversation,
-	                                              purple_account_get_contact_info(account));
-	g_assert_nonnull(test_member);
-
-	/* Connect our signals. */
-	g_signal_connect(conversation, "member-added",
-	                 G_CALLBACK(test_purple_conversation_membership_signal_cb),
-	                 &added_called);
-	g_signal_connect(conversation, "member-removed",
-	                 G_CALLBACK(test_purple_conversation_membership_signal_cb),
-	                 &removed_called);
-
-	/* Add the member. */
-	member = purple_conversation_add_member(conversation, info, TRUE,
-	                                        "announcement message");
-	g_assert_cmpint(added_called, ==, 1);
-	g_assert_true(PURPLE_IS_CONVERSATION_MEMBER(member));
-
-	test_member = purple_conversation_find_member(conversation, info);
-	g_assert_true(member == test_member);
-
-	/* Add our own reference to the returned member as we use it later to
-	 * verify that double remove doesn't do anything.
-	 */
-	g_object_ref(member);
-
-	/* Try to add the member again, which would just return the existing
-	 * member and not emit the signal.
-	 */
-	member1 = purple_conversation_add_member(conversation, info, TRUE,
-	                                         "announcement message");
-	g_assert_cmpint(added_called, ==, 1);
-	g_assert_true(PURPLE_IS_CONVERSATION_MEMBER(member1));
-	g_assert_true(member1 == member);
-
-	/* Now remove the member and verify the signal was called. */
-	member_info = purple_conversation_member_get_contact_info(member);
-	removed = purple_conversation_remove_member(conversation, member_info,
-	                                            TRUE, "announcement message");
-	g_assert_true(removed);
-	g_assert_cmpint(removed_called, ==, 1);
-
-	test_member = purple_conversation_find_member(conversation, info);
-	g_assert_null(test_member);
-
-	/* Try to remove the member again and verify that nothing was removed and
-	 * that the signal wasn't emitted.
-	 */
-	member_info = purple_conversation_member_get_contact_info(member);
-	removed = purple_conversation_remove_member(conversation, member_info,
-	                                            TRUE, "announcement message");
-	g_assert_false(removed);
-	g_assert_cmpint(removed_called, ==, 1);
-
-	/* TODO: Conversations are automatically registered on construction for
-	 * legacy reasons, so we need to explicitly unregister to clean them up,
-	 * but this can go away once that stops happening. */
-	conversation_manager = purple_conversation_manager_get_default();
-	purple_conversation_manager_unregister(conversation_manager, conversation);
-
-	/* Clean up everything. */
-	g_clear_object(&info);
-	g_clear_object(&member);
-	g_clear_object(&account);
-	g_clear_object(&conversation);
-}
-
-/******************************************************************************
  * Message Tests
  *****************************************************************************/
 static void
@@ -737,9 +623,6 @@
 	g_test_add_func("/conversation/is-thread",
 	                test_purple_conversation_is_thread);
 
-	g_test_add_func("/conversation/members/add-remove",
-	                test_purple_conversation_members_add_remove);
-
 	g_test_add_func("/conversation/message/write-one",
 	                test_purple_conversation_message_write_one);
 
diff --git a/libpurple/tests/test_conversation_members.c b/libpurple/tests/test_conversation_members.c
new file mode 100644
--- /dev/null
+++ b/libpurple/tests/test_conversation_members.c
@@ -0,0 +1,221 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <glib.h>
+
+#include <purple.h>
+
+/******************************************************************************
+ * Membership Tests
+ *****************************************************************************/
+static void
+test_purple_conversation_members_membership_signal_cb(PurpleConversationMembers *members,
+                                                      PurpleConversationMember *member,
+                                                      gboolean announce,
+                                                      const char *message,
+                                                      gpointer data)
+{
+	guint *counter = data;
+
+	g_assert_true(PURPLE_IS_CONVERSATION_MEMBERS(members));
+	g_assert_true(PURPLE_IS_CONVERSATION_MEMBER(member));
+	g_assert_true(announce);
+	g_assert_cmpstr(message, ==, "announcement message");
+
+	*counter = *counter + 1;
+}
+
+static void
+test_purple_conversation_members_add_remove(void) {
+	PurpleContactInfo *info = NULL;
+	PurpleContactInfo *member_info = NULL;
+	PurpleConversationMember *member = NULL;
+	PurpleConversationMember *member1 = NULL;
+	PurpleConversationMember *test_member = NULL;
+	PurpleConversationMembers *members = NULL;
+	gboolean removed = FALSE;
+	guint added_counter = 0;
+	guint removed_counter = 0;
+
+	/* Create our instances. The id is just a uuid 4 to help us avoid a
+	 * g_warning.
+	 */
+	members = purple_conversation_members_new();
+	info = purple_contact_info_new("745c50ba-1189-48d9-827c-051783026c96");
+
+	/* Connect our signals. */
+	g_signal_connect(members, "member-added",
+	                 G_CALLBACK(test_purple_conversation_members_membership_signal_cb),
+	                 &added_counter);
+	g_signal_connect(members, "member-removed",
+	                 G_CALLBACK(test_purple_conversation_members_membership_signal_cb),
+	                 &removed_counter);
+
+	/* Add the member. */
+	added_counter = 0;
+	removed_counter = 0;
+	member = purple_conversation_members_add_member(members, info, TRUE,
+	                                                "announcement message");
+	g_assert_cmpint(added_counter, ==, 1);
+	g_assert_cmpint(removed_counter, ==, 0);
+	g_assert_true(PURPLE_IS_CONVERSATION_MEMBER(member));
+
+	test_member = purple_conversation_members_find_member(members, info);
+	g_assert_true(member == test_member);
+
+	/* Add our own reference to the returned member as we use it later to
+	 * verify that double remove doesn't do anything.
+	 */
+	g_object_ref(member);
+
+	/* Try to add the member again, which would just return the existing
+	 * member and not emit the signal.
+	 */
+	added_counter = 0;
+	removed_counter = 0;
+	member1 = purple_conversation_members_add_member(members, info, TRUE,
+	                                                 "announcement message");
+	g_assert_cmpint(added_counter, ==, 0);
+	g_assert_cmpint(removed_counter, ==, 0);
+	g_assert_true(PURPLE_IS_CONVERSATION_MEMBER(member1));
+	g_assert_true(member1 == member);
+
+	/* Now remove the member and verify the signal was called. */
+	added_counter = 0;
+	removed_counter = 0;
+	member_info = purple_conversation_member_get_contact_info(member);
+	removed = purple_conversation_members_remove_member(members, member_info,
+	                                                    TRUE,
+	                                                    "announcement message");
+	g_assert_true(removed);
+	g_assert_cmpint(added_counter, ==, 0);
+	g_assert_cmpint(removed_counter, ==, 1);
+
+	test_member = purple_conversation_members_find_member(members, info);
+	g_assert_null(test_member);
+
+	/* Try to remove the member again and verify that nothing was removed and
+	 * that the signal wasn't emitted.
+	 */
+	added_counter = 0;
+	removed_counter = 0;
+	member_info = purple_conversation_member_get_contact_info(member);
+	removed = purple_conversation_members_remove_member(members, member_info,
+	                                                    TRUE,
+	                                                    "announcement message");
+	g_assert_false(removed);
+	g_assert_cmpint(added_counter, ==, 0);
+	g_assert_cmpint(removed_counter, ==, 0);
+
+	/* Clean up everything. */
+	g_assert_finalize_object(members);
+	g_assert_finalize_object(member);
+	g_assert_finalize_object(info);
+}
+
+/******************************************************************************
+ * Items Changed Tests
+ *****************************************************************************/
+static void
+test_purple_conversation_members_item_changed_cb(GListModel *model,
+                                                 guint position,
+                                                 guint removed,
+                                                 guint added,
+                                                 gpointer data)
+{
+	guint *counter = data;
+
+	g_assert_true(PURPLE_IS_CONVERSATION_MEMBERS(model));
+
+	g_assert_cmpuint(position, ==, 1);
+	g_assert_cmpuint(removed, ==, 0);
+	g_assert_cmpuint(added, ==, 0);
+
+	*counter = *counter + 1;
+}
+
+static void
+test_purple_conversation_members_item_changed(void) {
+	PurpleContactInfo *info = NULL;
+	PurpleConversationMembers *members = NULL;
+	PurpleConversationMember *member = NULL;
+	guint counter = 0;
+
+	members = purple_conversation_members_new();
+
+	/* Add the first member, this is just so we can verify that position is non
+	 * zero in the callback handler.
+	 */
+	info = purple_contact_info_new("first");
+	purple_conversation_members_add_member(members, info, FALSE, NULL);
+	g_clear_object(&info);
+
+	/* Add the second member and hold on to their pointer as we're going to
+	 * make changes to it, to verify that the items-changed signal gets called.
+	 */
+	info = purple_contact_info_new("second");
+	member = purple_conversation_members_add_member(members, info, FALSE, NULL);
+	g_assert_true(PURPLE_IS_CONVERSATION_MEMBER(member));
+
+	/* Connect the items-changed signal. We do this after everything is added
+	 * to avoid having to deal with the additions which were already verified
+	 * by the membership test.
+	 */
+	g_signal_connect(members, "items-changed",
+	                 G_CALLBACK(test_purple_conversation_members_item_changed_cb),
+	                 &counter);
+
+	/* Change the alias on the contact info and verify that the signal got
+	 * called.
+	 */
+	counter = 0;
+	purple_contact_info_set_alias(info, "aliased");
+	g_assert_cmpuint(counter, ==, 1);
+
+	/* Change the nickname on the member. The callback only gets called once,
+	 * because the members is only watching the name-for-display property.
+	 */
+	counter = 0;
+	purple_conversation_member_set_nickname(member, "nickname");
+	g_assert_cmpuint(counter, ==, 1);
+
+	/* Change the typing-state on the member. */
+	counter = 0;
+	purple_conversation_member_set_typing_state(member,
+	                                            PURPLE_TYPING_STATE_TYPING, 6);
+	g_assert_cmpuint(counter, ==, 1);
+
+	g_assert_finalize_object(members);
+	g_assert_finalize_object(info);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+int
+main(int argc, char *argv[]) {
+	g_test_init(&argc, &argv, NULL);
+	g_test_set_nonfatal_assertions();
+
+	g_test_add_func("/conversation-members/add-remove",
+	                test_purple_conversation_members_add_remove);
+	g_test_add_func("/conversation-members/items-changed",
+	                test_purple_conversation_members_item_changed);
+
+	return g_test_run();
+}
