├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── CHANGES ├── LICENSE ├── README.md ├── config ├── ba.dysko.harbour.tooterb.service ├── ba.dysko.harbour.tooterb.xml ├── icon-lock-harbour-tooterb.png └── x-harbour.tooterb.activity.conf ├── harbour-tooterb.desktop ├── harbour-tooterb.pro ├── icons ├── 108x108 │ └── harbour-tooterb.png ├── 128x128 │ └── harbour-tooterb.png ├── 172x172 │ └── harbour-tooterb.png ├── 256x256 │ └── harbour-tooterb.png └── 86x86 │ └── harbour-tooterb.png ├── qml ├── cover │ └── CoverPage.qml ├── harbour-tooterb.qml ├── images │ ├── icon-l-profile.svg │ ├── icon-m-bookmark.svg │ ├── icon-m-emoji.svg │ ├── icon-m-profile.svg │ ├── icon-s-bookmark.svg │ ├── icon-s-follow.svg │ └── tooter-cover.svg ├── lib │ ├── API.js │ ├── Mastodon.js │ └── Worker.js └── pages │ ├── ConversationPage.qml │ ├── LoginPage.qml │ ├── MainPage.qml │ ├── ProfilePage.qml │ ├── SettingsPage.qml │ └── components │ ├── EmojiSelect.qml │ ├── InfoBanner.qml │ ├── ItemUser.qml │ ├── MediaBlock.qml │ ├── MediaFullScreen.qml │ ├── MediaItem.qml │ ├── MiniHeader.qml │ ├── MiniStatus.qml │ ├── MyList.qml │ ├── MyMedia.qml │ ├── NavigationPanel.qml │ ├── ProfileHeader.qml │ ├── ProfileImage.qml │ └── VisualContainer.qml ├── rpm ├── harbour-tooterb.changes ├── harbour-tooterb.changes.run └── harbour-tooterb.spec ├── screenshots ├── screenshot1.png ├── screenshot2.png └── screenshot3.png ├── src ├── dbus.cpp ├── dbus.h ├── dbusAdaptor.cpp ├── dbusAdaptor.h ├── filedownloader.cpp ├── filedownloader.h ├── harbour-tooterb.cpp ├── imageuploader.cpp ├── imageuploader.h ├── notifications.cpp └── notifications.h └── translations ├── harbour-tooterb-de.ts ├── harbour-tooterb-el.ts ├── harbour-tooterb-en.ts ├── harbour-tooterb-es.ts ├── harbour-tooterb-fr.ts ├── harbour-tooterb-it.ts ├── harbour-tooterb-nl.ts ├── harbour-tooterb-nl_BE.ts ├── harbour-tooterb-oc.ts ├── harbour-tooterb-pl.ts ├── harbour-tooterb-ru.ts ├── harbour-tooterb-sr.ts ├── harbour-tooterb-sv.ts ├── harbour-tooterb-zh_CN.ts └── harbour-tooterb.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | liberapay: poetaster 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build RPMs 2 | 3 | on: 4 | push: 5 | tags: 6 | - "1.*" 7 | 8 | env: 9 | OS_VERSION: 4.4.0.68 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | name: Build App 15 | strategy: 16 | matrix: 17 | arch: ['armv7hl', 'aarch64', 'i486'] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Prepare 23 | run: docker pull coderus/sailfishos-platform-sdk:$OS_VERSION && mkdir output 24 | 25 | - name: Build ${{ matrix.arch }} 26 | run: docker run --rm --privileged -v $PWD:/share coderus/sailfishos-platform-sdk:$OS_VERSION /bin/bash -c " 27 | mkdir -p build ; 28 | cd build ; 29 | cp -r /share/* . ; 30 | mb2 -t SailfishOS-$OS_VERSION-${{ matrix.arch }} build ; 31 | sudo cp -r RPMS/*.rpm /share/output" 32 | 33 | - name: Upload RPM (${{ matrix.arch }}) 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: rpm-${{ matrix.arch }} 37 | path: output 38 | release: 39 | name: Release 40 | if: startsWith(github.ref, 'refs/tags/1.1') 41 | needs: 42 | - build 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Download armv7hl 46 | uses: actions/download-artifact@v4 47 | with: 48 | name: rpm-armv7hl 49 | continue-on-error: true 50 | - name: Download aarch64 51 | uses: actions/download-artifact@v4 52 | with: 53 | name: rpm-aarch64 54 | continue-on-error: true 55 | - name: Download i486 56 | uses: actions/download-artifact@v4 57 | with: 58 | name: rpm-i486 59 | continue-on-error: true 60 | - name: Extract Version Name 61 | id: extract_name 62 | uses: actions/github-script@v4 63 | with: 64 | result-encoding: string 65 | script: | 66 | return context.payload.ref.replace(/refs\/tags\//, ''); 67 | - name: Create a Release 68 | uses: softprops/action-gh-release@v1 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | with: 72 | name: ${{ steps.extract_name.outputs.result }} 73 | draft: false 74 | prerelease: false 75 | body: This release was autogenerated. 76 | files: '*.rpm' 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.user 2 | harbour-tooterb.pro.user 3 | harbour-tooterb.pro.user* 4 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | * Thu Jan 26 2023 Mark Washeim - 1.1.6-2 2 | - [Release] obs fix, broken changes 3 | - [Release] changes spacing issues 4 | 5 | * Wed Jan 25 2023 Mark Washeim - 1.1.6 6 | - [Release] This release adds uploads for other filetypes and removes the hardcoded mimetype 7 | - [Feature] modified image uploader to serve other mimetypes, added pickers 8 | - [Fix] hot fix for a breaks tooting bug 9 | 10 | * Thu Jan 19 2023 Mark Washeim - 1.1.5 11 | - [Release] hot bug fix, broken tooting 12 | - [Release] 1.1.5 fixes a bug which stops tooting! 13 | - [Fix] hot fix for a breaks tooting bug. 14 | 15 | * Wed Jan 18 2023 Mark Washeim - 1.1.4 16 | - [Fix] Home loading and search deduping release 17 | - [Fix] search for tags and persons added header call for max/min and prepend on first call. 18 | - [Issue]: # 26. timelines/home on append uses the link header for min/max id 19 | 20 | * Thu Jan 5 2023 Mark Washeim 1.1.3 21 | - Add changes from gitlogs (++) Bump for tag release. 22 | - Add conditional append all for search when no knownIds 23 | - Add correction to linkprev for bookmarks (and follows, etc) 24 | 25 | * Wed Jan 4 2023 Mark Washeim 1.1.2 26 | - Add conditional append all for search when no knownIds Workers.js 27 | - Add linkprev/next for bookmarks (and follows, etc) MyList 28 | - getLink method in Mastodon.js HEAD requests for Link 29 | 30 | * Fri Dec 23 2022 Mark Washeim 1.1.1 31 | - This is an interim release that should not have been released 32 | 33 | * Fri Dec 2 2022 Mark Washeim 1.1.0 34 | - Added some more debug flags to stop spewing the console. 35 | - Added self to credits. 36 | - Added MediaItem display for audio and Integrated MediaItem into MediaBlock/MyMedia elements. 37 | - Add additional; media element for audio. 38 | - Merge pull request #16 from cintema/patch-2 39 | - Merge pull request #15 from cintema/patch-1 40 | - Update harbour-tooterb.spec 41 | - Update LoginPage.qml 42 | - Merge pull request #5 from eson57/patch-2 43 | - Update harbour-tooterb-sv.ts 44 | 45 | * Wed Nov 16 2022 Mark Washeim 1.0.9 46 | - Forgot to remove the yaml from spec. duh. 47 | - Boost release. 48 | - Update translations, partially for new bookmarks view. 49 | - Remove yaml since it's just in the way. 50 | - Added navigation elements and model for bookmarks. 51 | 52 | * Mon Nov 14 2022 Mark Washeim 1.0.8-3 53 | - Had neglected linguist include which is required for building on obs 54 | 55 | * Wed Nov 9 2022 Mark Washeim 1.0.8-2 56 | - Added minimal info to spec/yaml to include a release in chum. 57 | - Merge pull request #100 from poetaster/master 58 | - Merge pull request #99 from juanro49/master 59 | - Fix reblog content view 60 | 61 | * Wed Nov 9 2022 Mark Washeim 1.0.8 62 | - Add python server for callbacks 63 | - Add new WebView to better render callbacks 64 | 65 | * Sun Jul 12 2020 molan 1.0.7-0 66 | - Fix missing / wrong reblog and favourite counts in Retoots (issue #90) 67 | - Added full landscape support 68 | - Added new Pulley Menu options 69 | - Improved Toot context menu 70 | - Improved media page 71 | - Improved loading indicators 72 | - Small changes for some UI-elements 73 | - New Emojis 74 | - New translated strings 75 | 76 | * Fri Jun 18 2020 molan 1.0.6-3 77 | - Fix broken reblog indication 78 | 79 | * Fri Jun 18 2020 molan 1.0.6-2 80 | - Fix reported small UI issue 81 | - Updated translations 82 | 83 | * Thu Jun 18 2020 molan 1.0.6-1 84 | - Fix app crash when open some Profile pages 85 | - Fix sometimes missing favourite / reblog counts 86 | - Fix various QML warnings, replace deprecated Silica items 87 | - Add save to Bookmarks feature 88 | - Add Follows you / Locked / Bot / Group labels to Profile Page header 89 | - Add Bot icon to user display name 90 | - Add clicking on reblog-avatar opens reblog user profile 91 | - Remove Locked icon from user display name 92 | - Further improved Notification Page / general UI 93 | - Code refactoring & other changes under the hood 94 | - Translation updates 95 | 96 | * Fri Jun 12 2020 molan 1.0.5-1 97 | - [hotfix] fix missing images in mentions on Notification page 98 | 99 | * Thu Jun 11 2020 molan 1.0.5-0 100 | - fixed: show search results without entering # before term 101 | - fixed: non-clickable user mentions in Toots 102 | - fixed: Copy link to clipboard in Conversations 103 | - Notifications Page: Reworked UI and context menus for notifications 104 | - Profile Page: Open fullscreen profile image 105 | - Profile Page: Show bot label 106 | - Profile Page: New expander for Profile details 107 | - Conversation Page: Possibility to hide and reopen Toot text field 108 | - Conversation Page: Improved display of uploaded images 109 | - Media Page: Adjust size of images to screen width or height 110 | - Media Page: Only automatically restart videos if shorter than 30 seconds 111 | - new Settings Page 112 | - bigger custom emojis in Toots 113 | - overall improvement of UI 114 | 115 | * Mon May 25 2020 molan 1.0.4-3 116 | - Show user profile background image (if available) 117 | - New Sailfish 3-styled image/video viewer page (WIP) 118 | - Added "Toot sent!" notification banner 119 | - Show Pulley Menu for copying Toot-link only if link is provided (WIP) 120 | - Distiction between "New Toot" and "Conversation" page 121 | - some small fixes 122 | 123 | * Mon May 11 2020 molan 1.0.4-2 124 | - Beta release by molan 125 | - Login / Settings Page: Small changes in text wording 126 | - Login Page: Use of correct label coloring and text alignment 127 | - Login Page: Highlight login confirmation button + 'accept' icon on Sailfish keyboard 128 | - Media Page: Switched play / pause buttons during media playback 129 | - Conversation Page: Improved alignment of elements in 'New Toot' (no more overlapping) 130 | - Settings Page: Replaced icons in Settings page for consistency and clarity 131 | - Settings Page: Added missing language contributor 132 | - Translations: Completed and fixed German and French translations 133 | - Translations: Added complete Italian translation 134 | - Translations: Added missing/lost strings and updates to other translation files 135 | - Timeline: Better text formatting in toots (show paragraph breaks) 136 | - Timeline: Use shortend username if display_name isn't provided in ProfileHeader and MiniHeader 137 | - Timeline: Created new placeholder for profile avatars if instance doesn't provide valid image 138 | 139 | * Thu Apr 16 2020 Dusko Angirevic 1.0.4-1 140 | - Merge with molan code 141 | 142 | * Tue Feb 04 2020 molan 1.0.3-8 143 | - Fix for broken translations 144 | - Updated Spanish translation 145 | 146 | * Mon Feb 03 2020 molan 1.0.3-7 147 | - Updated translations for new language strings 148 | 149 | * Thu Jan 30 2020 molan 1.0.3-6 150 | - Workaround for opening user profiles in toots 151 | - Show profile descriptions (Bio) with option to open them in Browser 152 | - Updated and improved UI for Conversation page 153 | - Indication for sending toot (move back to previous page) 154 | - New arrangement of main pages (like used in Mastodon websites and other apps) 155 | - Small UI and text/label changes 156 | 157 | * Thu Jan 16 2020 molan 1.0.3-5 [fork of Tooter 1.0.3] 158 | - Fix for broken profile pages when clicking on usernames in toots 159 | - Fixed navigation icons for inverted ambiences 160 | - Updated Chinese translation (thanks to dashinfantry) 161 | 162 | * Wed Jan 15 2020 molan 1.0.3-4 [fork of Tooter 1.0.3] 163 | - Website links in toots now open directly in browser since the web scraper service which was used before is discontinued 164 | - Profile page now shows full display name in title instead of user name 165 | - Changed send, content warning and add emoji icon in Conversation page for clarification 166 | - Small update to Chinese translation (thanks to dashinfantry) 167 | - Completed German and French translations 168 | 169 | * Mon Jan 06 2020 molan 1.0.3-3 [fork of Tooter 1.0.3] 170 | - Update and rename harbour-tooter-zh.ts to harbour-tooter-zh_CN.ts (thanks to dashinfantry) 171 | 172 | * Sat Dec 28 2019 molan 1.0.3-1 [fork of Tooter 1.0.3] 173 | - Fixed broken Mastodon login (app built with Sailfish SDK 2.4) 174 | - Fixed crash on certain notifications 175 | 176 | * Sun Jan 27 2019 Dusko Angirevic 1.0.3-0 177 | - Remorse popup added for account removal 178 | - Updated translations 179 | 180 | * Tue Jan 15 2019 Dusko Angirevic 1.0.2-0 181 | - SailfishOS 3.0 build 182 | - Chinese language translation added 183 | 184 | * Fri Oct 26 2018 Dusko Angirevic 0.2.8-0 185 | - Fixed conversation bug 186 | 187 | * Thu Oct 25 2018 Dusko Angirevic 0.2.8-0 188 | - Fixed localisation issue 189 | - Added character counter 190 | 191 | * Tue Oct 23 2018 Dusko Angirevic 0.2.7-0 192 | - Added emoji custom support 193 | - Bugfix: missing media on boosted toots 194 | 195 | * Thu May 24 2018 Dusko Angirevic 0.2.6-0 196 | - Minor bugfix 197 | 198 | * Thu May 24 2018 Dusko Angirevic 0.2.5-0 199 | - Added local timeline 200 | 201 | * Thu Nov 02 2017 Dusko Angirevic 0.2.4-0 202 | - Updated translations 203 | - Added Russian 204 | 205 | * Fri Oct 27 2017 Dusko Angirevic 0.2.3-0 206 | - Added User autocomplete options 207 | - Added video player options 208 | - Smileys are inserted on the cursor position 209 | - Pinch to zoom photos 210 | - Support for downloading media to the device 211 | - Added cover action for new toot 212 | 213 | * Thu Oct 19 2017 Dusko Angirevic 0.2.2-0 214 | - Updated translations 215 | - Cover page fix for SailfishX 216 | - Added Copy URL option 217 | - Privacy option in the conversation is now inherited by toot in the thread 218 | 219 | * Tue Oct 10 2017 Dusko Angirevic 0.2.1-0 220 | - Added bugs for later (merging branches and contributions) 221 | 222 | * Thu Jul 20 2017 Dusko Angirevic 0.2.0-0 223 | - Better tablet displaying 224 | - "boosted" notification bugfix 225 | - ES lang update by Caballlero 226 | 227 | * Thu Jul 7 2017 Dusko Angirevic 0.1.9-0 228 | - Image Upload added [#9] 229 | - Emoji pannel added 230 | - ES lang update by Carlos Gonzales 231 | 232 | * Tue Jul 4 2017 Dusko Angirevic 0.1.8-0 233 | - Added notifications 234 | - App Cover redesigned 235 | 236 | * Tue Jul 4 2017 Dusko Angirevic 0.1.7-0 237 | - Added spoiler support for toots 238 | - Press and hold for boost and favourite option 239 | - Updated harbour-tooter-es.ts by Caballlero 240 | - Unable to connect to unixcorn.xyz [#12] [bugfix] 241 | 242 | * Tue Jun 20 2017 Dusko Angirevic 0.1.6-1 243 | - Hashtag search added 244 | - Conversation with sections 245 | 246 | * Mon Jun 19 2017 Dusko Angirevic 0.1.5-1 247 | - Autoload older tweets on the list end 248 | - Displaying images on the timeline 249 | - NSFW support for images 250 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tooter β 2 | 3 | ## About 4 | Tooter is Mastodon client for [Sailfish OS](https://sailfishos.org). 5 | 6 | This fork is being used to further develop and maintain the Tooter app by dysko ([harbour-tooter](https://github.com/dysk0/harbour-tooter)) and molan [OpenRepos.net](https://openrepos.net/content/molan/tooter-v). 7 | 8 | This fork continues the work done by molan, thanks! primary distribution is through obs/chum but build actions on github provide a build here on github with every release. As soon as migration code for sailjail paths is implemented, tooter will make it to the harbour! 9 | 10 | * Releases from this repository (Tooter β from release branch *master*) can be found on [OpenRepos.net](https://openrepos.net/content/molan/tooter-v) 11 | * Releases by dysko can be found on the Jolla store and on [OpenRepos.net](https://openrepos.net/content/dysko/tooter) 12 | 13 | ## Build 14 | Clone / download this repository and import it into your SailfishOS IDE using the harbour-tooter.pro project file. No additional configuration needed. 15 | 16 | ## Repository branches: 17 | * master: release branch which includes specifics for harbour-tooterb (Tooter β) 18 | 19 | ## Contributions 20 | Contributions to this project are very welcome, since there are still many things which can be done for Tooter. If you already know what you want to add or fix, please make a Pull Request (PR) with your proposal. Your PR should include an explanation or a change log summary. Merging will not be allowed until the PR has been reviewed. 21 | 22 | ## Screenshots 23 | 24 | 25 | 26 | ## License 27 | Licensed under GNU GPLv3 28 | -------------------------------------------------------------------------------- /config/ba.dysko.harbour.tooterb.service: -------------------------------------------------------------------------------- 1 | [D-BUS Service] 2 | Name=ba.dysko.harbour.tooter 3 | Exec=/usr/bin/invoker --type=silica-qt5 --desktop-file=harbour-tooter.desktop -s -n /usr/bin/harbour-tooter -------------------------------------------------------------------------------- /config/ba.dysko.harbour.tooterb.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /config/icon-lock-harbour-tooterb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poetaster/harbour-tooter/1f404be9e3303e367c225317be030123584e5861/config/icon-lock-harbour-tooterb.png -------------------------------------------------------------------------------- /config/x-harbour.tooterb.activity.conf: -------------------------------------------------------------------------------- 1 | appIcon=/usr/share/harbour-tooterb/config/icon-lock-harbour-tooterb.png 2 | x-nemo-icon=/usr/share/harbour-tooterb/config/icon-lock-harbour-tooterb.png 3 | x-nemo-priority=120 4 | x-nemo-feedback=sms_exists 5 | x-nemo-led-disabled-without-body-and-summary=false 6 | -------------------------------------------------------------------------------- /harbour-tooterb.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | X-Nemo-Application-Type=silica-qt5 4 | Icon=harbour-tooterb 5 | Exec=harbour-tooterb 6 | Name=Tooter β 7 | 8 | [X-Sailjail] 9 | OrganizationName=de.poetaster 10 | ApplicationName=harbour-tooterb 11 | Permissions=Compatibility;Internet;Audio;MediaIndexing;RemovableMedia;UserDirs 12 | -------------------------------------------------------------------------------- /harbour-tooterb.pro: -------------------------------------------------------------------------------- 1 | # NOTICE: 2 | # 3 | # Application name defined in TARGET has a corresponding QML filename. 4 | # If name defined in TARGET is changed, the following needs to be done 5 | # to match new name: 6 | # - corresponding QML filename must be changed 7 | # - desktop icon filename must be changed 8 | # - desktop filename must be changed 9 | # - icon definition filename in desktop file must be changed 10 | # - translation filenames have to be changed 11 | 12 | TARGET = harbour-tooterb 13 | 14 | CONFIG += sailfishapp 15 | 16 | QT += network dbus sql 17 | QT += multimedia 18 | CONFIG += link_pkgconfig 19 | PKGCONFIG += sailfishapp \ 20 | nemonotifications-qt5 21 | 22 | DEFINES += "APPVERSION=\\\"$${SPECVERSION}\\\"" 23 | DEFINES += "APPNAME=\\\"$${TARGET}\\\"" 24 | 25 | !exists( src/dbusAdaptor.h ) { 26 | system(qdbusxml2cpp config/ba.dysko.harbour.tooterb.xml -i dbus.h -a src/dbusAdaptor) 27 | } 28 | 29 | config.path = /usr/share/$${TARGET}/config/ 30 | config.files = config/icon-lock-harbour-tooterb.png 31 | 32 | notification_categories.path = /usr/share/lipstick/notificationcategories 33 | notification_categories.files = config/x-harbour.tooterb.activity.* 34 | 35 | dbus_services.path = /usr/share/dbus-1/services/ 36 | dbus_services.files = config/ba.dysko.harbour.tooterb.service 37 | 38 | interfaces.path = /usr/share/dbus-1/interfaces/ 39 | interfaces.files = config/ba.dysko.harbourb.tooterb.xml 40 | 41 | SOURCES += src/harbour-tooterb.cpp \ 42 | src/imageuploader.cpp \ 43 | src/filedownloader.cpp \ 44 | src/notifications.cpp \ 45 | src/dbusAdaptor.cpp \ 46 | src/dbus.cpp 47 | 48 | HEADERS += src/imageuploader.h \ 49 | src/filedownloader.h \ 50 | src/notifications.h \ 51 | src/dbusAdaptor.h \ 52 | src/dbus.h 53 | 54 | DISTFILES += qml/harbour-tooterb.qml \ 55 | qml/images/tooterb-cover.svg \ 56 | qml/pages/ConversationPage.qml \ 57 | qml/pages/ProfilePage.qml \ 58 | qml/pages/SettingsPage.qml \ 59 | qml/pages/components/InfoBanner.qml \ 60 | qml/pages/components/MediaFullScreen.qml \ 61 | qml/pages/components/MyMedia.qml \ 62 | qml/pages/components/NavigationPanel.qml \ 63 | qml/pages/components/ProfileImage.qml \ 64 | qml/pages/components/VisualContainer.qml \ 65 | qml/pages/components/MiniStatus.qml \ 66 | qml/pages/components/MiniHeader.qml \ 67 | qml/pages/components/ItemUser.qml \ 68 | qml/pages/components/MyList.qml \ 69 | qml/pages/components/ProfileHeader.qml \ 70 | qml/pages/components/MediaBlock.qml \ 71 | qml/pages/components/MediaItem.qml \ 72 | qml/cover/CoverPage.qml \ 73 | qml/pages/MainPage.qml \ 74 | qml/pages/LoginPage.qml \ 75 | qml/pages/Browser.qml \ 76 | qml/lib/API.js \ 77 | qml/images/icon-s-following \ 78 | qml/images/icon-s-bookmark \ 79 | qml/images/icon-m-bookmark \ 80 | qml/images/icon-m-emoji.svg \ 81 | qml/images/icon-m-profile.svg \ 82 | qml/images/icon-l-profile.svg \ 83 | qml/lib/Mastodon.js \ 84 | qml/lib/Worker.js \ 85 | config/icon-lock-harbour-tooterb.png \ 86 | config/x-harbour.tooterb.activity.conf \ 87 | rpm/harbour-tooterb.changes.run \ 88 | rpm/harbour-tooterb.changes \ 89 | rpm/harbour-tooterb.spec \ 90 | translations/*.ts \ 91 | harbour-tooterb.desktop 92 | 93 | SAILFISHAPP_ICONS = 86x86 108x108 128x128 172x172 94 | 95 | # to disable building translations every time, comment out the 96 | # following CONFIG line 97 | CONFIG += sailfishapp_i18n 98 | 99 | TRANSLATIONS += translations/harbour-tooterb.ts \ 100 | translations/harbour-tooterb-de.ts \ 101 | translations/harbour-tooterb-el.ts \ 102 | translations/harbour-tooterb-es.ts \ 103 | translations/harbour-tooterb-fr.ts \ 104 | translations/harbour-tooterb-it.ts \ 105 | translations/harbour-tooterb-nl.ts \ 106 | translations/harbour-tooterb-nl_BE.ts \ 107 | translations/harbour-tooterb-oc.ts \ 108 | translations/harbour-tooterb-pl.ts \ 109 | translations/harbour-tooterb-ru.ts \ 110 | translations/harbour-tooterb-sr.ts \ 111 | translations/harbour-tooterb-sv.ts \ 112 | translations/harbour-tooterb-zh_CN.ts 113 | -------------------------------------------------------------------------------- /icons/108x108/harbour-tooterb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poetaster/harbour-tooter/1f404be9e3303e367c225317be030123584e5861/icons/108x108/harbour-tooterb.png -------------------------------------------------------------------------------- /icons/128x128/harbour-tooterb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poetaster/harbour-tooter/1f404be9e3303e367c225317be030123584e5861/icons/128x128/harbour-tooterb.png -------------------------------------------------------------------------------- /icons/172x172/harbour-tooterb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poetaster/harbour-tooter/1f404be9e3303e367c225317be030123584e5861/icons/172x172/harbour-tooterb.png -------------------------------------------------------------------------------- /icons/256x256/harbour-tooterb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poetaster/harbour-tooter/1f404be9e3303e367c225317be030123584e5861/icons/256x256/harbour-tooterb.png -------------------------------------------------------------------------------- /icons/86x86/harbour-tooterb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poetaster/harbour-tooter/1f404be9e3303e367c225317be030123584e5861/icons/86x86/harbour-tooterb.png -------------------------------------------------------------------------------- /qml/cover/CoverPage.qml: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2013 Jolla Ltd. 3 | Contact: Thomas Perl 4 | All rights reserved. 5 | 6 | You may use this file under the terms of BSD license as follows: 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | * Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | * Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the distribution. 15 | * Neither the name of the Jolla Ltd nor the 16 | names of its contributors may be used to endorse or promote products 17 | derived from this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR 23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 26 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | */ 30 | 31 | import QtQuick 2.0 32 | import Sailfish.Silica 1.0 33 | import "../lib/API.js" as Logic 34 | 35 | 36 | CoverBackground { 37 | onStatusChanged: { 38 | switch (status ){ 39 | case PageStatus.Activating: 40 | console.log("PageStatus.Activating") 41 | //timer.triggered() 42 | break; 43 | case PageStatus.Inactive: 44 | //timer.triggered() 45 | console.log("PageStatus.Inactive") 46 | break; 47 | } 48 | } 49 | 50 | Image { 51 | id: bg 52 | source: "../images/tooter-cover.svg" 53 | horizontalAlignment: Image.AlignLeft 54 | verticalAlignment: Image.AlignBottom 55 | fillMode: Image.PreserveAspectFit 56 | anchors { 57 | bottom : parent.bottom 58 | left: parent.left 59 | right: parent.right 60 | top: parent.top 61 | } 62 | } 63 | 64 | Timer { 65 | id: timer 66 | interval: 60*1000 67 | triggeredOnStart: true 68 | repeat: true 69 | onTriggered: checkNotifications(); 70 | } 71 | 72 | Image { 73 | id: iconNot 74 | source: "image://theme/icon-s-alarm?" + Theme.highlightColor 75 | anchors { 76 | left: parent.left 77 | top: parent.top 78 | leftMargin: Theme.paddingLarge 79 | topMargin: Theme.paddingLarge 80 | } 81 | } 82 | 83 | Label { 84 | id: notificationsLbl 85 | text: " " 86 | color: Theme.highlightColor 87 | anchors { 88 | left: iconNot.right 89 | leftMargin: Theme.paddingMedium 90 | verticalCenter: iconNot.verticalCenter 91 | } 92 | } 93 | 94 | Label { 95 | text: "Tooter β" 96 | color: Theme.secondaryColor 97 | anchors { 98 | right: parent.right 99 | rightMargin: Theme.paddingLarge 100 | verticalCenter: iconNot.verticalCenter 101 | } 102 | } 103 | 104 | signal activateapp(string person, string notice) 105 | CoverActionList { 106 | id: coverAction 107 | /*CoverAction { 108 | iconSource: "image://theme/icon-cover-next" 109 | onTriggered: { 110 | Logic.conf.notificationLastID = 0; 111 | } 112 | }*/ 113 | 114 | CoverAction { 115 | iconSource: "image://theme/icon-cover-new" 116 | onTriggered: { 117 | pageStack.push(Qt.resolvedUrl("./../pages/ConversationPage.qml"), { 118 | headerTitle: qsTr("New Toot"), 119 | type: "new" 120 | }) 121 | appWindow.activate() 122 | } 123 | } 124 | } 125 | function checkNotifications(){ 126 | console.log("checkNotifications") 127 | var notificationsNum = 0 128 | var notificationLastID = Logic.conf.notificationLastID; 129 | //Logic.conf.notificationLastID = 0; 130 | for(var i = 0; i < Logic.modelTLnotifications.count; i++) { 131 | if (notificationLastID < Logic.modelTLnotifications.get(i).id) { 132 | notificationLastID = Logic.modelTLnotifications.get(i).id 133 | } 134 | 135 | if (Logic.conf.notificationLastID < Logic.modelTLnotifications.get(i).id) { 136 | notificationsNum++ 137 | Logic.notifier(Logic.modelTLnotifications.get(i)) 138 | } 139 | } 140 | notificationsLbl.text = notificationsNum; 141 | Logic.conf.notificationLastID = notificationLastID; 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /qml/harbour-tooterb.qml: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2013 Jolla Ltd. 3 | Contact: Thomas Perl 4 | All rights reserved. 5 | 6 | You may use this file under the terms of BSD license as follows: 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | * Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | * Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the distribution. 15 | * Neither the name of the Jolla Ltd nor the 16 | names of its contributors may be used to endorse or promote products 17 | derived from this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR 23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 26 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | */ 30 | 31 | import QtQuick 2.0 32 | import Sailfish.Silica 1.0 33 | import "pages" 34 | import "./lib/API.js" as Logic 35 | 36 | ApplicationWindow { 37 | id: appWindow 38 | allowedOrientations: defaultAllowedOrientations 39 | cover: Qt.resolvedUrl("cover/CoverPage.qml") 40 | Component.onCompleted: { 41 | var obj = {} 42 | Logic.mediator.installTo(obj) 43 | obj.subscribe('confLoaded', function() { 44 | //console.log('confLoaded'); 45 | //console.log(JSON.stringify(Logic.conf)) 46 | if (!Logic.conf['notificationLastID']) 47 | Logic.conf['notificationLastID'] = 0 48 | 49 | if (Logic.conf['instance']) { 50 | Logic.api = Logic.mastodonAPI({ 51 | "instance": Logic.conf['instance'], 52 | "api_user_token": "" 53 | }) 54 | } 55 | 56 | if (Logic.conf['login']) { 57 | //Logic.conf['notificationLastID'] = 0 58 | Logic.api.setConfig("api_user_token", Logic.conf['api_user_token']) 59 | //accounts/verify_credentials 60 | Logic.api.get('instance', [], function(data) { 61 | // console.log(JSON.stringify(data)) 62 | pageStack.push(Qt.resolvedUrl("./pages/MainPage.qml"), {}) 63 | }) 64 | //pageStack.push(Qt.resolvedUrl("./pages/Conversation.qml"), {}) 65 | } else { 66 | pageStack.push(Qt.resolvedUrl("./pages/LoginPage.qml"), {}) 67 | } 68 | }) 69 | Logic.init() 70 | 71 | 72 | } 73 | 74 | Component.onDestruction: { 75 | //Logic.conf.notificationLastID = 0; 76 | Logic.saveData() 77 | } 78 | 79 | Connections { 80 | target: Dbus 81 | onViewtoot: { 82 | //console.log(key, "dbus onViewtoot") 83 | } 84 | onActivateapp: { 85 | //console.log ("dbus activate app") 86 | pageStack.pop(pageStack.find( function(page) { 87 | return (page._depth === 0) 88 | })) 89 | activate() 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /qml/images/icon-l-profile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 12 | 13 | 15 | 17 | 18 | -------------------------------------------------------------------------------- /qml/images/icon-m-bookmark.svg: -------------------------------------------------------------------------------- 1 | 2 | icon-m-bookmark 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /qml/images/icon-m-emoji.svg: -------------------------------------------------------------------------------- 1 | 2 | Artboard 1 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /qml/images/icon-m-profile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 20 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /qml/images/icon-s-bookmark.svg: -------------------------------------------------------------------------------- 1 | 2 | icon-s-bookmark 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /qml/images/icon-s-follow.svg: -------------------------------------------------------------------------------- 1 | 2 | Artboard 1 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /qml/images/tooter-cover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 12 | 13 | 20 | 21 | 22 | 27 | 28 | 31 | 33 | 34 | 35 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /qml/lib/API.js: -------------------------------------------------------------------------------- 1 | .pragma library 2 | .import QtQuick.LocalStorage 2.0 as LS 3 | 4 | var db = LS.LocalStorage.openDatabaseSync("tooterb", "", "harbour-tooterb", 100000); 5 | var conf = {}; 6 | var mediator = (function(){ 7 | var subscribe = function(channel, fn){ 8 | if(!mediator.channels[channel]) mediator.channels[channel] = []; 9 | mediator.channels[channel].push({ context : this, callback : fn }); 10 | return this; 11 | }; 12 | 13 | var publish = function(channel){ 14 | if(!mediator.channels[channel]) return false; 15 | var args = Array.prototype.slice.call(arguments, 1); 16 | for(var i = 0, l = mediator.channels[channel].length; i < l; i++){ 17 | var subscription = mediator.channels[channel][i]; 18 | subscription.callback.apply(subscription.context.args); 19 | }; 20 | return this; 21 | }; 22 | 23 | return { 24 | channels : {}, 25 | publish : publish, 26 | subscribe : subscribe, 27 | installTo : function(obj){ 28 | obj.subscribe = subscribe; 29 | obj.publish = publish; 30 | } 31 | }; 32 | }()); 33 | 34 | var init = function(){ 35 | console.log("db.version: "+db.version); 36 | if(db.version === '') { 37 | db.transaction(function(tx) { 38 | tx.executeSql('CREATE TABLE IF NOT EXISTS settings (' 39 | + ' key TEXT UNIQUE, ' 40 | + ' value TEXT ' 41 | + ');'); 42 | //tx.executeSql('INSERT INTO settings (key, value) VALUES (?, ?)', ["conf", "{}"]); 43 | }); 44 | db.changeVersion('', '0.1', function(tx) { 45 | 46 | }); 47 | } 48 | db.transaction(function(tx) { 49 | var rs = tx.executeSql('SELECT * FROM settings;'); 50 | console.log("READING CONF FROM DB") 51 | for (var i = 0; i < rs.rows.length; i++) { 52 | //var json = JSON.parse(rs.rows.item(i).value); 53 | console.log(rs.rows.item(i).key+" \t > \t "+rs.rows.item(i).value) 54 | conf[rs.rows.item(i).key] = JSON.parse(rs.rows.item(i).value) 55 | } 56 | console.log("END OF READING") 57 | console.log(JSON.stringify(conf)); 58 | mediator.publish('confLoaded', { loaded: true}); 59 | }); 60 | }; 61 | 62 | function saveData() { 63 | console.log("SAVING CONF TO DB") 64 | db.transaction(function(tx) { 65 | for (var key in conf) { 66 | if (conf.hasOwnProperty(key)){ 67 | console.log(key + "\t>\t"+conf[key]); 68 | if (typeof conf[key] === "object" && conf[key] === null) { 69 | tx.executeSql('DELETE FROM settings WHERE key=? ', [key]) 70 | } else { 71 | tx.executeSql('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?) ', [key, JSON.stringify(conf[key])]) 72 | } 73 | } 74 | } 75 | console.log("END OF SAVING") 76 | }); 77 | } 78 | 79 | var tootParser = function(data){ 80 | console.log(data) 81 | var ret = {}; 82 | ret.id = data.id 83 | ret.content = data.content 84 | ret.created_at = data.created_at 85 | ret.in_reply_to_account_id = data.in_reply_to_account_id 86 | ret.in_reply_to_id = data.in_reply_to_id 87 | 88 | ret.user_id = data.account.id 89 | ret.user_locked = data.account.locked 90 | ret.username = data.account.username 91 | ret.display_name = data.account.display_name 92 | ret.avatar_static = data.account.avatar_static 93 | 94 | ret.favourited = data.favourited ? true : false 95 | ret.status_favourites_count = data.favourites_count ? data.favourites_count : 0 96 | 97 | ret.reblog = data.reblog ? true : false 98 | ret.reblogged = data.reblogged ? true : false 99 | ret.status_reblogs_count = data.reblogs_count ? data.reblogs_count : false 100 | 101 | ret.bookmarked = data.bookmarked ? true : false 102 | 103 | ret.muted = data.muted ? true : false 104 | ret.sensitive = data.sensitive ? true : false 105 | ret.visibility = data.visibility ? data.visibility : false 106 | 107 | console.log(ret) 108 | } 109 | 110 | var test = 1; 111 | 112 | Qt.include("Mastodon.js") 113 | 114 | var modelTLhome = Qt.createQmlObject('import QtQuick 2.0; ListModel { }', Qt.application, 'InternalQmlObject'); 115 | var modelTLpublic = Qt.createQmlObject('import QtQuick 2.0; ListModel { }', Qt.application, 'InternalQmlObject'); 116 | var modelTLlocal = Qt.createQmlObject('import QtQuick 2.0; ListModel { }', Qt.application, 'InternalQmlObject'); 117 | var modelTLnotifications = Qt.createQmlObject('import QtQuick 2.0; ListModel { }', Qt.application, 'InternalQmlObject'); 118 | var modelTLsearch = Qt.createQmlObject('import QtQuick 2.0; ListModel { }', Qt.application, 'InternalQmlObject'); 119 | var modelTLbookmarks = Qt.createQmlObject('import QtQuick 2.0; ListModel { }', Qt.application, 'InternalQmlObject'); 120 | 121 | var notificationsList = [] 122 | 123 | var notificationGenerator = function(item){ 124 | var notification; 125 | switch (item.urgency){ 126 | case "normal": 127 | notification = Qt.createQmlObject('import org.nemomobile.notifications 1.0; Notification { category: "x-harbour.tooterb.activity"; appName: "Tooter β"; itemCount: 1; remoteActions: [ { "name": "default", "displayName": "Do something", "icon": "icon-s-certificates", "service": "ba.dysko.harbour.tooterb", "path": "/", "iface": "ba.dysko.harbour.tooterb", "method": "openapp", "arguments": [ "'+item.service+'", "'+item.key+'" ] }]; urgency: Notification.Normal; }', Qt.application, 'InternalQmlObject'); 128 | break; 129 | case "critical": 130 | notification = Qt.createQmlObject('import org.nemomobile.notifications 1.0; Notification { appName: "Tooter β"; itemCount: 1; remoteActions: [ { "name": "default", "displayName": "Do something", "icon": "icon-s-certificates", "service": "ba.dysko.harbour.tooterb", "path": "/", "iface": "ba.dysko.harbour.tooterb", "method": "openapp", "arguments": [ "'+item.service+'", "'+item.key+'" ] }]; urgency: Notification.Critical; }', Qt.application, 'InternalQmlObject'); 131 | break; 132 | default: 133 | notification = Qt.createQmlObject('import org.nemomobile.notifications 1.0; Notification { category: "x-harbour.tooterb.activity"; appName: "Tooter β"; itemCount: 1; remoteActions: [ { "name": "default", "displayName": "Do something", "icon": "icon-s-certificates", "service": "ba.dysko.harbour.tooterb", "path": "/", "iface": "ba.dysko.harbour.tooterb", "method": "openapp", "arguments": [ "'+item.service+'", "'+item.key+'" ] }]; urgency: Notification.Low; }', Qt.application, 'InternalQmlObject'); 134 | } 135 | 136 | console.log(JSON.stringify(notification.remoteActions[0].arguments)) 137 | //Notifications.notify("Tooter β", "serverinfo.serverTitle", " new activity", false, "2015-10-15 00:00:00", "aaa") 138 | 139 | notification.timestamp = item.timestamp 140 | notification.summary = item.summary 141 | notification.body = item.body 142 | if(item.previewBody) 143 | notification.previewBody = item.previewBody; 144 | else 145 | notification.previewBody = item.body; 146 | if(item.previewSummary) 147 | notification.previewSummary = item.previewSummary; 148 | else 149 | notification.previewSummary = item.summary 150 | if(notification.replacesId){ notification.replacesId = 0 } 151 | notification.publish() 152 | } 153 | 154 | var notifier = function(item){ 155 | 156 | item.content = item.content.replace(/(<([^>]+)>)/ig,"").replaceAll(""", "\"") 157 | 158 | var msg; 159 | switch (item.type){ 160 | case "favourite": 161 | msg = { 162 | urgency: "normal", 163 | timestamp: item.created_at, 164 | summary: (item.reblog_account_display_name !== "" ? item.reblog_account_display_name : '@'+item.reblog_account_username) + ' ' + qsTr("favourited"), 165 | body: item.content, 166 | service: 'toot', 167 | key: item.id 168 | } 169 | break; 170 | 171 | case "follow": 172 | msg = { 173 | urgency: "critical", 174 | timestamp: item.created_at, 175 | summary: (item.account_display_name !== "" ? item.account_display_name : '@'+item.account_username), 176 | body: qsTr("followed you"), 177 | service: 'profile', 178 | key: item.account_username 179 | } 180 | break; 181 | 182 | case "reblog": 183 | msg = { 184 | urgency: "low", 185 | timestamp: item.created_at, 186 | summary: (item.reblog_account_display_name !== "" ? item.reblog_account_display_name : '@'+item.reblog_account_username) + ' ' + qsTr("boosted"), 187 | body: item.content, 188 | service: 'toot', 189 | key: item.id 190 | } 191 | break; 192 | 193 | case "mention": 194 | msg = { 195 | urgency: "critical", 196 | timestamp: item.created_at, 197 | summary: (item.account_display_name !== "" ? item.account_display_name : '@'+item.account_username) + ' ' + qsTr("said"), 198 | body: item.content, 199 | previewBody: (item.account_display_name !== "" ? item.account_display_name : '@'+item.account_username) + ' ' + qsTr("said") + ': ' + item.content, 200 | service: 'toot', 201 | key: item.id 202 | } 203 | break; 204 | 205 | default: 206 | //console.log(JSON.stringify(messageObject.data)) 207 | return; 208 | } 209 | notificationGenerator(msg) 210 | } 211 | 212 | 213 | var api; 214 | 215 | function func() { 216 | console.log(api) 217 | } 218 | -------------------------------------------------------------------------------- /qml/lib/Mastodon.js: -------------------------------------------------------------------------------- 1 | // by @kirschn@pleasehug.me 2017 2 | // no fucking copyright 3 | // do whatever you want with it 4 | // but please don't hurt it (and keep this header) 5 | 6 | var mastodonAPI = function(config) { 7 | var apiBase = config.instance + "/api/v1/"; 8 | return { 9 | setConfig: function (key, value) { 10 | // modify initial config afterwards 11 | config[key] = value; 12 | }, 13 | 14 | getConfig: function(key) { 15 | //get config key 16 | return config[key]; 17 | }, 18 | 19 | /* 20 | * function to retrieve the Link header 21 | * using HEAD, so bookmarks has head followed by GET 22 | */ 23 | 24 | getLink: function (endpoint) { 25 | // variables 26 | var queryData, callback, 27 | queryStringAppend = "?"; 28 | 29 | // check with which arguments we're supplied 30 | if (typeof arguments[1] === "function") { 31 | queryData = {}; 32 | callback = arguments[1]; 33 | } else { 34 | queryData = arguments[1]; 35 | callback = arguments[2]; 36 | } 37 | // build queryData Object into a URL Query String 38 | for (var i in queryData) { 39 | if (queryData.hasOwnProperty(i)) { 40 | if (typeof queryData[i] === "string") { 41 | queryStringAppend += queryData[i] + "&"; 42 | } else if (typeof queryData[i] === "object") { 43 | queryStringAppend += queryData[i].name + "="+ queryData[i].data + "&"; 44 | } 45 | } 46 | } 47 | var http = new XMLHttpRequest() 48 | var url = apiBase + endpoint; 49 | console.log("HEAD" + apiBase + endpoint + queryStringAppend) 50 | 51 | http.open("HEAD", apiBase + endpoint + queryStringAppend, true); 52 | // Send the proper header information along with the request 53 | http.setRequestHeader("Authorization", "Bearer " + config.api_user_token); 54 | http.setRequestHeader("Content-Type", "application/json"); 55 | http.setRequestHeader("Connection", "close"); 56 | 57 | http.onreadystatechange = function() { 58 | if (http.readyState === 4) { 59 | if (http.status === 200) { 60 | callback( http.getResponseHeader("Link") , http.status) 61 | console.log("Successful HEAD API request to " +apiBase+endpoint); 62 | } else { 63 | console.log("error: " + http.status) 64 | } 65 | } 66 | } 67 | http.send(); 68 | 69 | }, 70 | 71 | get: function (endpoint) { 72 | // for GET API calls 73 | // can be called with two or three parameters 74 | // endpoint, callback 75 | // or 76 | // endpoint, queryData, callback 77 | // where querydata is an object {["paramname1", "paramvalue1], ["paramname2","paramvalue2"]} 78 | 79 | // variables 80 | var queryData, callback, 81 | queryStringAppend = "?"; 82 | 83 | // check with which arguments we're supplied 84 | if (typeof arguments[1] === "function") { 85 | queryData = {}; 86 | callback = arguments[1]; 87 | } else { 88 | queryData = arguments[1]; 89 | callback = arguments[2]; 90 | } 91 | // build queryData Object into a URL Query String 92 | for (var i in queryData) { 93 | if (queryData.hasOwnProperty(i)) { 94 | if (typeof queryData[i] === "string") { 95 | queryStringAppend += queryData[i] + "&"; 96 | } else if (typeof queryData[i] === "object") { 97 | queryStringAppend += queryData[i].name + "="+ queryData[i].data + "&"; 98 | } 99 | } 100 | } 101 | //queryStringAppend += "limit=20" 102 | // ajax function 103 | var http = new XMLHttpRequest() 104 | var url = apiBase + endpoint; 105 | console.log(apiBase + endpoint + queryStringAppend) 106 | http.open("GET", apiBase + endpoint + queryStringAppend, true); 107 | 108 | // Send the proper header information along with the request 109 | http.setRequestHeader("Authorization", "Bearer " + config.api_user_token); 110 | http.setRequestHeader("Content-Type", "application/json"); 111 | http.setRequestHeader("Connection", "close"); 112 | 113 | http.onreadystatechange = function() { // Call a function when the state changes. 114 | if (http.readyState === 4) { 115 | if (http.status === 200) { 116 | callback(JSON.parse(http.response),http.status) 117 | console.log("Successful GET API request to " +apiBase+endpoint); 118 | } else { 119 | console.log("error: " + http.status) 120 | } 121 | } 122 | } 123 | http.send(); 124 | }, 125 | 126 | post: function (endpoint) { 127 | // for POST API calls 128 | var postData, callback; 129 | // check with which arguments we're supplied 130 | if (typeof arguments[1] === "function") { 131 | postData = {}; 132 | callback = arguments[1]; 133 | } else { 134 | postData = arguments[1]; 135 | callback = arguments[2]; 136 | } 137 | 138 | var http = new XMLHttpRequest() 139 | var url = apiBase + endpoint; 140 | var params = JSON.stringify(postData); 141 | http.open("POST", url, true); 142 | 143 | // Send the proper header information along with the request 144 | http.setRequestHeader("Authorization", "Bearer " + config.api_user_token); 145 | http.setRequestHeader("Content-Type", "application/json"); 146 | http.setRequestHeader("Content-length", params.length); 147 | http.setRequestHeader("Connection", "close"); 148 | 149 | http.onreadystatechange = function() { // Call a function when the state changes. 150 | if (http.readyState === 4) { 151 | if (http.status === 200) { 152 | console.log("Successful POST API request to " +apiBase+endpoint); 153 | callback(JSON.parse(http.response),http.status) 154 | } else { 155 | console.log("error: " + http.status) 156 | } 157 | } 158 | } 159 | http.send(params); 160 | 161 | /*$.ajax({ 162 | url: apiBase + endpoint, 163 | type: "POST", 164 | data: postData, 165 | headers: {"Authorization": "Bearer " + config.api_user_token}, 166 | success: function(data, textStatus) { 167 | console.log("Successful POST API request to " +apiBase+endpoint); 168 | callback(data,textStatus) 169 | } 170 | });*/ 171 | }, 172 | 173 | delete: function (endpoint, callback) { 174 | // for DELETE API calls. 175 | $.ajax({ 176 | url: apiBase + endpoint, 177 | type: "DELETE", 178 | headers: {"Authorization": "Bearer " + config.api_user_token}, 179 | success: function(data, textStatus) { 180 | console.log("Successful DELETE API request to " +apiBase+endpoint); 181 | callback(data,textStatus) 182 | } 183 | }); 184 | }, 185 | 186 | stream: function (streamType, onData) { 187 | // Event Stream Support 188 | // websocket streaming is undocumented. i had to reverse engineer the fucking web client. 189 | // streamType is either 190 | // user for your local home TL and notifications 191 | // public for your federated TL 192 | // public:local for your home TL 193 | // hashtag&tag=mastodonrocks for the stream of #mastodonrocks 194 | // callback gets called whenever new data ist recieved 195 | // callback { event: (eventtype), payload: {mastodon object as described in the api docs} } 196 | // eventtype could be notification (=notification) or update (= new toot in TL) 197 | //return "wss://" + apiBase.substr(8) +"streaming?access_token=" + config.api_user_token + "&stream=" + streamType 198 | 199 | var es = new WebSocket("wss://" + apiBase.substr(8) 200 | +"streaming?access_token=" + config.api_user_token + "&stream=" + streamType); 201 | var listener = function (event) { 202 | console.log("Got Data from Stream " + streamType); 203 | event = JSON.parse(event.data); 204 | event.payload = JSON.parse(event.payload); 205 | onData(event); 206 | }; 207 | es.onmessage = listener; 208 | }, 209 | 210 | registerApplication: function (client_name, redirect_uri, scopes, website, callback) { 211 | //register a new application 212 | // OAuth Auth flow: 213 | // First register the application 214 | // 2) get a access code from a user (using the link, generation function below!) 215 | // 3) insert the data you got from the application and the code from the user into 216 | // getAccessTokenFromAuthCode. Note: scopes has to be an array, every time! 217 | // For example ["read", "write"] 218 | 219 | //determine which parameters we got 220 | if (website === null) { 221 | website = ""; 222 | } 223 | // build scope array to string for the api request 224 | var scopeBuild = ""; 225 | if (typeof scopes !== "string") { 226 | scopes = scopes.join(" "); 227 | } 228 | 229 | var http = new XMLHttpRequest() 230 | var url = apiBase + "apps"; 231 | var params = 'client_name=' + client_name + '&redirect_uris=' + redirect_uri + '&scopes=' + scopes + '&website=' + website; 232 | console.log(params) 233 | http.open("POST", url, true); 234 | 235 | // Send the proper header information along with the request 236 | http.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); 237 | 238 | http.onreadystatechange = function() { // Call a function when the state changes. 239 | if (http.readyState === 4) { 240 | if (http.status === 200) { 241 | console.log("Registered Application: " + http.response); 242 | callback(http.response) 243 | } else { 244 | console.log("error: " + http.status) 245 | } 246 | } 247 | } 248 | http.send(params); 249 | }, 250 | 251 | generateAuthLink: function (client_id, redirect_uri, responseType, scopes) { 252 | return config.instance + "/oauth/authorize?client_id=" + client_id + "&redirect_uri=" + redirect_uri + 253 | "&response_type=" + responseType + "&scope=" + scopes.join("+"); 254 | }, 255 | 256 | getAccessTokenFromAuthCode: function (client_id, client_secret, redirect_uri, code, callback) { 257 | /*$.ajax({ 258 | url: config.instance + "/oauth/token", 259 | type: "POST", 260 | data: { 261 | client_id: client_id, 262 | client_secret: client_secret, 263 | redirect_uri: redirect_uri, 264 | grant_type: "authorization_code", 265 | code: code 266 | }, 267 | success: function (data, textStatus) { 268 | console.log("Got Token: " + data); 269 | callback(data); 270 | } 271 | });*/ 272 | var http = new XMLHttpRequest() 273 | var url = config.instance + "/oauth/token"; 274 | var params = 'client_id=' + client_id + '&client_secret=' + client_secret + '&redirect_uri=' + redirect_uri + '&grant_type=authorization_code&code=' + code; 275 | // console.log(params) 276 | http.open("POST", url, true); 277 | 278 | // Send the proper header information along with the request 279 | http.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); 280 | 281 | http.onreadystatechange = function() { // Call a function when the state changes. 282 | if (http.readyState === 4) { 283 | if (http.status === 200) { 284 | console.log("Got Token: " + http.response); 285 | callback(http.response) 286 | } else { 287 | console.log("error: " + http.status) 288 | } 289 | } 290 | } 291 | http.send(params); 292 | } 293 | }; 294 | }; 295 | 296 | // node.js 297 | if (typeof module !== 'undefined') { module.exports = mastodonAPI; }; 298 | 299 | String.prototype.replaceAll = function(search, replacement) { 300 | var target = this; 301 | return target.replace(new RegExp(search, 'g'), replacement); 302 | }; 303 | 304 | (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): 305 | void 0)||""+a+"";return""+b+c})}}).call(this); 306 | -------------------------------------------------------------------------------- /qml/pages/LoginPage.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | import Amber.Web.Authorization 1.0 4 | import "../lib/API.js" as Logic 5 | 6 | Page { 7 | property bool debug: false 8 | 9 | id: loginPage 10 | 11 | // The effective value will be restricted by ApplicationWindow.allowedOrientations 12 | allowedOrientations: Orientation.All 13 | 14 | SilicaFlickable { 15 | anchors.fill: parent 16 | contentHeight: column.height + Theme.paddingLarge 17 | 18 | VerticalScrollDecorator {} 19 | 20 | Column { 21 | id: column 22 | width: parent.width 23 | PageHeader { 24 | title: qsTr("Login") 25 | } 26 | 27 | SectionHeader { 28 | text: qsTr("Instance") 29 | } 30 | 31 | OAuth2Ac { 32 | id: mastodonOAuth 33 | authorizationEndpoint: instance.text + "/oauth/authorize" 34 | tokenEndpoint: instance.text + "/oauth/token" 35 | scopes: ["read", "write", "follow"] 36 | redirectListener.port: 7538 37 | 38 | onErrorOccurred: if (debug) console.log("Mastodon OAuth2 Error: " + error.code + " = " + error.message + " : " + error.httpCode) 39 | 40 | onReceivedAuthorizationCode: { 41 | if (debug) console.log("Got auth code, about to request token.") 42 | } 43 | 44 | onReceivedAccessToken: { 45 | if (debug) console.log("Got access token: " + token.access_token) 46 | Logic.conf["api_user_token"] = token.access_token 47 | Logic.conf["login"] = true; 48 | Logic.api.setConfig("api_user_token", Logic.conf["api_user_token"]) 49 | pageStack.replace(Qt.resolvedUrl("MainPage.qml"), {}) 50 | } 51 | } 52 | 53 | TextField { 54 | id: instance 55 | focus: true 56 | label: qsTr("Enter a valid Mastodon instance URL (will open a web browser for Authentication)") 57 | text: "https://" 58 | width: parent.width 59 | validator: RegExpValidator { regExp: /^(ftp|http|https):\/\/[^ "]+$/ } 60 | EnterKey.enabled: instance.acceptableInput; 61 | EnterKey.highlighted: instance.acceptableInput; 62 | EnterKey.iconSource: "image://theme/icon-m-accept" 63 | EnterKey.onClicked: { 64 | Logic.api = Logic.mastodonAPI({ instance: instance.text, api_user_token: "" }); 65 | Logic.api.registerApplication("Tooter", 66 | "http://127.0.0.1:7538", 67 | ["read", "write", "follow"], //scopes 68 | "https://github.com/poetaster/harbour-tooter#readme", //website on the login screen 69 | function(data) { 70 | 71 | if (debug) console.log(data) 72 | var conf = JSON.parse(data) 73 | conf.instance = instance.text; 74 | conf.login = false; 75 | 76 | Logic.conf = conf; 77 | if(debug) console.log(JSON.stringify(conf)) 78 | if(debug) console.log(JSON.stringify(Logic.conf)) 79 | 80 | // we got our application 81 | 82 | mastodonOAuth.clientId = conf["client_id"] 83 | mastodonOAuth.clientSecret = conf["client_secret"]; 84 | 85 | mastodonOAuth.authorizeInBrowser() 86 | } 87 | ); 88 | } 89 | } 90 | 91 | Label { 92 | id: serviceDescr 93 | text: qsTr("Mastodon is a free, open-source social network. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Pick a server that you trust — whichever you choose, you can interact with everyone else. Anyone can run their own Mastodon instance and participate in the social network seamlessly.") 94 | font.pixelSize: Theme.fontSizeExtraSmall 95 | color: Theme.highlightColor 96 | wrapMode: Text.WordWrap 97 | width: parent.width 98 | anchors { 99 | topMargin: Theme.paddingMedium 100 | left: parent.left 101 | leftMargin: Theme.horizontalPageMargin 102 | right: parent.right 103 | rightMargin: Theme.horizontalPageMargin 104 | } 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /qml/pages/MainPage.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | import "../lib/API.js" as Logic 4 | import "./components/" 5 | 6 | 7 | Page { 8 | id: mainPage 9 | property bool debug: false 10 | property bool isFirstPage: true 11 | property bool isTablet: true //Screen.sizeCategory >= Screen.Large 12 | 13 | allowedOrientations: Orientation.All 14 | 15 | // Docked Navigation panel 16 | DockedPanel { 17 | id: infoPanel 18 | open: true 19 | width: isPortrait ? parent.width : Theme.itemSizeLarge 20 | height: isPortrait ? Theme.itemSizeLarge : parent.height 21 | dock: isPortrait ? Dock.Bottom : Dock.Right 22 | 23 | NavigationPanel { 24 | id: navigation 25 | isPortrait: !mainPage.isPortrait 26 | onSlideshowShow: { 27 | if (debug) console.log(vIndex) 28 | 29 | slideshow.positionViewAtIndex(vIndex, ListView.SnapToItem) 30 | } 31 | } 32 | } 33 | 34 | VisualItemModel { 35 | id: visualModel 36 | 37 | MyList { 38 | id: tlHome 39 | title: qsTr("Home") 40 | type: "timelines/home" 41 | mdl: Logic.modelTLhome 42 | width: isPortrait ? parent.itemWidth : parent.itemWidth - Theme.itemSizeLarge 43 | height: parent.itemHeight 44 | onOpenDrawer: isPortrait ? infoPanel.open = setDrawer : infoPanel.open = true 45 | } 46 | 47 | MyList { 48 | id: tlNotifications 49 | title: qsTr("Notifications") 50 | type: "notifications" 51 | notifier: true 52 | mdl: Logic.modelTLnotifications 53 | width: isPortrait ? parent.itemWidth : parent.itemWidth - Theme.itemSizeLarge 54 | height: parent.itemHeight 55 | onOpenDrawer: isPortrait ? infoPanel.open = setDrawer : infoPanel.open = true 56 | } 57 | 58 | MyList { 59 | id: tlLocal 60 | title: qsTr("Local") 61 | type: "timelines/public?local=true" 62 | //params: ["local", true] 63 | mdl: Logic.modelTLlocal 64 | width: isPortrait ? parent.itemWidth : parent.itemWidth - Theme.itemSizeLarge 65 | height: parent.itemHeight 66 | onOpenDrawer: isPortrait ? infoPanel.open = setDrawer : infoPanel.open = true 67 | } 68 | 69 | MyList { 70 | id: tlPublic 71 | title: qsTr("Federated") 72 | type: "timelines/public" 73 | mdl: Logic.modelTLpublic 74 | width: isPortrait ? parent.itemWidth : parent.itemWidth - Theme.itemSizeLarge 75 | height: parent.itemHeight 76 | onOpenDrawer: isPortrait ? infoPanel.open = setDrawer : infoPanel.open = true 77 | } 78 | MyList { 79 | id: tlBookmarks 80 | title: qsTr("Bookmarks") 81 | type: "bookmarks" 82 | mdl: Logic.modelTLbookmarks 83 | width: isPortrait ? parent.itemWidth : parent.itemWidth - Theme.itemSizeLarge 84 | height: parent.itemHeight 85 | onOpenDrawer: isPortrait ? infoPanel.open = setDrawer : infoPanel.open = true 86 | } 87 | 88 | Item { 89 | id: tlSearch 90 | 91 | property ListModel mdl: ListModel {} 92 | property string search 93 | 94 | width: isPortrait ? parent.itemWidth : parent.itemWidth - Theme.itemSizeLarge 95 | height: parent.itemHeight 96 | onSearchChanged: { 97 | if (debug) console.log(search) 98 | loader.sourceComponent = loading 99 | if (search.charAt(0) === "@") { 100 | loader.sourceComponent = userListComponent 101 | } else if (search.charAt(0) === "#") { 102 | loader.sourceComponent = tagListComponent 103 | } else loader.sourceComponent = wordListComponent 104 | } 105 | 106 | Loader { 107 | id: loader 108 | anchors.fill: parent 109 | } 110 | 111 | Column { 112 | id: headerContainer 113 | width: tlSearch.width 114 | PageHeader { 115 | title: qsTr("Search") 116 | } 117 | 118 | SearchField { 119 | id: searchField 120 | width: parent.width 121 | placeholderText: qsTr("@user or #term") 122 | text: tlSearch.search 123 | EnterKey.iconSource: "image://theme/icon-m-enter-close" 124 | EnterKey.onClicked: { 125 | tlSearch.search = text.toLowerCase().trim() 126 | focus = false 127 | if (debug) console.log(text) 128 | } 129 | } 130 | } 131 | 132 | Component { 133 | id: loading 134 | BusyIndicator { 135 | size: BusyIndicatorSize.Large 136 | anchors.centerIn: parent 137 | running: true 138 | } 139 | } 140 | 141 | Component { 142 | id: tagListComponent 143 | MyList { 144 | id: view 145 | mdl: ListModel {} 146 | width: parent.width 147 | height: parent.height 148 | onOpenDrawer: isPortrait ? infoPanel.open = setDrawer : infoPanel.open = true 149 | anchors.fill: parent 150 | currentIndex: -1 // otherwise currentItem will steal focus 151 | header: Item { 152 | id: header 153 | width: headerContainer.width 154 | height: headerContainer.height 155 | Component.onCompleted: headerContainer.parent = header 156 | } 157 | 158 | delegate: VisualContainer 159 | Component.onCompleted: { 160 | view.type = "timelines/tag/"+tlSearch.search.substring(1) 161 | if (mdl.count) { 162 | view.loadData("append") 163 | } else { 164 | view.loadData("prepend") 165 | } 166 | } 167 | } 168 | } 169 | 170 | Component { 171 | id: userListComponent 172 | MyList { 173 | id: view2 174 | mdl: ListModel {} 175 | autoLoadMore: false 176 | width: parent.width 177 | height: parent.height 178 | onOpenDrawer: infoPanel.open = setDrawer 179 | anchors.fill: parent 180 | currentIndex: -1 // otherwise currentItem will steal focus 181 | header: Item { 182 | id: header 183 | width: headerContainer.width 184 | height: headerContainer.height 185 | Component.onCompleted: headerContainer.parent = header 186 | } 187 | 188 | delegate: ItemUser { 189 | onClicked: { 190 | pageStack.push(Qt.resolvedUrl("ProfilePage.qml"), { 191 | "display_name": model.account_display_name, 192 | "username": model.account_acct, 193 | "user_id": model.account_id, 194 | "profileImage": model.account_avatar, 195 | "profileBackground": model.account_header, 196 | "note": model.account_note, 197 | "url": model.account_url, 198 | "followers_count": model.account_followers_count, 199 | "following_count": model.account_following_count, 200 | "statuses_count": model.account_statuses_count, 201 | "locked": model.account_locked, 202 | "bot": model.account_bot, 203 | "group": model.account_group 204 | }) 205 | } 206 | } 207 | 208 | Component.onCompleted: { 209 | view2.type = "accounts/search" 210 | view2.params = [] 211 | view2.params.push({name: 'q', data: tlSearch.search.substring(1)}); 212 | view2.loadData("append") 213 | } 214 | } 215 | } 216 | 217 | Component { 218 | id: wordListComponent 219 | MyList { 220 | id: view3 221 | mdl: ListModel {} 222 | width: parent.width 223 | height: parent.height 224 | onOpenDrawer: infoPanel.open = setDrawer 225 | anchors.fill: parent 226 | currentIndex: -1 // otherwise currentItem will steal focus 227 | header: Item { 228 | id: header 229 | width: headerContainer.width 230 | height: headerContainer.height 231 | Component.onCompleted: headerContainer.parent = header 232 | } 233 | 234 | delegate: VisualContainer 235 | Component.onCompleted: { 236 | view3.type = "timelines/tag/"+tlSearch.search 237 | if (mdl.count) { 238 | view3.loadData("append") 239 | } else { 240 | view3.loadData("prepend") 241 | } 242 | } 243 | } 244 | } 245 | } 246 | } 247 | 248 | SlideshowView { 249 | id: slideshow 250 | width: parent.width 251 | height: parent.height 252 | itemWidth: isTablet ? Math.round(parent.width) : parent.width 253 | itemHeight: height 254 | clip: true 255 | model: visualModel 256 | onCurrentIndexChanged: { 257 | navigation.slideshowIndexChanged(currentIndex) 258 | } 259 | anchors { 260 | fill: parent 261 | top: parent.top 262 | rightMargin: isPortrait ? 0 : infoPanel.visibleSize 263 | bottomMargin: isPortrait ? infoPanel.visibleSize : 0 264 | } 265 | Component.onCompleted: { 266 | } 267 | } 268 | 269 | IconButton { 270 | id: newToot 271 | width: Theme.iconSizeLarge 272 | height: width 273 | visible: !isPortrait ? true : !infoPanel.open 274 | icon.source: "image://theme/icon-l-add" 275 | anchors { 276 | right: (mainPage.isPortrait ? parent.right : infoPanel.left) 277 | rightMargin: isPortrait ? Theme.paddingLarge : Theme.paddingLarge * 0.8 278 | bottom: (mainPage.isPortrait ? infoPanel.top : parent.bottom) 279 | bottomMargin: Theme.paddingLarge 280 | } 281 | onClicked: { 282 | pageStack.push(Qt.resolvedUrl("ConversationPage.qml"), { 283 | headerTitle: qsTr("New Toot"), 284 | type: "new" 285 | }) 286 | } 287 | } 288 | 289 | function onLinkActivated(href) { 290 | var test = href.split("/") 291 | debug = true 292 | if (debug) { 293 | console.log(href) 294 | console.log(JSON.stringify(test)) 295 | console.log(JSON.stringify(test.length)) 296 | } 297 | if (test.length === 5 && (test[3] === "tags" || test[3] === "tag") ) { 298 | tlSearch.search = "#"+decodeURIComponent(test[4]) 299 | slideshow.positionViewAtIndex(5, ListView.SnapToItem) 300 | navigation.navigateTo('search') 301 | if (debug) console.log("search tag") 302 | 303 | } else if (test.length === 4 && test[3][0] === "@" ) { 304 | tlSearch.search = decodeURIComponent("@"+test[3].substring(1)+"@"+test[2]) 305 | slideshow.positionViewAtIndex(5, ListView.SnapToItem) 306 | navigation.navigateTo('search') 307 | 308 | } else { 309 | Qt.openUrlExternally(href) 310 | } 311 | } 312 | 313 | Component.onCompleted: { 314 | //console.log("aaa") 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /qml/pages/SettingsPage.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | import "../lib/API.js" as Logic 4 | 5 | 6 | Page { 7 | id: settingsPage 8 | allowedOrientations: Orientation.All 9 | 10 | SilicaFlickable { 11 | contentHeight: column.height + Theme.paddingLarge 12 | contentWidth: parent.width 13 | anchors.fill: parent 14 | 15 | RemorsePopup { id: remorsePopup } 16 | 17 | VerticalScrollDecorator {} 18 | 19 | Column { 20 | id: column 21 | spacing: Theme.paddingMedium 22 | width: parent.width 23 | 24 | PageHeader { 25 | title: qsTr("Settings") 26 | } 27 | 28 | SectionHeader { text: qsTr("Options")} 29 | 30 | IconTextSwitch { 31 | text: qsTr("Load Images in Toots") 32 | description: qsTr("Disable this option if you want to preserve your data connection") 33 | icon.source: "image://theme/icon-m-image" 34 | checked: typeof Logic.conf['loadImages'] !== "undefined" && Logic.conf['loadImages'] 35 | onClicked: { 36 | Logic.conf['loadImages'] = checked 37 | } 38 | } 39 | 40 | SectionHeader { text: qsTr("Account") } 41 | 42 | Item { 43 | id: removeAccount 44 | width: parent.width 45 | height: txtRemoveAccount.height + btnRemoveAccount.height + Theme.paddingLarge 46 | anchors { 47 | left: parent.left 48 | leftMargin: Theme.horizontalPageMargin 49 | right: parent.right 50 | rightMargin: Theme.paddingLarge 51 | } 52 | 53 | Icon { 54 | id: icnRemoveAccount 55 | color: Theme.highlightColor 56 | width: Theme.iconSizeMedium 57 | fillMode: Image.PreserveAspectFit 58 | source: Logic.conf['login'] ? "image://theme/icon-m-contact" : "image://theme/icon-m-add" 59 | anchors.right: parent.right 60 | } 61 | 62 | Column { 63 | id: clnRemoveAccount 64 | spacing: Theme.paddingMedium 65 | anchors { 66 | left: parent.left 67 | right: icnRemoveAccount.left 68 | } 69 | 70 | Button { 71 | id: btnRemoveAccount 72 | text: Logic.conf['login'] ? qsTr("Remove Account") : qsTr("Add Account") 73 | preferredWidth: Theme.buttonWidthMedium 74 | anchors.horizontalCenter: parent.horizontalCenter 75 | onClicked: { 76 | remorsePopup.execute(btnRemoveAccount.text, function() { 77 | if (Logic.conf['login']) { 78 | Logic.conf['login'] = false 79 | Logic.conf['instance'] = null; 80 | Logic.conf['api_user_token'] = null; 81 | } 82 | pageStack.push(Qt.resolvedUrl("LoginPage.qml")) 83 | }) 84 | } 85 | 86 | Timer { 87 | id: timer1 88 | interval: 4700 89 | onTriggered: parent.busy = false 90 | } 91 | } 92 | 93 | Label { 94 | id: txtRemoveAccount 95 | text: Logic.conf['login'] ? qsTr("Deauthorize this app from using your account and remove account data from phone") : qsTr("Authorize this app to access your Mastodon account") 96 | font.pixelSize: Theme.fontSizeExtraSmall 97 | wrapMode: Text.Wrap 98 | color: Theme.highlightColor 99 | width: parent.width - Theme.paddingMedium 100 | anchors.left: parent.left 101 | } 102 | } 103 | } 104 | 105 | SectionHeader { 106 | text: qsTr("Translate") 107 | } 108 | 109 | LinkedLabel { 110 | id: translateLbl 111 | //: Full sentence for translation: "Use Transifex to help with app translation to your language." - The word Transifex is a link and doesn't need translation. 112 | text: qsTr("Use")+" "+"Transifex"+" "+qsTr("to help with app translation to your language.") 113 | textFormat: Text.StyledText 114 | color: Theme.highlightColor 115 | linkColor: Theme.primaryColor 116 | font.family: Theme.fontFamilyHeading 117 | font.pixelSize: Theme.fontSizeExtraSmall 118 | wrapMode: Text.Wrap 119 | anchors { 120 | left: parent.left 121 | leftMargin: Theme.horizontalPageMargin 122 | right: parent.right 123 | rightMargin: Theme.paddingLarge 124 | } 125 | } 126 | 127 | SectionHeader { 128 | //: Translation alternative: "Development" 129 | text: qsTr("Credits") 130 | } 131 | 132 | Column { 133 | width: parent.width 134 | anchors { 135 | left: parent.left 136 | right: parent.right 137 | rightMargin: Theme.paddingLarge 138 | } 139 | 140 | Repeater { 141 | model: ListModel { 142 | 143 | ListElement { 144 | name: "Duško Angirević" 145 | desc: qsTr("UI/UX design and development") 146 | mastodon: "dysko@mastodon.social" 147 | mail: "" 148 | } 149 | 150 | ListElement { 151 | name: "molan" 152 | desc: qsTr("Development and translations") 153 | mastodon: "molan@fosstodon.org" 154 | mail: "mol_an@sunrise.ch" 155 | } 156 | 157 | ListElement { 158 | name: "poetaster" 159 | desc: qsTr("Development") 160 | mastodon: "postaster@mastodon.gamedev.place" 161 | mail: "blueprint@poetaster.de" 162 | } 163 | ListElement { 164 | name: "Miodrag Nikolić" 165 | desc: qsTr("Visual identity") 166 | mastodon: "" 167 | mail: "micotakis@gmail.com" 168 | } 169 | ListElement { 170 | name: "Jozef Mlich" 171 | desc: qsTr("Documentation") 172 | mastodon: "@jmlich@fosstodon.org" 173 | mail: "" 174 | } 175 | 176 | ListElement { 177 | name: "Quentin PAGÈS / Quenti ♏" 178 | desc: qsTr("Occitan & French translation") 179 | mastodon: "Quenti@framapiaf.org" 180 | mail: "" 181 | } 182 | 183 | ListElement { 184 | name: "Luchy Kon / dashinfantry" 185 | desc: qsTr("Chinese translation") 186 | mastodon: "" 187 | mail: "dashinfantry@gmail.com" 188 | } 189 | 190 | ListElement { 191 | name: "André Koot" 192 | desc: qsTr("Dutch translation") 193 | mastodon: "meneer@mastodon.social" 194 | mail: "https://twitter.com/meneer" 195 | } 196 | 197 | ListElement { 198 | name: "CarmenFdez" 199 | desc: qsTr("Spanish translation") 200 | mastodon: "" 201 | mail: "" 202 | } 203 | } 204 | 205 | Item { 206 | width: parent.width 207 | height: Theme.itemSizeMedium 208 | 209 | IconButton { 210 | id: btn 211 | icon.source: "image://theme/" + (model.mastodon !== "" ? "icon-m-outline-chat" : "icon-m-mail") + "?" + (pressed 212 | ? Theme.highlightColor : Theme.primaryColor) 213 | anchors { 214 | verticalCenter: parent.verticalCenter 215 | right: parent.right 216 | } 217 | onClicked: { 218 | if (model.mastodon !== ""){ 219 | var m = Qt.createQmlObject('import QtQuick 2.0; ListModel { }', Qt.application, 'InternalQmlObject'); 220 | pageStack.push(Qt.resolvedUrl("ConversationPage.qml"), { 221 | headerTitle: "Mention", 222 | description: '@'+model.mastodon, 223 | type: "new" 224 | }) 225 | } else { 226 | Qt.openUrlExternally("mailto:"+model.mail); 227 | } 228 | } 229 | } 230 | 231 | Column { 232 | anchors { 233 | verticalCenter: parent.verticalCenter 234 | left: parent.left 235 | leftMargin: Theme.horizontalPageMargin 236 | right: btn.left 237 | rightMargin: Theme.paddingMedium 238 | } 239 | 240 | Label { 241 | id: lblName 242 | text: model.name 243 | color: Theme.highlightColor 244 | font.pixelSize: Theme.fontSizeSmall 245 | } 246 | 247 | Label { 248 | text: model.desc 249 | color: Theme.secondaryHighlightColor 250 | font.pixelSize: Theme.fontSizeExtraSmall 251 | } 252 | } 253 | } 254 | } 255 | } 256 | } 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /qml/pages/components/InfoBanner.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | 4 | 5 | DockedPanel { 6 | id: root 7 | dock: Dock.Top 8 | width: isPortrait ? parent.width : Theme.buttonWidthLarge * 1.5 9 | height: content.height 10 | anchors.horizontalCenter: parent.horizontalCenter 11 | 12 | Rectangle { 13 | id: content 14 | color: Theme.highlightBackgroundColor 15 | width: root.width 16 | height: infoLabel.height + 2 * Theme.paddingMedium 17 | 18 | Label { 19 | id: infoLabel 20 | text : "" 21 | font.family: Theme.fontFamilyHeading 22 | font.pixelSize: Theme.fontSizeMedium 23 | color: Theme.primaryColor 24 | wrapMode: Text.WrapAnywhere 25 | width: parent.width 26 | anchors { 27 | left: parent.left 28 | leftMargin: Theme.horizontalPageMargin*2 29 | right: parent.right 30 | rightMargin: Theme.horizontalPageMargin 31 | verticalCenter: parent.verticalCenter 32 | } 33 | } 34 | 35 | MouseArea { 36 | anchors.fill: parent 37 | onClicked: { 38 | root.hide() 39 | autoClose.stop() 40 | } 41 | } 42 | } 43 | 44 | function showText(text) { 45 | infoLabel.text = text 46 | root.show() 47 | autoClose.start() 48 | } 49 | 50 | Timer { 51 | id: autoClose 52 | interval: 4500 53 | running: false 54 | onTriggered: { 55 | root.hide() 56 | stop() 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /qml/pages/components/ItemUser.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | 4 | 5 | BackgroundItem { 6 | id: delegate 7 | 8 | signal openUser (string notice) 9 | 10 | width: parent.width 11 | height: Theme.itemSizeMedium 12 | 13 | Item { 14 | id: avatar 15 | width: Theme.itemSizeExtraSmall 16 | height: width 17 | anchors.verticalCenter: parent.verticalCenter 18 | anchors.left: parent.left 19 | anchors.leftMargin: Theme.horizontalPageMargin 20 | 21 | Image { 22 | id: img 23 | opacity: status === Image.Ready ? 1.0 : 0.0 24 | Behavior on opacity { FadeAnimator {} } 25 | anchors.fill: parent 26 | source: model.account_avatar 27 | } 28 | 29 | BusyIndicator { 30 | size: BusyIndicatorSize.Small 31 | opacity: img.status === Image.Ready ? 0.0 : 1.0 32 | Behavior on opacity { FadeAnimator {} } 33 | running: avatar.status !== Image.Ready 34 | anchors.centerIn: parent 35 | } 36 | 37 | MouseArea { 38 | anchors.fill: parent 39 | onClicked: pageStack.push(Qt.resolvedUrl("./../ProfilePage.qml"), { 40 | "display_name": model.account_display_name, 41 | "username": model.account_acct, 42 | "user_id": model.account_id, 43 | "profileImage": model.account_avatar, 44 | "profileBackground": model.account_header, 45 | "note": model.account_note, 46 | "url": model.account_url, 47 | "followers_count": model.account_followers_count, 48 | "following_count": model.account_following_count, 49 | "statuses_count": model.account_statuses_count, 50 | "locked": model.account_locked, 51 | "bot": model.account_bot, 52 | "group": model.account_group 53 | }) 54 | } 55 | } 56 | 57 | Item { 58 | id: userDescription 59 | height: account_acct.height + display_name.height 60 | anchors.left: avatar.right 61 | anchors.leftMargin: Theme.paddingLarge 62 | anchors.right: parent.right 63 | anchors.rightMargin: Theme.horizontalPageMargin 64 | anchors.verticalCenter: parent.verticalCenter 65 | 66 | Label { 67 | id: display_name 68 | text: account_display_name ? account_display_name : account_username.split('@')[0] 69 | color: !pressed ? Theme.primaryColor : Theme.highlightColor 70 | font.pixelSize: Theme.fontSizeSmall 71 | truncationMode: TruncationMode.Fade 72 | width: parent.width - Theme.paddingMedium 73 | anchors.top: parent.top 74 | } 75 | 76 | Label { 77 | id: account_acct 78 | text: "@"+model.account_acct 79 | color: !pressed ? Theme.secondaryColor : Theme.secondaryHighlightColor 80 | anchors.leftMargin: Theme.paddingMedium 81 | font.pixelSize: Theme.fontSizeExtraSmall 82 | truncationMode: TruncationMode.Fade 83 | width: parent.width - Theme.paddingMedium 84 | anchors.top: display_name.bottom 85 | } 86 | } 87 | 88 | onClicked: openUser( { 89 | "display_name": model.account_display_name, 90 | "username": model.account_acct, 91 | "user_id": model.account_id, 92 | "profileImage": model.account_avatar, 93 | "profileBackground": model.account_header, 94 | "note": model.account_note, 95 | "url": model.account_url, 96 | "followers_count": model.account_followers_count, 97 | "following_count": model.account_following_count, 98 | "statuses_count": model.account_statuses_count, 99 | "locked": model.account_locked, 100 | "bot": model.account_bot, 101 | "group": model.account_group 102 | } ) 103 | } 104 | -------------------------------------------------------------------------------- /qml/pages/components/MediaBlock.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | import QtMultimedia 5.0 4 | 5 | 6 | Item { 7 | id: holder 8 | 9 | property ListModel model 10 | property double wRatio : 16/9 11 | property double hRatio : 9/16 12 | 13 | property bool debug: false 14 | width: width 15 | height: height 16 | Component.onCompleted: { 17 | if(debug) console.log("MB: " + JSON.stringify(model.get(0))) 18 | 19 | if (model && model.count && model.get(0).type === "video") { 20 | //console.log("Mediablock") 21 | //console.log(JSON.stringify(model.get(0).type)) 22 | while (model.count>1) { 23 | model.remove(model.count-1) 24 | } 25 | } 26 | var count = 0 27 | if (model && model.count) 28 | count = model.count 29 | switch(count){ 30 | 31 | case 1: 32 | placeholder1.width = holder.width 33 | placeholder1.height = placeholder1.width*hRatio 34 | placeholder1.visible = true; 35 | holder.height = placeholder1.height 36 | break; 37 | 38 | case 2: 39 | placeholder1.visible = true 40 | placeholder2.visible = true 41 | placeholder1.width = (holder.width-Theme.paddingSmall)/2 42 | placeholder1.height = placeholder1.width 43 | placeholder2.width = placeholder1.width 44 | placeholder2.height = placeholder1.width 45 | placeholder2.x = placeholder1.width + placeholder2.x + Theme.paddingSmall 46 | holder.height = placeholder1.height 47 | break; 48 | 49 | case 3: 50 | placeholder1.visible = true 51 | placeholder2.visible = true 52 | placeholder3.visible = true 53 | placeholder4.visible = false 54 | 55 | placeholder1.width = holder.width - Theme.paddingSmall - Theme.itemSizeLarge; 56 | placeholder1.height = Theme.itemSizeLarge*2+Theme.paddingSmall 57 | holder.height = placeholder1.height 58 | 59 | placeholder2.width = Theme.itemSizeLarge; 60 | placeholder3.height = placeholder3.width = placeholder2.height = placeholder2.width 61 | placeholder3.x = placeholder2.x = placeholder1.x + placeholder1.width + Theme.paddingSmall; 62 | placeholder3.y = placeholder2.y + placeholder2.height + Theme.paddingSmall; 63 | break; 64 | 65 | case 4: 66 | placeholder1.visible = true 67 | placeholder2.visible = true 68 | placeholder3.visible = true 69 | placeholder4.visible = true 70 | 71 | placeholder1.width = placeholder2.width = placeholder3.width = placeholder4.width = (holder.width - 3*Theme.paddingSmall)/4 72 | placeholder1.height = placeholder2.height = placeholder3.height = placeholder4.height = Theme.itemSizeLarge*2+Theme.paddingSmall 73 | placeholder2.x = 1*(placeholder1.width)+ 1*Theme.paddingSmall 74 | placeholder3.x = 2*(placeholder1.width)+ 2*Theme.paddingSmall 75 | placeholder4.x = 3*(placeholder1.width)+ 3*Theme.paddingSmall 76 | 77 | holder.height = placeholder1.height 78 | break; 79 | 80 | default: 81 | holder.height = 0 82 | placeholder1.visible = placeholder2.visible = placeholder3.visible = placeholder4.visible = false; 83 | } 84 | } 85 | 86 | MyMedia { 87 | id: placeholder1 88 | width: 2 89 | height: 1 90 | opacity: pressed ? 0.6 : 1 91 | visible: { 92 | if (model && model.count){ 93 | type = model.get(0).type 94 | previewURL = model.get(0).preview_url 95 | mediaURL = model.get(0).url 96 | url = model.get(0).url 97 | if(debug) console.log( model.get(0).url ) 98 | height = Theme.itemSizeLarge 99 | return true 100 | } else { 101 | height = 0 102 | return false 103 | } 104 | } 105 | } 106 | 107 | MyMedia { 108 | id: placeholder2 109 | width: 2 110 | height: 1 111 | opacity: pressed ? 0.6 : 1 112 | visible: { 113 | if (model && model.count && model.get(1)){ 114 | type = model.get(1).type 115 | previewURL = model.get(1).preview_url 116 | mediaURL = model.get(1).url 117 | url = model.get(1).url 118 | if(debug) console.log( model.get(1).url ) 119 | height = Theme.itemSizeLarge 120 | return true 121 | } else { 122 | height = 0 123 | return false 124 | } 125 | } 126 | } 127 | 128 | MyMedia { 129 | id: placeholder3 130 | width: 2 131 | height: 1 132 | opacity: pressed ? 0.6 : 1 133 | visible: { 134 | if (model && model.count && model.get(2)){ 135 | type = model.get(2).type 136 | previewURL = model.get(2).preview_url 137 | mediaURL = model.get(2).url 138 | url = model.get(2).url 139 | height = Theme.itemSizeLarge 140 | return true 141 | } else { 142 | height = 0 143 | return false 144 | } 145 | } 146 | } 147 | 148 | MyMedia { 149 | id: placeholder4 150 | width: 2 151 | height: 1 152 | opacity: pressed ? 0.6 : 1 153 | visible: { 154 | if (model && model.count && model.get(3)){ 155 | type = model.get(3).type 156 | previewURL = model.get(3).preview_url 157 | mediaURL = model.get(3).url 158 | url = model.get(3).url 159 | height = Theme.itemSizeLarge 160 | return true 161 | } else { 162 | height = 0 163 | return false 164 | } 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /qml/pages/components/MediaFullScreen.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | import QtMultimedia 5.6 4 | 5 | 6 | FullscreenContentPage { 7 | id: mediaPage 8 | 9 | property string type: "" 10 | property string previewURL: "" 11 | property string mediaURL: "" 12 | property string url: "" 13 | property bool debug: false 14 | 15 | allowedOrientations: Orientation.All 16 | Component.onCompleted: function() { 17 | if (debug) { 18 | console.log(type) 19 | console.log(previewURL) 20 | console.log(mediaURL) 21 | } 22 | if (type != 'gifv' && type != 'video') { 23 | imagePreview.source = mediaURL 24 | imageFlickable.visible = true 25 | } else if( type == 'audio'){ 26 | video.source = url 27 | videoFlickable.visible = true 28 | playerIcon.visible = true 29 | playerProgress.visible = true 30 | video.play() 31 | hideTimer.start() 32 | } else { 33 | video.source = mediaURL 34 | video.fillMode = VideoOutput.PreserveAspectFit 35 | videoFlickable.visible = true 36 | playerIcon.visible = true 37 | playerProgress.visible = true 38 | video.play() 39 | hideTimer.start() 40 | } 41 | } 42 | 43 | SilicaFlickable { 44 | id: videoFlickable 45 | visible: false 46 | contentWidth: imageContainer.width 47 | contentHeight: imageContainer.height 48 | anchors.fill: parent 49 | 50 | Image { 51 | id: videoPreview 52 | fillMode: Image.PreserveAspectFit 53 | anchors.fill: parent 54 | source: previewURL 55 | } 56 | 57 | Video { 58 | id: video 59 | anchors.fill: parent 60 | onErrorStringChanged: function() { 61 | videoError.visible = true 62 | } 63 | onStatusChanged: { 64 | if(debug) console.log(status) 65 | switch (status) { 66 | case MediaPlayer.Loading: 67 | if(debug) console.log("loading") 68 | return; 69 | case MediaPlayer.EndOfMedia: 70 | if (debug) console.log("EndOfMedia") 71 | return; 72 | } 73 | } 74 | onPlaybackStateChanged: { 75 | if (debug) console.log(playbackState) 76 | switch (playbackState) { 77 | case MediaPlayer.PlayingState: 78 | playerIcon.icon.source = "image://theme/icon-m-pause" 79 | return; 80 | case MediaPlayer.PausedState: 81 | playerIcon.icon.source = "image://theme/icon-m-play" 82 | return; 83 | case MediaPlayer.StoppedState: 84 | playerIcon.icon.source = "image://theme/icon-m-reload" 85 | return; 86 | } 87 | } 88 | onPositionChanged: function() { 89 | //console.log(duration) 90 | //console.log(bufferProgress) 91 | //console.log(position) 92 | if (status !== MediaPlayer.Loading){ 93 | playerProgress.indeterminate = false 94 | playerProgress.maximumValue = duration 95 | playerProgress.minimumValue = 0 96 | playerProgress.value = position 97 | } 98 | } 99 | onStopped: function() { 100 | if (type == 'gifv') { 101 | video.play() 102 | } else { 103 | video.stop() 104 | overlayIcons.active = true 105 | hideTimer.stop() 106 | } 107 | } 108 | 109 | 110 | MouseArea { 111 | anchors.fill: parent 112 | onClicked: function() { 113 | if (video.playbackState === MediaPlayer.PlayingState) { 114 | video.pause() 115 | overlayIcons.active = true 116 | hideTimer.stop() 117 | } else { 118 | video.play() 119 | hideTimer.start() 120 | } 121 | } 122 | } 123 | 124 | Rectangle { 125 | visible: videoError.text != "" 126 | anchors.left: parent.left 127 | anchors.right: parent.right 128 | anchors.bottom: parent.bottom 129 | color: Theme.highlightDimmerColor 130 | height: videoError.height + 2*Theme.paddingMedium 131 | width: parent.width 132 | 133 | Label { 134 | id: videoError 135 | visible: false 136 | text: video.errorString 137 | font.pixelSize: Theme.fontSizeSmall 138 | color: Theme.highlightColor 139 | wrapMode: Text.Wrap 140 | width: parent.width - 2*Theme.paddingMedium 141 | height: contentHeight 142 | anchors.centerIn: parent 143 | } 144 | } 145 | } 146 | } 147 | 148 | 149 | SilicaFlickable { 150 | id: imageFlickable 151 | visible: false 152 | contentWidth: imageContainer.width 153 | contentHeight: imageContainer.height 154 | anchors.fill: parent 155 | onHeightChanged: if (imagePreview.status === Image.Ready) { 156 | imagePreview.fitToScreen() 157 | } 158 | 159 | Item { 160 | id: imageContainer 161 | width: Math.max(imagePreview.width * imagePreview.scale, imageFlickable.width) 162 | height: Math.max(imagePreview.height * imagePreview.scale, imageFlickable.height) 163 | 164 | Image { 165 | id: imagePreview 166 | 167 | property real prevScale 168 | 169 | function fitToScreen() { 170 | scale = Math.min(imageFlickable.width / width, imageFlickable.height / height, imageFlickable.width, imageFlickable.height) 171 | pinchArea.minScale = scale 172 | prevScale = scale 173 | } 174 | 175 | fillMode: Image.PreserveAspectFit 176 | cache: true 177 | asynchronous: true 178 | sourceSize.width: mediaPage.width 179 | smooth: true 180 | anchors.centerIn: parent 181 | onStatusChanged: { 182 | if (status == Image.Ready) { 183 | fitToScreen() 184 | loadedAnimation.start() 185 | } 186 | } 187 | 188 | NumberAnimation { 189 | id: loadedAnimation 190 | target: imagePreview 191 | property: "opacity" 192 | duration: 250 193 | from: 0; to: 1 194 | easing.type: Easing.InOutQuad 195 | } 196 | 197 | onScaleChanged: { 198 | if ((width * scale) > imageFlickable.width) { 199 | var xoff = (imageFlickable.width / 2 + imageFlickable.contentX) * scale / prevScale; 200 | imageFlickable.contentX = xoff - imageFlickable.width / 2 201 | } 202 | if ((height * scale) > imageFlickable.height) { 203 | var yoff = (imageFlickable.height / 2 + imageFlickable.contentY) * scale / prevScale; 204 | imageFlickable.contentY = yoff - imageFlickable.height / 2 205 | } 206 | prevScale = scale 207 | } 208 | } 209 | } 210 | 211 | PinchArea { 212 | id: pinchArea 213 | 214 | property real minScale: 1.0 215 | property real maxScale: 3.0 216 | 217 | anchors.fill: parent 218 | enabled: imagePreview.status === Image.Ready 219 | pinch.target: imagePreview 220 | pinch.minimumScale: minScale * 0.5 // This is to create "bounce back effect" 221 | pinch.maximumScale: maxScale * 1.5 // when over zoomed} 222 | 223 | onPinchFinished: { 224 | imageFlickable.returnToBounds() 225 | if (imagePreview.scale < pinchArea.minScale) { 226 | bounceBackAnimation.to = pinchArea.minScale 227 | bounceBackAnimation.start() 228 | } 229 | else if (imagePreview.scale > pinchArea.maxScale) { 230 | bounceBackAnimation.to = pinchArea.maxScale 231 | bounceBackAnimation.start() 232 | } 233 | } 234 | 235 | NumberAnimation { 236 | id: bounceBackAnimation 237 | target: imagePreview 238 | duration: 250 239 | property: "scale" 240 | from: imagePreview.scale 241 | } 242 | 243 | MouseArea { 244 | anchors.fill: parent 245 | onClicked: overlayIcons.active = !overlayIcons.active 246 | } 247 | } 248 | } 249 | 250 | Loader { 251 | anchors.centerIn: parent 252 | sourceComponent: { 253 | switch (imagePreview.status) { 254 | case Image.Loading: 255 | return loadingIndicator 256 | case Image.Error: 257 | return failedLoading 258 | default: 259 | return undefined 260 | } 261 | } 262 | 263 | Component { 264 | id: loadingIndicator 265 | Item { 266 | width: mediaPage.width 267 | height: childrenRect.height 268 | 269 | ProgressCircle { 270 | id: imageLoadingIndicator 271 | progressValue: imagePreview.progress 272 | progressColor: inAlternateCycle ? Theme.highlightColor : Theme.highlightDimmerColor 273 | backgroundColor: inAlternateCycle ? Theme.highlightDimmerColor : Theme.highlightColor 274 | anchors.horizontalCenter: parent.horizontalCenter 275 | } 276 | } 277 | } 278 | } 279 | 280 | Component { 281 | id: failedLoading 282 | Text { 283 | text: qsTr("Error loading") 284 | font.pixelSize: Theme.fontSizeSmall 285 | color: Theme.highlightColor 286 | } 287 | } 288 | 289 | Item { 290 | id: overlayIcons 291 | 292 | property bool active: true 293 | 294 | enabled: active 295 | anchors.fill: parent 296 | opacity: active ? 1.0 : 0.0 297 | Behavior on opacity { FadeAnimator {}} 298 | 299 | IconButton { 300 | y: Theme.paddingLarge 301 | icon.source: "image://theme/icon-m-dismiss" 302 | onClicked: pageStack.pop() 303 | anchors { 304 | right: parent.right 305 | rightMargin: Theme.horizontalPageMargin 306 | } 307 | } 308 | 309 | IconButton { 310 | id: mediaDlBtn 311 | icon.source: "image://theme/icon-m-cloud-download" 312 | anchors { 313 | right: parent.right 314 | rightMargin: Theme.horizontalPageMargin 315 | bottom: parent.bottom 316 | bottomMargin: Theme.horizontalPageMargin 317 | } 318 | onClicked: { 319 | var filename = mediaURL.split("/") 320 | FileDownloader.downloadFile(mediaURL, filename[filename.length-1]) 321 | } 322 | } 323 | 324 | IconButton { 325 | id: playerIcon 326 | visible: false 327 | icon.source: "image://theme/icon-m-play" 328 | anchors { 329 | left: parent.left 330 | bottom: parent.bottom 331 | leftMargin: Theme.horizontalPageMargin 332 | bottomMargin: Theme.horizontalPageMargin 333 | } 334 | onClicked: function() { 335 | if (video.playbackState === MediaPlayer.PlayingState) { 336 | video.pause() 337 | hideTimer.stop() 338 | } else { 339 | video.play() 340 | hideTimer.start() 341 | } 342 | } 343 | } 344 | 345 | ProgressBar { 346 | id: playerProgress 347 | visible: false 348 | indeterminate: true 349 | width: 400 350 | anchors { 351 | verticalCenter: playerIcon.verticalCenter 352 | left: playerIcon.right 353 | right: parent.right 354 | rightMargin: Theme.horizontalPageMargin + Theme.iconSizeMedium 355 | bottomMargin: Theme.horizontalPageMargin 356 | } 357 | } 358 | 359 | Timer { 360 | id: hideTimer 361 | running: false 362 | interval: 2000 363 | onTriggered: { 364 | overlayIcons.active = !overlayIcons.active 365 | } 366 | } 367 | } 368 | 369 | VerticalScrollDecorator { flickable: imageFlickable } 370 | } 371 | 372 | -------------------------------------------------------------------------------- /qml/pages/components/MediaItem.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.6 2 | import Sailfish.Silica 1.0 3 | import QtMultimedia 5.6 4 | 5 | 6 | 7 | ListItem { 8 | id: item 9 | 10 | property string url 11 | property string mediaUrl 12 | property string mimeType: 'audio/mp3' 13 | property int length 14 | 15 | property bool _isAudio: mimeType.substring(0, 6) === "audio/" 16 | property bool _isImage: mimeType.substring(0, 6) === "image/" 17 | 18 | function _toTime(s) 19 | { 20 | if (s < 0) 21 | { 22 | return "-"; 23 | } 24 | 25 | s /= 1000; 26 | var seconds = Math.floor(s) % 60; 27 | s /= 60; 28 | var minutes = Math.floor(s) % 60; 29 | s /= 60; 30 | var hours = Math.floor(s); 31 | 32 | if (seconds < 10) 33 | { 34 | seconds = "0" + seconds; 35 | } 36 | if (minutes < 10) 37 | { 38 | minutes = "0" + minutes; 39 | } 40 | 41 | if (hours > 0) 42 | { 43 | return hours + ":" + minutes + ":" + seconds; 44 | } 45 | else 46 | { 47 | return minutes + ":" + seconds; 48 | } 49 | } 50 | 51 | /* Returns the filename of the given URL. 52 | */ 53 | function _urlFilename(url) { 54 | var idx = url.lastIndexOf("="); 55 | if (idx !== -1) { 56 | return url.substring(idx + 1); 57 | } 58 | 59 | idx = url.lastIndexOf("/"); 60 | if (idx === url.length - 1) { 61 | idx = url.substring(0, idx).lastIndexOf("/"); 62 | } 63 | 64 | if (idx !== -1) { 65 | return url.substring(idx + 1); 66 | } 67 | 68 | return url; 69 | } 70 | 71 | /* Returns the icon source for the given media. 72 | */ 73 | function _mediaIcon(url, type) { 74 | if (type.substring(0, 6) === "image/") { 75 | return url; 76 | } else if (type.substring(0, 6) === "video/") { 77 | return "image://theme/icon-l-play"; 78 | } else { 79 | return "image://theme/icon-m-other"; 80 | } 81 | } 82 | 83 | /* Returns a user-friendly media type name for the given MIME type. 84 | */ 85 | function _mediaTypeName(type) { 86 | if (type.substring(0, 6) === "image/") { 87 | return qsTr("Image"); 88 | } else if (type.substring(0, 6) === "video/") { 89 | return qsTr("Video"); 90 | } else if (type === "application/pdf") { 91 | return qsTr("PDF document"); 92 | } else { 93 | return type; 94 | } 95 | } 96 | 97 | onClicked: { 98 | console.log('MediaItem') 99 | console.log(url) 100 | console.log(mediaUrl) 101 | if (_isAudio) 102 | { 103 | if (audioProxy.playing) 104 | { 105 | audioProxy.pause(); 106 | } 107 | else 108 | { 109 | audioProxy.play(); 110 | } 111 | } 112 | else if (_isImage) 113 | { 114 | var props = { 115 | "url": item.url, 116 | "name": _urlFilename(item.url) 117 | } 118 | pageStack.push(Qt.resolvedUrl("ImagePage.qml"), props); 119 | } 120 | else 121 | { 122 | Qt.openUrlExternally(item.url); 123 | } 124 | } 125 | 126 | QtObject { 127 | id: audioProxy 128 | 129 | property bool _active: audioPlayer.source == source 130 | property bool playing: _active ? audioPlayer.playing 131 | : false 132 | property bool paused: _active ? audioPlayer.paused 133 | : false 134 | property real duration: _active ? audioPlayer.duration 135 | : -1 136 | property real position: _active ? audioPlayer.position 137 | : 0 138 | 139 | property string source: _isAudio ? item.url : "" 140 | 141 | property Timer _seeker: Timer { 142 | interval: 50 143 | 144 | onTriggered: { 145 | if (audioProxy._active) 146 | { 147 | if (! audioPlayer.playing) 148 | { 149 | console.log("Stream is not ready. Deferring seek operation.") 150 | _seeker.start(); 151 | } 152 | else 153 | { 154 | audioPlayer.seek(Math.max(0, database.audioBookmark(audioProxy.source) - 3000)); 155 | } 156 | } 157 | } 158 | } 159 | 160 | function play() 161 | { 162 | if (_active) 163 | { 164 | audioPlayer.play(); 165 | } 166 | else 167 | { 168 | // save bookmark before switching to another podcast 169 | if (audioPlayer.playing) 170 | { 171 | database.setAudioBookmark(audioPlayer.source, 172 | audioPlayer.position); 173 | } 174 | 175 | audioPlayer.stop(); 176 | audioPlayer.source = source; 177 | audioPlayer.play(); 178 | _seeker.start(); 179 | } 180 | } 181 | 182 | function pause() 183 | { 184 | if (_active) 185 | { 186 | //database.setAudioBookmark(source, audioPlayer.position); 187 | audioPlayer.pause(); 188 | } 189 | } 190 | 191 | function seek(value) 192 | { 193 | if (_active) audioPlayer.seek(value); 194 | } 195 | 196 | onPositionChanged: { 197 | if (_active) 198 | { 199 | if (! slider.down) 200 | { 201 | slider.value = position; 202 | } 203 | } 204 | } 205 | 206 | onDurationChanged: { 207 | if (_active) 208 | { 209 | slider.maximumValue = duration; 210 | } 211 | } 212 | } 213 | Audio { 214 | id: audioPlayer 215 | property bool playing: playbackState === Audio.PlayingState 216 | property bool paused: playbackState === Audio.PausedState 217 | autoLoad: false 218 | autoPlay: false 219 | } 220 | Image { 221 | id: mediaIcon 222 | 223 | anchors.left: parent.left 224 | anchors.leftMargin: Theme.paddingLarge 225 | anchors.rightMargin: Theme.paddingMedium 226 | width: height 227 | height: parent.height 228 | asynchronous: true 229 | smooth: true 230 | fillMode: Image.PreserveAspectCrop 231 | sourceSize.width: width * 2 232 | sourceSize.height: height * 2 233 | source: ! _isAudio ? _mediaIcon(item.url, item.mimeType) 234 | : audioProxy.playing ? "image://theme/icon-l-pause" 235 | : "image://theme/icon-l-play" 236 | clip: true 237 | 238 | BusyIndicator { 239 | running: parent.status === Image.Loading 240 | anchors.centerIn: parent 241 | size: BusyIndicatorSize.Medium 242 | } 243 | } 244 | 245 | Label { 246 | id: mediaNameLabel 247 | 248 | anchors.left: mediaIcon.right 249 | anchors.right: parent.right 250 | anchors.leftMargin: Theme.paddingLarge 251 | anchors.rightMargin: Theme.paddingLarge 252 | truncationMode: TruncationMode.Fade 253 | font.pixelSize: Theme.fontSizeSmall 254 | color: Theme.primaryColor 255 | text: _urlFilename(item.url) 256 | } 257 | Label { 258 | id: label1 259 | anchors.top: mediaNameLabel.bottom 260 | anchors.left: mediaNameLabel.left 261 | font.pixelSize: Theme.fontSizeExtraSmall 262 | color: Theme.secondaryColor 263 | text: ! slider.visible ? _mediaTypeName(item.mimeType) 264 | : audioProxy.playing ? _toTime(slider.sliderValue) 265 | : _toTime(database.audioBookmark(audioProxy.source)) 266 | 267 | } 268 | Label { 269 | id: label2 270 | anchors.top: mediaNameLabel.bottom 271 | anchors.right: parent.right 272 | anchors.rightMargin: Theme.paddingLarge 273 | font.pixelSize: Theme.fontSizeExtraSmall 274 | color: Theme.secondaryColor 275 | text: slider.visible ? _toTime(audioProxy.duration) 276 | : item.length >= 0 ? Format.formatFileSize(item.length) 277 | : "" 278 | } 279 | 280 | Slider { 281 | id: slider 282 | 283 | visible: _isAudio 284 | enabled: audioProxy.playing || audioProxy.paused 285 | 286 | anchors.left: label1.right 287 | anchors.right: label2.left 288 | anchors.verticalCenter: label1.verticalCenter 289 | 290 | leftMargin: Theme.paddingSmall 291 | rightMargin: Theme.paddingSmall 292 | height: Theme.itemSizeSmall / 3 293 | 294 | handleVisible: false 295 | minimumValue: 0 296 | 297 | onDownChanged: { 298 | if (! down) 299 | { 300 | audioProxy.seek(sliderValue); 301 | if (! audioProxy.playing) 302 | { 303 | audioProxy.play(); 304 | } 305 | } 306 | } 307 | 308 | }//Slider 309 | IconButton { 310 | id: mediaDlBtn 311 | icon.source: "image://theme/icon-m-cloud-download" 312 | anchors { 313 | right: parent.right 314 | rightMargin: Theme.horizontalPageMargin 315 | bottom: parent.bottom 316 | bottomMargin: Theme.horizontalPageMargin 317 | } 318 | onClicked: { 319 | var filename = url.split("/") 320 | FileDownloader.downloadFile(url, filename[filename.length-1]) 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /qml/pages/components/MiniHeader.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | 4 | 5 | Item { 6 | id: miniHeader 7 | height: lblName.height 8 | width: parent.width 9 | 10 | Label { 11 | id: lblName 12 | text: account_display_name ? account_display_name : account_username.split('@')[0] 13 | font.weight: Font.Bold 14 | font.pixelSize: Theme.fontSizeSmall 15 | color: if ( myList.type === "notifications" && ( model.type === "favourite" || model.type === "reblog" )) { 16 | ( pressed ? Theme.secondaryHighlightColor : (!highlight ? Theme.secondaryColor : Theme.secondaryHighlightColor )) 17 | } else ( pressed ? Theme.highlightColor : ( !highlight ? Theme.primaryColor : Theme.secondaryColor )) 18 | truncationMode: TruncationMode.Fade 19 | width: myList.type !== "follow" ? ( contentWidth > parent.width /2 ? parent.width /2 : contentWidth ) : parent.width - Theme.paddingMedium 20 | anchors { 21 | left: parent.left 22 | leftMargin: Theme.paddingMedium 23 | } 24 | } 25 | 26 | Label { 27 | id: lblScreenName 28 | visible: model.type !== "follow" 29 | text: '@'+account_username 30 | font.pixelSize: Theme.fontSizeExtraSmall 31 | color: ( pressed ? Theme.secondaryHighlightColor : Theme.secondaryColor ) 32 | truncationMode: TruncationMode.Fade 33 | anchors { 34 | left: lblName.right 35 | leftMargin: Theme.paddingMedium 36 | right: lblDate.left 37 | rightMargin: Theme.paddingMedium 38 | verticalCenter: lblName.verticalCenter 39 | } 40 | } 41 | 42 | Label { 43 | id: lblScreenNameFollow 44 | visible: model.type === "follow" 45 | text: '@'+account_username 46 | font.pixelSize: Theme.fontSizeExtraSmall 47 | color: ( pressed ? Theme.secondaryHighlightColor : Theme.secondaryColor ) 48 | width: parent.width - Theme.paddingMedium 49 | truncationMode: TruncationMode.Fade 50 | anchors { 51 | top: lblName.bottom 52 | left: parent.left 53 | leftMargin: Theme.paddingMedium 54 | } 55 | } 56 | 57 | Label { 58 | id: lblDate 59 | text: Format.formatDate(created_at, new Date() - created_at < 60*60*1000 ? Formatter.DurationElapsedShort : Formatter.TimeValueTwentyFourHours) 60 | font.pixelSize: Theme.fontSizeExtraSmall 61 | color: ( pressed ? Theme.highlightColor : Theme.secondaryColor ) 62 | horizontalAlignment: Text.AlignRight 63 | anchors { 64 | right: parent.right 65 | rightMargin: Theme.horizontalPageMargin 66 | verticalCenter: lblName.verticalCenter 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /qml/pages/components/MiniStatus.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | 4 | 5 | Item { 6 | id: miniStatus 7 | visible: true 8 | width: parent.width 9 | height: icon.height+Theme.paddingMedium 10 | 11 | Icon { 12 | id: icon 13 | visible: type.length 14 | color: Theme.highlightColor 15 | width: Theme.iconSizeExtraSmall 16 | height: width 17 | source: typeof typeIcon !== "undefined" ? typeIcon : "" 18 | anchors { 19 | top: parent.top 20 | topMargin: Theme.paddingMedium 21 | left: parent.left 22 | leftMargin: Theme.horizontalPageMargin + Theme.iconSizeMedium - width 23 | bottomMargin: Theme.paddingMedium 24 | } 25 | } 26 | 27 | Label { 28 | id: lblRtByName 29 | visible: type.length 30 | text: { 31 | var action = ""; 32 | switch(type){ 33 | case "reblog": 34 | action = qsTr('boosted'); 35 | break; 36 | case "favourite": 37 | action = qsTr('favourited'); 38 | break; 39 | case "follow": 40 | action = qsTr('followed you'); 41 | break; 42 | default: 43 | miniStatus.visible = false 44 | action = type; 45 | } 46 | return typeof reblog_account_username !== "undefined" ? '@' + reblog_account_username + " " + action : " " 47 | } 48 | font.pixelSize: Theme.fontSizeExtraSmall 49 | color: Theme.highlightColor 50 | anchors { 51 | left: icon.right 52 | leftMargin: Theme.paddingMedium 53 | verticalCenter: icon.verticalCenter 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /qml/pages/components/MyList.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.2 2 | import Sailfish.Silica 1.0 3 | import "../../lib/API.js" as Logic 4 | import "." 5 | 6 | 7 | SilicaListView { 8 | id: myList 9 | 10 | property bool debug: false 11 | property string type 12 | property string title 13 | property string description 14 | property ListModel mdl: [] 15 | property variant params: [] 16 | property var locale: Qt.locale() 17 | property bool autoLoadMore: true 18 | property bool loadStarted: false 19 | property int scrollOffset 20 | property string action: "" 21 | // should consider better names or 22 | // using min_ & max_id 23 | property string linkprev: "" 24 | property string linknext: "" 25 | property variant vars 26 | property variant conf 27 | property bool notifier: false 28 | property bool deduping: false 29 | property variant uniqueIds: [] 30 | 31 | model: mdl 32 | 33 | signal notify (string what, int num) 34 | onNotify: { 35 | if(debug) console.log(what + " - " + num) 36 | } 37 | signal openDrawer (bool setDrawer) 38 | onOpenDrawer: { 39 | //console.log("Open drawer: " + setDrawer) 40 | } 41 | signal send (string notice) 42 | onSend: { 43 | if (debug) console.log("LIST send signal emitted with notice: " + notice) 44 | } 45 | 46 | header: PageHeader { 47 | title: myList.title 48 | description: myList.description 49 | } 50 | 51 | BusyLabel { 52 | id: myListBusyLabel 53 | running: model.count === 0 54 | anchors { 55 | horizontalCenter: parent.horizontalCenter 56 | verticalCenter: parent.verticalCenter 57 | } 58 | 59 | Timer { 60 | interval: 5000 61 | running: true 62 | onTriggered: { 63 | myListBusyLabel.visible = false 64 | loadStatusPlaceholder.visible = true 65 | } 66 | } 67 | } 68 | 69 | ViewPlaceholder { 70 | id: loadStatusPlaceholder 71 | visible: false 72 | enabled: model.count === 0 73 | text: qsTr("Nothing found") 74 | } 75 | 76 | PullDownMenu { 77 | id: mainPulleyMenu 78 | MenuItem { 79 | text: qsTr("Settings") 80 | visible: ! parent.profilePage 81 | onClicked: { 82 | pageStack.push(Qt.resolvedUrl("../SettingsPage.qml"), {}) 83 | } 84 | } 85 | MenuItem { 86 | text: qsTr("New Toot") 87 | visible: ! parent.profilePage 88 | onClicked: { 89 | pageStack.push(Qt.resolvedUrl("../ConversationPage.qml"), { 90 | headerTitle: qsTr("New Toot"), 91 | type: "new" 92 | }) 93 | } 94 | } 95 | 96 | MenuItem { 97 | text: qsTr("Open in Browser") 98 | visible: !mainPage 99 | onClicked: { 100 | Qt.openUrlExternally(url) 101 | } 102 | } 103 | 104 | MenuItem { 105 | text: qsTr("Reload") 106 | onClicked: { 107 | loadData("prepend") 108 | } 109 | } 110 | } 111 | 112 | delegate: VisualContainer {} 113 | 114 | add: Transition { 115 | NumberAnimation { property: "opacity"; from: 0; to: 1.0; duration: 800 } 116 | NumberAnimation { property: "x"; duration: 800; easing.type: Easing.InOutBack } 117 | } 118 | 119 | remove: Transition { 120 | NumberAnimation { properties: "x,y"; duration: 800; easing.type: Easing.InOutBack } 121 | } 122 | 123 | onCountChanged: { 124 | if (debug) console.log("count changed on: " + title) 125 | //deDouble() 126 | //loadStarted = false 127 | 128 | /*contentY = scrollOffset 129 | console.log("CountChanged!")*/ 130 | } 131 | 132 | footer: Item { 133 | visible: autoLoadMore 134 | width: parent.width 135 | height: Theme.itemSizeLarge 136 | Button { 137 | anchors.horizontalCenter: parent.horizontalCenter 138 | anchors.margins: Theme.paddingSmall 139 | anchors.bottomMargin: Theme.paddingLarge 140 | visible: false 141 | onClicked: { 142 | if (!loadStarted && !deduping) loadData("append") 143 | } 144 | } 145 | 146 | BusyIndicator { 147 | running: loadStarted 148 | visible: myListBusyLabel.running ? false : true 149 | size: BusyIndicatorSize.Small 150 | anchors { 151 | verticalCenter: parent.verticalCenter 152 | horizontalCenter: parent.horizontalCenter 153 | } 154 | } 155 | } 156 | 157 | onContentYChanged: { 158 | if (Math.abs(contentY - scrollOffset) > Theme.itemSizeMedium) { 159 | openDrawer(contentY - scrollOffset > 0 ? false : true ) 160 | scrollOffset = contentY 161 | } 162 | if(contentY+height > footerItem.y && !deduping && !loadStarted && autoLoadMore) { 163 | loadStarted = true 164 | loadData("append") 165 | } 166 | } 167 | 168 | VerticalScrollDecorator {} 169 | 170 | WorkerScript { 171 | id: worker 172 | source: "../../lib/Worker.js" 173 | onMessage: { 174 | if (messageObject.error){ 175 | if (debug) console.log(JSON.stringify(messageObject)) 176 | } else { 177 | if (debug) console.log(JSON.stringify(messageObject)) 178 | // loadStarted = false 179 | } 180 | 181 | if (messageObject.fireNotification && notifier){ 182 | Logic.notifier(messageObject.data) 183 | } 184 | 185 | // temporary debugging measure 186 | if (messageObject.updatedAll){ 187 | if (debug) console.log("Got em all.") 188 | if (model.count > 20) deDouble() 189 | loadStarted = false 190 | } 191 | 192 | // the api is stupid 193 | if (messageObject.LinkHeader) { 194 | // ; rel=\"next\", 195 | // ; rel=\"prev\"" 196 | 197 | var matches = /max_id=([0-9]+)/.exec(messageObject.LinkHeader); 198 | var maxlink = matches[0].split("=")[1]; 199 | var matches = /min_id=([0-9]+)/.exec(messageObject.LinkHeader); 200 | var minlink = matches[0].split("=")[1]; 201 | if (debug) console.log("maxlink: " + maxlink) 202 | if (debug) console.log("minlink: " + minlink) 203 | linkprev = maxlink 204 | linknext = minlink 205 | } 206 | } 207 | } 208 | 209 | Component.onCompleted: { 210 | loadData("prepend") 211 | if (debug) console.log("MyList completed: " + title) 212 | } 213 | 214 | Timer { 215 | triggeredOnStart: false; 216 | interval: { 217 | 218 | /* 219 | * Varied calls so that server isn't hit 220 | * simultaenously ... this is hamfisted 221 | */ 222 | var listInterval = Math.floor(Math.random() * 60)*10*1000 223 | if( title === "Home" ) listInterval = 20*60*1000 224 | if( title === "Local" ) listInterval = 10*60*1000 225 | if( title === "Federated" ) listInterval = 30*60*1000 226 | if( title === "Bookmarks" ) listInterval = 40*60*1000 227 | if( title === "Notifications" ) listInterval = 12*60*1000 228 | 229 | if(debug) console.log(title + ' interval: ' + listInterval) 230 | 231 | return listInterval 232 | } 233 | running: true; 234 | repeat: true 235 | onTriggered: { 236 | if(debug) console.log(title + ' ' + Date().toString()) 237 | // let's avoid pre and appending at the same time! 238 | if ( ! loadStarted && ! deduping ) loadData("prepend") 239 | } 240 | } 241 | 242 | /* 243 | * NOT actually doing deduping :) 244 | * utility called on updates to model to remove remove Duplicates: 245 | * the dupes are probably a result of improper syncing of the models 246 | * this is temporary and can probaly be removed because of the 247 | * loadData method passing in to the WorkerScript 248 | */ 249 | function deDouble(){ 250 | 251 | deduping = true 252 | var ids = [] 253 | var uniqueItems = [] 254 | var i 255 | var j 256 | var seenIt = 0 257 | 258 | if (debug) console.log(model.count) 259 | 260 | for(i = 0 ; i < model.count ; i++) { 261 | ids.push(model.get(i).id) 262 | uniqueItems = removeDuplicates(ids) 263 | 264 | } 265 | //if (debug) console.log(ids) 266 | if (debug) console.log(uniqueItems.length) 267 | if (debug) console.log( "max-one?:" + model.get(model.count - 2).id ) 268 | if (debug) console.log( "max:" + model.get(model.count - 1).id ) 269 | 270 | if ( uniqueItems.length < model.count) { 271 | 272 | // it seems that only the last one, is an issue 273 | /*if (model.get(model.count - 1).id > model.get(model.count - 2).id){ 274 | model.remove(model.count - 1,1) 275 | }*/ 276 | 277 | if (debug) console.log(model.count) 278 | for(j = 0; j <= uniqueItems.length - 1 ; j++) { 279 | seenIt = 0 280 | for(i = 0 ; i < model.count - 1 ; i++) { 281 | if (model.get(i).id === uniqueItems[j]){ 282 | seenIt = seenIt+1 283 | if (seenIt > 1) { 284 | if (debug) console.log(uniqueItems[j] + " - " + seenIt) 285 | 286 | // model.remove(i,1) // (model.get(i)) 287 | seenIt = seenIt-1 288 | } 289 | } 290 | } 291 | } 292 | } 293 | 294 | deduping = false 295 | } 296 | 297 | /* utility function because this version of qt doesn't support modern javascript 298 | * 299 | */ 300 | function removeDuplicates(arr) { 301 | var unique = []; 302 | for(var i=0; i < arr.length; i++){ 303 | if(unique.indexOf(arr[i]) === -1) { 304 | unique.push(arr[i]); 305 | } 306 | } 307 | return unique; 308 | } 309 | 310 | 311 | /* Principle load function, uses websocket's worker.js 312 | * 313 | */ 314 | 315 | function loadData(mode) { 316 | 317 | if (debug) console.log('loadData called: ' + mode + " in " + title) 318 | // since the worker adds Duplicates 319 | // we pass in current ids in the model 320 | // and skip those on insert append in the worker 321 | for(var i = 0 ; i < model.count ; i++) { 322 | uniqueIds.push(model.get(i).id) 323 | //if (debug) console.log(model.get(i).id) 324 | } 325 | uniqueIds = removeDuplicates(uniqueIds) 326 | 327 | var p = [] 328 | if (params.length) { 329 | for(var i = 0; i" + 'Audio file' + '' 43 | font.pixelSize: Theme.fontSizeLarge 44 | }*/ 45 | 46 | 47 | MediaItem { 48 | id: audioContent 49 | visible: type == 'audio' 50 | opacity: img.status === Image.Ready ? 0.0 : 1.0 51 | Behavior on opacity { FadeAnimator {} } 52 | mimeType: 'audio/mp3' 53 | url: mediaURL 54 | mediaUrl: mediaURL 55 | //source: "image://theme/icon-m-file-audio?" 56 | anchors.centerIn: parent 57 | /*MouseArea { 58 | anchors.fill: parent 59 | onClicked: { 60 | pageStack.push(Qt.resolvedUrl("./MediaItem.qml"), { 61 | "url": url, 62 | "type": type, 63 | "mimeType": type 64 | }) 65 | } 66 | } */ 67 | } 68 | 69 | Rectangle { 70 | id: progressRec 71 | width: 0 72 | height: Theme.paddingSmall 73 | color: Theme.highlightBackgroundColor 74 | anchors.bottom: parent.bottom 75 | } 76 | 77 | Image { 78 | id: img 79 | visible: type != 'audio' 80 | asynchronous: true 81 | opacity: status === Image.Ready ? 1.0 : 0.0 82 | Behavior on opacity { FadeAnimator {} } 83 | source: previewURL 84 | fillMode: Image.PreserveAspectCrop 85 | anchors.fill: parent 86 | onProgressChanged: { 87 | if (progress != 1) 88 | progressRec.width = parent.width * progress 89 | else { 90 | progressRec.width = 0; 91 | } 92 | } 93 | 94 | MouseArea { 95 | anchors.fill: parent 96 | visible: type != 'audio' 97 | onClicked: { 98 | pageStack.push(Qt.resolvedUrl("./MediaFullScreen.qml"), { 99 | "previewURL": previewURL, 100 | "mediaURL": mediaURL, 101 | "type": type 102 | }) 103 | } 104 | } 105 | 106 | Image { 107 | id: videoIcon 108 | visible: type === "video" || type === "gifv" 109 | source: "image://theme/icon-l-play?" 110 | anchors.centerIn: parent 111 | } 112 | 113 | BusyIndicator { 114 | id: mediaLoader 115 | visible: type != 'audio' 116 | size: BusyIndicatorSize.Large 117 | running: img.status !== Image.Ready 118 | opacity: img.status === Image.Ready ? 0.0 : 1.0 119 | anchors { 120 | verticalCenter: parent.verticalCenter 121 | horizontalCenter: parent.horizontalCenter 122 | } 123 | } 124 | 125 | Rectangle { 126 | id: mediaWarning 127 | color: Theme.highlightDimmerColor 128 | visible: typeof status_sensitive != "undefined" && status_sensitive ? true : false 129 | Image { 130 | source: "image://theme/icon-l-attention?"+Theme.highlightColor 131 | anchors.centerIn: parent 132 | } 133 | anchors.fill: parent 134 | MouseArea { 135 | anchors.fill: parent 136 | onClicked: parent.visible = false 137 | } 138 | } 139 | /*IconButton { 140 | id: mediaDlBtn 141 | icon.source: "image://theme/icon-m-cloud-download" 142 | anchors { 143 | right: parent.right 144 | rightMargin: Theme.horizontalPageMargin 145 | bottom: parent.bottom 146 | bottomMargin: Theme.horizontalPageMargin 147 | } 148 | onClicked: { 149 | var filename = url.split("/") 150 | FileDownloader.downloadFile(url, filename[filename.length-1]) 151 | } 152 | }*/ 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /qml/pages/components/NavigationPanel.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | import QtGraphicalEffects 1.0 4 | 5 | 6 | SilicaGridView { 7 | id: gridView 8 | 9 | property bool isPortrait: false 10 | signal slideshowShow(int vIndex) 11 | signal slideshowIndexChanged(int vIndex) 12 | 13 | onSlideshowIndexChanged: { 14 | navigateTo(vIndex) 15 | } 16 | 17 | ListModel { 18 | id: listModel 19 | ListElement { 20 | icon: "image://theme/icon-m-home?" 21 | slug: "home" 22 | name: "Home" 23 | active: true 24 | unread: false 25 | } 26 | 27 | ListElement { 28 | icon: "image://theme/icon-m-alarm?" 29 | slug: "notifications" 30 | name: "Notifications" 31 | active: false 32 | } 33 | 34 | ListElement { 35 | icon: "image://theme/icon-m-whereami?" 36 | slug: "local" 37 | name: "Local" 38 | active: false 39 | unread: false 40 | } 41 | ListElement { 42 | icon: "image://theme/icon-m-website?" 43 | slug: "federated" 44 | name: "Federated" 45 | active: false 46 | unread: false 47 | } 48 | 49 | ListElement { 50 | icon: "../../images/icon-m-bookmark.svg?" 51 | //icon: "image://theme/icon-m-bookmark" 52 | slug: "bookmarks" 53 | name: "Bookmarks" 54 | active: false 55 | unread: false 56 | } 57 | ListElement { 58 | icon: "image://theme/icon-m-search?" 59 | slug: "search" 60 | name: "Search" 61 | active: false 62 | unread: false 63 | } 64 | } 65 | model: listModel 66 | currentIndex: -1 67 | cellWidth: isPortrait ? gridView.width : gridView.width / model.count 68 | cellHeight: isPortrait ? gridView.height/model.count : gridView.height 69 | anchors.fill: parent 70 | delegate: BackgroundItem { 71 | id: rectangle 72 | clip: true 73 | width: gridView.cellWidth 74 | height: gridView.cellHeight 75 | GridView.onAdd: AddAnimation { 76 | target: rectangle 77 | } 78 | GridView.onRemove: RemoveAnimation { 79 | target: rectangle 80 | } 81 | 82 | GlassItem { 83 | id: effect 84 | visible: !isPortrait && unread 85 | dimmed: true 86 | color: Theme.highlightColor 87 | width: Theme.itemSizeMedium 88 | height: Theme.itemSizeMedium 89 | anchors { 90 | bottom: parent.bottom 91 | bottomMargin: -height/2 92 | horizontalCenter: parent.horizontalCenter 93 | } 94 | } 95 | 96 | GlassItem { 97 | id: effect2 98 | visible: isPortrait && unread 99 | dimmed: false 100 | color: Theme.highlightColor 101 | width: Theme.itemSizeMedium 102 | height: Theme.itemSizeMedium 103 | anchors { 104 | right: parent.right 105 | rightMargin: -height/2 106 | verticalCenter: parent.verticalCenter 107 | } 108 | } 109 | 110 | Image { 111 | id: image 112 | visible: false 113 | source: model.icon 114 | sourceSize.width: Theme.iconSizeMedium 115 | sourceSize.height: Theme.iconSizeMedium 116 | anchors.centerIn: parent 117 | } 118 | 119 | ColorOverlay { 120 | source: image 121 | color: (highlighted ? Theme.highlightColor : (model.active ? Theme.secondaryHighlightColor : Theme.primaryColor)) 122 | anchors.fill: image 123 | } 124 | 125 | onClicked: { 126 | slideshowShow(index) 127 | console.log(index) 128 | navigateTo(model.slug) 129 | effect.state = "right" 130 | } 131 | } 132 | 133 | function navigateTo(slug){ 134 | for(var i = 0; i < listModel.count; i++){ 135 | if (listModel.get(i).slug === slug || i===slug) 136 | listModel.setProperty(i, 'active', true); 137 | else 138 | listModel.setProperty(i, 'active', false); 139 | } 140 | console.log(slug) 141 | } 142 | 143 | VerticalScrollDecorator {} 144 | } 145 | -------------------------------------------------------------------------------- /qml/pages/components/ProfileHeader.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | 4 | 5 | Item { 6 | id: profileHeader 7 | 8 | property int value: 0 9 | property string title: "" 10 | property string description: "" 11 | property string image: "" 12 | property string bg: "" 13 | 14 | width: parent.width 15 | height: isPortrait ? (avatarImage.height + Theme.paddingLarge*3 + infoLbl.height) : (avatarImage.height + Theme.paddingLarge*2.5 + infoLbl.height) 16 | 17 | Rectangle { 18 | id: bgImage 19 | opacity: 0.7 20 | gradient: Gradient { 21 | GradientStop { position: 0.0; color: Theme.highlightDimmerColor } 22 | GradientStop { position: 2.0; color: Theme.highlightBackgroundColor } 23 | } 24 | anchors.fill: parent 25 | 26 | Image { 27 | asynchronous: true 28 | fillMode: Image.PreserveAspectCrop 29 | source: bg 30 | opacity: 0.6 31 | anchors.fill: parent 32 | } 33 | } 34 | 35 | Image { 36 | id: avatarImage 37 | asynchronous: true 38 | source: if (avatarImage.status === Image.Error) 39 | source = "../../images/icon-l-profile.svg?" + Theme.primaryColor 40 | else image 41 | width: isPortrait ? Theme.iconSizeLarge : Theme.iconSizeExtraLarge 42 | height: width 43 | anchors { 44 | left: parent.left 45 | leftMargin: Theme.horizontalPageMargin 46 | top: parent.top 47 | topMargin: Theme.paddingLarge * 1.5 48 | } 49 | 50 | Button { 51 | id: imageButton 52 | opacity: 0 53 | width: Theme.iconSizeExtraLarge * 1.2 54 | anchors { 55 | top: parent.top 56 | left: parent.left 57 | bottom: parent.bottom 58 | } 59 | onClicked: { 60 | pageStack.push(Qt.resolvedUrl("ProfileImage.qml"), { 61 | "image": image 62 | }) 63 | } 64 | } 65 | } 66 | 67 | Column { 68 | anchors { 69 | top: parent.top 70 | topMargin: Theme.paddingLarge 71 | left: avatarImage.right 72 | leftMargin: Theme.horizontalPageMargin 73 | right: parent.right 74 | rightMargin: Theme.horizontalPageMargin 75 | verticalCenter: parent.verticalCenter 76 | } 77 | 78 | Label { 79 | id: profileTitle 80 | text: title ? title : description.split('@')[0] 81 | font.pixelSize: Theme.fontSizeLarge 82 | font.family: Theme.fontFamilyHeading 83 | color: Theme.highlightColor 84 | truncationMode: TruncationMode.Fade 85 | width: parent.width 86 | height: contentHeight 87 | horizontalAlignment: Text.AlignRight 88 | } 89 | 90 | Label { 91 | id: profileDescription 92 | text: "@"+description 93 | font.pixelSize: Theme.fontSizeSmall 94 | font.family: Theme.fontFamilyHeading 95 | color: Theme.secondaryHighlightColor 96 | truncationMode: TruncationMode.Fade 97 | width: parent.width 98 | height: contentHeight 99 | horizontalAlignment: Text.AlignRight 100 | } 101 | } 102 | 103 | Row { 104 | id: infoLbl 105 | spacing: Theme.paddingLarge 106 | layoutDirection: Qt.RightToLeft 107 | height: followed_by || locked || bot || group ? Theme.iconSizeSmall + Theme.paddingSmall : 0 108 | anchors { 109 | top: avatarImage.bottom 110 | topMargin: isPortrait ? Theme.paddingMedium : 0 111 | left: parent.left 112 | leftMargin: Theme.horizontalPageMargin 113 | right: parent.right 114 | rightMargin: Theme.horizontalPageMargin 115 | } 116 | 117 | Rectangle { 118 | id: groupBg 119 | visible: (group ? true : false) 120 | radius: Theme.paddingSmall 121 | color: Theme.secondaryHighlightColor 122 | width: groupLbl.width + 2*Theme.paddingLarge 123 | height: parent.height 124 | 125 | Label { 126 | id: groupLbl 127 | text: qsTr("Group") 128 | font.pixelSize: Theme.fontSizeExtraSmall 129 | color: Theme.primaryColor 130 | anchors.horizontalCenter: parent.horizontalCenter 131 | anchors.verticalCenter: parent.verticalCenter 132 | } 133 | } 134 | 135 | Rectangle { 136 | id: followingBg 137 | visible: (followed_by ? true : false) 138 | radius: Theme.paddingSmall 139 | color: Theme.secondaryHighlightColor 140 | width: followingLbl.width + 2*Theme.paddingLarge 141 | height: parent.height 142 | 143 | Label { 144 | id: followingLbl 145 | text: qsTr("Follows you") 146 | font.pixelSize: Theme.fontSizeExtraSmall 147 | color: Theme.primaryColor 148 | anchors.horizontalCenter: parent.horizontalCenter 149 | anchors.verticalCenter: parent.verticalCenter 150 | } 151 | } 152 | 153 | Rectangle { 154 | id: lockedBg 155 | visible: (locked ? true : false) 156 | radius: Theme.paddingSmall 157 | color: Theme.secondaryHighlightColor 158 | width: lockedImg.width + 2*Theme.paddingLarge 159 | height: parent.height 160 | 161 | HighlightImage { 162 | id: lockedImg 163 | source: "image://theme/icon-s-secure?" 164 | width: Theme.fontSizeExtraSmall 165 | height: width 166 | color: Theme.primaryColor 167 | anchors.horizontalCenter: lockedBg.horizontalCenter 168 | anchors.verticalCenter: lockedBg.verticalCenter 169 | } 170 | } 171 | 172 | Rectangle { 173 | id: botBg 174 | visible: (bot ? true : false) 175 | radius: Theme.paddingSmall 176 | color: Theme.secondaryHighlightColor 177 | width: botLbl.width + 2*Theme.paddingLarge 178 | height: parent.height 179 | 180 | Label { 181 | id: botLbl 182 | text: qsTr("Bot") 183 | font.pixelSize: Theme.fontSizeExtraSmall 184 | color: Theme.primaryColor 185 | anchors { 186 | horizontalCenter: parent.horizontalCenter 187 | verticalCenter: parent.verticalCenter 188 | } 189 | } 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /qml/pages/components/ProfileImage.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Sailfish.Silica 1.0 3 | 4 | FullscreenContentPage { 5 | id: profileImage 6 | 7 | property string image: "" 8 | 9 | allowedOrientations: Orientation.All 10 | 11 | Image { 12 | source: image 13 | fillMode: Image.PreserveAspectFit 14 | anchors.fill: parent 15 | } 16 | 17 | IconButton { 18 | icon.source: "image://theme/icon-m-dismiss" 19 | onClicked: pageStack.pop() 20 | anchors { 21 | top: profileImage.top 22 | topMargin: Theme.horizontalPageMargin 23 | right: parent.right 24 | rightMargin: Theme.horizontalPageMargin 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /rpm/harbour-tooterb.changes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poetaster/harbour-tooter/1f404be9e3303e367c225317be030123584e5861/rpm/harbour-tooterb.changes -------------------------------------------------------------------------------- /rpm/harbour-tooterb.changes.run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Rename this file as harbour-tooterb.changes.run to let mb2 automatically 4 | # generate changelog from well formatted Git commit messages and tag 5 | # annotations. 6 | 7 | #sfdk-changelog 8 | 9 | # Here are some basic examples how to change from the default behavior. Run 10 | # git-change-log --help inside the Sailfish OS SDK chroot or build engine to 11 | # learn all the options git-change-log accepts. 12 | 13 | # Use a subset of tags 14 | #git-change-log --tags refs/tags/my-prefix/* 15 | 16 | # Group entries by minor revision, suppress headlines for patch-level revisions 17 | #git-change-log --dense '/[0-9]\+\.[0-9\+$' 18 | 19 | # Trim very old changes 20 | #git-change-log --since 2014-04-01 21 | #echo '[ Some changelog entries trimmed for brevity ]' 22 | 23 | # Use the subjects (first lines) of tag annotations when no entry would be 24 | # included for a revision otherwise 25 | #git-change-log --auto-add-annotations 26 | -------------------------------------------------------------------------------- /rpm/harbour-tooterb.spec: -------------------------------------------------------------------------------- 1 | # 2 | # Do NOT Edit the Auto-generated Part! 3 | # Generated by: spectacle version 0.32 4 | # 5 | 6 | Name: harbour-tooterb 7 | 8 | # >> macros 9 | %define _binary_payload w2.xzdio 10 | # << macros 11 | 12 | %{!?qtc_qmake:%define qtc_qmake %qmake} 13 | %{!?qtc_qmake5:%define qtc_qmake5 %qmake5} 14 | %{!?qtc_make:%define qtc_make make} 15 | %{?qtc_builddir:%define _builddir %qtc_builddir} 16 | Summary: Tooter β 17 | Version: 1.1.9 18 | Release: 3 19 | Group: Qt/Qt 20 | License: GPLv3 21 | URL: https://github.com/poetaster/harbour-tooter#readme 22 | Source0: %{name}-%{version}.tar.bz2 23 | Requires: sailfishsilica-qt5 >= 0.10.9 24 | Requires: nemo-qml-plugin-configuration-qt5 25 | Requires: amber-web-authorization 26 | 27 | BuildRequires: qt5-qttools-linguist 28 | BuildRequires: pkgconfig(sailfishapp) >= 1.0.2 29 | BuildRequires: pkgconfig(Qt5Core) 30 | BuildRequires: pkgconfig(Qt5Qml) 31 | BuildRequires: pkgconfig(Qt5Quick) 32 | BuildRequires: pkgconfig(Qt5DBus) 33 | BuildRequires: pkgconfig(Qt5Multimedia) 34 | BuildRequires: pkgconfig(nemonotifications-qt5) 35 | BuildRequires: pkgconfig(openssl) 36 | BuildRequires: desktop-file-utils 37 | 38 | %description 39 | Tooter Beta is a native client for Mastodon network instances. 40 | 41 | %if "%{?vendor}" == "chum" 42 | PackageName: Tooter β 43 | Type: desktop-application 44 | Categories: 45 | - Network 46 | PackagerName: Mark Washeim (poetaster) 47 | Custom: 48 | - Repo: https://github.com/poetaster/harbour-tooter 49 | PackageIcon: https://raw.githubusercontent.com/poetaster/harbour-tooter/master/icons/256x256/harbour-tooterb.png 50 | Url: 51 | - Bugtracker: https://github.com/poetaster/harbour-tooter/issues 52 | Screenshots: 53 | - https://github.com/poetaster/harbour-tooter/raw/master/screenshots/screenshot1.png 54 | - https://github.com/poetaster/harbour-tooter/raw/master/screenshots/screenshot2.png 55 | - https://github.com/poetaster/harbour-tooter/raw/master/screenshots/screenshot3.png 56 | Links: 57 | Homepage: https://github.com/poetaster/harbour-tooter 58 | Bugtracker: https://github.com/poetaster/harbour-tooter/issues 59 | Donation: https://liberapay.com/poetaster 60 | %endif 61 | 62 | %prep 63 | %setup -q -n %{name}-%{version} 64 | 65 | # >> setup 66 | # << setup 67 | 68 | %build 69 | # >> build pre 70 | # << build pre 71 | 72 | %qtc_qmake5 73 | 74 | %qtc_make %{?_smp_mflags} 75 | 76 | # >> build post 77 | # << build post 78 | 79 | %install 80 | rm -rf %{buildroot} 81 | # >> install pre 82 | # << install pre 83 | %qmake5_install 84 | 85 | # >> install post 86 | # << install post 87 | 88 | desktop-file-install --delete-original \ 89 | --dir %{buildroot}%{_datadir}/applications \ 90 | %{buildroot}%{_datadir}/applications/*.desktop 91 | 92 | %files 93 | %defattr(-,root,root,-) 94 | %{_bindir} 95 | %{_datadir}/%{name} 96 | %{_datadir}/applications/%{name}.desktop 97 | %{_datadir}/icons/hicolor/*/apps/%{name}.png 98 | # >> files 99 | # << files 100 | -------------------------------------------------------------------------------- /screenshots/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poetaster/harbour-tooter/1f404be9e3303e367c225317be030123584e5861/screenshots/screenshot1.png -------------------------------------------------------------------------------- /screenshots/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poetaster/harbour-tooter/1f404be9e3303e367c225317be030123584e5861/screenshots/screenshot2.png -------------------------------------------------------------------------------- /screenshots/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poetaster/harbour-tooter/1f404be9e3303e367c225317be030123584e5861/screenshots/screenshot3.png -------------------------------------------------------------------------------- /src/dbus.cpp: -------------------------------------------------------------------------------- 1 | #include "dbus.h" 2 | 3 | static const char *PATH = "/"; 4 | static const char *SERVICE = SERVICE_NAME; 5 | 6 | Dbus::Dbus(QObject *parent) : 7 | QObject(parent) 8 | { 9 | m_dbusRegistered = false; 10 | new TooterbAdaptor(this); 11 | registerDBus(); 12 | } 13 | 14 | Dbus::~Dbus() 15 | { 16 | if (m_dbusRegistered) 17 | { 18 | QDBusConnection connection = QDBusConnection::sessionBus(); 19 | connection.unregisterObject(PATH); 20 | connection.unregisterService(SERVICE); 21 | } 22 | } 23 | 24 | void Dbus::registerDBus() 25 | { 26 | if (!m_dbusRegistered) 27 | { 28 | QDBusConnection connection = QDBusConnection::sessionBus(); 29 | if (!connection.registerService(SERVICE)) 30 | { 31 | QCoreApplication::quit(); 32 | return; 33 | } 34 | 35 | if (!connection.registerObject(PATH, this)) 36 | { 37 | QCoreApplication::quit(); 38 | return; 39 | } 40 | m_dbusRegistered = true; 41 | } 42 | } 43 | 44 | void Dbus::showtoot(const QStringList &key) 45 | { 46 | emit viewtoot(key.at(0)); 47 | } 48 | 49 | void Dbus::openapp() 50 | { 51 | emit activateapp(); 52 | } 53 | -------------------------------------------------------------------------------- /src/dbus.h: -------------------------------------------------------------------------------- 1 | #ifndef DBUS_H 2 | #define DBUS_H 3 | 4 | #include 5 | #include 6 | #include "dbusAdaptor.h" 7 | 8 | #define SERVICE_NAME "ba.dysko.harbour.tooterb" 9 | 10 | class QDBusInterface; 11 | class Dbus : public QObject 12 | { 13 | Q_OBJECT 14 | Q_CLASSINFO("D-Bus Interface", SERVICE_NAME) 15 | 16 | public: 17 | explicit Dbus(QObject *parent = 0); 18 | ~Dbus(); 19 | void registerDBus(); 20 | 21 | public Q_SLOTS: 22 | Q_NOREPLY void showtoot(const QStringList &key); 23 | Q_NOREPLY void openapp(); 24 | 25 | signals: 26 | void viewtoot(QString key); 27 | void activateapp(); 28 | 29 | private: 30 | bool m_dbusRegistered; 31 | }; 32 | 33 | #endif // DBUS_H 34 | -------------------------------------------------------------------------------- /src/dbusAdaptor.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | * This file was generated by qdbusxml2cpp version 0.8 3 | * Command line was: qdbusxml2cpp config/com.kimmoli.harbour.maira.xml -i dbus.h -a src/dbusAdaptor 4 | * 5 | * qdbusxml2cpp is Copyright (C) 2016 The Qt Company Ltd. 6 | * 7 | * This is an auto-generated file. 8 | * Do not edit! All changes made to it will be lost. 9 | */ 10 | 11 | #include "src/dbusAdaptor.h" 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | 20 | /* 21 | * Implementation of adaptor class TooterbAdaptor 22 | */ 23 | 24 | TooterbAdaptor::TooterbAdaptor(QObject *parent) 25 | : QDBusAbstractAdaptor(parent) 26 | { 27 | // constructor 28 | setAutoRelaySignals(true); 29 | } 30 | 31 | TooterbAdaptor::~TooterbAdaptor() 32 | { 33 | // destructor 34 | } 35 | 36 | void TooterbAdaptor::openapp() 37 | { 38 | // handle method call ba.dysko.harbour.tooterb.openapp 39 | QMetaObject::invokeMethod(parent(), "openapp"); 40 | } 41 | 42 | void TooterbAdaptor::showtoot(const QStringList &key) 43 | { 44 | // handle method call ba.dysko.harbour.tooterb.showtoot 45 | QMetaObject::invokeMethod(parent(), "showtoot", Q_ARG(QStringList, key)); 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/dbusAdaptor.h: -------------------------------------------------------------------------------- 1 | /* 2 | * This file was generated by qdbusxml2cpp version 0.8 3 | * Command line was: qdbusxml2cpp config/com.kimmoli.harbour.maira.xml -i dbus.h -a src/dbusAdaptor 4 | * 5 | * qdbusxml2cpp is Copyright (C) 2016 The Qt Company Ltd. 6 | * 7 | * This is an auto-generated file. 8 | * This file may have been hand-edited. Look for HAND-EDIT comments 9 | * before re-generating it. 10 | */ 11 | 12 | #ifndef DBUSADAPTOR_H 13 | #define DBUSADAPTOR_H 14 | 15 | #include 16 | #include 17 | #include "dbus.h" 18 | QT_BEGIN_NAMESPACE 19 | class QByteArray; 20 | template class QList; 21 | template class QMap; 22 | class QString; 23 | class QStringList; 24 | class QVariant; 25 | QT_END_NAMESPACE 26 | 27 | /* 28 | * Adaptor class for interface com.kimmoli.harbour.maira 29 | */ 30 | class TooterbAdaptor: public QDBusAbstractAdaptor 31 | { 32 | Q_OBJECT 33 | Q_CLASSINFO("D-Bus Interface", "ba.dysko.harbour.tooterb") 34 | Q_CLASSINFO("D-Bus Introspection", "" 35 | " \n" 36 | " \n" 37 | " \n" 38 | " \n" 39 | " \n" 40 | " \n" 41 | " \n" 42 | "") 43 | public: 44 | TooterbAdaptor(QObject *parent); 45 | virtual ~TooterbAdaptor(); 46 | 47 | public: // PROPERTIES 48 | public Q_SLOTS: // METHODS 49 | void openapp(); 50 | Q_NOREPLY void showtoot(const QStringList &key); 51 | Q_SIGNALS: // SIGNALS 52 | }; 53 | 54 | #endif 55 | -------------------------------------------------------------------------------- /src/filedownloader.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2015-2017 kimmoli 3 | * All rights reserved. 4 | * 5 | * This file is part of Maira 6 | * 7 | * You may use this file under the terms of BSD license 8 | */ 9 | 10 | #include "filedownloader.h" 11 | #include 12 | 13 | FileDownloader::FileDownloader(QQmlEngine *engine, QObject *parent) : 14 | QObject(parent) 15 | { 16 | m_engine = engine; 17 | } 18 | 19 | void FileDownloader::downloadFile(QUrl url, QString filename) 20 | { 21 | emit downloadStarted(); 22 | 23 | m_filename = filename; 24 | qDebug() << "downloading" << url << "to" << filename; 25 | 26 | QNetworkAccessManager *nam = m_engine->networkAccessManager(); 27 | 28 | QNetworkRequest request(url); 29 | QNetworkReply *r = nam->get(request); 30 | connect(r, SIGNAL(finished()), this, SLOT(fileDownloaded())); 31 | } 32 | 33 | void FileDownloader::open(QString filename) 34 | { 35 | QProcess proc; 36 | QString path = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation) + "/" + filename; 37 | 38 | proc.startDetached("/usr/bin/xdg-open" , QStringList() << path); 39 | } 40 | 41 | void FileDownloader::fileDownloaded() 42 | { 43 | QNetworkReply *pReply = qobject_cast(sender()); 44 | 45 | m_DownloadedData = pReply->readAll(); 46 | int httpstatus = pReply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); 47 | qDebug() << "HttpStatusCode" << httpstatus; 48 | 49 | pReply->deleteLater(); 50 | 51 | if (httpstatus == 200) 52 | { 53 | QFile file(QStandardPaths::writableLocation(QStandardPaths::DownloadLocation) + "/" + m_filename); 54 | if (!file.open(QIODevice::WriteOnly)) 55 | { 56 | emit downloadFailed("Download failed, can't create file"); 57 | return; 58 | } 59 | file.write(m_DownloadedData); 60 | file.close(); 61 | emit downloadSuccess(); 62 | open(m_filename); 63 | } 64 | else 65 | { 66 | emit downloadFailed(QString("Download failed, error %1").arg(httpstatus)); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/filedownloader.h: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2015-2017 kimmoli 3 | * All rights reserved. 4 | * 5 | * This file is part of Maira 6 | * 7 | * You may use this file under the terms of BSD license 8 | */ 9 | 10 | #ifndef FILEDOWNLOADER_H 11 | #define FILEDOWNLOADER_H 12 | 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | #include 20 | #include 21 | #include 22 | 23 | class FileDownloader : public QObject 24 | { 25 | Q_OBJECT 26 | public: 27 | explicit FileDownloader(QQmlEngine *engine, QObject *parent = nullptr); 28 | Q_INVOKABLE void downloadFile(QUrl url, QString filename); 29 | Q_INVOKABLE void open(QString filename); 30 | 31 | signals: 32 | void downloadStarted(); 33 | void downloadSuccess(); 34 | void downloadFailed(QString errorMsg); 35 | 36 | private slots: 37 | void fileDownloaded(); 38 | 39 | private: 40 | QQmlEngine *m_engine; 41 | QByteArray m_DownloadedData; 42 | QString m_filename; 43 | }; 44 | 45 | #endif // FILEDOWNLOADER_H 46 | -------------------------------------------------------------------------------- /src/harbour-tooterb.cpp: -------------------------------------------------------------------------------- 1 | #ifdef QT_QML_DEBUG 2 | #include 3 | #endif 4 | 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | //#include 16 | #include "filedownloader.h" 17 | #include "imageuploader.h" 18 | #include "notifications.h" 19 | #include "dbus.h" 20 | 21 | 22 | int main(int argc, char *argv[]) 23 | { 24 | QScopedPointer app(SailfishApp::application(argc, argv)); 25 | QScopedPointer view(SailfishApp::createView()); 26 | //QQmlContext *context = view.data()->rootContext(); 27 | QQmlEngine* engine = view->engine(); 28 | 29 | FileDownloader *fd = new FileDownloader(engine); 30 | view->rootContext()->setContextProperty("FileDownloader", fd); 31 | qmlRegisterType("harbour.tooterb.Uploader", 1, 0, "ImageUploader"); 32 | 33 | Notifications *no = new Notifications(); 34 | view->rootContext()->setContextProperty("Notifications", no); 35 | QObject::connect(engine, SIGNAL(quit()), app.data(), SLOT(quit())); 36 | 37 | Dbus *dbus = new Dbus(); 38 | view->rootContext()->setContextProperty("Dbus", dbus); 39 | 40 | view->setSource(SailfishApp::pathTo("qml/harbour-tooterb.qml")); 41 | view->show(); 42 | return app->exec(); 43 | } 44 | -------------------------------------------------------------------------------- /src/imageuploader.cpp: -------------------------------------------------------------------------------- 1 | #include "imageuploader.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | #include 10 | 11 | //static const QUrl IMGUR_UPLOAD_URL(); 12 | 13 | ImageUploader::ImageUploader(QObject *parent) : QObject(parent), m_networkAccessManager(nullptr), m_reply(nullptr) { 14 | m_networkAccessManager = new QNetworkAccessManager(this); 15 | } 16 | 17 | ImageUploader::~ImageUploader() { 18 | if (m_reply != nullptr) { 19 | m_reply->disconnect(); 20 | m_reply->deleteLater(); 21 | m_reply = nullptr; 22 | } 23 | } 24 | 25 | void ImageUploader::setFile(const QString &fileName) { 26 | m_fileName = fileName; 27 | } 28 | void ImageUploader::setMime(const QString &mimeType) { 29 | m_mimeType = mimeType; 30 | } 31 | 32 | void ImageUploader::setParameters(const QString &album, const QString &title, const QString &description) { 33 | //if (!album.isEmpty()) { 34 | postdata.append(QString("album=").toUtf8()); 35 | postdata.append(QUrl::toPercentEncoding(album)); 36 | //} 37 | if (!title.isEmpty()) { 38 | postdata.append(QString("&title=").toUtf8()); 39 | postdata.append(QUrl::toPercentEncoding(title)); 40 | } 41 | if (!description.isEmpty()) { 42 | postdata.append(QString("&description=").toUtf8()); 43 | postdata.append(QUrl::toPercentEncoding(description)); 44 | } 45 | } 46 | 47 | void ImageUploader::setAuthorizationHeader(const QString &authorizationHeader) { 48 | m_authorizationHeader = "Bearer "+authorizationHeader.toUtf8(); 49 | } 50 | 51 | void ImageUploader::setUploadUrl(const QString &UrlString) { 52 | qDebug() << "Set Upload URL " + UrlString; 53 | m_uploadUrl = UrlString.toUtf8(); 54 | } 55 | 56 | void ImageUploader::upload() { 57 | 58 | if (!m_networkAccessManager) { 59 | qWarning("ImageUploader::send(): networkAccessManager not set"); 60 | return; 61 | } 62 | 63 | if (m_reply != nullptr) { 64 | m_reply->disconnect(); 65 | m_reply->deleteLater(); 66 | m_reply = nullptr; 67 | } 68 | 69 | /*QFileInfo fileInfo(QUrl(m_fileName).toLocalFile()); 70 | qDebug("fileName: %s", qPrintable(m_fileName)); 71 | if (!fileInfo.exists()) { 72 | emit failure(-1, tr("The file %1 does not exists").arg(m_fileName)); 73 | postdata.clear(); 74 | return; 75 | }*/ 76 | 77 | QHttpMultiPart* multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); 78 | QHttpPart imagePart; 79 | 80 | 81 | //QFile file(fileInfo.absoluteFilePath()); 82 | QFileInfo fileInfo(QUrl(m_fileName).toLocalFile()); 83 | QFile* file = new QFile(QUrl(m_fileName).toLocalFile()); 84 | if (!file->open(QIODevice::ReadWrite)) { 85 | emit failure(-1, tr("The file %1 does not exists").arg(m_fileName)); 86 | return; 87 | } 88 | /*bool opened = file.open(QIODevice::ReadOnly); 89 | 90 | if (!opened) { 91 | qDebug("can't read file: %s", qPrintable(m_fileName)); 92 | emit failure(-1, tr("Unable to open the file %1").arg(file.fileName())); 93 | postdata.clear(); 94 | return; 95 | }*/ 96 | 97 | //QByteArray fileData = file.readAll().toBase64(); 98 | //imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant("image/jpeg")); 99 | imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(m_mimeType)); 100 | imagePart.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant(QString("form-data; name=\"file\"; filename=\"%1\"").arg(fileInfo.fileName()).toLatin1())); 101 | imagePart.setBodyDevice(file); 102 | file->setParent(multiPart); 103 | multiPart->append(imagePart); 104 | 105 | //imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(fileInfo)); 106 | 107 | //POST data 108 | 109 | QNetworkRequest request(m_uploadUrl); 110 | request.setRawHeader("Authorization", m_authorizationHeader); 111 | m_reply = m_networkAccessManager->post(request, multiPart); 112 | multiPart->setParent(m_reply); 113 | connect(m_reply, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(uploadProgress(qint64,qint64))); 114 | connect(m_reply, SIGNAL(finished()), this, SLOT(replyFinished())); 115 | 116 | //connect(m_reply, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(uploadProgress(qint64,qint64))); 117 | //connect(m_reply, SIGNAL(finished()), this, SLOT(replyFinished()));*/ 118 | } 119 | 120 | qreal ImageUploader::progress() const { 121 | return m_progress; 122 | } 123 | 124 | void ImageUploader::uploadProgress(qint64 bytesSent, qint64 bytesTotal) { 125 | qreal progress = qreal(bytesSent) / qreal(bytesTotal); 126 | //qDebug("uploadProgress: %f , %f, %f", qreal(bytesSent), qreal(bytesTotal), qreal(progress)); 127 | 128 | if (m_progress != progress) { 129 | m_progress = progress; 130 | emit progressChanged(); 131 | } 132 | } 133 | 134 | void ImageUploader::replyFinished() { 135 | if (!m_reply->error()) { 136 | QByteArray replyData = m_reply->readAll(); 137 | emit success(replyData); 138 | } 139 | else { 140 | int status = m_reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); 141 | QString statusText = m_reply->errorString(); 142 | emit failure(status, statusText); 143 | } 144 | 145 | m_reply->deleteLater(); 146 | m_reply = nullptr; 147 | postdata.clear(); 148 | } 149 | -------------------------------------------------------------------------------- /src/imageuploader.h: -------------------------------------------------------------------------------- 1 | #ifndef IMAGEUPLOADER_H 2 | #define IMAGEUPLOADER_H 3 | 4 | #include 5 | 6 | class QNetworkAccessManager; 7 | class QNetworkReply; 8 | 9 | class ImageUploader : public QObject 10 | { 11 | Q_OBJECT 12 | Q_PROPERTY(qreal progress READ progress NOTIFY progressChanged) 13 | 14 | public: 15 | explicit ImageUploader(QObject *parent = nullptr); 16 | ~ImageUploader(); 17 | 18 | Q_INVOKABLE void setFile(const QString &fileName); 19 | Q_INVOKABLE void setMime(const QString &mimeType); 20 | Q_INVOKABLE void setAuthorizationHeader(const QString &authorizationHeader); 21 | Q_INVOKABLE void setUploadUrl(const QString &userAgent); 22 | Q_INVOKABLE void setParameters(const QString &album, const QString &title, const QString &description); 23 | Q_INVOKABLE void upload(); 24 | 25 | qreal progress() const; 26 | 27 | signals: 28 | void success(const QString &replyData); 29 | void failure(const int status, const QString &statusText); 30 | void progressChanged(); 31 | 32 | private slots: 33 | void uploadProgress(qint64 bytesSent, qint64 bytesTotal); 34 | void replyFinished(); 35 | 36 | private: 37 | qreal m_progress; 38 | QNetworkAccessManager *m_networkAccessManager; 39 | 40 | QString m_fileName; 41 | QString m_mimeType; 42 | QByteArray m_authorizationHeader; 43 | QString m_uploadUrl; 44 | QByteArray postdata; 45 | QNetworkReply *m_reply; 46 | }; 47 | 48 | #endif // IMAGEUPLOADER_H 49 | -------------------------------------------------------------------------------- /src/notifications.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2015-2017 kimmoli 3 | * All rights reserved. 4 | * 5 | * This file is part of Maira 6 | * 7 | * You may use this file under the terms of BSD license 8 | */ 9 | 10 | #include "notifications.h" 11 | 12 | Notifications::Notifications(QObject *parent) : 13 | QObject(parent) 14 | { 15 | } 16 | 17 | void Notifications::notify(QString appName, QString summary, QString body, bool preview, QString ts, QString issuekey) 18 | { 19 | Notification notif; 20 | 21 | QVariantList remoteactions; 22 | 23 | if (preview) 24 | { 25 | notif.setPreviewSummary(summary); 26 | notif.setPreviewBody(body); 27 | notif.setCategory("x-harbour.tooterb.activity"); 28 | if (issuekey.isEmpty()) 29 | { 30 | remoteactions << Notification::remoteAction("default", 31 | QString(), 32 | "ba.dysko.habour.tooterb", 33 | "/", 34 | "ba.dysko.habour.tooterb", 35 | "openapp", 36 | QVariantList()); 37 | } 38 | } 39 | else 40 | { 41 | notif.setAppName(appName); 42 | notif.setSummary(summary); 43 | notif.setBody(body); 44 | notif.setItemCount(1); 45 | notif.setCategory("x-harbour.tooterb.activity"); 46 | remoteactions << Notification::remoteAction("app", 47 | QString(), 48 | "ba.dysko.habour.tooterb", 49 | "/", 50 | "ba.dysko.habour.tooterb", 51 | "openapp", 52 | QVariantList()); 53 | } 54 | 55 | notif.setReplacesId(0); 56 | 57 | if (!ts.isEmpty()) 58 | notif.setHintValue("x-nemo-timestamp", QVariant(ts)); 59 | 60 | if (!issuekey.isEmpty()) 61 | { 62 | QList args; 63 | args.append(QStringList() << issuekey); 64 | 65 | remoteactions << Notification::remoteAction("default", 66 | QString(), 67 | "ba.dysko.habour.tooterb", 68 | "/", 69 | "ba.dysko.habour.tooterb", 70 | "showtoot", 71 | args); 72 | } 73 | 74 | if (remoteactions.count() > 0) 75 | notif.setRemoteActions(remoteactions); 76 | 77 | notif.publish(); 78 | } 79 | -------------------------------------------------------------------------------- /src/notifications.h: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2015-2017 kimmoli 3 | * All rights reserved. 4 | * 5 | * This file is part of Maira 6 | * 7 | * You may use this file under the terms of BSD license 8 | */ 9 | 10 | #ifndef NOTIFICATIONS_H 11 | #define NOTIFICATIONS_H 12 | 13 | #include 14 | #include 15 | 16 | class Notifications : public QObject 17 | { 18 | Q_OBJECT 19 | public: 20 | explicit Notifications(QObject *parent = nullptr); 21 | Q_INVOKABLE void notify(QString appName, QString summary, QString body, bool preview, QString ts, QString issuekey); 22 | }; 23 | 24 | #endif // NOTIFICATIONS_H 25 | -------------------------------------------------------------------------------- /translations/harbour-tooterb-en.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | API 4 | 5 | favourited 6 | favourited 7 | 8 | 9 | followed you 10 | followed you 11 | 12 | 13 | boosted 14 | boosted 15 | 16 | 17 | said 18 | said 19 | 20 | 21 | 22 | ConversationPage 23 | 24 | Copy Link to Clipboard 25 | Use the translation of "Copy Link" for a shorter PullDownMenu label 26 | Copy Link to Clipboard 27 | 28 | 29 | Write your warning here 30 | placeholderText in Toot content warning panel 31 | Write your warning here 32 | 33 | 34 | What's on your mind? 35 | placeholderText in Toot text panel 36 | What's on your mind? 37 | 38 | 39 | Public 40 | Public 41 | 42 | 43 | Unlisted 44 | Unlisted 45 | 46 | 47 | Followers-only 48 | Followers-only 49 | 50 | 51 | Direct 52 | Direct 53 | 54 | 55 | Toot sent! 56 | Toot sent! 57 | 58 | 59 | Reply 60 | "Reply" will show the Toot text entry Panel. "Hide Reply" closes it. Alternative: Use "Close Reply" 61 | Reply 62 | 63 | 64 | Hide Reply 65 | Hide Reply 66 | 67 | 68 | Open in Browser 69 | Open in Browser 70 | 71 | 72 | 73 | CoverPage 74 | 75 | New Toot 76 | New Toot 77 | 78 | 79 | 80 | EmojiSelect 81 | 82 | Emojis 83 | Emojis 84 | 85 | 86 | Tap to insert 87 | Tap to insert 88 | 89 | 90 | 91 | ImageUploader 92 | 93 | The file %1 does not exists 94 | The file %1 does not exists 95 | 96 | 97 | 98 | LoginPage 99 | 100 | Login 101 | Login 102 | 103 | 104 | Instance 105 | Instance 106 | 107 | 108 | Enter a valid Mastodon instance URL 109 | Enter a valid Mastodon instance URL 110 | 111 | 112 | Mastodon is a free, open-source social network. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Pick a server that you trust — whichever you choose, you can interact with everyone else. Anyone can run their own Mastodon instance and participate in the social network seamlessly. 113 | Mastodon is a free, open-source social network. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Pick a server that you trust — whichever you choose, you can interact with everyone else. Anyone can run their own Mastodon instance and participate in the social network seamlessly. 114 | 115 | 116 | Reload 117 | Reload 118 | 119 | 120 | 121 | MainPage 122 | 123 | Home 124 | Home 125 | 126 | 127 | Notifications 128 | Notifications 129 | 130 | 131 | Local 132 | Local 133 | 134 | 135 | Federated 136 | Federated 137 | 138 | 139 | Search 140 | Search 141 | 142 | 143 | @user or #term 144 | @user or #term 145 | 146 | 147 | New Toot 148 | New Toot 149 | 150 | 151 | Bookmarks 152 | Bookmarks 153 | 154 | 155 | 156 | MediaFullScreen 157 | 158 | Error loading 159 | Error loading 160 | 161 | 162 | 163 | MediaItem 164 | 165 | Image 166 | Image 167 | 168 | 169 | Video 170 | Video 171 | 172 | 173 | PDF document 174 | PDF document 175 | 176 | 177 | 178 | MiniStatus 179 | 180 | boosted 181 | boosted 182 | 183 | 184 | favourited 185 | favourited 186 | 187 | 188 | followed you 189 | followed you 190 | 191 | 192 | 193 | MyList 194 | 195 | Settings 196 | Settings 197 | 198 | 199 | New Toot 200 | New Toot 201 | 202 | 203 | Reload 204 | Reload 205 | 206 | 207 | Open in Browser 208 | Open in Browser 209 | 210 | 211 | Nothing found 212 | Nothing found 213 | 214 | 215 | 216 | ProfileHeader 217 | 218 | Bot 219 | Bot 220 | 221 | 222 | Follows you 223 | Follows you 224 | 225 | 226 | Group 227 | Group 228 | 229 | 230 | 231 | ProfilePage 232 | 233 | About 234 | If there's no good translation for "About", use "Details" (in details about profile). 235 | About 236 | 237 | 238 | Followers 239 | Will show as: "35 Followers" 240 | Followers 241 | 242 | 243 | Following 244 | Will show as: "23 Following" 245 | Following 246 | 247 | 248 | Statuses 249 | Will show as: "115 Statuses" 250 | Statuses 251 | 252 | 253 | Mention 254 | Mention 255 | 256 | 257 | Unfollow 258 | Is a button. Keep it as short as possible. 259 | Unfollow 260 | 261 | 262 | Requested 263 | Is a button. Keep it as short as possible. 264 | Requested 265 | 266 | 267 | Follow 268 | Is a button. Keep it as short as possible. 269 | Follow 270 | 271 | 272 | Unmute 273 | Is a button. Keep it as short as possible. 274 | Unmute 275 | 276 | 277 | Mute 278 | Is a button. Keep it as short as possible. 279 | Mute 280 | 281 | 282 | Unblock 283 | Is a button. Keep it as short as possible. 284 | Unblock 285 | 286 | 287 | Block 288 | Is a button. Keep it as short as possible. 289 | Block 290 | 291 | 292 | 293 | SettingsPage 294 | 295 | Settings 296 | Settings 297 | 298 | 299 | Options 300 | Options 301 | 302 | 303 | Load Images in Toots 304 | Load Images in Toots 305 | 306 | 307 | Disable this option if you want to preserve your data connection 308 | Disable this option if you want to preserve your data connection 309 | 310 | 311 | Account 312 | Account 313 | 314 | 315 | Remove Account 316 | Remove Account 317 | 318 | 319 | Add Account 320 | Add Account 321 | 322 | 323 | Deauthorize this app from using your account and remove account data from phone 324 | Deauthorize this app from using your account and remove account data from phone 325 | 326 | 327 | Authorize this app to access your Mastodon account 328 | Authorize this app to access your Mastodon account 329 | 330 | 331 | Translate 332 | Translate 333 | 334 | 335 | Credits 336 | Translation alternative: "Development" 337 | Credits 338 | 339 | 340 | UI/UX design and development 341 | UI/UX design and development 342 | 343 | 344 | Visual identity 345 | Visual identity 346 | 347 | 348 | Development and translations 349 | Development and translations 350 | 351 | 352 | Occitan & French translation 353 | Occitan & French translation 354 | 355 | 356 | Chinese translation 357 | Chinese translation 358 | 359 | 360 | Dutch translation 361 | Dutch translation 362 | 363 | 364 | Spanish translation 365 | Spanish translation 366 | 367 | 368 | Use 369 | Full sentence for translation: "Use Transifex to help with app translation to your language." - The word Transifex is a link and doesn't need translation. 370 | Use 371 | 372 | 373 | to help with app translation to your language. 374 | to help with app translation to your language. 375 | 376 | 377 | Development 378 | Development 379 | 380 | 381 | 382 | VisualContainer 383 | 384 | Unboost 385 | Unboost 386 | 387 | 388 | Boost 389 | Boost 390 | 391 | 392 | Unfavorite 393 | Unfavourite 394 | 395 | 396 | Favorite 397 | Favourite 398 | 399 | 400 | Mention 401 | Mention 402 | 403 | 404 | Conversation 405 | Conversation 406 | 407 | 408 | Remove Bookmark 409 | Remove Bookmark 410 | 411 | 412 | Bookmark 413 | Bookmark 414 | 415 | 416 | -------------------------------------------------------------------------------- /translations/harbour-tooterb-zh_CN.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API 6 | 7 | favourited 8 | 收藏 9 | 10 | 11 | followed you 12 | 关注你的 13 | 14 | 15 | boosted 16 | 推起的 17 | 18 | 19 | said 20 | 说过 21 | 22 | 23 | 24 | ConversationPage 25 | 26 | Copy Link to Clipboard 27 | Use the translation of "Copy Link" for a shorter PullDownMenu label 28 | 复制链接到剪切板 29 | 30 | 31 | Write your warning here 32 | placeholderText in Toot content warning panel 33 | 在此编写你的警告信息 34 | 35 | 36 | What's on your mind? 37 | placeholderText in Toot text panel 38 | 有何想法? 39 | 40 | 41 | Public 42 | 公共区域 43 | 44 | 45 | Unlisted 46 | 不公开 47 | 48 | 49 | Followers-only 50 | 仅关注者 51 | 52 | 53 | Direct 54 | 私信 55 | 56 | 57 | Toot sent! 58 | 已发送嘟嘟! 59 | 60 | 61 | Reply 62 | "Reply" will show the Toot text entry Panel. "Hide Reply" closes it. Alternative: Use "Close Reply" 63 | 回复 64 | 65 | 66 | Hide Reply 67 | 隐藏回复 68 | 69 | 70 | Open in Browser 71 | 在浏览器打开个人简介 72 | 73 | 74 | 75 | CoverPage 76 | 77 | New Toot 78 | 新嘟嘟 79 | 80 | 81 | 82 | EmojiSelect 83 | 84 | Emojis 85 | 表情 86 | 87 | 88 | Tap to insert 89 | 点击以插入 90 | 91 | 92 | 93 | ImageUploader 94 | 95 | The file %1 does not exists 96 | 文件 %1 不存在 97 | 98 | 99 | 100 | LoginPage 101 | 102 | Login 103 | 登录 104 | 105 | 106 | Instance 107 | 实例 108 | 109 | 110 | Mastodon is a free, open-source social network. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Pick a server that you trust — whichever you choose, you can interact with everyone else. Anyone can run their own Mastodon instance and participate in the social network seamlessly. 111 | Mastodon 是一个自由且开源的社交网络。一个去中心化的商业平台的替代品。它能够避免某个公司垄断你的通讯方式的风险。选择一个你所信任的服务器——无论你选择什么,你都可以和其他人进行互动。任何人都能运行他们自己的 Mastodon 实例,然后无缝加入社交网站。 112 | 113 | 114 | Enter a valid Mastodon instance URL (will open a web browser for Authentication) 115 | 116 | 117 | 118 | 119 | MainPage 120 | 121 | Home 122 | 主页 123 | 124 | 125 | Notifications 126 | 通知 127 | 128 | 129 | Local 130 | 本地 131 | 132 | 133 | Federated 134 | 联合 135 | 136 | 137 | Search 138 | 搜索 139 | 140 | 141 | @user or #term 142 | @用户或#项目 143 | 144 | 145 | New Toot 146 | 新嘟嘟 147 | 148 | 149 | Bookmarks 150 | 151 | 152 | 153 | 154 | MediaFullScreen 155 | 156 | Error loading 157 | 加载错误 158 | 159 | 160 | 161 | MediaItem 162 | 163 | Image 164 | 165 | 166 | 167 | Video 168 | 169 | 170 | 171 | PDF document 172 | 173 | 174 | 175 | 176 | MiniStatus 177 | 178 | boosted 179 | 推起的 180 | 181 | 182 | favourited 183 | 收藏 184 | 185 | 186 | followed you 187 | 关注你的 188 | 189 | 190 | 191 | MyList 192 | 193 | Settings 194 | 设置 195 | 196 | 197 | New Toot 198 | 新嘟嘟 199 | 200 | 201 | Reload 202 | 重新加载 203 | 204 | 205 | Open in Browser 206 | 在浏览器打开个人简介 207 | 208 | 209 | Nothing found 210 | 没有发现任何东西 211 | 212 | 213 | 214 | ProfileHeader 215 | 216 | Bot 217 | 机器人 218 | 219 | 220 | Follows you 221 | 关注你 222 | 223 | 224 | Group 225 | 群组 226 | 227 | 228 | 229 | ProfilePage 230 | 231 | About 232 | If there's no good translation for "About", use "Details" (in details about profile). 233 | 关于 234 | 235 | 236 | Followers 237 | Will show as: "35 Followers" 238 | 关注者 239 | 240 | 241 | Following 242 | Will show as: "23 Following" 243 | 关注中 244 | 245 | 246 | Statuses 247 | Will show as: "115 Statuses" 248 | 状态 249 | 250 | 251 | Mention 252 | 提及 253 | 254 | 255 | Unfollow 256 | Is a button. Keep it as short as possible. 257 | 取消关注 258 | 259 | 260 | Requested 261 | Is a button. Keep it as short as possible. 262 | 请求 263 | 264 | 265 | Follow 266 | Is a button. Keep it as short as possible. 267 | 关注 268 | 269 | 270 | Unmute 271 | Is a button. Keep it as short as possible. 272 | 未静音 273 | 274 | 275 | Mute 276 | Is a button. Keep it as short as possible. 277 | 静音 278 | 279 | 280 | Unblock 281 | Is a button. Keep it as short as possible. 282 | 解除封锁 283 | 284 | 285 | Block 286 | Is a button. Keep it as short as possible. 287 | 封锁 288 | 289 | 290 | 291 | SettingsPage 292 | 293 | Settings 294 | 设置 295 | 296 | 297 | Options 298 | 选项 299 | 300 | 301 | Load Images in Toots 302 | 加载嘟嘟图片 303 | 304 | 305 | Disable this option if you want to preserve your data connection 306 | 如果你想保护你的数据连接,请禁用此选项 307 | 308 | 309 | Account 310 | 账号 311 | 312 | 313 | Remove Account 314 | 移除账号 315 | 316 | 317 | Add Account 318 | 添加账号 319 | 320 | 321 | Deauthorize this app from using your account and remove account data from phone 322 | 取消授权此软件并移除你的账号 323 | 324 | 325 | Authorize this app to access your Mastodon account 326 | 授权此软件使用你的 Mastodon 账号 327 | 328 | 329 | Translate 330 | 翻译 331 | 332 | 333 | Credits 334 | Translation alternative: "Development" 335 | 信誉 336 | 337 | 338 | UI/UX design and development 339 | UI/UX设计及开发 340 | 341 | 342 | Visual identity 343 | 视觉识别 344 | 345 | 346 | Development and translations 347 | 开发及翻译 348 | 349 | 350 | Occitan & French translation 351 | 奥克西坦语及法语翻译 352 | 353 | 354 | Chinese translation 355 | 汉语翻译 356 | 357 | 358 | Dutch translation 359 | 尼德兰语翻译 360 | 361 | 362 | Spanish translation 363 | 西班牙语翻译 364 | 365 | 366 | Use 367 | Full sentence for translation: "Use Transifex to help with app translation to your language." - The word Transifex is a link and doesn't need translation. 368 | 使用 369 | 370 | 371 | to help with app translation to your language. 372 | 以帮助翻译软件为你使用的语言. 373 | 374 | 375 | Development 376 | 377 | 378 | 379 | Documentation 380 | 381 | 382 | 383 | 384 | VisualContainer 385 | 386 | Unboost 387 | 取消推起 388 | 389 | 390 | Boost 391 | 推起 392 | 393 | 394 | Unfavorite 395 | 取消关注 396 | 397 | 398 | Favorite 399 | 关注 400 | 401 | 402 | Mention 403 | 提及 404 | 405 | 406 | Conversation 407 | 对话 408 | 409 | 410 | Remove Bookmark 411 | 移除收藏 412 | 413 | 414 | Bookmark 415 | 收藏 416 | 417 | 418 | 419 | --------------------------------------------------------------------------------