Compare commits

...

11 commits

Author SHA1 Message Date
Jamie Bliss 6f7b8db3a2
Merge a09914beb2 into 7c34ac78ed 2024-02-09 14:10:54 -07:00
Andrew Godwin 7c34ac78ed Write a release checklist and do a couple things on it 2024-02-06 14:49:35 -07:00
Henri Dickson 72eb6a6271
add application/activity+json to accept header to improve compatibility (#694) 2024-02-05 21:40:04 -05:00
Jamie Bliss b2223ddf42
Back out push notification changes 2024-02-05 21:18:59 -05:00
Jamie Bliss 045a499ddf
Fix docs 2024-02-05 20:59:22 -05:00
Jamie Bliss 0fa48578f2
Write release notes for 0.11.0 2024-02-05 20:53:09 -05:00
Jamie Bliss a09914beb2
Add test for mixed reactions handling 2023-12-05 19:43:05 +00:00
Jamie Bliss 54bddb0dd7
Update UI to display reactions 2023-12-05 19:43:02 +00:00
Jamie Bliss b3e67ffe3a
Implement reaction aggregation. 2023-12-05 19:42:59 +00:00
Jamie Bliss 42b0b5831a
Add more reaction tests 2023-12-05 19:42:55 +00:00
Jamie Bliss 4c3cae337c
Store any incoming reactions PostInteraction.value 2023-12-05 19:42:51 +00:00
12 changed files with 505 additions and 26 deletions

View file

@ -3,7 +3,7 @@
A *beta* Fediverse server for microblogging. Not fully polished yet -
we're still working towards a 1.0!
**Current version: [0.10.1](https://docs.jointakahe.org/en/latest/releases/0.10/)**
**Current version: [0.11.0](https://docs.jointakahe.org/en/latest/releases/0.11/)**
Key features:

View file

@ -471,6 +471,7 @@ class Post(StatorModel):
"likes": self.stats.get("likes", 0) if self.stats else 0,
"boosts": self.stats.get("boosts", 0) if self.stats else 0,
"replies": self.stats.get("replies", 0) if self.stats else 0,
"reactions": self.stats.get("reactions", {}) if self.stats else {},
}
### Local creation/editing ###
@ -610,12 +611,24 @@ class Post(StatorModel):
"likes": self.interactions.filter(
type=PostInteraction.Types.like,
state__in=PostInteractionStates.group_active(),
).count(),
)
.values("identity")
.distinct()
.count(), # This counts each user that's had any likes/reactions
"boosts": self.interactions.filter(
type=PostInteraction.Types.boost,
state__in=PostInteractionStates.group_active(),
).count(),
"replies": Post.objects.filter(in_reply_to=self.object_uri).count(),
"reactions": {
row["value"] or "": row["count"]
for row in self.interactions.filter(
type=PostInteraction.Types.like,
state__in=PostInteractionStates.group_active(),
)
.values("value")
.annotate(count=models.Count("identity"))
},
}
if save:
self.save()

View file

