Add 'gtk/' from commit '7d0dfb455aa86245231b383a92e79b3c08a12d5e'
git-subtree-dir: gtk git-subtree-mainline:c710c6e053git-subtree-split:7d0dfb455a
This commit is contained in:
50
gtk/src/application/kordophone-application.vala
Normal file
50
gtk/src/application/kordophone-application.vala
Normal file
@@ -0,0 +1,50 @@
|
||||
using Adw;
|
||||
using Gtk;
|
||||
|
||||
public class KordophoneApp : Adw.Application
|
||||
{
|
||||
private MainWindow window;
|
||||
|
||||
public KordophoneApp () {
|
||||
Object (application_id: "net.buzzert.kordophone2", flags: ApplicationFlags.FLAGS_NONE);
|
||||
}
|
||||
|
||||
protected override void startup () {
|
||||
base.startup ();
|
||||
|
||||
// Set default icon theme
|
||||
Gtk.Settings.get_default().set_property("gtk-icon-theme-name", "Adwaita");
|
||||
|
||||
// Load CSS from resources
|
||||
var provider = new Gtk.CssProvider ();
|
||||
provider.load_from_resource ("/net/buzzert/kordophone2/style.css");
|
||||
Gtk.StyleContext.add_provider_for_display (
|
||||
Gdk.Display.get_default (),
|
||||
provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
);
|
||||
|
||||
// Setup application actions
|
||||
var quit_action = new SimpleAction("quit", null);
|
||||
quit_action.activate.connect(() => {
|
||||
this.quit();
|
||||
});
|
||||
add_action(quit_action);
|
||||
|
||||
// Warm up dbus connections
|
||||
Repository.get_instance();
|
||||
}
|
||||
|
||||
protected override void activate () {
|
||||
window = new MainWindow ();
|
||||
window.set_default_size (1200, 1000);
|
||||
window.application = this;
|
||||
|
||||
window.present ();
|
||||
}
|
||||
|
||||
public static int main (string[] args) {
|
||||
var app = new KordophoneApp ();
|
||||
return app.run (args);
|
||||
}
|
||||
}
|
||||
116
gtk/src/application/main-window.vala
Normal file
116
gtk/src/application/main-window.vala
Normal file
@@ -0,0 +1,116 @@
|
||||
using Adw;
|
||||
using Gtk;
|
||||
|
||||
public class MainWindow : Adw.ApplicationWindow
|
||||
{
|
||||
private ConversationListView conversation_list_view;
|
||||
private TranscriptContainerView transcript_container_view;
|
||||
|
||||
private EventControllerMotion _motion_controller = new EventControllerMotion();
|
||||
private bool _motion_queued = false;
|
||||
|
||||
public MainWindow () {
|
||||
Object (title: "Kordophone");
|
||||
|
||||
var split_view = new NavigationSplitView ();
|
||||
split_view.set_min_sidebar_width (400);
|
||||
set_content (split_view);
|
||||
|
||||
conversation_list_view = new ConversationListView ();
|
||||
conversation_list_view.conversation_selected.connect (conversation_selected);
|
||||
conversation_list_view.conversation_activated.connect (open_conversation_in_new_window);
|
||||
|
||||
var conversation_list_page = new NavigationPage (conversation_list_view, "Conversations");
|
||||
split_view.sidebar = conversation_list_page;
|
||||
|
||||
transcript_container_view = new TranscriptContainerView ();
|
||||
var transcript_page = new NavigationPage (transcript_container_view, "Transcript");
|
||||
split_view.content = transcript_page;
|
||||
|
||||
var show_settings_action = new SimpleAction ("settings", null);
|
||||
show_settings_action.activate.connect(show_settings);
|
||||
add_action(show_settings_action);
|
||||
|
||||
_motion_controller.motion.connect((x, y) => {
|
||||
queue_motion();
|
||||
});
|
||||
_motion_controller.set_propagation_phase(PropagationPhase.CAPTURE);
|
||||
split_view.add_controller(_motion_controller);
|
||||
}
|
||||
|
||||
private void queue_motion() {
|
||||
if (_motion_queued) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_active) {
|
||||
return;
|
||||
}
|
||||
|
||||
_motion_queued = true;
|
||||
GLib.Timeout.add(500, () => {
|
||||
_motion_queued = false;
|
||||
on_motion();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private void on_motion() {
|
||||
var selected_conversation = transcript_container_view.transcript_view.model?.conversation;
|
||||
if (selected_conversation == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var list_model = conversation_list_view.conversation_model;
|
||||
var conversation_in_list = list_model.get_conversation(selected_conversation.guid);
|
||||
if (conversation_in_list == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (conversation_in_list.unread_count == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
GLib.message("Marking conversation as read on motion: %s", selected_conversation.guid);
|
||||
Repository.get_instance().mark_conversation_as_read(selected_conversation.guid);
|
||||
} catch (Error e) {
|
||||
GLib.warning("Failed to mark conversation as read: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private void show_settings () {
|
||||
var dialog = new PreferencesWindow (this);
|
||||
dialog.present (this);
|
||||
}
|
||||
|
||||
private void conversation_selected(Conversation conversation) {
|
||||
TranscriptView transcript_view = transcript_container_view.transcript_view;
|
||||
if (conversation == null) {
|
||||
transcript_view.model = null;
|
||||
} else {
|
||||
if (transcript_view.model == null || transcript_view.model.conversation.guid != conversation.guid) {
|
||||
transcript_view.model = new MessageListModel (conversation);
|
||||
transcript_view.title = conversation.display_name;
|
||||
|
||||
try {
|
||||
Repository.get_instance().mark_conversation_as_read(conversation.guid);
|
||||
Repository.get_instance().sync_conversation(conversation.guid);
|
||||
} catch (Error e) {
|
||||
GLib.warning("Failed to sync conversation: %s", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void open_conversation_in_new_window(Conversation conversation) {
|
||||
var view = new TranscriptContainerView();
|
||||
view.transcript_view.model = new MessageListModel (conversation);
|
||||
view.transcript_view.title = conversation.display_name;
|
||||
|
||||
var window = new Adw.Window();
|
||||
window.set_default_size(750, 990);
|
||||
window.set_content(view);
|
||||
window.present();
|
||||
}
|
||||
}
|
||||
69
gtk/src/application/preferences-window.vala
Normal file
69
gtk/src/application/preferences-window.vala
Normal file
@@ -0,0 +1,69 @@
|
||||
using Adw;
|
||||
using Gtk;
|
||||
|
||||
public class PreferencesWindow : Adw.PreferencesDialog {
|
||||
private Adw.EntryRow server_url_row;
|
||||
private Adw.EntryRow username_row;
|
||||
private Adw.PasswordEntryRow password_row;
|
||||
private Settings settings;
|
||||
|
||||
public PreferencesWindow (Gtk.Window parent) {
|
||||
Object (
|
||||
title: "Settings"
|
||||
);
|
||||
|
||||
add_css_class ("settings-dialog");
|
||||
|
||||
var page = new PreferencesPage ();
|
||||
page.margin_top = 14;
|
||||
page.margin_bottom = 14;
|
||||
page.margin_start = 50;
|
||||
page.margin_end = 50;
|
||||
add (page);
|
||||
|
||||
var connection_group = new PreferencesGroup ();
|
||||
connection_group.title = "Connection Settings";
|
||||
page.add (connection_group);
|
||||
|
||||
server_url_row = new Adw.EntryRow ();
|
||||
server_url_row.title = "Server URL";
|
||||
connection_group.add (server_url_row);
|
||||
|
||||
username_row = new Adw.EntryRow ();
|
||||
username_row.title = "Username";
|
||||
connection_group.add (username_row);
|
||||
|
||||
password_row = new Adw.PasswordEntryRow ();
|
||||
password_row.title = "Password";
|
||||
connection_group.add (password_row);
|
||||
|
||||
settings = new Settings();
|
||||
settings.settings_ready.connect(load_settings);
|
||||
load_settings();
|
||||
|
||||
unowned var self = this;
|
||||
closed.connect(() => {
|
||||
self.save_settings();
|
||||
});
|
||||
}
|
||||
|
||||
private void load_settings() {
|
||||
try {
|
||||
username_row.text = settings.get_username();
|
||||
server_url_row.text = settings.get_server_url();
|
||||
password_row.text = settings.get_password();
|
||||
} catch (Error e) {
|
||||
warning("Failed to load settings: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private void save_settings() {
|
||||
try {
|
||||
settings.set_server_url(server_url_row.text);
|
||||
settings.set_username(username_row.text);
|
||||
settings.set_password(password_row.text);
|
||||
} catch (Error e) {
|
||||
warning("Failed to save settings: %s", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
150
gtk/src/conversation-list/conversation-list-model.vala
Normal file
150
gtk/src/conversation-list/conversation-list-model.vala
Normal file
@@ -0,0 +1,150 @@
|
||||
using GLib;
|
||||
using Gee;
|
||||
|
||||
public class ConversationListModel : Object, ListModel
|
||||
{
|
||||
public SortedSet<Conversation> conversations {
|
||||
owned get { return _conversations.read_only_view; }
|
||||
}
|
||||
|
||||
private SortedSet<Conversation> _conversations;
|
||||
|
||||
public ConversationListModel() {
|
||||
_conversations = new TreeSet<Conversation>((a, b) => {
|
||||
// Sort by date in descending order (newest first)
|
||||
return (int)(b.date - a.date);
|
||||
});
|
||||
|
||||
weak ConversationListModel self = this;
|
||||
Repository.get_instance().conversations_updated.connect(() => {
|
||||
self.load_conversations();
|
||||
});
|
||||
|
||||
Repository.get_instance().messages_updated.connect((conversation_guid) => {
|
||||
self.load_conversations();
|
||||
});
|
||||
|
||||
Repository.get_instance().reconnected.connect(() => {
|
||||
// Trigger a sync-list to get the latest conversations
|
||||
try {
|
||||
Repository.get_instance().sync_conversation_list();
|
||||
} catch (GLib.Error e) {
|
||||
warning("Failed to sync conversation list: %s", e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Conversation? get_conversation(string guid) {
|
||||
foreach (var conv in _conversations) {
|
||||
if (conv.guid == guid) {
|
||||
return conv;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void load_conversations() {
|
||||
try {
|
||||
Conversation[] new_conversations = Repository.get_instance().get_conversations();
|
||||
|
||||
// Create a map of old conversations for quick lookup
|
||||
var old_conversations_map = new HashMap<string, Conversation>();
|
||||
foreach (var conv in _conversations) {
|
||||
old_conversations_map[conv.guid] = conv;
|
||||
}
|
||||
|
||||
// Create a map of new conversations for quick lookup
|
||||
var new_conversations_map = new HashMap<string, Conversation>();
|
||||
foreach (var conv in new_conversations) {
|
||||
new_conversations_map[conv.guid] = conv;
|
||||
}
|
||||
|
||||
// Find removed conversations
|
||||
var removed_positions = new ArrayList<uint>();
|
||||
var current_position = 0;
|
||||
foreach (var old_conv in _conversations) {
|
||||
if (!new_conversations_map.has_key(old_conv.guid)) {
|
||||
removed_positions.add(current_position);
|
||||
}
|
||||
current_position++;
|
||||
}
|
||||
|
||||
// Remove conversations in reverse order to maintain correct positions
|
||||
for (int i = removed_positions.size - 1; i >= 0; i--) {
|
||||
var pos = removed_positions[i];
|
||||
_conversations.remove(_conversations.to_array()[pos]);
|
||||
items_changed(pos, 1, 0);
|
||||
}
|
||||
|
||||
// Find added conversations and changed conversations
|
||||
var added_conversations = new ArrayList<Conversation>();
|
||||
var changed_conversations = new ArrayList<Conversation>();
|
||||
foreach (var new_conv in new_conversations) {
|
||||
if (!old_conversations_map.has_key(new_conv.guid)) {
|
||||
added_conversations.add(new_conv);
|
||||
} else {
|
||||
var old_conv = old_conversations_map[new_conv.guid];
|
||||
if (!old_conv.equals(new_conv)) {
|
||||
changed_conversations.add(new_conv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add new conversations
|
||||
foreach (var conv in added_conversations) {
|
||||
_conversations.add(conv);
|
||||
// Find the position by counting how many items are before this one
|
||||
uint pos = 0;
|
||||
foreach (var existing_conv in _conversations) {
|
||||
if (existing_conv.guid == conv.guid) break;
|
||||
pos++;
|
||||
}
|
||||
items_changed(pos, 0, 1);
|
||||
}
|
||||
|
||||
// Update changed conversations
|
||||
foreach (var conv in changed_conversations) {
|
||||
// Find position of old conversation
|
||||
uint old_pos = 0;
|
||||
var old_conv = old_conversations_map[conv.guid];
|
||||
foreach (var existing_conv in _conversations) {
|
||||
if (existing_conv.guid == old_conv.guid) break;
|
||||
old_pos++;
|
||||
}
|
||||
|
||||
// Remove the old one
|
||||
_conversations.remove(old_conv);
|
||||
|
||||
// Add the new one
|
||||
_conversations.add(conv);
|
||||
|
||||
// Find the new (sorted) position
|
||||
uint new_pos = 0;
|
||||
foreach (var existing_conv in _conversations) {
|
||||
if (existing_conv.guid == conv.guid) break;
|
||||
new_pos++;
|
||||
}
|
||||
|
||||
// Notify of the change
|
||||
items_changed(old_pos, 1, 0);
|
||||
items_changed(new_pos, 0, 1);
|
||||
}
|
||||
} catch (Error e) {
|
||||
warning("Failed to load conversations: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ListModel implementation
|
||||
public Type get_item_type() {
|
||||
return typeof(Conversation);
|
||||
}
|
||||
|
||||
public uint get_n_items() {
|
||||
return _conversations.size;
|
||||
}
|
||||
|
||||
public Object? get_item(uint position) {
|
||||
return _conversations.to_array()[position];
|
||||
}
|
||||
}
|
||||
126
gtk/src/conversation-list/conversation-list-view.vala
Normal file
126
gtk/src/conversation-list/conversation-list-view.vala
Normal file
@@ -0,0 +1,126 @@
|
||||
using Adw;
|
||||
using Gtk;
|
||||
|
||||
public class ConversationListView : Adw.Bin
|
||||
{
|
||||
public ConversationListModel conversation_model { get; private set; }
|
||||
public signal void conversation_selected(Conversation conversation);
|
||||
public signal void conversation_activated(Conversation conversation);
|
||||
|
||||
private Adw.ToolbarView container;
|
||||
private ListBox list_box;
|
||||
private ScrolledWindow scrolled_window;
|
||||
private Adw.HeaderBar header_bar;
|
||||
|
||||
private string? selected_conversation_guid = null;
|
||||
private bool selection_update_queued = false;
|
||||
|
||||
public ConversationListView () {
|
||||
container = new Adw.ToolbarView ();
|
||||
set_child (container);
|
||||
|
||||
scrolled_window = new ScrolledWindow ();
|
||||
container.set_content (scrolled_window);
|
||||
|
||||
list_box = new ListBox ();
|
||||
list_box.add_css_class ("boxed-list");
|
||||
list_box.set_selection_mode (SelectionMode.SINGLE);
|
||||
list_box.activate_on_single_click = false;
|
||||
scrolled_window.set_child (list_box);
|
||||
|
||||
list_box.row_selected.connect ((row) => {
|
||||
var conversation_row = (ConversationRow?) row;
|
||||
if (conversation_row != null) {
|
||||
selected_conversation_guid = conversation_row.conversation.guid;
|
||||
|
||||
Conversation conversation = conversation_model.get_conversation(selected_conversation_guid);
|
||||
conversation_selected(conversation);
|
||||
}
|
||||
});
|
||||
|
||||
list_box.row_activated.connect((row) => {
|
||||
var conversation_row = (ConversationRow?) row;
|
||||
if (conversation_row != null) {
|
||||
Conversation conversation = conversation_row.conversation;
|
||||
conversation_activated(conversation);
|
||||
}
|
||||
});
|
||||
|
||||
header_bar = new Adw.HeaderBar ();
|
||||
header_bar.set_title_widget (new Label ("Kordophone"));
|
||||
container.add_top_bar (header_bar);
|
||||
|
||||
// Setup application menu
|
||||
var app_menu = new Menu ();
|
||||
|
||||
var section = new Menu ();
|
||||
section.append ("Manual Sync", "list.refresh");
|
||||
section.append ("Settings...", "win.settings");
|
||||
app_menu.append_section (null, section);
|
||||
|
||||
section = new Menu ();
|
||||
section.append ("Quit", "app.quit");
|
||||
app_menu.append_section (null, section);
|
||||
|
||||
var refresh_action = new SimpleAction("refresh", null);
|
||||
refresh_action.activate.connect (() => {
|
||||
try {
|
||||
Repository.get_instance().sync_conversation_list();
|
||||
} catch (GLib.Error e) {
|
||||
warning("Failed to sync conversation list: %s", e.message);
|
||||
}
|
||||
});
|
||||
|
||||
var action_group = new SimpleActionGroup ();
|
||||
action_group.add_action(refresh_action);
|
||||
insert_action_group ("list", action_group);
|
||||
|
||||
var menu_button = new Gtk.MenuButton ();
|
||||
menu_button.menu_model = app_menu;
|
||||
menu_button.primary = true;
|
||||
menu_button.icon_name = "open-menu-symbolic";
|
||||
header_bar.pack_end (menu_button);
|
||||
|
||||
// Set up model and bind to list
|
||||
conversation_model = new ConversationListModel ();
|
||||
conversation_model.items_changed.connect (on_items_changed);
|
||||
list_box.bind_model (conversation_model, create_conversation_row);
|
||||
}
|
||||
|
||||
private void on_items_changed (uint position, uint removed, uint added) {
|
||||
enqueue_selection_update();
|
||||
}
|
||||
|
||||
private void enqueue_selection_update() {
|
||||
if (selection_update_queued) {
|
||||
return;
|
||||
}
|
||||
|
||||
selection_update_queued = true;
|
||||
GLib.Idle.add(() => {
|
||||
update_selection();
|
||||
selection_update_queued = false;
|
||||
return false;
|
||||
}, GLib.Priority.HIGH);
|
||||
}
|
||||
|
||||
private void update_selection() {
|
||||
// Re-select selected_conversation_guid, if it has changed.
|
||||
if (selected_conversation_guid != null) {
|
||||
for (uint i = 0; i < conversation_model.get_n_items(); i++) {
|
||||
var conversation = (Conversation) conversation_model.get_item(i);
|
||||
if (conversation.guid == selected_conversation_guid) {
|
||||
var row = list_box.get_row_at_index((int)i);
|
||||
if (row != null) {
|
||||
list_box.select_row(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Widget create_conversation_row (Object item) {
|
||||
Conversation conversation = (Conversation) item;
|
||||
return new ConversationRow (conversation);
|
||||
}
|
||||
}
|
||||
61
gtk/src/conversation-list/conversation-row.vala
Normal file
61
gtk/src/conversation-list/conversation-row.vala
Normal file
@@ -0,0 +1,61 @@
|
||||
using Adw;
|
||||
using Gtk;
|
||||
|
||||
public class ConversationRow : Adw.ActionRow {
|
||||
public Conversation conversation;
|
||||
private Image unread_badge;
|
||||
|
||||
public ConversationRow(Conversation conversation) {
|
||||
this.conversation = conversation;
|
||||
this.activatable = true;
|
||||
|
||||
title = conversation.display_name.strip();
|
||||
title_lines = 1;
|
||||
|
||||
var preview = conversation.last_message_preview
|
||||
.strip()
|
||||
.replace("\n", " ")
|
||||
.replace("<", "\\<")
|
||||
.replace(">", "\\>")
|
||||
.replace("&", "&");
|
||||
|
||||
subtitle = preview.length > 100 ? preview.substring(0, 100) : preview;
|
||||
subtitle_lines = 1;
|
||||
|
||||
add_css_class("conversation-row");
|
||||
|
||||
unread_badge = new Image.from_icon_name ("media-record-symbolic");
|
||||
unread_badge.add_css_class("badge");
|
||||
unread_badge.add_css_class("accent");
|
||||
add_prefix(unread_badge);
|
||||
|
||||
if (conversation.is_unread) {
|
||||
unread_badge.opacity = 1.0;
|
||||
} else {
|
||||
unread_badge.opacity = 0.0;
|
||||
}
|
||||
|
||||
// Add timestamp if available
|
||||
if (conversation.date > 0) {
|
||||
var datetime = new DateTime.from_unix_local(conversation.date);
|
||||
if (datetime != null) {
|
||||
var now = new DateTime.now_local();
|
||||
|
||||
string time_str;
|
||||
if (datetime.get_year() == now.get_year() &&
|
||||
datetime.get_day_of_year() == now.get_day_of_year()) {
|
||||
// Today - show time
|
||||
time_str = datetime.format("%H:%M");
|
||||
} else {
|
||||
// Not today - show date
|
||||
time_str = datetime.format("%b %d");
|
||||
}
|
||||
|
||||
var time_label = new Label(time_str);
|
||||
time_label.add_css_class("dim-label");
|
||||
time_label.margin_start = 8;
|
||||
add_suffix(time_label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
gtk/src/meson.build
Normal file
91
gtk/src/meson.build
Normal file
@@ -0,0 +1,91 @@
|
||||
dependencies = [
|
||||
dependency('gtk4', required : true),
|
||||
dependency('libadwaita-1', required : true),
|
||||
dependency('gio-2.0', required : true),
|
||||
dependency('gee-0.8', required : true),
|
||||
dependency('gio-unix-2.0', required : true),
|
||||
dependency('libsecret-1', required : true),
|
||||
]
|
||||
|
||||
gnome = import('gnome')
|
||||
kp_prefix = get_option('prefix')
|
||||
datadir = join_paths(kp_prefix, get_option('datadir'))
|
||||
resources = gnome.compile_resources(
|
||||
'kordophone-resources',
|
||||
'resources/kordophone.gresource.xml',
|
||||
source_dir: 'resources'
|
||||
)
|
||||
|
||||
# Icons
|
||||
app_icon_dirs = [
|
||||
'16x16',
|
||||
'24x24',
|
||||
'32x32',
|
||||
'48x48',
|
||||
'256x256',
|
||||
'512x512',
|
||||
]
|
||||
|
||||
build_tools_dir = meson.source_root() / 'build-aux'
|
||||
|
||||
image_magick = find_program('convert', required : true)
|
||||
resizer = find_program(build_tools_dir / 'resize.py')
|
||||
icons = custom_target('icons',
|
||||
output: 'hicolor',
|
||||
input: 'resources/net.buzzert.kordophone.png',
|
||||
command: [resizer, image_magick, '@INPUT@', '@OUTPUT@'],
|
||||
install: true,
|
||||
install_dir: join_paths(datadir, 'icons'),
|
||||
)
|
||||
|
||||
# Full res icon for Desktop Entry
|
||||
install_data('resources/net.buzzert.kordophone.png',
|
||||
install_dir: join_paths(datadir, 'icons'),
|
||||
)
|
||||
|
||||
# Desktop
|
||||
install_data('resources/net.buzzert.kordophone.desktop',
|
||||
install_dir: join_paths(datadir, 'applications')
|
||||
)
|
||||
|
||||
sources = [
|
||||
'application/kordophone-application.vala',
|
||||
'application/main-window.vala',
|
||||
'application/preferences-window.vala',
|
||||
|
||||
'service/interface/dbusservice.vala',
|
||||
'service/dbus-service-base.vala',
|
||||
'service/repository.vala',
|
||||
'service/settings.vala',
|
||||
|
||||
'conversation-list/conversation-list-view.vala',
|
||||
'conversation-list/conversation-list-model.vala',
|
||||
'conversation-list/conversation-row.vala',
|
||||
|
||||
'transcript/attachment-preview.vala',
|
||||
'transcript/message-list-model.vala',
|
||||
'transcript/transcript-container-view.vala',
|
||||
'transcript/transcript-drawing-area.vala',
|
||||
'transcript/transcript-view.vala',
|
||||
|
||||
'transcript/layouts/bubble-layout.vala',
|
||||
'transcript/layouts/chat-item-layout.vala',
|
||||
'transcript/layouts/date-item-layout.vala',
|
||||
'transcript/layouts/image-bubble-layout.vala',
|
||||
'transcript/layouts/sender-annotation-layout.vala',
|
||||
'transcript/layouts/text-bubble-layout.vala',
|
||||
|
||||
'models/attachment.vala',
|
||||
'models/conversation.vala',
|
||||
'models/message.vala',
|
||||
]
|
||||
|
||||
executable('kordophone',
|
||||
sources,
|
||||
resources,
|
||||
icons,
|
||||
dependencies : dependencies,
|
||||
vala_args: ['--pkg', 'posix'],
|
||||
link_args: ['-lm'],
|
||||
install : true
|
||||
)
|
||||
88
gtk/src/models/attachment.vala
Normal file
88
gtk/src/models/attachment.vala
Normal file
@@ -0,0 +1,88 @@
|
||||
public class AttributionInfo : Object {
|
||||
// Picture width
|
||||
public int64 width;
|
||||
|
||||
// Picture height
|
||||
public int64 height;
|
||||
|
||||
public static AttributionInfo from_variant(Variant variant) {
|
||||
var attribution_info = new AttributionInfo();
|
||||
|
||||
VariantDict dict = new VariantDict(variant);
|
||||
attribution_info.width = dict.lookup_value("width", VariantType.INT32)?.get_int32() ?? 0;
|
||||
attribution_info.height = dict.lookup_value("height", VariantType.INT32)?.get_int32() ?? 0;
|
||||
|
||||
return attribution_info;
|
||||
}
|
||||
}
|
||||
|
||||
public class AttachmentMetadata : Object {
|
||||
public AttributionInfo? attribution_info = null;
|
||||
|
||||
public static AttachmentMetadata from_variant(Variant variant) {
|
||||
var metadata = new AttachmentMetadata();
|
||||
|
||||
VariantDict dict = new VariantDict(variant);
|
||||
metadata.attribution_info = AttributionInfo.from_variant(dict.lookup_value("attribution_info", VariantType.DICTIONARY));
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
public class AttachmentInfo : Object {
|
||||
public string? path;
|
||||
public string? preview_path;
|
||||
public bool? downloaded;
|
||||
public bool? preview_downloaded;
|
||||
|
||||
public AttachmentInfo(string? path, string? preview_path, bool? downloaded, bool? preview_downloaded) {
|
||||
this.path = path;
|
||||
this.preview_path = preview_path;
|
||||
this.downloaded = downloaded;
|
||||
this.preview_downloaded = preview_downloaded;
|
||||
}
|
||||
}
|
||||
|
||||
public class Attachment : Object {
|
||||
public string guid;
|
||||
public string path;
|
||||
public string preview_path;
|
||||
public bool downloaded;
|
||||
public bool preview_downloaded;
|
||||
public AttachmentMetadata? metadata;
|
||||
|
||||
public Attachment(string guid, AttachmentMetadata? metadata) {
|
||||
this.guid = guid;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public Attachment.from_variant(Variant variant) {
|
||||
VariantIter iter;
|
||||
variant.get("a{sv}", out iter);
|
||||
|
||||
string key;
|
||||
Variant val;
|
||||
while (iter.next("{sv}", out key, out val)) {
|
||||
switch (key) {
|
||||
case "guid":
|
||||
this.guid = val.get_string();
|
||||
break;
|
||||
case "path":
|
||||
this.path = val.get_string();
|
||||
break;
|
||||
case "preview_path":
|
||||
this.preview_path = val.get_string();
|
||||
break;
|
||||
case "downloaded":
|
||||
this.downloaded = val.get_boolean();
|
||||
break;
|
||||
case "preview_downloaded":
|
||||
this.preview_downloaded = val.get_boolean();
|
||||
break;
|
||||
case "metadata":
|
||||
this.metadata = AttachmentMetadata.from_variant(val);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
gtk/src/models/conversation.vala
Normal file
72
gtk/src/models/conversation.vala
Normal file
@@ -0,0 +1,72 @@
|
||||
using GLib;
|
||||
|
||||
public class Conversation : Object {
|
||||
public string guid { get; set; default = ""; }
|
||||
public string last_message_preview { get; set; default = ""; }
|
||||
public int64 date { get; set; default = 0; }
|
||||
public string[] participants { get; set; default = new string[0]; }
|
||||
public int unread_count { get; set; default = 0; }
|
||||
|
||||
public bool is_unread { get { return unread_count > 0; } }
|
||||
|
||||
public string display_name {
|
||||
owned get {
|
||||
if (_display_name != null && _display_name.length > 0) {
|
||||
return _display_name;
|
||||
}
|
||||
|
||||
if (participants.length == 1) {
|
||||
return participants[0];
|
||||
}
|
||||
|
||||
if (participants.length > 1) {
|
||||
return string.joinv(", ", participants);
|
||||
}
|
||||
|
||||
return "Untitled";
|
||||
}
|
||||
}
|
||||
|
||||
private string? _display_name = null;
|
||||
|
||||
public Conversation.from_hash_table(HashTable<string, Variant> conversation_data) {
|
||||
guid = conversation_data["guid"].get_string();
|
||||
|
||||
if (conversation_data.contains("last_message_preview")) {
|
||||
last_message_preview = conversation_data["last_message_preview"].get_string();
|
||||
}
|
||||
|
||||
if (conversation_data.contains("participants")) {
|
||||
participants = conversation_data["participants"].dup_strv();
|
||||
}
|
||||
|
||||
if (conversation_data.contains("unread_count")) {
|
||||
unread_count = conversation_data["unread_count"].get_int32();
|
||||
}
|
||||
|
||||
if (conversation_data.contains("date")) {
|
||||
date = conversation_data["date"].get_int64();
|
||||
}
|
||||
|
||||
if (conversation_data.contains("display_name")) {
|
||||
_display_name = conversation_data["display_name"].get_string();
|
||||
}
|
||||
}
|
||||
|
||||
public bool equals(Conversation other) {
|
||||
if (other == null) return false;
|
||||
if (guid != other.guid) return false;
|
||||
if (date != other.date) return false;
|
||||
if (unread_count != other.unread_count) return false;
|
||||
if (last_message_preview != other.last_message_preview) return false;
|
||||
if (_display_name != other._display_name) return false;
|
||||
|
||||
// Compare participants arrays
|
||||
if (participants.length != other.participants.length) return false;
|
||||
for (int i = 0; i < participants.length; i++) {
|
||||
if (participants[i] != other.participants[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
90
gtk/src/models/message.vala
Normal file
90
gtk/src/models/message.vala
Normal file
@@ -0,0 +1,90 @@
|
||||
using GLib;
|
||||
using Gee;
|
||||
|
||||
public class Message : Object, Comparable<Message>, Hashable<Message>
|
||||
{
|
||||
public string guid { get; set; default = ""; }
|
||||
public string text { get; set; default = ""; }
|
||||
public DateTime date { get; set; default = new DateTime.now_local(); }
|
||||
public string sender { get; set; default = null; }
|
||||
|
||||
public Attachment[] attachments { get; set; default = {}; }
|
||||
|
||||
public bool should_animate = false;
|
||||
|
||||
public bool from_me {
|
||||
get {
|
||||
// Hm, this may have been accidental.
|
||||
return sender == "(Me)";
|
||||
}
|
||||
}
|
||||
|
||||
public bool is_attachment_marker {
|
||||
get {
|
||||
uint8[] attachment_marker_str = { 0xEF, 0xBF, 0xBC, 0x00 };
|
||||
if (text.length > attachment_marker_str.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (string)attachment_marker_str == text;
|
||||
}
|
||||
}
|
||||
|
||||
public string markup {
|
||||
owned get {
|
||||
const string link_regex_pattern = "https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)";
|
||||
|
||||
try {
|
||||
var regex = new GLib.Regex(link_regex_pattern);
|
||||
|
||||
var escaped_text = GLib.Markup.escape_text(this.text);
|
||||
return regex.replace(escaped_text, escaped_text.length, 0, "<u>\\0</u>");
|
||||
} catch (GLib.RegexError e) {
|
||||
GLib.warning("Error linking text: %s", e.message);
|
||||
return GLib.Markup.escape_text(this.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Message(string text, DateTime date, string? sender) {
|
||||
this.text = text;
|
||||
this.date = date;
|
||||
this.sender = sender;
|
||||
}
|
||||
|
||||
public Message.from_hash_table(HashTable<string, Variant> message_data) {
|
||||
guid = message_data["id"].get_string();
|
||||
text = message_data["text"].get_string();
|
||||
sender = message_data["sender"].get_string();
|
||||
date = new DateTime.from_unix_utc(message_data["date"].get_int64());
|
||||
|
||||
// Attachments
|
||||
var attachments_variant = message_data["attachments"];
|
||||
var attachments = new Gee.ArrayList<Attachment>();
|
||||
if (attachments_variant != null) {
|
||||
for (int i = 0; i < attachments_variant.n_children(); i++) {
|
||||
var attachment_variant = attachments_variant.get_child_value(i);
|
||||
var attachment = new Attachment.from_variant(attachment_variant);
|
||||
attachments.add(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
this.attachments = attachments.to_array();
|
||||
}
|
||||
|
||||
public int compare_to(Message other) {
|
||||
if (guid == other.guid) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
public bool equal_to(Message other) {
|
||||
return guid == other.guid;
|
||||
}
|
||||
|
||||
public uint hash() {
|
||||
return guid.hash();
|
||||
}
|
||||
}
|
||||
6
gtk/src/resources/kordophone.gresource.xml
Normal file
6
gtk/src/resources/kordophone.gresource.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gresources>
|
||||
<gresource prefix="/net/buzzert/kordophone2">
|
||||
<file>style.css</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
9
gtk/src/resources/net.buzzert.kordophone.desktop
Normal file
9
gtk/src/resources/net.buzzert.kordophone.desktop
Normal file
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Encoding=UTF-8
|
||||
Name=Kordophone
|
||||
Comment=Kordophone GTK Client
|
||||
Exec=kordophone
|
||||
Icon=net.buzzert.kordophone.png
|
||||
Terminal=false
|
||||
|
||||
BIN
gtk/src/resources/net.buzzert.kordophone.png
Normal file
BIN
gtk/src/resources/net.buzzert.kordophone.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
59
gtk/src/resources/style.css
Normal file
59
gtk/src/resources/style.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/* Kordophone application styles */
|
||||
|
||||
.conversation-row {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid alpha(#000, 0.1);
|
||||
}
|
||||
|
||||
.conversation-row:selected {
|
||||
background-color: alpha(@accent_bg_color, 0.50);
|
||||
}
|
||||
|
||||
.flipped-y-axis {
|
||||
/* Invert the y-axis, so the messages are drawn bottom-to-top */
|
||||
/* Individual messages are drawn upside down in the custom renderer */
|
||||
transform: scale(1, -1);
|
||||
}
|
||||
|
||||
.transcript-drawing-area {
|
||||
color: darker(@accent_bg_color);
|
||||
}
|
||||
|
||||
.message-input-box {
|
||||
margin-bottom: 14px;
|
||||
margin-top: 14px;
|
||||
margin-left: 14px;
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.message-input-entry {
|
||||
font-size: 1.1rem;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid alpha(@borders, 0.5);
|
||||
}
|
||||
|
||||
.attachment-preview-row {
|
||||
background-color: alpha(@window_bg_color, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
border-radius: 8px;
|
||||
border: 1px solid alpha(@borders, 0.5);
|
||||
}
|
||||
|
||||
.attachment-preview.completed {
|
||||
border-color: @success_color;
|
||||
}
|
||||
|
||||
.attachment-image {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hovering-text-view {
|
||||
background-color: transparent;
|
||||
color: transparent;
|
||||
line-height: 1.18; /* TextBubbleLayout.line_height */
|
||||
}
|
||||
12
gtk/src/service/dbus-service-base.vala
Normal file
12
gtk/src/service/dbus-service-base.vala
Normal file
@@ -0,0 +1,12 @@
|
||||
public abstract class DBusServiceProxy : Object {
|
||||
protected const string DBUS_PATH = "/net/buzzert/kordophonecd/daemon";
|
||||
protected const string DBUS_NAME = "net.buzzert.kordophonecd";
|
||||
|
||||
protected DBusServiceProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
protected errordomain DBusServiceProxyError {
|
||||
NOT_CONNECTED,
|
||||
PASSWORD_STORAGE;
|
||||
}
|
||||
87
gtk/src/service/interface/dbusservice.vala
Normal file
87
gtk/src/service/interface/dbusservice.vala
Normal file
@@ -0,0 +1,87 @@
|
||||
/* Generated by vala-dbus-binding-tool 1.0-aa2fb. Do not modify! */
|
||||
/* Generated with: vala-dbus-binding-tool --no-synced --strip-namespace=net --strip-namespace=buzzert --rename-namespace=kordophone:DBusService --api-path=xml/ */
|
||||
using GLib;
|
||||
|
||||
namespace DBusService {
|
||||
|
||||
[DBus (name = "net.buzzert.kordophone.Settings", timeout = 120000)]
|
||||
public interface Settings : GLib.Object {
|
||||
|
||||
[DBus (name = "ServerURL")]
|
||||
public abstract string server_u_r_l { owned get; set; }
|
||||
|
||||
[DBus (name = "Username")]
|
||||
public abstract string username { owned get; set; }
|
||||
|
||||
[DBus (name = "SetServer")]
|
||||
public abstract void set_server(string url, string user) throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "ConfigChanged")]
|
||||
public signal void config_changed();
|
||||
}
|
||||
|
||||
[DBus (name = "net.buzzert.kordophone.Repository", timeout = 120000)]
|
||||
public interface Repository : GLib.Object {
|
||||
|
||||
[DBus (name = "GetVersion")]
|
||||
public abstract string get_version() throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "GetConversations")]
|
||||
public abstract GLib.HashTable<string, GLib.Variant>[] get_conversations(int limit, int offset) throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "SyncConversationList")]
|
||||
public abstract void sync_conversation_list() throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "SyncAllConversations")]
|
||||
public abstract void sync_all_conversations() throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "SyncConversation")]
|
||||
public abstract void sync_conversation(string conversation_id) throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "MarkConversationAsRead")]
|
||||
public abstract void mark_conversation_as_read(string conversation_id) throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "ConversationsUpdated")]
|
||||
public signal void conversations_updated();
|
||||
|
||||
[DBus (name = "DeleteAllConversations")]
|
||||
public abstract void delete_all_conversations() throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "GetMessages")]
|
||||
public abstract GLib.HashTable<string, GLib.Variant>[] get_messages(string conversation_id, string last_message_id) throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "SendMessage")]
|
||||
public abstract string send_message(string conversation_id, string text, string[] attachment_guids) throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "MessagesUpdated")]
|
||||
public signal void messages_updated(string conversation_id);
|
||||
|
||||
[DBus (name = "UpdateStreamReconnected")]
|
||||
public signal void update_stream_reconnected();
|
||||
|
||||
[DBus (name = "GetAttachmentInfo")]
|
||||
public abstract RepositoryAttachmentInfoStruct get_attachment_info(string attachment_id) throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "DownloadAttachment")]
|
||||
public abstract void download_attachment(string attachment_id, bool preview) throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "UploadAttachment")]
|
||||
public abstract string upload_attachment(string path) throws DBusError, IOError;
|
||||
|
||||
[DBus (name = "AttachmentDownloadCompleted")]
|
||||
public signal void attachment_download_completed(string attachment_id);
|
||||
|
||||
[DBus (name = "AttachmentDownloadFailed")]
|
||||
public signal void attachment_download_failed(string attachment_id, string error_message);
|
||||
|
||||
[DBus (name = "AttachmentUploadCompleted")]
|
||||
public signal void attachment_upload_completed(string upload_guid, string attachment_guid);
|
||||
}
|
||||
|
||||
public struct RepositoryAttachmentInfoStruct {
|
||||
public string attr1;
|
||||
public string attr2;
|
||||
public bool attr3;
|
||||
public bool attr4;
|
||||
}
|
||||
}
|
||||
10
gtk/src/service/interface/generate.sh
Executable file
10
gtk/src/service/interface/generate.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
|
||||
if ! command -v vala-dbus-binding-tool >/dev/null 2>&1; then
|
||||
echo "Error: vala-dbus-binding-tool not found. Please install it first."
|
||||
echo "https://github.com/freesmartphone/vala-dbus-binding-tool"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
vala-dbus-binding-tool --no-synced --strip-namespace=net --strip-namespace=buzzert --rename-namespace=kordophone:DBusService --api-path=xml/
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
|
||||
<node>
|
||||
<interface name="net.buzzert.kordophone.Repository">
|
||||
<method name="GetVersion">
|
||||
<arg type="s" name="version" direction="out" />
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Returns the version of the client daemon."/>
|
||||
</method>
|
||||
|
||||
<!-- Conversations -->
|
||||
|
||||
<method name="GetConversations">
|
||||
<arg type="i" name="limit" direction="in"/>
|
||||
<arg type="i" name="offset" direction="in"/>
|
||||
|
||||
<arg type="aa{sv}" direction="out" name="conversations">
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Array of dictionaries. Each dictionary has keys:
|
||||
'id' (string): Unique identifier
|
||||
'display_name' (string): Display name
|
||||
'last_message_preview' (string): Preview text
|
||||
'is_unread' (boolean): Unread status
|
||||
'date' (int64): Date of last message
|
||||
'participants' (array of strings): List of participants
|
||||
'unread_count' (int32): Number of unread messages"/>
|
||||
</arg>
|
||||
</method>
|
||||
|
||||
<method name="SyncConversationList">
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Initiates a background sync of the conversation list with the server."/>
|
||||
</method>
|
||||
|
||||
<method name="SyncAllConversations">
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Initiates a background sync of all conversations with the server."/>
|
||||
</method>
|
||||
|
||||
<method name="SyncConversation">
|
||||
<arg type="s" name="conversation_id" direction="in"/>
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Initiates a background sync of a single conversation with the server."/>
|
||||
</method>
|
||||
|
||||
<method name="MarkConversationAsRead">
|
||||
<arg type="s" name="conversation_id" direction="in"/>
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Marks a conversation as read."/>
|
||||
</method>
|
||||
|
||||
<signal name="ConversationsUpdated">
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Emitted when the list of conversations is updated."/>
|
||||
</signal>
|
||||
|
||||
<method name="DeleteAllConversations">
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Deletes all conversations from the database."/>
|
||||
</method>
|
||||
|
||||
<!-- Messages -->
|
||||
|
||||
<method name="GetMessages">
|
||||
<arg type="s" name="conversation_id" direction="in"/>
|
||||
<arg type="s" name="last_message_id" direction="in"/>
|
||||
<arg type="aa{sv}" direction="out" name="messages">
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Array of dictionaries. Each dictionary has keys:
|
||||
'id' (string): Unique message identifier
|
||||
'text' (string): Message body text
|
||||
'date' (int64): Message timestamp
|
||||
'sender' (string): Sender display name
|
||||
'attachments' (array of dictionaries): List of attachments
|
||||
'guid' (string): Attachment GUID
|
||||
'path' (string): Attachment path
|
||||
'preview_path' (string): Preview attachment path
|
||||
'downloaded' (boolean): Whether the attachment is downloaded
|
||||
'preview_downloaded' (boolean): Whether the preview is downloaded
|
||||
'metadata' (dictionary, optional): Attachment metadata
|
||||
'attribution_info' (dictionary, optional): Attribution info
|
||||
'width' (int32, optional): Width
|
||||
'height' (int32, optional): Height"/>
|
||||
</arg>
|
||||
</method>
|
||||
|
||||
<method name="SendMessage">
|
||||
<arg type="s" name="conversation_id" direction="in"/>
|
||||
<arg type="s" name="text" direction="in"/>
|
||||
<arg type="as" name="attachment_guids" direction="in"/>
|
||||
|
||||
<arg type="s" name="outgoing_message_id" direction="out"/>
|
||||
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Sends a message to the server. Returns the outgoing message ID.
|
||||
Arguments:
|
||||
- conversation_id: The ID of the conversation to send the message to.
|
||||
- text: The text of the message to send.
|
||||
- attachment_guids: The GUIDs of the attachments to send.
|
||||
|
||||
Returns:
|
||||
- outgoing_message_id: The ID of the outgoing message.
|
||||
"/>
|
||||
</method>
|
||||
|
||||
<signal name="MessagesUpdated">
|
||||
<arg type="s" name="conversation_id" direction="in"/>
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Emitted when the list of messages is updated."/>
|
||||
</signal>
|
||||
|
||||
<signal name="UpdateStreamReconnected">
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Emitted when the update stream is reconnected after a timeout or configuration change."/>
|
||||
</signal>
|
||||
|
||||
<!-- Attachments -->
|
||||
|
||||
<method name="GetAttachmentInfo">
|
||||
<arg type="s" name="attachment_id" direction="in"/>
|
||||
<arg type="(ssbb)" name="attachment_info" direction="out"/>
|
||||
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Returns attachment info:
|
||||
- path: string
|
||||
- preview_path: string
|
||||
- downloaded: boolean
|
||||
- preview_downloaded: boolean
|
||||
"/>
|
||||
</method>
|
||||
|
||||
<method name="DownloadAttachment">
|
||||
<arg type="s" name="attachment_id" direction="in"/>
|
||||
<arg type="b" name="preview" direction="in"/>
|
||||
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Initiates download of the specified attachment if not already downloaded.
|
||||
Arguments:
|
||||
attachment_id: the attachment GUID
|
||||
preview: whether to download the preview (true) or full attachment (false)
|
||||
"/>
|
||||
</method>
|
||||
|
||||
<method name="UploadAttachment">
|
||||
<arg type="s" name="path" direction="in"/>
|
||||
<arg type="s" name="upload_guid" direction="out"/>
|
||||
</method>
|
||||
|
||||
<signal name="AttachmentDownloadCompleted">
|
||||
<arg type="s" name="attachment_id"/>
|
||||
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Emitted when an attachment download completes successfully."/>
|
||||
</signal>
|
||||
|
||||
<signal name="AttachmentDownloadFailed">
|
||||
<arg type="s" name="attachment_id"/>
|
||||
<arg type="s" name="error_message"/>
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Emitted when an attachment download fails."/>
|
||||
</signal>
|
||||
|
||||
<signal name="AttachmentUploadCompleted">
|
||||
<arg type="s" name="upload_guid"/>
|
||||
<arg type="s" name="attachment_guid"/>
|
||||
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Emitted when an attachment upload completes successfully.
|
||||
Returns:
|
||||
- upload_guid: The GUID of the upload.
|
||||
- attachment_guid: The GUID of the attachment on the server.
|
||||
"/>
|
||||
</signal>
|
||||
</interface>
|
||||
|
||||
<interface name="net.buzzert.kordophone.Settings">
|
||||
<!-- editable properties -->
|
||||
<property name="ServerURL" type="s" access="readwrite"/>
|
||||
<property name="Username" type="s" access="readwrite"/>
|
||||
|
||||
<!-- helpers for atomic updates -->
|
||||
<method name="SetServer">
|
||||
<arg name="url" type="s" direction="in"/>
|
||||
<arg name="user" type="s" direction="in"/>
|
||||
</method>
|
||||
|
||||
<!-- emitted when anything changes -->
|
||||
<signal name="ConfigChanged"/>
|
||||
</interface>
|
||||
</node>
|
||||
147
gtk/src/service/repository.vala
Normal file
147
gtk/src/service/repository.vala
Normal file
@@ -0,0 +1,147 @@
|
||||
using GLib;
|
||||
using Gee;
|
||||
|
||||
public class Repository : DBusServiceProxy {
|
||||
public signal void conversations_updated();
|
||||
public signal void messages_updated(string conversation_guid);
|
||||
public signal void attachment_downloaded(string attachment_guid);
|
||||
public signal void attachment_uploaded(string upload_guid, string attachment_guid);
|
||||
public signal void reconnected();
|
||||
|
||||
public static Repository get_instance() {
|
||||
if (instance == null) {
|
||||
instance = new Repository();
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static Repository instance = null;
|
||||
private DBusService.Repository? dbus_repository;
|
||||
private uint dbus_watch_id;
|
||||
|
||||
private Repository() {
|
||||
this.dbus_watch_id = Bus.watch_name(BusType.SESSION, DBUS_NAME, BusNameWatcherFlags.AUTO_START, (name, name_owner) => {
|
||||
connect_to_repository();
|
||||
});
|
||||
}
|
||||
|
||||
private void connect_to_repository() {
|
||||
GLib.info("Connecting to repository");
|
||||
|
||||
try {
|
||||
this.dbus_repository = Bus.get_proxy_sync<DBusService.Repository>(BusType.SESSION, DBUS_NAME, DBUS_PATH);
|
||||
this.dbus_repository.conversations_updated.connect(() => {
|
||||
conversations_updated();
|
||||
});
|
||||
|
||||
this.dbus_repository.messages_updated.connect((conversation_guid) => {
|
||||
messages_updated(conversation_guid);
|
||||
});
|
||||
|
||||
this.dbus_repository.attachment_download_completed.connect((attachment_guid) => {
|
||||
attachment_downloaded(attachment_guid);
|
||||
});
|
||||
|
||||
this.dbus_repository.attachment_upload_completed.connect((upload_guid, attachment_guid) => {
|
||||
attachment_uploaded(upload_guid, attachment_guid);
|
||||
});
|
||||
|
||||
this.dbus_repository.update_stream_reconnected.connect(() => {
|
||||
reconnected();
|
||||
});
|
||||
|
||||
conversations_updated();
|
||||
reconnected();
|
||||
} catch (GLib.Error e) {
|
||||
warning("Failed to connect to repository: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public void sync_conversation_list() throws DBusServiceProxyError, GLib.Error {
|
||||
if (dbus_repository == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
||||
}
|
||||
|
||||
dbus_repository.sync_conversation_list();
|
||||
}
|
||||
|
||||
public Conversation[] get_conversations(int limit = 200) throws DBusServiceProxyError, GLib.Error {
|
||||
if (dbus_repository == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
||||
}
|
||||
|
||||
var conversations = dbus_repository.get_conversations(limit, 0);
|
||||
Conversation[] returned_conversations = new Conversation[conversations.length];
|
||||
|
||||
for (int i = 0; i < conversations.length; i++) {
|
||||
returned_conversations[i] = new Conversation.from_hash_table(conversations[i]);
|
||||
}
|
||||
|
||||
return returned_conversations;
|
||||
}
|
||||
|
||||
public Message[] get_messages(string conversation_guid, string last_message_id = "") throws DBusServiceProxyError, GLib.Error {
|
||||
if (dbus_repository == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
||||
}
|
||||
|
||||
var messages = dbus_repository.get_messages(conversation_guid, last_message_id);
|
||||
Message[] returned_messages = new Message[messages.length];
|
||||
|
||||
for (int i = 0; i < messages.length; i++) {
|
||||
returned_messages[i] = new Message.from_hash_table(messages[i]);
|
||||
}
|
||||
|
||||
return returned_messages;
|
||||
}
|
||||
|
||||
public string send_message(string conversation_guid, string message, string[] attachment_guids) throws DBusServiceProxyError, GLib.Error {
|
||||
if (dbus_repository == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
||||
}
|
||||
|
||||
return dbus_repository.send_message(conversation_guid, message, attachment_guids);
|
||||
}
|
||||
|
||||
public void sync_conversation(string conversation_guid) throws DBusServiceProxyError, GLib.Error {
|
||||
if (dbus_repository == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
||||
}
|
||||
|
||||
dbus_repository.sync_conversation(conversation_guid);
|
||||
}
|
||||
|
||||
public void mark_conversation_as_read(string conversation_guid) throws DBusServiceProxyError, GLib.Error {
|
||||
if (dbus_repository == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
||||
}
|
||||
|
||||
dbus_repository.mark_conversation_as_read(conversation_guid);
|
||||
}
|
||||
|
||||
public void download_attachment(string attachment_guid, bool preview) throws DBusServiceProxyError, GLib.Error {
|
||||
if (dbus_repository == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
||||
}
|
||||
|
||||
dbus_repository.download_attachment(attachment_guid, preview);
|
||||
}
|
||||
|
||||
public string upload_attachment(string filename) throws DBusServiceProxyError, GLib.Error {
|
||||
if (dbus_repository == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
||||
}
|
||||
|
||||
return dbus_repository.upload_attachment(filename);
|
||||
}
|
||||
|
||||
public AttachmentInfo get_attachment_info(string attachment_guid) throws DBusServiceProxyError, GLib.Error {
|
||||
if (dbus_repository == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
||||
}
|
||||
|
||||
var info = dbus_repository.get_attachment_info(attachment_guid);
|
||||
return new AttachmentInfo(info.attr1, info.attr2, info.attr3, info.attr4);
|
||||
}
|
||||
}
|
||||
94
gtk/src/service/settings.vala
Normal file
94
gtk/src/service/settings.vala
Normal file
@@ -0,0 +1,94 @@
|
||||
using GLib;
|
||||
|
||||
public class Settings : DBusServiceProxy
|
||||
{
|
||||
public signal void config_changed();
|
||||
public signal void settings_ready();
|
||||
|
||||
private DBusService.Settings? dbus_settings;
|
||||
private Secret.Service secret_service;
|
||||
|
||||
public Settings() {
|
||||
base();
|
||||
|
||||
try {
|
||||
secret_service = Secret.Service.get_sync(Secret.ServiceFlags.OPEN_SESSION);
|
||||
|
||||
this.dbus_settings = Bus.get_proxy_sync<DBusService.Settings>(BusType.SESSION, DBUS_NAME, DBUS_PATH);
|
||||
settings_ready();
|
||||
} catch (GLib.Error e) {
|
||||
warning("Failed to get secret service: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public string get_server_url() throws DBusServiceProxyError, GLib.Error {
|
||||
if (dbus_settings == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Settings not connected");
|
||||
}
|
||||
return dbus_settings.server_u_r_l;
|
||||
}
|
||||
|
||||
public void set_server_url(string url) throws Error, GLib.Error {
|
||||
if (dbus_settings == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Settings not connected");
|
||||
}
|
||||
dbus_settings.server_u_r_l = url;
|
||||
}
|
||||
|
||||
public string get_username() throws Error, GLib.Error {
|
||||
if (dbus_settings == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Settings not connected");
|
||||
}
|
||||
return dbus_settings.username;
|
||||
}
|
||||
|
||||
public void set_username(string username) throws Error, GLib.Error {
|
||||
if (dbus_settings == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Settings not connected");
|
||||
}
|
||||
dbus_settings.username = username;
|
||||
}
|
||||
|
||||
public void set_server(string url, string username) throws Error, GLib.Error {
|
||||
if (dbus_settings == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Settings not connected");
|
||||
}
|
||||
dbus_settings.set_server(url, username);
|
||||
}
|
||||
|
||||
private HashTable<string, string> password_attributes() {
|
||||
var attributes = new HashTable<string, string>(str_hash, str_equal);
|
||||
attributes["service"] = "net.buzzert.kordophonecd";
|
||||
attributes["target"] = "default";
|
||||
attributes["username"] = get_username();
|
||||
return attributes;
|
||||
}
|
||||
|
||||
public string get_password() throws Error {
|
||||
var attributes = password_attributes();
|
||||
var password = secret_service.lookup_sync(null, attributes, null);
|
||||
if (password == null) {
|
||||
warning("No password found for user %s", get_username());
|
||||
return "";
|
||||
}
|
||||
|
||||
return password.get_text();
|
||||
}
|
||||
|
||||
public void set_password(string password) throws Error, GLib.Error {
|
||||
var attributes = password_attributes();
|
||||
bool result = secret_service.store_sync(
|
||||
null,
|
||||
attributes,
|
||||
Secret.COLLECTION_DEFAULT,
|
||||
"Kordophone Keystore",
|
||||
new Secret.Value(password, password.length, "text/plain"),
|
||||
null
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
warning("Failed to store password for user %s", get_username());
|
||||
throw new DBusServiceProxyError.PASSWORD_STORAGE("Failed to store password");
|
||||
}
|
||||
}
|
||||
}
|
||||
93
gtk/src/transcript/attachment-preview.vala
Normal file
93
gtk/src/transcript/attachment-preview.vala
Normal file
@@ -0,0 +1,93 @@
|
||||
using Gtk;
|
||||
using Gdk;
|
||||
|
||||
class AttachmentPreview : Gtk.Box {
|
||||
public signal void remove_requested();
|
||||
|
||||
private File file;
|
||||
private string upload_guid;
|
||||
private string? attachment_guid = null;
|
||||
private bool is_completed = false;
|
||||
|
||||
private Overlay overlay;
|
||||
private Image picture;
|
||||
private Spinner spinner;
|
||||
private Button remove_button;
|
||||
|
||||
public AttachmentPreview(File file, string upload_guid) {
|
||||
Object(orientation: Orientation.VERTICAL, spacing: 0);
|
||||
this.file = file;
|
||||
this.upload_guid = upload_guid;
|
||||
|
||||
setup_ui();
|
||||
load_image();
|
||||
}
|
||||
|
||||
private void setup_ui() {
|
||||
set_size_request(100, 100);
|
||||
add_css_class("attachment-preview");
|
||||
|
||||
overlay = new Overlay();
|
||||
overlay.set_size_request(100, 100);
|
||||
append(overlay);
|
||||
|
||||
// Image preview
|
||||
picture = new Image();
|
||||
overlay.set_child(picture);
|
||||
|
||||
// Loading spinner
|
||||
spinner = new Spinner();
|
||||
spinner.set_halign(Align.CENTER);
|
||||
spinner.set_valign(Align.CENTER);
|
||||
spinner.set_size_request(24, 24);
|
||||
spinner.start();
|
||||
overlay.add_overlay(spinner);
|
||||
|
||||
// Remove button
|
||||
remove_button = new Button();
|
||||
remove_button.set_icon_name("window-close-symbolic");
|
||||
remove_button.add_css_class("circular");
|
||||
remove_button.add_css_class("destructive-action");
|
||||
remove_button.set_halign(Align.END);
|
||||
remove_button.set_valign(Align.START);
|
||||
remove_button.set_margin_top(4);
|
||||
remove_button.set_margin_end(4);
|
||||
remove_button.set_size_request(20, 20);
|
||||
remove_button.clicked.connect(() => {
|
||||
remove_requested();
|
||||
});
|
||||
overlay.add_overlay(remove_button);
|
||||
}
|
||||
|
||||
private void load_image() {
|
||||
try {
|
||||
picture.set_from_file(file.get_path());
|
||||
} catch (Error e) {
|
||||
warning("Failed to load image preview: %s", e.message);
|
||||
|
||||
// Show a placeholder icon if image loading fails
|
||||
var icon = new Image.from_icon_name("image-x-generic");
|
||||
icon.set_pixel_size(48);
|
||||
overlay.set_child(icon);
|
||||
}
|
||||
}
|
||||
|
||||
public void set_completed(string attachment_guid) {
|
||||
this.attachment_guid = attachment_guid;
|
||||
this.is_completed = true;
|
||||
|
||||
spinner.stop();
|
||||
spinner.set_visible(false);
|
||||
|
||||
// Optionally change visual state to indicate completion
|
||||
add_css_class("completed");
|
||||
}
|
||||
|
||||
public string? get_attachment_guid() {
|
||||
return attachment_guid;
|
||||
}
|
||||
|
||||
public bool get_is_completed() {
|
||||
return is_completed;
|
||||
}
|
||||
}
|
||||
204
gtk/src/transcript/layouts/bubble-layout.vala
Normal file
204
gtk/src/transcript/layouts/bubble-layout.vala
Normal file
@@ -0,0 +1,204 @@
|
||||
using Gtk;
|
||||
|
||||
public struct BubbleLayoutConstants {
|
||||
public float tail_width;
|
||||
public float tail_curve_offset;
|
||||
public float tail_side_offset;
|
||||
public float tail_bottom_padding;
|
||||
public float corner_radius;
|
||||
public float text_x_padding;
|
||||
public float text_y_padding;
|
||||
|
||||
public BubbleLayoutConstants(float scale_factor) {
|
||||
// TODO: Remove this, scale factor ignored for now.
|
||||
scale_factor = 2.0f;
|
||||
|
||||
tail_width = 15.0f / scale_factor;
|
||||
tail_curve_offset = 2.5f / scale_factor;
|
||||
tail_side_offset = 0.0f / scale_factor;
|
||||
tail_bottom_padding = 4.0f / scale_factor;
|
||||
corner_radius = 34.0f / scale_factor;
|
||||
text_x_padding = 31.0f / scale_factor;
|
||||
text_y_padding = 8.0f / scale_factor;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class BubbleLayout : Object, ChatItemLayout
|
||||
{
|
||||
public bool from_me { get; set; }
|
||||
public float vertical_padding { get; set; }
|
||||
public string id { get; set; }
|
||||
|
||||
protected float max_width;
|
||||
protected Widget parent;
|
||||
protected BubbleLayoutConstants constants;
|
||||
|
||||
protected BubbleLayout(Widget parent, float max_width) {
|
||||
this.max_width = max_width;
|
||||
this.parent = parent;
|
||||
this.constants = BubbleLayoutConstants(parent.get_scale_factor());
|
||||
}
|
||||
|
||||
private Gdk.RGBA background_color {
|
||||
get {
|
||||
return from_me ? parent.get_color() : Gdk.RGBA() {
|
||||
red = 1.0f,
|
||||
green = 1.0f,
|
||||
blue = 1.0f,
|
||||
alpha = 0.08f
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public abstract float get_height();
|
||||
|
||||
public abstract float get_width();
|
||||
|
||||
public void draw(Snapshot snapshot) {
|
||||
with_bubble_clip(snapshot, (snapshot) => {
|
||||
draw_background(snapshot);
|
||||
draw_content(snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
public abstract void draw_content(Snapshot snapshot);
|
||||
|
||||
public abstract void copy(Gdk.Clipboard clipboard);
|
||||
|
||||
private void draw_background(Snapshot snapshot)
|
||||
{
|
||||
var width = get_width();
|
||||
var height = get_height();
|
||||
var color = background_color;
|
||||
var bounds = Graphene.Rect().init(0, 0, width, height);
|
||||
|
||||
const float gradient_darkening = 0.67f;
|
||||
var start_point = Graphene.Point() { x = 0, y = 0 };
|
||||
var end_point = Graphene.Point() { x = 0, y = height };
|
||||
var stops = new Gsk.ColorStop[] {
|
||||
Gsk.ColorStop() { offset = 0.0f, color = color },
|
||||
Gsk.ColorStop() { offset = 1.0f, color = Gdk.RGBA () {
|
||||
red = color.red * gradient_darkening,
|
||||
green = color.green * gradient_darkening,
|
||||
blue = color.blue * gradient_darkening,
|
||||
alpha = color.alpha
|
||||
} },
|
||||
};
|
||||
|
||||
snapshot.append_linear_gradient(bounds, start_point, end_point, stops);
|
||||
}
|
||||
|
||||
protected void with_bubble_clip(Snapshot snapshot, Func<Snapshot> func) {
|
||||
var width = get_width();
|
||||
var height = get_height();
|
||||
|
||||
if (width > 10 && height > 10) {
|
||||
var path = create_bubble_path(width, height, from_me);
|
||||
snapshot.push_fill(path, Gsk.FillRule.WINDING);
|
||||
func(snapshot);
|
||||
snapshot.pop();
|
||||
} else {
|
||||
func(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private Gsk.Path create_bubble_path(float width, float height, bool tail_on_right = false)
|
||||
{
|
||||
var builder = new Gsk.PathBuilder();
|
||||
|
||||
float bubble_width = width - constants.tail_width;
|
||||
float bubble_height = height - constants.tail_bottom_padding;
|
||||
|
||||
// Base position adjustments based on tail position
|
||||
float x = tail_on_right ? 0.0f : constants.tail_width;
|
||||
float y = 0.0f;
|
||||
|
||||
// Calculate tail direction multiplier (-1 for left, 1 for right)
|
||||
float dir = tail_on_right ? 1.0f : -1.0f;
|
||||
|
||||
// Calculate tail side positions based on direction
|
||||
float tail_side_x = tail_on_right ? (x + bubble_width) : x;
|
||||
|
||||
// Start at top corner opposite to the tail
|
||||
builder.move_to(tail_on_right ? (x + constants.corner_radius) : (x + bubble_width - constants.corner_radius), y);
|
||||
|
||||
// Top edge
|
||||
builder.line_to(tail_on_right ? (x + bubble_width - constants.corner_radius) : (x + constants.corner_radius), y);
|
||||
|
||||
// Top corner on tail side
|
||||
if (tail_on_right) {
|
||||
builder.html_arc_to(x + bubble_width, y,
|
||||
x + bubble_width, y + constants.corner_radius,
|
||||
constants.corner_radius);
|
||||
} else {
|
||||
builder.html_arc_to(x, y,
|
||||
x, y + constants.corner_radius,
|
||||
constants.corner_radius);
|
||||
}
|
||||
|
||||
// Side edge on tail side
|
||||
builder.line_to(tail_side_x, y + bubble_height - constants.corner_radius);
|
||||
|
||||
// Corner with tail
|
||||
float tail_tip_x = tail_side_x + (dir * (constants.tail_width - constants.tail_curve_offset));
|
||||
float tail_tip_y = y + bubble_height;
|
||||
|
||||
// Control points for the bezier curve
|
||||
float ctrl_point1_x = tail_side_x + (dir * constants.tail_side_offset);
|
||||
float ctrl_point1_y = y + bubble_height - constants.corner_radius/3;
|
||||
|
||||
float ctrl_point2_x = tail_side_x + (dir * (constants.tail_width / 2.0f));
|
||||
float ctrl_point2_y = y + bubble_height - 2;
|
||||
|
||||
// Point where the tail meets the bottom edge
|
||||
float tail_base_x = tail_side_x - (dir * constants.corner_radius/2);
|
||||
float tail_base_y = y + bubble_height;
|
||||
|
||||
// Draw the corner with tail using bezier curves
|
||||
builder.cubic_to(ctrl_point1_x, ctrl_point1_y,
|
||||
ctrl_point2_x, ctrl_point2_y,
|
||||
tail_tip_x, tail_tip_y);
|
||||
|
||||
builder.cubic_to(tail_tip_x - (dir * constants.tail_curve_offset), tail_tip_y + constants.tail_curve_offset,
|
||||
tail_base_x + (dir * constants.tail_width), tail_base_y,
|
||||
tail_base_x, tail_base_y);
|
||||
|
||||
// Bottom edge
|
||||
builder.line_to(tail_on_right ? (x + constants.corner_radius) : (x + bubble_width - constants.corner_radius), y + bubble_height);
|
||||
|
||||
// Bottom corner opposite to tail
|
||||
if (tail_on_right) {
|
||||
builder.html_arc_to(x, y + bubble_height,
|
||||
x, y + bubble_height - constants.corner_radius,
|
||||
constants.corner_radius);
|
||||
} else {
|
||||
builder.html_arc_to(x + bubble_width, y + bubble_height,
|
||||
x + bubble_width, y + bubble_height - constants.corner_radius,
|
||||
constants.corner_radius);
|
||||
}
|
||||
|
||||
// Side edge opposite to tail
|
||||
if (tail_on_right) {
|
||||
builder.line_to(x, y + constants.corner_radius);
|
||||
|
||||
// Top corner to close path
|
||||
builder.html_arc_to(x, y,
|
||||
x + constants.corner_radius, y,
|
||||
constants.corner_radius);
|
||||
} else {
|
||||
builder.line_to(x + bubble_width, y + constants.corner_radius);
|
||||
|
||||
// Top corner to close path
|
||||
builder.html_arc_to(x + bubble_width, y,
|
||||
x + bubble_width - constants.corner_radius, y,
|
||||
constants.corner_radius);
|
||||
}
|
||||
|
||||
// Close the path
|
||||
builder.close();
|
||||
|
||||
return builder.to_path();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
gtk/src/transcript/layouts/chat-item-layout.vala
Normal file
14
gtk/src/transcript/layouts/chat-item-layout.vala
Normal file
@@ -0,0 +1,14 @@
|
||||
using Gtk;
|
||||
|
||||
public interface ChatItemLayout : Object
|
||||
{
|
||||
public abstract bool from_me { get; set; }
|
||||
public abstract float vertical_padding { get; set; }
|
||||
public abstract string id { get; set; }
|
||||
|
||||
public abstract float get_height();
|
||||
public abstract float get_width();
|
||||
|
||||
public abstract void draw(Snapshot snapshot);
|
||||
public abstract void copy(Gdk.Clipboard clipboard);
|
||||
}
|
||||
54
gtk/src/transcript/layouts/date-item-layout.vala
Normal file
54
gtk/src/transcript/layouts/date-item-layout.vala
Normal file
@@ -0,0 +1,54 @@
|
||||
using Gtk;
|
||||
|
||||
class DateItemLayout : Object, ChatItemLayout {
|
||||
public bool from_me { get; set; }
|
||||
public float vertical_padding { get; set; }
|
||||
public string id { get; set; }
|
||||
|
||||
private Pango.Layout layout;
|
||||
private float max_width;
|
||||
|
||||
public DateItemLayout(string date_str, Widget parent, float max_width) {
|
||||
this.max_width = max_width;
|
||||
|
||||
layout = parent.create_pango_layout(date_str);
|
||||
layout.set_font_description(Pango.FontDescription.from_string("Sans 9"));
|
||||
layout.set_alignment(Pango.Alignment.CENTER);
|
||||
}
|
||||
|
||||
public float get_height() {
|
||||
Pango.Rectangle ink_rect, logical_rect;
|
||||
layout.get_pixel_extents(out ink_rect, out logical_rect);
|
||||
|
||||
return logical_rect.height + 50.0f;
|
||||
}
|
||||
|
||||
public float get_width() {
|
||||
return max_width;
|
||||
}
|
||||
|
||||
public void draw(Snapshot snapshot) {
|
||||
snapshot.save();
|
||||
|
||||
Pango.Rectangle ink_rect, logical_rect;
|
||||
layout.get_pixel_extents(out ink_rect, out logical_rect);
|
||||
|
||||
snapshot.translate(Graphene.Point() {
|
||||
x = (max_width - logical_rect.width) / 2,
|
||||
y = (get_height() - logical_rect.height) / 2
|
||||
});
|
||||
|
||||
snapshot.append_layout(layout, Gdk.RGBA() {
|
||||
red = 1.0f,
|
||||
green = 1.0f,
|
||||
blue = 1.0f,
|
||||
alpha = 0.5f
|
||||
});
|
||||
|
||||
snapshot.restore();
|
||||
}
|
||||
|
||||
public void copy(Gdk.Clipboard clipboard) {
|
||||
clipboard.set_text(layout.get_text());
|
||||
}
|
||||
}
|
||||
140
gtk/src/transcript/layouts/image-bubble-layout.vala
Normal file
140
gtk/src/transcript/layouts/image-bubble-layout.vala
Normal file
@@ -0,0 +1,140 @@
|
||||
using Gee;
|
||||
using Gtk;
|
||||
|
||||
private class SizeCache
|
||||
{
|
||||
private static SizeCache instance = null;
|
||||
private HashMap<string, Graphene.Size?> size_cache = new HashMap<string, Graphene.Size?>();
|
||||
|
||||
public static SizeCache get_instance() {
|
||||
if (instance == null) {
|
||||
instance = new SizeCache();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public Graphene.Size? get_size(string image_path) {
|
||||
return size_cache.get(image_path);
|
||||
}
|
||||
|
||||
public void set_size(string image_path, Graphene.Size size) {
|
||||
size_cache.set(image_path, size);
|
||||
}
|
||||
}
|
||||
|
||||
private class ImageBubbleLayout : BubbleLayout
|
||||
{
|
||||
public string image_path;
|
||||
public bool is_downloaded;
|
||||
public string? attachment_guid;
|
||||
|
||||
private Graphene.Size image_size;
|
||||
private Gdk.Texture? cached_texture = null;
|
||||
|
||||
public ImageBubbleLayout(string image_path, bool from_me, Widget parent, float max_width, Graphene.Size? image_size = null) {
|
||||
base(parent, max_width);
|
||||
|
||||
this.from_me = from_me;
|
||||
this.image_path = image_path;
|
||||
this.is_downloaded = false;
|
||||
|
||||
// Calculate image dimensions for layout
|
||||
calculate_image_dimensions(image_size);
|
||||
}
|
||||
|
||||
private void calculate_image_dimensions(Graphene.Size? image_size) {
|
||||
if (image_size != null) {
|
||||
this.image_size = image_size;
|
||||
return;
|
||||
}
|
||||
|
||||
var cached_size = SizeCache.get_instance().get_size(image_path);
|
||||
if (cached_size != null) {
|
||||
this.image_size = cached_size;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to load the image to get its dimensions
|
||||
try {
|
||||
warning("No image size provided, loading image to get dimensions");
|
||||
|
||||
var texture = Gdk.Texture.from_filename(image_path);
|
||||
var original_width = (float)texture.get_width();
|
||||
var original_height = (float)texture.get_height();
|
||||
|
||||
this.image_size = Graphene.Size() { width = original_width, height = original_height };
|
||||
SizeCache.get_instance().set_size(image_path, this.image_size);
|
||||
} catch (Error e) {
|
||||
// Fallback dimensions if image can't be loaded
|
||||
warning("Failed to load image %s: %s", image_path, e.message);
|
||||
this.image_size = Graphene.Size() { width = 200.0f, height = 150.0f };
|
||||
}
|
||||
}
|
||||
|
||||
private void load_image_if_needed() {
|
||||
if (cached_texture != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_downloaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
cached_texture = Gdk.Texture.from_filename(image_path);
|
||||
} catch (Error e) {
|
||||
warning("Failed to load image %s: %s", image_path, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private float intrinsic_height {
|
||||
get {
|
||||
var scale_factor = float.min(max_width / image_size.width, 1.0f);
|
||||
return image_size.height * scale_factor;
|
||||
}
|
||||
}
|
||||
|
||||
private float intrinsic_width {
|
||||
get {
|
||||
var scale_factor = float.min(max_width / image_size.width, 1.0f);
|
||||
return image_size.width * scale_factor;
|
||||
}
|
||||
}
|
||||
|
||||
public override float get_height() {
|
||||
return float.max(intrinsic_height, 100.0f);
|
||||
}
|
||||
|
||||
public override float get_width() {
|
||||
return float.max(intrinsic_width, 200.0f);
|
||||
}
|
||||
|
||||
public override void draw_content(Snapshot snapshot) {
|
||||
load_image_if_needed();
|
||||
|
||||
snapshot.save();
|
||||
|
||||
var image_rect = Graphene.Rect () {
|
||||
origin = Graphene.Point() { x = 0, y = 0 },
|
||||
size = Graphene.Size() { width = intrinsic_width, height = intrinsic_height }
|
||||
};
|
||||
|
||||
// Center image in the bubble (if it's smaller than the bubble)
|
||||
snapshot.translate(Graphene.Point() {
|
||||
x = (get_width() - intrinsic_width) / 2,
|
||||
y = (get_height() - intrinsic_height) / 2
|
||||
});
|
||||
|
||||
if (cached_texture != null) {
|
||||
snapshot.append_texture(cached_texture, image_rect);
|
||||
} else {
|
||||
snapshot.append_color(Gdk.RGBA() { red = 0.6f, green = 0.6f, blue = 0.6f, alpha = 0.5f }, image_rect);
|
||||
}
|
||||
|
||||
snapshot.restore();
|
||||
}
|
||||
|
||||
public override void copy(Gdk.Clipboard clipboard) {
|
||||
clipboard.set_texture(cached_texture);
|
||||
}
|
||||
}
|
||||
61
gtk/src/transcript/layouts/sender-annotation-layout.vala
Normal file
61
gtk/src/transcript/layouts/sender-annotation-layout.vala
Normal file
@@ -0,0 +1,61 @@
|
||||
using Gtk;
|
||||
|
||||
private class SenderAnnotationLayout : Object, ChatItemLayout
|
||||
{
|
||||
public string sender;
|
||||
public float max_width;
|
||||
public bool from_me { get; set; default = false; }
|
||||
public float vertical_padding { get; set; default = 0.0f; }
|
||||
public string id { get; set; }
|
||||
|
||||
private Pango.Layout layout;
|
||||
private BubbleLayoutConstants constants;
|
||||
private const float padding = 10.0f;
|
||||
|
||||
public SenderAnnotationLayout(string sender, float max_width, Widget parent) {
|
||||
this.sender = sender;
|
||||
this.max_width = max_width;
|
||||
this.constants = BubbleLayoutConstants(parent.get_scale_factor());
|
||||
|
||||
layout = parent.create_pango_layout(sender);
|
||||
var font_desc = Pango.FontDescription.from_string("Sans 8");
|
||||
layout.set_font_description(font_desc);
|
||||
}
|
||||
|
||||
public float get_height() {
|
||||
Pango.Rectangle ink_rect, logical_rect;
|
||||
layout.get_pixel_extents(out ink_rect, out logical_rect);
|
||||
|
||||
return logical_rect.height + padding;
|
||||
}
|
||||
|
||||
public float get_width() {
|
||||
return max_width;
|
||||
}
|
||||
|
||||
public void draw(Snapshot snapshot) {
|
||||
snapshot.save();
|
||||
|
||||
Pango.Rectangle ink_rect, logical_rect;
|
||||
layout.get_pixel_extents(out ink_rect, out logical_rect);
|
||||
|
||||
|
||||
snapshot.translate(Graphene.Point() {
|
||||
x = constants.text_x_padding + constants.tail_width,
|
||||
y = get_height() - logical_rect.height,
|
||||
});
|
||||
|
||||
snapshot.append_layout(layout, Gdk.RGBA() {
|
||||
red = 1.0f,
|
||||
green = 1.0f,
|
||||
blue = 1.0f,
|
||||
alpha = 0.8f
|
||||
});
|
||||
|
||||
snapshot.restore();
|
||||
}
|
||||
|
||||
public void copy(Gdk.Clipboard clipboard) {
|
||||
clipboard.set_text(sender);
|
||||
}
|
||||
}
|
||||
113
gtk/src/transcript/layouts/text-bubble-layout.vala
Normal file
113
gtk/src/transcript/layouts/text-bubble-layout.vala
Normal file
@@ -0,0 +1,113 @@
|
||||
using Gtk;
|
||||
|
||||
public class TextBubbleLayout : BubbleLayout
|
||||
{
|
||||
public Message message;
|
||||
private Pango.Layout layout;
|
||||
|
||||
public static float line_height { get { return 1.18f; } }
|
||||
|
||||
public static Pango.FontDescription body_font {
|
||||
owned get {
|
||||
var settings = Gtk.Settings.get_default();
|
||||
var font_name = settings.gtk_font_name;
|
||||
|
||||
// Create font description from system font
|
||||
var font_desc = Pango.FontDescription.from_string(font_name);
|
||||
return font_desc;
|
||||
}
|
||||
}
|
||||
|
||||
public TextBubbleLayout(Message message, Widget parent, float max_width) {
|
||||
base(parent, max_width);
|
||||
|
||||
this.from_me = message.from_me;
|
||||
this.message = message;
|
||||
|
||||
layout = parent.create_pango_layout(null);
|
||||
layout.set_markup(message.markup, -1);
|
||||
|
||||
var font_desc = TextBubbleLayout.body_font;
|
||||
layout.set_font_description(font_desc);
|
||||
layout.set_wrap(Pango.WrapMode.WORD_CHAR);
|
||||
layout.set_line_spacing(line_height);
|
||||
|
||||
// Set max width
|
||||
layout.set_width((int)text_available_width * Pango.SCALE);
|
||||
}
|
||||
|
||||
private float text_available_width {
|
||||
get {
|
||||
return max_width - text_x_offset - constants.text_x_padding;
|
||||
}
|
||||
}
|
||||
|
||||
private float text_x_offset {
|
||||
get {
|
||||
return from_me ? constants.text_x_padding : constants.tail_width + constants.text_x_padding;
|
||||
}
|
||||
}
|
||||
|
||||
private float text_x_padding {
|
||||
get {
|
||||
// Opposite of text_x_offset
|
||||
return from_me ? constants.tail_width + constants.text_x_padding : constants.text_x_padding;
|
||||
}
|
||||
}
|
||||
|
||||
private Gdk.RGBA background_color {
|
||||
get {
|
||||
return from_me ? parent.get_color() : Gdk.RGBA() {
|
||||
red = 1.0f,
|
||||
green = 1.0f,
|
||||
blue = 1.0f,
|
||||
alpha = 0.08f
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public override float get_height() {
|
||||
Pango.Rectangle ink_rect, logical_rect;
|
||||
layout.get_pixel_extents(out ink_rect, out logical_rect);
|
||||
|
||||
return logical_rect.height + constants.corner_radius + constants.tail_bottom_padding + constants.text_y_padding;
|
||||
}
|
||||
|
||||
public override float get_width() {
|
||||
Pango.Rectangle ink_rect, logical_rect;
|
||||
layout.get_pixel_extents(out ink_rect, out logical_rect);
|
||||
|
||||
return logical_rect.width + text_x_offset + text_x_padding;
|
||||
}
|
||||
|
||||
public Graphene.Point get_text_origin() {
|
||||
Pango.Rectangle ink_rect, logical_rect;
|
||||
layout.get_pixel_extents(out ink_rect, out logical_rect);
|
||||
|
||||
return Graphene.Point() {
|
||||
x = text_x_offset,
|
||||
y = ((get_height() - constants.tail_bottom_padding) - logical_rect.height) / 2
|
||||
};
|
||||
}
|
||||
|
||||
public override void draw_content(Snapshot snapshot) {
|
||||
snapshot.save();
|
||||
|
||||
snapshot.translate(get_text_origin());
|
||||
|
||||
snapshot.append_layout(layout, Gdk.RGBA() {
|
||||
red = 1.0f,
|
||||
green = 1.0f,
|
||||
blue = 1.0f,
|
||||
alpha = 1.0f
|
||||
});
|
||||
|
||||
snapshot.restore();
|
||||
}
|
||||
|
||||
public override void copy(Gdk.Clipboard clipboard) {
|
||||
clipboard.set_text(message.text);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
137
gtk/src/transcript/message-list-model.vala
Normal file
137
gtk/src/transcript/message-list-model.vala
Normal file
@@ -0,0 +1,137 @@
|
||||
using GLib;
|
||||
using Gee;
|
||||
|
||||
public class MessageListModel : Object, ListModel
|
||||
{
|
||||
public signal void messages_changed();
|
||||
|
||||
public ArrayList<Message> messages {
|
||||
get { return _messages; }
|
||||
}
|
||||
|
||||
public bool is_group_chat {
|
||||
get {
|
||||
return participants.size > 2;
|
||||
}
|
||||
}
|
||||
|
||||
public Conversation conversation { get; private set; }
|
||||
|
||||
private ArrayList<Message> _messages;
|
||||
private HashSet<string> participants = new HashSet<string>();
|
||||
private ulong update_handler_id = 0;
|
||||
private ulong reconnected_handler_id = 0;
|
||||
|
||||
public MessageListModel(Conversation conversation) {
|
||||
_messages = new ArrayList<Message>();
|
||||
this.conversation = conversation;
|
||||
}
|
||||
|
||||
~MessageListModel() {
|
||||
// NOTE: this won't actually get destructed automatically because of a retain cycle with the signal handler.
|
||||
// unwatch_updates() should be called explicitly when the model is no longer needed.
|
||||
unwatch_updates();
|
||||
}
|
||||
|
||||
public void watch_updates() {
|
||||
if (this.update_handler_id == 0) {
|
||||
weak MessageListModel self = this;
|
||||
this.update_handler_id = Repository.get_instance().messages_updated.connect((conversation_guid) => {
|
||||
self.got_messages_updated(conversation_guid);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.reconnected_handler_id == 0) {
|
||||
weak MessageListModel self = this;
|
||||
this.reconnected_handler_id = Repository.get_instance().reconnected.connect(() => {
|
||||
// On reconnect, reload the messages that we're looking at now.
|
||||
self.load_messages();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void unwatch_updates() {
|
||||
if (this.update_handler_id != 0) {
|
||||
Repository.get_instance().disconnect(this.update_handler_id);
|
||||
this.update_handler_id = 0;
|
||||
}
|
||||
|
||||
if (this.reconnected_handler_id != 0) {
|
||||
Repository.get_instance().disconnect(this.reconnected_handler_id);
|
||||
this.reconnected_handler_id = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void load_messages() {
|
||||
var previous_messages = new HashSet<Message>();
|
||||
previous_messages.add_all(_messages);
|
||||
|
||||
try {
|
||||
bool first_load = _messages.size == 0;
|
||||
|
||||
Message[] messages = Repository.get_instance().get_messages(conversation.guid);
|
||||
|
||||
// Clear existing set
|
||||
uint old_count = _messages.size;
|
||||
_messages.clear();
|
||||
participants.clear();
|
||||
|
||||
// Notify of removal
|
||||
if (old_count > 0) {
|
||||
items_changed(0, old_count, 0);
|
||||
}
|
||||
|
||||
// Process each conversation
|
||||
uint position = 0;
|
||||
|
||||
for (int i = 0; i < messages.length; i++) {
|
||||
var message = messages[i];
|
||||
participants.add(message.sender);
|
||||
|
||||
if (!first_load && !previous_messages.contains(message)) {
|
||||
// This is a new message according to the UI, schedule an animation for it.
|
||||
message.should_animate = true;
|
||||
}
|
||||
|
||||
_messages.add(message);
|
||||
position++;
|
||||
}
|
||||
|
||||
// Notify of additions
|
||||
if (position > 0) {
|
||||
items_changed(0, 0, position);
|
||||
}
|
||||
} catch (Error e) {
|
||||
warning("Failed to load messages: %s", e.message);
|
||||
}
|
||||
|
||||
messages_changed();
|
||||
}
|
||||
|
||||
public void mark_as_read() {
|
||||
try {
|
||||
Repository.get_instance().mark_conversation_as_read(conversation.guid);
|
||||
} catch (Error e) {
|
||||
warning("Failed to mark conversation as read: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private void got_messages_updated(string conversation_guid) {
|
||||
if (conversation_guid == this.conversation.guid) {
|
||||
load_messages();
|
||||
}
|
||||
}
|
||||
|
||||
// ListModel implementation
|
||||
public Type get_item_type() {
|
||||
return typeof(Message);
|
||||
}
|
||||
|
||||
public uint get_n_items() {
|
||||
return _messages.size;
|
||||
}
|
||||
|
||||
public Object? get_item(uint position) {
|
||||
return _messages.get((int)position);
|
||||
}
|
||||
}
|
||||
336
gtk/src/transcript/transcript-container-view.vala
Normal file
336
gtk/src/transcript/transcript-container-view.vala
Normal file
@@ -0,0 +1,336 @@
|
||||
using Gtk;
|
||||
using Adw;
|
||||
using Gee;
|
||||
using Gdk;
|
||||
using GLib;
|
||||
|
||||
class TranscriptContainerView : Adw.Bin
|
||||
{
|
||||
public TranscriptView transcript_view;
|
||||
|
||||
private Box container;
|
||||
private Button send_button;
|
||||
private FlowBox attachment_flow_box;
|
||||
|
||||
private TextView message_view;
|
||||
private TextBuffer message_buffer;
|
||||
private HashSet<string> pending_uploads;
|
||||
private HashMap<string, AttachmentPreview> attachment_previews;
|
||||
private ArrayList<UploadedAttachment> completed_attachments;
|
||||
|
||||
public string message_body {
|
||||
owned get {
|
||||
TextIter start_iter, end_iter;
|
||||
message_buffer.get_bounds(out start_iter, out end_iter);
|
||||
return message_buffer.get_text(start_iter, end_iter, false);
|
||||
}
|
||||
}
|
||||
|
||||
public ArrayList<string> attachment_guids {
|
||||
owned get {
|
||||
var attachment_guids = new ArrayList<string>();
|
||||
completed_attachments.foreach((attachment) => {
|
||||
attachment_guids.add(attachment.attachment_guid);
|
||||
return true;
|
||||
});
|
||||
|
||||
return attachment_guids;
|
||||
}
|
||||
}
|
||||
|
||||
private bool can_send {
|
||||
get {
|
||||
return (message_body.length > 0 || completed_attachments.size > 0) && pending_uploads.size == 0;
|
||||
}
|
||||
}
|
||||
|
||||
public TranscriptContainerView () {
|
||||
container = new Gtk.Box (Gtk.Orientation.VERTICAL, 0);
|
||||
set_child (container);
|
||||
|
||||
pending_uploads = new HashSet<string>();
|
||||
attachment_previews = new HashMap<string, AttachmentPreview>();
|
||||
completed_attachments = new ArrayList<UploadedAttachment>();
|
||||
|
||||
// Create message list view
|
||||
transcript_view = new TranscriptView();
|
||||
transcript_view.set_vexpand(true);
|
||||
container.append(transcript_view);
|
||||
|
||||
// Create attachment preview row (initially hidden)
|
||||
setup_attachment_row();
|
||||
|
||||
// Create bottom box for input
|
||||
var input_box = new Box(Orientation.HORIZONTAL, 6);
|
||||
input_box.add_css_class("message-input-box");
|
||||
input_box.set_valign(Align.END);
|
||||
input_box.set_spacing(6);
|
||||
container.append(input_box);
|
||||
|
||||
// Setup drag and drop
|
||||
setup_drag_and_drop();
|
||||
|
||||
// Connect to repository signals
|
||||
Repository.get_instance().attachment_uploaded.connect(on_attachment_uploaded);
|
||||
|
||||
// Create attach button (paperclip)
|
||||
var attach_button = new Button.from_icon_name("mail-attachment");
|
||||
attach_button.set_tooltip_text("Attach file…");
|
||||
attach_button.add_css_class("flat");
|
||||
attach_button.clicked.connect(on_attach_button_clicked);
|
||||
input_box.append(attach_button);
|
||||
|
||||
// Create message text view (added after attachment button so button stays to the left)
|
||||
message_buffer = new TextBuffer(null);
|
||||
message_view = new TextView.with_buffer(message_buffer);
|
||||
message_view.add_css_class("message-input-entry");
|
||||
message_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR);
|
||||
message_view.set_hexpand(true);
|
||||
message_view.set_vexpand(false);
|
||||
message_view.set_size_request(-1, 12); // intrinsic
|
||||
message_buffer.changed.connect(on_text_changed);
|
||||
|
||||
// Key controller for sending on Enter (Shift+Enter for newline)
|
||||
var send_key_ctrl = new EventControllerKey();
|
||||
send_key_ctrl.key_pressed.connect((keyval, keycode, state) => {
|
||||
if (keyval == Gdk.Key.Return && (state & Gdk.ModifierType.SHIFT_MASK) == 0) {
|
||||
on_request_send();
|
||||
return true; // consume
|
||||
}
|
||||
return false;
|
||||
});
|
||||
message_view.add_controller(send_key_ctrl);
|
||||
|
||||
// Handle paste events to detect images
|
||||
message_view.paste_clipboard.connect(on_message_paste_clipboard);
|
||||
|
||||
input_box.append(message_view);
|
||||
|
||||
// Create send button
|
||||
send_button = new Button();
|
||||
send_button.set_label("Send");
|
||||
send_button.set_sensitive(false);
|
||||
send_button.add_css_class("suggested-action");
|
||||
send_button.clicked.connect(on_request_send);
|
||||
input_box.append(send_button);
|
||||
}
|
||||
|
||||
private void setup_attachment_row() {
|
||||
attachment_flow_box = new FlowBox();
|
||||
attachment_flow_box.set_max_children_per_line(6);
|
||||
attachment_flow_box.set_row_spacing(6);
|
||||
attachment_flow_box.set_column_spacing(6);
|
||||
attachment_flow_box.halign = Align.START;
|
||||
attachment_flow_box.add_css_class("attachment-preview-row");
|
||||
container.append(attachment_flow_box);
|
||||
}
|
||||
|
||||
private void setup_drag_and_drop() {
|
||||
var drop_target = new DropTarget(typeof(File), Gdk.DragAction.COPY);
|
||||
drop_target.drop.connect(on_file_dropped);
|
||||
this.add_controller(drop_target);
|
||||
}
|
||||
|
||||
private bool on_file_dropped(Value val, double x, double y) {
|
||||
if (!val.holds(typeof(File))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var file = (File)val.get_object();
|
||||
if (file == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's an image file
|
||||
try {
|
||||
var file_info = file.query_info(FileAttribute.STANDARD_CONTENT_TYPE, FileQueryInfoFlags.NONE);
|
||||
string content_type = file_info.get_content_type();
|
||||
|
||||
if (!content_type.has_prefix("image/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
upload_file(file);
|
||||
return true;
|
||||
} catch (Error e) {
|
||||
warning("Failed to get file info: %s", e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void upload_file(File file) {
|
||||
try {
|
||||
string upload_guid = Repository.get_instance().upload_attachment(file.get_path());
|
||||
pending_uploads.add(upload_guid);
|
||||
|
||||
var preview = new AttachmentPreview(file, upload_guid);
|
||||
preview.remove_requested.connect(() => {
|
||||
remove_attachment(upload_guid);
|
||||
});
|
||||
|
||||
attachment_previews[upload_guid] = preview;
|
||||
attachment_flow_box.append(preview);
|
||||
|
||||
update_attachment_row_visibility();
|
||||
} catch (Error e) {
|
||||
warning("Failed to upload attachment: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private void on_attachment_uploaded(string upload_guid, string attachment_guid) {
|
||||
if (attachment_previews.has_key(upload_guid)) {
|
||||
var preview = attachment_previews[upload_guid];
|
||||
preview.set_completed(attachment_guid);
|
||||
completed_attachments.add(new UploadedAttachment(upload_guid, attachment_guid));
|
||||
pending_uploads.remove(upload_guid);
|
||||
update_send_button_sensitivity();
|
||||
}
|
||||
}
|
||||
|
||||
private void remove_attachment(string upload_guid) {
|
||||
if (attachment_previews.has_key(upload_guid)) {
|
||||
var preview = attachment_previews[upload_guid];
|
||||
attachment_flow_box.remove(preview);
|
||||
attachment_previews.unset(upload_guid);
|
||||
|
||||
completed_attachments.foreach((attachment) => {
|
||||
if (attachment.upload_guid == upload_guid) {
|
||||
completed_attachments.remove(attachment);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
pending_uploads.remove(upload_guid);
|
||||
|
||||
update_attachment_row_visibility();
|
||||
update_send_button_sensitivity();
|
||||
}
|
||||
}
|
||||
|
||||
private void update_attachment_row_visibility() {
|
||||
bool has_attachments = attachment_previews.size > 0;
|
||||
attachment_flow_box.set_visible(has_attachments);
|
||||
}
|
||||
|
||||
private void on_text_changed() {
|
||||
update_send_button_sensitivity();
|
||||
}
|
||||
|
||||
private void update_send_button_sensitivity() {
|
||||
send_button.set_sensitive(can_send);
|
||||
}
|
||||
|
||||
private void on_request_send() {
|
||||
if (can_send) {
|
||||
on_send();
|
||||
|
||||
// Clear the message text
|
||||
message_buffer.set_text("");
|
||||
|
||||
// Clear the attachment previews
|
||||
attachment_flow_box.remove_all();
|
||||
attachment_previews.clear();
|
||||
completed_attachments.clear();
|
||||
pending_uploads.clear();
|
||||
|
||||
update_send_button_sensitivity();
|
||||
}
|
||||
}
|
||||
|
||||
private void on_send() {
|
||||
var body = message_body;
|
||||
|
||||
// Strip empty space at the beginning and end of the body
|
||||
body = body.strip();
|
||||
|
||||
if (transcript_view.model == null) {
|
||||
GLib.warning("No conversation selected");
|
||||
return;
|
||||
}
|
||||
|
||||
var selected_conversation = transcript_view.model.conversation;
|
||||
if (selected_conversation == null) {
|
||||
GLib.warning("No conversation selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Repository.get_instance().send_message(selected_conversation.guid, body, attachment_guids.to_array());
|
||||
} catch (Error e) {
|
||||
GLib.warning("Failed to send message: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private void on_attach_button_clicked() {
|
||||
var dialog = new Gtk.FileDialog();
|
||||
dialog.set_title("Select attachment");
|
||||
dialog.set_accept_label("Attach");
|
||||
dialog.set_modal(true);
|
||||
|
||||
// Images only for now
|
||||
var filter = new Gtk.FileFilter();
|
||||
filter.set_filter_name("Images");
|
||||
filter.add_mime_type("image/png");
|
||||
filter.add_mime_type("image/jpeg");
|
||||
filter.add_mime_type("image/gif");
|
||||
filter.add_mime_type("image/bmp");
|
||||
filter.add_mime_type("image/webp");
|
||||
filter.add_mime_type("image/svg+xml");
|
||||
filter.add_mime_type("image/tiff");
|
||||
|
||||
dialog.set_default_filter(filter);
|
||||
|
||||
var parent_window = get_root() as Gtk.Window;
|
||||
dialog.open.begin(parent_window, null, (obj, res) => {
|
||||
try {
|
||||
var file = dialog.open.end(res);
|
||||
if (file != null) {
|
||||
upload_file(file);
|
||||
}
|
||||
} catch (Error e) {
|
||||
warning("Failed to open file dialog: %s", e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void on_message_paste_clipboard() {
|
||||
var display = get_display();
|
||||
if (display == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var clipboard = display.get_clipboard();
|
||||
if (clipboard == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clipboard.read_texture_async.begin(null, (obj, res) => {
|
||||
try {
|
||||
var clip = obj as Gdk.Clipboard;
|
||||
var texture = clip.read_texture_async.end(res);
|
||||
if (texture != null) {
|
||||
string tmp_path = Path.build_filename(Environment.get_tmp_dir(), "clipboard-" + Uuid.string_random() + ".png");
|
||||
texture.save_to_png(tmp_path);
|
||||
var tmp_file = File.new_for_path(tmp_path);
|
||||
upload_file(tmp_file);
|
||||
}
|
||||
} catch (Error e) {
|
||||
// Ignore if clipboard does not contain image
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class UploadedAttachment
|
||||
{
|
||||
public string upload_guid;
|
||||
public string attachment_guid;
|
||||
|
||||
public UploadedAttachment(string upload_guid, string attachment_guid)
|
||||
{
|
||||
this.upload_guid = upload_guid;
|
||||
this.attachment_guid = attachment_guid;
|
||||
}
|
||||
}
|
||||
450
gtk/src/transcript/transcript-drawing-area.vala
Normal file
450
gtk/src/transcript/transcript-drawing-area.vala
Normal file
@@ -0,0 +1,450 @@
|
||||
using Gtk;
|
||||
using Gee;
|
||||
using Gdk;
|
||||
|
||||
private class TranscriptDrawingArea : Widget
|
||||
{
|
||||
public bool show_sender = true;
|
||||
public Adjustment? viewport {
|
||||
get {
|
||||
return _viewport;
|
||||
}
|
||||
|
||||
set {
|
||||
_viewport = value;
|
||||
queue_draw();
|
||||
}
|
||||
}
|
||||
|
||||
public signal void on_text_bubble_hover(VisibleLayout? text_bubble);
|
||||
public signal void on_text_bubble_click(VisibleLayout? text_bubble);
|
||||
public signal void on_image_bubble_activate(string attachment_guid);
|
||||
|
||||
private ArrayList<Message> _messages = new ArrayList<Message>();
|
||||
private ArrayList<ChatItemLayout> _chat_items = new ArrayList<ChatItemLayout>();
|
||||
|
||||
private Adjustment? _viewport = null;
|
||||
private const float bubble_margin = 18.0f;
|
||||
|
||||
private GestureClick _click_gesture = new GestureClick();
|
||||
private Gdk.Rectangle? _click_bounding_box = null;
|
||||
|
||||
private EventControllerMotion _motion_controller = new EventControllerMotion();
|
||||
private ArrayList<VisibleLayout?> _visible_text_layouts = new ArrayList<VisibleLayout?>();
|
||||
|
||||
private const bool debug_viewport = false;
|
||||
private uint? _tick_callback_id = null;
|
||||
private HashMap<string, ChatItemAnimation> _animations = new HashMap<string, ChatItemAnimation>();
|
||||
|
||||
public TranscriptDrawingArea() {
|
||||
add_css_class("transcript-drawing-area");
|
||||
|
||||
weak TranscriptDrawingArea self = this;
|
||||
|
||||
_click_gesture.button = 0;
|
||||
_click_gesture.pressed.connect((n_press, x, y) => {
|
||||
self.on_click(self._click_gesture.get_current_button(), n_press);
|
||||
});
|
||||
add_controller(_click_gesture);
|
||||
|
||||
_motion_controller.motion.connect((x, y) => {
|
||||
if (self == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only print motion events if window is active/focused
|
||||
var window = self.get_native() as Gtk.Window;
|
||||
if (window == null || !window.is_active) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.on_mouse_motion(x, y);
|
||||
});
|
||||
_motion_controller.set_propagation_phase(PropagationPhase.CAPTURE);
|
||||
add_controller(_motion_controller);
|
||||
|
||||
SimpleAction copy_action = new SimpleAction("copy", null);
|
||||
copy_action.activate.connect(() => {
|
||||
if (_click_bounding_box != null) {
|
||||
copy_message_at(_click_bounding_box);
|
||||
} else {
|
||||
GLib.warning("Failed to get bounding box for right click");
|
||||
}
|
||||
});
|
||||
|
||||
SimpleActionGroup action_group = new SimpleActionGroup();
|
||||
action_group.add_action(copy_action);
|
||||
|
||||
insert_action_group("transcript", action_group);
|
||||
}
|
||||
|
||||
public void set_messages(ArrayList<Message> messages) {
|
||||
_messages = messages;
|
||||
recompute_message_layouts();
|
||||
}
|
||||
|
||||
public override SizeRequestMode get_request_mode() {
|
||||
return SizeRequestMode.HEIGHT_FOR_WIDTH;
|
||||
}
|
||||
|
||||
public override void measure(Orientation orientation, int for_size, out int minimum, out int natural, out int minimum_baseline, out int natural_baseline)
|
||||
{
|
||||
if (orientation == Orientation.HORIZONTAL) {
|
||||
// Horizontal, so we take up the full width provided
|
||||
minimum = 0;
|
||||
natural = for_size;
|
||||
} else {
|
||||
// compute total message layout height
|
||||
float total_height = 0.0f;
|
||||
_chat_items.foreach((chat_item) => {
|
||||
total_height += chat_item.get_height() + chat_item.vertical_padding;
|
||||
return true;
|
||||
});
|
||||
|
||||
minimum = (int)total_height;
|
||||
natural = (int)total_height;
|
||||
}
|
||||
|
||||
minimum_baseline = -1;
|
||||
natural_baseline = -1;
|
||||
}
|
||||
|
||||
public override void size_allocate(int width, int height, int baseline) {
|
||||
base.size_allocate(width, height, baseline);
|
||||
recompute_message_layouts();
|
||||
}
|
||||
|
||||
public override void snapshot(Gtk.Snapshot snapshot) {
|
||||
const int viewport_y_padding = 50;
|
||||
var viewport_rect = Graphene.Rect() {
|
||||
origin = Graphene.Point() { x = 0, y = ((int)viewport.value - viewport_y_padding) },
|
||||
size = Graphene.Size() { width = get_width(), height = (int)viewport.page_size + (viewport_y_padding * 2) }
|
||||
};
|
||||
|
||||
if (debug_viewport) {
|
||||
// Draw viewport outline for debugging
|
||||
var color = Gdk.RGBA() { red = 1.0f, green = 0, blue = 0, alpha = 1.0f };
|
||||
snapshot.append_border(
|
||||
Gsk.RoundedRect().init_from_rect(viewport_rect, 0),
|
||||
{ 1, 1, 1, 1 },
|
||||
{ color, color, color, color }
|
||||
);
|
||||
}
|
||||
|
||||
// Draw each item in reverse order, since the messages are in reverse order
|
||||
float y_offset = 0;
|
||||
int container_width = get_width();
|
||||
_visible_text_layouts.clear();
|
||||
for (int i = _chat_items.size - 1; i >= 0; i--) {
|
||||
var chat_item = _chat_items[i];
|
||||
var item_width = chat_item.get_width();
|
||||
var item_height = chat_item.get_height();
|
||||
|
||||
var origin = Graphene.Point() {
|
||||
x = (chat_item.from_me ? (container_width - item_width - bubble_margin) : bubble_margin),
|
||||
y = y_offset
|
||||
};
|
||||
|
||||
var size = Graphene.Size() {
|
||||
width = item_width,
|
||||
height = item_height
|
||||
};
|
||||
|
||||
var rect = Graphene.Rect() {
|
||||
origin = origin,
|
||||
size = size
|
||||
};
|
||||
|
||||
// Skip drawing if this item is not in the viewport
|
||||
float height_offset = 0.0f;
|
||||
if (viewport_rect.intersection(rect, null)) {
|
||||
if (chat_item is BubbleLayout) {
|
||||
_visible_text_layouts.add(VisibleLayout(chat_item as BubbleLayout, rect));
|
||||
}
|
||||
|
||||
snapshot.save();
|
||||
|
||||
var pushed_opacity = false;
|
||||
if (_animations.has_key(chat_item.id)) {
|
||||
var animation = _animations[chat_item.id];
|
||||
|
||||
var item_height_offset = (float) (-item_height * (1.0 - animation.progress));
|
||||
snapshot.translate(Graphene.Point() { x = 0, y = item_height_offset });
|
||||
height_offset = item_height_offset;
|
||||
|
||||
snapshot.push_opacity(animation.progress);
|
||||
pushed_opacity = true;
|
||||
}
|
||||
|
||||
// Translate to the correct position
|
||||
snapshot.translate(origin);
|
||||
|
||||
// Flip the y-axis, since our parent is upside down (so newest messages are at the bottom)
|
||||
snapshot.scale(1.0f, -1.0f);
|
||||
|
||||
// Undo the y-axis flip, origin is top left
|
||||
snapshot.translate(Graphene.Point() { x = 0, y = -item_height });
|
||||
|
||||
chat_item.draw(snapshot);
|
||||
|
||||
if (pushed_opacity) {
|
||||
snapshot.pop();
|
||||
}
|
||||
|
||||
snapshot.restore();
|
||||
}
|
||||
|
||||
y_offset += item_height + chat_item.vertical_padding + height_offset;
|
||||
}
|
||||
|
||||
animation_tick();
|
||||
}
|
||||
|
||||
private VisibleLayout? get_visible_layout_at(double x, double y) {
|
||||
var point = Graphene.Point() { x = (float)x, y = (float)y };
|
||||
foreach (var layout in _visible_text_layouts) {
|
||||
if (layout.rect.contains_point(point)) {
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private VisibleLayout? get_text_bubble_at(double x, double y) {
|
||||
var layout = get_visible_layout_at(x, y);
|
||||
if (layout != null && layout.bubble is TextBubbleLayout) {
|
||||
return layout;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void on_mouse_motion(double x, double y) {
|
||||
VisibleLayout? hovered_text_bubble = get_text_bubble_at(x, y);
|
||||
on_text_bubble_hover(hovered_text_bubble);
|
||||
}
|
||||
|
||||
private void on_click(uint button, int n_press) {
|
||||
if (button == Gdk.BUTTON_SECONDARY) {
|
||||
on_right_click();
|
||||
} else if (button == Gdk.BUTTON_PRIMARY) {
|
||||
on_left_click();
|
||||
|
||||
if (n_press == 2) {
|
||||
on_double_click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void on_left_click() {
|
||||
Gdk.Rectangle? bounding_box = null;
|
||||
if (_click_gesture.get_bounding_box(out bounding_box)) {
|
||||
var text_bubble = get_text_bubble_at(bounding_box.x, bounding_box.y);
|
||||
on_text_bubble_click(text_bubble);
|
||||
}
|
||||
}
|
||||
|
||||
private void on_double_click() {
|
||||
Gdk.Rectangle? bounding_box = null;
|
||||
if (_click_gesture.get_bounding_box(out bounding_box)) {
|
||||
var double_clicked_bubble = get_visible_layout_at(bounding_box.x, bounding_box.y);
|
||||
if (double_clicked_bubble != null && double_clicked_bubble.bubble is ImageBubbleLayout) {
|
||||
var image_bubble = double_clicked_bubble.bubble as ImageBubbleLayout;
|
||||
on_image_bubble_activate(image_bubble.attachment_guid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void on_right_click() {
|
||||
var menu_model = new Menu();
|
||||
menu_model.append("Copy", "transcript.copy");
|
||||
|
||||
Gdk.Rectangle? bounding_box = null;
|
||||
if (_click_gesture.get_bounding_box(out bounding_box)) {
|
||||
_click_bounding_box = bounding_box;
|
||||
|
||||
var menu = new PopoverMenu.from_model(menu_model);
|
||||
menu.set_position(PositionType.TOP);
|
||||
menu.pointing_to = bounding_box;
|
||||
menu.set_parent(this);
|
||||
menu.popup();
|
||||
}
|
||||
}
|
||||
|
||||
private void copy_message_at(Gdk.Rectangle bounding_box) {
|
||||
double y_offset = 0.0;
|
||||
for (int i = _chat_items.size - 1; i >= 0; i--) {
|
||||
var chat_item = _chat_items[i];
|
||||
y_offset += chat_item.get_height() + chat_item.vertical_padding;
|
||||
|
||||
if (y_offset > bounding_box.y) {
|
||||
chat_item.copy(get_clipboard());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool animation_tick() {
|
||||
HashSet<string> animations_to_remove = new HashSet<string>();
|
||||
_animations.foreach(entry => {
|
||||
var animation = entry.value;
|
||||
animation.tick_animation();
|
||||
|
||||
if ((animation.progress - 1.0).abs() < 0.000001) {
|
||||
animations_to_remove.add(entry.key);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
foreach (var key in animations_to_remove) {
|
||||
_animations.unset(key);
|
||||
}
|
||||
|
||||
if (_animations.size > 0) {
|
||||
queue_draw();
|
||||
}
|
||||
|
||||
return _animations.size > 0;
|
||||
}
|
||||
|
||||
private bool on_frame_clock_tick(Gtk.Widget widget, Gdk.FrameClock frame_clock) {
|
||||
return animation_tick();
|
||||
}
|
||||
|
||||
private void start_animation(string id) {
|
||||
if (_animations.has_key(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var animation = new ChatItemAnimation(get_frame_clock());
|
||||
animation.start_animation(0.18f);
|
||||
_animations.set(id, animation);
|
||||
|
||||
_tick_callback_id = add_tick_callback(on_frame_clock_tick);
|
||||
}
|
||||
|
||||
private void recompute_message_layouts() {
|
||||
var container_width = get_width();
|
||||
float max_width = container_width * 0.90f;
|
||||
|
||||
DateTime? last_date = null;
|
||||
string? last_sender = null;
|
||||
ArrayList<ChatItemLayout> items = new ArrayList<ChatItemLayout>();
|
||||
_messages.foreach((message) => {
|
||||
// Date Annotation
|
||||
DateTime date = message.date;
|
||||
if (last_date != null && date.difference(last_date) > (TimeSpan.HOUR * 1)) {
|
||||
var date_item = new DateItemLayout(date.to_local().format("%b %d, %Y at %H:%M"), this, max_width);
|
||||
items.add(date_item);
|
||||
last_date = date;
|
||||
last_sender = null;
|
||||
}
|
||||
|
||||
// Sender Annotation
|
||||
if (show_sender && !message.from_me && last_sender != message.sender) {
|
||||
var sender_bubble = new SenderAnnotationLayout(message.sender, max_width, this);
|
||||
items.add(sender_bubble);
|
||||
}
|
||||
|
||||
// Text Bubble
|
||||
var animate = message.should_animate;
|
||||
if (message.text.length > 0 && !message.is_attachment_marker) {
|
||||
var text_bubble = new TextBubbleLayout(message, this, max_width);
|
||||
text_bubble.id = @"text-$(message.guid)";
|
||||
if (animate) {
|
||||
start_animation(text_bubble.id);
|
||||
}
|
||||
|
||||
text_bubble.vertical_padding = (last_sender == message.sender) ? 4.0f : 10.0f;
|
||||
items.add(text_bubble);
|
||||
}
|
||||
|
||||
// Check for attachments. For each one, add an image layout bubble
|
||||
foreach (var attachment in message.attachments) {
|
||||
Graphene.Size? image_size = null;
|
||||
if (attachment.metadata != null) {
|
||||
image_size = Graphene.Size() {
|
||||
width = attachment.metadata.attribution_info.width,
|
||||
height = attachment.metadata.attribution_info.height
|
||||
};
|
||||
}
|
||||
|
||||
var image_layout = new ImageBubbleLayout(attachment.preview_path, message.from_me, this, max_width, image_size);
|
||||
image_layout.id = @"image-$(attachment.guid)";
|
||||
image_layout.attachment_guid = attachment.guid;
|
||||
|
||||
if (animate) {
|
||||
start_animation(image_layout.id);
|
||||
}
|
||||
|
||||
image_layout.is_downloaded = attachment.preview_downloaded;
|
||||
items.add(image_layout);
|
||||
|
||||
// If the attachment isn't downloaded, queue a download since we are going to be showing it here.
|
||||
// TODO: Probably would be better if we only did this for stuff in the viewport.
|
||||
if (!attachment.preview_downloaded) {
|
||||
try {
|
||||
Repository.get_instance().download_attachment(attachment.guid, true);
|
||||
} catch (GLib.Error e) {
|
||||
warning("Wasn't able to message daemon about queuing attachment download: %s", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_sender = message.sender;
|
||||
last_date = date;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
_chat_items.clear();
|
||||
_chat_items.add_all(items);
|
||||
|
||||
queue_draw();
|
||||
queue_resize();
|
||||
}
|
||||
}
|
||||
|
||||
private class ChatItemAnimation
|
||||
{
|
||||
public double progress = 0.0;
|
||||
private int64 start_time = 0;
|
||||
private double duration = 0.0;
|
||||
private Gdk.FrameClock frame_clock = null;
|
||||
|
||||
delegate double EaseFunction(double t);
|
||||
private EaseFunction ease_function = ease_out_quart;
|
||||
|
||||
public ChatItemAnimation(Gdk.FrameClock frame_clock) {
|
||||
this.frame_clock = frame_clock;
|
||||
progress = 0.0f;
|
||||
}
|
||||
|
||||
public void start_animation(double duration) {
|
||||
this.duration = duration;
|
||||
this.progress = 0.0;
|
||||
this.start_time = frame_clock.get_frame_time();
|
||||
}
|
||||
|
||||
public void tick_animation() {
|
||||
double t = (frame_clock.get_frame_time() - start_time) / (duration * 1000000.0);
|
||||
t = double.min(1.0, double.max(0.0, t));
|
||||
progress = ease_function(t);
|
||||
}
|
||||
|
||||
private static double ease_out_quart(double t) {
|
||||
return 1.0 - Math.pow(1.0 - t, 4);
|
||||
}
|
||||
}
|
||||
|
||||
public struct VisibleLayout {
|
||||
public weak BubbleLayout bubble;
|
||||
public Graphene.Rect rect;
|
||||
|
||||
public VisibleLayout(BubbleLayout bubble, Graphene.Rect rect) {
|
||||
this.bubble = bubble;
|
||||
this.rect = rect;
|
||||
}
|
||||
}
|
||||
280
gtk/src/transcript/transcript-view.vala
Normal file
280
gtk/src/transcript/transcript-view.vala
Normal file
@@ -0,0 +1,280 @@
|
||||
using Adw;
|
||||
using Gtk;
|
||||
using Gee;
|
||||
|
||||
public class TranscriptView : Adw.Bin
|
||||
{
|
||||
public MessageListModel? model {
|
||||
get {
|
||||
return _model;
|
||||
}
|
||||
set {
|
||||
if (_model != null) {
|
||||
_model.disconnect(messages_changed_handler_id);
|
||||
_model.unwatch_updates();
|
||||
}
|
||||
|
||||
_model = value;
|
||||
|
||||
if (value != null) {
|
||||
reset_for_conversation_change();
|
||||
|
||||
weak TranscriptView self = this;
|
||||
messages_changed_handler_id = value.messages_changed.connect(() => {
|
||||
self.reload_messages();
|
||||
});
|
||||
|
||||
value.load_messages();
|
||||
value.watch_updates();
|
||||
} else {
|
||||
transcript_drawing_area.set_messages(new ArrayList<Message>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string title {
|
||||
get { return title_label.label; }
|
||||
set { title_label.label = value; }
|
||||
}
|
||||
|
||||
private MessageListModel? _model = null;
|
||||
private Adw.ToolbarView container;
|
||||
private Label title_label = new Label("Messages");
|
||||
|
||||
private Overlay overlay = new Overlay();
|
||||
private TranscriptDrawingArea transcript_drawing_area = new TranscriptDrawingArea();
|
||||
private ScrolledWindow scrolled_window = new ScrolledWindow();
|
||||
private ulong messages_changed_handler_id = 0;
|
||||
private bool needs_reload = false;
|
||||
|
||||
private Graphene.Point _hovering_text_view_origin = Graphene.Point() { x = 0, y = 0 };
|
||||
private TextView _hovering_text_view = new TextView();
|
||||
|
||||
private bool _queued_url_open = false;
|
||||
private VisibleLayout? hovered_text_bubble = null;
|
||||
private VisibleLayout? locked_text_bubble = null;
|
||||
|
||||
public TranscriptView() {
|
||||
container = new Adw.ToolbarView();
|
||||
set_child(container);
|
||||
|
||||
// Set minimum width for the transcript view
|
||||
set_size_request(330, -1);
|
||||
|
||||
overlay.set_child(transcript_drawing_area);
|
||||
|
||||
weak TranscriptView self = this;
|
||||
overlay.get_child_position.connect((child, out allocation) => {
|
||||
allocation = Gtk.Allocation() { x = 0, y = 0, width = 0, height = 0 };
|
||||
if (self.hovered_text_bubble != null) {
|
||||
var rect = self.hovered_text_bubble.rect;
|
||||
allocation.x = (int)(rect.origin.x + self._hovering_text_view_origin.x);
|
||||
allocation.y = (int)(rect.origin.y - self._hovering_text_view_origin.y);
|
||||
allocation.width = (int)rect.size.width;
|
||||
allocation.height = (int)rect.size.height;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
scrolled_window.set_child(overlay);
|
||||
scrolled_window.add_css_class("flipped-y-axis");
|
||||
transcript_drawing_area.viewport = scrolled_window.vadjustment;
|
||||
container.set_content(scrolled_window);
|
||||
|
||||
// Connect to the adjustment's value_changed signal
|
||||
scrolled_window.vadjustment.value_changed.connect(() => {
|
||||
transcript_drawing_area.viewport = scrolled_window.vadjustment;
|
||||
});
|
||||
|
||||
var header_bar = new Adw.HeaderBar();
|
||||
title_label.single_line_mode = true;
|
||||
title_label.ellipsize = Pango.EllipsizeMode.END;
|
||||
header_bar.set_title_widget(title_label);
|
||||
container.add_top_bar(header_bar);
|
||||
|
||||
// This is an invisible text view that's used to handle selection.
|
||||
_hovering_text_view.add_css_class("hovering-text-view");
|
||||
_hovering_text_view.add_css_class("flipped-y-axis");
|
||||
_hovering_text_view.set_editable(false);
|
||||
_hovering_text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR);
|
||||
overlay.add_overlay(_hovering_text_view);
|
||||
|
||||
// When the selection changes, lock the text bubble so that if the cursor moves to another bubble we don't clear it.
|
||||
_hovering_text_view.buffer.mark_set.connect((location, end) => {
|
||||
if (location.get_offset() == 0) { return; }
|
||||
self.on_hovering_text_view_clicked(location, end);
|
||||
});
|
||||
|
||||
// When the mouse hovers over a text bubble, configure the hovering text view to show the text of the bubble.
|
||||
transcript_drawing_area.on_text_bubble_hover.connect((visible_text_layout) => {
|
||||
if (visible_text_layout != null) {
|
||||
configure_hovering_text_view(visible_text_layout);
|
||||
}
|
||||
});
|
||||
|
||||
// This is triggered when another bubble is currently locked, and the user clicks on a new bubble.
|
||||
transcript_drawing_area.on_text_bubble_click.connect((visible_text_layout) => {
|
||||
if (visible_text_layout != null) {
|
||||
locked_text_bubble = null;
|
||||
configure_hovering_text_view(visible_text_layout);
|
||||
}
|
||||
});
|
||||
|
||||
transcript_drawing_area.on_image_bubble_activate.connect((attachment_guid) => {
|
||||
self.open_attachment(attachment_guid);
|
||||
});
|
||||
|
||||
Repository.get_instance().attachment_downloaded.connect((attachment_guid) => {
|
||||
debug("Attachment downloaded: %s", attachment_guid);
|
||||
|
||||
// See if this attachment is part of this transcript.
|
||||
bool contains_attachment = false;
|
||||
foreach (var message in _model.messages) {
|
||||
foreach (var attachment in message.attachments) {
|
||||
if (attachment.guid == attachment_guid) {
|
||||
contains_attachment = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contains_attachment && !needs_reload) {
|
||||
debug("Queueing reload of messages for attachment download");
|
||||
|
||||
needs_reload = true;
|
||||
GLib.Idle.add(() => {
|
||||
if (needs_reload) {
|
||||
debug("Reloading messages for attachment download");
|
||||
model.load_messages();
|
||||
needs_reload = false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, GLib.Priority.HIGH);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
delegate void OpenPath(string path);
|
||||
private ulong attachment_downloaded_handler_id = 0;
|
||||
private void open_attachment(string attachment_guid) {
|
||||
OpenPath open_path = (path) => {
|
||||
try {
|
||||
GLib.AppInfo.launch_default_for_uri("file://" + path, null);
|
||||
} catch (GLib.Error e) {
|
||||
warning("Failed to open image %s: %s", path, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
var attachment_info = Repository.get_instance().get_attachment_info(attachment_guid);
|
||||
if (attachment_info.downloaded == true) {
|
||||
// We already have it, so open it.
|
||||
open_path(attachment_info.path);
|
||||
} else {
|
||||
// We need to download this, then open it once the downloaded signal is emitted.
|
||||
Repository.get_instance().download_attachment(attachment_guid, false);
|
||||
|
||||
// TODO: Should probably indicate progress here.
|
||||
|
||||
attachment_downloaded_handler_id = Repository.get_instance().attachment_downloaded.connect((guid) => {
|
||||
if (guid == attachment_guid) {
|
||||
open_path(attachment_info.path);
|
||||
Repository.get_instance().disconnect(attachment_downloaded_handler_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (GLib.Error e) {
|
||||
warning("Failed to get attachment info: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private void on_hovering_text_view_clicked(TextIter location, TextMark end) {
|
||||
lock_text_bubble(hovered_text_bubble);
|
||||
|
||||
if (!_queued_url_open) {
|
||||
_queued_url_open = true;
|
||||
|
||||
// 100ms timeout to let the selection state settle. (this is a workaround)
|
||||
GLib.Timeout.add(100, () => {
|
||||
open_url_at_location(location);
|
||||
_queued_url_open = false;
|
||||
return false;
|
||||
}, GLib.Priority.HIGH);
|
||||
}
|
||||
}
|
||||
|
||||
private void open_url_at_location(TextIter location) {
|
||||
Gtk.TextTag? underline_tag = null;
|
||||
foreach (unowned Gtk.TextTag tag in location.get_tags()) {
|
||||
if (tag.underline != Pango.Underline.NONE) {
|
||||
underline_tag = tag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (underline_tag == null) {
|
||||
return; // Click wasn't on an underlined (i.e. link) region
|
||||
}
|
||||
|
||||
// Determine the full extent (start/end iters) of this underlined region
|
||||
Gtk.TextIter start_iter = location;
|
||||
Gtk.TextIter end_iter = location;
|
||||
start_iter.backward_to_tag_toggle(underline_tag);
|
||||
end_iter.forward_to_tag_toggle(underline_tag);
|
||||
|
||||
string url = _hovering_text_view.buffer.get_text(start_iter, end_iter, false);
|
||||
|
||||
// Try to open the URL – guard against malformed data
|
||||
if (url != null && url.strip().length > 0) {
|
||||
try {
|
||||
GLib.AppInfo.launch_default_for_uri(url.strip(), null);
|
||||
} catch (GLib.Error e) {
|
||||
warning("Failed to open URL %s: %s", url, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void reset_for_conversation_change() {
|
||||
locked_text_bubble = null;
|
||||
hovered_text_bubble = null;
|
||||
_hovering_text_view.buffer.text = "";
|
||||
overlay.queue_allocate();
|
||||
|
||||
// Reset scroll position by updating the existing adjustment
|
||||
scrolled_window.vadjustment.value = 0;
|
||||
scrolled_window.vadjustment.upper = 0;
|
||||
scrolled_window.vadjustment.page_size = 0;
|
||||
}
|
||||
|
||||
private void lock_text_bubble(VisibleLayout? visible_text_layout) {
|
||||
if (visible_text_layout != null) {
|
||||
locked_text_bubble = visible_text_layout;
|
||||
configure_hovering_text_view(visible_text_layout);
|
||||
}
|
||||
}
|
||||
|
||||
private void configure_hovering_text_view(VisibleLayout? visible_text_layout) {
|
||||
hovered_text_bubble = visible_text_layout;
|
||||
|
||||
if (locked_text_bubble == null) {
|
||||
TextBubbleLayout text_bubble = visible_text_layout.bubble as TextBubbleLayout;
|
||||
_hovering_text_view_origin = text_bubble.get_text_origin();
|
||||
|
||||
_hovering_text_view.buffer.text = "";
|
||||
Gtk.TextIter start_iter;
|
||||
_hovering_text_view.buffer.get_start_iter(out start_iter);
|
||||
_hovering_text_view.buffer.insert_markup(ref start_iter, text_bubble.message.markup, -1);
|
||||
overlay.queue_allocate();
|
||||
}
|
||||
}
|
||||
|
||||
private void reload_messages() {
|
||||
transcript_drawing_area.show_sender = _model.is_group_chat;
|
||||
transcript_drawing_area.set_messages(_model.messages);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user