Add 'gtk/' from commit '7d0dfb455aa86245231b383a92e79b3c08a12d5e'
git-subtree-dir: gtk git-subtree-mainline:c710c6e053git-subtree-split:7d0dfb455a
This commit is contained in:
1
gtk/.gitignore
vendored
Normal file
1
gtk/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
build/
|
||||||
58
gtk/CLAUDE.md
Normal file
58
gtk/CLAUDE.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup build directory (first time)
|
||||||
|
meson setup builddir
|
||||||
|
|
||||||
|
# Build project
|
||||||
|
meson compile -C builddir
|
||||||
|
|
||||||
|
# Clean rebuild
|
||||||
|
rm -rf builddir && meson setup builddir && meson compile -C builddir
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
./builddir/src/kordophone
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
Kordophone is a GTK4/Libadwaita messaging client written in Vala that acts as a frontend to a separate DBus daemon (`kordophonecd`). The application follows a clean separation of concerns:
|
||||||
|
|
||||||
|
### Core Architecture
|
||||||
|
- **DBus Client**: This GTK app connects to `net.buzzert.kordophonecd` daemon over DBus
|
||||||
|
- **Split UI**: Main window has conversation list sidebar + transcript view
|
||||||
|
- **Custom Rendering**: Transcript uses Gtk.DrawingArea with snapshot API for performance
|
||||||
|
- **Repository Pattern**: All data operations go through `Repository` singleton which proxies to DBus service
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
- **Application** (`src/application/`): App lifecycle, main window, preferences
|
||||||
|
- **Service** (`src/service/`): DBus integration, data repository, settings management
|
||||||
|
- **Conversation List** (`src/conversation-list/`): Sidebar with conversation list and search
|
||||||
|
- **Transcript** (`src/transcript/`): Message display with pluggable bubble layouts
|
||||||
|
- **Models** (`src/models/`): Data structures (Conversation, Message, Attachment)
|
||||||
|
|
||||||
|
### DBus Interface
|
||||||
|
The app depends on a running `kordophonecd` daemon that provides:
|
||||||
|
- **Repository interface**: Conversation/message CRUD, real-time sync via signals
|
||||||
|
- **Settings interface**: Server configuration, user credentials
|
||||||
|
|
||||||
|
### Development Notes
|
||||||
|
- Vala compiles to C, build artifacts in `builddir/`
|
||||||
|
- DBus interfaces auto-generated from XML: run `src/service/interface/generate.sh`
|
||||||
|
- Resources bundled via GResource (`src/resources/kordophone.gresource.xml`)
|
||||||
|
- Custom CSS styling in `src/resources/style.css`
|
||||||
|
- Application ID: `net.buzzert.kordophone2`
|
||||||
|
|
||||||
|
### Message Layout System
|
||||||
|
Transcript view uses a pluggable layout system in `src/transcript/layouts/`:
|
||||||
|
- `BubbleLayout`: Base class for message bubbles
|
||||||
|
- `TextBubbleLayout`: Text messages
|
||||||
|
- `ImageBubbleLayout`: Image attachments
|
||||||
|
- `DateItemLayout`: Date separators
|
||||||
|
- `SenderAnnotationLayout`: Sender labels
|
||||||
|
|
||||||
|
All layouts render to Cairo context via Gtk snapshot API for efficient scrolling performance.
|
||||||
30
gtk/Dockerfile
Normal file
30
gtk/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM fedora:40
|
||||||
|
|
||||||
|
# Install RPM build tools and dependencies
|
||||||
|
RUN dnf update -y && dnf install -y \
|
||||||
|
rpm-build \
|
||||||
|
rpmdevtools \
|
||||||
|
meson \
|
||||||
|
vala \
|
||||||
|
gcc \
|
||||||
|
pkgconfig \
|
||||||
|
gtk4-devel \
|
||||||
|
libadwaita-devel \
|
||||||
|
glib2-devel \
|
||||||
|
libgee-devel \
|
||||||
|
libsecret-devel \
|
||||||
|
ImageMagick \
|
||||||
|
git \
|
||||||
|
&& dnf clean all
|
||||||
|
|
||||||
|
# Create RPM build environment
|
||||||
|
RUN rpmdev-setuptree
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /root/rpmbuild
|
||||||
|
|
||||||
|
# Copy spec file
|
||||||
|
COPY dist/rpm/kordophone.spec SPECS/
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
CMD ["rpmbuild", "-ba", "SPECS/kordophone.spec"]
|
||||||
7
gtk/README.md
Normal file
7
gtk/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# kordophone-2-gtk
|
||||||
|
|
||||||
|
Libadwaita/GTK4 client for the Kordophone client daemon.
|
||||||
|
|
||||||
|
# Building
|
||||||
|
|
||||||
|
Build an RPM using `rpmbuild -ba dist/rpm/kordophone.spec`
|
||||||
10
gtk/build-aux/post-install.sh
Normal file
10
gtk/build-aux/post-install.sh
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
datadir=$1
|
||||||
|
|
||||||
|
# Package managers set this so we don't need to run
|
||||||
|
if [ -z "$DESTDIR" ]; then
|
||||||
|
echo Compiling GSettings schemas...
|
||||||
|
glib-compile-schemas ${datadir}/glib-2.0/schemas
|
||||||
|
fi
|
||||||
|
|
||||||
26
gtk/build-aux/resize.py
Executable file
26
gtk/build-aux/resize.py
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os, sys
|
||||||
|
|
||||||
|
icon_class = [
|
||||||
|
'16x16',
|
||||||
|
'24x24',
|
||||||
|
'32x32',
|
||||||
|
'48x48',
|
||||||
|
'256x256',
|
||||||
|
'512x512',
|
||||||
|
]
|
||||||
|
|
||||||
|
magick = sys.argv[1]
|
||||||
|
input_file = sys.argv[2]
|
||||||
|
output = sys.argv[3]
|
||||||
|
|
||||||
|
for iclass in icon_class:
|
||||||
|
outdir = os.path.join(output, iclass, 'apps')
|
||||||
|
outfile = os.path.join(outdir, 'net.buzzert.kordophone.png')
|
||||||
|
|
||||||
|
os.makedirs(outdir, exist_ok=True)
|
||||||
|
os.system('{magick} {input} -resize {klass} {outfile}'
|
||||||
|
.format(magick=magick, input=input_file, klass=iclass, outfile=outfile))
|
||||||
|
|
||||||
|
|
||||||
51
gtk/dist/rpm/kordophone.spec
vendored
Normal file
51
gtk/dist/rpm/kordophone.spec
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
Name: kordophone
|
||||||
|
Version: 1.0.1
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: GTK4/Libadwaita client for Kordophone
|
||||||
|
|
||||||
|
License: GPL
|
||||||
|
URL: https://git.sr.ht/~buzzert/kordophone-2-gtk
|
||||||
|
|
||||||
|
BuildRequires: meson >= 0.56.0
|
||||||
|
BuildRequires: vala
|
||||||
|
BuildRequires: gcc
|
||||||
|
BuildRequires: pkgconfig(gtk4)
|
||||||
|
BuildRequires: pkgconfig(libadwaita-1)
|
||||||
|
BuildRequires: pkgconfig(gio-2.0)
|
||||||
|
BuildRequires: pkgconfig(gee-0.8)
|
||||||
|
BuildRequires: pkgconfig(gio-unix-2.0)
|
||||||
|
BuildRequires: pkgconfig(libsecret-1)
|
||||||
|
|
||||||
|
Requires: gtk4
|
||||||
|
Requires: libadwaita
|
||||||
|
Requires: glib2
|
||||||
|
Requires: libgee
|
||||||
|
Requires: libsecret
|
||||||
|
Requires: kordophoned >= 1.0.0
|
||||||
|
|
||||||
|
%description
|
||||||
|
A GTK4/Libadwaita Linux Client for the Kordophone client daemon.
|
||||||
|
|
||||||
|
%prep
|
||||||
|
if [ ! -d .git ]; then
|
||||||
|
git clone --bare https://git.sr.ht/~buzzert/kordophone-2-gtk .git
|
||||||
|
git config --local --bool core.bare false
|
||||||
|
git reset --hard
|
||||||
|
fi
|
||||||
|
|
||||||
|
%build
|
||||||
|
%meson
|
||||||
|
%meson_build
|
||||||
|
|
||||||
|
%install
|
||||||
|
%meson_install
|
||||||
|
|
||||||
|
%files
|
||||||
|
%{_bindir}/kordophone
|
||||||
|
%{_datadir}/applications/net.buzzert.kordophone.desktop
|
||||||
|
%{_datadir}/icons/
|
||||||
|
|
||||||
|
%changelog
|
||||||
|
* Fri Aug 8 2025 James Magahern <james@magahern.com>
|
||||||
|
- Updated rpmspec
|
||||||
|
|
||||||
7
gtk/meson.build
Normal file
7
gtk/meson.build
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
project('kordophone', 'vala',
|
||||||
|
version : '1.0.1',
|
||||||
|
meson_version : '>=0.56.0',
|
||||||
|
default_options : ['warning_level=2']
|
||||||
|
)
|
||||||
|
|
||||||
|
subdir('src')
|
||||||
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