mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-25 09:30:33 +00:00
Merge pull request #1630 from joachimesque/add-feed-filters
Add feed filters
This commit is contained in:
commit
cb2a890c3b
11 changed files with 286 additions and 29 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
32
bookwyrm/migrations/0119_user_feed_status_types.py
Normal file
32
bookwyrm/migrations/0119_user_feed_status_types.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 #}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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']}",
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue