From 54ca00189200546c7908448346111be029d9fdf2 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Tue, 17 Jun 2025 00:47:03 -0700 Subject: [PATCH] Adds incoming bubble animations --- src/meson.build | 1 + src/models/message.vala | 21 +++- src/transcript/layouts/bubble-layout.vala | 1 + src/transcript/layouts/chat-item-layout.vala | 1 + src/transcript/layouts/date-item-layout.vala | 3 +- .../layouts/sender-annotation-layout.vala | 1 + src/transcript/message-list-model.vala | 12 +- src/transcript/transcript-drawing-area.vala | 107 +++++++++++++++++- 8 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/meson.build b/src/meson.build index f1ec3f0..f60fed6 100644 --- a/src/meson.build +++ b/src/meson.build @@ -51,5 +51,6 @@ executable('kordophone', resources, dependencies : dependencies, vala_args: ['--pkg', 'posix'], + link_args: ['-lm'], install : true ) \ No newline at end of file diff --git a/src/models/message.vala b/src/models/message.vala index 7fa7886..e3cf1d6 100644 --- a/src/models/message.vala +++ b/src/models/message.vala @@ -1,6 +1,7 @@ using GLib; +using Gee; -public class Message : Object +public class Message : Object, Comparable, Hashable { public string guid { get; set; default = ""; } public string text { get; set; default = ""; } @@ -9,6 +10,8 @@ public class Message : Object public Attachment[] attachments { get; set; default = {}; } + public bool should_animate = false; + public bool from_me { get { // Hm, this may have been accidental. @@ -52,4 +55,20 @@ public class Message : Object 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(); + } } \ No newline at end of file diff --git a/src/transcript/layouts/bubble-layout.vala b/src/transcript/layouts/bubble-layout.vala index 725f2ef..c812cbe 100644 --- a/src/transcript/layouts/bubble-layout.vala +++ b/src/transcript/layouts/bubble-layout.vala @@ -27,6 +27,7 @@ private 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; diff --git a/src/transcript/layouts/chat-item-layout.vala b/src/transcript/layouts/chat-item-layout.vala index e1f5a06..8623e03 100644 --- a/src/transcript/layouts/chat-item-layout.vala +++ b/src/transcript/layouts/chat-item-layout.vala @@ -4,6 +4,7 @@ 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(); diff --git a/src/transcript/layouts/date-item-layout.vala b/src/transcript/layouts/date-item-layout.vala index 4e18ee2..c9661ce 100644 --- a/src/transcript/layouts/date-item-layout.vala +++ b/src/transcript/layouts/date-item-layout.vala @@ -3,6 +3,7 @@ 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; @@ -14,7 +15,7 @@ class DateItemLayout : Object, ChatItemLayout { 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); diff --git a/src/transcript/layouts/sender-annotation-layout.vala b/src/transcript/layouts/sender-annotation-layout.vala index dac8e57..d442836 100644 --- a/src/transcript/layouts/sender-annotation-layout.vala +++ b/src/transcript/layouts/sender-annotation-layout.vala @@ -6,6 +6,7 @@ private class SenderAnnotationLayout : Object, ChatItemLayout 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; diff --git a/src/transcript/message-list-model.vala b/src/transcript/message-list-model.vala index bd43b6f..445f6a3 100644 --- a/src/transcript/message-list-model.vala +++ b/src/transcript/message-list-model.vala @@ -63,7 +63,12 @@ public class MessageListModel : Object, ListModel } public void load_messages() { + var previous_messages = new HashSet(); + previous_messages.add_all(_messages); + try { + bool first_load = _messages.size == 0; + Message[] messages = Repository.get_instance().get_messages(conversation_guid); // Clear existing set @@ -81,9 +86,14 @@ public class MessageListModel : Object, ListModel for (int i = 0; i < messages.length; i++) { var message = messages[i]; - _messages.add(message); 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++; } diff --git a/src/transcript/transcript-drawing-area.vala b/src/transcript/transcript-drawing-area.vala index dd29b9c..883de0a 100644 --- a/src/transcript/transcript-drawing-area.vala +++ b/src/transcript/transcript-drawing-area.vala @@ -1,5 +1,6 @@ using Gtk; using Gee; +using Gdk; private class TranscriptDrawingArea : Widget { @@ -22,6 +23,8 @@ private class TranscriptDrawingArea : Widget private const float bubble_margin = 18.0f; private const bool debug_viewport = false; + private uint? _tick_callback_id = null; + private HashMap _animations = new HashMap(); public TranscriptDrawingArea() { add_css_class("transcript-drawing-area"); @@ -63,7 +66,7 @@ private class TranscriptDrawingArea : Widget recompute_message_layouts(); } - public override void snapshot(Snapshot snapshot) { + 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) }, @@ -104,9 +107,22 @@ private class TranscriptDrawingArea : Widget }; // Skip drawing if this item is not in the viewport + float height_offset = 0.0f; if (viewport_rect.intersection(rect, null)) { 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); @@ -117,17 +133,64 @@ private class TranscriptDrawingArea : Widget 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; + y_offset += item_height + chat_item.vertical_padding + height_offset; } + + animation_tick(); + } + + private bool animation_tick() { + HashSet animations_to_remove = new HashSet(); + _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 items = new ArrayList(); @@ -147,8 +210,14 @@ private class TranscriptDrawingArea : Widget } // 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); } @@ -164,6 +233,11 @@ private class TranscriptDrawingArea : Widget } var image_layout = new ImageBubbleLayout(attachment.preview_path, message.from_me, this, max_width, image_size); + image_layout.id = @"image-$(attachment.guid)"; + if (animate) { + start_animation(image_layout.id); + } + image_layout.is_downloaded = attachment.preview_downloaded; items.add(image_layout); @@ -191,3 +265,30 @@ private class TranscriptDrawingArea : Widget 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; + + 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() { + progress = ease_out_quart((frame_clock.get_frame_time() - start_time) / (duration * 1000000.0)); + } + + private static double ease_out_quart(double t) { + return 1.0 - Math.pow(1.0 - t, 4); + } +} \ No newline at end of file