Boosting! Incoming, anyway.

This commit is contained in:
Andrew Godwin 2022-11-13 18:42:47 -07:00
parent 68c156fd27
commit ddb3436275
24 changed files with 661 additions and 69 deletions

View File

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from activities.models import FanOut, Post, TimelineEvent from activities.models import FanOut, Post, PostInteraction, TimelineEvent
@admin.register(Post) @admin.register(Post)
@ -19,3 +19,9 @@ class TimelineEventAdmin(admin.ModelAdmin):
class FanOutAdmin(admin.ModelAdmin): class FanOutAdmin(admin.ModelAdmin):
list_display = ["id", "state", "state_attempted", "type", "identity"] list_display = ["id", "state", "state_attempted", "type", "identity"]
raw_id_fields = ["identity", "subject_post"] 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"]

View File

@ -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",
),
),
]

View File

@ -1,3 +1,4 @@
from .fan_out import FanOut # noqa from .fan_out import FanOut # noqa
from .post import Post # noqa from .post import Post # noqa
from .post_interaction import PostInteraction # noqa
from .timeline_event import TimelineEvent # noqa from .timeline_event import TimelineEvent # noqa

View File

@ -19,23 +19,27 @@ class FanOutStates(StateGraph):
Sends the fan-out to the right inbox. Sends the fan-out to the right inbox.
""" """
fan_out = await instance.afetch_full() fan_out = await instance.afetch_full()
if fan_out.identity.local: # Handle Posts
# Make a timeline event directly if fan_out.type == FanOut.Types.post:
await sync_to_async(TimelineEvent.add_post)( if fan_out.identity.local:
identity=fan_out.identity, # Make a timeline event directly
post=fan_out.subject_post, 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: else:
# Send it to the remote inbox raise ValueError(f"Cannot fan out with type {fan_out.type}")
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
class FanOut(StatorModel): class FanOut(StatorModel):
@ -45,7 +49,7 @@ class FanOut(StatorModel):
class Types(models.TextChoices): class Types(models.TextChoices):
post = "post" post = "post"
boost = "boost" interaction = "interaction"
state = StateField(FanOutStates) state = StateField(FanOutStates)
@ -67,6 +71,13 @@ class FanOut(StatorModel):
null=True, null=True,
related_name="fan_outs", 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) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=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 Returns a version of the object with all relations pre-loaded
""" """
return await FanOut.objects.select_related("identity", "subject_post").aget( return await FanOut.objects.select_related(
pk=self.pk "identity",
) "subject_post",
"subject_post_interaction",
).aget(pk=self.pk)

View File

