pidgin/pidginconversation.c

Tue, 13 May 2025 14:29:06 -0500

author
Gary Kramlich <grim@reaperworld.com>
date
Tue, 13 May 2025 14:29:06 -0500
changeset 43251
8bd7eee2f178
parent 43201
1889c68bc5a0
child 43291
a14a8ae209a9
permissions
-rw-r--r--

Create a Privacy preference page with the send typing notification preference

This only controls whether or not the conversation window will send typing
notifications, plugins can still do this on their own.

Testing Done:
Used ngrep to verify if the irc typing messages were being sent or not. Also manually modified the settings file and verified the ui update and vice versa.

Bugs closed: PIDGIN-17450

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

/*
 * Pidgin - Internet Messenger
 * Copyright (C) Pidgin Developers <devel@pidgin.im>
 *
 * Pidgin 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 program 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 program 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 program; if not, see <https://www.gnu.org/licenses/>.
 */

#include <glib/gi18n-lib.h>

#include <libspelling.h>

#include <gtksourceview/gtksourceview.h>

#include <purple.h>

#include "pidginautoadjustment.h"
#include "pidgincontactinfomenu.h"
#include "pidginconversation.h"
#include "pidgininfopane.h"
#include "pidginmessage.h"
#include "pidginnotifiable.h"

#define PIDGIN_CONVERSATION_DATA ("pidgin-conversation")

enum {
	PROP_0,
	PROP_CONVERSATION,
	N_PROPERTIES,
	/* Overrides */
	PROP_NEEDS_ATTENTION = N_PROPERTIES,
	PROP_NOTIFICATION_COUNT,
};
static GParamSpec *properties[N_PROPERTIES] = {NULL, };

struct _PidginConversation {
	GtkBox parent;

	PurpleConversation *conversation;

	GSettings *privacy_settings;

	GtkWidget *info_pane;
	GtkWidget *history;
	GtkAdjustment *history_adjustment;

	GtkWidget *member_list_search_entry;
	GtkCustomFilter *member_list_filter;
	GtkCustomSorter *member_list_sorter;

	GtkWidget *input;
	GtkSourceBuffer *input_buffer;

	GtkWidget *typing_label;
	GtkWidget *status_label;

	/* This is a temporary work around to get new message notifications working
	 * until we implement Purple.History properly.
	 * -- gk 2025-03-11
	 */
	guint notification_count;
};

static void
pidgin_conversation_messages_changed_cb(GListModel *model, guint position,
                                        guint removed, guint added,
                                        gpointer data);

/******************************************************************************
 * Helpers
 *****************************************************************************/
static void
pidgin_conversation_set_notification_count(PidginConversation *conversation,
                                           guint notification_count)
{
	if(conversation->notification_count != notification_count) {
		GObject *obj = G_OBJECT(conversation);

		conversation->notification_count = notification_count;

		g_object_freeze_notify(obj);
		g_object_notify(obj, "needs-attention");
		g_object_notify(obj, "notification-count");
		g_object_thaw_notify(obj);
	}
}

static void
pidgin_conversation_set_conversation(PidginConversation *conversation,
                                     PurpleConversation *purple_conversation)
{
	/* Disconnect our old signal. */
	if(PURPLE_IS_CONVERSATION(conversation->conversation)) {
		GListModel *model = NULL;

		model = purple_conversation_get_messages(conversation->conversation);
		g_signal_handlers_disconnect_by_func(model,
		                                     G_CALLBACK(pidgin_conversation_messages_changed_cb),
		                                     conversation);
	}

	if(g_set_object(&conversation->conversation, purple_conversation)) {
		if(PURPLE_IS_CONVERSATION(purple_conversation)) {
			GListModel *model = NULL;

			g_object_set_data(G_OBJECT(purple_conversation),
			                  PIDGIN_CONVERSATION_DATA, conversation);

			model = purple_conversation_get_messages(conversation->conversation);

			g_signal_connect_object(model, "items-changed",
			                        G_CALLBACK(pidgin_conversation_messages_changed_cb),
			                        conversation, G_CONNECT_DEFAULT);
		}

		g_object_notify_by_pspec(G_OBJECT(conversation),
		                         properties[PROP_CONVERSATION]);
	}
}

/* This is used to call g_markup_escape_text for the topic before displaying it
 * in its normal label and the tool tip for that label.
 */
static char *
pidgin_conversation_escape_topic(G_GNUC_UNUSED GObject *self,
                                 const char *topic,
                                 G_GNUC_UNUSED gpointer data)
{
	if(topic == NULL) {
		return g_strdup("");
	}

	return g_markup_escape_text(topic, -1);
}

