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
- [ ] 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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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