Improve notifications

- Keep track of unread count
 - "follow back" action
This commit is contained in:
Thomas Sileo 2019-07-29 19:36:22 +02:00
parent 2ab59d9476
commit d70c73cad7
9 changed files with 261 additions and 36 deletions

View file

@ -30,6 +30,7 @@ from config import ME
from config import USER_AGENT from config import USER_AGENT
from config import USERNAME from config import USERNAME
from tasks import Tasks from tasks import Tasks
from utils.meta import Box
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -95,12 +96,6 @@ def _is_local_reply(create: ap.Create) -> bool:
return False return False
class Box(Enum):
INBOX = "inbox"
OUTBOX = "outbox"
REPLIES = "replies"
class MicroblogPubBackend(Backend): class MicroblogPubBackend(Backend):
"""Implements a Little Boxes backend, backed by MongoDB.""" """Implements a Little Boxes backend, backed by MongoDB."""

93
app.py
View file

@ -84,14 +84,18 @@ from config import USER_AGENT
from config import USERNAME from config import USERNAME
from config import VERSION from config import VERSION
from config import VERSION_DATE from config import VERSION_DATE
from config import MetaKey
from config import _drop_db from config import _drop_db
from config import _meta
from poussetaches import PousseTaches from poussetaches import PousseTaches
from tasks import Tasks from tasks import Tasks
from utils import now
from utils import opengraph from utils import opengraph
from utils import parse_datetime from utils import parse_datetime
from utils.key import get_secret_key from utils.key import get_secret_key
from utils.lookup import lookup from utils.lookup import lookup
from utils.media import Kind from utils.media import Kind
from utils.notifications import set_inbox_flags
p = PousseTaches( p = PousseTaches(
os.getenv("MICROBLOGPUB_POUSSETACHES_HOST", "http://localhost:7991"), os.getenv("MICROBLOGPUB_POUSSETACHES_HOST", "http://localhost:7991"),
@ -179,6 +183,7 @@ def inject_config():
"type": ActivityType.FOLLOW.value, "type": ActivityType.FOLLOW.value,
"meta.undo": False, "meta.undo": False,
} }
unread_notifications_q = {_meta(MetaKey.NOTIFICATION_UNREAD): True}
logged_in = session.get("logged_in", False) logged_in = session.get("logged_in", False)
@ -191,6 +196,9 @@ def inject_config():
notes_count=notes_count, notes_count=notes_count,
liked_count=liked_count, liked_count=liked_count,
with_replies_count=DB.activities.count(all_q) if logged_in else 0, 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, me=ME,
base_url=config.BASE_URL, base_url=config.BASE_URL,
) )
@ -695,6 +703,21 @@ def serve_uploads(oid, fname):
# Login # 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") @app.route("/admin/logout")
@login_required @login_required
def admin_logout(): def admin_logout():
@ -783,11 +806,7 @@ def authorize_follow():
return redirect("/following") return redirect("/following")
follow = ap.Follow( follow = ap.Follow(
actor=MY_PERSON.id, actor=MY_PERSON.id, object=actor, to=[actor], cc=[ap.AS_PUBLIC], published=now()
object=actor,
to=[actor],
cc=[ap.AS_PUBLIC],
published=ap.format_datetime(datetime.now(timezone.utc)),
) )
post_to_outbox(follow) post_to_outbox(follow)
@ -1649,17 +1668,23 @@ def admin_notifications():
.sort("_id", -1) .sort("_id", -1)
.limit(50) .limit(50)
) )
print(inbox_data)
nid = None
if inbox_data:
nid = inbox_data[0]["_id"]
inbox_data.extend(notifs) inbox_data.extend(notifs)
inbox_data = sorted( inbox_data = sorted(
inbox_data, reverse=True, key=lambda doc: doc["_id"].generation_time inbox_data, reverse=True, key=lambda doc: doc["_id"].generation_time
) )
print(inbox_data)
return render_template( return render_template(
"stream.html", "stream.html",
inbox_data=inbox_data, inbox_data=inbox_data,
older_than=older_than, older_than=older_than,
newer_than=newer_than, newer_than=newer_than,
nid=nid,
) )
@ -1721,7 +1746,7 @@ def api_delete():
object=ap.Tombstone(id=note.id).to_dict(embed=True), object=ap.Tombstone(id=note.id).to_dict(embed=True),
to=note.to, to=note.to,
cc=note.cc, cc=note.cc,
published=ap.format_datetime(datetime.now(timezone.utc)), published=now(),
) )
delete_id = post_to_outbox(delete) delete_id = post_to_outbox(delete)
@ -1743,13 +1768,26 @@ def api_boost():
object=note.id, object=note.id,
to=[MY_PERSON.followers, note.attributedTo], to=[MY_PERSON.followers, note.attributedTo],
cc=[ap.AS_PUBLIC], cc=[ap.AS_PUBLIC],
published=ap.format_datetime(datetime.now(timezone.utc)), published=now(),
) )
announce_id = post_to_outbox(announce) announce_id = post_to_outbox(announce)
return _user_api_response(activity=announce_id) 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"]) @app.route("/api/vote", methods=["POST"])
@api_required @api_required
def api_vote(): def api_vote():
@ -1794,13 +1832,7 @@ def api_like():
else: else:
to = [note.get_actor().id] to = [note.get_actor().id]
like = ap.Like( like = ap.Like(object=note.id, actor=MY_PERSON.id, to=to, cc=cc, published=now())
object=note.id,
actor=MY_PERSON.id,
to=to,
cc=cc,
published=ap.format_datetime(datetime.now(timezone.utc)),
)
like_id = post_to_outbox(like) like_id = post_to_outbox(like)
@ -1870,7 +1902,7 @@ def api_undo():
undo = ap.Undo( undo = ap.Undo(
actor=MY_PERSON.id, actor=MY_PERSON.id,
object=obj.to_dict(embed=True, embed_object_id_only=True), object=obj.to_dict(embed=True, embed_object_id_only=True),
published=ap.format_datetime(datetime.now(timezone.utc)), published=now(),
to=obj.to, to=obj.to,
cc=obj.cc, cc=obj.cc,
) )
@ -2360,11 +2392,7 @@ def api_follow():
return _user_api_response(activity=existing["activity"]["id"]) return _user_api_response(activity=existing["activity"]["id"])
follow = ap.Follow( follow = ap.Follow(
actor=MY_PERSON.id, actor=MY_PERSON.id, object=actor, to=[actor], cc=[ap.AS_PUBLIC], published=now()
object=actor,
to=[actor],
cc=[ap.AS_PUBLIC],
published=ap.format_datetime(datetime.now(timezone.utc)),
) )
follow_id = post_to_outbox(follow) follow_id = post_to_outbox(follow)
@ -2904,12 +2932,17 @@ def task_finish_post_to_inbox():
back.inbox_like(MY_PERSON, activity) back.inbox_like(MY_PERSON, activity)
elif activity.has_type(ap.ActivityType.FOLLOW): elif activity.has_type(ap.ActivityType.FOLLOW):
# Reply to a Follow with an Accept # Reply to a Follow with an Accept
actor_id = activity.get_actor().id
accept = ap.Accept( accept = ap.Accept(
actor=ID, actor=ID,
object=activity.to_dict(), object={
to=[activity.get_actor().id], "type": "Follow",
cc=[ap.AS_PUBLIC], "id": activity.id,
published=ap.format_datetime(datetime.now(timezone.utc)), "object": activity.get_object_id(),
"actor": actor_id,
},
to=[actor_id],
published=now(),
) )
post_to_outbox(accept) post_to_outbox(accept)
elif activity.has_type(ap.ActivityType.UNDO): elif activity.has_type(ap.ActivityType.UNDO):
@ -3095,6 +3128,16 @@ def task_process_new_activity():
should_delete = False should_delete = False
should_keep = 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 tag_stream = False
if activity.has_type(ap.ActivityType.ANNOUNCE): if activity.has_type(ap.ActivityType.ANNOUNCE):
# FIXME(tsileo): Ensure it's follower and store into a "dead activities" DB # FIXME(tsileo): Ensure it's follower and store into a "dead activities" DB

