summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md6
-rw-r--r--activities/admin.py1
-rw-r--r--activities/models/fan_out.py2
-rw-r--r--activities/models/timeline_event.py3
-rw-r--r--activities/views/posts.py18
-rw-r--r--activities/views/search.py32
-rw-r--r--activities/views/timelines.py17
-rw-r--r--static/css/style.css46
-rw-r--r--takahe/urls.py11
-rw-r--r--templates/activities/_event.html48
-rw-r--r--templates/activities/_identity.html15
-rw-r--r--templates/activities/_menu.html (renamed from templates/activities/_home_menu.html)1
-rw-r--r--templates/activities/_post.html5
-rw-r--r--templates/activities/post.html12
-rw-r--r--templates/activities/search.html21
-rw-r--r--templates/base.html6
-rw-r--r--templates/identity/view.html20
-rw-r--r--users/models/follow.py13
18 files changed, 197 insertions, 80 deletions
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("@<handle>/actor/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()),
# Posts
- path("compose/", posts.Compose.as_view()),
+ path("compose/", posts.Compose.as_view(), name="compose"),
path("@<handle>/posts/<int:post_id>/", posts.Individual.as_view()),
path("@<handle>/posts/<int:post_id>/like/", posts.Like.as_view()),
path("@<handle>/posts/<int:post_id>/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 %}
-<div class="post">
- <time>
- {% if event.published %}
- {{ event.published | timedeltashort }}
- {% else %}
- {{ event.created | timedeltashort }}
- {% endif %}
- </time>
-
- {% 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 %}
-</div>
+{% if event.type == "followed" %}
+ <div class="follow-banner">
+ <a href="{{ event.subject_identity.urls.view }}">
+ {{ event.subject_identity.name_or_handle }}
+ </a> followed you
+ </div>
+ {% include "activities/_identity.html" with identity=event.subject_identity created=event.created %}
+{% elif event.type == "liked" %}
+ <div class="like-banner">
+ <a href="{{ event.subject_identity.urls.view }}">
+ {{ event.subject_identity.name_or_handle }}
+ </a> liked your post
+ </div>
+ {% include "activities/_post.html" with post=event.subject_post %}
+{% elif event.type == "mentioned" %}
+ <div class="mention-banner">
+ <a href="{{ event.subject_identity.urls.view }}">
+ {{ event.subject_identity.name_or_handle }}
+ </a> mentioned you
+ </div>
+ {% 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 %}
+<div class="post user">
+
+ <img src="{{ identity.local_icon_url }}" class="icon">
+
+ {% if created %}
+ <time>
+ {{ event.created | timedeltashort }}
+ </time>
+ {% endif %}
+
+ <a href="{{ identity.urls.view }}" class="handle">
+ {{ identity.name_or_handle }} <small>@{{ identity.handle }}</small>
+ </a>
+</div>
diff --git a/templates/activities/_home_menu.html b/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 }}
+ <input type="hidden" name="visibility" value="0">
<div class="buttons">
<span class="button toggle" _="on click toggle .enabled then toggle .hidden on #id_content_warning">CW</span>
<button>{% if config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
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 @@
<div class="actions">
{% include "activities/_like.html" %}
{% include "activities/_boost.html" %}
- {% if request.user.admin %}
- <a title="Admin" href="/djadmin/activities/post/{{ post.pk }}/change/">
- <i class="fa-solid fa-file-code"></i>
- </a>
- {% endif %}
</div>
{% endif %}
</div>
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 %}
- <nav>
- <a href="." class="selected">Post</a>
- </nav>
-
- <section class="columns">
-
- <div class="left-column">
- {% include "activities/_post.html" %}
- </div>
-
- </section>
+ {% 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 %}
+ <form action="." method="POST">
+ {% csrf_token %}
+ <fieldset>
+ {% include "forms/_field.html" with field=form.query %}
+ </fieldset>
+ <div class="buttons">
+ <button>Search</button>
+ </div>
+ </form>
+ {% if results.identities %}
+ <h2>People</h2>
+ {% 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 @@
</a>
<menu>
{% if user.is_authenticated %}
- <a href="/compose/" title="Compose" {% if top_section == "compose" %}class="selected"{% endif %}>
+ <a href="{% url "compose" %}" title="Compose" {% if top_section == "compose" %}class="selected"{% endif %}>
<i class="fa-solid fa-feather"></i> Compose
</a>
- <a href="#" title="Search" {% if top_section == "search" %}class="selected"{% endif %}>
+ <a href="{% url "search" %}" title="Search" {% if top_section == "search" %}class="selected"{% endif %}>
<i class="fa-solid fa-search"></i> Search
</a>
<a href="{% url "settings" %}" title="Settings" {% if top_section == "settings" %}class="selected"{% endif %}>
@@ -67,7 +67,7 @@
</div>
<div class="right-column">
{% block right_content %}
- {% include "activities/_home_menu.html" %}
+ {% include "activities/_menu.html" %}
{% endblock %}
</div>
</div>
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 %}
- <nav>
- <a href="." class="selected">Profile</a>
- </nav>
-
<h1 class="identity">
{% if identity.local_image_url %}
<img src="{{ identity.local_image_url }}" class="banner">
{% endif %}
+
<img src="{{ identity.local_icon_url }}" class="icon">
{% if request.identity %}
@@ -43,13 +39,9 @@
{% endif %}
{% endif %}
- <section class="columns">
- <div class="left-column">
- {% for post in posts %}
- {% include "activities/_post.html" %}
- {% empty %}
- No posts yet.
- {% endfor %}
- </div>
- </section>
+ {% 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):