diff --git a/libpurple/purplecontactinfo.h b/libpurple/purplecontactinfo.h
--- a/libpurple/purplecontactinfo.h
+++ b/libpurple/purplecontactinfo.h
@@ -355,6 +355,22 @@
  */
 int purple_contact_info_compare(PurpleContactInfo *a, PurpleContactInfo *b);
 
+/**
+ * purple_contact_info_matches:
+ * @info: The instance.
+ * @needle: (nullable): The string to match.
+ *
+ * This will determine if the alias, display name, or username matches @needle.
+ *
+ * If @needle is %NULL or empty string, %TRUE will be returned.
+ *
+ * Returns: %TRUE if @needle matches any of the above properties, otherwise
+ *          %FALSE.
+ *
+ * Since: 3.0.0
+ */
+gboolean purple_contact_info_matches(PurpleContactInfo *info, const char *needle);
+
 G_END_DECLS
 
 #endif /* PURPLE_CONTACT_INFO_H */
# HG changeset patch
# Node ID c3a735f08ac7
# Parent  941734e74261
diff --git a/libpurple/purplecontactinfo.c b/libpurple/purplecontactinfo.c
--- a/libpurple/purplecontactinfo.c
+++ b/libpurple/purplecontactinfo.c
@@ -797,3 +797,43 @@
 
 	return purple_utf8_strcasecmp(name_a, name_b);
 }
+
+gboolean
+purple_contact_info_matches(PurpleContactInfo *info, const char *needle) {
+	PurpleContactInfoPrivate *priv = NULL;
+
+	g_return_val_if_fail(PURPLE_IS_CONTACT_INFO(info), FALSE);
+
+	if(purple_strempty(needle)) {
+		return TRUE;
+	}
+
+	priv = purple_contact_info_get_instance_private(info);
+
+	if(!purple_strempty(priv->id)) {
+		if(strstr(priv->id, needle) != NULL) {
+			return TRUE;
+		}
+	}
+
+	if(!purple_strempty(priv->username)) {
+		if(strstr(priv->username, needle) != NULL) {
+			return TRUE;
+		}
+	}
+
+	if(!purple_strempty(priv->alias)) {
+		if(strstr(priv->alias, needle) != NULL) {
+			return TRUE;
+		}
+	}
+
+	if(!purple_strempty(priv->display_name)) {
+		if(strstr(priv->display_name, needle) != NULL) {
+			return TRUE;
+		}
+	}
+
+	/* Nothing matched, so return FALSE. */
+	return FALSE;
+}
diff --git a/libpurple/purpleperson.h b/libpurple/purpleperson.h
--- a/libpurple/purpleperson.h
+++ b/libpurple/purpleperson.h
@@ -192,6 +192,23 @@
  */
 gboolean purple_person_has_contacts(PurplePerson *person);
 
+/**
+ * purple_person_matches:
+ * @person: The instance.
+ * @needle: (nullable): The string to match on.
+ *
+ * Checks if the alias matches @needle. This also checks @needle against
+ * [method@Purple.ContactInfo.matches] for each [class@Purple.ContactInfo] that
+ * @person is tracking.
+ *
+ * If @needle is %NULL or empty string, %TRUE will be returned.
+ *
+ * Returns: %TRUE if @person matches @needle in any way.
+ *
+ * Since: 3.0.0
+ */
+gboolean purple_person_matches(PurplePerson *person, const char *needle);
+
 G_END_DECLS
 
 #endif /* PURPLE_PERSON_H */
diff --git a/libpurple/purpleperson.c b/libpurple/purpleperson.c
--- a/libpurple/purpleperson.c
+++ b/libpurple/purpleperson.c
@@ -18,6 +18,8 @@
 
 #include "purpleperson.h"
 
+#include "util.h"
+
 struct _PurplePerson {
 	GObject parent;
 
@@ -102,6 +104,17 @@
 	}
 }
 
+/* This function is used by purple_person_matches to determine if a contact info
+ * matches the needle.
+ */
+static gboolean
+purple_person_matches_find_func(gconstpointer a, gconstpointer b) {
+	PurpleContactInfo *info = (gpointer)a;
+	const char *needle = b;
+
+	return purple_contact_info_matches(info, needle);
+}
+
 /******************************************************************************
  * Callbacks
  *****************************************************************************/