static char *
pidgin_conversation_conversation_members_items_changed(G_GNUC_UNUSED PidginConversation *self,
                                                       guint n_members)
{
	/* TRANSLATORS: This is a label for how many participants are in a
	 * conversation.
	 */
	const char *format = g_dngettext(GETTEXT_PACKAGE, "%u Member",
	                                 "%u Members", n_members);

	return g_strdup_printf(format, n_members);
}

/**
 * pidgin_conversation_send_message:
 * @conversation: The instance.
 *
 * Creates a [class@Purple.Message] from the input widgets of @conversation and
 * sends it.
 *
 * Since: 3.0
 */
static void
pidgin_conversation_send_message(PidginConversation *conversation) {
	PurpleAccount *account = NULL;
	PurpleContactInfo *info = NULL;
	PurpleMessage *message = NULL;
	GtkTextBuffer *buffer = NULL;
	GtkTextIter start;
	GtkTextIter end;
	char *contents = NULL;
	gboolean command_executed = FALSE;

	account = purple_conversation_get_account(conversation->conversation);

	/* Get the contents from the buffer. */
	buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(conversation->input));
	gtk_text_buffer_get_start_iter(buffer, &start);
	gtk_text_buffer_get_end_iter(buffer, &end);

	contents = gtk_text_buffer_get_text(buffer, &start, &end, TRUE);

	if(contents != NULL && contents[0] == '/') {
		PurpleCommandManager *manager = NULL;

		manager = purple_command_manager_get_default();
		command_executed = purple_command_manager_find_and_execute(manager,
		                                                           conversation->conversation,
		                                                           contents + 1);
	}

	if(!command_executed) {
		PurpleConversationMember *author = NULL;

		/* Create the message. */
		info = purple_account_get_contact_info(account);
		author = purple_conversation_find_or_add_member(conversation->conversation,
		                                                info, FALSE, NULL);
		message = purple_message_new(author, contents);

		/* Send the message and clean up. We don't worry about the callback as we
		 * don't have anything to do in it right now.
		 */
		purple_conversation_send_message_async(conversation->conversation, message,
		                                       NULL, NULL, NULL);

		g_clear_object(&message);
	}

	g_clear_pointer(&contents, g_free);

	gtk_text_buffer_set_text(buffer, "", -1);
}

static gboolean
pidgin_conversation_get_needs_attention(PidginConversation *conversation)
{
	return conversation->notification_count > 0;
}

static guint
pidgin_conversation_get_notification_count(PidginConversation *conversation) {
	return conversation->notification_count;
}

/******************************************************************************
 * Callbacks
 *****************************************************************************/
static void
pidgin_conversation_messages_changed_cb(GListModel *model,
                                        guint position,
                                        G_GNUC_UNUSED guint removed,
                                        guint added,
                                        gpointer data)
{
	PidginConversation *conversation = data;
	guint current = conversation->notification_count;

	for(guint i = position; i < position + added; i++) {
		PurpleMessage *message = NULL;

		message = g_list_model_get_item(model, i);
		if(!purple_message_get_event(message)) {
			current += 1;
		}
		g_clear_object(&message);
	}

	pidgin_conversation_set_notification_count(conversation, current);
}

static char *
pidgin_conversation_get_status_label(G_GNUC_UNUSED PidginConversation *conversation,
                                     GError *error, gboolean online)
{
	const char *message = NULL;

	if(error != NULL) {
		message = error->message;
	}

	if(purple_strempty(message) && !online) {
		message = _("Conversation offline");
	}

	if(purple_strempty(message)) {
		message = "";
	}

	return g_strdup(message);
}

static GtkWidget *
pidgin_conversation_get_status_page(PidginConversation *conversation,
                                    GError *error, gboolean online)
{
	if(error != NULL || !online) {
		return g_object_ref(conversation->status_label);
	}

	return g_object_ref(conversation->typing_label);
}

static void
pidgin_conversation_input_insert_text_cb(G_GNUC_UNUSED GtkTextBuffer *buffer,
                                         G_GNUC_UNUSED const GtkTextIter *iter,
                                         G_GNUC_UNUSED char *text,
                                         G_GNUC_UNUSED int length,
                                         gpointer data)
{
	PidginConversation *conversation = data;

	if(g_settings_get_boolean(conversation->privacy_settings,
	                          "send-typing-notifications"))
	{
		purple_conversation_set_typing_state(conversation->conversation,
		                                     PURPLE_TYPING_STATE_TYPING);
	}
}