@ -1,5 +1,6 @@
from typing import Dict, Optional from typing import Dict, Optional
import httpx
import urlman import urlman
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
@ -7,7 +8,7 @@ from django.utils import timezone
from activities.models.fan_out import FanOut from activities.models.fan_out import FanOut
from activities.models.timeline_event import TimelineEvent from activities.models.timeline_event import TimelineEvent
from core.html import sanitize_post 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 stator.models import State, StateField, StateGraph, StatorModel
from users.models.follow import Follow from users.models.follow import Follow
from users.models.identity import Identity 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) # 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) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
@ -173,7 +174,7 @@ class Post(StatorModel):
value = { value = {
"type": "Note", "type": "Note",
"id": self.object_uri, "id": self.object_uri,
"published": format_ld_date(self.created), "published": format_ld_date(self.published),
"attributedTo": self.author.actor_uri, "attributedTo": self.author.actor_uri,
"content": self.safe_content, "content": self.safe_content,
"to": "as:Public", "to": "as:Public",
@ -227,13 +228,37 @@ class Post(StatorModel):
post.summary = data.get("summary", None) post.summary = data.get("summary", None)
post.sensitive = data.get("as:sensitive", False) post.sensitive = data.get("as:sensitive", False)
post.url = data.get("url", None) 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: to
# TODO: mentions # TODO: mentions
# TODO: visibility # TODO: visibility
post.save() post.save()
return post 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 @classmethod
def handle_create_ap(cls, data): def handle_create_ap(cls, data):
""" """

View File

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

View File

@ -9,10 +9,11 @@ class TimelineEvent(models.Model):
class Types(models.TextChoices): class Types(models.TextChoices):
post = "post" post = "post"
mention = "mention" boost = "boost" # A boost from someone (post substitude)
like = "like" mentioned = "mentioned"
follow = "follow" liked = "liked" # Someone liking one of our posts
boost = "boost" followed = "followed"
boosted = "boosted" # Someone boosting one of our posts
# The user this event is for # The user this event is for
identity = models.ForeignKey( identity = models.ForeignKey(
@ -30,7 +31,14 @@ class TimelineEvent(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
blank=True, blank=True,
null=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( subject_identity = models.ForeignKey(
"users.Identity", "users.Identity",
@ -74,12 +82,35 @@ class TimelineEvent(models.Model):
)[0] )[0]
@classmethod @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( if interaction.type == interaction.Types.like:
identity=identity, return cls.objects.get_or_create(
type=cls.Types.like, identity=identity,
subject_post=post, type=cls.Types.liked,
)[0] 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]

View File

View File

View File

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

View File

@ -33,15 +33,15 @@ class Home(FormView):
def get_context_data(self): def get_context_data(self):
context = super().get_context_data() context = super().get_context_data()
context["timeline_posts"] = [ context["events"] = (
te.subject_post TimelineEvent.objects.filter(
for te in TimelineEvent.objects.filter(
identity=self.request.identity, identity=self.request.identity,
type=TimelineEvent.Types.post, type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost],
) )
.select_related("subject_post", "subject_post__author") .select_related("subject_post", "subject_post__author")
.order_by("-created")[:100] .order_by("-created")[:100]
] )
context["current_page"] = "home" context["current_page"] = "home"
return context return context
@ -54,6 +54,22 @@ class Home(FormView):
return redirect(".") return redirect(".")
@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") @method_decorator(identity_required, name="dispatch")
class Federated(TemplateView): class Federated(TemplateView):
@ -61,10 +77,28 @@ class Federated(TemplateView):
def get_context_data(self): def get_context_data(self):
context = super().get_context_data() context = super().get_context_data()
context["timeline_posts"] = ( context["posts"] = (
Post.objects.filter(visibility=Post.Visibilities.public) Post.objects.filter(visibility=Post.Visibilities.public)
.select_related("author") .select_related("author")
.order_by("-created")[:100] .order_by("-created")[:100]
) )
context["current_page"] = "federated" context["current_page"] = "federated"
return context 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

View File

@ -459,7 +459,7 @@ form .button:hover {
/* Identities */ /* Identities */
h1.identity { h1.identity {
margin: 20px 0 20px 20px; margin: 15px 0 20px 15px;
} }
h1.identity .icon { h1.identity .icon {
@ -482,7 +482,7 @@ h1.identity small {
color: var(--color-text-dull); color: var(--color-text-dull);
border-radius: 3px; border-radius: 3px;
padding: 5px 8px; padding: 5px 8px;
margin: 20px; margin: 15px;
} }
.system-note a { .system-note a {
@ -527,3 +527,17 @@ h1.identity small {
.post .content p { .post .content p {
margin: 12px 0 4px 0; 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;
}

View File

@ -12,9 +12,16 @@ class Command(BaseCommand):
help = "Runs a Stator runner for a short period" help = "Runs a Stator runner for a short period"
def add_arguments(self, parser): 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) 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 # Resolve the models list into names
models = cast( models = cast(
List[Type[StatorModel]], List[Type[StatorModel]],
@ -24,5 +31,5 @@ class Command(BaseCommand):
models = StatorModel.subclasses models = StatorModel.subclasses
print("Running for models: " + " ".join(m._meta.label_lower for m in models)) print("Running for models: " + " ".join(m._meta.label_lower for m in models))
# Run a runner # Run a runner
runner = StatorRunner(models) runner = StatorRunner(models, concurrency=concurrency)
async_to_sync(runner.run)() async_to_sync(runner.run)()

View File

@ -16,16 +16,20 @@ class StatorRunner:
Designed to run in a one-shot mode, living inside a request. Designed to run in a one-shot mode, living inside a request.
""" """
START_TIMEOUT = 30 def __init__(
TOTAL_TIMEOUT = 60 self,
LOCK_TIMEOUT = 120 models: List[Type[StatorModel]],
concurrency: int = 30,
MAX_TASKS = 30 concurrency_per_model: int = 5,
MAX_TASKS_PER_MODEL = 5 run_period: int = 30,
wait_period: int = 30,
def __init__(self, models: List[Type[StatorModel]]): ):
self.models = models self.models = models
self.runner_id = uuid.uuid4().hex 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): async def run(self):
start_time = time.monotonic() start_time = time.monotonic()
@ -40,15 +44,18 @@ class StatorRunner:
await asyncio.gather(*initial_tasks) await asyncio.gather(*initial_tasks)
# For the first time period, launch tasks # For the first time period, launch tasks
print("Running main task loop") 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() self.remove_completed_tasks()
space_remaining = self.MAX_TASKS - len(self.tasks) space_remaining = self.concurrency - len(self.tasks)
# Fetch new tasks # Fetch new tasks
for model in self.models: for model in self.models:
if space_remaining > 0: if space_remaining > 0:
for instance in await model.atransition_get_with_lock( for instance in await model.atransition_get_with_lock(
min(space_remaining, self.MAX_TASKS_PER_MODEL), number=min(space_remaining, self.concurrency_per_model),
timezone.now() + datetime.timedelta(seconds=self.LOCK_TIMEOUT), lock_expiry=(
timezone.now()
+ datetime.timedelta(seconds=(self.total_period * 2) + 60)
),
): ):
self.tasks.append( self.tasks.append(
asyncio.create_task(self.run_transition(instance)) asyncio.create_task(self.run_transition(instance))
@ -59,7 +66,7 @@ class StatorRunner:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# Then wait for tasks to finish # Then wait for tasks to finish
print("Waiting for tasks to complete") 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() self.remove_completed_tasks()
if not self.tasks: if not self.tasks:
break break

View File

@ -9,6 +9,8 @@ from users.views import activitypub, auth, identity
urlpatterns = [ urlpatterns = [
path("", core.homepage), path("", core.homepage),
# Activity views # Activity views
path("notifications/", timelines.Notifications.as_view()),
path("local/", timelines.Local.as_view()),
path("federated/", timelines.Federated.as_view()), path("federated/", timelines.Federated.as_view()),
# Authentication # Authentication
path("auth/login/", auth.Login.as_view()), path("auth/login/", auth.Login.as_view()),

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<nav> <nav>
<a href="/" {% if current_page == "home" %}class="selected"{% endif %}>Home</a> <a href="/" {% if current_page == "home" %}class="selected"{% endif %}>Home</a>
<a href="/" {% if current_page == "mentions" %}class="selected"{% endif %}>Mentions</a> <a href="/notifications/" {% if current_page == "notifications" %}class="selected"{% endif %}>Notifications</a>
<a href="/" {% if current_page == "public" %}class="selected"{% endif %}>Public</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> <a href="/federated/" {% if current_page == "federated" %}class="selected"{% endif %}>Federated</a>
</nav> </nav>

View File

@ -10,8 +10,8 @@
<time> <time>
<a href="{{ post.url }}"> <a href="{{ post.url }}">
{% if post.authored %} {% if post.published %}
{{ post.authored | timedeltashort }} {{ post.published | timedeltashort }}
{% else %} {% else %}
{{ post.created | timedeltashort }} {{ post.created | timedeltashort }}
{% endif %} {% endif %}

View File

@ -7,7 +7,7 @@
<section class="columns"> <section class="columns">
<div class="left-column"> <div class="left-column">
{% for post in timeline_posts %} {% for post in posts %}
{% include "activities/_post.html" %} {% include "activities/_post.html" %}
{% empty %} {% empty %}
No posts yet. No posts yet.

View File

@ -8,10 +8,19 @@
<section class="columns"> <section class="columns">
<div class="left-column"> <div class="left-column">
{% for post in timeline_posts %} {% for event in events %}
{% include "activities/_post.html" %} {% 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 %} {% empty %}
No posts yet. Nothing to show yet.
{% endfor %} {% endfor %}
</div> </div>

View File

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

View File

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

View File

@ -12,12 +12,16 @@ class InboxMessageStates(StateGraph):
@classmethod @classmethod
async def handle_received(cls, instance: "InboxMessage"): async def handle_received(cls, instance: "InboxMessage"):
from activities.models import Post from activities.models import Post, PostInteraction
from users.models import Follow from users.models import Follow
match instance.message_type: match instance.message_type:
case "follow": case "follow":
await sync_to_async(Follow.handle_request_ap)(instance.message) 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": case "create":
match instance.message_object_type: match instance.message_object_type:
case "note": case "note":