├── testr
├── i18n.toml
├── assets
└── missing.png
├── README.md
├── res
├── icons
│ └── hicolor
│ │ └── scalable
│ │ └── apps
│ │ └── icon.svg
└── app.desktop
├── src
├── widgets
│ ├── mod.rs
│ ├── notification.rs
│ ├── account.rs
│ └── status.rs
├── error.rs
├── main.rs
├── settings.rs
├── config.rs
├── subscriptions
│ ├── notifications.rs
│ ├── home.rs
│ └── public.rs
├── i18n.rs
├── pages.rs
├── subscriptions.rs
├── pages
│ ├── notifications.rs
│ ├── public.rs
│ └── home.rs
├── utils.rs
└── app.rs
├── .gitignore
├── .github
└── FUNDING.yml
├── i18n
├── sv
│ └── cosmic_ext_toot.ftl
├── en
│ └── cosmic_ext_toot.ftl
├── nl
│ └── cosmic_ext_toot.ftl
├── pl
│ └── cosmic_ext_toot.ftl
└── bg
│ └── cosmic_ext_toot.ftl
├── Cargo.toml
└── justfile
/testr:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/i18n.toml:
--------------------------------------------------------------------------------
1 | fallback_language = "en"
2 |
3 | [fluent]
4 | assets_dir = "i18n"
--------------------------------------------------------------------------------
/assets/missing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cosmic-utils/toot/HEAD/assets/missing.png
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Toot
2 | Toot is a Mastodon client for COSMIC.
3 |
4 | ## Dependencies
5 | - libsecret-1-dev
6 |
--------------------------------------------------------------------------------
/res/icons/hicolor/scalable/apps/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/pl/cosmic_ext_toot.ftl:
--------------------------------------------------------------------------------
1 | app-title = Zatrąb
2 | about = O zatrąbieniu
3 | view = Widok
4 |
5 | ## Navbar
6 | home = Strona Domowa
7 | notifications = Powiadomienia
8 | search = Wyszukaj
9 | favorites = Ulubione
10 | bookmarks = Zakładki
11 | hashtags = Hasztagi
12 | lists = Listy
13 | explore = Odkrywaj
14 | local = Lokalne
15 | federated = Zfederowane
16 |
17 | ## About
18 | repository = Repozytorium
19 | support = Wsparcie
20 |
21 | ## Login
22 | server-question = Na jakim jesteś serwerze?
23 | server-description = Jeśli nie masz jeszcze konta, zarejestruj się na wybranym przez ciebie serwerze.
24 | server-url = URL serwera
25 | continue = Kontynuuj
26 |
27 | ## Authorization
28 | confirm-authorization = Potwierdź uwierzytelnienie
29 | confirm-authorization-description = Kopiuj kod uwierzytelnienia z przeglądarki i wklej go tutaj.
30 | authorization-code = Kod uwierzytelnienia
31 |
32 | ## Context
33 | about = O zatrąbieniu
34 | profile = Profil
35 | status = Status
36 |
37 | ## Dialogs
38 | switch-instance = Zmień instancję
39 | logout-question = Jesteś pewien, że chcesz się wylogować?
40 | logout-description = Będzie konieczne ponowne logowanie aby móc korzystać z tego konta ponownie.
41 |
42 | ## Actions
43 | reply = Odpowiedz
44 | cancel = Anuloj
45 | login = Zaloguj
46 | confirm = Potwierdź
47 |
--------------------------------------------------------------------------------
/i18n/bg/cosmic_ext_toot.ftl:
--------------------------------------------------------------------------------
1 | app-title = Тут
2 | about = Относно
3 | view = Изглед
4 |
5 | ## Navbar
6 | home = Начало
7 | notifications = Известия
8 | search = Търсене
9 | favorites = Любими
10 | bookmarks = Отметки
11 | hashtags = Хаштагове
12 | lists = Списъци
13 | explore = Разглеждане
14 | local = Локални
15 | federated = Федеративни
16 |
17 | ## About
18 | repository = Хранилище
19 | support = Поддръжка
20 |
21 | ## Login
22 | server-question = Какъв е вашият сървър?
23 | server-description = Ако все още нямате регистрация, регистрирайте се на сървър по ваш избор.
24 | server-url = Адрес на сървъра
25 | continue = Продължаване
26 |
27 | ## Authorization
28 | confirm-authorization = Потвърдете упълномощаването
29 | confirm-authorization-description = Копирайте кода за упълномощаването от браузъра и го поставете тук.
30 | authorization-code = Код за упълномощаване
31 |
32 | ## Context
33 | about = Относно
34 | profile = Регистрация
35 | status = Състояние
36 |
37 | ## Dialogs
38 | switch-instance = Сменяне на инстанцията
39 | logout-question = Сигурни ли сте, че искате да се отпишете?
40 | logout-description = Ще трябва да се впищете отново, за да получите достъп до регистрацията си.
41 |
42 | ## Actions
43 | reply = Отговаряне
44 | cancel = Отказване
45 | login = Вписване
46 | confirm = Потвърждаване
47 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/pages/notifications.rs:
--------------------------------------------------------------------------------
1 | use std::collections::VecDeque;
2 |
3 | use cosmic::{
4 | app::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::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/pages/home.rs:
--------------------------------------------------------------------------------
1 | use std::collections::VecDeque;
2 |
3 | use cosmic::{
4 | app::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/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 | #[allow(unused)]
48 | pub fn clear(&mut self) {
49 | self.statuses.clear();
50 | self.notifications.clear();
51 | self.handles.clear();
52 | }
53 | }
54 |
55 | pub fn fallback_avatar<'a>() -> widget::Image<'a> {
56 | widget::image(image::Handle::from_bytes(
57 | include_bytes!("../assets/missing.png").to_vec(),
58 | ))
59 | }
60 |
61 | pub fn fallback_handle() -> widget::image::Handle {
62 | image::Handle::from_bytes(include_bytes!("../assets/missing.png").to_vec())
63 | }
64 |
65 | pub async fn get(url: impl ToString) -> Result {
66 | let response = reqwest::get(url.to_string()).await?;
67 | match response.error_for_status() {
68 | Ok(response) => {
69 | let bytes = response.bytes().await?;
70 | let handle = Handle::from_bytes(bytes.to_vec());
71 | Ok(handle)
72 | }
73 | Err(err) => Err(err.into()),
74 | }
75 | }
76 |
77 | pub fn extract_status_images(status: &Status) -> Vec {
78 | let mut urls = Vec::new();
79 | urls.push(status.account.avatar.clone());
80 | urls.push(status.account.header.clone());
81 |
82 | if let Some(reblog) = &status.reblog {
83 | urls.push(reblog.account.avatar.clone());
84 | urls.push(reblog.account.header.clone());
85 | if let Some(card) = &reblog.card {
86 | if let Some(image) = &card.image {
87 | if let Ok(url) = Url::from_str(image) {
88 | urls.push(url);
89 | }
90 | }
91 | }
92 | for attachment in &reblog.media_attachments {
93 | urls.push(attachment.preview_url.clone());
94 | }
95 | }
96 |
97 | if let Some(card) = &status.card {
98 | if let Some(image) = &card.image {
99 | if let Ok(url) = Url::from_str(image) {
100 | urls.push(url);
101 | }
102 | }
103 | }
104 |
105 | for attachment in &status.media_attachments {
106 | urls.push(attachment.preview_url.clone());
107 | }
108 |
109 | urls
110 | }
111 |
112 | pub fn extract_notification_images(notification: &Notification) -> Vec {
113 | let mut urls = Vec::new();
114 | urls.push(notification.account.avatar.clone());
115 | urls.push(notification.account.header.clone());
116 |
117 | if let Some(status) = ¬ification.status {
118 | urls.push(status.account.avatar.clone());
119 | urls.push(status.account.header.clone());
120 | if let Some(card) = &status.card {
121 | if let Some(image) = &card.image {
122 | if let Ok(url) = Url::from_str(image) {
123 | urls.push(url);
124 | }
125 | }
126 | }
127 | for attachment in &status.media_attachments {
128 | urls.push(attachment.preview_url.clone());
129 | }
130 | }
131 | urls
132 | }
133 |
--------------------------------------------------------------------------------
/src/widgets/account.rs:
--------------------------------------------------------------------------------
1 | use capitalize::Capitalize;
2 | use cosmic::{
3 | app::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/status.rs:
--------------------------------------------------------------------------------
1 | use std::str::FromStr;
2 |
3 | use cosmic::{
4 | app::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<'_, Message> {
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 |
--------------------------------------------------------------------------------
/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