diff --git a/index.yaml b/index.yaml index d22ee1d..c151a64 100644 --- a/index.yaml +++ b/index.yaml @@ -19,6 +19,13 @@ indexes: - name: updated direction: desc +- kind: Response + properties: + - name: direction + - name: domain + - name: created + direction: desc + - kind: Follower properties: - name: dest diff --git a/pages.py b/pages.py index 450e246..a4ffb2e 100644 --- a/pages.py +++ b/pages.py @@ -7,6 +7,7 @@ import urllib.parse from flask import redirect, render_template, request from google.cloud.ndb.stats import KindStat +from granary import as2, atom, microformats2, rss from oauth_dropins.webutil import flask_util, logs, util from oauth_dropins.webutil.flask_util import error from oauth_dropins.webutil.util import json_dumps, json_loads @@ -108,6 +109,44 @@ def following(domain): ) +@app.get(f'/user//feed') +def feed(domain): + format = request.args.get('format', 'html') + if format not in ('html', 'atom', 'rss'): + error(f'format {format} not supported; expected html, atom, or rss') + + as2_activities, _, _ = Activity.query( + Activity.domain == domain, Activity.direction == 'in' + ).order(-Activity.created + ).fetch_page(PAGE_SIZE) + as1_activities = [as2.to_as1(json_loads(a.source_as2)) + for a in as2_activities + if a.source_as2] + as1_activities = [a for a in as1_activities + if a.get('verb') not in ('like', 'update', 'follow')] + + actor = { + 'displayName': domain, + 'url': f'https://{domain}', + } + title = f'Bridgy Fed feed for {domain}' + extra = '' + if not as1_activities: + extra += '\n

Nothing yet. Follow more people, check back soon!

' + + if format == 'html': + return microformats2.activities_to_html(as1_activities, extra=extra, + body_class='h-feed') + elif format == 'atom': + body = atom.activities_to_atom(as1_activities, actor=actor, title=title, + request_url=request.url) + return body, {'Content-Type': atom.CONTENT_TYPE} + elif format == 'rss': + body = rss.from_activities(as1_activities, actor=actor, title=title, + feed_url=request.url) + return body, {'Content-Type': rss.CONTENT_TYPE} + + @app.get('/responses') # deprecated def recent_deprecated(): return redirect('/recent', code=301) diff --git a/static/feed.css b/static/feed.css new file mode 100644 index 0000000..1a2f03b --- /dev/null +++ b/static/feed.css @@ -0,0 +1,24 @@ +/** From Bridgy's mf2 handlers: + * https://github.com/snarfed/bridgy/blob/0b4b37cab61257510b45aee5b0678ba53af69d80/handlers.py#L43-L70 + * + * Also see: + * https://github.com/kevinmarks/unmung/blob/master/styles/hfeed.css + * https://github.com/kevinmarks/unmung/blob/master/styles/mastoview.css + */ +body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +.p-uid { + display: none; +} + +.u-photo { + max-width: 50px; + border-radius: 4px; +} + +.e-content { + margin-top: 10px; + font-size: 1.3em; +} diff --git a/templates/user.html b/templates/user.html index 8a7e2ea..69cf973 100644 --- a/templates/user.html +++ b/templates/user.html @@ -11,6 +11,9 @@

| {{ followers }} follower{% if followers != '1' %}s{% endif %} | following {{ following }} + | HTML, + Atom, + RSS feeds

diff --git a/tests/test_pages.py b/tests/test_pages.py new file mode 100644 index 0000000..138ec46 --- /dev/null +++ b/tests/test_pages.py @@ -0,0 +1,69 @@ +"""Unit tests for pages.py.""" +from oauth_dropins.webutil import util +from oauth_dropins.webutil.util import json_dumps, json_loads +from granary import as2, atom, microformats2, rss + +from models import Follower, Activity +from . import testutil +from .test_activitypub import LIKE, MENTION, NOTE, REPLY + + +def contents(activities): + return [a['object']['content'] for a in activities] + + +class PagesTest(testutil.TestCase): + + EXPECTED = contents([as2.to_as1(REPLY), as2.to_as1(NOTE)]) + + @staticmethod + def add_activities(): + Activity(id='a', domain=['foo.com'], direction='in', + source_as2=json_dumps(NOTE)).put() + # different domain + Activity(id='b', domain=['bar.org'], direction='in', + source_as2=json_dumps(MENTION)).put() + # empty, should be skipped + Activity(id='c', domain=['foo.com'], direction='in').put() + Activity(id='d', domain=['foo.com'], direction='in', + source_as2=json_dumps(REPLY)).put() + # wrong direction + Activity(id='e', domain=['foo.com'], direction='out', + source_as2=json_dumps(NOTE)).put() + # skip Likes + Activity(id='f', domain=['foo.com'], direction='in', + source_as2=json_dumps(LIKE)).put() + + def test_feed_html_empty(self): + got = self.client.get('/user/foo.com/feed') + self.assert_equals(200, got.status_code) + self.assert_equals([], microformats2.html_to_activities(got.text)) + + def test_feed_html(self): + self.add_activities() + got = self.client.get('/user/foo.com/feed') + self.assert_equals(200, got.status_code) + self.assert_equals(self.EXPECTED, + contents(microformats2.html_to_activities(got.text))) + + def test_feed_atom_empty(self): + got = self.client.get('/user/foo.com/feed?format=atom') + self.assert_equals(200, got.status_code) + self.assert_equals([], atom.atom_to_activities(got.text)) + + def test_feed_atom(self): + self.add_activities() + got = self.client.get('/user/foo.com/feed?format=atom') + self.assert_equals(200, got.status_code) + self.assert_equals(self.EXPECTED, contents(atom.atom_to_activities(got.text))) + + def test_feed_rss_empty(self): + got = self.client.get('/user/foo.com/feed?format=rss') + self.assert_equals(200, got.status_code) + self.assert_equals([], rss.to_activities(got.text)) + + def test_feed_rss(self): + self.add_activities() + got = self.client.get('/user/foo.com/feed?format=rss') + self.assert_equals(200, got.status_code) + self.assert_equals(self.EXPECTED, contents(rss.to_activities(got.text))) diff --git a/tests/test_render.py b/tests/test_render.py index f0fed2c..cbe2d54 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -43,7 +43,7 @@ class RenderTest(testutil.TestCase): - +
http://this/reply http://this/reply