├── .github └── FUNDING.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── assets └── missing.png ├── i18n.toml ├── i18n ├── en │ └── cosmic_ext_toot.ftl ├── nl │ └── cosmic_ext_toot.ftl └── sv │ └── cosmic_ext_toot.ftl ├── justfile ├── res ├── app.desktop └── icons │ └── hicolor │ └── scalable │ └── apps │ └── icon.svg ├── src ├── app.rs ├── config.rs ├── error.rs ├── i18n.rs ├── main.rs ├── pages.rs ├── pages │ ├── home.rs │ ├── notifications.rs │ └── public.rs ├── settings.rs ├── subscriptions.rs ├── subscriptions │ ├── home.rs │ ├── notifications.rs │ └── public.rs ├── utils.rs └── widgets │ ├── account.rs │ ├── mod.rs │ ├── notification.rs │ └── status.rs └── testr /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: edfloreshz 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: edfloreshz 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: edfloreshz 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cargo/ 2 | *.pdb 3 | **/*.rs.bk 4 | debug/ 5 | target/ 6 | vendor/ 7 | vendor.tar 8 | debian/* 9 | !debian/changelog 10 | !debian/control 11 | !debian/copyright 12 | !debian/install 13 | !debian/rules 14 | !debian/source 15 | .vscode 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cosmic-ext-toot" 3 | version = "0.1.0" 4 | edition = "2021" 5 | repository = "https://github.com/edfloreshz/toot" 6 | 7 | [dependencies] 8 | capitalize = "0.3.4" 9 | futures-util = "0.3.31" 10 | html2text = "0.13.4" 11 | i18n-embed-fl = "0.9.2" 12 | keytar = "0.1.6" 13 | open = "5.3.0" 14 | reqwest = "0.12.9" 15 | rust-embed = "8.5.0" 16 | thiserror = "2.0.3" 17 | time = "0.3.36" 18 | tracing = "0.1.40" 19 | 20 | [dependencies.mastodon-async] 21 | git = "https://github.com/edfloreshz-ext/mastodon-async" 22 | features = ["all"] 23 | 24 | [dependencies.serde] 25 | version = "1.0.215" 26 | features = ["derive"] 27 | 28 | [dependencies.chrono] 29 | version = "0.4.38" 30 | features = ["serde"] 31 | 32 | [dependencies.tracing-subscriber] 33 | version = "0.3.18" 34 | features = ["env-filter"] 35 | 36 | [dependencies.i18n-embed] 37 | version = "0.15" 38 | features = ["fluent-system", "desktop-requester"] 39 | 40 | [dependencies.libcosmic] 41 | git = "https://github.com/pop-os/libcosmic.git" 42 | # See https://github.com/pop-os/libcosmic/blob/master/Cargo.toml for available features. 43 | features = [ 44 | # Accessibility support 45 | "a11y", 46 | # Uses cosmic-settings-daemon to watch for config file changes 47 | "dbus-config", 48 | # Support creating additional application windows. 49 | "multi-window", 50 | # On app startup, focuses an existing instance if the app is already open 51 | "single-instance", 52 | # Uses tokio as the executor for the runtime 53 | "tokio", 54 | # Windowing support for X11, Windows, Mac, & Redox 55 | "winit", 56 | # Add Wayland support to winit 57 | "wayland", 58 | # About context drawer support 59 | "about", 60 | ] 61 | 62 | # Uncomment to test a locally-cloned libcosmic 63 | # [patch.'https://github.com/pop-os/libcosmic'] 64 | # libcosmic = { path = "../libcosmic" } 65 | # cosmic-config = { path = "../libcosmic/cosmic-config" } 66 | # cosmic-theme = { path = "../libcosmic/cosmic-theme" } 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Toot 2 | Toot is a Mastodon client for COSMIC. 3 | 4 | ## Dependencies 5 | - libsecret-1-dev 6 | -------------------------------------------------------------------------------- /assets/missing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmic-utils/toot/01235ad71025a3549cd008b0fe5d0d6e425e3d07/assets/missing.png -------------------------------------------------------------------------------- /i18n.toml: -------------------------------------------------------------------------------- 1 | fallback_language = "en" 2 | 3 | [fluent] 4 | assets_dir = "i18n" -------------------------------------------------------------------------------- /i18n/en/cosmic_ext_toot.ftl: -------------------------------------------------------------------------------- 1 | app-title = Toot 2 | about = About 3 | view = View 4 | 5 | ## Navbar 6 | home = Home 7 | notifications = Notifications 8 | search = Search 9 | favorites = Favorites 10 | bookmarks = Bookmarks 11 | hashtags = Hashtags 12 | lists = Lists 13 | explore = Explore 14 | local = Local 15 | federated = Federated 16 | 17 | ## About 18 | repository = Repository 19 | support = Support 20 | 21 | ## Login 22 | server-question = What's your server? 23 | server-description = If you don't have an account yet, register to a server of your choice. 24 | server-url = Server URL 25 | continue = Continue 26 | 27 | ## Authorization 28 | confirm-authorization = Confirm authorization 29 | confirm-authorization-description = Copy the authorization code from the browser and paste it here. 30 | authorization-code = Authorization code 31 | 32 | ## Context 33 | about = About 34 | profile = Profile 35 | status = Status 36 | 37 | ## Dialogs 38 | switch-instance = Switch instance 39 | logout-question = Are you sure you want to logout? 40 | logout-description = You will need to login again to access your account. 41 | 42 | ## Actions 43 | reply = Reply 44 | cancel = Cancel 45 | login = Login 46 | confirm = Confirm 47 | -------------------------------------------------------------------------------- /i18n/nl/cosmic_ext_toot.ftl: -------------------------------------------------------------------------------- 1 | app-title = Toot 2 | about = Over 3 | view = Beeld 4 | 5 | ## Navbar 6 | home = Startpagina 7 | notifications = Meldingen 8 | search = Zoeken 9 | favorites = Favorieten 10 | bookmarks = Bladwijzers 11 | hashtags = Hashtags 12 | lists = Lijsten 13 | explore = Ontdekken 14 | local = Lokaal 15 | federated = Gefedereerd 16 | 17 | ## About 18 | repository = Repository 19 | support = Ondersteuning 20 | 21 | ## Login 22 | server-question = Wat is uw server? 23 | server-description = Als u nog geen account heeft, registreert u zich dan op een server naar keuze. 24 | server-url = Server-URL 25 | continue = Ga verder 26 | 27 | ## Authorization 28 | confirm-authorization = Autorisatie bevestigen 29 | confirm-authorization-description = Kopieer de autorisatiecode uit uw browser en plak die hier. 30 | authorization-code = Autorisatiecode 31 | 32 | ## Context 33 | about = Over 34 | profile = Profiel 35 | status = Status 36 | 37 | ## Dialogs 38 | switch-instance = Naar andere instantie overschakelen 39 | logout-question = Weet u zeker dat u wilt uitloggen? 40 | logout-description = U moet dan opnieuw inloggen om toegang te krijgen tot uw account. 41 | 42 | ## Actions 43 | reply = Reageren 44 | cancel = Annuleren 45 | login = Inloggen 46 | confirm = Bevestigen 47 | -------------------------------------------------------------------------------- /i18n/sv/cosmic_ext_toot.ftl: -------------------------------------------------------------------------------- 1 | app-title = Toot 2 | about = Om 3 | view = Visa 4 | 5 | ## Navigeringsfältet 6 | home = Hem 7 | notifications = Aviseringar 8 | search = Sök 9 | favorites = Favoriter 10 | bookmarks = Bokmärken 11 | hashtags = Hashtaggar 12 | lists = Listor 13 | explore = Utforska 14 | local = Lokal 15 | federated = Federerat 16 | 17 | ## Om 18 | repository = Förråd 19 | support = Support 20 | 21 | ## Inloggning 22 | server-question = Vad är din server? 23 | server-description = Om du inte har ett konto ännu, registrera dig på en valfri server. 24 | server-url = Server webbadress 25 | continue = Fortsätt 26 | 27 | ## Auktorisation 28 | confirm-authorization = Bekräfta auktorisation 29 | confirm-authorization-description = Kopiera auktoriseringskoden från webbläsaren och klistra in den här. 30 | authorization-code = Auktoriseringskod 31 | 32 | ## Kontext 33 | about = Om 34 | profile = Profil 35 | status = Status 36 | 37 | ## Åtgärder 38 | reply = Svara 39 | cancel = Avbryt 40 | login = Logga in 41 | confirm = Bekräfta 42 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | name := 'cosmic-ext-toot' 2 | appid := 'dev.edfloreshz.Toot' 3 | 4 | rootdir := '' 5 | prefix := '/usr' 6 | 7 | base-dir := absolute_path(clean(rootdir / prefix)) 8 | 9 | bin-src := 'target' / 'release' / name 10 | bin-dst := base-dir / 'bin' / name 11 | 12 | desktop := appid + '.desktop' 13 | desktop-src := 'res' / desktop 14 | desktop-dst := clean(rootdir / prefix) / 'share' / 'applications' / desktop 15 | 16 | icons-src := 'res' / 'icons' / 'hicolor' 17 | icons-dst := clean(rootdir / prefix) / 'share' / 'icons' / 'hicolor' 18 | 19 | icon-svg-src := icons-src / 'scalable' / 'apps' / 'icon.svg' 20 | icon-svg-dst := icons-dst / 'scalable' / 'apps' / appid + '.svg' 21 | 22 | # Default recipe which runs `just build-release` 23 | default: build-release 24 | 25 | # Runs `cargo clean` 26 | clean: 27 | cargo clean 28 | 29 | # Removes vendored dependencies 30 | clean-vendor: 31 | rm -rf .cargo vendor vendor.tar 32 | 33 | # `cargo clean` and removes vendored dependencies 34 | clean-dist: clean clean-vendor 35 | 36 | # Compiles with debug profile 37 | build-debug *args: 38 | cargo build {{args}} 39 | 40 | # Compiles with release profile 41 | build-release *args: (build-debug '--release' args) 42 | 43 | # Compiles release profile with vendored dependencies 44 | build-vendored *args: vendor-extract (build-release '--frozen --offline' args) 45 | 46 | # Runs a clippy check 47 | check *args: 48 | cargo clippy --all-features {{args}} -- -W clippy::pedantic 49 | 50 | # Runs a clippy check with JSON message format 51 | check-json: (check '--message-format=json') 52 | 53 | # Run the application for testing purposes 54 | run *args: 55 | env RUST_BACKTRACE=full cargo run --release {{args}} 56 | 57 | # Installs files 58 | install: 59 | install -Dm0755 {{bin-src}} {{bin-dst}} 60 | install -Dm0644 res/app.desktop {{desktop-dst}} 61 | install -Dm0644 {{icon-svg-src}} {{icon-svg-dst}} 62 | 63 | # Uninstalls installed files 64 | uninstall: 65 | rm {{bin-dst}} {{desktop-dst}} {{icon-svg-dst}} 66 | 67 | # Vendor dependencies locally 68 | vendor: 69 | #!/usr/bin/env bash 70 | mkdir -p .cargo 71 | cargo vendor --sync Cargo.toml | head -n -1 > .cargo/config.toml 72 | echo 'directory = "vendor"' >> .cargo/config.toml 73 | echo >> .cargo/config.toml 74 | echo '[env]' >> .cargo/config.toml 75 | if [ -n "${SOURCE_DATE_EPOCH}" ] 76 | then 77 | source_date="$(date -d "@${SOURCE_DATE_EPOCH}" "+%Y-%m-%d")" 78 | echo "VERGEN_GIT_COMMIT_DATE = \"${source_date}\"" >> .cargo/config.toml 79 | fi 80 | if [ -n "${SOURCE_GIT_HASH}" ] 81 | then 82 | echo "VERGEN_GIT_SHA = \"${SOURCE_GIT_HASH}\"" >> .cargo/config.toml 83 | fi 84 | tar pcf vendor.tar .cargo vendor 85 | rm -rf .cargo vendor 86 | 87 | # Extracts vendored dependencies 88 | vendor-extract: 89 | rm -rf vendor 90 | tar pxf vendor.tar 91 | -------------------------------------------------------------------------------- /res/app.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Toot 3 | Exec=cosmic-ext-toot %u 4 | Terminal=false 5 | Type=Application 6 | StartupNotify=true 7 | Icon=dev.edfloreshz.Toot 8 | Categories=COSMIC; 9 | Keywords=toot;mastodon;fediverse; 10 | MimeType=x-scheme-handler/toot; 11 | -------------------------------------------------------------------------------- /res/icons/hicolor/scalable/apps/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: {{LICENSE}} 2 | 3 | use crate::config::TootConfig; 4 | use crate::pages::public::TimelineType; 5 | use crate::pages::Page; 6 | use crate::utils::{self, Cache}; 7 | use crate::widgets::status::StatusOptions; 8 | use crate::{fl, pages, widgets}; 9 | use cosmic::app::{context_drawer, Core, Task}; 10 | use cosmic::cosmic_config; 11 | use cosmic::iced::alignment::{Horizontal, Vertical}; 12 | use cosmic::iced::{Length, Subscription}; 13 | use cosmic::widget::about::About; 14 | use cosmic::widget::image::Handle; 15 | use cosmic::widget::menu::{ItemHeight, ItemWidth}; 16 | use cosmic::widget::{self, menu, nav_bar}; 17 | use cosmic::{Application, ApplicationExt, Apply, Element}; 18 | use mastodon_async::helpers::toml; 19 | use mastodon_async::prelude::{Account, Notification, Scopes, Status, StatusId}; 20 | use mastodon_async::registration::Registered; 21 | use mastodon_async::{Data, Mastodon, NewStatus, Registration}; 22 | use reqwest::Url; 23 | use std::collections::{HashMap, VecDeque}; 24 | use std::str::FromStr; 25 | 26 | const REPOSITORY: &str = "https://github.com/edfloreshz/toot"; 27 | const SUPPORT: &str = "https://github.com/edfloreshz/toot/issues"; 28 | 29 | pub struct AppModel { 30 | core: Core, 31 | about: About, 32 | nav: nav_bar::Model, 33 | context_page: ContextPage, 34 | key_binds: HashMap, 35 | dialog_pages: VecDeque, 36 | dialog_editor: widget::text_editor::Content, 37 | config: TootConfig, 38 | handler: Option, 39 | instance: String, 40 | code: String, 41 | registration: Option, 42 | mastodon: Mastodon, 43 | cache: Cache, 44 | home: pages::home::Home, 45 | notifications: pages::notifications::Notifications, 46 | explore: pages::public::Public, 47 | local: pages::public::Public, 48 | federated: pages::public::Public, 49 | } 50 | 51 | #[derive(Debug, Clone)] 52 | pub enum Message { 53 | Open(String), 54 | ToggleContextPage(ContextPage), 55 | ToggleContextDrawer, 56 | UpdateConfig(TootConfig), 57 | InstanceEdit, 58 | RegisterMastodonClient, 59 | CompleteRegistration, 60 | StoreMastodonData(Mastodon), 61 | StoreRegistration(Option), 62 | Home(pages::home::Message), 63 | Notifications(pages::notifications::Message), 64 | Explore(pages::public::Message), 65 | Local(pages::public::Message), 66 | Federated(pages::public::Message), 67 | Account(widgets::account::Message), 68 | Status(widgets::status::Message), 69 | Fetch(Vec), 70 | CacheStatus(Status), 71 | CacheNotification(Notification), 72 | CacheHandle(Url, Handle), 73 | Dialog(DialogAction), 74 | EditorAction(widget::text_editor::Action), 75 | UpdateMastodonInstance, 76 | None, 77 | } 78 | 79 | #[derive(Debug, Clone)] 80 | pub enum DialogAction { 81 | Open(Dialog), 82 | Update(Dialog), 83 | Close, 84 | Complete, 85 | } 86 | 87 | #[derive(Debug, Clone)] 88 | pub enum Dialog { 89 | Reply(NewStatus), 90 | SwitchInstance(String), 91 | Login(String), 92 | Code(String), 93 | Logout, 94 | } 95 | 96 | pub struct Flags { 97 | pub config: TootConfig, 98 | pub handler: Option, 99 | } 100 | 101 | impl Application for AppModel { 102 | type Executor = cosmic::executor::multi::Executor; 103 | type Flags = Flags; 104 | type Message = Message; 105 | const APP_ID: &'static str = "dev.edfloreshz.Toot"; 106 | 107 | fn core(&self) -> &Core { 108 | &self.core 109 | } 110 | 111 | fn core_mut(&mut self) -> &mut Core { 112 | &mut self.core 113 | } 114 | 115 | fn init(core: Core, flags: Self::Flags) -> (Self, Task) { 116 | let mut nav = nav_bar::Model::default(); 117 | 118 | let instance = instance(flags.config.server.clone()); 119 | 120 | let mastodon = match keytar::get_password(Self::APP_ID, "mastodon-data") { 121 | Ok(data) => { 122 | if data.success { 123 | let data: Data = toml::from_str(&data.password).unwrap(); 124 | Mastodon::from(data) 125 | } else { 126 | Mastodon::from(Data { 127 | base: instance.into(), 128 | ..Default::default() 129 | }) 130 | } 131 | } 132 | Err(err) => { 133 | tracing::error!("{err}"); 134 | Mastodon::from(Data { 135 | base: instance.into(), 136 | ..Default::default() 137 | }) 138 | } 139 | }; 140 | 141 | let variants = mastodon 142 | .data 143 | .token 144 | .is_empty() 145 | .then(|| Page::public_variants()) 146 | .unwrap_or_else(|| Page::variants()); 147 | 148 | for page in variants { 149 | let id = nav 150 | .insert() 151 | .text(page.to_string()) 152 | .icon(widget::icon::from_name(page.icon())) 153 | .data::(page.clone()) 154 | .id(); 155 | 156 | if page == Page::default() { 157 | nav.activate(id); 158 | } 159 | } 160 | 161 | let about = About::default() 162 | .name(fl!("app-title")) 163 | .version("0.1.0") 164 | .icon(Self::APP_ID) 165 | .author("Eduardo Flores") 166 | .developers([("Eduardo Flores", "edfloreshz@proton.me")]) 167 | .links([(fl!("repository"), REPOSITORY), (fl!("support"), SUPPORT)]); 168 | 169 | let mut app = AppModel { 170 | core, 171 | about, 172 | nav, 173 | context_page: ContextPage::default(), 174 | key_binds: HashMap::new(), 175 | dialog_pages: VecDeque::new(), 176 | dialog_editor: widget::text_editor::Content::default(), 177 | config: flags.config.clone(), 178 | handler: flags.handler, 179 | instance: flags.config.server, 180 | code: String::new(), 181 | registration: None, 182 | mastodon: mastodon.clone(), 183 | cache: Cache::new(), 184 | home: pages::home::Home::new(mastodon.clone()), 185 | notifications: pages::notifications::Notifications::new(mastodon.clone()), 186 | explore: pages::public::Public::new(mastodon.clone(), TimelineType::Public), 187 | local: pages::public::Public::new(mastodon.clone(), TimelineType::Local), 188 | federated: pages::public::Public::new(mastodon.clone(), TimelineType::Remote), 189 | }; 190 | 191 | app.nav.activate_position(0); 192 | 193 | let tasks = vec![app.update_title()]; 194 | 195 | (app, Task::batch(tasks)) 196 | } 197 | 198 | fn header_start(&self) -> Vec> { 199 | let spacing = cosmic::theme::active().cosmic().spacing; 200 | let menu_bar = menu::bar(vec![menu::Tree::with_children( 201 | menu::root(fl!("view")), 202 | menu::items( 203 | &self.key_binds, 204 | vec![menu::Item::Button( 205 | fl!("about"), 206 | Some(widget::icon::from_name("help-info-symbolic").into()), 207 | MenuAction::About, 208 | )], 209 | ), 210 | )]) 211 | .item_height(ItemHeight::Dynamic(40)) 212 | .item_width(ItemWidth::Uniform(260)) 213 | .spacing(spacing.space_xxxs.into()); 214 | 215 | vec![menu_bar.into()] 216 | } 217 | 218 | fn header_center(&self) -> Vec> { 219 | vec![widget::text(self.instance.clone()).into()] 220 | } 221 | 222 | fn header_end(&self) -> Vec> { 223 | if self.mastodon.data.token.is_empty() { 224 | vec![ 225 | // widget::icon::from_name("network-server-symbolic") 226 | // .apply(widget::button::icon) 227 | // .on_press(Message::Dialog(DialogAction::Open(Dialog::SwitchInstance( 228 | // self.instance.clone(), 229 | // )))) 230 | // .into(), 231 | widget::icon::from_name("system-users-symbolic") 232 | .apply(widget::button::icon) 233 | .on_press(Message::Dialog(DialogAction::Open(Dialog::Login( 234 | self.instance.clone(), 235 | )))) 236 | .into(), 237 | ] 238 | } else { 239 | vec![widget::icon::from_name("system-log-out-symbolic") 240 | .apply(widget::button::icon) 241 | .on_press(Message::Dialog(DialogAction::Open(Dialog::Logout))) 242 | .into()] 243 | } 244 | } 245 | 246 | fn nav_model(&self) -> Option<&nav_bar::Model> { 247 | Some(&self.nav) 248 | } 249 | 250 | fn on_nav_select(&mut self, id: nav_bar::Id) -> Task { 251 | self.nav.activate(id); 252 | let mut tasks = vec![]; 253 | match self.nav.data::(id).unwrap() { 254 | Page::Home => tasks.push( 255 | self.home 256 | .update(pages::home::Message::SetClient(self.mastodon.clone())), 257 | ), 258 | Page::Notifications => tasks.push(self.notifications.update( 259 | pages::notifications::Message::SetClient(self.mastodon.clone()), 260 | )), 261 | Page::Search => (), 262 | Page::Favorites => (), 263 | Page::Bookmarks => (), 264 | Page::Hashtags => (), 265 | Page::Lists => (), 266 | Page::Explore => tasks.push( 267 | self.explore 268 | .update(pages::public::Message::SetClient(self.mastodon.clone())), 269 | ), 270 | Page::Local => tasks.push( 271 | self.local 272 | .update(pages::public::Message::SetClient(self.mastodon.clone())), 273 | ), 274 | Page::Federated => tasks.push( 275 | self.federated 276 | .update(pages::public::Message::SetClient(self.mastodon.clone())), 277 | ), 278 | }; 279 | tasks.push(self.update_title()); 280 | Task::batch(tasks) 281 | } 282 | 283 | fn context_drawer(&self) -> Option> { 284 | if !self.core.window.show_context { 285 | return None; 286 | } 287 | 288 | Some(match &self.context_page { 289 | ContextPage::About => { 290 | context_drawer::about(&self.about, Message::Open, Message::ToggleContextDrawer) 291 | .title(self.context_page.title()) 292 | } 293 | ContextPage::Account(account) => { 294 | context_drawer::context_drawer(self.account(account), Message::ToggleContextDrawer) 295 | .title(self.context_page.title()) 296 | } 297 | ContextPage::Status(status) => { 298 | context_drawer::context_drawer(self.status(status), Message::ToggleContextDrawer) 299 | .title(self.context_page.title()) 300 | } 301 | }) 302 | } 303 | 304 | fn dialog(&self) -> Option> { 305 | let dialog_page = self.dialog_pages.front()?; 306 | 307 | let spacing = cosmic::theme::active().cosmic().spacing; 308 | 309 | let dialog = match dialog_page { 310 | Dialog::Reply(new_status) => widget::dialog() 311 | .title(fl!("reply")) 312 | .control( 313 | widget::container( 314 | widget::scrollable( 315 | widget::column() 316 | .push_maybe( 317 | self.cache 318 | .statuses 319 | .get(&new_status.in_reply_to_id.clone().unwrap()) 320 | .map(|status| { 321 | widgets::status( 322 | status, 323 | StatusOptions::none(), 324 | &self.cache, 325 | ) 326 | .map(Message::Status) 327 | .apply(widget::container) 328 | .class(cosmic::style::Container::Card) 329 | }), 330 | ) 331 | .push( 332 | widget::text_editor(&self.dialog_editor) 333 | .height(200.) 334 | .padding(spacing.space_s) 335 | .on_action(Message::EditorAction), 336 | ) 337 | .spacing(spacing.space_xs), 338 | ) 339 | .width(Length::Fill), 340 | ) 341 | .height(Length::Fixed(400.0)) 342 | .width(Length::Fill), 343 | ) 344 | .primary_action( 345 | widget::button::suggested(fl!("reply")) 346 | .on_press_maybe(Some(Message::Dialog(DialogAction::Complete))), 347 | ) 348 | .secondary_action( 349 | widget::button::standard(fl!("cancel")) 350 | .on_press(Message::Dialog(DialogAction::Close)), 351 | ), 352 | Dialog::SwitchInstance(instance) => self.switch_instance(instance.clone()), 353 | Dialog::Login(instance) => self.login(instance.clone()), 354 | Dialog::Code(code) => self.code(code.clone()), 355 | Dialog::Logout => self.logout(), 356 | }; 357 | 358 | Some(dialog.into()) 359 | } 360 | 361 | fn on_escape(&mut self) -> Task { 362 | if self.dialog_pages.pop_front().is_some() { 363 | return Task::none(); 364 | } 365 | 366 | if self.core.window.show_context { 367 | self.core.window.show_context = false; 368 | } 369 | 370 | Task::none() 371 | } 372 | 373 | fn view(&self) -> Element { 374 | match self.nav.active_data::() { 375 | Some(page) => match page { 376 | Page::Home => self.home.view(&self.cache).map(Message::Home), 377 | Page::Notifications => self 378 | .notifications 379 | .view(&self.cache) 380 | .map(Message::Notifications), 381 | Page::Explore => self.explore.view(&self.cache).map(Message::Explore), 382 | Page::Local => self.local.view(&self.cache).map(Message::Local), 383 | Page::Federated => self.federated.view(&self.cache).map(Message::Federated), 384 | _ => widget::text("Not yet implemented").into(), 385 | }, 386 | None => widget::text("Select a page").into(), 387 | } 388 | .apply(widget::container) 389 | .width(Length::Fill) 390 | .height(Length::Fill) 391 | .align_x(Horizontal::Center) 392 | .align_y(Vertical::Center) 393 | .into() 394 | } 395 | 396 | fn subscription(&self) -> Subscription { 397 | let mut subscriptions = vec![self 398 | .core() 399 | .watch_config::(Self::APP_ID) 400 | .map(|update| Message::UpdateConfig(update.config))]; 401 | 402 | match self.nav.active_data::() { 403 | Some(Page::Home) => subscriptions.push(self.home.subscription().map(Message::Home)), 404 | Some(Page::Notifications) => subscriptions.push( 405 | self.notifications 406 | .subscription() 407 | .map(Message::Notifications), 408 | ), 409 | Some(Page::Search) => (), 410 | Some(Page::Favorites) => (), 411 | Some(Page::Bookmarks) => (), 412 | Some(Page::Hashtags) => (), 413 | Some(Page::Lists) => (), 414 | Some(Page::Explore) => { 415 | subscriptions.push(self.explore.subscription().map(Message::Explore)) 416 | } 417 | Some(Page::Local) => subscriptions.push(self.local.subscription().map(Message::Local)), 418 | Some(Page::Federated) => { 419 | subscriptions.push(self.federated.subscription().map(Message::Federated)) 420 | } 421 | None => (), 422 | }; 423 | 424 | if !self.mastodon.data.token.is_empty() { 425 | subscriptions.push(crate::subscriptions::stream_user_events( 426 | self.mastodon.clone(), 427 | )); 428 | } 429 | 430 | Subscription::batch(subscriptions) 431 | } 432 | 433 | fn update(&mut self, message: Self::Message) -> Task { 434 | let mut tasks = vec![]; 435 | match message { 436 | Message::Home(message) => { 437 | tasks.push(self.home.update(message)); 438 | } 439 | Message::Notifications(message) => { 440 | tasks.push(self.notifications.update(message)); 441 | } 442 | Message::Explore(message) => { 443 | tasks.push(self.explore.update(message.clone())); 444 | } 445 | Message::Local(message) => { 446 | tasks.push(self.local.update(message)); 447 | } 448 | Message::Federated(message) => { 449 | tasks.push(self.federated.update(message)); 450 | } 451 | Message::Account(message) => tasks.push(widgets::account::update(message)), 452 | Message::Status(message) => match message { 453 | widgets::status::Message::Favorite(status_id, favorited) => { 454 | let mastodon = self.mastodon.clone(); 455 | tasks.push(cosmic::task::future(async move { 456 | let result = if favorited { 457 | mastodon.unfavourite(&status_id).await 458 | } else { 459 | mastodon.favourite(&status_id).await 460 | }; 461 | match result { 462 | Ok(status) => Message::CacheStatus(status), 463 | Err(err) => { 464 | tracing::error!("{err}"); 465 | Message::None 466 | } 467 | } 468 | })) 469 | } 470 | widgets::status::Message::Boost(status_id, boosted) => { 471 | let mastodon = self.mastodon.clone(); 472 | tasks.push(cosmic::task::future(async move { 473 | let result = if boosted { 474 | mastodon.unreblog(&status_id).await 475 | } else { 476 | mastodon.reblog(&status_id).await 477 | }; 478 | match result { 479 | Ok(status) => Message::CacheStatus(status), 480 | Err(err) => { 481 | tracing::error!("{err}"); 482 | Message::None 483 | } 484 | } 485 | })) 486 | } 487 | widgets::status::Message::OpenLink(_) => todo!(), 488 | _ => tasks.push(widgets::status::update(message)), 489 | }, 490 | Message::CacheHandle(url, handle) => { 491 | self.cache.insert_handle(url.clone(), handle); 492 | } 493 | Message::CacheStatus(status) => { 494 | self.cache.insert_status(status.clone()); 495 | } 496 | Message::CacheNotification(notification) => { 497 | self.cache.insert_notification(notification.clone()); 498 | } 499 | Message::Fetch(urls) => { 500 | for url in urls { 501 | if !self.cache.handles.contains_key(&url) { 502 | tasks.push(cosmic::task::future(async move { 503 | let result = match utils::get(&url).await { 504 | Ok(handle) => Some((url, handle)), 505 | Err(err) => { 506 | tracing::error!("Failed to fetch image: {}", err); 507 | None 508 | } 509 | }; 510 | match result { 511 | Some((url, handle)) => { 512 | Message::CacheHandle(url.clone(), handle.clone()) 513 | } 514 | None => Message::None, 515 | } 516 | })); 517 | } 518 | } 519 | } 520 | Message::InstanceEdit => { 521 | let instance = self.instance.clone(); 522 | if let Some(ref handler) = self.handler { 523 | match self.config.set_server(handler, instance) { 524 | Ok(true) => (), 525 | Ok(false) => tracing::error!("Failed to write config"), 526 | Err(err) => tracing::error!("{err}"), 527 | } 528 | } 529 | } 530 | Message::RegisterMastodonClient => { 531 | let mut registration = Registration::new(self.config.url()); 532 | tasks.push(cosmic::task::future(async move { 533 | let scopes = Scopes::from_str("read write").unwrap(); 534 | match registration 535 | .client_name("Toot") 536 | .scopes(scopes) 537 | .build() 538 | .await 539 | { 540 | Ok(registration) => Message::StoreRegistration(Some(registration)), 541 | Err(err) => { 542 | tracing::error!("{err}"); 543 | Message::None 544 | } 545 | } 546 | })); 547 | } 548 | Message::StoreRegistration(registration) => { 549 | if let Some(ref registration) = registration { 550 | if let Ok(url) = registration.authorize_url() { 551 | if let Err(err) = open::that_detached(url) { 552 | tracing::error!("{err}"); 553 | } 554 | } 555 | } 556 | self.registration = registration; 557 | } 558 | Message::CompleteRegistration => { 559 | if let Some(registration) = self.registration.take() { 560 | let code = self.code.clone(); 561 | let task = cosmic::task::future(async move { 562 | match registration.complete(code).await { 563 | Ok(mastodon) => Message::StoreMastodonData(mastodon), 564 | Err(err) => { 565 | tracing::error!("{err}"); 566 | Message::None 567 | } 568 | } 569 | }); 570 | tasks.push(task); 571 | } 572 | } 573 | Message::StoreMastodonData(mastodon) => { 574 | let data = &toml::to_string(&mastodon.data).unwrap(); 575 | match keytar::set_password(Self::APP_ID, "mastodon-data", data) { 576 | Ok(_) => { 577 | self.mastodon = mastodon; 578 | self.update_navbar(); 579 | tasks.push(self.on_nav_select(self.nav.active())); 580 | } 581 | Err(err) => tracing::error!("{err}"), 582 | } 583 | } 584 | Message::UpdateMastodonInstance => { 585 | self.mastodon = Mastodon::from(Data { 586 | base: self.instance().clone().into(), 587 | ..Default::default() 588 | }); 589 | } 590 | Message::Open(url) => { 591 | if let Err(err) = open::that_detached(url) { 592 | tracing::error!("{err}") 593 | } 594 | } 595 | Message::ToggleContextPage(context_page) => { 596 | if self.context_page == context_page { 597 | self.core.window.show_context = !self.core.window.show_context; 598 | } else { 599 | self.context_page = context_page; 600 | self.core.window.show_context = true; 601 | } 602 | } 603 | Message::ToggleContextDrawer => { 604 | self.core.window.show_context = !self.core.window.show_context; 605 | } 606 | Message::Dialog(action) => match action { 607 | DialogAction::Open(dialog) => match dialog { 608 | Dialog::Reply(new_status) => { 609 | if let Some(status) = new_status.status.clone() { 610 | self.dialog_editor = widget::text_editor::Content::with_text(&status); 611 | } 612 | self.dialog_pages.push_back(Dialog::Reply(new_status)) 613 | } 614 | _ => self.dialog_pages.push_back(dialog), 615 | }, 616 | DialogAction::Update(dialog_page) => { 617 | self.dialog_pages[0] = dialog_page; 618 | } 619 | DialogAction::Close => { 620 | self.dialog_pages.pop_front(); 621 | } 622 | DialogAction::Complete => { 623 | if let Some(dialog_page) = self.dialog_pages.pop_front() { 624 | match dialog_page { 625 | Dialog::Reply(mut new_status) => { 626 | new_status.status = Some(self.dialog_editor.text()); 627 | let mastodon = self.mastodon.clone(); 628 | tasks.push(cosmic::task::future(async move { 629 | match mastodon.new_status(new_status).await { 630 | Ok(status) => Message::CacheStatus(status), 631 | Err(err) => { 632 | tracing::error!("{err}"); 633 | Message::None 634 | } 635 | } 636 | })); 637 | } 638 | Dialog::SwitchInstance(instance) => { 639 | self.instance = instance; 640 | tasks.push(self.update(Message::InstanceEdit)); 641 | tasks.push(self.update(Message::UpdateMastodonInstance)) 642 | } 643 | Dialog::Login(instance) => { 644 | self.instance = instance; 645 | tasks.push(self.update(Message::InstanceEdit)); 646 | tasks.push(self.update(Message::RegisterMastodonClient)); 647 | tasks.push(self.update(Message::Dialog(DialogAction::Open( 648 | Dialog::Code(String::new()), 649 | )))) 650 | } 651 | Dialog::Code(code) => { 652 | self.code = code; 653 | tasks.push(self.update(Message::CompleteRegistration)) 654 | } 655 | Dialog::Logout => { 656 | self.mastodon = Mastodon::from(Data { 657 | base: self.instance().into(), 658 | ..Default::default() 659 | }); 660 | self.update_navbar(); 661 | if let Err(err) = 662 | keytar::delete_password(Self::APP_ID, "mastodon-data") 663 | { 664 | tracing::error!("{err}"); 665 | } 666 | } 667 | } 668 | } 669 | } 670 | }, 671 | Message::EditorAction(action) => { 672 | self.dialog_editor.perform(action); 673 | } 674 | Message::UpdateConfig(config) => { 675 | self.config = config; 676 | } 677 | Message::None => (), 678 | } 679 | Task::batch(tasks) 680 | } 681 | } 682 | 683 | impl AppModel { 684 | pub fn update_title(&mut self) -> Task { 685 | let mut window_title = fl!("app-title"); 686 | if let Some(page) = self.nav.text(self.nav.active()) { 687 | window_title.push_str(" — "); 688 | window_title.push_str(page); 689 | } 690 | if let Some(id) = self.core.main_window_id() { 691 | self.set_window_title(window_title, id) 692 | } else { 693 | Task::none() 694 | } 695 | } 696 | 697 | fn switch_instance(&self, instance: String) -> widget::Dialog { 698 | widget::dialog() 699 | .title(fl!("server-question")) 700 | .body(fl!("server-description")) 701 | .icon(widget::icon::from_name("network-server-symbolic")) 702 | .control( 703 | widget::text_input(fl!("server-url"), instance) 704 | .on_input(|instance| { 705 | Message::Dialog(DialogAction::Update(Dialog::SwitchInstance(instance))) 706 | }) 707 | .on_submit(Message::Dialog(DialogAction::Complete)), 708 | ) 709 | .primary_action( 710 | widget::button::suggested(fl!("confirm")) 711 | .on_press(Message::Dialog(DialogAction::Complete)), 712 | ) 713 | .secondary_action( 714 | widget::button::standard(fl!("cancel")) 715 | .on_press(Message::Dialog(DialogAction::Close)), 716 | ) 717 | } 718 | 719 | fn login(&self, instance: String) -> widget::Dialog { 720 | widget::dialog() 721 | .title(fl!("server-question")) 722 | .body(fl!("server-description")) 723 | .icon(widget::icon::from_name("network-server-symbolic")) 724 | .control( 725 | widget::text_input(fl!("server-url"), instance.clone()) 726 | .on_input(move |instance| { 727 | Message::Dialog(DialogAction::Update(Dialog::Login(instance.clone()))) 728 | }) 729 | .on_submit(Message::Dialog(DialogAction::Complete)), 730 | ) 731 | .primary_action( 732 | widget::button::suggested(fl!("continue")) 733 | .on_press(Message::Dialog(DialogAction::Complete)), 734 | ) 735 | .secondary_action( 736 | widget::button::standard(fl!("cancel")) 737 | .on_press(Message::Dialog(DialogAction::Close)), 738 | ) 739 | } 740 | 741 | fn code(&self, code: String) -> widget::Dialog { 742 | widget::dialog() 743 | .title(fl!("confirm-authorization")) 744 | .body(fl!("confirm-authorization-description")) 745 | .icon(widget::icon::from_name("network-server-symbolic")) 746 | .control( 747 | widget::text_input(fl!("authorization-code"), code.clone()) 748 | .on_input(|code| Message::Dialog(DialogAction::Update(Dialog::Code(code)))) 749 | .on_submit(Message::Dialog(DialogAction::Complete)), 750 | ) 751 | .primary_action( 752 | widget::button::suggested(fl!("confirm")) 753 | .on_press(Message::Dialog(DialogAction::Complete)), 754 | ) 755 | .secondary_action( 756 | widget::button::standard(fl!("cancel")) 757 | .on_press(Message::Dialog(DialogAction::Close)), 758 | ) 759 | } 760 | 761 | fn logout(&self) -> widget::Dialog { 762 | widget::dialog() 763 | .title(fl!("logout-question")) 764 | .body(fl!("logout-description")) 765 | .icon(widget::icon::from_name("system-log-out-symbolic")) 766 | .primary_action( 767 | widget::button::suggested(fl!("continue")) 768 | .on_press(Message::Dialog(DialogAction::Complete)), 769 | ) 770 | .secondary_action( 771 | widget::button::standard(fl!("cancel")) 772 | .on_press(Message::Dialog(DialogAction::Close)), 773 | ) 774 | } 775 | 776 | fn status(&self, id: &StatusId) -> Element { 777 | let status = self.cache.statuses.get(&id.to_string()).map(|status| { 778 | crate::widgets::status( 779 | status, 780 | StatusOptions::new(true, true, true, false), 781 | &self.cache, 782 | ) 783 | .map(pages::home::Message::Status) 784 | .map(Message::Home) 785 | .apply(widget::container) 786 | .class(cosmic::theme::Container::Dialog) 787 | }); 788 | widget::column().push_maybe(status).into() 789 | } 790 | 791 | fn account<'a>(&'a self, account: &'a Account) -> Element<'a, Message> { 792 | crate::widgets::account(account, &self.cache.handles).map(Message::Account) 793 | } 794 | } 795 | 796 | fn instance(instance: impl Into) -> String { 797 | let instance: String = instance.into(); 798 | instance 799 | .is_empty() 800 | .then(|| format!("https://{}", "mastodon.social".to_string())) 801 | .unwrap_or(format!("https://{}", instance)) 802 | } 803 | 804 | impl AppModel 805 | where 806 | Self: Application, 807 | { 808 | fn instance(&self) -> String { 809 | instance(self.instance.clone()) 810 | } 811 | 812 | fn update_navbar(&mut self) { 813 | self.nav.clear(); 814 | 815 | let variants = self 816 | .mastodon 817 | .data 818 | .token 819 | .is_empty() 820 | .then(|| Page::public_variants()) 821 | .unwrap_or_else(|| Page::variants()); 822 | 823 | for page in variants { 824 | self.nav 825 | .insert() 826 | .text(page.to_string()) 827 | .icon(widget::icon::from_name(page.icon())) 828 | .data::(page.clone()); 829 | 830 | self.nav.activate_position(0); 831 | } 832 | } 833 | } 834 | 835 | #[derive(Clone, Debug, Default, PartialEq)] 836 | pub enum ContextPage { 837 | #[default] 838 | About, 839 | Account(Account), 840 | Status(StatusId), 841 | } 842 | 843 | impl ContextPage { 844 | fn title(&self) -> String { 845 | match self { 846 | ContextPage::About => fl!("about"), 847 | ContextPage::Account(_) => fl!("profile"), 848 | ContextPage::Status(_) => fl!("status"), 849 | } 850 | } 851 | } 852 | 853 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 854 | pub enum MenuAction { 855 | About, 856 | } 857 | 858 | impl menu::action::MenuAction for MenuAction { 859 | type Message = Message; 860 | 861 | fn message(&self) -> Self::Message { 862 | match self { 863 | MenuAction::About => Message::ToggleContextPage(ContextPage::About), 864 | } 865 | } 866 | } 867 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: {{LICENSE}} 2 | 3 | use cosmic::{ 4 | cosmic_config::{self, cosmic_config_derive::CosmicConfigEntry, Config, CosmicConfigEntry}, 5 | Application, 6 | }; 7 | 8 | use crate::app::AppModel; 9 | 10 | #[derive(Debug, Default, Clone, CosmicConfigEntry, Eq, PartialEq)] 11 | #[version = 1] 12 | pub struct TootConfig { 13 | pub server: String, 14 | } 15 | 16 | impl TootConfig { 17 | pub fn config_handler() -> Option { 18 | Config::new(AppModel::APP_ID, TootConfig::VERSION).ok() 19 | } 20 | 21 | pub fn config() -> TootConfig { 22 | match Self::config_handler() { 23 | Some(config_handler) => { 24 | TootConfig::get_entry(&config_handler).unwrap_or_else(|(errs, config)| { 25 | tracing::error!("errors loading config: {:?}", errs); 26 | config 27 | }) 28 | } 29 | None => TootConfig::default(), 30 | } 31 | } 32 | 33 | pub fn url(&self) -> String { 34 | format!("https://{}", self.server) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Debug, Error)] 4 | pub enum Error { 5 | #[error("Mastodon API error: {0}")] 6 | Mastodon(#[from] mastodon_async::Error), 7 | #[error("Iced error: {0}")] 8 | Iced(#[from] cosmic::iced::Error), 9 | #[error("Reqwest error: {0}")] 10 | Reqwest(#[from] reqwest::Error), 11 | } 12 | -------------------------------------------------------------------------------- /src/i18n.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: {{LICENSE}} 2 | 3 | //! Provides localization support for this crate. 4 | 5 | use std::sync::LazyLock; 6 | 7 | use i18n_embed::{ 8 | fluent::{fluent_language_loader, FluentLanguageLoader}, 9 | unic_langid::LanguageIdentifier, 10 | DefaultLocalizer, LanguageLoader, Localizer, 11 | }; 12 | use rust_embed::RustEmbed; 13 | 14 | /// Applies the requested language(s) to requested translations from the `fl!()` macro. 15 | pub fn init(requested_languages: &[LanguageIdentifier]) { 16 | if let Err(why) = localizer().select(requested_languages) { 17 | eprintln!("error while loading fluent localizations: {why}"); 18 | } 19 | } 20 | 21 | // Get the `Localizer` to be used for localizing this library. 22 | #[must_use] 23 | pub fn localizer() -> Box { 24 | Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations)) 25 | } 26 | 27 | #[derive(RustEmbed)] 28 | #[folder = "i18n/"] 29 | struct Localizations; 30 | 31 | pub static LANGUAGE_LOADER: LazyLock = LazyLock::new(|| { 32 | let loader: FluentLanguageLoader = fluent_language_loader!(); 33 | 34 | loader 35 | .load_fallback_language(&Localizations) 36 | .expect("Error while loading fallback language"); 37 | 38 | loader 39 | }); 40 | 41 | /// Request a localized string by ID from the i18n/ directory. 42 | #[macro_export] 43 | macro_rules! fl { 44 | ($message_id:literal) => {{ 45 | i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id) 46 | }}; 47 | 48 | ($message_id:literal, $($args:expr),*) => {{ 49 | i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, $($args), *) 50 | }}; 51 | } 52 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: {{LICENSE}} 2 | 3 | use error::Error; 4 | 5 | mod app; 6 | mod config; 7 | mod error; 8 | mod i18n; 9 | mod pages; 10 | mod settings; 11 | mod subscriptions; 12 | mod utils; 13 | mod widgets; 14 | 15 | fn main() -> Result<(), Error> { 16 | settings::init(); 17 | cosmic::app::run::(settings::settings(), settings::flags()).map_err(Error::Iced) 18 | } 19 | -------------------------------------------------------------------------------- /src/pages.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::fl; 4 | 5 | pub mod home; 6 | pub mod notifications; 7 | pub mod public; 8 | 9 | pub trait MastodonPage { 10 | fn is_authenticated(&self) -> bool; 11 | } 12 | 13 | #[derive(Debug, Clone, Default, PartialEq)] 14 | pub enum Page { 15 | #[default] 16 | Home, 17 | Notifications, 18 | Search, 19 | Favorites, 20 | Bookmarks, 21 | Hashtags, 22 | Lists, 23 | Explore, 24 | Local, 25 | Federated, 26 | } 27 | 28 | impl Display for Page { 29 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 30 | match self { 31 | Page::Home => write!(f, "{}", fl!("home")), 32 | Page::Notifications => write!(f, "{}", fl!("notifications")), 33 | Page::Search => write!(f, "{}", fl!("search")), 34 | Page::Favorites => write!(f, "{}", fl!("favorites")), 35 | Page::Bookmarks => write!(f, "{}", fl!("bookmarks")), 36 | Page::Hashtags => write!(f, "{}", fl!("hashtags")), 37 | Page::Lists => write!(f, "{}", fl!("lists")), 38 | Page::Explore => write!(f, "{}", fl!("explore")), 39 | Page::Local => write!(f, "{}", fl!("local")), 40 | Page::Federated => write!(f, "{}", fl!("federated")), 41 | } 42 | } 43 | } 44 | 45 | impl Page { 46 | pub fn public_variants() -> Vec { 47 | vec![ 48 | Self::Explore, 49 | Self::Local, 50 | Self::Federated, 51 | Self::Search, 52 | Self::Hashtags, 53 | ] 54 | } 55 | 56 | pub fn variants() -> Vec { 57 | vec![ 58 | Self::Home, 59 | Self::Notifications, 60 | Self::Search, 61 | Self::Favorites, 62 | Self::Bookmarks, 63 | Self::Hashtags, 64 | Self::Lists, 65 | Self::Explore, 66 | Self::Local, 67 | Self::Federated, 68 | ] 69 | } 70 | 71 | pub fn icon(&self) -> &str { 72 | match self { 73 | Page::Home => "user-home-symbolic", 74 | Page::Notifications => "emblem-important-symbolic", 75 | Page::Search => "folder-saved-search-symbolic", 76 | Page::Favorites => "starred-symbolic", 77 | Page::Bookmarks => "bookmark-new-symbolic", 78 | Page::Hashtags => "lang-include-symbolic", 79 | Page::Lists => "view-list-symbolic", 80 | Page::Explore => "find-location-symbolic", 81 | Page::Local => "network-server-symbolic", 82 | Page::Federated => "network-workgroup-symbolic", 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/pages/home.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | use cosmic::{ 4 | app::command::Task, 5 | iced::{Length, Subscription}, 6 | iced_widget::scrollable::{Direction, Scrollbar}, 7 | widget, Apply, Element, 8 | }; 9 | use mastodon_async::prelude::{Mastodon, Status, StatusId}; 10 | 11 | use crate::{ 12 | app, 13 | utils::{self, Cache}, 14 | widgets::{self, status::StatusOptions}, 15 | }; 16 | 17 | use super::MastodonPage; 18 | 19 | #[derive(Debug, Clone)] 20 | pub struct Home { 21 | pub mastodon: Mastodon, 22 | statuses: VecDeque, 23 | skip: usize, 24 | loading: bool, 25 | } 26 | 27 | #[derive(Debug, Clone)] 28 | pub enum Message { 29 | SetClient(Mastodon), 30 | AppendStatus(Status), 31 | PrependStatus(Status), 32 | DeleteStatus(String), 33 | Status(crate::widgets::status::Message), 34 | LoadMore(bool), 35 | } 36 | 37 | impl MastodonPage for Home { 38 | fn is_authenticated(&self) -> bool { 39 | !self.mastodon.data.token.is_empty() 40 | } 41 | } 42 | 43 | impl Home { 44 | pub fn new(mastodon: Mastodon) -> Self { 45 | Self { 46 | mastodon, 47 | statuses: VecDeque::new(), 48 | skip: 0, 49 | loading: false, 50 | } 51 | } 52 | 53 | pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, Message> { 54 | let spacing = cosmic::theme::active().cosmic().spacing; 55 | let statuses: Vec> = self 56 | .statuses 57 | .iter() 58 | .filter_map(|id| cache.statuses.get(&id.to_string())) 59 | .map(|status| { 60 | crate::widgets::status(status, StatusOptions::all(), cache).map(Message::Status) 61 | }) 62 | .collect(); 63 | 64 | widget::scrollable(widget::settings::section().extend(statuses)) 65 | .direction(Direction::Vertical( 66 | Scrollbar::default().spacing(spacing.space_xxs), 67 | )) 68 | .on_scroll(|viewport| { 69 | Message::LoadMore(!self.loading && viewport.relative_offset().y == 1.0) 70 | }) 71 | .apply(widget::container) 72 | .max_width(700) 73 | .height(Length::Fill) 74 | .into() 75 | } 76 | 77 | pub fn update(&mut self, message: Message) -> Task { 78 | let mut tasks = vec![]; 79 | match message { 80 | Message::SetClient(mastodon) => self.mastodon = mastodon, 81 | Message::LoadMore(load) => { 82 | if !self.loading && load { 83 | self.loading = true; 84 | self.skip += 20; 85 | } 86 | } 87 | Message::AppendStatus(status) => { 88 | self.loading = false; 89 | self.statuses.push_back(status.id.clone()); 90 | tasks.push(cosmic::task::message(app::Message::CacheStatus( 91 | status.clone(), 92 | ))); 93 | 94 | tasks.push(cosmic::task::message(app::Message::Fetch( 95 | utils::extract_status_images(&status), 96 | ))); 97 | } 98 | Message::PrependStatus(status) => { 99 | self.loading = false; 100 | self.statuses.push_front(status.id.clone()); 101 | tasks.push(cosmic::task::message(app::Message::CacheStatus(status))); 102 | } 103 | Message::DeleteStatus(id) => self 104 | .statuses 105 | .retain(|status_id| *status_id.to_string() != id), 106 | Message::Status(message) => tasks.push(widgets::status::update(message)), 107 | } 108 | Task::batch(tasks) 109 | } 110 | 111 | pub fn subscription(&self) -> Subscription { 112 | if self.is_authenticated() 113 | && (self.statuses.is_empty() || self.statuses.len() != self.skip + 20) 114 | { 115 | Subscription::batch(vec![crate::subscriptions::home::user_timeline( 116 | self.mastodon.clone(), 117 | self.skip, 118 | )]) 119 | } else { 120 | Subscription::none() 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/pages/notifications.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | use cosmic::{ 4 | app::command::Task, 5 | iced::{Length, Subscription}, 6 | iced_widget::scrollable::{Direction, Scrollbar}, 7 | widget, Apply, Element, 8 | }; 9 | use mastodon_async::{ 10 | entities::notification::Notification, 11 | prelude::{Mastodon, NotificationId}, 12 | }; 13 | 14 | use crate::{ 15 | app, 16 | utils::{self, Cache}, 17 | widgets, 18 | }; 19 | 20 | use super::MastodonPage; 21 | 22 | #[derive(Debug, Clone)] 23 | pub struct Notifications { 24 | pub mastodon: Mastodon, 25 | notifications: VecDeque, 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | pub enum Message { 30 | SetClient(Mastodon), 31 | AppendNotification(Notification), 32 | PrependNotification(Notification), 33 | Notification(crate::widgets::notification::Message), 34 | } 35 | 36 | impl MastodonPage for Notifications { 37 | fn is_authenticated(&self) -> bool { 38 | !self.mastodon.data.token.is_empty() 39 | } 40 | } 41 | 42 | impl Notifications { 43 | pub fn new(mastodon: Mastodon) -> Self { 44 | Self { 45 | mastodon, 46 | notifications: VecDeque::new(), 47 | } 48 | } 49 | 50 | pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, Message> { 51 | let spacing = cosmic::theme::active().cosmic().spacing; 52 | let notifications: Vec> = self 53 | .notifications 54 | .iter() 55 | .filter_map(|id| cache.notifications.get(&id.to_string())) 56 | .map(|notification| { 57 | crate::widgets::notification(notification, cache).map(Message::Notification) 58 | }) 59 | .collect(); 60 | 61 | widget::scrollable(widget::settings::section().extend(notifications)) 62 | .direction(Direction::Vertical( 63 | Scrollbar::default().spacing(spacing.space_xxs), 64 | )) 65 | .apply(widget::container) 66 | .max_width(700) 67 | .height(Length::Fill) 68 | .into() 69 | } 70 | 71 | pub fn update(&mut self, message: Message) -> Task { 72 | let mut tasks = vec![]; 73 | match message { 74 | Message::SetClient(mastodon) => self.mastodon = mastodon, 75 | Message::AppendNotification(notification) => { 76 | self.notifications.push_back(notification.id.clone()); 77 | tasks.push(cosmic::task::message(app::Message::CacheNotification( 78 | notification.clone(), 79 | ))); 80 | 81 | tasks.push(cosmic::task::message(app::Message::Fetch( 82 | utils::extract_notification_images(¬ification), 83 | ))); 84 | } 85 | Message::PrependNotification(notification) => { 86 | self.notifications.push_front(notification.id.clone()); 87 | tasks.push(cosmic::task::message(app::Message::CacheNotification( 88 | notification, 89 | ))); 90 | } 91 | Message::Notification(message) => match message { 92 | crate::widgets::notification::Message::Status(message) => { 93 | tasks.push(widgets::status::update(message)) 94 | } 95 | }, 96 | } 97 | Task::batch(tasks) 98 | } 99 | 100 | pub fn subscription(&self) -> Subscription { 101 | if self.is_authenticated() && self.notifications.is_empty() { 102 | return Subscription::batch(vec![crate::subscriptions::notifications::timeline( 103 | self.mastodon.clone(), 104 | )]); 105 | } 106 | 107 | Subscription::none() 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/pages/public.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | use cosmic::{ 4 | app::command::Task, 5 | iced::{Length, Subscription}, 6 | iced_widget::scrollable::{Direction, Scrollbar}, 7 | widget, Apply, Element, 8 | }; 9 | use mastodon_async::prelude::{Mastodon, Status, StatusId}; 10 | 11 | use crate::{ 12 | app, 13 | utils::Cache, 14 | widgets::{self, status::StatusOptions}, 15 | }; 16 | 17 | use super::MastodonPage; 18 | 19 | #[derive(Debug, Clone)] 20 | pub struct Public { 21 | pub mastodon: Mastodon, 22 | statuses: VecDeque, 23 | timeline: TimelineType, 24 | } 25 | 26 | #[derive(Debug, Clone)] 27 | pub enum TimelineType { 28 | Public, 29 | Local, 30 | Remote, 31 | } 32 | 33 | #[derive(Debug, Clone)] 34 | pub enum Message { 35 | SetClient(Mastodon), 36 | AppendStatus(Status), 37 | Status(crate::widgets::status::Message), 38 | } 39 | 40 | impl MastodonPage for Public { 41 | fn is_authenticated(&self) -> bool { 42 | !self.mastodon.data.token.is_empty() 43 | } 44 | } 45 | 46 | impl Public { 47 | pub fn new(mastodon: Mastodon, timeline: TimelineType) -> Self { 48 | Self { 49 | mastodon, 50 | statuses: VecDeque::new(), 51 | timeline, 52 | } 53 | } 54 | 55 | pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, Message> { 56 | let spacing = cosmic::theme::active().cosmic().spacing; 57 | let statuses: Vec> = self 58 | .statuses 59 | .iter() 60 | .filter_map(|id| cache.statuses.get(&id.to_string())) 61 | .map(|status| { 62 | crate::widgets::status(status, StatusOptions::all(), cache).map(Message::Status) 63 | }) 64 | .collect(); 65 | 66 | widget::scrollable(widget::settings::section().extend(statuses)) 67 | .direction(Direction::Vertical( 68 | Scrollbar::default().spacing(spacing.space_xxs), 69 | )) 70 | .apply(widget::container) 71 | .max_width(700) 72 | .height(Length::Fill) 73 | .into() 74 | } 75 | 76 | pub fn update(&mut self, message: Message) -> Task { 77 | let mut tasks = vec![]; 78 | match message { 79 | Message::SetClient(mastodon) => self.mastodon = mastodon, 80 | Message::AppendStatus(status) => { 81 | self.statuses.push_back(status.id.clone()); 82 | tasks.push(cosmic::task::message(app::Message::CacheStatus( 83 | status.clone(), 84 | ))); 85 | 86 | tasks.push(cosmic::task::message(app::Message::Fetch( 87 | crate::utils::extract_status_images(&status), 88 | ))); 89 | } 90 | Message::Status(message) => tasks.push(widgets::status::update(message)), 91 | } 92 | Task::batch(tasks) 93 | } 94 | 95 | pub fn subscription(&self) -> Subscription { 96 | if self.statuses.is_empty() { 97 | return match self.timeline { 98 | TimelineType::Public => { 99 | Subscription::batch(vec![crate::subscriptions::public::timeline( 100 | self.mastodon.clone(), 101 | )]) 102 | } 103 | TimelineType::Local => { 104 | Subscription::batch(vec![crate::subscriptions::public::local_timeline( 105 | self.mastodon.clone(), 106 | )]) 107 | } 108 | TimelineType::Remote => { 109 | Subscription::batch(vec![crate::subscriptions::public::remote_timeline( 110 | self.mastodon.clone(), 111 | )]) 112 | } 113 | }; 114 | } 115 | 116 | Subscription::none() 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use cosmic::{app::Settings, iced::Limits}; 2 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 3 | 4 | use crate::{app::Flags, config::TootConfig, i18n}; 5 | 6 | pub fn init() { 7 | let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages(); 8 | i18n::init(&requested_languages); 9 | 10 | tracing_subscriber::registry() 11 | .with( 12 | tracing_subscriber::EnvFilter::try_from_default_env() 13 | .unwrap_or_else(|_| format!("{}=debug", env!("CARGO_CRATE_NAME")).into()), 14 | ) 15 | .with(tracing_subscriber::fmt::layer()) 16 | .init(); 17 | } 18 | 19 | pub fn settings() -> Settings { 20 | Settings::default().size_limits(Limits::NONE.min_width(360.0).min_height(180.0)) 21 | } 22 | 23 | pub fn flags() -> Flags { 24 | let (config, handler) = (TootConfig::config(), TootConfig::config_handler()); 25 | Flags { config, handler } 26 | } 27 | -------------------------------------------------------------------------------- /src/subscriptions.rs: -------------------------------------------------------------------------------- 1 | use crate::pages; 2 | use cosmic::iced::{stream, Subscription}; 3 | use futures_util::{SinkExt, TryStreamExt}; 4 | use mastodon_async::entities::event::Event; 5 | use mastodon_async::Mastodon; 6 | 7 | use crate::app; 8 | 9 | pub mod home; 10 | pub mod notifications; 11 | pub mod public; 12 | 13 | pub fn stream_user_events(mastodon: Mastodon) -> Subscription { 14 | Subscription::run_with_id( 15 | "posts", 16 | stream::channel(1, |output| async move { 17 | let stream = mastodon.stream_user().await.unwrap(); 18 | stream 19 | .try_for_each(|(event, _client)| { 20 | let mut output = output.clone(); 21 | async move { 22 | match event { 23 | Event::Update(ref status) => { 24 | if let Err(err) = output 25 | .send(app::Message::Home(pages::home::Message::PrependStatus( 26 | status.clone(), 27 | ))) 28 | .await 29 | { 30 | tracing::warn!("failed to send post: {}", err); 31 | } 32 | } 33 | Event::Notification(ref notification) => { 34 | if let Err(err) = output 35 | .send(app::Message::Notifications( 36 | pages::notifications::Message::PrependNotification( 37 | notification.clone(), 38 | ), 39 | )) 40 | .await 41 | { 42 | tracing::warn!("failed to send post: {}", err); 43 | } 44 | } 45 | Event::Delete(ref id) => { 46 | if let Err(err) = output 47 | .send(app::Message::Home(pages::home::Message::DeleteStatus( 48 | id.clone(), 49 | ))) 50 | .await 51 | { 52 | tracing::warn!("failed to send post: {}", err); 53 | } 54 | } 55 | Event::FiltersChanged => (), 56 | }; 57 | Ok(()) 58 | } 59 | }) 60 | .await 61 | .unwrap(); 62 | 63 | std::future::pending().await 64 | }), 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/subscriptions/home.rs: -------------------------------------------------------------------------------- 1 | use cosmic::iced::{stream, Subscription}; 2 | use futures_util::{SinkExt, StreamExt}; 3 | use mastodon_async::Mastodon; 4 | 5 | use crate::pages; 6 | 7 | pub fn user_timeline(mastodon: Mastodon, skip: usize) -> Subscription { 8 | Subscription::run_with_id( 9 | format!("timeline-{}-{}", skip, mastodon.data.base), 10 | stream::channel(1, move |mut output| async move { 11 | println!("{}", format!("timeline-{}-{}", skip, mastodon.data.base)); 12 | 13 | // First fetch the timeline 14 | let mut stream = Box::pin( 15 | mastodon 16 | .get_home_timeline() 17 | .await 18 | .unwrap() 19 | .items_iter() 20 | .skip(skip) 21 | .take(20), 22 | ); 23 | 24 | while let Some(status) = stream.next().await { 25 | if let Err(err) = output 26 | .send(pages::home::Message::AppendStatus(status.clone())) 27 | .await 28 | { 29 | tracing::warn!("failed to send post: {}", err); 30 | } 31 | } 32 | 33 | std::future::pending().await 34 | }), 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/subscriptions/notifications.rs: -------------------------------------------------------------------------------- 1 | use cosmic::iced::{stream, Subscription}; 2 | use futures_util::{SinkExt, StreamExt}; 3 | use mastodon_async::Mastodon; 4 | 5 | use crate::pages; 6 | 7 | pub fn timeline(mastodon: Mastodon) -> Subscription { 8 | Subscription::run_with_id( 9 | format!("notifications-{}", mastodon.data.base), 10 | stream::channel(1, |mut output| async move { 11 | println!("{}", format!("notifications-{}", mastodon.data.base)); 12 | let mut stream = Box::pin( 13 | mastodon 14 | .notifications() 15 | .await 16 | .unwrap() 17 | .items_iter() 18 | .take(100), 19 | ); 20 | 21 | while let Some(notification) = stream.next().await { 22 | if let Err(err) = output 23 | .send(pages::notifications::Message::AppendNotification( 24 | notification.clone(), 25 | )) 26 | .await 27 | { 28 | tracing::warn!("failed to send post: {}", err); 29 | } 30 | } 31 | 32 | std::future::pending().await 33 | }), 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/subscriptions/public.rs: -------------------------------------------------------------------------------- 1 | use cosmic::iced::{stream, Subscription}; 2 | use futures_util::SinkExt; 3 | use mastodon_async::Mastodon; 4 | 5 | use crate::pages; 6 | 7 | pub fn timeline(mastodon: Mastodon) -> Subscription { 8 | Subscription::run_with_id( 9 | format!("public-timeline-{}", mastodon.data.base), 10 | stream::channel(1, move |mut output| async move { 11 | match mastodon.get_public_timeline(false, false).await { 12 | Ok(statuses) => { 13 | for status in statuses { 14 | if let Err(err) = output 15 | .send(pages::public::Message::AppendStatus(status.clone())) 16 | .await 17 | { 18 | tracing::warn!("failed to send post: {}", err); 19 | } 20 | } 21 | } 22 | Err(err) => { 23 | tracing::warn!("failed to get local timeline: {}", err); 24 | } 25 | } 26 | 27 | std::future::pending().await 28 | }), 29 | ) 30 | } 31 | 32 | pub fn local_timeline(mastodon: Mastodon) -> Subscription { 33 | Subscription::run_with_id( 34 | format!("local-timeline-{}", mastodon.data.base), 35 | stream::channel(1, move |mut output| async move { 36 | match mastodon.get_public_timeline(true, false).await { 37 | Ok(statuses) => { 38 | for status in statuses { 39 | if let Err(err) = output 40 | .send(pages::public::Message::AppendStatus(status.clone())) 41 | .await 42 | { 43 | tracing::warn!("failed to send post: {}", err); 44 | } 45 | } 46 | } 47 | Err(err) => { 48 | tracing::warn!("failed to get local timeline: {}", err); 49 | } 50 | } 51 | 52 | std::future::pending().await 53 | }), 54 | ) 55 | } 56 | 57 | pub fn remote_timeline(mastodon: Mastodon) -> Subscription { 58 | Subscription::run_with_id( 59 | format!("remote-timeline-{}", mastodon.data.base), 60 | stream::channel(1, move |mut output| async move { 61 | match mastodon.get_public_timeline(false, true).await { 62 | Ok(statuses) => { 63 | for status in statuses { 64 | if let Err(err) = output 65 | .send(pages::public::Message::AppendStatus(status.clone())) 66 | .await 67 | { 68 | tracing::warn!("failed to send post: {}", err); 69 | } 70 | } 71 | } 72 | Err(err) => { 73 | tracing::warn!("failed to get local timeline: {}", err); 74 | } 75 | } 76 | 77 | std::future::pending().await 78 | }), 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, str::FromStr}; 2 | 3 | use cosmic::{ 4 | iced_core::image, 5 | widget::{self, image::Handle}, 6 | }; 7 | use mastodon_async::prelude::*; 8 | use reqwest::Url; 9 | 10 | use crate::error::Error; 11 | 12 | #[derive(Debug, Clone)] 13 | pub struct Cache { 14 | pub handles: HashMap, 15 | pub statuses: HashMap, 16 | pub notifications: HashMap, 17 | } 18 | 19 | impl Cache { 20 | pub fn new() -> Self { 21 | Self { 22 | handles: HashMap::new(), 23 | statuses: HashMap::new(), 24 | notifications: HashMap::new(), 25 | } 26 | } 27 | 28 | pub fn insert_status(&mut self, status: Status) { 29 | self.statuses.insert(status.id.to_string(), status.clone()); 30 | if let Some(reblog) = status.reblog { 31 | self.statuses.insert(reblog.id.to_string(), *reblog); 32 | } 33 | } 34 | 35 | pub fn insert_notification(&mut self, notification: Notification) { 36 | self.notifications 37 | .insert(notification.id.to_string(), notification.clone()); 38 | if let Some(status) = notification.status { 39 | self.insert_status(status.clone()); 40 | } 41 | } 42 | 43 | pub fn insert_handle(&mut self, url: Url, handle: Handle) { 44 | self.handles.insert(url, handle); 45 | } 46 | 47 | pub fn clear(&mut self) { 48 | self.statuses.clear(); 49 | self.notifications.clear(); 50 | self.handles.clear(); 51 | } 52 | } 53 | 54 | pub fn fallback_avatar<'a>() -> widget::Image<'a> { 55 | widget::image(image::Handle::from_bytes( 56 | include_bytes!("../assets/missing.png").to_vec(), 57 | )) 58 | } 59 | 60 | pub fn fallback_handle() -> widget::image::Handle { 61 | image::Handle::from_bytes(include_bytes!("../assets/missing.png").to_vec()) 62 | } 63 | 64 | pub async fn get(url: impl ToString) -> Result { 65 | let response = reqwest::get(url.to_string()).await?; 66 | match response.error_for_status() { 67 | Ok(response) => { 68 | let bytes = response.bytes().await?; 69 | let handle = Handle::from_bytes(bytes.to_vec()); 70 | Ok(handle) 71 | } 72 | Err(err) => Err(err.into()), 73 | } 74 | } 75 | 76 | pub fn extract_status_images(status: &Status) -> Vec { 77 | let mut urls = Vec::new(); 78 | urls.push(status.account.avatar.clone()); 79 | urls.push(status.account.header.clone()); 80 | 81 | if let Some(reblog) = &status.reblog { 82 | urls.push(reblog.account.avatar.clone()); 83 | urls.push(reblog.account.header.clone()); 84 | if let Some(card) = &reblog.card { 85 | if let Some(image) = &card.image { 86 | if let Ok(url) = Url::from_str(image) { 87 | urls.push(url); 88 | } 89 | } 90 | } 91 | for attachment in &reblog.media_attachments { 92 | urls.push(attachment.preview_url.clone()); 93 | } 94 | } 95 | 96 | if let Some(card) = &status.card { 97 | if let Some(image) = &card.image { 98 | if let Ok(url) = Url::from_str(image) { 99 | urls.push(url); 100 | } 101 | } 102 | } 103 | 104 | for attachment in &status.media_attachments { 105 | urls.push(attachment.preview_url.clone()); 106 | } 107 | 108 | urls 109 | } 110 | 111 | pub fn extract_notification_images(notification: &Notification) -> Vec { 112 | let mut urls = Vec::new(); 113 | urls.push(notification.account.avatar.clone()); 114 | urls.push(notification.account.header.clone()); 115 | 116 | if let Some(status) = ¬ification.status { 117 | urls.push(status.account.avatar.clone()); 118 | urls.push(status.account.header.clone()); 119 | if let Some(card) = &status.card { 120 | if let Some(image) = &card.image { 121 | if let Ok(url) = Url::from_str(image) { 122 | urls.push(url); 123 | } 124 | } 125 | } 126 | for attachment in &status.media_attachments { 127 | urls.push(attachment.preview_url.clone()); 128 | } 129 | } 130 | urls 131 | } 132 | -------------------------------------------------------------------------------- /src/widgets/account.rs: -------------------------------------------------------------------------------- 1 | use capitalize::Capitalize; 2 | use cosmic::{ 3 | app::command::Task, 4 | iced::{alignment::Horizontal, ContentFit, Length}, 5 | iced_widget::Stack, 6 | widget::{self, image::Handle}, 7 | Apply, Element, 8 | }; 9 | use mastodon_async::prelude::Account; 10 | use reqwest::Url; 11 | use std::{collections::HashMap, str::FromStr}; 12 | 13 | use crate::app; 14 | 15 | #[derive(Debug, Clone)] 16 | pub enum Message { 17 | Open(Url), 18 | } 19 | 20 | pub fn account<'a>( 21 | account: &'a Account, 22 | handles: &'a HashMap, 23 | ) -> Element<'a, Message> { 24 | let spacing = cosmic::theme::active().cosmic().spacing; 25 | 26 | let header = handles.get(&account.header).map(|handle| { 27 | widget::image(handle) 28 | .content_fit(ContentFit::Cover) 29 | .height(120.0) 30 | }); 31 | let avatar = handles.get(&account.avatar).map(|handle| { 32 | widget::container( 33 | widget::button::image(handle) 34 | .on_press(Message::Open(account.avatar.clone())) 35 | .width(100), 36 | ) 37 | .center(Length::Fill) 38 | }); 39 | let stack = Stack::new().push_maybe(header).push_maybe(avatar); 40 | let display_name = widget::text(&account.display_name).size(18); 41 | let username = widget::button::link(format!("@{}", account.username)) 42 | .on_press(Message::Open(account.url.clone())); 43 | let bio = (!account.note.is_empty()).then_some(widget::text( 44 | html2text::config::rich() 45 | .string_from_read(account.note.as_bytes(), 700) 46 | .unwrap(), 47 | )); 48 | let joined = widget::text::caption(format!( 49 | "Joined on {}", 50 | account 51 | .created_at 52 | .format(&time::format_description::parse("[day] [month repr:short] [year]").unwrap()) 53 | .unwrap() 54 | )); 55 | let fields: Vec> = account 56 | .fields 57 | .iter() 58 | .map(|field| { 59 | let value = html2text::config::rich() 60 | .string_from_read(field.value.as_bytes(), 700) 61 | .unwrap(); 62 | widget::column() 63 | .push(widget::text(field.name.capitalize())) 64 | .push(widget::text(value.clone()).class(cosmic::style::Text::Accent)) 65 | .width(Length::Fill) 66 | .apply(widget::button::custom) 67 | .class(cosmic::style::Button::Icon) 68 | .on_press_maybe(Url::from_str(&value).map(Message::Open).ok()) 69 | .into() 70 | }) 71 | .collect(); 72 | let followers = widget::column() 73 | .push(widget::text::text("Followers")) 74 | .push(widget::text::title3(account.followers_count.to_string())) 75 | .width(Length::FillPortion(1)) 76 | .align_x(Horizontal::Center); 77 | let following = widget::column() 78 | .push(widget::text::text("Following")) 79 | .push(widget::text::title3(account.following_count.to_string())) 80 | .width(Length::FillPortion(1)) 81 | .align_x(Horizontal::Center); 82 | let statuses = widget::column() 83 | .push(widget::text::text("Posts")) 84 | .push(widget::text::title3(account.statuses_count.to_string())) 85 | .width(Length::FillPortion(1)) 86 | .align_x(Horizontal::Center); 87 | 88 | let info = widget::container( 89 | widget::row() 90 | .push(followers) 91 | .push(widget::divider::vertical::light().height(Length::Fixed(50.))) 92 | .push(following) 93 | .push(widget::divider::vertical::light().height(Length::Fixed(50.))) 94 | .push(statuses) 95 | .padding(spacing.space_xs) 96 | .spacing(spacing.space_xs), 97 | ) 98 | .class(cosmic::style::Container::Card); 99 | 100 | let content = widget::column() 101 | .push(stack) 102 | .push(display_name) 103 | .push(username) 104 | .push_maybe(bio) 105 | .push(joined) 106 | .push(info) 107 | .push_maybe((!fields.is_empty()).then_some(widget::settings::section().extend(fields))) 108 | .align_x(Horizontal::Center) 109 | .width(Length::Fill) 110 | .spacing(spacing.space_xs); 111 | 112 | widget::settings::flex_item_row(vec![content.into()]) 113 | .padding(spacing.space_xs) 114 | .into() 115 | } 116 | 117 | pub fn update(message: Message) -> Task { 118 | let tasks = vec![]; 119 | match message { 120 | Message::Open(url) => { 121 | if let Err(err) = open::that_detached(url.to_string()) { 122 | tracing::error!("{err}"); 123 | } 124 | } 125 | } 126 | Task::batch(tasks) 127 | } 128 | -------------------------------------------------------------------------------- /src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod status; 2 | pub use status::status; 3 | pub mod notification; 4 | pub use notification::notification; 5 | pub mod account; 6 | pub use account::account; 7 | -------------------------------------------------------------------------------- /src/widgets/notification.rs: -------------------------------------------------------------------------------- 1 | use cosmic::{widget, Element}; 2 | use mastodon_async::prelude::{notification::Type, Notification}; 3 | 4 | use crate::utils::{self, Cache}; 5 | 6 | use super::status::StatusOptions; 7 | 8 | #[derive(Debug, Clone)] 9 | pub enum Message { 10 | Status(crate::widgets::status::Message), 11 | } 12 | 13 | pub fn notification<'a>(notification: &'a Notification, cache: &'a Cache) -> Element<'a, Message> { 14 | let spacing = cosmic::theme::active().cosmic().spacing; 15 | 16 | let display_name = notification.account.display_name.clone(); 17 | 18 | let action = match notification.notification_type { 19 | Type::Mention => format!("{} mentioned you", display_name), 20 | Type::Reblog => format!("{} boosted", display_name), 21 | Type::Favourite => format!("{} liked", display_name), 22 | Type::Follow => { 23 | format!("{} followed you", display_name) 24 | } 25 | Type::FollowRequest => format!("{} requested to follow you", display_name), 26 | Type::Poll => { 27 | format!("{} created a poll", display_name) 28 | } 29 | Type::Status => format!("{} has posted a status", display_name), 30 | Type::Update => "A post has been edited".to_string(), 31 | Type::SignUp => "Someone signed up (optionally sent to admins)".to_string(), 32 | Type::Report => "A new report has been filed".to_string(), 33 | }; 34 | 35 | let action = widget::button::custom( 36 | widget::row() 37 | .push( 38 | cache 39 | .handles 40 | .get(¬ification.account.avatar) 41 | .map(|handle| widget::image(handle).width(20)) 42 | .unwrap_or(utils::fallback_avatar().width(20)), 43 | ) 44 | .push(widget::text(action)) 45 | .spacing(spacing.space_xs), 46 | ) 47 | .on_press(Message::Status( 48 | crate::widgets::status::Message::OpenAccount(notification.account.clone()), 49 | )); 50 | 51 | let content = notification.status.as_ref().map(|status| { 52 | widget::container( 53 | crate::widgets::status(status, StatusOptions::new(false, true, false, true), cache) 54 | .map(Message::Status), 55 | ) 56 | .padding(spacing.space_xxs) 57 | .class(cosmic::theme::Container::Dialog) 58 | }); 59 | 60 | let content = widget::column() 61 | .push(action) 62 | .push_maybe(content) 63 | .spacing(spacing.space_xs); 64 | 65 | widget::settings::flex_item_row(vec![content.into()]) 66 | .padding(spacing.space_xs) 67 | .into() 68 | } 69 | -------------------------------------------------------------------------------- /src/widgets/status.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use cosmic::{ 4 | app::command::Task, 5 | iced::{mouse::Interaction, Alignment, Length}, 6 | iced_widget::scrollable::{Direction, Scrollbar}, 7 | widget, Apply, Element, 8 | }; 9 | use mastodon_async::{ 10 | prelude::{Account, Status, StatusId}, 11 | NewStatus, 12 | }; 13 | use reqwest::Url; 14 | 15 | use crate::{ 16 | app, 17 | utils::{self, Cache}, 18 | }; 19 | 20 | #[derive(Debug, Clone)] 21 | pub enum Message { 22 | OpenAccount(Account), 23 | ExpandStatus(StatusId), 24 | Reply(StatusId, String), 25 | Favorite(StatusId, bool), 26 | Boost(StatusId, bool), 27 | OpenLink(Url), 28 | } 29 | 30 | #[derive(Debug, Copy, Clone)] 31 | pub struct StatusOptions { 32 | media: bool, 33 | tags: bool, 34 | actions: bool, 35 | expand: bool, 36 | } 37 | 38 | impl StatusOptions { 39 | pub fn new(media: bool, tags: bool, actions: bool, expand: bool) -> Self { 40 | Self { 41 | media, 42 | tags, 43 | actions, 44 | expand, 45 | } 46 | } 47 | 48 | pub fn all() -> StatusOptions { 49 | StatusOptions::new(true, true, true, true) 50 | } 51 | 52 | pub fn none() -> StatusOptions { 53 | StatusOptions::new(false, false, false, false) 54 | } 55 | } 56 | 57 | pub fn status<'a>( 58 | status: &'a Status, 59 | options: StatusOptions, 60 | cache: &'a Cache, 61 | ) -> Element<'a, Message> { 62 | let spacing = cosmic::theme::active().cosmic().spacing; 63 | let reblog_button = reblog_button(cache, status); 64 | let status = status 65 | .reblog 66 | .as_ref() 67 | .map(|reblog| cache.statuses.get(&reblog.id.to_string()).unwrap_or(reblog)) 68 | .unwrap_or(status); 69 | 70 | widget::column() 71 | .push_maybe(reblog_button) 72 | .push(header(status, cache)) 73 | .push(content(status, options)) 74 | .push_maybe(card(status, cache)) 75 | .push_maybe(media(status, cache, options)) 76 | .push_maybe(tags(status, options)) 77 | .push_maybe(actions(status, options)) 78 | .padding(spacing.space_xs) 79 | .spacing(spacing.space_xs) 80 | .width(Length::Fill) 81 | .into() 82 | } 83 | 84 | fn card<'a>(status: &'a Status, cache: &'a Cache) -> Option> { 85 | let spacing = cosmic::theme::active().cosmic().spacing; 86 | status.card.as_ref().map(|card| { 87 | widget::column() 88 | .push_maybe(card.image.as_ref().map(|image| { 89 | Url::from_str(image) 90 | .ok() 91 | .map(|url| { 92 | cache 93 | .handles 94 | .get(&url) 95 | .map(widget::image) 96 | .unwrap_or(utils::fallback_avatar()) 97 | }) 98 | .unwrap_or(utils::fallback_avatar()) 99 | })) 100 | .push( 101 | widget::column() 102 | .push(widget::text::title4(&card.title)) 103 | .push(widget::text(&card.description)) 104 | .spacing(spacing.space_xs) 105 | .padding(spacing.space_xs), 106 | ) 107 | .apply(widget::container) 108 | .class(cosmic::style::Container::Dialog) 109 | .apply(widget::button::custom) 110 | .class(cosmic::style::Button::Image) 111 | .on_press(Message::OpenLink(card.url.clone())) 112 | .into() 113 | }) 114 | } 115 | 116 | pub fn update(message: Message) -> Task { 117 | match message { 118 | Message::OpenAccount(account) => cosmic::task::message(app::Message::ToggleContextPage( 119 | app::ContextPage::Account(account), 120 | )), 121 | Message::ExpandStatus(id) => cosmic::task::message(app::Message::ToggleContextPage( 122 | app::ContextPage::Status(id), 123 | )), 124 | Message::Reply(status_id, username) => { 125 | let new_status = NewStatus { 126 | in_reply_to_id: Some(status_id.to_string()), 127 | status: Some(format!("@{} ", username)), 128 | ..Default::default() 129 | }; 130 | cosmic::task::message(app::Message::Dialog(app::DialogAction::Open( 131 | app::Dialog::Reply(new_status), 132 | ))) 133 | } 134 | Message::Favorite(status_id, favorited) => cosmic::task::message(app::Message::Status( 135 | Message::Favorite(status_id, favorited), 136 | )), 137 | Message::Boost(status_id, boosted) => { 138 | cosmic::task::message(app::Message::Status(Message::Boost(status_id, boosted))) 139 | } 140 | Message::OpenLink(url) => cosmic::task::message(app::Message::Open(url.to_string())), 141 | } 142 | } 143 | 144 | fn actions(status: &Status, options: StatusOptions) -> Option> { 145 | let spacing = cosmic::theme::active().cosmic().spacing; 146 | 147 | let actions = (options.actions).then_some({ 148 | widget::row() 149 | .push( 150 | widget::button::icon(widget::icon::from_name("mail-replied-symbolic")) 151 | .label(status.replies_count.to_string()) 152 | .on_press(Message::Reply( 153 | status.id.clone(), 154 | status.account.username.clone(), 155 | )), 156 | ) 157 | .push( 158 | widget::button::icon(widget::icon::from_name("emblem-shared-symbolic")) 159 | .label(status.reblogs_count.to_string()) 160 | .class( 161 | status 162 | .reblogged 163 | .map(|reblogged| { 164 | if reblogged { 165 | cosmic::theme::Button::Suggested 166 | } else { 167 | cosmic::theme::Button::Icon 168 | } 169 | }) 170 | .unwrap_or(cosmic::theme::Button::Icon), 171 | ) 172 | .on_press_maybe( 173 | status 174 | .reblogged 175 | .map(|reblogged| Message::Boost(status.id.clone(), reblogged)), 176 | ), 177 | ) 178 | .push( 179 | widget::button::icon(widget::icon::from_name("starred-symbolic")) 180 | .label(status.favourites_count.to_string()) 181 | .class( 182 | status 183 | .favourited 184 | .map(|favourited| { 185 | if favourited { 186 | cosmic::theme::Button::Suggested 187 | } else { 188 | cosmic::theme::Button::Icon 189 | } 190 | }) 191 | .unwrap_or(cosmic::theme::Button::Icon), 192 | ) 193 | .on_press_maybe( 194 | status 195 | .favourited 196 | .map(|favourited| Message::Favorite(status.id.clone(), favourited)), 197 | ), 198 | ) 199 | .spacing(spacing.space_xs) 200 | .into() 201 | }); 202 | actions 203 | } 204 | 205 | fn media<'a>( 206 | status: &'a Status, 207 | cache: &'a Cache, 208 | options: StatusOptions, 209 | ) -> Option> { 210 | let spacing = cosmic::theme::active().cosmic().spacing; 211 | 212 | let attachments = status 213 | .media_attachments 214 | .iter() 215 | .map(|media| { 216 | widget::button::image( 217 | cache 218 | .handles 219 | .get(&media.preview_url) 220 | .cloned() 221 | .unwrap_or(crate::utils::fallback_handle()), 222 | ) 223 | .on_press_maybe(media.url.as_ref().cloned().map(Message::OpenLink)) 224 | .into() 225 | }) 226 | .collect::>>(); 227 | 228 | let media = (!status.media_attachments.is_empty() && options.media).then_some({ 229 | widget::scrollable(widget::row().extend(attachments).spacing(spacing.space_xxs)) 230 | .direction(Direction::Horizontal(Scrollbar::new())) 231 | }); 232 | media 233 | } 234 | 235 | fn tags(status: &Status, options: StatusOptions) -> Option> { 236 | let spacing = cosmic::theme::active().cosmic().spacing; 237 | 238 | let tags: Option> = (!status.tags.is_empty() && options.tags).then(|| { 239 | widget::row() 240 | .spacing(spacing.space_xxs) 241 | .extend( 242 | status 243 | .tags 244 | .iter() 245 | .map(|tag| { 246 | widget::button::suggested(format!("#{}", tag.name.clone())) 247 | .on_press_maybe(Url::from_str(&tag.url).map(Message::OpenLink).ok()) 248 | .into() 249 | }) 250 | .collect::>>(), 251 | ) 252 | .into() 253 | }); 254 | tags 255 | } 256 | 257 | fn header<'a>( 258 | status: &'a Status, 259 | cache: &'a Cache, 260 | ) -> cosmic::iced_widget::Row<'a, Message, cosmic::Theme> { 261 | let spacing = cosmic::theme::active().cosmic().spacing; 262 | 263 | let header = widget::row() 264 | .push( 265 | widget::button::image( 266 | cache 267 | .handles 268 | .get(&status.account.avatar) 269 | .cloned() 270 | .unwrap_or(crate::utils::fallback_handle()), 271 | ) 272 | .width(50) 273 | .height(50) 274 | .on_press(Message::OpenAccount(status.account.clone())), 275 | ) 276 | .push( 277 | widget::column() 278 | .push(widget::text(status.account.display_name.clone()).size(18)) 279 | .push( 280 | widget::button::link(format!("@{}", status.account.username.clone())) 281 | .on_press(Message::OpenAccount(status.account.clone())), 282 | ), 283 | ) 284 | .align_y(Alignment::Center) 285 | .spacing(spacing.space_xs); 286 | header 287 | } 288 | 289 | fn content(status: &Status, options: StatusOptions) -> Element { 290 | let mut status_text: Element<_> = widget::text( 291 | html2text::config::rich() 292 | .string_from_read(status.content.as_bytes(), 700) 293 | .unwrap(), 294 | ) 295 | .into(); 296 | 297 | if options.expand { 298 | status_text = widget::MouseArea::new(status_text) 299 | .on_press(Message::ExpandStatus(status.id.clone())) 300 | .interaction(Interaction::Pointer) 301 | .into(); 302 | } 303 | status_text 304 | } 305 | 306 | fn reblog_button<'a>(cache: &'a Cache, status: &'a Status) -> Option> { 307 | let spacing = cosmic::theme::active().cosmic().spacing; 308 | 309 | (status.reblog.is_some()).then_some( 310 | widget::button::custom( 311 | widget::row() 312 | .push( 313 | cache 314 | .handles 315 | .get(&status.account.avatar) 316 | .map(|avatar| widget::image(avatar).width(20).height(20)) 317 | .unwrap_or(crate::utils::fallback_avatar().width(20).height(20)), 318 | ) 319 | .push(widget::text(format!( 320 | "{} boosted", 321 | status.account.display_name 322 | ))) 323 | .spacing(spacing.space_xs), 324 | ) 325 | .on_press(Message::OpenAccount(status.account.clone())), 326 | ) 327 | } 328 | -------------------------------------------------------------------------------- /testr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmic-utils/toot/01235ad71025a3549cd008b0fe5d0d6e425e3d07/testr --------------------------------------------------------------------------------