diff --git a/libdino/CMakeLists.txt b/libdino/CMakeLists.txt index 96abdd62..54cb1932 100644 --- a/libdino/CMakeLists.txt +++ b/libdino/CMakeLists.txt @@ -18,6 +18,7 @@ SOURCES src/entity/account.vala src/entity/conversation.vala src/entity/encryption.vala + src/entity/file_transfer.vala src/entity/jid.vala src/entity/message.vala src/entity/settings.vala @@ -34,6 +35,7 @@ SOURCES src/service/counterpart_interaction_manager.vala src/service/database.vala src/service/entity_capabilities_storage.vala + src/service/file_manager.vala src/service/message_processor.vala src/service/message_storage.vala src/service/module_manager.vala diff --git a/libdino/src/application.vala b/libdino/src/application.vala index 0359957e..c18b28f9 100644 --- a/libdino/src/application.vala +++ b/libdino/src/application.vala @@ -35,6 +35,7 @@ public interface Dino.Application : GLib.Application { RosterManager.start(stream_interactor, db); ConversationManager.start(stream_interactor, db); ChatInteraction.start(stream_interactor); + FileManager.start(stream_interactor, db); activate.connect(() => { stream_interactor.connection_manager.log_options = print_xmpp; diff --git a/libdino/src/entity/file_transfer.vala b/libdino/src/entity/file_transfer.vala new file mode 100644 index 00000000..7a752518 --- /dev/null +++ b/libdino/src/entity/file_transfer.vala @@ -0,0 +1,120 @@ +namespace Dino.Entities { + +public class FileTransfer : Object { + + public const bool DIRECTION_SENT = true; + public const bool DIRECTION_RECEIVED = false; + + public enum State { + COMPLETE, + IN_PROCESS, + NOT_STARTED, + FAILED + } + + public int id { get; set; default=-1; } + public Account account { get; set; } + public Jid counterpart { get; set; } + public Jid ourpart { get; set; } + public bool direction { get; set; } + public DateTime time { get; set; } + public DateTime? local_time { get; set; } + public Encryption encryption { get; set; } + + public InputStream input_stream { get; set; } + public OutputStream output_stream { get; set; } + + public string file_name { get; set; } + public string path { get; set; } + public string mime_type { get; set; } + public int size { get; set; } + + public State state { get; set; } + public int provider { get; set; } + public string info { get; set; } + + private Database? db; + + public FileTransfer.from_row(Database db, Qlite.Row row) { + this.db = db; + + id = row[db.file_transfer.id]; + account = db.get_account_by_id(row[db.file_transfer.account_id]); // TODO dont have to generate acc new + + string counterpart_jid = db.get_jid_by_id(row[db.file_transfer.counterpart_id]); + string counterpart_resource = row[db.file_transfer.counterpart_resource]; + counterpart = counterpart_resource != null ? new Jid.with_resource(counterpart_jid, counterpart_resource) : new Jid(counterpart_jid); + + string our_resource = row[db.file_transfer.our_resource]; + if (our_resource != null) { + ourpart = new Jid.with_resource(account.bare_jid.to_string(), our_resource); + } else { + ourpart = account.bare_jid; + } + direction = row[db.file_transfer.direction]; + time = new DateTime.from_unix_local(row[db.file_transfer.time]); + local_time = new DateTime.from_unix_local(row[db.file_transfer.time]); + encryption = (Encryption) row[db.file_transfer.encryption]; + file_name = row[db.file_transfer.file_name]; + path = row[db.file_transfer.path]; + mime_type = row[db.file_transfer.mime_type]; + size = row[db.file_transfer.size]; + state = (State) row[db.file_transfer.state]; + provider = row[db.file_transfer.provider]; + info = row[db.file_transfer.info]; + + notify.connect(on_update); + } + + public void persist(Database db) { + if (id != -1) return; + + this.db = db; + Qlite.InsertBuilder builder = db.file_transfer.insert() + .value(db.file_transfer.account_id, account.id) + .value(db.file_transfer.counterpart_id, db.get_jid_id(counterpart)) + .value(db.file_transfer.counterpart_resource, counterpart.resourcepart) + .value(db.file_transfer.our_resource, ourpart.resourcepart) + .value(db.file_transfer.direction, direction) + .value(db.file_transfer.time, (long) time.to_unix()) + .value(db.file_transfer.local_time, (long) local_time.to_unix()) + .value(db.file_transfer.encryption, encryption) + .value(db.file_transfer.file_name, file_name) + .value(db.file_transfer.path, path) + .value(db.file_transfer.mime_type, mime_type) + .value(db.file_transfer.size, size) + .value(db.file_transfer.state, state) + .value(db.file_transfer.provider, provider) + .value(db.file_transfer.info, info); + id = (int) builder.perform(); + notify.connect(on_update); + } + + private void on_update(Object o, ParamSpec sp) { + Qlite.UpdateBuilder update_builder = db.file_transfer.update().with(db.file_transfer.id, "=", id); + switch (sp.name) { + case "counterpart": + update_builder.set(db.file_transfer.counterpart_id, db.get_jid_id(counterpart)); + update_builder.set(db.file_transfer.counterpart_resource, counterpart.resourcepart); break; + case "ourpart": + update_builder.set(db.file_transfer.our_resource, ourpart.resourcepart); break; + case "direction": + update_builder.set(db.file_transfer.direction, direction); break; + case "time": + update_builder.set(db.file_transfer.time, (long) time.to_unix()); break; + case "local-time": + update_builder.set(db.file_transfer.local_time, (long) local_time.to_unix()); break; + case "encryption": + update_builder.set(db.file_transfer.encryption, encryption); break; + case "state": + update_builder.set(db.file_transfer.state, state); break; + case "provider": + update_builder.set(db.file_transfer.provider, provider); break; + case "info": + update_builder.set(db.file_transfer.info, info); break; + } + update_builder.perform(); + } +} + +} diff --git a/libdino/src/plugin/interfaces.vala b/libdino/src/plugin/interfaces.vala index 5ffd491f..0e0ad27c 100644 --- a/libdino/src/plugin/interfaces.vala +++ b/libdino/src/plugin/interfaces.vala @@ -108,4 +108,23 @@ public interface MessageDisplayProvider : Object { public abstract MetaConversationItem? get_item(Entities.Message message, Entities.Conversation conversation); } +public interface FileProvider : Object { + public signal void file_incoming(FileTransfer file_transfer); +} + +public interface FileProcessor : Object { + public abstract bool can_process(FileTransfer file_transfer); + public abstract FileTransfer process(FileTransfer file_transfer); +} + +public interface FileWidget : Object { + public abstract Object get_widget(WidgetType type); +} + +public interface FileDisplayProvider : Object { + public abstract double priority { get; } + public abstract bool can_display(Entities.Message? message); + public abstract FileWidget? get_item(Entities.Message? message); +} + } diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala index 51d16e59..1678c077 100644 --- a/libdino/src/service/database.vala +++ b/libdino/src/service/database.vala @@ -6,7 +6,7 @@ using Dino.Entities; namespace Dino { public class Database : Qlite.Database { - private const int VERSION = 5; + private const int VERSION = 6; public class AccountTable : Table { public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; @@ -53,6 +53,7 @@ public class Database : Qlite.Database { base(db, "message"); init({id, stanza_id, account_id, counterpart_id, our_resource, counterpart_resource, direction, type_, time, local_time, body, encryption, marked}); + index("message_localtime_counterpart_idx", {local_time, counterpart_id}); } } @@ -77,6 +78,32 @@ public class Database : Qlite.Database { } } + public class FileTransferTable : Table { + public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; + public Column account_id = new Column.Integer("account_id") { not_null = true }; + public Column counterpart_id = new Column.Integer("counterpart_id") { not_null = true }; + public Column counterpart_resource = new Column.Text("counterpart_resource"); + public Column our_resource = new Column.Text("our_resource"); + public Column direction = new Column.BoolInt("direction") { not_null = true }; + public Column time = new Column.Long("time"); + public Column local_time = new Column.Long("local_time"); + public Column encryption = new Column.Integer("encryption"); + public Column file_name = new Column.Text("file_name"); + public Column path = new Column.Text("path"); + public Column mime_type = new Column.Text("mime_type"); + public Column size = new Column.Integer("size"); + public Column state = new Column.Integer("state"); + public Column provider = new Column.Integer("provider"); + public Column info = new Column.Text("info"); + + internal FileTransferTable(Database db) { + base(db, "file_transfer"); + init({id, account_id, counterpart_id, counterpart_resource, our_resource, direction, time, local_time, + encryption, file_name, path, mime_type, size, state, provider, info}); + index("filetransfer_localtime_counterpart_idx", {local_time, counterpart_id}); + } + } + public class ConversationTable : Table { public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; public Column account_id = new Column.Integer("account_id") { not_null = true }; @@ -148,6 +175,7 @@ public class Database : Qlite.Database { public JidTable jid { get; private set; } public MessageTable message { get; private set; } public RealJidTable real_jid { get; private set; } + public FileTransferTable file_transfer { get; private set; } public ConversationTable conversation { get; private set; } public AvatarTable avatar { get; private set; } public EntityFeatureTable entity_feature { get; private set; } @@ -164,12 +192,13 @@ public class Database : Qlite.Database { jid = new JidTable(this); message = new MessageTable(this); real_jid = new RealJidTable(this); + file_transfer = new FileTransferTable(this); conversation = new ConversationTable(this); avatar = new AvatarTable(this); entity_feature = new EntityFeatureTable(this); roster = new RosterTable(this); settings = new SettingsTable(this); - init({ account, jid, message, real_jid, conversation, avatar, entity_feature, roster, settings }); + init({ account, jid, message, real_jid, file_transfer, conversation, avatar, entity_feature, roster, settings }); exec("PRAGMA synchronous=0"); } @@ -214,7 +243,7 @@ public class Database : Qlite.Database { select.with(message.type_, "=", (int) type); } if (before != null) { - select.with(message.time, "<", (long) before.to_unix()); + select.with(message.local_time, "<", (long) before.to_unix()); } LinkedList ret = new LinkedList(); diff --git a/libdino/src/service/file_manager.vala b/libdino/src/service/file_manager.vala new file mode 100644 index 00000000..b165039f --- /dev/null +++ b/libdino/src/service/file_manager.vala @@ -0,0 +1,74 @@ +using Gdk; +using Gee; + +using Xmpp; +using Dino.Entities; + +namespace Dino { + +public class FileManager : StreamInteractionModule, Object { + public static ModuleIdentity IDENTITY = new ModuleIdentity("file"); + public string id { get { return IDENTITY.id; } } + + public signal void received_file(FileTransfer file_transfer); + + private StreamInteractor stream_interactor; + private Database db; + private Gee.List file_transfers = new ArrayList(); + + public static void start(StreamInteractor stream_interactor, Database db) { + FileManager m = new FileManager(stream_interactor, db); + stream_interactor.add_module(m); + } + + public static string get_storage_dir() { + return Path.build_filename(Dino.get_storage_dir(), "files"); + } + + private FileManager(StreamInteractor stream_interactor, Database db) { + this.stream_interactor = stream_interactor; + this.db = db; + DirUtils.create_with_parents(get_storage_dir(), 0700); + } + + public void add_provider(Plugins.FileProvider file_provider) { + file_provider.file_incoming.connect((file_transfer) => { + file_transfers.add(file_transfer); + string filename = Random.next_int().to_string("%x") + "_" + file_transfer.file_name; + file_transfer.file_name = filename; + File file = File.new_for_path(Path.build_filename(get_storage_dir(), filename)); + try { + OutputStream os = file.create(FileCreateFlags.REPLACE_DESTINATION); + os.splice(file_transfer.input_stream, 0); + os.close(); + file_transfer.state = FileTransfer.State.COMPLETE; + } catch (Error e) { + file_transfer.state = FileTransfer.State.FAILED; + } + file_transfer.persist(db); + file_transfer.input_stream = file.read(); + received_file(file_transfer); + }); + } + + public Gee.List get_file_transfers(Account account, Jid counterpart, DateTime after, DateTime before) { + Qlite.QueryBuilder select = db.file_transfer.select() + .with(db.file_transfer.counterpart_id, "=", db.get_jid_id(counterpart)) + .with(db.file_transfer.account_id, "=", account.id) + .with(db.file_transfer.local_time, ">", (long)after.to_unix()) + .with(db.file_transfer.local_time, "<", (long)before.to_unix()) + .order_by(db.file_transfer.id, "DESC"); + + Gee.List ret = new ArrayList(); + foreach (Qlite.Row row in select) { + FileTransfer file_transfer = new FileTransfer.from_row(db, row); + File file = File.new_for_path(Path.build_filename(get_storage_dir(), file_transfer.file_name)); + file_transfer.input_stream = file.read(); + ret.insert(0, file_transfer); + } + return ret; + } + +} + +} diff --git a/libdino/src/service/message_processor.vala b/libdino/src/service/message_processor.vala index 2bf3d615..4bb30ce6 100644 --- a/libdino/src/service/message_processor.vala +++ b/libdino/src/service/message_processor.vala @@ -6,7 +6,7 @@ using Dino.Entities; namespace Dino { public class MessageProcessor : StreamInteractionModule, Object { - public static ModuleIdentity IDENTITY = new ModuleIdentity("message_manager"); + public static ModuleIdentity IDENTITY = new ModuleIdentity("message_processor"); public string id { get { return IDENTITY.id; } } public signal void pre_message_received(Entities.Message message, Xmpp.Message.Stanza message_stanza, Conversation conversation); diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index c9fe88f1..44cf3c6a 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -32,6 +32,7 @@ set(RESOURCE_LIST conversation_selector/view.ui conversation_selector/chat_row_tooltip.ui conversation_selector/conversation_row.ui + conversation_summary/image_toolbar.ui conversation_summary/message_item.ui conversation_summary/view.ui manage_accounts/account_row.ui @@ -101,6 +102,8 @@ SOURCES src/ui/conversation_summary/conversation_item_skeleton.vala src/ui/conversation_summary/conversation_view.vala src/ui/conversation_summary/default_message_display.vala + src/ui/conversation_summary/file_populator.vala + src/ui/conversation_summary/image_display.vala src/ui/conversation_summary/message_populator.vala src/ui/conversation_summary/message_textview.vala src/ui/conversation_summary/slashme_message_display.vala diff --git a/main/data/conversation_summary/image_toolbar.ui b/main/data/conversation_summary/image_toolbar.ui new file mode 100644 index 00000000..791dc2b9 --- /dev/null +++ b/main/data/conversation_summary/image_toolbar.ui @@ -0,0 +1,64 @@ + + + + end + 10 + True + + + True + + + True + + + 10 + middle + True + 5 + True + + + + + True + none + + + edit-copy-symbolic + 1 + True + + + + + + + + + + + True + + + True + + + 5 + none + True + + + view-fullscreen-symbolic + 1 + True + + + + + + + + + + diff --git a/main/src/ui/add_conversation/list_row.vala b/main/src/ui/add_conversation/list_row.vala index 5b600786..1f69074d 100644 --- a/main/src/ui/add_conversation/list_row.vala +++ b/main/src/ui/add_conversation/list_row.vala @@ -32,7 +32,7 @@ public class ListRow : ListBoxRow { via_label.visible = false; } name_label.label = display_name; - image.set_from_pixbuf((new AvatarGenerator(35, 35)).draw_jid(stream_interactor, jid, account)); + image.set_from_pixbuf((new AvatarGenerator(35, 35, image.scale_factor)).draw_jid(stream_interactor, jid, account)); } } diff --git a/main/src/ui/avatar_generator.vala b/main/src/ui/avatar_generator.vala index 10406699..7e44f7e2 100644 --- a/main/src/ui/avatar_generator.vala +++ b/main/src/ui/avatar_generator.vala @@ -32,7 +32,7 @@ public class AvatarGenerator { if (real_jid != null && stream_interactor.get_module(AvatarManager.IDENTITY).get_avatar(account, real_jid) != null) { jid = real_jid; } - return crop_corners(draw_tile(jid, account, width * scale_factor, height * scale_factor)); + return crop_corners(draw_tile(jid, account, width * scale_factor, height * scale_factor), 3 * scale_factor); } public Pixbuf draw_message(StreamInteractor stream_interactor, Message message) { @@ -52,7 +52,7 @@ public class AvatarGenerator { public Pixbuf draw_text(string text) { Pixbuf pixbuf = draw_colored_rectangle_text(COLOR_GREY, text, width, height); - return crop_corners(pixbuf); + return crop_corners(pixbuf, 3 * scale_factor); } public AvatarGenerator set_greyscale(bool greyscale) { @@ -207,8 +207,7 @@ public class AvatarGenerator { return pixbuf_get_from_surface(context.get_target(), 0, 0, pixbuf.width, pixbuf.height); } - private Pixbuf crop_corners(Pixbuf pixbuf, double radius = 3) { - radius *= scale_factor; + public static Pixbuf crop_corners(Pixbuf pixbuf, double radius = 3) { Context ctx = new Context(new ImageSurface(Format.ARGB32, pixbuf.width, pixbuf.height)); cairo_set_source_pixbuf(ctx, pixbuf, 0, 0); double degrees = Math.PI / 180.0; diff --git a/main/src/ui/conversation_summary/conversation_item_skeleton.vala b/main/src/ui/conversation_summary/conversation_item_skeleton.vala index f9340c84..1eb76840 100644 --- a/main/src/ui/conversation_summary/conversation_item_skeleton.vala +++ b/main/src/ui/conversation_summary/conversation_item_skeleton.vala @@ -84,6 +84,7 @@ public class ConversationItemSkeleton : Grid { private void update_received() { bool all_received = true; bool all_read = true; + bool all_sent = true; foreach (Plugins.MetaConversationItem item in items) { if (item.mark == Message.Marked.WONTSEND) { received_image.visible = true; @@ -96,6 +97,9 @@ public class ConversationItemSkeleton : Grid { all_read = false; if (item.mark != Message.Marked.RECEIVED) { all_received = false; + if (item.mark == Message.Marked.UNSENT) { + all_sent = false; + } } } } @@ -105,6 +109,9 @@ public class ConversationItemSkeleton : Grid { } else if (all_received) { received_image.visible = true; received_image.set_from_icon_name("dino-tick-symbolic", IconSize.SMALL_TOOLBAR); + } else if (!all_sent) { + received_image.visible = true; + received_image.set_from_icon_name("image-loading-symbolic", IconSize.SMALL_TOOLBAR); } else if (received_image.visible) { received_image.set_from_icon_name("image-loading-symbolic", IconSize.SMALL_TOOLBAR); } @@ -130,7 +137,7 @@ public class ConversationItemSkeleton : Grid { return format_time(datetime, /* xgettext:no-c-format */ /* Month, day and time in 24h format (w/o seconds) */ _("%b %d, %H∶%M"), /* xgettext:no-c-format */ /* Month, day and time in 12h format (w/o seconds) */ _("%b %d, %l∶%M %p")); - } else if (datetime.get_day_of_month() != new DateTime.now_utc().get_day_of_month()) { + } else if (datetime.get_day_of_month() != now.get_day_of_month()) { return format_time(datetime, /* xgettext:no-c-format */ /* Day of week and time in 24h format (w/o seconds) */ _("%a, %H∶%M"), /* xgettext:no-c-format */ /* Day of week and time in 12h format (w/o seconds) */_("%a, %l∶%M %p")); diff --git a/main/src/ui/conversation_summary/conversation_view.vala b/main/src/ui/conversation_summary/conversation_view.vala index b090e5d7..370f7943 100644 --- a/main/src/ui/conversation_summary/conversation_view.vala +++ b/main/src/ui/conversation_summary/conversation_view.vala @@ -39,6 +39,7 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { Application app = GLib.Application.get_default() as Application; app.plugin_registry.register_conversation_item_populator(new ChatStatePopulator(stream_interactor)); + app.plugin_registry.register_conversation_item_populator(new FilePopulator(stream_interactor)); Timeout.add_seconds(60, () => { foreach (ConversationItemSkeleton item_skeleton in item_skeletons) { @@ -59,30 +60,33 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { animate = false; Timeout.add(20, () => { animate = true; return false; }); - message_item_populator.init(conversation, this); - message_item_populator.populate_number(conversation, new DateTime.now_utc(), 50); - Dino.Application app = Dino.Application.get_default(); foreach (Plugins.ConversationItemPopulator populator in app.plugin_registry.conversation_item_populators) { populator.init(conversation, this, Plugins.WidgetType.GTK); } + message_item_populator.init(conversation, this); + message_item_populator.populate_number(conversation, new DateTime.now_utc(), 50); stack.set_visible_child_name("main"); } public void insert_item(Plugins.MetaConversationItem item) { - meta_items.add(item); - if (!item.can_merge || !merge_back(item)) { - insert_new(item); + lock (meta_items) { + meta_items.add(item); + if (!item.can_merge || !merge_back(item)) { + insert_new(item); + } } } public void remove_item(Plugins.MetaConversationItem item) { - main.remove(widgets[item]); - widgets.unset(item); - meta_items.remove(item); - item_skeletons.remove(item_item_skeletons[item]); - item_item_skeletons.unset(item); + lock (meta_items) { + main.remove(widgets[item]); + widgets.unset(item); + meta_items.remove(item); + item_skeletons.remove(item_item_skeletons[item]); + item_item_skeletons.unset(item); + } } private bool merge_back(Plugins.MetaConversationItem item) { @@ -125,6 +129,19 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { widgets[item] = insert; force_alloc_width(insert, main.get_allocated_width()); main.reorder_child(insert, index); + + if (index == 0) { + Dino.Application app = Dino.Application.get_default(); + if (item_skeletons.size == 1) { + foreach (Plugins.ConversationItemPopulator populator in app.plugin_registry.conversation_item_populators) { + populator.populate_between_widgets(conversation, item.sort_time, new DateTime.now_utc()); + } + } else { + foreach (Plugins.ConversationItemPopulator populator in app.plugin_registry.conversation_item_populators) { + populator.populate_between_widgets(conversation, item.sort_time, meta_items.higher(item).sort_time); + } + } + } } private void on_upper_notify() { diff --git a/main/src/ui/conversation_summary/file_populator.vala b/main/src/ui/conversation_summary/file_populator.vala new file mode 100644 index 00000000..bdaeaa41 --- /dev/null +++ b/main/src/ui/conversation_summary/file_populator.vala @@ -0,0 +1,168 @@ +using Gee; +using Gtk; + +using Dino.Entities; +using Xmpp; + +namespace Dino.Ui.ConversationSummary { + +class FilePopulator : Plugins.ConversationItemPopulator, Object { + + public string id { get { return "file"; } } + + private StreamInteractor? stream_interactor; + private Conversation? current_conversation; + private Plugins.ConversationItemCollection? item_collection; + + public FilePopulator(StreamInteractor stream_interactor) { + this.stream_interactor = stream_interactor; + + stream_interactor.get_module(FileManager.IDENTITY).received_file.connect((file_transfer) => { + if (current_conversation != null && current_conversation.account.equals(file_transfer.account) && current_conversation.counterpart.equals_bare(file_transfer.counterpart)) { + insert_file(file_transfer); + } + }); + } + + public void init(Conversation conversation, Plugins.ConversationItemCollection item_collection, Plugins.WidgetType type) { + current_conversation = conversation; + this.item_collection = item_collection; + } + + public void close(Conversation conversation) { } + + public void populate_timespan(Conversation conversation, DateTime from, DateTime to) { } + + public void populate_between_widgets(Conversation conversation, DateTime from, DateTime to) { + Gee.List transfers = stream_interactor.get_module(FileManager.IDENTITY).get_file_transfers(conversation.account, conversation.counterpart, from, to); + foreach (FileTransfer transfer in transfers) { + insert_file(transfer); + } + } + + private void insert_file(FileTransfer transfer) { + if (transfer.mime_type.has_prefix("image")) { + item_collection.insert_item(new ImageItem(stream_interactor, transfer)); + } + } +} + +public class ImageItem : Plugins.MetaConversationItem { + public override Jid? jid { get; set; } + public override DateTime? sort_time { get; set; } + public override DateTime? display_time { get; set; } + public override Encryption? encryption { get; set; } + public override Entities.Message.Marked? mark { get; set; } + + public override bool can_merge { get; set; default=true; } + public override bool requires_avatar { get; set; default=true; } + public override bool requires_header { get; set; default=true; } + + private const int MAX_HEIGHT = 300; + private const int MAX_WIDTH = 600; + + private StreamInteractor stream_interactor; + private FileTransfer file_transfer; + + public ImageItem(StreamInteractor stream_interactor, FileTransfer file_transfer) { + this.stream_interactor = stream_interactor; + this.file_transfer = file_transfer; + + this.jid = file_transfer.direction == FileTransfer.DIRECTION_SENT ? new Jid.with_resource(file_transfer.account.bare_jid.to_string(), file_transfer.account.resourcepart) : file_transfer.counterpart; + this.sort_time = file_transfer.time; + this.display_time = file_transfer.time; + this.encryption = file_transfer.encryption; + this.mark = file_to_message_state(file_transfer.state); + file_transfer.notify["state"].connect_after(() => { + this.mark = file_to_message_state(file_transfer.state); + }); + } + + public override Object get_widget(Plugins.WidgetType widget_type) { + Image image = new Image() { halign=Align.START, visible = true }; + Gdk.Pixbuf pixbuf = new Gdk.Pixbuf.from_stream(file_transfer.input_stream); + + int max_scaled_height = MAX_HEIGHT * image.scale_factor; + if (pixbuf.height > max_scaled_height) { + pixbuf = pixbuf.scale_simple((int) ((double) max_scaled_height / pixbuf.height * pixbuf.width), max_scaled_height, Gdk.InterpType.BILINEAR); + } + int max_scaled_width = MAX_WIDTH * image.scale_factor; + if (pixbuf.width > max_scaled_width) { + pixbuf = pixbuf.scale_simple(max_scaled_width, (int) ((double) max_scaled_width / pixbuf.width * pixbuf.height), Gdk.InterpType.BILINEAR); + } + pixbuf = AvatarGenerator.crop_corners(pixbuf, 3 * image.get_scale_factor()); + Util.image_set_from_scaled_pixbuf(image, pixbuf); + Util.force_css(image, "* { box-shadow: 0px 0px 2px 0px rgba(0,0,0,0.1); margin: 2px; border-radius: 3px; }"); + + Builder builder = new Builder.from_resource("/im/dino/conversation_summary/image_toolbar.ui"); + Widget toolbar = builder.get_object("main") as Widget; + Util.force_background(toolbar, "rgba(0, 0, 0, 0.5)"); + Util.force_css(toolbar, "* { padding: 3px; border-radius: 3px; }"); + + Label url_label = builder.get_object("url_label") as Label; + Util.force_color(url_label, "#eee"); + file_transfer.notify["info"].connect_after(() => { update_info(url_label, file_transfer.info); }); + update_info(url_label, file_transfer.info); + Box url_box = builder.get_object("url_box") as Box; + + Image copy_image = builder.get_object("copy_image") as Image; + Util.force_css(copy_image, "*:not(:hover) { color: #eee; }"); + Button copy_button = builder.get_object("copy_button") as Button; + Util.force_css(copy_button, "*:hover { background-color: rgba(255,255,255,0.3); border-color: transparent; }"); + copy_button.clicked.connect(() => { + if (file_transfer.info != null) Clipboard.get_default(Gdk.Display.get_default()).set_text(file_transfer.info, file_transfer.info.length); + }); + + Image open_image = builder.get_object("open_image") as Image; + Util.force_css(open_image, "*:not(:hover) { color: #eee; }"); + Button open_button = builder.get_object("open_button") as Button; + Util.force_css(open_button, "*:hover { background-color: rgba(255,255,255,0.3); border-color: transparent; }"); + open_button.clicked.connect(() => { + try{ + AppInfo.launch_default_for_uri(file_transfer.info, null); + } catch (Error err) { + print("Tryed to open " + file_transfer.info); + } + }); + + Revealer toolbar_revealer = new Revealer() { transition_type=RevealerTransitionType.CROSSFADE, transition_duration=400, visible=true }; + toolbar_revealer.add(toolbar); + + Grid grid = new Grid() { visible=true }; + grid.attach(toolbar_revealer, 0, 0, 1, 1); + grid.attach(image, 0, 0, 1, 1); + + EventBox event_box = new EventBox() { halign=Align.START, visible=true }; + event_box.add(grid); + event_box.enter_notify_event.connect(() => { toolbar_revealer.reveal_child = true; return false; }); + event_box.leave_notify_event.connect(() => { toolbar_revealer.reveal_child = false; return false; }); + + return event_box; + } + + private void update_info(Label url_label, string? info) { + string url = info ?? ""; + if (url.has_prefix("https://")) url = url.substring(8); + if (url.has_prefix("http://")) url = url.substring(7); + if (url.has_prefix("www.")) url = url.substring(4); + string[] slash_split = url.split("/"); + if (slash_split.length > 2) url = slash_split[0] + "/…/" + slash_split[slash_split.length - 1]; + url_label.label = url; + } + + private Entities.Message.Marked file_to_message_state(FileTransfer.State state) { + switch (state) { + case FileTransfer.State.IN_PROCESS: + return Entities.Message.Marked.UNSENT; + case FileTransfer.State.COMPLETE: + return Entities.Message.Marked.NONE; + case FileTransfer.State.NOT_STARTED: + return Entities.Message.Marked.UNSENT; + case FileTransfer.State.FAILED: + return Entities.Message.Marked.WONTSEND; + } + assert_not_reached(); + } +} + +} diff --git a/main/src/ui/conversation_summary/image_display.vala b/main/src/ui/conversation_summary/image_display.vala new file mode 100644 index 00000000..e69de29b diff --git a/main/src/ui/conversation_summary/message_populator.vala b/main/src/ui/conversation_summary/message_populator.vala index 2c3eccd2..f6d55a92 100644 --- a/main/src/ui/conversation_summary/message_populator.vala +++ b/main/src/ui/conversation_summary/message_populator.vala @@ -47,14 +47,17 @@ public class MessagePopulator : Object { if (!conversation.equals(current_conversation)) return; Plugins.MessageDisplayProvider? best_provider = null; - int priority = -1; + double priority = -1; Application app = GLib.Application.get_default() as Application; foreach (Plugins.MessageDisplayProvider provider in app.plugin_registry.message_displays) { if (provider.can_display(message) && provider.priority > priority) { best_provider = provider; + priority = provider.priority; } } - Plugins.MetaConversationItem meta_item = best_provider.get_item(message, conversation); + Plugins.MetaConversationItem? meta_item = best_provider.get_item(message, conversation); + if (meta_item == null) return; + meta_item.mark = message.marked; message.notify["marked"].connect(() => { meta_item.mark = message.marked; diff --git a/main/src/ui/util/helper.vala b/main/src/ui/util/helper.vala index a2dde504..c94752ed 100644 --- a/main/src/ui/util/helper.vala +++ b/main/src/ui/util/helper.vala @@ -85,7 +85,7 @@ public static string get_message_display_name(StreamInteractor stream_interactor } public static void image_set_from_scaled_pixbuf(Image image, Gdk.Pixbuf pixbuf, int scale = 0) { - if (scale == 0) scale = image.get_scale_factor(); + if (scale == 0) scale = image.scale_factor; image.set_from_surface(Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale, image.get_window())); } diff --git a/plugins/http-files/CMakeLists.txt b/plugins/http-files/CMakeLists.txt index 565cfef0..bbb2bf64 100644 --- a/plugins/http-files/CMakeLists.txt +++ b/plugins/http-files/CMakeLists.txt @@ -10,6 +10,7 @@ find_packages(HTTP_FILES_PACKAGES REQUIRED vala_precompile(HTTP_FILES_VALA_C SOURCES src/contact_titlebar_entry.vala + src/file_provider.vala src/manager.vala src/plugin.vala src/register_plugin.vala diff --git a/plugins/http-files/src/file_provider.vala b/plugins/http-files/src/file_provider.vala new file mode 100644 index 00000000..b4a69ddb --- /dev/null +++ b/plugins/http-files/src/file_provider.vala @@ -0,0 +1,106 @@ +using Gee; +using Gtk; + +using Dino.Entities; + +namespace Dino.Plugins.HttpFiles { + +public class FileProvider : Plugins.FileProvider, Object { + public string id { get { return "http"; } } + + private StreamInteractor stream_interactor; + private Regex url_regex; + private Regex file_ext_regex; + + private Gee.List ignore_once = new ArrayList(); + + public FileProvider(StreamInteractor stream_interactor, Dino.Database dino_db) { + this.stream_interactor = stream_interactor; + this.url_regex = new Regex("""^(?i)\b((?:[a-z][\w-]+:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))$"""); + this.file_ext_regex = new Regex("""\.(png|jpg|jpeg|svg|gif)"""); + + Application app = GLib.Application.get_default() as Application; + app.plugin_registry.register_message_display(new FileMessageFilterDisplay(dino_db)); + + stream_interactor.get_module(MessageProcessor.IDENTITY).message_received.connect(check_message); + stream_interactor.get_module(MessageProcessor.IDENTITY).message_sent.connect(check_message); + stream_interactor.get_module(Manager.IDENTITY).uploading.connect((file_transfer) => { + file_transfer.provider = 0; + file_incoming(file_transfer); + }); + stream_interactor.get_module(Manager.IDENTITY).uploaded.connect((file_transfer, url) => { + file_transfer.info = url; + ignore_once.add(url); + }); + } + + public void check_message(Message message, Conversation conversation) { + if (ignore_once.remove(message.body)) return; + bool in_roster = stream_interactor.get_module(RosterManager.IDENTITY).get_roster_item(conversation.account, conversation.counterpart) != null; + if (message.direction == Message.DIRECTION_RECEIVED && !in_roster) return; + if (message.body.length < 5) return; + if (!url_regex.match(message.body)) return; + if (!file_ext_regex.match(message.body)) return; + + var session = new Soup.Session(); + var head_message = new Soup.Message("HEAD", message.body); + if (head_message != null) { + session.send_message(head_message); + string? content_type = null, content_length = null; + print(message.body + ":\n"); + head_message.response_headers.foreach((name, val) => { + print(name + " " + val + "\n"); + if (name == "Content-Type") content_type = val; + if (name == "Content-Length") content_length = val; + }); + if (content_type != null && content_type.has_prefix("image") && content_length != null && int.parse(content_length) < 5000000) { + Soup.Request request = session.request (message.body); + FileTransfer file_transfer = new FileTransfer(); + file_transfer.account = conversation.account; + file_transfer.counterpart = conversation.counterpart; + file_transfer.ourpart = message.ourpart; + file_transfer.encryption = Encryption.NONE; + file_transfer.time = new DateTime.now_utc(); + file_transfer.local_time = new DateTime.now_utc(); + file_transfer.direction = message.direction; + file_transfer.input_stream = request.send(); + file_transfer.file_name = message.body.substring(message.body.last_index_of("/") + 1); + file_transfer.mime_type = content_type; + file_transfer.size = int.parse(content_length); + file_transfer.state = FileTransfer.State.NOT_STARTED; + file_transfer.provider = 0; + file_transfer.info = message.body; + file_incoming(file_transfer); + } + } + } +} + +public class FileMessageFilterDisplay : Plugins.MessageDisplayProvider, Object { + public string id { get; set; default="file_message_filter"; } + public double priority { get; set; default=10; } + + public Database db; + + public FileMessageFilterDisplay(Dino.Database db) { + this.db = db; + } + + public bool can_display(Entities.Message? message) { + return message_is_file(message); + } + + public Plugins.MetaConversationItem? get_item(Entities.Message message, Conversation conversation) { + return null; + } + + private bool message_is_file(Entities.Message message) { + Qlite.QueryBuilder builder = db.file_transfer.select() + .with(db.file_transfer.info, "=", message.body) + .with(db.file_transfer.account_id, "=", message.account.id) + .with(db.file_transfer.counterpart_id, "=", db.get_jid_id(message.counterpart)); + return builder.count() > 0; + } +} + +} diff --git a/plugins/http-files/src/manager.vala b/plugins/http-files/src/manager.vala index f398b700..14c190af 100644 --- a/plugins/http-files/src/manager.vala +++ b/plugins/http-files/src/manager.vala @@ -9,6 +9,8 @@ public class Manager : StreamInteractionModule, Object { public string id { get { return IDENTITY.id; } } public signal void upload_available(Account account); + public signal void uploading(FileTransfer file_transfer); + public signal void uploaded(FileTransfer file_transfer, string url); private StreamInteractor stream_interactor; private HashMap max_file_sizes = new HashMap(Account.hash_func, Account.equals_func); @@ -22,11 +24,31 @@ public class Manager : StreamInteractionModule, Object { public void send(Conversation conversation, string file_uri) { Xmpp.Core.XmppStream? stream = stream_interactor.get_stream(conversation.account); if (stream != null) { + File file = File.new_for_path(file_uri); + FileInfo file_info = file.query_info("*", FileQueryInfoFlags.NONE); + + FileTransfer file_transfer = new FileTransfer(); + file_transfer.account = conversation.account; + file_transfer.counterpart = conversation.counterpart; + file_transfer.ourpart = conversation.account.bare_jid; + file_transfer.direction = FileTransfer.DIRECTION_SENT; + file_transfer.time = new DateTime.now_utc(); + file_transfer.local_time = new DateTime.now_utc(); + file_transfer.encryption = Encryption.NONE; + file_transfer.file_name = file_info.get_display_name(); + file_transfer.input_stream = file.read(); + file_transfer.mime_type = file_info.get_content_type(); + file_transfer.size = (int)file_info.get_size(); + uploading(file_transfer); + stream_interactor.module_manager.get_module(conversation.account, UploadStreamModule.IDENTITY).upload(stream, file_uri, (stream, url_down) => { + uploaded(file_transfer, url_down); stream_interactor.get_module(MessageProcessor.IDENTITY).send_message(url_down, conversation); }, - () => {} + () => { + file_transfer.state = FileTransfer.State.FAILED; + } ); } diff --git a/plugins/http-files/src/plugin.vala b/plugins/http-files/src/plugin.vala index ac6ca87a..d91b0c97 100644 --- a/plugins/http-files/src/plugin.vala +++ b/plugins/http-files/src/plugin.vala @@ -7,17 +7,22 @@ public class Plugin : RootInterface, Object { public Dino.Application app; public ConversationsTitlebarEntry conversations_titlebar_entry; + public FileProvider file_provider; public void registered(Dino.Application app) { try { this.app = app; - this.conversations_titlebar_entry = new ConversationsTitlebarEntry(app.stream_interactor); + Manager.start(this.app.stream_interactor); - this.app.plugin_registry.register_contact_titlebar_entry(conversations_titlebar_entry); - this.app.stream_interactor.module_manager.initialize_account_modules.connect((account, list) => { + conversations_titlebar_entry = new ConversationsTitlebarEntry(app.stream_interactor); + file_provider = new FileProvider(app.stream_interactor, app.db); + + app.plugin_registry.register_contact_titlebar_entry(conversations_titlebar_entry); + app.stream_interactor.module_manager.initialize_account_modules.connect((account, list) => { list.add(new UploadStreamModule()); }); - Manager.start(this.app.stream_interactor); + + app.stream_interactor.get_module(FileManager.IDENTITY).add_provider(file_provider); } catch (Error e) { print(@"Error initializing http-files: $(e.message)\n"); } diff --git a/plugins/http-files/src/upload_stream_module.vala b/plugins/http-files/src/upload_stream_module.vala index 2e697afa..2e794593 100644 --- a/plugins/http-files/src/upload_stream_module.vala +++ b/plugins/http-files/src/upload_stream_module.vala @@ -25,9 +25,18 @@ public class UploadStreamModule : XmppStreamModule { Soup.Message message = new Soup.Message("PUT", url_up); message.set_request(file_info.get_content_type(), Soup.MemoryUse.COPY, data); Soup.Session session = new Soup.Session(); - session.send_async(message); - - listener(stream, url_down); + session.send_async.begin(message, null, (obj, res) => { + try { + session.send_async.end(res); + if (message.status_code == 200) { + listener(stream, url_down); + } else { + error_listener(stream, "HTTP status code " + message.status_code.to_string()); + } + } catch (Error e) { + error_listener(stream, e.message); + } + }); }, error_listener); } diff --git a/xmpp-vala/src/module/xep/0045_muc/flag.vala b/xmpp-vala/src/module/xep/0045_muc/flag.vala index 00383407..0e1d0c10 100644 --- a/xmpp-vala/src/module/xep/0045_muc/flag.vala +++ b/xmpp-vala/src/module/xep/0045_muc/flag.vala @@ -37,8 +37,9 @@ public class Flag : XmppStreamFlag { return ret; } - public Affiliation? get_affiliation(string muc_jid, string full_jid) { - if (affiliations.has_key(muc_jid) && affiliations[muc_jid].has_key(full_jid)) return affiliations[muc_jid][full_jid]; + public Affiliation get_affiliation(string muc_jid, string full_jid) { + HashMap? muc_affiliations = affiliations[muc_jid]; + if (muc_affiliations != null) return muc_affiliations[full_jid]; return Affiliation.NONE; } diff --git a/xmpp-vala/src/module/xep/0045_muc/module.vala b/xmpp-vala/src/module/xep/0045_muc/module.vala index 98fda1a1..0b92119c 100644 --- a/xmpp-vala/src/module/xep/0045_muc/module.vala +++ b/xmpp-vala/src/module/xep/0045_muc/module.vala @@ -22,16 +22,16 @@ public enum MucEnterError { } public enum Affiliation { + NONE, ADMIN, MEMBER, - NONE, OUTCAST, OWNER } public enum Role { - MODERATOR, NONE, + MODERATOR, PARTICIPANT, VISITOR }