Add search and better notifications

This commit is contained in:
Andrew Godwin 2022-11-17 18:52:00 -07:00
parent 2154e6f022
commit 0851fbd1ec
18 changed files with 197 additions and 80 deletions

View File

@ -55,11 +55,12 @@ the less sure I am about it.
- [x] Receive follow undos - [x] Receive follow undos
- [ ] Do outgoing mentions properly - [ ] Do outgoing mentions properly
- [x] Home timeline (posts and boosts from follows) - [x] Home timeline (posts and boosts from follows)
- [ ] Notifications page (followed, boosted, liked) - [x] Notifications page (followed, boosted, liked)
- [x] Local timeline - [x] Local timeline
- [x] Federated timeline - [x] Federated timeline
- [x] Profile pages - [x] Profile pages
- [ ] Settable icon and background image for profiles - [x] Settable icon and background image for profiles
- [x] User search
- [ ] Following page - [ ] Following page
- [ ] Followers page - [ ] Followers page
- [x] Multiple domain support - [x] Multiple domain support
@ -88,6 +89,7 @@ the less sure I am about it.
- [ ] Emoji fetching and display - [ ] Emoji fetching and display
- [ ] Emoji creation - [ ] Emoji creation
- [ ] Image descriptions - [ ] Image descriptions
- [ ] Hashtag search
- [ ] Flag for moderation - [ ] Flag for moderation
- [ ] Moderation queue - [ ] Moderation queue
- [ ] User management page - [ ] User management page

View File

@ -36,6 +36,7 @@ class PostAdmin(admin.ModelAdmin):
@admin.register(TimelineEvent) @admin.register(TimelineEvent)
class TimelineEventAdmin(admin.ModelAdmin): class TimelineEventAdmin(admin.ModelAdmin):
list_display = ["id", "identity", "created", "type"] list_display = ["id", "identity", "created", "type"]
readonly_fields = ["created"]
raw_id_fields = [ raw_id_fields = [
"identity", "identity",
"subject_post", "subject_post",

View File

@ -37,7 +37,6 @@ class FanOutStates(StateGraph):
private_key=post.author.private_key, private_key=post.author.private_key,
key_id=post.author.public_key_id, key_id=post.author.public_key_id,
) )
return cls.sent
# Handle boosts/likes # Handle boosts/likes
elif fan_out.type == FanOut.Types.interaction: elif fan_out.type == FanOut.Types.interaction:
interaction = await fan_out.subject_post_interaction.afetch_full() interaction = await fan_out.subject_post_interaction.afetch_full()
@ -74,6 +73,7 @@ class FanOutStates(StateGraph):
) )
else: else:
raise ValueError(f"Cannot fan out with type {fan_out.type}") raise ValueError(f"Cannot fan out with type {fan_out.type}")
return cls.sent
class FanOut(StatorModel): class FanOut(StatorModel):

View File

@ -66,7 +66,7 @@ class TimelineEvent(models.Model):
""" """
return cls.objects.get_or_create( return cls.objects.get_or_create(
identity=identity, identity=identity,
type=cls.Types.follow, type=cls.Types.followed,
subject_identity=source_identity, subject_identity=source_identity,
)[0] )[0]
@ -90,6 +90,7 @@ class TimelineEvent(models.Model):
identity=identity, identity=identity,
type=cls.Types.mentioned, type=cls.Types.mentioned,
subject_post=post, subject_post=post,
subject_identity=post.author,
)[0] )[0]
@classmethod @classmethod

View File