@ -154,7 +154,7 @@ class PostInteraction(StatorModel):
)
# Used to store any interaction extra text value like the vote
# in the question/poll case
# in the question/poll case, or the reaction
value = models.CharField(max_length=50, blank=True, null=True)
# When the activity was originally created (as opposed to when we received it)
@ -392,6 +392,7 @@ class PostInteraction(StatorModel):
# Get the right type
if data["type"].lower() == "like":
type = cls.Types.like
value = data.get("content") or data.get("_misskey_reaction")
elif data["type"].lower() == "announce":
type = cls.Types.boost
elif (

View file

@ -1,4 +1,5 @@
import logging
from types import EllipsisType
from activities.models import (
Post,
@ -38,7 +39,7 @@ class PostService:
def __init__(self, post: Post):
self.post = post
def interact_as(self, identity: Identity, type: str):
def interact_as(self, identity: Identity, type: str, value: str | None = None):
"""
Performs an interaction on this Post
"""
@ -46,28 +47,39 @@ class PostService:
type=type,
identity=identity,
post=self.post,
value=value,
)[0]
if interaction.state not in PostInteractionStates.group_active():
interaction.transition_perform(PostInteractionStates.new)
self.post.calculate_stats()
def uninteract_as(self, identity, type):
def uninteract_as(self, identity, type, value: str | None | EllipsisType = ...):
"""
Undoes an interaction on this Post
"""
# Only search by value if it was actually given
additional_fields = {}
if value is not ...:
additional_fields["value"] = value
for interaction in PostInteraction.objects.filter(
type=type,
identity=identity,
post=self.post,
**additional_fields,
):
interaction.transition_perform(PostInteractionStates.undone)
self.post.calculate_stats()
def like_as(self, identity: Identity):
self.interact_as(identity, PostInteraction.Types.like)
def like_as(self, identity: Identity, reaction: str | None = None):
"""
Add a Like to the post, including reactions.
"""
self.interact_as(identity, PostInteraction.Types.like, value=reaction)
def unlike_as(self, identity: Identity):
self.uninteract_as(identity, PostInteraction.Types.like)
def unlike_as(self, identity: Identity, reaction: str | None = None):
self.uninteract_as(identity, PostInteraction.Types.like, value=reaction)
def boost_as(self, identity: Identity):
self.interact_as(identity, PostInteraction.Types.boost)

View file

@ -219,7 +219,7 @@ class HttpSignature:
body_bytes = b""
# GET requests get implicit accept headers added
if method == "get":
headers["Accept"] = "application/ld+json"
headers["Accept"] = "application/activity+json,application/ld+json"
# Sign the headers
signed_string = "\n".join(
f"{name.lower()}: {value}" for name, value in headers.items()

View file

@ -172,3 +172,37 @@ We use `HTMX <https://htmx.org/>`_ for dynamically loading content, and
`Hyperscript <https://hyperscript.org/>`_ for most interactions rather than raw
JavaScript. If you can accomplish what you need with these tools, please use them
rather than adding JS.
Cutting a release
-----------------
In order to make a release of Takahē, follow these steps:
* Create or update the release document (in ``/docs/releases``) for the
release; major versions get their own document, minor releases get a
subheading in the document for their major release.
* Go through the git commit history since the last release in order to write
a reasonable summary of features.
* Be sure to include the little paragraphs at the end about contributing and
the docker tag, and an Upgrade Notes section that at minimum mentions
migrations and if they're normal or weird (even if there aren't any, it's
nice to call that out).
* If it's a new doc, make sure you include it in ``docs/releases/index.rst``!
* Update the version number in ``/takahe/__init__.py``
* Update the version number in ``README.md``
* Make a commit containing these changes called ``Releasing 1.23.45``.
* Tag that commit with a tag in the format ``1.23.45``.
* Wait for the GitHub Actions to run and publish the docker images (around 20
minutes as the ARM build is a bit slow)
* Post on the official account announcing the relase and linking to the
now-published release notes.

View file

@ -1,21 +1,54 @@
0.11
====
*Released: Not Yet Released*
*Released: 2024-02-05*
Notes TBD.
This is largely a bugfix and catch up release.
Some highlights:
* Python 3.10 has been dropped. The new minimum Python version is 3.11
* Jamie (`@astraluma@tacobelllabs.net <https://tacobelllabs.net/@astraluma>`_)
has officially joined the project
* If your S3 does not use TLS, you must use ``s3-insecure`` in your
configuration
* Takahē now supports unicode hashtags
* Add a Maximum Media Attachments setting
* Inverted the pruning command exit codes
* Posts are no longer required to have text content
And some interoperability bugs:
* Fixed a bug with GoToSocial
* Attempted to fix follows from Misskey family
* Correctly handle when a federated report doesn't have content
In additions, there's many bugfixes and minor changes, including:
* Several JSON handling improvements
* Post pruning now has a random element to it
* More specific loggers
* Don't make local identities stale
* Don't try to unmute when there's no expiration
* Don't try to WebFinger local users
* Synchronize follow accepting and profile fetching
* Perform some basic domain validity
* Correctly reject more operations when the identity is deleted
* Post edit fanouts for likers/boosters
If you'd like to help with code, design, or other areas, see
:doc:`/contributing` to see how to get in touch.
You can download images from `Docker Hub <https://hub.docker.com/r/jointakahe/takahe>`_,
or use the image name ``jointakahe/takahe:0.11``.
Upgrade Notes
-------------
VAPID keys and Push notifications
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Migrations
~~~~~~~~~~
Takahē now supports push notifications if you supply a valid VAPID keypair as
the ``TAKAHE_VAPID_PUBLIC_KEY`` and ``TAKAHE_VAPID_PRIVATE_KEY`` environment
variables. You can generate a keypair via `https://web-push-codelab.glitch.me/`_.
Note that users of apps may need to sign out and in again to their accounts for
the app to notice that it can now do push notifications. Some apps, like Elk,
may cache the fact your server didn't support it for a while.
There are new database migrations; they are backwards-compatible and should
not present any major database load.

View file

@ -7,6 +7,7 @@ Versions
.. toctree::
:maxdepth: 1
0.11
0.10
0.9
0.8

15
docs/releases/next.rst Normal file
View file

@ -0,0 +1,15 @@
Upgrade Notes
-------------
VAPID keys and Push notifications
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Takahē now supports push notifications if you supply a valid VAPID keypair as
the ``TAKAHE_VAPID_PUBLIC_KEY`` and ``TAKAHE_VAPID_PRIVATE_KEY`` environment
variables. You can generate a keypair via `https://web-push-codelab.glitch.me/`_.
Note that users of apps may need to sign out and in again to their accounts for
the app to notice that it can now do push notifications. Some apps, like Elk,
may cache the fact your server didn't support it for a while.

View file

@ -1 +1 @@
__version__ = "0.10.1"
__version__ = "0.11.0"

View file

@ -78,10 +78,16 @@
<i class="fa-solid fa-reply"></i>
<span class="like-count">{{ post.stats_with_defaults.replies|default:"0" }}</span>
</a>
<a title="Likes" class="no-action">
<i class="fa-solid fa-star"></i>
<span class="like-count">{{ post.stats_with_defaults.likes|default:"0" }}</span>
</a>
{% for reaction, count in post.stats_with_defaults.reactions.items %}
<a title="Reaction {{reaction}}" class="no-action">
{% if reaction %}
<span>{{reaction}}</span>
{% else %}
<i class="fa-solid fa-star"></i>
{% endif %}
<span class="like-count">{{count}}</span>
</a>
{% endfor %}
<a title="Boosts" class="no-action">
<i class="fa-solid fa-retweet"></i>
<span class="like-count">{{ post.stats_with_defaults.boosts|default:"0" }}</span>

View file

@ -0,0 +1,364 @@
import pytest
from activities.models import Post, TimelineEvent
from activities.services import PostService
from users.models import Identity, InboxMessage
@pytest.mark.django_db
@pytest.mark.parametrize("local", [True, False])
@pytest.mark.parametrize("reaction", ["\U0001F607"])
def test_react_notification(
identity: Identity,
other_identity: Identity,
remote_identity: Identity,
stator,
local: bool,
reaction: str,
):
"""
Ensures that a reaction of a local Post notifies its author.
This mostly ensures that basic reaction flows happen.
"""
post = Post.create_local(author=identity, content="I love birds!")
if local:
PostService(post).like_as(other_identity, reaction)
else:
message = {
"id": "test",
"type": "Like",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
"content": reaction,
}
InboxMessage.objects.create(message=message)
interactor = other_identity if local else remote_identity
# Run stator thrice - to receive the post, make fanouts and then process them
stator.run_single_cycle()
stator.run_single_cycle()
stator.run_single_cycle()
# Verify we got an event
event = TimelineEvent.objects.filter(
type=TimelineEvent.Types.liked, identity=identity
).first()
assert event
assert event.subject_identity == interactor
assert event.subject_post_interaction.value == reaction
@pytest.mark.django_db
@pytest.mark.parametrize("local", [True, False])
@pytest.mark.parametrize("reaction", ["\U0001F607"])
def test_react_duplicate(
identity: Identity,
other_identity: Identity,
remote_identity: Identity,
stator,
local: bool,
reaction: str,
):
"""
Ensures that if we receive the same reaction from the same actor multiple times,
only one notification and interaction are produced.
"""
post = Post.create_local(author=identity, content="I love birds!")
for _ in range(3):
if local:
PostService(post).like_as(other_identity, reaction)
else:
message = {
"id": "test",
"type": "Like",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
"content": reaction,
}
InboxMessage.objects.create(message=message)
interactor = other_identity if local else remote_identity
# Running stator 3 times for each interaction. Not sure what's the right number.
for _ in range(9):
stator.run_single_cycle()
# Verify we got an event
events = TimelineEvent.objects.filter(
type=TimelineEvent.Types.liked, identity=identity
).all()
assert len(events) == 1
(event,) = events
assert event.subject_identity == interactor
assert event.subject_post_interaction.value == reaction
@pytest.mark.django_db
@pytest.mark.parametrize("local", [True, False])
@pytest.mark.parametrize("reaction", ["\U0001F607"])
def test_react_undo(
identity: Identity,
other_identity: Identity,
remote_identity: Identity,
stator,
local: bool,
reaction: str,
):
"""
Ensures basic un-reacting.
"""
post = Post.create_local(author=identity, content="I love birds!")
if local:
PostService(post).like_as(other_identity, reaction)
else:
message = {
"id": "test",
"type": "Like",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
"content": reaction,
}
InboxMessage.objects.create(message=message)
# Run stator thrice - to receive the post, make fanouts and then process them
stator.run_single_cycle()
stator.run_single_cycle()
stator.run_single_cycle()
# Verify we got an event
events = TimelineEvent.objects.filter(
type=TimelineEvent.Types.liked, identity=identity
).all()
assert len(events) == 1
if local:
PostService(post).unlike_as(other_identity, reaction)
else:
message = {
"id": "test/undo",
"type": "Undo",
"actor": remote_identity.actor_uri,
"object": {
"id": "test",
"type": "Like",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
"content": reaction,
},
}
InboxMessage.objects.create(message=message)
# Run stator thrice - to receive the post, make fanouts and then process them
stator.run_single_cycle()
stator.run_single_cycle()
stator.run_single_cycle()
# Verify the event was removed.
events = TimelineEvent.objects.filter(
type=TimelineEvent.Types.liked, identity=identity
).all()
assert len(events) == 0
@pytest.mark.django_db
@pytest.mark.parametrize("local", [True, False])
def test_react_undo_mismatched(
identity: Identity,
other_identity: Identity,
remote_identity: Identity,
stator,
local: bool,
):
"""
Ensures that un-reacting deletes the right reaction.
"""
post = Post.create_local(author=identity, content="I love birds!")
if local:
PostService(post).like_as(other_identity, "foo")
else:
message = {
"id": "test",
"type": "Like",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
"content": "foo",
}
InboxMessage.objects.create(message=message)
# Run stator thrice - to receive the post, make fanouts and then process them
stator.run_single_cycle()
stator.run_single_cycle()
stator.run_single_cycle()
# Verify we got an event
events = TimelineEvent.objects.filter(
type=TimelineEvent.Types.liked, identity=identity
).all()
assert len(events) == 1
if local:
PostService(post).unlike_as(other_identity, "bar")
else:
message = {
"id": "test/undo",
"type": "Undo",
"actor": remote_identity.actor_uri,
"object": {
# AstraLuma: I'm actually unsure if this test should use the same or different ID.
"id": "test2",
"type": "Like",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
"content": "bar",
},
}
InboxMessage.objects.create(message=message)
# Run stator thrice - to receive the post, make fanouts and then process them
stator.run_single_cycle()
stator.run_single_cycle()
stator.run_single_cycle()
# Verify the event was removed.
events = TimelineEvent.objects.filter(
type=TimelineEvent.Types.liked, identity=identity
).all()
assert len(events) == 1
@pytest.mark.django_db
@pytest.mark.parametrize("local", [True, False])
@pytest.mark.parametrize("reaction", ["\U0001F607"])
def test_react_stats(
identity: Identity,
other_identity: Identity,
remote_identity: Identity,
stator,
local: bool,
reaction: str,
):
"""
Checks basic post stats generation
"""
post = Post.create_local(author=identity, content="I love birds!")
if local:
PostService(post).like_as(other_identity, reaction)
else:
message = {
"id": "test",
"type": "Like",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
"content": reaction,
}
InboxMessage.objects.create(message=message)
# Run stator thrice - to receive the post, make fanouts and then process them
stator.run_single_cycle()
stator.run_single_cycle()
stator.run_single_cycle()
post.refresh_from_db()
assert "reactions" in post.stats
assert post.stats["reactions"] == {reaction: 1}
@pytest.mark.django_db
@pytest.mark.parametrize("local", [True, False])
def test_react_stats_multiple(
identity: Identity,
other_identity: Identity,
remote_identity: Identity,
stator,
local: bool,
):
"""
Ensures that multiple reactions get aggregated correctly.
Basically, if the same person leaves multiple reactions, aggregate all of them into one Like.
"""
post = Post.create_local(author=identity, content="I love birds!")
for i, reaction in enumerate("abc"):
if local:
PostService(post).like_as(other_identity, reaction)
else:
message = {
"id": f"test{i}",
"type": "Like",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
"content": reaction,
}
InboxMessage.objects.create(message=message)
# Run stator thrice - to receive the post, make fanouts and then process them
stator.run_single_cycle()
stator.run_single_cycle()
stator.run_single_cycle()
post.refresh_from_db()
assert post.stats["reactions"] == {"a": 1, "b": 1, "c": 1}
assert post.stats["likes"] == 1
@pytest.mark.django_db
@pytest.mark.parametrize("local", [True, False])
def test_react_stats_mixed(
identity: Identity,
other_identity: Identity,
remote_identity: Identity,
stator,
local: bool,
):
"""
Ensures that mixed Likes and Reactions get aggregated
"""
post = Post.create_local(author=identity, content="I love birds!")
for i, reaction in enumerate("abc"):
if local:
PostService(post).like_as(other_identity, reaction)
else:
message = {
"id": f"test{i}",
"type": "Like",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
"content": reaction,
}
InboxMessage.objects.create(message=message)
if local:
PostService(post).like_as(other_identity)
else:
message = {
"id": "test",
"type": "Like",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
}
InboxMessage.objects.create(message=message)
# Run stator thrice - to receive the post, make fanouts and then process them
for _ in range(4):
stator.run_single_cycle()
post.refresh_from_db()
assert post.stats["reactions"] == {"a": 1, "b": 1, "c": 1, "": 1}
assert post.stats["likes"] == 1
# TODO: Test that multiple reactions can be added and deleted correctly
# TODO: How should plain likes and reactions from the same source be handled?
# Specifically if we receive an unlike without a specific reaction.
# Hm, If Misskey is single-reaction, will it send Like interactions for changes
# in reaction? Then we're expected to overwrite that users previous interaction
# rather than create a new one.