Refactor accounts (#284)
This commit is contained in:
parent
0e65502349
commit
7d85bc5660
28
README.md
28
README.md
|
@ -13,22 +13,18 @@ Simple [Mastodon](https://github.com/tootsuite/mastodon) client for Linux
|
|||
|
||||
1. Make sure you have these dependencies:
|
||||
|
||||
Package Name | Required Version
|
||||
--- |:---:
|
||||
meson | 0.50
|
||||
valac | 0.48
|
||||
libglib-2.0-dev | 2.30.0
|
||||
libjson-glib-dev | 1.4.4
|
||||
libxml2-dev | 2.9.10
|
||||
libgee-0.8-dev | 0.8.5
|
||||
libsoup2.4-dev | 2.64
|
||||
libgtk-3-dev | 3.22.0
|
||||
|
||||
The following packages are also required, but will be installed automatically if not present in your system:
|
||||
|
||||
Package Name | Required Version
|
||||
--- |:---:
|
||||
libhandy-1.0-dev | 1.0.0
|
||||
Package Name | Required Version | Notes
|
||||
--- |:---:| ---
|
||||
meson | 0.50 |
|
||||
valac | 0.48 |
|
||||
libglib-2.0-dev | 2.30 |
|
||||
libjson-glib-dev | 1.4.4 |
|
||||
libxml2-dev | 2.9.10 |
|
||||
libgee-0.8-dev | 0.8.5 |
|
||||
libsoup2.4-dev | 2.64 |
|
||||
libgtk-3-dev | 3.22 |
|
||||
libhandy-1.0-dev | 1.0.0 | Will be attempted to install automatically if not present.
|
||||
libsecret-1-dev | 0.20 | Optional. Used for storing accounts using Secret Service API.
|
||||
|
||||
|
||||
2. Run `install.sh` in the project directory. The app will launch automatically on success.
|
||||
|
|
82
meson.build
82
meson.build
|
@ -1,37 +1,11 @@
|
|||
project('com.github.bleakgrey.tootle', 'vala', 'c', version: '1.0.0')
|
||||
|
||||
config = configuration_data()
|
||||
config.set('EXEC_NAME', meson.project_name())
|
||||
config.set('GETTEXT_PACKAGE', meson.project_name())
|
||||
config.set('RESOURCES', '/' + '/'.join(meson.project_name().split('.')) + '/' )
|
||||
config.set('VERSION', meson.project_version())
|
||||
config.set('PREFIX', get_option('prefix'))
|
||||
config.set('NAME', 'Tootle')
|
||||
config.set('WEBSITE', 'https://github.com/bleakgrey/tootle')
|
||||
config.set('SUPPORT_WEBSITE', 'https://github.com/bleakgrey/tootle/issues')
|
||||
config.set('COPYRIGHT', '© 2018-2020 bleak_grey')
|
||||
|
||||
gnome = import('gnome')
|
||||
i18n = import('i18n')
|
||||
|
||||
add_global_arguments([
|
||||
'-DGETTEXT_PACKAGE="@0@"'.format(meson.project_name()),
|
||||
],
|
||||
language: 'c',
|
||||
)
|
||||
|
||||
asresources = gnome.compile_resources(
|
||||
'as-resources', 'data/gresource.xml',
|
||||
source_dir: 'data',
|
||||
c_name: 'as'
|
||||
)
|
||||
|
||||
build_file = configure_file(
|
||||
input: 'src/Build.vala',
|
||||
output: 'Build.vala',
|
||||
configuration: config
|
||||
)
|
||||
|
||||
libhandy_dep = dependency('libhandy-1', version: '>= 1.0', required: false)
|
||||
if not libhandy_dep.found()
|
||||
libhandy = subproject(
|
||||
|
@ -51,21 +25,41 @@ if not libhandy_dep.found()
|
|||
)
|
||||
endif
|
||||
|
||||
executable(
|
||||
meson.project_name(),
|
||||
asresources,
|
||||
build_file,
|
||||
config = configuration_data()
|
||||
config.set('EXEC_NAME', meson.project_name())
|
||||
config.set('GETTEXT_PACKAGE', meson.project_name())
|
||||
config.set('RESOURCES', '/' + '/'.join(meson.project_name().split('.')) + '/' )
|
||||
config.set('VERSION', meson.project_version())
|
||||
config.set('PREFIX', get_option('prefix'))
|
||||
config.set('NAME', 'Tootle')
|
||||
config.set('WEBSITE', 'https://github.com/bleakgrey/tootle')
|
||||
config.set('SUPPORT_WEBSITE', 'https://github.com/bleakgrey/tootle/issues')
|
||||
config.set('COPYRIGHT', '© 2018-2020 bleak_grey')
|
||||
config.set('ACCOUNT_STORE', 'FileAccountStore')
|
||||
|
||||
gnome = import('gnome')
|
||||
i18n = import('i18n')
|
||||
|
||||
asresources = gnome.compile_resources(
|
||||
'as-resources', 'data/gresource.xml',
|
||||
source_dir: 'data',
|
||||
c_name: 'as'
|
||||
)
|
||||
|
||||
sources = files(
|
||||
'src/Application.vala',
|
||||
'src/Desktop.vala',
|
||||
'src/Drawing.vala',
|
||||
'src/Html.vala',
|
||||
'src/Request.vala',
|
||||
'src/DateTime.vala',
|
||||
'src/InstanceAccount.vala',
|
||||
'src/Services/Accounts/InstanceAccount.vala',
|
||||
'src/Services/Accounts/AccountStore.vala',
|
||||
'src/Services/Accounts/FileAccountStore.vala',
|
||||
'src/Services/Accounts/IAccountListener.vala',
|
||||
'src/Services/Accounts/Mastodon/MastodonAccount.vala',
|
||||
'src/Services/Streams.vala',
|
||||
'src/Services/Settings.vala',
|
||||
'src/Services/Accounts.vala',
|
||||
'src/Services/IAccountListener.vala',
|
||||
'src/Services/IStreamListener.vala',
|
||||
'src/Services/Cache.vala',
|
||||
'src/Services/Network.vala',
|
||||
|
@ -122,6 +116,27 @@ executable(
|
|||
'src/Views/Hashtag.vala',
|
||||
'src/Views/Lists.vala',
|
||||
'src/Views/List.vala',
|
||||
)
|
||||
|
||||
libsecret_dep = dependency('libsecret-1', required: false)
|
||||
if not libsecret_dep.found()
|
||||
warning('Keyring support disabled. Accounts will be stored in a text file.')
|
||||
else
|
||||
config.set('ACCOUNT_STORE','SecretAccountStore')
|
||||
sources += 'src/Services/Accounts/'+config.get('ACCOUNT_STORE')+'.vala'
|
||||
endif
|
||||
|
||||
build_file = configure_file(
|
||||
input: 'src/Build.vala.in',
|
||||
output: 'Build.vala',
|
||||
configuration: config
|
||||
)
|
||||
|
||||
executable(
|
||||
meson.project_name(),
|
||||
asresources,
|
||||
build_file,
|
||||
sources,
|
||||
dependencies: [
|
||||
dependency('gtk+-3.0', version: '>=3.22.0'),
|
||||
dependency('glib-2.0', version: '>=2.30.0'),
|
||||
|
@ -130,6 +145,7 @@ executable(
|
|||
dependency('json-glib-1.0', version: '>=1.4.4'),
|
||||
dependency('libxml-2.0'),
|
||||
libhandy_dep,
|
||||
libsecret_dep,
|
||||
],
|
||||
install: true,
|
||||
)
|
||||
|
|
|
@ -11,12 +11,6 @@ public class Tootle.API.Notification : Entity, Widgetizable {
|
|||
}
|
||||
|
||||
public Soup.Message? dismiss () {
|
||||
if (kind == NotificationType.WATCHLIST) {
|
||||
if (accounts.active.cached_notifications.remove (this))
|
||||
accounts.save ();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kind == NotificationType.FOLLOW_REQUEST)
|
||||
return reject_follow_request ();
|
||||
|
||||
|
|
|
@ -4,8 +4,7 @@ public enum Tootle.API.NotificationType {
|
|||
REBLOG_REMOTE_USER, // Internal
|
||||
FAVOURITE,
|
||||
FOLLOW,
|
||||
FOLLOW_REQUEST, // Internal
|
||||
WATCHLIST; // Internal
|
||||
FOLLOW_REQUEST; // Internal
|
||||
|
||||
public string to_string () {
|
||||
switch (this) {
|
||||
|
@ -21,8 +20,6 @@ public enum Tootle.API.NotificationType {
|
|||
return "follow";
|
||||
case FOLLOW_REQUEST:
|
||||
return "follow_request";
|
||||
case WATCHLIST:
|
||||
return "watchlist";
|
||||
default:
|
||||
warning (@"Unknown notification type: $this");
|
||||
return "";
|
||||
|
@ -43,8 +40,6 @@ public enum Tootle.API.NotificationType {
|
|||
return FOLLOW;
|
||||
case "follow_request":
|
||||
return FOLLOW_REQUEST;
|
||||
case "watchlist":
|
||||
return WATCHLIST;
|
||||
default:
|
||||
throw new Oopsie.INSTANCE (@"Unknown notification type: $str");
|
||||
}
|
||||
|
@ -64,8 +59,6 @@ public enum Tootle.API.NotificationType {
|
|||
return _("<span underline=\"none\"><a href=\"%s\">%s</a> now follows you</span>").printf (account.url, account.display_name);
|
||||
case FOLLOW_REQUEST:
|
||||
return _("<span underline=\"none\"><a href=\"%s\">%s</a> wants to follow you</span>").printf (account.url, account.display_name);
|
||||
case WATCHLIST:
|
||||
return _("<span underline=\"none\"><a href=\"%s\">%s</a> posted a status</span>").printf (account.url, account.display_name);
|
||||
default:
|
||||
warning (@"Unknown notification type: $this");
|
||||
return "";
|
||||
|
@ -75,7 +68,6 @@ public enum Tootle.API.NotificationType {
|
|||
public string get_icon () {
|
||||
switch (this) {
|
||||
case MENTION:
|
||||
case WATCHLIST:
|
||||
return "user-available-symbolic";
|
||||
case REBLOG:
|
||||
case REBLOG_REMOTE_USER:
|
||||
|
|
|
@ -15,7 +15,7 @@ namespace Tootle {
|
|||
public static Window window_dummy;
|
||||
|
||||
public static Settings settings;
|
||||
public static Accounts accounts;
|
||||
public static AccountStore accounts;
|
||||
public static Network network;
|
||||
public static Cache cache;
|
||||
public static Streams streams;
|
||||
|
@ -26,14 +26,13 @@ namespace Tootle {
|
|||
|
||||
// These are used for the GTK Inspector
|
||||
public Settings app_settings { get {return Tootle.settings; } }
|
||||
public Accounts app_accounts { get {return Tootle.accounts; } }
|
||||
public AccountStore app_accounts { get {return Tootle.accounts; } }
|
||||
public Network app_network { get {return Tootle.network; } }
|
||||
public Cache app_cache { get {return Tootle.cache; } }
|
||||
public Streams app_streams { get {return Tootle.streams; } }
|
||||
|
||||
public signal void refresh ();
|
||||
public signal void toast (string title);
|
||||
public signal void error (string title, string? text);
|
||||
|
||||
public CssProvider css_provider = new CssProvider ();
|
||||
public CssProvider zoom_css_provider = new CssProvider ();
|
||||
|
@ -84,33 +83,36 @@ namespace Tootle {
|
|||
|
||||
protected override void startup () {
|
||||
base.startup ();
|
||||
Build.print_info ();
|
||||
Hdy.init ();
|
||||
try {
|
||||
Build.print_info ();
|
||||
Hdy.init ();
|
||||
|
||||
settings = new Settings ();
|
||||
streams = new Streams ();
|
||||
accounts = new Accounts ();
|
||||
network = new Network ();
|
||||
cache = new Cache ();
|
||||
accounts.init ();
|
||||
settings = new Settings ();
|
||||
streams = new Streams ();
|
||||
network = new Network ();
|
||||
cache = new Cache ();
|
||||
accounts = Build.get_account_store ();
|
||||
accounts.init ();
|
||||
|
||||
app.error.connect ((title, msg) => {
|
||||
inform (Gtk.MessageType.ERROR, title, msg);
|
||||
});
|
||||
css_provider.load_from_resource (@"$(Build.RESOURCES)app.css");
|
||||
StyleContext.add_provider_for_screen (Gdk.Screen.get_default (), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||
StyleContext.add_provider_for_screen (Gdk.Screen.get_default (), zoom_css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||
|
||||
window_dummy = new Window ();
|
||||
add_window (window_dummy);
|
||||
|
||||
css_provider.load_from_resource (@"$(Build.RESOURCES)app.css");
|
||||
StyleContext.add_provider_for_screen (Gdk.Screen.get_default (), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||
StyleContext.add_provider_for_screen (Gdk.Screen.get_default (), zoom_css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||
window_dummy = new Window ();
|
||||
add_window (window_dummy);
|
||||
}
|
||||
catch (Error e) {
|
||||
var msg = _("Could not start application: %s").printf (e.message);
|
||||
inform (Gtk.MessageType.ERROR, _("Error"), msg);
|
||||
error (msg);
|
||||
}
|
||||
|
||||
set_accels_for_action ("app.about", ACCEL_ABOUT);
|
||||
set_accels_for_action ("app.compose", ACCEL_NEW_POST);
|
||||
set_accels_for_action ("app.back", ACCEL_BACK);
|
||||
set_accels_for_action ("app.refresh", ACCEL_REFRESH);
|
||||
set_accels_for_action ("app.search", ACCEL_SEARCH);
|
||||
set_accels_for_action ("app.switch-timeline(0)", ACCEL_TIMELINE_0);
|
||||
set_accels_for_action ("app.switch-timeline(0)", ACCEL_TIMELINE_0); //TODO: There's no action for handling these
|
||||
set_accels_for_action ("app.switch-timeline(1)", ACCEL_TIMELINE_1);
|
||||
set_accels_for_action ("app.switch-timeline(2)", ACCEL_TIMELINE_2);
|
||||
set_accels_for_action ("app.switch-timeline(3)", ACCEL_TIMELINE_3);
|
||||
|
@ -138,10 +140,11 @@ namespace Tootle {
|
|||
}
|
||||
|
||||
public void present_window () {
|
||||
if (accounts.is_empty ()) {
|
||||
if (accounts.saved.is_empty) {
|
||||
message ("Presenting NewAccount dialog");
|
||||
if (new_account_window == null)
|
||||
new Dialogs.NewAccount ();
|
||||
new_account_window.present ();
|
||||
}
|
||||
else {
|
||||
message ("Presenting MainWindow");
|
||||
|
@ -152,7 +155,7 @@ namespace Tootle {
|
|||
}
|
||||
|
||||
public bool on_window_closed () {
|
||||
if (!settings.work_in_background || accounts.is_empty ())
|
||||
if (!settings.work_in_background || accounts.saved.is_empty)
|
||||
app.remove_window (window_dummy);
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -38,4 +38,8 @@ public class Build {
|
|||
return GLib.Environment.get_os_info (key) ?? "Unknown";
|
||||
}
|
||||
|
||||
public static Tootle.AccountStore get_account_store () {
|
||||
return new Tootle.@ACCOUNT_STORE@ ();
|
||||
}
|
||||
|
||||
}
|
|
@ -21,7 +21,7 @@ public class Tootle.Desktop {
|
|||
}
|
||||
catch (Error e){
|
||||
warning (@"xdg-open failed too: $(e.message)");
|
||||
app.error (_("Open this URL in your browser"), uri);
|
||||
app.inform (Gtk.MessageType.WARNING, _("Open this URL in your browser"), uri);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -93,16 +93,15 @@ public class Tootle.Dialogs.NewAccount: Hdy.Window {
|
|||
|
||||
async void step () throws Error {
|
||||
if (stack.visible_child == done_step) {
|
||||
if (accounts.is_empty ())
|
||||
accounts.switch_account (0);
|
||||
|
||||
app.present_window ();
|
||||
destroy ();
|
||||
return;
|
||||
}
|
||||
|
||||
if (stack.visible_child == instance_step)
|
||||
if (stack.visible_child == instance_step) {
|
||||
setup_instance ();
|
||||
yield accounts.guess_backend (account);
|
||||
}
|
||||
|
||||
if (account.client_secret == null || account.client_id == null) {
|
||||
yield register_client ();
|
||||
|
@ -113,7 +112,7 @@ public class Tootle.Dialogs.NewAccount: Hdy.Window {
|
|||
}
|
||||
|
||||
void setup_instance () throws Error {
|
||||
message ("Checking instance URL");
|
||||
message ("Checking instance");
|
||||
|
||||
var str = instance_entry.text
|
||||
.replace ("/", "")
|
||||
|
@ -156,7 +155,7 @@ public class Tootle.Dialogs.NewAccount: Hdy.Window {
|
|||
}
|
||||
|
||||
async void request_token () throws Error {
|
||||
if (code_entry.text.char_count () <= 10)
|
||||
if (code_entry.text.char_count () <= 1)
|
||||
throw new Oopsie.USER (_("Please enter a valid authorization code"));
|
||||
|
||||
message ("Requesting access token");
|
||||
|
@ -175,20 +174,18 @@ public class Tootle.Dialogs.NewAccount: Hdy.Window {
|
|||
if (account.access_token == null)
|
||||
throw new Oopsie.INSTANCE (_("Instance failed to authorize the access token"));
|
||||
|
||||
message ("Trying to get the user profile");
|
||||
var profile_req = new Request.GET ("/api/v1/accounts/verify_credentials")
|
||||
.with_account (account);
|
||||
yield profile_req.await ();
|
||||
yield account.verify_credentials ();
|
||||
|
||||
var node = network.parse_node (profile_req);
|
||||
var profile = API.Account.from (node);
|
||||
account.patch (profile);
|
||||
account = accounts.create_account (account.to_json ());
|
||||
|
||||
message ("Saving account");
|
||||
accounts.add (account);
|
||||
|
||||
hello_label.label = _("Hello, %s!").printf (account.handle);
|
||||
stack.visible_child = done_step;
|
||||
|
||||
message ("Switching to account");
|
||||
accounts.activate (account);
|
||||
}
|
||||
|
||||
public void redirect (string uri) {
|
||||
|
@ -231,3 +228,4 @@ public class Tootle.Dialogs.NewAccount: Hdy.Window {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -1,141 +0,0 @@
|
|||
using Gee;
|
||||
|
||||
public class Tootle.Accounts : GLib.Object {
|
||||
|
||||
private string dir_path;
|
||||
private string file_path;
|
||||
|
||||
public ArrayList<InstanceAccount> saved { get; set; default = new ArrayList<InstanceAccount> (); }
|
||||
public InstanceAccount? active { get; set; }
|
||||
|
||||
construct {
|
||||
dir_path = @"$(GLib.Environment.get_user_config_dir ())/$(app.application_id)";
|
||||
file_path = @"$dir_path/accounts.json";
|
||||
}
|
||||
|
||||
public void switch_account (int id) {
|
||||
var acc = saved.@get (id);
|
||||
message (@"Switching to $(acc.handle)...");
|
||||
new Request.GET ("/api/v1/accounts/verify_credentials")
|
||||
.with_account (acc)
|
||||
.then ((sess, mess) => {
|
||||
var node = network.parse_node (mess);
|
||||
var updated = API.Account.from (node);
|
||||
acc.patch (updated);
|
||||
message ("OK: Token is valid");
|
||||
active = acc;
|
||||
settings.current_account = id;
|
||||
})
|
||||
.on_error ((code, reason) => {
|
||||
warning ("Token invalid!");
|
||||
app.error (
|
||||
_("Network Error"),
|
||||
_("The instance has invalidated this session. Please sign in again.\n\n%s").printf (reason)
|
||||
);
|
||||
})
|
||||
.exec ();
|
||||
}
|
||||
|
||||
public void add (InstanceAccount account) {
|
||||
message (@"Adding new account: $(account.handle)");
|
||||
saved.add (account);
|
||||
save ();
|
||||
switch_account (saved.size - 1);
|
||||
account.subscribe ();
|
||||
}
|
||||
|
||||
public void remove (InstanceAccount account) {
|
||||
account.unsubscribe ();
|
||||
saved.remove (account);
|
||||
saved.notify_property ("size");
|
||||
|
||||
if (saved.size < 1)
|
||||
active = null;
|
||||
else {
|
||||
var id = settings.current_account - 1;
|
||||
if (id > saved.size - 1)
|
||||
id = saved.size - 1;
|
||||
else if (id < saved.size - 1)
|
||||
id = 0;
|
||||
switch_account (id);
|
||||
}
|
||||
save ();
|
||||
|
||||
if (is_empty ())
|
||||
new Dialogs.NewAccount ();
|
||||
}
|
||||
|
||||
public bool is_empty () {
|
||||
return saved.size == 0;
|
||||
}
|
||||
|
||||
public void init () {
|
||||
save (false);
|
||||
load ();
|
||||
|
||||
if (!is_empty ())
|
||||
switch_account (settings.current_account);
|
||||
}
|
||||
|
||||
public void save (bool overwrite = true) {
|
||||
try {
|
||||
var dir = File.new_for_path (dir_path);
|
||||
if (!dir.query_exists ())
|
||||
dir.make_directory ();
|
||||
|
||||
var file = File.new_for_path (file_path);
|
||||
if (file.query_exists () && !overwrite)
|
||||
return;
|
||||
|
||||
var builder = new Json.Builder ();
|
||||
builder.begin_array ();
|
||||
saved.foreach ((acc) => {
|
||||
var node = acc.to_json ();
|
||||
builder.add_value (node);
|
||||
return true;
|
||||
});
|
||||
builder.end_array ();
|
||||
|
||||
var generator = new Json.Generator ();
|
||||
generator.set_root (builder.get_root ());
|
||||
var data = generator.to_data (null);
|
||||
|
||||
if (file.query_exists ())
|
||||
file.@delete ();
|
||||
|
||||
FileOutputStream stream = file.create (FileCreateFlags.PRIVATE);
|
||||
stream.write (data.data);
|
||||
message ("Saved accounts");
|
||||
}
|
||||
catch (Error e){
|
||||
warning (e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private void load () {
|
||||
try {
|
||||
uint8[] data;
|
||||
string etag;
|
||||
var file = File.new_for_path (file_path);
|
||||
file.load_contents (null, out data, out etag);
|
||||
var contents = (string) data;
|
||||
|
||||
var parser = new Json.Parser ();
|
||||
parser.load_from_data (contents, -1);
|
||||
var array = parser.get_root ().get_array ();
|
||||
|
||||
array.foreach_element ((_arr, _i, node) => {
|
||||
var account = InstanceAccount.from (node);
|
||||
if (account != null) {
|
||||
saved.add (account);
|
||||
account.subscribe ();
|
||||
}
|
||||
});
|
||||
message (@"Loaded $(saved.size) accounts");
|
||||
}
|
||||
catch (Error e){
|
||||
warning (e.message);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
using Gee;
|
||||
|
||||
public abstract class Tootle.AccountStore : GLib.Object {
|
||||
|
||||
public ArrayList<InstanceAccount> saved { get; set; default = new ArrayList<InstanceAccount> (); }
|
||||
public InstanceAccount? active { get; set; default = null; }
|
||||
|
||||
// TODO: Make settings.current_account a string
|
||||
public bool ensure_active_account () {
|
||||
var has_active = false;
|
||||
|
||||
if (!saved.is_empty) {
|
||||
if (settings.current_account > saved.size || settings.current_account <= 0)
|
||||
settings.current_account = 0;
|
||||
|
||||
var last_account = saved[settings.current_account];
|
||||
if (active != last_account) {
|
||||
activate (last_account);
|
||||
has_active = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!has_active)
|
||||
app.present_window ();
|
||||
|
||||
return has_active;
|
||||
}
|
||||
|
||||
public virtual void init () throws GLib.Error {
|
||||
Mastodon.Account.register (this);
|
||||
|
||||
load ();
|
||||
ensure_active_account ();
|
||||
}
|
||||
|
||||
public abstract void load () throws GLib.Error;
|
||||
public abstract void save () throws GLib.Error;
|
||||
public void safe_save () {
|
||||
try {
|
||||
save ();
|
||||
}
|
||||
catch (GLib.Error e) {
|
||||
warning (e.message);
|
||||
app.inform (Gtk.MessageType.ERROR, _("Error"), e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual void add (InstanceAccount account) throws GLib.Error {
|
||||
message (@"Adding new account: $(account.handle)");
|
||||
saved.add (account);
|
||||
save ();
|
||||
account.subscribe ();
|
||||
ensure_active_account ();
|
||||
}
|
||||
|
||||
public virtual void remove (InstanceAccount account) throws GLib.Error {
|
||||
message (@"Removing account: $(account.handle)");
|
||||
account.unsubscribe ();
|
||||
saved.remove (account);
|
||||
saved.notify_property ("size");
|
||||
save ();
|
||||
|
||||
var id = settings.current_account - 1;
|
||||
if (saved.size < 1)
|
||||
active = null;
|
||||
else {
|
||||
if (id > saved.size - 1)
|
||||
id = saved.size - 1;
|
||||
else if (id < saved.size - 1)
|
||||
id = 0;
|
||||
}
|
||||
settings.current_account = id;
|
||||
|
||||
ensure_active_account ();
|
||||
}
|
||||
|
||||
public void activate (InstanceAccount account) {
|
||||
message (@"Activating $(account.handle)...");
|
||||
account.verify_credentials.begin ((obj, res) => {
|
||||
try {
|
||||
account.verify_credentials.end (res);
|
||||
account.error = null;
|
||||
}
|
||||
catch (Error e) {
|
||||
warning (@"Couldn't activate account $(account.handle):");
|
||||
warning (e.message);
|
||||
account.error = e;
|
||||
}
|
||||
});
|
||||
|
||||
accounts.active = account;
|
||||
settings.current_account = accounts.saved.index_of (account);
|
||||
}
|
||||
|
||||
[Signal (detailed = true)]
|
||||
public signal InstanceAccount? create_for_backend (Json.Node node);
|
||||
|
||||
public InstanceAccount create_account (Json.Node node) throws GLib.Error {
|
||||
var obj = node.get_object ();
|
||||
var backend = obj.get_string_member ("backend");
|
||||
var handle = obj.get_string_member ("handle");
|
||||
var account = create_for_backend[backend] (node);
|
||||
if (account == null)
|
||||
throw new Oopsie.INTERNAL (@"Account $handle has unknown backend: $backend");
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
// This is a super overcomplicated way and I don't like this.
|
||||
// I just want to store an array with functions that return
|
||||
// a "string?" value and keep the first non-null one.
|
||||
//
|
||||
// I figured signals with GSignalAccumulator could be
|
||||
// useful here, but Vala doesn't support that either.
|
||||
//
|
||||
// So here we go. Vala bad. No cookie.
|
||||
public abstract class BackendTest : GLib.Object {
|
||||
|
||||
public abstract string? get_backend (Json.Object obj);
|
||||
|
||||
}
|
||||
|
||||
public Gee.ArrayList<BackendTest> backend_tests = new Gee.ArrayList<BackendTest> ();
|
||||
|
||||
public async void guess_backend (InstanceAccount account) throws GLib.Error {
|
||||
var req = new Request.GET ("/api/v1/instance")
|
||||
.with_account (account);
|
||||
yield req.await ();
|
||||
|
||||
var root = network.parse (req);
|
||||
|
||||
string? backend = null;
|
||||
backend_tests.foreach (test => {
|
||||
backend = test.get_backend (root);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (backend == null)
|
||||
throw new Oopsie.INTERNAL ("This instance is unsupported.");
|
||||
else {
|
||||
account.backend = backend;
|
||||
message (@"$(account.instance) is using $(account.backend)");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
using Gee;
|
||||
|
||||
public class Tootle.FileAccountStore : AccountStore {
|
||||
|
||||
string dir_path;
|
||||
string file_path;
|
||||
|
||||
construct {
|
||||
dir_path = @"$(GLib.Environment.get_user_config_dir ())/$(app.application_id)";
|
||||
file_path = @"$dir_path/accounts.json";
|
||||
}
|
||||
|
||||
public override void load () throws GLib.Error {
|
||||
uint8[] data;
|
||||
string etag;
|
||||
var file = File.new_for_path (file_path);
|
||||
file.load_contents (null, out data, out etag);
|
||||
var contents = (string) data;
|
||||
|
||||
var parser = new Json.Parser ();
|
||||
parser.load_from_data (contents, -1);
|
||||
var array = parser.get_root ().get_array ();
|
||||
|
||||
array.foreach_element ((arr, i, node) => {
|
||||
try {
|
||||
var account = accounts.create_account (node);
|
||||
saved.add (account);
|
||||
}
|
||||
catch (Error e) {
|
||||
warning (@"Couldn't load account $i: $(e.message)");
|
||||
}
|
||||
});
|
||||
|
||||
message (@"Loaded $(saved.size) accounts");
|
||||
}
|
||||
|
||||
public override void save () throws GLib.Error {
|
||||
var dir = File.new_for_path (dir_path);
|
||||
if (!dir.query_exists ())
|
||||
dir.make_directory_with_parents ();
|
||||
|
||||
var file = File.new_for_path (file_path);
|
||||
|
||||
var builder = new Json.Builder ();
|
||||
builder.begin_array ();
|
||||
saved.foreach ((acc) => {
|
||||
var node = acc.to_json ();
|
||||
builder.add_value (node);
|
||||
return true;
|
||||
});
|
||||
builder.end_array ();
|
||||
|
||||
var generator = new Json.Generator ();
|
||||
generator.set_root (builder.get_root ());
|
||||
var data = generator.to_data (null);
|
||||
|
||||
if (file.query_exists ())
|
||||
file.@delete ();
|
||||
|
||||
FileOutputStream stream = file.create (FileCreateFlags.PRIVATE);
|
||||
stream.write (data.data);
|
||||
|
||||
message (@"Saved $(saved.size) accounts");
|
||||
}
|
||||
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
[Deprecated]
|
||||
public interface Tootle.IAccountListener : GLib.Object {
|
||||
|
||||
//TODO: Refactor into AccountHolder
|
||||
|
||||
protected void account_listener_init () {
|
||||
accounts.notify["active"].connect (_on_active_acc_update);
|
||||
accounts.saved.notify["size"].connect (_on_saved_accs_update);
|
|
@ -3,10 +3,12 @@ using Gee;
|
|||
|
||||
public class Tootle.InstanceAccount : API.Account, IStreamListener {
|
||||
|
||||
public string? backend { set; get; }
|
||||
public string? instance { get; set; }
|
||||
public string? client_id { get; set; }
|
||||
public string? client_secret { get; set; }
|
||||
public string? access_token { get; set; }
|
||||
public Error? error { get; set; }
|
||||
|
||||
public int64 last_seen_notification { get; set; default = 0; }
|
||||
public bool has_unread_notifications { get; set; default = false; }
|
||||
|
@ -18,30 +20,22 @@ public class Tootle.InstanceAccount : API.Account, IStreamListener {
|
|||
owned get { return @"@$username@$domain"; }
|
||||
}
|
||||
|
||||
public new static InstanceAccount from (Json.Node node) throws Error {
|
||||
return Entity.from_json (typeof (InstanceAccount), node) as InstanceAccount;
|
||||
}
|
||||
|
||||
public InstanceAccount () {
|
||||
construct {
|
||||
on_notification.connect (show_notification);
|
||||
}
|
||||
~InstanceAccount () {
|
||||
unsubscribe ();
|
||||
}
|
||||
|
||||
public InstanceAccount.empty (string instance){
|
||||
Object (id: "", instance: instance);
|
||||
}
|
||||
|
||||
public InstanceAccount.from_account (API.Account account) {
|
||||
Object (id: account.id);
|
||||
patch (account);
|
||||
~InstanceAccount () {
|
||||
unsubscribe ();
|
||||
}
|
||||
|
||||
public bool is_current () {
|
||||
return accounts.active.access_token == access_token;
|
||||
}
|
||||
|
||||
// TODO: This should be IStreamable
|
||||
public string get_stream_url () {
|
||||
return @"$instance/api/v1/streaming/?stream=user&access_token=$access_token";
|
||||
}
|
||||
|
@ -54,6 +48,17 @@ public class Tootle.InstanceAccount : API.Account, IStreamListener {
|
|||
streams.unsubscribe (stream, this);
|
||||
}
|
||||
|
||||
public async void verify_credentials () throws Error {
|
||||
var req = new Request.GET ("/api/v1/accounts/verify_credentials").with_account (this);
|
||||
yield req.await ();
|
||||
|
||||
var node = network.parse_node (req);
|
||||
var updated = API.Account.from (node);
|
||||
patch (updated);
|
||||
|
||||
message (@"$handle: profile updated");
|
||||
}
|
||||
|
||||
public async Entity resolve (string url) throws Error {
|
||||
message (@"Resolving URL: \"$url\"...");
|
||||
var results = yield API.SearchResults.request (url, this);
|
||||
|
@ -62,6 +67,7 @@ public class Tootle.InstanceAccount : API.Account, IStreamListener {
|
|||
return entity;
|
||||
}
|
||||
|
||||
// TODO: notification actions
|
||||
void show_notification (API.Notification obj) {
|
||||
var title = HtmlUtils.remove_tags (obj.kind.get_desc (obj.account));
|
||||
var notification = new GLib.Notification (title);
|
||||
|
@ -74,11 +80,6 @@ public class Tootle.InstanceAccount : API.Account, IStreamListener {
|
|||
}
|
||||
|
||||
app.send_notification (app.application_id + ":" + obj.id.to_string (), notification);
|
||||
|
||||
if (obj.kind == API.NotificationType.WATCHLIST) {
|
||||
cached_notifications.add (obj);
|
||||
accounts.save ();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
public class Tootle.Mastodon.Account : InstanceAccount {
|
||||
|
||||
public const string BACKEND = "Mastodon";
|
||||
|
||||
class Test : AccountStore.BackendTest {
|
||||
|
||||
public override string? get_backend (Json.Object obj) {
|
||||
return BACKEND; // Always treat instances as compatible with Mastodon
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static void register (AccountStore store) {
|
||||
store.backend_tests.add (new Test ());
|
||||
store.create_for_backend[BACKEND].connect ((node) => {
|
||||
var account = Entity.from_json (typeof (Account), node) as Account;
|
||||
account.backend = BACKEND;
|
||||
return account;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
using Secret;
|
||||
|
||||
public class Tootle.SecretAccountStore : AccountStore {
|
||||
|
||||
const string VERSION = "1";
|
||||
|
||||
Secret.Schema schema;
|
||||
GLib.HashTable<string,SchemaAttributeType> schema_attributes;
|
||||
|
||||
public override void init () throws GLib.Error {
|
||||
message (@"Using libsecret v$(Secret.MAJOR_VERSION).$(Secret.MINOR_VERSION).$(Secret.MICRO_VERSION)");
|
||||
|
||||
schema_attributes = new GLib.HashTable<string,SchemaAttributeType> (str_hash, str_equal);
|
||||
schema_attributes["login"] = SchemaAttributeType.STRING;
|
||||
schema_attributes["version"] = SchemaAttributeType.STRING;
|
||||
schema = new Secret.Schema.newv (
|
||||
Build.DOMAIN,
|
||||
Secret.SchemaFlags.NONE,
|
||||
schema_attributes
|
||||
);
|
||||
|
||||
base.init ();
|
||||
}
|
||||
|
||||
public override void load () throws GLib.Error {
|
||||
var attrs = new GLib.HashTable<string,string> (str_hash, str_equal);
|
||||
attrs["version"] = VERSION;
|
||||
|
||||
var secrets = Secret.password_searchv_sync (
|
||||
schema,
|
||||
attrs,
|
||||
Secret.SearchFlags.ALL,
|
||||
null
|
||||
);
|
||||
|
||||
secrets.foreach (item => {
|
||||
var account = secret_to_account (item);
|
||||
if (account != null)
|
||||
saved.add (account);
|
||||
});
|
||||
|
||||
message (@"Loaded $(saved.size) accounts");
|
||||
}
|
||||
|
||||
public override void save () throws GLib.Error {
|
||||
saved.foreach (account => {
|
||||
account_to_secret (account);
|
||||
return true;
|
||||
});
|
||||
message (@"Saved $(saved.size) accounts");
|
||||
}
|
||||
|
||||
public override void remove (InstanceAccount account) throws GLib.Error {
|
||||
base.remove (account);
|
||||
|
||||
var attrs = new GLib.HashTable<string,string> (str_hash, str_equal);
|
||||
attrs["version"] = VERSION;
|
||||
attrs["login"] = account.handle;
|
||||
|
||||
Secret.password_clearv_sync (
|
||||
schema,
|
||||
attrs,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
void account_to_secret (InstanceAccount account) {
|
||||
var attrs = new GLib.HashTable<string,string> (str_hash, str_equal);
|
||||
attrs["login"] = account.handle;
|
||||
attrs["version"] = VERSION;
|
||||
|
||||
var generator = new Json.Generator ();
|
||||
generator.set_root (account.to_json ());
|
||||
var secret = generator.to_data (null);
|
||||
var label = _("%s Account").printf (account.backend);
|
||||
|
||||
try {
|
||||
Secret.password_storev_sync (
|
||||
schema,
|
||||
attrs,
|
||||
Secret.COLLECTION_DEFAULT,
|
||||
label,
|
||||
secret,
|
||||
null
|
||||
);
|
||||
}
|
||||
catch (GLib.Error e) {
|
||||
warning (e.message);
|
||||
app.inform (Gtk.MessageType.ERROR, _("Error"), e.message);
|
||||
}
|
||||
|
||||
message (@"Saved secret for $(account.handle)");
|
||||
}
|
||||
|
||||
InstanceAccount? secret_to_account (Secret.Retrievable item) {
|
||||
InstanceAccount? account = null;
|
||||
try {
|
||||
var secret = item.retrieve_secret_sync ();
|
||||
var contents = secret.get_text ();
|
||||
var parser = new Json.Parser ();
|
||||
parser.load_from_data (contents, -1);
|
||||
account = accounts.create_account (parser.get_root ());
|
||||
}
|
||||
catch (GLib.Error e) {
|
||||
warning (e.message);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
}
|
|
@ -20,7 +20,6 @@ public class Tootle.Network : GLib.Object {
|
|||
session = new Soup.Session ();
|
||||
session.ssl_strict = true;
|
||||
session.ssl_use_system_ca_file = true;
|
||||
session.timeout = 15;
|
||||
session.request_unqueued.connect (msg => {
|
||||
requests_processing--;
|
||||
if (requests_processing <= 0)
|
||||
|
|
|
@ -30,7 +30,7 @@ public class Tootle.Streams : Object {
|
|||
}
|
||||
|
||||
public bool start () {
|
||||
info (@"Opening stream: $name");
|
||||
message (@"Opening stream: $name");
|
||||
network.session.websocket_connect_async.begin (msg, null, null, null, (obj, res) => {
|
||||
socket = network.session.websocket_connect_async.end (res);
|
||||
socket.error.connect (on_error);
|
||||
|
@ -69,7 +69,7 @@ public class Tootle.Streams : Object {
|
|||
GLib.Timeout.add_seconds (timeout, start);
|
||||
timeout = int.min (timeout*2, 6);
|
||||
}
|
||||
warning (@"Closing stream: $name");
|
||||
message (@"Closing stream: $name");
|
||||
}
|
||||
|
||||
void on_message (int i, Bytes bytes) {
|
||||
|
|
|
@ -25,7 +25,7 @@ public class Tootle.Views.Notifications : Views.Timeline, IAccountListener, IStr
|
|||
needs_attention = false;
|
||||
account.has_unread_notifications = false;
|
||||
account.last_seen_notification = last_id;
|
||||
accounts.save ();
|
||||
accounts.safe_save ();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -39,7 +39,7 @@ public class Tootle.Views.Notifications : Views.Timeline, IAccountListener, IStr
|
|||
|
||||
needs_attention = has_unread () && !current;
|
||||
if (needs_attention)
|
||||
accounts.save ();
|
||||
accounts.safe_save ();
|
||||
}
|
||||
|
||||
public override void on_account_changed (InstanceAccount? acc) {
|
||||
|
|
|
@ -43,7 +43,13 @@ public class Tootle.Widgets.AccountsButton : Gtk.MenuButton, IAccountListener {
|
|||
);
|
||||
if (forget) {
|
||||
button.active = false;
|
||||
accounts.remove (account);
|
||||
try {
|
||||
accounts.remove (account);
|
||||
}
|
||||
catch (Error e) {
|
||||
warning (e.message);
|
||||
app.inform (Gtk.MessageType.ERROR, _("Error"), e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -130,8 +136,9 @@ public class Tootle.Widgets.AccountsButton : Gtk.MenuButton, IAccountListener {
|
|||
var account = accounts.saved.@get (i);
|
||||
if (accounts.active == account)
|
||||
return;
|
||||
else
|
||||
accounts.activate (account);
|
||||
|
||||
accounts.switch_account (i);
|
||||
popover.popdown ();
|
||||
}
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ public class Tootle.Widgets.Attachment.Slot : FlowBoxChild {
|
|||
Desktop.open_uri (path);
|
||||
}
|
||||
catch (Error e) {
|
||||
app.error (_("Error"), e.message);
|
||||
app.inform (Gtk.MessageType.ERROR, _("Error"), e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue