diff --git a/fedireads/incoming.py b/fedireads/incoming.py
index 1962ba43..22a701e3 100644
--- a/fedireads/incoming.py
+++ b/fedireads/incoming.py
@@ -13,7 +13,8 @@ import requests
from fedireads import activitypub
from fedireads import models
from fedireads import outgoing
-from fedireads.status import create_review, create_status, create_tag
+from fedireads.status import create_review, create_status, create_tag, \
+ create_notification
from fedireads.remote_user import get_or_create_remote_user
@@ -212,6 +213,7 @@ def handle_incoming_follow(activity):
# Accept, but then do we need to match the activity id?
return HttpResponse()
+ create_notification(to_follow, 'FOLLOW', related_user=user)
outgoing.handle_outgoing_accept(user, to_follow, activity)
return HttpResponse()
@@ -271,7 +273,14 @@ def handle_incoming_create(activity):
return HttpResponseBadRequest()
elif not user.local:
try:
- create_status(user, content)
+ status = create_status(user, content)
+ if status.reply_parent:
+ create_notification(
+ status.reply_parent.user,
+ 'REPLY',
+ related_user=status.user,
+ related_status=status,
+ )
except ValueError:
return HttpResponseBadRequest()
@@ -289,6 +298,13 @@ def handle_incoming_favorite(activity):
if not liker.local:
status.favorites.add(liker)
+
+ create_notification(
+ status.user,
+ 'FAVORITE',
+ related_user=liker,
+ related_status=status,
+ )
return HttpResponse()
diff --git a/fedireads/migrations/0011_notification.py b/fedireads/migrations/0011_notification.py
new file mode 100644
index 00000000..fbdc4b1a
--- /dev/null
+++ b/fedireads/migrations/0011_notification.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.0.3 on 2020-03-07 22:23
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fedireads', '0010_auto_20200307_0655'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Notification',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_date', models.DateTimeField(auto_now_add=True)),
+ ('updated_date', models.DateTimeField(auto_now=True)),
+ ('read', models.BooleanField(default=False)),
+ ('notification_type', models.CharField(max_length=255)),
+ ('related_book', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
+ ('related_status', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Status')),
+ ('related_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='related_user', to=settings.AUTH_USER_MODEL)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/fedireads/models/__init__.py b/fedireads/models/__init__.py
index dc463a07..6aecd41e 100644
--- a/fedireads/models/__init__.py
+++ b/fedireads/models/__init__.py
@@ -1,5 +1,5 @@
''' bring all the models into the app namespace '''
from .book import Book, Work, Edition, Author
from .shelf import Shelf, ShelfBook
-from .status import Status, Review, Favorite, Tag
+from .status import Status, Review, Favorite, Tag, Notification
from .user import User, UserRelationship, FederatedServer
diff --git a/fedireads/models/status.py b/fedireads/models/status.py
index 01acdae3..4077cef7 100644
--- a/fedireads/models/status.py
+++ b/fedireads/models/status.py
@@ -76,3 +76,29 @@ class Tag(FedireadsModel):
class Meta:
unique_together = ('user', 'book', 'name')
+
+class Notification(FedireadsModel):
+ ''' you've been tagged, liked, followed, etc '''
+ user = models.ForeignKey('User', on_delete=models.PROTECT)
+ related_book = models.ForeignKey(
+ 'Book', on_delete=models.PROTECT, null=True)
+ related_user = models.ForeignKey(
+ 'User',
+ on_delete=models.PROTECT, null=True, related_name='related_user')
+ related_status = models.ForeignKey(
+ 'Status', on_delete=models.PROTECT, null=True)
+ read = models.BooleanField(default=False)
+ notification_type = models.CharField(max_length=255)
+
+ def save(self, *args, **kwargs):
+ # TODO: there's probably a real way to do enums
+ types = [
+ 'FAVORITE',
+ 'REPLY',
+ 'TAG',
+ 'FOLLOW'
+ ]
+ if not self.notification_type in types:
+ raise ValueError('Invalid notitication type')
+ super().save(*args, **kwargs)
+
diff --git a/fedireads/outgoing.py b/fedireads/outgoing.py
index 8d5eb9f4..ac45f405 100644
--- a/fedireads/outgoing.py
+++ b/fedireads/outgoing.py
@@ -7,7 +7,8 @@ from urllib.parse import urlencode
from fedireads import activitypub
from fedireads import models
-from fedireads.status import create_review, create_status, create_tag
+from fedireads.status import create_review, create_status, create_tag, \
+ create_notification
from fedireads.remote_user import get_or_create_remote_user
from fedireads.broadcast import get_recipients, broadcast
@@ -189,6 +190,13 @@ def handle_comment(user, review, content):
''' respond to a review or status '''
# validated and saves the comment in the database so it has an id
comment = create_status(user, content, reply_parent=review)
+ if comment.reply_parent:
+ create_notification(
+ comment.reply_parent.user,
+ 'REPLY',
+ related_user=user,
+ related_status=comment,
+ )
comment_activity = activitypub.get_status(comment)
create_activity = activitypub.get_create(user, comment_activity)
diff --git a/fedireads/status.py b/fedireads/status.py
index 9fada128..120b4ba1 100644
--- a/fedireads/status.py
+++ b/fedireads/status.py
@@ -61,6 +61,18 @@ def create_tag(user, possible_book, name):
return tag
+def create_notification(user, notification_type, related_user=None, \
+ related_book=None, related_status=None):
+ ''' let a user know when someone interacts with their content '''
+ models.Notification.objects.create(
+ user=user,
+ related_book=related_book,
+ related_user=related_user,
+ related_status=related_status,
+ notification_type=notification_type,
+ )
+
+
def sanitize(content):
''' remove invalid html from free text '''
parser = InputHtmlParser()
diff --git a/fedireads/templates/layout.html b/fedireads/templates/layout.html
index c6290f93..e37679f0 100644
--- a/fedireads/templates/layout.html
+++ b/fedireads/templates/layout.html
@@ -1,3 +1,4 @@
+{% load fr_display %}
@@ -47,6 +48,13 @@
+ {% if request.user.is_authenticated %}
+
+ {% endif %}
diff --git a/fedireads/templates/notifications.html b/fedireads/templates/notifications.html
new file mode 100644
index 00000000..91b98b40
--- /dev/null
+++ b/fedireads/templates/notifications.html
@@ -0,0 +1,37 @@
+{% extends 'layout.html' %}
+{% load humanize %}l
+{% block content %}
+
+
+
Notifications
+
+ {% for notification in notifications %}
+
+
+ {% if notification.notification_type == 'FAVORITE' %}
+ {% include 'snippets/username.html' with user=notification.related_user %}
+ favorited your
+ status
+
+ {% elif notification.notification_type == 'REPLY' %}
+ {% include 'snippets/username.html' with user=notification.related_user %}
+ replied
+ to your
+ status
+
+ {% elif notification.notification_type == 'FOLLOW' %}
+ {% include 'snippets/username.html' with user=notification.related_user %}
+ followed you
+ {% endif %}
+ {{ notification.created_date | naturaltime }}
+
+
+ {% endfor %}
+
+
+
+{% endblock %}
+
diff --git a/fedireads/templates/snippets/user_preview.html b/fedireads/templates/snippets/user_preview.html
new file mode 100644
index 00000000..3989e908
--- /dev/null
+++ b/fedireads/templates/snippets/user_preview.html
@@ -0,0 +1,11 @@
+
+
+ {% include 'snippets/avatar.html' with user=user %}
+ {% include 'snippets/username.html' with user=user %}
+ {{ user.username }}
+
+ {% if not is_self %}
+ {% include 'snippets/follow_button.html' with user=user %}
+ {% endif %}
+
+
diff --git a/fedireads/templatetags/fr_display.py b/fedireads/templatetags/fr_display.py
index 2f5b5ac6..62d86175 100644
--- a/fedireads/templatetags/fr_display.py
+++ b/fedireads/templatetags/fr_display.py
@@ -49,6 +49,12 @@ def get_user_identifier(user):
return user.localname if user.localname else user.username
+@register.filter(name='notification_count')
+def get_notification_count(user):
+ ''' how many UNREAD notifications are there '''
+ return user.notification_set.filter(read=False).count()
+
+
@register.simple_tag(takes_context=True)
def shelve_button_identifier(context, book):
''' check what shelf a user has a book on, if any '''
diff --git a/fedireads/urls.py b/fedireads/urls.py
index 61cb48da..0de29308 100644
--- a/fedireads/urls.py
+++ b/fedireads/urls.py
@@ -38,7 +38,7 @@ urlpatterns = [
re_path(r'^register/?$', views.register),
re_path(r'^login/?$', views.user_login),
re_path(r'^logout/?$', views.user_logout),
- # this endpoint is both ui and fed depending on Accept type
+ re_path(r'^notifications/?', views.notifications_page),
re_path(r'%s/?$' % user_path, views.user_page),
re_path(r'%s/edit/?$' % user_path, views.edit_profile_page),
re_path(r'^user/edit/?$', views.edit_profile_page),
@@ -59,5 +59,6 @@ urlpatterns = [
re_path(r'^unfollow/?$', actions.unfollow),
re_path(r'^search/?$', actions.search),
re_path(r'^edit_profile/?$', actions.edit_profile),
+ re_path(r'^clear-notifications/?$', actions.clear_notifications),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/fedireads/view_actions.py b/fedireads/view_actions.py
index 5595cb02..eb8e5f47 100644
--- a/fedireads/view_actions.py
+++ b/fedireads/view_actions.py
@@ -157,3 +157,8 @@ def search(request):
return TemplateResponse(request, template, {'results': results})
+@login_required
+def clear_notifications(request):
+ request.user.notification_set.filter(read=True).delete()
+ return redirect('/notifications')
+
diff --git a/fedireads/views.py b/fedireads/views.py
index f115024f..2ad6ff7f 100644
--- a/fedireads/views.py
+++ b/fedireads/views.py
@@ -141,6 +141,15 @@ def register(request):
return redirect('/')
+def notifications_page(request):
+ ''' list notitications '''
+ data = {
+ 'notifications': request.user.notification_set.all().order_by('-created_date')
+ }
+ request.user.notification_set.update(read=True)
+ return TemplateResponse(request, 'notifications.html', data)
+
+
def user_page(request, username):
''' profile page for a user '''
content = request.headers.get('Accept')