summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrew Godwin2022-11-13 18:42:47 -0700
committerAndrew Godwin2022-11-13 18:43:09 -0700
commitddb3436275d3f02183f515c38cd3193cd1dfe2f4 (patch)
tree8902d4f085ad6d8323f43af20ca497d291e4d28a
parent68c156fd2758da5831bd83bfb1249dd014d78177 (diff)
downloadtakahe-ddb3436275d3f02183f515c38cd3193cd1dfe2f4.tar.gz
takahe-ddb3436275d3f02183f515c38cd3193cd1dfe2f4.tar.bz2
takahe-ddb3436275d3f02183f515c38cd3193cd1dfe2f4.zip
Boosting! Incoming, anyway.
-rw-r--r--activities/admin.py8
-rw-r--r--activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py126
-rw-r--r--activities/models/__init__.py1
-rw-r--r--activities/models/fan_out.py53
-rw-r--r--activities/models/post.py33
-rw-r--r--activities/models/post_interaction.py191
-rw-r--r--activities/models/timeline_event.py55
-rw-r--r--activities/tests/__init__.py0
-rw-r--r--activities/tests/models/__init__.py0
-rw-r--r--activities/tests/models/test_post.py31
-rw-r--r--activities/views/timelines.py46
-rw-r--r--static/css/style.css18
-rw-r--r--stator/management/commands/runstator.py11
-rw-r--r--stator/runner.py33
-rw-r--r--takahe/urls.py2
-rw-r--r--templates/activities/_boost.html28
-rw-r--r--templates/activities/_event.html20
-rw-r--r--templates/activities/_home_menu.html4
-rw-r--r--templates/activities/_post.html4
-rw-r--r--templates/activities/federated.html2
-rw-r--r--templates/activities/home.html15
-rw-r--r--templates/activities/local.html21
-rw-r--r--templates/activities/notifications.html22
-rw-r--r--users/models/inbox_message.py6
24 files changed, 661 insertions, 69 deletions
diff --git a/activities/admin.py b/activities/admin.py
index 2dec3bf..d4603a4 100644
--- a/activities/admin.py
+++ b/activities/admin.py
@@ -1,6 +1,6 @@
from django.contrib import admin
-from activities.models import FanOut, Post, TimelineEvent
+from activities.models import FanOut, Post, PostInteraction, TimelineEvent
@admin.register(Post)
@@ -19,3 +19,9 @@ class TimelineEventAdmin(admin.ModelAdmin):
class FanOutAdmin(admin.ModelAdmin):
list_display = ["id", "state", "state_attempted", "type", "identity"]
raw_id_fields = ["identity", "subject_post"]
+
+
+@admin.register(PostInteraction)
+class PostInteractionAdmin(admin.ModelAdmin):
+ list_display = ["id", "state", "state_attempted", "type", "identity", "post"]
+ raw_id_fields = ["identity", "post"]
diff --git a/activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py b/activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py
new file mode 100644
index 0000000..7972f18
--- /dev/null
+++ b/activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py
@@ -0,0 +1,126 @@
+# Generated by Django 4.1.3 on 2022-11-14 00:41
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations, models
+
+import activities.models.post_interaction
+import stator.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("users", "0002_identity_public_key_id"),
+ ("activities", "0003_alter_post_object_uri"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="post",
+ old_name="authored",
+ new_name="published",
+ ),
+ migrations.AlterField(
+ model_name="fanout",
+ name="type",
+ field=models.CharField(
+ choices=[("post", "Post"), ("interaction", "Interaction")],
+ max_length=100,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="timelineevent",
+ name="subject_post",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="timeline_events",
+ to="activities.post",
+ ),
+ ),
+ migrations.CreateModel(
+ name="PostInteraction",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("state_ready", models.BooleanField(default=True)),
+ ("state_changed", models.DateTimeField(auto_now_add=True)),
+ ("state_attempted", models.DateTimeField(blank=True, null=True)),
+ ("state_locked_until", models.DateTimeField(blank=True, null=True)),
+ (
+ "state",
+ stator.models.StateField(
+ choices=[("new", "new"), ("fanned_out", "fanned_out")],
+ default="new",
+ graph=activities.models.post_interaction.PostInteractionStates,
+ max_length=100,
+ ),
+ ),
+ (
+ "object_uri",
+ models.CharField(
+ blank=True, max_length=500, null=True, unique=True
+ ),
+ ),
+ (
+ "type",
+ models.CharField(
+ choices=[("like", "Like"), ("boost", "Boost")], max_length=100
+ ),
+ ),
+ ("published", models.DateTimeField(default=django.utils.timezone.now)),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("updated", models.DateTimeField(auto_now=True)),
+ (
+ "identity",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="interactions",
+ to="users.identity",
+ ),
+ ),
+ (
+ "post",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="interactions",
+ to="activities.post",
+ ),
+ ),
+ ],
+ options={
+ "index_together": {("type", "identity", "post")},
+ },
+ ),
+ migrations.AddField(
+ model_name="fanout",
+ name="subject_post_interaction",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="fan_outs",
+ to="activities.postinteraction",
+ ),
+ ),
+ migrations.AddField(
+ model_name="timelineevent",
+ name="subject_post_interaction",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="timeline_events",
+ to="activities.postinteraction",
+ ),
+ ),
+ ]
diff --git a/activities/models/__init__.py b/activities/models/__init__.py
index b0ed474..a0680ad 100644
--- a/activities/models/__init__.py
+++ b/activities/models/__init__.py
@@ -1,3 +1,4 @@
from .fan_out import FanOut # noqa
from .post import Post # noqa
+from .post_interaction import PostInteraction # noqa
from .timeline_event import TimelineEvent # noqa
diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py
index 958fbe2..dbe86c0 100644
--- a/activities/models/fan_out.py
+++ b/activities/models/fan_out.py
@@ -19,23 +19,27 @@ class FanOutStates(StateGraph):
Sends the fan-out to the right inbox.
"""
fan_out = await instance.afetch_full()
- if fan_out.identity.local:
- # Make a timeline event directly
- await sync_to_async(TimelineEvent.add_post)(
- identity=fan_out.identity,
- post=fan_out.subject_post,
- )
+ # Handle Posts
+ if fan_out.type == FanOut.Types.post:
+ if fan_out.identity.local:
+ # Make a timeline event directly
+ await sync_to_async(TimelineEvent.add_post)(
+ identity=fan_out.identity,
+ post=fan_out.subject_post,
+ )
+ else:
+ # Send it to the remote inbox
+ post = await fan_out.subject_post.afetch_full()
+ # Sign it and send it
+ await HttpSignature.signed_request(
+ uri=fan_out.identity.inbox_uri,
+ body=canonicalise(post.to_create_ap()),
+ private_key=post.author.private_key,
+ key_id=post.author.public_key_id,
+ )
+ return cls.sent
else:
- # Send it to the remote inbox
- post = await fan_out.subject_post.afetch_full()
- # Sign it and send it
- await HttpSignature.signed_request(
- uri=fan_out.identity.inbox_uri,
- body=canonicalise(post.to_create_ap()),
- private_key=post.author.private_key,
- key_id=post.author.public_key_id,
- )
- return cls.sent
+ raise ValueError(f"Cannot fan out with type {fan_out.type}")
class FanOut(StatorModel):
@@ -45,7 +49,7 @@ class FanOut(StatorModel):
class Types(models.TextChoices):
post = "post"
- boost = "boost"
+ interaction = "interaction"
state = StateField(FanOutStates)
@@ -67,6 +71,13 @@ class FanOut(StatorModel):
null=True,
related_name="fan_outs",
)
+ subject_post_interaction = models.ForeignKey(
+ "activities.PostInteraction",
+ on_delete=models.CASCADE,
+ blank=True,
+ null=True,
+ related_name="fan_outs",
+ )
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@@ -77,6 +88,8 @@ class FanOut(StatorModel):
"""
Returns a version of the object with all relations pre-loaded
"""
- return await FanOut.objects.select_related("identity", "subject_post").aget(
- pk=self.pk
- )
+ return await FanOut.objects.select_related(
+ "identity",
+ "subject_post",
+ "subject_post_interaction",
+ ).aget(pk=self.pk)
diff --git a/activities/models/post.py b/activities/models/post.py
index 75a4388..d847307 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -1,5 +1,6 @@
from typing import Dict, Optional
+import httpx
import urlman
from django.db import models
from django.utils import timezone
@@ -7,7 +8,7 @@ from django.utils import timezone
from activities.models.fan_out import FanOut
from activities.models.timeline_event import TimelineEvent
from core.html import sanitize_post
-from core.ld import format_ld_date, parse_ld_date
+from core.ld import canonicalise, format_ld_date, parse_ld_date
from stator.models import State, StateField, StateGraph, StatorModel
from users.models.follow import Follow
from users.models.identity import Identity
@@ -91,7 +92,7 @@ class Post(StatorModel):
)
# When the post was originally created (as opposed to when we received it)
- authored = models.DateTimeField(default=timezone.now)
+ published = models.DateTimeField(default=timezone.now)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@@ -173,7 +174,7 @@ class Post(StatorModel):
value = {
"type": "Note",
"id": self.object_uri,
- "published": format_ld_date(self.created),
+ "published": format_ld_date(self.published),
"attributedTo": self.author.actor_uri,
"content": self.safe_content,
"to": "as:Public",
@@ -227,7 +228,7 @@ class Post(StatorModel):
post.summary = data.get("summary", None)
post.sensitive = data.get("as:sensitive", False)
post.url = data.get("url", None)
- post.authored = parse_ld_date(data.get("published", None))
+ post.published = parse_ld_date(data.get("published", None))
# TODO: to
# TODO: mentions
# TODO: visibility
@@ -235,6 +236,30 @@ class Post(StatorModel):
return post
@classmethod
+ def by_object_uri(cls, object_uri, fetch=False):
+ """
+ Gets the post by URI - either looking up locally, or fetching
+ from the other end if it's not here.
+ """
+ try:
+ return cls.objects.get(object_uri=object_uri)
+ except cls.DoesNotExist:
+ if fetch:
+ # Go grab the data from the URI
+ response = httpx.get(
+ object_uri,
+ headers={"Accept": "application/json"},
+ follow_redirects=True,
+ )
+ if 200 <= response.status_code < 300:
+ return cls.by_ap(
+ canonicalise(response.json(), include_security=True),
+ create=True,
+ update=True,
+ )
+ raise ValueError(f"Cannot find Post with URI {object_uri}")
+
+ @classmethod
def handle_create_ap(cls, data):
"""
Handles an incoming create request
diff --git a/activities/models/post_interaction.py b/activities/models/post_interaction.py
new file mode 100644
index 0000000..151ab45
--- /dev/null
+++ b/activities/models/post_interaction.py
@@ -0,0 +1,191 @@
+from typing import Dict
+
+from django.db import models
+from django.utils import timezone
+
+from activities.models.fan_out import FanOut
+from activities.models.post import Post
+from activities.models.timeline_event import TimelineEvent
+from core.ld import format_ld_date, parse_ld_date
+from stator.models import State, StateField, StateGraph, StatorModel
+from users.models.follow import Follow
+from users.models.identity import Identity
+
+
+class PostInteractionStates(StateGraph):
+ new = State(try_interval=300)
+ fanned_out = State()
+
+ new.transitions_to(fanned_out)
+
+ @classmethod
+ async def handle_new(cls, instance: "PostInteraction"):
+ """
+ Creates all needed fan-out objects for a new PostInteraction.
+ """
+ interaction = await instance.afetch_full()
+ # Boost: send a copy to all people who follow this user
+ if interaction.type == interaction.Types.boost:
+ async for follow in interaction.identity.inbound_follows.select_related(
+ "source", "target"
+ ):
+ if follow.source.local or follow.target.local:
+ await FanOut.objects.acreate(
+ identity_id=follow.source_id,
+ type=FanOut.Types.interaction,
+ subject_post=interaction,
+ )
+ # Like: send a copy to the original post author only
+ elif interaction.type == interaction.Types.like:
+ await FanOut.objects.acreate(
+ identity_id=interaction.post.author_id,
+ type=FanOut.Types.interaction,
+ subject_post=interaction,
+ )
+ else:
+ raise ValueError("Cannot fan out unknown type")
+ # And one for themselves if they're local
+ if interaction.identity.local:
+ await FanOut.objects.acreate(
+ identity_id=interaction.identity_id,
+ type=FanOut.Types.interaction,
+ subject_post=interaction,
+ )
+
+
+class PostInteraction(StatorModel):
+ """
+ Handles both boosts and likes
+ """
+
+ class Types(models.TextChoices):
+ like = "like"
+ boost = "boost"
+
+ # The state the boost is in
+ state = StateField(PostInteractionStates)
+
+ # The canonical object ID
+ object_uri = models.CharField(max_length=500, blank=True, null=True, unique=True)
+
+ # What type of interaction it is
+ type = models.CharField(max_length=100, choices=Types.choices)
+
+ # The user who boosted/liked/etc.
+ identity = models.ForeignKey(
+ "users.Identity",
+ on_delete=models.CASCADE,
+ related_name="interactions",
+ )
+
+ # The post that was boosted/liked/etc
+ post = models.ForeignKey(
+ "activities.Post",
+ on_delete=models.CASCADE,
+ related_name="interactions",
+ )
+
+ # When the activity was originally created (as opposed to when we received it)
+ # Mastodon only seems to send this for boosts, not likes
+ published = models.DateTimeField(default=timezone.now)
+
+ created = models.DateTimeField(auto_now_add=True)
+ updated = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ index_together = [["type", "identity", "post"]]
+
+ ### Async helpers ###
+
+ async def afetch_full(self):
+ """
+ Returns a version of the object with all relations pre-loaded
+ """
+ return await PostInteraction.objects.select_related("identity", "post").aget(
+ pk=self.pk
+ )
+
+ ### ActivityPub (outbound) ###
+
+ def to_ap(self) -> Dict:
+ """
+ Returns the AP JSON for this object
+ """
+ if self.type == self.Types.boost:
+ value = {
+ "type": "Announce",
+ "id": self.object_uri,
+ "published": format_ld_date(self.published),
+ "actor": self.identity.actor_uri,
+ "object": self.post.object_uri,
+ "to": "as:Public",
+ }
+ elif self.type == self.Types.like:
+ value = {
+ "type": "Like",
+ "id": self.object_uri,
+ "published": format_ld_date(self.published),
+ "actor": self.identity.actor_uri,
+ "object": self.post.object_uri,
+ }
+ else:
+ raise ValueError("Cannot turn into AP")
+ return value
+
+ ### ActivityPub (inbound) ###
+
+ @classmethod
+ def by_ap(cls, data, create=False) -> "PostInteraction":
+ """
+ Retrieves a PostInteraction instance by its ActivityPub JSON object.
+
+ Optionally creates one if it's not present.
+ Raises KeyError if it's not found and create is False.
+ """
+ # Do we have one with the right ID?
+ try:
+ boost = cls.objects.get(object_uri=data["id"])
+ except cls.DoesNotExist:
+ if create:
+ # Resolve the author
+ identity = Identity.by_actor_uri(data["actor"], create=True)
+ # Resolve the post
+ post = Post.by_object_uri(data["object"], fetch=True)
+ # Get the right type
+ if data["type"].lower() == "like":
+ type = cls.Types.like
+ elif data["type"].lower() == "announce":
+ type = cls.Types.boost
+ else:
+ raise ValueError(f"Cannot handle AP type {data['type']}")
+ # Make the actual interaction
+ boost = cls.objects.create(
+ object_uri=data["id"],
+ identity=identity,
+ post=post,
+ published=parse_ld_date(data.get("published", None))
+ or timezone.now(),
+ type=type,
+ )
+ else:
+ raise KeyError(f"No post with ID {data['id']}", data)
+ return boost
+
+ @classmethod
+ def handle_ap(cls, data):
+ """
+ Handles an incoming announce/like
+ """
+ # Create it
+ interaction = cls.by_ap(data, create=True)
+ # Boosts (announces) go to everyone who follows locally
+ if interaction.type == cls.Types.boost:
+ for follow in Follow.objects.filter(
+ target=interaction.identity, source__local=True
+ ):
+ TimelineEvent.add_post_interaction(follow.source, interaction)
+ # Likes go to just the author of the post
+ elif interaction.type == cls.Types.like:
+ TimelineEvent.add_post_interaction(interaction.post.author, interaction)
+ # Force it into fanned_out as it's not ours
+ interaction.transition_perform(PostInteractionStates.fanned_out)
diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py
index 43fc458..6dba32c 100644
--- a/activities/models/timeline_event.py
+++ b/activities/models/timeline_event.py
@@ -9,10 +9,11 @@ class TimelineEvent(models.Model):
class Types(models.TextChoices):
post = "post"
- mention = "mention"
- like = "like"
- follow = "follow"
- boost = "boost"
+ boost = "boost" # A boost from someone (post substitude)
+ mentioned = "mentioned"
+ liked = "liked" # Someone liking one of our posts
+ followed = "followed"
+ boosted = "boosted" # Someone boosting one of our posts
# The user this event is for
identity = models.ForeignKey(
@@ -30,7 +31,14 @@ class TimelineEvent(models.Model):
on_delete=models.CASCADE,
blank=True,
null=True,
- related_name="timeline_events_about_us",
+ related_name="timeline_events",
+ )
+ subject_post_interaction = models.ForeignKey(
+ "activities.PostInteraction",
+ on_delete=models.CASCADE,
+ blank=True,
+ null=True,
+ related_name="timeline_events",
)
subject_identity = models.ForeignKey(
"users.Identity",
@@ -74,12 +82,35 @@ class TimelineEvent(models.Model):
)[0]
@classmethod
- def add_like(cls, identity, post):
+ def add_post_interaction(cls, identity, interaction):
"""
- Adds a like to the timeline if it's not there already
+ Adds a boost/like to the timeline if it's not there already.
+
+ For boosts, may make two objects - one "boost" and one "boosted".
+ It'll return the "boost" in that case.
"""
- return cls.objects.get_or_create(
- identity=identity,
- type=cls.Types.like,
- subject_post=post,
- )[0]
+ if interaction.type == interaction.Types.like:
+ return cls.objects.get_or_create(
+ identity=identity,
+ type=cls.Types.liked,
+ subject_post_id=interaction.post_id,
+ subject_identity_id=interaction.identity_id,
+ subject_post_interaction=interaction,
+ )[0]
+ elif interaction.type == interaction.Types.boost:
+ # If the boost is on one of our posts, then that's a boosted too
+ if interaction.post.author_id == identity.id:
+ return cls.objects.get_or_create(
+ identity=identity,
+ type=cls.Types.boosted,
+ subject_post_id=interaction.post_id,
+ subject_identity_id=interaction.identity_id,
+ subject_post_interaction=interaction,
+ )[0]
+ return cls.objects.get_or_create(
+ identity=identity,
+ type=cls.Types.boost,
+ subject_post_id=interaction.post_id,
+ subject_identity_id=interaction.identity_id,
+ subject_post_interaction=interaction,
+ )[0]
diff --git a/activities/tests/__init__.py b/activities/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/activities/tests/__init__.py
diff --git a/activities/tests/models/__init__.py b/activities/tests/models/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/activities/tests/models/__init__.py
diff --git a/activities/tests/models/test_post.py b/activities/tests/models/test_post.py
new file mode 100644
index 0000000..5c7fca2
--- /dev/null
+++ b/activities/tests/models/test_post.py
@@ -0,0 +1,31 @@
+import pytest
+from pytest_httpx import HTTPXMock
+
+from activities.models import Post
+
+
+@pytest.mark.django_db
+def test_fetch_post(httpx_mock: HTTPXMock):
+ """
+ Tests that a post we don't have locally can be fetched by by_object_uri
+ """
+ httpx_mock.add_response(
+ url="https://example.com/test-post",
+ json={
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ ],
+ "id": "https://example.com/test-post",
+ "type": "Note",
+ "published": "2022-11-13T23:20:16Z",
+ "url": "https://example.com/test-post",
+ "attributedTo": "https://example.com/test-actor",
+ "content": "BEEEEEES",
+ },
+ )
+ # Fetch with a HTTP access
+ post = Post.by_object_uri("https://example.com/test-post", fetch=True)
+ assert post.content == "BEEEEEES"
+ assert post.author.actor_uri == "https://example.com/test-actor"
+ # Fetch again with a DB hit
+ assert Post.by_object_uri("https://example.com/test-post").id == post.id
diff --git a/activities/views/timelines.py b/activities/views/timelines.py
index 76cf018..9be988d 100644
--- a/activities/views/timelines.py
+++ b/activities/views/timelines.py
@@ -33,15 +33,15 @@ class Home(FormView):
def get_context_data(self):
context = super().get_context_data()
- context["timeline_posts"] = [
- te.subject_post
- for te in TimelineEvent.objects.filter(
+ context["events"] = (
+ TimelineEvent.objects.filter(
identity=self.request.identity,
- type=TimelineEvent.Types.post,
+ type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost],
)
.select_related("subject_post", "subject_post__author")
.order_by("-created")[:100]
- ]
+ )
+
context["current_page"] = "home"
return context
@@ -55,16 +55,50 @@ class Home(FormView):
@method_decorator(identity_required, name="dispatch")
+class Local(TemplateView):
+
+ template_name = "activities/local.html"
+
+ def get_context_data(self):
+ context = super().get_context_data()
+ context["posts"] = (
+ Post.objects.filter(visibility=Post.Visibilities.public, author__local=True)
+ .select_related("author")
+ .order_by("-created")[:100]
+ )
+ context["current_page"] = "local"
+ return context
+
+
+@method_decorator(identity_required, name="dispatch")
class Federated(TemplateView):
template_name = "activities/federated.html"
def get_context_data(self):
context = super().get_context_data()
- context["timeline_posts"] = (
+ context["posts"] = (
Post.objects.filter(visibility=Post.Visibilities.public)
.select_related("author")
.order_by("-created")[:100]
)
context["current_page"] = "federated"
return context
+
+
+@method_decorator(identity_required, name="dispatch")
+class Notifications(TemplateView):
+
+ template_name = "activities/notifications.html"
+
+ def get_context_data(self):
+ context = super().get_context_data()
+ context["events"] = (
+ TimelineEvent.objects.filter(
+ identity=self.request.identity,
+ )
+ .exclude(type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost])
+ .select_related("subject_post", "subject_post__author", "subject_identity")
+ )
+ context["current_page"] = "notifications"
+ return context
diff --git a/static/css/style.css b/static/css/style.css
index 0755baa..59590ef 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -459,7 +459,7 @@ form .button:hover {
/* Identities */
h1.identity {
- margin: 20px 0 20px 20px;
+ margin: 15px 0 20px 15px;
}
h1.identity .icon {
@@ -482,7 +482,7 @@ h1.identity small {
color: var(--color-text-dull);
border-radius: 3px;
padding: 5px 8px;
- margin: 20px;
+ margin: 15px;
}
.system-note a {
@@ -527,3 +527,17 @@ h1.identity small {
.post .content p {
margin: 12px 0 4px 0;
}
+
+.boost-banner {
+ padding: 0 0 3px 5px;
+}
+
+.boost-banner::before {
+ content: "\f079";
+ font: var(--fa-font-solid);
+ margin-right: 4px;
+}
+
+.boost-banner a {
+ font-weight: bold;
+}
diff --git a/stator/management/commands/runstator.py b/stator/management/commands/runstator.py
index 1307fef..a77192e 100644
--- a/stator/management/commands/runstator.py
+++ b/stator/management/commands/runstator.py
@@ -12,9 +12,16 @@ class Command(BaseCommand):
help = "Runs a Stator runner for a short period"
def add_arguments(self, parser):
+ parser.add_argument(
+ "--concurrency",
+ "-c",
+ type=int,
+ default=30,
+ help="How many tasks to run at once",
+ )
parser.add_argument("model_labels", nargs="*", type=str)
- def handle(self, model_labels: List[str], *args, **options):
+ def handle(self, model_labels: List[str], concurrency: int, *args, **options):
# Resolve the models list into names
models = cast(
List[Type[StatorModel]],
@@ -24,5 +31,5 @@ class Command(BaseCommand):
models = StatorModel.subclasses
print("Running for models: " + " ".join(m._meta.label_lower for m in models))
# Run a runner
- runner = StatorRunner(models)
+ runner = StatorRunner(models, concurrency=concurrency)
async_to_sync(runner.run)()
diff --git a/stator/runner.py b/stator/runner.py
index 0b42b27..187aa47 100644
--- a/stator/runner.py
+++ b/stator/runner.py
@@ -16,16 +16,20 @@ class StatorRunner:
Designed to run in a one-shot mode, living inside a request.
"""
- START_TIMEOUT = 30
- TOTAL_TIMEOUT = 60
- LOCK_TIMEOUT = 120
-
- MAX_TASKS = 30
- MAX_TASKS_PER_MODEL = 5
-
- def __init__(self, models: List[Type[StatorModel]]):
+ def __init__(
+ self,
+ models: List[Type[StatorModel]],
+ concurrency: int = 30,
+ concurrency_per_model: int = 5,
+ run_period: int = 30,
+ wait_period: int = 30,
+ ):
self.models = models
self.runner_id = uuid.uuid4().hex
+ self.concurrency = concurrency
+ self.concurrency_per_model = concurrency_per_model
+ self.run_period = run_period
+ self.total_period = run_period + wait_period
async def run(self):
start_time = time.monotonic()
@@ -40,15 +44,18 @@ class StatorRunner:
await asyncio.gather(*initial_tasks)
# For the first time period, launch tasks
print("Running main task loop")
- while (time.monotonic() - start_time) < self.START_TIMEOUT:
+ while (time.monotonic() - start_time) < self.run_period:
self.remove_completed_tasks()
- space_remaining = self.MAX_TASKS - len(self.tasks)
+ space_remaining = self.concurrency - len(self.tasks)
# Fetch new tasks
for model in self.models:
if space_remaining > 0:
for instance in await model.atransition_get_with_lock(
- min(space_remaining, self.MAX_TASKS_PER_MODEL),
- timezone.now() + datetime.timedelta(seconds=self.LOCK_TIMEOUT),
+ number=min(space_remaining, self.concurrency_per_model),
+ lock_expiry=(
+ timezone.now()
+ + datetime.timedelta(seconds=(self.total_period * 2) + 60)
+ ),
):
self.tasks.append(
asyncio.create_task(self.run_transition(instance))
@@ -59,7 +66,7 @@ class StatorRunner:
await asyncio.sleep(0.1)
# Then wait for tasks to finish
print("Waiting for tasks to complete")
- while (time.monotonic() - start_time) < self.TOTAL_TIMEOUT:
+ while (time.monotonic() - start_time) < self.total_period:
self.remove_completed_tasks()
if not self.tasks:
break
diff --git a/takahe/urls.py b/takahe/urls.py
index 2517364..bebd63a 100644
--- a/takahe/urls.py
+++ b/takahe/urls.py
@@ -9,6 +9,8 @@ from users.views import activitypub, auth, identity
urlpatterns = [
path("", core.homepage),
# Activity views
+ path("notifications/", timelines.Notifications.as_view()),
+ path("local/", timelines.Local.as_view()),
path("federated/", timelines.Federated.as_view()),
# Authentication
path("auth/login/", auth.Login.as_view()),
diff --git a/templates/activities/_boost.html b/templates/activities/_boost.html
new file mode 100644
index 0000000..601466e
--- /dev/null
+++ b/templates/activities/_boost.html
@@ -0,0 +1,28 @@
+{% load static %}
+{% load activity_tags %}
+<div class="post">
+
+ {% if post.author.icon_uri %}
+ <img src="{{post.author.icon_uri}}" class="icon">
+ {% else %}
+ <img src="{% static "img/unknown-icon-128.png" %}" class="icon">
+ {% endif %}
+
+ <time>
+ <a href="{{ post.url }}">
+ {% if post.published %}
+ {{ post.published | timedeltashort }}
+ {% else %}
+ {{ post.created | timedeltashort }}
+ {% endif %}
+ </a>
+ </time>
+
+ <a href="{{ post.author.urls.view }}" class="handle">
+ {{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small>
+ </a>
+
+ <div class="content">
+ {{ post.safe_content }}
+ </div>
+</div>
diff --git a/templates/activities/_event.html b/templates/activities/_event.html
new file mode 100644
index 0000000..bbe0ae5
--- /dev/null
+++ b/templates/activities/_event.html
@@ -0,0 +1,20 @@
+{% load static %}
+{% load activity_tags %}
+<div class="post">
+
+ <time>
+ {% if event.published %}
+ {{ event.published | timedeltashort }}
+ {% else %}
+ {{ event.created | timedeltashort }}
+ {% endif %}
+ </time>
+
+ {% if event.type == "follow" %}
+ {{ event.subject_identity.name_or_handle }} followed you
+ {% elif event.type == "like" %}
+ {{ event.subject_identity.name_or_handle }} liked {{ event.subject_post }}
+ {% else %}
+ Unknown event type {{event.type}}
+ {% endif %}
+</div>
diff --git a/templates/activities/_home_menu.html b/templates/activities/_home_menu.html
index 29d19e9..c88a1d7 100644
--- a/templates/activities/_home_menu.html
+++ b/templates/activities/_home_menu.html
@@ -1,6 +1,6 @@
<nav>
<a href="/" {% if current_page == "home" %}class="selected"{% endif %}>Home</a>
- <a href="/" {% if current_page == "mentions" %}class="selected"{% endif %}>Mentions</a>
- <a href="/" {% if current_page == "public" %}class="selected"{% endif %}>Public</a>
+ <a href="/notifications/" {% if current_page == "notifications" %}class="selected"{% endif %}>Notifications</a>
+ <a href="/local/" {% if current_page == "local" %}class="selected"{% endif %}>Local</a>
<a href="/federated/" {% if current_page == "federated" %}class="selected"{% endif %}>Federated</a>
</nav>
diff --git a/templates/activities/_post.html b/templates/activities/_post.html
index 38022a7..601466e 100644
--- a/templates/activities/_post.html
+++ b/templates/activities/_post.html
@@ -10,8 +10,8 @@
<time>
<a href="{{ post.url }}">
- {% if post.authored %}
- {{ post.authored | timedeltashort }}
+ {% if post.published %}
+ {{ post.published | timedeltashort }}
{% else %}
{{ post.created | timedeltashort }}
{% endif %}
diff --git a/templates/activities/federated.html b/templates/activities/federated.html
index 8d8297b..b6143c3 100644
--- a/templates/activities/federated.html
+++ b/templates/activities/federated.html
@@ -7,7 +7,7 @@
<section class="columns">
<div class="left-column">
- {% for post in timeline_posts %}
+ {% for post in posts %}
{% include "activities/_post.html" %}
{% empty %}
No posts yet.
diff --git a/templates/activities/home.html b/templates/activities/home.html
index c777ce8..2cdaaea 100644
--- a/templates/activities/home.html
+++ b/templates/activities/home.html
@@ -8,10 +8,19 @@
<section class="columns">
<div class="left-column">
- {% for post in timeline_posts %}
- {% include "activities/_post.html" %}
+ {% for event in events %}
+ {% if event.type == "post" %}
+ {% include "activities/_post.html" with post=event.subject_post %}
+ {% elif event.type == "boost" %}
+ <div class="boost-banner">
+ <a href="{{ event.subject_identity.urls.view }}">
+ {{ event.subject_identity.name_or_handle }}
+ </a> boosted
+ </div>
+ {% include "activities/_post.html" with post=event.subject_post %}
+ {% endif %}
{% empty %}
- No posts yet.
+ Nothing to show yet.
{% endfor %}
</div>
diff --git a/templates/activities/local.html b/templates/activities/local.html
new file mode 100644
index 0000000..520a0ce
--- /dev/null
+++ b/templates/activities/local.html
@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+
+{% block title %}Local Timeline{% endblock %}
+
+{% block content %}
+ {% include "activities/_home_menu.html" %}
+
+ <section class="columns">
+ <div class="left-column">
+ {% for post in posts %}
+ {% include "activities/_post.html" %}
+ {% empty %}
+ No posts yet.
+ {% endfor %}
+ </div>
+ <div class="right-column">
+ <h2>?</h2>
+ </div>
+
+ </section>
+{% endblock %}
diff --git a/templates/activities/notifications.html b/templates/activities/notifications.html
new file mode 100644
index 0000000..c071a49
--- /dev/null
+++ b/templates/activities/notifications.html
@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+
+{% block title %}Notifications{% endblock %}
+
+{% block content %}
+ {% include "activities/_home_menu.html" %}
+
+ <section class="columns">
+ <div class="left-column">
+ {% for event in events %}
+ {% include "activities/_event.html" %}
+ {% empty %}
+ No events yet.
+ {% endfor %}
+ </div>
+
+ <div class="right-column">
+ <h2>?</h2>
+ </div>
+
+ </section>
+{% endblock %}
diff --git a/users/models/inbox_message.py b/users/models/inbox_message.py
index 43424c9..6dbf5e8 100644
--- a/users/models/inbox_message.py
+++ b/users/models/inbox_message.py
@@ -12,12 +12,16 @@ class InboxMessageStates(StateGraph):
@classmethod
async def handle_received(cls, instance: "InboxMessage"):
- from activities.models import Post
+ from activities.models import Post, PostInteraction
from users.models import Follow
match instance.message_type:
case "follow":
await sync_to_async(Follow.handle_request_ap)(instance.message)
+ case "announce":
+ await sync_to_async(PostInteraction.handle_ap)(instance.message)
+ case "like":
+ await sync_to_async(PostInteraction.handle_ap)(instance.message)
case "create":
match instance.message_object_type:
case "note":