From 47a56a9580acb7309238c6105de73f6111573c76 Mon Sep 17 00:00:00 2001 From: Dusko Angirevic Date: Wed, 7 Jun 2017 07:56:42 +0200 Subject: [PATCH] Home view, timeline view, user profile and notifications --- harbour-tooter.pro | 22 +- harbour-tooter.pro.user | 26 +- harbour-tooter.pro.user.2549760 | 801 +++++++++++++++++++ qml/cover/CoverPage.qml | 2 +- qml/images/home.svg | 32 + qml/images/mesagess.svg | 17 + qml/images/notification.svg | 13 + qml/images/search.svg | 14 + qml/images/verified.svg | 26 + qml/lib/API.js | 240 +----- qml/lib/Mastodon.js | 247 ++++++ qml/lib/Worker.js | 157 ++++ qml/pages/Browser.qml | 282 +++++++ qml/pages/MainPage.qml | 114 ++- qml/pages/Profile.qml | 219 +++++ qml/pages/{ => components}/JSONListModel.qml | 0 qml/pages/components/MyList.qml | 183 +++++ qml/pages/components/Navigation.qml | 164 ++++ qml/pages/components/Notification.qml | 158 ++++ qml/pages/components/ProfileHeader.qml | 73 ++ qml/pages/components/Toot.qml | 165 ++++ translations/harbour-tooter-de.ts | 104 ++- translations/harbour-tooter.ts | 110 ++- 23 files changed, 2881 insertions(+), 288 deletions(-) create mode 100644 harbour-tooter.pro.user.2549760 create mode 100644 qml/images/home.svg create mode 100644 qml/images/mesagess.svg create mode 100644 qml/images/notification.svg create mode 100644 qml/images/search.svg create mode 100644 qml/images/verified.svg create mode 100644 qml/lib/Mastodon.js create mode 100644 qml/lib/Worker.js create mode 100644 qml/pages/Browser.qml create mode 100644 qml/pages/Profile.qml rename qml/pages/{ => components}/JSONListModel.qml (100%) create mode 100644 qml/pages/components/MyList.qml create mode 100644 qml/pages/components/Navigation.qml create mode 100644 qml/pages/components/Notification.qml create mode 100644 qml/pages/components/ProfileHeader.qml create mode 100644 qml/pages/components/Toot.qml diff --git a/harbour-tooter.pro b/harbour-tooter.pro index 4fe1487..03b2599 100644 --- a/harbour-tooter.pro +++ b/harbour-tooter.pro @@ -19,6 +19,11 @@ SOURCES += src/harbour-tooter.cpp OTHER_FILES += qml/harbour-tooter.qml \ qml/cover/CoverPage.qml \ qml/pages/SecondPage.qml \ + qml/pages/MainPage.qml \ + qml/pages/LoginPage.qml \ + qml/pages/components/MyList.qml \ + qml/pages/components/JSONListModel.qml \ + qml/pages/components/Navigation.qml \ rpm/harbour-tooter.changes.in \ rpm/harbour-tooter.spec \ rpm/harbour-tooter.yaml \ @@ -39,7 +44,16 @@ TRANSLATIONS += translations/harbour-tooter-de.ts DISTFILES += \ qml/lib/API.js \ - qml/pages/MainPage.qml \ - qml/pages/LoginPage.qml \ - qml/pages/JSONListModel.qml \ - qml/lib/jsonpath.js + qml/lib/jsonpath.js \ + qml/images/notification.svg \ + qml/images/home.svg \ + qml/images/mesagess.svg \ + qml/images/search.svg \ + qml/images/verified.svg \ + qml/lib/Mastodon.js \ + qml/lib/Worker.js \ + qml/pages/components/Toot.qml \ + qml/pages/Browser.qml \ + qml/pages/Profile.qml \ + qml/pages/components/ProfileHeader.qml \ + qml/pages/components/Notification.qml diff --git a/harbour-tooter.pro.user b/harbour-tooter.pro.user index 9e76803..02a6031 100644 --- a/harbour-tooter.pro.user +++ b/harbour-tooter.pro.user @@ -1,10 +1,10 @@ - + EnvironmentId - {25497605-1bff-4134-a878-76c1475dd8e3} + {41ec03ca-9430-48f3-b421-990d428b2838} ProjectExplorer.Project.ActiveTarget @@ -61,12 +61,12 @@ MerSDK-SailfishOS-i486 MerSDK-SailfishOS-i486 - {f49c1b5a-d715-401a-9a10-0e5fe9e5b72a} + {fd18ca89-dfc9-4054-9c53-c67be7689951} 0 2 0 - C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_i486-Debug + /Users/dysko/GIT/build-harbour-tooter-MerSDK_SailfishOS_i486-Debug true @@ -138,7 +138,7 @@ true - C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_i486-Release + /Users/dysko/GIT/build-harbour-tooter-MerSDK_SailfishOS_i486-Release true @@ -210,7 +210,7 @@ true - C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_i486-Profile + /Users/dysko/GIT/build-harbour-tooter-MerSDK_SailfishOS_i486-Profile true @@ -396,7 +396,7 @@ 13 14 - C:/Users/dysko/SF/harbour-tooter + /Users/dysko/GIT/harbour-tooter false 10234 3 @@ -426,12 +426,12 @@ MerSDK-SailfishOS-armv7hl MerSDK-SailfishOS-armv7hl - {588087e2-ecc1-41aa-b652-86f16cba9351} + {f895389d-b51a-4d4f-8b03-5ec64bda6f66} 0 2 0 - C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_armv7hl-Debug + /Users/dysko/GIT/build-harbour-tooter-MerSDK_SailfishOS_armv7hl-Debug true @@ -503,7 +503,7 @@ true - C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_armv7hl-Release + /Users/dysko/GIT/build-harbour-tooter-MerSDK_SailfishOS_armv7hl-Release true @@ -575,7 +575,7 @@ true - C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_armv7hl-Profile + /Users/dysko/GIT/build-harbour-tooter-MerSDK_SailfishOS_armv7hl-Profile true @@ -761,7 +761,7 @@ 13 14 - C:/Users/dysko/SF/harbour-tooter + /Users/dysko/GIT/harbour-tooter false 10234 3 @@ -779,7 +779,7 @@ 3768 false true - false + true false true diff --git a/harbour-tooter.pro.user.2549760 b/harbour-tooter.pro.user.2549760 new file mode 100644 index 0000000..9e76803 --- /dev/null +++ b/harbour-tooter.pro.user.2549760 @@ -0,0 +1,801 @@ + + + + + + EnvironmentId + {25497605-1bff-4134-a878-76c1475dd8e3} + + + ProjectExplorer.Project.ActiveTarget + 1 + + + ProjectExplorer.Project.EditorSettings + + true + false + true + + Cpp + + CppGlobal + + + + QmlJS + + QmlJSGlobal + + + 2 + UTF-8 + false + 4 + false + 80 + true + true + 1 + true + false + 0 + true + true + 0 + 8 + true + 1 + true + true + true + false + + + + ProjectExplorer.Project.PluginSettings + + + + ProjectExplorer.Project.Target.0 + + MerSDK-SailfishOS-i486 + MerSDK-SailfishOS-i486 + {f49c1b5a-d715-401a-9a10-0e5fe9e5b72a} + 0 + 2 + 0 + + C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_i486-Debug + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + qmake + + QtProjectManager.QMakeBuildStep + true + + false + false + false + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + false + + + + 3 + Build + + ProjectExplorer.BuildSteps.Build + + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + true + clean + + + 2 + Clean + + ProjectExplorer.BuildSteps.Clean + + 2 + false + + Debug + + Qt4ProjectManager.Qt4BuildConfiguration + 2 + true + + + C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_i486-Release + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + qmake + + QtProjectManager.QMakeBuildStep + false + + false + false + false + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + false + + + + 3 + Build + + ProjectExplorer.BuildSteps.Build + + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + true + clean + + + 2 + Clean + + ProjectExplorer.BuildSteps.Clean + + 2 + false + + Release + + Qt4ProjectManager.Qt4BuildConfiguration + 0 + true + + + C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_i486-Profile + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + qmake + + QtProjectManager.QMakeBuildStep + true + + false + true + false + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + false + + + + 3 + Build + + ProjectExplorer.BuildSteps.Build + + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + true + clean + + + 2 + Clean + + ProjectExplorer.BuildSteps.Clean + + 2 + false + + Profile + + Qt4ProjectManager.Qt4BuildConfiguration + 0 + true + + 3 + + + + true + RPM + + QmakeProjectManager.MerRpmBuildStep + + + true + RPM Validation + + QmakeProjectManager.MerRpmValidationStep + + 2 + Deploy + + ProjectExplorer.BuildSteps.Deploy + + 1 + Deploy By Building An RPM Package + + QmakeProjectManager.MerMb2RpmBuildConfiguration + + + + + true + Prepare Target + + QmakeProjectManager.MerPrepareTargetStep + + + true + Rsync + + QmakeProjectManager.MerRsyncDeployStep + + 2 + Deploy + + ProjectExplorer.BuildSteps.Deploy + + 1 + Deploy By Copying Binaries + + QmakeProjectManager.MerRSyncDeployConfiguration + + + + + true + Prepare Target + + QmakeProjectManager.MerPrepareTargetStep + + + true + RPM + + QmakeProjectManager.MerRpmDeployStep + + 2 + Deploy + + ProjectExplorer.BuildSteps.Deploy + + 1 + Deploy As RPM Package + + QmakeProjectManager.MerRpmDeployConfiguration + + 3 + + + false + false + 1000 + + true + + false + false + false + false + true + 0.01 + 10 + true + 1 + 25 + + 1 + true + false + true + valgrind + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + + C:/Users/dysko/SF/harbour-tooter + false + 10234 + 3 + + 1 + + harbour-tooter (on Remote Device) + + QmakeProjectManager.MerRunConfiguration:harbour-tooter + + harbour-tooter + + false + + 3768 + false + true + false + false + true + + 1 + + + + ProjectExplorer.Project.Target.1 + + MerSDK-SailfishOS-armv7hl + MerSDK-SailfishOS-armv7hl + {588087e2-ecc1-41aa-b652-86f16cba9351} + 0 + 2 + 0 + + C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_armv7hl-Debug + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + qmake + + QtProjectManager.QMakeBuildStep + true + + false + false + false + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + false + + + + 3 + Build + + ProjectExplorer.BuildSteps.Build + + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + true + clean + + + 2 + Clean + + ProjectExplorer.BuildSteps.Clean + + 2 + false + + Debug + + Qt4ProjectManager.Qt4BuildConfiguration + 2 + true + + + C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_armv7hl-Release + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + qmake + + QtProjectManager.QMakeBuildStep + false + + false + false + false + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + false + + + + 3 + Build + + ProjectExplorer.BuildSteps.Build + + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + true + clean + + + 2 + Clean + + ProjectExplorer.BuildSteps.Clean + + 2 + false + + Release + + Qt4ProjectManager.Qt4BuildConfiguration + 0 + true + + + C:/Users/dysko/SF/build-harbour-tooter-MerSDK_SailfishOS_armv7hl-Profile + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + qmake + + QtProjectManager.QMakeBuildStep + true + + false + true + false + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + false + + + + 3 + Build + + ProjectExplorer.BuildSteps.Build + + + + true + Start SDK + + Mer.MerSdkStartStep + + + true + Make + + Qt4ProjectManager.MakeStep + + -w + -r + + true + clean + + + 2 + Clean + + ProjectExplorer.BuildSteps.Clean + + 2 + false + + Profile + + Qt4ProjectManager.Qt4BuildConfiguration + 0 + true + + 3 + + + + true + RPM + + QmakeProjectManager.MerRpmBuildStep + + + true + RPM Validation + + QmakeProjectManager.MerRpmValidationStep + + 2 + Deploy + + ProjectExplorer.BuildSteps.Deploy + + 1 + Deploy By Building An RPM Package + + QmakeProjectManager.MerMb2RpmBuildConfiguration + + + + + true + Prepare Target + + QmakeProjectManager.MerPrepareTargetStep + + + true + Rsync + + QmakeProjectManager.MerRsyncDeployStep + + 2 + Deploy + + ProjectExplorer.BuildSteps.Deploy + + 1 + Deploy By Copying Binaries + + QmakeProjectManager.MerRSyncDeployConfiguration + + + + + true + Prepare Target + + QmakeProjectManager.MerPrepareTargetStep + + + true + RPM + + QmakeProjectManager.MerRpmDeployStep + + 2 + Deploy + + ProjectExplorer.BuildSteps.Deploy + + 1 + Deploy As RPM Package + + QmakeProjectManager.MerRpmDeployConfiguration + + 3 + + + false + false + 1000 + + true + + false + false + false + false + true + 0.01 + 10 + true + 1 + 25 + + 1 + true + false + true + valgrind + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + + C:/Users/dysko/SF/harbour-tooter + false + 10234 + 3 + + 1 + + harbour-tooter (on Remote Device) + + QmakeProjectManager.MerRunConfiguration:harbour-tooter + + harbour-tooter + + false + + 3768 + false + true + false + false + true + + 1 + + + + ProjectExplorer.Project.TargetCount + 2 + + + ProjectExplorer.Project.Updater.FileVersion + 18 + + + Version + 18 + + diff --git a/qml/cover/CoverPage.qml b/qml/cover/CoverPage.qml index 0d08923..355cc23 100644 --- a/qml/cover/CoverPage.qml +++ b/qml/cover/CoverPage.qml @@ -45,7 +45,7 @@ CoverBackground { CoverAction { iconSource: "image://theme/icon-cover-next" onTriggered: { - label.text = Logic.test + label.text = Logic.modelTLhome.count } } diff --git a/qml/images/home.svg b/qml/images/home.svg new file mode 100644 index 0000000..17d3a96 --- /dev/null +++ b/qml/images/home.svg @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/qml/images/mesagess.svg b/qml/images/mesagess.svg new file mode 100644 index 0000000..8e7becc --- /dev/null +++ b/qml/images/mesagess.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/qml/images/notification.svg b/qml/images/notification.svg new file mode 100644 index 0000000..0ddcd12 --- /dev/null +++ b/qml/images/notification.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/qml/images/search.svg b/qml/images/search.svg new file mode 100644 index 0000000..3d61944 --- /dev/null +++ b/qml/images/search.svg @@ -0,0 +1,14 @@ + + + + + + + diff --git a/qml/images/verified.svg b/qml/images/verified.svg new file mode 100644 index 0000000..65d529b --- /dev/null +++ b/qml/images/verified.svg @@ -0,0 +1,26 @@ + + + + + + diff --git a/qml/lib/API.js b/qml/lib/API.js index 398ef4b..641583a 100644 --- a/qml/lib/API.js +++ b/qml/lib/API.js @@ -103,245 +103,13 @@ var tootParser = function(data){ console.log(ret) } - -// by @kirschn@pleasehug.me 2017 -// no fucking copyright -// do whatever you want with it -// but please don't hurt it (and keep this header) var test = 1; -var MastodonAPI = function(config) { - var apiBase = config.instance + "/api/v1/"; - return { - setConfig: function (key, value) { - // modify initial config afterwards - config[key] = value; - }, - getConfig: function(key) { - //get config key - return config[key]; - }, - get: function (endpoint) { - // for GET API calls - // can be called with two or three parameters - // endpoint, callback - // or - // endpoint, queryData, callback - // where querydata is an object {["paramname1", "paramvalue1], ["paramname2","paramvalue2"]} - // variables - var queryData, callback, - queryStringAppend = "?"; +Qt.include("Mastodon.js") - // check with which arguments we're supplied - if (typeof arguments[1] === "function") { - queryData = {}; - callback = arguments[1]; - } else { - queryData = arguments[1]; - callback = arguments[2]; - } - // build queryData Object into a URL Query String - for (var i in queryData) { - if (queryData.hasOwnProperty(i)) { - if (typeof queryData[i] === "string") { - queryStringAppend += queryData[i] + "&"; - } else if (typeof queryData[i] === "object") { - queryStringAppend += queryData[i].name + "="+ queryData[i].data + "&"; - } - } - } - // ajax function - var http = new XMLHttpRequest() - var url = apiBase + endpoint; - http.open("GET", apiBase + endpoint + queryStringAppend, true); - - // Send the proper header information along with the request - http.setRequestHeader("Authorization", "Bearer " + config.api_user_token); - http.setRequestHeader("Content-Type", "application/json"); - http.setRequestHeader("Connection", "close"); - - http.onreadystatechange = function() { // Call a function when the state changes. - if (http.readyState == 4) { - if (http.status == 200) { - console.log("Successful GET API request to " +apiBase+endpoint); - callback(JSON.parse(http.response),http.status) - } else { - console.log("error: " + http.status) - } - } - } - http.send(); - }, - post: function (endpoint) { - // for POST API calls - var postData, callback; - // check with which arguments we're supplied - if (typeof arguments[1] === "function") { - postData = {}; - callback = arguments[1]; - } else { - postData = arguments[1]; - callback = arguments[2]; - } - - var http = new XMLHttpRequest() - var url = apiBase + endpoint; - var params = JSON.stringify(postData); - http.open("POST", url, true); - - // Send the proper header information along with the request - http.setRequestHeader("Authorization", "Bearer " + config.api_user_token); - http.setRequestHeader("Content-Type", "application/json"); - http.setRequestHeader("Content-length", params.length); - http.setRequestHeader("Connection", "close"); - - http.onreadystatechange = function() { // Call a function when the state changes. - if (http.readyState == 4) { - if (http.status == 200) { - console.log("Successful POST API request to " +apiBase+endpoint); - callback(http.response,http.status) - } else { - console.log("error: " + http.status) - } - } - } - http.send(params); - - /*$.ajax({ - url: apiBase + endpoint, - type: "POST", - data: postData, - headers: {"Authorization": "Bearer " + config.api_user_token}, - success: function(data, textStatus) { - console.log("Successful POST API request to " +apiBase+endpoint); - callback(data,textStatus) - } - });*/ - }, - delete: function (endpoint, callback) { - // for DELETE API calls. - $.ajax({ - url: apiBase + endpoint, - type: "DELETE", - headers: {"Authorization": "Bearer " + config.api_user_token}, - success: function(data, textStatus) { - console.log("Successful DELETE API request to " +apiBase+endpoint); - callback(data,textStatus) - } - }); - }, - stream: function (streamType, onData) { - // Event Stream Support - // websocket streaming is undocumented. i had to reverse engineer the fucking web client. - // streamType is either - // user for your local home TL and notifications - // public for your federated TL - // public:local for your home TL - // hashtag&tag=fuckdonaldtrump for the stream of #fuckdonaldtrump - // callback gets called whenever new data ist recieved - // callback { event: (eventtype), payload: {mastodon object as described in the api docs} } - // eventtype could be notification (=notification) or update (= new toot in TL) - //return "wss://" + apiBase.substr(8) +"streaming?access_token=" + config.api_user_token + "&stream=" + streamType - - var es = new WebSocket("wss://" + apiBase.substr(8) - +"streaming?access_token=" + config.api_user_token + "&stream=" + streamType); - var listener = function (event) { - console.log("Got Data from Stream " + streamType); - event = JSON.parse(event.data); - event.payload = JSON.parse(event.payload); - onData(event); - }; - es.onmessage = listener; - - - }, - registerApplication: function (client_name, redirect_uri, scopes, website, callback) { - //register a new application - - // OAuth Auth flow: - // First register the application - // 2) get a access code from a user (using the link, generation function below!) - // 3) insert the data you got from the application and the code from the user into - // getAccessTokenFromAuthCode. Note: scopes has to be an array, every time! - // For example ["read", "write"] - - //determine which parameters we got - if (website === null) { - website = ""; - } - // build scope array to string for the api request - var scopeBuild = ""; - if (typeof scopes !== "string") { - scopes = scopes.join(" "); - } - - var http = new XMLHttpRequest() - var url = apiBase + "apps"; - var params = 'client_name=' + client_name + '&redirect_uris=' + redirect_uri + '&scopes=' + scopes + '&website=' + website; - console.log(params) - http.open("POST", url, true); - - // Send the proper header information along with the request - http.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - - http.onreadystatechange = function() { // Call a function when the state changes. - if (http.readyState == 4) { - if (http.status == 200) { - console.log("Registered Application: " + http.response); - callback(http.response) - } else { - console.log("error: " + http.status) - } - } - } - http.send(params); - }, - generateAuthLink: function (client_id, redirect_uri, responseType, scopes) { - return config.instance + "/oauth/authorize?client_id=" + client_id + "&redirect_uri=" + redirect_uri + - "&response_type=" + responseType + "&scope=" + scopes.join("+"); - }, - getAccessTokenFromAuthCode: function (client_id, client_secret, redirect_uri, code, callback) { - /*$.ajax({ - url: config.instance + "/oauth/token", - type: "POST", - data: { - client_id: client_id, - client_secret: client_secret, - redirect_uri: redirect_uri, - grant_type: "authorization_code", - code: code - }, - success: function (data, textStatus) { - console.log("Got Token: " + data); - callback(data); - } - });*/ - var http = new XMLHttpRequest() - var url = config.instance + "/oauth/token"; - var params = 'client_id=' + client_id + '&client_secret=' + client_secret + '&redirect_uri=' + redirect_uri + '&grant_type=authorization_code&code=' + code; - console.log(params) - http.open("POST", url, true); - - // Send the proper header information along with the request - http.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - - http.onreadystatechange = function() { // Call a function when the state changes. - if (http.readyState == 4) { - if (http.status == 200) { - console.log("Got Token: " + http.response); - callback(http.response) - } else { - console.log("error: " + http.status) - } - } - } - http.send(params); - } - }; -}; - -// node.js -if (typeof module !== 'undefined') { module.exports = MastodonAPI; }; +var modelTLhome = Qt.createQmlObject('import QtQuick 2.0; ListModel { }', Qt.application, 'InternalQmlObject'); +var modelTLpublic = Qt.createQmlObject('import QtQuick 2.0; ListModel { }', Qt.application, 'InternalQmlObject'); +var modelTLnotifications = Qt.createQmlObject('import QtQuick 2.0; ListModel { }', Qt.application, 'InternalQmlObject'); var api; diff --git a/qml/lib/Mastodon.js b/qml/lib/Mastodon.js new file mode 100644 index 0000000..8a8e4c6 --- /dev/null +++ b/qml/lib/Mastodon.js @@ -0,0 +1,247 @@ +// by @kirschn@pleasehug.me 2017 +// no fucking copyright +// do whatever you want with it +// but please don't hurt it (and keep this header) + +var MastodonAPI = function(config) { + var apiBase = config.instance + "/api/v1/"; + return { + setConfig: function (key, value) { + // modify initial config afterwards + config[key] = value; + }, + getConfig: function(key) { + //get config key + return config[key]; + }, + get: function (endpoint) { + // for GET API calls + // can be called with two or three parameters + // endpoint, callback + // or + // endpoint, queryData, callback + // where querydata is an object {["paramname1", "paramvalue1], ["paramname2","paramvalue2"]} + + // variables + var queryData, callback, + queryStringAppend = "?"; + + // check with which arguments we're supplied + if (typeof arguments[1] === "function") { + queryData = {}; + callback = arguments[1]; + } else { + queryData = arguments[1]; + callback = arguments[2]; + } + // build queryData Object into a URL Query String + for (var i in queryData) { + if (queryData.hasOwnProperty(i)) { + if (typeof queryData[i] === "string") { + queryStringAppend += queryData[i] + "&"; + } else if (typeof queryData[i] === "object") { + queryStringAppend += queryData[i].name + "="+ queryData[i].data + "&"; + } + } + } + // ajax function + var http = new XMLHttpRequest() + var url = apiBase + endpoint; + console.log(queryStringAppend) + http.open("GET", apiBase + endpoint + queryStringAppend, true); + + // Send the proper header information along with the request + http.setRequestHeader("Authorization", "Bearer " + config.api_user_token); + http.setRequestHeader("Content-Type", "application/json"); + http.setRequestHeader("Connection", "close"); + + http.onreadystatechange = function() { // Call a function when the state changes. + if (http.readyState == 4) { + if (http.status == 200) { + console.log("Successful GET API request to " +apiBase+endpoint); + callback(JSON.parse(http.response),http.status) + } else { + console.log("error: " + http.status) + } + } + } + http.send(); + }, + post: function (endpoint) { + // for POST API calls + var postData, callback; + // check with which arguments we're supplied + if (typeof arguments[1] === "function") { + postData = {}; + callback = arguments[1]; + } else { + postData = arguments[1]; + callback = arguments[2]; + } + + var http = new XMLHttpRequest() + var url = apiBase + endpoint; + var params = JSON.stringify(postData); + http.open("POST", url, true); + + // Send the proper header information along with the request + http.setRequestHeader("Authorization", "Bearer " + config.api_user_token); + http.setRequestHeader("Content-Type", "application/json"); + http.setRequestHeader("Content-length", params.length); + http.setRequestHeader("Connection", "close"); + + http.onreadystatechange = function() { // Call a function when the state changes. + if (http.readyState == 4) { + if (http.status == 200) { + console.log("Successful POST API request to " +apiBase+endpoint); + callback(JSON.parse(http.response),http.status) + } else { + console.log("error: " + http.status) + } + } + } + http.send(params); + + /*$.ajax({ + url: apiBase + endpoint, + type: "POST", + data: postData, + headers: {"Authorization": "Bearer " + config.api_user_token}, + success: function(data, textStatus) { + console.log("Successful POST API request to " +apiBase+endpoint); + callback(data,textStatus) + } + });*/ + }, + delete: function (endpoint, callback) { + // for DELETE API calls. + $.ajax({ + url: apiBase + endpoint, + type: "DELETE", + headers: {"Authorization": "Bearer " + config.api_user_token}, + success: function(data, textStatus) { + console.log("Successful DELETE API request to " +apiBase+endpoint); + callback(data,textStatus) + } + }); + }, + stream: function (streamType, onData) { + // Event Stream Support + // websocket streaming is undocumented. i had to reverse engineer the fucking web client. + // streamType is either + // user for your local home TL and notifications + // public for your federated TL + // public:local for your home TL + // hashtag&tag=fuckdonaldtrump for the stream of #fuckdonaldtrump + // callback gets called whenever new data ist recieved + // callback { event: (eventtype), payload: {mastodon object as described in the api docs} } + // eventtype could be notification (=notification) or update (= new toot in TL) + //return "wss://" + apiBase.substr(8) +"streaming?access_token=" + config.api_user_token + "&stream=" + streamType + + var es = new WebSocket("wss://" + apiBase.substr(8) + +"streaming?access_token=" + config.api_user_token + "&stream=" + streamType); + var listener = function (event) { + console.log("Got Data from Stream " + streamType); + event = JSON.parse(event.data); + event.payload = JSON.parse(event.payload); + onData(event); + }; + es.onmessage = listener; + + + }, + registerApplication: function (client_name, redirect_uri, scopes, website, callback) { + //register a new application + + // OAuth Auth flow: + // First register the application + // 2) get a access code from a user (using the link, generation function below!) + // 3) insert the data you got from the application and the code from the user into + // getAccessTokenFromAuthCode. Note: scopes has to be an array, every time! + // For example ["read", "write"] + + //determine which parameters we got + if (website === null) { + website = ""; + } + // build scope array to string for the api request + var scopeBuild = ""; + if (typeof scopes !== "string") { + scopes = scopes.join(" "); + } + + var http = new XMLHttpRequest() + var url = apiBase + "apps"; + var params = 'client_name=' + client_name + '&redirect_uris=' + redirect_uri + '&scopes=' + scopes + '&website=' + website; + console.log(params) + http.open("POST", url, true); + + // Send the proper header information along with the request + http.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + + http.onreadystatechange = function() { // Call a function when the state changes. + if (http.readyState == 4) { + if (http.status == 200) { + console.log("Registered Application: " + http.response); + callback(http.response) + } else { + console.log("error: " + http.status) + } + } + } + http.send(params); + }, + generateAuthLink: function (client_id, redirect_uri, responseType, scopes) { + return config.instance + "/oauth/authorize?client_id=" + client_id + "&redirect_uri=" + redirect_uri + + "&response_type=" + responseType + "&scope=" + scopes.join("+"); + }, + getAccessTokenFromAuthCode: function (client_id, client_secret, redirect_uri, code, callback) { + /*$.ajax({ + url: config.instance + "/oauth/token", + type: "POST", + data: { + client_id: client_id, + client_secret: client_secret, + redirect_uri: redirect_uri, + grant_type: "authorization_code", + code: code + }, + success: function (data, textStatus) { + console.log("Got Token: " + data); + callback(data); + } + });*/ + var http = new XMLHttpRequest() + var url = config.instance + "/oauth/token"; + var params = 'client_id=' + client_id + '&client_secret=' + client_secret + '&redirect_uri=' + redirect_uri + '&grant_type=authorization_code&code=' + code; + console.log(params) + http.open("POST", url, true); + + // Send the proper header information along with the request + http.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + + http.onreadystatechange = function() { // Call a function when the state changes. + if (http.readyState == 4) { + if (http.status == 200) { + console.log("Got Token: " + http.response); + callback(http.response) + } else { + console.log("error: " + http.status) + } + } + } + http.send(params); + } + }; +}; + +// node.js +if (typeof module !== 'undefined') { module.exports = MastodonAPI; }; + +String.prototype.replaceAll = function(search, replacement) { + var target = this; + return target.replace(new RegExp(search, 'g'), replacement); +}; + +(function(){var k=[].slice;String.prototype.autoLink=function(){var d,b,g,a,e,f,h;e=1<=arguments.length?k.call(arguments,0):[];f=/(^|[\s\n]|<[A-Za-z]*\/?>)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi;if(!(0$2");a=e[0];d=a.callback;g=function(){var c;c=[];for(b in a)h=a[b],"callback"!==b&&c.push(" "+b+"='"+h+"'");return c}().join("");return this.replace(f,function(c,b,a){c=("function"===typeof d?d(a): +void 0)||""+a+"";return""+b+c})}}).call(this); diff --git a/qml/lib/Worker.js b/qml/lib/Worker.js new file mode 100644 index 0000000..ef4cd16 --- /dev/null +++ b/qml/lib/Worker.js @@ -0,0 +1,157 @@ +Qt.include("Mastodon.js") +WorkerScript.onMessage = function(msg) { + console.log("Action > " + msg.action) + console.log("Model > " + msg.model) + console.log("Mode > " + msg.mode) + console.log("Conf > " + JSON.stringify(msg.conf)) + console.log("Params > " + JSON.stringify(msg.params)) + if (!msg.conf.login){ + console.log("Not loggedin") + return; + } + var API = MastodonAPI({ instance: msg.conf.instance, api_user_token: msg.conf.api_user_token}); + if (msg.method === "POST"){ + API.post(msg.action, msg.params, function(data) { + for (var i in data) { + if (data.hasOwnProperty(i)) { + console.log(JSON.stringify(data[i])) + WorkerScript.sendMessage({ 'action': msg.action, 'success': true, key: i, "data": data[i]}) + } + } + }); + return; + } + + API.get(msg.action, msg.params, function(data) { + var items = []; + for (var i in data) { + if (data.hasOwnProperty(i)) { + if(msg.action === "notifications") { + console.log("Is notification... parsing...") + var item = parseNotification(data[i]); + items.push(item) + } else if (data[i].hasOwnProperty("content")){ + console.log("Is toot... parsing...") + var item = parseToot(data[i]); + items.push(item) + } else { + WorkerScript.sendMessage({ 'action': msg.action, 'success': true, key: i, "data": data[i]}) + } + } + } + if(msg.model) + addDataToModel(msg.model, msg.mode, items) + }); + +} +//WorkerScript.sendMessage({ 'notifyNewItems': length - i }) +function addDataToModel (model, mode, items){ + var length = items.length; + console.log("Fetched > " +length) + + if (mode === "append") { + model.append(items) + } else if (mode === "prepend") { + for(var i = length-1; i >= 0 ; i--){ + model.insert(0,items[i]) + } + } + + model.sync() +} +function parseNotification(data){ + var item = { + id: data.id, + type: data.type, + created_at: data.created_at, + account_id: data.account.id, + account_acct: data.account.acct, + account_username: data.account.username, + account_display_name: data.account.display_name, + account_avatar: data.account.avatar, + account_locked: data.account.locked + }; + switch (item['type']){ + case "mention": + item['typeIcon'] = "image://theme/icon-s-alarm" + break; + case "reblog": + item['typeIcon'] = "image://theme/icon-s-retweet" + break; + case "favourite": + item['typeIcon'] = "image://theme/icon-s-favorite" + break; + case "follow": + item['typeIcon'] = "image://theme/icon-s-installed" + break; + default: + item['typeIcon'] = "image://theme/icon-s-sailfish" + } + + + + return item; +} + +function parseToot (data){ + //console.log(JSON.stringify(data)) + var item = {}; + item['username'] = "Mjau" + + item['retweetScreenName'] = ''; + item['isVerified'] = false; + item['isReblog'] = false; + item['favourited'] = data['favourited']; + item['reblogged'] = data['reblogged']; + item['muted'] = data['muted']; + item['reblogs_count'] = data['reblogs_count']; + item['favourites_count'] = data['favourites_count']; + + if(data['id']){ + item['id'] = data['id']; + } + if(data['created_at']){ + item['created_at'] = data['created_at']; + } + if(data['account']){ + item['account_id'] = data['account']['id']; + item['username'] = data['account']['username']; + item['displayname'] = data['account']['display_name']; + item['account_locked'] = data['account']['locked']; + item['account_avatar'] = data['account']['avatar']; + } + if(data['reblog']){ + item['isReblog'] = true; + item['retweetScreenName'] = data['account']['username']; + item['reblog_id'] = data['reblog']['id']; + item['account_id'] = data['reblog']['account']['id']; + item['username'] = data['reblog']['account']['username']; + item['displayname'] = data['reblog']['account']['display_name']; + item['account_locked'] = data['reblog']['account']['locked']; + item['account_avatar'] = data['reblog']['account']['avatar']; + + item['reblogs_count'] = data['reblog']['reblogs_count']; + item['favourites_count'] = data['reblog']['favourites_count']; + item['favourited'] = data['reblog']['favourited']; + item['reblogged'] = data['reblog']['reblogged']; + item['muted'] = data['reblog']['muted']; + } + + item['content'] = data['content'].replace(/(<([^>]+)>)/ig,""); + item['content'] = item['content'].split(" ") + for(var i = 0; i < item['content'].length ; i++){ + if(item['content'][i][0] === "#"){ + item['content'][i] = ''+item['content'][i]+''; + } + if(item['content'][i][0] === "@"){ + item['content'][i] = ''+item['content'][i]+''; + } + + console.log(item['content'][i]) + } + item['content'] = item['content'].join(" ").autoLink() + + + + return item; +} diff --git a/qml/pages/Browser.qml b/qml/pages/Browser.qml new file mode 100644 index 0000000..8c97ff0 --- /dev/null +++ b/qml/pages/Browser.qml @@ -0,0 +1,282 @@ +/**************************************************************************************** +** +** Copyright (C) 2013 Jolla Ltd. +** Contact: Raine Makelainen +** All rights reserved. +** +** This file is part of Sailfish Silica UI component package. +** +** You may use this file under the terms of BSD license as follows: +** +** Redistribution and use in source and binary forms, with or without +** modification, are permitted provided that the following conditions are met: +** * Redistributions of source code must retain the above copyright +** notice, this list of conditions and the following disclaimer. +** * Redistributions in binary form must reproduce the above copyright +** notice, this list of conditions and the following disclaimer in the +** documentation and/or other materials provided with the distribution. +** * Neither the name of the Jolla Ltd nor the +** names of its contributors may be used to endorse or promote products +** derived from this software without specific prior written permission. +** +** THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +** ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +** WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +** DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR +** ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +** (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +** LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +** ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +** (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +** SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +** +****************************************************************************************/ + +import QtQuick 2.0 +import QtWebKit 3.0 +import Sailfish.Silica 1.0 + +Page { + id: browser + property string href; + property bool screenReaderMode: true + property bool loaded: false + property string articleContent: "" + property string articleTitle: "" + property string articleDate: "" + property string articleImage: "" + onLoadedChanged: { + pullDownMenu.busy = pullDownMenu2.busy = !loaded + } + onStatusChanged: { + if (status === PageStatus.Active) { + fetchData(); + } + + } + onScreenReaderModeChanged: { + loaded = false; + fetchData(); + } + + allowedOrientations: Orientation.All + function fetchData(){ + var xhr = new XMLHttpRequest(); + xhr.open("GET", "https://mercury.postlight.com/parser?url="+href, true); + xhr.onreadystatechange = function() { + if ( xhr.readyState === xhr.DONE ) { + if ( xhr.status === 200 ) { + console.log(xhr.responseText) + var response = JSON.parse(xhr.responseText); + if (response.date_published) + //articleDate = new Date(response.date_published.replace(/^(\w+) (\w+) (\d+) ([\d:]+) \+0000 (\d+)$/,"$1, $2 $3 $5 $4 GMT")); + if (response.title) + articleTitle = response.title; + if (response.lead_image_url) + articleImage = response.lead_image_url + if (response.content) + articleContent = response.content; + if (response.content && response.lead_image_url) + articleContent = articleContent.replace(articleImage, "") + } else { + + } + loaded = true; + } + } + xhr.setRequestHeader("Content-Type", 'application/json'); + xhr.setRequestHeader("x-api-key", 'uakC11NlSubREs1r5NjkOCS1NJEkwti6DnDutcYC'); + + if (screenReaderMode) + xhr.send(); + else + webView.url = 'https://mercury.postlight.com/amp?url='+href + } + + + + BusyIndicator { + id: loading + size: BusyIndicatorSize.Large + anchors.centerIn: parent + running: !loaded + } + + SilicaWebView { + enabled: !screenReaderMode + visible: !screenReaderMode + id: webView + anchors { + fill: parent + } + + PullDownMenu { + id: pullDownMenu + MenuItem { + text: qsTr("Open in Browser") + onClicked: { + Qt.openUrlExternally(href); + } + } + MenuItem { + text: screenReaderMode ? qsTr("Web mode") : qsTr("Reading mode") + onClicked: { + screenReaderMode = !screenReaderMode + } + } + } + + opacity: 0 + onLoadingChanged: { + switch (loadRequest.status) + { + case WebView.LoadSucceededStatus: + opacity = 1 + loaded = true; + break + case WebView.LoadFailedStatus: + opacity = 0 + loaded = true; + viewPlaceHolder.errorString = loadRequest.errorString + break + default: + opacity = 0 + loaded = false; + break + } + } + FadeAnimation on opacity {} + } + ViewPlaceholder { + id: viewPlaceHolder + property string errorString + enabled: webView.opacity === 0 && loaded && !screenReaderMode + text: errorString + hintText: "Check network connectivity and pull down to reload" + } + + + + SilicaFlickable { + visible: screenReaderMode + enabled: screenReaderMode + anchors { + fill: parent + } + contentHeight: article.height + VerticalScrollDecorator {} + PullDownMenu { + id: pullDownMenu2 + MenuItem { + text: qsTr("Open in Browser") + onClicked: { + Qt.openUrlExternally(href); + } + } + MenuItem { + text: screenReaderMode ? qsTr("Web mode") : qsTr("Reading mode") + onClicked: { + screenReaderMode = !screenReaderMode + } + } + } + Column { + + id: article + width: parent.width + + Rectangle { + height: Theme.itemSizeExtraSmall/3 + width: parent.width + opacity: 0 + } + + Label { + id: title + text: articleTitle + font.pixelSize: Theme.fontSizeLarge + color: Theme.highlightColor + textFormat: Text.StyledText + wrapMode: Text.WordWrap + font.bold: true + anchors { + left: parent.left + right: parent.right + leftMargin: Theme.paddingLarge + rightMargin: Theme.paddingLarge + } + } + Label { + id: date + visible: articleDate !== "" + text: articleDate + font.pixelSize: Theme.fontSizeExtraSmall + color: Theme.secondaryColor + anchors { + left: parent.left + right: parent.right + topMargin: Theme.paddingSmall + bottomMargin: Theme.paddingSmall + leftMargin: Theme.paddingLarge + rightMargin: Theme.paddingLarge + } + } + Rectangle { + height: image.visible ? Theme.itemSizeExtraSmall/3 : 0 + width: parent.width + opacity: 0 + } + Image { + id: image + visible: articleImage !== "" ? true : false + source: articleImage + width: parent.width + height: Theme.itemSizeExtraLarge + fillMode: Image.PreserveAspectCrop + anchors { + left: parent.left + right: parent.right + } + BusyIndicator { + size: BusyIndicatorSize.Small + anchors.centerIn: parent + running: parent.status != Image.Ready + } + + onStatusChanged: if (image.status === Image.Ready) { + var ratio = image.sourceSize.width/image.sourceSize.height + height = width / ratio + } + } + Rectangle { + height: image.visible ? Theme.itemSizeExtraSmall/3 : 0 + width: parent.width + opacity: 0 + } + Label { + id: content + readonly property string _linkStyle: "" + textFormat: Text.RichText + text: _linkStyle + articleContent; + font.pixelSize: Theme.fontSizeSmall + color: Theme.secondaryColor + wrapMode: Text.WordWrap + anchors { + left: parent.left + right: parent.right + topMargin: image.visible ? Theme.paddingSmall : Theme.paddingLarge + leftMargin: Theme.paddingLarge + rightMargin: Theme.paddingLarge + bottomMargin: Theme.paddingLarge + } + + } + Rectangle { + height: Theme.itemSizeExtraSmall/3 + width: parent.width + opacity: 0 + } + + } + } +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index 159c1ec..b99aed2 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -31,6 +31,7 @@ import QtQuick 2.0 import Sailfish.Silica 1.0 import "../lib/API.js" as Logic +import "./components/" Page { @@ -39,42 +40,97 @@ Page { // The effective value will be restricted by ApplicationWindow.allowedOrientations allowedOrientations: Orientation.All - // To enable PullDownMenu, place our content in a SilicaFlickable - SilicaFlickable { - anchors.fill: parent - - PageHeader { - title: "Tooter" - } - - // PullDownMenu and PushUpMenu must be declared in SilicaFlickable, SilicaListView or SilicaGridView - PullDownMenu { - MenuItem { - text: Logic.conf['login'] ? qsTrId("Logout"): qsTrId("Login") - onClicked: { - if (Logic.conf['login']) { - Logic.conf['login'] = false - Logic.conf['instance'] = null; - Logic.conf['api_user_token'] = null; - Logic.conf['dysko'] = null; - } else { - Logic.conf['login'] = true - Logic.conf['instance'] = "https://mastodon.social"; - Logic.conf['api_user_token'] = '6d8cb23e3ebf3c7a97dd9adf204e47ad159f1a3d07dbbd0325e98981368d8c51'; - } - } - } - - MenuItem { - text: qsTr("Show Page 2") - onClicked: pageStack.push(Qt.resolvedUrl("SecondPage.qml")) + DockedPanel { + id: infoPanel + open: true + width: mainPage.isPortrait ? parent.width : Theme.itemSizeLarge + height: mainPage.isPortrait ? Theme.itemSizeLarge : parent.height + dock: mainPage.isPortrait ? Dock.Bottom : Dock.Right + Navigation { + id: navigation + isPortrait: !mainPage.isPortrait + onSlideshowShow: { + console.log(vIndex) + slideshow.positionViewAtIndex(vIndex, ListView.SnapToItem) } } + } + SlideshowView { + id: slideshow + width: parent.width + height: parent.height + itemWidth: parent.width + clip: true + onCurrentIndexChanged: { + navigation.slideshowIndexChanged(currentIndex) + } + anchors { + fill: parent + leftMargin: 0 + top: parent.top + topMargin: 0 + rightMargin: mainPage.isPortrait ? 0 : infoPanel.visibleSize + bottomMargin: mainPage.isPortrait ? infoPanel.visibleSize : 0 + } + model: VisualItemModel { + MyList{ + id: tlHome; + title: qsTr("Home") + type: "timelines/home" + mdl: Logic.modelTLhome + width: parent.width + height: parent.height + onOpenDrawer: infoPanel.open = setDrawer + } + MyList{ + id: tlPublic; + title: qsTr("Timeline") + type: "timelines/public" + mdl: Logic.modelTLpublic + width: parent.width + height: parent.height + onOpenDrawer: infoPanel.open = setDrawer + } + MyList{ + id: tlNotifications; + title: qsTr("Notifications") + type: "notifications" + mdl: Logic.modelTLnotifications + width: parent.width + height: parent.height + onOpenDrawer: infoPanel.open = setDrawer + delegate: Notification {} + } + /* + MyList{ + id: timeline2; + width: parent.width + height: parent.height + model: 0 + onOpenDrawer: infoPanel.open = setDrawer + } + MyList{ + id: timeline3; + width: parent.width + height: parent.height + model: 30 + onOpenDrawer: infoPanel.open = setDrawer + } + MyList{ + id: timeline4; + width: parent.width + height: parent.height + model: 4 + onOpenDrawer: infoPanel.open = setDrawer + }*/ + + } } + Component.onCompleted: { /*Mastodon.api.post("statuses", {status:"First toot by Tooter - Mastodon client for #SailfishOS"}, function (data) { console.log(JSON.stringify(data)) diff --git a/qml/pages/Profile.qml b/qml/pages/Profile.qml new file mode 100644 index 0000000..c50ee52 --- /dev/null +++ b/qml/pages/Profile.qml @@ -0,0 +1,219 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import "../lib/API.js" as Logic +import "./components/" +import QtGraphicalEffects 1.0 + +Page { + property ListModel tweets; + property string displayname : ""; + property string username : ""; + property string profileImage : ""; + property int user_id; + property int statuses_count; + property int following_count; + property int followers_count; + property int favourites_count; + property int count_moments; + property string profile_background : ""; + property string note: ""; + + property bool locked : false; + property date created_at; + property bool following : false; + property bool requested : false; + property bool followed_by : false; + property bool blocking : false; + property bool muting : false; + property bool domain_blocking : false; + + + WorkerScript { + id: worker + source: "../lib/Worker.js" + onMessage: { + console.log(JSON.stringify(messageObject)) + if(messageObject.action === "accounts/relationships/"){ + console.log(JSON.stringify(messageObject)) + following= messageObject.data.following + requested= messageObject.data.requested + followed_by= messageObject.data.followed_by + blocking= messageObject.data.blocking + muting= messageObject.data.muting + domain_blocking= messageObject.data.domain_blocking + } + switch (messageObject.key) { + case 'followers_count': + followers_count = messageObject.data + break; + case 'following_count': + following_count = messageObject.data + break; + case 'acct': + username = messageObject.data + break; + case 'locked': + locked = messageObject.data + break; + case 'created_at': + created_at = messageObject.data + break; + case 'statuses_count': + statuses_count = messageObject.data + break; + case 'note': + note = messageObject.data + break; + case 'following': + following = messageObject.data + followers_count = followers_count + (following ? 1 : - 1) + break; + case 'muting': + muting = messageObject.data + break; + case 'muting': + muting = messageObject.data + break; + case 'blocking': + blocking = messageObject.data + followers_count = followers_count + (blocking ? -1 : 0) + break; + case 'followed_by': + followed_by = messageObject.data + break; + } + } + } + // The effective value will be restricted by ApplicationWindow.allowedOrientations + allowedOrientations: Orientation.All + Component.onCompleted: { + var msg = { + 'action' : "accounts/relationships/", + 'params' : [ {name: "id", data: user_id}], + 'conf' : Logic.conf + }; + worker.sendMessage(msg); + msg = { + 'action' : "accounts/"+user_id, + 'conf' : Logic.conf + }; + worker.sendMessage(msg); + } + + + + MyList { + header: ProfileHeader { + id: header + title: displayname + description: '@'+username + image: profileImage + } + + anchors { + top: parent.top + bottom: expander.top + left: parent.left + right: parent.right + } + clip: true + + model: ListModel {} + type: "accounts/"+user_id+"/statuses" + vars: {} + conf: Logic.getConfTW() + } + + + ExpandingSectionGroup { + id: expander + //currentIndex: 0 + anchors { + bottom: parent.bottom + left: parent.left + right: parent.right + } + ExpandingSection { + title: qsTr("Summary") + content.sourceComponent: Column { + spacing: Theme.paddingMedium + anchors.bottomMargin: Theme.paddingLarge + DetailItem { + visible: followers_count ? true : false + label: qsTr("Followers") + value: followers_count + } + DetailItem { + visible: following_count ? true : false + label: qsTr("Following") + value: (following_count) + } + DetailItem { + visible: statuses_count ? true : false + label: qsTr("Statuses") + value: (statuses_count) + } + DetailItem { + visible: favourites_count ? true : false + label: qsTr("Favourites") + value: (favourites_count) + } + + Column { + spacing: Theme.paddingMedium + anchors.horizontalCenter: parent.horizontalCenter + Button { + id: btnFollow + text: (following ? qsTr("Unfollow") : (requested ? qsTr("Follow request sent!") : qsTr("Follow"))) + onClicked: { + var msg = { + 'method' : 'POST', + 'params' : [], + 'action' : "accounts/" + user_id + (following ? '/unfollow':'/follow'), + 'conf' : Logic.conf + }; + worker.sendMessage(msg); + } + } + Button { + id: btnMute + text: (muting ? qsTr("Unmute") : qsTr("Mute")) + onClicked: { + var msg = { + 'method' : 'POST', + 'params' : [], + 'action' : "accounts/" + user_id + (muting ? '/unmute':'/mute'), + 'conf' : Logic.conf + }; + worker.sendMessage(msg); + } + } + Button { + id: btnBlock + text: (blocking ? qsTr("Unblock") : qsTr("Block") ) + onClicked: { + var msg = { + 'method' : 'POST', + 'params' : [], + 'action' : "accounts/" + user_id + (blocking ? '/unblock':'/block'), + 'conf' : Logic.conf + }; + worker.sendMessage(msg); + } + } + } + Label { + text: " " + } + } + + } + /*ExpandingSection { + title: "Tweets" + + }*/ + } + + + +} diff --git a/qml/pages/JSONListModel.qml b/qml/pages/components/JSONListModel.qml similarity index 100% rename from qml/pages/JSONListModel.qml rename to qml/pages/components/JSONListModel.qml diff --git a/qml/pages/components/MyList.qml b/qml/pages/components/MyList.qml new file mode 100644 index 0000000..75f6b3a --- /dev/null +++ b/qml/pages/components/MyList.qml @@ -0,0 +1,183 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import "../../lib/API.js" as Logic +import "." + + +SilicaListView { + id: myList + property string type; + property string title + property string description + property ListModel mdl: [] + property variant params: [] + property var locale: Qt.locale() + property bool loadStarted : false; + property int scrollOffset; + property string action: "" + property variant vars + property variant conf + model: mdl + signal notify (string what, int num) + onNotify: { + console.log(what + " - " + num) + } + + + signal openDrawer (bool setDrawer) + onOpenDrawer: { + //console.log("Open drawer: " + setDrawer) + } + signal send (string notice) + onSend: { + console.log("LIST send signal emitted with notice: " + notice) + } + + + BusyIndicator { + size: BusyIndicatorSize.Large + running: myList.model.count === 0 && !viewPlaceHolder.visible + anchors.centerIn: parent + } + + header: PageHeader { + title: myList.title + description: myList.description + } + + + + ViewPlaceholder { + id: viewPlaceHolder + enabled: model.count === 0 + text: "" + hintText: "" + } + + PullDownMenu { + MenuItem { + text: Logic.conf['login'] ? qsTrId("Logout"): qsTrId("Login") + onClicked: { + if (Logic.conf['login']) { + Logic.conf['login'] = false + Logic.conf['instance'] = null; + Logic.conf['api_user_token'] = null; + Logic.conf['dysko'] = null; + } else { + Logic.conf['login'] = true + Logic.conf['instance'] = "https://mastodon.social"; + Logic.conf['api_user_token'] = '6d8cb23e3ebf3c7a97dd9adf204e47ad159f1a3d07dbbd0325e98981368d8c51'; + } + } + } + + MenuItem { + text: qsTr("Load more") + onClicked: { + loadData("prepend") + } + } + } + PushUpMenu { + MenuItem { + text: qsTr("Load more") + onClicked: { + loadData("append") + } + } + } + clip: true + section { + property: 'section' + criteria: ViewSection.FullString + delegate: SectionHeader { + text: { + var dat = Date.fromLocaleDateString(locale, section); + dat = Format.formatDate(dat, Formatter.TimepointRelativeCurrentDay) + if (dat === "00:00:00" || dat === "00:00") { + visible = false; + height = 0; + return " "; + }else { + return dat; + } + + } + + } + } + + delegate: Toot {} + + add: Transition { + NumberAnimation { property: "opacity"; from: 0; to: 1.0; duration: 800 } + NumberAnimation { property: "x"; duration: 800; easing.type: Easing.InOutBack } + } + + displaced: Transition { + NumberAnimation { properties: "x,y"; duration: 800; easing.type: Easing.InOutBack } + } + + onCountChanged: { + contentY = scrollOffset + console.log("CountChanged!") + + //last_id_MN + + } + onContentYChanged: { + + if (contentY > scrollOffset) { + openDrawer(false) + + } else { + if (contentY < 100 && !loadStarted){ + } + openDrawer(true) + } + scrollOffset = contentY + } + VerticalScrollDecorator {} + + WorkerScript { + id: worker + source: "../../lib/Worker.js" + onMessage: { + if (messageObject.error){ + console.log(JSON.stringify(messageObject)) + } + if (messageObject.notifyNewItems){ + console.log(JSON.stringify(messageObject.notifyNewItems)) + } + } + } + + Component.onCompleted: { + var msg = { + 'action' : type, + 'params' : [ ], + 'model' : model, + 'mode' : "append", + 'conf' : Logic.conf + }; + worker.sendMessage(msg); + } + function loadData(mode){ + var p = []; + if (mode === "append" && model.count){ + p.push({name: 'max_id', data: model.get(model.count-1).id}); + } + if (mode === "prepend" && model.count){ + p.push({name:'since_id', data: model.get(0).id}); + } + + var msg = { + 'action' : type, + 'params' : p, + 'model' : model, + 'mode' : mode, + 'conf' : Logic.conf + }; + worker.sendMessage(msg); + } +} diff --git a/qml/pages/components/Navigation.qml b/qml/pages/components/Navigation.qml new file mode 100644 index 0000000..8252914 --- /dev/null +++ b/qml/pages/components/Navigation.qml @@ -0,0 +1,164 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import QtGraphicalEffects 1.0 + +SilicaGridView { + signal slideshowShow(int vIndex); + signal slideshowIndexChanged(int vIndex); + onSlideshowIndexChanged: { + navigateTo(vIndex) + } + + id: gridView + property bool isPortrait: false + ListModel { + id: listModel + ListElement { + icon: "../../images/home.svg" + slug: "timeline" + name: "Timeline" + active: true + unread: false + } + ListElement { + icon: "../../images/home.svg" + slug: "mentions" + name: "Mentions" + active: false + unread: false + } + ListElement { + icon: "../../images/notification.svg" + slug: "msgs" + name: "Messagess" + active: false + } + ListElement { + icon: "../../images/search.svg" + slug: "search" + name: "Search" + active: false + unread: false + } + } + model: listModel + anchors.fill: parent + currentIndex: -1 + + cellWidth: isPortrait ? gridView.width : gridView.width / listModel.count + cellHeight: isPortrait ? gridView.height/listModel.count : gridView.height + + + delegate: BackgroundItem { + clip: true + id: rectangle + width: gridView.cellWidth + height: gridView.cellHeight + GridView.onAdd: AddAnimation { + target: rectangle + } + GridView.onRemove: RemoveAnimation { + target: rectangle + } + GlassItem { + id: effect + visible: !isPortrait && unread + width: Theme.itemSizeMedium + height: Theme.itemSizeMedium + dimmed: true + anchors.bottom: parent.bottom + anchors.bottomMargin: -height/2 + anchors.horizontalCenter: parent.horizontalCenter + color: Theme.highlightColor + } + + GlassItem { + id: effect2 + visible: isPortrait && unread + width: Theme.itemSizeMedium + height: Theme.itemSizeMedium + dimmed: false + anchors.right: parent.right; + anchors.rightMargin: -height/2; + anchors.verticalCenter: parent.verticalCenter + color: Theme.highlightColor + } + + OpacityRampEffect { + sourceItem: label + offset: 0.5 + } + + /*Image { + source: model.icon + (highlighted + ? Theme.highlightColor + : (model.active ? Theme.primaryColor : Theme.secondaryHighlightColor)) + anchors.centerIn: parent + }*/ + Image { + id: image + source: model.icon + (highlighted + ? Theme.highlightColor + : (model.active ? Theme.primaryColor : Theme.secondaryHighlightColor)) + anchors.centerIn: parent + smooth: true + visible: false + } + ColorOverlay { + anchors.fill: image + source: image + color: (highlighted + ? Theme.highlightColor + : (model.active ? Theme.primaryColor : Theme.secondaryHighlightColor)) + } + Text { + anchors.centerIn: parent + visible: false + text: model.icon + color: (highlighted + ? Theme.highlightColor + : (model.active ? Theme.primaryColor : Theme.secondaryHighlightColor)) + } + + Label { + id: label + visible: false + anchors { + bottom: parent.bottom + } + horizontalAlignment : Text.AlignHCente + width: parent.width + color: (highlighted ? Theme.highlightColor : Theme.secondaryHighlightColor) + + text: { + return model.name.toUpperCase(); + } + + font { + pixelSize: Theme.fontSizeExtraSmall + family: Theme.fontFamilyHeading + } + } + onClicked: { + slideshowShow(index) + console.log(index) + navigateTo(model.slug) + effect.state = "right" + } + + } + function navigateTo(slug){ + for(var i = 0; i < listModel.count; i++){ + if (listModel.get(i).slug === slug || i===slug) + listModel.setProperty(i, 'active', true); + else + listModel.setProperty(i, 'active', false); + } + console.log(slug) + + } + + + + VerticalScrollDecorator {} +} diff --git a/qml/pages/components/Notification.qml b/qml/pages/components/Notification.qml new file mode 100644 index 0000000..f87137b --- /dev/null +++ b/qml/pages/components/Notification.qml @@ -0,0 +1,158 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import QtGraphicalEffects 1.0 + +BackgroundItem { + signal send (string notice) + + id: delegate + //property string text: "0" + width: parent.width + signal navigateTo(string link) + height: lblText.paintedHeight + (lblText.text.length > 0 ? Theme.paddingLarge : 0 )+ lblName.paintedHeight + Theme.paddingLarge + + Image { + id: avatar + x: Theme.horizontalPageMargin + y: Theme.paddingLarge + asynchronous: true + width: Theme.iconSizeMedium + height: width + smooth: true + source: account_avatar + visible: true + MouseArea { + anchors.fill: parent + onClicked: { + pageStack.push(Qt.resolvedUrl("../Profile.qml"), { + "user_id": account_id, + "displayname": account_display_name, + "username": account_acct, + "profileImage": account_avatar + }) + } + + } + + } + Label { + id: lblName + anchors { + top: avatar.top + topMargin: 0 + left: avatar.right + leftMargin: Theme.paddingMedium + } + text: account_display_name + font.weight: Font.Bold + font.pixelSize: Theme.fontSizeSmall + color: (pressed ? Theme.highlightColor : Theme.primaryColor) + } + + Image { + id: iconVerified + y: Theme.paddingLarge + anchors { + left: lblName.right + leftMargin: Theme.paddingSmall + verticalCenter: lblName.verticalCenter + } + visible: account_locked + width: account_locked ? Theme.iconSizeExtraSmall*0.8 : 0 + opacity: 0.8 + height: width + source: "image://theme/icon-s-secure?" + (pressed + ? Theme.highlightColor + : Theme.primaryColor) + } + + + Label { + id: lblScreenName + anchors { + left: iconVerified.right + right: lblDate.left + leftMargin: Theme.paddingMedium + baseline: lblName.baseline + } + truncationMode: TruncationMode.Fade + text: '@'+account_acct + font.pixelSize: Theme.fontSizeExtraSmall + color: (pressed ? Theme.secondaryHighlightColor : Theme.secondaryColor) + } + Label { + function timestamp() { + var txt = Format.formatDate(created_at, Formatter.Timepoint) + var elapsed = Format.formatDate(created_at, Formatter.DurationElapsedShort) + return (elapsed ? elapsed : txt ) + } + id: lblDate + color: (pressed ? Theme.highlightColor : Theme.primaryColor) + text: Format.formatDate(created_at, new Date() - created_at < 60*60*1000 ? Formatter.DurationElapsedShort : Formatter.TimeValueTwentyFourHours) + font.pixelSize: Theme.fontSizeExtraSmall + horizontalAlignment: Text.AlignRight + anchors { + right: parent.right + baseline: lblName.baseline + rightMargin: Theme.paddingLarge + } + } + + Label { + id: lblText + anchors { + left: lblName.left + right: parent.right + top: lblScreenName.bottom + topMargin: Theme.paddingSmall + rightMargin: Theme.paddingLarge + } + height: content.length ? paintedHeight : 0 + onLinkActivated: { + console.log(link) + if (link[0] === "@") { + pageStack.push(Qt.resolvedUrl("../Profile.qml"), { + "name": "", + "username": link.substring(1), + "profileImage": "" + }) + } else if (link[0] === "#") { + + pageStack.pop(pageStack.find(function(page) { + var check = page.isFirstPage === true; + if (check) + page.onLinkActivated(link) + return check; + })); + + send(link) + } else { + pageStack.push(Qt.resolvedUrl("../Browser.qml"), {"href" : link}) + } + + + } + text: { + switch (type){ + case "mention": + return qsTr("metioned you") + case "reblog": + return qsTr("boosted your status") + case "favourite": + return qsTr("favourited your status") + case "follow": + return qsTr("followed you") + default: + return type + } + } + + textFormat: Text.StyledText + linkColor : Theme.highlightColor + wrapMode: Text.Wrap + maximumLineCount: 6 + font.pixelSize: Theme.fontSizeSmall + color: (pressed ? Theme.highlightColor : Theme.primaryColor) + } + +} diff --git a/qml/pages/components/ProfileHeader.qml b/qml/pages/components/ProfileHeader.qml new file mode 100644 index 0000000..88dbe89 --- /dev/null +++ b/qml/pages/components/ProfileHeader.qml @@ -0,0 +1,73 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 + +Item { + id: header + property int value: 0; + property string title: ""; + property string description: ""; + property string image: ""; + property string bg: ""; + width: parent.width + height: icon.height + Theme.paddingLarge*2 + /*Image { + anchors.fill: parent + asynchronous: true + fillMode: Image.PreserveAspectCrop + source: bg + opacity: 0.3 + }*/ + Rectangle { + anchors.fill: parent + opacity: 0.1 + gradient: Gradient { + GradientStop { position: 0.0; color: Theme.highlightBackgroundColor } + GradientStop { position: 1.0; color: Theme.highlightBackgroundColor } + } + + } + Image { + id: icon + anchors { + left: parent.left + leftMargin: Theme.paddingLarge + top: parent.top + topMargin: Theme.paddingLarge + } + asynchronous: true + width: description === "" ? Theme.iconSizeMedium : Theme.iconSizeLarge + height: width + source: image + } + Column { + anchors { + left: icon.left + leftMargin: Theme.paddingLarge + right: parent.right + rightMargin: Theme.paddingLarge + verticalCenter: parent.verticalCenter + } + Label { + id: ttl + text: title + height: contentHeight + color: Theme.highlightColor + font.pixelSize: Theme.fontSizeLarge + font.family: Theme.fontFamilyHeading + horizontalAlignment: Text.AlignRight + truncationMode: TruncationMode.Fade + width: parent.width + } + Label { + height: description === "" ? 0 : contentHeight + text: description + color: Theme.secondaryHighlightColor + font.pixelSize: Theme.fontSizeSmall + font.family: Theme.fontFamilyHeading + horizontalAlignment: Text.AlignRight + truncationMode: TruncationMode.Fade + width: parent.width + } + } + +} diff --git a/qml/pages/components/Toot.qml b/qml/pages/components/Toot.qml new file mode 100644 index 0000000..5e8e453 --- /dev/null +++ b/qml/pages/components/Toot.qml @@ -0,0 +1,165 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import QtGraphicalEffects 1.0 + +BackgroundItem { + signal send (string notice) + + id: delegate + //property string text: "0" + width: parent.width + signal navigateTo(string link) + height: lblText.paintedHeight + (lblText.text.length > 0 ? Theme.paddingLarge : 0 )+ lblName.paintedHeight + (isReblog ? Theme.paddingLarge + iconRT.height : 0) + Theme.paddingLarge + Image { + id: iconRT + y: Theme.paddingLarge + anchors { + right: avatar.right + } + visible: isReblog + width: Theme.iconSizeExtraSmall + height: width + source: "image://theme/icon-s-retweet?" + (pressed ? Theme.primaryColor : Theme.secondaryColor) + } + Label { + id: lblRtByName + visible: isReblog + anchors { + left: lblName.left + bottom: iconRT.bottom + } + text: '@' + retweetScreenName + ' boosted' + font.pixelSize: Theme.fontSizeExtraSmall + color: Theme.secondaryColor + } + Image { + id: avatar + x: Theme.horizontalPageMargin + y: Theme.paddingLarge + (isReblog ? iconRT.height+Theme.paddingMedium : 0) + asynchronous: true + width: Theme.iconSizeMedium + height: width + smooth: true + source: account_avatar + visible: true + MouseArea { + anchors.fill: parent + onClicked: { + pageStack.push(Qt.resolvedUrl("../Profile.qml"), { + "user_id": account_id, + "displayname": displayname, + "username": username, + "profileImage": account_avatar + }) + } + + } + + } + Label { + id: lblName + anchors { + top: avatar.top + topMargin: 0 + left: avatar.right + leftMargin: Theme.paddingMedium + } + text: displayname + font.weight: Font.Bold + font.pixelSize: Theme.fontSizeSmall + color: (pressed ? Theme.highlightColor : Theme.primaryColor) + } + + Image { + id: iconVerified + y: Theme.paddingLarge + anchors { + left: lblName.right + leftMargin: Theme.paddingSmall + verticalCenter: lblName.verticalCenter + } + visible: account_locked + width: account_locked ? Theme.iconSizeExtraSmall*0.8 : 0 + opacity: 0.8 + height: width + source: "image://theme/icon-s-secure?" + (pressed + ? Theme.highlightColor + : Theme.primaryColor) + } + + + Label { + id: lblScreenName + anchors { + left: iconVerified.right + right: lblDate.left + leftMargin: Theme.paddingMedium + baseline: lblName.baseline + } + truncationMode: TruncationMode.Fade + text: '@'+username + font.pixelSize: Theme.fontSizeExtraSmall + color: (pressed ? Theme.secondaryHighlightColor : Theme.secondaryColor) + } + Label { + function timestamp() { + var txt = Format.formatDate(created_at, Formatter.Timepoint) + var elapsed = Format.formatDate(created_at, Formatter.DurationElapsedShort) + return (elapsed ? elapsed : txt ) + } + id: lblDate + color: (pressed ? Theme.highlightColor : Theme.primaryColor) + text: Format.formatDate(created_at, new Date() - created_at < 60*60*1000 ? Formatter.DurationElapsedShort : Formatter.TimeValueTwentyFourHours) + font.pixelSize: Theme.fontSizeExtraSmall + horizontalAlignment: Text.AlignRight + anchors { + right: parent.right + baseline: lblName.baseline + rightMargin: Theme.paddingLarge + } + } + + Label { + id: lblText + anchors { + left: lblName.left + right: parent.right + top: lblScreenName.bottom + topMargin: Theme.paddingSmall + rightMargin: Theme.paddingLarge + } + height: content.length ? paintedHeight : 0 + onLinkActivated: { + console.log(link) + if (link[0] === "@") { + pageStack.push(Qt.resolvedUrl("../Profile.qml"), { + "name": "", + "username": link.substring(1), + "profileImage": "" + }) + } else if (link[0] === "#") { + + pageStack.pop(pageStack.find(function(page) { + var check = page.isFirstPage === true; + if (check) + page.onLinkActivated(link) + return check; + })); + + send(link) + } else { + pageStack.push(Qt.resolvedUrl("../Browser.qml"), {"href" : link}) + } + + + } + text: content + textFormat: Text.StyledText + linkColor : Theme.highlightColor + wrapMode: Text.Wrap + maximumLineCount: 6 + font.pixelSize: Theme.fontSizeSmall + color: (pressed ? Theme.highlightColor : Theme.primaryColor) + } + +} diff --git a/translations/harbour-tooter-de.ts b/translations/harbour-tooter-de.ts index 106df21..2db46c7 100644 --- a/translations/harbour-tooter-de.ts +++ b/translations/harbour-tooter-de.ts @@ -12,11 +12,111 @@ + + Browser + + Open in Browser + + + + Web mode + + + + Reading mode + + + MainPage - Show Page 2 - Zur Seite 2 + Timeline + + + + Home + + + + Notifications + + + + + MyList + + Load more + + + + + Notification + + metioned you + + + + boosted your status + + + + favourited your status + + + + followed you + + + + + Profile + + Unfollow + + + + Follow request sent! + + + + Following + + + + Mute + + + + Unmute + + + + Unblock + + + + Block + + + + Statuses + + + + Favourites + + + + Follow + + + + Summary + + + + Followers + diff --git a/translations/harbour-tooter.ts b/translations/harbour-tooter.ts index ebb8a23..030e74c 100644 --- a/translations/harbour-tooter.ts +++ b/translations/harbour-tooter.ts @@ -5,14 +5,118 @@ - Tooter + + + + + - Settings + Browser + + Open in Browser + + + + Web mode + + + + Reading mode + + + + + MainPage + + Timeline + + + + Home + + + + Notifications + + + + + MyList Load more - Cargar más + Cargar más + + + + Notification + + metioned you + + + + boosted your status + + + + favourited your status + + + + followed you + + + + + Profile + + Unfollow + + + + Follow request sent! + + + + Following + + + + Mute + + + + Unmute + + + + Unblock + + + + Block + + + + Statuses + + + + Favourites + + + + Follow + + + + Summary + + + + Followers +