@ -5,6 +5,7 @@ from django.utils.decorators import method_decorator
from django.views.generic import FormView, TemplateView, View from django.views.generic import FormView, TemplateView, View
from activities.models import Post, PostInteraction, PostInteractionStates from activities.models import Post, PostInteraction, PostInteractionStates
from core.models import Config
from users.decorators import identity_required from users.decorators import identity_required
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
@ -112,6 +113,7 @@ class Compose(FormView):
template_name = "activities/compose.html" template_name = "activities/compose.html"
class form_class(forms.Form): class form_class(forms.Form):
text = forms.CharField( text = forms.CharField(
widget=forms.Textarea( widget=forms.Textarea(
attrs={ attrs={
@ -137,6 +139,22 @@ class Compose(FormView):
help_text="Optional - Post will be hidden behind this text until clicked", 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): def form_valid(self, form):
Post.create_local( Post.create_local(
author=self.request.identity, author=self.request.identity,

View File

@ -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)

View File

@ -98,9 +98,18 @@ class Notifications(TemplateView):
def get_context_data(self): def get_context_data(self):
context = super().get_context_data() context = super().get_context_data()
context["events"] = TimelineEvent.objects.filter( context["events"] = (
identity=self.request.identity, TimelineEvent.objects.filter(
type__in=[TimelineEvent.Types.mentioned, TimelineEvent.Types.boosted], identity=self.request.identity,
).select_related("subject_post", "subject_post__author", "subject_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" context["current_page"] = "notifications"
return context return context

View File

@ -247,6 +247,10 @@ nav a i {
padding: 15px; padding: 15px;
} }
.left-column h2 {
margin: 10px 0 10px 0;
}
.right-column { .right-column {
width: 250px; width: 250px;
background: var(--color-bg-menu); background: var(--color-bg-menu);
@ -335,7 +339,7 @@ form.inline {
form.follow { form.follow {
float: right; float: right;
margin: 20px 20px 0 0; margin: 20px 0 0 0;
font-size: 16px; font-size: 16px;
} }
@ -530,12 +534,13 @@ form .button:hover {
/* Identities */ /* Identities */
h1.identity { h1.identity {
margin: 15px 0 20px 15px; margin: 0 0 20px 0;
} }
h1.identity .banner { h1.identity .banner {
width: 870px; width: 100%;
height: auto; height: 200px;
object-fit: cover;
display: block; display: block;
margin: 0 0 20px 0; margin: 0 0 20px 0;
} }
@ -560,7 +565,7 @@ h1.identity small {
color: var(--color-text-dull); color: var(--color-text-dull);
border-radius: 3px; border-radius: 3px;
padding: 5px 8px; padding: 5px 8px;
margin: 15px; margin: 15px 0;
} }
.system-note a { .system-note a {
@ -658,6 +663,7 @@ h1.identity small {
.post .actions a { .post .actions a {
cursor: pointer; cursor: pointer;
color: var(--color-text-dull); color: var(--color-text-dull);
margin-right: 10px;
} }
.post .actions a:hover { .post .actions a:hover {
@ -668,18 +674,42 @@ h1.identity small {
color: var(--color-highlight); color: var(--color-highlight);
} }
.boost-banner { .boost-banner,
.mention-banner,
.follow-banner,
.like-banner {
padding: 0 0 3px 5px; padding: 0 0 3px 5px;
} }
.boost-banner a,
.mention-banner a,
.follow-banner a,
.like-banner a {
font-weight: bold;
}
.boost-banner::before { .boost-banner::before {
content: "\f079"; content: "\f079";
font: var(--fa-font-solid); font: var(--fa-font-solid);
margin-right: 4px; margin-right: 4px;
} }
.boost-banner a { .mention-banner::before {
font-weight: bold; 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;
} }

View File

@ -5,7 +5,7 @@ from django.contrib import admin as djadmin
from django.urls import path, re_path from django.urls import path, re_path
from django.views.static import serve 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 core import views as core
from stator import views as stator from stator import views as stator
from users.views import activitypub, admin, auth, identity, settings from users.views import activitypub, admin, auth, identity, settings
@ -14,9 +14,10 @@ urlpatterns = [
path("", core.homepage), path("", core.homepage),
path("manifest.json", core.AppManifest.as_view()), path("manifest.json", core.AppManifest.as_view()),
# Activity views # Activity views
path("notifications/", timelines.Notifications.as_view()), path("notifications/", timelines.Notifications.as_view(), name="notifications"),
path("local/", timelines.Local.as_view()), path("local/", timelines.Local.as_view(), name="local"),
path("federated/", timelines.Federated.as_view()), path("federated/", timelines.Federated.as_view(), name="federated"),
path("search/", search.Search.as_view(), name="search"),
path( path(
"settings/", "settings/",
settings.SettingsRoot.as_view(), settings.SettingsRoot.as_view(),
@ -76,7 +77,7 @@ urlpatterns = [
path("@<handle>/actor/inbox/", activitypub.Inbox.as_view()), path("@<handle>/actor/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()), path("@<handle>/action/", identity.ActionIdentity.as_view()),
# Posts # 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>/", posts.Individual.as_view()),
path("@<handle>/posts/<int:post_id>/like/", posts.Like.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)), path("@<handle>/posts/<int:post_id>/unlike/", posts.Like.as_view(undo=True)),

View File

@ -1,24 +1,28 @@
{% load static %}
{% load activity_tags %} {% load activity_tags %}
<div class="post">
<time> {% if event.type == "followed" %}
{% if event.published %} <div class="follow-banner">
{{ event.published | timedeltashort }} <a href="{{ event.subject_identity.urls.view }}">
{% else %} {{ event.subject_identity.name_or_handle }}
{{ event.created | timedeltashort }} </a> followed you
{% endif %} </div>
</time> {% include "activities/_identity.html" with identity=event.subject_identity created=event.created %}
{% elif event.type == "liked" %}
{% if event.type == "follow" %} <div class="like-banner">
{{ event.subject_identity.name_or_handle }} followed you <a href="{{ event.subject_identity.urls.view }}">
{% elif event.type == "like" %} {{ event.subject_identity.name_or_handle }}
{{ event.subject_identity.name_or_handle }} liked {{ event.subject_post }} </a> liked your post
{% elif event.type == "mentioned" %} </div>
{{ event.subject_post.author.name_or_handle }} mentioned you in {{ event.subject_post }} {% include "activities/_post.html" with post=event.subject_post %}
{% elif event.type == "boosted" %} {% elif event.type == "mentioned" %}
{{ event.subject_identity.name_or_handle }} boosted your post {{ event.subject_post }} <div class="mention-banner">
{% else %} <a href="{{ event.subject_identity.urls.view }}">
Unknown event type {{event.type}} {{ event.subject_identity.name_or_handle }}
{% endif %} </a> mentioned you
</div> </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 %}

View File

@ -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>

View File

@ -19,6 +19,7 @@
{% csrf_token %} {% csrf_token %}
{{ form.text }} {{ form.text }}
{{ form.content_warning }} {{ form.content_warning }}
<input type="hidden" name="visibility" value="0">
<div class="buttons"> <div class="buttons">
<span class="button toggle" _="on click toggle .enabled then toggle .hidden on #id_content_warning">CW</span> <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> <button>{% if config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>

View File

@ -51,11 +51,6 @@
<div class="actions"> <div class="actions">
{% include "activities/_like.html" %} {% include "activities/_like.html" %}
{% include "activities/_boost.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> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -3,15 +3,5 @@
{% block title %}Post by {{ post.author.name_or_handle }}{% endblock %} {% block title %}Post by {{ post.author.name_or_handle }}{% endblock %}
{% block content %} {% block content %}
<nav> {% include "activities/_post.html" %}
<a href="." class="selected">Post</a>
</nav>
<section class="columns">
<div class="left-column">
{% include "activities/_post.html" %}
</div>
</section>
{% endblock %} {% endblock %}

View File

@ -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 %}

View File

@ -28,10 +28,10 @@
</a> </a>
<menu> <menu>
{% if user.is_authenticated %} {% 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 <i class="fa-solid fa-feather"></i> Compose
</a> </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 <i class="fa-solid fa-search"></i> Search
</a> </a>
<a href="{% url "settings" %}" title="Settings" {% if top_section == "settings" %}class="selected"{% endif %}> <a href="{% url "settings" %}" title="Settings" {% if top_section == "settings" %}class="selected"{% endif %}>
@ -67,7 +67,7 @@
</div> </div>
<div class="right-column"> <div class="right-column">
{% block right_content %} {% block right_content %}
{% include "activities/_home_menu.html" %} {% include "activities/_menu.html" %}
{% endblock %} {% endblock %}
</div> </div>
</div> </div>

View File

@ -1,17 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block title %}{{ identity }}{% endblock %} {% block title %}{{ identity }}{% endblock %}
{% block content %} {% block content %}
<nav>
<a href="." class="selected">Profile</a>
</nav>
<h1 class="identity"> <h1 class="identity">
{% if identity.local_image_url %} {% if identity.local_image_url %}
<img src="{{ identity.local_image_url }}" class="banner"> <img src="{{ identity.local_image_url }}" class="banner">
{% endif %} {% endif %}
<img src="{{ identity.local_icon_url }}" class="icon"> <img src="{{ identity.local_icon_url }}" class="icon">
{% if request.identity %} {% if request.identity %}
@ -43,13 +39,9 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
<section class="columns"> {% for post in posts %}
<div class="left-column"> {% include "activities/_post.html" %}
{% for post in posts %} {% empty %}
{% include "activities/_post.html" %} No posts yet.
{% empty %} {% endfor %}
No posts yet.
{% endfor %}
</div>
</section>
{% endblock %} {% endblock %}

View File

@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from django.db import models from django.db import models, transaction
from core.ld import canonicalise from core.ld import canonicalise
from core.signatures import HttpSignature from core.signatures import HttpSignature
@ -218,9 +218,14 @@ class Follow(StatorModel):
""" """
Handles an incoming follow request Handles an incoming follow request
""" """
follow = cls.by_ap(data, create=True) from activities.models import TimelineEvent
# Force it into remote_requested so we send an accept
follow.transition_perform(FollowStates.remote_requested) 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 @classmethod
def handle_accept_ap(cls, data): def handle_accept_ap(cls, data):