From b154ef0e927502e6afc147456c21fcbceb128b27 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Wed, 24 Jun 2026 12:27:50 +0000 Subject: [PATCH 01/25] feat(muc): Track MAM support for MUCs Track whether a MUC supports MAM by resolving room features during discovery. Signed-off-by: Michael Vetter --- src/xmpp/muc.c | 17 +++++++++++++++++ src/xmpp/muc.h | 1 + 2 files changed, 18 insertions(+) diff --git a/src/xmpp/muc.c b/src/xmpp/muc.c index c5fedf696..5dcdfdbc2 100644 --- a/src/xmpp/muc.c +++ b/src/xmpp/muc.c @@ -50,6 +50,7 @@ typedef struct _muc_room_t muc_anonymity_type_t anonymity_type; gint64 last_activity; gint64 ping_sent_time; + gboolean supports_mam; } ChatRoom; GHashTable* rooms = NULL; @@ -315,6 +316,22 @@ muc_set_features(const char* const room, GSList* features) } else { chat_room->anonymity_type = MUC_ANONYMITY_TYPE_UNKNOWN; } + if (g_slist_find_custom(features, "urn:xmpp:mam:2", (GCompareFunc)g_strcmp0)) { + chat_room->supports_mam = TRUE; + } else { + chat_room->supports_mam = FALSE; + } + } +} + +gboolean +muc_supports_mam(const char* const room) +{ + ChatRoom* chat_room = g_hash_table_lookup(rooms, room); + if (chat_room) { + return chat_room->supports_mam; + } else { + return FALSE; } } diff --git a/src/xmpp/muc.h b/src/xmpp/muc.h index 8885d06f6..2debed869 100644 --- a/src/xmpp/muc.h +++ b/src/xmpp/muc.h @@ -71,6 +71,7 @@ gboolean muc_autojoin(const char* const room); GList* muc_rooms(void); void muc_set_features(const char* const room, GSList* features); +gboolean muc_supports_mam(const char* const room); const char* muc_nick(const char* const room); char* muc_password(const char* const room); From 896bd358eb87faf6e2fae7f5528bcfa50c3f183a Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Wed, 24 Jun 2026 12:27:54 +0000 Subject: [PATCH 02/25] feat(mam): implement room-targeted MAM stanza creation Implement stanza_create_muc_mam_iq() to generate MUC targeted MAM queries. This sets the 'to' attribute to the rooms JID and omits the personal 'with' field filter. Signed-off-by: Michael Vetter --- src/xmpp/stanza.c | 76 +++++++++++++++++++++++++++++++++++++++++++++++ src/xmpp/stanza.h | 1 + 2 files changed, 77 insertions(+) diff --git a/src/xmpp/stanza.c b/src/xmpp/stanza.c index 8876662e6..8b3666514 100644 --- a/src/xmpp/stanza.c +++ b/src/xmpp/stanza.c @@ -2782,6 +2782,82 @@ stanza_create_mam_iq(xmpp_ctx_t* ctx, const char* const jid, const char* const s return iq; } +xmpp_stanza_t* +stanza_create_muc_mam_iq(xmpp_ctx_t* ctx, const char* const room, const char* const startdate, const char* const enddate, const char* const firstid, const char* const lastid) +{ + auto_char char* id = connection_create_stanza_id(); + xmpp_stanza_t* iq = xmpp_iq_new(ctx, STANZA_TYPE_SET, id); + xmpp_stanza_set_to(iq, room); + + xmpp_stanza_t* query = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(query, STANZA_NAME_QUERY); + xmpp_stanza_set_ns(query, STANZA_NS_MAM2); + + xmpp_stanza_t* x = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(x, STANZA_NAME_X); + xmpp_stanza_set_ns(x, STANZA_NS_DATA); + xmpp_stanza_set_type(x, "submit"); + + // field FORM_TYPE MAM2 + xmpp_stanza_t* field_form_type = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(field_form_type, STANZA_NAME_FIELD); + xmpp_stanza_set_attribute(field_form_type, STANZA_ATTR_VAR, "FORM_TYPE"); + xmpp_stanza_set_type(field_form_type, "hidden"); + + xmpp_stanza_t* value_mam = _text_stanza(ctx, STANZA_NAME_VALUE, STANZA_NS_MAM2); + xmpp_stanza_add_child_ex(field_form_type, value_mam, 0); + + // 4.3.2 set/rsm + xmpp_stanza_t* set = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(set, STANZA_TYPE_SET); + xmpp_stanza_set_ns(set, STANZA_NS_RSM); + + xmpp_stanza_t* max = _text_stanza(ctx, STANZA_NAME_MAX, PROF_STRINGIFY(MESSAGES_TO_RETRIEVE)); + xmpp_stanza_add_child_ex(set, max, 0); + + if (lastid) { + xmpp_stanza_t* after = _text_stanza(ctx, STANZA_NAME_AFTER, lastid); + xmpp_stanza_add_child_ex(set, after, 0); + } + + if (firstid) { + xmpp_stanza_t* before = _text_stanza(ctx, STANZA_NAME_BEFORE, firstid); + xmpp_stanza_add_child_ex(set, before, 0); + } + + // add and release + xmpp_stanza_add_child_ex(iq, query, 0); + xmpp_stanza_add_child_ex(query, x, 0); + xmpp_stanza_add_child_ex(x, field_form_type, 0); + + // field 'start' + if (startdate) { + xmpp_stanza_t* field_start = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(field_start, STANZA_NAME_FIELD); + xmpp_stanza_set_attribute(field_start, STANZA_ATTR_VAR, "start"); + + xmpp_stanza_t* value_start = _text_stanza(ctx, STANZA_NAME_VALUE, startdate); + + xmpp_stanza_add_child_ex(field_start, value_start, 0); + xmpp_stanza_add_child_ex(x, field_start, 0); + } + + if (enddate) { + xmpp_stanza_t* field_end = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(field_end, STANZA_NAME_FIELD); + xmpp_stanza_set_attribute(field_end, STANZA_ATTR_VAR, "end"); + + xmpp_stanza_t* value_end = _text_stanza(ctx, STANZA_NAME_VALUE, enddate); + + xmpp_stanza_add_child_ex(field_end, value_end, 0); + xmpp_stanza_add_child_ex(x, field_end, 0); + } + + xmpp_stanza_add_child_ex(query, set, 0); + + return iq; +} + xmpp_stanza_t* stanza_change_password(xmpp_ctx_t* ctx, const char* const user, const char* const password) { diff --git a/src/xmpp/stanza.h b/src/xmpp/stanza.h index e50b5c78f..592d00b16 100644 --- a/src/xmpp/stanza.h +++ b/src/xmpp/stanza.h @@ -397,6 +397,7 @@ xmpp_stanza_t* stanza_create_avatar_metadata_publish_iq(xmpp_ctx_t* ctx, const c xmpp_stanza_t* stanza_disable_avatar_publish_iq(xmpp_ctx_t* ctx); xmpp_stanza_t* stanza_create_vcard_request_iq(xmpp_ctx_t* ctx, const char* const jid, const char* const stanza_id); xmpp_stanza_t* stanza_create_mam_iq(xmpp_ctx_t* ctx, const char* const jid, const char* const startdate, const char* const enddate, const char* const firstid, const char* const lastid); +xmpp_stanza_t* stanza_create_muc_mam_iq(xmpp_ctx_t* ctx, const char* const room, const char* const startdate, const char* const enddate, const char* const firstid, const char* const lastid); xmpp_stanza_t* stanza_change_password(xmpp_ctx_t* ctx, const char* const user, const char* const password); xmpp_stanza_t* stanza_register_new_account(xmpp_ctx_t* ctx, const char* const user, const char* const password); xmpp_stanza_t* stanza_request_voice(xmpp_ctx_t* ctx, const char* const room); From 3bcc265d55f452daf357216b2b57db33847f96bc Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Wed, 24 Jun 2026 12:27:58 +0000 Subject: [PATCH 03/25] feat(mam): Generalize mam and rsm functions to work on more window types Adapt iq_mam_request and iq_mam_request_older to accept ProfWin* and support WIN_MUC windows. Update _mam_buffer_commit_handler to invoke the proper database history loader when page sync is complete. Signed-off-by: Michael Vetter --- src/xmpp/iq.c | 54 +++++++++++++++++++++++--------- src/xmpp/xmpp.h | 4 +-- tests/unittests/xmpp/stub_xmpp.c | 2 +- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/src/xmpp/iq.c b/src/xmpp/iq.c index dc13af3e7..cbbebb795 100644 --- a/src/xmpp/iq.c +++ b/src/xmpp/iq.c @@ -80,7 +80,7 @@ typedef struct mam_rsm_userdata char* start_datestr; char* end_datestr; gboolean fetch_next; - ProfChatWin* win; + ProfWin* win; } MamRsmUserdata; typedef struct late_delivery_userdata @@ -127,7 +127,7 @@ static int _command_exec_response_handler(xmpp_stanza_t* const stanza, void* con static int _mam_rsm_id_handler(xmpp_stanza_t* const stanza, void* const userdata); static int _register_change_password_result_id_handler(xmpp_stanza_t* const stanza, void* const userdata); -static void _iq_mam_request(ProfChatWin* win, GDateTime* startdate, GDateTime* enddate); +static void _iq_mam_request(ProfWin* win, GDateTime* startdate, GDateTime* enddate); static void _iq_free_room_data(ProfRoomInfoData* roominfo); static void _iq_free_affiliation_set(ProfPrivilegeSet* affiliation_set); static void _iq_free_affiliation_list(ProfAffiliationList* affiliation_list); @@ -2341,6 +2341,10 @@ _room_info_response_id_handler(xmpp_stanza_t* const stanza, void* const userdata mucwin->is_omemo = TRUE; } #endif + if (prefs_get_boolean(PREF_MAM) && muc_supports_mam(cb_data->room)) { + win_print_loading_history((ProfWin*)mucwin); + iq_mam_request((ProfWin*)mucwin, NULL); + } if (cb_data->display) { mucwin_room_disco_info(mucwin, identities, features); } @@ -2721,17 +2725,21 @@ _iq_free_affiliation_list(ProfAffiliationList* affiliation_list) static int _mam_buffer_commit_handler(xmpp_stanza_t* const stanza, void* const userdata) { - ProfChatWin* chatwin = (ProfChatWin*)userdata; + ProfWin* window = (ProfWin*)userdata; // Remove the "Loading messages…" message - buffer_remove_entry(((ProfWin*)chatwin)->layout->buffer, 0); - chatwin_db_history(chatwin, NULL, NULL, TRUE); + buffer_remove_entry(window->layout->buffer, 0); + if (window->type == WIN_MUC) { + mucwin_db_history((ProfMucWin*)window, NULL, NULL, TRUE); + } else { + chatwin_db_history((ProfChatWin*)window, NULL, NULL, TRUE); + } return 0; } static const gchar* mam_timestamp_format_string = "%FT%T.%f%:z"; void -iq_mam_request_older(ProfChatWin* win) +iq_mam_request_older(ProfWin* win) { if (connection_supports(XMPP_FEATURE_MAM2) == FALSE) { log_warning("Server doesn't advertise %s feature.", XMPP_FEATURE_MAM2); @@ -2739,7 +2747,9 @@ iq_mam_request_older(ProfChatWin* win) return; } - ProfMessage* first_msg = log_database_get_limits_info(win->barejid, FALSE); + const char* target_jid = (win->type == WIN_MUC) ? ((ProfMucWin*)win)->roomjid : ((ProfChatWin*)win)->barejid; + + ProfMessage* first_msg = log_database_get_limits_info(target_jid, FALSE); char* firstid = NULL; auto_gchar gchar* enddate = NULL; @@ -2755,7 +2765,12 @@ iq_mam_request_older(ProfChatWin* win) } xmpp_ctx_t* const ctx = connection_get_ctx(); - xmpp_stanza_t* iq = stanza_create_mam_iq(ctx, win->barejid, NULL, enddate, firstid, NULL); + xmpp_stanza_t* iq; + if (win->type == WIN_MUC) { + iq = stanza_create_muc_mam_iq(ctx, target_jid, NULL, enddate, firstid, NULL); + } else { + iq = stanza_create_mam_iq(ctx, target_jid, NULL, enddate, firstid, NULL); + } iq_id_handler_add(xmpp_stanza_get_id(iq), _mam_buffer_commit_handler, NULL, win); message_free(first_msg); @@ -2779,7 +2794,7 @@ _mam_userdata_free(MamRsmUserdata* data) } void -_iq_mam_request(ProfChatWin* win, GDateTime* startdate, GDateTime* enddate) +_iq_mam_request(ProfWin* win, GDateTime* startdate, GDateTime* enddate) { if (connection_supports(XMPP_FEATURE_MAM2) == FALSE) { log_warning("Server doesn't advertise %s feature.", XMPP_FEATURE_MAM2); @@ -2791,6 +2806,8 @@ _iq_mam_request(ProfChatWin* win, GDateTime* startdate, GDateTime* enddate) return; } + const char* target_jid = (win->type == WIN_MUC) ? ((ProfMucWin*)win)->roomjid : ((ProfChatWin*)win)->barejid; + char* firstid = ""; char* startdate_str = NULL; char* enddate_str = NULL; @@ -2813,13 +2830,18 @@ _iq_mam_request(ProfChatWin* win, GDateTime* startdate, GDateTime* enddate) xmpp_ctx_t* const ctx = connection_get_ctx(); - xmpp_stanza_t* iq = stanza_create_mam_iq(ctx, win->barejid, startdate_str, enddate_str, firstid, NULL); + xmpp_stanza_t* iq; + if (win->type == WIN_MUC) { + iq = stanza_create_muc_mam_iq(ctx, target_jid, startdate_str, enddate_str, firstid, NULL); + } else { + iq = stanza_create_mam_iq(ctx, target_jid, startdate_str, enddate_str, firstid, NULL); + } MamRsmUserdata* data = g_new0(MamRsmUserdata, 1); if (data) { data->start_datestr = startdate_str; data->end_datestr = enddate_str; - data->barejid = strdup(win->barejid); + data->barejid = strdup(target_jid); data->fetch_next = fetch_next; data->win = win; @@ -2833,9 +2855,11 @@ _iq_mam_request(ProfChatWin* win, GDateTime* startdate, GDateTime* enddate) } void -iq_mam_request(ProfChatWin* win, GDateTime* enddate) +iq_mam_request(ProfWin* win, GDateTime* enddate) { - ProfMessage* last_msg = log_database_get_limits_info(win->barejid, TRUE); + const char* target_jid = (win->type == WIN_MUC) ? ((ProfMucWin*)win)->roomjid : ((ProfChatWin*)win)->barejid; + + ProfMessage* last_msg = log_database_get_limits_info(target_jid, TRUE); GDateTime* startdate = NULL; if (last_msg) { if (last_msg->timestamp) @@ -2846,11 +2870,11 @@ iq_mam_request(ProfChatWin* win, GDateTime* enddate) // Save request for later if disco items haven't been received yet if (!received_disco_items) { LateDeliveryUserdata* cur_del_data = g_new0(LateDeliveryUserdata, 1); - cur_del_data->win = win; + cur_del_data->win = (ProfChatWin*)win; cur_del_data->enddate = enddate; cur_del_data->startdate = startdate; late_delivery_windows = g_slist_append(late_delivery_windows, cur_del_data); - log_debug("Save MAM request of %s for later", win->barejid); + log_debug("Save MAM request of %s for later", target_jid); return; } diff --git a/src/xmpp/xmpp.h b/src/xmpp/xmpp.h index 83d87fb66..34c4c31ae 100644 --- a/src/xmpp/xmpp.h +++ b/src/xmpp/xmpp.h @@ -253,8 +253,8 @@ void iq_autoping_check(void); void iq_http_upload_request(HTTPUpload* upload); void iq_command_list(const char* const target); void iq_command_exec(const char* const target, const char* const command); -void iq_mam_request(ProfChatWin* win, GDateTime* enddate); -void iq_mam_request_older(ProfChatWin* win); +void iq_mam_request(ProfWin* win, GDateTime* enddate); +void iq_mam_request_older(ProfWin* win); void iq_register_change_password(const char* const user, const char* const password); void iq_muc_register_nick(const char* const roomjid); diff --git a/tests/unittests/xmpp/stub_xmpp.c b/tests/unittests/xmpp/stub_xmpp.c index b519a0059..5233f7923 100644 --- a/tests/unittests/xmpp/stub_xmpp.c +++ b/tests/unittests/xmpp/stub_xmpp.c @@ -447,7 +447,7 @@ iq_muc_register_nick(const char* const roomjid) } void -iq_mam_request(ProfChatWin* win, GDateTime* enddate) +iq_mam_request(ProfWin* win, GDateTime* enddate) { } From 4eb8a91fb45f035ee30ebde2ce65bb5b57da02d6 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Wed, 24 Jun 2026 12:28:02 +0000 Subject: [PATCH 04/25] feat(ui): Load MUC history from database Implement mucwin_db_history to load room logs from the sql db and print them to the rooms buffer. Signed-off-by: Michael Vetter --- src/ui/mucwin.c | 34 ++++++++++++++++++++++++++++++++++ src/ui/ui.h | 1 + 2 files changed, 35 insertions(+) diff --git a/src/ui/mucwin.c b/src/ui/mucwin.c index 6e308a769..9feb4260b 100644 --- a/src/ui/mucwin.c +++ b/src/ui/mucwin.c @@ -988,3 +988,37 @@ mucwin_set_room_name(const gchar* const muc_jid, const gchar* const new_room_nam } return TRUE; } + +gboolean +mucwin_db_history(ProfMucWin* mucwin, const char* start_time, const char* end_time, gboolean flip) +{ + auto_gchar gchar* end_time_ = NULL; + if (!end_time && buffer_size(((ProfWin*)mucwin)->layout->buffer) != 0) { + end_time = end_time_ = g_date_time_format_iso8601(buffer_get_entry(((ProfWin*)mucwin)->layout->buffer, 0)->time); + } + + GSList* history = log_database_get_previous_chat(mucwin->roomjid, start_time, end_time, !flip, flip); + gboolean has_items = g_slist_length(history) != 0; + GSList* curr = history; + + while (curr) { + ProfMessage* msg = curr->data; + char* old_plain = msg->plain; + auto_char char* plugin_msg = plugins_pre_room_message_display(msg->from_jid->barejid, msg->from_jid->resourcepart, msg->plain); + if (plugin_msg) + msg->plain = plugin_msg; + if (flip) { + win_print_old_history((ProfWin*)mucwin, msg); + } else { + win_print_history((ProfWin*)mucwin, msg); + } + plugins_post_room_message_display(msg->from_jid->barejid, msg->from_jid->resourcepart, msg->plain); + msg->plain = old_plain; + curr = g_slist_next(curr); + } + + g_slist_free_full(history, (GDestroyNotify)message_free); + win_redraw((ProfWin*)mucwin); + + return has_items; +} diff --git a/src/ui/ui.h b/src/ui/ui.h index 1332f18db..ebca9b37b 100644 --- a/src/ui/ui.h +++ b/src/ui/ui.h @@ -126,6 +126,7 @@ void chatwin_unset_incoming_char(ProfChatWin* chatwin); void chatwin_set_outgoing_char(ProfChatWin* chatwin, const char* const ch); void chatwin_unset_outgoing_char(ProfChatWin* chatwin); gboolean chatwin_db_history(ProfChatWin* chatwin, const char* start_time, const char* end_time, gboolean flip); +gboolean mucwin_db_history(ProfMucWin* mucwin, const char* start_time, const char* end_time, gboolean flip); // MUC window ProfMucWin* mucwin_new(const char* const barejid); From 9fa062a81df2a1de1ef1d90acd806716b3d599a3 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Wed, 24 Jun 2026 12:28:06 +0000 Subject: [PATCH 05/25] feat(mam): Distinguish between MAM and regular MUC message Distinguish in sv_ev_room_message() between MAM and regular MUC messages. Dont notify, flash, beep in case of MAM. Use proper timestamps. Signed-off-by: Michael Vetter --- src/event/server_events.c | 78 +++++++++++++------------ src/xmpp/message.c | 119 ++++++++++++++++++++++++-------------- 2 files changed, 119 insertions(+), 78 deletions(-) diff --git a/src/event/server_events.c b/src/event/server_events.c index 5c2b69740..79fba953c 100644 --- a/src/event/server_events.c +++ b/src/event/server_events.c @@ -334,56 +334,62 @@ sv_ev_room_message(ProfMessage* message) GList* triggers = prefs_message_get_triggers(message->plain); _clean_incoming_message(message); - mucwin_incoming_msg(mucwin, message, mentions, triggers, TRUE); - g_slist_free(mentions); + if (message->is_mam) { + // For MAM, print to window but disable all notifications, unread count increment etc + // And don't filter reflection of our own sent messages (so they get displayed) + mucwin_incoming_msg(mucwin, message, mentions, triggers, FALSE); + } else { + mucwin_incoming_msg(mucwin, message, mentions, triggers, TRUE); - ProfWin* window = (ProfWin*)mucwin; - int num = wins_get_num(window); - gboolean is_current = FALSE; + ProfWin* window = (ProfWin*)mucwin; + int num = wins_get_num(window); + gboolean is_current = FALSE; - // currently in groupchat window - if (wins_is_current(window)) { - is_current = TRUE; - status_bar_active(num, WIN_MUC, mucwin->roomjid); + // currently in groupchat window + if (wins_is_current(window)) { + is_current = TRUE; + status_bar_active(num, WIN_MUC, mucwin->roomjid); - if ((g_strcmp0(mynick, message->from_jid->resourcepart) != 0) && (prefs_get_boolean(PREF_BEEP))) { - ui_beep(); - } + if ((g_strcmp0(mynick, message->from_jid->resourcepart) != 0) && (prefs_get_boolean(PREF_BEEP))) { + ui_beep(); + } - // not currently on groupchat window - } else { - status_bar_new(num, WIN_MUC, mucwin->roomjid); + // not currently on groupchat window + } else { + status_bar_new(num, WIN_MUC, mucwin->roomjid); - if ((g_strcmp0(mynick, message->from_jid->resourcepart) != 0) && (prefs_get_boolean(PREF_FLASH))) { - ui_flash(); - } + if ((g_strcmp0(mynick, message->from_jid->resourcepart) != 0) && (prefs_get_boolean(PREF_FLASH))) { + ui_flash(); + } - cons_show_incoming_room_message(message->from_jid->resourcepart, mucwin->roomjid, num, mention, triggers, mucwin->unread, window); + cons_show_incoming_room_message(message->from_jid->resourcepart, mucwin->roomjid, num, mention, triggers, mucwin->unread, window); - mucwin->unread++; + mucwin->unread++; - if (mention) { - mucwin->unread_mentions = TRUE; - } - if (triggers) { - mucwin->unread_triggers = TRUE; + if (mention) { + mucwin->unread_mentions = TRUE; + } + if (triggers) { + mucwin->unread_triggers = TRUE; + } } - } - // save timestamp of last received muc message - if (mucwin->last_msg_timestamp) { - g_date_time_unref(mucwin->last_msg_timestamp); - } - mucwin->last_msg_timestamp = g_date_time_new_now_local(); + // save timestamp of last received muc message + if (mucwin->last_msg_timestamp) { + g_date_time_unref(mucwin->last_msg_timestamp); + } + mucwin->last_msg_timestamp = g_date_time_new_now_local(); - if (prefs_do_room_notify(is_current, mucwin->roomjid, mynick, message->from_jid->resourcepart, message->plain, mention, triggers != NULL)) { - auto_jid Jid* jidp = jid_create(mucwin->roomjid); - if (jidp) { - notify_room_message(message->from_jid->resourcepart, jidp->localpart, num, message->plain); + if (prefs_do_room_notify(is_current, mucwin->roomjid, mynick, message->from_jid->resourcepart, message->plain, mention, triggers != NULL)) { + auto_jid Jid* jidp = jid_create(mucwin->roomjid); + if (jidp) { + notify_room_message(message->from_jid->resourcepart, jidp->localpart, num, message->plain); + } } } + g_slist_free(mentions); if (triggers) { g_list_free_full(triggers, free); } @@ -622,7 +628,7 @@ sv_ev_incoming_message(ProfMessage* message) if (prefs_get_boolean(PREF_MAM)) { win_print_loading_history(window); - iq_mam_request(chatwin, g_date_time_ref(message->timestamp)); // copy timestamp + iq_mam_request((ProfWin*)chatwin, g_date_time_ref(message->timestamp)); // copy timestamp } #ifdef HAVE_OMEMO diff --git a/src/xmpp/message.c b/src/xmpp/message.c index 232457905..66caf66e0 100644 --- a/src/xmpp/message.c +++ b/src/xmpp/message.c @@ -50,7 +50,7 @@ typedef struct p_message_handle_t static int _message_handler(xmpp_conn_t* const conn, xmpp_stanza_t* const stanza, void* const userdata); static void _handle_error(xmpp_stanza_t* const stanza); -static void _handle_groupchat(xmpp_stanza_t* const stanza); +static void _handle_groupchat(xmpp_stanza_t* const stanza, gboolean is_mam, const char* result_id, GDateTime* timestamp); static void _handle_muc_user(xmpp_stanza_t* const stanza); static void _handle_muc_private_message(xmpp_stanza_t* const stanza); static void _handle_conference(xmpp_stanza_t* const stanza); @@ -75,6 +75,18 @@ static GHashTable* pubsub_event_handlers; gchar* get_display_name(const ProfMessage* const message, int* flags) { + if (message->type == PROF_MSG_TYPE_MUC) { + const char* const mynick = muc_nick(message->from_jid->barejid); + gboolean is_me = (g_strcmp0(mynick, message->from_jid->resourcepart) == 0); + if (is_me) { + return prefs_get_string(PREF_OUTGOING_STAMP); + } else { + if (flags) + *flags = NO_ME; + return g_strdup(message->from_jid->resourcepart); + } + } + if (equals_our_barejid(message->from_jid->barejid)) { return prefs_get_string(PREF_OUTGOING_STAMP); } else { @@ -152,7 +164,7 @@ _message_handler(xmpp_conn_t* const conn, xmpp_stanza_t* const stanza, void* con _handle_error(stanza); } else if (type && g_strcmp0(type, STANZA_TYPE_GROUPCHAT) == 0) { // XEP-0045: Multi-User Chat - _handle_groupchat(stanza); + _handle_groupchat(stanza, FALSE, NULL, NULL); } else if (type && g_strcmp0(type, STANZA_TYPE_HEADLINE) == 0) { xmpp_stanza_t* event = xmpp_stanza_get_child_by_ns(stanza, STANZA_NS_PUBSUB_EVENT); @@ -984,7 +996,7 @@ _room_config_handler(xmpp_stanza_t* const stanza, void* const userdata) } static void -_handle_groupchat(xmpp_stanza_t* const stanza) +_handle_groupchat(xmpp_stanza_t* const stanza, gboolean is_mam, const char* result_id, GDateTime* timestamp) { xmpp_ctx_t* ctx = connection_get_ctx(); @@ -1057,6 +1069,7 @@ _handle_groupchat(xmpp_stanza_t* const stanza) } ProfMessage* message = message_init(); + message->is_mam = is_mam; jid_ref(from_jid); message->from_jid = from_jid; message->type = PROF_MSG_TYPE_MUC; @@ -1066,14 +1079,22 @@ _handle_groupchat(xmpp_stanza_t* const stanza) message->id = strdup(id); } - char* stanzaid = NULL; - xmpp_stanza_t* stanzaidst = xmpp_stanza_get_child_by_name_and_ns(stanza, STANZA_NAME_STANZA_ID, STANZA_NS_STABLE_ID); - if (stanzaidst) { - const char* by = xmpp_stanza_get_attribute(stanzaidst, "by"); - if (by && (g_strcmp0(by, from_jid->barejid) == 0)) { - stanzaid = (char*)xmpp_stanza_get_attribute(stanzaidst, STANZA_ATTR_ID); - if (stanzaid) { - message->stanzaid = strdup(stanzaid); + if (is_mam) { + if (result_id) { + message->stanzaid = strdup(result_id); + } else { + log_warning("MAM received with no result id"); + } + } else { + char* stanzaid = NULL; + xmpp_stanza_t* stanzaidst = xmpp_stanza_get_child_by_name_and_ns(stanza, STANZA_NAME_STANZA_ID, STANZA_NS_STABLE_ID); + if (stanzaidst) { + const char* by = xmpp_stanza_get_attribute(stanzaidst, "by"); + if (by && (g_strcmp0(by, from_jid->barejid) == 0)) { + stanzaid = (char*)xmpp_stanza_get_attribute(stanzaidst, STANZA_ATTR_ID); + if (stanzaid) { + message->stanzaid = strdup(stanzaid); + } } } } @@ -1108,42 +1129,51 @@ _handle_groupchat(xmpp_stanza_t* const stanza) message->plain = strdup(message->body); } - // determine if the notifications happened whilst offline (MUC history) - message->timestamp = stanza_get_delay_from(stanza, from_jid->barejid); - if (message->timestamp == NULL) { - // checking the domainpart is a workaround for some prosody versions (gh#1190) - message->timestamp = stanza_get_delay_from(stanza, from_jid->domainpart); - } + if (is_mam) { + if (timestamp) { + message->timestamp = timestamp; + } else { + message->timestamp = g_date_time_new_now_local(); + } + sv_ev_room_message(message); + } else { + // determine if the notifications happened whilst offline (MUC history) + message->timestamp = stanza_get_delay_from(stanza, from_jid->barejid); + if (message->timestamp == NULL) { + // checking the domainpart is a workaround for some prosody versions (gh#1190) + message->timestamp = stanza_get_delay_from(stanza, from_jid->domainpart); + } - bool is_muc_history = FALSE; - if (message->timestamp != NULL) { - is_muc_history = TRUE; - g_date_time_unref(message->timestamp); - message->timestamp = NULL; - } + bool is_muc_history = FALSE; + if (message->timestamp != NULL) { + is_muc_history = TRUE; + g_date_time_unref(message->timestamp); + message->timestamp = NULL; + } - // we want to display the oldest delay - message->timestamp = stanza_get_oldest_delay(stanza); + // we want to display the oldest delay + message->timestamp = stanza_get_oldest_delay(stanza); - // now this has nothing to do with MUC history - // it's just setting the time to the received time so upon displaying we can use this time - // for example in win_println_incoming_muc_msg() - if (!message->timestamp) { - message->timestamp = g_date_time_new_now_local(); - } + // now this has nothing to do with MUC history + // it's just setting the time to the received time so upon displaying we can use this time + // for example in win_println_incoming_muc_msg() + if (!message->timestamp) { + message->timestamp = g_date_time_new_now_local(); + } - if (is_muc_history) { - sv_ev_room_history(message); - } else { - // XEP-0308 states: `corrections must not be allowed (by the receiver) for messages received before the sender joined the room` - xmpp_stanza_t* replace_id_stanza = xmpp_stanza_get_child_by_ns(stanza, STANZA_NS_LAST_MESSAGE_CORRECTION); - if (replace_id_stanza) { - const char* replace_id = xmpp_stanza_get_id(replace_id_stanza); - if (replace_id) { - message->replace_id = strdup(replace_id); + if (is_muc_history) { + sv_ev_room_history(message); + } else { + // XEP-0308 states: `corrections must not be allowed (by the receiver) for messages received before the sender joined the room` + xmpp_stanza_t* replace_id_stanza = xmpp_stanza_get_child_by_ns(stanza, STANZA_NS_LAST_MESSAGE_CORRECTION); + if (replace_id_stanza) { + const char* replace_id = xmpp_stanza_get_id(replace_id_stanza); + if (replace_id) { + message->replace_id = strdup(replace_id); + } } + sv_ev_room_message(message); } - sv_ev_room_message(message); } out: @@ -1554,7 +1584,12 @@ _handle_mam(xmpp_stanza_t* const stanza) xmpp_stanza_t* message_stanza = xmpp_stanza_get_child_by_ns(forwarded, "jabber:client"); - _handle_chat(message_stanza, TRUE, FALSE, result_id, timestamp); + const char* inner_type = xmpp_stanza_get_type(message_stanza); + if (inner_type && g_strcmp0(inner_type, STANZA_TYPE_GROUPCHAT) == 0) { + _handle_groupchat(message_stanza, TRUE, result_id, timestamp); + } else { + _handle_chat(message_stanza, TRUE, FALSE, result_id, timestamp); + } return TRUE; } From 13ba287a8d4a6c3cc66020e6f6bb77e28843d6f3 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Wed, 24 Jun 2026 12:28:10 +0000 Subject: [PATCH 06/25] feat(mam): trigger MAM queries on joined MUC rooms Trigger iq_mam_request for joined rooms that support MAM. Add groupchat tag for /mam command. Signed-off-by: Michael Vetter --- src/command/cmd_defs.c | 26 +++++++++++++------------- src/ui/chatwin.c | 2 +- src/ui/window.c | 11 +++++++---- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/command/cmd_defs.c b/src/command/cmd_defs.c index 1b65356fe..34e75bb45 100644 --- a/src/command/cmd_defs.c +++ b/src/command/cmd_defs.c @@ -2615,19 +2615,19 @@ static const struct cmd_t command_defs[] = { }, { CMD_PREAMBLE("/mam", - parse_args, 1, 1, &cons_mam_setting) - CMD_MAINFUNC(cmd_mam) - CMD_TAGS( - CMD_TAG_CHAT) - CMD_SYN( - "/mam |") - CMD_DESC( - "Enable/Disable Message Archive Management (XEP-0313) " - "Currently MAM in groupchats (MUCs) is not supported. " - "Use the PG UP key to load more history.") - CMD_ARGS( - { "on|off", "Enable or disable MAM" }) - }, + parse_args, 1, 1, &cons_mam_setting) + CMD_MAINFUNC(cmd_mam) + CMD_TAGS( + CMD_TAG_CHAT, + CMD_TAG_GROUPCHAT) + CMD_SYN( + "/mam |") + CMD_DESC( + "Enable/Disable Message Archive Management (XEP-0313). " + "Use the PG UP key to load more history.") + CMD_ARGS( + { "on|off", "Enable or disable MAM" }) + }, { CMD_PREAMBLE("/changepassword", parse_args, 0, 0, NULL) diff --git a/src/ui/chatwin.c b/src/ui/chatwin.c index 52c2915fc..11fa15310 100644 --- a/src/ui/chatwin.c +++ b/src/ui/chatwin.c @@ -121,7 +121,7 @@ chatwin_new(const char* const barejid) } if (prefs_get_boolean(PREF_MAM)) { - iq_mam_request(chatwin, NULL); + iq_mam_request((ProfWin*)chatwin, NULL); win_print_loading_history(window); } diff --git a/src/ui/window.c b/src/ui/window.c index 2ee55176d..c5d319605 100644 --- a/src/ui/window.c +++ b/src/ui/window.c @@ -688,19 +688,22 @@ win_page_up(ProfWin* window, int scroll_size) *scroll_state = (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) ? WIN_SCROLL_INNER : *scroll_state; *page_start -= scroll_size; - if (*page_start == -scroll_size && window->type == WIN_CHAT) { - ProfChatWin* chatwin = (ProfChatWin*)window; + if (*page_start == -scroll_size && (window->type == WIN_CHAT || window->type == WIN_MUC)) { ProfBuffEntry* first_entry = buffer_size(window->layout->buffer) != 0 ? buffer_get_entry(window->layout->buffer, 0) : NULL; // Don't do anything if still fetching mam messages if (first_entry && !(first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, LOADING_MESSAGE) == 0)) { if (*scroll_state != WIN_SCROLL_REACHED_TOP) { - *scroll_state = !chatwin_db_history(chatwin, NULL, NULL, TRUE) ? WIN_SCROLL_REACHED_TOP : WIN_SCROLL_INNER; + if (window->type == WIN_MUC) { + *scroll_state = !mucwin_db_history((ProfMucWin*)window, NULL, NULL, TRUE) ? WIN_SCROLL_REACHED_TOP : WIN_SCROLL_INNER; + } else { + *scroll_state = !chatwin_db_history((ProfChatWin*)window, NULL, NULL, TRUE) ? WIN_SCROLL_REACHED_TOP : WIN_SCROLL_INNER; + } } if (*scroll_state == WIN_SCROLL_REACHED_TOP && prefs_get_boolean(PREF_MAM)) { win_print_loading_history(window); - iq_mam_request_older(chatwin); + iq_mam_request_older(window); } unsigned int buff_size = buffer_size(window->layout->buffer); From a55ca9fa02391c6faf3e457140196c201b6fee4f Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Wed, 24 Jun 2026 12:30:51 +0000 Subject: [PATCH 07/25] fix(mam): fix compiler warnings and add missing casts Signed-off-by: Michael Vetter --- src/ui/mucwin.c | 1 + src/xmpp/iq.c | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/ui/mucwin.c b/src/ui/mucwin.c index 9feb4260b..1a706a095 100644 --- a/src/ui/mucwin.c +++ b/src/ui/mucwin.c @@ -11,6 +11,7 @@ #include "config.h" #include "ui.h" +#include "database.h" #include #include diff --git a/src/xmpp/iq.c b/src/xmpp/iq.c index cbbebb795..58013187b 100644 --- a/src/xmpp/iq.c +++ b/src/xmpp/iq.c @@ -2661,7 +2661,7 @@ _disco_items_result_handler(xmpp_stanza_t* const stanza) while (late_delivery_windows) { LateDeliveryUserdata* del_data = late_delivery_windows->data; - _iq_mam_request(del_data->win, del_data->startdate, del_data->enddate); + _iq_mam_request((ProfWin*)del_data->win, del_data->startdate, del_data->enddate); free(del_data); late_delivery_windows = g_slist_delete_link(late_delivery_windows, late_delivery_windows); @@ -2922,11 +2922,19 @@ _mam_rsm_id_handler(xmpp_stanza_t* const stanza, void* const userdata) } if (is_complete || !data->fetch_next) { - chatwin_db_history(data->win, is_complete ? NULL : start_str, end_str, TRUE); + if (window->type == WIN_MUC) { + mucwin_db_history((ProfMucWin*)window, is_complete ? NULL : start_str, end_str, TRUE); + } else { + chatwin_db_history((ProfChatWin*)window, is_complete ? NULL : start_str, end_str, TRUE); + } return 0; } - chatwin_db_history(data->win, start_str, end_str, TRUE); + if (window->type == WIN_MUC) { + mucwin_db_history((ProfMucWin*)window, start_str, end_str, TRUE); + } else { + chatwin_db_history((ProfChatWin*)window, start_str, end_str, TRUE); + } xmpp_stanza_t* set = xmpp_stanza_get_child_by_name_and_ns(fin, STANZA_TYPE_SET, STANZA_NS_RSM); if (set) { @@ -2944,7 +2952,12 @@ _mam_rsm_id_handler(xmpp_stanza_t* const stanza, void* const userdata) free(data->end_datestr); data->end_datestr = NULL; } - xmpp_stanza_t* iq = stanza_create_mam_iq(ctx, data->barejid, data->start_datestr, NULL, firstid, NULL); + xmpp_stanza_t* iq; + if (window->type == WIN_MUC) { + iq = stanza_create_muc_mam_iq(ctx, data->barejid, data->start_datestr, NULL, firstid, NULL); + } else { + iq = stanza_create_mam_iq(ctx, data->barejid, data->start_datestr, NULL, firstid, NULL); + } MamRsmUserdata* ndata = g_new0(MamRsmUserdata, 1); *ndata = *data; From 04731c3969665f2a61b65236b588a10b0a765258 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Wed, 24 Jun 2026 12:59:36 +0000 Subject: [PATCH 08/25] fix(mam): resolve endless loading and database log pollution Ensure servers receive the correct empty before tag and page backward properly. Signed-off-by: Michael Vetter --- src/database.c | 119 +++++++++++++++++++++++++++++++++++++++++++++++- src/database.h | 2 + src/ui/mucwin.c | 2 +- src/xmpp/iq.c | 16 +++++-- 4 files changed, 133 insertions(+), 6 deletions(-) diff --git a/src/database.c b/src/database.c index c9059f019..2d837d9fd 100644 --- a/src/database.c +++ b/src/database.c @@ -256,7 +256,7 @@ log_database_add_outgoing_muc_pm(const char* const id, const char* const barejid _log_database_add_outgoing("mucpm", id, barejid, message, replace_id, enc); } -// Get info (timestamp and stanza_id) of the first or last message in db +// Get info (timestamp and stanza_id) of the first or last message in db (personal chats) ProfMessage* log_database_get_limits_info(const gchar* const contact_barejid, gboolean is_last) { @@ -267,8 +267,9 @@ log_database_get_limits_info(const gchar* const contact_barejid, gboolean is_las const char* order = is_last ? "DESC" : "ASC"; auto_sqlite char* query = sqlite3_mprintf("SELECT `archive_id`, `timestamp` FROM `ChatLogs` WHERE " + "`type` = 'chat' AND (" "(`from_jid` = %Q AND `to_jid` = %Q) OR " - "(`from_jid` = %Q AND `to_jid` = %Q) " + "(`from_jid` = %Q AND `to_jid` = %Q)) " "ORDER BY `timestamp` %s LIMIT 1;", contact_barejid, myjid->barejid, myjid->barejid, contact_barejid, order); @@ -300,6 +301,51 @@ log_database_get_limits_info(const gchar* const contact_barejid, gboolean is_las return msg; } +// Get info (timestamp and stanza_id) of the first or last message in db (MUCs) +ProfMessage* +log_database_get_limits_info_muc(const gchar* const room_jid, gboolean is_last) +{ + sqlite3_stmt* stmt = NULL; + const Jid* myjid = connection_get_jid(); + if (!myjid->str) + return NULL; + + const char* order = is_last ? "DESC" : "ASC"; + auto_sqlite char* query = sqlite3_mprintf("SELECT `archive_id`, `timestamp` FROM `ChatLogs` WHERE " + "`type` = 'muc' AND (" + "(`from_jid` = %Q AND `to_jid` = %Q) OR " + "(`from_jid` = %Q AND `to_jid` = %Q)) " + "ORDER BY `timestamp` %s LIMIT 1;", + room_jid, myjid->barejid, myjid->barejid, room_jid, order); + + if (!query) { + log_error("Could not allocate memory for SQL query in log_database_get_limits_info_muc()"); + return NULL; + } + + int rc = sqlite3_prepare_v2(g_chatlog_database, query, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + log_error("Unknown SQLite error in log_database_get_limits_info_muc()."); + return NULL; + } + + ProfMessage* msg = message_init(); + + if (sqlite3_step(stmt) == SQLITE_ROW) { + char* archive_id = (char*)sqlite3_column_text(stmt, 0); + char* date = (char*)sqlite3_column_text(stmt, 1); + + msg->stanzaid = _db_strdup(archive_id); + msg->timestamp = g_date_time_new_from_iso8601(date, NULL); + } else { + message_free(msg); + msg = NULL; + } + sqlite3_finalize(stmt); + + return msg; +} + // Query previous chats, constraints start_time and end_time. If end_time is // null the current time is used. from_start gets first few messages if true // otherwise the last ones. Flip flips the order of the results @@ -320,6 +366,7 @@ log_database_get_previous_chat(const gchar* const contact_barejid, const char* s "A.`timestamp`, A.`from_jid`, A.`from_resource`, A.`to_jid`, A.`to_resource`, A.`type`, A.`encryption`, A.`stanza_id` FROM `ChatLogs` AS A " "LEFT JOIN `ChatLogs` AS B ON (A.`replaced_by_db_id` = B.`id` AND A.`from_jid` = B.`from_jid`) " "WHERE (A.`replaces_db_id` IS NULL) " + "AND A.`type` = 'chat' " "AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) " "AND A.`timestamp` < %Q " "AND (%Q IS NULL OR A.`timestamp` > %Q) " @@ -367,6 +414,74 @@ log_database_get_previous_chat(const gchar* const contact_barejid, const char* s return history; } +// Query previous MUCs, constraints start_time and end_time. If end_time is +// null the current time is used. from_start gets first few messages if true +// otherwise the last ones. Flip flips the order of the results +GSList* +log_database_get_previous_muc(const gchar* const room_jid, const char* start_time, const char* end_time, gboolean from_start, gboolean flip) +{ + sqlite3_stmt* stmt = NULL; + const Jid* myjid = connection_get_jid(); + if (!myjid->str) + return NULL; + + // Flip order when querying older pages + gchar* sort1 = from_start ? "ASC" : "DESC"; + gchar* sort2 = !flip ? "ASC" : "DESC"; + auto_gchar gchar* end_date_fmt = end_time ? g_strdup(end_time) : prof_date_time_format_iso8601(NULL); + auto_sqlite gchar* query = sqlite3_mprintf("SELECT * FROM (" + "SELECT COALESCE(B.`message`, A.`message`) AS message, " + "A.`timestamp`, A.`from_jid`, A.`from_resource`, A.`to_jid`, A.`to_resource`, A.`type`, A.`encryption`, A.`stanza_id` FROM `ChatLogs` AS A " + "LEFT JOIN `ChatLogs` AS B ON (A.`replaced_by_db_id` = B.`id` AND A.`from_jid` = B.`from_jid`) " + "WHERE (A.`replaces_db_id` IS NULL) " + "AND A.`type` = 'muc' " + "AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) " + "AND A.`timestamp` < %Q " + "AND (%Q IS NULL OR A.`timestamp` > %Q) " + "ORDER BY A.`timestamp` %s LIMIT %d) " + "ORDER BY `timestamp` %s;", + room_jid, myjid->barejid, myjid->barejid, room_jid, end_date_fmt, start_time, start_time, sort1, MESSAGES_TO_RETRIEVE, sort2); + + if (!query) { + log_error("Could not allocate memory in log_database_get_previous_muc()"); + return NULL; + } + + int rc = sqlite3_prepare_v2(g_chatlog_database, query, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + log_error("SQLite error in log_database_get_previous_muc(): %s", sqlite3_errmsg(g_chatlog_database)); + return NULL; + } + + GSList* history = NULL; + + while (sqlite3_step(stmt) == SQLITE_ROW) { + char* message = (char*)sqlite3_column_text(stmt, 0); + char* date = (char*)sqlite3_column_text(stmt, 1); + char* from_jid = (char*)sqlite3_column_text(stmt, 2); + char* from_resource = (char*)sqlite3_column_text(stmt, 3); + char* to_jid = (char*)sqlite3_column_text(stmt, 4); + char* to_resource = (char*)sqlite3_column_text(stmt, 5); + char* type = (char*)sqlite3_column_text(stmt, 6); + char* encryption = (char*)sqlite3_column_text(stmt, 7); + char* id = (char*)sqlite3_column_text(stmt, 8); + + ProfMessage* msg = message_init(); + msg->id = id ? strdup(id) : NULL; + msg->from_jid = jid_create_from_bare_and_resource(from_jid, from_resource); + msg->to_jid = jid_create_from_bare_and_resource(to_jid, to_resource); + msg->plain = strdup(message ?: ""); + msg->timestamp = g_date_time_new_from_iso8601(date, NULL); + msg->type = _get_message_type_type(type); + msg->enc = _get_message_enc_type(encryption); + + history = g_slist_append(history, msg); + } + sqlite3_finalize(stmt); + + return history; +} + static const char* _get_message_type_str(prof_msg_type_t type) { diff --git a/src/database.h b/src/database.h index dc5312d3f..f442489db 100644 --- a/src/database.h +++ b/src/database.h @@ -22,7 +22,9 @@ void log_database_add_outgoing_chat(const char* const id, const char* const bare void log_database_add_outgoing_muc(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc); void log_database_add_outgoing_muc_pm(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc); GSList* log_database_get_previous_chat(const gchar* const contact_barejid, const char* start_time, const char* end_time, gboolean from_start, gboolean flip); +GSList* log_database_get_previous_muc(const gchar* const room_jid, const char* start_time, const char* end_time, gboolean from_start, gboolean flip); ProfMessage* log_database_get_limits_info(const gchar* const contact_barejid, gboolean is_last); +ProfMessage* log_database_get_limits_info_muc(const gchar* const room_jid, gboolean is_last); void log_database_close(void); #endif // DATABASE_H diff --git a/src/ui/mucwin.c b/src/ui/mucwin.c index 1a706a095..70db1cf92 100644 --- a/src/ui/mucwin.c +++ b/src/ui/mucwin.c @@ -998,7 +998,7 @@ mucwin_db_history(ProfMucWin* mucwin, const char* start_time, const char* end_ti end_time = end_time_ = g_date_time_format_iso8601(buffer_get_entry(((ProfWin*)mucwin)->layout->buffer, 0)->time); } - GSList* history = log_database_get_previous_chat(mucwin->roomjid, start_time, end_time, !flip, flip); + GSList* history = log_database_get_previous_muc(mucwin->roomjid, start_time, end_time, !flip, flip); gboolean has_items = g_slist_length(history) != 0; GSList* curr = history; diff --git a/src/xmpp/iq.c b/src/xmpp/iq.c index 58013187b..37ecceb9d 100644 --- a/src/xmpp/iq.c +++ b/src/xmpp/iq.c @@ -2749,13 +2749,18 @@ iq_mam_request_older(ProfWin* win) const char* target_jid = (win->type == WIN_MUC) ? ((ProfMucWin*)win)->roomjid : ((ProfChatWin*)win)->barejid; - ProfMessage* first_msg = log_database_get_limits_info(target_jid, FALSE); + ProfMessage* first_msg; + if (win->type == WIN_MUC) { + first_msg = log_database_get_limits_info_muc(target_jid, FALSE); + } else { + first_msg = log_database_get_limits_info(target_jid, FALSE); + } char* firstid = NULL; auto_gchar gchar* enddate = NULL; // If first message found if (first_msg && first_msg->timestamp) { - firstid = first_msg->stanzaid; + firstid = first_msg->stanzaid ? first_msg->stanzaid : ""; enddate = g_date_time_format(first_msg->timestamp, mam_timestamp_format_string); } else { if (first_msg) { @@ -2859,7 +2864,12 @@ iq_mam_request(ProfWin* win, GDateTime* enddate) { const char* target_jid = (win->type == WIN_MUC) ? ((ProfMucWin*)win)->roomjid : ((ProfChatWin*)win)->barejid; - ProfMessage* last_msg = log_database_get_limits_info(target_jid, TRUE); + ProfMessage* last_msg; + if (win->type == WIN_MUC) { + last_msg = log_database_get_limits_info_muc(target_jid, TRUE); + } else { + last_msg = log_database_get_limits_info(target_jid, TRUE); + } GDateTime* startdate = NULL; if (last_msg) { if (last_msg->timestamp) From aa9b4232666d5de60421df0dc076ba7633f81c98 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Thu, 25 Jun 2026 06:40:33 +0000 Subject: [PATCH 09/25] feat(mam): suppress duplicate history replay on room join Add max_stanzas_zero parameter to stanza_create_room_join_presence. When enabled, it adds to the MUC join stanza. Query this preference in presence.c and pass it to avoid replaying duplicate history when MAM is active and supported. Signed-off-by: Michael Vetter --- src/xmpp/presence.c | 3 ++- src/xmpp/stanza.c | 13 ++++++++++++- src/xmpp/stanza.h | 3 ++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/xmpp/presence.c b/src/xmpp/presence.c index c62d862ac..b5422050e 100644 --- a/src/xmpp/presence.c +++ b/src/xmpp/presence.c @@ -266,7 +266,8 @@ presence_join_room(const char* const room, const char* const nick, const char* c int pri = accounts_get_priority_for_presence_type(session_get_account_name(), presence_type); xmpp_ctx_t* ctx = connection_get_ctx(); - xmpp_stanza_t* presence = stanza_create_room_join_presence(ctx, jid->fulljid, passwd); + gboolean max_stanzas_zero = prefs_get_boolean(PREF_MAM) && connection_supports(XMPP_FEATURE_MAM2); + xmpp_stanza_t* presence = stanza_create_room_join_presence(ctx, jid->fulljid, passwd, max_stanzas_zero); stanza_attach_show(ctx, presence, show); stanza_attach_status(ctx, presence, status); stanza_attach_priority(ctx, presence, pri); diff --git a/src/xmpp/stanza.c b/src/xmpp/stanza.c index 8b3666514..4c54c2fc4 100644 --- a/src/xmpp/stanza.c +++ b/src/xmpp/stanza.c @@ -509,7 +509,8 @@ stanza_create_mediated_invite(xmpp_ctx_t* ctx, const char* const room, xmpp_stanza_t* stanza_create_room_join_presence(xmpp_ctx_t* const ctx, - const char* const full_room_jid, const char* const passwd) + const char* const full_room_jid, const char* const passwd, + gboolean max_stanzas_zero) { xmpp_stanza_t* presence = xmpp_presence_new(ctx); xmpp_stanza_set_to(presence, full_room_jid); @@ -530,6 +531,16 @@ stanza_create_room_join_presence(xmpp_ctx_t* const ctx, xmpp_stanza_release(pass); } + if (max_stanzas_zero) { + xmpp_stanza_t* history = xmpp_stanza_new(ctx); + if (history) { + xmpp_stanza_set_name(history, "history"); + xmpp_stanza_set_attribute(history, "maxstanzas", "0"); + xmpp_stanza_add_child(x, history); + xmpp_stanza_release(history); + } + } + xmpp_stanza_add_child(presence, x); xmpp_stanza_release(x); diff --git a/src/xmpp/stanza.h b/src/xmpp/stanza.h index 592d00b16..ec54dc903 100644 --- a/src/xmpp/stanza.h +++ b/src/xmpp/stanza.h @@ -281,7 +281,8 @@ xmpp_stanza_t* stanza_attach_origin_id(xmpp_ctx_t* ctx, xmpp_stanza_t* stanza, c xmpp_stanza_t* stanza_attach_correction(xmpp_ctx_t* ctx, xmpp_stanza_t* stanza, const char* const replace_id); xmpp_stanza_t* stanza_create_room_join_presence(xmpp_ctx_t* const ctx, - const char* const full_room_jid, const char* const passwd); + const char* const full_room_jid, const char* const passwd, + gboolean max_stanzas_zero); xmpp_stanza_t* stanza_create_room_newnick_presence(xmpp_ctx_t* ctx, const char* const full_room_jid); From d12255b2ca9d9ce0314b7473aad6c9ce110f8ee0 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Thu, 25 Jun 2026 06:49:07 +0000 Subject: [PATCH 10/25] fix(mam): filter out non-groupchat messages from MUC archives Add a defensive check in _handle_mam to verify if the MAM result is from an active MUC room. If so, discard any forwarded message that is not of type "groupchat" (such as "chat" room PM leaks sent by buggy servers). Signed-off-by: Michael Vetter --- src/xmpp/message.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/xmpp/message.c b/src/xmpp/message.c index 66caf66e0..722bfafdd 100644 --- a/src/xmpp/message.c +++ b/src/xmpp/message.c @@ -1585,6 +1585,15 @@ _handle_mam(xmpp_stanza_t* const stanza) xmpp_stanza_t* message_stanza = xmpp_stanza_get_child_by_ns(forwarded, "jabber:client"); const char* inner_type = xmpp_stanza_get_type(message_stanza); + + if (from && muc_active(from)) { + if (!inner_type || g_strcmp0(inner_type, STANZA_TYPE_GROUPCHAT) != 0) { + log_warning("Ignoring non-groupchat MAM message of type '%s' from MUC archive '%s' to prevent PM leak.", + inner_type ? inner_type : "none", from); + return TRUE; + } + } + if (inner_type && g_strcmp0(inner_type, STANZA_TYPE_GROUPCHAT) == 0) { _handle_groupchat(message_stanza, TRUE, result_id, timestamp); } else { From 35a42f9c95e3ea1b27b34cfa514395403f71ad9e Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Thu, 25 Jun 2026 06:53:06 +0000 Subject: [PATCH 11/25] feat(mam): add end of archive visual indicator Print `End of archive reached` to top of the window buffer if we reached the end of the archive. Signed-off-by: Michael Vetter --- src/ui/window.c | 30 ++++++++++++++++++++++++++++++ src/ui/window.h | 1 + src/xmpp/iq.c | 17 +++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/src/ui/window.c b/src/ui/window.c index c5d319605..a857b675e 100644 --- a/src/ui/window.c +++ b/src/ui/window.c @@ -2098,6 +2098,36 @@ win_print_loading_history(ProfWin* window) win_redraw(window); } +void +win_print_end_of_archive(ProfWin* window) +{ + GDateTime* timestamp; + gboolean is_buffer_empty = buffer_size(window->layout->buffer) == 0; + + if (!is_buffer_empty) { + timestamp = buffer_get_entry(window->layout->buffer, 0)->time; + } else { + timestamp = g_date_time_new_now_local(); + } + + if (!is_buffer_empty) { + ProfBuffEntry* first_entry = buffer_get_entry(window->layout->buffer, 0); + if (first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, "End of archive reached") == 0) { + if (is_buffer_empty) + g_date_time_unref(timestamp); + return; + } + } + + int cur_y = getcury(window->layout->win); + buffer_prepend(window->layout->buffer, "-", 0, timestamp, NO_DATE, THEME_ROOMINFO, NULL, NULL, "End of archive reached", NULL, NULL, cur_y, cur_y + 1); + + if (is_buffer_empty) + g_date_time_unref(timestamp); + + win_redraw(window); +} + gboolean win_has_active_subwin(ProfWin* window) { diff --git a/src/ui/window.h b/src/ui/window.h index 556655118..24a4822d6 100644 --- a/src/ui/window.h +++ b/src/ui/window.h @@ -50,6 +50,7 @@ void win_print_http_transfer(ProfWin* window, const char* const message, char* i void win_newline(ProfWin* window); void win_redraw(ProfWin* window); void win_print_loading_history(ProfWin* window); +void win_print_end_of_archive(ProfWin* window); int win_roster_cols(void); int win_occpuants_cols(void); void win_sub_print(WINDOW* win, char* msg, gboolean newline, gboolean wrap, int indent); diff --git a/src/xmpp/iq.c b/src/xmpp/iq.c index 37ecceb9d..42419e6d1 100644 --- a/src/xmpp/iq.c +++ b/src/xmpp/iq.c @@ -2726,13 +2726,30 @@ static int _mam_buffer_commit_handler(xmpp_stanza_t* const stanza, void* const userdata) { ProfWin* window = (ProfWin*)userdata; + if (wins_get_num(window) == -1) { + log_error("Window %p should not get any events anymore", window); + return 0; + } + // Remove the "Loading messages…" message buffer_remove_entry(window->layout->buffer, 0); + + xmpp_stanza_t* fin = xmpp_stanza_get_child_by_name_and_ns(stanza, STANZA_NAME_FIN, STANZA_NS_MAM2); + gboolean is_complete = FALSE; + if (fin) { + is_complete = g_strcmp0(xmpp_stanza_get_attribute(fin, "complete"), "true") == 0; + } + if (window->type == WIN_MUC) { mucwin_db_history((ProfMucWin*)window, NULL, NULL, TRUE); } else { chatwin_db_history((ProfChatWin*)window, NULL, NULL, TRUE); } + + if (is_complete) { + win_print_end_of_archive(window); + } + return 0; } From f0c56857ca5fbefe9a074d9613d6d661158d445c Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Thu, 25 Jun 2026 07:14:58 +0000 Subject: [PATCH 12/25] feat(mam): Implement ondemand history loading New `/mam verify` checks the integrity of the local history. It takes the dates from the messages on the current screen. Then asks the server how many messages should be between those timestamps. In case the server notifies us about more messages that we have in the DB we will retrieve the messages. It's useful to fill gaps in case the user knows that there is something missing. Signed-off-by: Michael Vetter --- src/command/cmd_defs.c | 8 ++- src/command/cmd_funcs.c | 55 +++++++++++++++++ src/database.c | 51 +++++++++++++++ src/database.h | 2 + src/xmpp/iq.c | 103 +++++++++++++++++++++++++++++++ src/xmpp/stanza.c | 72 +++++++++++++++++++++ src/xmpp/stanza.h | 1 + src/xmpp/xmpp.h | 1 + tests/unittests/ui/stub_ui.c | 12 ++++ tests/unittests/xmpp/stub_xmpp.c | 5 ++ 10 files changed, 307 insertions(+), 3 deletions(-) diff --git a/src/command/cmd_defs.c b/src/command/cmd_defs.c index 34e75bb45..4d021b1b4 100644 --- a/src/command/cmd_defs.c +++ b/src/command/cmd_defs.c @@ -2621,12 +2621,14 @@ static const struct cmd_t command_defs[] = { CMD_TAG_CHAT, CMD_TAG_GROUPCHAT) CMD_SYN( - "/mam |") + "/mam |", + "/mam verify") CMD_DESC( - "Enable/Disable Message Archive Management (XEP-0313). " + "Enable/Disable Message Archive Management (XEP-0313), or verify history integrity. " "Use the PG UP key to load more history.") CMD_ARGS( - { "on|off", "Enable or disable MAM" }) + { "on|off", "Enable or disable MAM" }, + { "verify", "Verify scrollback history integrity and auto-repair any gaps" }) }, { CMD_PREAMBLE("/changepassword", diff --git a/src/command/cmd_funcs.c b/src/command/cmd_funcs.c index 96267ca52..2327a7c85 100644 --- a/src/command/cmd_funcs.c +++ b/src/command/cmd_funcs.c @@ -9648,6 +9648,61 @@ cmd_executable_vcard_photo(ProfWin* window, const char* const command, gchar** a gboolean cmd_mam(ProfWin* window, const char* const command, gchar** args) { + if (g_strcmp0(args[0], "verify") == 0) { + if (connection_get_status() != JABBER_CONNECTED) { + cons_show("You are not currently connected."); + return TRUE; + } + if (window->type != WIN_CHAT && window->type != WIN_MUC) { + cons_show("MAM verification can only be performed in chat or groupchat windows."); + return TRUE; + } + if (!prefs_get_boolean(PREF_MAM)) { + cons_show("Message Archive Management (MAM) is currently disabled."); + return TRUE; + } + if (!connection_supports(XMPP_FEATURE_MAM2)) { + cons_show("Your server does not support Message Archive Management (MAM2)."); + return TRUE; + } + + unsigned int buf_size = buffer_size(window->layout->buffer); + if (buf_size == 0) { + cons_show("No loaded history in this window to verify."); + return TRUE; + } + + ProfBuffEntry* first_msg = NULL; + ProfBuffEntry* last_msg = NULL; + + for (unsigned int i = 0; i < buf_size; i++) { + ProfBuffEntry* entry = buffer_get_entry(window->layout->buffer, i); + if (entry && entry->message && entry->message[0] != '-') { + if (!first_msg) { + first_msg = entry; + } + last_msg = entry; + } + } + + if (!first_msg || !last_msg) { + cons_show("No messages found in the active scrollback buffer."); + return TRUE; + } + + GDateTime* utc_start = g_date_time_to_timezone(first_msg->time, g_time_zone_new_utc()); + GDateTime* utc_end = g_date_time_to_timezone(last_msg->time, g_time_zone_new_utc()); + auto_gchar gchar* start_str = g_date_time_format(utc_start, "%FT%T.%f%:z"); + auto_gchar gchar* end_str = g_date_time_format(utc_end, "%FT%T.%f%:z"); + g_date_time_unref(utc_start); + g_date_time_unref(utc_end); + + cons_show("Verifying history from %s to %s...", start_str, end_str); + iq_mam_verify_request(window, start_str, end_str); + + return TRUE; + } + _cmd_set_boolean_preference(args[0], "Message Archive Management", PREF_MAM); return TRUE; diff --git a/src/database.c b/src/database.c index 2d837d9fd..c449df693 100644 --- a/src/database.c +++ b/src/database.c @@ -482,6 +482,57 @@ log_database_get_previous_muc(const gchar* const room_jid, const char* start_tim return history; } +int +log_database_get_chat_count(const gchar* const contact_barejid, const char* start_time, const char* end_time) +{ + sqlite3_stmt* stmt = NULL; + const Jid* myjid = connection_get_jid(); + if (!myjid->str) + return 0; + + auto_sqlite char* query = sqlite3_mprintf("SELECT COUNT(*) FROM `ChatLogs` WHERE " + "`type` = 'chat' AND (" + "(`from_jid` = %Q AND `to_jid` = %Q) OR " + "(`from_jid` = %Q AND `to_jid` = %Q)) " + "AND `timestamp` >= %Q AND `timestamp` <= %Q;", + contact_barejid, myjid->barejid, myjid->barejid, contact_barejid, start_time, end_time); + + int count = 0; + int rc = sqlite3_prepare_v2(g_chatlog_database, query, -1, &stmt, NULL); + if (rc == SQLITE_OK) { + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + } + sqlite3_finalize(stmt); + return count; +} + +int +log_database_get_muc_count(const gchar* const room_jid, const char* start_time, const char* end_time) +{ + sqlite3_stmt* stmt = NULL; + const Jid* myjid = connection_get_jid(); + if (!myjid->str) + return 0; + + auto_sqlite char* query = sqlite3_mprintf("SELECT COUNT(*) FROM `ChatLogs` WHERE " + "`type` = 'muc' AND " + "(`from_jid` = %Q OR `to_jid` = %Q) " + "AND `timestamp` >= %Q AND `timestamp` <= %Q;", + room_jid, room_jid, start_time, end_time); + + int count = 0; + int rc = sqlite3_prepare_v2(g_chatlog_database, query, -1, &stmt, NULL); + if (rc == SQLITE_OK) { + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + } + sqlite3_finalize(stmt); + return count; +} + static const char* _get_message_type_str(prof_msg_type_t type) { diff --git a/src/database.h b/src/database.h index f442489db..5f45dce88 100644 --- a/src/database.h +++ b/src/database.h @@ -23,6 +23,8 @@ void log_database_add_outgoing_muc(const char* const id, const char* const barej void log_database_add_outgoing_muc_pm(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc); GSList* log_database_get_previous_chat(const gchar* const contact_barejid, const char* start_time, const char* end_time, gboolean from_start, gboolean flip); GSList* log_database_get_previous_muc(const gchar* const room_jid, const char* start_time, const char* end_time, gboolean from_start, gboolean flip); +int log_database_get_chat_count(const gchar* const contact_barejid, const char* start_time, const char* end_time); +int log_database_get_muc_count(const gchar* const room_jid, const char* start_time, const char* end_time); ProfMessage* log_database_get_limits_info(const gchar* const contact_barejid, gboolean is_last); ProfMessage* log_database_get_limits_info_muc(const gchar* const room_jid, gboolean is_last); void log_database_close(void); diff --git a/src/xmpp/iq.c b/src/xmpp/iq.c index 42419e6d1..05a9eb855 100644 --- a/src/xmpp/iq.c +++ b/src/xmpp/iq.c @@ -2815,6 +2815,109 @@ _mam_userdata_free(MamRsmUserdata* data) free(data); } +typedef struct mam_verify_userdata +{ + char* barejid; + char* start_datestr; + char* end_datestr; + gboolean is_muc; + ProfWin* win; +} MamVerifyUserdata; + +static void +_mam_verify_userdata_free(MamVerifyUserdata* data) +{ + free(data->start_datestr); + free(data->end_datestr); + free(data->barejid); + free(data); +} + +static int +_mam_verify_id_handler(xmpp_stanza_t* const stanza, void* const userdata) +{ + MamVerifyUserdata* data = (MamVerifyUserdata*)userdata; + ProfWin* window = data->win; + if (wins_get_num(window) == -1) { + log_error("Window %p is not active anymore", window); + return 0; + } + + const char* type = xmpp_stanza_get_type(stanza); + if (g_strcmp0(type, "error") == 0) { + auto_char char* error_message = stanza_get_error_message(stanza); + cons_show_error("MAM verification failed: %s", error_message); + return 0; + } + + xmpp_stanza_t* fin = xmpp_stanza_get_child_by_name_and_ns(stanza, STANZA_NAME_FIN, STANZA_NS_MAM2); + if (fin) { + xmpp_stanza_t* set = xmpp_stanza_get_child_by_name_and_ns(fin, STANZA_TYPE_SET, STANZA_NS_RSM); + if (set) { + xmpp_stanza_t* count_st = xmpp_stanza_get_child_by_name(set, "count"); + if (count_st) { + char* count_str = xmpp_stanza_get_text(count_st); + int server_count = count_str ? atoi(count_str) : 0; + + int local_count = 0; + if (data->is_muc) { + local_count = log_database_get_muc_count(data->barejid, data->start_datestr, data->end_datestr); + } else { + local_count = log_database_get_chat_count(data->barejid, data->start_datestr, data->end_datestr); + } + + if (local_count >= server_count) { + cons_show("MAM Verification for %s: History is contiguous and complete (verified %d messages).", + data->barejid, local_count); + } else { + int missing = server_count - local_count; + cons_show("MAM Verification for %s: Missing %d messages. Initiating auto-repair...", + data->barejid, missing); + + GDateTime* start = g_date_time_new_from_iso8601(data->start_datestr, NULL); + GDateTime* end = g_date_time_new_from_iso8601(data->end_datestr, NULL); + if (start && end) { + win_print_loading_history(window); + _iq_mam_request(window, start, end); + } else { + if (start) + g_date_time_unref(start); + if (end) + g_date_time_unref(end); + } + } + } + } + } + + return 0; +} + +void +iq_mam_verify_request(ProfWin* win, const char* const startdate, const char* const enddate) +{ + gboolean is_muc = (win->type == WIN_MUC); + char* barejid = is_muc ? strdup(((ProfMucWin*)win)->roomjid) : strdup(((ProfChatWin*)win)->barejid); + + xmpp_ctx_t* const ctx = connection_get_ctx(); + xmpp_stanza_t* iq = stanza_create_mam_count_iq(ctx, barejid, startdate, enddate, is_muc); + + MamVerifyUserdata* data = g_new0(MamVerifyUserdata, 1); + if (data) { + data->barejid = strdup(barejid); + data->start_datestr = strdup(startdate); + data->end_datestr = strdup(enddate); + data->is_muc = is_muc; + data->win = win; + + iq_id_handler_add(xmpp_stanza_get_id(iq), _mam_verify_id_handler, (ProfIqFreeCallback)_mam_verify_userdata_free, data); + } + + iq_send_stanza(iq); + free(barejid); + xmpp_stanza_release(iq); +} + void _iq_mam_request(ProfWin* win, GDateTime* startdate, GDateTime* enddate) { diff --git a/src/xmpp/stanza.c b/src/xmpp/stanza.c index 4c54c2fc4..c14e067dc 100644 --- a/src/xmpp/stanza.c +++ b/src/xmpp/stanza.c @@ -2869,6 +2869,78 @@ stanza_create_muc_mam_iq(xmpp_ctx_t* ctx, const char* const room, const char* co return iq; } +xmpp_stanza_t* +stanza_create_mam_count_iq(xmpp_ctx_t* ctx, const char* const jid, const char* const startdate, const char* const enddate, gboolean is_muc) +{ + auto_char char* id = connection_create_stanza_id(); + xmpp_stanza_t* iq = xmpp_iq_new(ctx, STANZA_TYPE_SET, id); + if (is_muc) { + xmpp_stanza_set_to(iq, jid); + } + + xmpp_stanza_t* query = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(query, STANZA_NAME_QUERY); + xmpp_stanza_set_ns(query, STANZA_NS_MAM2); + + xmpp_stanza_t* x = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(x, STANZA_NAME_X); + xmpp_stanza_set_ns(x, STANZA_NS_DATA); + xmpp_stanza_set_type(x, "submit"); + + // FORM_TYPE + xmpp_stanza_t* field_form_type = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(field_form_type, STANZA_NAME_FIELD); + xmpp_stanza_set_attribute(field_form_type, STANZA_ATTR_VAR, "FORM_TYPE"); + xmpp_stanza_set_type(field_form_type, "hidden"); + + xmpp_stanza_t* value_mam = _text_stanza(ctx, STANZA_NAME_VALUE, STANZA_NS_MAM2); + xmpp_stanza_add_child_ex(field_form_type, value_mam, 0); + xmpp_stanza_add_child_ex(x, field_form_type, 0); + + if (!is_muc) { + xmpp_stanza_t* field_with = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(field_with, STANZA_NAME_FIELD); + xmpp_stanza_set_attribute(field_with, STANZA_ATTR_VAR, "with"); + + xmpp_stanza_t* value_with = _text_stanza(ctx, STANZA_NAME_VALUE, jid); + xmpp_stanza_add_child_ex(field_with, value_with, 0); + xmpp_stanza_add_child_ex(x, field_with, 0); + } + + if (startdate) { + xmpp_stanza_t* field_start = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(field_start, STANZA_NAME_FIELD); + xmpp_stanza_set_attribute(field_start, STANZA_ATTR_VAR, "start"); + + xmpp_stanza_t* value_start = _text_stanza(ctx, STANZA_NAME_VALUE, startdate); + xmpp_stanza_add_child_ex(field_start, value_start, 0); + xmpp_stanza_add_child_ex(x, field_start, 0); + } + + if (enddate) { + xmpp_stanza_t* field_end = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(field_end, STANZA_NAME_FIELD); + xmpp_stanza_set_attribute(field_end, STANZA_ATTR_VAR, "end"); + + xmpp_stanza_t* value_end = _text_stanza(ctx, STANZA_NAME_VALUE, enddate); + xmpp_stanza_add_child_ex(field_end, value_end, 0); + xmpp_stanza_add_child_ex(x, field_end, 0); + } + + xmpp_stanza_t* set = xmpp_stanza_new(ctx); + xmpp_stanza_set_name(set, STANZA_TYPE_SET); + xmpp_stanza_set_ns(set, STANZA_NS_RSM); + + xmpp_stanza_t* max = _text_stanza(ctx, STANZA_NAME_MAX, "0"); + xmpp_stanza_add_child_ex(set, max, 0); + + xmpp_stanza_add_child_ex(query, x, 0); + xmpp_stanza_add_child_ex(query, set, 0); + xmpp_stanza_add_child_ex(iq, query, 0); + + return iq; +} + xmpp_stanza_t* stanza_change_password(xmpp_ctx_t* ctx, const char* const user, const char* const password) { diff --git a/src/xmpp/stanza.h b/src/xmpp/stanza.h index ec54dc903..466f61c8a 100644 --- a/src/xmpp/stanza.h +++ b/src/xmpp/stanza.h @@ -399,6 +399,7 @@ xmpp_stanza_t* stanza_disable_avatar_publish_iq(xmpp_ctx_t* ctx); xmpp_stanza_t* stanza_create_vcard_request_iq(xmpp_ctx_t* ctx, const char* const jid, const char* const stanza_id); xmpp_stanza_t* stanza_create_mam_iq(xmpp_ctx_t* ctx, const char* const jid, const char* const startdate, const char* const enddate, const char* const firstid, const char* const lastid); xmpp_stanza_t* stanza_create_muc_mam_iq(xmpp_ctx_t* ctx, const char* const room, const char* const startdate, const char* const enddate, const char* const firstid, const char* const lastid); +xmpp_stanza_t* stanza_create_mam_count_iq(xmpp_ctx_t* ctx, const char* const jid, const char* const startdate, const char* const enddate, gboolean is_muc); xmpp_stanza_t* stanza_change_password(xmpp_ctx_t* ctx, const char* const user, const char* const password); xmpp_stanza_t* stanza_register_new_account(xmpp_ctx_t* ctx, const char* const user, const char* const password); xmpp_stanza_t* stanza_request_voice(xmpp_ctx_t* ctx, const char* const room); diff --git a/src/xmpp/xmpp.h b/src/xmpp/xmpp.h index 34c4c31ae..c597dbef3 100644 --- a/src/xmpp/xmpp.h +++ b/src/xmpp/xmpp.h @@ -255,6 +255,7 @@ void iq_command_list(const char* const target); void iq_command_exec(const char* const target, const char* const command); void iq_mam_request(ProfWin* win, GDateTime* enddate); void iq_mam_request_older(ProfWin* win); +void iq_mam_verify_request(ProfWin* win, const char* const startdate, const char* const enddate); void iq_register_change_password(const char* const user, const char* const password); void iq_muc_register_nick(const char* const roomjid); diff --git a/tests/unittests/ui/stub_ui.c b/tests/unittests/ui/stub_ui.c index a9d76fa51..074dc2e59 100644 --- a/tests/unittests/ui/stub_ui.c +++ b/tests/unittests/ui/stub_ui.c @@ -1479,3 +1479,15 @@ void ui_flash(void) { } + +unsigned int +buffer_size(ProfBuff buffer) +{ + return 0; +} + +ProfBuffEntry* +buffer_get_entry(ProfBuff buffer, unsigned int index) +{ + return NULL; +} diff --git a/tests/unittests/xmpp/stub_xmpp.c b/tests/unittests/xmpp/stub_xmpp.c index 5233f7923..6ef7fd457 100644 --- a/tests/unittests/xmpp/stub_xmpp.c +++ b/tests/unittests/xmpp/stub_xmpp.c @@ -451,6 +451,11 @@ iq_mam_request(ProfWin* win, GDateTime* enddate) { } +void +iq_mam_verify_request(ProfWin* win, const char* const startdate, const char* const enddate) +{ +} + void iq_feature_retrieval_complete_handler(void) { From dbbed80917c4601377d53283b723b8be1f6b44b2 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Thu, 25 Jun 2026 07:37:55 +0000 Subject: [PATCH 13/25] feat(ui): Display correct timestamps on historical mentions and triggers Use the messages original timestamp in mention and trigger related code. Signed-off-by: Michael Vetter --- src/ui/mucwin.c | 36 ++++++++++++++++++------------------ src/ui/ui.h | 4 ++-- src/ui/window.c | 30 +++++++++++++++++++++--------- src/ui/window.h | 2 +- 4 files changed, 42 insertions(+), 30 deletions(-) diff --git a/src/ui/mucwin.c b/src/ui/mucwin.c index 70db1cf92..f943d33b6 100644 --- a/src/ui/mucwin.c +++ b/src/ui/mucwin.c @@ -356,7 +356,7 @@ mucwin_history(ProfMucWin* mucwin, const ProfMessage* const message) } static void -_mucwin_print_mention(ProfWin* window, const char* const message, const char* const from, const char* const mynick, GSList* mentions, const char* const ch, int flags) +_mucwin_print_mention(ProfWin* window, const char* const message, const char* const from, const char* const mynick, GSList* mentions, const char* const ch, int flags, GDateTime* timestamp) { int last_pos = 0; int pos; @@ -369,19 +369,19 @@ _mucwin_print_mention(ProfWin* window, const char* const message, const char* co auto_gchar gchar* before_str = g_utf8_substring(message, last_pos, pos); if (last_pos == 0 && strncmp(before_str, "/me ", 4) == 0) { - win_print_them(window, THEME_ROOMMENTION, ch, flags, ""); - win_append_highlight(window, THEME_ROOMMENTION, "*%s ", from); - win_append_highlight(window, THEME_ROOMMENTION, "%s", before_str + 4); + win_print_them(window, THEME_ROOMMENTION, timestamp, ch, flags, ""); + win_append_highlight(window, THEME_ROOMMENTION, timestamp, "*%s ", from); + win_append_highlight(window, THEME_ROOMMENTION, timestamp, "%s", before_str + 4); } else { // print time and nick only once at beginning of the line if (last_pos == 0) { - win_print_them(window, THEME_ROOMMENTION, ch, flags, from); + win_print_them(window, THEME_ROOMMENTION, timestamp, ch, flags, from); } - win_append_highlight(window, THEME_ROOMMENTION, "%s", before_str); + win_append_highlight(window, THEME_ROOMMENTION, timestamp, "%s", before_str); } auto_gchar gchar* mynick_str = g_utf8_substring(message, pos, pos + mynick_len); - win_append_highlight(window, THEME_ROOMMENTION_TERM, "%s", mynick_str); + win_append_highlight(window, THEME_ROOMMENTION_TERM, timestamp, "%s", mynick_str); last_pos = pos + mynick_len; @@ -392,9 +392,9 @@ _mucwin_print_mention(ProfWin* window, const char* const message, const char* co if (last_pos < message_len) { // get tail without allocating a new string gchar* rest = g_utf8_offset_to_pointer(message, last_pos); - win_appendln_highlight(window, THEME_ROOMMENTION, "%s", rest); + win_appendln_highlight(window, THEME_ROOMMENTION, timestamp, "%s", rest); } else { - win_appendln_highlight(window, THEME_ROOMMENTION, ""); + win_appendln_highlight(window, THEME_ROOMMENTION, timestamp, ""); } } @@ -413,7 +413,7 @@ _cmp_trigger_weight(gconstpointer a, gconstpointer b) } static void -_mucwin_print_triggers(ProfWin* window, const char* const message, GList* triggers) +_mucwin_print_triggers(ProfWin* window, const char* const message, GList* triggers, GDateTime* timestamp) { GList* weighted_triggers = NULL; GList* curr = triggers; @@ -452,7 +452,7 @@ _mucwin_print_triggers(ProfWin* window, const char* const message, GList* trigge // no triggers found if (first_trigger_pos == -1) { - win_appendln_highlight(window, THEME_ROOMTRIGGER, "%s", message); + win_appendln_highlight(window, THEME_ROOMTRIGGER, timestamp, "%s", message); } else { if (first_trigger_pos > 0) { char message_section[strlen(message) + 1]; @@ -462,7 +462,7 @@ _mucwin_print_triggers(ProfWin* window, const char* const message, GList* trigge i++; } message_section[i] = '\0'; - win_append_highlight(window, THEME_ROOMTRIGGER, "%s", message_section); + win_append_highlight(window, THEME_ROOMTRIGGER, timestamp, "%s", message_section); } char trigger_section[first_trigger_len + 1]; int i = 0; @@ -473,10 +473,10 @@ _mucwin_print_triggers(ProfWin* window, const char* const message, GList* trigge trigger_section[i] = '\0'; if (first_trigger_pos + first_trigger_len < (int)strlen(message)) { - win_append_highlight(window, THEME_ROOMTRIGGER_TERM, "%s", trigger_section); - _mucwin_print_triggers(window, &message[first_trigger_pos + first_trigger_len], triggers); + win_append_highlight(window, THEME_ROOMTRIGGER_TERM, timestamp, "%s", trigger_section); + _mucwin_print_triggers(window, &message[first_trigger_pos + first_trigger_len], triggers, timestamp); } else { - win_appendln_highlight(window, THEME_ROOMTRIGGER_TERM, "%s", trigger_section); + win_appendln_highlight(window, THEME_ROOMTRIGGER_TERM, timestamp, "%s", trigger_section); } } } @@ -533,10 +533,10 @@ mucwin_incoming_msg(ProfMucWin* mucwin, const ProfMessage* const message, GSList wins_add_quotes_ac(window, message->plain, FALSE); if (g_slist_length(mentions) > 0) { - _mucwin_print_mention(window, message->plain, message->from_jid->resourcepart, mynick, mentions, ch, flags); + _mucwin_print_mention(window, message->plain, message->from_jid->resourcepart, mynick, mentions, ch, flags, message->timestamp); } else if (triggers) { - win_print_them(window, THEME_ROOMTRIGGER, ch, flags, message->from_jid->resourcepart); - _mucwin_print_triggers(window, message->plain, triggers); + win_print_them(window, THEME_ROOMTRIGGER, message->timestamp, ch, flags, message->from_jid->resourcepart); + _mucwin_print_triggers(window, message->plain, triggers, message->timestamp); } else { win_println_incoming_muc_msg(window, ch, flags, message); } diff --git a/src/ui/ui.h b/src/ui/ui.h index ebca9b37b..0c291f39b 100644 --- a/src/ui/ui.h +++ b/src/ui/ui.h @@ -383,8 +383,8 @@ void win_println_va(ProfWin* window, theme_item_t theme_item, const char* show_c void win_append(ProfWin* window, theme_item_t theme_item, const char* const message, ...); void win_appendln(ProfWin* window, theme_item_t theme_item, const char* const message, ...); -void win_append_highlight(ProfWin* window, theme_item_t theme_item, const char* const message, ...); -void win_appendln_highlight(ProfWin* window, theme_item_t theme_item, const char* const message, ...); +void win_append_highlight(ProfWin* window, theme_item_t theme_item, GDateTime* timestamp, const char* const message, ...); +void win_appendln_highlight(ProfWin* window, theme_item_t theme_item, GDateTime* timestamp, const char* const message, ...); gchar* win_get_title(ProfWin* window); void win_show_occupant(ProfWin* window, Occupant* occupant); diff --git a/src/ui/window.c b/src/ui/window.c index a857b675e..22b0935fe 100644 --- a/src/ui/window.c +++ b/src/ui/window.c @@ -1493,9 +1493,9 @@ win_print_incoming(ProfWin* window, const char* const display_name_from, ProfMes } void -win_print_them(ProfWin* window, theme_item_t theme_item, const char* const show_char, int flags, const char* const them) +win_print_them(ProfWin* window, theme_item_t theme_item, GDateTime* timestamp, const char* const show_char, int flags, const char* const them) { - _win_printf(window, show_char, 0, NULL, flags | NO_ME | NO_EOL, theme_item, them, NULL, NULL, ""); + _win_printf(window, show_char, 0, timestamp, flags | NO_ME | NO_EOL, theme_item, them, NULL, NULL, ""); } void @@ -1576,9 +1576,13 @@ win_print_old_history(ProfWin* window, const ProfMessage* const message) } static void -win_println_va_internal(ProfWin* window, theme_item_t theme_item, int pad, int flags, const char* show_char, const char* const message, va_list arg) +win_println_va_internal_with_timestamp(ProfWin* window, theme_item_t theme_item, int pad, int flags, const char* show_char, GDateTime* timestamp, const char* const message, va_list arg) { - GDateTime* timestamp = g_date_time_new_now_local(); + gboolean created_timestamp = FALSE; + if (!timestamp) { + timestamp = g_date_time_new_now_local(); + created_timestamp = TRUE; + } auto_gchar gchar* msg = g_strdup_vprintf(message, arg); @@ -1587,7 +1591,15 @@ win_println_va_internal(ProfWin* window, theme_item_t theme_item, int pad, int f buffer_append(window->layout->buffer, show_char, pad, timestamp, flags, theme_item, "", NULL, msg, NULL, NULL, y_start_pos, getcury(window->layout->win)); inp_nonblocking(TRUE); - g_date_time_unref(timestamp); + if (created_timestamp) { + g_date_time_unref(timestamp); + } +} + +static void +win_println_va_internal(ProfWin* window, theme_item_t theme_item, int pad, int flags, const char* show_char, const char* const message, va_list arg) +{ + win_println_va_internal_with_timestamp(window, theme_item, pad, flags, show_char, NULL, message, arg); } void @@ -1642,20 +1654,20 @@ win_appendln(ProfWin* window, theme_item_t theme_item, const char* const message } void -win_append_highlight(ProfWin* window, theme_item_t theme_item, const char* const message, ...) +win_append_highlight(ProfWin* window, theme_item_t theme_item, GDateTime* timestamp, const char* const message, ...) { va_list arg; va_start(arg, message); - win_println_va_internal(window, theme_item, 0, NO_DATE | NO_ME | NO_EOL, "-", message, arg); + win_println_va_internal_with_timestamp(window, theme_item, 0, NO_DATE | NO_ME | NO_EOL, "-", timestamp, message, arg); va_end(arg); } void -win_appendln_highlight(ProfWin* window, theme_item_t theme_item, const char* const message, ...) +win_appendln_highlight(ProfWin* window, theme_item_t theme_item, GDateTime* timestamp, const char* const message, ...) { va_list arg; va_start(arg, message); - win_println_va_internal(window, theme_item, 0, NO_DATE | NO_ME, "-", message, arg); + win_println_va_internal_with_timestamp(window, theme_item, 0, NO_DATE | NO_ME, "-", timestamp, message, arg); va_end(arg); } diff --git a/src/ui/window.h b/src/ui/window.h index 24a4822d6..3d7da5cc0 100644 --- a/src/ui/window.h +++ b/src/ui/window.h @@ -36,7 +36,7 @@ void win_show_status_string(ProfWin* window, const char* const from, GDateTime* last_activity, const char* const pre, const char* const default_show); -void win_print_them(ProfWin* window, theme_item_t theme_item, const char* const show_char, int flags, const char* const them); +void win_print_them(ProfWin* window, theme_item_t theme_item, GDateTime* timestamp, const char* const show_char, int flags, const char* const them); void win_print_incoming(ProfWin* window, const char* const from, ProfMessage* message); void win_print_outgoing(ProfWin* window, const char* show_char, const char* const id, const char* const replace_id, const char* const message); void win_print_outgoing_with_receipt(ProfWin* window, const char* show_char, const char* const from, const char* const message, const char* id, const char* const replace_id); From 5181093c3dc51dbc94adc3655b462507e8a33f42 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Thu, 25 Jun 2026 07:42:44 +0000 Subject: [PATCH 14/25] feat(mam): Trigger automatic MAM catchup sync on reconnection When connection is re-established, trigger iq_mam_request for all active chat and groupchat windows. This automatically queries the database for the latest recorded timestamp in each window and performs a targeted MAM query to synchronize and catch up on any missed offline messages. Signed-off-by: Michael Vetter --- src/ui/window_list.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ui/window_list.c b/src/ui/window_list.c index 4f73bf9b6..0fce68004 100644 --- a/src/ui/window_list.c +++ b/src/ui/window_list.c @@ -847,6 +847,10 @@ wins_reestablished_connection(void) } #endif + if (prefs_get_boolean(PREF_MAM) && (window->type == WIN_CHAT || window->type == WIN_MUC)) { + iq_mam_request(window, NULL); + } + // if current win, set current_win_dirty if (wins_is_current(window)) { win_update_virtual(window); From 56a0f95e0cb4be8394b38b736879c23e80d125f2 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Thu, 25 Jun 2026 16:44:21 +0200 Subject: [PATCH 15/25] feat: Add `/mam verify` autocompleter Signed-off-by: Michael Vetter --- src/command/cmd_ac.c | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/command/cmd_ac.c b/src/command/cmd_ac.c index f6ad8caa1..c2fa6c448 100644 --- a/src/command/cmd_ac.c +++ b/src/command/cmd_ac.c @@ -112,6 +112,7 @@ static char* _strophe_autocomplete(ProfWin* window, const char* const input, gbo static char* _stamp_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _adhoc_cmd_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _vcard_autocomplete(ProfWin* window, const char* const input, gboolean previous); +static char* _mam_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _script_autocomplete_func(const char* const prefix, gboolean previous, void* context); @@ -276,6 +277,7 @@ static Autocomplete vcard_name_ac; static Autocomplete vcard_set_param_ac; static Autocomplete vcard_togglable_param_ac; static Autocomplete vcard_address_type_ac; +static Autocomplete mam_ac; static char* last_filepath_input = NULL; @@ -434,6 +436,7 @@ static Autocomplete* all_acs[] = { &vcard_set_param_ac, &vcard_togglable_param_ac, &vcard_address_type_ac, + &mam_ac, }; static GHashTable* ac_funcs = NULL; @@ -1345,6 +1348,10 @@ cmd_ac_init(void) autocomplete_add(vcard_address_type_ac, "domestic"); autocomplete_add(vcard_address_type_ac, "international"); + autocomplete_add(mam_ac, "on"); + autocomplete_add(mam_ac, "off"); + autocomplete_add(mam_ac, "verify"); + if (ac_funcs != NULL) g_hash_table_destroy(ac_funcs); ac_funcs = g_hash_table_new(g_str_hash, g_str_equal); @@ -1376,6 +1383,7 @@ cmd_ac_init(void) g_hash_table_insert(ac_funcs, "/lastactivity", _lastactivity_autocomplete); g_hash_table_insert(ac_funcs, "/log", _log_autocomplete); g_hash_table_insert(ac_funcs, "/logging", _logging_autocomplete); + g_hash_table_insert(ac_funcs, "/mam", _mam_autocomplete); g_hash_table_insert(ac_funcs, "/privacy", _privacy_autocomplete); g_hash_table_insert(ac_funcs, "/mood", _mood_autocomplete); g_hash_table_insert(ac_funcs, "/notify", _notify_autocomplete); @@ -1771,6 +1779,16 @@ _spellcheck_autocomplete(ProfWin* window, const char* const input, gboolean prev return result; } +static char* +_mam_autocomplete(ProfWin* window, const char* const input, gboolean previous) +{ + char* result = NULL; + + result = autocomplete_param_with_ac(input, "/mam", mam_ac, TRUE, previous); + + return result; +} + static char* _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previous) { @@ -1781,7 +1799,7 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ // autocomplete boolean settings gchar* boolean_choices[] = { "/beep", "/states", "/outtype", "/flash", "/splash", "/history", "/vercheck", "/privileges", "/wrap", - "/carbons", "/slashguard", "/mam", "/silence" }; + "/carbons", "/slashguard", "/silence" }; for (size_t i = 0; i < ARRAY_SIZE(boolean_choices); i++) { result = autocomplete_param_with_func(input, boolean_choices[i], prefs_autocomplete_boolean_choice, previous, NULL); From b2b85e5e7aae8c9e193729daac5015df3c12fbe8 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Thu, 25 Jun 2026 16:44:40 +0200 Subject: [PATCH 16/25] feat: Update `/mam` command description Signed-off-by: Michael Vetter --- src/command/cmd_defs.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/command/cmd_defs.c b/src/command/cmd_defs.c index 4d021b1b4..9861f94ad 100644 --- a/src/command/cmd_defs.c +++ b/src/command/cmd_defs.c @@ -2624,11 +2624,11 @@ static const struct cmd_t command_defs[] = { "/mam |", "/mam verify") CMD_DESC( - "Enable/Disable Message Archive Management (XEP-0313), or verify history integrity. " + "Enable or disable Message Archive Management (XEP-0313), or verify loaded history. " "Use the PG UP key to load more history.") CMD_ARGS( { "on|off", "Enable or disable MAM" }, - { "verify", "Verify scrollback history integrity and auto-repair any gaps" }) + { "verify", "Verify history between the oldest and newest loaded messages in the current window and fetch any missing ones from the server" }) }, { CMD_PREAMBLE("/changepassword", From f11bcdade28269a313a392f56cb46a541a9460c4 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Fri, 26 Jun 2026 09:46:00 +0200 Subject: [PATCH 17/25] fix(xmpp): accept MAM results without 'by' attribute for active MUCs XEP-0313 Section 4.1.2 states: ``` The element MUST have a 'by' attribute. [...] For archives that are bound to a specific JID (like a MUC room or a pubsub node), the value of this attribute is the JID of the entity that hosts the archive. [...] If the 'by' attribute is not present, or if it does not match the expected JID, the client MUST ignore the result. ``` But some XMPP servers omit this attribute on MUC MAM responses. Previously, when the 'by' attribute was missing, Profanity assumed a user archive and required the sender's JID to match our own bare JID: if (from && !equals_our_barejid(from)) { ... ignore ... } For MUC archives, the sender is the MUCs bare JID, which caused Profanity to log a JID mismatch warning and discard the fetched messages. Update the validation logic to also accept the result when the sender is an active MUC. Signed-off-by: Michael Vetter --- src/xmpp/message.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/xmpp/message.c b/src/xmpp/message.c index 722bfafdd..83465e839 100644 --- a/src/xmpp/message.c +++ b/src/xmpp/message.c @@ -1572,8 +1572,8 @@ _handle_mam(xmpp_stanza_t* const stanza) return TRUE; } } else { - if (from && !equals_our_barejid(from)) { - log_warning("MAM result from %s with no 'by' attribute (expected our own JID)", from); + if (from && !equals_our_barejid(from) && !muc_active(from)) { + log_warning("MAM result from %s with no 'by' attribute (expected our own JID or active MUC JID)", from); return TRUE; } } From 5896d10d6d842d2cccc5f2c17549f71ccd4a3af7 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Fri, 26 Jun 2026 12:15:48 +0000 Subject: [PATCH 18/25] refactor: Use a const for "End of archive reached" Signed-off-by: Michael Vetter --- src/ui/window.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ui/window.c b/src/ui/window.c index 22b0935fe..bfc49d8e6 100644 --- a/src/ui/window.c +++ b/src/ui/window.c @@ -65,6 +65,7 @@ _win_ensure_pad_capacity(ProfWin* window, WINDOW* win, int lines_needed) } } static const char* LOADING_MESSAGE = "Loading older messages…"; +static const char* END_OF_ARCHIVE_MESSAGE = "End of archive reached"; static const char* CONS_WIN_TITLE = "Profanity. Type /help for help information."; static const char* XML_WIN_TITLE = "XML Console"; @@ -2124,7 +2125,7 @@ win_print_end_of_archive(ProfWin* window) if (!is_buffer_empty) { ProfBuffEntry* first_entry = buffer_get_entry(window->layout->buffer, 0); - if (first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, "End of archive reached") == 0) { + if (first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, END_OF_ARCHIVE_MESSAGE) == 0) { if (is_buffer_empty) g_date_time_unref(timestamp); return; @@ -2132,7 +2133,7 @@ win_print_end_of_archive(ProfWin* window) } int cur_y = getcury(window->layout->win); - buffer_prepend(window->layout->buffer, "-", 0, timestamp, NO_DATE, THEME_ROOMINFO, NULL, NULL, "End of archive reached", NULL, NULL, cur_y, cur_y + 1); + buffer_prepend(window->layout->buffer, "-", 0, timestamp, NO_DATE, THEME_ROOMINFO, NULL, NULL, END_OF_ARCHIVE_MESSAGE, NULL, NULL, cur_y, cur_y + 1); if (is_buffer_empty) g_date_time_unref(timestamp); From 542760bd0888cda5819c529eabcc43d13667b1da Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Fri, 26 Jun 2026 12:16:20 +0000 Subject: [PATCH 19/25] fix: Ensure end of archive is visible and redrawn correctly Reset the scroll position to the top of the history and exit the paged/scrolled state when the archive ends. Signed-off-by: Michael Vetter --- src/ui/window.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ui/window.c b/src/ui/window.c index bfc49d8e6..78f507a36 100644 --- a/src/ui/window.c +++ b/src/ui/window.c @@ -2117,6 +2117,10 @@ win_print_end_of_archive(ProfWin* window) GDateTime* timestamp; gboolean is_buffer_empty = buffer_size(window->layout->buffer) == 0; + // Reset scroll position and exit paged state to make the banner visible! + window->layout->y_pos = 0; + window->layout->paged = 0; + if (!is_buffer_empty) { timestamp = buffer_get_entry(window->layout->buffer, 0)->time; } else { @@ -2128,6 +2132,7 @@ win_print_end_of_archive(ProfWin* window) if (first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, END_OF_ARCHIVE_MESSAGE) == 0) { if (is_buffer_empty) g_date_time_unref(timestamp); + win_redraw(window); return; } } From fa73af96412d0869a801dac9c125abd07c1637bd Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Fri, 26 Jun 2026 12:16:53 +0000 Subject: [PATCH 20/25] fix: Prevent redundant MAM requests after archive ends In case we show the end of archive banner dont request older MAM messages. Signed-off-by: Michael Vetter --- src/ui/window.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ui/window.c b/src/ui/window.c index 78f507a36..a5135f978 100644 --- a/src/ui/window.c +++ b/src/ui/window.c @@ -692,8 +692,9 @@ win_page_up(ProfWin* window, int scroll_size) if (*page_start == -scroll_size && (window->type == WIN_CHAT || window->type == WIN_MUC)) { ProfBuffEntry* first_entry = buffer_size(window->layout->buffer) != 0 ? buffer_get_entry(window->layout->buffer, 0) : NULL; - // Don't do anything if still fetching mam messages - if (first_entry && !(first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, LOADING_MESSAGE) == 0)) { + // Don't do anything if still fetching mam messages or if end of archive is reached + if (first_entry && !(first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, LOADING_MESSAGE) == 0) + && !(first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, END_OF_ARCHIVE_MESSAGE) == 0)) { if (*scroll_state != WIN_SCROLL_REACHED_TOP) { if (window->type == WIN_MUC) { *scroll_state = !mucwin_db_history((ProfMucWin*)window, NULL, NULL, TRUE) ? WIN_SCROLL_REACHED_TOP : WIN_SCROLL_INNER; From 1ab997a3b01a0bb455b932e22b7c6a837b841f99 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Fri, 26 Jun 2026 14:24:14 +0200 Subject: [PATCH 21/25] fix: Add space between titlebar name and SCROLLED indicator We do the same for OMEMO etc. Signed-off-by: Michael Vetter --- src/ui/titlebar.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/titlebar.c b/src/ui/titlebar.c index 07a5d2be4..d9b7ed7d2 100644 --- a/src/ui/titlebar.c +++ b/src/ui/titlebar.c @@ -243,6 +243,7 @@ _show_scrolled(ProfWin* current) int bracket_attrs = theme_attrs(THEME_TITLE_BRACKET); int scrolled_attrs = theme_attrs(THEME_TITLE_SCROLLED); + wprintw(win, " "); wattron(win, bracket_attrs); wprintw(win, "["); wattroff(win, bracket_attrs); From eb2aa094598660cf382a7cfe9e39a7852d2fca01 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Fri, 26 Jun 2026 15:14:52 +0200 Subject: [PATCH 22/25] fix(muc): delay bookmark autojoin to prevent muc history Profanity requests bookmarks and queries the server features in parallel. Because the bookmark often arrives before the server features are resolved, rooms are autojoined without knowing if the server supports MAM. So we ask to join the MUC without history disabled. Then server sends back standard room history, which also lacks delayed delivery tags under certain configurations. We then display and record these messages as fresh messages. We now postpone the bookmark request until after the service discovery features have been received. Signed-off-by: Michael Vetter --- src/event/server_events.c | 2 ++ src/xmpp/session.c | 1 - tests/unittests/xmpp/stub_xmpp.c | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/event/server_events.c b/src/event/server_events.c index 79fba953c..2f9b9eb28 100644 --- a/src/event/server_events.c +++ b/src/event/server_events.c @@ -38,6 +38,7 @@ #include "xmpp/roster_list.h" #include "xmpp/avatar.h" #include "xmpp/vcard_funcs.h" +#include "xmpp/bookmark.h" #ifdef HAVE_LIBOTR #include "otr/otr.h" @@ -181,6 +182,7 @@ sv_ev_connection_features_received(void) #ifdef HAVE_OMEMO omemo_publish_crypto_materials(); #endif + bookmark_request(); } void diff --git a/src/xmpp/session.c b/src/xmpp/session.c index 2e70343b9..a66712be4 100644 --- a/src/xmpp/session.c +++ b/src/xmpp/session.c @@ -324,7 +324,6 @@ session_login_success(gboolean secured) } roster_request(); - bookmark_request(); blocking_request(); // items discovery diff --git a/tests/unittests/xmpp/stub_xmpp.c b/tests/unittests/xmpp/stub_xmpp.c index 6ef7fd457..f9c0b3a18 100644 --- a/tests/unittests/xmpp/stub_xmpp.c +++ b/tests/unittests/xmpp/stub_xmpp.c @@ -551,6 +551,11 @@ bookmark_autocomplete_reset(void) { } +void +bookmark_request(void) +{ +} + void roster_send_name_change(const char* const barejid, const char* const new_name, GSList* groups) { From 63e710d0e4e6171dfb467363312d506695fce145 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Fri, 26 Jun 2026 15:20:03 +0200 Subject: [PATCH 23/25] fix(carbons): Enable message carbons conditionally Profanity requests to enable message carbons immediately on connection. Without knowing the server features yet. If the server does not support message carbons, it responds with a service-unavailable error which spams the user during login. Signed-off-by: Michael Vetter --- src/event/server_events.c | 5 +++++ src/xmpp/session.c | 4 ---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/event/server_events.c b/src/event/server_events.c index 2f9b9eb28..21893795a 100644 --- a/src/event/server_events.c +++ b/src/event/server_events.c @@ -39,6 +39,7 @@ #include "xmpp/avatar.h" #include "xmpp/vcard_funcs.h" #include "xmpp/bookmark.h" +#include "xmpp/stanza.h" #ifdef HAVE_LIBOTR #include "otr/otr.h" @@ -183,6 +184,10 @@ sv_ev_connection_features_received(void) omemo_publish_crypto_materials(); #endif bookmark_request(); + + if (prefs_get_boolean(PREF_CARBONS) && connection_supports(STANZA_NS_CARBONS)) { + iq_enable_carbons(); + } } void diff --git a/src/xmpp/session.c b/src/xmpp/session.c index a66712be4..4a169a8b6 100644 --- a/src/xmpp/session.c +++ b/src/xmpp/session.c @@ -331,10 +331,6 @@ session_login_success(gboolean secured) const char* domain = connection_get_domain(); iq_disco_items_request_onconnect(domain); - if (prefs_get_boolean(PREF_CARBONS)) { - iq_enable_carbons(); - } - if ((prefs_get_reconnect() != 0) && reconnect_timer) { g_timer_destroy(reconnect_timer); reconnect_timer = NULL; From 91e044bb88212a62ae475ff1e362eb62b4073207 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Fri, 26 Jun 2026 15:36:13 +0200 Subject: [PATCH 24/25] fix(muc): Prevent duplicate messages if MAM is enabled When joining a MUC the local db history is loaded when the window is created. Then the servers MAM response arrives including messages sent by the ourselves in previous sessions. The database layer correctly skips duplicate insertions. But doesn't inform the caller that it skipped the insertion. This caused the event handler to assume the replayed message was a realtime message and render it a second time. Signed-off-by: Michael Vetter --- src/database.c | 28 ++++++++++++++---------- src/database.h | 2 +- src/event/server_events.c | 21 +++++++++++++++--- tests/unittests/database/stub_database.c | 3 ++- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/database.c b/src/database.c index c449df693..2f3fcb66a 100644 --- a/src/database.c +++ b/src/database.c @@ -29,7 +29,7 @@ static sqlite3* g_chatlog_database; -static void _add_to_db(ProfMessage* message, char* type, const Jid* const from_jid, const Jid* const to_jid); +static gboolean _add_to_db(ProfMessage* message, char* type, const Jid* const from_jid, const Jid* const to_jid); static char* _get_db_filename(ProfAccount* account); static prof_msg_type_t _get_message_type_type(const char* const type); static prof_enc_t _get_message_enc_type(const char* const encstr); @@ -211,13 +211,13 @@ log_database_close(void) } } -void +gboolean log_database_add_incoming(ProfMessage* message) { if (message->to_jid) { - _add_to_db(message, NULL, message->from_jid, message->to_jid); + return _add_to_db(message, NULL, message->from_jid, message->to_jid); } else { - _add_to_db(message, NULL, message->from_jid, connection_get_jid()); + return _add_to_db(message, NULL, message->from_jid, connection_get_jid()); } } @@ -598,14 +598,14 @@ _get_message_enc_type(const char* const encstr) return PROF_MSG_ENC_NONE; } -static void +static gboolean _add_to_db(ProfMessage* message, char* type, const Jid* const from_jid, const Jid* const to_jid) { auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG); sqlite_int64 original_message_id = -1; if (g_strcmp0(pref_dblog, "off") == 0) { - return; + return TRUE; } else if (g_strcmp0(pref_dblog, "redact") == 0) { if (message->plain) { free(message->plain); @@ -615,7 +615,7 @@ _add_to_db(ProfMessage* message, char* type, const Jid* const from_jid, const Ji if (!g_chatlog_database) { log_debug("log_database_add() called but db is not initialized"); - return; + return TRUE; } auto_gchar gchar* date_fmt = prof_date_time_format_iso8601(message->timestamp); @@ -632,14 +632,14 @@ _add_to_db(ProfMessage* message, char* type, const Jid* const from_jid, const Ji if (!replace_check_query) { log_error("Could not allocate memory for SQL replace query in log_database_add()"); - return; + return FALSE; } sqlite3_stmt* lmc_stmt = NULL; if (SQLITE_OK != sqlite3_prepare_v2(g_chatlog_database, replace_check_query, -1, &lmc_stmt, NULL)) { log_error("SQLite error in _add_to_db() on selecting original message: %s", sqlite3_errmsg(g_chatlog_database)); - return; + return FALSE; } if (sqlite3_step(lmc_stmt) == SQLITE_ROW) { @@ -654,7 +654,7 @@ _add_to_db(ProfMessage* message, char* type, const Jid* const from_jid, const Ji log_error("Mismatch in sender JIDs when trying to do LMC. Corrected message sender: %s. Original message sender: %s. Replace-ID: %s. Message: %s", from_jid->barejid, from_jid_orig, message->replace_id, message->plain); cons_show_error("%s sent a message correction with mismatched sender. See log for details.", from_jid->barejid); sqlite3_finalize(lmc_stmt); - return; + return FALSE; } } else { log_warning("Got LMC message that does not have original message counterpart in the database from %s", message->from_jid->fulljid); @@ -688,7 +688,7 @@ _add_to_db(ProfMessage* message, char* type, const Jid* const from_jid, const Ji enc); if (!query) { log_error("Could not allocate memory for SQL insert query in log_database_add()"); - return; + return FALSE; } log_debug("Writing to DB. Query: %s", query); @@ -697,19 +697,23 @@ _add_to_db(ProfMessage* message, char* type, const Jid* const from_jid, const Ji int rc = sqlite3_prepare_v2(g_chatlog_database, query, -1, &stmt, NULL); if (rc != SQLITE_OK) { log_error("SQLite error in _add_to_db() (prepare): %s", sqlite3_errmsg(g_chatlog_database)); - return; + return FALSE; } + gboolean is_new = TRUE; rc = sqlite3_step(stmt); if (rc == SQLITE_ROW) { log_debug("Successfully inserted message into database."); } else if (rc == SQLITE_DONE) { log_debug("Message already exists in database (archive_id: %s), skipping.", message->stanzaid); + is_new = FALSE; } else { log_error("SQLite error in _add_to_db() (step): %s", sqlite3_errmsg(g_chatlog_database)); + is_new = FALSE; } sqlite3_finalize(stmt); + return is_new; } static int diff --git a/src/database.h b/src/database.h index 5f45dce88..129f25eab 100644 --- a/src/database.h +++ b/src/database.h @@ -17,7 +17,7 @@ #define MESSAGES_TO_RETRIEVE 10 gboolean log_database_init(ProfAccount* account); -void log_database_add_incoming(ProfMessage* message); +gboolean log_database_add_incoming(ProfMessage* message); void log_database_add_outgoing_chat(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc); void log_database_add_outgoing_muc(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc); void log_database_add_outgoing_muc_pm(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc); diff --git a/src/event/server_events.c b/src/event/server_events.c index 21893795a..8b05e8613 100644 --- a/src/event/server_events.c +++ b/src/event/server_events.c @@ -304,7 +304,7 @@ sv_ev_room_history(ProfMessage* message) } } -static void +static gboolean _log_muc(ProfMessage* message) { if (message->enc == PROF_MSG_ENC_OMEMO) { @@ -312,7 +312,7 @@ _log_muc(ProfMessage* message) } else { groupchat_log_msg_in(message->from_jid->barejid, message->from_jid->resourcepart, message->plain); } - log_database_add_incoming(message); + return log_database_add_incoming(message); } void @@ -325,10 +325,14 @@ sv_ev_room_message(ProfMessage* message) const char* const mynick = muc_nick(mucwin->roomjid); + gboolean is_duplicate = FALSE; + // only log message not coming from this client (but maybe same account, different client) // our messages are logged when outgoing if (!(g_strcmp0(mynick, message->from_jid->resourcepart) == 0 && message_is_sent_by_us(message, TRUE))) { - _log_muc(message); + if (!_log_muc(message)) { + is_duplicate = TRUE; + } } char* old_plain = message->plain; @@ -342,6 +346,17 @@ sv_ev_room_message(ProfMessage* message) _clean_incoming_message(message); + if (is_duplicate) { + log_debug("Skipping duplicate room message display: JID=%s, message=%s", + message->from_jid->barejid, message->plain); + g_slist_free(mentions); + if (triggers) { + g_list_free_full(triggers, free); + } + message->plain = old_plain; + return; + } + if (message->is_mam) { // For MAM, print to window but disable all notifications, unread count increment etc // And don't filter reflection of our own sent messages (so they get displayed) diff --git a/tests/unittests/database/stub_database.c b/tests/unittests/database/stub_database.c index 913877e37..cedfc16c8 100644 --- a/tests/unittests/database/stub_database.c +++ b/tests/unittests/database/stub_database.c @@ -16,9 +16,10 @@ log_database_init(ProfAccount* account) { return TRUE; } -void +gboolean log_database_add_incoming(ProfMessage* message) { + return TRUE; } void log_database_add_outgoing_chat(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc) From 51c86a6df67c5f8fdc3831e11bdf7957efa6f9ca Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Fri, 26 Jun 2026 16:36:36 +0200 Subject: [PATCH 25/25] chore: Mark MAM as done in doap Fixes: https://github.com/profanity-im/profanity/issues/660 Signed-off-by: Michael Vetter --- profanity.doap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profanity.doap b/profanity.doap index bb188d403..b0c725d5b 100644 --- a/profanity.doap +++ b/profanity.doap @@ -360,7 +360,7 @@ partial 1.1.0 0.14.0 - No MUC MAM + MAM since 0.14.0. MUC MAM since DEV