View file

@ -16,6 +16,8 @@ from utils.key import KEY_DIR
from utils.key import get_key from utils.key import get_key
from utils.key import get_secret_key from utils.key import get_secret_key
from utils.media import MediaCache from utils.media import MediaCache
from utils.meta import MetaKey
from utils.meta import _meta
class ThemeStyle(Enum): class ThemeStyle(Enum):
@ -111,6 +113,10 @@ def create_indexes():
DB.create_collection("trash", capped=True, size=50 << 20) # 50 MB DB.create_collection("trash", capped=True, size=50 << 20) # 50 MB
DB.command("compact", "activities") 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([("remote_id", pymongo.ASCENDING)])
DB.activities.create_index([("activity.object.id", pymongo.ASCENDING)]) DB.activities.create_index([("activity.object.id", pymongo.ASCENDING)])
DB.activities.create_index([("meta.thread_root_parent", pymongo.ASCENDING)]) DB.activities.create_index([("meta.thread_root_parent", pymongo.ASCENDING)])

View file

@ -244,13 +244,24 @@ a:hover {
background: $color-menu-background; background: $color-menu-background;
padding: 5px; padding: 5px;
color: $color-light; color: $color-light;
margin-right:5px; margin-right:10px;
border-radius:2px; border-radius:2px;
float: left; float: left;
} }
.bar-item-no-hover:hover { .bar-item-no-hover:hover {
cursor: default; 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 { .bar-item-pinned {
cursor: default; cursor: default;
background: $color-menu-background; background: $color-menu-background;

View file

@ -27,7 +27,10 @@
<li class="left"><a href="/" class="admin-title {% if not request.path.startswith("/admin") %} selected{% endif %}">Public</a></li> <li class="left"><a href="/" class="admin-title {% if not request.path.startswith("/admin") %} selected{% endif %}">Public</a></li>
<li class="left"><a href="/admin/new"{% if request.path == "/admin/new" %} class="selected" {% endif %}>New</a></li> <li class="left"><a href="/admin/new"{% if request.path == "/admin/new" %} class="selected" {% endif %}>New</a></li>
<li class="left"><a href="/admin/stream"{% if request.path == "/admin/stream" %} class="selected" {% endif %}>Stream</a></li> <li class="left"><a href="/admin/stream"{% if request.path == "/admin/stream" %} class="selected" {% endif %}>Stream</a></li>
<li class="left"><a href="/admin/notifications"{% if request.path == "/admin/notifications" %} class="selected" {% endif %}>Notifications</a></li> <li class="left"><a href="/admin/notifications"{% if request.path == "/admin/notifications" %} class="selected" {% endif %}>Notifications
{% if unread_notifications_count %}
({{unread_notifications_count}})
{% endif %}</a></li>
<li class="left"><a href="/admin/lists"{% if request.path == url_for('admin_lists') %} class="selected" {% endif %}>Lists</a></li> <li class="left"><a href="/admin/lists"{% if request.path == url_for('admin_lists') %} class="selected" {% endif %}>Lists</a></li>
<li class="left"><a href="/admin/bookmarks"{% if request.path == "/admin/bookmarks" %} class="selected" {% endif %}>Bookmarks</a></li> <li class="left"><a href="/admin/bookmarks"{% if request.path == "/admin/bookmarks" %} class="selected" {% endif %}>Bookmarks</a></li>
<li class="left"><a href="/admin/lookup"{% if request.path == "/admin/lookup" %} class="selected" {% endif %}>Lookup</a></li> <li class="left"><a href="/admin/lookup"{% if request.path == "/admin/lookup" %} class="selected" {% endif %}>Lookup</a></li>

View file

@ -6,7 +6,18 @@
{% include "header.html" %} {% include "header.html" %}
<div id="admin"> <div id="admin">
<div id="notes"> {% if request.path == url_for('admin_notifications') and unread_notifications_count %}
<div style="clear:both;padding-bottom:30px;">
<form action="/api/mark_notifications_as_read" method="POST">
<input type="hidden" name="redirect" value="{{ request.path }}"/>
<input type="hidden" name="nid" value="{{ nid }}"/>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="bar-item" style="text-transform:uppercase">Mark as read</button>
</form>
</div>
{% endif %}
<div id="notes" style="clear:both;">
{% for item in inbox_data %} {% for item in inbox_data %}
{% if 'actor' in item.meta %} {% if 'actor' in item.meta %}
{% if item | has_type('Create') %} {% if item | has_type('Create') %}
@ -31,13 +42,30 @@
{% endif %} {% endif %}
{% if item | has_type('Follow') %} {% if item | has_type('Follow') %}
<p style="margin-left:70px;padding-bottom:5px;display:inline-block;"><span class="bar-item-no-hover">new follower</span> <!-- <a href="" class="bar-item">follow back</a></p> --> <div style="margin-left:70px;padding-bottom:5px;margin-bottom:15px;display:inline-block;">
{% if item.meta.notification_unread %}<span class="bar-item-no-bg"><span class="pcolor">new</span></span>{% endif %}
<span class="bar-item-no-bg">new follower</span>
{% if item.meta.notification_follows_back %}<span class="bar-item-no-hover">already following</span>
{% else %}
<form action="/api/follow" class="action-form" method="POST">
<input type="hidden" name="redirect" value="{{ request.path }}"/>
<input type="hidden" name="actor" value="{{ item.meta.actor_id }}"/>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="bar-item">follow back</button>
</form>
{% endif %}
</div>
<div style="height: 100px;"> <div style="height: 100px;">
{{ utils.display_actor_inline(item.meta.actor, size=50) }} {{ utils.display_actor_inline(item.meta.actor, size=50) }}
</div> </div>
{% elif item | has_type('Accept') %} {% elif item | has_type('Accept') %}
<p style="margin-left:70px;padding-bottom:5px;display:inline-block;"><span class="bar-item-no-hover">you started following</span></p> <div style="margin-left:70px;padding-bottom:5px;margin-bottom:15px;display:inline-block;">
{% if item.meta.notification_unread %}<span class="bar-item-no-bg"><span class="pcolor">new</span></span>{% endif %}
<span class="bar-item-no-bg">you started following</span>
{% if item.meta.notification_follows_back %}<span class="bar-item-no-hover">follows you back</span>{% endif %}
</div>
<div style="height: 100px;"> <div style="height: 100px;">
{{ utils.display_actor_inline(item.meta.actor, size=50) }} {{ utils.display_actor_inline(item.meta.actor, size=50) }}
</div> </div>

View file

@ -3,6 +3,7 @@ from datetime import datetime
from datetime import timezone from datetime import timezone
from dateutil import parser from dateutil import parser
from little_boxes import activitypub as ap
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -25,3 +26,7 @@ def parse_datetime(s: str) -> datetime:
dt = dt.replace(tzinfo=timezone.utc) dt = dt.replace(tzinfo=timezone.utc)
return dt return dt
def now() -> str:
ap.format_datetime(datetime.now(timezone.utc))

50
utils/meta.py Normal file
View file

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

84
utils/notifications.py Normal file
View file

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