diff --git a/activities/models/hashtag.py b/activities/models/hashtag.py index 176bdc1..e5fc55f 100644 --- a/activities/models/hashtag.py +++ b/activities/models/hashtag.py @@ -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 diff --git a/activities/models/post.py b/activities/models/post.py index e3e3da2..72f68ab 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -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: diff --git a/activities/views/hashtags.py b/activities/views/hashtags.py new file mode 100644 index 0000000..e5d5c44 --- /dev/null +++ b/activities/views/hashtags.py @@ -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) diff --git a/activities/views/timelines.py b/activities/views/timelines.py index ec75a5b..2e7b710 100644 --- a/activities/views/timelines.py +++ b/activities/views/timelines.py @@ -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 diff --git a/api/schemas.py b/api/schemas.py index fd5b89d..83a252f 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -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): diff --git a/api/urls.py b/api/urls.py index 13cc9dc..88012bd 100644 --- a/api/urls.py +++ b/api/urls.py @@ -95,6 +95,8 @@ urlpatterns = [ path("v1/statuses//unbookmark", statuses.unbookmark_status), # Tags path("v1/followed_tags", tags.followed_tags), + path("v1/tags//follow", tags.follow), + path("v1/tags//unfollow", tags.unfollow), # Timelines path("v1/timelines/home", timelines.home), path("v1/timelines/public", timelines.public), diff --git a/api/views/tags.py b/api/views/tags.py index b589ee6..16a63e1 100644 --- a/api/views/tags.py +++ b/api/views/tags.py @@ -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, + ) diff --git a/static/css/style.css b/static/css/style.css index 97816fd..c8c5740 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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 { diff --git a/takahe/urls.py b/takahe/urls.py index a6e368b..e78ebd5 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -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//", timelines.Tag.as_view(), name="tag"), + path("tags//follow/", hashtags.HashtagFollow.as_view()), + path("tags//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( diff --git a/templates/activities/_hashtag_follow.html b/templates/activities/_hashtag_follow.html new file mode 100644 index 0000000..cdf00c5 --- /dev/null +++ b/templates/activities/_hashtag_follow.html @@ -0,0 +1,9 @@ +{% if follow %} + +{% else %} + +{% endif %} diff --git a/templates/activities/tag.html b/templates/activities/tag.html index 2bb7406..e0e35bf 100644 --- a/templates/activities/tag.html +++ b/templates/activities/tag.html @@ -3,7 +3,14 @@ {% block title %}#{{ hashtag.display_name }} Timeline{% endblock %} {% block content %} -
{{ hashtag.display_name }}
+
+ +
+ {{ hashtag.display_name }} +
+
{% for post in page_obj %} {% include "activities/_post.html" %} {% empty %} diff --git a/templates/identity/_view_menu.html b/templates/identity/_view_menu.html index 6f24ef3..4c05723 100644 --- a/templates/identity/_view_menu.html +++ b/templates/identity/_view_menu.html @@ -1,4 +1,4 @@ -