@@ -459,3 +472,24 @@
 
 	return person->contacts->len > 0;
 }
+
+gboolean
+purple_person_matches(PurplePerson *person, const char *needle) {
+	g_return_val_if_fail(PURPLE_IS_PERSON(person), FALSE);
+
+	if(purple_strempty(needle)) {
+		return TRUE;
+	}
+
+	/* Check if the person's alias matches. */
+	if(!purple_strempty(person->alias)) {
+		if(strstr(person->alias, needle) != NULL) {
+			return TRUE;
+		}
+	}
+
+	/* See if any of the contact infos match. */
+	return g_ptr_array_find_with_equal_func(person->contacts, needle,
+	                                        purple_person_matches_find_func,
+	                                        NULL);
+}
diff --git a/libpurple/tests/test_contact_info.c b/libpurple/tests/test_contact_info.c
--- a/libpurple/tests/test_contact_info.c
+++ b/libpurple/tests/test_contact_info.c
@@ -315,6 +315,82 @@
 }
 
 /******************************************************************************
+ * Matches
+ *****************************************************************************/
+static void
+test_purple_contact_info_matches_accepts_null(void) {
+	PurpleContactInfo *info = purple_contact_info_new(NULL);
+
+	g_assert_true(purple_contact_info_matches(info, NULL));
+
+	g_clear_object(&info);
+}
+
+static void
+test_purple_contact_info_matches_empty_string(void) {
+	PurpleContactInfo *info = purple_contact_info_new(NULL);
+
+	g_assert_true(purple_contact_info_matches(info, ""));
+
+	g_clear_object(&info);
+}
+
+static void
+test_purple_contact_info_matches_id(void) {
+	PurpleContactInfo *info = purple_contact_info_new("this is an id");
+
+	g_assert_true(purple_contact_info_matches(info, "an"));
+
+	g_clear_object(&info);
+}
+
+static void
+test_purple_contact_info_matches_username(void) {
+	PurpleContactInfo *info = purple_contact_info_new(NULL);
+
+	purple_contact_info_set_username(info, "username");
+
+	g_assert_true(purple_contact_info_matches(info, "name"));
+
+	g_clear_object(&info);
+}
+
+static void
+test_purple_contact_info_matches_alias(void) {
+	PurpleContactInfo *info = purple_contact_info_new(NULL);
+
+	purple_contact_info_set_alias(info, "alias");
+
+	g_assert_true(purple_contact_info_matches(info, "lia"));
+
+	g_clear_object(&info);
+}
+
+static void
+test_purple_contact_info_matches_display_name(void) {
+	PurpleContactInfo *info = purple_contact_info_new(NULL);
+
+	purple_contact_info_set_display_name(info, "display name");
+
+	g_assert_true(purple_contact_info_matches(info, "play"));
+
+	g_clear_object(&info);
+}
+
+static void
+test_purple_contact_info_matches_none(void) {
+	PurpleContactInfo *info = purple_contact_info_new("id");
+
+	purple_contact_info_set_username(info, "username");
+	purple_contact_info_set_alias(info, "alias");
+	purple_contact_info_set_display_name(info, "display name");
+
+	g_assert_false(purple_contact_info_matches(info, "nothing"));
+
+	g_clear_object(&info);
+}
+
+/******************************************************************************
  * Main
  *****************************************************************************/
 gint
@@ -352,5 +428,20 @@
 	g_test_add_func("/contact-info/compare/name__name",
 	                test_purple_contact_info_compare_name__name);
 
+	g_test_add_func("/contact-info/matches/accepts_null",
+	                test_purple_contact_info_matches_accepts_null);
+	g_test_add_func("/contact-info/matches/emptry_string",
+	                test_purple_contact_info_matches_empty_string);
+	g_test_add_func("/contact-info/matches/id",
+	                test_purple_contact_info_matches_id);
+	g_test_add_func("/contact-info/matches/username",
+	                test_purple_contact_info_matches_username);
+	g_test_add_func("/contact-info/matches/alias",
+	                test_purple_contact_info_matches_alias);
+	g_test_add_func("/contact-info/matches/display_name",
+	                test_purple_contact_info_matches_display_name);
+	g_test_add_func("/contact-info/matches/none",
+	                test_purple_contact_info_matches_none);
+
 	return g_test_run();
 }
