├── .github └── workflows │ └── build.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── data ├── app.css ├── com.github.cleac.olifant.appdata.xml.in ├── com.github.cleac.olifant.desktop.in ├── com.github.cleac.olifant.gresource.xml ├── com.github.cleac.olifant.gschema.xml ├── dark.css ├── empty_state.png ├── icons │ ├── 16 │ │ └── com.github.cleac.olifant.svg │ ├── 24 │ │ └── com.github.cleac.olifant.svg │ ├── 32 │ │ └── com.github.cleac.olifant.svg │ ├── 48 │ │ └── com.github.cleac.olifant.svg │ ├── 64 │ │ └── com.github.cleac.olifant.svg │ ├── 128 │ │ └── com.github.cleac.olifant.svg │ ├── COPYING │ └── LICENSE ├── light.css ├── logo128.png ├── meson.build ├── screenshot.png ├── screenshot2.png ├── screenshot3.png └── screenshot4.png ├── debian ├── changelog ├── compat ├── control ├── copyright ├── rules └── source │ └── format ├── meson.build ├── meson └── post_install.py ├── po ├── LINGUAS ├── POTFILES ├── com.github.cleac.olifant.pot ├── de_DE.po ├── fr_FR.po ├── meson.build ├── pl.po ├── ru.po └── zh_CN.po └── src ├── API ├── Account.vala ├── Attachment.vala ├── Instance.vala ├── Mention.vala ├── Notification.vala ├── NotificationType.vala ├── Relationship.vala ├── Status.vala ├── StatusVisibility.vala ├── Tag.vala └── VersionInfo.vala ├── Accounts.vala ├── Application.vala ├── Desktop.vala ├── Dialogs ├── Compose.vala ├── ISavedWindow.vala ├── MainWindow.vala ├── NewAccount.vala ├── Preferences.vala └── WatchlistEditor.vala ├── Drawing.vala ├── Html.vala ├── ImageCache.vala ├── InstanceAccount.vala ├── Network.vala ├── Notificator.vala ├── Settings.vala ├── Views ├── Abstract.vala ├── Direct.vala ├── ExpandedStatus.vala ├── Favorites.vala ├── Federated.vala ├── Followers.vala ├── Following.vala ├── Hashtag.vala ├── Home.vala ├── Local.vala ├── Notifications.vala ├── Profile.vala ├── Search.vala └── Timeline.vala ├── Watchlist.vala └── Widgets ├── Account.vala ├── AccountsButton.vala ├── AlignedLabel.vala ├── AttachmentGrid.vala ├── ImageAttachment.vala ├── ImageToggleButton.vala ├── Notification.vala ├── RichLabel.vala └── Status.vala /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Check-Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-18.04 12 | 13 | steps: 14 | - uses: actions/checkout@v2.3.1 15 | - name: Setup environment 16 | run: | 17 | sudo add-apt-repository ppa:elementary-os/stable && \ 18 | sudo apt-get update && \ 19 | sudo apt-get install meson valac libgtk-3-dev libsoup2.4-dev libgranite-dev libjson-glib-dev -y; 20 | - name: Build project 21 | run: meson build && ninja -C build 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _ignore 2 | build 3 | build.sh 4 | build-po.sh 5 | install.sh 6 | uninstall.sh 7 | *~ 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | language: node_js 4 | 5 | node_js: 6 | - lts/* 7 | 8 | sudo: required 9 | 10 | services: 11 | - docker 12 | 13 | addons: 14 | apt: 15 | sources: 16 | - ubuntu-toolchain-r-test 17 | packages: 18 | - libstdc++-5-dev 19 | 20 | install: 21 | - npm i -g @elementaryos/houston 22 | 23 | script: 24 | - houston ci 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This project is not under maintenance 2 | 3 | I am sorry to inform you, but for now my focuses have shifted and I am no longer able to maintain this project. If you want to maintain the project, please contact me in an issue. I can transfer everything you need to make sure this project is running. 4 | 5 | ---- 6 | 7 | 8 | # Olifant 9 | 10 | A simple [Mastodon](https://github.com/tootsuite/mastodon) client designed for elementary OS, originally developed by [@bleakgrey](https://github.com/bleakgrey/tootle). 11 | 12 | ![Olifant Screenshot](https://raw.githubusercontent.com/cleac/olifant/master/data/screenshot.png) 13 | 14 | ## Building and Installation 15 | 16 | First of all you'll need some dependencies to build and run the app: 17 | * meson 18 | * valac 19 | * libgtk-3-dev 20 | * libsoup2.4-dev 21 | * libgranite-dev 22 | * libjson-glib-dev 23 | 24 | Then run these commands to build and install it: 25 | 26 | meson build --prefix=/usr 27 | ninja -C build install 28 | com.github.cleac.olifant 29 | 30 | ## Contributing 31 | 32 | If you feel like contributing, you're always welcome to help the project in many ways: 33 | * Reporting any issues 34 | * Suggesting ideas and functionality 35 | * Submitting pull requests 36 | 37 | ## Credits 38 | * Original project by [@bleakgrey](https://github.com/bleakgrey) 39 | * Olifant Logo by [Han "FanOKnives" Aral](https://github.com/hanaral) 40 | * Name of forked project by [Kev Quirk](https://fosstodon.org/@kev/) 41 | * Features coded and bugs fought by [@jcamposz](https://github.com/jcamposz) 42 | * Medel typeface by Ozan Karakoc 43 | * French translation by [@Larnicone](https://github.com/Larnicone) 44 | * Polish translation by [@m4sk1n](https://github.com/m4sk1n) 45 | * German translation by [@koyuawsmbrtn](https://github.com/koyuawsmbrtn) 46 | * Simplified Chinese translation by [@gloomy-ghost](https://github.com/gloomy-ghost) 47 | -------------------------------------------------------------------------------- /data/app.css: -------------------------------------------------------------------------------- 1 | .titlebar.compact { 2 | padding: 0 6px; 3 | } 4 | 5 | .mode .toggle{ 6 | border-radius:0px; 7 | border-top:none; 8 | border-bottom:none; 9 | padding:10px; 10 | margin:0px; 11 | } 12 | 13 | .button_avatar{ 14 | padding:0; 15 | border:0; 16 | box-shadow:none; 17 | background:none; 18 | } 19 | 20 | .toot-text, .toot-text text{ 21 | background-color: transparent; 22 | } 23 | 24 | .header{ 25 | background-size: cover; 26 | background-position: 50%; 27 | opacity: 0.15; 28 | } 29 | 30 | .relationship { 31 | background: rgba (0,0,0,.5); 32 | padding: 6px; 33 | border-radius: 3px; 34 | color: #fff; 35 | } 36 | -------------------------------------------------------------------------------- /data/com.github.cleac.olifant.appdata.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.github.cleac.olifant 6 | CC0-1.0 7 | GPL-3.0+ 8 | Olifant 9 | Lightning fast client for Mastodon 10 | 11 | 12 |

13 | Olifant is a client for the world’s largest free, open-source, decentralized microblogging network with real-time notifications and support for multiple accounts. 14 |

15 |

16 | Mastodon is lovingly crafted with power and speed in mind, resulting in a free, independent, and popular alternative to the centralized social networks. 17 |

18 |

19 | Anyone can run a Mastodon server. Each server hosts individual user accounts, the content they produce, and the content to which they are subscribed. Every user can follow each other and share their posts regardless of their server. 20 |

21 |
22 | 23 | 24 | com.github.cleac.olifant 25 | 26 | 27 | alexcleac 28 | https://github.com/cleac/olifant 29 | https://github.com/cleac/olifant/issues 30 | 31 | 32 | none 33 | none 34 | none 35 | none 36 | none 37 | none 38 | none 39 | none 40 | none 41 | none 42 | none 43 | none 44 | none 45 | none 46 | none 47 | none 48 | none 49 | none 50 | none 51 | none 52 | moderate 53 | none 54 | moderate 55 | none 56 | intense 57 | none 58 | none 59 | 60 | 61 | 62 | 63 | https://raw.githubusercontent.com/cleac/olifant/master/data/screenshot.png 64 | 65 | 66 | https://raw.githubusercontent.com/cleac/olifant/master/data/screenshot2.png 67 | 68 | 69 | https://raw.githubusercontent.com/cleac/olifant/master/data/screenshot3.png 70 | 71 | 72 | https://raw.githubusercontent.com/cleac/olifant/master/data/screenshot4.png 73 | 74 | 75 | 76 | 77 | 78 | 79 |
    80 |
  • Stability improvements
  • 81 |
  • Added ability to clear notifications
  • 82 |
  • Fixed duplication of follow requests in notifications
  • 83 |
84 |
85 |
86 | 87 | 88 |
    89 |
  • One step further in fork process
  • 90 |
  • Implemented way to log in when xdg-open is not available
  • 91 |
92 |
93 |
94 | 95 | 96 |
    97 |
  • Fixed crash, when a poll notification arrives
  • 98 |
  • Forked a project
  • 99 |
100 |
101 |
102 | 103 | 104 |
    105 |
  • Added Watchlist
  • 106 |
  • Added Redraft support
  • 107 |
  • Added Pinning support
  • 108 |
  • Added Simplified Chinese and German translations
  • 109 |
  • Added --hidden Start Flag
  • 110 |
  • Added Shortcuts and Back mouse button support
  • 111 |
  • Changed Notifications screen behavior
  • 112 |
  • Fixed minor bugs
  • 113 |
114 |
115 |
116 | 117 | 118 |
    119 |
  • Added Russian, French and Polish translations
  • 120 |
  • Added Direct timeline
  • 121 |
  • Added support for custom character limit
  • 122 |
  • Added support for streaming all timelines
  • 123 |
  • Added tooltips for image attachments
  • 124 |
  • Added remove action for attachments
  • 125 |
  • Changed behavior for mentioning users
  • 126 |
  • Changed behavior for missing image attachments
  • 127 |
  • Fixed minor bugs
  • 128 |
129 |
130 |
131 | 132 | 133 |
    134 |
  • Initial release
  • 135 |
136 |
137 |
138 |
139 | 140 | 141 | #F5F8FF 142 | #413F58 143 | 144 | 145 |
146 | 147 | -------------------------------------------------------------------------------- /data/com.github.cleac.olifant.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=Olifant 4 | Comment=Mastodon Client 5 | GenericName=Mastodon Client 6 | Exec=com.github.cleac.olifant 7 | Icon=com.github.cleac.olifant 8 | Terminal=false 9 | Categories=GNOME;GTK;Network; 10 | Keywords=toot;mastodon;social;network;post; 11 | X-GNOME-Gettext-Domain=com.github.cleac.olifant 12 | X-GNOME-UsesNotifications=true 13 | -------------------------------------------------------------------------------- /data/com.github.cleac.olifant.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | app.css 5 | light.css 6 | dark.css 7 | logo128.png 8 | empty_state.png 9 | 10 | 11 | -------------------------------------------------------------------------------- /data/com.github.cleac.olifant.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 0 6 | Current Account 7 | Do not edit or it shall set your house on fire 8 | 9 | 10 | true 11 | Display desktop notifications 12 | 13 | 14 | 15 | false 16 | Always monitor new notifications 17 | 18 | 19 | 20 | false 21 | Cache images to reduce network load 22 | 23 | 24 | 25 | 64 26 | Cache size 27 | Sets the maximum size of cached content 28 | 29 | 30 | true 31 | Real-time timelines 32 | Update timelines in real-time 33 | 34 | 35 | false 36 | Real-time public timelines 37 | Update local and federated timelines in real-time. May clog up memory on busy instances. 38 | 39 | 40 | false 41 | Sets application theme to dark 42 | 43 | 44 | 45 | 500 46 | Default character limit 47 | Change this if your instance supports more than 500 characters in posts 48 | 49 | 50 | '' 51 | Watched Users 52 | Comma separated list of usernames to notify you about 53 | 54 | 55 | '' 56 | Watched Hashtags 57 | Comma separated list of hashtags to notify you about 58 | 59 | 60 | 61 | -1 62 | 63 | 64 | -1 65 | 66 | 67 | -1 68 | 69 | 70 | -1 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /data/dark.css: -------------------------------------------------------------------------------- 1 | @define-color colorAccent #c92e34; 2 | @define-color colorPrimary #35393c; 3 | 4 | .header-counters{ 5 | background: rgba(0,0,0,.2); 6 | } 7 | 8 | .attachment{ 9 | background: rgba (255,255,255,.15); 10 | } 11 | 12 | .card{ 13 | background: rgba (255,255,255,.15); 14 | } 15 | -------------------------------------------------------------------------------- /data/empty_state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cleac/olifant/02ed9bf60091f46f5b8764dd22e374c63bb9859f/data/empty_state.png -------------------------------------------------------------------------------- /data/icons/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 @alexcleac 4 | Copyright (c) 2018 CallMeFib3r 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /data/light.css: -------------------------------------------------------------------------------- 1 | @define-color colorAccent #9aa7c8; 2 | @define-color colorPrimary #9aa7c8; 3 | 4 | .header-counters{ 5 | background: rgba(255,255,255,.4); 6 | } 7 | 8 | .attachment{ 9 | background: rgba (255,255,255,.8); 10 | } 11 | 12 | .card{ 13 | background: #fff; 14 | } 15 | -------------------------------------------------------------------------------- /data/logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cleac/olifant/02ed9bf60091f46f5b8764dd22e374c63bb9859f/data/logo128.png -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | icon_sizes = ['16', '24', '32', '48', '64', '128'] 2 | 3 | foreach i : icon_sizes 4 | install_data( 5 | join_paths('icons', i, meson.project_name() + '.svg'), 6 | install_dir: join_paths(get_option('datadir'), 'icons', 'hicolor', i + 'x' + i, 'apps') 7 | ) 8 | install_data( 9 | join_paths('icons', i, meson.project_name() + '.svg'), 10 | install_dir: join_paths(get_option('datadir'), 'icons', 'hicolor', i + 'x' + i + '@2', 'apps') 11 | ) 12 | endforeach 13 | 14 | install_data( 15 | meson.project_name() + '.gschema.xml', 16 | install_dir: join_paths(get_option('prefix'), get_option('datadir'), 'glib-2.0', 'schemas') 17 | ) 18 | 19 | i18n.merge_file( 20 | input: meson.project_name() + '.desktop.in', 21 | output: meson.project_name() + '.desktop', 22 | po_dir: join_paths(meson.source_root(), 'po'), 23 | type: 'desktop', 24 | install: true, 25 | install_dir: join_paths(get_option('datadir'), 'applications') 26 | ) 27 | 28 | i18n.merge_file( 29 | input: meson.project_name() + '.appdata.xml.in', 30 | output: meson.project_name() + '.appdata.xml', 31 | po_dir: join_paths(meson.source_root(), 'po'), 32 | install: true, 33 | install_dir: join_paths(get_option('datadir'), 'metainfo') 34 | ) 35 | -------------------------------------------------------------------------------- /data/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cleac/olifant/02ed9bf60091f46f5b8764dd22e374c63bb9859f/data/screenshot.png -------------------------------------------------------------------------------- /data/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cleac/olifant/02ed9bf60091f46f5b8764dd22e374c63bb9859f/data/screenshot2.png -------------------------------------------------------------------------------- /data/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cleac/olifant/02ed9bf60091f46f5b8764dd22e374c63bb9859f/data/screenshot3.png -------------------------------------------------------------------------------- /data/screenshot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cleac/olifant/02ed9bf60091f46f5b8764dd22e374c63bb9859f/data/screenshot4.png -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | olifant (0.2.1-beta7) bionic; urgency=medium 2 | 3 | * Fixed loading of accounts, whenever you are signed in into more than one at once 4 | * Changed the way HTML tags were dropped, now more posts will be able to be displayed non-empty 5 | * Implemented correct tags parsing of statuses that came from Pleroma 6 | * Updated visual icons of empty state and application itself (kudos to @hanaral) 7 | * Dropped authenticated queries at places, where they are not needed 8 | * Improved loading of images, that caused crashes sometimes 9 | 10 | olifant (0.2.1-beta4) bionic; urgency=medium 11 | 12 | * Stability improvements 13 | * Added ability to clear notifications 14 | * Fixed duplication of follow requests in notifications 15 | 16 | -- Alex Cleac Wed, 01 Jul 2020 21:51:30 +0200 17 | 18 | olifant (0.2.1-beta1) bionic; urgency=medium 19 | 20 | * Preparations for applying application to Elementary Store 21 | * There is a way to sign in even if there is no xdg-open 22 | * Fix getting characters limit of instance 23 | 24 | tootle (0.2.1-beta) bionic; urgency=medium 25 | 26 | * Fixed crash when there is a poll notification 27 | * Improved networking a bit 28 | * Fork established, including with changing icon slightly 29 | 30 | -- Alex Cleac Sat, 29 Feb 2020 10:00:41 +0200 31 | 32 | tootle (0.2.0) bionic; urgency=low 33 | 34 | * Initial release 35 | 36 | -- bleak_grey Tue, 27 Feb 2018 10:00:00 -0500 37 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 11 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: com.github.cleac.olifant 2 | Section: x11 3 | Priority: optional 4 | Maintainer: Alex Cleac 5 | Build-Depends: meson, 6 | valac (>= 0.26), 7 | debhelper (>= 11), 8 | libgranite-dev (>= 5.2.0), 9 | libgtk-3-dev (>= 3.22.0), 10 | libglib2.0-dev (>= 2.30.0), 11 | libgee-0.8-dev (>= 0.8.5), 12 | libsoup2.4-dev, 13 | libjson-glib-dev 14 | Standards-Version: 4.1.5 15 | 16 | Package: com.github.cleac.olifant 17 | Architecture: any 18 | Depends: ${misc:Depends}, ${shlibs:Depends} 19 | Description: Mastodon client 20 | Lightning fast Mastodon client. 21 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://dep.debian.net/deps/dep5 2 | Upstream-Name: olifant 3 | Source: https://github.com/cleac/olifant 4 | 5 | Files: * 6 | Copyright: 2018-2020 bleak_grey 2020 alex cleac 7 | License: GPL-3.0+ 8 | 9 | License: GPL-3.0+ 10 | This program is free software: you can redistribute it and/or modify 11 | it under the terms of the GNU General Public License as published by 12 | the Free Software Foundation, either version 3 of the License, or 13 | (at your option) any later version. 14 | . 15 | This package is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU General Public License for more details. 19 | . 20 | You should have received a copy of the GNU General Public License 21 | along with this program. If not, see . 22 | . 23 | On Debian systems, the complete text of the GNU General 24 | Public License version 3 can be found in "/usr/share/common-licenses/GPL-3". 25 | 26 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | export DH_VERBOSE=1 3 | export DEB_BUILD_MAINT_OPTIONS = hardening=+all 4 | 5 | %: 6 | dh $@ 7 | 8 | override_dh_auto_clean: 9 | rm -rf debian/build 10 | 11 | override_dh_auto_configure: 12 | mkdir -p debian/build 13 | cd debian/build && meson --prefix=/usr ../.. 14 | 15 | override_dh_auto_build: 16 | cd debian/build && ninja -v 17 | 18 | override_dh_auto_test: 19 | cd debian/build && ninja test 20 | 21 | override_dh_auto_install: 22 | cd debian/build && DESTDIR=${CURDIR}/debian/com.github.cleac.olifant ninja install 23 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('com.github.cleac.olifant', 'vala', 'c') 2 | 3 | gnome = import('gnome') 4 | i18n = import('i18n') 5 | 6 | add_global_arguments([ 7 | '-DGETTEXT_PACKAGE="@0@"'.format(meson.project_name()) 8 | ], 9 | language: 'c', 10 | ) 11 | 12 | asresources = gnome.compile_resources( 13 | 'as-resources', 'data/' + meson.project_name() + '.gresource.xml', 14 | source_dir: 'data', 15 | c_name: 'as' 16 | ) 17 | 18 | executable( 19 | meson.project_name(), 20 | asresources, 21 | 'src/Application.vala', 22 | 'src/Desktop.vala', 23 | 'src/Drawing.vala', 24 | 'src/Html.vala', 25 | 'src/Settings.vala', 26 | 'src/Accounts.vala', 27 | 'src/ImageCache.vala', 28 | 'src/Network.vala', 29 | 'src/Watchlist.vala', 30 | 'src/Notificator.vala', 31 | 'src/InstanceAccount.vala', 32 | 'src/API/Account.vala', 33 | 'src/API/Relationship.vala', 34 | 'src/API/Mention.vala', 35 | 'src/API/Tag.vala', 36 | 'src/API/Status.vala', 37 | 'src/API/StatusVisibility.vala', 38 | 'src/API/Notification.vala', 39 | 'src/API/NotificationType.vala', 40 | 'src/API/Attachment.vala', 41 | 'src/API/Instance.vala', 42 | 'src/API/VersionInfo.vala', 43 | 'src/Widgets/AlignedLabel.vala', 44 | 'src/Widgets/RichLabel.vala', 45 | 'src/Widgets/ImageToggleButton.vala', 46 | 'src/Widgets/AccountsButton.vala', 47 | 'src/Widgets/Status.vala', 48 | 'src/Widgets/Account.vala', 49 | 'src/Widgets/Notification.vala', 50 | 'src/Widgets/ImageAttachment.vala', 51 | 'src/Widgets/AttachmentGrid.vala', 52 | 'src/Dialogs/ISavedWindow.vala', 53 | 'src/Dialogs/MainWindow.vala', 54 | 'src/Dialogs/NewAccount.vala', 55 | 'src/Dialogs/Compose.vala', 56 | 'src/Dialogs/Preferences.vala', 57 | 'src/Dialogs/WatchlistEditor.vala', 58 | 'src/Views/Abstract.vala', 59 | 'src/Views/Timeline.vala', 60 | 'src/Views/Home.vala', 61 | 'src/Views/Local.vala', 62 | 'src/Views/Federated.vala', 63 | 'src/Views/Notifications.vala', 64 | 'src/Views/Direct.vala', 65 | 'src/Views/ExpandedStatus.vala', 66 | 'src/Views/Profile.vala', 67 | 'src/Views/Followers.vala', 68 | 'src/Views/Following.vala', 69 | 'src/Views/Favorites.vala', 70 | 'src/Views/Search.vala', 71 | 'src/Views/Hashtag.vala', 72 | dependencies: [ 73 | dependency('gtk+-3.0', version: '>=3.22.0'), 74 | dependency('glib-2.0', version: '>=2.30.0'), 75 | dependency('gee-0.8', version: '>=0.8.5'), 76 | dependency('granite', version: '>=5.2.0'), 77 | dependency('json-glib-1.0'), 78 | dependency('libsoup-2.4') 79 | ], 80 | install: true 81 | ) 82 | 83 | subdir('data') 84 | subdir('po') 85 | 86 | meson.add_install_script('meson/post_install.py') 87 | -------------------------------------------------------------------------------- /meson/post_install.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import subprocess 5 | 6 | schemadir = os.path.join(os.environ['MESON_INSTALL_PREFIX'], 'share', 'glib-2.0', 'schemas') 7 | iconsdir = os.path.join(os.environ['MESON_INSTALL_PREFIX'], 'share', 'icons', 'hicolor') 8 | 9 | if not os.environ.get('DESTDIR'): 10 | print('Compiling gsettings schemas...') 11 | subprocess.call(['glib-compile-schemas', schemadir]) 12 | 13 | print('Updating icon cache...') 14 | if not os.path.exists(iconsdir): 15 | os.makedirs(iconsdir) 16 | subprocess.call(['gtk-update-icon-cache', '-qtf', iconsdir]) -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | fr_FR 2 | ru 3 | pl 4 | de_DE 5 | zh_CN 6 | -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | data/com.github.cleac.olifant.desktop.in 2 | data/com.github.cleac.olifant.appdata.xml.in 3 | src/Drawing.vala 4 | src/Dialogs/Preferences.vala 5 | src/Dialogs/MainWindow.vala 6 | src/Dialogs/WatchlistEditor.vala 7 | src/Dialogs/NewAccount.vala 8 | src/Dialogs/ISavedWindow.vala 9 | src/Dialogs/Compose.vala 10 | src/Desktop.vala 11 | src/Application.vala 12 | src/Html.vala 13 | src/Widgets/ImageAttachment.vala 14 | src/Widgets/Status.vala 15 | src/Widgets/ImageToggleButton.vala 16 | src/Widgets/AlignedLabel.vala 17 | src/Widgets/RichLabel.vala 18 | src/Widgets/Notification.vala 19 | src/Widgets/AccountsButton.vala 20 | src/Widgets/Account.vala 21 | src/Widgets/AttachmentGrid.vala 22 | src/Notificator.vala 23 | src/Settings.vala 24 | src/API/Attachment.vala 25 | src/API/Status.vala 26 | src/API/Mention.vala 27 | src/API/StatusVisibility.vala 28 | src/API/Notification.vala 29 | src/API/Tag.vala 30 | src/API/NotificationType.vala 31 | src/API/Account.vala 32 | src/API/Relationship.vala 33 | src/Accounts.vala 34 | src/Network.vala 35 | src/Watchlist.vala 36 | src/Views/Following.vala 37 | src/Views/Local.vala 38 | src/Views/Followers.vala 39 | src/Views/Abstract.vala 40 | src/Views/Profile.vala 41 | src/Views/Home.vala 42 | src/Views/Notifications.vala 43 | src/Views/Search.vala 44 | src/Views/Hashtag.vala 45 | src/Views/Timeline.vala 46 | src/Views/Favorites.vala 47 | src/Views/ExpandedStatus.vala 48 | src/Views/Federated.vala 49 | src/Views/Direct.vala 50 | src/InstanceAccount.vala 51 | src/ImageCache.vala 52 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | #i18n.gettext(meson.project_name(), 2 | # args: ['--directory='+meson.source_root(), '--from-code=UTF-8'], 3 | # install: false, 4 | #) 5 | i18n.gettext(meson.project_name(), preset: 'glib') -------------------------------------------------------------------------------- /src/API/Account.vala: -------------------------------------------------------------------------------- 1 | public class Olifant.API.Account { 2 | 3 | public abstract signal void updated (); 4 | 5 | public string id; 6 | public string username; 7 | public string acct; 8 | public string display_name; 9 | public string note; 10 | public string header; 11 | public string avatar; 12 | public string url; 13 | public string created_at; 14 | public int64 followers_count; 15 | public int64 following_count; 16 | public int64 statuses_count; 17 | 18 | public Relationship? rs; 19 | 20 | public Account (string _id){ 21 | id = _id; 22 | } 23 | 24 | public static Account parse(Json.Object obj) { 25 | var id = obj.get_string_member ("id"); 26 | var account = new Account (id); 27 | 28 | account.username = obj.get_string_member ("username"); 29 | account.acct = obj.get_string_member ("acct"); 30 | account.display_name = obj.get_string_member ("display_name"); 31 | if (account.display_name == "") 32 | account.display_name = account.username; 33 | account.note = obj.get_string_member ("note"); 34 | account.avatar = obj.get_string_member ("avatar"); 35 | account.header = obj.get_string_member ("header"); 36 | account.url = obj.get_string_member ("url"); 37 | account.created_at = obj.get_string_member ("created_at"); 38 | 39 | account.followers_count = obj.get_int_member ("followers_count"); 40 | account.following_count = obj.get_int_member ("following_count"); 41 | account.statuses_count = obj.get_int_member ("statuses_count"); 42 | 43 | if (obj.has_member ("fields")) { 44 | obj.get_array_member ("fields").foreach_element ((array, i, node) => { 45 | var field_obj = node.get_object (); 46 | var field_name = field_obj.get_string_member ("name"); 47 | var field_val = field_obj.get_string_member ("value"); 48 | account.note += "\n"; 49 | account.note += field_name + ": "; 50 | account.note += field_val; 51 | }); 52 | } 53 | 54 | return account; 55 | } 56 | 57 | public Json.Node? serialize () { 58 | var builder = new Json.Builder (); 59 | builder.begin_object (); 60 | builder.set_member_name ("id"); 61 | builder.add_string_value (id.to_string ()); 62 | builder.set_member_name ("created_at"); 63 | builder.add_string_value (created_at); 64 | builder.set_member_name ("following_count"); 65 | builder.add_int_value (following_count); 66 | builder.set_member_name ("followers_count"); 67 | builder.add_int_value (followers_count); 68 | builder.set_member_name ("statuses_count"); 69 | builder.add_int_value (statuses_count); 70 | builder.set_member_name ("display_name"); 71 | builder.add_string_value (display_name); 72 | builder.set_member_name ("username"); 73 | builder.add_string_value (username); 74 | builder.set_member_name ("acct"); 75 | builder.add_string_value (acct); 76 | builder.set_member_name ("note"); 77 | builder.add_string_value (note); 78 | builder.set_member_name ("header"); 79 | builder.add_string_value (header); 80 | builder.set_member_name ("avatar"); 81 | builder.add_string_value (avatar); 82 | builder.set_member_name ("url"); 83 | builder.add_string_value (url); 84 | 85 | builder.end_object (); 86 | return builder.get_root (); 87 | } 88 | 89 | public bool is_self (){ 90 | return id == accounts.current.id; 91 | } 92 | 93 | public Soup.Message get_relationship (){ 94 | var url = "%s/api/v1/accounts/relationships?id=%s".printf (accounts.formal.instance, id); 95 | var msg = new Soup.Message("GET", url); 96 | msg.priority = Soup.MessagePriority.HIGH; 97 | network.queue ( 98 | msg, 99 | (sess, mess) => { 100 | var root = network.parse_array (mess).get_object_element (0); 101 | rs = Relationship.parse (root); 102 | updated (); 103 | }, 104 | (status, status_message) => { 105 | app.error (_("Error"), status_message); 106 | warning ("Account::set_following Couldn't get account relationship with status %i".printf(status)); 107 | } 108 | ); 109 | return msg; 110 | } 111 | 112 | public Soup.Message set_following (bool follow = true){ 113 | var action = follow ? "follow" : "unfollow"; 114 | var url = "%s/api/v1/accounts/%s/%s".printf (accounts.formal.instance, id, action); 115 | var msg = new Soup.Message("POST", url); 116 | msg.priority = Soup.MessagePriority.HIGH; 117 | network.queue ( 118 | msg, 119 | (sess, mess) => { 120 | var root = network.parse (mess); 121 | rs = Relationship.parse (root); 122 | updated (); 123 | }, 124 | (status, status_message) => { 125 | app.error (_("Error"), status_message); 126 | warning ("Account::set_following Could not set following status with status %i".printf(status)); 127 | } 128 | ); 129 | return msg; 130 | } 131 | 132 | public Soup.Message set_muted (bool mute = true){ 133 | var action = mute ? "mute" : "unmute"; 134 | var url = "%s/api/v1/accounts/%s/%s".printf (accounts.formal.instance, id, action); 135 | var msg = new Soup.Message("POST", url); 136 | msg.priority = Soup.MessagePriority.HIGH; 137 | network.queue ( 138 | msg, 139 | (sess, mess) => { 140 | var root = network.parse (mess); 141 | rs = Relationship.parse (root); 142 | updated (); 143 | }, 144 | (status, status_message) => { 145 | app.error (_("Error"), status_message); 146 | warning ("Account::set_muted Could not set muting status with status %i".printf(status)); 147 | } 148 | ); 149 | return msg; 150 | } 151 | 152 | public Soup.Message set_blocked (bool block = true){ 153 | var action = block ? "block" : "unblock"; 154 | var url = "%s/api/v1/accounts/%s/%s".printf (accounts.formal.instance, id, action); 155 | var msg = new Soup.Message("POST", url); 156 | msg.priority = Soup.MessagePriority.HIGH; 157 | network.queue ( 158 | msg, 159 | (sess, mess) => { 160 | var root = network.parse (mess); 161 | rs = Relationship.parse (root); 162 | updated (); 163 | }, 164 | (status, status_message) => { 165 | app.error (_("Error"), status_message); 166 | warning ("Account::set_blocked Could not set blocking status with status %i".printf(status)); 167 | } 168 | ); 169 | return msg; 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /src/API/Attachment.vala: -------------------------------------------------------------------------------- 1 | public class Olifant.API.Attachment { 2 | 3 | public string id; 4 | public string type; 5 | public string url; 6 | public string preview_url; 7 | public string? description; 8 | 9 | public Attachment (string _id) { 10 | id = _id; 11 | } 12 | 13 | public static Attachment parse (Json.Object obj) { 14 | var id = obj.get_string_member ("id"); 15 | var attachment = new Attachment (id); 16 | 17 | attachment.type = obj.get_string_member ("type"); 18 | attachment.preview_url = obj.get_string_member ("preview_url"); 19 | attachment.url = obj.get_string_member ("url"); 20 | 21 | if (obj.has_member ("description")) 22 | attachment.description = obj.get_string_member ("description"); 23 | 24 | return attachment; 25 | } 26 | 27 | public Json.Node? serialize () { 28 | var builder = new Json.Builder (); 29 | builder.begin_object (); 30 | builder.set_member_name ("id"); 31 | builder.add_string_value (id.to_string ()); 32 | builder.set_member_name ("type"); 33 | builder.add_string_value (type); 34 | builder.set_member_name ("url"); 35 | builder.add_string_value (url); 36 | builder.set_member_name ("preview_url"); 37 | builder.add_string_value (preview_url); 38 | 39 | if (description != null) { 40 | builder.set_member_name ("description"); 41 | builder.add_string_value (description); 42 | } 43 | 44 | builder.end_object (); 45 | return builder.get_root (); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/API/Instance.vala: -------------------------------------------------------------------------------- 1 | using Olifant.API; 2 | 3 | public class Olifant.API.Instance { 4 | public string uri; 5 | public string email; 6 | public VersionInfo version; 7 | public string[] languages; 8 | public string title; 9 | 10 | public Instance (owned string _uri){ 11 | uri = _uri; 12 | } 13 | 14 | public bool is_mastodon_v3 () { 15 | return this.version.major >= 3; 16 | } 17 | 18 | public static Instance parse(Json.Object obj) { 19 | var uri= obj.get_string_member ("uri"); 20 | var instance = new Instance (uri); 21 | 22 | instance.title = obj.get_string_member ("title"); 23 | instance.version = VersionInfo.parse (obj.get_string_member ("version")); 24 | instance.email = obj.get_string_member ("email"); 25 | 26 | return instance; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/API/Mention.vala: -------------------------------------------------------------------------------- 1 | public class Olifant.API.Mention : GLib.Object { 2 | 3 | public string id; 4 | public string username; 5 | public string acct; 6 | public string url; 7 | 8 | public Mention (string _id){ 9 | id = _id; 10 | } 11 | 12 | public Mention.from_account (Account account){ 13 | id = account.id; 14 | username = account.username; 15 | acct = account.acct; 16 | url = account.url; 17 | } 18 | 19 | public static Mention parse (Json.Object obj){ 20 | var id = obj.get_string_member ("id"); 21 | var mention = new Mention (id); 22 | 23 | mention.username = obj.get_string_member ("username"); 24 | mention.acct = obj.get_string_member ("acct"); 25 | mention.url = obj.get_string_member ("url"); 26 | 27 | return mention; 28 | } 29 | 30 | public Json.Node? serialize () { 31 | var builder = new Json.Builder (); 32 | builder.begin_object (); 33 | builder.set_member_name ("id"); 34 | builder.add_string_value (id.to_string ()); 35 | builder.set_member_name ("username"); 36 | builder.add_string_value (username); 37 | builder.set_member_name ("acct"); 38 | builder.add_string_value (acct); 39 | builder.set_member_name ("url"); 40 | builder.add_string_value (url); 41 | builder.end_object (); 42 | return builder.get_root (); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/API/Notification.vala: -------------------------------------------------------------------------------- 1 | public class Olifant.API.Notification { 2 | 3 | public string id; 4 | public NotificationType type; 5 | public string created_at; 6 | 7 | public Status? status; 8 | public Account? account; 9 | 10 | public Notification (string _id) { 11 | id = _id; 12 | } 13 | 14 | public static Notification parse (Json.Object obj) { 15 | var id = obj.get_string_member ("id"); 16 | var notification = new Notification (id); 17 | 18 | notification.type = NotificationType.from_string (obj.get_string_member ("type")); 19 | notification.created_at = obj.get_string_member ("created_at"); 20 | 21 | if (obj.has_member ("status")) 22 | notification.status = Status.parse (obj.get_object_member ("status")); 23 | if (obj.has_member ("account")) 24 | notification.account = Account.parse (obj.get_object_member ("account")); 25 | 26 | return notification; 27 | } 28 | 29 | public Json.Node? serialize () { 30 | var builder = new Json.Builder (); 31 | builder.begin_object (); 32 | builder.set_member_name ("id"); 33 | builder.add_string_value (id.to_string ()); 34 | builder.set_member_name ("type"); 35 | builder.add_string_value (type.to_string ()); 36 | builder.set_member_name ("created_at"); 37 | builder.add_string_value (created_at); 38 | 39 | if (status != null) { 40 | builder.set_member_name ("status"); 41 | builder.add_value (status.serialize ()); 42 | } 43 | if (account != null) { 44 | builder.set_member_name ("account"); 45 | builder.add_value (account.serialize ()); 46 | } 47 | 48 | builder.end_object (); 49 | return builder.get_root (); 50 | } 51 | 52 | public static Notification parse_follow_request (Json.Object obj) { 53 | var notification = new Notification (""); 54 | var account = Account.parse (obj); 55 | 56 | notification.type = NotificationType.FOLLOW_REQUEST; 57 | notification.account = account; 58 | 59 | return notification; 60 | } 61 | 62 | public Soup.Message? dismiss () { 63 | if (type == NotificationType.WATCHLIST) { 64 | if (accounts.formal.cached_notifications.remove (this)) 65 | accounts.save (); 66 | return null; 67 | } 68 | 69 | if (type == NotificationType.FOLLOW_REQUEST) 70 | return reject_follow_request (); 71 | 72 | var url = "%s/api/v1/notifications/dismiss?id=%s".printf (accounts.formal.instance, id); 73 | var msg = new Soup.Message ("POST", url); 74 | network.inject (msg, Network.INJECT_TOKEN); 75 | network.queue (msg); 76 | return msg; 77 | } 78 | 79 | public Soup.Message accept_follow_request () { 80 | var url = "%s/api/v1/follow_requests/%s/authorize".printf (accounts.formal.instance, account.id); 81 | var msg = new Soup.Message ("POST", url); 82 | network.inject (msg, Network.INJECT_TOKEN); 83 | network.queue (msg); 84 | return msg; 85 | } 86 | 87 | public Soup.Message reject_follow_request () { 88 | var url = "%s/api/v1/follow_requests/%s/reject".printf (accounts.formal.instance, account.id); 89 | var msg = new Soup.Message ("POST", url); 90 | network.inject (msg, Network.INJECT_TOKEN); 91 | network.queue (msg); 92 | return msg; 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/API/NotificationType.vala: -------------------------------------------------------------------------------- 1 | public enum Olifant.API.NotificationType { 2 | MENTION, 3 | REBLOG, 4 | REBLOG_REMOTE_USER, // Internal 5 | FAVORITE, 6 | FOLLOW, 7 | FOLLOW_REQUEST, // Internal 8 | WATCHLIST, // Internal 9 | UNKNOWN; // Fallback 10 | 11 | public string to_string() { 12 | switch (this) { 13 | case MENTION: 14 | return "mention"; 15 | case REBLOG: 16 | return "reblog"; 17 | case REBLOG_REMOTE_USER: 18 | return "reblog_remote"; 19 | case FAVORITE: 20 | return "favourite"; 21 | case FOLLOW: 22 | return "follow"; 23 | case FOLLOW_REQUEST: 24 | return "follow_request"; 25 | case WATCHLIST: 26 | return "watchlist"; 27 | case UNKNOWN: 28 | default: 29 | return "unknown"; 30 | } 31 | } 32 | 33 | public static NotificationType from_string (string str) { 34 | switch (str) { 35 | case "mention": 36 | return MENTION; 37 | case "reblog": 38 | return REBLOG; 39 | case "reblog_remote": 40 | return REBLOG_REMOTE_USER; 41 | case "favourite": 42 | return FAVORITE; 43 | case "follow": 44 | return FOLLOW; 45 | case "follow_request": 46 | return FOLLOW_REQUEST; 47 | case "watchlist": 48 | return WATCHLIST; 49 | case "unknown": 50 | default: 51 | return UNKNOWN; 52 | } 53 | } 54 | 55 | public string get_desc (Account? account) { 56 | switch (this) { 57 | case MENTION: 58 | return _("%s mentioned you").printf (account.url, account.display_name); 59 | case REBLOG: 60 | return _("%s boosted your toot").printf (account.url, account.display_name); 61 | case REBLOG_REMOTE_USER: 62 | return _("%s boosted").printf (account.url, account.display_name); 63 | case FAVORITE: 64 | return _("%s favorited your toot").printf (account.url, account.display_name); 65 | case FOLLOW: 66 | return _("%s now follows you").printf (account.url, account.display_name); 67 | case FOLLOW_REQUEST: 68 | return _("%s wants to follow you").printf (account.url, account.display_name); 69 | case WATCHLIST: 70 | return _("%s posted a toot").printf (account.url, account.display_name); 71 | case UNKNOWN: 72 | return _("Unrecognized notification from %s").printf (account.url, account.display_name); 73 | default: 74 | assert_not_reached(); 75 | } 76 | } 77 | 78 | public string get_icon () { 79 | switch (this) { 80 | case MENTION: 81 | case WATCHLIST: 82 | return "user-available-symbolic"; 83 | case REBLOG: 84 | return "media-playlist-repeat-symbolic"; 85 | case FAVORITE: 86 | return "emblem-favorite-symbolic"; 87 | case FOLLOW: 88 | case FOLLOW_REQUEST: 89 | return "contact-new-symbolic"; 90 | case UNKNOWN: 91 | default: 92 | return "dialog-warning"; 93 | } 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /src/API/Relationship.vala: -------------------------------------------------------------------------------- 1 | using GLib; 2 | 3 | public class Olifant.API.Relationship : Object { 4 | 5 | public string id; 6 | public bool following; 7 | public bool followed_by; 8 | public bool blocking; 9 | public bool muting; 10 | public bool muting_notifications; 11 | public bool requested; 12 | public bool domain_blocking; 13 | 14 | public Relationship (string _id) { 15 | id = _id; 16 | } 17 | 18 | public static Relationship parse (Json.Object obj) { 19 | var id = obj.get_string_member ("id"); 20 | var relationship = new Relationship (id); 21 | relationship.following = obj.get_boolean_member ("following"); 22 | relationship.followed_by = obj.get_boolean_member ("followed_by"); 23 | relationship.blocking = obj.get_boolean_member ("blocking"); 24 | relationship.muting = obj.get_boolean_member ("muting"); 25 | relationship.muting_notifications = obj.get_boolean_member ("muting_notifications"); 26 | relationship.requested = obj.get_boolean_member ("requested"); 27 | relationship.domain_blocking = obj.get_boolean_member ("domain_blocking"); 28 | return relationship; 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/API/Status.vala: -------------------------------------------------------------------------------- 1 | public class Olifant.API.Status { 2 | 3 | public signal void updated (); 4 | 5 | public API.Account account; 6 | public string id; 7 | public string uri; 8 | public string url; 9 | public string? spoiler_text; 10 | public string content; 11 | public int64 replies_count; 12 | public int64 reblogs_count; 13 | public int64 favourites_count; 14 | public string created_at; 15 | public bool reblogged = false; 16 | public bool favorited = false; 17 | public bool sensitive = false; 18 | public bool muted = false; 19 | public bool pinned = false; 20 | public API.StatusVisibility visibility; 21 | public API.Status? reblog; 22 | public API.Mention[]? mentions; 23 | public API.Attachment[]? attachments; 24 | 25 | public Status (string _id) { 26 | id = _id; 27 | } 28 | 29 | public Status get_formal () { 30 | return reblog != null ? reblog : this; 31 | } 32 | 33 | public static Status parse (Json.Object obj) { 34 | var id = obj.get_string_member ("id"); 35 | var status = new Status (id); 36 | 37 | status.account = Account.parse (obj.get_object_member ("account")); 38 | status.uri = obj.get_string_member ("uri"); 39 | status.created_at = obj.get_string_member ("created_at"); 40 | status.replies_count = obj.get_int_member ("replies_count"); 41 | status.reblogs_count = obj.get_int_member ("reblogs_count"); 42 | status.favourites_count = obj.get_int_member ("favourites_count"); 43 | status.content = Html.simplify ( obj.get_string_member ("content")); 44 | status.sensitive = obj.get_boolean_member ("sensitive"); 45 | status.visibility = StatusVisibility.from_string (obj.get_string_member ("visibility")); 46 | 47 | if (obj.has_member ("url")) 48 | status.url = obj.get_string_member ("url"); 49 | else 50 | status.url = obj.get_string_member ("uri").replace ("/activity", ""); 51 | 52 | var spoiler = obj.get_string_member ("spoiler_text"); 53 | if (spoiler != "") 54 | status.spoiler_text = Html.simplify (spoiler); 55 | 56 | if (obj.has_member ("reblogged")) 57 | status.reblogged = obj.get_boolean_member ("reblogged"); 58 | if (obj.has_member ("favourited")) 59 | status.favorited = obj.get_boolean_member ("favourited"); 60 | if (obj.has_member ("muted")) 61 | status.muted = obj.get_boolean_member ("muted"); 62 | if (obj.has_member ("pinned")) 63 | status.pinned = obj.get_boolean_member ("pinned"); 64 | 65 | if (obj.has_member ("reblog") && obj.get_null_member("reblog") != true) 66 | status.reblog = Status.parse (obj.get_object_member ("reblog")); 67 | 68 | API.Mention[]? _mentions = {}; 69 | obj.get_array_member ("mentions").foreach_element ((array, i, node) => { 70 | var object = node.get_object (); 71 | if (object != null) 72 | _mentions += API.Mention.parse (object); 73 | }); 74 | if (_mentions.length > 0) 75 | status.mentions = _mentions; 76 | 77 | API.Attachment[]? _attachments = {}; 78 | obj.get_array_member ("media_attachments").foreach_element ((array, i, node) => { 79 | var object = node.get_object (); 80 | if (object != null) 81 | _attachments += API.Attachment.parse (object); 82 | }); 83 | if (_attachments.length > 0) 84 | status.attachments = _attachments; 85 | 86 | return status; 87 | } 88 | 89 | public Json.Node? serialize () { 90 | var builder = new Json.Builder (); 91 | builder.begin_object (); 92 | builder.set_member_name ("id"); 93 | builder.add_string_value (id.to_string ()); 94 | builder.set_member_name ("uri"); 95 | builder.add_string_value (uri); 96 | builder.set_member_name ("url"); 97 | builder.add_string_value (url); 98 | builder.set_member_name ("content"); 99 | builder.add_string_value (content); 100 | builder.set_member_name ("created_at"); 101 | builder.add_string_value (created_at); 102 | builder.set_member_name ("visibility"); 103 | builder.add_string_value (visibility.to_string ()); 104 | builder.set_member_name ("sensitive"); 105 | builder.add_boolean_value (sensitive); 106 | builder.set_member_name ("sensitive"); 107 | builder.add_boolean_value (sensitive); 108 | builder.set_member_name ("replies_count"); 109 | builder.add_int_value (replies_count); 110 | builder.set_member_name ("favourites_count"); 111 | builder.add_int_value (favourites_count); 112 | builder.set_member_name ("reblogs_count"); 113 | builder.add_int_value (reblogs_count); 114 | builder.set_member_name ("account"); 115 | builder.add_value (account.serialize ()); 116 | 117 | if (spoiler_text != null) { 118 | builder.set_member_name ("spoiler_text"); 119 | builder.add_string_value (spoiler_text); 120 | } 121 | if (reblog != null) { 122 | builder.set_member_name ("reblog"); 123 | builder.add_value (reblog.serialize ()); 124 | } 125 | if (attachments != null) { 126 | builder.set_member_name ("media_attachments"); 127 | builder.begin_array (); 128 | foreach (API.Attachment attachment in attachments) 129 | builder.add_value (attachment.serialize ()); 130 | builder.end_array (); 131 | } 132 | if (mentions != null) { 133 | builder.set_member_name ("mentions"); 134 | builder.begin_array (); 135 | foreach (API.Mention mention in mentions) 136 | builder.add_value (mention.serialize ()); 137 | builder.end_array (); 138 | } 139 | 140 | builder.end_object (); 141 | return builder.get_root (); 142 | } 143 | 144 | public bool is_owned (){ 145 | return get_formal ().account.id == accounts.current.id; 146 | } 147 | 148 | public bool has_spoiler () { 149 | return get_formal ().spoiler_text != null || get_formal ().sensitive; 150 | } 151 | 152 | public string get_reply_mentions () { 153 | var result = ""; 154 | if (account.acct != accounts.current.acct) 155 | result = "@%s ".printf (account.acct); 156 | 157 | if (mentions != null) { 158 | foreach (var mention in mentions) { 159 | var equals_current = mention.acct == accounts.current.acct; 160 | var already_mentioned = mention.acct in result; 161 | 162 | if (!equals_current && ! already_mentioned) 163 | result += "@%s ".printf (mention.acct); 164 | } 165 | } 166 | 167 | return result; 168 | } 169 | 170 | public void set_reblogged (bool rebl, Network.ErrorCallback? err = network.on_error) { 171 | var action = rebl ? "reblog" : "unreblog"; 172 | var msg = new Soup.Message ("POST", "%s/api/v1/statuses/%s/%s".printf (accounts.formal.instance, id, action)); 173 | msg.priority = Soup.MessagePriority.HIGH; 174 | network.inject (msg, Network.INJECT_TOKEN); 175 | network.queue (msg, (sess, message) => { 176 | reblogged = rebl; 177 | updated (); 178 | }, (status, reason) => { 179 | err (status, reason); 180 | }); 181 | } 182 | 183 | public void set_favorited (bool fav, Network.ErrorCallback? err = network.on_error) { 184 | var action = fav ? "favourite" : "unfavourite"; 185 | var msg = new Soup.Message ("POST", "%s/api/v1/statuses/%s/%s".printf (accounts.formal.instance, id, action)); 186 | msg.priority = Soup.MessagePriority.HIGH; 187 | network.inject (msg, Network.INJECT_TOKEN); 188 | network.queue (msg, (sess, message) => { 189 | favorited = fav; 190 | updated (); 191 | }, (status, reason) => { 192 | err (status, reason); 193 | }); 194 | } 195 | 196 | public void set_muted (bool mute, Network.ErrorCallback? err = network.on_error) { 197 | var action = mute ? "mute" : "unmute"; 198 | var msg = new Soup.Message ("POST", "%s/api/v1/statuses/%s/%s".printf (accounts.formal.instance, id, action)); 199 | msg.priority = Soup.MessagePriority.HIGH; 200 | network.inject (msg, Network.INJECT_TOKEN); 201 | network.queue (msg, (sess, message) => { 202 | muted = mute; 203 | updated (); 204 | }, (status, reason) => { 205 | err (status, reason); 206 | }); 207 | } 208 | 209 | public void set_pinned (bool pin, Network.ErrorCallback? err = network.on_error) { 210 | var action = pin ? "pin" : "unpin"; 211 | var msg = new Soup.Message ("POST", "%s/api/v1/statuses/%s/%s".printf (accounts.formal.instance, id, action)); 212 | msg.priority = Soup.MessagePriority.HIGH; 213 | network.inject (msg, Network.INJECT_TOKEN); 214 | network.queue (msg, (sess, message) => { 215 | pinned = pin; 216 | updated (); 217 | }, (status, reason) => { 218 | err (status, reason); 219 | }); 220 | } 221 | 222 | public void poof (Soup.SessionCallback? cb = null, Network.ErrorCallback? err = network.on_error) { 223 | var msg = new Soup.Message ("DELETE", "%s/api/v1/statuses/%s".printf (accounts.formal.instance, id)); 224 | msg.priority = Soup.MessagePriority.HIGH; 225 | network.inject (msg, Network.INJECT_TOKEN); 226 | network.queue (msg, (sess, message) => { 227 | network.status_removed (id); 228 | if (cb != null) 229 | cb (sess, message); 230 | }, (status, reason) => { 231 | err (status, reason); 232 | }); 233 | } 234 | 235 | } 236 | -------------------------------------------------------------------------------- /src/API/StatusVisibility.vala: -------------------------------------------------------------------------------- 1 | public enum Olifant.API.StatusVisibility { 2 | PUBLIC, 3 | UNLISTED, 4 | PRIVATE, 5 | DIRECT; 6 | 7 | public string to_string () { 8 | switch (this) { 9 | case PUBLIC: 10 | return "public"; 11 | case UNLISTED: 12 | return "unlisted"; 13 | case PRIVATE: 14 | return "private"; 15 | case DIRECT: 16 | return "direct"; 17 | default: 18 | assert_not_reached(); 19 | } 20 | } 21 | 22 | public static StatusVisibility from_string (string str) { 23 | switch (str) { 24 | case "public": 25 | return StatusVisibility.PUBLIC; 26 | case "unlisted": 27 | return StatusVisibility.UNLISTED; 28 | case "private": 29 | return StatusVisibility.PRIVATE; 30 | case "direct": 31 | return StatusVisibility.DIRECT; 32 | default: 33 | assert_not_reached(); 34 | } 35 | } 36 | 37 | public string get_desc () { 38 | switch (this) { 39 | case PUBLIC: 40 | return _("Post to public timelines"); 41 | case UNLISTED: 42 | return _("Don\'t post to public timelines"); 43 | case PRIVATE: 44 | return _("Post to followers only"); 45 | case DIRECT: 46 | return _("Post to mentioned users only"); 47 | default: 48 | assert_not_reached(); 49 | } 50 | } 51 | 52 | public string get_icon () { 53 | switch (this) { 54 | case PUBLIC: 55 | return "network-workgroup-symbolic"; 56 | case UNLISTED: 57 | return "view-private-symbolic"; 58 | case PRIVATE: 59 | return "security-medium-symbolic"; 60 | case DIRECT: 61 | return "user-available-symbolic"; 62 | default: 63 | assert_not_reached(); 64 | } 65 | } 66 | 67 | public static StatusVisibility[] get_all () { 68 | return {StatusVisibility.PUBLIC, StatusVisibility.UNLISTED, StatusVisibility.PRIVATE, StatusVisibility.DIRECT}; 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/API/Tag.vala: -------------------------------------------------------------------------------- 1 | public class Olifant.API.Tag{ 2 | 3 | public string name; 4 | public string url; 5 | 6 | public Tag (string _name, string _url) { 7 | name = _name; 8 | url = _url; 9 | } 10 | 11 | public static Tag parse (Json.Object obj) { 12 | var name = obj.get_string_member ("name"); 13 | var url = obj.get_string_member ("url"); 14 | return new Tag (name, url); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/API/VersionInfo.vala: -------------------------------------------------------------------------------- 1 | public class Olifant.API.VersionInfo { 2 | public uint major; 3 | public uint minor; 4 | public uint patch; 5 | 6 | public VersionInfo(uint _major = 0, uint _minor = 0, uint _patch = 0) { 7 | major = _major; 8 | minor = _minor; 9 | patch = _patch; 10 | } 11 | 12 | public static VersionInfo parse(string ver) { 13 | var inform = new VersionInfo (); 14 | info ("Parsing version: %s".printf (ver)); 15 | string[] parts = ver.split("."); 16 | 17 | if (parts[0] != null) 18 | inform.major = parts[0].to_int(); 19 | 20 | if (parts[1] != null) 21 | inform.minor = parts[1].to_int(); 22 | 23 | if (parts[2] != null) 24 | inform.patch = parts[2].to_int(); 25 | 26 | return inform; 27 | } 28 | 29 | public string show () { 30 | return "VersionInfo(major=%u, minor=%u, patch=%u)".printf (major, minor, patch); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Accounts.vala: -------------------------------------------------------------------------------- 1 | using GLib; 2 | 3 | public class Olifant.Accounts : Object { 4 | 5 | private string dir_path; 6 | private string file_path; 7 | 8 | public signal void switched (API.Account? account); 9 | public signal void updated (GenericArray accounts); 10 | 11 | public GenericArray saved_accounts = new GenericArray (); 12 | public GenericArray instance_data = new GenericArray (); 13 | public InstanceAccount? formal {get; private set;} 14 | public API.Account? current {get; private set;} 15 | public API.Instance? currentInstance { 16 | get { return instance_data.@get (settings.current_account); } 17 | } 18 | 19 | public Accounts () { 20 | dir_path = "%s/%s".printf (GLib.Environment.get_user_config_dir (), app.application_id); 21 | file_path = "%s/%s".printf (dir_path, "accounts.json"); 22 | } 23 | 24 | public void switch_account (int id) { 25 | info ("Switching to #%i", id); 26 | settings.current_account = id; 27 | formal = saved_accounts.@get (id); 28 | 29 | var msg = new Soup.Message ("GET", "%s/api/v1/accounts/verify_credentials".printf (accounts.formal.instance)); 30 | network.inject (msg, Network.INJECT_TOKEN); 31 | network.queue (msg, (sess, mess) => { 32 | var root = network.parse (mess); 33 | current = API.Account.parse (root); 34 | switched (current); 35 | updated (saved_accounts); 36 | }, 37 | network.on_show_error); 38 | } 39 | 40 | public void add (InstanceAccount account) { 41 | info ("Adding account for %s at %s", account.username, account.instance); 42 | saved_accounts.add (account); 43 | save (); 44 | load_instances_info (saved_accounts); 45 | updated (saved_accounts); 46 | switch_account (saved_accounts.length - 1); 47 | account.start_notificator (); 48 | } 49 | 50 | public void remove (int i) { 51 | var account = saved_accounts.@get (i); 52 | account.close_notificator (); 53 | 54 | saved_accounts.remove_index (i); 55 | if (saved_accounts.length < 1) 56 | switched (null); 57 | else { 58 | var id = settings.current_account - 1; 59 | if (id > saved_accounts.length - 1) 60 | id = saved_accounts.length - 1; 61 | else if (id < saved_accounts.length - 1) 62 | id = 0; 63 | switch_account (id); 64 | } 65 | save (); 66 | load_instances_info (saved_accounts); 67 | updated (saved_accounts); 68 | 69 | if (is_empty ()) { 70 | window.destroy (); 71 | Dialogs.NewAccount.open (); 72 | } 73 | } 74 | 75 | public bool is_empty () { 76 | return saved_accounts.length == 0; 77 | } 78 | 79 | public void init () { 80 | save (false); 81 | load (); 82 | 83 | if (saved_accounts.length < 1) 84 | Dialogs.NewAccount.open (); 85 | else 86 | switch_account (settings.current_account); 87 | } 88 | 89 | protected void load_instances_info (GenericArray saved_accounts) { 90 | info ("Reloading instances info"); 91 | instance_data = new GenericArray (); 92 | for (var curId = 0; curId < saved_accounts.length; curId++) { 93 | // Kind of a dirty hack, if no value added, but if array size 94 | // specified in constructor, value gets deconstructed as long 95 | // as it leaves load_single_instance function 96 | instance_data.add (null); 97 | load_single_instance.begin (curId); 98 | } 99 | } 100 | 101 | protected async void load_single_instance(int current_id) { 102 | var cur_acc = this.saved_accounts.@get (current_id); 103 | var cur_instance = cur_acc.instance; 104 | info ("Getting information for %s for #%i", cur_instance, current_id); 105 | var instMsg = new Soup.Message ("GET", "%s/api/v1/instance".printf (cur_instance)); 106 | network.queue_noauth (instMsg, (sess, mess) => { 107 | var root = network.parse (mess); 108 | var instance = API.Instance.parse (root); 109 | instance_data.@set (current_id, instance); 110 | info ("DONE: Getting information of %s for #%i", cur_instance, current_id); 111 | }, 112 | network.on_show_error); 113 | } 114 | 115 | public void save (bool overwrite = true) { 116 | try { 117 | var dir = File.new_for_path (dir_path); 118 | if (!dir.query_exists ()) 119 | dir.make_directory (); 120 | 121 | var file = File.new_for_path (file_path); 122 | if (file.query_exists () && !overwrite) 123 | return; 124 | 125 | var builder = new Json.Builder (); 126 | builder.begin_array (); 127 | saved_accounts.foreach ((acc) => { 128 | var node = acc.serialize (); 129 | builder.add_value (node); 130 | }); 131 | builder.end_array (); 132 | 133 | var generator = new Json.Generator (); 134 | generator.set_root (builder.get_root ()); 135 | var data = generator.to_data (null); 136 | 137 | if (file.query_exists ()) 138 | file.@delete (); 139 | 140 | FileOutputStream stream = file.create (FileCreateFlags.PRIVATE); 141 | stream.write (data.data); 142 | } 143 | catch (GLib.Error e){ 144 | warning (e.message); 145 | } 146 | } 147 | 148 | private void load () { 149 | try { 150 | uint8[] data; 151 | string etag; 152 | var file = File.new_for_path (file_path); 153 | file.load_contents (null, out data, out etag); 154 | var contents = (string) data; 155 | 156 | var parser = new Json.Parser (); 157 | parser.load_from_data (contents, -1); 158 | var array = parser.get_root ().get_array (); 159 | 160 | saved_accounts = new GenericArray (); 161 | array.foreach_element ((_arr, _i, node) => { 162 | var obj = node.get_object (); 163 | var account = InstanceAccount.parse (obj); 164 | if (account != null) { 165 | saved_accounts.add (account); 166 | account.start_notificator (); 167 | } 168 | }); 169 | debug ("Loaded %i saved accounts", saved_accounts.length); 170 | load_instances_info (saved_accounts); 171 | updated (saved_accounts); 172 | } 173 | catch (GLib.Error e){ 174 | warning (e.message); 175 | } 176 | } 177 | 178 | } 179 | -------------------------------------------------------------------------------- /src/Application.vala: -------------------------------------------------------------------------------- 1 | using Gtk; 2 | using Granite; 3 | 4 | namespace Olifant { 5 | 6 | public static Application app; 7 | public static Dialogs.MainWindow? window; 8 | public static Window window_dummy; 9 | 10 | public static Settings settings; 11 | public static Accounts accounts; 12 | public static Network network; 13 | public static ImageCache image_cache; 14 | public static Watchlist watchlist; 15 | 16 | public static bool start_hidden = false; 17 | 18 | public class Application : Granite.Application { 19 | 20 | public abstract signal void refresh (); 21 | public abstract signal void toast (string title); 22 | public abstract signal void error (string title, string text); 23 | 24 | public const GLib.OptionEntry[] app_options = { 25 | { "hidden", 0, 0, OptionArg.NONE, ref start_hidden, "Do not show main window on start", null }, 26 | { null } 27 | }; 28 | 29 | public const GLib.ActionEntry[] app_entries = { 30 | {"compose-toot", compose_toot_activated }, 31 | {"toggle-reveal", on_sensitive_toggled }, 32 | {"back", back_activated }, 33 | {"refresh", refresh_activated }, 34 | {"switch-timeline", switch_timeline_activated, "i" } 35 | }; 36 | 37 | construct { 38 | application_id = "com.github.cleac.olifant"; 39 | flags = ApplicationFlags.FLAGS_NONE; 40 | program_name = "Olifant"; 41 | build_version = "0.2.1"; 42 | } 43 | 44 | public string[] ACCEL_NEW_POST = {"T"}; 45 | public string[] ACCEL_TOGGLE_REVEAL = {"S"}; 46 | public string[] ACCEL_BACK = {"BackSpace", "Left"}; 47 | public string[] ACCEL_REFRESH = {"R", "F5"}; 48 | public string[] ACCEL_TIMELINE_0 = {"1"}; 49 | public string[] ACCEL_TIMELINE_1 = {"2"}; 50 | public string[] ACCEL_TIMELINE_2 = {"3"}; 51 | public string[] ACCEL_TIMELINE_3 = {"4"}; 52 | 53 | public static int main (string[] args) { 54 | Gtk.init (ref args); 55 | 56 | try { 57 | var opt_context = new OptionContext ("- Options"); 58 | opt_context.add_main_entries (app_options, null); 59 | opt_context.parse (ref args); 60 | } 61 | catch (GLib.OptionError e) { 62 | warning (e.message); 63 | } 64 | 65 | app = new Application (); 66 | return app.run (args); 67 | } 68 | 69 | protected override void startup () { 70 | base.startup (); 71 | Granite.Services.Logger.DisplayLevel = Granite.Services.LogLevel.INFO; 72 | 73 | settings = new Settings (); 74 | accounts = new Accounts (); 75 | network = new Network (); 76 | image_cache = new ImageCache (); 77 | watchlist = new Watchlist (); 78 | accounts.init (); 79 | 80 | app.error.connect (app.on_error); 81 | 82 | window_dummy = new Window (); 83 | add_window (window_dummy); 84 | 85 | set_accels_for_action ("app.compose-toot", ACCEL_NEW_POST); 86 | set_accels_for_action ("app.toggle-reveal", ACCEL_TOGGLE_REVEAL); 87 | set_accels_for_action ("app.back", ACCEL_BACK); 88 | set_accels_for_action ("app.refresh", ACCEL_REFRESH); 89 | set_accels_for_action ("app.switch-timeline(0)", ACCEL_TIMELINE_0); 90 | set_accels_for_action ("app.switch-timeline(1)", ACCEL_TIMELINE_1); 91 | set_accels_for_action ("app.switch-timeline(2)", ACCEL_TIMELINE_2); 92 | set_accels_for_action ("app.switch-timeline(3)", ACCEL_TIMELINE_3); 93 | add_action_entries (app_entries, this); 94 | } 95 | 96 | protected override void activate () { 97 | if (window != null) { 98 | window.present (); 99 | return; 100 | } 101 | 102 | if (start_hidden) { 103 | start_hidden = false; 104 | return; 105 | } 106 | 107 | debug ("Creating new window"); 108 | if (accounts.is_empty ()) 109 | Dialogs.NewAccount.open (); 110 | else { 111 | window = new Dialogs.MainWindow (this); 112 | window.present (); 113 | } 114 | } 115 | 116 | protected void on_error (string title, string msg){ 117 | var message_dialog = new Granite.MessageDialog.with_image_from_icon_name (title, msg, "dialog-warning"); 118 | message_dialog.transient_for = window; 119 | message_dialog.run (); 120 | message_dialog.destroy (); 121 | } 122 | 123 | private void on_sensitive_toggled () { 124 | window.button_reveal.clicked (); 125 | } 126 | 127 | private void compose_toot_activated () { 128 | Dialogs.Compose.open (); 129 | } 130 | 131 | private void back_activated () { 132 | window.back (); 133 | } 134 | 135 | private void refresh_activated () { 136 | refresh (); 137 | } 138 | 139 | private void switch_timeline_activated (SimpleAction a, Variant? parameter) { 140 | int32 timeline_no = parameter.get_int32 (); 141 | window.switch_timeline (timeline_no); 142 | } 143 | 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /src/Desktop.vala: -------------------------------------------------------------------------------- 1 | public class Olifant.Desktop { 2 | 3 | // Open URI in the user's default application associated with it 4 | public static bool open_uri (string uri) { 5 | try { 6 | Gtk.show_uri (null, uri, Gdk.CURRENT_TIME); 7 | } 8 | catch (GLib.Error e){ 9 | warning ("Can't open %s: %s", uri, e.message); 10 | app.error (_("Error"), e.message); 11 | } 12 | return true; 13 | } 14 | 15 | // Copy a string to the clipboard 16 | public static void copy (string str) { 17 | var display = window.get_display (); 18 | var clipboard = Gtk.Clipboard.get_for_display (display, Gdk.SELECTION_CLIPBOARD); 19 | clipboard.set_text (Widgets.RichLabel.restore_entities (str), -1); 20 | } 21 | 22 | // Download a file from the web to a user's configured Downloads folder 23 | public static void download_file (string url) { 24 | debug ("Downloading file: %s", url); 25 | 26 | var i = url.last_index_of ("/"); 27 | var name = url.substring (i + 1, url.length - i - 1); 28 | if (name == null) 29 | name = "unknown"; 30 | 31 | var dir_path = "%s/%s".printf (GLib.Environment.get_user_special_dir (UserDirectory.DOWNLOAD), app.program_name); 32 | var file_path = "%s/%s".printf (dir_path, name); 33 | 34 | var msg = new Soup.Message("GET", url); 35 | msg.finished.connect(() => { 36 | try { 37 | var dir = File.new_for_path (dir_path); 38 | if (!dir.query_exists ()) 39 | dir.make_directory (); 40 | 41 | var file = File.new_for_path (file_path); 42 | if (!file.query_exists ()) { 43 | var data = msg.response_body.data; 44 | FileOutputStream stream = file.create (FileCreateFlags.PRIVATE); 45 | stream.write (data); 46 | } 47 | app.toast (_("Media downloaded")); 48 | } catch (Error e) { 49 | app.toast (e.message); 50 | warning ("Error: %s\n", e.message); 51 | } 52 | }); 53 | network.queue (msg); 54 | } 55 | 56 | public static string fallback_icon (string normal, string fallback) { 57 | var theme = Gtk.IconTheme.get_default (); 58 | return theme.has_icon (normal) ? normal : fallback; 59 | } 60 | 61 | public static void set_hotkey_tooltip (Gtk.Widget widget, string? description, string[] accelerators) { 62 | widget.tooltip_markup = Granite.markup_accel_tooltip (accelerators, description); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/Dialogs/Compose.vala: -------------------------------------------------------------------------------- 1 | using Gtk; 2 | 3 | public class Olifant.Dialogs.Compose : Dialog { 4 | 5 | private static Compose dialog; 6 | 7 | protected TextView text; 8 | private ScrolledWindow scroll; 9 | private Label counter; 10 | private Widgets.ImageToggleButton spoiler; 11 | private MenuButton visibility; 12 | private Button attach; 13 | private Button cancel; 14 | private Button publish; 15 | protected Widgets.AttachmentGrid attachments; 16 | private Revealer spoiler_revealer; 17 | private Entry spoiler_text; 18 | 19 | protected API.Status? replying_to; 20 | protected API.Status? redrafting; 21 | protected API.StatusVisibility visibility_opt = API.StatusVisibility.PUBLIC; 22 | protected int64 char_limit; 23 | 24 | public Compose (API.Status? _replying_to = null, API.Status? _redrafting = null) { 25 | border_width = 6; 26 | deletable = false; 27 | resizable = true; 28 | title = _("Toot"); 29 | transient_for = window; 30 | char_limit = accounts.formal.status_char_limit ?? settings.char_limit; 31 | replying_to = _replying_to; 32 | redrafting = _redrafting; 33 | 34 | if (replying_to != null) 35 | visibility_opt = replying_to.visibility; 36 | if (redrafting != null) 37 | visibility_opt = redrafting.visibility; 38 | 39 | var actions = get_action_area ().get_parent () as Box; 40 | var content = get_content_area (); 41 | get_action_area ().hexpand = false; 42 | 43 | visibility = get_visibility_btn (); 44 | visibility.tooltip_text = _("Post Visibility"); 45 | visibility.get_style_context ().add_class (Gtk.STYLE_CLASS_FLAT); 46 | visibility.get_style_context ().remove_class ("image-button"); 47 | visibility.can_default = false; 48 | (visibility as Widget).set_focus_on_click (false); 49 | 50 | attach = new Button.from_icon_name ("mail-attachment-symbolic"); 51 | attach.tooltip_text = _("Add Media"); 52 | attach.valign = Align.CENTER; 53 | attach.get_style_context ().add_class (Gtk.STYLE_CLASS_FLAT); 54 | attach.get_style_context ().remove_class ("image-button"); 55 | attach.can_default = false; 56 | (attach as Widget).set_focus_on_click (false); 57 | attach.clicked.connect (() => attachments.select ()); 58 | 59 | spoiler = new Widgets.ImageToggleButton ("image-red-eye-symbolic"); 60 | spoiler.tooltip_text = _("Spoiler Warning"); 61 | spoiler.set_action (); 62 | spoiler.toggled.connect (() => { 63 | spoiler_revealer.reveal_child = spoiler.active; 64 | validate (); 65 | }); 66 | 67 | cancel = add_button (_("Cancel"), 5) as Button; 68 | cancel.clicked.connect(() => destroy ()); 69 | 70 | if (redrafting != null) { 71 | publish = add_button (_("Redraft"), 5) as Button; 72 | publish.get_style_context ().add_class (Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION); 73 | publish.clicked.connect (redraft_post); 74 | } 75 | else { 76 | publish = add_button (_("Toot!"), 5) as Button; 77 | publish.get_style_context ().add_class (Gtk.STYLE_CLASS_SUGGESTED_ACTION); 78 | publish.clicked.connect (publish_post); 79 | } 80 | 81 | spoiler_text = new Entry (); 82 | spoiler_text.margin_start = 6; 83 | spoiler_text.margin_end = 6; 84 | spoiler_text.placeholder_text = _("Write your warning here"); 85 | spoiler_text.changed.connect (validate); 86 | 87 | spoiler_revealer = new Revealer (); 88 | spoiler_revealer.add (spoiler_text); 89 | 90 | text = new TextView (); 91 | text.get_style_context ().add_class ("toot-text"); 92 | text.wrap_mode = WrapMode.WORD; 93 | text.accepts_tab = false; 94 | text.vexpand = true; 95 | text.buffer.changed.connect (validate); 96 | 97 | scroll = new ScrolledWindow (null, null); 98 | scroll.hscrollbar_policy = PolicyType.NEVER; 99 | scroll.min_content_height = 120; 100 | scroll.vexpand = true; 101 | scroll.propagate_natural_height = true; 102 | scroll.margin_start = 6; 103 | scroll.margin_end = 6; 104 | scroll.add (text); 105 | scroll.show_all (); 106 | 107 | attachments = new Widgets.AttachmentGrid (true); 108 | counter = new Label (""); 109 | 110 | actions.pack_start (counter, false, false, 6); 111 | actions.pack_end (spoiler, false, false, 6); 112 | actions.pack_end (visibility, false, false, 0); 113 | actions.pack_end (attach, false, false, 6); 114 | content.pack_start (spoiler_revealer, false, false, 6); 115 | content.pack_start (scroll, false, false, 6); 116 | content.pack_start (attachments, false, false, 6); 117 | content.set_size_request (350, 120); 118 | 119 | if (replying_to != null) { 120 | spoiler.active = replying_to.sensitive; 121 | var status_spoiler_text = replying_to.spoiler_text != null ? replying_to.spoiler_text : ""; 122 | spoiler_text.set_text (status_spoiler_text); 123 | } 124 | if (redrafting != null) { 125 | spoiler.active = redrafting.sensitive; 126 | var status_spoiler_text = redrafting.spoiler_text != null ? redrafting.spoiler_text : ""; 127 | spoiler_text.set_text (status_spoiler_text); 128 | } 129 | 130 | destroy.connect (() => dialog = null); 131 | 132 | show_all (); 133 | attachments.hide (); 134 | text.grab_focus (); 135 | validate (); 136 | } 137 | 138 | private MenuButton get_visibility_btn () { 139 | var button = new MenuButton (); 140 | var menu = new Popover (null); 141 | var box = new Box (Orientation.VERTICAL, 6); 142 | box.margin = 12; 143 | menu.add (box); 144 | button.direction = ArrowType.DOWN; 145 | button.image = new Image.from_icon_name (visibility_opt.get_icon (), IconSize.BUTTON); 146 | 147 | RadioButton? first = null; 148 | foreach (API.StatusVisibility opt in API.StatusVisibility.get_all ()){ 149 | var item = new RadioButton.with_label_from_widget (first, opt.get_desc ()); 150 | if (first == null) 151 | first = item; 152 | 153 | item.toggled.connect (() => { 154 | visibility_opt = opt; 155 | (button.image as Image).icon_name = visibility_opt.get_icon (); 156 | }); 157 | item.active = visibility_opt == opt; 158 | box.pack_start (item, false, false, 0); 159 | } 160 | 161 | box.show_all (); 162 | button.use_popover = true; 163 | button.popover = menu; 164 | button.valign = Align.CENTER; 165 | button.show (); 166 | return button; 167 | } 168 | 169 | private void validate () { 170 | var remain = char_limit - text.buffer.get_char_count (); 171 | if (spoiler.active) 172 | remain -= (int)spoiler_text.buffer.length; 173 | 174 | counter.label = remain.to_string (); 175 | publish.sensitive = remain >= 0; 176 | } 177 | 178 | public static void open (string? text = null, API.Status? reply_to = null) { 179 | if (dialog == null){ 180 | dialog = new Compose (reply_to); 181 | 182 | if (text != null) 183 | dialog.text.buffer.text = text; 184 | } 185 | else if (text != null) 186 | dialog.text.buffer.text += text; 187 | } 188 | 189 | public static void reply (API.Status status) { 190 | if (dialog != null) 191 | return; 192 | 193 | open (null, status); 194 | dialog.text.buffer.text = status.get_reply_mentions (); 195 | } 196 | 197 | public static void redraft (API.Status status) { 198 | if (dialog != null) 199 | return; 200 | dialog = new Compose (null, status); 201 | 202 | if (status.attachments != null) { 203 | foreach (API.Attachment attachment in status.attachments) 204 | dialog.attachments.append (attachment); 205 | } 206 | 207 | var content = Html.simplify (status.content); 208 | content = Html.remove_tags (content); 209 | content = Widgets.RichLabel.restore_entities (content); 210 | dialog.text.buffer.text = content; 211 | } 212 | 213 | private void publish_post () { 214 | var pars = "?status=%s&visibility=%s".printf (Html.uri_encode (text.buffer.text), visibility_opt.to_string ()); 215 | pars += attachments.get_uri_array (); 216 | if (replying_to != null) 217 | pars += "&in_reply_to_id=%s".printf (replying_to.id.to_string ()); 218 | 219 | if (spoiler.active) { 220 | pars += "&sensitive=true"; 221 | pars += "&spoiler_text=" + Html.uri_encode (spoiler_text.buffer.text); 222 | } 223 | 224 | var url = "%s/api/v1/statuses%s".printf (accounts.formal.instance, pars); 225 | var msg = new Soup.Message ("POST", url); 226 | network.inject (msg, Network.INJECT_TOKEN); 227 | network.queue (msg, (sess, mess) => { 228 | var root = network.parse (mess); 229 | var status = API.Status.parse (root); 230 | debug ("Posted: %s", status.id.to_string ()); //TODO: Live updates 231 | destroy (); 232 | }); 233 | } 234 | 235 | private void redraft_post () { 236 | redrafting.poof ((sess, msg) => { 237 | publish_post (); 238 | }); 239 | } 240 | 241 | } 242 | -------------------------------------------------------------------------------- /src/Dialogs/ISavedWindow.vala: -------------------------------------------------------------------------------- 1 | using Gtk; 2 | 3 | public interface Olifant.Dialogs.ISavedWindow : Window { 4 | 5 | public void restore_state () { 6 | var settings = new Settings (); 7 | configure_window (settings); 8 | configure_event.connect ((ev) => on_configure (ev, settings)); 9 | } 10 | 11 | public bool on_configure (Gdk.EventConfigure event, Settings settings) { 12 | int x, y, w, h; 13 | get_position (out x, out y); 14 | get_size (out w, out h); 15 | 16 | settings.window_x = x; 17 | settings.window_y = y; 18 | settings.window_w = w; 19 | settings.window_h = h; 20 | return false; 21 | } 22 | 23 | public void configure_window (Settings settings) { 24 | var x = settings.window_x; 25 | var y = settings.window_y; 26 | var w = settings.window_w; 27 | var h = settings.window_h; 28 | 29 | if (x + y > 0) 30 | this.move (x, y); 31 | 32 | if (h + w > 0) { 33 | this.default_width = w; 34 | this.default_height = h; 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/Dialogs/MainWindow.vala: -------------------------------------------------------------------------------- 1 | using Gtk; 2 | using Gdk; 3 | 4 | public class Olifant.Dialogs.MainWindow: Gtk.Window, ISavedWindow { 5 | 6 | private Overlay overlay; 7 | public Granite.Widgets.Toast toast; 8 | private Grid grid; 9 | private Stack view_stack; 10 | private Stack timeline_stack; 11 | 12 | public HeaderBar header; 13 | public Granite.Widgets.ModeButton button_mode; 14 | private Widgets.AccountsButton button_accounts; 15 | private Spinner spinner; 16 | private Button button_toot; 17 | private Button button_back; 18 | public Button button_reveal; 19 | 20 | public Views.Home home = new Views.Home (); 21 | public Views.Notifications notifications = new Views.Notifications (); 22 | public Views.Local local = new Views.Local (); 23 | public Views.Federated federated = new Views.Federated (); 24 | 25 | construct { 26 | 27 | var provider = new Gtk.CssProvider (); 28 | provider.load_from_resource ("/me/cleac/olifant/app.css"); 29 | StyleContext.add_provider_for_screen (Gdk.Screen.get_default (), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); 30 | 31 | settings.changed.connect (update_theme); 32 | update_theme (); 33 | 34 | timeline_stack = new Stack(); 35 | timeline_stack.transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT; 36 | timeline_stack.show (); 37 | view_stack = new Stack(); 38 | view_stack.transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT; 39 | view_stack.show (); 40 | view_stack.add_named (timeline_stack, "0"); 41 | view_stack.hexpand = view_stack.vexpand = true; 42 | 43 | spinner = new Spinner (); 44 | spinner.active = true; 45 | 46 | button_accounts = new Widgets.AccountsButton (); 47 | 48 | button_back = new Button (); 49 | button_back.valign = Align.CENTER; 50 | button_back.label = _("Back"); 51 | button_back.get_style_context ().add_class (Granite.STYLE_CLASS_BACK_BUTTON); 52 | button_back.clicked.connect (() => back ()); 53 | Desktop.set_hotkey_tooltip (button_back, null, app.ACCEL_BACK); 54 | 55 | button_toot = new Button (); 56 | button_toot.valign = Align.CENTER; 57 | button_toot.image = new Image.from_icon_name ("document-edit-symbolic", IconSize.LARGE_TOOLBAR); 58 | button_toot.clicked.connect (() => Dialogs.Compose.open ()); 59 | Desktop.set_hotkey_tooltip (button_toot, _("Toot"), app.ACCEL_NEW_POST); 60 | 61 | button_reveal = new Button (); 62 | button_reveal.valign = Align.CENTER; 63 | button_reveal.image = new Image.from_icon_name ("image-red-eye-symbolic", IconSize.LARGE_TOOLBAR); 64 | Desktop.set_hotkey_tooltip (button_reveal, _("Toggle content"), app.ACCEL_TOGGLE_REVEAL); 65 | 66 | button_mode = new Granite.Widgets.ModeButton (); 67 | button_mode.get_style_context ().add_class ("mode"); 68 | button_mode.vexpand = true; 69 | button_mode.valign = Align.FILL; 70 | button_mode.mode_changed.connect (on_mode_changed); 71 | button_mode.show (); 72 | 73 | header = new HeaderBar (); 74 | header.get_style_context ().add_class ("compact"); 75 | header.show_close_button = true; 76 | header.title = _("Olifant"); 77 | header.custom_title = button_mode; 78 | header.pack_start (button_back); 79 | header.pack_start (button_toot); 80 | header.pack_end (button_accounts); 81 | header.pack_end (button_reveal); 82 | header.pack_end (spinner); 83 | header.show_all (); 84 | 85 | grid = new Grid (); 86 | grid.attach (view_stack, 0, 0, 1, 1); 87 | 88 | add_header_view (home, app.ACCEL_TIMELINE_0, 0); 89 | add_header_view (notifications, app.ACCEL_TIMELINE_1, 1); 90 | add_header_view (local, app.ACCEL_TIMELINE_2, 2); 91 | add_header_view (federated, app.ACCEL_TIMELINE_3, 3); 92 | button_mode.set_active (0); 93 | 94 | toast = new Granite.Widgets.Toast (""); 95 | overlay = new Overlay (); 96 | overlay.add_overlay (grid); 97 | overlay.add_overlay (toast); 98 | overlay.set_size_request (450, 600); 99 | add (overlay); 100 | 101 | restore_state (); 102 | show_all (); 103 | 104 | button_reveal.hide (); 105 | } 106 | 107 | public MainWindow (Gtk.Application _app) { 108 | application = _app; 109 | icon_name = "com.github.cleac.olifant"; 110 | resizable = true; 111 | window_position = WindowPosition.CENTER; 112 | set_titlebar (header); 113 | update_header (); 114 | 115 | app.toast.connect (on_toast); 116 | network.started.connect (() => spinner.show ()); 117 | network.finished.connect (() => spinner.hide ()); 118 | accounts.updated (accounts.saved_accounts); 119 | button_press_event.connect (on_button_press); 120 | } 121 | 122 | private bool on_button_press (EventButton ev) { 123 | if (ev.button == 8) 124 | return back (); 125 | return false; 126 | } 127 | 128 | private void add_header_view (Views.Abstract view, string[] accelerators, int32 num) { 129 | var img = new Image.from_icon_name (view.get_icon (), IconSize.LARGE_TOOLBAR); 130 | Desktop.set_hotkey_tooltip (img, view.get_name (), accelerators); 131 | button_mode.append (img); 132 | view.image = img; 133 | timeline_stack.add_named (view, num.to_string ()); 134 | 135 | if (view is Views.Notifications) 136 | img.pixel_size = 20; // For some reason Notifications icon is too small without this 137 | } 138 | 139 | public int get_visible_id () { 140 | return int.parse (view_stack.get_visible_child_name ()); 141 | } 142 | 143 | public bool open_view (Views.Abstract widget) { 144 | var i = get_visible_id (); 145 | i++; 146 | widget.stack_pos = i; 147 | widget.show (); 148 | view_stack.add_named (widget, i.to_string ()); 149 | view_stack.set_visible_child_name (i.to_string ()); 150 | update_header (); 151 | return true; 152 | } 153 | 154 | public bool back () { 155 | var i = get_visible_id (); 156 | if (i == 0) 157 | return false; 158 | 159 | var child = view_stack.get_child_by_name (i.to_string ()); 160 | view_stack.set_visible_child_name ((i-1).to_string ()); 161 | child.destroy (); 162 | update_header (); 163 | return true; 164 | } 165 | 166 | public void reopen_view (int view_id) { 167 | var i = get_visible_id (); 168 | while (i != view_id && view_id != 0) { 169 | back (); 170 | i = get_visible_id (); 171 | } 172 | } 173 | 174 | public override bool delete_event (Gdk.EventAny event) { 175 | destroy.connect (() => { 176 | if (!settings.always_online || accounts.is_empty ()) 177 | app.remove_window (window_dummy); 178 | window = null; 179 | }); 180 | return false; 181 | } 182 | 183 | public void switch_timeline (int32 timeline_no) { 184 | button_mode.set_active (timeline_no); 185 | } 186 | 187 | private void update_theme () { 188 | var provider = new Gtk.CssProvider (); 189 | var is_dark = settings.dark_theme; 190 | var theme = is_dark ? "dark" : "light"; 191 | provider.load_from_resource ("/me/cleac/olifant/%s.css".printf (theme)); 192 | StyleContext.add_provider_for_screen (Gdk.Screen.get_default (), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); 193 | Gtk.Settings.get_default ().gtk_application_prefer_dark_theme = is_dark; 194 | } 195 | 196 | private void update_header () { 197 | bool primary_mode = get_visible_id () == 0; 198 | button_mode.sensitive = primary_mode; 199 | button_mode.opacity = primary_mode ? 1 : 0; //Prevent HeaderBar height jitter 200 | button_toot.set_visible (primary_mode); 201 | button_back.set_visible (!primary_mode); 202 | button_accounts.set_visible (true); 203 | } 204 | 205 | private void on_toast (string msg){ 206 | toast.title = msg; 207 | toast.send_notification (); 208 | } 209 | 210 | private void on_mode_changed (Widget widget) { 211 | var visible = timeline_stack.get_visible_child () as Views.Abstract; 212 | visible.current = false; 213 | 214 | timeline_stack.set_visible_child_name (button_mode.selected.to_string ()); 215 | 216 | visible = timeline_stack.get_visible_child () as Views.Abstract; 217 | visible.current = true; 218 | visible.on_set_current (); 219 | } 220 | 221 | } 222 | -------------------------------------------------------------------------------- /src/Dialogs/NewAccount.vala: -------------------------------------------------------------------------------- 1 | using Gtk; 2 | 3 | public class Olifant.Dialogs.NewAccount : Dialog { 4 | 5 | private static NewAccount dialog; 6 | 7 | private Grid grid; 8 | private Button button_done; 9 | private Image logo; 10 | private Entry instance_entry; 11 | private Label instance_register; 12 | private Label code_name; 13 | private Label url_hint; 14 | private Entry code_entry; 15 | 16 | private string? instance; 17 | private string? client_id; 18 | private string? client_secret; 19 | private string? code; 20 | private string? token; 21 | private string? username; 22 | private int64? instance_status_char_limit; 23 | 24 | private const int64 DEFAULT_INSTANCE_STATUS_CHAR_LIMIT = 500; 25 | 26 | public NewAccount () { 27 | border_width = 6; 28 | deletable = true; 29 | resizable = false; 30 | title = _("New Account"); 31 | transient_for = window; 32 | 33 | logo = new Image.from_resource ("/me/cleac/olifant/logo128"); 34 | logo.halign = Align.CENTER; 35 | logo.hexpand = true; 36 | logo.margin_bottom = 24; 37 | 38 | instance_entry = new Entry (); 39 | instance_entry.width_chars = 30; 40 | 41 | instance_register = new Label ("%s".printf (_("What's an instance?"))); 42 | instance_register.halign = Align.END; 43 | instance_register.set_use_markup (true); 44 | 45 | code_name = new Widgets.AlignedLabel (_("Code:")); 46 | 47 | code_entry = new Entry (); 48 | code_entry.secondary_icon_name = "dialog-question-symbolic"; 49 | code_entry.secondary_icon_tooltip_text = _("Paste your instance authorization code here"); 50 | code_entry.secondary_icon_activatable = false; 51 | 52 | button_done = new Button.with_label (_("Add Account")); 53 | button_done.clicked.connect (on_done_clicked); 54 | button_done.halign = Align.END; 55 | button_done.margin_top = 24; 56 | 57 | url_hint = new Label("test"); 58 | url_hint.halign = Align.END; 59 | url_hint.set_use_markup (true); 60 | 61 | grid = new Grid (); 62 | grid.column_spacing = 12; 63 | grid.row_spacing = 6; 64 | grid.hexpand = true; 65 | grid.halign = Align.CENTER; 66 | grid.attach (logo, 0, 0, 2, 1); 67 | grid.attach (new Widgets.AlignedLabel (_("Instance:")), 0, 1); 68 | grid.attach (instance_entry, 1, 1); 69 | grid.attach (code_name, 0, 3); 70 | grid.attach (code_entry, 1, 3); 71 | grid.attach (url_hint, 1, 4); 72 | grid.attach (instance_register, 1, 5); 73 | grid.attach (button_done, 1, 10); 74 | 75 | var content = get_content_area () as Box; 76 | content.pack_start (grid, false, false, 0); 77 | 78 | destroy.connect (() => { 79 | dialog = null; 80 | 81 | if (accounts.is_empty ()) 82 | app.remove_window (window_dummy); 83 | }); 84 | 85 | show_all (); 86 | clear (); 87 | } 88 | 89 | private void clear () { 90 | code_name.hide (); 91 | code_entry.hide (); 92 | url_hint.hide (); 93 | code_entry.text = ""; 94 | client_id = client_secret = code = token = null; 95 | } 96 | 97 | private void on_done_clicked () { 98 | instance = "https://" + instance_entry.text 99 | .replace ("/", "") 100 | .replace (":", "") 101 | .replace ("https", "") 102 | .replace ("http", ""); 103 | code = code_entry.text; 104 | 105 | request_instance_status_charlimit(); 106 | 107 | if (client_id == null || client_secret == null) { 108 | request_client_tokens (); 109 | return; 110 | } 111 | 112 | if (code == "") 113 | app.error (_("Error"), _("Please paste valid instance authorization code")); 114 | else 115 | try_auth (code); 116 | } 117 | 118 | private void request_instance_status_charlimit () { 119 | var instance_query = new Soup.Message("GET", "%s/api/v1/instance".printf(instance)); 120 | network.queue(instance_query, (sess, msg) => { 121 | var root = network.parse (msg); 122 | instance_status_char_limit = root.get_int_member ("max_toot_chars"); 123 | if (instance_status_char_limit > 0) { 124 | info ("Got new instance status character limit: %s".printf(instance_status_char_limit.to_string())); 125 | } else { 126 | instance_status_char_limit = DEFAULT_INSTANCE_STATUS_CHAR_LIMIT; 127 | warning ("Could not determine maximum status length, falling back to 500"); 128 | } 129 | }, (_, __) => { 130 | instance_status_char_limit = DEFAULT_INSTANCE_STATUS_CHAR_LIMIT; 131 | warning ("Could not determine maximum status length, falling back to 500"); 132 | }); 133 | } 134 | 135 | private void request_client_tokens (){ 136 | var pars = "?client_name=Olifant"; 137 | pars += "&redirect_uris=urn:ietf:wg:oauth:2.0:oob"; 138 | pars += "&website=https://github.com/cleac/tootle"; 139 | pars += "&scopes=read%20write%20follow"; 140 | 141 | grid.sensitive = false; 142 | var message = new Soup.Message ("POST", "%s/api/v1/apps%s".printf (instance, pars)); 143 | network.queue_noauth (message, (sess, msg) => { 144 | grid.sensitive = true; 145 | 146 | var root = network.parse (msg); 147 | var id = root.get_string_member ("client_id"); 148 | var secret = root.get_string_member ("client_secret"); 149 | client_id = id; 150 | client_secret = secret; 151 | 152 | info ("Received tokens from %s", instance); 153 | request_auth_code (); 154 | code_name.show (); 155 | code_entry.show (); 156 | url_hint.show (); 157 | url_hint.set_markup ("Browser did not open? Try link".printf (GLib.Markup.escape_text (generate_auth_url ()))); 158 | }, (status, reason) => { 159 | network.on_show_error (status, reason); 160 | }); 161 | } 162 | 163 | private string generate_auth_url () { 164 | var pars = "?scope=read%20write%20follow"; 165 | pars += "&response_type=code"; 166 | pars += "&redirect_uri=urn:ietf:wg:oauth:2.0:oob"; 167 | pars += "&client_id=" + client_id; 168 | 169 | return "%s/oauth/authorize%s".printf (instance, pars); 170 | } 171 | 172 | private void request_auth_code (){ 173 | info ("Requesting auth token"); 174 | Desktop.open_uri (generate_auth_url ()); 175 | } 176 | 177 | private void try_auth (string code){ 178 | var pars = "?client_id=" + client_id; 179 | pars += "&client_secret=" + client_secret; 180 | pars += "&redirect_uri=urn:ietf:wg:oauth:2.0:oob"; 181 | pars += "&grant_type=authorization_code"; 182 | pars += "&code=" + code; 183 | 184 | info ("Querying access token for %s".printf (instance)); 185 | var message = new Soup.Message ("POST", "%s/oauth/token%s".printf (instance, pars)); 186 | network.queue_noauth (message, (sess, msg) => { 187 | var root = network.parse (msg); 188 | token = root.get_string_member ("access_token"); 189 | 190 | info ("Got access token for %s".printf (instance)); 191 | get_username (); 192 | }, (status, reason) => { 193 | network.on_show_error (status, reason); 194 | }); 195 | } 196 | 197 | private void get_username () { 198 | var message = new Soup.Message("GET", "%s/api/v1/accounts/verify_credentials".printf (instance)); 199 | message.request_headers.append ("Authorization", "Bearer " + token); 200 | network.queue_noauth (message, (sess, msg) => { 201 | var root = network.parse (msg); 202 | username = root.get_string_member ("username"); 203 | add_account (); 204 | window.show (); 205 | window.present (); 206 | destroy (); 207 | }, (status, reason) => { 208 | network.on_show_error (status, reason); 209 | }); 210 | } 211 | 212 | private void add_account () { 213 | var account = new InstanceAccount (); 214 | account.username = username; 215 | account.instance = instance; 216 | account.client_id = client_id; 217 | account.client_secret = client_secret; 218 | account.token = token; 219 | account.status_char_limit = instance_status_char_limit; 220 | accounts.add (account); 221 | app.activate (); 222 | } 223 | 224 | public static void open () { 225 | if (dialog == null) 226 | dialog = new NewAccount (); 227 | } 228 | 229 | } 230 | -------------------------------------------------------------------------------- /src/Dialogs/Preferences.vala: -------------------------------------------------------------------------------- 1 | using Gtk; 2 | 3 | public class Olifant.Dialogs.Preferences : Dialog { 4 | 5 | private static Preferences dialog; 6 | 7 | private SettingsSwitch switch_notifications; 8 | private SettingsSwitch switch_watcher; 9 | private SettingsSwitch switch_stream; 10 | private SettingsSwitch switch_stream_public; 11 | private Grid grid; 12 | 13 | public Preferences () { 14 | border_width = 6; 15 | deletable = false; 16 | resizable = false; 17 | title = _("Settings"); 18 | transient_for = window; 19 | 20 | int i = 0; 21 | grid = new Grid (); 22 | 23 | switch_watcher = new SettingsSwitch ("always-online"); 24 | switch_notifications = new SettingsSwitch ("notifications"); 25 | switch_notifications.state_set.connect (state => { 26 | switch_watcher.sensitive = state; 27 | return false; 28 | }); 29 | switch_stream = new SettingsSwitch ("live-updates"); 30 | switch_stream_public = new SettingsSwitch ("live-updates-public"); 31 | switch_stream.state_set.connect (state => { 32 | switch_stream_public.sensitive = state; 33 | return false; 34 | }); 35 | 36 | grid.attach (new Granite.HeaderLabel (_("Appearance")), 0, i++, 2, 1); 37 | grid.attach (new SettingsLabel (_("Dark theme:")), 0, i); 38 | grid.attach (new SettingsSwitch ("dark-theme"), 1, i++); 39 | 40 | grid.attach (new Granite.HeaderLabel (_("Timelines")), 0, i++, 2, 1); 41 | grid.attach (new SettingsLabel (_("Real-time updates:")), 0, i); 42 | grid.attach (switch_stream, 1, i++); 43 | grid.attach (new SettingsLabel (_("Update public timelines:")), 0, i); 44 | grid.attach (switch_stream_public, 1, i++); 45 | 46 | // grid.attach (new Granite.HeaderLabel (_("Caching")), 0, i++, 2, 1); 47 | // grid.attach (new SettingsLabel (_("Use cache:")), 0, i); 48 | // grid.attach (new SettingsSwitch ("cache"), 1, i++); 49 | // grid.attach (new SettingsLabel (_("Max cache size (MB):")), 0, i); 50 | // var cache_size = new SpinButton.with_range (16, 256, 1); 51 | // settings.schema.bind ("cache-size", cache_size, "value", SettingsBindFlags.DEFAULT); 52 | // grid.attach (cache_size, 1, i++); 53 | 54 | grid.attach (new Granite.HeaderLabel (_("Notifications")), 0, i++, 2, 1); 55 | grid.attach (new SettingsLabel (_("Display notifications:")), 0, i); 56 | grid.attach (switch_notifications, 1, i++); 57 | grid.attach (new SettingsLabel (_("Always receive notifications:")), 0, i); 58 | grid.attach (switch_watcher, 1, i++); 59 | 60 | var clearNotificationsLabel = new SettingsLabel (_("Clear notifications:")); 61 | grid.attach (clearNotificationsLabel, 0, i); 62 | var cleanNotifications = new Button.with_label (_("Clear")); 63 | cleanNotifications.get_style_context ().add_class(Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION); 64 | cleanNotifications.clicked.connect (() => { 65 | var url = "%s/api/v1/notifications/clear".printf (accounts.formal.instance); 66 | var msg = new Soup.Message ("POST", url); 67 | network.inject (msg, Network.INJECT_TOKEN); 68 | network.queue (msg, (sess, mess) => { 69 | //update notifications tab 70 | Olifant.window.notifications.on_refresh(); 71 | }, (status, reason) => { 72 | open_link_fallback (url, reason); 73 | }); 74 | 75 | cleanNotifications.set_label (_("Done!")); 76 | cleanNotifications.set_sensitive (false); 77 | 78 | }); 79 | grid.attach (cleanNotifications, 1, i++); 80 | grid.set_margin_bottom(4); 81 | grid.set_margin_right(2); 82 | 83 | var content = get_content_area () as Box; 84 | content.pack_start (grid, false, false, 0); 85 | 86 | var close = add_button (_("_Close"), ResponseType.CLOSE) as Button; 87 | close.clicked.connect (() => { 88 | destroy (); 89 | dialog = null; 90 | }); 91 | 92 | show_all (); 93 | } 94 | 95 | public static void open () { 96 | if (dialog == null) 97 | dialog = new Preferences (); 98 | } 99 | 100 | protected class SettingsLabel : Label { 101 | public SettingsLabel (string text) { 102 | label = text; 103 | halign = Align.END; 104 | margin_start = 12; 105 | margin_end = 12; 106 | } 107 | } 108 | 109 | protected class SettingsSwitch : Switch { 110 | public SettingsSwitch (string setting) { 111 | halign = Align.START; 112 | valign = Align.CENTER; 113 | margin_bottom = 6; 114 | settings.schema.bind (setting, this, "active", SettingsBindFlags.DEFAULT); 115 | } 116 | } 117 | public bool open_link_fallback (string url, string reason) { 118 | warning ("Can't resolve url: " + url); 119 | warning ("Reason: " + reason); 120 | 121 | var toast = window.toast; 122 | toast.title = reason; 123 | toast.set_default_action (_("Open in Browser")); 124 | ulong signal_id = 0; 125 | signal_id = toast.default_action.connect (() => { 126 | Desktop.open_uri (url); 127 | toast.disconnect (signal_id); 128 | }); 129 | toast.send_notification (); 130 | return true; 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /src/Dialogs/WatchlistEditor.vala: -------------------------------------------------------------------------------- 1 | using Gtk; 2 | using Gee; 3 | 4 | public class Olifant.Dialogs.WatchlistEditor : Dialog { 5 | 6 | private static WatchlistEditor dialog; 7 | 8 | private StackSwitcher switcher; 9 | private MenuButton button_add; 10 | private Button button_remove; 11 | private Stack stack; 12 | private ListStack users; 13 | private ListStack hashtags; 14 | private ActionBar actionbar; 15 | private Popover popover; 16 | private Grid popover_grid; 17 | private Entry popover_entry; 18 | private Button popover_button; 19 | 20 | private const string TIP_USERS = _("You'll be notified when toots from this user appear in your Home timeline."); 21 | private const string TIP_HASHTAGS = _("You'll be notified when toots with this hashtag appear in any public timelines."); 22 | 23 | private class ModelItem : GLib.Object { 24 | public string name; 25 | 26 | public ModelItem (string name) { 27 | this.name = name; 28 | } 29 | } 30 | 31 | private class ModelView : ListBoxRow { 32 | public Label label; 33 | public ModelView (ModelItem item) { 34 | label = new Label (item.name); 35 | label.margin = 6; 36 | label.halign = Align.START; 37 | label.justify = Justification.LEFT; 38 | add (label); 39 | show_all (); 40 | } 41 | } 42 | 43 | private class Model : GLib.ListModel, GLib.Object { 44 | private GenericArray items = new GenericArray (); 45 | 46 | public GLib.Type get_item_type () { 47 | return typeof (ModelItem); 48 | } 49 | 50 | public uint get_n_items () { 51 | return items.length; 52 | } 53 | 54 | public GLib.Object? get_item (uint position) { 55 | return items.@get ((int)position); 56 | } 57 | 58 | public void append (ModelItem item) { 59 | this.items.add (item); 60 | } 61 | 62 | } 63 | 64 | public static Widget create_row (GLib.Object obj) { 65 | var item = (ModelItem) obj; 66 | return new ModelView (item); 67 | } 68 | 69 | private class ListStack : ScrolledWindow { 70 | public Model model; 71 | public ListBox list; 72 | 73 | public void update (ArrayList array) { 74 | array.@foreach (item => { 75 | model.append (new ModelItem (item)); 76 | return true; 77 | }); 78 | list.bind_model (model, create_row); 79 | } 80 | 81 | public ListStack (ArrayList array) { 82 | model = new Model (); 83 | list = new ListBox (); 84 | add (list); 85 | update (array); 86 | } 87 | } 88 | 89 | private void set_tip () { 90 | var is_user = stack.visible_child_name == "users"; 91 | popover_entry.secondary_icon_tooltip_text = is_user ? TIP_USERS : TIP_HASHTAGS; 92 | } 93 | 94 | public WatchlistEditor () { 95 | border_width = 6; 96 | deletable = false; 97 | resizable = false; 98 | transient_for = window; 99 | title = _("Watchlist"); 100 | 101 | users = new ListStack (watchlist.users); 102 | hashtags = new ListStack (watchlist.hashtags); 103 | 104 | stack = new Stack (); 105 | stack.transition_type = StackTransitionType.SLIDE_LEFT_RIGHT; 106 | stack.hexpand = true; 107 | stack.vexpand = true; 108 | stack.add_titled (users, "users", _("Users")); 109 | stack.add_titled (hashtags, "hashtags", _("Hashtags")); 110 | 111 | switcher = new StackSwitcher (); 112 | switcher.stack = stack; 113 | switcher.halign = Align.CENTER; 114 | switcher.margin_bottom = 12; 115 | 116 | popover_entry = new Entry (); 117 | popover_entry.hexpand = true; 118 | popover_entry.secondary_icon_name = "dialog-information-symbolic"; 119 | popover_entry.secondary_icon_activatable = false; 120 | popover_entry.activate.connect (() => submit ()); 121 | 122 | popover_button = new Button.with_label (_("Add")); 123 | popover_button.halign = Align.END; 124 | popover_button.margin_start = 6; 125 | popover_button.clicked.connect (() => submit ()); 126 | 127 | popover_grid = new Grid (); 128 | popover_grid.margin = 6; 129 | popover_grid.attach (popover_entry, 0, 0); 130 | popover_grid.attach (popover_button, 1, 0); 131 | popover_grid.show_all (); 132 | 133 | popover = new Popover (null); 134 | popover.add (popover_grid); 135 | 136 | button_add = new MenuButton (); 137 | button_add.image = new Image.from_icon_name ("list-add-symbolic", IconSize.BUTTON); 138 | button_add.popover = popover; 139 | button_add.clicked.connect (() => set_tip ()); 140 | 141 | button_remove = new Button (); 142 | button_remove.image = new Image.from_icon_name ("list-remove-symbolic", IconSize.BUTTON); 143 | button_remove.clicked.connect (on_remove); 144 | 145 | actionbar = new ActionBar (); 146 | actionbar.add (button_add); 147 | actionbar.add (button_remove); 148 | 149 | var grid = new Grid (); 150 | grid.attach (stack, 0, 1); 151 | grid.attach (actionbar, 0, 2); 152 | 153 | var frame = new Frame (null); 154 | frame.margin_bottom = 6; 155 | frame.add (grid); 156 | frame.set_size_request (350, 350); 157 | 158 | var content = get_content_area (); 159 | content.pack_start (switcher, true, true, 0); 160 | content.pack_start (frame, true, true, 0); 161 | 162 | add_button (_("_Close"), ResponseType.DELETE_EVENT); 163 | show_all (); 164 | 165 | response.connect (on_response); 166 | destroy.connect (() => dialog = null); 167 | } 168 | 169 | private void on_response (int i) { 170 | destroy (); 171 | } 172 | 173 | private void on_remove () { 174 | var is_hashtag = stack.visible_child_name == "hashtags"; 175 | ListStack stack = is_hashtag ? hashtags : users; 176 | stack.list.get_selected_rows ().@foreach (_row => { 177 | var row = _row as ModelView; 178 | watchlist.remove (row.label.label, is_hashtag); 179 | watchlist.save (); 180 | row.destroy (); 181 | }); 182 | } 183 | 184 | private void submit () { 185 | if (popover_entry.text_length < 1) 186 | return; 187 | 188 | var is_hashtag = stack.visible_child_name == "hashtags"; 189 | var entity = popover_entry.text 190 | .replace ("#", "") 191 | .replace (" ", ""); 192 | 193 | watchlist.add (entity, is_hashtag); 194 | watchlist.save (); 195 | button_add.active = false; 196 | 197 | var stack = is_hashtag ? hashtags : users; 198 | stack.list.insert (create_row (new ModelItem (entity)), 0); 199 | } 200 | 201 | public static void open () { 202 | if (dialog == null) 203 | dialog = new WatchlistEditor (); 204 | } 205 | 206 | } 207 | -------------------------------------------------------------------------------- /src/Drawing.vala: -------------------------------------------------------------------------------- 1 | using Gdk; 2 | using GLib; 3 | 4 | public class Olifant.Drawing { 5 | 6 | public static void draw_rounded_rect (Cairo.Context ctx, double x, double y, double w, double h, double r) { 7 | double degr = Math.PI / 180.0; 8 | ctx.new_sub_path (); 9 | ctx.arc (x + w - r, y + r, r, -90 * degr, 0 * degr); 10 | ctx.arc (x + w - r, y + h - r, r, 0 * degr, 90 * degr); 11 | ctx.arc (x + r, y + h - r, r, 90 * degr, 180 * degr); 12 | ctx.arc (x + r, y + r, r, 180 * degr, 270 * degr); 13 | ctx.close_path (); 14 | } 15 | 16 | public static Pixbuf make_pixbuf_thumbnail (Pixbuf pixbuf, int view_w, int view_h, bool fill_parent = false) { 17 | // Don't resize if parent view is bigger than actual image 18 | if (view_w >= pixbuf.width && view_h >= pixbuf.height) 19 | return pixbuf; 20 | 21 | //Otherwise fit the image into the parent view 22 | var resized_w = view_w; 23 | var resized_h = view_h; 24 | //resized_w = (pixbuf.width * view_h) / pixbuf.height; 25 | //resized_h = (pixbuf.height * view_w) / pixbuf.width; 26 | 27 | if (fill_parent) 28 | resized_h = (pixbuf.height * view_w) / pixbuf.width; 29 | else 30 | resized_w = (pixbuf.width * view_h) / pixbuf.height; 31 | 32 | return pixbuf.scale_simple (resized_w, resized_h, InterpType.BILINEAR); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Html.vala: -------------------------------------------------------------------------------- 1 | public class Olifant.Html { 2 | 3 | public static string remove_tags (string content) { 4 | var all_tags = new Regex("<(.|\n)*?>", RegexCompileFlags.CASELESS); 5 | return all_tags.replace(content, -1, 0, ""); 6 | } 7 | 8 | public static string simplify (string content) { 9 | var html_params = new Regex("(class|target|rel|data-user|data-tag)=\"(.|\n)*?\"", RegexCompileFlags.CASELESS); 10 | var tags_to_clean = new Regex("(]*/?>|

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