summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMichael Manfre2022-11-28 23:41:36 -0500
committerGitHub2022-11-28 21:41:36 -0700
commitfb8f2d10984bcfa2585dc272b4c85d285b722792 (patch)
treefa9616f745c7e9e4b5cc5d1ca82d61512ee64f01
parent7f838433edde6a03d1b7f71da269f9756a3f91e9 (diff)
downloadtakahe-fb8f2d10984bcfa2585dc272b4c85d285b722792.tar.gz
takahe-fb8f2d10984bcfa2585dc272b4c85d285b722792.tar.bz2
takahe-fb8f2d10984bcfa2585dc272b4c85d285b722792.zip
Hashtags
-rw-r--r--activities/admin.py25
-rw-r--r--activities/migrations/0002_hashtag.py51
-rw-r--r--activities/models/__init__.py1
-rw-r--r--activities/models/hashtag.py187
-rw-r--r--activities/models/post.py94
-rw-r--r--activities/templatetags/activity_tags.py13
-rw-r--r--activities/views/admin/__init__.py0
-rw-r--r--activities/views/explore.py26
-rw-r--r--activities/views/search.py43
-rw-r--r--activities/views/timelines.py45
-rw-r--r--core/models/config.py3
-rw-r--r--docs/features.rst2
-rw-r--r--static/css/style.css36
-rw-r--r--takahe/urls.py23
-rw-r--r--templates/activities/_hashtag.html11
-rw-r--r--templates/activities/_menu.html13
-rw-r--r--templates/activities/explore_tag.html16
-rw-r--r--templates/activities/search.html8
-rw-r--r--templates/activities/tag.html16
-rw-r--r--templates/admin/hashtag_create.html26
-rw-r--r--templates/admin/hashtag_delete.html17
-rw-r--r--templates/admin/hashtag_edit.html46
-rw-r--r--templates/admin/hashtags.html40
-rw-r--r--templates/settings/_menu.html3
-rw-r--r--tests/activities/models/test_hashtag.py41
-rw-r--r--tests/activities/templatetags/test_activity_tags.py12
-rw-r--r--users/views/admin/__init__.py6
-rw-r--r--users/views/admin/hashtags.py126
-rw-r--r--users/views/admin/settings.py10
29 files changed, 896 insertions, 44 deletions
diff --git a/activities/admin.py b/activities/admin.py
index 8e29d22..c4875ca 100644
--- a/activities/admin.py
+++ b/activities/admin.py
@@ -1,7 +1,9 @@
+from asgiref.sync import async_to_sync
from django.contrib import admin
from activities.models import (
FanOut,
+ Hashtag,
Post,
PostAttachment,
PostInteraction,
@@ -9,6 +11,20 @@ from activities.models import (
)
+@admin.register(Hashtag)
+class HashtagAdmin(admin.ModelAdmin):
+ list_display = ["hashtag", "name_override", "state", "stats_updated", "created"]
+
+ readonly_fields = ["created", "updated", "stats_updated"]
+
+ actions = ["force_execution"]
+
+ @admin.action(description="Force Execution")
+ def force_execution(self, request, queryset):
+ for instance in queryset:
+ instance.transition_perform("outdated")
+
+
class PostAttachmentInline(admin.StackedInline):
model = PostAttachment
extra = 0
@@ -18,7 +34,7 @@ class PostAttachmentInline(admin.StackedInline):
class PostAdmin(admin.ModelAdmin):
list_display = ["id", "state", "author", "created"]
raw_id_fields = ["to", "mentions", "author"]
- actions = ["force_fetch"]
+ actions = ["force_fetch", "reparse_hashtags"]
search_fields = ["content"]
inlines = [PostAttachmentInline]
readonly_fields = ["created", "updated", "object_json"]
@@ -28,6 +44,13 @@ class PostAdmin(admin.ModelAdmin):
for instance in queryset:
instance.debug_fetch()
+ @admin.action(description="Reprocess content for hashtags")
+ def reparse_hashtags(self, request, queryset):
+ for instance in queryset:
+ instance.hashtags = Hashtag.hashtags_from_content(instance.content) or None
+ instance.save()
+ async_to_sync(instance.ensure_hashtags)()
+
@admin.display(description="ActivityPub JSON")
def object_json(self, instance):
return instance.to_ap()
diff --git a/activities/migrations/0002_hashtag.py b/activities/migrations/0002_hashtag.py
new file mode 100644
index 0000000..468bd95
--- /dev/null
+++ b/activities/migrations/0002_hashtag.py
@@ -0,0 +1,51 @@
+# Generated by Django 4.1.3 on 2022-11-27 20:16
+
+from django.db import migrations, models
+
+import activities.models.hashtag
+import stator.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("activities", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Hashtag",
+ fields=[
+ ("state_ready", models.BooleanField(default=True)),
+ ("state_changed", models.DateTimeField(auto_now_add=True)),
+ ("state_attempted", models.DateTimeField(blank=True, null=True)),
+ ("state_locked_until", models.DateTimeField(blank=True, null=True)),
+ (
+ "hashtag",
+ models.SlugField(max_length=100, primary_key=True, serialize=False),
+ ),
+ (
+ "name_override",
+ models.CharField(blank=True, max_length=100, null=True),
+ ),
+ ("public", models.BooleanField(null=True)),
+ (
+ "state",
+ stator.models.StateField(
+ choices=[("outdated", "outdated"), ("updated", "updated")],
+ default="outdated",
+ graph=activities.models.hashtag.HashtagStates,
+ max_length=100,
+ ),
+ ),
+ ("stats", models.JSONField(blank=True, null=True)),
+ ("stats_updated", models.DateTimeField(blank=True, null=True)),
+ ("aliases", models.JSONField(blank=True, null=True)),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("updated", models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/activities/models/__init__.py b/activities/models/__init__.py
index 1ae3f4c..aa34c0f 100644
--- a/activities/models/__init__.py
+++ b/activities/models/__init__.py
@@ -1,4 +1,5 @@
from .fan_out import FanOut, FanOutStates # noqa
+from .hashtag import Hashtag, HashtagStates # noqa
from .post import Post, PostStates # noqa
from .post_attachment import PostAttachment, PostAttachmentStates # noqa
from .post_interaction import PostInteraction, PostInteractionStates # noqa
diff --git a/activities/models/hashtag.py b/activities/models/hashtag.py
new file mode 100644
index 0000000..a5754f7
--- /dev/null
+++ b/activities/models/hashtag.py
@@ -0,0 +1,187 @@
+import re
+from datetime import date, timedelta
+from typing import Dict, List
+
+import urlman
+from asgiref.sync import sync_to_async
+from django.db import models
+from django.utils import timezone
+from django.utils.safestring import mark_safe
+
+from core.models import Config
+from stator.models import State, StateField, StateGraph, StatorModel
+
+
+class HashtagStates(StateGraph):
+ outdated = State(try_interval=300, force_initial=True)
+ updated = State(try_interval=3600, attempt_immediately=False)
+
+ outdated.transitions_to(updated)
+ updated.transitions_to(outdated)
+
+ @classmethod
+ async def handle_outdated(cls, instance: "Hashtag"):
+ """
+ Computes the stats and other things for a Hashtag
+ """
+ from .post import Post
+
+ posts_query = Post.objects.local_public().tagged_with(instance)
+ total = await posts_query.acount()
+
+ today = timezone.now().date()
+ # TODO: single query
+ total_today = await posts_query.filter(
+ created__gte=today,
+ created__lte=today + timedelta(days=1),
+ ).acount()
+ total_month = await posts_query.filter(
+ created__year=today.year,
+ created__month=today.month,
+ ).acount()
+ total_year = await posts_query.filter(
+ created__year=today.year,
+ ).acount()
+ if total:
+ if not instance.stats:
+ instance.stats = {}
+ instance.stats.update(
+ {
+ "total": total,
+ today.isoformat(): total_today,
+ today.strftime("%Y-%m"): total_month,
+ today.strftime("%Y"): total_year,
+ }
+ )
+ instance.stats_updated = timezone.now()
+ await sync_to_async(instance.save)()
+
+ return cls.updated
+
+ @classmethod
+ async def handle_updated(cls, instance: "Hashtag"):
+ if instance.state_age > Config.system.hashtag_stats_max_age:
+ return cls.outdated
+
+
+class HashtagQuerySet(models.QuerySet):
+ def public(self):
+ public_q = models.Q(public=True)
+ if Config.system.hashtag_unreviewed_are_public:
+ public_q |= models.Q(public__isnull=True)
+ return self.filter(public_q)
+
+ def hashtag_or_alias(self, hashtag: str):
+ return self.filter(
+ models.Q(hashtag=hashtag) | models.Q(aliases__contains=hashtag)
+ )
+
+
+class HashtagManager(models.Manager):
+ def get_queryset(self):
+ return HashtagQuerySet(self.model, using=self._db)
+
+ def public(self):
+ return self.get_queryset().public()
+
+ def hashtag_or_alias(self, hashtag: str):
+ return self.get_queryset().hashtag_or_alias(hashtag)
+
+
+class Hashtag(StatorModel):
+
+ # Normalized hashtag without the '#'
+ hashtag = models.SlugField(primary_key=True, max_length=100)
+
+ # Friendly display override
+ name_override = models.CharField(max_length=100, null=True, blank=True)
+
+ # Should this be shown in the public UI?
+ public = models.BooleanField(null=True)
+
+ # State of this Hashtag
+ state = StateField(HashtagStates)
+
+ # Metrics for this Hashtag
+ stats = models.JSONField(null=True, blank=True)
+ # Timestamp of last time the stats were updated
+ stats_updated = models.DateTimeField(null=True, blank=True)
+
+ # List of other hashtags that are considered similar
+ aliases = models.JSONField(null=True, blank=True)
+
+ created = models.DateTimeField(auto_now_add=True)
+ updated = models.DateTimeField(auto_now=True)
+
+ objects = HashtagManager()
+
+ class urls(urlman.Urls):
+ root = "/admin/hashtags/"
+ create = "/admin/hashtags/create/"
+ edit = "/admin/hashtags/{self.hashtag}/"
+ delete = "{edit}delete/"
+ timeline = "/tags/{self.hashtag}/"
+
+ hashtag_regex = re.compile(r"((?:\B#)([a-zA-Z0-9(_)]{1,}\b))")
+
+ def save(self, *args, **kwargs):
+ self.hashtag = self.hashtag.lstrip("#")
+ if self.name_override:
+ self.name_override = self.name_override.lstrip("#")
+ return super().save(*args, **kwargs)
+
+ @property
+ def display_name(self):
+ return self.name_override or self.hashtag
+
+ def __str__(self):
+ return self.display_name
+
+ def usage_months(self, num: int = 12) -> Dict[date, int]:
+ """
+ Return the most recent num months of stats
+ """
+ if not self.stats:
+ return {}
+ results = {}
+ for key, val in self.stats.items():
+ parts = key.split("-")
+ if len(parts) == 2:
+ year = int(parts[0])
+ month = int(parts[1])
+ results[date(year, month, 1)] = val
+ return dict(sorted(results.items(), reverse=True)[:num])
+
+ def usage_days(self, num: int = 7) -> Dict[date, int]:
+ """
+ Return the most recent num days of stats
+ """
+ if not self.stats:
+ return {}
+ results = {}
+ for key, val in self.stats.items():
+ parts = key.split("-")
+ if len(parts) == 3:
+ year = int(parts[0])
+ month = int(parts[1])
+ day = int(parts[2])
+ results[date(year, month, day)] = val
+ return dict(sorted(results.items(), reverse=True)[:num])
+
+ @classmethod
+ def hashtags_from_content(cls, content) -> List[str]:
+ """
+ Return a parsed and sanitized of hashtags found in content without
+ leading '#'.
+ """
+ hashtag_hits = cls.hashtag_regex.findall(content)
+ hashtags = sorted({tag[1].lower() for tag in hashtag_hits})
+ return list(hashtags)
+
+ @classmethod
+ def linkify_hashtags(cls, content) -> str:
+ def replacer(match):
+ hashtag = match.group()
+ return f'<a class="hashtag" href="/tags/{hashtag.lstrip("#").lower()}/">{hashtag}</a>'
+
+ return mark_safe(Hashtag.hashtag_regex.sub(replacer, content))
diff --git a/activities/models/post.py b/activities/models/post.py
index f504fcb..b61abd4 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -10,6 +10,7 @@ from django.utils import timezone
from django.utils.safestring import mark_safe
from activities.models.fan_out import FanOut
+from activities.models.hashtag import Hashtag
from core.html import sanitize_post, strip_html
from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date
from stator.models import State, StateField, StateGraph, StatorModel
@@ -35,18 +36,23 @@ class PostStates(StateGraph):
edited_fanned_out.transitions_to(deleted)
@classmethod
- async def handle_new(cls, instance: "Post"):
- """
- Creates all needed fan-out objects for a new Post.
- """
- post = await instance.afetch_full()
+ async def targets_fan_out(cls, post: "Post", type_: str) -> None:
# Fan out to each target
for follow in await post.aget_targets():
await FanOut.objects.acreate(
identity=follow,
- type=FanOut.Types.post,
+ type=type_,
subject_post=post,
)
+
+ @classmethod
+ async def handle_new(cls, instance: "Post"):
+ """
+ Creates all needed fan-out objects for a new Post.
+ """
+ post = await instance.afetch_full()
+ await cls.targets_fan_out(post, FanOut.Types.post)
+ await post.ensure_hashtags()
return cls.fanned_out
@classmethod
@@ -55,13 +61,7 @@ class PostStates(StateGraph):
Creates all needed fan-out objects needed to delete a Post.
"""
post = await instance.afetch_full()
- # Fan out to each target
- for follow in await post.aget_targets():
- await FanOut.objects.acreate(
- identity=follow,
- type=FanOut.Types.post_deleted,
- subject_post=post,
- )
+ await cls.targets_fan_out(post, FanOut.Types.post_deleted)
return cls.deleted_fanned_out
@classmethod
@@ -70,16 +70,46 @@ class PostStates(StateGraph):
Creates all needed fan-out objects for an edited Post.
"""
post = await instance.afetch_full()
- # Fan out to each target
- for follow in await post.aget_targets():
- await FanOut.objects.acreate(
- identity=follow,
- type=FanOut.Types.post_edited,
- subject_post=post,
- )
+ await cls.targets_fan_out(post, FanOut.Types.post_edited)
+ await post.ensure_hashtags()
return cls.edited_fanned_out
+class PostQuerySet(models.QuerySet):
+ def local_public(self, include_replies: bool = False):
+ query = self.filter(
+ visibility__in=[
+ Post.Visibilities.public,
+ Post.Visibilities.local_only,
+ ],
+ author__local=True,
+ )
+ if not include_replies:
+ return query.filter(in_reply_to__isnull=True)
+ return query
+
+ def tagged_with(self, hashtag: str | Hashtag):
+ if isinstance(hashtag, str):
+ tag_q = models.Q(hashtags__contains=hashtag)
+ else:
+ tag_q = models.Q(hashtags__contains=hashtag.hashtag)
+ if hashtag.aliases:
+ for alias in hashtag.aliases:
+ tag_q |= models.Q(hashtags__contains=alias)
+ return self.filter(tag_q)
+
+
+class PostManager(models.Manager):
+ def get_queryset(self):
+ return PostQuerySet(self.model, using=self._db)
+
+ def local_public(self, include_replies: bool = False):
+ return self.get_queryset().local_public(include_replies=include_replies)
+
+ def tagged_with(self, hashtag: str | Hashtag):
+ return self.get_queryset().tagged_with(hashtag=hashtag)
+
+
class Post(StatorModel):
"""
A post (status, toot) that is either local or remote.
@@ -155,6 +185,8 @@ class Post(StatorModel):
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
+ objects = PostManager()
+
class urls(urlman.Urls):
view = "{self.author.urls.view}posts/{self.id}/"
object_uri = "{self.author.actor_uri}posts/{self.id}/"
@@ -236,7 +268,9 @@ class Post(StatorModel):
"""
Returns the content formatted for local display
"""
- return self.linkify_mentions(sanitize_post(self.content), local=True)
+ return Hashtag.linkify_hashtags(
+ self.linkify_mentions(sanitize_post(self.content), local=True)
+ )
def safe_content_remote(self):
"""
@@ -252,7 +286,7 @@ class Post(StatorModel):
### Async helpers ###
- async def afetch_full(self):
+ async def afetch_full(self) -> "Post":
"""
Returns a version of the object with all relations pre-loaded
"""
@@ -281,6 +315,8 @@ class Post(StatorModel):
# Maintain local-only for replies
if reply_to.visibility == reply_to.Visibilities.local_only:
visibility = reply_to.Visibilities.local_only
+ # Find hashtags in this post
+ hashtags = Hashtag.hashtags_from_content(content) or None
# Strip all HTML and apply linebreaks filter
content = linebreaks_filter(strip_html(content))
# Make the Post object
@@ -291,6 +327,7 @@ class Post(StatorModel):
sensitive=bool(summary),
local=True,
visibility=visibility,
+ hashtags=hashtags,
in_reply_to=reply_to.object_uri if reply_to else None,
)
post.object_uri = post.urls.object_uri
@@ -312,6 +349,7 @@ class Post(StatorModel):
self.sensitive = bool(summary)
self.visibility = visibility
self.edited = timezone.now()
+ self.hashtags = Hashtag.hashtags_from_content(content) or None
self.mentions.set(self.mentions_from_content(content, self.author))
self.save()
@@ -334,6 +372,18 @@ class Post(StatorModel):
mentions.add(identity)
return mentions
+ async def ensure_hashtags(self) -> None:
+ """
+ Ensure any of the already parsed hashtags from this Post
+ have a corresponding Hashtag record.
+ """
+ # Ensure hashtags
+ if self.hashtags:
+ for hashtag in self.hashtags:
+ await Hashtag.objects.aget_or_create(
+ hashtag=hashtag,
+ )
+
### ActivityPub (outbound) ###
def to_ap(self) -> Dict:
diff --git a/activities/templatetags/activity_tags.py b/activities/templatetags/activity_tags.py
index 571e2d6..fb822f6 100644
--- a/activities/templatetags/activity_tags.py
+++ b/activities/templatetags/activity_tags.py
@@ -3,6 +3,8 @@ import datetime
from django import template
from django.utils import timezone
+from activities.models import Hashtag
+
register = template.Library()
@@ -31,3 +33,14 @@ def timedeltashort(value: datetime.datetime):
years = max(days // 365.25, 1)
text = f"{years:0n}y"
return text
+
+
+@register.filter
+def linkify_hashtags(value: str):
+ """
+ Convert hashtags in content in to /tags/<hashtag>/ links.
+ """
+ if not value:
+ return ""
+
+ return Hashtag.linkify_hashtags(value)
diff --git a/activities/views/admin/__init__.py b/activities/views/admin/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/activities/views/admin/__init__.py
diff --git a/activities/views/explore.py b/activities/views/explore.py
new file mode 100644
index 0000000..ddb1e6c
--- /dev/null
+++ b/activities/views/explore.py
@@ -0,0 +1,26 @@
+from django.views.generic import ListView
+
+from activities.models import Hashtag
+
+
+class ExploreTag(ListView):
+
+ template_name = "activities/explore_tag.html"
+ extra_context = {
+ "current_page": "explore",
+ "allows_refresh": True,
+ }
+ paginate_by = 20
+
+ def get_queryset(self):
+ return (
+ Hashtag.objects.public()
+ .filter(
+ stats__total__gt=0,
+ )
+ .order_by("-stats__total")
+ )[:20]
+
+
+class Explore(ExploreTag):
+ pass
diff --git a/activities/views/search.py b/activities/views/search.py
index b175052..4719f64 100644
--- a/activities/views/search.py
+++ b/activities/views/search.py
@@ -1,6 +1,9 @@
+from typing import Set
+
from django import forms
from django.views.generic import FormView
+from activities.models import Hashtag
from users.models import Domain, Identity
@@ -9,13 +12,13 @@ class Search(FormView):
template_name = "activities/search.html"
class form_class(forms.Form):
- query = forms.CharField(help_text="Search for a user by @username@domain")
-
- def form_valid(self, form):
- query = form.cleaned_data["query"].lstrip("@").lower()
- results = {"identities": set()}
- # Search identities
+ query = forms.CharField(
+ help_text="Search for a user by @username@domain or hashtag by #tagname"
+ )
+ def search_identities(self, query: str):
+ query = query.lstrip("@")
+ results: Set[Identity] = set()
if "@" in query:
username, domain = query.split("@", 1)
@@ -35,13 +38,35 @@ class Search(FormView):
)
identity = None
if identity:
- results["identities"].add(identity)
+ results.add(identity)
else:
for identity in Identity.objects.filter(username=query)[:20]:
- results["identities"].add(identity)
+ results.add(identity)
for identity in Identity.objects.filter(username__startswith=query)[:20]:
- results["identities"].add(identity)
+ results.add(identity)
+ return results
+
+ def search_hashtags(self, query: str):
+ results: Set[Hashtag] = set()
+
+ if "@" in query:
+ return results
+
+ query = query.lstrip("#")
+ for hashtag in Hashtag.objects.public().hashtag_or_alias(query)[:10]:
+ results.add(hashtag)
+ for hashtag in Hashtag.objects.public().filter(hashtag__startswith=query)[:10]:
+ results.add(hashtag)
+ return results
+
+ def form_valid(self, form):
+ query = form.cleaned_data["query"].lower()
+ results = {
+ "identities": self.search_identities(query),
+ "hashtags": self.search_hashtags(query),
+ }
+
# Render results
context = self.get_context_data(form=form)
context["results"] = results
diff --git a/activities/views/timelines.py b/activities/views/timelines.py
index 4f2a515..ffe329c 100644
--- a/activities/views/timelines.py
+++ b/activities/views/timelines.py
@@ -1,10 +1,10 @@
from django import forms
-from django.shortcuts import redirect
+from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import linebreaks_filter
from django.utils.decorators import method_decorator
from django.views.generic import FormView, ListView
-from activities.models import Post, PostInteraction, TimelineEvent
+from activities.models import Hashtag, Post, PostInteraction, TimelineEvent
from core.models import Config
from users.decorators import identity_required
@@ -61,6 +61,41 @@ class Home(FormView):
return redirect(".")
+class Tag(ListView):
+
+ template_name = "activities/tag.html"
+ extra_context = {
+ "current_page": "tag",
+ "allows_refresh": True,
+ }
+ paginate_by = 50
+
+ def get(self, request, hashtag, *args, **kwargs):
+ tag = hashtag.lower().lstrip("#")
+ if hashtag != tag:
+ # SEO sanitize
+ return redirect(f"/tags/{tag}/", permanent=True)
+ self.hashtag = get_object_or_404(Hashtag.objects.public(), hashtag=tag)
+ return super().get(request, *args, **kwargs)
+
+ def get_queryset(self):
+ return (
+ Post.objects.local_public()
+ .tagged_with(self.hashtag)
+ .select_related("author")
+ .prefetch_related("attachments")
+ .order_by("-created")[:50]
+ )
+
+ def get_context_data(self):
+ context = super().get_context_data()
+ context["hashtag"] = self.hashtag
+ context["interactions"] = PostInteraction.get_post_interactions(
+ context["page_obj"], self.request.identity
+ )
+ return context
+
+
class Local(ListView):
template_name = "activities/local.html"
@@ -72,11 +107,7 @@ class Local(ListView):
def get_queryset(self):
return (
- Post.objects.filter(
- visibility=Post.Visibilities.public,
- author__local=True,
- in_reply_to__isnull=True,
- )
+ Post.objects.local_public()
.select_related("author")
.prefetch_related("attachments")
.order_by("-created")[:50]
diff --git a/core/models/config.py b/core/models/config.py
index dab0059..dd7da9f 100644
--- a/core/models/config.py
+++ b/core/models/config.py
@@ -215,6 +215,9 @@ class Config(models.Model):
identity_max_age: int = 24 * 60 * 60
inbox_message_purge_after: int = 24 * 60 * 60
+ hashtag_unreviewed_are_public: bool = True
+ hashtag_stats_max_age: int = 60 * 60
+
restricted_usernames: str = "admin\nadmins\nadministrator\nadministrators\nsystem\nroot\nannounce\nannouncement\nannouncements"
class UserOptions(pydantic.BaseModel):
diff --git a/docs/features.rst b/docs/features.rst
index 35ec53c..27ea102 100644
--- a/docs/features.rst
+++ b/docs/features.rst
@@ -22,6 +22,7 @@ Currently, it supports:
* Server defederation (blocking)
* Signup flow
* Password reset flow
+* Hashtag trending system with moderation
Features planned for releases up to 1.0:
@@ -40,7 +41,6 @@ Features that may make it into 1.0, or might be further out:
* Creating polls on posts, and handling received polls
* Filter system for Home timeline
-* Hashtag trending system with moderation
* Mastodon-compatible account migration target/source
* Relay support
diff --git a/static/css/style.css b/static/css/style.css
index 1dbf30e..ebbedb0 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -448,6 +448,23 @@ form .field .label-input {
flex-grow: 1;
}
+form .field.stats {
+ width: 100%;
+}
+form .field.stats table {
+ width: 50%;
+}
+
+form .field.stats table tr th {
+ color: var(--color-text-main);
+}
+
+
+form .field.stats table tbody td {
+ color: var(--color-text-dull);
+ text-align: center;
+}
+
.right-column form .field {
margin: 0;
background: none;
@@ -704,6 +721,17 @@ table.metadata td.name {
font-weight: bold;
}
+/* Named Timelines */
+
+.left-column .timeline-name {
+ margin: 0 0 10px 0;
+ color: var(--color-text-main);
+ font-size: 130%;
+}
+
+.left-column .timeline-name i {
+ margin-right: 10px
+}
/* Posts */
@@ -879,6 +907,14 @@ table.metadata td.name {
width: 16px;
}
+.post a.hashtag, .post.mini a.hashtag {
+ text-decoration: none;
+}
+
+.post a.hashtag:hover, .post.mini a.hashtag:hover {
+ text-decoration: underline;
+}
+
.boost-banner,
.mention-banner,
.follow-banner,
diff --git a/takahe/urls.py b/takahe/urls.py
index 6f5ac79..dc3946f 100644
--- a/takahe/urls.py
+++ b/takahe/urls.py
@@ -3,7 +3,7 @@ from django.contrib import admin as djadmin
from django.urls import path, re_path
from django.views.static import serve
-from activities.views import posts, search, timelines
+from activities.views import explore, posts, search, timelines
from core import views as core
from stator import views as stator
from users.views import activitypub, admin, auth, follows, identity, settings
@@ -16,6 +16,9 @@ urlpatterns = [
path("local/", timelines.Local.as_view(), name="local"),
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("explore/", explore.Explore.as_view(), name="explore"),
+ path("explore/tags/", explore.ExploreTag.as_view(), name="explore-tag"),
path(
"settings/",
settings.SettingsRoot.as_view(),
@@ -94,6 +97,24 @@ urlpatterns = [
admin.Invites.as_view(),
name="admin_invites",
),
+ path(
+ "admin/hashtags/",
+ admin.Hashtags.as_view(),
+ name="admin_hashtags",
+ ),
+ path(
+ "admin/hashtags/create/",
+ admin.HashtagCreate.as_view(),
+ name="admin_hashtags_create",
+ ),
+ path(
+ "admin/hashtags/<hashtag>/",
+ admin.HashtagEdit.as_view(),
+ ),
+ path(
+ "admin/hashtags/<hashtag>/delete/",
+ admin.HashtagDelete.as_view(),
+ ),
# Identity views
path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/inbox/", activitypub.Inbox.as_view()),
diff --git a/templates/activities/_hashtag.html b/templates/activities/_hashtag.html
new file mode 100644
index 0000000..19233e5
--- /dev/null
+++ b/templates/activities/_hashtag.html
@@ -0,0 +1,11 @@
+<a class="option" href="{{ hashtag.urls.timeline }}">
+ <i class="fa-solid fa-hashtag"></i>
+ <span class="handle">
+ {{ hashtag.display_name }}
+ </span>
+ {% if not hide_stats %}
+ <span>
+ Post count: {{ hashtag.stats.total }}
+ </span>
+ {% endif %}
+</a>
diff --git a/templates/activities/_menu.html b/templates/activities/_menu.html
index 1ebe940..58295a9 100644
--- a/templates/activities/_menu.html
+++ b/templates/activities/_menu.html
@@ -6,6 +6,9 @@
<a href="{% url "notifications" %}" {% if current_page == "notifications" %}class="selected"{% endif %} title="Notifications">
<i class="fa-solid fa-at"></i> Notifications
</a>
+ <a href="{% url "explore" %}" {% if current_page == "explore" %}class="selected"{% endif %} title="Explore">
+ <i class="fa-solid fa-hashtag"></i> Explore
+ </a>
<a href="{% url "local" %}" {% if current_page == "local" %}class="selected"{% endif %} title="Local">
<i class="fa-solid fa-city"></i> Local
</a>
@@ -19,13 +22,21 @@
<a href="{% url "search" %}" {% if top_section == "search" %}class="selected"{% endif %} title="Search">
<i class="fa-solid fa-search"></i> Search
</a>
+ {% if current_page == "tag" %}
+ <a href="{% url "tag" hashtag.hashtag %}" class="selected" title="Tag {{ hashtag.display_name }}">
+ <i class="fa-solid fa-hashtag"></i> {{ hashtag.display_name }}
+ </a>
+ {% endif %}
<a href="{% url "settings" %}" {% if top_section == "settings" %}class="selected"{% endif %} title="Settings">
<i class="fa-solid fa-gear"></i> Settings
</a>
- {% else %}
+ {% else %}
<a href="{% url "local" %}" {% if current_page == "local" %}class="selected"{% endif %} title="Local Posts">
<i class="fa-solid fa-city"></i> Local Posts
</a>
+ <a href="{% url "explore" %}" {% if current_page == "explore" %}class="selected"{% endif %} title="Explore">
+ <i class="fa-solid fa-hashtag"></i> Explore
+ </a>
<h3></h3>
{% if config.signup_allowed %}
<a href="{% url "signup" %}" {% if current_page == "signup" %}class="selected"{% endif %} title="Create Account">
diff --git a/templates/activities/explore_tag.html b/templates/activities/explore_tag.html
new file mode 100644
index 0000000..b2fd79d
--- /dev/null
+++ b/templates/activities/explore_tag.html
@@ -0,0 +1,16 @@
+{% extends "base.html" %}
+
+{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %}
+
+{% block content %}
+<div class="timeline-name">Explore Trending Tags</div>
+
+<section class="icon-menu">
+ {% for hashtag in page_obj %}
+ {% include "activities/_hashtag.html" %}
+ {% empty %}
+ No tags are trending yet.
+ {% endfor %}
+
+</section>
+{% endblock %}
diff --git a/templates/activities/search.html b/templates/activities/search.html
index 3cff2a2..5137740 100644
--- a/templates/activities/search.html
+++ b/templates/activities/search.html
@@ -18,4 +18,12 @@
{% include "activities/_identity.html" %}
{% endfor %}
{% endif %}
+ {% if results.hashtags %}
+ <h2>Hashtags</h2>
+ <section class="icon-menu">
+ {% for hashtag in results.hashtags %}
+ {% include "activities/_hashtag.html" with hide_stats=True %}
+ {% endfor %}
+ </section>
+ {% endif %}
{% endblock %}
diff --git a/templates/activities/tag.html b/templates/activities/tag.html
new file mode 100644
index 0000000..a319b6a
--- /dev/null
+++ b/templates/activities/tag.html
@@ -0,0 +1,16 @@
+{% extends "base.html" %}
+
+{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %}
+
+{% block content %}
+ <div class="timeline-name"><i class="fa-solid fa-hashtag"></i>{{ hashtag.display_name }}</div>
+ {% for post in page_obj %}
+ {% include "activities/_post.html" %}
+ {% empty %}
+ No posts yet.
+ {% endfor %}
+
+ {% if page_obj.has_next %}
+ <div class="load-more"><a class="button" href=".?page={{ page_obj.next_page_number }}">Next Page</a></div>
+ {% endif %}
+{% endblock %}
diff --git a/templates/admin/hashtag_create.html b/templates/admin/hashtag_create.html
new file mode 100644
index 0000000..2d31cf7
--- /dev/null
+++ b/templates/admin/hashtag_create.html
@@ -0,0 +1,26 @@
+{% extends "settings/base.html" %}
+
+{% block title %}Add hashtag - Admin{% endblock %}
+
+{% block content %}
+ <form action="." method="POST">
+ <h1>Add A hashtag</h1>
+ <p>
+ Use this form to add a hashtag.
+ </p>
+ {% csrf_token %}
+ <fieldset>
+ <legend>hashtag Details</legend>
+ {% include "forms/_field.html" with field=form.hashtag %}
+ {% include "forms/_field.html" with field=form.name_override %}
+ </fieldset>
+ <fieldset>
+ <legend>Access Control</legend>
+ {% include "forms/_field.html" with field=form.public %}
+ </fieldset>
+ <div class="buttons">
+ <a href="{% url "admin_hashtags" %}" class="button secondary left">Back</a>
+ <button>Create</button>
+ </div>
+ </form>
+{% endblock %}
diff --git a/templates/admin/hashtag_delete.html b/templates/admin/hashtag_delete.html
new file mode 100644
index 0000000..9aca4e7
--- /dev/null
+++ b/templates/admin/hashtag_delete.html
@@ -0,0 +1,17 @@
+{% extends "settings/base.html" %}
+
+{% block title %}Delete <i class="fa-solid fa-hashtag"></i>{{ hashtag.hashtag }} - Admin{% endblock %}
+
+{% block content %}
+ <form action="." method="POST">
+ {% csrf_token %}
+
+ <h1>Deleting <i class="fa-solid fa-hashtag"></i>{{ hashtag.hashtag }}</h1>
+
+ <p>Please confirm deletion of this hashtag.</p>
+ <div class="buttons">
+ <a class="button" href="{{ hashtag.urls.edit }}">Cancel</a>
+ <button class="delete">Confirm Deletion</button>
+ </div>
+
+{% endblock %}
diff --git a/templates/admin/hashtag_edit.html b/templates/admin/hashtag_edit.html
new file mode 100644
index 0000000..b023dfa
--- /dev/null
+++ b/templates/admin/hashtag_edit.html
@@ -0,0 +1,46 @@
+{% extends "settings/base.html" %}
+
+{% block subtitle %}{{ hashtag.hashtag }}{% endblock %}
+
+{% block content %}
+ <form action="." method="POST">
+ {% csrf_token %}
+ <fieldset>
+ <legend>hashtag Details</legend>
+ {% include "forms/_field.html" with field=form.hashtag %}
+ {% include "forms/_field.html" with field=form.name_override %}
+ </fieldset>
+ <fieldset>
+ <legend>Access Control</legend>
+ {% include "forms/_field.html" with field=form.public %}
+ </fieldset>
+ <fieldset>
+ <legend>Stats</legend>
+ <div class="field stats">
+ {% for stat_month, stat_value in hashtag.usage_months.items|slice:":5" %}
+ {% if forloop.first %}
+ <table>
+ <tr>
+ <th>Month</th>
+ <th>Usage</th>
+ </tr>
+ {% endif %}
+ <tr>
+ <th>{{ stat_month|date:"M Y" }}</th>
+ <td>{{ stat_value }}</td>
+ </tr>
+ {% if forloop.last %}
+ </table>
+ {% endif %}
+ {% empty %}
+ <p class="help"></p>Hashtag is either not used or stats have not been computed yet.</p>
+ {% endfor %}
+ </div>
+ </fieldset>
+ <div class="buttons">
+ <a href="{{ hashtag.urls.root }}" class="button secondary left">Back</a>
+ <a href="{{ hashtag.urls.delete }}" class="button delete">Delete</a>
+ <button>Save</button>
+ </div>
+ </form>
+{% endblock %}
diff --git a/templates/admin/hashtags.html b/templates/admin/hashtags.html
new file mode 100644
index 0000000..4273ac2
--- /dev/null
+++ b/templates/admin/hashtags.html
@@ -0,0 +1,40 @@
+{% extends "settings/base.html" %}
+
+{% block subtitle %}Hashtags{% endblock %}
+
+{% block content %}
+ <section class="icon-menu">
+ {% for hashtag in hashtags %}
+ <a class="option" href="{{ hashtag.urls.edit }}">
+ <i class="fa-solid fa-hashtag"></i>
+ <span class="handle">
+ {{ hashtag.display_name }}
+ <small>
+ {% if hashtag.public %}Public{% elif hashtag.public is None %}Unreviewed{% else %}Private{% endif %}
+ </small>
+ </span>
+ {% if hashtag.stats %}
+ <span class="handle">
+ <small>Total:</small>
+ {{ hashtag.stats.total }}
+ </span>
+ {% endif %}
+ {% if hashtag.aliases %}
+
+ <span class="handle">
+ <small>Aliases:</small>
+ {% for alias in hashtag.aliases %}
+ {{ alias }}{% if not forloop.last %}, {% endif %}
+ {% endfor %}
+ </span>
+ {% endif %}
+
+ </a>
+ {% empty %}
+ <p class="option empty">You have no hashtags set up.</p>
+ {% endfor %}
+ <a href="{% url "admin_hashtags_create" %}" class="option new">
+ <i class="fa-solid fa-plus"></i> Add a hashtag
+ </a>
+ </section>
+{% endblock %}
diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html
index 531febb..8aede68 100644
--- a/templates/settings/_menu.html
+++ b/templates/settings/_menu.html
@@ -36,6 +36,9 @@
<a href="{% url "admin_invites" %}" {% if section == "invites" %}class="selected"{% endif %} title="Invites">
<i class="fa-solid fa-envelope"></i> Invites
</a>
+ <a href="{% url "admin_hashtags" %}" {% if section == "hashtags" %}class="selected"{% endif %} title="Hashtags">
+ <i class="fa-solid fa-hashtag"></i> Hashtags
+ </a>
<a href="/djadmin" title="">
<i class="fa-solid fa-gear"></i> Django Admin
</a>
diff --git a/tests/activities/models/test_hashtag.py b/tests/activities/models/test_hashtag.py
new file mode 100644
index 0000000..32742d6
--- /dev/null
+++ b/tests/activities/models/test_hashtag.py
@@ -0,0 +1,41 @@
+from activities.models import Hashtag
+
+
+def test_hashtag_from_content():
+ assert Hashtag.hashtags_from_content("#hashtag") == ["hashtag"]
+ assert Hashtag.hashtags_from_content("a#hashtag") == []
+ assert Hashtag.hashtags_from_content("Text #with #hashtag in it") == [
+ "hashtag",
+ "with",
+ ]
+ assert Hashtag.hashtags_from_content("#hashtag.") == ["hashtag"]
+ assert Hashtag.hashtags_from_content("More text\n#one # two ##three #hashtag;") == [
+ "hashtag",
+ "one",
+ "three",
+ ]
+
+
+def test_linkify_hashtag():
+ linkify = Hashtag.linkify_hashtags
+
+ assert linkify("# hashtag") == "# hashtag"
+ assert (
+ linkify('<a href="/url/with#anchor">Text</a>')
+ == '<a href="/url/with#anchor">Text</a>'
+ )
+ assert (
+ linkify("#HashTag") == '<a class="hashtag" href="/tags/hashtag/">#HashTag</a>'
+ )
+ assert (
+ linkify(
+ """A longer text #bigContent
+with #tags, linebreaks, and
+maybe a few <a href="https://awesome.sauce/about#spicy">links</a>
+#allTheTags #AllTheTags #ALLTHETAGS"""
+ )
+ == """A longer text <a class="hashtag" href="/tags/bigcontent/">#bigContent</a>
+with <a class="hashtag" href="/tags/tags/">#tags</a>, linebreaks, and
+maybe a few <a href="https://awesome.sauce/about#spicy">links</a>
+<a class="hashtag" href="/tags/allthetags/">#allTheTags</a> <a class="hashtag" href="/tags/allthetags/">#AllTheTags</a> <a class="hashtag" href="/tags/allthetags/">#ALLTHETAGS</a>"""
+ )
diff --git a/tests/activities/templatetags/test_activity_tags.py b/tests/activities/templatetags/test_activity_tags.py
index 987c008..85d8cdf 100644
--- a/tests/activities/templatetags/test_activity_tags.py
+++ b/tests/activities/templatetags/test_activity_tags.py
@@ -2,7 +2,7 @@ from datetime import timedelta
from django.utils import timezone
-from activities.templatetags.activity_tags import timedeltashort
+from activities.templatetags.activity_tags import linkify_hashtags, timedeltashort
def test_timedeltashort_regress():
@@ -19,3 +19,13 @@ def test_timedeltashort_regress():
assert timedeltashort(value - timedelta(days=364)) == "364d"
assert timedeltashort(value - timedelta(days=365)) == "1y"
assert timedeltashort(value - timedelta(days=366)) == "1y"
+
+
+def test_linkify_hashtags_regres():
+ assert linkify_hashtags(None) == ""
+ assert linkify_hashtags("") == ""
+
+ assert (
+ linkify_hashtags("#Takahe")
+ == '<a class="hashtag" href="/tags/takahe/">#Takahe</a>'
+ )
diff --git a/users/views/admin/__init__.py b/users/views/admin/__init__.py
index d1a4db1..101ca30 100644
--- a/users/views/admin/__init__.py
+++ b/users/views/admin/__init__.py
@@ -11,6 +11,12 @@ from users.views.admin.domains import ( # noqa
Domains,
)
from users.views.admin.federation import FederationEdit, FederationRoot # noqa
+from users.views.admin.hashtags import ( # noqa
+ HashtagCreate,
+ HashtagDelete,
+ HashtagEdit,
+ Hashtags,
+)
from users.views.admin.settings import BasicSettings # noqa
diff --git a/users/views/admin/hashtags.py b/users/views/admin/hashtags.py
new file mode 100644
index 0000000..90f7a84
--- /dev/null
+++ b/users/views/admin/hashtags.py
@@ -0,0 +1,126 @@
+from django import forms
+from django.shortcuts import get_object_or_404, redirect
+from django.utils.decorators import method_decorator
+from django.views.generic import FormView, TemplateView
+
+from activities.models import Hashtag, HashtagStates
+from users.decorators import admin_required
+
+
+@method_decorator(admin_required, name="dispatch")
+class Hashtags(TemplateView):
+
+ template_name = "admin/hashtags.html"
+
+ def get_context_data(self):
+ return {
+ "hashtags": Hashtag.objects.filter().order_by("hashtag"),
+ "section": "hashtag",
+ }
+
+
+@method_decorator(admin_required, name="dispatch")
+class HashtagCreate(FormView):
+
+ template_name = "admin/hashtag_create.html"
+ extra_context = {"section": "hashtags"}
+
+ class form_class(forms.Form):
+ hashtag = forms.SlugField(
+ help_text="The hashtag without the '#'",
+ )
+ name_override = forms.CharField(
+ help_text="Optional - a more human readable hashtag.",
+ required=False,
+ )
+ public = forms.NullBooleanField(
+ help_text="Should this hashtag appear in the UI",
+ widget=forms.Select(
+ choices=[(None, "Unreviewed"), (True, "Public"), (False, "Private")]
+ ),
+ required=False,
+ )
+
+ def clean_hashtag(self):
+ hashtag = self.cleaned_data["hashtag"].lstrip("#").lower()
+ if not Hashtag.hashtag_regex.match("#" + hashtag):
+ raise forms.ValidationError("This does not look like a hashtag name")
+ if Hashtag.objects.filter(hashtag=hashtag):
+ raise forms.ValidationError("This hashtag name is already in use")
+ return hashtag
+
+ def clean_name_override(self):
+ name_override = self.cleaned_data["name_override"]
+ if not name_override:
+ return None
+ if self.cleaned_data["hashtag"] != name_override.lower():
+ raise forms.ValidationError(
+ "Name override doesn't match hashtag. Only case changes are allowed."
+ )
+ return self.cleaned_data["name_override"]
+
+ def form_valid(self, form):
+ Hashtag.objects.create(
+ hashtag=form.cleaned_data["hashtag"],
+ name_override=form.cleaned_data["name_override"] or None,
+ public=form.cleaned_data["public"],
+ )
+ return redirect(Hashtag.urls.root)
+
+
+@method_decorator(admin_required, name="dispatch")
+class HashtagEdit(FormView):
+
+ template_name = "admin/hashtag_edit.html"
+ extra_context = {"section": "hashtags"}
+
+ class form_class(HashtagCreate.form_class):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["hashtag"].disabled = True
+
+ def clean_hashtag(self):
+ return self.cleaned_data["hashtag"]
+
+ def dispatch(self, request, hashtag):
+ self.hashtag = get_object_or_404(Hashtag.objects, hashtag=hashtag)
+ return super().dispatch(request)
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(*args, **kwargs)
+ context["hashtag"] = self.hashtag
+ return context
+
+ def form_valid(self, form):
+ self.hashtag.public = form.cleaned_data["public"]
+ self.hashtag.name_override = form.cleaned_data["name_override"]
+ self.hashtag.save()
+ Hashtag.transition_perform(self.hashtag, HashtagStates.outdated)
+ return redirect(Hashtag.urls.root)
+
+ def get_initial(self):
+ return {
+ "hashtag": self.hashtag.hashtag,
+ "name_override": self.hashtag.name_override,
+ "public": self.hashtag.public,
+ }
+
+
+@method_decorator(admin_required, name="dispatch")
+class HashtagDelete(TemplateView):
+
+ template_name = "admin/hashtag_delete.html"
+
+ def dispatch(self, request, hashtag):
+ self.hashtag = get_object_or_404(Hashtag.objects, hashtag=hashtag)
+ return super().dispatch(request)
+
+ def get_context_data(self):
+ return {
+ "hashtag": self.hashtag,
+ "section": "hashtags",
+ }
+
+ def post(self, request):
+ self.hashtag.delete()
+ return redirect("admin_hashtags")
diff --git a/users/views/admin/settings.py b/users/views/admin/settings.py
index 44a338f..a9ec78b 100644
--- a/users/views/admin/settings.py
+++ b/users/views/admin/settings.py
@@ -80,6 +80,10 @@ class BasicSettings(AdminSettingsPage):
"help_text": "Usernames that only admins can register for identities. One per line.",
"display": "textarea",
},
+ "hashtag_unreviewed_are_public": {
+ "title": "Unreviewed Hashtags Are Public",
+ "help_text": "Public Hashtags may appear in Trending and have a Tags timeline",
+ },
}
layout = {
@@ -91,7 +95,11 @@ class BasicSettings(AdminSettingsPage):
"highlight_color",
],
"Signups": ["signup_allowed", "signup_invite_only", "signup_text"],
- "Posts": ["post_length", "content_warning_text"],
+ "Posts": [
+ "post_length",
+ "content_warning_text",
+ "hashtag_unreviewed_are_public",
+ ],
"Identities": [
"identity_max_per_user",
"identity_min_length",