diff --git a/libpurple/tests/test_person.c b/libpurple/tests/test_person.c
--- a/libpurple/tests/test_person.c
+++ b/libpurple/tests/test_person.c
@@ -355,6 +355,53 @@
 }
 
 /******************************************************************************
+ * Matches tests
+ *****************************************************************************/
+static void
+test_purple_person_matches_accepts_null(void) {
+	PurplePerson *person = purple_person_new();
+
+	g_assert_true(purple_person_matches(person, NULL));
+
+	g_clear_object(&person);
+}
+
+static void
+test_purple_person_matches_empty_string(void) {
+	PurplePerson *person = purple_person_new();
+
+	g_assert_true(purple_person_matches(person, ""));
+
+	g_clear_object(&person);
+}
+
+static void
+test_purple_person_matches_alias(void) {
+	PurplePerson *person = purple_person_new();
+
+	purple_person_set_alias(person, "this is the alias");
+
+	g_assert_true(purple_person_matches(person, "the"));
+	g_assert_false(purple_person_matches(person, "what"));
+
+	g_clear_object(&person);
+}
+
+static void
+test_purple_person_matches_contact_info(void) {
+	PurplePerson *person = purple_person_new();
+	PurpleContactInfo *info = purple_contact_info_new(NULL);
+
+	purple_contact_info_set_username(info, "user1");
+	purple_person_add_contact_info(person, info);
+	g_clear_object(&info);
+
+	g_assert_true(purple_person_matches(person, "user1"));
+
+	g_clear_object(&person);
+}
+
+/******************************************************************************
  * Main
  *****************************************************************************/
 gint
@@ -377,5 +424,14 @@
 	g_test_add_func("/person/priority/multiple-with-change",
 	                test_purple_person_priority_multiple_with_change);
 
+	g_test_add_func("/person/matches/accepts_null",
+	                test_purple_person_matches_accepts_null);
+	g_test_add_func("/person/matches/empty_string",
+	                test_purple_person_matches_empty_string);
+	g_test_add_func("/person/matches/alias",
+	                test_purple_person_matches_alias);
+	g_test_add_func("/person/matches/contact_info",
+	                test_purple_person_matches_contact_info);
+
 	return g_test_run();
-}
\ No newline at end of file
+}
diff --git a/pidgin/pidgincontactlist.h b/pidgin/pidgincontactlist.h
--- a/pidgin/pidgincontactlist.h
+++ b/pidgin/pidgincontactlist.h
@@ -46,7 +46,7 @@
 
 #define PIDGIN_TYPE_CONTACT_LIST (pidgin_contact_list_get_type())
 G_DECLARE_FINAL_TYPE(PidginContactList, pidgin_contact_list, PIDGIN,
-                     CONTACT_LIST, AdwBin)
+                     CONTACT_LIST, GtkBox)
 
 /**
  * pidgin_contact_list_new:
diff --git a/pidgin/pidgincontactlist.c b/pidgin/pidgincontactlist.c
--- a/pidgin/pidgincontactlist.c
+++ b/pidgin/pidgincontactlist.c
@@ -27,16 +27,44 @@
 #include "pidgin/pidgincontactlist.h"
 
 struct _PidginContactList {
-	AdwBin parent;
+	GtkBox parent;
 
+	GtkFilterListModel *filter_model;
+	GtkCustomFilter *search_filter;
+
+	GtkWidget *search_entry;
 	GtkWidget *view;
 };
 
-G_DEFINE_TYPE(PidginContactList, pidgin_contact_list, ADW_TYPE_BIN)
+G_DEFINE_TYPE(PidginContactList, pidgin_contact_list, GTK_TYPE_BOX)
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+static gboolean
+pidgin_contact_list_search_filter(GObject *item, gpointer data) {
+	PidginContactList *list = data;
+	PurplePerson *person = PURPLE_PERSON(item);
+	const char *needle = NULL;
+
+	needle = gtk_editable_get_text(GTK_EDITABLE(list->search_entry));
+
+	return purple_person_matches(person, needle);
+}
 
 /******************************************************************************
  * Callbacks
  *****************************************************************************/
