Private
Public Access
1
0

Add 'gtk/' from commit '7d0dfb455aa86245231b383a92e79b3c08a12d5e'

git-subtree-dir: gtk
git-subtree-mainline: c710c6e053
git-subtree-split: 7d0dfb455a
This commit is contained in:
2025-09-06 19:34:30 -07:00
39 changed files with 3599 additions and 0 deletions

View 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);
}
}

View 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();
}
}

View 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);
}
}
}

View 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];
}
}

View 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);
}
}

View 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("&", "&amp;");
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
View 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
)

View 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;
}
}
}
}

View 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;
}
}

View 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();
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/net/buzzert/kordophone2">
<file>style.css</file>
</gresource>
</gresources>

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

View 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 */
}

View 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;
}

View 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;
}
}

View 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/

View File

@@ -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>

View 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);
}
}

View 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");
}
}
}

View 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;
}
}

View 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();
}
}

View 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);
}

View 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());
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}