Add ability to follow hashtags

This commit is contained in:
Christof Dorner 2023-03-14 22:35:40 +01:00 committed by GitHub
parent 902891ff9e
commit 79c1be03a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 367 additions and 28 deletions

View file

@ -114,6 +114,8 @@ class Hashtag(StatorModel):
class urls(urlman.Urls):
view = "/tags/{self.hashtag}/"
follow = "/tags/{self.hashtag}/follow/"
unfollow = "/tags/{self.hashtag}/unfollow/"
admin = "/admin/hashtags/"
admin_edit = "{admin}{self.hashtag}/"
admin_enable = "{admin_edit}enable/"
@ -166,9 +168,14 @@ class Hashtag(StatorModel):
results[date(year, month, day)] = val
return dict(sorted(results.items(), reverse=True)[:num])
def to_mastodon_json(self):
return {
def to_mastodon_json(self, followed: bool | None = None):
value = {
"name": self.hashtag,
"url": self.urls.view.full(),
"url": self.urls.view.full(), # type: ignore
"history": [],
}
if followed is not None:
value["followed"] = followed
return value

View file

@ -38,6 +38,7 @@ from core.snowflake import Snowflake
from stator.exceptions import TryAgainLater
from stator.models import State, StateField, StateGraph, StatorModel
from users.models.follow import FollowStates
from users.models.hashtag_follow import HashtagFollow
from users.models.identity import Identity, IdentityStates
from users.models.inbox_message import InboxMessage
from users.models.system_actor import SystemActor
@ -726,12 +727,18 @@ class Post(StatorModel):
targets = set()
async for mention in self.mentions.all():
targets.add(mention)
# Then, if it's not mentions only, also deliver to followers
# Then, if it's not mentions only, also deliver to followers and all hashtag followers
if self.visibility != Post.Visibilities.mentioned:
async for follower in self.author.inbound_follows.filter(
state__in=FollowStates.group_active()
).select_related("source"):
targets.add(follower.source)
if self.hashtags:
async for follow in HashtagFollow.objects.by_hashtags(
self.hashtags
).prefetch_related("identity"):
targets.add(follow.identity)
# If it's a reply, always include the original author if we know them
reply_post = await self.ain_reply_to_post()
if reply_post:

View file

@ -0,0 +1,38 @@
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator
from django.views.generic import View
from activities.models.hashtag import Hashtag
from users.decorators import identity_required
@method_decorator(identity_required, name="dispatch")
class HashtagFollow(View):
"""
Follows/unfollows a hashtag with the current identity
"""
undo = False
def post(self, request: HttpRequest, hashtag):
hashtag = get_object_or_404(
Hashtag,
pk=hashtag,
)
follow = None
if self.undo:
request.identity.hashtag_follows.filter(hashtag=hashtag).delete()
else:
follow = request.identity.hashtag_follows.get_or_create(hashtag=hashtag)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
request,
"activities/_hashtag_follow.html",
{
"hashtag": hashtag,
"follow": follow,
},
)
return redirect(hashtag.urls.view)

View file

@ -7,7 +7,7 @@ from activities.models import Hashtag, PostInteraction, TimelineEvent
from activities.services import TimelineService
from core.decorators import cache_page
from users.decorators import identity_required
from users.models import Bookmark
from users.models import Bookmark, HashtagFollow
from .compose import Compose
@ -75,6 +75,10 @@ class Tag(ListView):
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
context["follow"] = HashtagFollow.maybe_get(
self.request.identity,
self.hashtag,
)
return context

View file

@ -276,13 +276,33 @@ class Tag(Schema):
name: str
url: str
history: dict
followed: bool | None
@classmethod
def from_hashtag(
cls,
hashtag: activities_models.Hashtag,
followed: bool | None = None,
) -> "Tag":
return cls(**hashtag.to_mastodon_json())
return cls(**hashtag.to_mastodon_json(followed=followed))
class FollowedTag(Tag):
id: str
@classmethod
def from_follow(
cls,
follow: users_models.HashtagFollow,
) -> "FollowedTag":
return cls(id=follow.id, **follow.hashtag.to_mastodon_json(followed=True))
@classmethod
def map_from_follows(
cls,
hashtag_follows: list[users_models.HashtagFollow],
) -> list["Tag"]:
return [cls.from_follow(follow) for follow in hashtag_follows]
class FeaturedTag(Schema):

