user page redesign: add home, notifications pages

for #442
pull/671/head
Ryan Barrett 2023-10-10 14:55:27 -07:00
rodzic afa16d3864
commit c1e0a08f72
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6BE31FDF4776E9D4
7 zmienionych plików z 97 dodań i 56 usunięć

Wyświetl plik

@ -957,7 +957,7 @@ class Follower(ndb.Model):
filter_prop == g.user.key, filter_prop == g.user.key,
).order(-Follower.updated) ).order(-Follower.updated)
followers, before, after = fetch_page(query, Follower) followers, before, after = fetch_page(query, Follower, by=Follower.updated)
users = ndb.get_multi(f.from_ if collection == 'followers' else f.to users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
for f in followers) for f in followers)
User.load_multi(u for u in users if u) User.load_multi(u for u in users if u)
@ -968,12 +968,11 @@ class Follower(ndb.Model):
return followers, before, after return followers, before, after
def fetch_page(query, model_class): def fetch_page(query, model_class, by=None):
"""Fetches a page of results from a datastore query. """Fetches a page of results from a datastore query.
Uses the ``before`` and ``after`` query params (if provided; should be Uses the ``before`` and ``after`` query params (if provided; should be
ISO8601 timestamps) and the queried model class's ``updated`` property to ISO8601 timestamps) and the ``by`` property to identify the page to fetch.
identify the page to fetch.
Populates a ``log_url_path`` property on each result entity that points to a Populates a ``log_url_path`` property on each result entity that points to a
its most recent logged request. its most recent logged request.
@ -981,6 +980,8 @@ def fetch_page(query, model_class):
Args: Args:
query (google.cloud.ndb.query.Query) query (google.cloud.ndb.query.Query)
model_class (class) model_class (class)
by (ndb.model.Property): paging property, eg :attr:`models.Object.updated`
or :attr:`models.Object.created`
Returns: Returns:
(list of Object or Follower, str, str) tuple: (results, new_before, (list of Object or Follower, str, str) tuple: (results, new_before,
@ -988,6 +989,8 @@ def fetch_page(query, model_class):
``before`` and ``after`` to fetch the previous and next pages, ``before`` and ``after`` to fetch the previous and next pages,
respectively respectively
""" """
assert by
# if there's a paging param ('before' or 'after'), update query with it # if there's a paging param ('before' or 'after'), update query with it
# TODO: unify this with Bridgy's user page # TODO: unify this with Bridgy's user page
def get_paging_param(param): def get_paging_param(param):
@ -1006,11 +1009,11 @@ def fetch_page(query, model_class):
if before and after: if before and after:
error("can't handle both before and after") error("can't handle both before and after")
elif after: elif after:
query = query.filter(model_class.updated >= after).order(model_class.updated) query = query.filter(by >= after).order(by)
elif before: elif before:
query = query.filter(model_class.updated < before).order(-model_class.updated) query = query.filter(by < before).order(-by)
else: else:
query = query.order(-model_class.updated) query = query.order(-by)
query_iter = query.iter() query_iter = query.iter()
results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE), results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),

Wyświetl plik

@ -9,7 +9,7 @@ from flask import g, render_template, request
from google.cloud.ndb import tasklets from google.cloud.ndb import tasklets
from google.cloud.ndb.query import AND, OR from google.cloud.ndb.query import AND, OR
from google.cloud.ndb.stats import KindStat from google.cloud.ndb.stats import KindStat
from granary import as1, as2, atom, microformats2, rss from granary import as1, atom, microformats2, rss
import humanize import humanize
from oauth_dropins.webutil import flask_util, logs, util from oauth_dropins.webutil import flask_util, logs, util
from oauth_dropins.webutil.flask_util import error, flash, redirect from oauth_dropins.webutil.flask_util import error, flash, redirect
@ -42,6 +42,9 @@ def load_user(protocol, id):
:class:`werkzeug.exceptions.HTTPException` on error or redirect :class:`werkzeug.exceptions.HTTPException` on error or redirect
""" """
assert id assert id
if protocol == 'ap' and not id.startswith('@'):
id = '@' + id
cls = PROTOCOLS[protocol] cls = PROTOCOLS[protocol]
g.user = cls.get_by_id(id) g.user = cls.get_by_id(id)
@ -95,15 +98,12 @@ def web_user_redirects(**kwargs):
# WARNING: this overrides the /ap/... actor URL route in activitypub.py, *only* # WARNING: this overrides the /ap/... actor URL route in activitypub.py, *only*
# for handles with leading @ character. be careful when changing this route! # for handles with leading @ character. be careful when changing this route!
@app.get(f'/ap/@<id>', defaults={'protocol': 'ap'}) @app.get(f'/ap/@<id>', defaults={'protocol': 'ap'})
def user(protocol, id): def profile(protocol, id):
if protocol == 'ap' and not id.startswith('@'):
id = '@' + id
load_user(protocol, id) load_user(protocol, id)
query = Object.query(OR(Object.users == g.user.key, query = Object.query(OR(Object.users == g.user.key,
Object.notify == g.user.key)) Object.notify == g.user.key))
objects, before, after = fetch_objects(query) objects, before, after = fetch_objects(query, by=Object.updated)
followers = Follower.query(Follower.to == g.user.key, followers = Follower.query(Follower.to == g.user.key,
Follower.status == 'active')\ Follower.status == 'active')\
@ -115,15 +115,27 @@ def user(protocol, id):
.count(limit=FOLLOWERS_UI_LIMIT) .count(limit=FOLLOWERS_UI_LIMIT)
following = f'{following}{"+" if following == FOLLOWERS_UI_LIMIT else ""}' following = f'{following}{"+" if following == FOLLOWERS_UI_LIMIT else ""}'
return render_template( return render_template('profile.html', logs=logs, util=util, g=g, **locals())
'profile.html',
follow_url=request.values.get('url'),
logs=logs, @app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/home')
util=util, def home(protocol, id):
address=request.args.get('address'), load_user(protocol, id)
g=g,
**locals(), query = Object.query(Object.feed == g.user.key)
) objects, before, after = fetch_objects(query, by=Object.created)
return render_template('home.html', logs=logs, util=util, g=g, **locals())
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/notifications')
def notifications(protocol, id):
load_user(protocol, id)
query = Object.query(Object.notify == g.user.key)
objects, before, after = fetch_objects(query, by=Object.updated)
return render_template('notifications.html', logs=logs, util=util, g=g, **locals())
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/<any(followers,following):collection>') @app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/<any(followers,following):collection>')
@ -134,10 +146,10 @@ def followers_or_following(protocol, id, collection):
return render_template( return render_template(
f'{collection}.html', f'{collection}.html',
address=request.args.get('address'), address=request.args.get('address'),
as2=as2, follow_url=request.values.get('url'),
g=g, g=g,
util=util, util=util,
**locals() **locals(),
) )
@ -149,13 +161,13 @@ def feed(protocol, id):
load_user(protocol, id) load_user(protocol, id)
objects = Object.query(OR(Object.feed == g.user.key, query = Object.query(Object.feed == g.user.key)
# backward compatibility # .order(-Object.created) \
AND(Object.users == g.user.key, # .fetch(PAGE_SIZE)
Object.labels == 'feed'))) \ # activities = [obj.as1 for obj in objects if not obj.deleted]
.order(-Object.created) \
.fetch(PAGE_SIZE) objects, _, _ = fetch_objects(query, by=Object.created)
activities = [obj.as1 for obj in objects if not obj.deleted] activities = [obj.as1 for obj in objects]
# hydrate authors, actors, objects from stored Objects # hydrate authors, actors, objects from stored Objects
fields = 'author', 'actor', 'object' fields = 'author', 'actor', 'object'
@ -230,7 +242,7 @@ def bridge_user():
return render_template('bridge_user.html') return render_template('bridge_user.html')
def fetch_objects(query): def fetch_objects(query, by=None):
"""Fetches a page of :class:`models.Object` entities from a datastore query. """Fetches a page of :class:`models.Object` entities from a datastore query.
Wraps :func:`models.fetch_page` and adds attributes to the returned Wraps :func:`models.fetch_page` and adds attributes to the returned
@ -238,16 +250,22 @@ def fetch_objects(query):
Args: Args:
query (ndb.Query) query (ndb.Query)
by (ndb.model.Property): either :attr:`models.Object.updated` or
:attr:`models.Object.created`
Returns: Returns:
(list of models.Object, str, str) tuple: (list of models.Object, str, str) tuple:
(results, new ``before`` query param, new ``after`` query param) (results, new ``before`` query param, new ``after`` query param)
to fetch the previous and next pages, respectively to fetch the previous and next pages, respectively
""" """
objects, new_before, new_after = fetch_page(query, Object) assert by is Object.updated or by is Object.created
objects, new_before, new_after = fetch_page(query, Object, by=by)
# synthesize human-friendly content for objects # synthesize human-friendly content for objects
for i, obj in enumerate(objects): for i, obj in enumerate(objects):
if obj.deleted:
continue
obj_as1 = obj.as1 obj_as1 = obj.as1
inner_obj = as1.get_object(obj_as1) inner_obj = as1.get_object(obj_as1)

