Start of new UI refactor

This commit is contained in:
Andrew Godwin 2023-04-27 00:54:38 -06:00
parent 7331591432
commit 5d68e3aaac
65 changed files with 519 additions and 1000 deletions

View file

@ -16,12 +16,12 @@ from activities.models import (
from core.files import blurhash_image, resize_image
from core.html import FediverseHtmlParser
from core.models import Config
from users.decorators import identity_required
from users.shortcuts import by_handle_or_404
from django.contrib.auth.decorators import login_required
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class Compose(FormView):
template_name = "activities/compose.html"
class form_class(forms.Form):
@ -54,9 +54,9 @@ class Compose(FormView):
)
reply_to = forms.CharField(widget=forms.HiddenInput(), required=False)
def __init__(self, request, *args, **kwargs):
def __init__(self, identity, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.identity = identity
self.fields["text"].widget.attrs[
"_"
] = rf"""
@ -83,7 +83,7 @@ class Compose(FormView):
def clean_text(self):
text = self.cleaned_data.get("text")
# Check minimum interval
last_post = self.request.identity.posts.order_by("-created").first()
last_post = self.identity.posts.order_by("-created").first()
if (
last_post
and (timezone.now() - last_post.created).total_seconds()
@ -103,7 +103,7 @@ class Compose(FormView):
return text
def get_form(self, form_class=None):
return self.form_class(request=self.request, **self.get_form_kwargs())
return self.form_class(identity=self.identity, **self.get_form_kwargs())
def get_initial(self):
initial = super().get_initial()
@ -119,20 +119,20 @@ class Compose(FormView):
else:
initial[
"visibility"
] = self.request.identity.config_identity.default_post_visibility
] = self.identity.config_identity.default_post_visibility
if self.reply_to:
initial["reply_to"] = self.reply_to.pk
if self.reply_to.visibility == Post.Visibilities.public:
initial[
"visibility"
] = self.request.identity.config_identity.default_reply_visibility
] = self.identity.config_identity.default_reply_visibility
else:
initial["visibility"] = self.reply_to.visibility
initial["content_warning"] = self.reply_to.summary
# Build a set of mentions for the content to start as
mentioned = {self.reply_to.author}
mentioned.update(self.reply_to.mentions.all())
mentioned.discard(self.request.identity)
mentioned.discard(self.identity)
initial["text"] = "".join(
f"@{identity.handle} "
for identity in mentioned
@ -158,7 +158,7 @@ class Compose(FormView):
self.post_obj.transition_perform(PostStates.edited)
else:
post = Post.create_local(
author=self.request.identity,
author=self.identity,
content=form.cleaned_data["text"],
summary=form.cleaned_data.get("content_warning"),
visibility=form.cleaned_data["visibility"],
@ -166,17 +166,14 @@ class Compose(FormView):
attachments=attachments,
)
# Add their own timeline event for immediate visibility
TimelineEvent.add_post(self.request.identity, post)
TimelineEvent.add_post(self.identity, post)
return redirect("/")
def dispatch(self, request, handle=None, post_id=None, *args, **kwargs):
self.identity = by_handle_or_404(self.request, handle, local=True, fetch=False)
self.post_obj = None
if handle and post_id:
# Make sure the request identity owns the post!
if handle != request.identity.handle:
raise PermissionDenied("Post author is not requestor")
self.post_obj = get_object_or_404(request.identity.posts, pk=post_id)
self.post_obj = get_object_or_404(self.identity.posts, pk=post_id)
# Grab the reply-to post info now
self.reply_to = None
@ -192,12 +189,13 @@ class Compose(FormView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["reply_to"] = self.reply_to
context["identity"] = self.identity
if self.post_obj:
context["post"] = self.post_obj
return context
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class ImageUpload(FormView):
"""
Handles image upload - returns a new input type hidden to embed in
@ -267,7 +265,7 @@ class ImageUpload(FormView):
height=main_file.image.height,
name=form.cleaned_data.get("description"),
state=PostAttachmentStates.fetched,
author=self.request.identity,
author=self.identity,
)
attachment.file.save(

View file

@ -2,11 +2,11 @@ from django.db import models
from django.utils.decorators import method_decorator
from django.views.generic import ListView
from users.decorators import identity_required
from django.contrib.auth.decorators import login_required
from users.models import Follow, FollowStates, IdentityStates
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class Follows(ListView):
"""
Shows followers/follows.

View file

@ -4,10 +4,10 @@ from django.utils.decorators import method_decorator
from django.views.generic import View
from activities.models.hashtag import Hashtag
from users.decorators import identity_required
from django.contrib.auth.decorators import login_required
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class HashtagFollow(View):
"""
Follows/unfollows a hashtag with the current identity

View file

@ -9,7 +9,7 @@ from activities.models import Post, PostInteraction, PostStates
from activities.services import PostService
from core.decorators import cache_page_by_ap_json
from core.ld import canonicalise
from users.decorators import identity_required
from django.contrib.auth.decorators import login_required
from users.models import Identity
from users.shortcuts import by_handle_or_404
@ -19,7 +19,6 @@ from users.shortcuts import by_handle_or_404
)
@method_decorator(vary_on_headers("Accept"), name="dispatch")
class Individual(TemplateView):
template_name = "activities/post.html"
identity: Identity
@ -32,7 +31,7 @@ class Individual(TemplateView):
self.post_obj = get_object_or_404(
PostService.queryset()
.filter(author=self.identity)
.visible_to(request.identity, include_replies=True),
.unlisted(include_replies=True),
pk=post_id,
)
if self.post_obj.state in [PostStates.deleted, PostStates.deleted_fanned_out]:
@ -48,18 +47,12 @@ class Individual(TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
ancestors, descendants = PostService(self.post_obj).context(
self.request.identity
)
ancestors, descendants = PostService(self.post_obj).context(None)
context.update(
{
"identity": self.identity,
"post": self.post_obj,
"interactions": PostInteraction.get_post_interactions(
[self.post_obj] + ancestors + descendants,
self.request.identity,
),
"link_original": True,
"ancestors": ancestors,
"descendants": descendants,
@ -78,108 +71,7 @@ class Individual(TemplateView):
)
@method_decorator(identity_required, name="dispatch")
class Like(View):
"""
Adds/removes a like from the current identity to the post
"""
undo = False
def post(self, request, handle, post_id):
identity = by_handle_or_404(self.request, handle, local=False)
post = get_object_or_404(
PostService.queryset()
.filter(author=identity)
.visible_to(request.identity, include_replies=True),
pk=post_id,
)
service = PostService(post)
if self.undo:
service.unlike_as(request.identity)
else:
service.like_as(request.identity)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
request,
"activities/_like.html",
{
"post": post,
"interactions": {"like": set() if self.undo else {post.pk}},
},
)
return redirect(post.urls.view)
@method_decorator(identity_required, name="dispatch")
class Boost(View):
"""
Adds/removes a boost from the current identity to the post
"""
undo = False
def post(self, request, handle, post_id):
identity = by_handle_or_404(self.request, handle, local=False)
post = get_object_or_404(
PostService.queryset()
.filter(author=identity)
.visible_to(request.identity, include_replies=True),
pk=post_id,
)
service = PostService(post)
if self.undo:
service.unboost_as(request.identity)
else:
service.boost_as(request.identity)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
request,
"activities/_boost.html",
{
"post": post,
"interactions": {"boost": set() if self.undo else {post.pk}},
},
)
return redirect(post.urls.view)
@method_decorator(identity_required, name="dispatch")
class Bookmark(View):
"""
Adds/removes a bookmark from the current identity to the post
"""
undo = False
def post(self, request, handle, post_id):
identity = by_handle_or_404(self.request, handle, local=False)
post = get_object_or_404(
PostService.queryset()
.filter(author=identity)
.visible_to(request.identity, include_replies=True),
pk=post_id,
)
if self.undo:
request.identity.bookmarks.filter(post=post).delete()
else:
request.identity.bookmarks.get_or_create(post=post)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
request,
"activities/_bookmark.html",
{
"post": post,
"bookmarks": set() if self.undo else {post.pk},
},
)
return redirect(post.urls.view)
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class Delete(TemplateView):
"""
Deletes a post

View file

@ -6,48 +6,30 @@ from django.views.generic import ListView, TemplateView
from activities.models import Hashtag, PostInteraction, TimelineEvent
from activities.services import TimelineService
from core.decorators import cache_page
from users.decorators import identity_required
from users.models import Bookmark, HashtagFollow
from .compose import Compose
from django.contrib.auth.decorators import login_required
from users.models import Bookmark, HashtagFollow, Identity
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class Home(TemplateView):
"""
Homepage for logged-in users - shows identities primarily.
"""
template_name = "activities/home.html"
form_class = Compose.form_class
def get_form(self, form_class=None):
return self.form_class(request=self.request, **self.get_form_kwargs())
def get_context_data(self):
events = TimelineService(self.request.identity).home()
paginator = Paginator(events, 25)
page_number = self.request.GET.get("page")
event_page = paginator.get_page(page_number)
context = {
"interactions": PostInteraction.get_event_interactions(
event_page,
self.request.identity,
),
"bookmarks": Bookmark.for_identity(
self.request.identity, event_page, "subject_post_id"
),
"current_page": "home",
"allows_refresh": True,
"page_obj": event_page,
"form": self.form_class(request=self.request),
return {
"identities": Identity.objects.filter(
users__pk=self.request.user.pk
).order_by("created"),
}
return context
@method_decorator(
cache_page("cache_timeout_page_timeline", public_only=True), name="dispatch"
)
class Tag(ListView):
template_name = "activities/tag.html"
extra_context = {
"current_page": "tag",
@ -86,7 +68,6 @@ class Tag(ListView):
cache_page("cache_timeout_page_timeline", public_only=True), name="dispatch"
)
class Local(ListView):
template_name = "activities/local.html"
extra_context = {
"current_page": "local",
@ -108,9 +89,8 @@ class Local(ListView):
return context
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class Federated(ListView):
template_name = "activities/federated.html"
extra_context = {
"current_page": "federated",
@ -132,9 +112,8 @@ class Federated(ListView):
return context
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class Notifications(ListView):
template_name = "activities/notifications.html"
extra_context = {
"current_page": "notifications",

View file

@ -6,8 +6,7 @@ from django.http import JsonResponse
def identity_required(function):
"""
API version of the identity_required decorator that just makes sure the
token is tied to one, not an app only.
Makes sure the token is tied to an identity, not an app only.
"""
@wraps(function)

View file

@ -4,9 +4,6 @@ from core.models import Config
def config_context(request):
return {
"config": Config.system,
"config_identity": (
request.identity.config_identity if request.identity else None
),
"top_section": request.path.strip("/").split("/")[0],
"opengraph_defaults": {
"og:site_name": Config.system.site_name,

View file

@ -27,7 +27,6 @@ def homepage(request):
@method_decorator(cache_page(public_only=True), name="dispatch")
class About(TemplateView):
template_name = "about.html"
def get_context_data(self):
@ -87,46 +86,6 @@ class RobotsTxt(TemplateView):
}
@method_decorator(cache_control(max_age=60 * 15), name="dispatch")
class AppManifest(StaticContentView):
"""
Serves a PWA manifest file. This is a view as we want to drive some
items from settings.
NOTE: If this view changes to need runtime Config, it should change from
StaticContentView to View, otherwise the settings will only get
picked up during boot time.
"""
content_type = "application/json"
def get_static_content(self) -> str | bytes:
return json.dumps(
{
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
"name": "Takahē",
"short_name": "Takahē",
"start_url": "/",
"display": "standalone",
"background_color": "#26323c",
"theme_color": "#26323c",
"description": "An ActivityPub server",
"icons": [
{
"src": static("img/icon-128.png"),
"sizes": "128x128",
"type": "image/png",
},
{
"src": static("img/icon-1024.png"),
"sizes": "1024x1024",
"type": "image/png",
},
],
}
)
class FlatPage(TemplateView):
"""
Serves a "flat page" from a config option,

