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)
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

View file

@ -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

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.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),

View file

@ -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);

View file

@ -16,10 +16,45 @@
</ul>
</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 #}
{% if not activities.number > 1 %}
<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 %}
{{ allowed_status_types|json_script:"unread-notifications-wrapper" }}
</a>
{% if request.user.show_goal and not goal and tab.key == 'home' %}
@ -36,6 +71,7 @@
{% if not activities %}
<div class="block content">
<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 %}
{# suggested users for when things are very lonely #}

View file

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

View file

@ -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']}",
},
}

View file

@ -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

View file

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

View file

@ -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 <span data-poll=\"stream/%(tab_key)s\">0</span> 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