static void
pidgin_conversation_input_delete_range_cb(GtkTextBuffer *buffer,
                                          G_GNUC_UNUSED const GtkTextIter *start,
                                          G_GNUC_UNUSED const GtkTextIter *end,
                                          gpointer data)
{
	PidginConversation *conversation = data;

	if(gtk_text_buffer_get_char_count(buffer) == 0) {
		if(g_settings_get_boolean(conversation->privacy_settings,
		                          "send-typing-notifications"))
		{
			purple_conversation_set_typing_state(conversation->conversation,
			                                     PURPLE_TYPING_STATE_NONE);
		}
	}
}

static gboolean
pidgin_conversation_input_key_pressed_cb(G_GNUC_UNUSED GtkEventControllerKey *self,
                                         guint keyval,
                                         G_GNUC_UNUSED guint keycode,
                                         GdkModifierType state,
                                         gpointer data)
{
	PidginConversation *conversation = data;
	gboolean handled = TRUE;

	if(keyval == GDK_KEY_Return || keyval == GDK_KEY_KP_Enter) {
		if(state == GDK_SHIFT_MASK || state == GDK_CONTROL_MASK) {
			return FALSE;
		}

		pidgin_conversation_send_message(conversation);
	} else if(keyval == GDK_KEY_Page_Up) {
		pidgin_auto_adjustment_decrement(PIDGIN_AUTO_ADJUSTMENT(conversation->history_adjustment));
	} else if(keyval == GDK_KEY_Page_Down) {
		pidgin_auto_adjustment_increment(PIDGIN_AUTO_ADJUSTMENT(conversation->history_adjustment));
	} else {
		handled = FALSE;
	}

	return handled;
}

static void
pidgin_conversation_detach(PidginConversation *conversation) {
	if(PURPLE_IS_CONVERSATION(conversation->conversation)) {
		gpointer us = NULL;

		us = g_object_get_data(G_OBJECT(conversation->conversation),
		                       PIDGIN_CONVERSATION_DATA);

		if(conversation == us) {
			g_object_set_data(G_OBJECT(conversation->conversation),
			                  PIDGIN_CONVERSATION_DATA, NULL);
		}
	}
}

static void
pidgin_conversation_members_search_changed_cb(GtkSearchEntry *self,
                                              gpointer data)
{
	PidginConversation *conversation = data;

	gtk_filter_changed(GTK_FILTER(conversation->member_list_filter),
	                   GTK_FILTER_CHANGE_DIFFERENT);

	/* Make sure the search widget has focus, this allows the user to clear a
	 * search that has filtered out every item in the list via their keyboard.
	 */
	if(!gtk_widget_has_focus(GTK_WIDGET(self))) {
		gtk_widget_grab_focus(GTK_WIDGET(self));
	}
}

static int
pidgin_conversation_member_list_sort(gconstpointer a, gconstpointer b,
                                     G_GNUC_UNUSED gpointer data)
{
	PurpleConversationMember *member_a = NULL;
	PurpleConversationMember *member_b = NULL;

	member_a = PURPLE_CONVERSATION_MEMBER((gpointer)a);
	member_b = PURPLE_CONVERSATION_MEMBER((gpointer)b);

	return purple_conversation_member_compare(member_a, member_b);
}

static gboolean
pidgin_conversation_member_list_filter(GObject *item, gpointer data) {
	PidginConversation *conversation = data;
	PurpleConversationMember *member = PURPLE_CONVERSATION_MEMBER(item);
	const char *needle = NULL;

	needle = gtk_editable_get_text(GTK_EDITABLE(conversation->member_list_search_entry));

	return purple_conversation_member_matches(member, needle);
}

static void
pidgin_conversation_message_setup(G_GNUC_UNUSED GtkSignalListItemFactory *self,
                                  GObject *object,
                                  G_GNUC_UNUSED gpointer data)
{
	gtk_list_item_set_child(GTK_LIST_ITEM(object), pidgin_message_new(NULL));
}

static void
pidgin_conversation_message_bind(G_GNUC_UNUSED GtkSignalListItemFactory *self,
                                 GObject *object,
                                 G_GNUC_UNUSED gpointer data)
{
	PurpleMessage *purple_message = NULL;
	GtkListItem *item = GTK_LIST_ITEM(object);
	GtkWidget *pidgin_message = NULL;

	purple_message = gtk_list_item_get_item(item);
	pidgin_message = gtk_list_item_get_child(item);

	pidgin_message_set_message(PIDGIN_MESSAGE(pidgin_message), purple_message);
}