View file

@ -147,13 +147,8 @@ body {
main {
width: 1100px;
margin: 20px auto;
box-shadow: 0 0 var(--size-main-shadow) var(--color-main-shadow);
border-radius: 5px;
}
.no-sidebar main {
box-shadow: none;
max-width: 800px;
border-radius: 5px;
}
footer {
@ -172,10 +167,8 @@ footer a {
header {
display: flex;
height: 50px;
}
.no-sidebar header {
height: 42px;
margin-top: -20px;
justify-content: center;
}
@ -184,11 +177,11 @@ header .logo {
font-family: "Raleway";
font-weight: bold;
background: var(--color-highlight);
border-radius: 5px 0 0 0;
border-radius: 0 0 5px 5px;
text-transform: lowercase;
padding: 10px 11px 9px 10px;
height: 50px;
font-size: 130%;
padding: 6px 8px 5px 7px;
height: 42px;
font-size: 120%;
color: var(--color-text-in-highlight);
border-bottom: 3px solid rgba(0, 0, 0, 0);
z-index: 10;
@ -196,10 +189,6 @@ header .logo {
white-space: nowrap;
}
.no-sidebar header .logo {
border-radius: 5px;
}
header .logo:hover {
border-bottom: 3px solid rgba(255, 255, 255, 0.3);
}
@ -211,31 +200,25 @@ header .logo img {
}
header menu {
flex-grow: 1;
flex-grow: 0;
display: flex;
list-style-type: none;
justify-content: flex-start;
z-index: 10;
}
.no-sidebar header menu {
flex-grow: 0;
}
header menu a {
padding: 10px 20px 4px 20px;
padding: 6px 10px 4px 10px;
color: var(--color-text-main);
line-height: 30px;
border-bottom: 3px solid rgba(0, 0, 0, 0);
margin: 0 2px;
text-align: center;
}
.no-sidebar header menu a {
margin: 0 10px;
}
body.has-banner header menu a {
background: rgba(0, 0, 0, 0.5);
border-right: 0;
header menu a.logo {
width: auto;
margin-right: 7px;
}
header menu a:hover,
@ -243,10 +226,10 @@ header menu a.selected {
border-bottom: 3px solid var(--color-highlight);
}
.no-sidebar header menu a:hover:not(.logo) {
header menu a:hover:not(.logo) {
border-bottom: 3px solid rgba(0, 0, 0, 0);
background-color: var(--color-bg-menu);
border-radius: 5px;
border-radius: 0 0 5px 5px;
}
header menu a i {
@ -279,26 +262,6 @@ header menu .gap {
flex-grow: 1;
}
header menu a.identity {
border-right: 0;
text-align: right;
padding-right: 10px;
background: var(--color-bg-menu) !important;
border-radius: 0 5px 0 0;
width: 250px;
}
header menu a.identity i {
display: inline-block;
vertical-align: middle;
padding: 0 7px 2px 0;
}
header menu a.identity a.view-profile {
display: inline-block;
margin-right: 20px;
}
header menu a img {
display: inline-block;
vertical-align: middle;
@ -313,7 +276,7 @@ header menu a small {
}
nav {
padding: 10px 10px 20px 0;
padding: 10px 0px 20px 5px;
}
nav hr {
@ -334,23 +297,23 @@ nav h3:first-child {
nav a {
display: block;
color: var(--color-text-dull);
padding: 7px 18px 7px 13px;
border-left: 3px solid transparent;
padding: 7px 18px 7px 10px;
border-right: 3px solid transparent;
}
nav a.selected {
color: var(--color-text-main);
background: var(--color-bg-main);
border-radius: 0 5px 5px 0;
border-radius: 5px 0 0 5px;
}
nav a:hover {
color: var(--color-text-main);
border-left: 3px solid var(--color-highlight);
border-right: 3px solid var(--color-highlight);
}
nav a.selected:hover {
border-left: 3px solid transparent;
border-right: 3px solid transparent;
}
nav a.danger {
@ -368,48 +331,55 @@ nav a i {
display: inline-block;
}
nav .identity-banner {
margin: 5px 0 10px 7px;
}
nav .identity-banner img.icon {
max-width: 32px;
max-height: 32px;
}
nav .identity-banner .avatar-link {
padding: 4px;
}
nav .identity-banner .handle {
word-wrap: break-word;
}
nav .identity-banner div.link {
color: var(--color-text-main);
}
nav .identity-banner a,
nav .identity-banner a:hover {
border-right: none;
}
/* Left-right columns */
.columns {
.settings {
display: flex;
align-items: stretch;
justify-content: center;
margin-top: 20px;
}
.left-column {
.settings .settings-content {
flex-grow: 1;
width: 300px;
max-width: 900px;
padding: 15px;
padding: 0 15px 15px 15px;
}
.left-column h1 {
margin: 0 0 10px 0;
}
.left-column h1 small {
font-size: 60%;
color: var(--color-text-dull);
display: block;
margin: -10px 0 0 0;
padding: 0;
}
.left-column h2 {
margin: 10px 0 10px 0;
}
.left-column h3 {
margin: 10px 0 0 0;
}
.right-column {
.settings nav {
width: var(--md-sidebar-width);
background: var(--color-bg-menu);
border-radius: 0 0 5px 0;
}
.right-column h2 {
.settings nav h2 {
background: var(--color-highlight);
color: var(--color-text-in-highlight);
padding: 8px 10px;
@ -418,12 +388,6 @@ nav a i {
text-transform: uppercase;
}
.right-column footer {
padding: 0 10px 20px 10px;
font-size: 90%;
text-align: left;
}
img.emoji {
height: var(--emoji-height);
vertical-align: baseline;
@ -433,27 +397,52 @@ img.emoji {
content: "…";
}
/* Generic markdown styling and sections */
/* Generic styling and sections */
.no-sidebar section {
section {
max-width: 700px;
background: var(--color-bg-box);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1);
margin: 25px auto 45px auto;
margin: 0 auto 45px auto;
padding: 5px 15px;
}
.no-sidebar section:last-of-type {
margin-bottom: 10px;
section:first-of-type {
margin-top: 30px;
}
.no-sidebar section.shell {
section.invisible {
background: none;
box-shadow: none;
padding: 0;
}
.no-sidebar #main-content>h1 {
section h1.above {
position: relative;
top: -35px;
left: -15px;
font-weight: bold;
text-transform: uppercase;
font-size: 120%;
color: var(--color-text-main);
margin-bottom: -20px;
}
section p {
margin: 5px 0 10px 0;
}
section:last-of-type {
margin-bottom: 10px;
}
section.shell {
background: none;
box-shadow: none;
padding: 0;
}
#main-content>h1 {
max-width: 700px;
margin: 25px auto 5px auto;
font-weight: bold;
@ -462,7 +451,7 @@ img.emoji {
color: var(--color-text-main);
}
.no-sidebar h1+section {
h1+section {
margin-top: 0;
}
@ -493,9 +482,7 @@ p.authorization-code {
.icon-menu .option {
display: block;
margin: 0 0 20px 0;
background: var(--color-bg-box);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1);
margin: 0 0 10px 0;
color: inherit;
text-decoration: none;
padding: 10px 20px;
@ -596,6 +583,40 @@ p.authorization-code {
color: var(--color-text-dull);
}
/* Icon/app listings */
.flex-icons {
display: flex;
list-style-type: none;
margin: 20px 0 10px 0;
padding: 0;
flex-wrap: wrap;
justify-content: space-between;
}
.flex-icons a {
display: inline-block;
width: 200px;
margin: 0 0 15px 0;
}
.flex-icons a img {
max-width: 64px;
max-height: 64px;
float: left;
}
.flex-icons a h2 {
margin: 7px 0 0 72px;
font-size: 110%;
}
.flex-icons a i {
margin: 0 0 0 72px;
font-size: 90%;
display: block;
}
/* Item tables */
table.items {
@ -685,7 +706,7 @@ table.items td.actions a.danger:hover {
/* Forms */
.no-sidebar form {
section form {
max-width: 500px;
margin: 40px auto;
}
@ -1174,42 +1195,48 @@ blockquote {
/* Identities */
h1.identity {
margin: 0 0 20px 0;
section.identity {
overflow: hidden;
margin-bottom: 10px;
}
h1.identity .banner {
section.identity .banner {
width: 100%;
height: 200px;
object-fit: cover;
display: block;
width: calc(100% + 30px);
margin: -65px -15px 20px -15px;
margin: -5px -15px 20px -15px;
border-radius: 5px 0 0 0;
}
h1.identity .icon {
section.identity .icon {
width: 80px;
height: 80px;
float: left;
margin: 0 20px 0 0;
margin: 0 20px 15px 0;
cursor: pointer;
}
h1.identity .emoji {
section.identity .emoji {
height: var(--emoji-height);
}
h1.identity small {
section.identity h1 {
margin: 30px 0 0 0;
}
section.identity small {
display: block;
font-size: 60%;
font-size: 100%;
font-weight: normal;
color: var(--color-text-dull);
margin: -5px 0 0 0;
}
.bio {
margin: 0 0 20px 0;
section.identity .bio {
clear: left;
margin: 0 0 10px 0;
}
.bio .emoji {
@ -1220,14 +1247,19 @@ h1.identity small {
margin: 0 0 10px 0;
}
.identity-metadata {
margin-bottom: 10px;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.identity-metadata .metadata-pair {
display: block;
margin: 0px 0 10px 0;
background: var(--color-bg-box);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1);
display: inline-block;
width: 300px;
margin: 0px 0 5px 0;
color: inherit;
text-decoration: none;
padding: 10px 20px;
border: 2px solid rgba(255, 255, 255, 0);
border-radius: 10px;
overflow: hidden;
@ -1235,24 +1267,18 @@ h1.identity small {
.identity-metadata .metadata-pair .metadata-name {
display: inline-block;
min-width: 80px;
min-width: 90px;
margin-right: 15px;
text-align: right;
color: var(--color-text-dull);
}
.identity-metadata .metadata-pair .metadata-name::after {
padding-left: 3px;
color: var(--color-text-dull);
}
.system-note {
background: var(--color-bg-menu);
color: var(--color-text-dull);
border-radius: 3px;
padding: 5px 8px;
margin: 15px 0;
margin-bottom: 20px;
}
.system-note a {
@ -1321,13 +1347,12 @@ table.metadata td .emoji {
}
.view-options {
margin: 0 0 10px 0px;
margin-bottom: 10px;
padding: 0;
display: flex;
flex-wrap: wrap;
}
.view-options.follows {
margin: 0 0 20px 0px;
background: none;
box-shadow: none;
}
.view-options a:not(.button) {
@ -1405,6 +1430,11 @@ table.metadata td .emoji {
}
.identity-banner img.icon {
max-width: 64px;
max-height: 64px;
}
/* Posts */
@ -1776,28 +1806,13 @@ form .post {
padding: 0;
}
body:not(.no-sidebar) header {
height: var(--md-header-height);
position: fixed;
width: 100%;
z-index: 9;
}
body:not(.no-sidebar) header menu a {
background: var(--color-header-menu);
}
main {
width: 100%;
margin: 0;
margin: 20px auto 20px auto;
box-shadow: none;
border-radius: 0;
}
.no-sidebar main {
margin: 20px auto 20px auto;
}
.post .attachments a.image img {
max-height: 300px;
}

View file

@ -220,7 +220,6 @@ MIDDLEWARE = [
"core.middleware.HeadersMiddleware",
"core.middleware.ConfigLoadingMiddleware",
"api.middleware.ApiTokenMiddleware",
"users.middleware.IdentityMiddleware",
]
ROOT_URLCONF = "takahe.urls"

View file

@ -29,7 +29,6 @@ from users.views import (
urlpatterns = [
path("", core.homepage),
path("robots.txt", core.RobotsTxt.as_view()),
path("manifest.json", core.AppManifest.as_view()),
# Activity views
path("notifications/", timelines.Notifications.as_view(), name="notifications"),
path("local/", timelines.Local.as_view(), name="local"),
@ -57,27 +56,32 @@ urlpatterns = [
name="settings_security",
),
path(
"settings/profile/",
"@<handle>/settings/",
settings.SettingsRoot.as_view(),
name="settings",
),
path(
"@<handle>/settings/profile/",
settings.ProfilePage.as_view(),
name="settings_profile",
),
path(
"settings/interface/",
"@<handle>/settings/interface/",
settings.InterfacePage.as_view(),
name="settings_interface",
),
path(
"settings/import_export/",
"@<handle>/settings/import_export/",
settings.ImportExportPage.as_view(),
name="settings_import_export",
),
path(
"settings/import_export/following.csv",
"@<handle>/settings/import_export/following.csv",
settings.CsvFollowing.as_view(),
name="settings_export_following_csv",
),
path(
"settings/import_export/followers.csv",
"@<handle>/settings/import_export/followers.csv",
settings.CsvFollowers.as_view(),
name="settings_export_followers_csv",
),
@ -242,21 +246,13 @@ urlpatterns = [
path("@<handle>/following/", identity.IdentityFollows.as_view(inbound=False)),
path("@<handle>/followers/", identity.IdentityFollows.as_view(inbound=True)),
# Posts
path("compose/", compose.Compose.as_view(), name="compose"),
path("@<handle>/compose/", compose.Compose.as_view(), name="compose"),
path(
"compose/image_upload/",
"@<handle>/compose/image_upload/",
compose.ImageUpload.as_view(),
name="compose_image_upload",
),
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)),
path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),
path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/bookmark/", posts.Bookmark.as_view()),
path(
"@<handle>/posts/<int:post_id>/unbookmark/", posts.Bookmark.as_view(undo=True)
),
path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()),
path("@<handle>/posts/<int:post_id>/report/", report.SubmitReport.as_view()),
path("@<handle>/posts/<int:post_id>/edit/", compose.Compose.as_view()),
@ -267,9 +263,7 @@ urlpatterns = [
path("auth/signup/<token>/", auth.Signup.as_view(), name="signup"),
path("auth/reset/", auth.TriggerReset.as_view(), name="trigger_reset"),
path("auth/reset/<token>/", auth.PerformReset.as_view(), name="password_reset"),
# Identity selection
path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
path("identity/select/", identity.SelectIdentity.as_view(), name="identity_select"),
# Identity handling
path("identity/create/", identity.CreateIdentity.as_view(), name="identity_create"),
# Flat pages
path("about/", core.About.as_view(), name="about"),

View file

@ -1,11 +0,0 @@
{% if post.pk in interactions.boost %}
<a title="Unboost" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_unboost }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-solid fa-retweet"></i>
<span class="like-count">{{ post.stats_with_defaults.boosts }}</span>
</a>
{% else %}
<a title="Boost" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_boost }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-solid fa-retweet"></i>
<span class="like-count">{{ post.stats_with_defaults.boosts }}</span>
</a>
{% endif %}

View file

@ -1,11 +0,0 @@
{% if post.pk in interactions.like %}
<a title="Unlike" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_unlike }}" hx-swap="outerHTML" role="menuitem" tabindex="0">
<i class="fa-solid fa-star"></i>
<span class="like-count">{{ post.stats_with_defaults.likes }}</span>
</a>
{% else %}
<a title="Like" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_like }}" hx-swap="outerHTML" role="menuitem" tabindex="0">
<i class="fa-solid fa-star"></i>
<span class="like-count">{{ post.stats_with_defaults.likes }}</span>
</a>
{% endif %}

View file

@ -27,11 +27,7 @@
</div>
{% if post.summary %}
{% if config_identity.expand_linked_cws %}
<div class="summary" _="on click or keyup[key is 'Enter'] toggle .enabled on <.{{ post.summary_class }} .summary/> then toggle .hidden on <.{{ post.summary_class }} .content/> then halt" tabindex="0">
{% else %}
<div class="summary" _="on click or keyup[key is 'Enter'] toggle .enabled then toggle .hidden on the next .content then halt" tabindex="0">
{% endif %}
{{ post.summary }}
</div>
{% endif %}
@ -77,12 +73,19 @@
</div>
{% endif %}
{% if request.identity %}
<div class="actions" role="menubar">
{% include "activities/_reply.html" %}
{% include "activities/_like.html" %}
{% include "activities/_boost.html" %}
{% include "activities/_bookmark.html" %}
<div class="actions">
<a title="Replies">
<i class="fa-solid fa-reply"></i>
<span class="like-count">{{ post.stats_with_defaults.replies }}</span>
</a>
<a title="Likes">
<i class="fa-solid fa-star"></i>
<span class="like-count">{{ post.stats_with_defaults.likes }}</span>
</a>
<a title="Boosts">
<i class="fa-solid fa-retweet"></i>
<span class="like-count">{{ post.stats_with_defaults.boosts }}</span>
</a>
<a title="Menu" class="menu" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" role="menuitem" aria-haspopup="menu" tabindex="0">
<i class="fa-solid fa-bars"></i>
</a>
@ -112,6 +115,5 @@
{% endif %}
</menu>
</div>
{% endif %}
</div>

View file

@ -1,5 +0,0 @@
<a title="Reply" href="{{ post.urls.action_reply }}" role="menuitem">
<i class="fa-solid fa-reply"></i>
<span class="like-count">{{ post.stats_with_defaults.replies }}</span>
</a>

View file

@ -26,7 +26,7 @@
{% endif %}
{% if not post or post.attachments.count < 4 %}
<button class="add-image"
hx-get='{% url "compose_image_upload" %}'
hx-get='{% url "compose_image_upload" handle=identity.handle %}'
hx-target="this"
hx-swap="outerHTML">
Add Image

View file

@ -1,37 +1,52 @@
{% extends "base.html" %}
{% load activity_tags %}
{% load static %}
{% block title %}Home{% endblock %}
{% block content %}
{% if page_obj.number == 1 %}
{% include "_announcements.html" %}
{% endif %}
{% for event in page_obj %}
{% if event.type == "post" %}
{% include "activities/_post.html" with post=event.subject_post %}
{% elif event.type == "boost" %}
<div class="boost-banner">
<a href="{{ event.subject_identity.urls.view }}">
{{ event.subject_identity.html_name_or_handle }}
</a> boosted
<time>
{{ event.subject_post_interaction.published | timedeltashort }} ago
</time>
</div>
{% include "activities/_post.html" with post=event.subject_post %}
{% endif %}
<section class="icon-menu">
<h1 class="above">Identities</h1>
{% for identity in identities %}
<a class="option" href="{{ identity.urls.view }}">
<img src="{{ identity.local_icon_url.relative }}">
<span class="handle">
{{ identity.html_name_or_handle }}
<small>@{{ identity.handle }}</small>
</span>
<button class="right secondary" _="on click go to url {{ identity.urls.view }} then halt">View</button>
<button class="right secondary" _="on click go to url {{ identity.urls.settings }} then halt">Settings</button>
</a>
{% empty %}
Nothing to show yet.
<p class="option empty">You have no identities.</p>
{% endfor %}
<div class="pagination">
{% if page_obj.has_previous and not request.htmx %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}" hx-get=".?page={{ page_obj.next_page_number }}" hx-select=".left-column > *:not(.view-options)" hx-target=".pagination" hx-swap="outerHTML" {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}>Next Page</a>
{% endif %}
<a href="{% url "identity_create" %}" class="option new">
<i class="fa-solid fa-plus"></i> Create a new identity
</a>
</section>
<section>
<h1 class="above">Apps</h1>
<p>
To see your timelines, compose new messages, and follow people,
you will need to use a Mastodon-compatible app. Our favourites
are listed below.
</p>
<div class="flex-icons">
<a href="#">
<img src="{% static "img/apps/elk.svg" %}" alt="Elk logo">
<h2>Elk</h2>
<i>Web/Mobile (Free)</i>
</a>
<a href="#">
<img src="{% static "img/apps/tusky.png" %}" alt="Tusky logo">
<h2>Tusky</h2>
<i>Android (Free)</i>
</a>
<a href="#">
<img src="{% static "img/apps/ivory.webp" %}" alt="Ivory logo">
<h2>Ivory</h2>
<i>iOS (Paid)</i>
</a>
</div>
</section>
{% endblock %}

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}Create Announcement{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>

View file

@ -2,7 +2,7 @@
{% block title %}Delete Announcement - Admin{% endblock %}
{% block content %}
{% block settings_content %}
<h1>Confirm Delete</h1>
<section>
<form action="." method="POST">

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}Announcement #{{ announcement.pk }}{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}Announcements{% endblock %}
{% block content %}
{% block settings_content %}
<div class="view-options">
<a href="{% url "admin_announcement_create" %}" class="button"><i class="fa-solid fa-plus"></i> Create</a>
</div>

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block title %}Add Domain - Admin{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
<h1>Add A Domain</h1>
<p>

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block title %}Delete {{ domain.domain }} - Admin{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
{% csrf_token %}

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}{{ domain.domain }}{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}Domains{% endblock %}
{% block content %}
{% block settings_content %}
<div class="view-options">
<span class="spacer"></span>
<a href="{% url "admin_domains_create" %}" class="button"><i class="fa-solid fa-plus"></i> Add Domain</a>

View file

@ -1,9 +1,9 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% load activity_tags %}
{% block subtitle %}Emoji{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." class="search">
<input type="search" name="query" value="{{ query }}" placeholder="Search by shortcode or domain">
{% if local_only %}

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}{{ emoji.shortcode }}{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %}
<fieldset>

View file

@ -1,9 +1,9 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% load activity_tags %}
{% block subtitle %}Federation{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." class="search">
<input type="search" name="query" value="{{ query }}" placeholder="Search by domain">
<button><i class="fa-solid fa-search"></i></button>

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}{{ domain.domain }}{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
{% csrf_token %}
<h1>{{ domain }}</h1>

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}{{ hashtag.hashtag }}{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}Hashtags{% endblock %}
{% block content %}
{% block settings_content %}
<table class="items">
{% for hashtag in page_obj %}
<tr>

View file

@ -1,9 +1,9 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% load activity_tags %}
{% block subtitle %}Identities{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." class="search">
<input type="search" name="query" value="{{ query }}" placeholder="Search by name/username">
{% if local_only %}

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}{{ identity.name_or_handle }}{% endblock %}
{% block content %}
{% block settings_content %}
<h1>{{ identity.html_name_or_handle }} <small>{{ identity.handle }}</small></h1>
<form action="." method="POST">
{% csrf_token %}

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}Create Invite{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}View Invite{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>

View file

@ -1,9 +1,9 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% load activity_tags %}
{% block subtitle %}Invites{% endblock %}
{% block content %}
{% block settings_content %}
<div class="view-options">
<span class="spacer"></span>
<a href="{% url "admin_invite_create" %}" class="button">Create New</a>

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}Report {{ report.pk }}{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>

View file

@ -1,9 +1,9 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% load activity_tags %}
{% block subtitle %}Reports{% endblock %}
{% block content %}
{% block settings_content %}
<div class="view-options">
{% if all %}
<a href="." class="selected"><i class="fa-solid fa-check"></i> Show Resolved</a>

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}Stator{% endblock %}
{% block content %}
{% block settings_content %}
{% for model, stats in model_stats.items %}
<fieldset>
<legend>{{ model }}</legend>

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% block subtitle %}{{ editing_user.email }}{% endblock %}
{% block content %}
{% block settings_content %}
<h1>{{ editing_user.email }}</h1>
<form action="." method="POST">
{% csrf_token %}

View file

@ -1,9 +1,9 @@
{% extends "settings/base.html" %}
{% extends "admin/base.html" %}
{% load activity_tags %}
{% block subtitle %}Users{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." class="search">
<input type="search" name="query" value="{{ query }}" placeholder="Search by email">
<button><i class="fa-solid fa-search"></i></button>

View file

@ -8,7 +8,6 @@
<link rel="stylesheet" href="{% static "css/style.css" %}" type="text/css" media="screen" />
<link rel="stylesheet" href="{% static "fonts/raleway/raleway.css" %}" type="text/css" />
<link rel="stylesheet" href="{% static "fonts/font_awesome/all.min.css" %}" type="text/css" />
<link rel="manifest" href="/manifest.json" />
<link rel="shortcut icon" href="{{ config.site_icon }}">
<script src="{% static "js/hyperscript.min.js" %}"></script>
<script src="{% static "js/htmx.min.js" %}"></script>
@ -39,61 +38,28 @@
<main>
{% block body_main %}
<header>
<menu>
<a class="logo" href="/">
<img src="{{ config.site_icon }}" width="32">
{{ config.site_name }}
</a>
<menu>
{% if user.is_authenticated %}
<a href="{% url "compose" %}" title="Compose" role="menuitem" {% if top_section == "compose" %}class="selected"{% endif %}>
<i class="fa-solid fa-feather"></i>
</a>
<a href="{% url "search" %}" title="Search" role="menuitem" class="search {% if top_section == "search" %}selected{% endif %}">
<i class="fa-solid fa-search"></i>
</a>
{% if allows_refresh %}
<a href="." title="Refresh" role="menuitem" hx-get="." hx-select=".left-column" hx-target=".left-column" hx-swap="outerHTML" hx-trigger="click, every 120s[isAtTopOfPage()]">
<i class="fa-solid fa-rotate"></i>
</a>
{% endif %}
<div class="gap"></div>
<a href="{{ request.identity.urls.view }}" role="menuitem" class="identity">
{% if not request.identity %}
No Identity
<img src="{% static "img/unknown-icon-128.png" %}" title="No identity selected">
<a href="/" title="My Account"><i class="fa-solid fa-user"></i></a>
{% else %}
{{ request.identity.username }}
<img src="{{ request.identity.local_icon_url.relative }}" title="{{ request.identity.handle }}">
{% endif %}
</a>
{% else %}
<div class="gap"></div>
<a href="{% url "login" %}" role="menuitem" class="identity"><i class="fa-solid fa-right-to-bracket"></i> Login</a>
<a href="{% url "login" %}" title="Login"><i class="fa-solid fa-right-to-bracket"></i></a>
{% endif %}
<a href="/settings" title="Settings"><i class="fa-solid fa-gear"></i></a>
<a href="/admin" title="Administration"><i class="fa-solid fa-screwdriver-wrench"></i></a>
</menu>
</header>
{% block full_content %}
{% include "activities/_image_viewer.html" %}
{% block pre_content %}
{% endblock %}
<div class="columns">
<div class="left-column" id="main-content">
{% block content %}
{% endblock %}
</div>
<div class="right-column" id="side-navigation">
{% block right_content %}
{% include "activities/_menu.html" %}
{% endblock %}
{% include "_footer.html" %}
</div>
</div>
{% endblock %}
{% endblock %}
</main>
{% block footer %}
{% include "_footer.html" %}
{% endblock %}
</body>

View file

@ -1,38 +1,5 @@
{% extends "base.html" %}
{% block body_class %}no-sidebar{% endblock %}
{% block opengraph %}
{# Error pages don't have the context loaded, so disable opengraph to keep it from spewing errors #}
{% endblock %}
{% block body_main %}
<header>
<menu>
{% if not request.user.is_authenticated and current_page == "about" and config.signup_allowed %}
<a href="{% url "signup" %}">Sign Up</a>
{% else %}
<a href="/">Home</a>
{% endif %}
<a class="logo" href="/">
<img src="{{ config.site_icon }}" width="32">
{{ config.site_name }}
</a>
{% if request.user.is_authenticated %}
<a href="javascript:history.back()">Back</a>
{% else %}
<a href="{% url "login" %}">Login</a>
{% endif %}
</menu>
</header>
<div id="main-content">
{% include "activities/_image_viewer.html" %}
{% block content %}
{% endblock %}
</div>
{% endblock %}
{% block footer %}
{% include "_footer.html" %}
{% endblock %}

View file

@ -1,78 +1,19 @@
<div class="inline follow {% if inbound_follow %}has-reverse{% endif %}">
<div class="actions" role="menubar">
{% if request.identity == identity %}
<a href="{% url "settings_profile" %}" class="button" title="Edit Profile">
{% if request.user in identity.users.all %}
<a href="{% url "settings_profile" handle=identity.handle %}" class="button" title="Edit Profile">
<i class="fa-solid fa-user-edit"></i> Edit
</a>
{% elif not inbound_block %}
{% if inbound_follow or outbound_mute %}
<span class="reverse-follow">
{% if inbound_follow %}Follows You{% endif %}{% if inbound_follow and outbound_mute %},{% endif %}
{% if outbound_mute %}Muted{% endif %}
</span>
{% endif %}
<form action="{{ identity.urls.action }}" method="POST" class="inline-menu">
{% csrf_token %}
{% if outbound_block %}
<input type="hidden" name="action" value="unblock">
<button class="destructive" title="Unblock"><i class="fa-solid fa-ban"></i>
Unblock
</button>
{% elif outbound_follow %}
<input type="hidden" name="action" value="unfollow">
<button class="destructive" title="Unfollow"><i class="fa-solid fa-user-minus"></i>
{% if outbound_follow.pending %}Follow Pending{% else %}Unfollow{% endif %}
</button>
{% else %}
<input type="hidden" name="action" value="follow">
<button><i class="fa-solid fa-user-plus"></i> Follow</button>
{% endif %}
</form>
{% endif %}
{% if request.user.admin or request.user.moderator %}
<a title="Menu" class="menu button" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" aria-haspopup="menu" tabindex="0">
<i class="fa-solid fa-bars"></i>
</a>
<menu>
{% if outbound_follow %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
{% if outbound_follow.boosts %}
<input type="hidden" name="action" value="hide_boosts">
<button role="menuitem"><i class="fa-solid fa-retweet"></i> Hide boosts</button>
{% else %}
<input type="hidden" name="action" value="show_boosts">
<button role="menuitem"><i class="fa-solid fa-retweet"></i> Show boosts</button>
{% endif %}
</form>
{% endif %}
{% if request.identity != identity and not outbound_block %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
<input type="hidden" name="action" value="block">
<button role="menuitem"><i class="fa-solid fa-ban"></i> Block user</button>
</form>
{% if outbound_mute %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
<input type="hidden" name="action" value="unmute">
<button role="menuitem"><i class="fa-solid fa-comment-slash"></i> Unmute user</button>
</form>
{% else %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
<input type="hidden" name="action" value="mute">
<button role="menuitem"><i class="fa-solid fa-comment-slash"></i> Mute user</button>
</form>
{% endif %}
{% endif %}
{% if request.user.admin %}
<a href="{{ identity.urls.admin_edit }}" role="menuitem">
<i class="fa-solid fa-user-gear"></i> View in Admin
</a>
<a href="{{ identity.urls.djadmin_edit }}" role="menuitem">
<i class="fa-solid fa-gear"></i> View in djadmin
</a>
{% endif %}
</menu>
{% endif %}
</div>
</div>

View file

@ -1,23 +0,0 @@
{% extends "identity/base.html" %}
{% block title %}Select Identity{% endblock %}
{% block content %}
<section class="icon-menu">
{% for identity in identities %}
<a class="option" href="{{ identity.urls.activate }}">
<img src="{{ identity.local_icon_url.relative }}">
<span class="handle">
{{ identity.html_name_or_handle }}
<small>@{{ identity.handle }}</small>
</span>
<button class="right secondary" _="on click go to url {{ identity.urls.view }} then halt">View</button>
</a>
{% empty %}
<p class="option empty">You have no identities.</p>
{% endfor %}
<a href="{% url "identity_create" %}" class="option new">
<i class="fa-solid fa-plus"></i> Create a new identity
</a>
</section>
{% endblock %}

View file

@ -12,11 +12,8 @@
{% endif %}
{% endblock %}
{% block body_class %}has-banner{% endblock %}
{% block content %}
{% if not request.htmx %}
<h1 class="identity">
<section class="identity">
{% if identity.local_image_url %}
<img src="{{ identity.local_image_url.relative }}" class="banner">
{% endif %}
@ -30,9 +27,9 @@
>
</span>
{% if request.identity %}{% include "identity/_view_menu.html" %}{% endif %}
{% if request.user.moderator or request.user.admin %}{% include "identity/_view_menu.html" %}{% endif %}
{{ identity.html_name_or_handle }}
<h1>{{ identity.html_name_or_handle }}</h1>
<small>
@{{ identity.handle }}
<a title="Copy handle"
@ -46,55 +43,44 @@
<i class="fa-solid fa-copy"></i>
</a>
</small>
</h1>
{% endif %}
{% if inbound_block %}
<p class="system-note">
This user has blocked you.
</p>
{% else %}
{% if not request.htmx %}
{% if identity.summary %}
<div class="bio">
{{ identity.safe_summary }}
</div>
{% endif %}
</section>
<section class="invisible identity-metadata">
{% if identity.metadata %}
<div class="identity-metadata">
{% for entry in identity.safe_metadata %}
<div class="metadata-pair">
<span class="metadata-name">{{ entry.name }}</span>
<span class="metadata-value">{{ entry.value }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</section>
<div class="view-options follows">
{% if not identity.local %}
<section class="system-note">
{% if identity.outdated and not identity.name %}
The system is still fetching this profile. Refresh to see updates.
{% else %}
This is a member of another server.
<a href="{{ identity.profile_uri|default:identity.actor_uri }}">See their original profile ➔</a>
{% endif %}
</section>
{% else %}
<section class="view-options">
<a href="{{ identity.urls.view }}" {% if not follows_page %}class="selected"{% endif %}><strong>{{ post_count }}</strong> posts</a>
{% if identity.local and identity.config_identity.visible_follows %}
<a href="{{ identity.urls.following }}" {% if not inbound and follows_page %}class="selected"{% endif %}><strong>{{ following_count }}</strong> following</a>
<a href="{{ identity.urls.followers }}" {% if inbound and follows_page %}class="selected"{% endif %}><strong>{{ followers_count }}</strong> follower{{ followers_count|pluralize }}</a>
{% endif %}
</div>
{% if not identity.local %}
{% if identity.outdated and not identity.name %}
<p class="system-note">
The system is still fetching this profile. Refresh to see updates.
</p>
{% else %}
<p class="system-note">
This is a member of another server.
<a href="{{ identity.profile_uri|default:identity.actor_uri }}">See their original profile ➔</a>
</p>
{% endif %}
{% endif %}
{% endif %}
</section>
<section class="invisible">
{% block subcontent %}
{% for post in page_obj %}
@ -125,5 +111,6 @@
</div>
{% endblock %}
</section>
{% endif %}
{% endblock %}

View file

@ -1,18 +1,20 @@
<nav>
<h3>Identity</h3>
<a href="{% url "settings_profile" %}" {% if section == "profile" %}class="selected"{% endif %} title="Profile">
{% if identity %}
{% include "identity/_identity_banner.html" %}
<a href="{% url "settings_profile" handle=identity.handle %}" {% if section == "profile" %}class="selected"{% endif %} title="Profile">
<i class="fa-solid fa-user"></i>
<span>Profile</span>
</a>
<a href="{% url "settings_interface" %}" {% if section == "interface" %}class="selected"{% endif %} title="Interface">
<a href="{% url "settings_interface" handle=identity.handle %}" {% if section == "interface" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-display"></i>
<span>Interface</span>
</a>
<a href="{% url "settings_import_export" %}" {% if section == "importexport" %}class="selected"{% endif %} title="Interface">
<a href="{% url "settings_import_export" handle=identity.handle %}" {% if section == "importexport" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>Import/Export</span>
</a>
<hr>
{% endif %}
<h3>Account</h3>
<a href="{% url "settings_security" %}" {% if section == "security" %}class="selected"{% endif %} title="Login &amp; Security">
<i class="fa-solid fa-key"></i>
@ -22,64 +24,4 @@
<i class="fa-solid fa-right-from-bracket" title="Logout"></i>
<span>Logout</span>
</a>
{% if request.user.moderator or request.user.admin %}
<hr>
<h3>Moderation</h3>
<a href="{% url "admin_identities" %}" {% if section == "identities" %}class="selected"{% endif %} title="Identities">
<i class="fa-solid fa-id-card"></i>
<span>Identities</span>
</a>
<a href="{% url "admin_invites" %}" {% if section == "invites" %}class="selected"{% endif %} title="Invites">
<i class="fa-solid fa-envelope"></i>
<span>Invites</span>
</a>
<a href="{% url "admin_hashtags" %}" {% if section == "hashtags" %}class="selected"{% endif %} title="Hashtags">
<i class="fa-solid fa-hashtag"></i>
<span>Hashtags</span>
</a>
<a href="{% url "admin_emoji" %}" {% if section == "emoji" %}class="selected"{% endif %} title="Emoji">
<i class="fa-solid fa-icons"></i>
<span>Emoji</span>
</a>
<a href="{% url "admin_reports" %}" {% if section == "reports" %}class="selected"{% endif %} title="Reports">
<i class="fa-solid fa-flag"></i>
<span>Reports</span>
</a>
{% endif %}
{% if request.user.admin %}
<hr>
<h3>Administration</h3>
<a href="{% url "admin_basic" %}" {% if section == "basic" %}class="selected"{% endif %} title="Basic">
<i class="fa-solid fa-book"></i>
<span>Basic</span>
</a>
<a href="{% url "admin_policies" %}" {% if section == "policies" %}class="selected"{% endif %} title="Policies">
<i class="fa-solid fa-file-lines"></i>
<span>Policies</span>
</a>
<a href="{% url "admin_announcements" %}" {% if section == "announcements" %}class="selected"{% endif %} title="Announcements">
<i class="fa-solid fa-bullhorn"></i>
<span>Announcements</span>
</a>
<a href="{% url "admin_domains" %}" {% if section == "domains" %}class="selected"{% endif %} title="Domains">
<i class="fa-solid fa-globe"></i>
<span>Domains</span>
</a>
<a href="{% url "admin_federation" %}" {% if section == "federation" %}class="selected"{% endif %} title="Federation">
<i class="fa-solid fa-diagram-project"></i>
<span>Federation</span>
</a>
<a href="{% url "admin_users" %}" {% if section == "users" %}class="selected"{% endif %} title="Users">
<i class="fa-solid fa-users"></i>
<span>Users</span>
</a>
<a href="{% url "admin_stator" %}" {% if section == "stator" %}class="selected"{% endif %} title="Stator">
<i class="fa-solid fa-clock-rotate-left"></i>
<span>Stator</span>
</a>
<a href="/djadmin" title="Django Admin" class="danger">
<i class="fa-solid fa-gear"></i>
<span>Django Admin</span>
</a>
{% endif %}
</nav>

View file

@ -2,6 +2,12 @@
{% block title %}{% block subtitle %}{% endblock %} - Settings{% endblock %}
{% block right_content %}
{% block content %}
<div class="settings">
{% include "settings/_menu.html" %}
<div class="settings-content">
{% block settings_content %}
{% endblock %}
</div>
</div>
{% endblock %}

View file

@ -2,7 +2,7 @@
{% block subtitle %}Import/Export{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %}

View file

@ -2,7 +2,7 @@
{% block subtitle %}Login &amp; Security{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>

View file

@ -2,7 +2,7 @@
{% block subtitle %}Profile{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST" enctype="multipart/form-data"
_="on submit metadata.collectMetadataFields()">
{% csrf_token %}

View file

@ -2,7 +2,7 @@
{% block subtitle %}{{ section.title }}{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %}
{% for title, fields in fieldsets.items %}

View file

@ -5,30 +5,6 @@ from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect
def identity_required(function):
"""
Decorator for views that ensures an active identity is selected.
"""
@wraps(function)
def inner(request, *args, **kwargs):
# They do have to be logged in
if not request.user.is_authenticated:
return redirect_to_login(next=request.get_full_path())
# If there's no active one, try to auto-select one
if request.identity is None:
possible_identities = list(request.user.identities.all())
if len(possible_identities) != 1:
# OK, send them to the identity selection page to select/create one
return HttpResponseRedirect("/identity/select/")
identity = possible_identities[0]
request.session["identity_id"] = identity.pk
request.identity = identity
return function(request, *args, **kwargs)
return inner
def moderator_required(function):
return user_passes_test(
lambda user: user.is_authenticated and (user.admin or user.moderator)

View file

@ -1,39 +0,0 @@
from django.utils import timezone
from users.models import Identity, User
class IdentityMiddleware:
"""
Adds a request.identity object which is either the current session's
identity, or None if they have not picked one yet/it's invalid.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# The API middleware might have set identity already
if not hasattr(request, "identity"):
# See if we have one in the session
identity_id = request.session.get("identity_id")
if not identity_id:
request.identity = None
else:
# Pull it out of the DB and assign it
try:
request.identity = Identity.objects.get(id=identity_id)
User.objects.filter(pk=request.user.pk).update(
last_seen=timezone.now()
)
except Identity.DoesNotExist:
request.identity = None
response = self.get_response(request)
if request.user:
response.headers["X-Takahe-User"] = str(request.user)
if request.identity:
response.headers["X-Takahe-Identity"] = str(request.identity)
return response

View file

@ -111,7 +111,6 @@ class IdentityStates(StateGraph):
@classmethod
async def handle_outdated(cls, identity: "Identity"):
# Local identities never need fetching
if identity.local:
return cls.updated
@ -231,6 +230,7 @@ class Identity(StatorModel):
class urls(urlman.Urls):
view = "/@{self.username}@{self.domain_id}/"
settings = "{view}settings/"
action = "{view}action/"
followers = "{view}followers/"
following = "{view}following/"

View file

@ -14,6 +14,7 @@ class AdminSettingsPage(SettingsPage):
"""
options_class = Config.SystemOptions
template_name = "admin/settings.html"
def load_config(self):
return Config.load_system()

View file

@ -3,12 +3,12 @@ from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django.views.generic import View
from users.decorators import identity_required
from django.contrib.auth.decorators import login_required
from users.models import Announcement
from users.services import AnnouncementService
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class AnnouncementDismiss(View):
"""
Dismisses an announcement for the current user

View file

@ -17,7 +17,7 @@ from activities.services import TimelineService
from core.decorators import cache_page, cache_page_by_ap_json
from core.ld import canonicalise
from core.models import Config
from users.decorators import identity_required
from django.contrib.auth.decorators import login_required
from users.models import Domain, FollowStates, Identity, IdentityStates
from users.services import IdentityService
from users.shortcuts import by_handle_or_404
@ -65,15 +65,11 @@ class ViewIdentity(ListView):
)
def get_queryset(self):
return TimelineService(self.request.identity).identity_public(self.identity)
return TimelineService(None).identity_public(self.identity)
def get_context_data(self):
context = super().get_context_data()
context["identity"] = self.identity
context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"],
self.request.identity,
)
context["post_count"] = self.identity.posts.count()
if self.identity.config_identity.visible_follows:
context["followers_count"] = self.identity.inbound_follows.filter(
@ -82,10 +78,6 @@ class ViewIdentity(ListView):
context["following_count"] = self.identity.outbound_follows.filter(
state__in=FollowStates.group_active()
).count()
if self.request.identity:
context.update(
IdentityService(self.identity).relationships(self.request.identity)
)
return context
@ -242,7 +234,7 @@ class IdentityFollows(ListView):
return context
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class ActionIdentity(View):
def post(self, request, handle):
identity = by_handle_or_404(self.request, handle, local=False)
@ -269,30 +261,6 @@ class ActionIdentity(View):
return redirect(identity.urls.view)
@method_decorator(login_required, name="dispatch")
class SelectIdentity(TemplateView):
template_name = "identity/select.html"
def get_context_data(self):
return {
"identities": Identity.objects.filter(users__pk=self.request.user.pk),
}
@method_decorator(login_required, name="dispatch")
class ActivateIdentity(View):
def get(self, request, handle):
identity = by_handle_or_404(request, handle)
if not identity.users.filter(pk=request.user.pk).exists():
raise Http404()
request.session["identity_id"] = identity.id
# Get next URL, not allowing offsite links
next = request.GET.get("next") or "/"
if ":" in next:
next = "/"
return redirect("/")
@method_decorator(login_required, name="dispatch")
class CreateIdentity(FormView):
template_name = "identity/create.html"

View file

@ -3,12 +3,12 @@ from django.shortcuts import get_object_or_404, render
from django.utils.decorators import method_decorator
from django.views.generic import FormView
from users.decorators import identity_required
from django.contrib.auth.decorators import login_required
from users.models import Report
from users.shortcuts import by_handle_or_404
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class SubmitReport(FormView):
"""
Submits a report on a user or a post

View file

@ -1,7 +1,8 @@
from django.utils.decorators import method_decorator
from django.views.generic import RedirectView
from django.views.generic import View
from django.shortcuts import redirect
from users.decorators import identity_required
from django.contrib.auth.decorators import login_required
from users.views.settings.import_export import ( # noqa
CsvFollowers,
CsvFollowing,
@ -13,6 +14,14 @@ from users.views.settings.security import SecurityPage # noqa
from users.views.settings.settings_page import SettingsPage # noqa
@method_decorator(identity_required, name="dispatch")
class SettingsRoot(RedirectView):
pattern_name = "settings_profile"
@method_decorator(login_required, name="dispatch")
class SettingsRoot(View):
"""
Redirects to a root settings page (varying on if there is an identity
in the URL or not)
"""
def get(self, request, handle: str | None = None):
if handle:
return redirect("settings_profile", handle=handle)
return redirect("settings_security")

View file

@ -6,11 +6,11 @@ from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.views.generic import FormView, View
from users.decorators import identity_required
from django.contrib.auth.decorators import login_required
from users.models import Follow, InboxMessage
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class ImportExportPage(FormView):
"""
Lets the identity's profile be edited
@ -116,7 +116,6 @@ class CsvView(View):
class CsvFollowing(CsvView):
columns = {
"Account address": "get_handle",
"Show boosts": "boosts",
@ -140,7 +139,6 @@ class CsvFollowing(CsvView):
class CsvFollowers(CsvView):
columns = {
"Account address": "get_handle",
}

View file

@ -3,14 +3,9 @@ from users.views.settings.settings_page import SettingsPage
class InterfacePage(SettingsPage):
section = "interface"
options = {
"toot_mode": {
"title": "I Will Toot As I Please",
"help_text": "Changes all 'Post' buttons to 'Toot!'",
},
"default_post_visibility": {
"title": "Default Post Visibility",
"help_text": "Visibility to use as default for new posts.",
@ -21,23 +16,11 @@ class InterfacePage(SettingsPage):
"help_text": "Visibility to use as default for replies.",
"choices": Post.Visibilities.choices,
},
"visible_reaction_counts": {
"title": "Show Boost and Like Counts",
"help_text": "Disable to hide the number of Likes and Boosts on a 'Post'",
},
"custom_css": {
"title": "Custom CSS",
"help_text": "Theme the website however you'd like, just for you. You should probably not use this unless you know what you're doing.",
"display": "textarea",
},
"expand_linked_cws": {
"title": "Expand linked Content Warnings",
"help_text": "Expands all CWs of the same type at once, rather than one at a time.",
},
"infinite_scroll": {
"title": "Infinite Scroll",
"help_text": "Automatically loads more posts when you get to the bottom of a timeline.",
},
"light_theme": {
"title": "Light Mode",
"help_text": "Use a light theme rather than the default dark theme.",
@ -45,7 +28,6 @@ class InterfacePage(SettingsPage):
}
layout = {
"Posting": ["toot_mode", "default_post_visibility", "default_reply_visibility"],
"Wellness": ["infinite_scroll", "visible_reaction_counts", "expand_linked_cws"],
"Posting": ["default_post_visibility", "default_reply_visibility"],
"Appearance": ["light_theme", "custom_css"],
}

View file

@ -6,12 +6,13 @@ from django.views.generic import FormView
from core.html import FediverseHtmlParser
from core.models.config import Config
from users.decorators import identity_required
from django.contrib.auth.decorators import login_required
from users.models import IdentityStates
from users.shortcuts import by_handle_or_404
from users.services import IdentityService
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class ProfilePage(FormView):
"""
Lets the identity's profile be edited
@ -61,28 +62,35 @@ class ProfilePage(FormView):
return None
return metadata
def dispatch(self, request, handle: str, *args, **kwargs):
self.identity = by_handle_or_404(self.request, handle, local=True, fetch=False)
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["identity"] = self.identity
return context
def get_initial(self):
identity = self.request.identity
return {
"name": identity.name,
"name": self.identity.name,
"summary": (
FediverseHtmlParser(identity.summary).plain_text
if identity.summary
FediverseHtmlParser(self.identity.summary).plain_text
if self.identity.summary
else ""
),
"icon": identity.icon and identity.icon.url,
"image": identity.image and identity.image.url,
"discoverable": identity.discoverable,
"visible_follows": identity.config_identity.visible_follows,
"metadata": identity.metadata or [],
"icon": self.identity.icon and self.identity.icon.url,
"image": self.identity.image and self.identity.image.url,
"discoverable": self.identity.discoverable,
"visible_follows": self.identity.config_identity.visible_follows,
"metadata": self.identity.metadata or [],
}
def form_valid(self, form):
# Update basic info
identity = self.request.identity
service = IdentityService(identity)
identity.name = form.cleaned_data["name"]
identity.discoverable = form.cleaned_data["discoverable"]
service = IdentityService(self.identity)
self.identity.name = form.cleaned_data["name"]
self.identity.discoverable = form.cleaned_data["discoverable"]
service.set_summary(form.cleaned_data["summary"])
# Resize images
icon = form.cleaned_data.get("icon")
@ -91,20 +99,20 @@ class ProfilePage(FormView):
service.set_icon(icon)
if isinstance(image, File):
service.set_image(image)
identity.metadata = form.cleaned_data.get("metadata")
self.identity.metadata = form.cleaned_data.get("metadata")
# Clear images if specified
if "icon__clear" in self.request.POST:
identity.icon = None
self.identity.icon = None
if "image__clear" in self.request.POST:
identity.image = None
self.identity.image = None
# Save and propagate
identity.save()
identity.transition_perform(IdentityStates.edited)
self.identity.save()
self.identity.transition_perform(IdentityStates.edited)
# Save profile-specific identity Config
Config.set_identity(
identity, "visible_follows", form.cleaned_data["visible_follows"]
self.identity, "visible_follows", form.cleaned_data["visible_follows"]
)
return redirect(".")

View file

@ -2,10 +2,10 @@ from django import forms
from django.utils.decorators import method_decorator
from django.views.generic import FormView
from users.decorators import identity_required
from django.contrib.auth.decorators import login_required
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class SecurityPage(FormView):
"""
Lets the identity's profile be edited

View file

@ -8,10 +8,11 @@ from django.utils.decorators import method_decorator
from django.views.generic import FormView
from core.models.config import Config, UploadedImage
from users.decorators import identity_required
from django.contrib.auth.decorators import login_required
from users.shortcuts import by_handle_or_404
@method_decorator(identity_required, name="dispatch")
@method_decorator(login_required, name="dispatch")
class SettingsPage(FormView):
"""
Shows a settings page dynamically created from our settings layout
@ -67,11 +68,16 @@ class SettingsPage(FormView):
# Create a form class dynamically (yeah, right?) and return that
return type("SettingsForm", (forms.Form,), fields)
def dispatch(self, request, *args, **kwargs):
if "handle" in kwargs:
self.identity = by_handle_or_404(request, kwargs["handle"])
return super().dispatch(request, *args, **kwargs)
def load_config(self):
return Config.load_identity(self.request.identity)
return Config.load_identity(self.identity)
def save_config(self, key, value):
Config.set_identity(self.request.identity, key, value)
Config.set_identity(self.identity, key, value)
def get_initial(self):
config = self.load_config()
@ -87,6 +93,8 @@ class SettingsPage(FormView):
context["fieldsets"] = {}
for title, fields in self.layout.items():
context["fieldsets"][title] = [context["form"][field] for field in fields]
if hasattr(self, "identity"):
context["identity"] = self.identity
return context
def form_valid(self, form):