diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index e1a52d263..4cba9939e 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -22,6 +22,11 @@ class ActivityStream(RedisStore): stream_id = self.stream_id(user) return f"{stream_id}-unread" + def unread_by_status_type_id(self, user): + """the redis key for this user's unread count for this stream""" + stream_id = self.stream_id(user) + return f"{stream_id}-unread-by-type" + def get_rank(self, obj): # pylint: disable=no-self-use """statuses are sorted by date published""" return obj.published_date.timestamp() @@ -35,6 +40,10 @@ class ActivityStream(RedisStore): for user in self.get_audience(status): # add to the unread status count pipeline.incr(self.unread_id(user)) + # add to the unread status count for status type + pipeline.hincrby( + self.unread_by_status_type_id(user), get_status_type(status), 1 + ) # and go! pipeline.execute() @@ -55,6 +64,7 @@ class ActivityStream(RedisStore): """load the statuses to be displayed""" # clear unreads for this feed r.set(self.unread_id(user), 0) + r.delete(self.unread_by_status_type_id(user)) statuses = self.get_store(self.stream_id(user)) return ( @@ -75,6 +85,14 @@ class ActivityStream(RedisStore): """get the unread status count for this user's feed""" return int(r.get(self.unread_id(user)) or 0) + def get_unread_count_by_status_type(self, user): + """get the unread status count for this user's feed's status types""" + status_types = r.hgetall(self.unread_by_status_type_id(user)) + return { + str(key.decode("utf-8")): int(value) or 0 + for key, value in status_types.items() + } + def populate_streams(self, user): """go from zero to a timeline""" self.populate_store(self.stream_id(user)) @@ -460,7 +478,7 @@ def remove_status_task(status_ids): @app.task(queue=HIGH) def add_status_task(status_id, increment_unread=False): """add a status to any stream it should be in""" - status = models.Status.objects.get(id=status_id) + status = models.Status.objects.select_subclasses().get(id=status_id) # we don't want to tick the unread count for csv import statuses, idk how better # to check than just to see if the states is more than a few days old if status.created_date < timezone.now() - timedelta(days=2): @@ -507,3 +525,20 @@ def handle_boost_task(boost_id): stream.remove_object_from_related_stores(boosted, stores=audience) for status in old_versions: stream.remove_object_from_related_stores(status, stores=audience) + + +def get_status_type(status): + """return status type even for boosted statuses""" + status_type = status.status_type.lower() + + # Check if current status is a boost + if hasattr(status, "boost"): + # Act in accordance of your findings + if hasattr(status.boost.boosted_status, "review"): + status_type = "review" + if hasattr(status.boost.boosted_status, "comment"): + status_type = "comment" + if hasattr(status.boost.boosted_status, "quotation"): + status_type = "quotation" + + return status_type diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index 847ca05c0..249b92113 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -9,6 +9,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from bookwyrm import models +from bookwyrm.models.user import FeedFilterChoices class CustomForm(ModelForm): @@ -174,6 +175,18 @@ class UserGroupForm(CustomForm): fields = ["groups"] +class FeedStatusTypesForm(CustomForm): + class Meta: + model = models.User + fields = ["feed_status_types"] + help_texts = {f: None for f in fields} + widgets = { + "feed_status_types": widgets.CheckboxSelectMultiple( + choices=FeedFilterChoices, + ), + } + + class CoverForm(CustomForm): class Meta: model = models.Book diff --git a/bookwyrm/migrations/0119_user_feed_status_types.py b/bookwyrm/migrations/0119_user_feed_status_types.py new file mode 100644 index 000000000..64fa91697 --- /dev/null +++ b/bookwyrm/migrations/0119_user_feed_status_types.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.5 on 2021-11-24 10:15 + +import bookwyrm.models.user +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0118_alter_user_preferred_language"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="feed_status_types", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("review", "Reviews"), + ("comment", "Comments"), + ("quotation", "Quotations"), + ("everything", "Everything else"), + ], + max_length=10, + ), + default=bookwyrm.models.user.get_feed_filter_choices, + size=8, + ), + ), + ] diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index d7945843f..4d98f5c57 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -4,11 +4,12 @@ from urllib.parse import urlparse from django.apps import apps from django.contrib.auth.models import AbstractUser, Group -from django.contrib.postgres.fields import CICharField +from django.contrib.postgres.fields import ArrayField, CICharField from django.core.validators import MinValueValidator from django.dispatch import receiver from django.db import models, transaction from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from model_utils import FieldTracker import pytz @@ -27,6 +28,19 @@ from .federated_server import FederatedServer from . import fields, Review +FeedFilterChoices = [ + ("review", _("Reviews")), + ("comment", _("Comments")), + ("quotation", _("Quotations")), + ("everything", _("Everything else")), +] + + +def get_feed_filter_choices(): + """return a list of filter choice keys""" + return [f[0] for f in FeedFilterChoices] + + def site_link(): """helper for generating links to the site""" protocol = "https" if USE_HTTPS else "http" @@ -128,6 +142,13 @@ class User(OrderedCollectionPageMixin, AbstractUser): show_suggested_users = models.BooleanField(default=True) discoverable = fields.BooleanField(default=False) + # feed options + feed_status_types = ArrayField( + models.CharField(max_length=10, blank=False, choices=FeedFilterChoices), + size=8, + default=get_feed_filter_choices, + ) + preferred_timezone = models.CharField( choices=[(str(tz), str(tz)) for tz in pytz.all_timezones], default=str(pytz.utc), diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index e18087eeb..d656ed183 100644 --- a/bookwyrm/static/js/bookwyrm.js +++ b/bookwyrm/static/js/bookwyrm.js @@ -120,9 +120,44 @@ let BookWyrm = new class { * @return {undefined} */ updateCountElement(counter, data) { + let count = data.count; + const count_by_type = data.count_by_type; const currentCount = counter.innerText; - const count = data.count; const hasMentions = data.has_mentions; + const allowedStatusTypesEl = document.getElementById('unread-notifications-wrapper'); + + // If we're on the right counter element + if (counter.closest('[data-poll-wrapper]').contains(allowedStatusTypesEl)) { + const allowedStatusTypes = JSON.parse(allowedStatusTypesEl.textContent); + + // For keys in common between allowedStatusTypes and count_by_type + // This concerns 'review', 'quotation', 'comment' + count = allowedStatusTypes.reduce(function(prev, currentKey) { + const currentValue = count_by_type[currentKey] | 0; + + return prev + currentValue; + }, 0); + + // Add all the "other" in count_by_type if 'everything' is allowed + if (allowedStatusTypes.includes('everything')) { + // Clone count_by_type with 0 for reviews/quotations/comments + const count_by_everything_else = Object.assign( + {}, + count_by_type, + {review: 0, quotation: 0, comment: 0} + ); + + count = Object.keys(count_by_everything_else).reduce( + function(prev, currentKey) { + const currentValue = + count_by_everything_else[currentKey] | 0 + + return prev + currentValue; + }, + count + ); + } + } if (count != currentCount) { this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1); diff --git a/bookwyrm/templates/feed/feed.html b/bookwyrm/templates/feed/feed.html index a6175199d..1a2488afe 100644 --- a/bookwyrm/templates/feed/feed.html +++ b/bookwyrm/templates/feed/feed.html @@ -16,10 +16,45 @@ +{# feed settings #} +
+ + + {{ _("Feed settings") }} + + {% if settings_saved %} + {{ _("Saved!") }} + {% endif %} + +
+ {% csrf_token %} + +
+
+
+ + {% for name, value in feed_status_types_options %} + + {% endfor %} +
+
+
+
+ +
+
+
+ {# announcements and system messages #} {% if not activities.number > 1 %} {% if request.user.show_goal and not goal and tab.key == 'home' %} @@ -36,6 +71,7 @@ {% if not activities %}

{% trans "There aren't any activities right now! Try following a user to get started" %}

+

{% if user.feed_status_types|length < 4 %}{% trans "Alternatively, you can try enabling more status types" %}{% endif %}

{% if request.user.show_suggested_users and suggested_users %} {# suggested users for when things are very lonely #} diff --git a/bookwyrm/tests/views/test_updates.py b/bookwyrm/tests/views/test_updates.py index 27181fc95..e7b466ccf 100644 --- a/bookwyrm/tests/views/test_updates.py +++ b/bookwyrm/tests/views/test_updates.py @@ -50,10 +50,17 @@ class UpdateViews(TestCase): request = self.factory.get("") request.user = self.local_user - with patch("bookwyrm.activitystreams.ActivityStream.get_unread_count") as mock: - mock.return_value = 3 - result = views.get_unread_status_count(request, "home") + with patch( + "bookwyrm.activitystreams.ActivityStream.get_unread_count" + ) as mock_count: + with patch( + "bookwyrm.activitystreams.ActivityStream.get_unread_count_by_status_type" + ) as mock_count_by_status: + mock_count.return_value = 3 + mock_count_by_status.return_value = {"review": 5} + result = views.get_unread_status_count(request, "home") self.assertIsInstance(result, JsonResponse) data = json.loads(result.getvalue()) self.assertEqual(data["count"], 3) + self.assertEqual(data["count_by_type"]["review"], 5) diff --git a/bookwyrm/views/feed.py b/bookwyrm/views/feed.py index 1e44884fd..7cf56d48f 100644 --- a/bookwyrm/views/feed.py +++ b/bookwyrm/views/feed.py @@ -10,10 +10,11 @@ from django.utils.decorators import method_decorator from django.views import View from bookwyrm import activitystreams, forms, models +from bookwyrm.models.user import FeedFilterChoices from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.settings import PAGE_LENGTH, STREAMS from bookwyrm.suggested_users import suggested_users -from .helpers import get_user_from_username +from .helpers import filter_stream_by_status_type, get_user_from_username from .helpers import is_api_request, is_bookwyrm_request @@ -22,7 +23,17 @@ from .helpers import is_api_request, is_bookwyrm_request class Feed(View): """activity stream""" - def get(self, request, tab): + def post(self, request, tab): + """save feed settings form, with a silent validation fail""" + settings_saved = False + form = forms.FeedStatusTypesForm(request.POST, instance=request.user) + if form.is_valid(): + form.save() + settings_saved = True + + return self.get(request, tab, settings_saved) + + def get(self, request, tab, settings_saved=False): """user's homepage with activity feed""" tab = [s for s in STREAMS if s["key"] == tab] tab = tab[0] if tab else STREAMS[0] @@ -30,7 +41,11 @@ class Feed(View): activities = activitystreams.streams[tab["key"]].get_activity_stream( request.user ) - paginated = Paginator(activities, PAGE_LENGTH) + filtered_activities = filter_stream_by_status_type( + activities, + allowed_types=request.user.feed_status_types, + ) + paginated = Paginator(filtered_activities, PAGE_LENGTH) suggestions = suggested_users.get_suggestions(request.user) @@ -43,6 +58,9 @@ class Feed(View): "tab": tab, "streams": STREAMS, "goal_form": forms.GoalForm(), + "feed_status_types_options": FeedFilterChoices, + "allowed_status_types": request.user.feed_status_types, + "settings_saved": settings_saved, "path": f"/{tab['key']}", }, } diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index f28d01023..173cb85b5 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -6,6 +6,7 @@ import dateutil.tz from dateutil.parser import ParserError from requests import HTTPError +from django.db.models import Q from django.http import Http404 from django.utils import translation @@ -153,3 +154,29 @@ def set_language(user, response): translation.activate(user.preferred_language) response.set_cookie(settings.LANGUAGE_COOKIE_NAME, user.preferred_language) return response + + +def filter_stream_by_status_type(activities, allowed_types=None): + """filter out activities based on types""" + if not allowed_types: + allowed_types = [] + + if "review" not in allowed_types: + activities = activities.filter( + Q(review__isnull=True), Q(boost__boosted_status__review__isnull=True) + ) + if "comment" not in allowed_types: + activities = activities.filter( + Q(comment__isnull=True), Q(boost__boosted_status__comment__isnull=True) + ) + if "quotation" not in allowed_types: + activities = activities.filter( + Q(quotation__isnull=True), Q(boost__boosted_status__quotation__isnull=True) + ) + if "everything" not in allowed_types: + activities = activities.filter( + Q(generatednote__isnull=True), + Q(boost__boosted_status__generatednote__isnull=True), + ) + + return activities diff --git a/bookwyrm/views/updates.py b/bookwyrm/views/updates.py index 726145626..2bbc54776 100644 --- a/bookwyrm/views/updates.py +++ b/bookwyrm/views/updates.py @@ -22,4 +22,9 @@ def get_unread_status_count(request, stream="home"): stream = activitystreams.streams.get(stream) if not stream: return JsonResponse({}) - return JsonResponse({"count": stream.get_unread_count(request.user)}) + return JsonResponse( + { + "count": stream.get_unread_count(request.user), + "count_by_type": stream.get_unread_count_by_status_type(request.user), + } + ) diff --git a/locale/en_US/LC_MESSAGES/django.po b/locale/en_US/LC_MESSAGES/django.po index 5080502f5..b931da695 100644 --- a/locale/en_US/LC_MESSAGES/django.po +++ b/locale/en_US/LC_MESSAGES/django.po @@ -18,58 +18,58 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: bookwyrm/forms.py:248 +#: bookwyrm/forms.py:262 msgid "A user with this email already exists." msgstr "" -#: bookwyrm/forms.py:262 +#: bookwyrm/forms.py:276 msgid "One Day" msgstr "" -#: bookwyrm/forms.py:263 +#: bookwyrm/forms.py:277 msgid "One Week" msgstr "" -#: bookwyrm/forms.py:264 +#: bookwyrm/forms.py:278 msgid "One Month" msgstr "" -#: bookwyrm/forms.py:265 +#: bookwyrm/forms.py:279 msgid "Does Not Expire" msgstr "" -#: bookwyrm/forms.py:269 +#: bookwyrm/forms.py:283 #, python-brace-format msgid "{i} uses" msgstr "" -#: bookwyrm/forms.py:270 +#: bookwyrm/forms.py:284 msgid "Unlimited" msgstr "" -#: bookwyrm/forms.py:338 +#: bookwyrm/forms.py:352 msgid "List Order" msgstr "" -#: bookwyrm/forms.py:339 +#: bookwyrm/forms.py:353 msgid "Book Title" msgstr "" -#: bookwyrm/forms.py:340 bookwyrm/templates/shelf/shelf.html:149 +#: bookwyrm/forms.py:354 bookwyrm/templates/shelf/shelf.html:149 #: bookwyrm/templates/shelf/shelf.html:181 #: bookwyrm/templates/snippets/create_status/review.html:33 msgid "Rating" msgstr "" -#: bookwyrm/forms.py:342 bookwyrm/templates/lists/list.html:110 +#: bookwyrm/forms.py:356 bookwyrm/templates/lists/list.html:110 msgid "Sort By" msgstr "" -#: bookwyrm/forms.py:346 +#: bookwyrm/forms.py:360 msgid "Ascending" msgstr "" -#: bookwyrm/forms.py:347 +#: bookwyrm/forms.py:361 msgid "Descending" msgstr "" @@ -153,6 +153,22 @@ msgstr "" msgid "A user with that username already exists." msgstr "" +#: bookwyrm/models/user.py:32 bookwyrm/templates/book/book.html:218 +msgid "Reviews" +msgstr "" + +#: bookwyrm/models/user.py:33 +msgid "Comments" +msgstr "" + +#: bookwyrm/models/user.py:34 +msgid "Quotations" +msgstr "" + +#: bookwyrm/models/user.py:35 +msgid "Everything else" +msgstr "" + #: bookwyrm/settings.py:118 msgid "Home Timeline" msgstr "" @@ -449,10 +465,6 @@ msgstr "" msgid "You don't have any reading activity for this book." msgstr "" -#: bookwyrm/templates/book/book.html:218 -msgid "Reviews" -msgstr "" - #: bookwyrm/templates/book/book.html:223 msgid "Your reviews" msgstr "" @@ -1082,15 +1094,31 @@ msgstr "" msgid "You have no messages right now." msgstr "" -#: bookwyrm/templates/feed/feed.html:22 +#: bookwyrm/templates/feed/feed.html:23 +msgid "Feed settings" +msgstr "" + +#: bookwyrm/templates/feed/feed.html:26 +msgid "Saved!" +msgstr "" + +#: bookwyrm/templates/feed/feed.html:47 +msgid "Save settings" +msgstr "" + +#: bookwyrm/templates/feed/feed.html:56 #, python-format msgid "load 0 unread status(es)" msgstr "" -#: bookwyrm/templates/feed/feed.html:38 +#: bookwyrm/templates/feed/feed.html:72 msgid "There aren't any activities right now! Try following a user to get started" msgstr "" +#: bookwyrm/templates/feed/feed.html:73 +msgid "Alternatively, you can try enabling more status types" +msgstr "" + #: bookwyrm/templates/feed/goal_card.html:6 #: bookwyrm/templates/feed/layout.html:90 #: bookwyrm/templates/user/goal_form.html:6