View file

@ -95,6 +95,8 @@ urlpatterns = [
path("v1/statuses/<id>/unbookmark", statuses.unbookmark_status),
# Tags
path("v1/followed_tags", tags.followed_tags),
path("v1/tags/<id>/follow", tags.follow),
path("v1/tags/<id>/unfollow", tags.unfollow),
# Timelines
path("v1/timelines/home", timelines.home),
path("v1/timelines/public", timelines.public),

View file

@ -1,8 +1,12 @@
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from hatchway import api_view
from activities.models import Hashtag
from api import schemas
from api.decorators import scope_required
from api.pagination import MastodonPaginator, PaginatingApiResponse, PaginationResult
from users.models import HashtagFollow
@scope_required("read:follows")
@ -14,5 +18,51 @@ def followed_tags(
min_id: str | None = None,
limit: int = 100,
) -> list[schemas.Tag]:
# We don't implement this yet
return []
queryset = HashtagFollow.objects.by_identity(request.identity)
paginator = MastodonPaginator()
pager: PaginationResult[HashtagFollow] = paginator.paginate(
queryset,
min_id=min_id,
max_id=max_id,
since_id=since_id,
limit=limit,
)
return PaginatingApiResponse(
schemas.FollowedTag.map_from_follows(pager.results),
request=request,
include_params=["limit"],
)
@scope_required("write:follows")
@api_view.post
def follow(
request: HttpRequest,
id: str,
) -> schemas.Tag:
hashtag = get_object_or_404(
Hashtag,
pk=id,
)
request.identity.hashtag_follows.get_or_create(hashtag=hashtag)
return schemas.Tag.from_hashtag(
hashtag,
followed=True,
)
@scope_required("write:follows")
@api_view.post
def unfollow(
request: HttpRequest,
id: str,
) -> schemas.Tag:
hashtag = get_object_or_404(
Hashtag,
pk=id,
)
request.identity.hashtag_follows.filter(hashtag=hashtag).delete()
return schemas.Tag.from_hashtag(
hashtag,
followed=False,
)

View file

@ -714,28 +714,32 @@ form.inline {
display: inline;
}
div.follow-profile {
div.follow {
float: right;
margin: 20px 0 0 0;
font-size: 16px;
text-align: center;
}
.follow-profile.has-reverse {
div.follow-hashtag {
margin: 0;
}
.follow.has-reverse {
margin-top: 0;
}
.follow-profile .reverse-follow {
.follow .reverse-follow {
display: block;
margin: 0 0 5px 0;
}
div.follow-profile button,
div.follow-profile .button {
div.follow button,
div.follow .button {
margin: 0;
}
div.follow-profile .actions {
div.follow .actions {
/* display: flex; */
position: relative;
justify-content: space-between;
@ -744,14 +748,14 @@ div.follow-profile .actions {
align-content: center;
}
div.follow-profile .actions a {
div.follow .actions a {
border-radius: 4px;
min-width: 40px;
text-align: center;
cursor: pointer;
}
div.follow-profile .actions menu {
div.follow .actions menu {
display: none;
background-color: var(--color-bg-menu);
border-radius: 5px;
@ -762,13 +766,13 @@ div.follow-profile .actions menu {
}
div.follow-profile .actions menu.enabled {
div.follow .actions menu.enabled {
display: block;
min-width: 160px;
z-index: 10;
}
div.follow-profile .actions menu a {
div.follow .actions menu a {
text-align: left;
display: block;
font-size: 15px;
@ -776,7 +780,7 @@ div.follow-profile .actions menu a {
color: var(--color-text-dull);
}
.follow-profile .actions menu button {
.follow .actions menu button {
background: none !important;
border: none;
cursor: pointer;
@ -787,25 +791,25 @@ div.follow-profile .actions menu a {
color: var(--color-text-dull);
}
.follow-profile .actions menu button i {
.follow .actions menu button i {
margin-right: 4px;
width: 16px;
}
.follow-profile .actions button:hover {
.follow .actions button:hover {
color: var(--color-text-main);
}
.follow-profile .actions menu a i {
.follow .actions menu a i {
margin-right: 4px;
width: 16px;
}
div.follow-profile .actions a:hover {
div.follow .actions a:hover {
color: var(--color-text-main);
}
div.follow-profile .actions a.active {
div.follow .actions a.active {
color: var(--color-text-link);
}
@ -1305,6 +1309,11 @@ table.metadata td .emoji {
margin: 0 0 10px 0;
color: var(--color-text-main);
font-size: 130%;
display: flow-root;
}
.left-column .timeline-name .hashtag {
margin-top: 5px;
}
.left-column .timeline-name i {

View file

@ -2,7 +2,16 @@ from django.conf import settings as djsettings
from django.contrib import admin as djadmin
from django.urls import include, path, re_path
from activities.views import compose, debug, explore, follows, posts, search, timelines
from activities.views import (
compose,
debug,
explore,
follows,
hashtags,
posts,
search,
timelines,
)
from api.views import oauth
from core import views as core
from mediaproxy import views as mediaproxy
@ -27,6 +36,8 @@ urlpatterns = [
path("federated/", timelines.Federated.as_view(), name="federated"),
path("search/", search.Search.as_view(), name="search"),
path("tags/<hashtag>/", timelines.Tag.as_view(), name="tag"),
path("tags/<hashtag>/follow/", hashtags.HashtagFollow.as_view()),
path("tags/<hashtag>/unfollow/", hashtags.HashtagFollow.as_view(undo=True)),
path("explore/", explore.Explore.as_view(), name="explore"),
path("explore/tags/", explore.ExploreTag.as_view(), name="explore-tag"),
path(

View file

@ -0,0 +1,9 @@
{% if follow %}
<button title="Unfollow" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ hashtag.urls.unfollow }}" hx-swap="outerHTML" tabindex="0">
Unfollow
</button>
{% else %}
<button title="Follow" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ hashtag.urls.follow }}" hx-swap="outerHTML" tabindex="0">
Follow
</button>
{% endif %}

View file

@ -3,7 +3,14 @@
{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %}
{% block content %}
<div class="timeline-name"><i class="fa-solid fa-hashtag"></i>{{ hashtag.display_name }}</div>
<div class="timeline-name">
<div class="inline follow follow-hashtag">
{% include "activities/_hashtag_follow.html" %}
</div>
<div class="hashtag">
<i class="fa-solid fa-hashtag"></i>{{ hashtag.display_name }}
</div>
</div>
{% for post in page_obj %}
{% include "activities/_post.html" %}
{% empty %}

View file

@ -1,4 +1,4 @@
<div class="inline follow-profile {% if inbound_follow %}has-reverse{% endif %}">
<div class="inline follow {% if inbound_follow %}has-reverse{% endif %}">
<div class="actions" role="menubar">
{% if request.identity == identity %}
<a href="{% url "settings_profile" %}" class="button" title="Edit Profile">

View file

@ -1,7 +1,7 @@
import pytest
from django.utils import timezone
from activities.models import Post, TimelineEvent
from activities.models import Hashtag, Post, TimelineEvent
from activities.services import PostService
from core.ld import format_ld_date
from users.models import Block, Follow, Identity, InboxMessage
@ -257,3 +257,70 @@ def test_clear_timeline(
assert TimelineEvent.objects.filter(
type=TimelineEvent.Types.mentioned, identity=identity
).exists() == (not full)
@pytest.mark.django_db
@pytest.mark.parametrize("local", [True, False])
@pytest.mark.parametrize("blocked", ["full", "mute", "no"])
def test_hashtag_followed(
identity: Identity,
other_identity: Identity,
remote_identity: Identity,
stator,
local: bool,
blocked: bool,
):
"""
Ensure that a new or incoming post with a hashtag followed by a local entity
results in a timeline event, unless the author is blocked.
"""
hashtag = Hashtag.objects.get_or_create(hashtag="takahe")[0]
identity.hashtag_follows.get_or_create(hashtag=hashtag)
if local:
Post.create_local(author=other_identity, content="Hello from #Takahe!")
else:
# Create an inbound new post message
message = {
"id": "test",
"type": "Create",
"actor": remote_identity.actor_uri,
"object": {
"id": "https://remote.test/test-post",
"type": "Note",
"published": format_ld_date(timezone.now()),
"attributedTo": remote_identity.actor_uri,
"to": "as:Public",
"content": '<p>Hello from <a href="https://remote.test/tags/takahe/" rel="tag">#Takahe</a>!',
"tag": {
"type": "Hashtag",
"href": "https://remote.test/tags/takahe/",
"name": "#Takahe",
},
},
}
InboxMessage.objects.create(message=message)
# Implement any blocks
author = other_identity if local else remote_identity
if blocked == "full":
Block.create_local_block(identity, author)
elif blocked == "mute":
Block.create_local_mute(identity, author)
# Run stator twice - to make fanouts and then process them
stator.run_single_cycle_sync()
stator.run_single_cycle_sync()
if blocked in ["full", "mute"]:
# Verify post is not in timeline
assert not TimelineEvent.objects.filter(
type=TimelineEvent.Types.post, identity=identity
).exists()
else:
# Verify post is in timeline
event = TimelineEvent.objects.filter(
type=TimelineEvent.Types.post, identity=identity
).first()
assert event
assert "Hello from " in event.subject_post.content

View file

@ -0,0 +1,49 @@
# Generated by Django 4.1.7 on 2023-03-11 19:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("activities", "0012_in_reply_to_index"),
("users", "0015_bookmark"),
]
operations = [
migrations.CreateModel(
name="HashtagFollow",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
(
"hashtag",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="followers",
to="activities.hashtag",
),
),
(
"identity",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="hashtag_follows",
to="users.identity",
),
),
],
options={
"unique_together": {("identity", "hashtag")},
},
),
]

View file

@ -3,6 +3,7 @@ from .block import Block, BlockStates # noqa
from .bookmark import Bookmark # noqa
from .domain import Domain # noqa
from .follow import Follow, FollowStates # noqa
from .hashtag_follow import HashtagFollow # noqa
from .identity import Identity, IdentityStates # noqa
from .inbox_message import InboxMessage, InboxMessageStates # noqa
from .invite import Invite # noqa

View file

@ -0,0 +1,58 @@
from typing import Optional
from django.db import models
class HashtagFollowQuerySet(models.QuerySet):
def by_hashtags(self, hashtags: list[str]):
return self.filter(hashtag_id__in=hashtags)
def by_identity(self, identity):
return self.filter(identity=identity)
class HashtagFollowManager(models.Manager):
def get_queryset(self):
return HashtagFollowQuerySet(self.model, using=self._db)
def by_hashtags(self, hashtags: list[str]):
return self.get_queryset().by_hashtags(hashtags)
def by_identity(self, identity):
return self.get_queryset().by_identity(identity)
class HashtagFollow(models.Model):
identity = models.ForeignKey(
"users.Identity",
on_delete=models.CASCADE,
related_name="hashtag_follows",
)
hashtag = models.ForeignKey(
"activities.Hashtag",
on_delete=models.CASCADE,
related_name="followers",
db_index=True,
)
created = models.DateTimeField(auto_now_add=True)
objects = HashtagFollowManager()
class Meta:
unique_together = [("identity", "hashtag")]
def __str__(self):
return f"#{self.id}: {self.identity}{self.hashtag_id}"
### Alternate fetchers/constructors ###
@classmethod
def maybe_get(cls, identity, hashtag) -> Optional["HashtagFollow"]:
"""
Returns a hashtag follow if it exists between identity and hashtag
"""
try:
return HashtagFollow.objects.get(identity=identity, hashtag=hashtag)
except HashtagFollow.DoesNotExist:
return None