diff --git a/README.md b/README.md index 7788a0d..4f2f5cc 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,12 @@ the less sure I am about it. - [x] Receive follow undos - [ ] Do outgoing mentions properly - [x] Home timeline (posts and boosts from follows) -- [ ] Notifications page (followed, boosted, liked) +- [x] Notifications page (followed, boosted, liked) - [x] Local timeline - [x] Federated timeline - [x] Profile pages -- [ ] Settable icon and background image for profiles +- [x] Settable icon and background image for profiles +- [x] User search - [ ] Following page - [ ] Followers page - [x] Multiple domain support @@ -88,6 +89,7 @@ the less sure I am about it. - [ ] Emoji fetching and display - [ ] Emoji creation - [ ] Image descriptions +- [ ] Hashtag search - [ ] Flag for moderation - [ ] Moderation queue - [ ] User management page diff --git a/activities/admin.py b/activities/admin.py index 371aa7b..e24304d 100644 --- a/activities/admin.py +++ b/activities/admin.py @@ -36,6 +36,7 @@ class PostAdmin(admin.ModelAdmin): @admin.register(TimelineEvent) class TimelineEventAdmin(admin.ModelAdmin): list_display = ["id", "identity", "created", "type"] + readonly_fields = ["created"] raw_id_fields = [ "identity", "subject_post", diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py index 771be19..6ebbe0a 100644 --- a/activities/models/fan_out.py +++ b/activities/models/fan_out.py @@ -37,7 +37,6 @@ class FanOutStates(StateGraph): private_key=post.author.private_key, key_id=post.author.public_key_id, ) - return cls.sent # Handle boosts/likes elif fan_out.type == FanOut.Types.interaction: interaction = await fan_out.subject_post_interaction.afetch_full() @@ -74,6 +73,7 @@ class FanOutStates(StateGraph): ) else: raise ValueError(f"Cannot fan out with type {fan_out.type}") + return cls.sent class FanOut(StatorModel): diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py index 368fdad..cf93661 100644 --- a/activities/models/timeline_event.py +++ b/activities/models/timeline_event.py @@ -66,7 +66,7 @@ class TimelineEvent(models.Model): """ return cls.objects.get_or_create( identity=identity, - type=cls.Types.follow, + type=cls.Types.followed, subject_identity=source_identity, )[0] @@ -90,6 +90,7 @@ class TimelineEvent(models.Model): identity=identity, type=cls.Types.mentioned, subject_post=post, + subject_identity=post.author, )[0] @classmethod diff --git a/activities/views/posts.py b/activities/views/posts.py index 7b93e42..14da9ca 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -5,6 +5,7 @@ from django.utils.decorators import method_decorator from django.views.generic import FormView, TemplateView, View from activities.models import Post, PostInteraction, PostInteractionStates +from core.models import Config from users.decorators import identity_required from users.shortcuts import by_handle_or_404 @@ -112,6 +113,7 @@ class Compose(FormView): template_name = "activities/compose.html" class form_class(forms.Form): + text = forms.CharField( widget=forms.Textarea( attrs={ @@ -137,6 +139,22 @@ class Compose(FormView): help_text="Optional - Post will be hidden behind this text until clicked", ) + def clean_text(self): + text = self.cleaned_data.get("text") + if not text: + return text + length = len(text) + if length > Config.system.post_length: + raise forms.ValidationError( + f"Maximum post length is {Config.system.post_length} characters (you have {length})" + ) + return text + + def get_form_class(self): + form = super().get_form_class() + form.declared_fields["text"] + return form + def form_valid(self, form): Post.create_local( author=self.request.identity, diff --git a/activities/views/search.py b/activities/views/search.py new file mode 100644 index 0000000..b748348 --- /dev/null +++ b/activities/views/search.py @@ -0,0 +1,32 @@ +from django import forms +from django.views.generic import FormView + +from users.models import Identity + + +class Search(FormView): + + template_name = "activities/search.html" + + class form_class(forms.Form): + query = forms.CharField() + + def form_valid(self, form): + query = form.cleaned_data["query"].lstrip("@").lower() + results = {"identities": set()} + # Search identities + if "@" in query: + username, domain = query.split("@", 1) + for identity in Identity.objects.filter( + domain_id=domain, username=username + )[:20]: + results["identities"].add(identity) + else: + for identity in Identity.objects.filter(username=query)[:20]: + results["identities"].add(identity) + for identity in Identity.objects.filter(username__startswith=query)[:20]: + results["identities"].add(identity) + # Render results + context = self.get_context_data(form=form) + context["results"] = results + return self.render_to_response(context) diff --git a/activities/views/timelines.py b/activities/views/timelines.py index 02afc2c..38f9331 100644 --- a/activities/views/timelines.py +++ b/activities/views/timelines.py @@ -98,9 +98,18 @@ class Notifications(TemplateView): def get_context_data(self): context = super().get_context_data() - context["events"] = TimelineEvent.objects.filter( - identity=self.request.identity, - type__in=[TimelineEvent.Types.mentioned, TimelineEvent.Types.boosted], - ).select_related("subject_post", "subject_post__author", "subject_identity") + context["events"] = ( + TimelineEvent.objects.filter( + identity=self.request.identity, + type__in=[ + TimelineEvent.Types.mentioned, + TimelineEvent.Types.boosted, + TimelineEvent.Types.liked, + TimelineEvent.Types.followed, + ], + ) + .order_by("-created")[:50] + .select_related("subject_post", "subject_post__author", "subject_identity") + ) context["current_page"] = "notifications" return context diff --git a/static/css/style.css b/static/css/style.css index fba7f97..9c9d625 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -247,6 +247,10 @@ nav a i { padding: 15px; } +.left-column h2 { + margin: 10px 0 10px 0; +} + .right-column { width: 250px; background: var(--color-bg-menu); @@ -335,7 +339,7 @@ form.inline { form.follow { float: right; - margin: 20px 20px 0 0; + margin: 20px 0 0 0; font-size: 16px; } @@ -530,12 +534,13 @@ form .button:hover { /* Identities */ h1.identity { - margin: 15px 0 20px 15px; + margin: 0 0 20px 0; } h1.identity .banner { - width: 870px; - height: auto; + width: 100%; + height: 200px; + object-fit: cover; display: block; margin: 0 0 20px 0; } @@ -560,7 +565,7 @@ h1.identity small { color: var(--color-text-dull); border-radius: 3px; padding: 5px 8px; - margin: 15px; + margin: 15px 0; } .system-note a { @@ -658,6 +663,7 @@ h1.identity small { .post .actions a { cursor: pointer; color: var(--color-text-dull); + margin-right: 10px; } .post .actions a:hover { @@ -668,18 +674,42 @@ h1.identity small { color: var(--color-highlight); } -.boost-banner { +.boost-banner, +.mention-banner, +.follow-banner, +.like-banner { padding: 0 0 3px 5px; } +.boost-banner a, +.mention-banner a, +.follow-banner a, +.like-banner a { + font-weight: bold; +} + .boost-banner::before { content: "\f079"; font: var(--fa-font-solid); margin-right: 4px; } -.boost-banner a { - font-weight: bold; +.mention-banner::before { + content: "\0040"; + font: var(--fa-font-solid); + margin-right: 4px; +} + +.follow-banner::before { + content: "\f007"; + font: var(--fa-font-solid); + margin-right: 4px; +} + +.like-banner::before { + content: "\f005"; + font: var(--fa-font-solid); + margin-right: 4px; } diff --git a/takahe/urls.py b/takahe/urls.py index 5f5d5c5..c2d9d6b 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -5,7 +5,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, timelines +from activities.views import posts, search, timelines from core import views as core from stator import views as stator from users.views import activitypub, admin, auth, identity, settings @@ -14,9 +14,10 @@ urlpatterns = [ path("", core.homepage), path("manifest.json", core.AppManifest.as_view()), # Activity views - path("notifications/", timelines.Notifications.as_view()), - path("local/", timelines.Local.as_view()), - path("federated/", timelines.Federated.as_view()), + path("notifications/", timelines.Notifications.as_view(), name="notifications"), + 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( "settings/", settings.SettingsRoot.as_view(), @@ -76,7 +77,7 @@ urlpatterns = [ path("@/actor/inbox/", activitypub.Inbox.as_view()), path("@/action/", identity.ActionIdentity.as_view()), # Posts - path("compose/", posts.Compose.as_view()), + path("compose/", posts.Compose.as_view(), name="compose"), path("@/posts//", posts.Individual.as_view()), path("@/posts//like/", posts.Like.as_view()), path("@/posts//unlike/", posts.Like.as_view(undo=True)), diff --git a/templates/activities/_event.html b/templates/activities/_event.html index 375e475..81e9dd2 100644 --- a/templates/activities/_event.html +++ b/templates/activities/_event.html @@ -1,24 +1,28 @@ -{% load static %} {% load activity_tags %} -
- - - {% if event.type == "follow" %} - {{ event.subject_identity.name_or_handle }} followed you - {% elif event.type == "like" %} - {{ event.subject_identity.name_or_handle }} liked {{ event.subject_post }} - {% elif event.type == "mentioned" %} - {{ event.subject_post.author.name_or_handle }} mentioned you in {{ event.subject_post }} - {% elif event.type == "boosted" %} - {{ event.subject_identity.name_or_handle }} boosted your post {{ event.subject_post }} - {% else %} - Unknown event type {{event.type}} - {% endif %} -
+{% if event.type == "followed" %} + + {% include "activities/_identity.html" with identity=event.subject_identity created=event.created %} +{% elif event.type == "liked" %} + + {% include "activities/_post.html" with post=event.subject_post %} +{% elif event.type == "mentioned" %} + + {% include "activities/_post.html" with post=event.subject_post %} +{% elif event.type == "boosted" %} + {{ event.subject_identity.name_or_handle }} boosted your post {{ event.subject_post }} +{% else %} + Unknown event type {{event.type}} +{% endif %} diff --git a/templates/activities/_identity.html b/templates/activities/_identity.html new file mode 100644 index 0000000..36d14a9 --- /dev/null +++ b/templates/activities/_identity.html @@ -0,0 +1,15 @@ +{% load activity_tags %} +
+ + + + {% if created %} + + {% endif %} + + + {{ identity.name_or_handle }} @{{ identity.handle }} + +
diff --git a/templates/activities/_home_menu.html b/templates/activities/_menu.html similarity index 94% rename from templates/activities/_home_menu.html rename to templates/activities/_menu.html index db441a2..6bb18c2 100644 --- a/templates/activities/_home_menu.html +++ b/templates/activities/_menu.html @@ -19,6 +19,7 @@ {% csrf_token %} {{ form.text }} {{ form.content_warning }} +
CW diff --git a/templates/activities/_post.html b/templates/activities/_post.html index 14b1cbf..5de8bc7 100644 --- a/templates/activities/_post.html +++ b/templates/activities/_post.html @@ -51,11 +51,6 @@
{% include "activities/_like.html" %} {% include "activities/_boost.html" %} - {% if request.user.admin %} - - - - {% endif %}
{% endif %}
diff --git a/templates/activities/post.html b/templates/activities/post.html index b44df40..eee254f 100644 --- a/templates/activities/post.html +++ b/templates/activities/post.html @@ -3,15 +3,5 @@ {% block title %}Post by {{ post.author.name_or_handle }}{% endblock %} {% block content %} - - -
- -
- {% include "activities/_post.html" %} -
- -
+ {% include "activities/_post.html" %} {% endblock %} diff --git a/templates/activities/search.html b/templates/activities/search.html new file mode 100644 index 0000000..3cff2a2 --- /dev/null +++ b/templates/activities/search.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Search{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+ {% include "forms/_field.html" with field=form.query %} +
+
+ +
+
+ {% if results.identities %} +

People

+ {% for identity in results.identities %} + {% include "activities/_identity.html" %} + {% endfor %} + {% endif %} +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 31bbc7b..edcb11a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -28,10 +28,10 @@ {% if user.is_authenticated %} - + Compose - + Search @@ -67,7 +67,7 @@
{% block right_content %} - {% include "activities/_home_menu.html" %} + {% include "activities/_menu.html" %} {% endblock %}
diff --git a/templates/identity/view.html b/templates/identity/view.html index c830fc5..0dd0592 100644 --- a/templates/identity/view.html +++ b/templates/identity/view.html @@ -1,17 +1,13 @@ {% extends "base.html" %} -{% load static %} {% block title %}{{ identity }}{% endblock %} {% block content %} -
-

{% if identity.local_image_url %} {% endif %} + {% if request.identity %} @@ -43,13 +39,9 @@ {% endif %} {% endif %} -
-
- {% for post in posts %} - {% include "activities/_post.html" %} - {% empty %} - No posts yet. - {% endfor %} -
-
+ {% for post in posts %} + {% include "activities/_post.html" %} + {% empty %} + No posts yet. + {% endfor %} {% endblock %} diff --git a/users/models/follow.py b/users/models/follow.py index defe399..d2ee493 100644 --- a/users/models/follow.py +++ b/users/models/follow.py @@ -1,6 +1,6 @@ from typing import Optional -from django.db import models +from django.db import models, transaction from core.ld import canonicalise from core.signatures import HttpSignature @@ -218,9 +218,14 @@ class Follow(StatorModel): """ Handles an incoming follow request """ - follow = cls.by_ap(data, create=True) - # Force it into remote_requested so we send an accept - follow.transition_perform(FollowStates.remote_requested) + from activities.models import TimelineEvent + + with transaction.atomic(): + follow = cls.by_ap(data, create=True) + # Force it into remote_requested so we send an accept + follow.transition_perform(FollowStates.remote_requested) + # Add a timeline event + TimelineEvent.add_follow(follow.target, follow.source) @classmethod def handle_accept_ap(cls, data):