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 pending_uploads; private HashMap attachment_previews; private ArrayList 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 attachment_guids { owned get { var attachment_guids = new ArrayList(); 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(); attachment_previews = new HashMap(); completed_attachments = new ArrayList(); // 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; } }