Add ability to follow hashtags
This commit is contained in:
parent
902891ff9e
commit
79c1be03a6
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue