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;
});