diff options
author | fiaxh <git@lightrise.org> | 2023-01-06 13:19:42 +0100 |
---|---|---|
committer | fiaxh <git@lightrise.org> | 2023-01-06 14:03:54 +0100 |
commit | dc52e7595cca06d0a2da7d11b3c88cb2f7ce529c (patch) | |
tree | 111f4a86a8541ce51bba7ec56f5b32197fcefc83 | |
parent | 4d7809bb12199a598b531ca3ca019a4bb5a867f7 (diff) | |
download | dino-dc52e7595cca06d0a2da7d11b3c88cb2f7ce529c.tar.gz dino-dc52e7595cca06d0a2da7d11b3c88cb2f7ce529c.zip |
Add support for XEP-0461 replies (with fallback)
28 files changed, 740 insertions, 110 deletions
diff --git a/libdino/CMakeLists.txt b/libdino/CMakeLists.txt index 99c1426f..5aa4035f 100644 --- a/libdino/CMakeLists.txt +++ b/libdino/CMakeLists.txt @@ -40,6 +40,7 @@ SOURCES src/service/database.vala src/service/entity_capabilities_storage.vala src/service/entity_info.vala + src/service/fallback_body.vala src/service/file_manager.vala src/service/file_transfer_storage.vala src/service/history_sync.vala @@ -51,6 +52,7 @@ SOURCES src/service/muc_manager.vala src/service/notification_events.vala src/service/presence_manager.vala + src/service/replies.vala src/service/reactions.vala src/service/registration.vala src/service/roster_manager.vala diff --git a/libdino/src/application.vala b/libdino/src/application.vala index 229a9de1..ce9ec14a 100644 --- a/libdino/src/application.vala +++ b/libdino/src/application.vala @@ -56,6 +56,8 @@ public interface Application : GLib.Application { MessageCorrection.start(stream_interactor, db); FileTransferStorage.start(stream_interactor, db); Reactions.start(stream_interactor, db); + Replies.start(stream_interactor, db); + FallbackBody.start(stream_interactor, db); create_actions(); diff --git a/libdino/src/entity/message.vala b/libdino/src/entity/message.vala index b11e2622..912639b1 100644 --- a/libdino/src/entity/message.vala +++ b/libdino/src/entity/message.vala @@ -67,6 +67,9 @@ public class Message : Object { } } public string? edit_to = null; + public int quoted_item_id = 0; + + private Gee.List<Xep.FallbackIndication.Fallback> fallbacks = null; private Database? db; @@ -105,6 +108,7 @@ public class Message : Object { if (real_jid_str != null) real_jid = new Jid(real_jid_str); edit_to = row[db.message_correction.to_stanza_id]; + quoted_item_id = row[db.reply.quoted_content_item_id]; notify.connect(on_update); } @@ -138,6 +142,32 @@ public class Message : Object { notify.connect(on_update); } + public Gee.List<Xep.FallbackIndication.Fallback> get_fallbacks() { + if (fallbacks != null) return fallbacks; + + var fallbacks_by_ns = new HashMap<string, ArrayList<Xep.FallbackIndication.FallbackLocation>>(); + foreach (Qlite.Row row in db.body_meta.select().with(db.body_meta.message_id, "=", id)) { + if (row[db.body_meta.info_type] != Xep.FallbackIndication.NS_URI) continue; + + string ns_uri = row[db.body_meta.info]; + if (!fallbacks_by_ns.has_key(ns_uri)) { + fallbacks_by_ns[ns_uri] = new ArrayList<Xep.FallbackIndication.FallbackLocation>(); + } + fallbacks_by_ns[ns_uri].add(new Xep.FallbackIndication.FallbackLocation(row[db.body_meta.from_char], row[db.body_meta.to_char])); + } + + var fallbacks = new ArrayList<Xep.FallbackIndication.Fallback>(); + foreach (string ns_uri in fallbacks_by_ns.keys) { + fallbacks.add(new Xep.FallbackIndication.Fallback(ns_uri, fallbacks_by_ns[ns_uri].to_array())); + } + this.fallbacks = fallbacks; + return fallbacks; + } + + public void set_fallbacks(Gee.List<Xep.FallbackIndication.Fallback> fallbacks) { + this.fallbacks = fallbacks; + } + public void set_type_string(string type) { switch (type) { case Xmpp.MessageStanza.TYPE_CHAT: @@ -210,6 +240,13 @@ public class Message : Object { .value(db.real_jid.real_jid, real_jid.to_string()) .perform(); } + + if (sp.get_name() == "quoted-item-id") { + db.reply.upsert() + .value(db.reply.message_id, id, true) + .value(db.reply.quoted_content_item_id, quoted_item_id) + .perform(); + } } } diff --git a/libdino/src/plugin/interfaces.vala b/libdino/src/plugin/interfaces.vala index b3402457..6a30f6dc 100644 --- a/libdino/src/plugin/interfaces.vala +++ b/libdino/src/plugin/interfaces.vala @@ -148,7 +148,7 @@ public abstract class MetaConversationItem : Object { } public interface ConversationItemWidgetInterface: Object { - public abstract void set_widget(Object object, WidgetType type); + public abstract void set_widget(Object object, WidgetType type, int priority); } public delegate void MessageActionEvoked(Object button, Plugins.MetaConversationItem evoked_on, Object widget); diff --git a/libdino/src/service/content_item_store.vala b/libdino/src/service/content_item_store.vala index 6371e00b..6a9e691f 100644 --- a/libdino/src/service/content_item_store.vala +++ b/libdino/src/service/content_item_store.vala @@ -40,7 +40,7 @@ public class ContentItemStore : StreamInteractionModule, Object { collection_conversations.unset(conversation); } - public Gee.List<ContentItem> get_items_from_query(QueryBuilder select, Conversation conversation) { + private Gee.List<ContentItem> get_items_from_query(QueryBuilder select, Conversation conversation) { Gee.TreeSet<ContentItem> items = new Gee.TreeSet<ContentItem>(ContentItem.compare_func); foreach (var row in select) { @@ -58,7 +58,7 @@ public class ContentItemStore : StreamInteractionModule, Object { return ret; } - public ContentItem get_item(Conversation conversation, int id, int content_type, int foreign_id, DateTime time) throws Error { + private ContentItem get_item(Conversation conversation, int id, int content_type, int foreign_id, DateTime time) throws Error { switch (content_type) { case 1: Message? message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(foreign_id, conversation); diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala index 5f422d2f..bfd85f06 100644 --- a/libdino/src/service/database.vala +++ b/libdino/src/service/database.vala @@ -7,7 +7,7 @@ using Dino.Entities; namespace Dino { public class Database : Qlite.Database { - private const int VERSION = 23; + private const int VERSION = 24; public class AccountTable : Table { public Column<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true }; @@ -97,6 +97,20 @@ public class Database : Qlite.Database { } } + public class BodyMeta : Table { + public Column<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true }; + public Column<int> message_id = new Column.Integer("message_id"); + public Column<int> from_char = new Column.Integer("from_char"); + public Column<int> to_char = new Column.Integer("to_char"); + public Column<string> info_type = new Column.Text("info_type"); + public Column<string> info = new Column.Text("info"); + + internal BodyMeta(Database db) { + base(db, "body_meta"); + init({id, message_id, from_char, to_char, info_type, info}); + } + } + public class MessageCorrectionTable : Table { public Column<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true }; public Column<int> message_id = new Column.Integer("message_id") { unique=true }; @@ -109,6 +123,20 @@ public class Database : Qlite.Database { } } + public class ReplyTable : Table { + public Column<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true }; + public Column<int> message_id = new Column.Integer("message_id") { not_null = true, unique=true }; + public Column<int> quoted_content_item_id = new Column.Integer("quoted_message_id"); + public Column<string?> quoted_message_stanza_id = new Column.Text("quoted_message_stanza_id"); + public Column<string?> quoted_message_from = new Column.Text("quoted_message_from"); + + internal ReplyTable(Database db) { + base(db, "reply"); + init({id, message_id, quoted_content_item_id, quoted_message_stanza_id, quoted_message_from}); + index("reply_quoted_message_stanza_id", {quoted_message_stanza_id}); + } + } + public class RealJidTable : Table { public Column<int> message_id = new Column.Integer("message_id") { primary_key = true }; public Column<string> real_jid = new Column.Text("real_jid"); @@ -337,6 +365,8 @@ public class Database : Qlite.Database { public EntityTable entity { get; private set; } public ContentItemTable content_item { get; private set; } public MessageTable message { get; private set; } + public BodyMeta body_meta { get; private set; } + public ReplyTable reply { get; private set; } public MessageCorrectionTable message_correction { get; private set; } public RealJidTable real_jid { get; private set; } public OccupantIdTable occupantid { get; private set; } @@ -364,7 +394,9 @@ public class Database : Qlite.Database { entity = new EntityTable(this); content_item = new ContentItemTable(this); message = new MessageTable(this); + body_meta = new BodyMeta(this); message_correction = new MessageCorrectionTable(this); + reply = new ReplyTable(this); occupantid = new OccupantIdTable(this); real_jid = new RealJidTable(this); file_transfer = new FileTransferTable(this); @@ -379,7 +411,7 @@ public class Database : Qlite.Database { reaction = new ReactionTable(this); settings = new SettingsTable(this); conversation_settings = new ConversationSettingsTable(this); - init({ account, jid, entity, content_item, message, message_correction, real_jid, occupantid, file_transfer, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, settings, conversation_settings }); + init({ account, jid, entity, content_item, message, body_meta, message_correction, reply, real_jid, occupantid, file_transfer, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, settings, conversation_settings }); try { exec("PRAGMA journal_mode = WAL"); diff --git a/libdino/src/service/fallback_body.vala b/libdino/src/service/fallback_body.vala new file mode 100644 index 00000000..cc9ba9a6 --- /dev/null +++ b/libdino/src/service/fallback_body.vala @@ -0,0 +1,67 @@ +using Gee; +using Qlite; + +using Xmpp; +using Xmpp.Xep; +using Dino.Entities; + +public class Dino.FallbackBody : StreamInteractionModule, Object { + public static ModuleIdentity<FallbackBody> IDENTITY = new ModuleIdentity<FallbackBody>("fallback-body"); + public string id { get { return IDENTITY.id; } } + + private StreamInteractor stream_interactor; + private Database db; + + private ReceivedMessageListener received_message_listener; + + public static void start(StreamInteractor stream_interactor, Database db) { + FallbackBody m = new FallbackBody(stream_interactor, db); + stream_interactor.add_module(m); + } + + private FallbackBody(StreamInteractor stream_interactor, Database db) { + this.stream_interactor = stream_interactor; + this.db = db; + this.received_message_listener = new ReceivedMessageListener(stream_interactor, db); + + stream_interactor.get_module(MessageProcessor.IDENTITY).received_pipeline.connect(received_message_listener); + } + + private class ReceivedMessageListener : MessageListener { + + public string[] after_actions_const = new string[]{ "STORE" }; + public override string action_group { get { return "Quote"; } } + public override string[] after_actions { get { return after_actions_const; } } + + private StreamInteractor stream_interactor; + private Database db; + + public ReceivedMessageListener(StreamInteractor stream_interactor, Database db) { + this.stream_interactor = stream_interactor; + this.db = db; + } + + public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + Gee.List<Xep.FallbackIndication.Fallback> fallbacks = Xep.FallbackIndication.get_fallbacks(stanza); + if (fallbacks.is_empty) return false; + + foreach (var fallback in fallbacks) { + if (fallback.ns_uri != Xep.Replies.NS_URI) continue; + + foreach (var location in fallback.locations) { + db.body_meta.insert() + .value(db.body_meta.message_id, message.id) + .value(db.body_meta.info_type, Xep.FallbackIndication.NS_URI) + .value(db.body_meta.info, fallback.ns_uri) + .value(db.body_meta.from_char, location.from_char) + .value(db.body_meta.to_char, location.to_char) + .perform(); + } + + message.set_fallbacks(fallbacks); + } + + return false; + } + } +}
\ No newline at end of file diff --git a/libdino/src/service/message_correction.vala b/libdino/src/service/message_correction.vala index d5d15578..2c9078ea 100644 --- a/libdino/src/service/message_correction.vala +++ b/libdino/src/service/message_correction.vala @@ -44,6 +44,7 @@ public class MessageCorrection : StreamInteractionModule, MessageListener { Message out_message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(correction_text, conversation); out_message.edit_to = stanza_id; + out_message.quoted_item_id = old_message.quoted_item_id; outstanding_correction_nodes[out_message.stanza_id] = stanza_id; stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(out_message, conversation); diff --git a/libdino/src/service/message_processor.vala b/libdino/src/service/message_processor.vala index a290132f..62822658 100644 --- a/libdino/src/service/message_processor.vala +++ b/libdino/src/service/message_processor.vala @@ -424,6 +424,26 @@ public class MessageProcessor : StreamInteractionModule, Object { } else { new_message.type_ = MessageStanza.TYPE_CHAT; } + + if (message.quoted_item_id > 0) { + ContentItem? content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(conversation, message.quoted_item_id); + if (content_item != null && content_item.type_ == MessageItem.TYPE) { + Message? quoted_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(((MessageItem) content_item).message.id, conversation); + if (quoted_message != null) { + Xep.Replies.set_reply_to(new_message, new Xep.Replies.ReplyTo(quoted_message.from, quoted_message.stanza_id)); + + string body_with_fallback = "> " + Dino.message_body_without_reply_fallback(quoted_message); + body_with_fallback.replace("\n", "\n> "); + body_with_fallback += "\n"; + long fallback_length = body_with_fallback.length; + body_with_fallback += message.body; + new_message.body = body_with_fallback; + var fallback_location = new Xep.FallbackIndication.FallbackLocation(0, (int)fallback_length); + Xep.FallbackIndication.set_fallback(new_message, new Xep.FallbackIndication.Fallback(Xep.Replies.NS_URI, new Xep.FallbackIndication.FallbackLocation[] { fallback_location })); + } + } + } + build_message_stanza(message, new_message, conversation); pre_message_send(message, new_message, conversation); if (message.marked == Entities.Message.Marked.UNSENT || message.marked == Entities.Message.Marked.WONTSEND) return; diff --git a/libdino/src/service/message_storage.vala b/libdino/src/service/message_storage.vala index a44c0b02..fbdbcf8a 100644 --- a/libdino/src/service/message_storage.vala +++ b/libdino/src/service/message_storage.vala @@ -42,6 +42,7 @@ public class MessageStorage : StreamInteractionModule, Object { .with(db.message.type_, "=", (int) Util.get_message_type_for_conversation(conversation)) .order_by(db.message.time, "DESC") .outer_join_with(db.message_correction, db.message_correction.message_id, db.message.id) + .outer_join_with(db.reply, db.reply.message_id, db.message.id) .limit(count); Gee.List<Message> ret = new LinkedList<Message>(Message.equals_func); @@ -92,6 +93,7 @@ public class MessageStorage : StreamInteractionModule, Object { RowOption row_option = db.message.select().with(db.message.id, "=", id) .outer_join_with(db.message_correction, db.message_correction.message_id, db.message.id) + .outer_join_with(db.reply, db.reply.message_id, db.message.id) .row(); return create_message_from_row_opt(row_option, conversation); @@ -111,7 +113,8 @@ public class MessageStorage : StreamInteractionModule, Object { .with(db.message.type_, "=", (int) Util.get_message_type_for_conversation(conversation)) .with(db.message.stanza_id, "=", stanza_id) .order_by(db.message.time, "DESC") - .outer_join_with(db.message_correction, db.message_correction.message_id, db.message.id); + .outer_join_with(db.message_correction, db.message_correction.message_id, db.message.id) + .outer_join_with(db.reply, db.reply.message_id, db.message.id); if (conversation.counterpart.resourcepart == null) { query.with_null(db.message.counterpart_resource); @@ -138,7 +141,8 @@ public class MessageStorage : StreamInteractionModule, Object { .with(db.message.type_, "=", (int) Util.get_message_type_for_conversation(conversation)) .with(db.message.server_id, "=", server_id) .order_by(db.message.time, "DESC") - .outer_join_with(db.message_correction, db.message_correction.message_id, db.message.id); + .outer_join_with(db.message_correction, db.message_correction.message_id, db.message.id) + .outer_join_with(db.reply, db.reply.message_id, db.message.id); if (conversation.counterpart.resourcepart == null) { query.with_null(db.message.counterpart_resource); diff --git a/libdino/src/service/replies.vala b/libdino/src/service/replies.vala new file mode 100644 index 00000000..6a9bced4 --- /dev/null +++ b/libdino/src/service/replies.vala @@ -0,0 +1,130 @@ +using Gee; +using Qlite; + +using Xmpp; +using Xmpp.Xep; +using Dino.Entities; + +public class Dino.Replies : StreamInteractionModule, Object { + public static ModuleIdentity<Replies> IDENTITY = new ModuleIdentity<Replies>("reply"); + public string id { get { return IDENTITY.id; } } + + private StreamInteractor stream_interactor; + private Database db; + private HashMap<Conversation, HashMap<string, Gee.List<Message>>> unmapped_replies = new HashMap<Conversation, HashMap<string, Gee.List<Message>>>(); + + private ReceivedMessageListener received_message_listener; + + public static void start(StreamInteractor stream_interactor, Database db) { + Replies m = new Replies(stream_interactor, db); + stream_interactor.add_module(m); + } + + private Replies(StreamInteractor stream_interactor, Database db) { + this.stream_interactor = stream_interactor; + this.db = db; + this.received_message_listener = new ReceivedMessageListener(stream_interactor, this); + + stream_interactor.get_module(MessageProcessor.IDENTITY).received_pipeline.connect(received_message_listener); + } + + public ContentItem? get_quoted_content_item(Message message, Conversation conversation) { + if (message.quoted_item_id == 0) return null; + + RowOption row_option = db.reply.select().with(db.reply.message_id, "=", message.id).row(); + if (row_option.is_present()) { + return stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(conversation, row_option[db.reply.quoted_content_item_id]); + } + return null; + } + + public void set_message_is_reply_to(Message message, ContentItem reply_to) { + message.quoted_item_id = reply_to.id; + + db.reply.upsert() + .value(db.reply.message_id, message.id, true) + .value(db.reply.quoted_content_item_id, reply_to.id) + .value_null(db.reply.quoted_message_stanza_id) + .value_null(db.reply.quoted_message_from) + .perform(); + } + + private void on_incoming_message(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + // Check if a previous message was in reply to this one + string relevant_id = conversation.type_ == Conversation.Type.GROUPCHAT ? message.server_id : message.stanza_id; + + var reply_qry = db.reply.select(); + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + reply_qry.with(db.reply.quoted_message_stanza_id, "=", message.server_id); + } else { + reply_qry.with(db.reply.quoted_message_stanza_id, "=", message.stanza_id); + } + reply_qry.join_with(db.message, db.reply.message_id, db.message.id) + .with(db.message.account_id, "=", conversation.account.id) + .with(db.message.counterpart_id, "=", db.get_jid_id(conversation.counterpart)) + .with(db.message.time, ">", (long)message.time.to_unix()) + .order_by(db.message.time, "DESC"); + + foreach (Row reply_row in reply_qry) { + ContentItem? message_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_foreign(conversation, 1, message.id); + Message? reply_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(reply_row[db.message.id], conversation); + if (message_item != null && reply_message != null) { + set_message_is_reply_to(reply_message, message_item); + } + } + + // Handle if this message is a reply + Xep.Replies.ReplyTo? reply_to = Xep.Replies.get_reply_to(stanza); + if (reply_to == null) return; + + Message? quoted_message = null; + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + quoted_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_server_id(reply_to.to_message_id, conversation); + } else { + quoted_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_stanza_id(reply_to.to_message_id, conversation); + } + if (quoted_message == null) { + db.reply.upsert() + .value(db.reply.message_id, message.id, true) + .value(db.reply.quoted_message_stanza_id, reply_to.to_message_id) + .value(db.reply.quoted_message_from, reply_to.to_jid.to_string()) + .perform(); + return; + } + + ContentItem? quoted_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_foreign(conversation, 1, quoted_message.id); + if (quoted_content_item == null) return; + + set_message_is_reply_to(message, quoted_content_item); + } + + private class ReceivedMessageListener : MessageListener { + + public string[] after_actions_const = new string[]{ "STORE", "STORE_CONTENT_ITEM" }; + public override string action_group { get { return "Quote"; } } + public override string[] after_actions { get { return after_actions_const; } } + + private Replies outer; + + public ReceivedMessageListener(StreamInteractor stream_interactor, Replies outer) { + this.outer = outer; + } + + public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + outer.on_incoming_message(message, stanza, conversation); + return false; + } + } +} + +namespace Dino { + public string message_body_without_reply_fallback(Message message) { + string body = message.body; + foreach (var fallback in message.get_fallbacks()) { + if (fallback.ns_uri == Xep.Replies.NS_URI && message.quoted_item_id > 0) { + body = body[0:fallback.locations[0].from_char] + body[fallback.locations[0].to_char:body.length]; + } + } + return body; + } +} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 4fc06339..88b52c63 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -82,6 +82,7 @@ set(RESOURCE_LIST message_item_widget_edit_mode.ui occupant_list.ui occupant_list_item.ui + quote.ui search_autocomplete.ui settings_dialog.ui shortcuts.ui @@ -157,6 +158,7 @@ SOURCES src/ui/conversation_content_view/file_image_widget.vala src/ui/conversation_content_view/file_widget.vala src/ui/conversation_content_view/message_widget.vala + src/ui/conversation_content_view/quote_widget.vala src/ui/conversation_content_view/reactions_widget.vala src/ui/conversation_content_view/subscription_notification.vala diff --git a/main/data/chat_input.ui b/main/data/chat_input.ui index 99b087aa..5e84c360 100644 --- a/main/data/chat_input.ui +++ b/main/data/chat_input.ui @@ -32,8 +32,21 @@ </object> </child> <child> - <object class="DinoUiChatTextView" id="chat_text_view"> - <property name="margin_start">7</property> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <child> + <object class="GtkBox" id="quote_box"> + <property name="margin-top">10</property> + <property name="margin-start">10</property> + <property name="margin-end">10</property> + <property name="visible">False</property> + </object> + </child> + <child> + <object class="DinoUiChatTextView" id="chat_text_view"> + <property name="margin_start">7</property> + </object> + </child> </object> </child> <child> diff --git a/main/data/conversation_content_view/view.ui b/main/data/conversation_content_view/view.ui index a9aae318..f6819b94 100644 --- a/main/data/conversation_content_view/view.ui +++ b/main/data/conversation_content_view/view.ui @@ -3,95 +3,64 @@ <requires lib="gtk" version="4.0"/> <template class="DinoUiConversationSummaryConversationView"> <child> - <object class="GtkStack" id="stack"> - <property name="transition_type">crossfade</property> - <child> - <object class="GtkStackPage"> - <property name="name">main</property> + <object class="GtkOverlay"> + <property name="child"> + <object class="GtkScrolledWindow" id="scrolled"> + <property name="hscrollbar_policy">never</property> + <property name="hexpand">1</property> + <property name="vexpand">1</property> <property name="child"> - <object class="GtkOverlay"> - <property name="child"> - <object class="GtkScrolledWindow" id="scrolled"> - <property name="hscrollbar_policy">never</property> - <property name="hexpand">1</property> - <property name="vexpand">1</property> - <property name="child"> - <object class="GtkBox" id="main_wrap_box"> - <property name="valign">end</property> + <object class="GtkBox" id="main_wrap_box"> + <property name="valign">end</property> + <child> + <object class="GtkOverlay"> + <child> + <object class="GtkBox" id="main_event_box"> <child> - <object class="GtkOverlay"> - <child> - <object class="GtkBox" id="main_event_box"> - <child> - <object class="DinoUiSizeRequestBox" id="main"> - <property name="margin-bottom">15</property> - <property name="orientation">vertical</property> - <property name="size-request-mode">height-for-width</property> - </object> - </child> - </object> - </child> - <child type="overlay"> - <object class="GtkBox" id="message_menu_box"> - <property name="margin-end">10</property> - <property name="halign">end</property> - <property name="valign">start</property> - <style> - <class name="linked"/> - </style> - <child> - <object class="GtkButton" id="button1"> - <property name="visible">0</property> - <property name="vexpand">0</property> - <property name="halign">end</property> - <property name="valign">end</property> - <child> - <object class="GtkImage" id="button1_icon"> - <property name="icon-size">normal</property> - </object> - </child> - </object> - </child> - </object> - </child> + <object class="DinoUiSizeRequestBox" id="main"> + <property name="margin-bottom">15</property> + <property name="orientation">vertical</property> + <property name="size-request-mode">height-for-width</property> </object> </child> </object> - </property> - </object> - </property> - <child type="overlay"> - <object class="GtkRevealer" id="notification_revealer"> - <property name="halign">center</property> - <property name="valign">start</property> - <property name="child"> - <object class="GtkFrame" id="frame2"> + </child> + <child type="overlay"> + <object class="GtkBox" id="message_menu_box"> + <property name="margin-end">10</property> + <property name="halign">end</property> + <property name="valign">start</property> <style> - <class name="app-notification"/> + <class name="linked"/> </style> - <property name="child"> - <object class="GtkBox" id="notifications"> - <property name="hexpand">0</property> - <property name="vexpand">0</property> - <property name="orientation">vertical</property> - </object> - </property> - <child type="label_item"> - <placeholder/> - </child> </object> - </property> + </child> </object> </child> </object> </property> </object> - </child> - <child> - <object class="GtkStackPage"> - <property name="name">void</property> + </property> + <child type="overlay"> + <object class="GtkRevealer" id="notification_revealer"> + <property name="halign">center</property> + <property name="valign">start</property> <property name="child"> - <object class="GtkBox"/> + <object class="GtkFrame" id="frame2"> + <style> + <class name="app-notification"/> + </style> + <property name="child"> + <object class="GtkBox" id="notifications"> + <property name="hexpand">0</property> + <property name="vexpand">0</property> + <property name="orientation">vertical</property> + </object> + </property> + <child type="label_item"> + <placeholder/> + </child> + </object> </property> </object> </child> diff --git a/main/data/quote.ui b/main/data/quote.ui new file mode 100644 index 00000000..a7c32ed8 --- /dev/null +++ b/main/data/quote.ui @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk" version="4.0"/> + <object class="GtkGrid" id="outer"> + <property name="column-spacing">5</property> + <style> + <class name="dino-quote"/> + </style> + <child> + <object class="DinoUiAvatarImage" id="avatar"> + <property name="allow_gray">False</property> + <property name="height">15</property> + <property name="width">15</property> + <property name="valign">center</property> + <layout> + <property name="column">0</property> + <property name="row">0</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel" id="author"> + <property name="ellipsize">end</property> + <property name="halign">start</property> + <property name="valign">baseline</property> + <property name="xalign">0</property> + <attributes> + <attribute name="weight" value="PANGO_WEIGHT_BOLD"/> + </attributes> + <layout> + <property name="column">1</property> + <property name="row">0</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel" id="time"> + <property name="ellipsize">end</property> + <property name="halign">start</property> + <property name="valign">baseline</property> + <property name="xalign">0</property> + <property name="hexpand">True</property> + <style> + <class name="dim-label"/> + </style> + <attributes> + <attribute name="scale" value="0.8"/> + </attributes> + <layout> + <property name="column">2</property> + <property name="row">0</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel" id="message"> + <property name="ellipsize">end</property> + <property name="halign">start</property> + <property name="xalign">0</property> + <layout> + <property name="column">0</property> + <property name="row">1</property> + <property name="column-span">3</property> + </layout> + </object> + </child> + <child> + <object class="GtkButton" id="abort-button"> + <property name="icon-name">window-close-symbolic</property> + <property name="has-frame">False</property> + <property name="valign">center</property> + <layout> + <property name="column">3</property> + <property name="row">0</property> + <property name="row-span">2</property> + </layout> + </object> + </child> + </object> +</interface>
\ No newline at end of file diff --git a/main/data/theme.css b/main/data/theme.css index d657e0a3..3d24750e 100644 --- a/main/data/theme.css +++ b/main/data/theme.css @@ -109,6 +109,17 @@ window.dino-main .dino-conversation .message-box.error:hover { background: alpha(@error_color, 0.12); } +window.dino-main .dino-quote { + border-left: 3px solid alpha(@theme_fg_color, 0.2); + background: alpha(@theme_fg_color, 0.05); + border-color: alpha(@theme_fg_color, 0.2); + padding: 10px; +} + +window.dino-main .dino-quote:hover { + background: alpha(@theme_fg_color, 0.08); +} + /* Message Menu */ .message-menu-box { diff --git a/main/src/ui/chat_input/chat_input_controller.vala b/main/src/ui/chat_input/chat_input_controller.vala index 7b260695..de75cdf8 100644 --- a/main/src/ui/chat_input/chat_input_controller.vala +++ b/main/src/ui/chat_input/chat_input_controller.vala @@ -24,6 +24,8 @@ public class ChatInputController : Object { private Plugins.InputFieldStatus input_field_status; private ChatTextViewController chat_text_view_controller; + private ContentItem? quoted_content_item = null; + public ChatInputController(ChatInput.View chat_input, StreamInteractor stream_interactor) { this.chat_input = chat_input; this.status_description_label = chat_input.chat_input_status; @@ -58,12 +60,34 @@ public class ChatInputController : Object { } return true; }); + + SimpleAction quote_action = new SimpleAction("quote", new VariantType.tuple(new VariantType[]{VariantType.INT32, VariantType.INT32})); + quote_action.activate.connect((variant) => { + int conversation_id = variant.get_child_value(0).get_int32(); + Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation_by_id(conversation_id); + if (conversation == null || !this.conversation.equals(conversation)) return; + + int content_item_id = variant.get_child_value(1).get_int32(); + ContentItem? content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(conversation, content_item_id); + if (content_item == null) return; + + quoted_content_item = content_item; + var quote_model = new Quote.Model.from_content_item(content_item, conversation, stream_interactor) { can_abort = true }; + quote_model.aborted.connect(() => { + content_item = null; + chat_input.unset_quoted_message(); + }); + chat_input.set_quoted_message(Quote.get_widget(quote_model)); + }); + GLib.Application.get_default().add_action(quote_action); } public void set_conversation(Conversation conversation) { - this.conversation = conversation; - + this.quoted_content_item = null; reset_input_field_status(); + chat_input.unset_quoted_message(); + + this.conversation = conversation; chat_input.encryption_widget.set_conversation(conversation); @@ -111,7 +135,10 @@ public class ChatInputController : Object { } string text = chat_input.chat_text_view.text_view.buffer.text; + chat_input.chat_text_view.text_view.buffer.text = ""; + chat_input.unset_quoted_message(); + if (text.has_prefix("/")) { string[] token = text.split(" ", 2); switch(token[0]) { @@ -164,7 +191,11 @@ public class ChatInputController : Object { break; } } - stream_interactor.get_module(MessageProcessor.IDENTITY).send_text(text, conversation); + Message out_message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(text, conversation); + if (quoted_content_item != null) { + stream_interactor.get_module(Replies.IDENTITY).set_message_is_reply_to(out_message, quoted_content_item); + } + stream_interactor.get_module(MessageProcessor.IDENTITY).send_message(out_message, conversation); } private void on_text_input_changed() { diff --git a/main/src/ui/chat_input/view.vala b/main/src/ui/chat_input/view.vala index 4be4455b..e16b4085 100644 --- a/main/src/ui/chat_input/view.vala +++ b/main/src/ui/chat_input/view.vala @@ -20,8 +20,8 @@ public class View : Box { private HashMap<Conversation, string> entry_cache = new HashMap<Conversation, string>(Conversation.hash_func, Conversation.equals_func); [GtkChild] public unowned Frame frame; + [GtkChild] public unowned Box quote_box; [GtkChild] public unowned ChatTextView chat_text_view; - [GtkChild] public unowned Box outer_box; [GtkChild] public unowned Button file_button; [GtkChild] public unowned MenuButton emoji_button; [GtkChild] public unowned MenuButton encryption_button; @@ -94,6 +94,19 @@ public class View : Box { }); } + public void set_quoted_message(Widget quote_widget) { + Widget? quote_box_child = quote_box.get_first_child(); + if (quote_box_child != null) quote_box.remove(quote_box_child); + quote_box.append(quote_widget); + quote_box.visible = true; + } + + public void unset_quoted_message() { + Widget? quote_box_child = quote_box.get_first_child(); + if (quote_box_child != null) quote_box.remove(quote_box_child); + quote_box.visible = false; + } + public void do_focus() { chat_text_view.text_view.grab_focus(); } diff --git a/main/src/ui/conversation_content_view/conversation_item_skeleton.vala b/main/src/ui/conversation_content_view/conversation_item_skeleton.vala index 7113b3b7..00c88db3 100644 --- a/main/src/ui/conversation_content_view/conversation_item_skeleton.vala +++ b/main/src/ui/conversation_content_view/conversation_item_skeleton.vala @@ -16,7 +16,7 @@ public class ConversationItemSkeleton : Plugins.ConversationItemWidgetInterface, public Image encryption_image { get; set; } public Image received_image { get; set; } - public Widget? content_widget = null; + private HashMap<int, Widget> content_widgets = new HashMap<int, Widget>(); private bool show_skeleton_ = false; public bool show_skeleton { @@ -58,7 +58,7 @@ public class ConversationItemSkeleton : Plugins.ConversationItemWidgetInterface, widget = item.get_widget(this, Plugins.WidgetType.GTK4) as Widget; if (widget != null) { widget.valign = Align.END; - set_widget(widget, Plugins.WidgetType.GTK4); + set_widget(widget, Plugins.WidgetType.GTK4, 2); } if (item.requires_header) { @@ -72,7 +72,7 @@ public class ConversationItemSkeleton : Plugins.ConversationItemWidgetInterface, if (content_meta_item != null) { reactions_controller = new ReactionsController(conversation, content_meta_item.content_item, stream_interactor); reactions_controller.box_activated.connect((widget) => { - main_grid.attach(widget, 1, 2, 4, 1); + set_widget(widget, Plugins.WidgetType.GTK4, 3); }); reactions_controller.init(); } @@ -103,12 +103,18 @@ public class ConversationItemSkeleton : Plugins.ConversationItemWidgetInterface, update_received_mark(); } - public void set_widget(Object object, Plugins.WidgetType type) { - if (content_widget != null) content_widget.unparent(); + public void set_widget(Object object, Plugins.WidgetType type, int priority) { + foreach (var content_widget in content_widgets.values) { + content_widget.unparent(); + } - Widget widget = (Widget) object; - content_widget = widget; - main_grid.attach(widget, 1, 1, 4, 1); + content_widgets[priority] = (Widget) object; + int row_no = 1; + for (int i = 0; i < 5; i++) { + if (!content_widgets.has_key(i)) continue; + main_grid.attach(content_widgets[i], 1, row_no, 4, 1); + row_no++; + } } private void update_margin() { diff --git a/main/src/ui/conversation_content_view/conversation_view.vala b/main/src/ui/conversation_content_view/conversation_view.vala index f0f6e118..70115512 100644 --- a/main/src/ui/conversation_content_view/conversation_view.vala +++ b/main/src/ui/conversation_content_view/conversation_view.vala @@ -18,7 +18,6 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug [GtkChild] private unowned Box notifications; [GtkChild] private unowned Box main; [GtkChild] private unowned Box main_wrap_box; - [GtkChild] private unowned Stack stack; private ArrayList<Widget> action_buttons = new ArrayList<Widget>(); private Gee.List<Dino.Plugins.MessageAction>? message_actions = null; @@ -208,7 +207,6 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug Button button = new Button(); button.icon_name = message_action.icon_name; button.clicked.connect(() => { - print(@"$(current_meta_item.jid) skdfj \n"); message_action.callback(button, current_meta_item, currently_highlighted); }); action_buttons.add(button); @@ -233,15 +231,12 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug }); firstLoad = false; } - stack.set_visible_child_name("void"); clear(); initialize_for_conversation_(conversation); display_latest(); - stack.set_visible_child_name("main"); } public void initialize_around_message(Conversation conversation, ContentItem content_item) { - stack.set_visible_child_name("void"); clear(); initialize_for_conversation_(conversation); Gee.List<ContentMetaItem> before_items = content_populator.populate_before(conversation, content_item, 40); @@ -277,7 +272,6 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug scrolled.vadjustment.value = h - scrolled.vadjustment.page_size * 1/3; w.add_css_class("highlight-once"); reload_messages = true; - stack.set_visible_child_name("main"); return false; }); } diff --git a/main/src/ui/conversation_content_view/message_widget.vala b/main/src/ui/conversation_content_view/message_widget.vala index 1f027c89..f4e1d22c 100644 --- a/main/src/ui/conversation_content_view/message_widget.vala +++ b/main/src/ui/conversation_content_view/message_widget.vala @@ -82,7 +82,8 @@ public class MessageMetaItem : ContentMetaItem { bool theme_dependent = false; - string markup_text = message.body; + string markup_text = Dino.message_body_without_reply_fallback(message); + if (markup_text.length > 10000) { markup_text = markup_text.substring(0, 10000) + " [" + _("Message too long") + "]"; } @@ -169,7 +170,7 @@ public class MessageMetaItem : ContentMetaItem { edit_mode.cancelled.connect(() => { in_edit_mode = false; - outer.set_widget(label, Plugins.WidgetType.GTK4); + outer.set_widget(label, Plugins.WidgetType.GTK4, 2); }); edit_mode.send.connect(() => { if (((MessageItem) content_item).message.body != edit_mode.chat_text_view.text_view.buffer.text) { @@ -178,18 +179,31 @@ public class MessageMetaItem : ContentMetaItem { // edit_cancelled(); } in_edit_mode = false; - outer.set_widget(label, Plugins.WidgetType.GTK4); + outer.set_widget(label, Plugins.WidgetType.GTK4, 2); }); edit_mode.chat_text_view.text_view.buffer.text = message.body; - outer.set_widget(edit_mode, Plugins.WidgetType.GTK4); + outer.set_widget(edit_mode, Plugins.WidgetType.GTK4, 2); edit_mode.chat_text_view.text_view.grab_focus(); } else { this.in_edit_mode = false; } }); + outer.set_widget(label, Plugins.WidgetType.GTK4, 2); + + if (message_item.message.quoted_item_id > 0) { + var quoted_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(message_item.conversation, message_item.message.quoted_item_id); + if (quoted_content_item != null) { + var quote_model = new Quote.Model.from_content_item(quoted_content_item, message_item.conversation, stream_interactor); + quote_model.jump_to.connect(() => { + GLib.Application.get_default().activate_action("jump-to-conversation-message", new GLib.Variant.tuple(new GLib.Variant[] { new GLib.Variant.int32(message_item.conversation.id), new GLib.Variant.int32(message_item.id) })); + }); + var quote_widget = Quote.get_widget(quote_model); + outer.set_widget(quote_widget, Plugins.WidgetType.GTK4, 1); + } + } return label; } @@ -209,6 +223,13 @@ public class MessageMetaItem : ContentMetaItem { actions.add(action1); } + Plugins.MessageAction reply_action = new Plugins.MessageAction(); + reply_action.icon_name = "mail-reply-sender-symbolic"; + reply_action.callback = (button, content_meta_item_activated, widget) => { + GLib.Application.get_default().activate_action("quote", new GLib.Variant.tuple(new GLib.Variant[] { new GLib.Variant.int32(message_item.conversation.id), new GLib.Variant.int32(message_item.id) })); + }; + actions.add(reply_action); + if (supports_reaction) { Plugins.MessageAction action2 = new Plugins.MessageAction(); action2.icon_name = "dino-emoticon-add-symbolic"; diff --git a/main/src/ui/conversation_content_view/quote_widget.vala b/main/src/ui/conversation_content_view/quote_widget.vala new file mode 100644 index 00000000..f627c852 --- /dev/null +++ b/main/src/ui/conversation_content_view/quote_widget.vala @@ -0,0 +1,73 @@ +using Dino.Ui.ConversationSummary; +using Gee; +using Gtk; +using Xmpp; + +using Dino.Entities; + +namespace Dino.Ui.Quote { + + public class Model : Object { + public signal void aborted(); + public signal void jump_to(); + + public string display_name { get; set; } + public string message { get; set; } + public DateTime message_time { get; set; } + + public StreamInteractor stream_interactor { get; set; } + public Conversation conversation { get; set; } + public Jid author_jid { get; set; } + + public bool can_abort { get; set; default=false; } + + public Model.from_content_item(ContentItem content_item, Conversation conversation, StreamInteractor stream_interactor) { + this.display_name = Util.get_participant_display_name(stream_interactor, conversation, content_item.jid, true); + if (content_item.type_ == MessageItem.TYPE) { + var message = ((MessageItem) content_item).message; + this.message = Dino.message_body_without_reply_fallback(message); + } else if (content_item.type_ == FileItem.TYPE) { + this.message = "[File]"; + } + this.message_time = content_item.time; + + this.stream_interactor = stream_interactor; + this.conversation = conversation; + this.author_jid = content_item.jid; + } + } + + public Widget get_widget(Model model) { + Builder builder = new Builder.from_resource("/im/dino/Dino/quote.ui"); + AvatarImage avatar = (AvatarImage) builder.get_object("avatar"); + Label author = (Label) builder.get_object("author"); + Label time = (Label) builder.get_object("time"); + Label message = (Label) builder.get_object("message"); + Button abort_button = (Button) builder.get_object("abort-button"); + + avatar.set_conversation_participant(model.stream_interactor, model.conversation, model.author_jid); + model.bind_property("display-name", author, "label", BindingFlags.SYNC_CREATE); + model.bind_property("message-time", time, "label", BindingFlags.SYNC_CREATE, (_, from_value, ref to_value) => { + DateTime message_time = (DateTime) from_value; + to_value = ConversationItemSkeleton.get_relative_time(message_time); + return true; + }); + model.bind_property("message", message, "label", BindingFlags.SYNC_CREATE); + model.bind_property("can-abort", abort_button, "visible", BindingFlags.SYNC_CREATE); + + abort_button.clicked.connect(() => { + model.aborted(); + }); + + Widget outer = builder.get_object("outer") as Widget; + + GestureClick gesture_click_controller = new GestureClick(); + outer.add_controller(gesture_click_controller); + gesture_click_controller.pressed.connect(() => { + model.jump_to(); + }); + + return outer; + } +} + diff --git a/main/src/ui/conversation_content_view/reactions_widget.vala b/main/src/ui/conversation_content_view/reactions_widget.vala index c9f93f66..890c1206 100644 --- a/main/src/ui/conversation_content_view/reactions_widget.vala +++ b/main/src/ui/conversation_content_view/reactions_widget.vala @@ -27,9 +27,6 @@ public class ReactionsController : Object { public void init() { Gee.List<ReactionUsers> reactions = stream_interactor.get_module(Reactions.IDENTITY).get_item_reactions(conversation, content_item); - if (reactions.size > 0) { - initialize_widget(); - } foreach (ReactionUsers reaction_users in reactions) { foreach (Jid jid in reaction_users.jids) { reaction_added(reaction_users.reaction, jid); diff --git a/main/src/ui/conversation_selector/conversation_selector_row.vala b/main/src/ui/conversation_selector/conversation_selector_row.vala index a2588d9a..bd2b0747 100644 --- a/main/src/ui/conversation_selector/conversation_selector_row.vala +++ b/main/src/ui/conversation_selector/conversation_selector_row.vala @@ -149,7 +149,7 @@ public class ConversationSelectorRow : ListBoxRow { MessageItem message_item = last_content_item as MessageItem; Message last_message = message_item.message; - string body = last_message.body; + string body = Dino.message_body_without_reply_fallback(last_message); bool me_command = body.has_prefix("/me "); /* If we have a /me command, we always show the display diff --git a/main/src/ui/main_window_controller.vala b/main/src/ui/main_window_controller.vala index 38ebcc9c..9e7e8ce7 100644 --- a/main/src/ui/main_window_controller.vala +++ b/main/src/ui/main_window_controller.vala @@ -23,6 +23,21 @@ public class MainWindowController : Object { stream_interactor.get_module(ConversationManager.IDENTITY).conversation_deactivated.connect(check_unset_conversation); stream_interactor.account_removed.connect(check_unset_conversation); + + SimpleAction jump_to_conversatio_message_action = new SimpleAction("jump-to-conversation-message", new VariantType.tuple(new VariantType[]{VariantType.INT32, VariantType.INT32})); + jump_to_conversatio_message_action.activate.connect((variant) => { + int conversation_id = variant.get_child_value(0).get_int32(); + Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation_by_id(conversation_id); + if (conversation == null || !this.conversation.equals(conversation)) return; + + int item_id = variant.get_child_value(1).get_int32(); + ContentItem? content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(conversation, item_id); + + select_conversation(conversation, false, false); + window.conversation_view.conversation_frame.initialize_around_message(conversation, content_item); + }); + app.add_action(jump_to_conversatio_message_action); + } public void set_window(MainWindow window) { diff --git a/xmpp-vala/CMakeLists.txt b/xmpp-vala/CMakeLists.txt index 0513d597..a988a088 100644 --- a/xmpp-vala/CMakeLists.txt +++ b/xmpp-vala/CMakeLists.txt @@ -133,7 +133,9 @@ SOURCES "src/module/xep/0391_jingle_encrypted_transports.vala" "src/module/xep/0410_muc_self_ping.vala" "src/module/xep/0421_occupant_ids.vala" + "src/module/xep/0428_fallback_indication.vala" "src/module/xep/0444_reactions.vala" + "src/module/xep/0461_replies.vala" "src/module/xep/pixbuf_storage.vala" "src/util.vala" diff --git a/xmpp-vala/src/module/xep/0428_fallback_indication.vala b/xmpp-vala/src/module/xep/0428_fallback_indication.vala new file mode 100644 index 00000000..6686b0ee --- /dev/null +++ b/xmpp-vala/src/module/xep/0428_fallback_indication.vala @@ -0,0 +1,67 @@ +using Gee; + +namespace Xmpp.Xep.FallbackIndication { + + public const string NS_URI = "urn:xmpp:fallback:0"; + + public class Fallback { + public string ns_uri { get; set; } + public FallbackLocation[] locations; + + + public Fallback(string ns_uri, FallbackLocation[] locations) { + this.ns_uri = ns_uri; + this.locations = locations; + } + } + + public class FallbackLocation { + public int from_char { get; set; } + public int to_char { get; set; } + + public FallbackLocation(int from_char, int to_char) { + this.from_char = from_char; + this.to_char = to_char; + } + } + + public static void set_fallback(MessageStanza message, Fallback fallback) { + StanzaNode fallback_node = (new StanzaNode.build("fallback", NS_URI)) + .add_self_xmlns() + .put_attribute("for", fallback.ns_uri); + foreach (FallbackLocation location in fallback.locations) { + fallback_node.put_node(new StanzaNode.build("body", NS_URI) + .add_self_xmlns() + .put_attribute("start", location.from_char.to_string()) + .put_attribute("end", location.to_char.to_string())); + } + message.stanza.put_node(fallback_node); + } + + public Gee.List<Fallback> get_fallbacks(MessageStanza message) { + var ret = new ArrayList<Fallback>(); + + Gee.List<StanzaNode> fallback_nodes = message.stanza.get_subnodes("fallback", NS_URI); + if (fallback_nodes.is_empty) return ret; + + foreach (StanzaNode fallback_node in fallback_nodes) { + string? ns_uri = fallback_node.get_attribute("for"); + if (ns_uri == null) continue; + + Gee.List<StanzaNode> body_nodes = fallback_node.get_subnodes("body", NS_URI); + if (body_nodes.is_empty) continue; + + var locations = new ArrayList<FallbackLocation>(); + foreach (StanzaNode body_node in body_nodes) { + int start_char = body_node.get_attribute_int("start", -1); + int end_char = body_node.get_attribute_int("end", -1); + if (start_char == -1 || end_char == -1) continue; + locations.add(new FallbackLocation(start_char, end_char)); + } + if (locations.is_empty) continue; + ret.add(new Fallback(ns_uri, locations.to_array())); + } + + return ret; + } +}
\ No newline at end of file diff --git a/xmpp-vala/src/module/xep/0461_replies.vala b/xmpp-vala/src/module/xep/0461_replies.vala new file mode 100644 index 00000000..870df295 --- /dev/null +++ b/xmpp-vala/src/module/xep/0461_replies.vala @@ -0,0 +1,41 @@ +namespace Xmpp.Xep.Replies { + + public const string NS_URI = "urn:xmpp:reply:0"; + + public class ReplyTo { + public Jid to_jid { get; set; } + public string to_message_id { get; set; } + + public ReplyTo(Jid to_jid, string to_message_id) { + this.to_jid = to_jid; + this.to_message_id = to_message_id; + } + } + + public static void set_reply_to(MessageStanza message, ReplyTo reply_to) { + StanzaNode reply_node = (new StanzaNode.build("reply", NS_URI)) + .add_self_xmlns() + .put_attribute("to", reply_to.to_jid.to_string()) + .put_attribute("id", reply_to.to_message_id); + message.stanza.put_node(reply_node); + } + + public ReplyTo? get_reply_to(MessageStanza message) { + StanzaNode? reply_node = message.stanza.get_subnode("reply", NS_URI); + if (reply_node == null) return null; + + string? to_str = reply_node.get_attribute("to"); + if (to_str == null) return null; + try { + Jid to_jid = new Jid(to_str); + + string? id = reply_node.get_attribute("id"); + if (id == null) return null; + + return new ReplyTo(to_jid, id); + } catch (InvalidJidError e) { + return null; + } + return null; + } +}
\ No newline at end of file |