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 %}
+
+
+
{% 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