static void
pidgin_conversation_message_unbind(G_GNUC_UNUSED GtkSignalListItemFactory *self,
                                   GObject *object,
                                   G_GNUC_UNUSED gpointer data)
{
	GtkListItem *item = GTK_LIST_ITEM(object);
	GtkWidget *message = NULL;

	message = gtk_list_item_get_child(item);

	pidgin_message_set_message(PIDGIN_MESSAGE(message), NULL);
}

static void
pidgin_conversation_map_cb(GtkWidget *self, G_GNUC_UNUSED gpointer data) {
	pidgin_conversation_set_notification_count(PIDGIN_CONVERSATION(self), 0);
}

/******************************************************************************
 * PidginNotifiable Implementation
 *****************************************************************************/
static void
pidgin_conversation_notifiable_init(G_GNUC_UNUSED PidginNotifiableInterface *iface)
{
}

/******************************************************************************
 * GObject Implementation
 *****************************************************************************/
G_DEFINE_FINAL_TYPE_WITH_CODE(PidginConversation,
                              pidgin_conversation,
                              GTK_TYPE_BOX,
                              G_IMPLEMENT_INTERFACE(PIDGIN_TYPE_NOTIFIABLE,
                                                    pidgin_conversation_notifiable_init))

static void
pidgin_conversation_dispose(GObject *obj) {
	PidginConversation *conversation = PIDGIN_CONVERSATION(obj);

	pidgin_conversation_detach(conversation);

	g_clear_object(&conversation->conversation);

	G_OBJECT_CLASS(pidgin_conversation_parent_class)->dispose(obj);
}

static void
pidgin_conversation_finalize(GObject *obj) {
	PidginConversation *conversation = PIDGIN_CONVERSATION(obj);

	g_clear_object(&conversation->privacy_settings);

	G_OBJECT_CLASS(pidgin_conversation_parent_class)->finalize(obj);
}

static void
pidgin_conversation_get_property(GObject *obj, guint param_id, GValue *value,
                                 GParamSpec *pspec)
{
	PidginConversation *conversation = PIDGIN_CONVERSATION(obj);

	switch(param_id) {
	case PROP_CONVERSATION:
		g_value_set_object(value,
		                   pidgin_conversation_get_conversation(conversation));
		break;
	case PROP_NEEDS_ATTENTION:
		g_value_set_boolean(value,
		                    pidgin_conversation_get_needs_attention(conversation));
		break;
	case PROP_NOTIFICATION_COUNT:
		g_value_set_uint(value,
		                 pidgin_conversation_get_notification_count(conversation));
		break;
	default:
		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
		break;
	}
}

static void
pidgin_conversation_set_property(GObject *obj, guint param_id,
                                 const GValue *value, GParamSpec *pspec)
{
	PidginConversation *conversation = PIDGIN_CONVERSATION(obj);

	switch(param_id) {
	case PROP_CONVERSATION:
		pidgin_conversation_set_conversation(conversation,
		                                     g_value_get_object(value));
		break;
	default:
		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
		break;
	}
}

static void
pidgin_conversation_init(PidginConversation *conversation) {
	GMenuModel *menu = NULL;
	SpellingChecker *checker = NULL;
	SpellingTextBufferAdapter *adapter = NULL;

	gtk_widget_init_template(GTK_WIDGET(conversation));

	conversation->privacy_settings =
		g_settings_new_with_backend("im.pidgin.Pidgin3.Privacy",
	                                purple_core_get_settings_backend());

	gtk_custom_sorter_set_sort_func(conversation->member_list_sorter,
	                                pidgin_conversation_member_list_sort,
	                                NULL, NULL);

	gtk_custom_filter_set_filter_func(conversation->member_list_filter,
	                                  (GtkCustomFilterFunc)pidgin_conversation_member_list_filter,
	                                  conversation, NULL);

	/* Create the spell checking adapter. */
	checker = spelling_checker_get_default();
	adapter = spelling_text_buffer_adapter_new(conversation->input_buffer,
	                                           checker);

	/* Add the spell checking menu items and actions. */
	menu = spelling_text_buffer_adapter_get_menu_model(adapter);
	gtk_text_view_set_extra_menu(GTK_TEXT_VIEW(conversation->input), menu);
	gtk_widget_insert_action_group(conversation->input, "spelling",
	                               G_ACTION_GROUP(adapter));
	spelling_text_buffer_adapter_set_enabled(adapter, TRUE);

	g_clear_object(&adapter);

	/* Connect to our map signal to reset the notification-count property. */
	g_signal_connect(conversation, "map",
	                 G_CALLBACK(pidgin_conversation_map_cb), NULL);
}

