mirror of
https://github.com/jointakahe/takahe.git
synced 2025-02-16 15:45:14 +00:00
Add ability to follow hashtags
This commit is contained in:
parent
902891ff9e
commit
79c1be03a6
16 changed files with 367 additions and 28 deletions
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
38
activities/views/hashtags.py
Normal file
38
activities/views/hashtags.py
Normal 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)
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
9
templates/activities/_hashtag_follow.html
Normal file
9
templates/activities/_hashtag_follow.html
Normal 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 %}
|
|
@ -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 %}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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
|
||||||
|
|
49
users/migrations/0016_hashtagfollow.py
Normal file
49
users/migrations/0016_hashtagfollow.py
Normal 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")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
||||||
|
|
58
users/models/hashtag_follow.py
Normal file
58
users/models/hashtag_follow.py
Normal 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
|
Loading…
Reference in a new issue