aboutsummaryrefslogtreecommitdiff
path: root/main/src/ui
diff options
context:
space:
mode:
Diffstat (limited to 'main/src/ui')
-rw-r--r--main/src/ui/application.vala50
-rw-r--r--main/src/ui/conversation_content_view/code_block_widget.vala225
-rw-r--r--main/src/ui/conversation_content_view/conversation_view.vala21
-rw-r--r--main/src/ui/conversation_content_view/file_video_widget.vala174
-rw-r--r--main/src/ui/conversation_content_view/file_widget.vala16
-rw-r--r--main/src/ui/conversation_content_view/message_widget.vala267
-rw-r--r--main/src/ui/conversation_content_view/quote_widget.vala10
-rw-r--r--main/src/ui/conversation_content_view/syntax_highlighting.vala136
-rw-r--r--main/src/ui/conversation_selector/conversation_selector.vala12
-rw-r--r--main/src/ui/conversation_selector/conversation_selector_row.vala31
-rw-r--r--main/src/ui/conversation_view_controller.vala12
-rw-r--r--main/src/ui/launcher_badge.vala118
-rw-r--r--main/src/ui/main_window.vala27
-rw-r--r--main/src/ui/notifier_freedesktop.vala57
-rw-r--r--main/src/ui/status_notifier_item.vala212
-rw-r--r--main/src/ui/status_notifier_menu.vala100
-rw-r--r--main/src/ui/widgets/avatar_picture.vala21
17 files changed, 1396 insertions, 93 deletions
diff --git a/main/src/ui/application.vala b/main/src/ui/application.vala
index 83290743..cb3d52e4 100644
--- a/main/src/ui/application.vala
+++ b/main/src/ui/application.vala
@@ -14,6 +14,9 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application {
public MainWindow window;
public MainWindowController controller;
+ private LauncherBadge? launcher_badge;
+ private StatusNotifierItem? status_notifier_item;
+ private CssProvider custom_css_provider;
public Database db { get; set; }
public Dino.Entities.Settings settings { get; set; }
@@ -31,6 +34,13 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application {
public Application() throws Error {
Object(application_id: "im.dino.Dino", flags: ApplicationFlags.HANDLES_OPEN);
init();
+ custom_css_provider = new CssProvider();
+ settings.notify["custom-css"].connect(update_custom_css);
+ update_custom_css();
+ Gdk.Display? display = Gdk.Display.get_default();
+ if (display != null) {
+ StyleContext.add_provider_for_display(display, custom_css_provider, STYLE_PROVIDER_PRIORITY_USER);
+ }
Environment.set_application_name("Dino");
Window.set_default_icon_name("im.dino.Dino");
@@ -43,6 +53,18 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application {
Process.exit(0);
}
+ try {
+ status_notifier_item = new StatusNotifierItem(this);
+ } catch (IOError e) {
+ warning("Could not create Dino's tray icon: %s", e.message);
+ }
+
+ try {
+ launcher_badge = new LauncherBadge(stream_interactor);
+ } catch (IOError e) {
+ warning("Could not create Dino's launcher badge: %s", e.message);
+ }
+
NotificationEvents notification_events = stream_interactor.get_module(NotificationEvents.IDENTITY);
get_notifications_dbus.begin((_, res) => {
// It might take a bit to get the interface. NotificationEvents will queue any notifications in the meantime.
@@ -68,18 +90,37 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application {
stream_interactor.get_module(FileManager.IDENTITY).add_metadata_provider(new Util.AudioVideoFileMetadataProvider());
});
+ shutdown.connect(() => {
+ if (launcher_badge != null) {
+ launcher_badge.shutdown();
+ launcher_badge = null;
+ }
+ if (status_notifier_item != null) {
+ status_notifier_item.shutdown();
+ status_notifier_item = null;
+ }
+ });
+
activate.connect(() => {
if (window == null) {
controller = new MainWindowController(this, stream_interactor, db);
config = new Config(db);
window = new MainWindow(this, stream_interactor, db, config);
controller.set_window(window);
- if ((get_flags() & ApplicationFlags.IS_SERVICE) == ApplicationFlags.IS_SERVICE) window.hide_on_close = true;
+ window.hide_on_close = true;
}
window.present();
});
}
+ private void update_custom_css() {
+#if GTK_4_12
+ custom_css_provider.load_from_string(settings.custom_css);
+#else
+ custom_css_provider.load_from_data(settings.custom_css.data);
+#endif
+ }
+
public void handle_uri(string jid, string query, Gee.Map<string, string> options) {
switch (query) {
case "join":
@@ -129,6 +170,12 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application {
about_action.activate.connect(show_about_window);
add_action(about_action);
+ SimpleAction mark_all_as_read_action = new SimpleAction("mark_all_as_read", null);
+ mark_all_as_read_action.activate.connect(() => {
+ stream_interactor.get_module(ChatInteraction.IDENTITY).mark_all_as_read.begin();
+ });
+ add_action(mark_all_as_read_action);
+
SimpleAction quit_action = new SimpleAction("quit", null);
quit_action.activate.connect(quit);
add_action(quit_action);
@@ -316,4 +363,3 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application {
dialog.present();
}
}
-
diff --git a/main/src/ui/conversation_content_view/code_block_widget.vala b/main/src/ui/conversation_content_view/code_block_widget.vala
new file mode 100644
index 00000000..3b24c944
--- /dev/null
+++ b/main/src/ui/conversation_content_view/code_block_widget.vala
@@ -0,0 +1,225 @@
+using Gee;
+using Gdk;
+using Gtk;
+using Pango;
+
+namespace Dino.Ui.ConversationSummary {
+
+public class CodeBlockPart : Object {
+ public bool is_code;
+ public string text;
+ public string language;
+ public int source_start_char;
+
+ public CodeBlockPart.from_text(string text, int source_start_char) {
+ this.text = text;
+ this.source_start_char = source_start_char;
+ this.language = "";
+ }
+
+ public CodeBlockPart.from_code(string text, string language) {
+ this.is_code = true;
+ this.text = text;
+ this.language = language;
+ }
+}
+
+public class CodeBlockParser : Object {
+ private static Regex? fence_regex;
+
+ public static ArrayList<CodeBlockPart> parse(string text, out bool has_blocks) {
+ var parts = new ArrayList<CodeBlockPart>();
+ Regex regex = get_fence_regex();
+ MatchInfo match_info;
+ int offset = 0;
+ has_blocks = false;
+ try {
+ regex.match_full(text, -1, offset, 0, out match_info);
+ } catch (RegexError error) {
+ parts.add(new CodeBlockPart.from_text(text, 0));
+ return parts;
+ }
+ while (match_info.matches()) {
+ int match_start;
+ int match_end;
+ int code_start;
+ int code_end;
+ match_info.fetch_pos(0, out match_start, out match_end);
+ match_info.fetch_pos(2, out code_start, out code_end);
+ int text_end = match_start;
+ if (text_end > offset && text[text_end - 1] == '\n') {
+ text_end--;
+ if (text_end > offset && text[text_end - 1] == '\r') text_end--;
+ }
+ if (text_end > offset) {
+ parts.add(new CodeBlockPart.from_text(text.substring(offset, text_end - offset), text.substring(0, offset).char_count()));
+ }
+ if (code_end > code_start && text[code_end - 1] == '\n') {
+ code_end--;
+ if (code_end > code_start && text[code_end - 1] == '\r') code_end--;
+ }
+ string code = text.substring(code_start, code_end - code_start).replace("\r\n", "\n");
+ parts.add(new CodeBlockPart.from_code(code, match_info.fetch(1).strip()));
+ has_blocks = true;
+ offset = match_end;
+ if (offset >= text.length) break;
+ try {
+ regex.match_full(text, -1, offset, 0, out match_info);
+ } catch (RegexError error) {
+ break;
+ }
+ }
+ if (offset < text.length) {
+ parts.add(new CodeBlockPart.from_text(text.substring(offset), text.substring(0, offset).char_count()));
+ }
+ return parts;
+ }
+
+ private static Regex get_fence_regex() {
+ if (fence_regex == null) {
+ try {
+ fence_regex = new Regex("(?ms)^```[\\t ]*([^\\r\\n`]*)[\\t ]*\\r?\\n(.*?)^```[\\t ]*(?:\\r?\\n|$)", RegexCompileFlags.OPTIMIZE);
+ } catch (RegexError error) {
+ GLib.error("Invalid code block expression: %s", error.message);
+ }
+ }
+ return fence_regex;
+ }
+}
+
+public class CodeBlockWidget : Box {
+ private string code;
+ private CodeSyntax.LanguageDefinition? definition;
+ private Label code_label;
+ private ulong realize_id;
+
+ public CodeBlockWidget(string code, string language) {
+ Object(orientation: Orientation.VERTICAL, spacing: 0, halign: Align.START, overflow: Overflow.HIDDEN);
+ this.code = code;
+ definition = CodeSyntax.find(language);
+ add_css_class("code-block");
+
+ var header = new Box(Orientation.HORIZONTAL, 8);
+ header.add_css_class("code-block-header");
+
+ string language_name = definition != null ? definition.name : language.strip();
+ if (language_name == "") language_name = _("Plain text");
+ var language_label = new Label(language_name) { xalign=0, hexpand=true };
+ language_label.add_css_class("code-block-language");
+ header.append(language_label);
+
+ int line_count = code == "" ? 0 : code.split("\n").length;
+ var line_label = new Label(n("%d line", "%d lines", line_count).printf(line_count));
+ line_label.add_css_class("code-block-meta");
+ header.append(line_label);
+
+ var download_button = new Button.from_icon_name("dino-file-download-symbolic") {
+ tooltip_text=_("Download code")
+ };
+ download_button.add_css_class("flat");
+ download_button.add_css_class("code-block-download");
+ download_button.clicked.connect(save);
+ header.append(download_button);
+ append(header);
+
+ code_label = new Label(code) {
+ use_markup=false,
+ selectable=true,
+ xalign=0,
+ yalign=0,
+ wrap=false
+ };
+ code_label.add_css_class("message-text");
+ code_label.add_css_class("code-block-content");
+ append(code_label);
+
+ apply_highlighting();
+ realize_id = realize.connect(apply_highlighting);
+ }
+
+ private void apply_highlighting() {
+ var attributes = new AttrList();
+ if (definition != null) {
+ foreach (CodeSyntax.HighlightSpan span in definition.highlight(code)) {
+ RGBA color = get_token_color(span.style);
+ Attribute foreground = Pango.attr_foreground_new(
+ (uint16) Math.round(color.red * uint16.MAX),
+ (uint16) Math.round(color.green * uint16.MAX),
+ (uint16) Math.round(color.blue * uint16.MAX)
+ );
+ foreground.start_index = span.start;
+ foreground.end_index = span.end;
+ attributes.insert(foreground.copy());
+ if (color.alpha < 1) {
+ Attribute alpha = Pango.attr_foreground_alpha_new((uint16) Math.round(color.alpha * uint16.MAX));
+ alpha.start_index = span.start;
+ alpha.end_index = span.end;
+ attributes.insert(alpha.copy());
+ }
+ }
+ }
+ code_label.attributes = attributes;
+ }
+
+ private RGBA get_token_color(CodeSyntax.TokenStyle style) {
+ StyleContext context = code_label.get_style_context();
+ RGBA color = context.get_color();
+ string? color_name = null;
+ switch (style) {
+ case CodeSyntax.TokenStyle.COMMENT:
+ color.alpha = 0.62f;
+ break;
+ case CodeSyntax.TokenStyle.CONSTANT:
+ color_name = "error_color";
+ break;
+ case CodeSyntax.TokenStyle.NUMBER:
+ case CodeSyntax.TokenStyle.PREPROCESSOR:
+ color_name = "warning_color";
+ break;
+ case CodeSyntax.TokenStyle.STRING:
+ case CodeSyntax.TokenStyle.TYPE:
+ color_name = "success_color";
+ break;
+ case CodeSyntax.TokenStyle.FUNCTION:
+ case CodeSyntax.TokenStyle.KEYWORD:
+ color_name = "accent_color";
+ break;
+ }
+ if (color_name != null) {
+ RGBA theme_color;
+ if (context.lookup_color(color_name, out theme_color)) color = theme_color;
+ }
+ return color;
+ }
+
+ private void save() {
+ var dialog = new FileChooserNative(_("Save code as…"), get_root() as Gtk.Window, FileChooserAction.SAVE, _("Save"), _("Cancel"));
+ dialog.modal = true;
+ dialog.set_current_name("code." + (definition != null ? definition.extension : "txt"));
+ dialog.response.connect((response_id) => {
+ if (response_id == ResponseType.ACCEPT) {
+ File? target = dialog.get_file();
+ if (target != null) {
+ try {
+ string? etag;
+ target.replace_contents(code.data, null, false, FileCreateFlags.REPLACE_DESTINATION, out etag);
+ } catch (Error error) {
+ warning("Failed to save code: %s", error.message);
+ }
+ }
+ }
+ dialog.destroy();
+ });
+ dialog.show();
+ }
+
+ public override void dispose() {
+ if (realize_id != 0) {
+ disconnect(realize_id);
+ realize_id = 0;
+ }
+ base.dispose();
+ }
+}
+
+}
diff --git a/main/src/ui/conversation_content_view/conversation_view.vala b/main/src/ui/conversation_content_view/conversation_view.vala
index 6a686f1b..83e86827 100644
--- a/main/src/ui/conversation_content_view/conversation_view.vala
+++ b/main/src/ui/conversation_content_view/conversation_view.vala
@@ -55,6 +55,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug
Widget currently_highlighted = null;
ContentMetaItem? current_meta_item = null;
double last_y = -1;
+ private bool message_text_selectable = true;
private Button create_action_button(string action_name) {
var button = new Button() { name=action_name };
@@ -82,6 +83,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug
create_action_button("reply");
create_action_button("delete");
+ create_action_button("copy");
var menu_button = new MenuButton() { name="menu", tooltip_text=_("More actions") };
action_buttons["menu"] = menu_button;
@@ -127,7 +129,10 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug
// reaches the overlapping part of a button.
EventControllerMotion main_motion_events = new EventControllerMotion();
main.add_controller(main_motion_events);
- main_motion_events.motion.connect(update_highlight);
+ main_motion_events.motion.connect((x, y) => {
+ set_message_text_selectable(true);
+ update_highlight(x, y);
+ });
// Process touch events and capture phase to allow highlighting a message without cursor
GestureClick click_controller = new GestureClick();
@@ -135,12 +140,23 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug
click_controller.propagation_phase = Gtk.PropagationPhase.CAPTURE;
main_wrap_box.add_controller(click_controller);
click_controller.pressed.connect_after((n, x, y) => {
+ set_message_text_selectable(false);
update_highlight(x, y);
});
return this;
}
+ private void set_message_text_selectable(bool selectable) {
+ if (message_text_selectable == selectable) return;
+
+ message_text_selectable = selectable;
+ foreach (Plugins.MetaConversationItem item in meta_items) {
+ MessageMetaItem? message_item = item as MessageMetaItem;
+ if (message_item != null) message_item.set_text_selectable(selectable);
+ }
+ }
+
public void activate_last_message_correction() {
Gee.BidirIterator<Plugins.MetaConversationItem> iter = content_items.bidir_iterator();
iter.last();
@@ -578,6 +594,9 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug
private Widget insert_new(Plugins.MetaConversationItem item) {
Plugins.MetaConversationItem? lower_item = meta_items.lower(item);
+ MessageMetaItem? message_item = item as MessageMetaItem;
+ if (message_item != null) message_item.set_text_selectable(message_text_selectable);
+
// Fill datastructure
ConversationItemSkeleton item_skeleton = new ConversationItemSkeleton(stream_interactor, conversation, item);
item_item_skeletons[item] = item_skeleton;
diff --git a/main/src/ui/conversation_content_view/file_video_widget.vala b/main/src/ui/conversation_content_view/file_video_widget.vala
new file mode 100644
index 00000000..703428d9
--- /dev/null
+++ b/main/src/ui/conversation_content_view/file_video_widget.vala
@@ -0,0 +1,174 @@
+using Gtk;
+
+using Dino.Entities;
+
+namespace Dino.Ui {
+
+public class FileVideoWidget : Widget {
+ private const int MIN_WIDTH = 100;
+ private const int MIN_HEIGHT = 100;
+ private const int MAX_WIDTH = 600;
+ private const int MAX_HEIGHT = 300;
+
+ private Overlay overlay = new Overlay();
+ private FixedRatioVideo video = new FixedRatioVideo() {
+ min_width = MIN_WIDTH,
+ min_height = MIN_HEIGHT,
+ max_width = MAX_WIDTH,
+ max_height = MAX_HEIGHT
+ };
+ private Gtk.Box video_overlay_toolbar = new Gtk.Box(Orientation.VERTICAL, 0) {
+ halign = Align.END,
+ valign = Align.START,
+ margin_top = 10,
+ margin_start = 10,
+ margin_end = 10,
+ margin_bottom = 10,
+ vexpand = false,
+ visible = false
+ };
+
+ construct {
+ layout_manager = new BinLayout();
+ check_widget_leak(this);
+ }
+
+ public FileVideoWidget(FileTransfer file_transfer) {
+ this.halign = Align.START;
+ this.add_css_class("file-video-widget");
+
+ MenuButton button = new MenuButton();
+ button.icon_name = "dino-view-more";
+ Menu menu_model = new Menu();
+ menu_model.append(_("Open"), "file.open");
+ menu_model.append(_("Save as…"), "file.save_as");
+ Gtk.PopoverMenu popover_menu = new Gtk.PopoverMenu.from_model(menu_model);
+ button.popover = popover_menu;
+ video_overlay_toolbar.append(button);
+ video_overlay_toolbar.add_css_class("card");
+ video_overlay_toolbar.add_css_class("toolbar");
+ video_overlay_toolbar.add_css_class("compact-card-toolbar");
+ video_overlay_toolbar.set_cursor_from_name("default");
+
+ video.file = file_transfer.get_file();
+
+ overlay.set_child(video);
+ overlay.add_overlay(video_overlay_toolbar);
+ overlay.set_clip_overlay(video_overlay_toolbar, true);
+ overlay.insert_after(this, null);
+
+ EventControllerMotion motion_events = new EventControllerMotion();
+ this.add_controller(motion_events);
+ motion_events.enter.connect(() => {
+ video_overlay_toolbar.visible = true;
+ });
+ motion_events.leave.connect(() => {
+ if (button.popover != null && button.popover.visible) return;
+ video_overlay_toolbar.visible = false;
+ });
+ }
+
+ public static bool can_display(FileTransfer file_transfer) {
+ if (file_transfer.state != FileTransfer.State.COMPLETE || file_transfer.path == null || file_transfer.content_type == null) {
+ return false;
+ }
+
+ string? mime_type = file_transfer.content_type.get_mime_type();
+ return mime_type != null && mime_type.has_prefix("video/");
+ }
+
+ public override void dispose() {
+ if (video.media_stream != null) video.media_stream.pause();
+ if (overlay != null && overlay.parent != null) overlay.unparent();
+ base.dispose();
+ }
+}
+
+private class FixedRatioVideo : Widget {
+ public int min_width { get; set; default = -1; }
+ public int max_width { get; set; default = int.MAX; }
+ public int min_height { get; set; default = -1; }
+ public int max_height { get; set; default = int.MAX; }
+ public File? file { get { return inner.file; } set { inner.file = value; } }
+ public MediaStream? media_stream { get { return inner.media_stream; } }
+
+ private Gtk.Video inner = new Gtk.Video() {
+ autoplay = false,
+ loop = false
+ };
+
+ construct {
+ set_css_name("video");
+ inner.insert_after(this, null);
+ this.notify.connect(queue_resize);
+ check_widget_leak(this);
+ }
+
+ private void measure_target_size(out int width, out int height) {
+ if (width_request != -1 && height_request != -1) {
+ width = width_request;
+ height = height_request;
+ return;
+ }
+
+ width = min_width;
+ height = min_height;
+
+ if (inner.should_layout()) {
+ int child_min, child_nat, child_min_baseline, child_nat_baseline;
+ inner.measure(Orientation.HORIZONTAL, -1, out child_min, out child_nat, out child_min_baseline, out child_nat_baseline);
+ width = int.max(child_nat, width);
+ }
+ width = int.min(width, max_width);
+
+ if (inner.should_layout()) {
+ int child_min, child_nat, child_min_baseline, child_nat_baseline;
+ inner.measure(Orientation.VERTICAL, width, out child_min, out child_nat, out child_min_baseline, out child_nat_baseline);
+ height = int.max(child_nat, height);
+ }
+
+ if (height > max_height) {
+ height = max_height;
+ width = min_width;
+
+ if (inner.should_layout()) {
+ int child_min, child_nat, child_min_baseline, child_nat_baseline;
+ inner.measure(Orientation.HORIZONTAL, max_height, out child_min, out child_nat, out child_min_baseline, out child_nat_baseline);
+ width = int.max(child_nat, width);
+ }
+ width = int.min(width, max_width);
+ }
+ }
+
+ public override void measure(Orientation orientation, int for_size, out int minimum, out int natural, out int minimum_baseline, out int natural_baseline) {
+ minimum_baseline = -1;
+ natural_baseline = -1;
+
+ int width, height;
+ measure_target_size(out width, out height);
+ if (orientation == Orientation.HORIZONTAL) {
+ minimum = min_width;
+ natural = int.max(min_width, int.min(width, max_width));
+ } else if (for_size == -1) {
+ minimum = min_height;
+ natural = int.max(min_height, int.min(height, max_height));
+ } else {
+ minimum = natural = int.max(min_height, int.min(height * for_size / width, height));
+ }
+ }
+
+ public override void size_allocate(int width, int height, int baseline) {
+ if (inner.should_layout()) inner.allocate(width, height, baseline, null);
+ }
+
+ public override SizeRequestMode get_request_mode() {
+ return SizeRequestMode.HEIGHT_FOR_WIDTH;
+ }
+
+ public override void dispose() {
+ inner.unparent();
+ base.dispose();
+ }
+}
+
+}
diff --git a/main/src/ui/conversation_content_view/file_widget.vala b/main/src/ui/conversation_content_view/file_widget.vala
index 04185343..d6d2415a 100644
--- a/main/src/ui/conversation_content_view/file_widget.vala
+++ b/main/src/ui/conversation_content_view/file_widget.vala
@@ -46,6 +46,7 @@ public class FileWidget : SizeRequestBin {
enum State {
IMAGE,
+ VIDEO,
DEFAULT
}
@@ -77,6 +78,7 @@ public class FileWidget : SizeRequestBin {
private async void update_widget() {
bool show_image = FileImageWidget.can_display(file_transfer);
+ bool show_video = FileVideoWidget.can_display(file_transfer);
if (show_image && state != State.IMAGE) {
var content_bak = content;
@@ -97,7 +99,19 @@ public class FileWidget : SizeRequestBin {
} catch (Error e) { }
}
- if (!show_image && state != State.DEFAULT) {
+ if (show_image && state == State.IMAGE) return;
+
+ if (show_video && state != State.VIDEO) {
+ if (content != null) content.unparent();
+ content = new FileVideoWidget(file_transfer);
+ state = State.VIDEO;
+ content.insert_after(this, null);
+ return;
+ }
+
+ if (show_video && state == State.VIDEO) return;
+
+ if (state != State.DEFAULT) {
if (content != null) content.unparent();
FileDefaultWidget default_file_widget = new FileDefaultWidget();
default_widget_controller = new FileDefaultWidgetController(default_file_widget);
diff --git a/main/src/ui/conversation_content_view/message_widget.vala b/main/src/ui/conversation_content_view/message_widget.vala
index 25d3775b..c5a9c145 100644
--- a/main/src/ui/conversation_content_view/message_widget.vala
+++ b/main/src/ui/conversation_content_view/message_widget.vala
@@ -30,12 +30,16 @@ public class MessageMetaItem : ContentMetaItem {
uint pending_timeout_id = -1;
public Label label = new Label("") { use_markup=true, xalign=0, selectable=true, wrap=true, wrap_mode=Pango.WrapMode.WORD_CHAR, hexpand=true, vexpand=true };
+ private Widget? content_widget;
+ private bool text_selectable = true;
public MessageMetaItem(ContentItem content_item, StreamInteractor stream_interactor) {
base(content_item);
message_item = content_item as MessageItem;
this.stream_interactor = stream_interactor;
+ label.add_css_class("message-text");
+
stream_interactor.get_module(MessageCorrection.IDENTITY).received_correction.connect(on_updated_item);
stream_interactor.get_module(MessageDeletion.IDENTITY).item_deleted.connect(on_updated_item);
@@ -73,111 +77,176 @@ public class MessageMetaItem : ContentMetaItem {
update_label();
}
- private void generate_markup_text(ContentItem item, Label label) {
- MessageItem message_item = item as MessageItem;
- Conversation conversation = message_item.conversation;
+ private void generate_markup_text(Label label, string text, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) {
Message message = message_item.message;
+ label.set_attributes(create_text_attributes(text, 0, markups, action_name_length));
- // Get a copy of the markup spans, such that we can modify them
- var markups = new ArrayList<Xep.MessageMarkup.Span>();
- foreach (var markup in message.get_markups()) {
- markups.add(new Xep.MessageMarkup.Span() { types=markup.types, start_char=markup.start_char, end_char=markup.end_char });
+ bool theme_dependent;
+ string markup_text = parse_text_markup(label, text, out theme_dependent);
+
+ int only_emoji_count = Util.get_only_emoji_count(markup_text);
+ if (only_emoji_count != -1) {
+ string size_str = only_emoji_count < 5 ? "xx-large" : "large";
+ markup_text = @"<span size=\'$size_str\'>" + markup_text + "</span>";
}
- string markup_text = message.body;
+ string dim_color = Util.is_dark_theme(label) ? "#BDBDBD" : "#707070";
- var attrs = new AttrList();
- label.set_attributes(attrs);
+ if (message.body == "") {
+ markup_text = @"<i><span size='small' color='$dim_color'>%s</span></i>".printf(_("Message deleted"));
+ theme_dependent = true;
+ }
+ markup_text += get_meta_markup(label, true, ref theme_dependent);
- if (markup_text == null) return; // TODO remove
+ if (theme_dependent && realize_id == -1 && !label.get_realized()) {
+ realize_id = label.realize.connect(update_label);
+ } else if (!theme_dependent && realize_id != -1) {
+ label.disconnect(realize_id);
+ }
+ label.label = markup_text;
+ }
- // Only process messages up to a certain size
- if (markup_text.length > 10000) {
- markup_text = markup_text.substring(0, 10000) + " [" + _("Message too long") + "]";
+ private ArrayList<Xep.MessageMarkup.Span> copy_markups(Message message) {
+ var markups = new ArrayList<Xep.MessageMarkup.Span>();
+ foreach (var markup in message.get_markups()) {
+ markups.add(new Xep.MessageMarkup.Span() { types=markup.types, start_char=markup.start_char, end_char=markup.end_char });
}
+ return markups;
+ }
- bool theme_dependent = false;
+ private string prepare_message(ArrayList<Xep.MessageMarkup.Span> markups, out int action_name_length, bool truncate = true) {
+ Message message = message_item.message;
+ string text = message.body ?? "";
+ action_name_length = 0;
- markup_text = Util.remove_fallbacks_adjust_markups(markup_text, message.quoted_item_id > 0, message.get_fallbacks(), markups);
+ if (truncate && text.char_count() > 10000) {
+ text = text.substring(0, text.index_of_nth_char(10000)) + " [" + _("Message too long") + "]";
+ }
- var bold_attr = Pango.attr_weight_new(Pango.Weight.BOLD);
- var italic_attr = Pango.attr_style_new(Pango.Style.ITALIC);
- var strikethrough_attr = Pango.attr_strikethrough_new(true);
+ text = Util.remove_fallbacks_adjust_markups(text, message.quoted_item_id > 0, message.get_fallbacks(), markups);
- // Prefix message with name instead of /me
- if (markup_text.has_prefix("/me ")) {
- string display_name = Util.get_participant_display_name(stream_interactor, conversation, message.from);
- markup_text = display_name + " " + markup_text.substring(4);
+ if (text.has_prefix("/me ")) {
+ string display_name = Util.get_participant_display_name(stream_interactor, message_item.conversation, message.from);
+ text = display_name + " " + text.substring(4);
+ action_name_length = display_name.char_count();
foreach (Xep.MessageMarkup.Span span in markups) {
- int length = display_name.char_count() - 4 + 1;
- span.start_char += length;
- span.end_char += length;
+ int difference = action_name_length - 3;
+ span.start_char += difference;
+ span.end_char += difference;
}
+ }
+
+ return text;
+ }
- bold_attr.end_index = display_name.length;
- italic_attr.end_index = display_name.length;
- attrs.insert(bold_attr.copy());
- attrs.insert(italic_attr.copy());
+ private Label create_text_label() {
+ var text_label = new Label("") {
+ use_markup=true,
+ xalign=0,
+ selectable=text_selectable,
+ wrap=true,
+ wrap_mode=Pango.WrapMode.WORD_CHAR,
+ hexpand=true
+ };
+ text_label.add_css_class("message-text");
+ text_label.activate_link.connect(on_label_activate_link);
+ return text_label;
+ }
+
+ public void set_text_selectable(bool selectable) {
+ text_selectable = selectable;
+ if (content_widget != null) set_text_selectable_recursive(content_widget, selectable);
+ }
+
+ private void set_text_selectable_recursive(Widget widget, bool selectable) {
+ Label? text_label = widget as Label;
+ if (text_label != null && text_label.has_css_class("message-text")) {
+ text_label.selectable = selectable;
+ }
+
+ for (Widget? child = widget.get_first_child(); child != null; child = child.get_next_sibling()) {
+ set_text_selectable_recursive(child, selectable);
}
+ }
+
+ private void add_text_attribute(AttrList attributes, Attribute attribute, string text, int source_start, int span_start, int span_end) {
+ int source_end = source_start + text.char_count();
+ int start = int.max(source_start, span_start);
+ int end = int.min(source_end, span_end);
+ if (start >= end) return;
+
+ attribute.start_index = text.index_of_nth_char(start - source_start);
+ attribute.end_index = text.index_of_nth_char(end - source_start);
+ attributes.insert(attribute.copy());
+ }
+ private AttrList create_text_attributes(string text, int source_start, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) {
+ var attributes = new AttrList();
foreach (var markup in markups) {
- foreach (var ty in markup.types) {
- Attribute attr = null;
- switch (ty) {
+ foreach (var type in markup.types) {
+ Attribute attribute;
+ switch (type) {
case Xep.MessageMarkup.SpanType.EMPHASIS:
- attr = Pango.attr_style_new(Pango.Style.ITALIC);
+ attribute = Pango.attr_style_new(Pango.Style.ITALIC);
break;
case Xep.MessageMarkup.SpanType.STRONG_EMPHASIS:
- attr = Pango.attr_weight_new(Pango.Weight.BOLD);
+ attribute = Pango.attr_weight_new(Pango.Weight.BOLD);
break;
case Xep.MessageMarkup.SpanType.DELETED:
- attr = Pango.attr_strikethrough_new(true);
+ attribute = Pango.attr_strikethrough_new(true);
break;
+ default:
+ continue;
}
- attr.start_index = markup_text.index_of_nth_char(markup.start_char);
- attr.end_index = markup_text.index_of_nth_char(markup.end_char);
- attrs.insert(attr.copy());
+ add_text_attribute(attributes, (owned) attribute, text, source_start, markup.start_char, markup.end_char);
}
}
- // Work around pango bug
- markup_text = Util.unbreak_space_around_non_spacing_mark((owned) markup_text);
+ if (action_name_length > 0) {
+ add_text_attribute(attributes, Pango.attr_weight_new(Pango.Weight.BOLD), text, source_start, 0, action_name_length);
+ add_text_attribute(attributes, Pango.attr_style_new(Pango.Style.ITALIC), text, source_start, 0, action_name_length);
+ }
+
+ return attributes;
+ }
+ private string parse_text_markup(Label text_label, string text, out bool theme_dependent) {
+ string markup_text = Util.unbreak_space_around_non_spacing_mark(text);
+ theme_dependent = false;
+ Conversation conversation = message_item.conversation;
if (conversation.type_ == Conversation.Type.GROUPCHAT) {
- markup_text = Util.parse_add_markup_theme(markup_text, conversation.nickname, true, true, true, Util.is_dark_theme(this.label), ref theme_dependent);
- } else {
- markup_text = Util.parse_add_markup_theme(markup_text, null, true, true, true, Util.is_dark_theme(this.label), ref theme_dependent);
+ return Util.parse_add_markup_theme(markup_text, conversation.nickname, true, true, true, Util.is_dark_theme(text_label), ref theme_dependent);
}
+ return Util.parse_add_markup_theme(markup_text, null, true, true, true, Util.is_dark_theme(text_label), ref theme_dependent);
+ }
- int only_emoji_count = Util.get_only_emoji_count(markup_text);
- if (only_emoji_count != -1) {
- string size_str = only_emoji_count < 5 ? "xx-large" : "large";
- markup_text = @"<span size=\'$size_str\'>" + markup_text + "</span>";
- }
+ private void format_text_segment(Label text_label, CodeBlockPart part, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) {
+ text_label.set_attributes(create_text_attributes(part.text, part.source_start_char, markups, action_name_length));
+ bool theme_dependent;
+ text_label.label = parse_text_markup(text_label, part.text, out theme_dependent);
+ }
- string dim_color = Util.is_dark_theme(this.label) ? "#BDBDBD" : "#707070";
+ private string get_meta_markup(Label meta_label, bool inline, ref bool theme_dependent) {
+ Message message = message_item.message;
+ string dim_color = Util.is_dark_theme(meta_label) ? "#BDBDBD" : "#707070";
+ string separator = inline ? " " : "";
+ string markup = "";
- if (message.body == "") {
- markup_text = @"<i><span size='small' color='$dim_color'>%s</span></i>".printf(_("Message deleted"));
- theme_dependent = true;
- }
if (message.edit_to != null) {
- markup_text += @" <span size='small' color='$dim_color'>(%s)</span>".printf(_("edited"));
+ markup = @"$separator<span size='small' color='$dim_color'>(%s)</span>".printf(_("edited"));
+ separator = " ";
theme_dependent = true;
}
- // Append message status info
additional_info = AdditionalInfo.NONE;
if (message.direction == Message.DIRECTION_SENT && (message.marked == Message.Marked.SENDING || message.marked == Message.Marked.UNSENT)) {
- // Append "pending..." iff message has not been sent yet
if (message.time.compare(new DateTime.now_utc().add_seconds(-10)) < 0) {
- markup_text += @" <span size='small' color='$dim_color'>%s</span>".printf(_("pending…"));
+ markup += @"$separator<span size='small' color='$dim_color'>%s</span>".printf(_("pending…"));
theme_dependent = true;
additional_info = AdditionalInfo.PENDING;
} else {
int time_diff = (- (int) message.time.difference(new DateTime.now_utc()) / 1000);
- if (pending_timeout_id != -1) Source.remove(pending_timeout_id);
pending_timeout_id = Timeout.add(10000 - time_diff, () => {
update_label();
pending_timeout_id = -1;
@@ -185,24 +254,63 @@ public class MessageMetaItem : ContentMetaItem {
});
}
} else if (message.direction == Message.DIRECTION_SENT && message.marked == Message.Marked.ERROR) {
- // Append "delivery failed" if there was a server error
- // TODO: Use a dedicated label, not appended text
- string error_color = Util.rgba_to_hex(Util.get_label_pango_color(label, "error_color"));
- markup_text += " <span size='small' color='%s'>%s</span>".printf(error_color, _("delivery failed"));
+ string error_color = Util.rgba_to_hex(Util.get_label_pango_color(meta_label, "error_color"));
+ markup += @"$separator<span size='small' color='$error_color'>%s</span>".printf(_("delivery failed"));
theme_dependent = true;
additional_info = AdditionalInfo.DELIVERY_FAILED;
}
- if (theme_dependent && realize_id == -1) {
- realize_id = label.realize.connect(update_label);
- } else if (!theme_dependent && realize_id != -1) {
- label.disconnect(realize_id);
+ return markup;
+ }
+
+ private Widget generate_code_widget(ArrayList<CodeBlockPart> parts, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) {
+ var content = new Box(Orientation.VERTICAL, 6) { hexpand=true };
+ foreach (CodeBlockPart part in parts) {
+ if (part.is_code) {
+ content.append(new CodeBlockWidget(part.text, part.language));
+ } else {
+ Label text_label = create_text_label();
+ format_text_segment(text_label, part, markups, action_name_length);
+ content.append(text_label);
+ }
}
- label.label = markup_text;
+
+ Label meta_label = create_text_label();
+ bool theme_dependent = false;
+ string meta_markup = get_meta_markup(meta_label, false, ref theme_dependent);
+ if (meta_markup != "") {
+ meta_label.label = meta_markup;
+ content.append(meta_label);
+ }
+ return content;
}
public void update_label() {
- generate_markup_text(content_item, label);
+ if (pending_timeout_id != -1) {
+ Source.remove(pending_timeout_id);
+ pending_timeout_id = -1;
+ }
+ if (realize_id != -1) {
+ label.disconnect(realize_id);
+ realize_id = -1;
+ }
+
+ var markups = copy_markups(message_item.message);
+ int action_name_length;
+ string text = prepare_message(markups, out action_name_length);
+ bool has_blocks;
+ ArrayList<CodeBlockPart> parts = CodeBlockParser.parse(text, out has_blocks);
+ if (has_blocks) {
+ content_widget = generate_code_widget(parts, markups, action_name_length);
+ } else {
+ generate_markup_text(label, text, markups, action_name_length);
+ content_widget = label;
+ }
+ set_text_selectable_recursive(content_widget, text_selectable);
+
+ if (outer != null && !in_edit_mode) {
+ outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2);
+ }
}
public override Object? get_widget(Plugins.ConversationItemWidgetInterface outer, Plugins.WidgetType type) {
@@ -210,7 +318,7 @@ public class MessageMetaItem : ContentMetaItem {
this.notify["in-edit-mode"].connect(on_in_edit_mode_changed);
- outer.set_widget(label, Plugins.WidgetType.GTK4, 2);
+ outer.set_widget(content_widget, 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);
@@ -223,7 +331,7 @@ public class MessageMetaItem : ContentMetaItem {
outer.set_widget(quote_widget, Plugins.WidgetType.GTK4, 1);
}
}
- return label;
+ return content_widget;
}
public override Gee.List<Plugins.MessageAction>? get_item_actions(Plugins.WidgetType type) {
@@ -244,6 +352,19 @@ public class MessageMetaItem : ContentMetaItem {
actions.add(action1);
}
+ Plugins.MessageAction copy_action = new Plugins.MessageAction();
+ copy_action.name = "copy";
+ copy_action.icon_name = "dino-edit-copy-symbolic";
+ copy_action.tooltip = _("Copy message");
+ copy_action.callback = () => {
+ var markups = copy_markups(message_item.message);
+ int action_name_length;
+ string text = prepare_message(markups, out action_name_length, false);
+ Gdk.Display? display = Gdk.Display.get_default();
+ if (display != null) display.get_clipboard().set_text(text);
+ };
+ actions.add(copy_action);
+
actions.add(get_reply_action(content_item, message_item.conversation, stream_interactor));
actions.add(get_reaction_action(content_item, message_item.conversation, stream_interactor));
@@ -267,7 +388,7 @@ public class MessageMetaItem : ContentMetaItem {
edit_mode.cancelled.connect(() => {
in_edit_mode = false;
- outer.set_widget(label, Plugins.WidgetType.GTK4, 2);
+ outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2);
});
edit_mode.send.connect(() => {
string text = edit_mode.chat_text_view.text_view.buffer.text;
@@ -275,7 +396,7 @@ public class MessageMetaItem : ContentMetaItem {
Dino.send_message(message_item.conversation, text, message_item.message.quoted_item_id, message_item.message, markups);
in_edit_mode = false;
- outer.set_widget(label, Plugins.WidgetType.GTK4, 2);
+ outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2);
});
edit_mode.chat_text_view.set_text(message);
diff --git a/main/src/ui/conversation_content_view/quote_widget.vala b/main/src/ui/conversation_content_view/quote_widget.vala
index ce0011e1..98aefacb 100644
--- a/main/src/ui/conversation_content_view/quote_widget.vala
+++ b/main/src/ui/conversation_content_view/quote_widget.vala
@@ -87,11 +87,17 @@ namespace Dino.Ui.Quote {
GestureClick gesture_click_controller = new GestureClick();
outer.add_controller(gesture_click_controller);
+ bool click_stopped = false;
gesture_click_controller.pressed.connect(() => {
- model.jump_to();
+ click_stopped = false;
+ });
+ gesture_click_controller.stopped.connect(() => {
+ click_stopped = true;
+ });
+ gesture_click_controller.released.connect(() => {
+ if (!click_stopped) model.jump_to();
});
return outer;
}
}
-
diff --git a/main/src/ui/conversation_content_view/syntax_highlighting.vala b/main/src/ui/conversation_content_view/syntax_highlighting.vala
new file mode 100644
index 00000000..b45ba81c
--- /dev/null
+++ b/main/src/ui/conversation_content_view/syntax_highlighting.vala
@@ -0,0 +1,136 @@
+using Gee;
+
+namespace Dino.Ui.CodeSyntax {
+
+public enum TokenStyle {
+ COMMENT,
+ CONSTANT,
+ FUNCTION,
+ KEYWORD,
+ NUMBER,
+ PREPROCESSOR,
+ STRING,
+ TYPE
+}
+
+public class HighlightSpan : Object {
+ public int start;
+ public int end;
+ public TokenStyle style;
+
+ public HighlightSpan(int start, int end, TokenStyle style) {
+ this.start = start;
+ this.end = end;
+ this.style = style;
+ }
+}
+
+public class HighlightRule : Object {
+ public TokenStyle style;
+ private Regex regex;
+
+ public HighlightRule(TokenStyle style, string pattern) {
+ this.style = style;
+ try {
+ regex = new Regex(pattern, RegexCompileFlags.OPTIMIZE);
+ } catch (RegexError error) {
+ GLib.error("Invalid syntax highlighting rule: %s", error.message);
+ }
+ }
+
+ public bool find(string text, int offset, out int start, out int end) {
+ MatchInfo match_info;
+ try {
+ regex.match_full(text, -1, offset, 0, out match_info);
+ } catch (RegexError error) {
+ start = -1;
+ end = -1;
+ return false;
+ }
+ if (!match_info.matches()) {
+ start = -1;
+ end = -1;
+ return false;
+ }
+ match_info.fetch_pos(0, out start, out end);
+ return true;
+ }
+}
+
+public class LanguageDefinition : Object {
+ public string id;
+ public string name;
+ public string extension;
+ public string[] aliases;
+ public HighlightRule[] rules;
+
+ public LanguageDefinition(string id, string name, string extension, string[] aliases, HighlightRule[] rules) {
+ this.id = id;
+ this.name = name;
+ this.extension = extension;
+ this.aliases = aliases;
+ this.rules = rules;
+ }
+
+ public bool matches(string language) {
+ string normalized = language.down().strip();
+ if (normalized == id) return true;
+ foreach (string alias in aliases) {
+ if (normalized == alias) return true;
+ }
+ return false;
+ }
+
+ public ArrayList<HighlightSpan> highlight(string text) {
+ var spans = new ArrayList<HighlightSpan>();
+ int offset = 0;
+ while (offset < text.length) {
+ HighlightRule? selected_rule = null;
+ int selected_start = text.length;
+ int selected_end = text.length;
+ foreach (HighlightRule rule in rules) {
+ int start;
+ int end;
+ if (rule.find(text, offset, out start, out end) && start < selected_start) {
+ selected_rule = rule;
+ selected_start = start;
+ selected_end = end;
+ }
+ }
+ if (selected_rule == null || selected_end <= selected_start) break;
+ spans.add(new HighlightSpan(selected_start, selected_end, selected_rule.style));
+ offset = selected_end;
+ }
+ return spans;
+ }
+}
+
+private static LanguageDefinition[]? definitions;
+
+public static LanguageDefinition? find(string language) {
+ if (definitions == null) definitions = create_definitions();
+ foreach (LanguageDefinition definition in definitions) {
+ if (definition.matches(language)) return definition;
+ }
+ return null;
+}
+
+private static LanguageDefinition[] create_definitions() {
+ return { create_c_definition() };
+}
+
+private static LanguageDefinition create_c_definition() {
+ HighlightRule[] rules = {
+ new HighlightRule(TokenStyle.COMMENT, "//[^\\r\\n]*|/\\*[\\s\\S]*?\\*/"),
+ new HighlightRule(TokenStyle.STRING, "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'"),
+ new HighlightRule(TokenStyle.PREPROCESSOR, "(?m)^[\\t ]*#[\\t ]*[A-Za-z_][A-Za-z0-9_]*"),
+ new HighlightRule(TokenStyle.KEYWORD, "\\b(?:alignas|alignof|asm|auto|break|case|const|constexpr|continue|default|do|else|enum|extern|for|goto|if|inline|register|restrict|return|sizeof|static|static_assert|struct|switch|thread_local|typedef|typeof|typeof_unqual|union|volatile|while|_Alignas|_Alignof|_Atomic|_Generic|_Noreturn|_Static_assert|_Thread_local)\\b"),
+ new HighlightRule(TokenStyle.TYPE, "\\b(?:bool|char|char8_t|double|float|int|long|short|signed|unsigned|void|_Bool|_Complex|_Decimal32|_Decimal64|_Decimal128|_Imaginary|int8_t|int16_t|int32_t|int64_t|uint8_t|uint16_t|uint32_t|uint64_t|intptr_t|uintptr_t|ptrdiff_t|size_t|ssize_t|wchar_t)\\b"),
+ new HighlightRule(TokenStyle.CONSTANT, "\\b(?:false|NULL|nullptr|true)\\b"),
+ new HighlightRule(TokenStyle.NUMBER, "\\b(?:0[xX][0-9A-Fa-f]+|0[bB][01]+|(?:[0-9]+\\.[0-9]*|[0-9]+)(?:[eEpP][+-]?[0-9]+)?)[fFlLuU]*\\b"),
+ new HighlightRule(TokenStyle.FUNCTION, "\\b[A-Za-z_][A-Za-z0-9_]*(?=[\\t ]*\\()")
+ };
+ return new LanguageDefinition("c", "C", "c", { "h", "c89", "c90", "c99", "c11", "c17", "c18", "c23" }, rules);
+}
+
+}
diff --git a/main/src/ui/conversation_selector/conversation_selector.vala b/main/src/ui/conversation_selector/conversation_selector.vala
index 70e192b7..25ea1b1e 100644
--- a/main/src/ui/conversation_selector/conversation_selector.vala
+++ b/main/src/ui/conversation_selector/conversation_selector.vala
@@ -14,6 +14,7 @@ public class ConversationSelector : Widget {
ListBox list_box = new ListBox() { hexpand=true };
private StreamInteractor stream_interactor;
+ private string search_text = "";
private HashMap<Conversation, ConversationSelectorRow> rows = new HashMap<Conversation, ConversationSelectorRow>(Conversation.hash_func, Conversation.equals_func);
public ConversationSelector init(StreamInteractor stream_interactor) {
@@ -38,6 +39,7 @@ public class ConversationSelector : Widget {
}
construct {
+ list_box.set_filter_func(filter);
list_box.set_sort_func(sort);
realize.connect(() => {
@@ -53,6 +55,16 @@ public class ConversationSelector : Widget {
check_widget_leak(this);
}
+ public void set_search_text(string text) {
+ search_text = text.strip().casefold();
+ list_box.invalidate_filter();
+ }
+
+ private bool filter(ListBoxRow list_box_row) {
+ ConversationSelectorRow? row = list_box_row as ConversationSelectorRow;
+ return row != null && (search_text == "" || row.matches_search(search_text));
+ }
+
public void row_activated(ListBoxRow r) {
ConversationSelectorRow? row = r as ConversationSelectorRow;
if (row != null) {
diff --git a/main/src/ui/conversation_selector/conversation_selector_row.vala b/main/src/ui/conversation_selector/conversation_selector_row.vala
index 0e565c84..95384a5e 100644
--- a/main/src/ui/conversation_selector/conversation_selector_row.vala
+++ b/main/src/ui/conversation_selector/conversation_selector_row.vala
@@ -13,6 +13,7 @@ namespace Dino.Ui {
public class ConversationSelectorRow : ListBoxRow {
[GtkChild] protected unowned AvatarPicture picture;
+ [GtkChild] protected unowned DrawingArea account_color_indicator;
[GtkChild] protected unowned Label name_label;
[GtkChild] protected unowned Label time_label;
[GtkChild] protected unowned Label nick_label;
@@ -42,6 +43,7 @@ public class ConversationSelectorRow : ListBoxRow {
var display_name_model = stream_interactor.get_module(ContactModels.IDENTITY).get_display_name_model(conversation);
display_name_model.bind_property("display-name", name_label, "label", BindingFlags.SYNC_CREATE);
+ name_label.notify["label"].connect(changed);
// Set tooltip
switch (conversation.type_) {
@@ -63,6 +65,7 @@ public class ConversationSelectorRow : ListBoxRow {
last_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_latest(conversation);
picture.model = new ViewModel.CompatAvatarPictureModel(stream_interactor).set_conversation(conversation);
+ account_color_indicator.set_draw_func(draw_account_color);
conversation.notify["read-up-to-item"].connect(() => update_read());
conversation.notify["pinned"].connect(() => { update_pinned_icon(); });
@@ -73,9 +76,31 @@ public class ConversationSelectorRow : ListBoxRow {
connect_signals_weak(this);
}
+ private static void draw_account_color(DrawingArea area, Cairo.Context context, int width, int height) {
+ ConversationSelectorRow? row = area.get_ancestor(typeof(ConversationSelectorRow)) as ConversationSelectorRow;
+ if (row == null) return;
+
+ Gdk.RGBA color = Gdk.RGBA();
+ if (!color.parse(Dino.Application.get_default().settings.get_account_color(row.conversation.account))) return;
+
+ Gdk.cairo_set_source_rgba(context, color);
+ context.rectangle(0, 0, width, height);
+ context.fill();
+ }
+
private static void connect_signals_weak(ConversationSelectorRow local_self) {
var self_weak = WeakRef(local_self);
+ var settings = Dino.Application.get_default().settings;
+ ulong account_color_changed_handler_id = 0;
+ account_color_changed_handler_id = settings.account_color_changed.connect((account, color) => {
+ ConversationSelectorRow? self = self_weak.get() as ConversationSelectorRow?;
+ if (self == null) return;
+ if (self.conversation.account.equals(account)) {
+ self.account_color_indicator.queue_draw();
+ }
+ });
+
ulong room_info_updated_handler_id = 0;
if (local_self.conversation.type_ == Conversation.Type.GROUPCHAT) {
room_info_updated_handler_id = local_self.stream_interactor.get_module(MucManager.IDENTITY).room_info_updated.connect((account, jid) => {
@@ -116,6 +141,7 @@ public class ConversationSelectorRow : ListBoxRow {
local_self.weak_ref((self_obj) => {
ConversationSelectorRow self = (ConversationSelectorRow) self_obj;
+ settings.disconnect(account_color_changed_handler_id);
if (room_info_updated_handler_id != 0) {
self.stream_interactor.get_module(MucManager.IDENTITY).disconnect(room_info_updated_handler_id);
}
@@ -154,6 +180,11 @@ public class ConversationSelectorRow : ListBoxRow {
name_label.label = Util.get_conversation_display_name(stream_interactor, conversation);
}
+ public bool matches_search(string search_text) {
+ return name_label.label.casefold().contains(search_text) ||
+ conversation.counterpart.bare_jid.to_string().casefold().contains(search_text);
+ }
+
private void update_pinned_icon() {
pinned_image.visible = conversation.pinned != 0;
}
diff --git a/main/src/ui/conversation_view_controller.vala b/main/src/ui/conversation_view_controller.vala
index 9626a39d..bff8a70b 100644
--- a/main/src/ui/conversation_view_controller.vala
+++ b/main/src/ui/conversation_view_controller.vala
@@ -53,6 +53,16 @@ public class ConversationViewController : Object {
key_controller3.key_pressed.connect(forward_key_press_to_chat_input);
main_window.conversation_headerbar.add_controller(key_controller3);
+ main_window.conversation_title_button.clicked.connect(() => {
+ if (conversation == null) return;
+
+ var variant = new Variant.tuple(new Variant[] {
+ new Variant.int32(conversation.id),
+ new Variant.string("about")
+ });
+ app.activate_action("open-conversation-details", variant);
+ });
+
// goto-end floating button
var vadjustment = view.conversation_frame.scrolled.vadjustment;
vadjustment.notify["value"].connect(() => {
@@ -113,6 +123,7 @@ public class ConversationViewController : Object {
}
this.conversation = conversation;
+ main_window.conversation_title_button.sensitive = true;
// Set list model onto list view
// Dino.Application app = GLib.Application.get_default() as Dino.Application;
@@ -143,6 +154,7 @@ public class ConversationViewController : Object {
}
public void unset_conversation() {
+ main_window.conversation_title_button.sensitive = false;
main_window.conversation_window_title.title = "";
main_window.conversation_window_title.subtitle = "";
}
diff --git a/main/src/ui/launcher_badge.vala b/main/src/ui/launcher_badge.vala
new file mode 100644
index 00000000..aa07fbaa
--- /dev/null
+++ b/main/src/ui/launcher_badge.vala
@@ -0,0 +1,118 @@
+using Gee;
+using GLib;
+
+using Dino.Entities;
+
+namespace Dino.Ui {
+
+[DBus (name = "com.canonical.Unity.LauncherEntry")]
+public class LauncherBadge : Object {
+ private const string APP_URI = "application://im.dino.Dino.desktop";
+ private const string BUS_NAME = "com.canonical.Unity";
+ private const string OBJECT_PATH = "/im/dino/Dino/LauncherEntry";
+
+ private StreamInteractor stream_interactor;
+ private DBusConnection connection;
+ private HashMap<Conversation, ulong> read_handler_ids = new HashMap<Conversation, ulong>(Conversation.hash_func, Conversation.equals_func);
+ private int64 unread_count = -1;
+ private uint object_registration_id;
+ private uint watcher_id;
+ private ulong conversation_activated_handler_id;
+ private ulong conversation_deactivated_handler_id;
+ private ulong new_item_handler_id;
+ private ulong setting_handler_id;
+
+ public signal void update(string app_uri, HashTable<string, Variant> properties);
+
+ public LauncherBadge(StreamInteractor stream_interactor) throws IOError {
+ this.stream_interactor = stream_interactor;
+ connection = Bus.get_sync(BusType.SESSION);
+ object_registration_id = connection.register_object(OBJECT_PATH, this);
+
+ ConversationManager conversation_manager = stream_interactor.get_module(ConversationManager.IDENTITY);
+ conversation_activated_handler_id = conversation_manager.conversation_activated.connect(add_conversation);
+ conversation_deactivated_handler_id = conversation_manager.conversation_deactivated.connect(remove_conversation);
+ new_item_handler_id = stream_interactor.get_module(ContentItemStore.IDENTITY).new_item.connect(() => update_count());
+ setting_handler_id = Dino.Application.get_default().settings.notify["notifications-badge"].connect(() => update_count(true));
+
+ foreach (Conversation conversation in conversation_manager.get_active_conversations()) {
+ connect_conversation(conversation);
+ }
+
+ watcher_id = Bus.watch_name_on_connection(
+ connection,
+ BUS_NAME,
+ BusNameWatcherFlags.NONE,
+ () => update_count(true));
+ update_count(true);
+ }
+
+ public void query(out string app_uri, out HashTable<string, Variant> properties) throws Error {
+ app_uri = APP_URI;
+ properties = get_properties();
+ }
+
+ internal void shutdown() {
+ unread_count = 0;
+ update(APP_URI, get_properties());
+
+ ConversationManager conversation_manager = stream_interactor.get_module(ConversationManager.IDENTITY);
+ conversation_manager.disconnect(conversation_activated_handler_id);
+ conversation_manager.disconnect(conversation_deactivated_handler_id);
+ stream_interactor.get_module(ContentItemStore.IDENTITY).disconnect(new_item_handler_id);
+ Dino.Application.get_default().settings.disconnect(setting_handler_id);
+
+ foreach (Conversation conversation in read_handler_ids.keys) {
+ conversation.disconnect(read_handler_ids[conversation]);
+ }
+ read_handler_ids.clear();
+
+ if (watcher_id != 0) {
+ Bus.unwatch_name(watcher_id);
+ watcher_id = 0;
+ }
+ if (object_registration_id != 0) {
+ connection.unregister_object(object_registration_id);
+ object_registration_id = 0;
+ }
+ }
+
+ private void add_conversation(Conversation conversation) {
+ connect_conversation(conversation);
+ update_count();
+ }
+
+ private void remove_conversation(Conversation conversation) {
+ if (read_handler_ids.has_key(conversation)) {
+ conversation.disconnect(read_handler_ids[conversation]);
+ read_handler_ids.unset(conversation);
+ }
+ update_count();
+ }
+
+ private void connect_conversation(Conversation conversation) {
+ if (read_handler_ids.has_key(conversation)) return;
+ read_handler_ids[conversation] = conversation.notify["read-up-to-item"].connect(() => update_count());
+ }
+
+ private void update_count(bool force = false) {
+ int64 count = 0;
+ ChatInteraction chat_interaction = stream_interactor.get_module(ChatInteraction.IDENTITY);
+ foreach (Conversation conversation in stream_interactor.get_module(ConversationManager.IDENTITY).get_active_conversations()) {
+ count += chat_interaction.get_num_unread(conversation);
+ }
+
+ if (!force && count == unread_count) return;
+ unread_count = count;
+ update(APP_URI, get_properties());
+ }
+
+ private HashTable<string, Variant> get_properties() {
+ HashTable<string, Variant> properties = new HashTable<string, Variant>(str_hash, str_equal);
+ properties["count"] = new Variant.int64(unread_count);
+ properties["count-visible"] = new Variant.boolean(Dino.Application.get_default().settings.notifications_badge && unread_count > 0);
+ return properties;
+ }
+}
+
+}
diff --git a/main/src/ui/main_window.vala b/main/src/ui/main_window.vala
index da6960ea..fc18d2bd 100644
--- a/main/src/ui/main_window.vala
+++ b/main/src/ui/main_window.vala
@@ -7,17 +7,19 @@ using Dino.Entities;
namespace Dino.Ui {
[GtkTemplate (ui = "/im/dino/Dino/main_window.ui")]
-public class MainWindow : Adw.ApplicationWindow {
+public class MainWindow : Gtk.ApplicationWindow {
public signal void conversation_selected(Conversation conversation);
[GtkChild] public unowned Stack stack;
[GtkChild] public unowned Adw.NavigationSplitView navigation_split_view;
- [GtkChild] public unowned MenuButton add_button;
+ [GtkChild] public unowned SearchEntry conversation_search_entry;
[GtkChild] public unowned MenuButton menu_button;
+ [GtkChild] private unowned Box conversation_list_toolbar;
[GtkChild] public unowned Adw.HeaderBar conversation_headerbar;
+ [GtkChild] public unowned Button conversation_title_button;
[GtkChild] public unowned Adw.WindowTitle conversation_window_title;
[GtkChild] public unowned ConversationView conversation_view;
@@ -56,15 +58,25 @@ public class MainWindow : Adw.ApplicationWindow {
this.add_css_class("devel");
}
+ var header_size_group = new SizeGroup(SizeGroupMode.VERTICAL);
+ header_size_group.add_widget(conversation_list_toolbar);
+ header_size_group.add_widget(conversation_headerbar);
+
((Widget)this).realize.connect(restore_window_size);
conversation_selector.init(stream_interactor);
conversation_selector.conversation_selected.connect_after(() => { navigation_split_view.show_content = true; });
+ conversation_search_entry.search_changed.connect(() => {
+ conversation_selector.set_search_text(conversation_search_entry.text);
+ });
+ conversation_search_entry.stop_search.connect(() => {
+ conversation_search_entry.text = "";
+ });
global_search = new GlobalSearch(stream_interactor);
search_frame.set_child(global_search.get_widget());
- create_add_menu(add_button, menu_button);
+ create_menu(menu_button);
stack.add_named(welcome_placeholder, "welcome_placeholder");
stack.add_named(accounts_placeholder, "accounts_placeholder");
@@ -137,13 +149,8 @@ public class MainWindow : Adw.ApplicationWindow {
}
}
- private static void create_add_menu(MenuButton add_button, MenuButton menu_button) {
- add_button.tooltip_text = Util.string_if_tooltips_active(_("Start Conversation"));
-
- Builder add_builder = new Builder.from_resource("/im/dino/Dino/menu_add.ui");
- MenuModel add_menu_model = add_builder.get_object("menu_add") as MenuModel;
- add_button.set_menu_model(add_menu_model);
-
+ private static void create_menu(MenuButton menu_button) {
+ menu_button.tooltip_text = Util.string_if_tooltips_active(_("Main Menu"));
Builder menu_builder = new Builder.from_resource("/im/dino/Dino/menu_app.ui");
MenuModel menu_menu_model = menu_builder.get_object("menu_app") as MenuModel;
menu_button.set_menu_model(menu_menu_model);
diff --git a/main/src/ui/notifier_freedesktop.vala b/main/src/ui/notifier_freedesktop.vala
index 99428315..3c8bee1c 100644
--- a/main/src/ui/notifier_freedesktop.vala
+++ b/main/src/ui/notifier_freedesktop.vala
@@ -11,10 +11,12 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object {
private DBusNotifications dbus_notifications;
private bool supports_body_markup = false;
private bool supports_body_hyperlinks = false;
+ private bool supports_inline_reply = false;
private HashMap<Conversation, uint32> content_notifications = new HashMap<Conversation, uint32>(Conversation.hash_func, Conversation.equals_func);
private HashMap<Conversation, Gee.List<uint32>> conversation_notifications = new HashMap<Conversation, Gee.List<uint32>>(Conversation.hash_func, Conversation.equals_func);
private HashMap<uint32, HashMap<string, ListenerFuncWrapper>> action_listeners = new HashMap<uint32, HashMap<string, ListenerFuncWrapper>>();
+ private HashMap<uint32, NotificationReplyTarget> reply_targets = new HashMap<uint32, NotificationReplyTarget>();
private HashMap<Call, uint32> call_notifications = new HashMap<Call, uint32>(Call.hash_func, Call.equals_func);
public FreeDesktopNotifier(StreamInteractor stream_interactor, DBusNotifications dbus_notifications) {
@@ -35,6 +37,9 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object {
case "body-hyperlinks":
supports_body_hyperlinks = true;
break;
+ case "inline-reply":
+ supports_inline_reply = true;
+ break;
}
}
@@ -46,6 +51,22 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object {
dbus_notifications.notification_closed.connect((id) => {
action_listeners.unset(id);
+ reply_targets.unset(id);
+ });
+
+ dbus_notifications.notification_replied.connect((id, text) => {
+ if (!reply_targets.has_key(id)) return;
+
+ NotificationReplyTarget target = reply_targets[id];
+ if (text.strip() != "") {
+ Dino.send_message(target.conversation, text, target.content_item_id, null, new ArrayList<Xep.MessageMarkup.Span>());
+ }
+
+ reply_targets.unset(id);
+ if (content_notifications.has_key(target.conversation) && content_notifications[target.conversation] == id) {
+ content_notifications.unset(target.conversation);
+ }
+ dbus_notifications.close_notification.begin(id);
});
}
@@ -62,7 +83,9 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object {
} else {
body = message.body;
}
- yield notify_content_item(conversation, conversation_display_name, participant_display_name, body);
+ ContentItem? reply_to_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_content_item_for_message(conversation, message);
+ int reply_to_id = reply_to_item != null ? reply_to_item.id : 0;
+ yield notify_content_item(conversation, conversation_display_name, participant_display_name, body, reply_to_id);
}
public async void notify_file(FileTransfer file_transfer, Conversation conversation, bool is_image, string conversation_display_name, string? participant_display_name) {
@@ -77,10 +100,10 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object {
text = "<i>" + text + "</i>";
}
- yield notify_content_item(conversation, conversation_display_name, participant_display_name, text);
+ yield notify_content_item(conversation, conversation_display_name, participant_display_name, text, 0);
}
- private async void notify_content_item(Conversation conversation, string conversation_display_name, string? participant_display_name, string body_) {
+ private async void notify_content_item(Conversation conversation, string conversation_display_name, string? participant_display_name, string body_, int reply_to_id) {
string body = body_;
if (participant_display_name != null) {
if (supports_body_markup) {
@@ -96,10 +119,22 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object {
hash_table["desktop-entry"] = new Variant.string(Dino.Application.get_default().get_application_id());
hash_table["category"] = new Variant.string("im.received");
string[] actions = new string[] {"default", "Open conversation"};
+ if (reply_to_id != 0 && supports_inline_reply) {
+ actions += "inline-reply";
+ actions += _("Reply");
+ hash_table["x-kde-reply-placeholder-text"] = new Variant.string(_("Reply"));
+ hash_table["x-kde-reply-submit-button-text"] = new Variant.string(_("Send"));
+ }
try {
uint32 notification_id = yield dbus_notifications.notify("Dino", replace_id, "", conversation_display_name, body, actions, hash_table, -1);
content_notifications[conversation] = notification_id;
+ if (reply_to_id != 0 && supports_inline_reply) {
+ reply_targets[notification_id] = new NotificationReplyTarget(conversation, reply_to_id);
+ } else {
+ reply_targets.unset(notification_id);
+ }
+
add_action_listener(notification_id, "default", () => {
GLib.Application.get_default().activate_action("open-conversation", new Variant.int32(conversation.id));
});
@@ -284,12 +319,15 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object {
} catch (Error e) { }
}
content_notifications.clear();
+ reply_targets.clear();
}
public async void retract_conversation_notifications(Conversation conversation) {
try {
if (content_notifications.has_key(conversation)) {
- dbus_notifications.close_notification.begin(content_notifications[conversation]);
+ uint32 notification_id = content_notifications[conversation];
+ dbus_notifications.close_notification.begin(notification_id);
+ reply_targets.unset(notification_id);
content_notifications.unset(conversation);
}
@@ -323,6 +361,17 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object {
}
delegate void ListenerFunc();
+
+ class NotificationReplyTarget {
+ public Conversation conversation;
+ public int content_item_id;
+
+ public NotificationReplyTarget(Conversation conversation, int content_item_id) {
+ this.conversation = conversation;
+ this.content_item_id = content_item_id;
+ }
+ }
+
class ListenerFuncWrapper {
public ListenerFunc func;
diff --git a/main/src/ui/status_notifier_item.vala b/main/src/ui/status_notifier_item.vala
new file mode 100644
index 00000000..70db17d5
--- /dev/null
+++ b/main/src/ui/status_notifier_item.vala
@@ -0,0 +1,212 @@
+using GLib;
+
+namespace Dino.Ui {
+
+public struct StatusNotifierIconPixmap {
+ public int width;
+ public int height;
+ public uint8[] data;
+}
+
+public struct StatusNotifierToolTip {
+ public string icon_name;
+ public StatusNotifierIconPixmap[] icon_pixmap;
+ public string title;
+ public string description;
+}
+
+[DBus (name = "org.kde.StatusNotifierItem")]
+public class StatusNotifierItem : Object {
+ private const string BUS_NAME_TEMPLATE = "org.kde.StatusNotifierItem-%d-1";
+ private const string ICON_NAME = "im.dino.Dino";
+ private const string ITEM_OBJECT_PATH = "/StatusNotifierItem";
+ private const string MENU_OBJECT_PATH = "/MenuBar";
+ private const string WATCHER_BUS_NAME = "org.kde.StatusNotifierWatcher";
+ private const string WATCHER_INTERFACE = "org.kde.StatusNotifierWatcher";
+ private const string WATCHER_OBJECT_PATH = "/StatusNotifierWatcher";
+
+ private weak Application application;
+ private DBusConnection connection;
+ private string bus_name;
+ private bool name_acquired;
+ private bool registration_in_progress;
+ private bool shutting_down;
+ private bool watcher_available;
+ private bool registered_with_watcher;
+ private string? activation_token;
+ private string? watcher_owner;
+ private Cancellable registration_cancellable = new Cancellable();
+ private StatusNotifierMenu status_notifier_menu;
+ private uint bus_name_owner_id;
+ private uint menu_registration_id;
+ private uint object_registration_id;
+ private uint watcher_id;
+
+ public string category { owned get { return "Communications"; } }
+ public string id { owned get { return "Dino"; } }
+ public string title { owned get { return "Dino"; } }
+ public string status { owned get { return "Active"; } }
+ public int window_id { get { return 0; } }
+ public string icon_theme_path { owned get { return ""; } }
+ public ObjectPath menu { owned get { return new ObjectPath(MENU_OBJECT_PATH); } }
+ public bool item_is_menu { get { return false; } }
+ public string icon_name { owned get { return ICON_NAME; } }
+ public StatusNotifierIconPixmap[] icon_pixmap { owned get { return new StatusNotifierIconPixmap[0]; } }
+ public string overlay_icon_name { owned get { return ""; } }
+ public StatusNotifierIconPixmap[] overlay_icon_pixmap { owned get { return new StatusNotifierIconPixmap[0]; } }
+ public string attention_icon_name { owned get { return ""; } }
+ public StatusNotifierIconPixmap[] attention_icon_pixmap { owned get { return new StatusNotifierIconPixmap[0]; } }
+ public string attention_movie_name { owned get { return ""; } }
+ public StatusNotifierToolTip tool_tip {
+ owned get {
+ StatusNotifierToolTip tool_tip = StatusNotifierToolTip();
+ tool_tip.icon_name = ICON_NAME;
+ tool_tip.icon_pixmap = new StatusNotifierIconPixmap[0];
+ tool_tip.title = "Dino";
+ tool_tip.description = _("Open Dino");
+ return tool_tip;
+ }
+ }
+
+ public signal void new_title();
+ public signal void new_icon();
+ public signal void new_attention_icon();
+ public signal void new_overlay_icon();
+ public signal void new_menu();
+ public signal void new_tool_tip();
+ public signal void new_status(string status);
+
+ public StatusNotifierItem(Application application) throws IOError {
+ this.application = application;
+ this.connection = Bus.get_sync(BusType.SESSION);
+ this.bus_name = BUS_NAME_TEMPLATE.printf((int) Posix.getpid());
+
+ status_notifier_menu = new StatusNotifierMenu(application);
+ menu_registration_id = connection.register_object(MENU_OBJECT_PATH, status_notifier_menu);
+ object_registration_id = connection.register_object(ITEM_OBJECT_PATH, this);
+ bus_name_owner_id = Bus.own_name_on_connection(
+ connection,
+ bus_name,
+ BusNameOwnerFlags.NONE,
+ () => {
+ name_acquired = true;
+ register_with_watcher.begin();
+ },
+ () => {
+ name_acquired = false;
+ registered_with_watcher = false;
+ });
+ watcher_id = Bus.watch_name_on_connection(
+ connection,
+ WATCHER_BUS_NAME,
+ BusNameWatcherFlags.NONE,
+ (watcher_connection, watcher_name, name_owner) => {
+ watcher_owner = name_owner;
+ watcher_available = true;
+ registered_with_watcher = false;
+ register_with_watcher.begin();
+ },
+ () => {
+ watcher_owner = null;
+ watcher_available = false;
+ registered_with_watcher = false;
+ });
+ }
+
+ internal void shutdown() {
+ shutting_down = true;
+ name_acquired = false;
+ watcher_available = false;
+ registration_cancellable.cancel();
+
+ if (watcher_id != 0) {
+ Bus.unwatch_name(watcher_id);
+ watcher_id = 0;
+ }
+ if (bus_name_owner_id != 0) {
+ Bus.unown_name(bus_name_owner_id);
+ bus_name_owner_id = 0;
+ }
+ if (object_registration_id != 0) {
+ connection.unregister_object(object_registration_id);
+ object_registration_id = 0;
+ }
+ if (menu_registration_id != 0) {
+ connection.unregister_object(menu_registration_id);
+ menu_registration_id = 0;
+ }
+ }
+
+ public void provide_xdg_activation_token(string token) throws Error {
+ activation_token = token;
+ }
+
+ public void context_menu(int x, int y) throws Error {
+ }
+
+ public void activate(int x, int y) throws Error {
+ if (application.window != null && application.window.is_active) {
+ application.window.visible = false;
+ activation_token = null;
+ return;
+ }
+
+ show_window();
+ }
+
+ public void secondary_activate(int x, int y) throws Error {
+ show_window();
+ }
+
+ public void scroll(int delta, string orientation) throws Error {
+ }
+
+ private async void register_with_watcher() {
+ if (shutting_down || !name_acquired || !watcher_available ||
+ registered_with_watcher || registration_in_progress) return;
+
+ registration_in_progress = true;
+ string? registering_watcher = watcher_owner;
+
+ try {
+ yield connection.call(
+ WATCHER_BUS_NAME,
+ WATCHER_OBJECT_PATH,
+ WATCHER_INTERFACE,
+ "RegisterStatusNotifierItem",
+ new Variant("(s)", bus_name),
+ null,
+ DBusCallFlags.NONE,
+ 5000,
+ registration_cancellable);
+ if (!shutting_down && name_acquired && watcher_owner == registering_watcher) {
+ registered_with_watcher = true;
+ }
+ } catch (Error e) {
+ if (!(e is IOError.CANCELLED)) {
+ warning("Could not register Dino's tray icon: %s", e.message);
+ }
+ }
+
+ registration_in_progress = false;
+ if (!shutting_down && name_acquired && watcher_available && !registered_with_watcher &&
+ watcher_owner != registering_watcher) {
+ register_with_watcher.begin();
+ }
+ }
+
+ private void show_window() {
+ if (application.window == null) {
+ application.activate();
+ return;
+ }
+
+ if (activation_token != null) {
+ application.window.set_startup_id(activation_token);
+ activation_token = null;
+ }
+ application.window.present();
+ }
+}
+
+}
diff --git a/main/src/ui/status_notifier_menu.vala b/main/src/ui/status_notifier_menu.vala
new file mode 100644
index 00000000..cc8b1375
--- /dev/null
+++ b/main/src/ui/status_notifier_menu.vala
@@ -0,0 +1,100 @@
+using GLib;
+
+namespace Dino.Ui {
+
+public struct DBusMenuLayout {
+ public int id;
+ public HashTable<string, Variant> properties;
+ public Variant[] children;
+}
+
+[DBus (name = "com.canonical.dbusmenu")]
+public class StatusNotifierMenu : Object {
+ private const int ROOT_ITEM_ID = 0;
+ private const int QUIT_ITEM_ID = 1;
+
+ private weak Application application;
+ private bool quit_scheduled;
+
+ public uint version { get { return 3; } }
+ public string text_direction { owned get { return "ltr"; } }
+ public string status { owned get { return "normal"; } }
+ public string[] icon_theme_path { owned get { return new string[0]; } }
+
+ public signal void layout_updated(uint revision, int parent);
+
+ public StatusNotifierMenu(Application application) {
+ this.application = application;
+ }
+
+ public void get_layout(int parent_id, int recursion_depth, string[] property_names,
+ out uint revision, out DBusMenuLayout layout) throws Error {
+ revision = 1;
+ layout = create_layout(parent_id, recursion_depth, property_names);
+ }
+
+ public void @event(int id, string event_id, Variant data, uint timestamp) throws Error {
+ if (id != QUIT_ITEM_ID || event_id != "clicked" || quit_scheduled) return;
+
+ quit_scheduled = true;
+ Idle.add(() => {
+ application.quit();
+ return Source.REMOVE;
+ });
+ }
+
+ public bool about_to_show(int id) throws Error {
+ return false;
+ }
+
+ private DBusMenuLayout create_layout(int id, int recursion_depth, string[] property_names) {
+ DBusMenuLayout layout = DBusMenuLayout();
+ layout.id = id;
+ layout.properties = get_item_properties(id, property_names);
+ layout.children = new Variant[0];
+
+ if (id == ROOT_ITEM_ID && recursion_depth != 0) {
+ DBusMenuLayout quit_item = create_layout(QUIT_ITEM_ID, 0, property_names);
+ layout.children = new Variant[] { layout_to_variant(quit_item) };
+ }
+ return layout;
+ }
+
+ private HashTable<string, Variant> get_item_properties(int id, string[] property_names) {
+ HashTable<string, Variant> properties = new HashTable<string, Variant>(str_hash, str_equal);
+
+ if (id == ROOT_ITEM_ID) {
+ add_property(properties, property_names, "children-display", new Variant.string("submenu"));
+ } else if (id == QUIT_ITEM_ID) {
+ add_property(properties, property_names, "label", new Variant.string(_("Quit Dino")));
+ }
+ return properties;
+ }
+
+ private static void add_property(HashTable<string, Variant> properties, string[] property_names,
+ string name, Variant value) {
+ if (property_names.length == 0 || name in property_names) {
+ properties.insert(name, value);
+ }
+ }
+
+ private static Variant layout_to_variant(DBusMenuLayout layout) {
+ VariantBuilder properties = new VariantBuilder(new VariantType("a{sv}"));
+ layout.properties.foreach((name, value) => {
+ properties.add("{sv}", name, value);
+ });
+
+ Variant[] children = new Variant[layout.children.length];
+ for (int i = 0; i < layout.children.length; i++) {
+ children[i] = new Variant.variant(layout.children[i]);
+ }
+
+ return new Variant.tuple(new Variant[] {
+ new Variant.int32(layout.id),
+ properties.end(),
+ new Variant.array(VariantType.VARIANT, children)
+ });
+ }
+}
+
+}
diff --git a/main/src/ui/widgets/avatar_picture.vala b/main/src/ui/widgets/avatar_picture.vala
index d22f9cfe..b4636f49 100644
--- a/main/src/ui/widgets/avatar_picture.vala
+++ b/main/src/ui/widgets/avatar_picture.vala
@@ -272,6 +272,13 @@ public class Dino.Ui.CompatAvatarDrawer {
public int width_request { get; set; default = 35; }
public string font_family { get; set; default = "Sans"; }
+ public CompatAvatarDrawer() {
+ Dino.Application? application = Dino.Application.get_default();
+ if (application != null && application.settings != null) {
+ radius_percent = (float) application.settings.avatar_roundness / 100.0f;
+ }
+ }
+
public Cairo.ImageSurface draw_image_surface() {
Cairo.ImageSurface surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, width_request, height_request);
draw_on_context(new Cairo.Context(surface));
@@ -392,12 +399,17 @@ public class Dino.Ui.AvatarPicture : Gtk.Widget {
private ViewModel.AvatarPictureModel? old_model;
private ulong model_tiles_items_changed_handler;
+ private Binding? avatar_roundness_binding;
construct {
height_request = 35;
width_request = 35;
set_css_name("picture");
add_css_class("avatar");
+ Dino.Application? application = Dino.Application.get_default();
+ if (application != null && application.settings != null) {
+ avatar_roundness_binding = application.settings.bind_property("avatar-roundness", this, "radius-percent", BindingFlags.SYNC_CREATE, avatar_roundness_to_radius_percent);
+ }
notify["radius-percent"].connect(queue_draw);
notify["model"].connect(on_model_changed);
check_widget_leak(this);
@@ -488,11 +500,20 @@ public class Dino.Ui.AvatarPicture : Gtk.Widget {
}
public override void dispose() {
+ if (avatar_roundness_binding != null) {
+ avatar_roundness_binding.unbind();
+ avatar_roundness_binding = null;
+ }
model = null;
on_model_changed();
base.dispose();
}
+ private static bool avatar_roundness_to_radius_percent(Binding binding, Value from_value, ref Value to_value) {
+ to_value.set_float((float) from_value.get_int() / 100.0f);
+ return true;
+ }
+
private class Tile : Gtk.Widget {
public ViewModel.AvatarPictureTileModel? model { get; set; }
public Gdk.RGBA background_color { get; set; default = Gdk.RGBA(){ red = 1.0f, green = 1.0f, blue = 1.0f, alpha = 0.0f }; }