Repository: LemmyNet/lemmy (language: rust)

Issue:
Encourage users to donate more
Lemmy is entirely reliant on donations to function, yet the current amount of donations is barely enough to fund development, and some instances also dont receive enough funding. 

To improve this I suggest to display donation links more prominently. Namely on the homepage, below the sidebar text we can show the following:
- Donation info for Lemmy development
- Donation info local instance
- Donation info for current frontend/app

It could also be shown above the sidebar and collapsed by default. For the local instance, there needs to be a new config option with markdown text for the donation info. The backend returns this together with donation info for Lemmy development. Frontends and apps can add their own donation info as well.

Additionally, when viewing a community we could show this:
- Donation info for Lemmy development
- Donation info for instance where community is hosted
- Donation info for current frontend/app

In this case it would be necessary to federate the donation info.


Discussion / hints:
Users should be shown an estimate of the server costs that their usage caused
This is pretty critical, as lemmy won't have a sustainable development future without funding. Even with our ~47k active monthly users, we're not able to fund even a single developer fully. We need to not only fund our current devs, but also grow the dev co-op.

We should consider bunding in dismissible wikipedia style funding status banners into lemmy-ui, that pull the current [donation stats from join-lemmy.](https://join-lemmy.org/donate) They're annoying, but they work.
Another thing we can do is list on the donation page the people who are receiving donations (dessalines and me currently). With name, avatar, github and lemmy profiles as well as a description what exactly we work on. That would give users more transparency, so they know what exactly the donations are for.
> Another thing we can do is list on the donation page the people who are receiving donations (dessalines and me currently). With name, avatar, github and lemmy profiles as well as a description what exactly we work on. That would give users more transparency, so they know what exactly the donations are for.

Maybe also some sort of local badge or reward within lemmy. I know that some people will pay for such badges. But I wonder if this will spoil them within the community.
I wouldn't have any problem with the flair /badge model, as long as that flair doesn't give you any extra abilities. It's definitely something to think about, and possibly less annoying than adding wikipedia style banners/notifs and funding drives (although those do seem to work well).
Just to continue the conversation [here](https://lemmy.ml/post/23281272/15403144) (TL;DR: KDE donations went up by at least 7x to 93K this month when KDE added a popup).

I follow the development and i see no task more important or useful then completing this, I think that whatever is worked on should be dropped for this IMO.

If we are worried about a pop up being too annoying we could allow users or admins to disable it (that is what KDE allows).

Reddit seems to be getting worst, i think a lot of people will want to help create an alternative if they will be presented with the option.
Thanks for pointing out those KDE blog posts, they are very encouraging! Here are the direct links:
- [Asking for donations in Plasma](https://pointieststick.com/2024/08/28/asking-for-donations-in-plasma/)
- [I think the donation notification works](https://pointieststick.com/2024/12/02/i-think-the-donation-notification-works/)

I think we should definitely give these donation notifications a try, especially as the implementation is relatively simple:
- Per-user value `last_donation_notification_time`
- Instance config option `disable_donation_notification`
- A way to show the donation message to users

For the display part, we could show a dialog in lemmy-ui when `last_donation_notification_time` is more than one year ago, and user registration date is more than 2 weeks ago. This means that apps would have to implement their own donation dialog (which would allow them to display their own donation info as well). Another option would be to use the existing notification system, eg by sending a private message from a dummy account.
> For the display part, we could show a dialog in lemmy-ui when `last_donation_notification_time` is more than one year ago, and user registration date is more than 2 weeks ago. This means that apps would have to implement their own donation dialog (which would allow them to display their own donation info as well).

I think this is the best option.
> This means that apps would have to implement their own donation dialog (which would allow them to display their own donation info as well). Another option would be to use the existing notification system, eg by sending a private message from a dummy account.

There are people with a lot of unread messages on their email iirc, so for some people it could be easy to miss (going from 20 unread message to 21 unread messages). maybe if lemmy had a different indication for  "important messages" like getting banned or a warning from a moderator (i think reddit has those).

but otherwise i think we can have fallback mechanism. the server sends a message asking to display the message, if the client sends "ok got it" ( like it displayed the message or didn't show it because the user disabled it) then don't send a DM. if not then the client didn't implement it and you can send a DM.

getting the resources to compete with tech giants is important so i think it is worth the extra effort.
> Users should be shown an estimate of the server costs that their usage caused

That would be a great idea, the instance admins could set the cost per user.

Fix patch:
diff --git a/crates/api/src/local_user/donation_dialog_shown.rs b/crates/api/src/local_user/donation_dialog_shown.rs
new file mode 100644
index 0000000000..103d6382d4
--- /dev/null
+++ b/crates/api/src/local_user/donation_dialog_shown.rs
@@ -0,0 +1,19 @@
+use actix_web::web::{Data, Json};
+use chrono::Utc;
+use lemmy_api_common::{context::LemmyContext, SuccessResponse};
+use lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm};
+use lemmy_db_views::structs::LocalUserView;
+use lemmy_utils::error::LemmyResult;
+
+pub async fn donation_dialog_shown(
+  context: Data<LemmyContext>,
+  local_user_view: LocalUserView,
+) -> LemmyResult<Json<SuccessResponse>> {
+  let form = LocalUserUpdateForm {
+    last_donation_notification: Some(Utc::now()),
+    ..Default::default()
+  };
+  LocalUser::update(&mut context.pool(), local_user_view.local_user.id, &form).await?;
+
+  Ok(Json(SuccessResponse::default()))
+}
diff --git a/crates/api/src/local_user/mod.rs b/crates/api/src/local_user/mod.rs
index 0dcd24affc..15243056dc 100644
--- a/crates/api/src/local_user/mod.rs
+++ b/crates/api/src/local_user/mod.rs
@@ -3,6 +3,7 @@ pub mod ban_person;
 pub mod block;
 pub mod change_password;
 pub mod change_password_after_reset;
+pub mod donation_dialog_shown;
 pub mod generate_totp_secret;
 pub mod get_captcha;
 pub mod list_banned;
diff --git a/crates/api_common/src/site.rs b/crates/api_common/src/site.rs
index 083f7af81e..8dfb1132b9 100644
--- a/crates/api_common/src/site.rs
+++ b/crates/api_common/src/site.rs
@@ -238,6 +238,8 @@ pub struct CreateSite {
   pub comment_upvotes: Option<FederationMode>,
   #[cfg_attr(feature = "full", ts(optional))]
   pub comment_downvotes: Option<FederationMode>,
+  #[cfg_attr(feature = "full", ts(optional))]
+  pub disable_donation_dialog: Option<bool>,
 }
 
 #[skip_serializing_none]
@@ -365,6 +367,10 @@ pub struct EditSite {
   /// What kind of comment downvotes your site allows.
   #[cfg_attr(feature = "full", ts(optional))]
   pub comment_downvotes: Option<FederationMode>,
+  /// If this is true, users will never see the dialog asking to support Lemmy development with
+  /// donations.
+  #[cfg_attr(feature = "full", ts(optional))]
+  pub disable_donation_dialog: Option<bool>,
 }
 
 #[derive(Debug, Serialize, Deserialize, Clone)]
diff --git a/crates/api_crud/src/site/create.rs b/crates/api_crud/src/site/create.rs
index 34965742d9..454b04dbde 100644
--- a/crates/api_crud/src/site/create.rs
+++ b/crates/api_crud/src/site/create.rs
@@ -105,6 +105,7 @@ pub async fn create_site(
     post_downvotes: data.post_downvotes,
     comment_upvotes: data.comment_upvotes,
     comment_downvotes: data.comment_downvotes,
+    disable_donation_dialog: data.disable_donation_dialog,
     ..Default::default()
   };
 
diff --git a/crates/api_crud/src/site/update.rs b/crates/api_crud/src/site/update.rs
index 959c07ff07..439e9f9c49 100644
--- a/crates/api_crud/src/site/update.rs
+++ b/crates/api_crud/src/site/update.rs
@@ -111,6 +111,7 @@ pub async fn update_site(
     post_downvotes: data.post_downvotes,
     comment_upvotes: data.comment_upvotes,
     comment_downvotes: data.comment_downvotes,
+    disable_donation_dialog: data.disable_donation_dialog,
     ..Default::default()
   };
 
diff --git a/crates/api_crud/src/user/create.rs b/crates/api_crud/src/user/create.rs
index 16156abe45..e82ae8cc4a 100644
--- a/crates/api_crud/src/user/create.rs
+++ b/crates/api_crud/src/user/create.rs
@@ -21,7 +21,7 @@ use lemmy_api_common::{
 };
 use lemmy_db_schema::{
   aggregates::structs::PersonAggregates,
-  newtypes::{InstanceId, OAuthProviderId, SiteId},
+  newtypes::{InstanceId, OAuthProviderId},
   source::{
     actor_language::SiteLanguage,
     captcha_answer::{CaptchaAnswer, CheckCaptchaAnswer},
@@ -139,21 +139,11 @@ pub async fn register(
     email: data.email.as_deref().map(str::to_lowercase),
     show_nsfw: Some(show_nsfw),
     accepted_application,
-    default_listing_type: Some(local_site.default_post_listing_type),
-    post_listing_mode: Some(local_site.default_post_listing_mode),
-    interface_language: language_tags.first().cloned(),
-    // If its the initial site setup, they are an admin
-    admin: Some(!local_site.site_setup),
     ..LocalUserInsertForm::new(inserted_person.id, Some(data.password.to_string()))
   };
 
-  let inserted_local_user = create_local_user(
-    &context,
-    language_tags,
-    &local_user_form,
-    local_site.site_id,
-  )
-  .await?;
+  let inserted_local_user =
+    create_local_user(&context, language_tags, local_user_form, &local_site).await?;
 
   if local_site.site_setup && require_registration_application {
     if let Some(answer) = data.answer.clone() {
@@ -369,20 +359,10 @@ pub async fn authenticate_with_oauth(
         show_nsfw: Some(show_nsfw),
         accepted_application: Some(!require_registration_application),
         email_verified: Some(oauth_provider.auto_verify_email),
-        post_listing_mode: Some(local_site.default_post_listing_mode),
-        interface_language: language_tags.first().cloned(),
-        // If its the initial site setup, they are an admin
-        admin: Some(!local_site.site_setup),
         ..LocalUserInsertForm::new(person.id, None)
       };
 
-      local_user = create_local_user(
-        &context,
-        language_tags,
-        &local_user_form,
-        local_site.site_id,
-      )
-      .await?;
+      local_user = create_local_user(&context, language_tags, local_user_form, &local_site).await?;
 
       // Create the oauth account
       let oauth_account_form =
@@ -472,28 +452,33 @@ fn get_language_tags(req: &HttpRequest) -> Vec<String> {
 async fn create_local_user(
   context: &Data<LemmyContext>,
   language_tags: Vec<String>,
-  local_user_form: &LocalUserInsertForm,
-  local_site_id: SiteId,
+  mut local_user_form: LocalUserInsertForm,
+  local_site: &LocalSite,
 ) -> Result<LocalUser, LemmyError> {
   let all_languages = Language::read_all(&mut context.pool()).await?;
   // use hashset to avoid duplicates
   let mut language_ids = HashSet::new();
 
   // Enable languages from `Accept-Language` header
-  for l in language_tags {
-    if let Some(found) = all_languages.iter().find(|all| all.code == l) {
+  for l in &language_tags {
+    if let Some(found) = all_languages.iter().find(|all| &all.code == l) {
       language_ids.insert(found.id);
     }
   }
 
   // Enable site languages. Ignored if all languages are enabled.
-  let discussion_languages = SiteLanguage::read(&mut context.pool(), local_site_id).await?;
+  let discussion_languages = SiteLanguage::read(&mut context.pool(), local_site.site_id).await?;
   language_ids.extend(discussion_languages);
 
   let language_ids = language_ids.into_iter().collect();
 
+  local_user_form.default_listing_type = Some(local_site.default_post_listing_type);
+  local_user_form.post_listing_mode = Some(local_site.default_post_listing_mode);
+  // If its the initial site setup, they are an admin
+  local_user_form.admin = Some(!local_site.site_setup);
+  local_user_form.interface_language = language_tags.first().cloned();
   let inserted_local_user =
-    LocalUser::create(&mut context.pool(), local_user_form, language_ids).await?;
+    LocalUser::create(&mut context.pool(), &local_user_form, language_ids).await?;
 
   Ok(inserted_local_user)
 }
diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs
index 39598ec366..80a1b4c6f0 100644
--- a/crates/db_schema/src/schema.rs
+++ b/crates/db_schema/src/schema.rs
@@ -443,6 +443,7 @@ diesel::table! {
         post_downvotes -> FederationModeEnum,
         comment_upvotes -> FederationModeEnum,
         comment_downvotes -> FederationModeEnum,
+        disable_donation_dialog -> Bool,
     }
 }
 
@@ -514,6 +515,7 @@ diesel::table! {
         collapse_bot_comments -> Bool,
         default_comment_sort_type -> CommentSortTypeEnum,
         auto_mark_fetched_posts_as_read -> Bool,
+        last_donation_notification -> Timestamptz,
         hide_media -> Bool,
     }
 }
diff --git a/crates/db_schema/src/source/local_site.rs b/crates/db_schema/src/source/local_site.rs
index b5bcebc587..25ec40ca92 100644
--- a/crates/db_schema/src/source/local_site.rs
+++ b/crates/db_schema/src/source/local_site.rs
@@ -83,6 +83,9 @@ pub struct LocalSite {
   pub comment_upvotes: FederationMode,
   /// What kind of comment downvotes your site allows.
   pub comment_downvotes: FederationMode,
+  /// If this is true, users will never see the dialog asking to support Lemmy development with
+  /// donations.
+  pub disable_donation_dialog: bool,
 }
 
 #[derive(Clone, derive_new::new)]
@@ -142,6 +145,8 @@ pub struct LocalSiteInsertForm {
   pub comment_upvotes: Option<FederationMode>,
   #[new(default)]
   pub comment_downvotes: Option<FederationMode>,
+  #[new(default)]
+  pub disable_donation_dialog: Option<bool>,
 }
 
 #[derive(Clone, Default)]
@@ -175,4 +180,5 @@ pub struct LocalSiteUpdateForm {
   pub post_downvotes: Option<FederationMode>,
   pub comment_upvotes: Option<FederationMode>,
   pub comment_downvotes: Option<FederationMode>,
+  pub disable_donation_dialog: Option<bool>,
 }
diff --git a/crates/db_schema/src/source/local_user.rs b/crates/db_schema/src/source/local_user.rs
index 357d7b6b7f..26e1c54751 100644
--- a/crates/db_schema/src/source/local_user.rs
+++ b/crates/db_schema/src/source/local_user.rs
@@ -8,6 +8,7 @@ use crate::{
   PostListingMode,
   PostSortType,
 };
+use chrono::{DateTime, Utc};
 use serde::{Deserialize, Serialize};
 use serde_with::skip_serializing_none;
 #[cfg(feature = "full")]
@@ -70,6 +71,9 @@ pub struct LocalUser {
   pub default_comment_sort_type: CommentSortType,
   /// Whether to automatically mark fetched posts as read.
   pub auto_mark_fetched_posts_as_read: bool,
+  /// The last time a donation request was shown to this user. If this is more than a year ago,
+  /// a new notification request should be shown.
+  pub last_donation_notification: DateTime<Utc>,
   /// Whether to hide posts containing images/videos
   pub hide_media: bool,
 }
@@ -131,6 +135,8 @@ pub struct LocalUserInsertForm {
   #[new(default)]
   pub auto_mark_fetched_posts_as_read: Option<bool>,
   #[new(default)]
+  pub last_donation_notification: Option<DateTime<Utc>>,
+  #[new(default)]
   pub hide_media: Option<bool>,
 }
 
@@ -164,5 +170,6 @@ pub struct LocalUserUpdateForm {
   pub collapse_bot_comments: Option<bool>,
   pub default_comment_sort_type: Option<CommentSortType>,
   pub auto_mark_fetched_posts_as_read: Option<bool>,
+  pub last_donation_notification: Option<DateTime<Utc>>,
   pub hide_media: Option<bool>,
 }
diff --git a/crates/db_views/src/registration_application_view.rs b/crates/db_views/src/registration_application_view.rs
index 227110887a..87bbd5b01f 100644
--- a/crates/db_views/src/registration_application_view.rs
+++ b/crates/db_views/src/registration_application_view.rs
@@ -235,15 +235,14 @@ mod tests {
         password_encrypted: inserted_sara_local_user.password_encrypted,
         open_links_in_new_tab: inserted_sara_local_user.open_links_in_new_tab,
         infinite_scroll_enabled: inserted_sara_local_user.infinite_scroll_enabled,
-        admin: false,
         post_listing_mode: inserted_sara_local_user.post_listing_mode,
         totp_2fa_enabled: inserted_sara_local_user.totp_2fa_enabled,
         enable_keyboard_navigation: inserted_sara_local_user.enable_keyboard_navigation,
         enable_animated_images: inserted_sara_local_user.enable_animated_images,
         enable_private_messages: inserted_sara_local_user.enable_private_messages,
         collapse_bot_comments: inserted_sara_local_user.collapse_bot_comments,
-        auto_mark_fetched_posts_as_read: false,
-        hide_media: false,
+        last_donation_notification: inserted_sara_local_user.last_donation_notification,
+        ..Default::default()
       },
       creator: Person {
         id: inserted_sara_person.id,
diff --git a/crates/utils/src/settings/structs.rs b/crates/utils/src/settings/structs.rs
index 4e5152acca..effd68a640 100644
--- a/crates/utils/src/settings/structs.rs
+++ b/crates/utils/src/settings/structs.rs
@@ -12,17 +12,14 @@ use url::Url;
 #[serde(default)]
 pub struct Settings {
   /// settings related to the postgresql database
-  #[default(Default::default())]
   pub database: DatabaseConfig,
   /// Pictrs image server configuration.
   #[default(Some(Default::default()))]
   pub(crate) pictrs: Option<PictrsConfig>,
   /// Email sending configuration. All options except login/password are mandatory
-  #[default(None)]
   #[doku(example = "Some(Default::default())")]
   pub email: Option<EmailConfig>,
   /// Parameters for automatic configuration of new instance (only used at first start)
-  #[default(None)]
   #[doku(example = "Some(Default::default())")]
   pub setup: Option<SetupConfig>,
   /// the domain name of your instance (mandatory)
@@ -41,18 +38,14 @@ pub struct Settings {
   pub tls_enabled: bool,
   /// Set the URL for opentelemetry exports. If you do not have an opentelemetry collector, do not
   /// set this option
-  #[default(None)]
   #[doku(skip)]
   pub opentelemetry_url: Option<Url>,
-  #[default(Default::default())]
   pub federation: FederationWorkerConfig,
   // Prometheus configuration.
-  #[default(None)]
   #[doku(example = "Some(Default::default())")]
   pub prometheus: Option<PrometheusConfig>,
   /// Sets a response Access-Control-Allow-Origin CORS header
   /// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
-  #[default(None)]
   #[doku(example = "lemmy.tld")]
   cors_origin: Option<String>,
 }
@@ -74,7 +67,6 @@ pub struct PictrsConfig {
   pub url: Url,
 
   /// Set a custom pictrs API key. ( Required for deleting images )
-  #[default(None)]
   pub api_key: Option<String>,
 
   /// Specifies how to handle remote images, so that users don't have to connect directly to remote
@@ -114,7 +106,7 @@ pub struct PictrsConfig {
   pub image_upload_disabled: bool,
 }
 
-#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document, PartialEq)]
+#[derive(Debug, Deserialize, Serialize, Clone, Default, Document, PartialEq)]
 #[serde(deny_unknown_fields)]
 pub enum PictrsImageMode {
   /// Leave images unchanged, don't generate any local thumbnails for post urls. Instead the
@@ -185,7 +177,7 @@ impl EmailConfig {
   }
 }
 
-#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)]
+#[derive(Debug, Deserialize, Serialize, Clone, Default, Document)]
 #[serde(deny_unknown_fields)]
 pub struct SetupConfig {
   /// Username for the admin user
@@ -199,7 +191,6 @@ pub struct SetupConfig {
   pub site_name: String,
   /// Email for the admin user (optional, can be omitted and set later through the website)
   #[doku(example = "user@example.com")]
-  #[default(None)]
   pub admin_email: Option<String>,
 }
 
diff --git a/migrations/2025-01-10-135505_donation-dialog/down.sql b/migrations/2025-01-10-135505_donation-dialog/down.sql
new file mode 100644
index 0000000000..8956c15eb0
--- /dev/null
+++ b/migrations/2025-01-10-135505_donation-dialog/down.sql
@@ -0,0 +1,6 @@
+ALTER TABLE local_user
+    DROP COLUMN last_donation_notification;
+
+ALTER TABLE local_site
+    DROP COLUMN disable_donation_dialog;
+
diff --git a/migrations/2025-01-10-135505_donation-dialog/up.sql b/migrations/2025-01-10-135505_donation-dialog/up.sql
new file mode 100644
index 0000000000..0f263e0048
--- /dev/null
+++ b/migrations/2025-01-10-135505_donation-dialog/up.sql
@@ -0,0 +1,8 @@
+-- Generate new column last_donation_notification with default value at random time in the
+-- past year (so that users dont see it all at the same time after instance upgrade).
+ALTER TABLE local_user
+    ADD COLUMN last_donation_notification timestamptz NOT NULL DEFAULT (now() - (random() * (interval '12 months')));
+
+ALTER TABLE local_site
+    ADD COLUMN disable_donation_dialog boolean NOT NULL DEFAULT FALSE;
+
diff --git a/src/api_routes_v4.rs b/src/api_routes_v4.rs
index c0eb33fab7..852e868fd2 100644
--- a/src/api_routes_v4.rs
+++ b/src/api_routes_v4.rs
@@ -26,6 +26,7 @@ use lemmy_api::{
     block::user_block_person,
     change_password::change_password,
     change_password_after_reset::change_password_after_reset,
+    donation_dialog_shown::donation_dialog_shown,
     generate_totp_secret::generate_totp_secret,
     get_captcha::get_captcha,
     list_banned::list_banned_users,
@@ -331,6 +332,7 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) {
           .route("/unread_count", get().to(unread_count))
           .route("/list_logins", get().to(list_logins))
           .route("/validate_auth", get().to(validate_auth))
+          .route("/donation_dialog_shown", post().to(donation_dialog_shown))
           .route("/avatar", post().to(upload_user_avatar))
           .route("/avatar", delete().to(delete_user_avatar))
           .route("/banner", post().to(upload_user_banner))


Test patch: