Add ability to follow hashtags

pull/543/head
Christof Dorner 2023-03-14 22:35:40 +01:00 zatwierdzone przez GitHub
rodzic 902891ff9e
commit 79c1be03a6
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
16 zmienionych plików z 367 dodań i 28 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -276,13 +276,33 @@ class Tag(Schema):
name: str name: str
url: str url: str
history: dict history: dict
followed: bool | None
@classmethod @classmethod
def from_hashtag( def from_hashtag(
cls, cls,
hashtag: activities_models.Hashtag, hashtag: activities_models.Hashtag,
followed: bool | None = None,
) -> "Tag": ) -> "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): class FeaturedTag(Schema):

Wyświetl plik

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

Wyświetl plik

@ -1,8 +1,12 @@
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from hatchway import api_view from hatchway import api_view
from activities.models import Hashtag
from api import schemas from api import schemas
from api.decorators import scope_required from api.decorators import scope_required
from api.pagination import MastodonPaginator, PaginatingApiResponse, PaginationResult
from users.models import HashtagFollow
@scope_required("read:follows") @scope_required("read:follows")
@ -14,5 +18,51 @@ def followed_tags(
min_id: str | None = None, min_id: str | None = None,
limit: int = 100, limit: int = 100,
) -> list[schemas.Tag]: ) -> list[schemas.Tag]:
# We don't implement this yet queryset = HashtagFollow.objects.by_identity(request.identity)
return [] 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,
)

Wyświetl plik

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

Wyświetl plik

@ -2,7 +2,16 @@ from django.conf import settings as djsettings
from django.contrib import admin as djadmin from django.contrib import admin as djadmin
from django.urls import include, path, re_path 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 api.views import oauth
from core import views as core from core import views as core
from mediaproxy import views as mediaproxy from mediaproxy import views as mediaproxy
@ -27,6 +36,8 @@ urlpatterns = [
path("federated/", timelines.Federated.as_view(), name="federated"), path("federated/", timelines.Federated.as_view(), name="federated"),
path("search/", search.Search.as_view(), name="search"), path("search/", search.Search.as_view(), name="search"),
path("tags/<hashtag>/", timelines.Tag.as_view(), name="tag"), 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/", explore.Explore.as_view(), name="explore"),
path("explore/tags/", explore.ExploreTag.as_view(), name="explore-tag"), path("explore/tags/", explore.ExploreTag.as_view(), name="explore-tag"),
path( path(

Wyświetl plik

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

Wyświetl plik

@ -3,7 +3,14 @@
{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %} {% block title %}#{{ hashtag.display_name }} Timeline{% endblock %}
{% block content %} {% 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 %} {% for post in page_obj %}
{% include "activities/_post.html" %} {% include "activities/_post.html" %}
{% empty %} {% empty %}

Wyświetl plik

@ -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"> <div class="actions" role="menubar">
{% if request.identity == identity %} {% if request.identity == identity %}
<a href="{% url "settings_profile" %}" class="button" title="Edit Profile"> <a href="{% url "settings_profile" %}" class="button" title="Edit Profile">

Wyświetl plik

@ -1,7 +1,7 @@
import pytest import pytest
from django.utils import timezone from django.utils import timezone
from activities.models import Post, TimelineEvent from activities.models import Hashtag, Post, TimelineEvent
from activities.services import PostService from activities.services import PostService
from core.ld import format_ld_date from core.ld import format_ld_date
from users.models import Block, Follow, Identity, InboxMessage from users.models import Block, Follow, Identity, InboxMessage
@ -257,3 +257,70 @@ def test_clear_timeline(
assert TimelineEvent.objects.filter( assert TimelineEvent.objects.filter(
type=TimelineEvent.Types.mentioned, identity=identity type=TimelineEvent.Types.mentioned, identity=identity
).exists() == (not full) ).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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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