array) {
82 | model = new Model ();
83 | list = new ListBox ();
84 | add (list);
85 | update (array);
86 | }
87 | }
88 |
89 | private void set_tip () {
90 | var is_user = stack.visible_child_name == "users";
91 | popover_entry.secondary_icon_tooltip_text = is_user ? TIP_USERS : TIP_HASHTAGS;
92 | }
93 |
94 | public WatchlistEditor () {
95 | border_width = 6;
96 | deletable = false;
97 | resizable = false;
98 | transient_for = window;
99 | title = _("Watchlist");
100 |
101 | users = new ListStack (watchlist.users);
102 | hashtags = new ListStack (watchlist.hashtags);
103 |
104 | stack = new Stack ();
105 | stack.transition_type = StackTransitionType.SLIDE_LEFT_RIGHT;
106 | stack.hexpand = true;
107 | stack.vexpand = true;
108 | stack.add_titled (users, "users", _("Users"));
109 | stack.add_titled (hashtags, "hashtags", _("Hashtags"));
110 |
111 | switcher = new StackSwitcher ();
112 | switcher.stack = stack;
113 | switcher.halign = Align.CENTER;
114 | switcher.margin_bottom = 12;
115 |
116 | popover_entry = new Entry ();
117 | popover_entry.hexpand = true;
118 | popover_entry.secondary_icon_name = "dialog-information-symbolic";
119 | popover_entry.secondary_icon_activatable = false;
120 | popover_entry.activate.connect (() => submit ());
121 |
122 | popover_button = new Button.with_label (_("Add"));
123 | popover_button.halign = Align.END;
124 | popover_button.margin_start = 6;
125 | popover_button.clicked.connect (() => submit ());
126 |
127 | popover_grid = new Grid ();
128 | popover_grid.margin = 6;
129 | popover_grid.attach (popover_entry, 0, 0);
130 | popover_grid.attach (popover_button, 1, 0);
131 | popover_grid.show_all ();
132 |
133 | popover = new Popover (null);
134 | popover.add (popover_grid);
135 |
136 | button_add = new MenuButton ();
137 | button_add.image = new Image.from_icon_name ("list-add-symbolic", IconSize.BUTTON);
138 | button_add.popover = popover;
139 | button_add.clicked.connect (() => set_tip ());
140 |
141 | button_remove = new Button ();
142 | button_remove.image = new Image.from_icon_name ("list-remove-symbolic", IconSize.BUTTON);
143 | button_remove.clicked.connect (on_remove);
144 |
145 | actionbar = new ActionBar ();
146 | actionbar.add (button_add);
147 | actionbar.add (button_remove);
148 |
149 | var grid = new Grid ();
150 | grid.attach (stack, 0, 1);
151 | grid.attach (actionbar, 0, 2);
152 |
153 | var frame = new Frame (null);
154 | frame.margin_bottom = 6;
155 | frame.add (grid);
156 | frame.set_size_request (350, 350);
157 |
158 | var content = get_content_area ();
159 | content.pack_start (switcher, true, true, 0);
160 | content.pack_start (frame, true, true, 0);
161 |
162 | add_button (_("_Close"), ResponseType.DELETE_EVENT);
163 | show_all ();
164 |
165 | response.connect (on_response);
166 | destroy.connect (() => dialog = null);
167 | }
168 |
169 | private void on_response (int i) {
170 | destroy ();
171 | }
172 |
173 | private void on_remove () {
174 | var is_hashtag = stack.visible_child_name == "hashtags";
175 | ListStack stack = is_hashtag ? hashtags : users;
176 | stack.list.get_selected_rows ().@foreach (_row => {
177 | var row = _row as ModelView;
178 | watchlist.remove (row.label.label, is_hashtag);
179 | watchlist.save ();
180 | row.destroy ();
181 | });
182 | }
183 |
184 | private void submit () {
185 | if (popover_entry.text_length < 1)
186 | return;
187 |
188 | var is_hashtag = stack.visible_child_name == "hashtags";
189 | var entity = popover_entry.text
190 | .replace ("#", "")
191 | .replace (" ", "");
192 |
193 | watchlist.add (entity, is_hashtag);
194 | watchlist.save ();
195 | button_add.active = false;
196 |
197 | var stack = is_hashtag ? hashtags : users;
198 | stack.list.insert (create_row (new ModelItem (entity)), 0);
199 | }
200 |
201 | public static void open () {
202 | if (dialog == null)
203 | dialog = new WatchlistEditor ();
204 | }
205 |
206 | }
207 |
--------------------------------------------------------------------------------
/src/Drawing.vala:
--------------------------------------------------------------------------------
1 | using Gdk;
2 | using GLib;
3 |
4 | public class Olifant.Drawing {
5 |
6 | public static void draw_rounded_rect (Cairo.Context ctx, double x, double y, double w, double h, double r) {
7 | double degr = Math.PI / 180.0;
8 | ctx.new_sub_path ();
9 | ctx.arc (x + w - r, y + r, r, -90 * degr, 0 * degr);
10 | ctx.arc (x + w - r, y + h - r, r, 0 * degr, 90 * degr);
11 | ctx.arc (x + r, y + h - r, r, 90 * degr, 180 * degr);
12 | ctx.arc (x + r, y + r, r, 180 * degr, 270 * degr);
13 | ctx.close_path ();
14 | }
15 |
16 | public static Pixbuf make_pixbuf_thumbnail (Pixbuf pixbuf, int view_w, int view_h, bool fill_parent = false) {
17 | // Don't resize if parent view is bigger than actual image
18 | if (view_w >= pixbuf.width && view_h >= pixbuf.height)
19 | return pixbuf;
20 |
21 | //Otherwise fit the image into the parent view
22 | var resized_w = view_w;
23 | var resized_h = view_h;
24 | //resized_w = (pixbuf.width * view_h) / pixbuf.height;
25 | //resized_h = (pixbuf.height * view_w) / pixbuf.width;
26 |
27 | if (fill_parent)
28 | resized_h = (pixbuf.height * view_w) / pixbuf.width;
29 | else
30 | resized_w = (pixbuf.width * view_h) / pixbuf.height;
31 |
32 | return pixbuf.scale_simple (resized_w, resized_h, InterpType.BILINEAR);
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/src/Html.vala:
--------------------------------------------------------------------------------
1 | public class Olifant.Html {
2 |
3 | public static string remove_tags (string content) {
4 | var all_tags = new Regex("<(.|\n)*?>", RegexCompileFlags.CASELESS);
5 | return all_tags.replace(content, -1, 0, "");
6 | }
7 |
8 | public static string simplify (string content) {
9 | var html_params = new Regex("(class|target|rel|data-user|data-tag)=\"(.|\n)*?\"", RegexCompileFlags.CASELESS);
10 | var tags_to_clean = new Regex("(?(em)[^<>]*/?>|)");
11 | var tags_to_make_linebreak = new Regex("?(br|p|blockquote)[^<>]*/?>");
12 | var simplified = tags_to_make_linebreak.replace (
13 | tags_to_clean.replace(
14 | html_params.replace(content, -1, 0, ""),
15 | -1, 0, ""
16 | ),
17 | -1, 0, "\n"
18 | );
19 |
20 | while (simplified.has_suffix ("\n"))
21 | simplified = simplified.slice (0, simplified.last_index_of ("\n"));
22 |
23 | return simplified;
24 | }
25 |
26 | public static string uri_encode (string content) {
27 | var to_escape = ";&+";
28 | return Soup.URI.encode (content, to_escape);
29 | }
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/src/ImageCache.vala:
--------------------------------------------------------------------------------
1 | using Soup;
2 | using GLib;
3 | using Gdk;
4 | using Json;
5 |
6 | private struct CachedImage {
7 |
8 | public string uri;
9 | public int size;
10 |
11 | public CachedImage (string _uri, int _size) {
12 | uri = _uri;
13 | size = _size;
14 | }
15 |
16 | public static uint hash(CachedImage? c) {
17 | assert (c != null);
18 | assert (c.uri != null);
19 | return GLib.int64_hash (c.size) ^ c.uri.hash ();
20 | }
21 |
22 | public static bool equal (CachedImage? a, CachedImage? b) {
23 | if (a == null || b == null)
24 | return false;
25 | return a.size == b.size && a.uri == b.uri;
26 | }
27 |
28 | }
29 |
30 | public delegate void PixbufCallback (Gdk.Pixbuf pb);
31 |
32 | public class Olifant.ImageCache : GLib.Object {
33 |
34 | private GLib.HashTable in_progress;
35 | private GLib.HashTable pixbufs;
36 | private uint total_size_est;
37 | private uint size_limit;
38 | private string cache_path;
39 |
40 | construct {
41 | pixbufs = new GLib.HashTable (CachedImage.hash, CachedImage.equal);
42 | in_progress = new GLib.HashTable (CachedImage.hash, CachedImage.equal);
43 | total_size_est = 0;
44 | cache_path = "%s/%s".printf (GLib.Environment.get_user_cache_dir (), app.application_id);
45 |
46 | settings.changed.connect (on_settings_changed);
47 | on_settings_changed ();
48 | }
49 |
50 | public ImageCache() {}
51 |
52 | private void on_settings_changed () {
53 | // assume 32BPP (divide bytes by 4 to get # pixels) and raw, overhead-free storage
54 | // cache_size setting is number of megabytes
55 | size_limit = (1024 * 1024 * settings.cache_size) / 4;
56 | if (settings.cache)
57 | enforce_size_limit ();
58 | else
59 | remove_all ();
60 | }
61 |
62 | public void remove_all () {
63 | debug("Image cache cleared");
64 | pixbufs.remove_all ();
65 | total_size_est = 0;
66 | }
67 |
68 | public void remove_one (string uri, int size) {
69 | CachedImage ci = CachedImage (uri, size);
70 | bool removed = pixbufs.remove(ci);
71 | if (removed) {
72 | assert (total_size_est >= size * size);
73 | total_size_est -= size * size;
74 | debug("Cache usage: %zd", total_size_est);
75 | }
76 | }
77 |
78 | //TODO: fix me
79 | // remove least used image
80 | private void remove_least_used () {
81 | var keys = pixbufs.get_keys();
82 | if (keys.first() != null) {
83 | var ci = keys.first().data;
84 | remove_one(ci.uri, ci.size);
85 | }
86 | }
87 |
88 | private void enforce_size_limit () {
89 | debug("Updating size limit (%zd/%zd)", total_size_est, size_limit);
90 | while (total_size_est > size_limit && pixbufs.size() > 0)
91 | remove_least_used ();
92 |
93 | assert (total_size_est <= size_limit);
94 | }
95 |
96 | private void store_pixbuf (CachedImage ci, Gdk.Pixbuf pixbuf) {
97 | assert (!pixbufs.contains (ci));
98 | pixbufs.insert (ci, pixbuf);
99 | in_progress.remove (ci);
100 | total_size_est += ci.size * ci.size;
101 | enforce_size_limit ();
102 | }
103 |
104 | public async void get_image (string uri, int size, owned PixbufCallback? cb = null) {
105 | CachedImage ci = CachedImage (uri, size);
106 | Gdk.Pixbuf? pb = pixbufs.get(ci);
107 | if (pb != null) {
108 | cb (pb);
109 | return;
110 | }
111 |
112 | Soup.Message? msg = in_progress.get(ci);
113 | if (msg == null) {
114 | msg = new Soup.Message("GET", uri);
115 | ulong id = 0;
116 | id = msg.finished.connect(() => {
117 | debug("Caching %s@%d", uri, size);
118 | var data = msg.response_body.data;
119 | var stream = new MemoryInputStream.from_data (data);
120 | var pixbuf = new Gdk.Pixbuf.from_stream_at_scale (stream, size, size, true);
121 | store_pixbuf(ci, pixbuf);
122 | cb(pixbuf);
123 | msg.disconnect(id);
124 | });
125 | in_progress[ci] = msg;
126 | network.queue (msg);
127 | } else {
128 | ulong id = 0;
129 | id = msg.finished.connect(() => {
130 | cb(pixbufs[ci]);
131 | msg.disconnect(id);
132 | });
133 | }
134 | }
135 |
136 | public void load_avatar (string uri, Granite.Widgets.Avatar avatar, int size) {
137 | get_image.begin (uri, size, (pixbuf) => avatar.pixbuf = pixbuf.scale_simple (size, size, Gdk.InterpType.BILINEAR));
138 | }
139 |
140 | public void load_image (string uri, Gtk.Image image) {
141 | load_scaled_image (uri, image, -1);
142 | }
143 |
144 | public void load_scaled_image (string uri, Gtk.Image image, int size) {
145 | get_image.begin (uri, size, image.set_from_pixbuf);
146 | }
147 |
148 | }
149 |
--------------------------------------------------------------------------------
/src/InstanceAccount.vala:
--------------------------------------------------------------------------------
1 | using GLib;
2 | using Gee;
3 |
4 | public class Olifant.InstanceAccount : Object {
5 |
6 | public string username {get; set;}
7 | public string instance {get; set;}
8 | public string client_id {get; set;}
9 | public string client_secret {get; set;}
10 | public string token {get; set;}
11 | public int64? status_char_limit {get; set;}
12 |
13 | public string last_seen_notification {get; set; default = "";}
14 | public bool has_unread_notifications {get; set; default = false;}
15 | public ArrayList cached_notifications {get; set;}
16 |
17 | private Notificator? notificator;
18 |
19 | public InstanceAccount () {
20 | cached_notifications = new ArrayList ();
21 | }
22 |
23 | public string get_pretty_instance () {
24 | return instance
25 | .replace ("https://", "")
26 | .replace ("/","");
27 | }
28 |
29 | public void start_notificator () {
30 | if (notificator != null)
31 | notificator.close ();
32 |
33 | notificator = new Notificator (get_stream ());
34 | notificator.status_added.connect (status_added);
35 | notificator.status_removed.connect (status_removed);
36 | notificator.notification.connect (notification);
37 | notificator.start ();
38 | }
39 |
40 | public bool is_current () {
41 | return accounts.formal.token == token;
42 | }
43 |
44 | public Soup.Message get_stream () {
45 | var url = "%s/api/v1/streaming/?stream=user&access_token=%s".printf (instance, token);
46 | return new Soup.Message ("GET", url);
47 | }
48 |
49 | public void close_notificator () {
50 | if (notificator != null)
51 | notificator.close ();
52 | }
53 |
54 | public Json.Node serialize () {
55 | var builder = new Json.Builder ();
56 | builder.begin_object ();
57 | builder.set_member_name ("hash");
58 | builder.add_string_value ("test");
59 | builder.set_member_name ("username");
60 | builder.add_string_value (username);
61 | builder.set_member_name ("instance");
62 | builder.add_string_value (instance);
63 | builder.set_member_name ("id");
64 | builder.add_string_value (client_id);
65 | builder.set_member_name ("secret");
66 | builder.add_string_value (client_secret);
67 | builder.set_member_name ("token");
68 | builder.add_string_value (token);
69 | builder.set_member_name ("last_seen_notification");
70 | builder.add_string_value (last_seen_notification);
71 | builder.set_member_name ("has_unread_notifications");
72 | builder.add_boolean_value (has_unread_notifications);
73 | if (status_char_limit != null) {
74 | builder.set_member_name ("status_char_limit");
75 | builder.add_int_value (status_char_limit);
76 | }
77 |
78 | builder.set_member_name ("cached_notifications");
79 | builder.begin_array ();
80 | cached_notifications.@foreach (notification => {
81 | var node = notification.serialize ();
82 | if (node != null)
83 | builder.add_value (node);
84 | return true;
85 | });
86 | builder.end_array ();
87 |
88 | builder.end_object ();
89 | return builder.get_root ();
90 | }
91 |
92 | public static InstanceAccount parse (Json.Object obj) {
93 | var acc = new InstanceAccount ();
94 | acc.username = obj.get_string_member ("username");
95 | acc.instance = obj.get_string_member ("instance");
96 | acc.client_id = obj.get_string_member ("id");
97 | acc.client_secret = obj.get_string_member ("secret");
98 | acc.token = obj.get_string_member ("token");
99 | acc.last_seen_notification = obj.get_string_member ("last_seen_notification");
100 | acc.has_unread_notifications = obj.get_boolean_member ("has_unread_notifications");
101 | if (obj.has_member("status_char_limit")) {
102 | acc.status_char_limit = obj.get_int_member ("status_char_limit");
103 | } else {
104 | acc.status_char_limit = null;
105 | }
106 |
107 | var notifications = obj.get_array_member ("cached_notifications");
108 | notifications.foreach_element ((arr, i, node) => {
109 | var notification = API.Notification.parse (node.get_object ());
110 | acc.cached_notifications.add (notification);
111 | });
112 |
113 | return acc;
114 | }
115 |
116 | public void notification (API.Notification obj) {
117 | var title = Html.remove_tags (obj.type.get_desc (obj.account));
118 | var notification = new GLib.Notification (title);
119 | if (obj.status != null) {
120 | var body = "";
121 | body += get_pretty_instance ();
122 | body += "\n";
123 | body += Html.remove_tags (obj.status.content);
124 | notification.set_body (body);
125 | }
126 |
127 | if (settings.notifications)
128 | app.send_notification (app.application_id + ":" + obj.id.to_string (), notification);
129 |
130 | if (is_current ())
131 | network.notification (obj);
132 |
133 | if (obj.type == API.NotificationType.WATCHLIST) {
134 | cached_notifications.add (obj);
135 | accounts.save ();
136 | }
137 | }
138 |
139 | private void status_removed (string id) {
140 | if (is_current ())
141 | network.status_removed (id);
142 | }
143 |
144 | private void status_added (API.Status status) {
145 | if (!is_current ())
146 | return;
147 |
148 | watchlist.users.@foreach (item => {
149 | var acct = status.account.acct;
150 | if (item == acct || item == "@" + acct) {
151 | var obj = new API.Notification ("");
152 | obj.type = API.NotificationType.WATCHLIST;
153 | obj.account = status.account;
154 | obj.status = status;
155 | notification (obj);
156 | }
157 | return true;
158 | });
159 | }
160 |
161 | }
162 |
--------------------------------------------------------------------------------
/src/Network.vala:
--------------------------------------------------------------------------------
1 | using Soup;
2 | using GLib;
3 | using Gdk;
4 | using Json;
5 |
6 | public class Olifant.Network : GLib.Object {
7 |
8 | public const string INJECT_TOKEN = "X-HeyMate-PlsInjectToken4MeThx";
9 |
10 | public signal void started ();
11 | public signal void finished ();
12 | public signal void notification (API.Notification notification);
13 | public signal void status_removed (string id);
14 |
15 | public delegate void ErrorCallback (int32 code, string reason);
16 | public delegate void SuccessCallback (Session session, Message msg) throws GLib.Error;
17 |
18 | private int requests_processing = 0;
19 | private Soup.Session session;
20 |
21 | construct {
22 | session = new Soup.Session ();
23 | session.ssl_strict = true;
24 | session.ssl_use_system_ca_file = true;
25 | session.timeout = 15;
26 | session.max_conns = 20;
27 | session.request_unqueued.connect (msg => {
28 | requests_processing--;
29 | if (requests_processing <= 0)
30 | finished ();
31 | });
32 |
33 | // Soup.Logger logger = new Soup.Logger (Soup.LoggerLogLevel.BODY, -1);
34 | // session.add_feature (logger);
35 | }
36 |
37 | public Network () {}
38 |
39 | public async WebsocketConnection stream (Soup.Message msg) throws GLib.Error {
40 | return yield session.websocket_connect_async (msg, null, null, null);
41 | }
42 |
43 | public void cancel_request (Soup.Message? msg) {
44 | if (msg == null)
45 | return;
46 | switch (msg.status_code) {
47 | case Soup.Status.CANCELLED:
48 | case Soup.Status.OK:
49 | return;
50 | }
51 | session.cancel_message (msg, Soup.Status.CANCELLED);
52 | }
53 |
54 | public void inject (Soup.Message msg, string header) {
55 | msg.request_headers.append (header, "VeryPls");
56 | }
57 |
58 | private void inject_headers (ref Soup.Message msg) {
59 | var headers = msg.request_headers;
60 | var formal = accounts.formal;
61 | if (headers.get_one (INJECT_TOKEN) != null) {
62 | headers.remove (INJECT_TOKEN);
63 | }
64 | if (formal != null) {
65 | headers.append ("Authorization", "Bearer " + formal.token);
66 | }
67 | }
68 |
69 | public void queue (owned Soup.Message message, owned SuccessCallback? cb = null, owned ErrorCallback? errcb = null) {
70 | requests_processing++;
71 | started ();
72 |
73 | inject_headers (ref message);
74 |
75 | session.queue_message (message, (sess, msg) => {
76 | var status = msg.status_code;
77 | if (status != Soup.Status.CANCELLED) {
78 | if (status == Soup.Status.OK) {
79 | if (cb != null) {
80 | try {
81 | cb (session, msg);
82 | }
83 | catch (Error e) {
84 | warning ("Caught exception on network request:");
85 | warning (e.message);
86 | if (errcb != null)
87 | errcb (Soup.Status.NONE, e.message);
88 | }
89 | }
90 | }
91 | else {
92 | if (errcb != null)
93 | errcb ((int32)status, get_error_reason ((int32)status));
94 | }
95 | }
96 | // msg.request_body.free ();
97 | // msg.response_body.free ();
98 | // msg.request_headers.free ();
99 | // msg.response_headers.free ();
100 | });
101 | }
102 |
103 | public void queue_noauth (owned Soup.Message message, owned SuccessCallback? cb = null, owned ErrorCallback? errcb = null) {
104 | requests_processing++;
105 | started ();
106 |
107 | session.queue_message (message, (sess, msg) => {
108 | var status = msg.status_code;
109 | if (status != Soup.Status.CANCELLED) {
110 | if (status == Soup.Status.OK) {
111 | if (cb != null) {
112 | try {
113 | cb (session, msg);
114 | }
115 | catch (Error e) {
116 | warning ("Caught exception on network request:");
117 | warning (e.message);
118 | if (errcb != null)
119 | errcb (Soup.Status.NONE, e.message);
120 | }
121 | }
122 | }
123 | else {
124 | if (errcb != null)
125 | errcb ((int32)status, get_error_reason ((int32)status));
126 | }
127 | }
128 | // msg.request_body.free ();
129 | // msg.response_body.free ();
130 | // msg.request_headers.free ();
131 | // msg.response_headers.free ();
132 | });
133 | }
134 |
135 | public string get_error_reason (int32 status) {
136 | return "Error " + status.to_string () + ": " + Soup.Status.get_phrase (status);
137 | }
138 |
139 | public void on_error (int32 code, string message) {
140 | warning (message);
141 | app.toast (message);
142 | }
143 |
144 | public void on_show_error (int32 code, string message) {
145 | warning (message);
146 | app.error (_("Network Error"), message);
147 | }
148 |
149 | public Json.Object parse (Soup.Message msg) throws GLib.Error {
150 | // debug ("Status Code: %u", msg.status_code);
151 | // debug ("Message length: %lld", msg.response_body.length);
152 | // debug ("Object: %s", (string) msg.response_body.data);
153 |
154 | var parser = new Json.Parser ();
155 | parser.load_from_data ((string) msg.response_body.flatten ().data, -1);
156 | return parser.get_root ().get_object ();
157 | }
158 |
159 | public Json.Array parse_array (Soup.Message msg) throws GLib.Error {
160 | // debug ("Status Code: %u", msg.status_code);
161 | // debug ("Message length: %lld", msg.response_body.length);
162 | // debug ("Array: %s", (string) msg.response_body.data);
163 |
164 | var parser = new Json.Parser ();
165 | parser.load_from_data ((string) msg.response_body.flatten ().data, -1);
166 | return parser.get_root ().get_array ();
167 | }
168 |
169 | //TODO: Cache
170 | public delegate void PixbufCallback (Gdk.Pixbuf pixbuf);
171 | public Soup.Message load_pixbuf (string url, PixbufCallback cb, owned ErrorCallback? errcb = null, int? size = null) {
172 | var message = new Soup.Message("GET", url);
173 | network.queue_noauth (
174 | message,
175 | (sess, msg) => {
176 | Gdk.Pixbuf? pixbuf = null;
177 | try {
178 | var data = msg.response_body.flatten ().data;
179 | var stream = new MemoryInputStream.from_data (data);
180 | if (size == null)
181 | pixbuf = new Gdk.Pixbuf.from_stream (stream);
182 | else
183 | pixbuf = new Gdk.Pixbuf.from_stream_at_scale (stream, size, size, true);
184 | }
185 | catch (Error e) {
186 | warning ("Can't decode image: %s".printf (url));
187 | warning ("Reason: " + e.message);
188 | }
189 | cb (pixbuf);
190 | },
191 | (status, status_message) => {
192 | warning ("Could not get an image %s with http status: %i".printf (url, status));
193 | if (errcb != null)
194 | errcb (status, status_message);
195 | }
196 | );
197 | return message;
198 | }
199 |
200 | public void load_image (string url, Gtk.Image image) {
201 | load_pixbuf(
202 | url,
203 | image.set_from_pixbuf,
204 | (_, __) => {
205 | image.set_from_icon_name ("image-missing", Gtk.IconSize.LARGE_TOOLBAR);
206 | }
207 | );
208 | }
209 |
210 | public void load_scaled_image (string url, Gtk.Image image, int size) {
211 | load_pixbuf(
212 | url,
213 | image.set_from_pixbuf,
214 | (_, __) => {
215 | image.set_from_icon_name ("image-missing", Gtk.IconSize.LARGE_TOOLBAR);
216 | },
217 | size
218 | );
219 | }
220 |
221 | public void load_avatar (string url, Granite.Widgets.Avatar avatar, int size){
222 | load_pixbuf(
223 | url,
224 | (pixbuf) => { avatar.pixbuf = pixbuf.scale_simple (size, size, Gdk.InterpType.BILINEAR); },
225 | (_, __) => { avatar.show_default (size); },
226 | size
227 | );
228 | }
229 |
230 | }
231 |
--------------------------------------------------------------------------------
/src/Notificator.vala:
--------------------------------------------------------------------------------
1 | using GLib;
2 | using Soup;
3 |
4 | public class Olifant.Notificator : GLib.Object {
5 |
6 | private WebsocketConnection? connection;
7 | private Soup.Message msg;
8 | private bool closing = false;
9 | private int timeout = 2;
10 |
11 | public signal void notification (API.Notification notification);
12 | public signal void status_added (API.Status status);
13 | public signal void status_removed (string id);
14 |
15 | public Notificator (Soup.Message _msg){
16 | msg = _msg;
17 | msg.priority = Soup.MessagePriority.VERY_HIGH;
18 | msg.set_flags (Soup.MessageFlags.IGNORE_CONNECTION_LIMITS);
19 | }
20 |
21 | public string get_url () {
22 | return msg.get_uri ().to_string (false);
23 | }
24 |
25 | public string get_name () {
26 | var name = msg.get_uri ().to_string (true);
27 | if ("&access_token" in name) {
28 | var pos = name.last_index_of ("&access_token");
29 | name = name.slice (0, pos);
30 | }
31 |
32 | return name;
33 | }
34 |
35 | public async void start () {
36 | if (connection != null)
37 | return;
38 |
39 | try {
40 | info ("Starting: %s", get_name ());
41 | connection = yield network.stream (msg);
42 | connection.error.connect (on_error);
43 | connection.message.connect (on_message);
44 | connection.closed.connect (on_closed);
45 | timeout = 2;
46 | }
47 | catch (GLib.Error e) {
48 | warning (e.message);
49 | on_closed ();
50 | }
51 | }
52 |
53 | public void close () {
54 | if (connection == null)
55 | return;
56 |
57 | info ("Closing: %s", get_name ());
58 | closing = true;
59 | connection.close (0, null);
60 | }
61 |
62 | private bool reconnect () {
63 | start ();
64 | return false;
65 | }
66 |
67 | private void on_closed () {
68 | if (closing)
69 | return;
70 |
71 | warning ("Aborted: %s. Reconnecting in %i seconds.", get_name (), timeout);
72 | GLib.Timeout.add_seconds (timeout, reconnect);
73 | timeout = int.min (timeout*2, 60);
74 | }
75 |
76 | private void on_error (Error e) {
77 | if (!closing)
78 | warning ("Error in %s: %s", get_name (), e.message);
79 | }
80 |
81 | private void on_message (int i, Bytes bytes) {
82 | var msg = (string) bytes.get_data ();
83 |
84 | var parser = new Json.Parser ();
85 | parser.load_from_data (msg, -1);
86 | var root = parser.get_root ().get_object ();
87 |
88 | var type = root.get_string_member ("event");
89 | switch (type) {
90 | case "update":
91 | if (!settings.live_updates)
92 | return;
93 |
94 | var status = API.Status.parse (sanitize (root));
95 | status_added (status);
96 | break;
97 | case "delete":
98 | if (!settings.live_updates)
99 | return;
100 |
101 | var id = root.get_string_member("payload");
102 | status_removed (id);
103 | break;
104 | case "notification":
105 | var notif = API.Notification.parse (sanitize (root));
106 | notification (notif);
107 | break;
108 | default:
109 | warning ("Unknown push event: %s", type);
110 | break;
111 | }
112 | }
113 |
114 | private Json.Object sanitize (Json.Object root) {
115 | var payload = root.get_string_member ("payload");
116 | var sanitized = Soup.URI.decode (payload);
117 | var parser = new Json.Parser ();
118 | parser.load_from_data (sanitized, -1);
119 | return parser.get_root ().get_object ();
120 | }
121 |
122 | }
123 |
--------------------------------------------------------------------------------
/src/Settings.vala:
--------------------------------------------------------------------------------
1 | public class Olifant.Settings : Granite.Services.Settings {
2 |
3 | public int current_account { get; set; }
4 | public bool notifications { get; set; }
5 | public bool always_online { get; set; }
6 | public bool cache { get; set; }
7 | public int cache_size { get; set; }
8 | public int char_limit { get; set; }
9 | public bool live_updates { get; set; }
10 | public bool live_updates_public { get; set; }
11 | public bool dark_theme { get; set; }
12 | public string watched_users { get; set; }
13 | public string watched_hashtags { get; set; }
14 |
15 | public int window_x { get; set; }
16 | public int window_y { get; set; }
17 | public int window_w { get; set; }
18 | public int window_h { get; set; }
19 |
20 | public Settings () {
21 | base ("com.github.cleac.olifant");
22 | }
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/src/Views/Abstract.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 |
3 | public abstract class Olifant.Views.Abstract : ScrolledWindow {
4 |
5 | public bool current = false;
6 | public int stack_pos = -1;
7 | public Image? image;
8 | public Box view;
9 | protected Box? empty;
10 | protected Grid? header;
11 |
12 | construct {
13 | view = new Box (Orientation.VERTICAL, 0);
14 | view.valign = Align.START;
15 | add (view);
16 |
17 | hscrollbar_policy = PolicyType.NEVER;
18 | edge_reached.connect (pos => {
19 | if (pos == PositionType.BOTTOM)
20 | on_bottom_reached ();
21 | });
22 | }
23 |
24 | protected Abstract () {
25 | show_all ();
26 | }
27 |
28 | public virtual string get_icon () {
29 | return "null";
30 | }
31 |
32 | public virtual string get_name () {
33 | return "unnamed";
34 | }
35 |
36 | public virtual void clear (){
37 | view.forall (widget => {
38 | if (widget != header)
39 | widget.destroy ();
40 | });
41 | }
42 |
43 | public virtual void on_bottom_reached () {}
44 | public virtual void on_set_current () {}
45 |
46 | public virtual bool is_empty () {
47 | return view.get_children ().length () <= 1;
48 | }
49 |
50 | public virtual bool empty_state () {
51 | if (empty != null)
52 | empty.destroy ();
53 | if (!is_empty ())
54 | return false;
55 |
56 | empty = new Box (Orientation.VERTICAL, 0);
57 | empty.margin = 64;
58 | var image = new Image.from_resource ("/me/cleac/olifant/empty_state");
59 | var text = new Label (_("Nothing to see here"));
60 | text.get_style_context ().add_class ("h2");
61 | text.opacity = 0.5;
62 | empty.hexpand = true;
63 | empty.vexpand = true;
64 | empty.valign = Align.FILL;
65 | empty.pack_start (image, false, false, 0);
66 | empty.pack_start (text, false, false, 12);
67 | empty.show_all ();
68 | view.pack_start (empty, false, false, 0);
69 |
70 | return true;
71 | }
72 |
73 | }
74 |
--------------------------------------------------------------------------------
/src/Views/Direct.vala:
--------------------------------------------------------------------------------
1 | public class Olifant.Views.Direct : Views.Timeline {
2 |
3 | public Direct () {
4 | base ("direct");
5 | }
6 |
7 | public override string get_icon () {
8 | return "mail-send-symbolic";
9 | }
10 |
11 | public override string get_name () {
12 | return _("Direct Messages");
13 | }
14 |
15 | public override Soup.Message? get_stream () {
16 | var url = "%s/api/v1/streaming/?stream=direct&access_token=%s".printf (accounts.formal.instance, accounts.formal.token);
17 | return new Soup.Message("GET", url);
18 | }
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/src/Views/ExpandedStatus.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 |
3 | public class Olifant.Views.ExpandedStatus : Views.Abstract {
4 |
5 | private API.Status root_status;
6 | private bool last_status_was_root = false;
7 | private bool sensitive_visible = false;
8 |
9 | public ExpandedStatus (API.Status status) {
10 | base ();
11 | root_status = status;
12 | request ();
13 |
14 | window.button_reveal.clicked.connect (on_reveal_toggle);
15 | }
16 |
17 | ~ExpandedStatus () {
18 | if (window != null) {
19 | window.button_reveal.clicked.disconnect (on_reveal_toggle);
20 | window.button_reveal.hide ();
21 | }
22 | }
23 |
24 | private void prepend (API.Status status, bool is_root = false){
25 | var separator = new Separator (Orientation.HORIZONTAL);
26 | separator.show ();
27 |
28 | var widget = new Widgets.Status (status);
29 | widget.avatar.button_press_event.connect (widget.on_avatar_clicked);
30 | if (!is_root)
31 | widget.button_press_event.connect (widget.open);
32 | else
33 | widget.highlight ();
34 |
35 | if (!last_status_was_root) {
36 | widget.separator = separator;
37 | view.pack_start (separator, false, false, 0);
38 | }
39 | view.pack_start (widget, false, false, 0);
40 | last_status_was_root = is_root;
41 |
42 | if (status.has_spoiler ())
43 | window.button_reveal.show ();
44 | if (sensitive_visible)
45 | reveal_sensitive (widget);
46 | }
47 |
48 | public Soup.Message request (){
49 | var url = "%s/api/v1/statuses/%s/context".printf (accounts.formal.instance, root_status.id);
50 | var msg = new Soup.Message ("GET", url);
51 | network.inject (msg, Network.INJECT_TOKEN);
52 | network.queue (msg, (sess, mess) => {
53 | var root = network.parse (mess);
54 | var ancestors = root.get_array_member ("ancestors");
55 | ancestors.foreach_element ((array, i, node) => {
56 | var object = node.get_object ();
57 | if (object != null) {
58 | var status = API.Status.parse (object);
59 | prepend (status);
60 | }
61 | });
62 |
63 | prepend (root_status, true);
64 |
65 | var descendants = root.get_array_member ("descendants");
66 | descendants.foreach_element ((array, i, node) => {
67 | var object = node.get_object ();
68 | if (object != null) {
69 | var status = API.Status.parse (object);
70 | prepend (status);
71 | }
72 | });
73 | });
74 | return msg;
75 | }
76 |
77 | public static void open_from_link (string q){
78 | var url = "%s/api/v1/search?q=%s&resolve=true".printf (accounts.formal.instance, q);
79 | var msg = new Soup.Message ("GET", url);
80 | msg.priority = Soup.MessagePriority.HIGH;
81 | network.inject (msg, Network.INJECT_TOKEN);
82 | network.queue (msg, (sess, mess) => {
83 | var root = network.parse (mess);
84 | var statuses = root.get_array_member ("statuses");
85 | var object = statuses.get_element (0).get_object ();
86 | if (object != null){
87 | var st = API.Status.parse (object);
88 | window.open_view (new Views.ExpandedStatus (st));
89 | }
90 | else
91 | Desktop.open_uri (q);
92 | });
93 | }
94 |
95 | private void on_reveal_toggle () {
96 | sensitive_visible = !sensitive_visible;
97 | view.forall (w => {
98 | if (!(w is Widgets.Status))
99 | return;
100 |
101 | var widget = w as Widgets.Status;
102 | reveal_sensitive (widget);
103 | });
104 | }
105 |
106 | private void reveal_sensitive (Widgets.Status widget) {
107 | if (widget.status.has_spoiler ())
108 | widget.revealer.reveal_child = sensitive_visible;
109 | }
110 |
111 | }
112 |
--------------------------------------------------------------------------------
/src/Views/Favorites.vala:
--------------------------------------------------------------------------------
1 | public class Olifant.Views.Favorites : Views.Timeline {
2 |
3 | public Favorites () {
4 | base ("favorites");
5 | }
6 |
7 | public override string get_url (){
8 | if (page_next != null)
9 | return page_next;
10 |
11 | var url = "%s/api/v1/favourites/?limit=%i".printf (accounts.formal.instance, this.limit);
12 | return url;
13 | }
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/src/Views/Federated.vala:
--------------------------------------------------------------------------------
1 | public class Olifant.Views.Federated : Views.Timeline {
2 |
3 | public Federated () {
4 | base ("public");
5 | }
6 |
7 | public override string get_icon () {
8 | return "network-workgroup-symbolic";
9 | }
10 |
11 | public override string get_name () {
12 | return _("Federated Timeline");
13 | }
14 |
15 | protected override bool is_public () {
16 | return true;
17 | }
18 |
19 | public override Soup.Message? get_stream () {
20 | var url = "%s/api/v1/streaming/?stream=public&access_token=%s".printf (accounts.formal.instance, accounts.formal.token);
21 | return new Soup.Message("GET", url);
22 | }
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/src/Views/Followers.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 |
3 | public class Olifant.Views.Followers : Views.Timeline {
4 |
5 | public Followers (API.Account account) {
6 | base (account.id.to_string ());
7 | }
8 |
9 | public new void append (API.Account account){
10 | if (empty != null)
11 | empty.destroy ();
12 |
13 | var separator = new Separator (Orientation.HORIZONTAL);
14 | separator.show ();
15 |
16 | var widget = new Widgets.Account (account);
17 | widget.separator = separator;
18 | view.pack_start (separator, false, false, 0);
19 | view.pack_start (widget, false, false, 0);
20 | }
21 |
22 | public override string get_url (){
23 | if (page_next != null)
24 | return page_next;
25 |
26 | var url = "%s/api/v1/accounts/%s/followers".printf (accounts.formal.instance, this.timeline);
27 | return url;
28 | }
29 |
30 | public override void request (){
31 | var msg = new Soup.Message("GET", get_url ());
32 | msg.finished.connect (() => empty_state ());
33 | network.queue (msg, (sess, mess) => {
34 | try {
35 | network.parse_array (mess).foreach_element ((array, i, node) => {
36 | var object = node.get_object ();
37 | if (object != null){
38 | var status = API.Account.parse (object);
39 | append (status);
40 | }
41 | });
42 |
43 | get_pages (mess.response_headers.get_one ("Link"));
44 | }
45 | catch (GLib.Error e) {
46 | warning ("Can't get account follow info:");
47 | warning (e.message);
48 | }
49 | });
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/src/Views/Following.vala:
--------------------------------------------------------------------------------
1 | public class Olifant.Views.Following : Views.Followers {
2 |
3 | public Following (API.Account account) {
4 | base (account);
5 |
6 | }
7 |
8 | public override string get_url (){
9 | if (page_next != null)
10 | return page_next;
11 |
12 | var url = "%s/api/v1/accounts/%s/following".printf (accounts.formal.instance, this.timeline);
13 | return url;
14 | }
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/src/Views/Hashtag.vala:
--------------------------------------------------------------------------------
1 | public class Olifant.Views.Hashtag : Views.Timeline {
2 |
3 | public Hashtag (string hashtag) {
4 | base ("tag/" + hashtag);
5 | }
6 |
7 | public string get_hashtag () {
8 | return this.timeline.substring (4);
9 | }
10 |
11 | public override string get_name () {
12 | return get_hashtag ();
13 | }
14 |
15 | public override Soup.Message? get_stream () {
16 | var url = "%s/api/v1/streaming/?stream=hashtag&tag=%s&access_token=%s".printf (accounts.formal.instance, get_hashtag (), accounts.formal.token);
17 | return new Soup.Message("GET", url);
18 | }
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/src/Views/Home.vala:
--------------------------------------------------------------------------------
1 | public class Olifant.Views.Home : Views.Timeline {
2 |
3 | public Home () {
4 | base ("home");
5 | }
6 |
7 | public override string get_icon () {
8 | return "user-home-symbolic";
9 | }
10 |
11 | public override string get_name () {
12 | return _("Home");
13 | }
14 |
15 | public override Soup.Message? get_stream () {
16 | return accounts.formal.get_stream ();
17 | }
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/src/Views/Local.vala:
--------------------------------------------------------------------------------
1 | public class Olifant.Views.Local : Views.Timeline {
2 |
3 | public Local () {
4 | base ("public");
5 | }
6 |
7 | public override string get_icon () {
8 | return Desktop.fallback_icon ("system-users-symbolic", "document-open-recent-symbolic");
9 | }
10 |
11 | public override string get_name () {
12 | return _("Local Timeline");
13 | }
14 |
15 | public override string get_url (){
16 | var url = base.get_url ();
17 | url += "&local=true";
18 | return url;
19 | }
20 |
21 | protected override bool is_public () {
22 | return true;
23 | }
24 |
25 | public override Soup.Message? get_stream () {
26 | var url = "%s/api/v1/streaming/?stream=public:local&access_token=%s".printf (accounts.formal.instance, accounts.formal.token);
27 | return new Soup.Message("GET", url);
28 | }
29 |
30 | }
31 |
--------------------------------------------------------------------------------
/src/Views/Notifications.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 | using Gdk;
3 |
4 | public class Olifant.Views.Notifications : Views.Abstract {
5 |
6 | private string last_id = "";
7 | private bool force_dot = false;
8 |
9 | public Notifications () {
10 | base ();
11 | view.remove.connect (on_remove);
12 | accounts.switched.connect (on_account_changed);
13 | app.refresh.connect (on_refresh);
14 | network.notification.connect (prepend);
15 |
16 | request ();
17 | }
18 |
19 | private bool has_unread () {
20 | var account = accounts.formal;
21 | if (account == null)
22 | return false;
23 | return last_id > account.last_seen_notification || force_dot;
24 | }
25 |
26 | public override string get_icon () {
27 | if (has_unread ())
28 | return Desktop.fallback_icon ("notification-new-symbolic", "user-available-symbolic");
29 | else
30 | return Desktop.fallback_icon ("notification-symbolic", "user-invisible-symbolic");
31 | }
32 |
33 | public override string get_name () {
34 | return _("Notifications");
35 | }
36 |
37 | public void prepend (API.Notification notification) {
38 | append (notification, true);
39 | }
40 |
41 | public void append (API.Notification notification, bool reverse = false) {
42 | if (empty != null)
43 | empty.destroy ();
44 |
45 | var separator = new Separator (Orientation.HORIZONTAL);
46 | separator.show ();
47 |
48 | var widget = new Widgets.Notification (notification);
49 | widget.separator = separator;
50 | view.pack_start (separator, false, false, 0);
51 | view.pack_start (widget, false, false, 0);
52 |
53 | if (reverse) {
54 | view.reorder_child (widget, 0);
55 | view.reorder_child (separator, 0);
56 |
57 | if (!current) {
58 | force_dot = true;
59 | accounts.formal.has_unread_notifications = force_dot;
60 | }
61 | }
62 |
63 | if (notification.id > last_id)
64 | last_id = notification.id;
65 |
66 | if (has_unread ()) {
67 | accounts.save ();
68 | image.icon_name = get_icon ();
69 | }
70 | }
71 |
72 | public override void on_set_current () {
73 | var account = accounts.formal;
74 | if (has_unread ()) {
75 | force_dot = false;
76 | account.has_unread_notifications = force_dot;
77 | account.last_seen_notification = last_id;
78 | accounts.save ();
79 | image.icon_name = get_icon ();
80 | }
81 | }
82 |
83 | public virtual void on_remove (Widget widget) {
84 | if (!(widget is Widgets.Notification))
85 | return;
86 |
87 | empty_state ();
88 | }
89 |
90 | public override bool empty_state () {
91 | var is_empty = base.empty_state ();
92 | if (image != null && is_empty)
93 | image.icon_name = get_icon ();
94 |
95 | return is_empty;
96 | }
97 |
98 | public virtual void on_refresh () {
99 | clear ();
100 | request ();
101 | }
102 |
103 | public virtual void on_account_changed (API.Account? account) {
104 | if (account == null)
105 | return;
106 |
107 | last_id = accounts.formal.last_seen_notification;
108 | force_dot = accounts.formal.has_unread_notifications;
109 | on_refresh ();
110 | }
111 |
112 | public void request () {
113 | if (accounts.current == null) {
114 | empty_state ();
115 | return;
116 | }
117 |
118 | accounts.formal.cached_notifications.@foreach (notification => {
119 | append (notification);
120 | return true;
121 | });
122 |
123 | var url = "%s/api/v1/follow_requests".printf (accounts.formal.instance);
124 | var msg = new Soup.Message ("GET", url);
125 | network.inject (msg, Network.INJECT_TOKEN);
126 | network.queue (msg, (sess, mess) => {
127 | network.parse_array (mess).foreach_element ((array, i, node) => {
128 | var obj = node.get_object ();
129 | if (obj != null){
130 | var notification = API.Notification.parse_follow_request (obj);
131 | append (notification);
132 | }
133 | });
134 | });
135 |
136 | var url2 = "%s/api/v1/notifications?limit=30".printf (accounts.formal.instance);
137 | var msg2 = new Soup.Message ("GET", url2);
138 | network.inject (msg2, Network.INJECT_TOKEN);
139 | network.queue (msg2, (sess, mess) => {
140 | network.parse_array (mess).foreach_element ((array, i, node) => {
141 | var obj = node.get_object ();
142 | if (obj != null){
143 | var notification = API.Notification.parse (obj);
144 | if (notification.type != API.NotificationType.FOLLOW_REQUEST) {
145 | append (notification);
146 | }
147 | }
148 | });
149 | });
150 |
151 | empty_state ();
152 | }
153 |
154 | }
155 |
--------------------------------------------------------------------------------
/src/Views/Profile.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 | using Granite;
3 |
4 | public class Olifant.Views.Profile : Views.Timeline {
5 |
6 | const int AVATAR_SIZE = 128;
7 | protected API.Account account;
8 |
9 | protected Grid header_image;
10 | protected Box header_info;
11 | protected Granite.Widgets.Avatar avatar;
12 | protected Widgets.RichLabel display_name;
13 | protected Label username;
14 | protected Label relationship;
15 | protected Widgets.RichLabel note;
16 | protected Grid counters;
17 | protected Box actions;
18 | protected Button button_follow;
19 |
20 | protected Gtk.Menu menu;
21 | protected Gtk.MenuItem menu_edit;
22 | protected Gtk.MenuItem menu_mention;
23 | protected Gtk.MenuItem menu_mute;
24 | protected Gtk.MenuItem menu_block;
25 | protected Gtk.MenuItem menu_report;
26 | protected Gtk.MenuButton button_menu;
27 |
28 |
29 | construct {
30 | header = new Grid ();
31 | header_info = new Box (Orientation.VERTICAL, 0);
32 | header_info.margin = 12;
33 | actions = new Box (Orientation.HORIZONTAL, 0);
34 | actions.hexpand = false;
35 | actions.halign = Align.END;
36 | actions.vexpand = false;
37 | actions.valign = Align.START;
38 | actions.margin = 12;
39 |
40 | relationship = new Label ("");
41 | relationship.get_style_context ().add_class ("relationship");
42 | relationship.halign = Align.START;
43 | relationship.valign = Align.START;
44 | relationship.margin = 12;
45 | header.attach (relationship, 0, 0, 1, 1);
46 |
47 | avatar = new Granite.Widgets.Avatar.with_default_icon (AVATAR_SIZE);
48 | avatar.hexpand = true;
49 | avatar.margin_bottom = 6;
50 | header_info.pack_start (avatar, false, false, 0);
51 |
52 | display_name = new Widgets.RichLabel ("");
53 | display_name.get_style_context ().add_class (Granite.STYLE_CLASS_H2_LABEL);
54 | header_info.pack_start (display_name, false, false, 0);
55 |
56 | username = new Label ("");
57 | header_info.pack_start (username, false, false, 0);
58 |
59 | note = new Widgets.RichLabel ("");
60 | note.set_line_wrap (true);
61 | note.selectable = true;
62 | note.margin_top = 12;
63 | note.can_focus = false;
64 | note.justify = Justification.CENTER;
65 | header_info.pack_start (note, false, false, 0);
66 | header_info.show_all ();
67 | header.attach (header_info, 0, 0, 1, 1);
68 |
69 | counters = new Grid ();
70 | counters.column_homogeneous = true;
71 | counters.get_style_context ().add_class ("header-counters");
72 | header.attach (counters, 0, 1, 1, 1);
73 |
74 | header_image = new Grid ();
75 | header_image.get_style_context ().add_class ("header");
76 | header.attach (header_image, 0, 0, 2, 2);
77 |
78 | menu = new Gtk.Menu ();
79 | menu_edit = new Gtk.MenuItem.with_label (_("Edit Profile"));
80 | menu_mention = new Gtk.MenuItem.with_label (_("Mention"));
81 | menu_report = new Gtk.MenuItem.with_label (_("Report"));
82 | menu_mute = new Gtk.MenuItem.with_label (_("Mute"));
83 | menu_block = new Gtk.MenuItem.with_label (_("Block"));
84 | menu.add (menu_mention);
85 | //menu.add (new Gtk.SeparatorMenuItem ());
86 | menu.add (menu_mute);
87 | menu.add (menu_block);
88 | //menu.add (menu_report); //TODO: Report users
89 | //menu.add (menu_edit); //TODO: Edit profile
90 | menu.show_all ();
91 |
92 | button_follow = add_counter ("contact-new-symbolic");
93 | button_menu = new MenuButton ();
94 | button_menu.image = new Image.from_icon_name ("view-more-symbolic", IconSize.LARGE_TOOLBAR);
95 | button_menu.tooltip_text = _("More Actions");
96 | button_menu.get_style_context ().add_class (Gtk.STYLE_CLASS_FLAT);
97 | (button_menu as Widget).set_focus_on_click (false);
98 | button_menu.can_default = false;
99 | button_menu.can_focus = false;
100 | button_menu.popup = menu;
101 | actions.pack_end(button_menu, false, false, 0);
102 | actions.pack_end(button_follow, false, false, 0);
103 | button_menu.hide ();
104 | button_follow.hide ();
105 | header.attach (actions, 0, 0, 2, 2);
106 |
107 | view.pack_start (header, false, false, 0);
108 | }
109 |
110 | public Profile (API.Account acc) {
111 | base ("");
112 | account = acc;
113 | account.updated.connect (rebind);
114 |
115 | add_counter (_("Toots"), 1, account.statuses_count);
116 | add_counter (_("Follows"), 2, account.following_count).clicked.connect (() => {
117 | var view = new Views.Following (account);
118 | window.open_view (view);
119 | });
120 | add_counter (_("Followers"), 3, account.followers_count).clicked.connect (() => {
121 | var view = new Views.Followers (account);
122 | window.open_view (view);
123 | });
124 |
125 | show_all ();
126 |
127 | //TODO: Has this thing always been synchronous???
128 | //var stylesheet = ".header{background-image: url(\"%s\")}".printf (account.header);
129 | //var css_provider = Granite.Widgets.Utils.get_css_provider (stylesheet);
130 | //header_image.get_style_context ().add_provider (css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
131 |
132 | menu_mention.activate.connect (() => Dialogs.Compose.open ("@%s ".printf (account.acct)));
133 | menu_mute.activate.connect (() => account.set_muted (!account.rs.muting));
134 | menu_block.activate.connect (() => account.set_blocked (!account.rs.blocking));
135 | button_follow.clicked.connect (() => account.set_following (!account.rs.following));
136 |
137 | rebind ();
138 | account.get_relationship ();
139 | request ();
140 | }
141 |
142 |
143 |
144 | public void rebind (){
145 | display_name.set_label ("%s".printf (account.display_name));
146 | username.label = "@" + account.acct;
147 | note.set_label (account.note);
148 | button_follow.visible = !account.is_self ();
149 | network.load_avatar (account.avatar, avatar, 128);
150 |
151 | menu_edit.visible = account.is_self ();
152 |
153 | if (account.rs != null && !account.is_self ()) {
154 | button_follow.show ();
155 | if (account.rs.following) {
156 | button_follow.tooltip_text = _("Unfollow");
157 | (button_follow.get_image () as Image).icon_name = "close-symbolic";
158 | }
159 | else{
160 | button_follow.tooltip_text = _("Follow");
161 | (button_follow.get_image () as Image).icon_name = "contact-new-symbolic";
162 | }
163 | }
164 |
165 | if (account.rs != null){
166 | button_menu.show ();
167 | menu_block.label = account.rs.blocking ? _("Unblock") : _("Block");
168 | menu_mute.label = account.rs.muting ? _("Unmute") : _("Mute");
169 | menu_report.visible = menu_mute.visible = menu_block.visible = !account.is_self ();
170 |
171 | var rs_label = get_relationship_label ();
172 | if (rs_label != null) {
173 | relationship.label = rs_label;
174 | relationship.show ();
175 | }
176 | else
177 | relationship.hide ();
178 | }
179 | else
180 | relationship.hide ();
181 | }
182 |
183 | public override bool is_status_owned (API.Status status) {
184 | return status.is_owned ();
185 | }
186 |
187 | private Button add_counter (string name, int? i = null, int64? val = null) {
188 | Button btn;
189 | if (val != null){
190 | btn = new Button ();
191 | var label = new Label ("%s\n%s".printf (name.up (), val.to_string ()));
192 | label.justify = Justification.CENTER;
193 | label.use_markup = true;
194 | label.margin = 8;
195 | btn.add (label);
196 | }
197 | else
198 | btn = new Button.from_icon_name (name, IconSize.LARGE_TOOLBAR);
199 |
200 | btn.get_style_context ().add_class (Gtk.STYLE_CLASS_FLAT);
201 | (btn as Widget).set_focus_on_click (false);
202 | btn.can_default = false;
203 | btn.can_focus = false;
204 |
205 | if (i != null)
206 | counters.attach (btn, i, 1, 1, 1);
207 | return btn;
208 | }
209 |
210 | public override bool is_empty () {
211 | return view.get_children ().length () <= 2;
212 | }
213 |
214 | public override string get_url () {
215 | if (page_next != null)
216 | return page_next;
217 |
218 | var url = "%s/api/v1/accounts/%s/statuses?limit=%i".printf (accounts.formal.instance, account.id, this.limit);
219 | return url;
220 | }
221 |
222 | public override void request () {
223 | if (account != null)
224 | base.request ();
225 | }
226 |
227 | private string? get_relationship_label () {
228 | if (account.rs.requested)
229 | return _("Sent follow request");
230 | else if (account.rs.blocking)
231 | return _("Blocked");
232 | else if (account.rs.followed_by)
233 | return _("Follows you");
234 | else if (account.rs.domain_blocking)
235 | return _("Blocking this instance");
236 | else
237 | return null;
238 | }
239 |
240 | public static void open_from_id (string id){
241 | var url = "%s/api/v1/accounts/%s".printf (accounts.formal.instance, id);
242 | var msg = new Soup.Message ("GET", url);
243 | msg.priority = Soup.MessagePriority.HIGH;
244 | network.queue (msg, (sess, mess) => {
245 | var root = network.parse (mess);
246 | var acc = API.Account.parse (root);
247 | window.open_view (new Views.Profile (acc));
248 | }, (status, reason) => {
249 | network.on_error (status, reason);
250 | });
251 | }
252 |
253 | }
254 |
--------------------------------------------------------------------------------
/src/Views/Search.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 |
3 | public class Olifant.Views.Search : Views.Abstract {
4 |
5 | private string query = "";
6 | private Entry entry;
7 |
8 | construct {
9 | view.margin_bottom = 6;
10 |
11 | entry = new Entry ();
12 | entry.placeholder_text = _("Search");
13 | entry.secondary_icon_name = "system-search-symbolic";
14 | entry.width_chars = 25;
15 | entry.text = query;
16 | entry.valign = Align.CENTER;
17 | entry.show ();
18 | window.header.pack_start (entry);
19 |
20 | destroy.connect (() => entry.destroy ());
21 | entry.activate.connect (() => request ());
22 | entry.icon_press.connect (() => request ());
23 | }
24 |
25 | public Search () {
26 | entry.grab_focus_without_selecting ();
27 | }
28 |
29 | private void append_account (API.Account acc) {
30 | var widget = new Widgets.Account (acc);
31 | view.pack_start (widget, false, false, 0);
32 | }
33 |
34 | private void append_status (API.Status status) {
35 | var widget = new Widgets.Status (status);
36 | widget.button_press_event.connect (widget.on_avatar_clicked);
37 | view.pack_start (widget, false, false, 0);
38 | }
39 |
40 | private void append_header (string name) {
41 | var widget = new Label (name);
42 | widget.get_style_context ().add_class ("h4");
43 | widget.halign = Align.START;
44 | widget.margin = 6;
45 | widget.margin_bottom = 0;
46 | widget.show ();
47 | view.pack_start (widget, false, false, 0);
48 | }
49 |
50 | private void append_hashtag (string name) {
51 | var text = "#%s".printf (accounts.formal.instance, Soup.URI.encode (name, null), name);
52 | var widget = new Widgets.RichLabel (text);
53 | widget.use_markup = true;
54 | widget.halign = Align.START;
55 | widget.margin = 6;
56 | widget.margin_bottom = 0;
57 | widget.show ();
58 | view.pack_start (widget, false, false, 0);
59 | }
60 |
61 | private void request () {
62 | query = entry.text;
63 | if (query == "") {
64 | clear ();
65 | return;
66 | }
67 | window.reopen_view (this.stack_pos);
68 |
69 | var query_encoded = Soup.URI.encode (query, null);
70 | var url = "%s/api/v1/search?q=%s&resolve=true".printf (accounts.formal.instance, query_encoded);
71 | var msg = new Soup.Message("GET", url);
72 | network.inject (msg, Network.INJECT_TOKEN);
73 | network.queue (msg, (sess, mess) => {
74 | var root = network.parse (mess);
75 | var accounts = root.get_array_member ("accounts");
76 | var statuses = root.get_array_member ("statuses");
77 | var hashtags = root.get_array_member ("hashtags");
78 |
79 | clear ();
80 |
81 | if (accounts.get_length () > 0) {
82 | append_header (_("Accounts"));
83 | accounts.foreach_element ((array, i, node) => {
84 | var obj = node.get_object ();
85 | var acc = API.Account.parse (obj);
86 | append_account (acc);
87 | });
88 | }
89 |
90 | if (statuses.get_length () > 0) {
91 | append_header (_("Statuses"));
92 | statuses.foreach_element ((array, i, node) => {
93 | var obj = node.get_object ();
94 | var status = API.Status.parse (obj);
95 | append_status (status);
96 | });
97 | }
98 |
99 | if (hashtags.get_length () > 0) {
100 | append_header (_("Hashtags"));
101 | hashtags.foreach_element ((array, i, node) => {
102 | append_hashtag (node.get_string ());
103 | });
104 | }
105 |
106 | empty_state ();
107 | });
108 | }
109 |
110 | }
111 |
--------------------------------------------------------------------------------
/src/Views/Timeline.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 | using Gdk;
3 |
4 | public class Olifant.Views.Timeline : Views.Abstract {
5 |
6 | protected string timeline;
7 | protected string pars;
8 | protected int limit = 25;
9 | protected bool is_last_page = false;
10 | protected string? page_next;
11 | protected string? page_prev;
12 |
13 | protected Notificator? notificator;
14 |
15 | public Timeline (string timeline, string pars = "") {
16 | base ();
17 | this.timeline = timeline;
18 | this.pars = pars;
19 |
20 | accounts.switched.connect (on_account_changed);
21 | app.refresh.connect (on_refresh);
22 | destroy.connect (() => {
23 | if (notificator != null)
24 | notificator.close ();
25 | });
26 |
27 | setup_notificator ();
28 | request ();
29 | }
30 |
31 | public override string get_icon () {
32 | return "user-home-symbolic";
33 | }
34 |
35 | public override string get_name () {
36 | return _("Home");
37 | }
38 |
39 | public virtual void on_status_added (API.Status status) {
40 | prepend (status);
41 | }
42 |
43 | public virtual bool is_status_owned (API.Status status) {
44 | return false;
45 | }
46 |
47 | public void prepend (API.Status status) {
48 | append (status, true);
49 | }
50 |
51 | public void append (API.Status status, bool first = false){
52 | if (empty != null)
53 | empty.destroy ();
54 |
55 | var separator = new Separator (Orientation.HORIZONTAL);
56 | separator.show ();
57 |
58 | var widget = new Widgets.Status (status);
59 | widget.separator = separator;
60 | widget.button_press_event.connect (widget.open);
61 | if (!is_status_owned (status))
62 | widget.avatar.button_press_event.connect (widget.on_avatar_clicked);
63 | view.pack_start (separator, false, false, 0);
64 | view.pack_start (widget, false, false, 0);
65 |
66 | if (first || status.pinned) {
67 | var new_index = header == null ? 1 : 0;
68 | view.reorder_child (separator, new_index);
69 | view.reorder_child (widget, new_index);
70 | }
71 | }
72 |
73 | public override void clear () {
74 | this.page_prev = null;
75 | this.page_next = null;
76 | this.is_last_page = false;
77 | base.clear ();
78 | }
79 |
80 | public void get_pages (string? header) {
81 | page_next = page_prev = null;
82 | if (header == null)
83 | return;
84 |
85 | var pages = header.split (",");
86 | foreach (var page in pages) {
87 | var sanitized = page
88 | .replace ("<","")
89 | .replace (">", "")
90 | .split (";")[0];
91 |
92 | if ("rel=\"prev\"" in page)
93 | page_prev = sanitized;
94 | else
95 | page_next = sanitized;
96 | }
97 |
98 | is_last_page = page_prev != null & page_next == null;
99 | }
100 |
101 | public virtual string get_url () {
102 | if (page_next != null)
103 | return page_next;
104 |
105 | var url="";
106 | if (accounts.currentInstance.is_mastodon_v3 () && this.timeline == "direct") {
107 | url = "%s/api/v1/notifications?exclude_types[]=favourite&exclude_types[]=reblog".printf (accounts.formal.instance);
108 | url += "&exclude_types[]=follow&exclude_types[]=poll&limit=%i".printf (this.limit);
109 | url += this.pars;
110 | }
111 | else
112 | {
113 | url = "%s/api/v1/timelines/%s?limit=%i".printf (accounts.formal.instance, this.timeline, this.limit);
114 | url += this.pars;
115 | }
116 | return url;
117 | }
118 |
119 | public virtual void process_response (Json.Object object){
120 | if (object == null) {
121 | return;
122 | }
123 | if (accounts.currentInstance.is_mastodon_v3 () && this.timeline == "direct"){
124 | var nots = API.Notification.parse(object);
125 | if (nots.status != null && nots.status.visibility==API.StatusVisibility.DIRECT) {
126 | append(nots.status);
127 | }
128 | } else{
129 | var status = API.Status.parse (object);
130 | append (status);
131 | }
132 | }
133 |
134 | public virtual void request (){
135 | if (accounts.current == null) {
136 | empty_state ();
137 | return;
138 | }
139 |
140 | var msg = new Soup.Message ("GET", get_url ());
141 | network.inject (msg, Network.INJECT_TOKEN);
142 | network.queue (msg, (sess, mess) => {
143 | network.parse_array (mess).foreach_element ((array, i, node) => {
144 | process_response(node.get_object ());
145 | });
146 | get_pages (mess.response_headers.get_one ("Link"));
147 | empty_state ();
148 | },
149 | network.on_error);
150 | }
151 |
152 | public virtual void on_refresh (){
153 | clear ();
154 | request ();
155 | }
156 |
157 | public virtual Soup.Message? get_stream (){
158 | return null;
159 | }
160 |
161 | public virtual void on_account_changed (API.Account? account){
162 | if(account == null)
163 | return;
164 |
165 | var stream = get_stream ();
166 | if (notificator != null && stream != null) {
167 | var old_url = notificator.get_url ();
168 | var new_url = stream.get_uri ().to_string (false);
169 | if (old_url != new_url) {
170 | info ("Updating notificator %s", notificator.get_name ());
171 | setup_notificator ();
172 | }
173 | }
174 |
175 | on_refresh ();
176 | }
177 |
178 | protected void setup_notificator () {
179 | if (notificator != null)
180 | notificator.close ();
181 |
182 | var stream = get_stream ();
183 | if (stream == null)
184 | return;
185 |
186 | notificator = new Notificator (stream);
187 | notificator.status_added.connect ((status) => {
188 | if (can_stream ())
189 | on_status_added (status);
190 | });
191 | notificator.start ();
192 | }
193 |
194 | protected virtual bool is_public () {
195 | return false;
196 | }
197 |
198 | protected virtual bool can_stream () {
199 | var allowed_public = true;
200 | if (is_public ())
201 | allowed_public = settings.live_updates_public;
202 |
203 | return settings.live_updates && allowed_public;
204 | }
205 |
206 | protected override void on_bottom_reached () {
207 | if (is_last_page) {
208 | debug ("Last page reached");
209 | return;
210 | }
211 | request ();
212 | }
213 |
214 | }
215 |
--------------------------------------------------------------------------------
/src/Watchlist.vala:
--------------------------------------------------------------------------------
1 | using GLib;
2 | using Gee;
3 |
4 | public class Olifant.Watchlist : Object {
5 |
6 | public ArrayList users = new ArrayList ();
7 | public ArrayList hashtags = new ArrayList ();
8 | public ArrayList notificators = new ArrayList ();
9 |
10 | construct {
11 | accounts.switched.connect (on_account_changed);
12 | }
13 |
14 | public Watchlist () {}
15 |
16 | public virtual void on_account_changed (API.Account? account){
17 | if (account != null)
18 | reload ();
19 | }
20 |
21 | private void reload () {
22 | info ("Reloading");
23 |
24 | notificators.@foreach (notificator => {
25 | notificator.close ();
26 | return true;
27 | });
28 | notificators.clear ();
29 | users.clear ();
30 | hashtags.clear ();
31 |
32 | load ();
33 | info ("Watching for %i users and %i hashtags", users.size, hashtags.size);
34 | }
35 |
36 | private void load () {
37 | var users_array = settings.watched_users.split (",");
38 | foreach (string item in users_array)
39 | add (item, false);
40 |
41 | var hashtags_array = settings.watched_hashtags.split (",");
42 | foreach (string item in hashtags_array)
43 | add (item, true);
44 | }
45 |
46 | public void save () {
47 | var serialized_users = "";
48 | users.@foreach (item => {
49 | serialized_users += item + ",";
50 | return true;
51 | });
52 | serialized_users = remove_last_delimiter (serialized_users);
53 | settings.watched_users = serialized_users;
54 |
55 | var serialized_hashtags = "";
56 | hashtags.@foreach (item => {
57 | serialized_hashtags += item + ",";
58 | return true;
59 | });
60 | serialized_hashtags = remove_last_delimiter (serialized_hashtags);
61 | settings.watched_hashtags = serialized_hashtags;
62 |
63 | info ("Saved");
64 | }
65 |
66 | private string remove_last_delimiter (string str) {
67 | var i = str.last_index_of (",");
68 | if (i > -1)
69 | return str.substring (0, i);
70 | else
71 | return str;
72 | }
73 |
74 | private Notificator get_notificator (string hashtag) {
75 | var url = "%s/api/v1/streaming/?stream=hashtag&tag=%s&access_token=%s".printf (accounts.formal.instance, hashtag, accounts.formal.token);
76 | var msg = new Soup.Message ("GET", url);
77 | var notificator = new Notificator (msg);
78 | notificator.status_added.connect (on_status_added);
79 | return notificator;
80 | }
81 |
82 | private void on_status_added (API.Status status) {
83 | var obj = new API.Notification ("");
84 | obj.type = API.NotificationType.WATCHLIST;
85 | obj.account = status.account;
86 | obj.status = status;
87 | accounts.formal.notification (obj);
88 | }
89 |
90 | public void add (string entity, bool is_hashtag) {
91 | if (entity == "")
92 | return;
93 |
94 | if (is_hashtag) {
95 | hashtags.add (entity);
96 | var notificator = get_notificator (entity);
97 | notificator.start ();
98 | notificators.add (notificator);
99 | info ("Added #%s", entity);
100 | }
101 | else {
102 | users.add (entity);
103 | info ("Added @%s", entity);
104 | }
105 | }
106 |
107 | public void remove (string entity, bool is_hashtag) {
108 | if (entity == "")
109 | return;
110 |
111 | if (is_hashtag) {
112 | var i = hashtags.index_of (entity);
113 | var notificator = notificators.@get(i);
114 | notificator.close ();
115 | notificators.remove_at (i);
116 | hashtags.remove (entity);
117 | info ("Removed #%s", entity);
118 | }
119 | else {
120 | users.remove (entity);
121 | info ("Removed @%s", entity);
122 | }
123 | }
124 |
125 | }
126 |
--------------------------------------------------------------------------------
/src/Widgets/Account.vala:
--------------------------------------------------------------------------------
1 | using Gdk;
2 |
3 | public class Olifant.Widgets.Account : Widgets.Status {
4 |
5 | public Account (API.Account account) {
6 | var status = new API.Status ("");
7 | status.account = account;
8 | status.url = account.url;
9 | status.content = "@%s".printf (account.url, account.acct);
10 | status.created_at = account.created_at;
11 |
12 | base (status);
13 |
14 | counters.visible = false;
15 | title_acct.visible = false;
16 | content_label.margin_bottom = 12;
17 | }
18 |
19 | protected override bool on_clicked (EventButton ev) {
20 | if (ev.button == 1)
21 | return on_avatar_clicked (ev);
22 | return false;
23 | }
24 |
25 | public override bool open_menu (uint button, uint32 time) {
26 | var menu = new Gtk.Menu ();
27 |
28 | var item_open_link = new Gtk.MenuItem.with_label (_("Open in Browser"));
29 | item_open_link.activate.connect (() => Desktop.open_uri (status.url));
30 | var item_copy_link = new Gtk.MenuItem.with_label (_("Copy Link"));
31 | item_copy_link.activate.connect (() => Desktop.copy (status.url));
32 | menu.add (item_open_link);
33 | menu.add (item_copy_link);
34 |
35 | menu.show_all ();
36 | menu.popup_at_pointer ();
37 | return true;
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/src/Widgets/AccountsButton.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 |
3 | public class Olifant.Widgets.AccountsButton : MenuButton {
4 |
5 | const int AVATAR_SIZE = 24;
6 | Granite.Widgets.Avatar avatar;
7 | Grid grid;
8 | Popover menu;
9 | ListBox list;
10 | ModelButton item_settings;
11 | ModelButton item_refresh;
12 | ModelButton item_search;
13 | ModelButton item_favs;
14 | ModelButton item_direct;
15 | ModelButton item_watchlist;
16 |
17 | private class AccountItemView : ListBoxRow {
18 |
19 | private Grid grid;
20 | public Label display_name;
21 | public Label instance;
22 | public Button button;
23 | public int id = -1;
24 |
25 | construct {
26 | can_default = false;
27 |
28 | grid = new Grid ();
29 | grid.margin = 6;
30 | grid.margin_start = 14;
31 |
32 | display_name = new Label ("");
33 | display_name.hexpand = true;
34 | display_name.halign = Align.START;
35 | display_name.use_markup = true;
36 | instance = new Label ("");
37 | instance.halign = Align.START;
38 | button = new Button.from_icon_name ("window-close-symbolic", IconSize.SMALL_TOOLBAR);
39 | button.receives_default = false;
40 | button.get_style_context ().add_class (Gtk.STYLE_CLASS_FLAT);
41 |
42 | grid.attach (display_name, 1, 0, 1, 1);
43 | grid.attach (instance, 1, 1, 1, 1);
44 | grid.attach (button, 2, 0, 2, 2);
45 | add (grid);
46 | show_all ();
47 | }
48 |
49 | public AccountItemView (){
50 | button.clicked.connect (() => accounts.remove (id));
51 | }
52 |
53 | }
54 |
55 | construct{
56 | avatar = new Granite.Widgets.Avatar.with_default_icon (AVATAR_SIZE);
57 | list = new ListBox ();
58 |
59 | var item_separator = new Separator (Orientation.HORIZONTAL);
60 | item_separator.hexpand = true;
61 |
62 | item_refresh = new ModelButton ();
63 | item_refresh.text = _("Refresh");
64 | item_refresh.clicked.connect (() => app.refresh ());
65 | Desktop.set_hotkey_tooltip (item_refresh, null, app.ACCEL_REFRESH);
66 |
67 | item_favs = new ModelButton ();
68 | item_favs.text = _("Favorites");
69 | item_favs.clicked.connect (() => window.open_view (new Views.Favorites ()));
70 |
71 | item_direct = new ModelButton ();
72 | item_direct.text = _("Direct Messages");
73 | item_direct.clicked.connect (() => window.open_view (new Views.Direct ()));
74 |
75 | item_search = new ModelButton ();
76 | item_search.text = _("Search");
77 | item_search.clicked.connect (() => window.open_view (new Views.Search ()));
78 |
79 | item_watchlist = new ModelButton ();
80 | item_watchlist.text = _("Watchlist");
81 | item_watchlist.clicked.connect (() => Dialogs.WatchlistEditor.open ());
82 |
83 | item_settings = new ModelButton ();
84 | item_settings.text = _("Settings");
85 | item_settings.clicked.connect (() => Dialogs.Preferences.open ());
86 |
87 | grid = new Grid ();
88 | grid.orientation = Orientation.VERTICAL;
89 | grid.width_request = 200;
90 | grid.attach (list, 0, 1, 1, 1);
91 | grid.attach (item_separator, 0, 3, 1, 1);
92 | grid.attach (item_favs, 0, 4, 1, 1);
93 | grid.attach (item_direct, 0, 5, 1, 1);
94 | grid.attach (new Separator (Orientation.HORIZONTAL), 0, 6, 1, 1);
95 | grid.attach (item_refresh, 0, 7, 1, 1);
96 | grid.attach (item_search, 0, 8, 1, 1);
97 | grid.attach (item_watchlist, 0, 9, 1, 1);
98 | grid.attach (item_settings, 0, 10, 1, 1);
99 | grid.show_all ();
100 |
101 | menu = new Popover (null);
102 | menu.add (grid);
103 |
104 | get_style_context ().add_class ("button_avatar");
105 | popover = menu;
106 | add (avatar);
107 | show_all ();
108 |
109 | accounts.updated.connect (accounts_updated);
110 | accounts.switched.connect (account_switched);
111 | list.row_activated.connect (row => {
112 | var widget = row as AccountItemView;
113 | if (widget.id == -1) {
114 | Dialogs.NewAccount.open ();
115 | return;
116 | }
117 | if (widget.id == settings.current_account)
118 | Views.Profile.open_from_id (accounts.current.id);
119 | else
120 | accounts.switch_account (widget.id);
121 |
122 | menu.popdown ();
123 | });
124 | }
125 |
126 | private void accounts_updated (GenericArray accounts) {
127 | list.forall (widget => widget.destroy ());
128 | int i = -1;
129 | accounts.foreach (account => {
130 | i++;
131 | var widget = new AccountItemView ();
132 | widget.id = i;
133 | widget.display_name.label = "@"+account.username+"";
134 | widget.instance.label = account.get_pretty_instance ();
135 | list.add (widget);
136 | });
137 |
138 | var add_account = new AccountItemView ();
139 | add_account.display_name.label = _("New Account");
140 | add_account.instance.label = _("Click to add");
141 | add_account.button.hide ();
142 | list.add (add_account);
143 | update_selection ();
144 | }
145 |
146 | private void account_switched (API.Account? account) {
147 | if (account == null)
148 | avatar.show_default (AVATAR_SIZE);
149 | else
150 | network.load_avatar (account.avatar, avatar, get_avatar_size ());
151 | }
152 |
153 | private void update_selection () {
154 | var id = settings.current_account;
155 | var row = list.get_row_at_index (id);
156 | if (row != null)
157 | list.select_row (row);
158 | }
159 |
160 | public int get_avatar_size () {
161 | return AVATAR_SIZE * get_style_context ().get_scale ();
162 | }
163 |
164 | public AccountsButton () {
165 | account_switched (accounts.current);
166 | }
167 |
168 | }
169 |
--------------------------------------------------------------------------------
/src/Widgets/AlignedLabel.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 |
3 | public class Olifant.Widgets.AlignedLabel : Label {
4 |
5 | public AlignedLabel (string text) {
6 | label = text;
7 | halign = Align.END;
8 | //margin_start = 12;
9 | }
10 |
11 | }
12 |
--------------------------------------------------------------------------------
/src/Widgets/AttachmentGrid.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 | using GLib;
3 |
4 | public class Olifant.Widgets.AttachmentGrid : Grid {
5 |
6 | private int counter = 0;
7 | private bool allow_editing;
8 |
9 | construct {
10 | hexpand = true;
11 | }
12 |
13 | public AttachmentGrid (bool edit = false) {
14 | allow_editing = edit;
15 | }
16 |
17 | public void append (API.Attachment attachment) {
18 | var widget = new ImageAttachment (attachment);
19 | attach_widget (widget);
20 | }
21 | public void append_widget (ImageAttachment widget) {
22 | attach_widget (widget);
23 | }
24 |
25 | private void attach_widget (ImageAttachment widget) {
26 | attach (widget, counter++, 1);
27 | column_spacing = row_spacing = 12;
28 | show_all ();
29 | }
30 |
31 | public void pack (API.Attachment[] attachments) {
32 | clear ();
33 | var len = attachments.length;
34 |
35 | if (len == 1) {
36 | var widget = new ImageAttachment (attachments[0]);
37 | attach_widget (widget);
38 | widget.fill_parent ();
39 | }
40 | else {
41 | foreach (API.Attachment attachment in attachments) {
42 | append (attachment);
43 | }
44 | }
45 | }
46 |
47 | private void clear () {
48 | forall (widget => widget.destroy ());
49 | }
50 |
51 | public void select () {
52 | var filter = new Gtk.FileFilter ();
53 | filter.add_mime_type ("image/jpeg");
54 | filter.add_mime_type ("image/png");
55 | filter.add_mime_type ("image/gif");
56 | filter.add_mime_type ("video/webm");
57 | filter.add_mime_type ("video/mp4");
58 |
59 | var chooser = new Gtk.FileChooserDialog (
60 | _("Select media files to add"),
61 | null,
62 | Gtk.FileChooserAction.OPEN,
63 | _("_Cancel"),
64 | Gtk.ResponseType.CANCEL,
65 | _("_Open"),
66 | Gtk.ResponseType.ACCEPT);
67 |
68 | chooser.select_multiple = true;
69 | chooser.set_filter (filter);
70 |
71 | if (chooser.run () == Gtk.ResponseType.ACCEPT) {
72 | show ();
73 | foreach (unowned string uri in chooser.get_uris ()) {
74 | var widget = new ImageAttachment.upload (uri);
75 | append_widget (widget);
76 | }
77 | }
78 | chooser.close ();
79 | }
80 |
81 | public string get_uri_array () {
82 | var str = "";
83 | get_children ().@foreach (w => {
84 | var widget = (ImageAttachment) w;
85 | if (widget.attachment != null)
86 | str += "&media_ids[]=%s".printf (widget.attachment.id);
87 | });
88 | return str;
89 | }
90 |
91 | }
92 |
--------------------------------------------------------------------------------
/src/Widgets/ImageAttachment.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 | using Gdk;
3 |
4 | public class Olifant.Widgets.ImageAttachment : DrawingArea {
5 |
6 | public API.Attachment? attachment;
7 | private bool editable = false;
8 | private bool fill = false;
9 |
10 | private Pixbuf? pixbuf = null;
11 | private static Pixbuf? pixbuf_error;
12 | private int center_x = 0;
13 | private int center_y = 0;
14 |
15 | private Soup.Message? image_request;
16 |
17 | construct {
18 | if (pixbuf_error == null)
19 | pixbuf_error = IconTheme.get_default ().load_icon ("image-missing", 32, IconLookupFlags.GENERIC_FALLBACK);
20 |
21 | hexpand = true;
22 | vexpand = true;
23 | add_events (EventMask.BUTTON_PRESS_MASK);
24 | draw.connect (on_draw);
25 | button_press_event.connect (on_clicked);
26 | }
27 |
28 | ~ImageAttachment () {
29 | network.cancel_request (image_request);
30 | }
31 |
32 | public ImageAttachment (API.Attachment obj) {
33 | attachment = obj;
34 | image_request = network.load_pixbuf (attachment.preview_url, on_ready);
35 | set_size_request (32, 128);
36 | show_all ();
37 | }
38 |
39 | public ImageAttachment.upload (string uri) {
40 | halign = Align.START;
41 | valign = Align.START;
42 | set_size_request (100, 100);
43 | show_all ();
44 | try {
45 | GLib.File file = File.new_for_uri (uri);
46 | uint8[] contents;
47 | file.load_contents (null, out contents, null);
48 | var type = file.query_info (GLib.FileAttribute.STANDARD_CONTENT_TYPE, 0);
49 | var mime = type.get_content_type ();
50 |
51 | info ("Uploading %s (%s)", uri, mime);
52 | show ();
53 |
54 | var buffer = new Soup.Buffer.take (contents);
55 | var multipart = new Soup.Multipart (Soup.FORM_MIME_TYPE_MULTIPART);
56 | multipart.append_form_file ("file", mime.replace ("/", "."), mime, buffer);
57 | var url = "%s/api/v1/media".printf (accounts.formal.instance);
58 | var msg = Soup.Form.request_new_from_multipart (url, multipart);
59 |
60 | network.queue (msg, (sess, mess) => {
61 | var root = network.parse (mess);
62 | attachment = API.Attachment.parse (root);
63 | editable = true;
64 | invalidate ();
65 | network.load_pixbuf (attachment.preview_url, on_ready);
66 | info ("Uploaded media: %s", attachment.id);
67 | });
68 | }
69 | catch (Error e) {
70 | app.error (_("File read error"), _("Can't read file %s: %s").printf (uri, e.message));
71 | warning (e.message);
72 | }
73 | }
74 |
75 | private void on_ready (Pixbuf? result) {
76 | if (result == null)
77 | result = pixbuf_error;
78 |
79 | pixbuf = result;
80 | invalidate ();
81 | }
82 |
83 | private void invalidate () {
84 | var w = get_allocated_width ();
85 | var h = get_allocated_height ();
86 | if (fill) {
87 | var h_scaled = (pixbuf.height * w) / pixbuf.width;
88 | if (h_scaled > pixbuf.height) {
89 | halign = Align.START;
90 | set_size_request (pixbuf.width, pixbuf.height);
91 | }
92 | else {
93 | halign = Align.FILL;
94 | set_size_request (1, h_scaled);
95 | }
96 | }
97 | queue_draw_area (0, 0, w, h);
98 | }
99 |
100 | private void calc_center (int w, int h, int size_w, int size_h, Cairo.Context? ctx = null) {
101 | center_x = w/2 - size_w/2;
102 | center_y = h/2 - size_h/2;
103 |
104 | if (ctx != null)
105 | ctx.translate (center_x, center_y);
106 | }
107 |
108 | public void fill_parent () {
109 | fill = true;
110 | size_allocate.connect (on_size_changed);
111 | on_size_changed ();
112 | }
113 |
114 | public void on_size_changed () {
115 | if (fill && pixbuf != null)
116 | invalidate ();
117 | }
118 |
119 | private bool on_draw (Widget widget, Cairo.Context ctx) {
120 | var w = widget.get_allocated_width ();
121 | var h = widget.get_allocated_height ();
122 | if (halign == Align.START) {
123 | w = pixbuf.width;
124 | h = pixbuf.height;
125 | }
126 |
127 | //Draw frame
128 | ctx.set_source_rgba (1, 1, 1, 1);
129 | Drawing.draw_rounded_rect (ctx, 0, 0, w, h, 4);
130 | ctx.fill ();
131 |
132 | //Draw image, spinner or an error icon
133 | if (pixbuf != null) {
134 | var thumbnail = Drawing.make_pixbuf_thumbnail (pixbuf, w, h, fill);
135 | Drawing.draw_rounded_rect (ctx, 0, 0, w, h, 4);
136 | calc_center (w, h, thumbnail.width, thumbnail.height, ctx);
137 | Gdk.cairo_set_source_pixbuf (ctx, thumbnail, 0, 0);
138 | ctx.fill ();
139 | }
140 | else {
141 | calc_center (w, h, 32, 32, ctx);
142 | set_state_flags (StateFlags.CHECKED, false); //Y U NO SPIN
143 | get_style_context ().render_activity (ctx, 0, 0, 32, 32);
144 | }
145 |
146 | return false;
147 | }
148 |
149 | private bool on_clicked (EventButton ev){
150 | switch (ev.button) {
151 | case 3:
152 | return open_menu (ev.button, ev.time);
153 | case 1:
154 | return Desktop.open_uri (attachment.url);
155 | }
156 | return false;
157 | }
158 |
159 | public virtual bool open_menu (uint button, uint32 time) {
160 | var menu = new Gtk.Menu ();
161 |
162 | if (editable && attachment != null) {
163 | var item_remove = new Gtk.MenuItem.with_label (_("Remove"));
164 | item_remove.activate.connect (() => destroy ());
165 | menu.add (item_remove);
166 | menu.add (new Gtk.SeparatorMenuItem ());
167 | }
168 |
169 | var item_open_link = new Gtk.MenuItem.with_label (_("Open in Browser"));
170 | item_open_link.activate.connect (() => Desktop.open_uri (attachment.url));
171 | var item_copy_link = new Gtk.MenuItem.with_label (_("Copy Link"));
172 | item_copy_link.activate.connect (() => Desktop.copy (attachment.url));
173 | var item_download = new Gtk.MenuItem.with_label (_("Download"));
174 | item_download.activate.connect (() => Desktop.download_file (attachment.url));
175 | menu.add (item_open_link);
176 | if (attachment.type != "unknown")
177 | menu.add (item_download);
178 | menu.add (new Gtk.SeparatorMenuItem ());
179 | menu.add (item_copy_link);
180 |
181 | menu.show_all ();
182 | menu.attach_widget = this;
183 | menu.popup_at_pointer ();
184 | return true;
185 | }
186 |
187 | }
188 |
--------------------------------------------------------------------------------
/src/Widgets/ImageToggleButton.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 |
3 | public class Olifant.Widgets.ImageToggleButton : ToggleButton {
4 |
5 | public Image icon;
6 | public IconSize size;
7 |
8 | public ImageToggleButton (string icon_name, IconSize icon_size = IconSize.BUTTON) {
9 | valign = Align.CENTER;
10 | size = icon_size;
11 | icon = new Image.from_icon_name (icon_name, icon_size);
12 | add (icon);
13 | show_all ();
14 | }
15 |
16 | public void set_action () {
17 | can_default = false;
18 | set_focus_on_click (false);
19 | get_style_context ().add_class (Gtk.STYLE_CLASS_FLAT);
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/src/Widgets/Notification.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 | using Granite;
3 |
4 | public class Olifant.Widgets.Notification : Grid {
5 |
6 | private API.Notification notification;
7 |
8 | public Separator? separator;
9 | private Image image;
10 | private Widgets.RichLabel label;
11 | private Widgets.Status? status_widget;
12 | private Button dismiss;
13 |
14 | construct {
15 | margin = 6;
16 |
17 | image = new Image.from_icon_name ("notification-symbolic", IconSize.BUTTON);
18 | image.margin_start = 32;
19 | image.margin_end = 6;
20 | label = new RichLabel (_("Unknown Notification"));
21 | label.hexpand = true;
22 | label.halign = Align.START;
23 | dismiss = new Button.from_icon_name ("window-close-symbolic", IconSize.BUTTON);
24 | dismiss.get_style_context ().add_class (Gtk.STYLE_CLASS_FLAT);
25 | dismiss.tooltip_text = _("Dismiss");
26 | dismiss.clicked.connect (() => {
27 | notification.dismiss ();
28 | destroy ();
29 | });
30 |
31 | attach (image, 1, 2);
32 | attach (label, 2, 2);
33 | attach (dismiss, 3, 2);
34 | show_all ();
35 | }
36 |
37 | public Notification (API.Notification _notification) {
38 | notification = _notification;
39 | image.icon_name = notification.type.get_icon ();
40 | label.set_label (notification.type.get_desc (notification.account));
41 | get_style_context ().add_class ("notification");
42 |
43 | if (notification.status != null)
44 | network.status_removed.connect (on_status_removed);
45 |
46 | destroy.connect (() => {
47 | if (separator != null)
48 | separator.destroy ();
49 | separator = null;
50 | status_widget = null;
51 | });
52 |
53 | if (notification.status != null){
54 | status_widget = new Widgets.Status (notification.status, true);
55 | status_widget.is_notification = true;
56 | status_widget.button_press_event.connect (status_widget.open);
57 | status_widget.avatar.button_press_event.connect (status_widget.on_avatar_clicked);
58 | attach (status_widget, 1, 3, 3, 1);
59 | }
60 |
61 | if (notification.type == API.NotificationType.FOLLOW_REQUEST) {
62 | var box = new Box (Orientation.HORIZONTAL, 6);
63 | box.margin_start = 32 + 16 + 8;
64 | var accept = new Button.with_label (_("Accept"));
65 | box.pack_start (accept, false, false, 0);
66 | var reject = new Button.with_label (_("Reject"));
67 | box.pack_start (reject, false, false, 0);
68 |
69 | attach (box, 1, 3, 3, 1);
70 | box.show_all ();
71 |
72 | accept.clicked.connect (() => {
73 | destroy ();
74 | notification.accept_follow_request ();
75 | });
76 | reject.clicked.connect (() => {
77 | destroy ();
78 | notification.reject_follow_request ();
79 | });
80 | }
81 | }
82 |
83 | private void on_status_removed (string id) {
84 | if (id == notification.status.id) {
85 | if (notification.type == API.NotificationType.WATCHLIST)
86 | notification.dismiss ();
87 |
88 | destroy ();
89 | }
90 | }
91 |
92 | }
93 |
--------------------------------------------------------------------------------
/src/Widgets/RichLabel.vala:
--------------------------------------------------------------------------------
1 | using Gtk;
2 |
3 | public class Olifant.Widgets.RichLabel : Label {
4 |
5 | public weak API.Mention[]? mentions;
6 |
7 | public RichLabel (string text) {
8 | set_label (text);
9 | set_use_markup (true);
10 | activate_link.connect (open_link);
11 | }
12 |
13 | public static string escape_entities (string content) {
14 | return content
15 | .replace (" ", " ")
16 | .replace ("'", "'");
17 | }
18 |
19 | public static string restore_entities (string content) {
20 | return content
21 | .replace ("&", "&")
22 | .replace ("<", "<")
23 | .replace (">", ">")
24 | .replace ("'", "'")
25 | .replace (""", "\"");
26 | }
27 |
28 | public new void set_label (string text) {
29 | base.set_markup (Html.simplify(escape_entities (text)));
30 | }
31 |
32 | public void wrap_words () {
33 | halign = Align.START;
34 | single_line_mode = false;
35 | set_line_wrap (true);
36 | wrap_mode = Pango.WrapMode.WORD_CHAR;
37 | justify = Justification.LEFT;
38 | xalign = 0;
39 | }
40 |
41 | public bool open_link (string url) {
42 | if (mentions != null){
43 | foreach (API.Mention mention in mentions) {
44 | if (url == mention.url){
45 | Views.Profile.open_from_id (mention.id);
46 | return true;
47 | }
48 | }
49 | }
50 |
51 | if ("/tags/" in url) {
52 | var encoded = url.split("/tags/")[1];
53 | var hashtag = Soup.URI.decode (encoded);
54 | window.open_view (new Views.Hashtag (hashtag));
55 | return true;
56 | }
57 |
58 | if ("/tag/" in url) {
59 | var encoded = url.split("/tag/")[1];
60 | var hashtag = Soup.URI.decode (encoded);
61 | window.open_view (new Views.Hashtag (hashtag));
62 | return true;
63 | }
64 |
65 | if ("@" in url || "tags" in url) {
66 | var query = Soup.URI.encode (url, null);
67 | var msg_url="";
68 | if (accounts.currentInstance.is_mastodon_v3 ())
69 | msg_url = "%s/api/v2/search?q=%s&resolve=true".printf (accounts.formal.instance, query);
70 | else
71 | msg_url = "%s/api/v1/search?q=%s&resolve=true".printf (accounts.formal.instance, query);
72 | var msg = new Soup.Message("GET", msg_url);
73 | msg.priority = Soup.MessagePriority.HIGH;
74 | network.inject (msg, Network.INJECT_TOKEN);
75 | network.queue (msg, (sess, mess) => {
76 | var root = network.parse (mess);
77 | var accounts = root.get_array_member ("accounts");
78 | var statuses = root.get_array_member ("statuses");
79 | var hashtags = root.get_array_member ("hashtags");
80 |
81 | if (accounts.get_length () > 0) {
82 | var item = accounts.get_object_element (0);
83 | var obj = API.Account.parse (item);
84 | window.open_view (new Views.Profile (obj));
85 | }
86 | else if (statuses.get_length () > 0) {
87 | var item = accounts.get_object_element (0);
88 | var obj = API.Status.parse (item);
89 | window.open_view (new Views.ExpandedStatus (obj));
90 | }
91 | else if (hashtags.get_length () > 0) {
92 | var item = accounts.get_object_element (0);
93 | var obj = API.Tag.parse (item);
94 | window.open_view (new Views.Hashtag (obj.name));
95 | }
96 | else {
97 | Desktop.open_uri (url);
98 | }
99 |
100 | }, (status, reason) => {
101 | open_link_fallback (url, reason);
102 | });
103 | }
104 | else {
105 | Desktop.open_uri (url);
106 | }
107 | return true;
108 | }
109 |
110 | public bool open_link_fallback (string url, string reason) {
111 | warning ("Can't resolve url: " + url);
112 | warning ("Reason: " + reason);
113 |
114 | var toast = window.toast;
115 | toast.title = reason;
116 | toast.set_default_action (_("Open in Browser"));
117 | ulong signal_id = 0;
118 | signal_id = toast.default_action.connect (() => {
119 | Desktop.open_uri (url);
120 | toast.disconnect (signal_id);
121 | });
122 | toast.send_notification ();
123 | return true;
124 | }
125 |
126 | }
127 |
--------------------------------------------------------------------------------