Merge pull request #1630 from joachimesque/add-feed-filters

Add feed filters
This commit is contained in:
Mouse Reeve 2021-12-02 12:44:26 -08:00 committed by GitHub
commit cb2a890c3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 286 additions and 29 deletions

View file

@ -22,6 +22,11 @@ class ActivityStream(RedisStore):
stream_id = self.stream_id(user) stream_id = self.stream_id(user)
return f"{stream_id}-unread" 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 def get_rank(self, obj): # pylint: disable=no-self-use
"""statuses are sorted by date published""" """statuses are sorted by date published"""
return obj.published_date.timestamp() return obj.published_date.timestamp()
@ -35,6 +40,10 @@ class ActivityStream(RedisStore):
for user in self.get_audience(status): for user in self.get_audience(status):
# add to the unread status count # add to the unread status count
pipeline.incr(self.unread_id(user)) 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! # and go!
pipeline.execute() pipeline.execute()
@ -55,6 +64,7 @@ class ActivityStream(RedisStore):
"""load the statuses to be displayed""" """load the statuses to be displayed"""
# clear unreads for this feed # clear unreads for this feed
r.set(self.unread_id(user), 0) r.set(self.unread_id(user), 0)
r.delete(self.unread_by_status_type_id(user))
statuses = self.get_store(self.stream_id(user)) statuses = self.get_store(self.stream_id(user))
return ( return (
@ -75,6 +85,14 @@ class ActivityStream(RedisStore):
"""get the unread status count for this user's feed""" """get the unread status count for this user's feed"""
return int(r.get(self.unread_id(user)) or 0) 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): def populate_streams(self, user):
"""go from zero to a timeline""" """go from zero to a timeline"""
self.populate_store(self.stream_id(user)) self.populate_store(self.stream_id(user))
@ -460,7 +478,7 @@ def remove_status_task(status_ids):
@app.task(queue=HIGH) @app.task(queue=HIGH)
def add_status_task(status_id, increment_unread=False): def add_status_task(status_id, increment_unread=False):
"""add a status to any stream it should be in""" """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 # 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 # 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): 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) stream.remove_object_from_related_stores(boosted, stores=audience)
for status in old_versions: for status in old_versions:
stream.remove_object_from_related_stores(status, stores=audience) 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

View file

@ -9,6 +9,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from bookwyrm import models from bookwyrm import models
from bookwyrm.models.user import FeedFilterChoices
class CustomForm(ModelForm): class CustomForm(ModelForm):
@ -174,6 +175,18 @@ class UserGroupForm(CustomForm):
fields = ["groups"] 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 CoverForm(CustomForm):
class Meta: class Meta:
model = models.Book model = models.Book

View file

@ -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,
),
),
]

View file

@ -4,11 +4,12 @@ from urllib.parse import urlparse
from django.apps import apps from django.apps import apps
from django.contrib.auth.models import AbstractUser, Group 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.core.validators import MinValueValidator
from django.dispatch import receiver from django.dispatch import receiver
from django.db import models, transaction from django.db import models, transaction
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker from model_utils import FieldTracker
import pytz import pytz
@ -27,6 +28,19 @@ from .federated_server import FederatedServer
from . import fields, Review 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(): def site_link():
"""helper for generating links to the site""" """helper for generating links to the site"""
protocol = "https" if USE_HTTPS else "http" protocol = "https" if USE_HTTPS else "http"
@ -128,6 +142,13 @@ class User(OrderedCollectionPageMixin, AbstractUser):
show_suggested_users = models.BooleanField(default=True) show_suggested_users = models.BooleanField(default=True)
discoverable = fields.BooleanField(default=False) 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( preferred_timezone = models.CharField(
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones], choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
default=str(pytz.utc), default=str(pytz.utc),

View file

@ -120,9 +120,44 @@ let BookWyrm = new class {
* @return {undefined} * @return {undefined}
*/ */
updateCountElement(counter, data) { updateCountElement(counter, data) {
let count = data.count;
const count_by_type = data.count_by_type;
const currentCount = counter.innerText; const currentCount = counter.innerText;
const count = data.count;
const hasMentions = data.has_mentions; 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) { if (count != currentCount) {
this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1); this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1);

View file

@ -16,10 +16,45 @@
</ul> </ul>
</div> </div>
{# feed settings #}
<details class="mb-5" {% if settings_saved %}open{% endif %}>
<summary>
<span class="has-text-weight-bold">
{{ _("Feed settings") }}
</span>
{% if settings_saved %}
<span class="tag is-success is-light ml-2">{{ _("Saved!") }}</span>
{% endif %}
</summary>
<form class="level is-align-items-flex-end" method="post" action="/{{ tab.key }}#feed">
{% csrf_token %}
<div class="level-left">
<div class="field">
<div class="control">
<label class="label mt-2 mb-1">Status types</label>
{% for name, value in feed_status_types_options %}
<label class="mr-2">
<input type="checkbox" name="feed_status_types" value="{{ name }}" {% if name in user.feed_status_types %}checked=""{% endif %}/>
{{ value }}
</label>
{% endfor %}
</div>
</div>
</div>
<div class="level-right control">
<button class="button is-small is-primary is-outlined" type="submit">
{{ _("Save settings") }}
</button>
</div>
</form>
</details>
{# announcements and system messages #} {# announcements and system messages #}
{% if not activities.number > 1 %} {% if not activities.number > 1 %}
<a href="{{ request.path }}" class="transition-y is-hidden notification is-primary is-block" data-poll-wrapper> <a href="{{ request.path }}" class="transition-y is-hidden notification is-primary is-block" data-poll-wrapper>
{% blocktrans with tab_key=tab.key %}load <span data-poll="stream/{{ tab_key }}">0</span> unread status(es){% endblocktrans %} {% blocktrans with tab_key=tab.key %}load <span data-poll="stream/{{ tab_key }}">0</span> unread status(es){% endblocktrans %}
{{ allowed_status_types|json_script:"unread-notifications-wrapper" }}
</a> </a>
{% if request.user.show_goal and not goal and tab.key == 'home' %} {% if request.user.show_goal and not goal and tab.key == 'home' %}
@ -36,6 +71,7 @@
{% if not activities %} {% if not activities %}
<div class="block content"> <div class="block content">
<p>{% trans "There aren't any activities right now! Try following a user to get started" %}</p> <p>{% trans "There aren't any activities right now! Try following a user to get started" %}</p>
<p>{% if user.feed_status_types|length < 4 %}{% trans "Alternatively, you can try enabling more status types" %}{% endif %}</p>
{% if request.user.show_suggested_users and suggested_users %} {% if request.user.show_suggested_users and suggested_users %}
{# suggested users for when things are very lonely #} {# suggested users for when things are very lonely #}

View file

@ -50,10 +50,17 @@ class UpdateViews(TestCase):
request = self.factory.get("") request = self.factory.get("")
request.user = self.local_user request.user = self.local_user
with patch("bookwyrm.activitystreams.ActivityStream.get_unread_count") as mock: with patch(
mock.return_value = 3 "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") result = views.get_unread_status_count(request, "home")
self.assertIsInstance(result, JsonResponse) self.assertIsInstance(result, JsonResponse)
data = json.loads(result.getvalue()) data = json.loads(result.getvalue())
self.assertEqual(data["count"], 3) self.assertEqual(data["count"], 3)
self.assertEqual(data["count_by_type"]["review"], 5)

View file

@ -10,10 +10,11 @@ from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from bookwyrm import activitystreams, forms, models from bookwyrm import activitystreams, forms, models
from bookwyrm.models.user import FeedFilterChoices
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH, STREAMS from bookwyrm.settings import PAGE_LENGTH, STREAMS
from bookwyrm.suggested_users import suggested_users 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 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): class Feed(View):
"""activity stream""" """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""" """user's homepage with activity feed"""
tab = [s for s in STREAMS if s["key"] == tab] tab = [s for s in STREAMS if s["key"] == tab]
tab = tab[0] if tab else STREAMS[0] tab = tab[0] if tab else STREAMS[0]
@ -30,7 +41,11 @@ class Feed(View):
activities = activitystreams.streams[tab["key"]].get_activity_stream( activities = activitystreams.streams[tab["key"]].get_activity_stream(
request.user 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) suggestions = suggested_users.get_suggestions(request.user)
@ -43,6 +58,9 @@ class Feed(View):
"tab": tab, "tab": tab,
"streams": STREAMS, "streams": STREAMS,
"goal_form": forms.GoalForm(), "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']}", "path": f"/{tab['key']}",
}, },
} }