Wyświetl plik

@ -0,0 +1,11 @@
{% extends "user_base.html" %}
{% set tab = "home" %}
{% block subtabs %}
<div class="row">
Subscribe:
<a href="{{ g.user.user_page_path('feed') }}">HTML</a>
&middot; <a href="{{ g.user.user_page_path('feed?format=atom') }}">Atom</a>
&middot; <a href="{{ g.user.user_page_path('feed?format=rss') }}">RSS</a>
</div>
{% endblock subtabs %}

Wyświetl plik

@ -0,0 +1,11 @@
{% extends "user_base.html" %}
{% set tab = "notifications" %}
{% block subtabs %}
<div class="row">
Subscribe:
<a href="{{ g.user.user_page_path('feed') }}">HTML</a>
&middot; <a href="{{ g.user.user_page_path('feed?format=atom') }}">Atom</a>
&middot; <a href="{{ g.user.user_page_path('feed?format=rss') }}">RSS</a>
</div>
{% endblock subtabs %}

Wyświetl plik

@ -9,7 +9,7 @@
&middot; &middot;
{% if g.user.LABEL != 'activitypub' %} {% if g.user.LABEL != 'activitypub' %}
Bridged to bridged to
<nobr title="Fediverse address"> <nobr title="Fediverse address">
<img class="logo" src="/static/fediverse_logo.svg"> <img class="logo" src="/static/fediverse_logo.svg">
{{ g.user.ap_address() }} {{ g.user.ap_address() }}
@ -24,8 +24,3 @@
{% endif %} {% endif %}
</div> </div>
{% endblock subtabs %} {% endblock subtabs %}
{#
{% block feed %}
{% endblock feed %}
#}

Wyświetl plik

@ -41,7 +41,7 @@
<!-- tabs --> <!-- tabs -->
<div class="row tabs"> <div class="row tabs">
<a> <a></a>
<a href="{{ g.user.user_page_path() }}" <a href="{{ g.user.user_page_path() }}"
{% if tab == 'profile' %}class="active-tab"{% endif %} {% if tab == 'profile' %}class="active-tab"{% endif %}
>👤 Profile</a><a >👤 Profile</a><a
@ -51,17 +51,10 @@
href="{{ g.user.user_page_path('notifications') }}" href="{{ g.user.user_page_path('notifications') }}"
{% if tab == 'notifications' %}class="active-tab"{% endif %} {% if tab == 'notifications' %}class="active-tab"{% endif %}
>🔔 Notifications</a> >🔔 Notifications</a>
<a> <a></a>
</div> </div>
{% block subtabs %} {% block subtabs %}
<div class="row">
&middot; <a href="{{ g.user.user_page_path('feed') }}">HTML</a>
&middot; <a href="{{ g.user.user_page_path('feed?format=atom') }}">Atom</a>
&middot; <a href="{{ g.user.user_page_path('feed?format=rss') }}">RSS</a>
&middot; <a href="/.well-known/webfinger?resource=acct:{{ g.user.ap_address() }}">Webfinger</a>
&middot; <a href="{{ g.user.ap_actor() }}">ActivityPub</a>
</div>
{% endblock subtabs %} {% endblock subtabs %}
{% block feed %} {% block feed %}

Wyświetl plik

@ -44,8 +44,8 @@ class PagesTest(TestCase):
self.assert_equals(200, got.status_code) self.assert_equals(200, got.status_code)
def test_user_fake(self): def test_user_fake(self):
self.make_user('foo.com', cls=Fake) self.make_user('fake:foo', cls=Fake)
got = self.client.get('/fa/foo.com') got = self.client.get('/fa/fake:foo')
self.assert_equals(200, got.status_code) self.assert_equals(200, got.status_code)
def test_user_page_handle(self): def test_user_page_handle(self):
@ -171,9 +171,19 @@ class PagesTest(TestCase):
self.assertIn('@follow@stored', body) self.assertIn('@follow@stored', body)
self.assertIn('@me@plus.google.com', body) self.assertIn('@me@plus.google.com', body)
def test_home_fake(self):
self.make_user('fake:foo', cls=Fake)
got = self.client.get('/fa/fake:foo/home')
self.assert_equals(200, got.status_code)
def test_home_objects(self):
self.add_objects()
got = self.client.get('/web/user.com/home')
self.assert_equals(200, got.status_code)
def test_followers_fake(self): def test_followers_fake(self):
self.make_user('foo.com', cls=Fake) self.make_user('fake:foo', cls=Fake)
got = self.client.get('/fa/foo.com/followers') got = self.client.get('/fa/fake:foo/followers')
self.assert_equals(200, got.status_code) self.assert_equals(200, got.status_code)
def test_followers_empty(self): def test_followers_empty(self):
@ -216,8 +226,8 @@ class PagesTest(TestCase):
self.assertNotIn('class="follower', got.get_data(as_text=True)) self.assertNotIn('class="follower', got.get_data(as_text=True))
def test_following_fake(self): def test_following_fake(self):
self.make_user('foo.com', cls=Fake) self.make_user('fake:foo', cls=Fake)
got = self.client.get('/fa/foo.com/following') got = self.client.get('/fa/fake:foo/following')
self.assert_equals(200, got.status_code) self.assert_equals(200, got.status_code)
def test_following_user_not_found(self): def test_following_user_not_found(self):
@ -247,8 +257,8 @@ class PagesTest(TestCase):
self.assert_equals('/web/user.com/feed', got.headers['Location']) self.assert_equals('/web/user.com/feed', got.headers['Location'])
def test_feed_fake(self): def test_feed_fake(self):
self.make_user('foo.com', cls=Fake) self.make_user('fake:foo', cls=Fake)
got = self.client.get('/fa/foo.com/feed') got = self.client.get('/fa/fake:foo/feed')
self.assert_equals(200, got.status_code) self.assert_equals(200, got.status_code)
def test_feed_html_empty(self): def test_feed_html_empty(self):