Implements attachments display in transcript
This commit is contained in:
@@ -40,10 +40,9 @@ sources = [
|
|||||||
'transcript/layouts/sender-annotation-layout.vala',
|
'transcript/layouts/sender-annotation-layout.vala',
|
||||||
'transcript/layouts/text-bubble-layout.vala',
|
'transcript/layouts/text-bubble-layout.vala',
|
||||||
|
|
||||||
|
'models/attachment.vala',
|
||||||
'models/conversation.vala',
|
'models/conversation.vala',
|
||||||
'models/message.vala',
|
'models/message.vala',
|
||||||
|
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
executable('kordophone',
|
executable('kordophone',
|
||||||
|
|||||||
74
src/models/attachment.vala
Normal file
74
src/models/attachment.vala
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ public class Message : Object
|
|||||||
public DateTime date { get; set; default = new DateTime.now_local(); }
|
public DateTime date { get; set; default = new DateTime.now_local(); }
|
||||||
public string sender { get; set; default = null; }
|
public string sender { get; set; default = null; }
|
||||||
|
|
||||||
|
public Attachment[] attachments { get; set; default = {}; }
|
||||||
|
|
||||||
public bool from_me {
|
public bool from_me {
|
||||||
get {
|
get {
|
||||||
// Hm, this may have been accidental.
|
// Hm, this may have been accidental.
|
||||||
@@ -25,5 +27,18 @@ public class Message : Object
|
|||||||
text = message_data["text"].get_string();
|
text = message_data["text"].get_string();
|
||||||
sender = message_data["sender"].get_string();
|
sender = message_data["sender"].get_string();
|
||||||
date = new DateTime.from_unix_utc(message_data["date"].get_int64());
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,5 +52,24 @@ namespace DBusService {
|
|||||||
|
|
||||||
[DBus (name = "MessagesUpdated")]
|
[DBus (name = "MessagesUpdated")]
|
||||||
public signal void messages_updated(string conversation_id);
|
public signal void messages_updated(string conversation_id);
|
||||||
|
|
||||||
|
[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 = "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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RepositoryAttachmentInfoStruct {
|
||||||
|
public string attr1;
|
||||||
|
public string attr2;
|
||||||
|
public bool attr3;
|
||||||
|
public bool attr4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,24 @@
|
|||||||
<method name="GetMessages">
|
<method name="GetMessages">
|
||||||
<arg type="s" name="conversation_id" direction="in"/>
|
<arg type="s" name="conversation_id" direction="in"/>
|
||||||
<arg type="s" name="last_message_id" direction="in"/>
|
<arg type="s" name="last_message_id" direction="in"/>
|
||||||
<arg type="aa{sv}" direction="out" name="messages"/>
|
<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>
|
||||||
|
|
||||||
<method name="SendMessage">
|
<method name="SendMessage">
|
||||||
@@ -76,6 +93,46 @@
|
|||||||
value="Emitted when the list of messages is updated."/>
|
value="Emitted when the list of messages is updated."/>
|
||||||
</signal>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
</interface>
|
</interface>
|
||||||
|
|
||||||
<interface name="net.buzzert.kordophone.Settings">
|
<interface name="net.buzzert.kordophone.Settings">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Gee;
|
|||||||
public class Repository : DBusServiceProxy {
|
public class Repository : DBusServiceProxy {
|
||||||
public signal void conversations_updated();
|
public signal void conversations_updated();
|
||||||
public signal void messages_updated(string conversation_guid);
|
public signal void messages_updated(string conversation_guid);
|
||||||
|
public signal void attachment_downloaded(string attachment_guid);
|
||||||
|
|
||||||
public static Repository get_instance() {
|
public static Repository get_instance() {
|
||||||
if (instance == null) {
|
if (instance == null) {
|
||||||
@@ -36,6 +37,10 @@ public class Repository : DBusServiceProxy {
|
|||||||
messages_updated(conversation_guid);
|
messages_updated(conversation_guid);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.dbus_repository.attachment_download_completed.connect((attachment_guid) => {
|
||||||
|
attachment_downloaded(attachment_guid);
|
||||||
|
});
|
||||||
|
|
||||||
conversations_updated();
|
conversations_updated();
|
||||||
} catch (GLib.Error e) {
|
} catch (GLib.Error e) {
|
||||||
warning("Failed to connect to repository: %s", e.message);
|
warning("Failed to connect to repository: %s", e.message);
|
||||||
@@ -87,4 +92,12 @@ public class Repository : DBusServiceProxy {
|
|||||||
|
|
||||||
dbus_repository.sync_conversation(conversation_guid);
|
dbus_repository.sync_conversation(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ private class ImageBubbleLayout : BubbleLayout
|
|||||||
|
|
||||||
private Graphene.Size image_size;
|
private Graphene.Size image_size;
|
||||||
private Gdk.Texture? cached_texture = null;
|
private Gdk.Texture? cached_texture = null;
|
||||||
private const float max_image_width = 300.0f;
|
|
||||||
private const float max_image_height = 400.0f;
|
|
||||||
|
|
||||||
public ImageBubbleLayout(string image_path, bool from_me, Widget parent, float max_width, Graphene.Size? image_size = null) {
|
public ImageBubbleLayout(string image_path, bool from_me, Widget parent, float max_width, Graphene.Size? image_size = null) {
|
||||||
base(parent, max_width);
|
base(parent, max_width);
|
||||||
@@ -29,18 +27,13 @@ private class ImageBubbleLayout : BubbleLayout
|
|||||||
|
|
||||||
// Try to load the image to get its dimensions
|
// Try to load the image to get its dimensions
|
||||||
try {
|
try {
|
||||||
|
warning("No image size provided, loading image to get dimensions");
|
||||||
|
|
||||||
var texture = Gdk.Texture.from_filename(image_path);
|
var texture = Gdk.Texture.from_filename(image_path);
|
||||||
var original_width = (float)texture.get_width();
|
var original_width = (float)texture.get_width();
|
||||||
var original_height = (float)texture.get_height();
|
var original_height = (float)texture.get_height();
|
||||||
|
|
||||||
// Calculate scaled dimensions while maintaining aspect ratio
|
this.image_size = Graphene.Size() { width = original_width, height = original_height };
|
||||||
var scale_factor = float.min(
|
|
||||||
max_image_width / original_width,
|
|
||||||
max_image_height / original_height
|
|
||||||
);
|
|
||||||
scale_factor = float.min(scale_factor, 1.0f); // Don't scale up
|
|
||||||
|
|
||||||
this.image_size = Graphene.Size() { width = original_width * scale_factor, height = original_height * scale_factor };
|
|
||||||
} catch (Error e) {
|
} catch (Error e) {
|
||||||
// Fallback dimensions if image can't be loaded
|
// Fallback dimensions if image can't be loaded
|
||||||
warning("Failed to load image %s: %s", image_path, e.message);
|
warning("Failed to load image %s: %s", image_path, e.message);
|
||||||
@@ -65,12 +58,8 @@ private class ImageBubbleLayout : BubbleLayout
|
|||||||
}
|
}
|
||||||
|
|
||||||
public override float get_height() {
|
public override float get_height() {
|
||||||
float aspect_ratio = image_size.width / image_size.height;
|
var scale_factor = float.min(max_width / image_size.width, 1.0f);
|
||||||
if (image_size.width > max_width) {
|
return image_size.height * scale_factor;
|
||||||
return max_width / aspect_ratio;
|
|
||||||
}
|
|
||||||
|
|
||||||
return image_size.height;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override float get_width() {
|
public override float get_width() {
|
||||||
|
|||||||
@@ -108,6 +108,30 @@ private class TranscriptDrawingArea : Widget
|
|||||||
text_bubble.vertical_padding = (last_sender == message.sender) ? 0.0f : 10.0f;
|
text_bubble.vertical_padding = (last_sender == message.sender) ? 0.0f : 10.0f;
|
||||||
items.add(text_bubble);
|
items.add(text_bubble);
|
||||||
|
|
||||||
|
// Check for attachments. For each one, add an image layout bubble
|
||||||
|
foreach (var attachment in message.attachments) {
|
||||||
|
if (attachment.metadata != null) {
|
||||||
|
var 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.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_sender = message.sender;
|
||||||
last_date = date;
|
last_date = date;
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public class TranscriptView : Adw.Bin
|
|||||||
private TranscriptDrawingArea transcript_drawing_area = new TranscriptDrawingArea();
|
private TranscriptDrawingArea transcript_drawing_area = new TranscriptDrawingArea();
|
||||||
private ScrolledWindow scrolled_window = new ScrolledWindow();
|
private ScrolledWindow scrolled_window = new ScrolledWindow();
|
||||||
private ulong messages_changed_handler_id = 0;
|
private ulong messages_changed_handler_id = 0;
|
||||||
|
private bool needs_reload = false;
|
||||||
|
|
||||||
public TranscriptView() {
|
public TranscriptView() {
|
||||||
container = new Adw.ToolbarView();
|
container = new Adw.ToolbarView();
|
||||||
@@ -56,6 +57,36 @@ public class TranscriptView : Adw.Bin
|
|||||||
var header_bar = new Adw.HeaderBar();
|
var header_bar = new Adw.HeaderBar();
|
||||||
header_bar.set_title_widget(title_label);
|
header_bar.set_title_widget(title_label);
|
||||||
container.add_top_bar(header_bar);
|
container.add_top_bar(header_bar);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void reload_messages() {
|
private void reload_messages() {
|
||||||
|
|||||||
Reference in New Issue
Block a user