View file

@ -6,6 +6,7 @@ import dateutil.tz
from dateutil.parser import ParserError from dateutil.parser import ParserError
from requests import HTTPError from requests import HTTPError
from django.db.models import Q
from django.http import Http404 from django.http import Http404
from django.utils import translation from django.utils import translation
@ -153,3 +154,29 @@ def set_language(user, response):
translation.activate(user.preferred_language) translation.activate(user.preferred_language)
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, user.preferred_language) response.set_cookie(settings.LANGUAGE_COOKIE_NAME, user.preferred_language)
return response 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

View file

@ -22,4 +22,9 @@ def get_unread_status_count(request, stream="home"):
stream = activitystreams.streams.get(stream) stream = activitystreams.streams.get(stream)
if not stream: if not stream:
return JsonResponse({}) 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),
}
)

View file

@ -18,58 +18,58 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\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." msgid "A user with this email already exists."
msgstr "" msgstr ""
#: bookwyrm/forms.py:262 #: bookwyrm/forms.py:276
msgid "One Day" msgid "One Day"
msgstr "" msgstr ""
#: bookwyrm/forms.py:263 #: bookwyrm/forms.py:277
msgid "One Week" msgid "One Week"
msgstr "" msgstr ""
#: bookwyrm/forms.py:264 #: bookwyrm/forms.py:278
msgid "One Month" msgid "One Month"
msgstr "" msgstr ""
#: bookwyrm/forms.py:265 #: bookwyrm/forms.py:279
msgid "Does Not Expire" msgid "Does Not Expire"
msgstr "" msgstr ""
#: bookwyrm/forms.py:269 #: bookwyrm/forms.py:283
#, python-brace-format #, python-brace-format
msgid "{i} uses" msgid "{i} uses"
msgstr "" msgstr ""
#: bookwyrm/forms.py:270 #: bookwyrm/forms.py:284
msgid "Unlimited" msgid "Unlimited"
msgstr "" msgstr ""
#: bookwyrm/forms.py:338 #: bookwyrm/forms.py:352
msgid "List Order" msgid "List Order"
msgstr "" msgstr ""
#: bookwyrm/forms.py:339 #: bookwyrm/forms.py:353
msgid "Book Title" msgid "Book Title"
msgstr "" 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/shelf/shelf.html:181
#: bookwyrm/templates/snippets/create_status/review.html:33 #: bookwyrm/templates/snippets/create_status/review.html:33
msgid "Rating" msgid "Rating"
msgstr "" msgstr ""
#: bookwyrm/forms.py:342 bookwyrm/templates/lists/list.html:110 #: bookwyrm/forms.py:356 bookwyrm/templates/lists/list.html:110
msgid "Sort By" msgid "Sort By"
msgstr "" msgstr ""
#: bookwyrm/forms.py:346 #: bookwyrm/forms.py:360
msgid "Ascending" msgid "Ascending"
msgstr "" msgstr ""
#: bookwyrm/forms.py:347 #: bookwyrm/forms.py:361
msgid "Descending" msgid "Descending"
msgstr "" msgstr ""
@ -153,6 +153,22 @@ msgstr ""
msgid "A user with that username already exists." msgid "A user with that username already exists."
msgstr "" 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 #: bookwyrm/settings.py:118
msgid "Home Timeline" msgid "Home Timeline"
msgstr "" msgstr ""
@ -449,10 +465,6 @@ msgstr ""
msgid "You don't have any reading activity for this book." msgid "You don't have any reading activity for this book."
msgstr "" msgstr ""
#: bookwyrm/templates/book/book.html:218
msgid "Reviews"
msgstr ""
#: bookwyrm/templates/book/book.html:223 #: bookwyrm/templates/book/book.html:223
msgid "Your reviews" msgid "Your reviews"
msgstr "" msgstr ""
@ -1082,15 +1094,31 @@ msgstr ""
msgid "You have no messages right now." msgid "You have no messages right now."
msgstr "" 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 #, python-format
msgid "load <span data-poll=\"stream/%(tab_key)s\">0</span> unread status(es)" msgid "load <span data-poll=\"stream/%(tab_key)s\">0</span> unread status(es)"
msgstr "" 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" msgid "There aren't any activities right now! Try following a user to get started"
msgstr "" 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/goal_card.html:6
#: bookwyrm/templates/feed/layout.html:90 #: bookwyrm/templates/feed/layout.html:90
#: bookwyrm/templates/user/goal_form.html:6 #: bookwyrm/templates/user/goal_form.html:6