Add 'gtk/' from commit '7d0dfb455aa86245231b383a92e79b3c08a12d5e'
git-subtree-dir: gtk git-subtree-mainline:c710c6e053git-subtree-split:7d0dfb455a
This commit is contained in:
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