From 61915ca56617e8f45ae8bd85cb87f0b8a9a895b0 Mon Sep 17 00:00:00 2001 From: bobufa Date: Tue, 10 Jul 2018 00:31:39 +0200 Subject: initial search logic / display --- main/src/ui/global_search.vala | 174 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 main/src/ui/global_search.vala (limited to 'main/src/ui/global_search.vala') diff --git a/main/src/ui/global_search.vala b/main/src/ui/global_search.vala new file mode 100644 index 00000000..cadee9c1 --- /dev/null +++ b/main/src/ui/global_search.vala @@ -0,0 +1,174 @@ +using Gtk; +using Pango; + +using Dino.Entities; + +namespace Dino.Ui { + +[GtkTemplate (ui = "/im/dino/Dino/global_search.ui")] +class GlobalSearch : Box { + private StreamInteractor stream_interactor; + private string search = ""; + private int loaded_results = -1; + private Mutex reloading_mutex = Mutex(); + + [GtkChild] public SearchEntry search_entry; + [GtkChild] public Label entry_number_label; + [GtkChild] public ScrolledWindow results_scrolled; + [GtkChild] public Box results_box; + + public GlobalSearch init(StreamInteractor stream_interactor) { + this.stream_interactor = stream_interactor; + + search_entry.search_changed.connect(() => { + set_search(search_entry.text); + }); + + results_scrolled.vadjustment.notify["value"].connect(() => { + if (results_scrolled.vadjustment.upper - (results_scrolled.vadjustment.value + results_scrolled.vadjustment.page_size) < 100) { + if (!reloading_mutex.trylock()) return; + Gee.List new_messages = stream_interactor.get_module(SearchProcessor.IDENTITY).match_messages(search, loaded_results); + if (new_messages.size == 0) { + reloading_mutex.unlock(); + return; + } + loaded_results += new_messages.size; + append_messages(new_messages); + } + }); + results_scrolled.vadjustment.notify["upper"].connect_after(() => { + reloading_mutex.trylock(); + reloading_mutex.unlock(); + }); + return this; + } + + private void clear_search() { + results_box.@foreach((widget) => { widget.destroy(); }); + } + + private void set_search(string search) { + clear_search(); + this.search = search; + + int match_count = stream_interactor.get_module(SearchProcessor.IDENTITY).count_match_messages(search); + entry_number_label.label = "" + _("%i search results").printf(match_count) + ""; + Gee.List messages = stream_interactor.get_module(SearchProcessor.IDENTITY).match_messages(search); + loaded_results += messages.size; + append_messages(messages); + } + + private void append_messages(Gee.List messages) { + foreach (Message message in messages) { + if (message.from == null) { + print("wtf null\n"); + continue; + } + Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation_for_message(message); + Gee.List before_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_messages_before_message(conversation, message.local_time, message.id, 1); + Gee.List after_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_messages_after_message(conversation, message.local_time, message.id, 1); + + Box context_box = new Box(Orientation.VERTICAL, 5) { visible=true }; + if (before_message != null && before_message.size > 0) { + context_box.add(get_context_message_widget(before_message.first())); + } + context_box.add(get_match_message_widget(message)); + if (after_message != null && after_message.size > 0) { + context_box.add(get_context_message_widget(after_message.first())); + } + + Label date_label = new Label(ConversationSummary.DefaultSkeletonHeader.get_relative_time(message.time)) { xalign=0, visible=true }; + date_label.get_style_context().add_class("dim-label"); + + string display_name = Util.get_conversation_display_name(stream_interactor, conversation); + string title = message.type_ == Message.Type.GROUPCHAT ? _("In %s").printf(display_name) : _("With %s").printf(display_name); + Box header_box = new Box(Orientation.HORIZONTAL, 10) { margin_left=7, visible=true }; + header_box.add(new Label(@"$(Markup.escape_text(title))") { ellipsize=EllipsizeMode.END, xalign=0, use_markup=true, visible=true }); + header_box.add(date_label); + + Box result_box = new Box(Orientation.VERTICAL, 7) { visible=true }; + result_box.add(header_box); + result_box.add(context_box); + + results_box.add(result_box); + } + } + + // Workaround GTK TextView issues + private void force_alloc_width(Widget widget, int width) { + Allocation alloc = Allocation(); + widget.get_preferred_width(out alloc.width, null); + widget.get_preferred_height(out alloc.height, null); + alloc.width = width; + widget.size_allocate(alloc); + } + + private Widget get_match_message_widget(Message message) { + Grid grid = get_skeleton(message); + grid.margin_top = 3; + grid.margin_bottom = 3; + + string text = message.body.replace("\n", "").replace("\r", ""); + if (text.length > 200) { + int index = text.index_of(search); + if (index + search.length <= 100) { + text = text.substring(0, 150) + " … " + text.substring(text.length - 50, 50); + } else if (index >= text.length - 100) { + text = text.substring(0, 50) + " … " + text.substring(text.length - 150, 150); + } else { + text = text.substring(0, 25) + " … " + text.substring(index - 50, 50) + text.substring(index, 100) + " … " + text.substring(text.length - 25, 25); + } + } + TextView tv = new TextView() { wrap_mode=Gtk.WrapMode.WORD_CHAR, hexpand=true, visible=true }; + tv.buffer.text = text; + TextTag link_tag = tv.buffer.create_tag("hit", background: "yellow"); + + Regex url_regex = new Regex(search.down()); + MatchInfo match_info; + url_regex.match(text.down(), 0, out match_info); + for (; match_info.matches(); match_info.next()) { + int start; + int end; + match_info.fetch_pos(0, out start, out end); + start = text[0:start].char_count(); + end = text[0:end].char_count(); + TextIter start_iter; + TextIter end_iter; + tv.buffer.get_iter_at_offset(out start_iter, start); + tv.buffer.get_iter_at_offset(out end_iter, end); + tv.buffer.apply_tag(link_tag, start_iter, end_iter); + } + grid.attach(tv, 1, 1, 1, 1); + + // force_alloc_width(tv, this.width_request); + + Button button = new Button() { relief=ReliefStyle.NONE, visible=true }; + button.add(grid); + return button; + } + + private Grid get_context_message_widget(Message message) { + Grid grid = get_skeleton(message); + grid.margin_left = 7; + Label label = new Label(message.body.replace("\n", "").replace("\r", "")) { ellipsize=EllipsizeMode.MIDDLE, xalign=0, visible=true }; + grid.attach(label, 1, 1, 1, 1); + grid.opacity = 0.55; + return grid; + } + + private Grid get_skeleton(Message message) { + AvatarImage image = new AvatarImage() { height=32, width=32, margin_right=7, valign=Align.START, visible=true, allow_gray = false }; + image.set_jid(stream_interactor, message.from, message.account); + Grid grid = new Grid() { row_homogeneous=false, visible=true }; + grid.attach(image, 0, 0, 1, 2); + + string display_name = Util.get_display_name(stream_interactor, message.from, message.account); + string color = Util.get_name_hex_color(stream_interactor, message.account, message.from, false); // TODO Util.is_dark_theme(name_label) + Label name_label = new Label("") { use_markup=true, xalign=0, visible=true }; + name_label.label = @"$display_name"; + grid.attach(name_label, 1, 0, 1, 1); + return grid; + } +} + +} -- cgit v1.2.3-54-g00ecf From e376a577b6bfcdd9bdc0cc6ca283d99199a0197a Mon Sep 17 00:00:00 2001 From: bobufa Date: Wed, 25 Jul 2018 20:41:51 +0200 Subject: improve sidebar UI - only display messages that are content items - only display messages for active accounts - "fix" textview issue - add empty states (no search, no results) --- libdino/src/service/database.vala | 5 +- libdino/src/service/message_storage.vala | 18 ++- libdino/src/service/search_processor.vala | 21 ++-- main/data/global_search.ui | 137 +++++++++++++++++++-- main/data/theme.css | 16 +++ main/src/ui/application.vala | 2 +- .../ui/conversation_summary/conversation_view.vala | 93 ++++++++++---- .../ui/conversation_summary/message_textview.vala | 1 - .../src/ui/conversation_titlebar/search_entry.vala | 4 +- main/src/ui/global_search.vala | 129 +++++++++++-------- main/src/ui/unified_window.vala | 23 +++- main/src/ui/util/helper.vala | 13 +- 12 files changed, 341 insertions(+), 121 deletions(-) (limited to 'main/src/ui/global_search.vala') diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala index 01cc2f52..bea07dda 100644 --- a/libdino/src/service/database.vala +++ b/libdino/src/service/database.vala @@ -43,8 +43,9 @@ public class Database : Qlite.Database { public Column foreign_id = new Column.Integer("foreign_id") { not_null = true }; internal ContentTable(Database db) { - base(db, "content"); + base(db, "contentx"); init({id, conversation_id, time, local_time, content_type, foreign_id}); + unique({content_type, foreign_id}, "IGNORE"); } } @@ -227,7 +228,7 @@ public class Database : Qlite.Database { message.fts_rebuild(); } else if (oldVersion < 8) { exec(""" - insert into content (conversation_id, time, local_time, content_type, foreign_id) + insert into contentx (conversation_id, time, local_time, content_type, foreign_id) select conversation.id, message.time, message.local_time, 1, message.id from message join conversation on message.account_id=conversation.account_id and diff --git a/libdino/src/service/message_storage.vala b/libdino/src/service/message_storage.vala index 9fb6ab19..50fc94b3 100644 --- a/libdino/src/service/message_storage.vala +++ b/libdino/src/service/message_storage.vala @@ -52,7 +52,7 @@ public class MessageStorage : StreamInteractionModule, Object { return null; } - public Gee.List? get_messages_before_message(Conversation? conversation, DateTime before, int id, int count = 20) { + public Gee.List get_messages_before_message(Conversation? conversation, DateTime before, int id, int count = 20) { // SortedSet? before = messages[conversation].head_set(message); // if (before != null && before.size >= count) { // Gee.List ret = new ArrayList(Message.equals_func); @@ -66,14 +66,22 @@ public class MessageStorage : StreamInteractionModule, Object { // } // return ret; // } else { - Gee.List db_messages = db.get_messages(conversation.counterpart, conversation.account, Util.get_message_type_for_conversation(conversation), count, before, null, id); - return db_messages; + Gee.List db_messages = db.get_messages(conversation.counterpart, conversation.account, Util.get_message_type_for_conversation(conversation), count, before, null, id); + Gee.List ret = new ArrayList(); + foreach (Message message in db_messages) { + ret.add(new MessageItem(message, conversation, -1)); + } + return ret; // } } - public Gee.List? get_messages_after_message(Conversation? conversation, DateTime after, int id, int count = 20) { + public Gee.List get_messages_after_message(Conversation? conversation, DateTime after, int id, int count = 20) { Gee.List db_messages = db.get_messages(conversation.counterpart, conversation.account, Util.get_message_type_for_conversation(conversation), count, null, after, id); - return db_messages; + Gee.List ret = new ArrayList(); + foreach (Message message in db_messages) { + ret.add(new MessageItem(message, conversation, -1)); + } + return ret; } public Message? get_message_by_id(int id, Conversation conversation) { diff --git a/libdino/src/service/search_processor.vala b/libdino/src/service/search_processor.vala index 6962a7c1..e56efa41 100644 --- a/libdino/src/service/search_processor.vala +++ b/libdino/src/service/search_processor.vala @@ -23,7 +23,7 @@ public class SearchProcessor : StreamInteractionModule, Object { this.db = db; } - private QueryBuilder prepare_search(string query) { + private QueryBuilder prepare_search(string query, bool join_content) { string words = ""; string? with = null; string? in_ = null; @@ -60,7 +60,12 @@ public class SearchProcessor : StreamInteractionModule, Object { .order_by(db.message.id, "DESC") .join_with(db.jid, db.jid.id, db.message.counterpart_id) .join_with(db.account, db.account.id, db.message.account_id) - .outer_join_with(db.real_jid, db.real_jid.message_id, db.message.id); + .outer_join_with(db.real_jid, db.real_jid.message_id, db.message.id) + .with(db.account.enabled, "=", true); + if (join_content) { + rows.join_on(db.content, "message.id=contentx.foreign_id AND contentx.content_type=1") + .with(db.content.content_type, "=", 1); + } if (with != null) { if (with.index_of("/") > 0) { rows.with(db.message.type_, "=", Message.Type.GROUPCHAT_PM) @@ -85,20 +90,22 @@ public class SearchProcessor : StreamInteractionModule, Object { return rows; } - public Gee.List match_messages(string query, int offset = -1) { - Gee.List ret = new ArrayList(Message.equals_func); - var rows = prepare_search(query).limit(10); + public Gee.List match_messages(string query, int offset = -1) { + Gee.List ret = new ArrayList(); + var rows = prepare_search(query, true).limit(10); if (offset > 0) { rows.offset(offset); } foreach (Row row in rows) { - ret.add(new Message.from_row(db, row)); + Message message = new Message.from_row(db, row); + Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation_for_message(message); + ret.add(new MessageItem(message, conversation, row[db.content.id])); } return ret; } public int count_match_messages(string query) { - return (int)prepare_search(query).select({db.message.id}).count(); + return (int)prepare_search(query, false).select({db.message.id}).count(); } } diff --git a/main/data/global_search.ui b/main/data/global_search.ui index cc5f043b..3c4597c1 100644 --- a/main/data/global_search.ui +++ b/main/data/global_search.ui @@ -10,24 +10,135 @@ - - 0 - True - 17 - True - - - - - True + True - + + vertical + 10 + center + True + + + True + system-search-symbolic + 4 + 72 + + + + + + No active search + 0.5 + 0.5 + True + + + + + + + + + + Type to start a search + 0.5 + 0.5 + True + + + + + + empty + + + + + vertical + 10 + center + True + + + True + face-uncertain-symbolic + 4 + 72 + + + + + + No matching messages + 0.5 + 0.5 + True + + + + + + + + + + Check the spelling or try to remove filters + 0.5 + 0.5 + True + + + + + + no-result + + z + + vertical - 25 - 10 True + + + 0 + True + 17 + True + + + + + never + True + True + + + vertical + 25 + 10 + True + + + + + + results + diff --git a/main/data/theme.css b/main/data/theme.css index 61f15af4..42988c42 100644 --- a/main/data/theme.css +++ b/main/data/theme.css @@ -17,6 +17,22 @@ window.dino-main .dino-conversation undershoot { background: none; } +@keyframes highlight { + from { background: alpha(@warning_color, 0.5) } + to { background: transparent } +} + +window.dino-main .dino-conversation .highlight-once { + animation-duration: 3s; + animation-timing-function: ease-out; + animation-iteration-count: 1; + animation-name: highlight; +} + +window.dino-main .dino-conversation textview, window.dino-main .dino-conversation textview text { + background: transparent; +} + window.dino-main .dino-sidebar frame { background: @insensitive_bg_color; border-left: 1px solid @borders; diff --git a/main/src/ui/application.vala b/main/src/ui/application.vala index 22d6d93d..86a4e288 100644 --- a/main/src/ui/application.vala +++ b/main/src/ui/application.vala @@ -32,7 +32,7 @@ public class Dino.Ui.Application : Gtk.Application, Dino.Application { window = new UnifiedWindow(this, stream_interactor); notifications = new Notifications(stream_interactor, window); notifications.start(); - notifications.conversation_selected.connect(window.on_conversation_selected); + notifications.conversation_selected.connect((conversation) => window.on_conversation_selected(conversation)); } window.present(); }); diff --git a/main/src/ui/conversation_summary/conversation_view.vala b/main/src/ui/conversation_summary/conversation_view.vala index a1863cf4..c74884a4 100644 --- a/main/src/ui/conversation_summary/conversation_view.vala +++ b/main/src/ui/conversation_summary/conversation_view.vala @@ -34,6 +34,7 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { private bool animate = false; private bool firstLoad = true; private bool at_current_content = true; + private bool reload_messages = true; public ConversationView init(StreamInteractor stream_interactor) { this.stream_interactor = stream_interactor; @@ -57,7 +58,6 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { return true; }); - Util.force_base_background(this); return this; } @@ -66,14 +66,71 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { if (firstLoad) { int timeout = firstLoad ? 1000 : 0; Timeout.add(timeout, () => { + stack.set_visible_child_name("void"); initialize_for_conversation_(conversation); + display_latest(); + stack.set_visible_child_name("main"); return false; }); firstLoad = false; } else { + stack.set_visible_child_name("void"); 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 before_items = content_populator.populate_before(conversation, content_item, 40); + foreach (ContentMetaItem item in before_items) { + do_insert_item(item); + } + ContentMetaItem meta_item = content_populator.get_content_meta_item(content_item); + meta_item.can_merge = false; + Widget w = insert_new(meta_item); + content_items.add(meta_item); + meta_items.add(meta_item); + + Gee.List after_items = content_populator.populate_after(conversation, content_item, 40); + foreach (ContentMetaItem item in after_items) { + do_insert_item(item); + } + if (after_items.size == 40) { + at_current_content = false; + } + { + int h = 0, i = 0; + main.@foreach((widget) => { + if (i >= before_items.size) return; + ConversationItemSkeleton? sk = widget as ConversationItemSkeleton; + i += sk != null ? sk.items.size : 1; + int minimum_height, natural_height; + widget.get_preferred_height_for_width(main.get_allocated_width() - 2 * main.margin, out minimum_height, out natural_height); + h += minimum_height + 15; + }); + print(@"height_for_w: $(h)\n"); } + reload_messages = false; + Timeout.add(700, () => { + int h = 0, i = 0; + main.@foreach((widget) => { + if (i >= before_items.size) return; + ConversationItemSkeleton? sk = widget as ConversationItemSkeleton; + i += sk != null ? sk.items.size : 1; + h += widget.get_allocated_height() + 15; + }); + print(@"timeout: $(h)\n"); + scrolled.vadjustment.value = h - scrolled.vadjustment.page_size * 1/3; + w.get_style_context().add_class("highlight-once"); + reload_messages = true; + stack.set_visible_child_name("main"); + return false; + }); } private void initialize_for_conversation_(Conversation? conversation) { @@ -84,7 +141,6 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { } } this.conversation = conversation; - stack.set_visible_child_name("void"); foreach (Plugins.ConversationItemPopulator populator in app.plugin_registry.conversation_addition_populators) { populator.init(conversation, this, Plugins.WidgetType.GTK); @@ -92,17 +148,12 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { content_populator.init(this, conversation, Plugins.WidgetType.GTK); subscription_notification.init(conversation, this); - display_latest(); - - stack.set_visible_child_name("main"); + animate = false; + Timeout.add(20, () => { animate = true; return false; }); } private void display_latest() { clear(); - was_upper = null; - was_page_size = null; - animate = false; - Timeout.add(20, () => { animate = true; return false; }); Gee.List items = content_populator.populate_latest(conversation, 40); foreach (ContentMetaItem item in items) { @@ -163,7 +214,7 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { lower_start_item.encryption == item.encryption && (item.mark == Message.Marked.WONTSEND) == (lower_start_item.mark == Message.Marked.WONTSEND)) { lower_skeleton.add_meta_item(item); - force_alloc_width(lower_skeleton, main.get_allocated_width()); + Util.force_alloc_width(lower_skeleton, main.get_allocated_width()); widgets[item] = widgets[lower_start_item]; item_item_skeletons[item] = lower_skeleton; @@ -174,7 +225,7 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { return false; } - private void insert_new(Plugins.MetaConversationItem item) { + private Widget insert_new(Plugins.MetaConversationItem item) { Plugins.MetaConversationItem? lower_item = meta_items.lower(item); // Does another skeleton need to be split? @@ -206,7 +257,7 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { main.add(insert); } widgets[item] = insert; - force_alloc_width(insert, main.get_allocated_width()); + Util.force_alloc_width(insert, main.get_allocated_width()); main.reorder_child(insert, index); // If an item from the past was added, add everything between that item and the (post-)first present item @@ -222,6 +273,7 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { } } } + return insert; } private void split_at_time(ConversationItemSkeleton split_skeleton, DateTime time) { @@ -273,10 +325,6 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { Gee.List items = content_populator.populate_before(conversation, (content_items.first() as ContentMetaItem).content_item, 20); foreach (ContentMetaItem item in items) { do_insert_item(item); - if (content_items.size > 50) { - do_remove_item(content_items.last()); - at_current_content = false; - } } } else { reloading_mutex.unlock(); @@ -310,21 +358,14 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { int res = a.sort_time.compare(b.sort_time); if (res == 0) { if (a.seccondary_sort_indicator < b.seccondary_sort_indicator) res = -1; - else if (a.seccondary_sort_indicator > b.seccondary_sort_indicator) res = 1; + else if (a.seccondary_sort_indicator > b.seccondary_sort_indicator) res = 1; } return res; } - // Workaround GTK TextView issues - private void force_alloc_width(Widget widget, int width) { - Allocation alloc = Allocation(); - widget.get_preferred_width(out alloc.width, null); - widget.get_preferred_height(out alloc.height, null); - alloc.width = width; - widget.size_allocate(alloc); - } - private void clear() { + was_upper = null; + was_page_size = null; content_items.clear(); meta_items.clear(); item_skeletons.clear(); diff --git a/main/src/ui/conversation_summary/message_textview.vala b/main/src/ui/conversation_summary/message_textview.vala index 0c38c269..71ca35f8 100644 --- a/main/src/ui/conversation_summary/message_textview.vala +++ b/main/src/ui/conversation_summary/message_textview.vala @@ -24,7 +24,6 @@ public class MessageTextView : TextView { motion_notify_event.connect(change_cursor_over_url); update_display_style(); - Util.force_base_background(this, "textview, text:not(:selected)"); style_updated.connect(update_display_style); populate_popup.connect(populate_context_menu); } diff --git a/main/src/ui/conversation_titlebar/search_entry.vala b/main/src/ui/conversation_titlebar/search_entry.vala index e80e5954..b452bdce 100644 --- a/main/src/ui/conversation_titlebar/search_entry.vala +++ b/main/src/ui/conversation_titlebar/search_entry.vala @@ -24,9 +24,7 @@ public class SearchMenuEntry : Plugins.ConversationTitlebarEntry, Object { } public class GlobalSearchButton : Plugins.ConversationTitlebarWidget, Gtk.ToggleButton { - public new void set_conversation(Conversation conversation) { - active = false; - } + public new void set_conversation(Conversation conversation) { } } } diff --git a/main/src/ui/global_search.vala b/main/src/ui/global_search.vala index cadee9c1..8bd13e6f 100644 --- a/main/src/ui/global_search.vala +++ b/main/src/ui/global_search.vala @@ -1,3 +1,4 @@ +using Gee; using Gtk; using Pango; @@ -7,6 +8,8 @@ namespace Dino.Ui { [GtkTemplate (ui = "/im/dino/Dino/global_search.ui")] class GlobalSearch : Box { + public signal void selected_item(MessageItem item); + private StreamInteractor stream_interactor; private string search = ""; private int loaded_results = -1; @@ -16,6 +19,7 @@ class GlobalSearch : Box { [GtkChild] public Label entry_number_label; [GtkChild] public ScrolledWindow results_scrolled; [GtkChild] public Box results_box; + [GtkChild] public Stack results_empty_stack; public GlobalSearch init(StreamInteractor stream_interactor) { this.stream_interactor = stream_interactor; @@ -27,7 +31,7 @@ class GlobalSearch : Box { results_scrolled.vadjustment.notify["value"].connect(() => { if (results_scrolled.vadjustment.upper - (results_scrolled.vadjustment.value + results_scrolled.vadjustment.page_size) < 100) { if (!reloading_mutex.trylock()) return; - Gee.List new_messages = stream_interactor.get_module(SearchProcessor.IDENTITY).match_messages(search, loaded_results); + Gee.List new_messages = stream_interactor.get_module(SearchProcessor.IDENTITY).match_messages(search, loaded_results); if (new_messages.size == 0) { reloading_mutex.unlock(); return; @@ -51,37 +55,47 @@ class GlobalSearch : Box { clear_search(); this.search = search; - int match_count = stream_interactor.get_module(SearchProcessor.IDENTITY).count_match_messages(search); - entry_number_label.label = "" + _("%i search results").printf(match_count) + ""; - Gee.List messages = stream_interactor.get_module(SearchProcessor.IDENTITY).match_messages(search); - loaded_results += messages.size; - append_messages(messages); + if (get_keywords(search).is_empty) { + results_empty_stack.set_visible_child_name("empty"); + return; + } + + Gee.List messages = stream_interactor.get_module(SearchProcessor.IDENTITY).match_messages(search); + if (messages.size == 0) { + results_empty_stack.set_visible_child_name("no-result"); + } else { + results_empty_stack.set_visible_child_name("results"); + + int match_count = messages.size < 10 ? messages.size : stream_interactor.get_module(SearchProcessor.IDENTITY).count_match_messages(search); + entry_number_label.label = "" + _("%i search results").printf(match_count) + ""; + loaded_results += messages.size; + append_messages(messages); + } } - private void append_messages(Gee.List messages) { - foreach (Message message in messages) { - if (message.from == null) { - print("wtf null\n"); - continue; - } - Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation_for_message(message); - Gee.List before_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_messages_before_message(conversation, message.local_time, message.id, 1); - Gee.List after_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_messages_after_message(conversation, message.local_time, message.id, 1); + private void append_messages(Gee.List messages) { + foreach (MessageItem item in messages) { + Gee.List before_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_messages_before_message(item.conversation, item.message.local_time, item.message.id, 1); + Gee.List after_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_messages_after_message(item.conversation, item.message.local_time, item.message.id, 1); Box context_box = new Box(Orientation.VERTICAL, 5) { visible=true }; if (before_message != null && before_message.size > 0) { context_box.add(get_context_message_widget(before_message.first())); } - context_box.add(get_match_message_widget(message)); + + Widget match_widget = get_match_message_widget(item); + Util.force_alloc_width(match_widget, results_empty_stack.get_allocated_width() - results_box.margin * 2); + context_box.add(match_widget); + if (after_message != null && after_message.size > 0) { context_box.add(get_context_message_widget(after_message.first())); } - Label date_label = new Label(ConversationSummary.DefaultSkeletonHeader.get_relative_time(message.time)) { xalign=0, visible=true }; + Label date_label = new Label(ConversationSummary.DefaultSkeletonHeader.get_relative_time(item.display_time)) { xalign=0, visible=true }; date_label.get_style_context().add_class("dim-label"); - string display_name = Util.get_conversation_display_name(stream_interactor, conversation); - string title = message.type_ == Message.Type.GROUPCHAT ? _("In %s").printf(display_name) : _("With %s").printf(display_name); + string display_name = Util.get_conversation_display_name(stream_interactor, item.conversation); + string title = item.message.type_ == Message.Type.GROUPCHAT ? _("In %s").printf(display_name) : _("With %s").printf(display_name); Box header_box = new Box(Orientation.HORIZONTAL, 10) { margin_left=7, visible=true }; header_box.add(new Label(@"$(Markup.escape_text(title))") { ellipsize=EllipsizeMode.END, xalign=0, use_markup=true, visible=true }); header_box.add(date_label); @@ -94,21 +108,12 @@ class GlobalSearch : Box { } } - // Workaround GTK TextView issues - private void force_alloc_width(Widget widget, int width) { - Allocation alloc = Allocation(); - widget.get_preferred_width(out alloc.width, null); - widget.get_preferred_height(out alloc.height, null); - alloc.width = width; - widget.size_allocate(alloc); - } - - private Widget get_match_message_widget(Message message) { - Grid grid = get_skeleton(message); + private Widget get_match_message_widget(MessageItem item) { + Grid grid = get_skeleton(item); grid.margin_top = 3; grid.margin_bottom = 3; - string text = message.body.replace("\n", "").replace("\r", ""); + string text = item.message.body.replace("\n", "").replace("\r", ""); if (text.length > 200) { int index = text.index_of(search); if (index + search.length <= 100) { @@ -123,52 +128,68 @@ class GlobalSearch : Box { tv.buffer.text = text; TextTag link_tag = tv.buffer.create_tag("hit", background: "yellow"); - Regex url_regex = new Regex(search.down()); - MatchInfo match_info; - url_regex.match(text.down(), 0, out match_info); - for (; match_info.matches(); match_info.next()) { - int start; - int end; - match_info.fetch_pos(0, out start, out end); - start = text[0:start].char_count(); - end = text[0:end].char_count(); - TextIter start_iter; - TextIter end_iter; - tv.buffer.get_iter_at_offset(out start_iter, start); - tv.buffer.get_iter_at_offset(out end_iter, end); - tv.buffer.apply_tag(link_tag, start_iter, end_iter); + Gee.List keywords = get_keywords(Regex.escape_string(search.down())); + foreach (string keyword in keywords) { + Regex url_regex = new Regex(keyword.down()); + MatchInfo match_info; + url_regex.match(text.down(), 0, out match_info); + for (; match_info.matches(); match_info.next()) { + int start; + int end; + match_info.fetch_pos(0, out start, out end); + start = text[0:start].char_count(); + end = text[0:end].char_count(); + TextIter start_iter; + TextIter end_iter; + tv.buffer.get_iter_at_offset(out start_iter, start); + tv.buffer.get_iter_at_offset(out end_iter, end); + tv.buffer.apply_tag(link_tag, start_iter, end_iter); + } } - grid.attach(tv, 1, 1, 1, 1); - // force_alloc_width(tv, this.width_request); + grid.attach(tv, 1, 1, 1, 1); Button button = new Button() { relief=ReliefStyle.NONE, visible=true }; + button.clicked.connect(() => { + selected_item(item); + }); button.add(grid); return button; } - private Grid get_context_message_widget(Message message) { - Grid grid = get_skeleton(message); + private Grid get_context_message_widget(MessageItem item) { + Grid grid = get_skeleton(item); grid.margin_left = 7; - Label label = new Label(message.body.replace("\n", "").replace("\r", "")) { ellipsize=EllipsizeMode.MIDDLE, xalign=0, visible=true }; + Label label = new Label(item.message.body.replace("\n", "").replace("\r", "")) { ellipsize=EllipsizeMode.MIDDLE, xalign=0, visible=true }; grid.attach(label, 1, 1, 1, 1); grid.opacity = 0.55; return grid; } - private Grid get_skeleton(Message message) { + private Grid get_skeleton(MessageItem item) { AvatarImage image = new AvatarImage() { height=32, width=32, margin_right=7, valign=Align.START, visible=true, allow_gray = false }; - image.set_jid(stream_interactor, message.from, message.account); + image.set_jid(stream_interactor, item.jid, item.message.account); Grid grid = new Grid() { row_homogeneous=false, visible=true }; grid.attach(image, 0, 0, 1, 2); - string display_name = Util.get_display_name(stream_interactor, message.from, message.account); - string color = Util.get_name_hex_color(stream_interactor, message.account, message.from, false); // TODO Util.is_dark_theme(name_label) + string display_name = Util.get_display_name(stream_interactor, item.jid, item.message.account); + string color = Util.get_name_hex_color(stream_interactor, item.message.account, item.jid, false); // TODO Util.is_dark_theme(name_label) Label name_label = new Label("") { use_markup=true, xalign=0, visible=true }; name_label.label = @"$display_name"; grid.attach(name_label, 1, 0, 1, 1); return grid; } + + private static Gee.List get_keywords(string search_string) { + Gee.List ret = new ArrayList(); + foreach (string search in search_string.split(" ")) { + bool is_filter = search.has_prefix("from:") || search.has_prefix("in:") || search.has_prefix("with:"); + if (!is_filter && search != "") { + ret.add(search); + } + } + return ret; + } } } diff --git a/main/src/ui/unified_window.vala b/main/src/ui/unified_window.vala index cfcd2bff..60aeb832 100644 --- a/main/src/ui/unified_window.vala +++ b/main/src/ui/unified_window.vala @@ -39,7 +39,10 @@ public class UnifiedWindow : Window { setup_unified(); setup_stack(); - conversation_titlebar.search_button.bind_property("active", search_revealer, "reveal-child", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + + conversation_titlebar.search_button.clicked.connect(() => { + search_revealer.reveal_child = conversation_titlebar.search_button.active; + }); search_revealer.notify["child-revealed"].connect(() => { if (search_revealer.child_revealed) { if (conversation_frame.conversation != null) { @@ -58,6 +61,10 @@ public class UnifiedWindow : Window { search_box.search_entry.text = ""; } }); + search_box.selected_item.connect((item) => { + on_conversation_selected(item.conversation, false, false); + conversation_frame.initialize_around_message(item.conversation, item); + }); paned.bind_property("position", headerbar_paned, "position", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); @@ -71,8 +78,8 @@ public class UnifiedWindow : Window { accounts_placeholder.primary_button.clicked.connect(() => { get_application().activate_action("accounts", null); }); conversations_placeholder.primary_button.clicked.connect(() => { get_application().activate_action("add_chat", null); }); conversations_placeholder.secondary_button.clicked.connect(() => { get_application().activate_action("add_conference", null); }); - filterable_conversation_list.conversation_list.conversation_selected.connect(on_conversation_selected); - conversation_list_titlebar.conversation_opened.connect(on_conversation_selected); + filterable_conversation_list.conversation_list.conversation_selected.connect((conversation) => on_conversation_selected(conversation)); + conversation_list_titlebar.conversation_opened.connect((conversation) => on_conversation_selected(conversation)); check_stack(); } @@ -89,15 +96,21 @@ public class UnifiedWindow : Window { search_revealer.valign = Align.FILL; } - public void on_conversation_selected(Conversation conversation) { + public void on_conversation_selected(Conversation conversation, bool close_search = true, bool default_initialize_conversation = true) { if (this.conversation == null || !this.conversation.equals(conversation)) { this.conversation = conversation; stream_interactor.get_module(ChatInteraction.IDENTITY).on_conversation_selected(conversation); conversation.active = true; // only for conversation_selected filterable_conversation_list.conversation_list.on_conversation_selected(conversation); // only for conversation_opened + if (close_search) { + conversation_titlebar.search_button.active = false; + search_revealer.reveal_child = false; + } chat_input.initialize_for_conversation(conversation); - conversation_frame.initialize_for_conversation(conversation); + if (default_initialize_conversation) { + conversation_frame.initialize_for_conversation(conversation); + } conversation_titlebar.initialize_for_conversation(conversation); } } diff --git a/main/src/ui/util/helper.vala b/main/src/ui/util/helper.vala index 3cadfffb..4e9e942d 100644 --- a/main/src/ui/util/helper.vala +++ b/main/src/ui/util/helper.vala @@ -118,10 +118,6 @@ public static void force_background(Gtk.Widget widget, string color, string sele force_css(widget, force_background_css.printf(selector, color)); } -public static void force_base_background(Gtk.Widget widget, string selector = "*") { - force_background(widget, "@theme_base_color", selector); -} - public static void force_color(Gtk.Widget widget, string color, string selector = "*") { force_css(widget, force_color_css.printf(selector, color)); } @@ -142,4 +138,13 @@ public static bool is_24h_format() { return settings_format == "24h" || p_format == " "; } +// Workaround GTK TextView issues +public static void force_alloc_width(Widget widget, int width) { + Allocation alloc = Allocation(); + widget.get_preferred_width(out alloc.width, null); + widget.get_preferred_height(out alloc.height, null); + alloc.width = width; + widget.size_allocate(alloc); +} + } -- cgit v1.2.3-54-g00ecf From c0844bdea428c10949339960bd16ea5e2a335fb8 Mon Sep 17 00:00:00 2001 From: bobufa Date: Wed, 1 Aug 2018 15:20:56 +0200 Subject: add suggestions/auto-complete for search filters --- libdino/src/service/search_processor.vala | 159 ++++++++++++++++++- main/CMakeLists.txt | 1 + main/data/global_search.ui | 249 ++++++++++++++++-------------- main/data/search_autocomplete.ui | 24 +++ main/data/theme.css | 19 ++- main/data/unified_main_content.ui | 4 +- main/src/ui/global_search.vala | 68 +++++++- qlite/src/query_builder.vala | 16 +- 8 files changed, 410 insertions(+), 130 deletions(-) create mode 100644 main/data/search_autocomplete.ui (limited to 'main/src/ui/global_search.vala') diff --git a/libdino/src/service/search_processor.vala b/libdino/src/service/search_processor.vala index e56efa41..3f746981 100644 --- a/libdino/src/service/search_processor.vala +++ b/libdino/src/service/search_processor.vala @@ -31,19 +31,19 @@ public class SearchProcessor : StreamInteractionModule, Object { foreach(string word in query.split(" ")) { if (word.has_prefix("with:")) { if (with == null) { - with = word.substring(5) + "%"; + with = word.substring(5); } else { return db.message.select().where("0"); } } else if (word.has_prefix("in:")) { if (in_ == null) { - in_ = word.substring(3) + "%"; + in_ = word.substring(3); } else { return db.message.select().where("0"); } } else if (word.has_prefix("from:")) { if (from == null) { - from = word.substring(5) + "%"; + from = word.substring(5); } else { return db.message.select().where("0"); } @@ -90,9 +90,143 @@ public class SearchProcessor : StreamInteractionModule, Object { return rows; } + public Gee.List suggest_auto_complete(string query, int cursor_position, int limit = 5) { + int after_prev_space = query.substring(0, cursor_position).last_index_of(" ") + 1; + int next_space = query.index_of(" ", after_prev_space); + if (next_space < 0) next_space = query.length; + string current_query = query.substring(after_prev_space, next_space - after_prev_space); + Gee.List suggestions = new ArrayList(); + + if (current_query.has_prefix("from:")) { + if (cursor_position < after_prev_space + 5) return suggestions; + string current_from = current_query.substring(5); + string[] splitted = query.split(" "); + foreach(string s in splitted) { + if (s.has_prefix("from:") && s != "from:" + current_from) { + // Already have an from: filter -> no useful autocompletion possible + return suggestions; + } + } + string? current_in = null; + string? current_with = null; + foreach(string s in splitted) { + if (s.has_prefix("in:")) { + current_in = s.substring(3); + } else if (s.has_prefix("with:")) { + current_with = s.substring(5); + } + } + if (current_in != null && current_with != null) { + // in: and with: -> no useful autocompletion possible + return suggestions; + } + if (current_with != null) { + // Can only be the other one or us + + // Normal chat + QueryBuilder chats = db.conversation.select() + .join_with(db.jid, db.jid.id, db.conversation.jid_id) + .join_with(db.account, db.account.id, db.conversation.account_id) + .with(db.jid.bare_jid, "=", current_with) + .with(db.account.enabled, "=", true) + .with(db.conversation.type_, "=", Conversation.Type.CHAT) + .order_by(db.conversation.last_active, "DESC"); + foreach(Row chat in chats) { + if (suggestions.size == 0) { + suggestions.add(new SearchSuggestion(new Account.from_row(db, chat), new Jid(chat[db.jid.bare_jid]), "from:"+chat[db.jid.bare_jid], after_prev_space, next_space)); + } + suggestions.add(new SearchSuggestion(new Account.from_row(db, chat), new Jid(chat[db.account.bare_jid]), "from:"+chat[db.account.bare_jid], after_prev_space, next_space)); + } + return suggestions; + } + if (current_in != null) { + // All members of the MUC with history + QueryBuilder msgs = db.message.select() + .select_string(@"account.*, $(db.message.counterpart_resource)") + .join_with(db.jid, db.jid.id, db.message.counterpart_id) + .join_with(db.account, db.account.id, db.message.account_id) + .with(db.jid.bare_jid, "=", current_in) + .with(db.account.enabled, "=", true) + .with(db.message.type_, "=", Message.Type.GROUPCHAT) + .with(db.message.counterpart_resource, "LIKE", @"%$current_from%") + .group_by({db.message.counterpart_resource}) + .order_by_name(@"MAX($(db.message.time))", "DESC") + .limit(5); + foreach(Row msg in msgs) { + suggestions.add(new SearchSuggestion(new Account.from_row(db, msg), new Jid(current_in).with_resource(msg[db.message.counterpart_resource]), "from:"+msg[db.message.counterpart_resource], after_prev_space, next_space)); + } + } + // TODO: auto complete from + } else if (current_query.has_prefix("with:")) { + if (cursor_position < after_prev_space + 5) return suggestions; + string current_with = current_query.substring(5); + string[] splitted = query.split(" "); + foreach(string s in splitted) { + if ((s.has_prefix("with:") && s != "with:" + current_with) || s.has_prefix("in:")) { + // Already have an in: or with: filter -> no useful autocompletion possible + return suggestions; + } + } + + // Normal chat + QueryBuilder chats = db.conversation.select() + .join_with(db.jid, db.jid.id, db.conversation.jid_id) + .join_with(db.account, db.account.id, db.conversation.account_id) + .outer_join_on(db.roster, @"$(db.jid.bare_jid) = $(db.roster.jid) AND $(db.account.id) = $(db.roster.account_id)") + .where(@"$(db.jid.bare_jid) LIKE ? OR $(db.roster.handle) LIKE ?", {@"%$current_with%", @"%$current_with%"}) + .with(db.account.enabled, "=", true) + .with(db.conversation.type_, "=", Conversation.Type.CHAT) + .order_by(db.conversation.last_active, "DESC") + .limit(limit); + foreach(Row chat in chats) { + suggestions.add(new SearchSuggestion(new Account.from_row(db, chat), new Jid(chat[db.jid.bare_jid]), "with:"+chat[db.jid.bare_jid], after_prev_space, next_space) { order = chat[db.conversation.last_active]}); + } + + // Groupchat PM + if (suggestions.size < 5) { + chats = db.conversation.select() + .join_with(db.jid, db.jid.id, db.conversation.jid_id) + .join_with(db.account, db.account.id, db.conversation.account_id) + .where(@"$(db.jid.bare_jid) LIKE ? OR $(db.conversation.resource) LIKE ?", {@"%$current_with%", @"%$current_with%"}) + .with(db.account.enabled, "=", true) + .with(db.conversation.type_, "=", Conversation.Type.GROUPCHAT_PM) + .order_by(db.conversation.last_active, "DESC") + .limit(limit - suggestions.size); + foreach(Row chat in chats) { + suggestions.add(new SearchSuggestion(new Account.from_row(db, chat), new Jid(chat[db.jid.bare_jid]).with_resource(chat[db.conversation.resource]), "with:"+chat[db.jid.bare_jid]+"/"+chat[db.conversation.resource], after_prev_space, next_space) { order = chat[db.conversation.last_active]}); + } + suggestions.sort((a, b) => (int)(b.order - a.order)); + } + } else if (current_query.has_prefix("in:")) { + if (cursor_position < after_prev_space + 3) return suggestions; + string current_in = current_query.substring(3); + string[] splitted = query.split(" "); + foreach(string s in splitted) { + if ((s.has_prefix("in:") && s != "in:" + current_in) || s.has_prefix("with:")) { + // Already have an in: or with: filter -> no useful autocompletion possible + return suggestions; + } + } + QueryBuilder groupchats = db.conversation.select() + .join_with(db.jid, db.jid.id, db.conversation.jid_id) + .join_with(db.account, db.account.id, db.conversation.account_id) + .with(db.jid.bare_jid, "LIKE", @"%$current_in%") + .with(db.account.enabled, "=", true) + .with(db.conversation.type_, "=", Conversation.Type.GROUPCHAT) + .order_by(db.conversation.last_active, "DESC") + .limit(limit); + foreach(Row chat in groupchats) { + suggestions.add(new SearchSuggestion(new Account.from_row(db, chat), new Jid(chat[db.jid.bare_jid]), "in:"+chat[db.jid.bare_jid], after_prev_space, next_space)); + } + } else { + // Other auto complete? + } + return suggestions; + } + public Gee.List match_messages(string query, int offset = -1) { Gee.List ret = new ArrayList(); - var rows = prepare_search(query, true).limit(10); + QueryBuilder rows = prepare_search(query, false).limit(10); if (offset > 0) { rows.offset(offset); } @@ -109,4 +243,21 @@ public class SearchProcessor : StreamInteractionModule, Object { } } +public class SearchSuggestion : Object { + public Account account { get; private set; } + public Jid? jid { get; private set; } + public string completion { get; private set; } + public int start_index { get; private set; } + public int end_index { get; private set; } + public long order { get; set; } + + public SearchSuggestion(Account account, Jid? jid, string completion, int start_index, int end_index) { + this.account = account; + this.jid = jid; + this.completion = completion; + this.start_index = start_index; + this.end_index = end_index; + } +} + } diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 1af08217..49b1a9fc 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -44,6 +44,7 @@ set(RESOURCE_LIST menu_encryption.ui occupant_list.ui occupant_list_item.ui + search_autocomplete.ui settings_dialog.ui unified_main_content.ui unified_window_placeholder.ui diff --git a/main/data/global_search.ui b/main/data/global_search.ui index 3c4597c1..44abf6de 100644 --- a/main/data/global_search.ui +++ b/main/data/global_search.ui @@ -1,144 +1,167 @@ -