diff --git a/data/com.github.bleakgrey.tootle.gschema.xml b/data/com.github.bleakgrey.tootle.gschema.xml index 518cfc4..df7caf4 100644 --- a/data/com.github.bleakgrey.tootle.gschema.xml +++ b/data/com.github.bleakgrey.tootle.gschema.xml @@ -46,5 +46,15 @@ Default character limit Change this if your instance supports more than 500 characters in posts + + '' + Watched Users + Comma separated list of usernames to notify you about + + + '' + Watched Hashtags + Comma separated list of hashtags to notify you about + diff --git a/meson.build b/meson.build index 07b906b..2f30861 100644 --- a/meson.build +++ b/meson.build @@ -26,6 +26,7 @@ executable( 'src/Accounts.vala', 'src/ImageCache.vala', 'src/Network.vala', + 'src/Watchlist.vala', 'src/Notificator.vala', 'src/InstanceAccount.vala', 'src/API/Account.vala', @@ -49,6 +50,7 @@ executable( 'src/Dialogs/NewAccountDialog.vala', 'src/Dialogs/PostDialog.vala', 'src/Dialogs/SettingsDialog.vala', + 'src/Dialogs/WatchlistDialog.vala', 'src/Views/AbstractView.vala', 'src/Views/TimelineView.vala', 'src/Views/HomeView.vala', diff --git a/src/API/Notification.vala b/src/API/Notification.vala index 0185f5e..50ad382 100644 --- a/src/API/Notification.vala +++ b/src/API/Notification.vala @@ -36,27 +36,30 @@ public class Tootle.Notification{ return notification; } - public Soup.Message dismiss () { + public Soup.Message? dismiss () { + if (type == NotificationType.WATCHLIST) + return null; + if (type == NotificationType.FOLLOW_REQUEST) return reject_follow_request (); - var url = "%s/api/v1/notifications/dismiss?id=%lld".printf (Tootle.accounts.formal.instance, id); + var url = "%s/api/v1/notifications/dismiss?id=%lld".printf (accounts.formal.instance, id); var msg = new Soup.Message("POST", url); - Tootle.network.queue(msg); + network.queue(msg); return msg; } public Soup.Message accept_follow_request () { - var url = "%s/api/v1/follow_requests/%lld/authorize".printf (Tootle.accounts.formal.instance, account.id); + var url = "%s/api/v1/follow_requests/%lld/authorize".printf (accounts.formal.instance, account.id); var msg = new Soup.Message("POST", url); - Tootle.network.queue(msg); + network.queue(msg); return msg; } public Soup.Message reject_follow_request () { - var url = "%s/api/v1/follow_requests/%lld/reject".printf (Tootle.accounts.formal.instance, account.id); + var url = "%s/api/v1/follow_requests/%lld/reject".printf (accounts.formal.instance, account.id); var msg = new Soup.Message("POST", url); - Tootle.network.queue(msg); + network.queue(msg); return msg; } diff --git a/src/API/NotificationType.vala b/src/API/NotificationType.vala index a712114..98436bf 100644 --- a/src/API/NotificationType.vala +++ b/src/API/NotificationType.vala @@ -3,7 +3,8 @@ public enum Tootle.NotificationType { REBLOG, FAVORITE, FOLLOW, - FOLLOW_REQUEST; + FOLLOW_REQUEST, + WATCHLIST; // Internal public string to_string() { switch (this) { @@ -17,6 +18,8 @@ public enum Tootle.NotificationType { return "follow"; case FOLLOW_REQUEST: return "follow_request"; + case WATCHLIST: + return "watchlist"; default: assert_not_reached(); } @@ -34,6 +37,8 @@ public enum Tootle.NotificationType { return FOLLOW; case "follow_request": return FOLLOW_REQUEST; + case "watchlist": + return WATCHLIST; default: assert_not_reached(); } @@ -51,6 +56,8 @@ public enum Tootle.NotificationType { return _("%s now follows you").printf (account.url, account.display_name); case FOLLOW_REQUEST: return _("%s wants to follow you").printf (account.url, account.display_name); + case WATCHLIST: + return _("%s posted a toot").printf (account.url, account.display_name); default: assert_not_reached(); } @@ -59,6 +66,7 @@ public enum Tootle.NotificationType { public string get_icon () { switch (this) { case MENTION: + case WATCHLIST: return "user-available-symbolic"; case REBLOG: return "media-playlist-repeat-symbolic"; diff --git a/src/Application.vala b/src/Application.vala index 28de23d..9b1215c 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -11,6 +11,7 @@ namespace Tootle{ public static Accounts accounts; public static Network network; public static ImageCache image_cache; + public static Watchlist watchlist; public class Application : Granite.Application { @@ -39,8 +40,9 @@ namespace Tootle{ accounts = new Accounts (); network = new Network (); image_cache = new ImageCache (); + watchlist = new Watchlist (); accounts.init (); - + app.error.connect (app.on_error); window_dummy = new Window (); diff --git a/src/Dialogs/SettingsDialog.vala b/src/Dialogs/SettingsDialog.vala index 254c2a3..dbd5036 100644 --- a/src/Dialogs/SettingsDialog.vala +++ b/src/Dialogs/SettingsDialog.vala @@ -89,7 +89,7 @@ public class Tootle.SettingsDialog : Gtk.Dialog { halign = Gtk.Align.START; valign = Gtk.Align.CENTER; margin_bottom = 6; - Tootle.settings.schema.bind (setting, this, "active", SettingsBindFlags.DEFAULT); + settings.schema.bind (setting, this, "active", SettingsBindFlags.DEFAULT); } } diff --git a/src/Dialogs/WatchlistDialog.vala b/src/Dialogs/WatchlistDialog.vala new file mode 100644 index 0000000..5137ad0 --- /dev/null +++ b/src/Dialogs/WatchlistDialog.vala @@ -0,0 +1,205 @@ +using Gtk; +using Tootle; + +public class Tootle.WatchlistDialog : Gtk.Window { + + private static WatchlistDialog dialog; + + private Gtk.HeaderBar header; + private Gtk.StackSwitcher switcher; + private Gtk.MenuButton button_add; + private Gtk.Stack stack; + private ListStack users; + private ListStack hashtags; + + private Gtk.Popover popover; + private Gtk.Grid popover_grid; + private Gtk.Entry popover_entry; + private Gtk.Button popover_button; + + private const string TIP_USERS = _("Youl'll be notified when toots from specific users appear in your Home timeline."); + private const string TIP_HASHTAGS = _("You'll be notified when toots with specific hashtags are posted in any public timelines."); + + private class ModelItem : GLib.Object { + public string name; + public bool is_hashtag; + + public ModelItem (string name, bool is_hashtag) { + this.name = name; + this.is_hashtag = is_hashtag; + } + } + + private class ModelView : Gtk.ListBoxRow { + private Gtk.Box box; + private Gtk.Button button_remove; + private Gtk.Label label; + private bool is_hashtag; + + public ModelView (ModelItem item) { + is_hashtag = item.is_hashtag; + box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0); + box.margin = 6; + label = new Gtk.Label (item.name); + label.vexpand = true; + label.valign = Gtk.Align.CENTER; + label.justify = Gtk.Justification.LEFT; + button_remove = new Gtk.Button.from_icon_name ("list-remove-symbolic", Gtk.IconSize.BUTTON); + button_remove.get_style_context ().add_class (Gtk.STYLE_CLASS_FLAT); + button_remove.clicked.connect (() => { + watchlist.remove (label.label, is_hashtag); + watchlist.save (); + destroy (); + }); + + box.pack_start (label, false, false, 0); + box.pack_end (button_remove, false, false, 0); + add (box); + show_all (); + } + } + + private class Model : GLib.ListModel, GLib.Object { + private GenericArray items = new GenericArray (); + + public GLib.Type get_item_type () { + return typeof (ModelItem); + } + + public uint get_n_items () { + return items.length; + } + + public GLib.Object? get_item (uint position) { + return items.@get ((int)position); + } + + public void append (ModelItem item) { + this.items.add (item); + } + + } + + public static Widget create_row (GLib.Object obj) { + var item = (ModelItem) obj; + return new ModelView (item); + } + + private class ListStack : Gtk.ScrolledWindow { + public Model model; + public Gtk.ListBox list; + private bool is_hashtags; + + public void update () { + if (is_hashtags) + watchlist.hashtags.@foreach (item => { + model.append (new ModelItem (item, true)); + }); + else + watchlist.users.@foreach (item => { + model.append (new ModelItem (item, false)); + }); + + list.bind_model (model, create_row); + } + + public ListStack (bool is_hashtags) { + this.is_hashtags = is_hashtags; + model = new Model (); + list = new Gtk.ListBox (); + add (list); + update (); + } + } + + private void set_tip () { + var is_user = stack.visible_child_name == "users"; + popover_entry.secondary_icon_tooltip_text = is_user ? TIP_USERS : TIP_HASHTAGS; + } + + public WatchlistDialog () { + deletable = true; + resizable = true; + transient_for = window; + + stack = new Gtk.Stack (); + stack.transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT; + stack.hexpand = true; + stack.vexpand = true; + + users = new ListStack (false); + hashtags = new ListStack (true); + + stack.add_titled (users, "users", _("Users")); + stack.add_titled (hashtags, "hashtags", _("Hashtags")); + stack.set_size_request (400, 300); + + popover_entry = new Gtk.Entry (); + popover_entry.hexpand = true; + popover_entry.secondary_icon_name = "dialog-information-symbolic"; + popover_entry.secondary_icon_activatable = false; + popover_entry.activate.connect (() => submit ()); + + popover_button = new Gtk.Button.with_label (_("Add")); + popover_button.halign = Gtk.Align.END; + popover_button.margin_left = 8; + popover_button.clicked.connect (() => submit ()); + + popover_grid = new Gtk.Grid (); + popover_grid.margin = 8; + popover_grid.attach (popover_entry, 0, 0); + popover_grid.attach (popover_button, 1, 0); + popover_grid.show_all (); + + popover = new Gtk.Popover (null); + popover.add (popover_grid); + + button_add = new Gtk.MenuButton (); + button_add.image = new Gtk.Image.from_icon_name ("list-add-symbolic", Gtk.IconSize.BUTTON); + button_add.get_style_context ().add_class (Gtk.STYLE_CLASS_SUGGESTED_ACTION); + button_add.popover = popover; + button_add.clicked.connect (() => set_tip ()); + + switcher = new StackSwitcher (); + switcher.stack = stack; + switcher.halign = Gtk.Align.CENTER; + + header = new Gtk.HeaderBar (); + header.show_close_button = true; + header.pack_start (button_add); + header.set_custom_title (switcher); + set_titlebar (header); + + add (stack); + show_all (); + + destroy.connect (() => { + dialog = null; + }); + } + + private void submit () { + if (popover_entry.text_length < 1) + return; + + var is_hashtag = stack.visible_child_name == "hashtags"; + var entity = popover_entry.text + .replace ("#", "") + .replace (" ", ""); + + watchlist.add (entity, is_hashtag); + watchlist.save (); + button_add.active = false; + + if (is_hashtag) + hashtags.list.insert (create_row (new ModelItem (entity, true)), 0); + else + users.list.insert (create_row (new ModelItem (entity, false)), 0); + } + + public static void open () { + if (dialog == null) + dialog = new WatchlistDialog (); + } + +} diff --git a/src/InstanceAccount.vala b/src/InstanceAccount.vala index b00ade1..00e7561 100644 --- a/src/InstanceAccount.vala +++ b/src/InstanceAccount.vala @@ -23,6 +23,7 @@ public class Tootle.InstanceAccount : GLib.Object { notificator.close (); notificator = new Notificator (get_stream ()); + notificator.status_added.connect (status_added); notificator.status_removed.connect (status_removed); notificator.notification.connect (notification); notificator.start (); @@ -67,7 +68,7 @@ public class Tootle.InstanceAccount : GLib.Object { return acc; } - private void notification (ref Notification obj) { + public void notification (ref Notification obj) { var title = Html.remove_tags (obj.type.get_desc (obj.account)); var notification = new GLib.Notification (title); if (obj.status != null) { @@ -89,5 +90,20 @@ public class Tootle.InstanceAccount : GLib.Object { if (accounts.formal.token == this.token) network.status_removed (id); } + + private void status_added (ref Status status) { + if (accounts.formal.token != this.token) + return; + + var acct = status.account.acct; + var obj = new Notification (-1); + obj.type = NotificationType.WATCHLIST; + obj.account = status.account; + obj.status = status; + watchlist.users.@foreach (item => { + if (item == acct || item == "@" + acct) + notification (ref obj); + }); + } } diff --git a/src/Notificator.vala b/src/Notificator.vala index 935bc87..0a275d9 100644 --- a/src/Notificator.vala +++ b/src/Notificator.vala @@ -16,6 +16,7 @@ public class Tootle.Notificator : GLib.Object { Object (); this.msg = msg; this.msg.priority = Soup.MessagePriority.VERY_HIGH; + this.msg.set_flags (Soup.MessageFlags.IGNORE_CONNECTION_LIMITS); } public string get_url () { @@ -37,7 +38,7 @@ public class Tootle.Notificator : GLib.Object { return; try { - info ("Starting notificator: %s", get_name ()); + info ("Starting: %s", get_name ()); connection = yield network.stream (msg); connection.error.connect (on_error); connection.message.connect (on_message); @@ -54,7 +55,7 @@ public class Tootle.Notificator : GLib.Object { if (connection == null) return; - info ("Stopping notificator: %s", get_name ()); + info ("Closing: %s", get_name ()); closing = true; connection.close (0, null); } @@ -68,13 +69,13 @@ public class Tootle.Notificator : GLib.Object { if (closing) return; - warning ("Notificator %s aborted. Reconnecting in %i seconds.", get_name (), timeout); + warning ("Aborted: %s. Reconnecting in %i seconds.", get_name (), timeout); GLib.Timeout.add_seconds (timeout, reconnect); timeout = int.min (timeout*2, 60); } private void on_error (Error e) { - warning ("Error in notificator %s: %s", get_name (), e.message); + warning ("Error in %s: %s", get_name (), e.message); } private void on_message (int i, Bytes bytes) { diff --git a/src/Settings.vala b/src/Settings.vala index 6a6f561..b3d487d 100644 --- a/src/Settings.vala +++ b/src/Settings.vala @@ -9,6 +9,8 @@ public class Tootle.Settings : Granite.Services.Settings { public bool live_updates { get; set; } public bool live_updates_public { get; set; } public bool dark_theme { get; set; } + public string watched_users { get; set; } + public string watched_hashtags { get; set; } public Settings () { base ("com.github.bleakgrey.tootle"); diff --git a/src/Watchlist.vala b/src/Watchlist.vala new file mode 100644 index 0000000..45f22b2 --- /dev/null +++ b/src/Watchlist.vala @@ -0,0 +1,126 @@ +using Soup; +using GLib; +using Gdk; +using Json; + +public class Tootle.Watchlist : GLib.Object { + + public GenericArray users = new GenericArray (); + public GenericArray hashtags = new GenericArray (); + public GenericArray notificators = new GenericArray (); + + construct { + accounts.switched.connect (on_account_changed); + } + + public Watchlist () { + GLib.Object(); + } + + public virtual void on_account_changed (Account? account){ + if(account != null) + reload (); + } + + private void reload () { + info ("Reloading"); + + notificators.@foreach (notificator => notificator.close ()); + notificators.remove_range (0, notificators.length); + users.remove_range (0, users.length); + hashtags.remove_range (0, hashtags.length); + + load (); + + info ("Watching for %i users and %i hashtags", users.length, hashtags.length); + } + + private void load () { + var users_array = settings.watched_users.split (","); + foreach (string item in users_array) + add (item, false); + + var hashtags_array = settings.watched_hashtags.split (","); + foreach (string item in hashtags_array) + add (item, true); + } + + public void save () { + var serialized_users = ""; + users.@foreach (item => serialized_users += item + ","); + serialized_users = remove_last_delimiter (serialized_users); + settings.watched_users = serialized_users; + + var serialized_hashtags = ""; + hashtags.@foreach (item => serialized_hashtags += item + ","); + serialized_hashtags = remove_last_delimiter (serialized_hashtags); + settings.watched_hashtags = serialized_hashtags; + + info ("Saved"); + } + + private string remove_last_delimiter (string str) { + var i = str.last_index_of (","); + if (i > -1) + return str.substring (0, i); + else + return str; + } + + private Notificator get_notificator (string hashtag) { + var url = "%s/api/v1/streaming/?stream=hashtag&tag=%s&access_token=%s".printf (accounts.formal.instance, hashtag, accounts.formal.token); + var msg = new Soup.Message ("GET", url); + var notificator = new Notificator (msg); + notificator.status_added.connect (on_status_added); + return notificator; + } + + private void on_status_added (ref Status status) { + var obj = new Notification (-1); + obj.type = NotificationType.WATCHLIST; + obj.account = status.account; + obj.status = status; + accounts.formal.notification (ref obj); + } + + public void add (string entity, bool is_hashtag) { + if (entity == "") + return; + + if (is_hashtag) { + hashtags.add (entity); + var notificator = get_notificator (entity); + notificator.start (); + notificators.add (notificator); + info ("Added #%s", entity); + } + else { + users.add (entity); + info ("Added @%s", entity); + } + } + + public void remove (string entity, bool is_hashtag) { + int i = -1; + if (is_hashtag) + hashtags.@foreach (item => { + i++; + if (item == entity) { + var notificator = notificators.@get(i); + notificator.close (); + notificators.remove_index (i); + hashtags.remove_index (i); + info ("Removed #%s", entity); + } + }); + else + users.@foreach (item => { + i++; + if (item == entity) { + users.remove_index (i); + info ("Removed @%s", entity); + } + }); + } + +} diff --git a/src/Widgets/AccountsButton.vala b/src/Widgets/AccountsButton.vala index 8f0ee48..6395a3f 100644 --- a/src/Widgets/AccountsButton.vala +++ b/src/Widgets/AccountsButton.vala @@ -12,6 +12,7 @@ public class Tootle.AccountsButton : Gtk.MenuButton{ Gtk.ModelButton item_search; Gtk.ModelButton item_favs; Gtk.ModelButton item_direct; + Gtk.ModelButton item_watchlist; private class AccountView : Gtk.ListBoxRow{ @@ -78,6 +79,10 @@ public class Tootle.AccountsButton : Gtk.MenuButton{ item_search.text = _("Search"); item_search.clicked.connect (() => window.open_view (new SearchView ())); + item_watchlist = new Gtk.ModelButton (); + item_watchlist.text = _("Watchlist"); + item_watchlist.clicked.connect (() => WatchlistDialog.open ()); + item_settings = new Gtk.ModelButton (); item_settings.text = _("Settings"); item_settings.clicked.connect (() => SettingsDialog.open ()); @@ -92,7 +97,8 @@ public class Tootle.AccountsButton : Gtk.MenuButton{ grid.attach(new Gtk.Separator (Gtk.Orientation.HORIZONTAL), 0, 6, 1, 1); grid.attach(item_refresh, 0, 7, 1, 1); grid.attach(item_search, 0, 8, 1, 1); - grid.attach(item_settings, 0, 9, 1, 1); + grid.attach(item_watchlist, 0, 9, 1, 1); + grid.attach(item_settings, 0, 10, 1, 1); grid.show_all (); menu = new Gtk.Popover (null); diff --git a/src/Widgets/NotificationWidget.vala b/src/Widgets/NotificationWidget.vala index 4400f1e..502c800 100644 --- a/src/Widgets/NotificationWidget.vala +++ b/src/Widgets/NotificationWidget.vala @@ -41,13 +41,15 @@ public class Tootle.NotificationWidget : Gtk.Grid { get_style_context ().add_class ("notification"); if (notification.status != null) { - Tootle.network.status_removed.connect (id => { + network.status_removed.connect (id => { if (id == notification.status.id) destroy (); }); } destroy.connect (() => { + if (separator != null) + separator.destroy (); separator = null; status_widget = null; });