diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 251167f4e760c4fc1031472224d8878430c74b43..a20081dd2f3905c90baade172c51ee1fbe506f13 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -884,3 +884,7 @@ FEDERATION_OBJECT_FETCH_DELAY = env.int( MODERATION_EMAIL_NOTIFICATIONS_ENABLED = env.bool( "MODERATION_EMAIL_NOTIFICATIONS_ENABLED", default=True ) + +# Delay in days after signup before we show the "support us" messages +INSTANCE_SUPPORT_MESSAGE_DELAY = env.int("INSTANCE_SUPPORT_MESSAGE_DELAY", default=15) +FUNKWHALE_SUPPORT_MESSAGE_DELAY = env.int("FUNKWHALE_SUPPORT_MESSAGE_DELAY", default=15) diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py index 0d6260a83726fffa6118ba5f1ecfa30472f2c359..be360701dd7cb626eee8d6eafb7f3e867b2c7a13 100644 --- a/api/funkwhale_api/instance/dynamic_preferences_registry.py +++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py @@ -82,6 +82,35 @@ class InstanceContactEmail(types.StringPreference): field_kwargs = {"required": False} +@global_preferences_registry.register +class InstanceSupportMessage(types.StringPreference): + show_in_api = True + section = instance + name = "support_message" + verbose_name = "Support message" + default = "" + help_text = ( + "A short message that will be displayed periodically to local users. " + "Use it to ask for financial support or anything else you might need. " + "(markdown allowed)." + ) + widget = widgets.Textarea + field_kwargs = {"required": False} + + +@global_preferences_registry.register +class InstanceFunkwhaleSupportMessageEnabled(types.BooleanPreference): + show_in_api = True + section = instance + name = "funkwhale_support_message_enabled" + verbose_name = "Funkwhale Support message" + default = True + help_text = ( + "If this is enabled, we will periodically display a message to encourage " + "local users to support Funkwhale." + ) + + @global_preferences_registry.register class RavenDSN(types.StringPreference): show_in_api = True diff --git a/api/funkwhale_api/instance/nodeinfo.py b/api/funkwhale_api/instance/nodeinfo.py index ebeee2b03d0f67ea7e73445b377b4a558d84b1c5..712578c3c809a92df007043992cd3da6de3061a5 100644 --- a/api/funkwhale_api/instance/nodeinfo.py +++ b/api/funkwhale_api/instance/nodeinfo.py @@ -63,6 +63,10 @@ def get(): {"type": t, "label": l, "anonymous": t in unauthenticated_report_types} for t, l in moderation_models.REPORT_TYPES ], + "funkwhaleSupportMessageEnabled": all_preferences.get( + "instance__funkwhale_support_message_enabled" + ), + "instanceSupportMessage": all_preferences.get("instance__support_message"), }, } diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py index cd372e80a24f16afc2a0ce40655fcac2621023ee..24f08d0264cb7043d8e5991a6d065f52e806d532 100644 --- a/api/funkwhale_api/users/admin.py +++ b/api/funkwhale_api/users/admin.py @@ -90,6 +90,15 @@ class UserAdmin(AuthUserAdmin): }, ), (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ( + _("Other"), + { + "fields": ( + "instance_support_message_display_date", + "funkwhale_support_message_display_date", + ) + }, + ), (_("Useless fields"), {"fields": ("user_permissions", "groups")}), ) diff --git a/api/funkwhale_api/users/migrations/0016_auto_20190920_0857.py b/api/funkwhale_api/users/migrations/0016_auto_20190920_0857.py new file mode 100644 index 0000000000000000000000000000000000000000..a3fc6cf26e48ea4b9af63058628318696520f77b --- /dev/null +++ b/api/funkwhale_api/users/migrations/0016_auto_20190920_0857.py @@ -0,0 +1,47 @@ +# Generated by Django 2.2.4 on 2019-09-20 08:57 + +import datetime +from django.conf import settings + +from django.db import migrations, models +import django.utils.timezone +import funkwhale_api.users.models + + +def set_display_date(apps, schema_editor): + """ + Set display date for instance/funkwhale support message on existing users + """ + User = apps.get_model("users", "User") + now = django.utils.timezone.now() + instance_support_message_display_date = now + datetime.timedelta(days=settings.INSTANCE_SUPPORT_MESSAGE_DELAY) + funkwhale_support_message_display_date = now + datetime.timedelta(days=settings.FUNKWHALE_SUPPORT_MESSAGE_DELAY) + + User.objects.update(instance_support_message_display_date=instance_support_message_display_date) + User.objects.update(funkwhale_support_message_display_date=funkwhale_support_message_display_date) + + +def rewind(*args, **kwargs): + pass + + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0015_application_scope'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='funkwhale_support_message_display_date', + field=models.DateTimeField(blank=True, null=True, default=funkwhale_api.users.models.get_default_funkwhale_support_message_display_date), + ), + migrations.AddField( + model_name='user', + name='instance_support_message_display_date', + field=models.DateTimeField(blank=True, null=True, default=funkwhale_api.users.models.get_default_instance_support_message_display_date), + ), + migrations.RunPython(set_display_date, rewind), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 44bb06d2bd23c8007c95e1d03c926c81c75c2e1c..2a2f875a60350a66b3a5d1cfe9d8472349cd3f4b 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -82,6 +82,18 @@ PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys()) get_file_path = common_utils.ChunkedPath("users/avatars", preserve_file_name=False) +def get_default_instance_support_message_display_date(): + return timezone.now() + datetime.timedelta( + days=settings.INSTANCE_SUPPORT_MESSAGE_DELAY + ) + + +def get_default_funkwhale_support_message_display_date(): + return timezone.now() + datetime.timedelta( + days=settings.FUNKWHALE_SUPPORT_MESSAGE_DELAY + ) + + @python_2_unicode_compatible class User(AbstractUser): @@ -149,6 +161,15 @@ class User(AbstractUser): upload_quota = models.PositiveIntegerField(null=True, blank=True) + instance_support_message_display_date = models.DateTimeField( + default=get_default_instance_support_message_display_date, null=True, blank=True + ) + funkwhale_support_message_display_date = models.DateTimeField( + default=get_default_funkwhale_support_message_display_date, + null=True, + blank=True, + ) + def __str__(self): return self.username diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 67171b49ba73a79cb255fd411bbc5edc88ba975e..cddc2e82aea82859b0728d30777142f6349fec9b 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -109,7 +109,13 @@ class UserWriteSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = ["name", "privacy_level", "avatar"] + fields = [ + "name", + "privacy_level", + "avatar", + "instance_support_message_display_date", + "funkwhale_support_message_display_date", + ] class UserReadSerializer(serializers.ModelSerializer): @@ -146,7 +152,11 @@ class MeSerializer(UserReadSerializer): quota_status = serializers.SerializerMethodField() class Meta(UserReadSerializer.Meta): - fields = UserReadSerializer.Meta.fields + ["quota_status"] + fields = UserReadSerializer.Meta.fields + [ + "quota_status", + "instance_support_message_display_date", + "funkwhale_support_message_display_date", + ] def get_quota_status(self, o): return o.get_quota_status() if o.actor else 0 diff --git a/api/tests/instance/test_nodeinfo.py b/api/tests/instance/test_nodeinfo.py index c765ba4f93b98f2a6ccfc490a5dd4db1fe237bcf..2e9075d2667d3e4f3d8d55ae54c350fadb99e9fb 100644 --- a/api/tests/instance/test_nodeinfo.py +++ b/api/tests/instance/test_nodeinfo.py @@ -87,6 +87,10 @@ def test_nodeinfo_dump(preferences, mocker, avatar): }, {"type": "other", "label": "Other", "anonymous": True}, ], + "funkwhaleSupportMessageEnabled": preferences[ + "instance__funkwhale_support_message_enabled" + ], + "instanceSupportMessage": preferences["instance__support_message"], }, } assert nodeinfo.get() == expected @@ -151,6 +155,10 @@ def test_nodeinfo_dump_stats_disabled(preferences, mocker): }, {"type": "other", "label": "Other", "anonymous": True}, ], + "funkwhaleSupportMessageEnabled": preferences[ + "instance__funkwhale_support_message_enabled" + ], + "instanceSupportMessage": preferences["instance__support_message"], }, } assert nodeinfo.get() == expected diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index 973316424d27a78eb987620e2e461b05018e1bf0..7552094ae31943124f71c0493424855d35301832 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -220,3 +220,20 @@ def test_user_get_quota_status(factories, preferences, mocker): "errored": 3, "finished": 4, } + + +@pytest.mark.parametrize( + "setting_name, field", + [ + ("INSTANCE_SUPPORT_MESSAGE_DELAY", "instance_support_message_display_date"), + ("FUNKWHALE_SUPPORT_MESSAGE_DELAY", "funkwhale_support_message_display_date"), + ], +) +def test_creating_user_set_support_display_date( + setting_name, field, settings, factories, now +): + setattr(settings, setting_name, 66) # display message every 66 days + expected = now + datetime.timedelta(days=66) + user = factories["users.User"]() + + assert getattr(user, field) == expected diff --git a/changes/changelog.d/839.feature b/changes/changelog.d/839.feature new file mode 100644 index 0000000000000000000000000000000000000000..7f72f000c1133094502050349b04dfa8b3902d79 --- /dev/null +++ b/changes/changelog.d/839.feature @@ -0,0 +1 @@ +Added periodical message to incite people to support their pod and Funkwhale (#839) diff --git a/changes/notes.rst b/changes/notes.rst index 51c53e85792877782e4649cf96601e2bd3856aba..4a9d1eddb243dbb1bf0a7904767e9917623db663 100644 --- a/changes/notes.rst +++ b/changes/notes.rst @@ -113,6 +113,16 @@ your pod. If you want to enable this feature on your pod, or learn more, please refer to `our documentation <https://docs.funkwhale.audio/moderator/listing.html>`_! +Periodic message to incite people to support their pod and Funkwhale +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Users will now be reminded on a regular basis that they can help Funkwhale by donating or contributing. + +If specified by the pod admin, a separate and custom message will also be displayed in a similar way to provide instructions and links to support the pod. + +Both messages will appear for the first time 15 days after signup, in the notifications tab. For each message, users can schedule a reminder for a later time, or disable the messages entirely. + + Replaced Daphne by Gunicorn/Uvicorn [manual action required, non-docker only] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index d35d040d6e1cf2541a2338a6915bfb6c9d843dd7..9b99e6a277e3c189f4b74eff78f5b8f4221818c2 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -46,9 +46,9 @@ <i class="feed icon"></i> <translate translate-context="*/Notifications/*">Notifications</translate> <div - v-if="$store.state.ui.notifications.inbox > 0" + v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0" :class="['ui', 'teal', 'label']"> - {{ $store.state.ui.notifications.inbox }}</div> + {{ $store.state.ui.notifications.inbox + additionalNotifications }}</div> </router-link> <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i><translate translate-context="Sidebar/Login/List item.Link/Verb">Logout</translate></router-link> <template v-else> @@ -186,7 +186,7 @@ </template> <script> -import { mapState, mapActions } from "vuex" +import { mapState, mapActions, mapGetters } from "vuex" import Player from "@/components/audio/Player" import Logo from "@/components/Logo" @@ -219,6 +219,9 @@ export default { } }, computed: { + ...mapGetters({ + additionalNotifications: "ui/additionalNotifications", + }), ...mapState({ queue: state => state.queue, url: state => state.route.path diff --git a/front/src/store/auth.js b/front/src/store/auth.js index e1b14ac828923c0e67c43d7b60d86a5f6ff8e384..e3834cccbc3df02f5cb44ed916a6c5ddaf4dc4d6 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -1,7 +1,9 @@ +import Vue from 'vue' import axios from 'axios' import jwtDecode from 'jwt-decode' import logger from '@/logging' import router from '@/router' +import lodash from '@/lodash' export default { namespaced: true, @@ -73,6 +75,11 @@ export default { }, permission: (state, {key, status}) => { state.availablePermissions[key] = status + }, + profilePartialUpdate: (state, payload) => { + lodash.keys(payload).forEach((k) => { + Vue.set(state.profile, k, payload[k]) + }) } }, actions: { diff --git a/front/src/store/instance.js b/front/src/store/instance.js index efe40736493df800ac5b8aeb9f43c000356899de..ec4e47b43d16cc29d5c1bd98e2a1fdde88282078 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -28,6 +28,12 @@ export default { }, long_description: { value: '' + }, + funkwhale_support_message_enabled: { + value: true + }, + support_message: { + value: '' } }, users: { diff --git a/front/src/store/ui.js b/front/src/store/ui.js index 989e8b2adfdde78dbd6e27950a80256f55177129..48795116f98ed1081424845228b1d1ede6b8dc3a 100644 --- a/front/src/store/ui.js +++ b/front/src/store/ui.js @@ -26,6 +26,44 @@ export default { }, pageTitle: null }, + getters: { + showInstanceSupportMessage: (state, getters, rootState) => { + if (!rootState.auth.profile) { + return false + } + if (!rootState.instance.settings.instance.support_message.value) { + return false + } + let displayDate = rootState.auth.profile.instance_support_message_display_date + if (!displayDate) { + return false + } + return moment(displayDate) < moment(state.lastDate) + }, + showFunkwhaleSupportMessage: (state, getters, rootState) => { + if (!rootState.auth.profile) { + return false + } + if (!rootState.instance.settings.instance.funkwhale_support_message_enabled.value) { + return false + } + let displayDate = rootState.auth.profile.funkwhale_support_message_display_date + if (!displayDate) { + return false + } + return moment(displayDate) < moment(state.lastDate) + }, + additionalNotifications: (state, getters) => { + let count = 0 + if (getters.showInstanceSupportMessage) { + count += 1 + } + if (getters.showFunkwhaleSupportMessage) { + count += 1 + } + return count + } + }, mutations: { addWebsocketEventHandler: (state, {eventName, id, handler}) => { state.websocketEventsHandlers[eventName][id] = handler diff --git a/front/src/views/Notifications.vue b/front/src/views/Notifications.vue index 648654c8fc1c02ede3fc86a94263bacb528f902e..3a10d399d3d24f9ca1e577cda4cc07b6f0a3cc0a 100644 --- a/front/src/views/Notifications.vue +++ b/front/src/views/Notifications.vue @@ -2,6 +2,71 @@ <main class="main pusher" v-title="labels.title"> <section class="ui vertical aligned stripe segment"> <div class="ui container"> + <div class="ui container" v-if="additionalNotifications"> + <h1 class="ui header"><translate translate-context="Content/Notifications/Title">Your messages</translate></h1> + <div class="ui two column stackable grid"> + <div class="column" v-if="showInstanceSupportMessage"> + <div class="ui attached info message"> + <div class="header"> + <translate translate-context="Content/Notifications/Header">Support this Funkwhale pod</translate> + </div> + <div v-html="markdown.makeHtml($store.state.instance.settings.instance.support_message.value)"></div> + </div> + <div class="ui bottom attached segment"> + <form @submit.prevent="setDisplayDate('instance_support_message_display_date', instanceSupportMessageDelay)" class="ui inline form"> + <div class="inline field"> + <label> + <translate translate-context="Content/Notifications/Label">Remind me in:</translate> + </label> + <select v-model="instanceSupportMessageDelay"> + <option :value="30"><translate translate-context="*/*/*">30 days</translate></option> + <option :value="60"><translate translate-context="*/*/*">60 days</translate></option> + <option :value="90"><translate translate-context="*/*/*">90 days</translate></option> + <option :value="null"><translate translate-context="*/*/*">Never</translate></option> + </select> + <button type="submit" class="ui right floated basic button"> + <translate translate-context="Content/Notifications/Button.Label">Got it!</translate> + </button> + </div> + </form> + </div> + </div> + <div class="column" v-if="showFunkwhaleSupportMessage"> + <div class="ui info attached message"> + <div class="header"> + <translate translate-context="Content/Notifications/Header">Do you like Funkwhale?</translate> + </div> + <p> + <translate translate-context="Content/Notifications/Paragraph">We noticed you've been here for a while. If Funkwhale is useful to you, we could use your help to make it even better!</translate> + </p> + <a href="https://funkwhale.audio/support-us" _target="blank" rel="noopener" class="ui primary inverted button"> + <translate translate-context="Content/Notifications/Button.Label/Verb">Donate</translate> + </a> + <a href="https://contribute.funkwhale.audio" _target="blank" rel="noopener" class="ui secondary inverted button"> + <translate translate-context="Content/Notifications/Button.Label/Verb">Discover other ways to help</translate> + </a> + </div> + <div class="ui bottom attached segment"> + <form @submit.prevent="setDisplayDate('funkwhale_support_message_display_date', funkwhaleSupportMessageDelay)" class="ui inline form"> + <div class="inline field"> + <label> + <translate translate-context="Content/Notifications/Label">Remind me in:</translate> + </label> + <select v-model="funkwhaleSupportMessageDelay"> + <option :value="30"><translate translate-context="*/*/*">30 days</translate></option> + <option :value="60"><translate translate-context="*/*/*">60 days</translate></option> + <option :value="90"><translate translate-context="*/*/*">90 days</translate></option> + <option :value="null"><translate translate-context="*/*/*">Never</translate></option> + </select> + <button type="submit" class="ui right floated basic button"> + <translate translate-context="Content/Notifications/Button.Label">Got it!</translate> + </button> + </div> + </form> + </div> + </div> + </div> + </div> <h1 class="ui header"><translate translate-context="Content/Notifications/Title">Your notifications</translate></h1> <div class="ui toggle checkbox"> <input v-model="filters.is_read" type="checkbox"> @@ -25,7 +90,7 @@ <notification-row :item="item" v-for="item in notifications.results" :key="item.id" /> </tbody> </table> - <p v-else> + <p v-else-if="additionalNotifications === 0"> <translate translate-context="Content/Notifications/Paragraph">No notification to show.</translate> </p> </div> @@ -34,9 +99,11 @@ </template> <script> -import { mapState } from "vuex" +import { mapState, mapGetters } from "vuex" import axios from "axios" import logger from "@/logging" +import showdown from 'showdown' +import moment from 'moment' import NotificationRow from "@/components/notifications/NotificationRow" @@ -44,7 +111,10 @@ export default { data() { return { isLoading: false, + markdown: new showdown.Converter(), notifications: {count: 0, results: []}, + instanceSupportMessageDelay: 60, + funkwhaleSupportMessageDelay: 60, filters: { is_read: false } @@ -71,6 +141,11 @@ export default { ...mapState({ events: state => state.instance.events }), + ...mapGetters({ + additionalNotifications: 'ui/additionalNotifications', + showInstanceSupportMessage: 'ui/showInstanceSupportMessage', + showFunkwhaleSupportMessage: 'ui/showFunkwhaleSupportMessage', + }), labels() { return { title: this.$pgettext('*/Notifications/*', "Notifications") @@ -82,6 +157,20 @@ export default { this.notifications.count += 1 this.notifications.results.unshift(event.item) }, + setDisplayDate (field, days) { + let payload = {} + let newDisplayDate + if (days) { + newDisplayDate = moment().add({days}) + } else { + newDisplayDate = null + } + payload[field] = newDisplayDate + let self = this + axios.patch(`users/users/${this.$store.state.auth.username}/`, payload).then((response) => { + self.$store.commit('auth/profilePartialUpdate', response.data) + }) + }, fetch(params) { this.isLoading = true let self = this diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue index 8d080e0d4e55bb542ed9d0e92366b7c0aff48e6b..f7e9571c1a8f22debd39fa79745ee2c50053d8a1 100644 --- a/front/src/views/admin/Settings.vue +++ b/front/src/views/admin/Settings.vue @@ -99,6 +99,7 @@ export default { "instance__rules", "instance__terms", "instance__banner", + "instance__support_message" ] }, { @@ -152,7 +153,7 @@ export default { { label: uiLabel, id: "ui", - settings: ["ui__custom_css"] + settings: ["ui__custom_css", "instance__funkwhale_support_message_enabled"] }, { label: statisticsLabel,