From d70c73cad7e2a5a7ebc5938afd0b6a2a67b5b50d Mon Sep 17 00:00:00 2001 From: Thomas Sileo Date: Mon, 29 Jul 2019 19:36:22 +0200 Subject: [PATCH] Improve notifications - Keep track of unread count - "follow back" action --- activitypub.py | 7 +--- app.py | 93 ++++++++++++++++++++++++++++++------------ config.py | 6 +++ sass/base_theme.scss | 13 +++++- templates/layout.html | 5 ++- templates/stream.html | 34 +++++++++++++-- utils/__init__.py | 5 +++ utils/meta.py | 50 +++++++++++++++++++++++ utils/notifications.py | 84 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 261 insertions(+), 36 deletions(-) create mode 100644 utils/meta.py create mode 100644 utils/notifications.py diff --git a/activitypub.py b/activitypub.py index ad599fa..feb6dae 100644 --- a/activitypub.py +++ b/activitypub.py @@ -30,6 +30,7 @@ from config import ME from config import USER_AGENT from config import USERNAME from tasks import Tasks +from utils.meta import Box logger = logging.getLogger(__name__) @@ -95,12 +96,6 @@ def _is_local_reply(create: ap.Create) -> bool: return False -class Box(Enum): - INBOX = "inbox" - OUTBOX = "outbox" - REPLIES = "replies" - - class MicroblogPubBackend(Backend): """Implements a Little Boxes backend, backed by MongoDB.""" diff --git a/app.py b/app.py index 061ae97..88bec37 100644 --- a/app.py +++ b/app.py @@ -84,14 +84,18 @@ from config import USER_AGENT from config import USERNAME from config import VERSION from config import VERSION_DATE +from config import MetaKey from config import _drop_db +from config import _meta from poussetaches import PousseTaches from tasks import Tasks +from utils import now from utils import opengraph from utils import parse_datetime from utils.key import get_secret_key from utils.lookup import lookup from utils.media import Kind +from utils.notifications import set_inbox_flags p = PousseTaches( os.getenv("MICROBLOGPUB_POUSSETACHES_HOST", "http://localhost:7991"), @@ -179,6 +183,7 @@ def inject_config(): "type": ActivityType.FOLLOW.value, "meta.undo": False, } + unread_notifications_q = {_meta(MetaKey.NOTIFICATION_UNREAD): True} logged_in = session.get("logged_in", False) @@ -191,6 +196,9 @@ def inject_config(): notes_count=notes_count, liked_count=liked_count, with_replies_count=DB.activities.count(all_q) if logged_in else 0, + unread_notifications_count=DB.activities.count(unread_notifications_q) + if logged_in + else 0, me=ME, base_url=config.BASE_URL, ) @@ -695,6 +703,21 @@ def serve_uploads(oid, fname): # Login +@app.route("/admin/update_actor") +@login_required +def admin_update_actor(): + update = ap.Update( + actor=MY_PERSON.id, + object=MY_PERSON.to_dict(), + to=[MY_PERSON.followers], + cc=[ap.AS_PUBLIC], + published=now(), + ) + + post_to_outbox(update) + return "OK" + + @app.route("/admin/logout") @login_required def admin_logout(): @@ -783,11 +806,7 @@ def authorize_follow(): return redirect("/following") follow = ap.Follow( - actor=MY_PERSON.id, - object=actor, - to=[actor], - cc=[ap.AS_PUBLIC], - published=ap.format_datetime(datetime.now(timezone.utc)), + actor=MY_PERSON.id, object=actor, to=[actor], cc=[ap.AS_PUBLIC], published=now() ) post_to_outbox(follow) @@ -1649,17 +1668,23 @@ def admin_notifications(): .sort("_id", -1) .limit(50) ) + print(inbox_data) + + nid = None + if inbox_data: + nid = inbox_data[0]["_id"] + inbox_data.extend(notifs) inbox_data = sorted( inbox_data, reverse=True, key=lambda doc: doc["_id"].generation_time ) - print(inbox_data) return render_template( "stream.html", inbox_data=inbox_data, older_than=older_than, newer_than=newer_than, + nid=nid, ) @@ -1721,7 +1746,7 @@ def api_delete(): object=ap.Tombstone(id=note.id).to_dict(embed=True), to=note.to, cc=note.cc, - published=ap.format_datetime(datetime.now(timezone.utc)), + published=now(), ) delete_id = post_to_outbox(delete) @@ -1743,13 +1768,26 @@ def api_boost(): object=note.id, to=[MY_PERSON.followers, note.attributedTo], cc=[ap.AS_PUBLIC], - published=ap.format_datetime(datetime.now(timezone.utc)), + published=now(), ) announce_id = post_to_outbox(announce) return _user_api_response(activity=announce_id) +@app.route("/api/mark_notifications_as_read", methods=["POST"]) +@api_required +def api_mark_notification_as_read(): + nid = ObjectId(_user_api_arg("nid")) + + DB.activities.update_many( + {_meta(MetaKey.NOTIFICATION_UNREAD): True, "_id": {"$lte": nid}}, + {"$set": {_meta(MetaKey.NOTIFICATION_UNREAD): False}}, + ) + + return _user_api_response() + + @app.route("/api/vote", methods=["POST"]) @api_required def api_vote(): @@ -1794,13 +1832,7 @@ def api_like(): else: to = [note.get_actor().id] - like = ap.Like( - object=note.id, - actor=MY_PERSON.id, - to=to, - cc=cc, - published=ap.format_datetime(datetime.now(timezone.utc)), - ) + like = ap.Like(object=note.id, actor=MY_PERSON.id, to=to, cc=cc, published=now()) like_id = post_to_outbox(like) @@ -1870,7 +1902,7 @@ def api_undo(): undo = ap.Undo( actor=MY_PERSON.id, object=obj.to_dict(embed=True, embed_object_id_only=True), - published=ap.format_datetime(datetime.now(timezone.utc)), + published=now(), to=obj.to, cc=obj.cc, ) @@ -2360,11 +2392,7 @@ def api_follow(): return _user_api_response(activity=existing["activity"]["id"]) follow = ap.Follow( - actor=MY_PERSON.id, - object=actor, - to=[actor], - cc=[ap.AS_PUBLIC], - published=ap.format_datetime(datetime.now(timezone.utc)), + actor=MY_PERSON.id, object=actor, to=[actor], cc=[ap.AS_PUBLIC], published=now() ) follow_id = post_to_outbox(follow) @@ -2904,12 +2932,17 @@ def task_finish_post_to_inbox(): back.inbox_like(MY_PERSON, activity) elif activity.has_type(ap.ActivityType.FOLLOW): # Reply to a Follow with an Accept + actor_id = activity.get_actor().id accept = ap.Accept( actor=ID, - object=activity.to_dict(), - to=[activity.get_actor().id], - cc=[ap.AS_PUBLIC], - published=ap.format_datetime(datetime.now(timezone.utc)), + object={ + "type": "Follow", + "id": activity.id, + "object": activity.get_object_id(), + "actor": actor_id, + }, + to=[actor_id], + published=now(), ) post_to_outbox(accept) elif activity.has_type(ap.ActivityType.UNDO): @@ -3095,6 +3128,16 @@ def task_process_new_activity(): should_delete = False should_keep = False + flags = {} + + if not activity.published: + flags[_meta(MetaKey.PUBLISHED)] = now() + + set_inbox_flags(activity, flags) + app.logger.info(f"a={activity}, flags={flags!r}") + if flags: + DB.activities.update_one({"remote_id": activity.id}, {"$set": flags}) + tag_stream = False if activity.has_type(ap.ActivityType.ANNOUNCE): # FIXME(tsileo): Ensure it's follower and store into a "dead activities" DB diff --git a/config.py b/config.py index d5daf7a..040d7a7 100644 --- a/config.py +++ b/config.py @@ -16,6 +16,8 @@ from utils.key import KEY_DIR from utils.key import get_key from utils.key import get_secret_key from utils.media import MediaCache +from utils.meta import MetaKey +from utils.meta import _meta class ThemeStyle(Enum): @@ -111,6 +113,10 @@ def create_indexes(): DB.create_collection("trash", capped=True, size=50 << 20) # 50 MB DB.command("compact", "activities") + DB.activities.create_index([(_meta(MetaKey.NOTIFICATION), pymongo.ASCENDING)]) + DB.activities.create_index( + [(_meta(MetaKey.NOTIFICATION_UNREAD), pymongo.ASCENDING)] + ) DB.activities.create_index([("remote_id", pymongo.ASCENDING)]) DB.activities.create_index([("activity.object.id", pymongo.ASCENDING)]) DB.activities.create_index([("meta.thread_root_parent", pymongo.ASCENDING)]) diff --git a/sass/base_theme.scss b/sass/base_theme.scss index 371c29b..03a42a4 100644 --- a/sass/base_theme.scss +++ b/sass/base_theme.scss @@ -244,13 +244,24 @@ a:hover { background: $color-menu-background; padding: 5px; color: $color-light; - margin-right:5px; + margin-right:10px; border-radius:2px; float: left; } .bar-item-no-hover:hover { cursor: default; } +.bar-item-no-bg { + cursor: default; + padding: 5px; + color: $color-light; + margin-right:10px; + border-radius:2px; + float: left; +} +.bar-item-no-bg:hover { + cursor: default; +} .bar-item-pinned { cursor: default; background: $color-menu-background; diff --git a/templates/layout.html b/templates/layout.html index 502b083..c1865f5 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -27,7 +27,10 @@
  • Public
  • New
  • Stream
  • -
  • Notifications
  • +
  • Notifications + {% if unread_notifications_count %} + ({{unread_notifications_count}}) + {% endif %}
  • Lists
  • Bookmarks
  • Lookup
  • diff --git a/templates/stream.html b/templates/stream.html index b9d5768..bc55421 100644 --- a/templates/stream.html +++ b/templates/stream.html @@ -6,7 +6,18 @@ {% include "header.html" %}
    -
    +{% if request.path == url_for('admin_notifications') and unread_notifications_count %} +
    +
    + + + + +
    +
    +{% endif %} + +
    {% for item in inbox_data %} {% if 'actor' in item.meta %} {% if item | has_type('Create') %} @@ -31,13 +42,30 @@ {% endif %} {% if item | has_type('Follow') %} -

    new follower +

    + {% if item.meta.notification_unread %}new{% endif %} + new follower + {% if item.meta.notification_follows_back %}already following + {% else %} +
    + + + + +
    + {% endif %} +
    {{ utils.display_actor_inline(item.meta.actor, size=50) }}
    {% elif item | has_type('Accept') %} -

    you started following

    +
    + {% if item.meta.notification_unread %}new{% endif %} + you started following + {% if item.meta.notification_follows_back %}follows you back{% endif %} +
    +
    {{ utils.display_actor_inline(item.meta.actor, size=50) }}
    diff --git a/utils/__init__.py b/utils/__init__.py index ccffdd8..e64f407 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -3,6 +3,7 @@ from datetime import datetime from datetime import timezone from dateutil import parser +from little_boxes import activitypub as ap logger = logging.getLogger(__name__) @@ -25,3 +26,7 @@ def parse_datetime(s: str) -> datetime: dt = dt.replace(tzinfo=timezone.utc) return dt + + +def now() -> str: + ap.format_datetime(datetime.now(timezone.utc)) diff --git a/utils/meta.py b/utils/meta.py new file mode 100644 index 0000000..dd55866 --- /dev/null +++ b/utils/meta.py @@ -0,0 +1,50 @@ +from enum import Enum +from typing import Any +from typing import Dict + +from little_boxes import activitypub as ap + +_SubQuery = Dict[str, Any] + + +class Box(Enum): + INBOX = "inbox" + OUTBOX = "outbox" + REPLIES = "replies" + + +class MetaKey(Enum): + NOTIFICATION = "notification" + NOTIFICATION_UNREAD = "notification_unread" + NOTIFICATION_FOLLOWS_BACK = "notification_follows_back" + ACTOR_ID = "actor_id" + UNDO = "undo" + PUBLISHED = "published" + + +def _meta(mk: MetaKey) -> str: + return f"meta.{mk.value}" + + +def by_remote_id(remote_id: str) -> _SubQuery: + return {"remote_id": remote_id} + + +def in_inbox() -> _SubQuery: + return {"box": Box.INBOX.value} + + +def in_outbox() -> _SubQuery: + return {"box": Box.OUTBOX.value} + + +def by_type(type_: ap.ActivityType) -> _SubQuery: + return {"type": type_.value} + + +def not_undo() -> _SubQuery: + return {_meta(MetaKey.UNDO): False} + + +def by_actor(actor: ap.BaseActivity) -> _SubQuery: + return {_meta(MetaKey.ACTOR_ID): actor.id} diff --git a/utils/notifications.py b/utils/notifications.py new file mode 100644 index 0000000..54f9fb8 --- /dev/null +++ b/utils/notifications.py @@ -0,0 +1,84 @@ +import logging +from functools import singledispatch +from typing import Any +from typing import Dict + +from little_boxes import activitypub as ap + +from config import DB +from config import MetaKey +from config import _meta +from utils.meta import by_actor +from utils.meta import by_type +from utils.meta import in_inbox +from utils.meta import not_undo + +_logger = logging.getLogger(__name__) + +_NewMeta = Dict[str, Any] + + +@singledispatch +def set_inbox_flags(activity: ap.BaseActivity, new_meta: _NewMeta) -> None: + return None + + +@set_inbox_flags.register +def _accept_set_inbox_flags(activity: ap.Accept, new_meta: _NewMeta) -> None: + """Handle notifications for "accepted" following requests.""" + _logger.info(f"set_inbox_flags activity={activity!r}") + # Check if this actor already follow us back + follows_back = False + follow_query = { + **in_inbox(), + **by_type(ap.ActivityType.FOLLOW), + **by_actor(activity.get_actor()), + **not_undo(), + } + raw_follow = DB.activities.find_one(follow_query) + if raw_follow: + follows_back = True + + DB.activities.update_many( + follow_query, {"$set": {_meta(MetaKey.NOTIFICATION_FOLLOWS_BACK): True}} + ) + + # This Accept will be a "You started following $actor" notification + new_meta.update( + **{ + _meta(MetaKey.NOTIFICATION): True, + _meta(MetaKey.NOTIFICATION_UNREAD): True, + _meta(MetaKey.NOTIFICATION_FOLLOWS_BACK): follows_back, + } + ) + return None + + +@set_inbox_flags.register +def _follow_set_inbox_flags(activity: ap.Follow, new_meta: _NewMeta) -> None: + """Handle notification for new followers.""" + # Check if we're already following this actor + follows_back = False + accept_query = { + **in_inbox(), + **by_type(ap.ActivityType.ACCEPT), + **by_actor(activity.get_actor()), + **not_undo(), + } + raw_accept = DB.activities.find_one(accept_query) + if raw_accept: + follows_back = True + + DB.activities.update_many( + accept_query, {"$set": {_meta(MetaKey.NOTIFICATION_FOLLOWS_BACK): True}} + ) + + # This Follow will be a "$actor started following you" notification + new_meta.update( + **{ + _meta(MetaKey.NOTIFICATION): True, + _meta(MetaKey.NOTIFICATION_UNREAD): True, + _meta(MetaKey.NOTIFICATION_FOLLOWS_BACK): follows_back, + } + ) + return None