+static void
+pidgin_contact_list_search_changed_cb(G_GNUC_UNUSED GtkSearchEntry *self,
+                                      gpointer data)
+{
+	PidginContactList *list = data;
+
+	gtk_filter_changed(GTK_FILTER(list->search_filter),
+	                   GTK_FILTER_CHANGE_DIFFERENT);
+}
+
 static GdkTexture *
 pidgin_contact_list_avatar_cb(G_GNUC_UNUSED GObject *self,
                               PurplePerson *person,
@@ -47,6 +75,13 @@
 	PurpleBuddyIcon *icon = NULL;
 	GdkTexture *texture = NULL;
 
+	/* When filtering we get called for rows that have been filtered out. I'm
+	 * not sure why, but this does appear to be the case.
+	 */
+	if(person == NULL) {
+		return NULL;
+	}
+
 	info = purple_person_get_priority_contact_info(person);
 
 	/* All of the contact info in the manager are PurpleContact's so this cast
@@ -119,15 +154,15 @@
 static void
 pidgin_contact_list_init(PidginContactList *list) {
 	PurpleContactManager *manager = NULL;
-	GtkSingleSelection *selection = NULL;
 
 	gtk_widget_init_template(GTK_WIDGET(list));
 
 	manager = purple_contact_manager_get_default();
-	selection = gtk_single_selection_new(G_LIST_MODEL(manager));
+	gtk_filter_list_model_set_model(list->filter_model, G_LIST_MODEL(manager));
 
-	gtk_list_view_set_model(GTK_LIST_VIEW(list->view),
-	                        GTK_SELECTION_MODEL(selection));
+	gtk_custom_filter_set_filter_func(list->search_filter,
+	                                  (GtkCustomFilterFunc)pidgin_contact_list_search_filter,
+	                                  list, NULL);
 }
 
 static void
@@ -140,9 +175,18 @@
 	);
 
 	gtk_widget_class_bind_template_child(widget_class, PidginContactList,
+	                                     filter_model);
+	gtk_widget_class_bind_template_child(widget_class, PidginContactList,
+	                                     search_filter);
+
+	gtk_widget_class_bind_template_child(widget_class, PidginContactList,
+	                                     search_entry);
+	gtk_widget_class_bind_template_child(widget_class, PidginContactList,
 	                                     view);
 
 	gtk_widget_class_bind_template_callback(widget_class,
+	                                        pidgin_contact_list_search_changed_cb);
+	gtk_widget_class_bind_template_callback(widget_class,
 	                                        pidgin_contact_list_avatar_cb);
 	gtk_widget_class_bind_template_callback(widget_class,
 	                                        pidgin_contact_list_activate_cb);
diff --git a/pidgin/resources/ContactList/widget.ui b/pidgin/resources/ContactList/widget.ui
--- a/pidgin/resources/ContactList/widget.ui
+++ b/pidgin/resources/ContactList/widget.ui
@@ -24,9 +24,23 @@
   <!-- interface-name Pidgin -->
   <!-- interface-description Internet Messenger -->
   <!-- interface-copyright Pidgin Developers <devel@pidgin.im> -->
-  <template class="PidginContactList" parent="AdwBin">
+  <template class="PidginContactList" parent="GtkBox">
+    <property name="orientation">vertical</property>
+    <child>
+      <object class="GtkSearchBar" id="search_bar">
+        <property name="key-capture-widget">PidginContactList</property>
+        <property name="show-close-button">1</property>
+        <child>
+          <object class="GtkSearchEntry" id="search_entry">
+            <signal name="search-changed" handler="pidgin_contact_list_search_changed_cb"/>
+          </object>
+        </child>
+      </object>
+    </child>
     <child>
       <object class="GtkListView" id="view">
+        <property name="hexpand">1</property>
+        <property name="vexpand">1</property>
         <property name="factory">
           <object class="GtkBuilderListItemFactory">
             <property name="bytes">
@@ -70,7 +84,20 @@
             </property>
           </object>
         </property>
-        <signal name="activate" handler="pidgin_contact_list_activate_cb" swapped="no"/>
+        <property name="model">
+          <object class="GtkSingleSelection">
+            <property name="autoselect">1</property>
+            <property name="model">
+              <object class="GtkFilterListModel" id="filter_model">
+                <property name="incremental">0</property>
+                <property name="filter">
+                  <object class="GtkCustomFilter" id="search_filter"/>
+                </property>
+              </object>
+            </property>
+          </object>
+        </property>
+        <signal name="activate" handler="pidgin_contact_list_activate_cb"/>
       </object>
     </child>
   </template>