static void
pidgin_conversation_class_init(PidginConversationClass *klass) {
	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
	GtkWidgetClass *widget_class = GTK_WIDGET_CLASS(klass);

	obj_class->dispose = pidgin_conversation_dispose;
	obj_class->finalize = pidgin_conversation_finalize;
	obj_class->get_property = pidgin_conversation_get_property;
	obj_class->set_property = pidgin_conversation_set_property;

	/**
	 * PidginConversation:conversation:
	 *
	 * The [class@Purple.Conversation] that this conversation is displaying.
	 *
	 * Since: 3.0
	 */
	properties[PROP_CONVERSATION] = g_param_spec_object(
		"conversation", NULL, NULL,
		PURPLE_TYPE_CONVERSATION,
		G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS);

	g_object_class_install_properties(obj_class, N_PROPERTIES, properties);

	g_object_class_override_property(obj_class, PROP_NEEDS_ATTENTION,
	                                 "needs-attention");
	g_object_class_override_property(obj_class, PROP_NOTIFICATION_COUNT,
	                                 "notification-count");

	/* Template stuff. */
	gtk_widget_class_set_template_from_resource(
	    widget_class,
	    "/im/pidgin/Pidgin3/Conversations/conversation.ui"
	);

	gtk_widget_class_bind_template_child(widget_class, PidginConversation,
	                                     info_pane);
	gtk_widget_class_bind_template_child(widget_class, PidginConversation,
	                                     history);
	gtk_widget_class_bind_template_child(widget_class, PidginConversation,
	                                     history_adjustment);
	gtk_widget_class_bind_template_child(widget_class, PidginConversation,
	                                     member_list_search_entry);
	gtk_widget_class_bind_template_child(widget_class, PidginConversation,
	                                     member_list_filter);
	gtk_widget_class_bind_template_child(widget_class, PidginConversation,
	                                     member_list_sorter);
	gtk_widget_class_bind_template_child(widget_class, PidginConversation,
	                                     input);
	gtk_widget_class_bind_template_child(widget_class, PidginConversation,
	                                     input_buffer);
	gtk_widget_class_bind_template_child(widget_class, PidginConversation,
	                                     typing_label);
	gtk_widget_class_bind_template_child(widget_class, PidginConversation,
	                                     status_label);

	gtk_widget_class_bind_template_callback(widget_class,
	                                        pidgin_conversation_escape_topic);
	gtk_widget_class_bind_template_callback(widget_class,
	                                        pidgin_conversation_members_search_changed_cb);
	gtk_widget_class_bind_template_callback(widget_class,
	                                        pidgin_conversation_conversation_members_items_changed);
	gtk_widget_class_bind_template_callback(widget_class,
	                                        pidgin_conversation_get_status_label);
	gtk_widget_class_bind_template_callback(widget_class,
	                                        pidgin_conversation_get_status_page);
	gtk_widget_class_bind_template_callback(widget_class,
	                                        pidgin_conversation_input_insert_text_cb);
	gtk_widget_class_bind_template_callback(widget_class,
	                                        pidgin_conversation_input_delete_range_cb);
	gtk_widget_class_bind_template_callback(widget_class,
	                                        pidgin_conversation_input_key_pressed_cb);
	gtk_widget_class_bind_template_callback(widget_class,
	                                        pidgin_conversation_message_setup);
	gtk_widget_class_bind_template_callback(widget_class,
	                                        pidgin_conversation_message_bind);
	gtk_widget_class_bind_template_callback(widget_class,
	                                        pidgin_conversation_message_unbind);
}

/******************************************************************************
 * API
 *****************************************************************************/
GtkWidget *
pidgin_conversation_new(PurpleConversation *conversation) {
	g_return_val_if_fail(PURPLE_IS_CONVERSATION(conversation), NULL);

	return g_object_new(
		PIDGIN_TYPE_CONVERSATION,
		"conversation", conversation,
		NULL);
}

GtkWidget *
pidgin_conversation_from_purple_conversation(PurpleConversation *conversation)
{
	g_return_val_if_fail(PURPLE_IS_CONVERSATION(conversation), NULL);

	return g_object_get_data(G_OBJECT(conversation), PIDGIN_CONVERSATION_DATA);
}

PurpleConversation *
pidgin_conversation_get_conversation(PidginConversation *conversation) {
	g_return_val_if_fail(PIDGIN_IS_CONVERSATION(conversation), NULL);

	return conversation->conversation;
}

void
pidgin_conversation_close(PidginConversation *conversation) {
	g_return_if_fail(PIDGIN_IS_CONVERSATION(conversation));

	pidgin_conversation_detach(conversation);
}

mercurial