UI/Domains Refactor

Redoes the UI to remove timelines, promote domains, and a lot of other things to support the refactor.
This commit is contained in:
Andrew Godwin 2023-05-03 22:42:37 -06:00 committed by GitHub
parent 7331591432
commit 8f57aa5f37
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
135 changed files with 1966 additions and 2381 deletions

View file

@ -18,14 +18,10 @@ jobs:
python-version: ["3.10", "3.11"]
db:
- "postgres://postgres:postgres@localhost/postgres"
- "sqlite:///takahe.db"
include:
- db: "postgres://postgres:postgres@localhost/postgres"
db_name: postgres
search: true
- db: "sqlite:///takahe.db"
db_name: sqlite
search: false
services:
postgres:
image: postgres:15

View file

@ -0,0 +1,24 @@
# Generated by Django 4.2 on 2023-04-29 18:49
import django.contrib.postgres.indexes
import django.contrib.postgres.search
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("activities", "0013_postattachment_author"),
]
operations = [
migrations.AddIndex(
model_name="post",
index=django.contrib.postgres.indexes.GinIndex(
django.contrib.postgres.search.SearchVector(
"content", config="english"
),
name="content_vector_gin",
),
),
]

View file

@ -11,6 +11,7 @@ import httpx
import urlman
from asgiref.sync import async_to_sync, sync_to_async
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector
from django.db import models, transaction
from django.template import loader
from django.template.defaultfilters import linebreaks_filter
@ -312,6 +313,10 @@ class Post(StatorModel):
class Meta:
indexes = [
GinIndex(fields=["hashtags"], name="hashtags_gin"),
GinIndex(
SearchVector("content", config="english"),
name="content_vector_gin",
),
models.Index(
fields=["visibility", "local", "published"],
name="ix_post_local_public_published",

View file

@ -72,7 +72,12 @@ class PostService:
def unboost_as(self, identity: Identity):
self.uninteract_as(identity, PostInteraction.Types.boost)
def context(self, identity: Identity | None) -> tuple[list[Post], list[Post]]:
def context(
self,
identity: Identity | None,
num_ancestors: int = 10,
num_descendants: int = 50,
) -> tuple[list[Post], list[Post]]:
"""
Returns ancestor/descendant information.
@ -82,7 +87,6 @@ class PostService:
If identity is provided, includes mentions/followers-only posts they
can see. Otherwise, shows unlisted and above only.
"""
num_ancestors = 10
num_descendants = 50
# Retrieve ancestors via parent walk
ancestors: list[Post] = []

View file

@ -123,6 +123,12 @@ class SearchService:
results.add(hashtag)
return results
def search_post_content(self):
"""
Searches for posts on an identity via full text search
"""
return self.identity.posts.filter(content__search=self.query)[:50]
def search_all(self):
"""
Returns all possible results for a search

View file

@ -47,12 +47,15 @@ class TimelineService:
)
def local(self) -> models.QuerySet[Post]:
return (
queryset = (
PostService.queryset()
.local_public()
.filter(author__restriction=Identity.Restriction.none)
.order_by("-id")
)
if self.identity is not None:
queryset = queryset.filter(author__domain=self.identity.domain)
return queryset
def federated(self) -> models.QuerySet[Post]:
return (

View file

@ -1,27 +1,17 @@
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect, render
from django.contrib import messages
from django.shortcuts import redirect
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.generic import FormView
from activities.models import (
Post,
PostAttachment,
PostAttachmentStates,
PostStates,
TimelineEvent,
)
from activities.models import Post, PostAttachment, PostAttachmentStates, TimelineEvent
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.views.base import IdentityViewMixin
@method_decorator(identity_required, name="dispatch")
class Compose(FormView):
class Compose(IdentityViewMixin, FormView):
template_name = "activities/compose.html"
class form_class(forms.Form):
@ -33,6 +23,7 @@ class Compose(FormView):
},
)
)
visibility = forms.ChoiceField(
choices=[
(Post.Visibilities.public, "Public"),
@ -42,6 +33,7 @@ class Compose(FormView):
(Post.Visibilities.mentioned, "Mentioned Only"),
],
)
content_warning = forms.CharField(
required=False,
label=Config.lazy_system_value("content_warning_text"),
@ -52,11 +44,42 @@ class Compose(FormView):
),
help_text="Optional - Post will be hidden behind this text until clicked",
)
reply_to = forms.CharField(widget=forms.HiddenInput(), required=False)
def __init__(self, request, *args, **kwargs):
image = forms.ImageField(
required=False,
help_text="Optional - For multiple image uploads and cropping, please use an app",
widget=forms.FileInput(
attrs={
"_": f"""
on change
if me.files[0].size > {settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB * 1024 ** 2}
add [@disabled=] to #upload
remove <ul.errorlist/>
make <ul.errorlist/> called errorlist
make <li/> called error
set size_in_mb to (me.files[0].size / 1024 / 1024).toFixed(2)
put 'File must be {settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB}MB or less (actual: ' + size_in_mb + 'MB)' into error
put error into errorlist
put errorlist before me
else
remove @disabled from #upload
remove <ul.errorlist/>
end
end
"""
}
),
)
image_caption = forms.CharField(
required=False,
help_text="Provide an image caption for the visually impaired",
)
def __init__(self, identity, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.identity = identity
self.fields["text"].widget.attrs[
"_"
] = rf"""
@ -83,7 +106,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()
@ -102,184 +125,75 @@ class Compose(FormView):
)
return text
def clean_image(self):
value = self.cleaned_data.get("image")
if value:
max_mb = settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB
max_bytes = max_mb * 1024 * 1024
if value.size > max_bytes:
# Erase the file from our data to stop trying to show it again
self.files = {}
raise forms.ValidationError(
f"File must be {max_mb}MB or less (actual: {value.size / 1024 ** 2:.2f})"
)
return value
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()
if self.post_obj:
initial.update(
{
"reply_to": self.reply_to.pk if self.reply_to else "",
"visibility": self.post_obj.visibility,
"text": FediverseHtmlParser(self.post_obj.content).plain_text,
"content_warning": self.post_obj.summary,
}
)
else:
initial[
"visibility"
] = self.request.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
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)
initial["text"] = "".join(
f"@{identity.handle} "
for identity in mentioned
if identity.username
)
initial["visibility"] = self.identity.config_identity.default_post_visibility
return initial
def form_valid(self, form):
# Gather any attachment objects now, they're not in the form proper
# See if we need to make an image attachment
attachments = []
if "attachment" in self.request.POST:
attachments = PostAttachment.objects.filter(
pk__in=self.request.POST.getlist("attachment", [])
if form.cleaned_data.get("image"):
main_file = resize_image(
form.cleaned_data["image"],
size=(2000, 2000),
cover=False,
)
# Dispatch based on edit or not
if self.post_obj:
self.post_obj.edit_local(
content=form.cleaned_data["text"],
summary=form.cleaned_data.get("content_warning"),
visibility=form.cleaned_data["visibility"],
attachments=attachments,
thumbnail_file = resize_image(
form.cleaned_data["image"],
size=(400, 225),
cover=True,
)
self.post_obj.transition_perform(PostStates.edited)
else:
post = Post.create_local(
author=self.request.identity,
content=form.cleaned_data["text"],
summary=form.cleaned_data.get("content_warning"),
visibility=form.cleaned_data["visibility"],
reply_to=self.reply_to,
attachments=attachments,
attachment = PostAttachment.objects.create(
blurhash=blurhash_image(thumbnail_file),
mimetype="image/webp",
width=main_file.image.width,
height=main_file.image.height,
name=form.cleaned_data.get("image_caption"),
state=PostAttachmentStates.fetched,
author=self.identity,
)
# Add their own timeline event for immediate visibility
TimelineEvent.add_post(self.request.identity, post)
return redirect("/")
def dispatch(self, request, handle=None, post_id=None, *args, **kwargs):
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)
# Grab the reply-to post info now
self.reply_to = None
reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to")
if reply_to_id:
try:
self.reply_to = Post.objects.get(pk=reply_to_id)
except Post.DoesNotExist:
pass
# Keep going with normal rendering
return super().dispatch(request, *args, **kwargs)
attachment.file.save(
main_file.name,
main_file,
)
attachment.thumbnail.save(
thumbnail_file.name,
thumbnail_file,
)
attachment.save()
attachments.append(attachment)
# Create the post
post = Post.create_local(
author=self.identity,
content=form.cleaned_data["text"],
summary=form.cleaned_data.get("content_warning"),
visibility=form.cleaned_data["visibility"],
attachments=attachments,
)
# Add their own timeline event for immediate visibility
TimelineEvent.add_post(self.identity, post)
messages.success(self.request, "Your post was created.")
return redirect(".")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["reply_to"] = self.reply_to
if self.post_obj:
context["post"] = self.post_obj
context["identity"] = self.identity
context["section"] = "compose"
return context
@method_decorator(identity_required, name="dispatch")
class ImageUpload(FormView):
"""
Handles image upload - returns a new input type hidden to embed in
the main form that references an orphaned PostAttachment
"""
template_name = "activities/_image_upload.html"
class form_class(forms.Form):
image = forms.ImageField(
widget=forms.FileInput(
attrs={
"_": f"""
on change
if me.files[0].size > {settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB * 1024 ** 2}
add [@disabled=] to #upload
remove <ul.errorlist/>
make <ul.errorlist/> called errorlist
make <li/> called error
set size_in_mb to (me.files[0].size / 1024 / 1024).toFixed(2)
put 'File must be {settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB}MB or less (actual: ' + size_in_mb + 'MB)' into error
put error into errorlist
put errorlist before me
else
remove @disabled from #upload
remove <ul.errorlist/>
end
end
"""
}
)
)
description = forms.CharField(required=False)
def clean_image(self):
value = self.cleaned_data["image"]
max_mb = settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB
max_bytes = max_mb * 1024 * 1024
if value.size > max_bytes:
# Erase the file from our data to stop trying to show it again
self.files = {}
raise forms.ValidationError(
f"File must be {max_mb}MB or less (actual: {value.size / 1024 ** 2:.2f})"
)
return value
def form_invalid(self, form):
return super().form_invalid(form)
def form_valid(self, form):
# Make a PostAttachment
main_file = resize_image(
form.cleaned_data["image"],
size=(2000, 2000),
cover=False,
)
thumbnail_file = resize_image(
form.cleaned_data["image"],
size=(400, 225),
cover=True,
)
attachment = PostAttachment.objects.create(
blurhash=blurhash_image(thumbnail_file),
mimetype="image/webp",
width=main_file.image.width,
height=main_file.image.height,
name=form.cleaned_data.get("description"),
state=PostAttachmentStates.fetched,
author=self.request.identity,
)
attachment.file.save(
main_file.name,
main_file,
)
attachment.thumbnail.save(
thumbnail_file.name,
thumbnail_file,
)
attachment.save()
# Return the response, with a hidden input plus a note
return render(
self.request, "activities/_image_uploaded.html", {"attachment": attachment}
)

View file

@ -1,26 +0,0 @@
from django.views.generic import ListView
from activities.models import Hashtag
class ExploreTag(ListView):
template_name = "activities/explore_tag.html"
extra_context = {
"current_page": "explore",
"allows_refresh": True,
}
paginate_by = 20
def get_queryset(self):
return (
Hashtag.objects.public()
.filter(
stats__total__gt=0,
)
.order_by("-stats__total")
)[:20]
class Explore(ExploreTag):
pass

View file

@ -1,38 +0,0 @@
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect, render
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
@method_decorator(identity_required, name="dispatch")
class HashtagFollow(View):
"""
Follows/unfollows a hashtag with the current identity
"""
undo = False
def post(self, request: HttpRequest, hashtag):
hashtag = get_object_or_404(
Hashtag,
pk=hashtag,
)
follow = None
if self.undo:
request.identity.hashtag_follows.filter(hashtag=hashtag).delete()
else:
follow = request.identity.hashtag_follows.get_or_create(hashtag=hashtag)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
request,
"activities/_hashtag_follow.html",
{
"hashtag": hashtag,
"follow": follow,
},
)
return redirect(hashtag.urls.view)

View file

@ -1,15 +1,13 @@
from django.core.exceptions import PermissionDenied
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator
from django.views.decorators.vary import vary_on_headers
from django.views.generic import TemplateView, View
from django.views.generic import TemplateView
from activities.models import Post, PostInteraction, PostStates
from activities.models import Post, 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 users.models import Identity
from users.shortcuts import by_handle_or_404
@ -19,7 +17,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 +29,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]:
@ -49,20 +46,17 @@ class Individual(TemplateView):
context = super().get_context_data(**kwargs)
ancestors, descendants = PostService(self.post_obj).context(
self.request.identity
identity=None, num_ancestors=2
)
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,
"public_styling": True,
}
)
@ -76,128 +70,3 @@ class Individual(TemplateView):
canonicalise(self.post_obj.to_ap(), include_security=True),
content_type="application/activity+json",
)
@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")
class Delete(TemplateView):
"""
Deletes a post
"""
template_name = "activities/post_delete.html"
def dispatch(self, request, handle, post_id):
# Make sure the request identity owns the post!
if handle != request.identity.handle:
raise PermissionDenied("Post author is not requestor")
self.identity = by_handle_or_404(self.request, handle, local=False)
self.post_obj = get_object_or_404(self.identity.posts, pk=post_id)
return super().dispatch(request)
def get_context_data(self):
return {"post": self.post_obj}
def post(self, request):
PostService(self.post_obj).delete()
return redirect("/")

View file

@ -1,22 +0,0 @@
from django import forms
from django.views.generic import FormView
from activities.services import SearchService
class Search(FormView):
template_name = "activities/search.html"
class form_class(forms.Form):
query = forms.CharField(
help_text="Search for:\nA user by @username@domain or their profile URL\nA hashtag by #tagname\nA post by its URL",
widget=forms.TextInput(attrs={"type": "search", "autofocus": "autofocus"}),
)
def form_valid(self, form):
searcher = SearchService(form.cleaned_data["query"], self.request.identity)
# Render results
context = self.get_context_data(form=form)
context["results"] = searcher.search_all()
return self.render_to_response(context)

View file

@ -1,53 +1,35 @@
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator
from django.views.generic import ListView, TemplateView
from activities.models import Hashtag, PostInteraction, TimelineEvent
from activities.models import Hashtag, 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 users.models import Identity
from users.views.base import IdentityViewMixin
@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",
@ -64,77 +46,15 @@ class Tag(ListView):
return super().get(request, *args, **kwargs)
def get_queryset(self):
return TimelineService(self.request.identity).hashtag(self.hashtag)
return TimelineService(None).hashtag(self.hashtag)
def get_context_data(self):
context = super().get_context_data()
context["hashtag"] = self.hashtag
context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
context["follow"] = HashtagFollow.maybe_get(
self.request.identity,
self.hashtag,
)
return context
@method_decorator(
cache_page("cache_timeout_page_timeline", public_only=True), name="dispatch"
)
class Local(ListView):
template_name = "activities/local.html"
extra_context = {
"current_page": "local",
"allows_refresh": True,
}
paginate_by = 25
def get_queryset(self):
return TimelineService(self.request.identity).local()
def get_context_data(self):
context = super().get_context_data()
context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
return context
@method_decorator(identity_required, name="dispatch")
class Federated(ListView):
template_name = "activities/federated.html"
extra_context = {
"current_page": "federated",
"allows_refresh": True,
}
paginate_by = 25
def get_queryset(self):
return TimelineService(self.request.identity).federated()
def get_context_data(self):
context = super().get_context_data()
context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
return context
@method_decorator(identity_required, name="dispatch")
class Notifications(ListView):
class Notifications(IdentityViewMixin, ListView):
template_name = "activities/notifications.html"
extra_context = {
"current_page": "notifications",
@ -146,7 +66,6 @@ class Notifications(ListView):
"boosted": TimelineEvent.Types.boosted,
"mentioned": TimelineEvent.Types.mentioned,
"liked": TimelineEvent.Types.liked,
"identity_created": TimelineEvent.Types.identity_created,
}
def get_queryset(self):
@ -164,7 +83,7 @@ class Notifications(ListView):
for type_name, type in self.notification_types.items():
if notification_options.get(type_name, True):
types.append(type)
return TimelineService(self.request.identity).notifications(types)
return TimelineService(self.identity).notifications(types)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -185,12 +104,6 @@ class Notifications(ListView):
events.append(event)
# Retrieve what kinds of things to show
context["events"] = events
context["identity"] = self.identity
context["notification_options"] = self.request.session["notification_options"]
context["interactions"] = PostInteraction.get_event_interactions(
context["page_obj"],
self.request.identity,
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"], "subject_post_id"
)
return context

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

@ -1,3 +1,5 @@
import secrets
from django.db import models
@ -17,3 +19,23 @@ class Application(models.Model):
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@classmethod
def create(
cls,
client_name: str,
redirect_uris: str,
website: str | None,
scopes: str | None = None,
):
client_id = "tk-" + secrets.token_urlsafe(16)
client_secret = secrets.token_urlsafe(40)
return cls.objects.create(
name=client_name,
website=website,
client_id=client_id,
client_secret=client_secret,
redirect_uris=redirect_uris,
scopes=scopes or "read",
)

View file

@ -1,3 +1,4 @@
import urlman
from django.db import models
@ -37,6 +38,9 @@ class Token(models.Model):
updated = models.DateTimeField(auto_now=True)
revoked = models.DateTimeField(blank=True, null=True)
class urls(urlman.Urls):
edit = "/@{self.identity.handle}/settings/tokens/{self.id}/"
def has_scope(self, scope: str):
"""
Returns if this token has the given scope.

View file

@ -1,5 +1,3 @@
import secrets
from hatchway import QueryOrBody, api_view
from .. import schemas
@ -14,14 +12,10 @@ def add_app(
scopes: QueryOrBody[None | str] = None,
website: QueryOrBody[None | str] = None,
) -> schemas.Application:
client_id = "tk-" + secrets.token_urlsafe(16)
client_secret = secrets.token_urlsafe(40)
application = Application.objects.create(
name=client_name,
application = Application.create(
client_name=client_name,
website=website,
client_id=client_id,
client_secret=client_secret,
redirect_uris=redirect_uris,
scopes=scopes or "read",
scopes=scopes,
)
return schemas.Application.from_orm(application)

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

@ -20,16 +20,6 @@ def vary_by_ap_json(request, *args, **kwargs) -> str:
return "not_ap"
def vary_by_identity(request, *args, **kwargs) -> str:
"""
Return a cache usable string token that is different based upon the
request.identity
"""
if request.identity:
return f"ident{request.identity.pk}"
return "identNone"
def cache_page(
timeout: int | str = "cache_timeout_page_default",
*,

View file

@ -0,0 +1,36 @@
# Generated by Django 4.2 on 2023-04-29 18:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0016_hashtagfollow"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("core", "0001_initial"),
]
operations = [
migrations.AlterUniqueTogether(
name="config",
unique_together=set(),
),
migrations.AddField(
model_name="config",
name="domain",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="configs",
to="users.domain",
),
),
migrations.AlterUniqueTogether(
name="config",
unique_together={("key", "user", "identity", "domain")},
),
]

View file

@ -43,6 +43,14 @@ class Config(models.Model):
on_delete=models.CASCADE,
)
domain = models.ForeignKey(
"users.domain",
blank=True,
null=True,
related_name="configs",
on_delete=models.CASCADE,
)
json = models.JSONField(blank=True, null=True)
image = models.ImageField(
blank=True,
@ -52,7 +60,7 @@ class Config(models.Model):
class Meta:
unique_together = [
("key", "user", "identity"),
("key", "user", "identity", "domain"),
]
system: ClassVar["Config.ConfigOptions"] # type: ignore
@ -86,7 +94,7 @@ class Config(models.Model):
"""
return cls.load_values(
cls.SystemOptions,
{"identity__isnull": True, "user__isnull": True},
{"identity__isnull": True, "user__isnull": True, "domain__isnull": True},
)
@classmethod
@ -96,7 +104,7 @@ class Config(models.Model):
"""
return await sync_to_async(cls.load_values)(
cls.SystemOptions,
{"identity__isnull": True, "user__isnull": True},
{"identity__isnull": True, "user__isnull": True, "domain__isnull": True},
)
@classmethod
@ -106,7 +114,7 @@ class Config(models.Model):
"""
return cls.load_values(
cls.UserOptions,
{"identity__isnull": True, "user": user},
{"identity__isnull": True, "user": user, "domain__isnull": True},
)
@classmethod
@ -116,7 +124,7 @@ class Config(models.Model):
"""
return await sync_to_async(cls.load_values)(
cls.UserOptions,
{"identity__isnull": True, "user": user},
{"identity__isnull": True, "user": user, "domain__isnull": True},
)
@classmethod
@ -126,7 +134,7 @@ class Config(models.Model):
"""
return cls.load_values(
cls.IdentityOptions,
{"identity": identity, "user__isnull": True},
{"identity": identity, "user__isnull": True, "domain__isnull": True},
)
@classmethod
@ -136,7 +144,27 @@ class Config(models.Model):
"""
return await sync_to_async(cls.load_values)(
cls.IdentityOptions,
{"identity": identity, "user__isnull": True},
{"identity": identity, "user__isnull": True, "domain__isnull": True},
)
@classmethod
def load_domain(cls, domain):
"""
Loads an domain config options object
"""
return cls.load_values(
cls.DomainOptions,
{"domain": domain, "user__isnull": True, "identity__isnull": True},
)
@classmethod
async def aload_domain(cls, domain):
"""
Async loads an domain config options object
"""
return await sync_to_async(cls.load_values)(
cls.DomainOptions,
{"domain": domain, "user__isnull": True, "identity__isnull": True},
)
@classmethod
@ -170,7 +198,7 @@ class Config(models.Model):
key,
value,
cls.SystemOptions,
{"identity__isnull": True, "user__isnull": True},
{"identity__isnull": True, "user__isnull": True, "domain__isnull": True},
)
@classmethod
@ -179,7 +207,7 @@ class Config(models.Model):
key,
value,
cls.UserOptions,
{"identity__isnull": True, "user": user},
{"identity__isnull": True, "user": user, "domain__isnull": True},
)
@classmethod
@ -188,7 +216,16 @@ class Config(models.Model):
key,
value,
cls.IdentityOptions,
{"identity": identity, "user__isnull": True},
{"identity": identity, "user__isnull": True, "domain__isnull": True},
)
@classmethod
def set_domain(cls, domain, key, value):
cls.set_value(
key,
value,
cls.DomainOptions,
{"domain": domain, "user__isnull": True, "identity__isnull": True},
)
class SystemOptions(pydantic.BaseModel):
@ -210,6 +247,7 @@ class Config(models.Model):
policy_terms: str = ""
policy_privacy: str = ""
policy_rules: str = ""
policy_issues: str = ""
signup_allowed: bool = True
signup_text: str = ""
@ -239,20 +277,23 @@ class Config(models.Model):
custom_head: str | None
class UserOptions(pydantic.BaseModel):
pass
light_theme: bool = False
class IdentityOptions(pydantic.BaseModel):
toot_mode: bool = False
default_post_visibility: int = 0 # Post.Visibilities.public
default_reply_visibility: int = 1 # Post.Visibilities.unlisted
visible_follows: bool = True
light_theme: bool = False
search_enabled: bool = True
# wellness Options
# Wellness Options
visible_reaction_counts: bool = True
expand_linked_cws: bool = True
infinite_scroll: bool = True
custom_css: str | None
class DomainOptions(pydantic.BaseModel):
site_name: str = ""
site_icon: UploadedImage | None = None
hide_login: bool = False
custom_css: str = ""
single_user: str = ""

View file

@ -1,14 +1,11 @@
import json
from typing import ClassVar
import markdown_it
from django.conf import settings
from django.http import HttpResponse
from django.shortcuts import redirect
from django.templatetags.static import static
from django.utils.decorators import method_decorator
from django.utils.safestring import mark_safe
from django.views.decorators.cache import cache_control
from django.views.generic import TemplateView, View
from django.views.static import serve
@ -21,17 +18,18 @@ from core.models import Config
def homepage(request):
if request.user.is_authenticated:
return Home.as_view()(request)
elif request.domain.config_domain.single_user:
return redirect(f"/@{request.domain.config_domain.single_user}/")
else:
return About.as_view()(request)
@method_decorator(cache_page(public_only=True), name="dispatch")
class About(TemplateView):
template_name = "about.html"
def get_context_data(self):
service = TimelineService(self.request.identity)
service = TimelineService(None)
return {
"current_page": "about",
"content": mark_safe(
@ -87,46 +85,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,

102
docs/releases/0.9.rst Normal file
View file

@ -0,0 +1,102 @@
0.9
===
*Not yet released*
This release is a large overhaul Takahē that removes all timeline UI elements
in the web interface in favour of apps, while reworking the remaining pages
to be a pleasant profile viewing, post viewing, and settings experience.
We've also started on our path of making individual domains much more
customisable; you can now theme them individually, the Local timeline is now
domain-specific, and domains can be set to serve single user profiles.
This release's major changes:
* The Home, Notifications, Local and Federated timelines have been removed
from the web UI. They still function for apps.
* The ability to like, boost, bookmark and reply to posts has been removed from
the web UI. They still function for apps.
* The web Compose tool has been considerably simplified and relegated to a new
"tools" section; most users should now use an app for composing posts.
* The Follows page is now in settings and is view-only.
* Identity profiles and individual post pages are now considerably simplified
and have no sidebar.
* A Search feature is now available for posts from a single identity on its
profile page; users can turn this on or off in their identity's profile
settings.
* Domains can now have their own site name, site icon, and custom CSS
* Domains can be set to a "single user mode" where they redirect to a user
profile, rather than showing their own homepage.
* Added an Authorized Apps identity settings screen, that allows seeing what apps you've
authorized, revocation of app access, and generating your own personal API
tokens.
* Added a Delete Profile settings screen that allows self-serve identity deletion.
* The logged-in homepage now shows a list of identities to select from as well
as a set of recommended apps to use for timeline interfaces.
* We have totally dropped our alpha-quality SQLite support; it just doesn't have
sufficient full-text-search and JSON operator support, unfortunately.
There are many minor changes to support the new direction; important ones include:
* The dark/light mode toggle is now a User (login) setting, not an Identity setting
* Identity selection is no longer part of a session - now, multiple identity
settings pages can be opened at once.
* The ability for users to add their own custom CSS has been removed, as it
was potentially confusing with our upcoming profile customization work (it
only ever applied to your own session, and with timelines gone, it no longer
makes much sense!)
* API pagination has been further improved, specifically for Elk compatibility
* Server admins can now add a "Report a Problem" footer link with either
hosted content or an external link.
This is a large change in direction, and we hope that it will match the way
that people use Takahē and its multi-domain support far better. For more
discussion and rationale on the change, see `Andrew's blog post about it <https://aeracode.org/2023/04/29/refactor-treat/>`_.
Our future plans include stability and polish in order to get us to a 1.0 release,
as well as allowing users to customize their profiles more, account import
support, and protocol enhancements like automatic fetching of replies for
non-local posts. If you're curious about what we're up to, or have an idea,
we're very happy to chat about it in our Discord!
If you'd like to help with code, design, other areas, see
:doc:`/contributing` to see how to get in touch.
You can download images from `Docker Hub <https://hub.docker.com/r/jointakahe/takahe>`_,
or use the image name ``jointakahe/takahe:0.9``.
Upgrade Notes
-------------
Despite the large refactor to the UI, Takahē's internals are not significantly
changed, and this upgrade is operationally like any other minor release.
Migrations
~~~~~~~~~~
There are new database migrations; they are backwards-compatible, so please
apply them before restarting your webservers and stator processes.
One of the migrations involves adding a large search index and may take some time to
process (on the order of minutes) if you have a large database.
You may wish to bring your site down into
a maintenance mode before applying it to reduce the chance of lock conflicts
slowing things down, or causing request timeouts.

View file

@ -9,7 +9,7 @@ django-hatchway~=0.5.1
django-htmx~=1.13.0
django-oauth-toolkit~=2.2.0
django-storages[google,boto3]~=1.13.1
django~=4.1
django~=4.1.0
email-validator~=1.3.0
gunicorn~=20.1.0
httpx~=0.23

View file

@ -82,13 +82,14 @@ td a {
/* Base template styling */
:root {
--color-highlight: #449c8c;
--color-bg-main: #26323c;
--color-bg-menu: #2e3e4c;
--color-bg-box: #1a2631;
--color-bg-error: rgb(87, 32, 32);
--color-highlight: #449c8c;
--color-delete: #8b2821;
--color-header-menu: rgba(0, 0, 0, 0.5);
--color-header-menu: rgba(255, 255, 255, 0.8);
--color-main-shadow: rgba(0, 0, 0, 0.6);
--size-main-shadow: 50px;
@ -96,40 +97,36 @@ td a {
--color-text-dull: #99a;
--color-text-main: #fff;
--color-text-link: var(--color-highlight);
--color-text-in-highlight: #fff;
--color-text-in-highlight: var(--color-text-main);
--color-input-background: #26323c;
--color-input-background: var(--color-bg-main);
--color-input-border: #000;
--color-input-border-active: #444b5d;
--color-button-secondary: #2e3e4c;
--color-button-disabled: #7c9c97;
--sm-header-height: 50px;
--sm-sidebar-width: 50px;
--md-sidebar-width: 250px;
--md-header-height: 50px;
--width-sidebar-small: 200px;
--width-sidebar-medium: 250px;
--emoji-height: 1.1em;
}
body.light-theme {
--color-bg-main: #dfe3e7;
--color-bg-menu: #cfd6dd;
--color-bg-box: #f2f5f8;
body.theme-light {
--color-bg-main: #d4dee7;
--color-bg-menu: #c0ccd8;
--color-bg-box: #f0f3f5;
--color-bg-error: rgb(219, 144, 144);
--color-highlight: #449c8c;
--color-delete: #884743;
--color-header-menu: rgba(0, 0, 0, 0.1);
--color-header-menu: rgba(0, 0, 0, 0.7);
--color-main-shadow: rgba(0, 0, 0, 0.1);
--size-main-shadow: 20px;
--color-text-duller: #3a3b3d;
--color-text-dull: rgb(44, 44, 48);
--color-text-duller: #4f5157;
--color-text-dull: rgb(62, 62, 68);
--color-text-main: rgb(0, 0, 0);
--color-text-link: var(--color-highlight);
--color-input-background: #fff;
--color-input-background: var(--color-bg-main);
--color-input-border: rgb(109, 109, 109);
--color-input-border-active: #464646;
--color-button-secondary: #5c6770;
@ -145,15 +142,14 @@ body {
}
main {
width: 1100px;
width: 800px;
margin: 20px auto;
box-shadow: 0 0 var(--size-main-shadow) var(--color-main-shadow);
box-shadow: none;
border-radius: 5px;
}
.no-sidebar main {
box-shadow: none;
max-width: 800px;
body.wide main {
width: 1100px;
}
footer {
@ -172,10 +168,8 @@ footer a {
header {
display: flex;
height: 50px;
}
.no-sidebar header {
height: 42px;
margin: -20px 0 20px 0;
justify-content: center;
}
@ -184,11 +178,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 +190,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 +201,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;
color: var(--color-text-main);
padding: 6px 10px 4px 10px;
color: var(--color-header-menu);
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 +227,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 +263,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 +277,8 @@ header menu a small {
}
nav {
padding: 10px 10px 20px 0;
padding: 10px 0px 20px 5px;
border-radius: 5px;
}
nav hr {
@ -334,23 +299,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 +333,54 @@ 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 {
width: var(--md-sidebar-width);
.settings nav {
width: var(--width-sidebar-medium);
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 +389,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 +398,51 @@ img.emoji {
content: "…";
}
/* Generic markdown styling and sections */
/* Generic styling and sections */
.no-sidebar section {
max-width: 700px;
section {
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;
}
@ -716,29 +737,11 @@ form.inline {
div.follow {
float: right;
margin: 20px 0 0 0;
margin: 30px 0 0 0;
font-size: 16px;
text-align: center;
}
div.follow-hashtag {
margin: 0;
}
.follow.has-reverse {
margin-top: 0;
}
.follow .reverse-follow {
display: block;
margin: 0 0 5px 0;
}
div.follow button,
div.follow .button {
margin: 0;
}
div.follow .actions {
/* display: flex; */
position: relative;
@ -748,69 +751,8 @@ div.follow .actions {
align-content: center;
}
div.follow .actions a {
border-radius: 4px;
min-width: 40px;
.follow .actions button {
text-align: center;
cursor: pointer;
}
div.follow .actions menu {
display: none;
background-color: var(--color-bg-menu);
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
position: absolute;
right: 0;
top: 43px;
}
div.follow .actions menu.enabled {
display: block;
min-width: 160px;
z-index: 10;
}
div.follow .actions menu a {
text-align: left;
display: block;
font-size: 15px;
padding: 4px 10px;
color: var(--color-text-dull);
}
.follow .actions menu button {
background: none !important;
border: none;
cursor: pointer;
text-align: left;
display: block;
font-size: 15px;
padding: 4px 10px;
color: var(--color-text-dull);
}
.follow .actions menu button i {
margin-right: 4px;
width: 16px;
}
.follow .actions button:hover {
color: var(--color-text-main);
}
.follow .actions menu a i {
margin-right: 4px;
width: 16px;
}
div.follow .actions a:hover {
color: var(--color-text-main);
}
div.follow .actions a.active {
color: var(--color-text-link);
}
form.inline-menu {
@ -1118,12 +1060,9 @@ button i:first-child,
padding: 2px 6px;
}
form .field.multi-option {
margin-bottom: 10px;
}
form .field.multi-option {
form .multi-option {
margin-bottom: 10px;
display: block;
}
form .option.option-row {
@ -1156,6 +1095,25 @@ blockquote {
border-left: 2px solid var(--color-bg-menu);
}
.secret .label {
background-color: var(--color-bg-menu);
padding: 3px 7px;
border-radius: 3px;
cursor: pointer;
}
.secret.visible .label {
display: none;
}
.secret .value {
display: none;
}
.secret.visible .value {
display: inline;
}
/* Logged out homepage */
@ -1174,42 +1132,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 0px -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: 15px 20px 15px 0;
cursor: pointer;
}
h1.identity .emoji {
section.identity .emoji {
height: var(--emoji-height);
}
h1.identity small {
section.identity h1 {
margin: 25px 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 +1184,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 +1204,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 +1284,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) {
@ -1358,17 +1320,23 @@ table.metadata td .emoji {
min-width: 16px;
}
/* Announcements */
/* Announcements/Flash messages */
.announcement {
.announcement,
.message {
background-color: var(--color-highlight);
border-radius: 5px;
margin: 0 0 20px 0;
margin: 10px 0 0 0;
padding: 5px 30px 5px 8px;
position: relative;
}
.announcement .dismiss {
.message {
background-color: var(--color-bg-menu);
}
.announcement .dismiss,
.message .dismiss {
position: absolute;
top: 5px;
right: 10px;
@ -1405,6 +1373,11 @@ table.metadata td .emoji {
}
.identity-banner img.icon {
max-width: 64px;
max-height: 64px;
}
/* Posts */
@ -1577,7 +1550,7 @@ form .post {
.post .actions {
display: flex;
position: relative;
justify-content: space-between;
justify-content: right;
padding: 8px 0 0 0;
align-items: center;
align-content: center;
@ -1593,6 +1566,12 @@ form .post {
color: var(--color-text-dull);
}
.post .actions a.no-action:hover {
background-color: transparent;
cursor: default;
color: var(--color-text-dull);
}
.post .actions a:hover {
background-color: var(--color-bg-main);
}
@ -1750,52 +1729,30 @@ form .post {
color: var(--color-text-dull);
}
@media (max-width: 1100px) {
main {
max-width: 900px;
@media (max-width: 1120px),
(display-mode: standalone) {
body.wide main {
width: 100%;
padding: 0 10px;
}
}
@media (max-width: 920px),
@media (max-width: 850px),
(display-mode: standalone) {
.left-column {
margin: var(--md-header-height) var(--md-sidebar-width) 0 0;
}
.right-column {
width: var(--md-sidebar-width);
position: fixed;
height: 100%;
right: 0;
top: var(--md-header-height);
overflow-y: auto;
padding-bottom: 60px;
}
.right-column nav {
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;
padding: 0 10px;
}
.no-sidebar main {
margin: 20px auto 20px auto;
.settings nav {
width: var(--width-sidebar-small);
}
.post .attachments a.image img {
@ -1805,72 +1762,6 @@ form .post {
@media (max-width: 750px) {
header {
height: var(--sm-header-height);
}
header menu a.identity {
width: var(--sm-sidebar-width);
padding: 10px 10px 0 0;
font-size: 0;
}
header menu a.identity i {
font-size: 22px;
}
#main-content {
padding: 8px;
}
.left-column {
margin: var(--sm-header-height) var(--sm-sidebar-width) 0 0;
}
.right-column {
width: var(--sm-sidebar-width);
top: var(--sm-header-height);
}
.right-column nav {
padding-right: 0;
}
.right-column nav hr {
display: block;
color: var(--color-text-dull);
margin: 20px 10px;
}
.right-column nav a {
padding: 10px 0 10px 10px;
}
.right-column nav a i {
font-size: 22px;
}
.right-column nav a .fa-solid {
display: flex;
justify-content: center;
}
.right-column nav a span {
display: none;
}
.right-column h3 {
display: none;
}
.right-column h2,
.right-column .compose {
display: none;
}
.right-column footer {
display: none;
}
.post {
margin-bottom: 15px;
@ -1881,6 +1772,10 @@ form .post {
@media (max-width: 550px) {
main {
padding: 0;
}
.post .content,
.post .summary,
.post .edited,

42
static/img/apps/elk.svg Executable file
View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="250"
height="250"
fill="none"
version="1.1"
id="svg30"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs34" />
<mask
id="a"
width="240"
height="234"
x="4"
y="1"
maskUnits="userSpaceOnUse"
style="mask-type:alpha">
<path
fill="#D9D9D9"
d="M244 123c0 64.617-38.383 112-103 112-64.617 0-103-30.883-103-95.5C38 111.194-8.729 36.236 8 16 29.46-9.959 88.689 6 125 6c64.617 0 119 52.383 119 117Z"
id="path19" />
</mask>
<g
mask="url(#a)"
id="g28"
transform="matrix(0.90923731,0,0,1.0049564,13.520015,-3.1040835)">
<path
fill="#ea9e44"
d="m 116.94,88.1 c -13.344,1.552 -20.436,-2.019 -24.706,10.71 0,0 14.336,21.655 52.54,21.112 -2.135,8.848 -1.144,15.368 -1.144,23.207 0,26.079 -20.589,48.821 -65.961,48.821 -23.03,0 -51.015,4.191 -72.367,15.911 -15.175,8.305 -27.048,20.336 -32.302,37.023 l 5.956,8.461 11.4,0.155 v 47.889 l -13.91,21.966 3.998,63.645 H -6.364 L -5.22,335.773 C 1.338,331.892 16.36,321.802 29.171,306.279 46.557,285.4 59.902,255.052 44.193,217.486 l 11.744,-5.045 c 12.887,30.814 8.388,57.514 -2.898,79.013 21.58,-0.698 40.11,-2.095 55.819,-4.734 l -3.584,-43.698 12.659,-1.087 L 129.98,387 h 13.116 l 2.212,-94.459 c 10.447,-4.502 34.239,-21.034 45.372,-78.47 1.372,-6.986 2.135,-12.885 2.516,-17.93 1.754,-12.806 2.745,-27.243 3.051,-43.698 l -18.683,-5.976 h 57.42 l 5.567,-12.807 c -5.414,0.233 -11.896,-2.639 -11.896,-2.639 l 1.297,-6.209 H 242 L 176.801,90.428 c -7.244,2.794 -14.87,6.442 -20.208,10.866 -4.27,-3.105 -19.063,-12.807 -39.653,-13.195 z"
id="path22" />
<path
fill="#c16929"
d="M 6.217,24.493 18.494,21 c 5.948,21.577 13.345,33.375 22.648,39.352 8.388,5.099 19.75,5.239 31.799,4.579 C 69.433,63.767 66.154,62.137 63.104,59.886 56.317,54.841 50.522,46.458 46.175,31.246 l 12.201,-3.649 c 3.279,11.488 7.092,18.085 12.201,21.888 5.11,3.726 11.286,4.657 18.606,5.433 13.726,1.553 30.884,2.174 52.312,12.264 2.898,1.086 5.872,2.483 8.769,4.036 -0.381,-0.776 -0.762,-1.553 -1.296,-2.406 -3.66,-5.822 -10.828,-11.953 -24.097,-16.92 l 4.27,-12.109 c 21.581,7.917 30.121,19.171 33.553,28.097 3.965,10.168 1.525,18.124 1.525,18.124 -3.05,1.009 -6.1,2.406 -9.608,3.492 -6.634,-4.579 -12.887,-8.033 -18.835,-10.75 C 113.814,70.442 92.31,76.108 73.246,77.893 58.91,79.213 45.794,78.591 34.432,71.295 23.222,64.155 13.385,50.495 6.217,24.493 Z"
id="path24" />
<path
fill="#c16929"
d="M 90.098,45.294 C 87.582,39.55 86.057,32.487 86.743,23.794 l 12.659,0.932 c -0.763,10.555 2.897,17.696 7.015,22.353 -5.338,-0.931 -10.447,-1.04 -16.319,-1.785 z m 80.069,-1.32 8.312,-9.702 c 21.58,19.094 8.159,46.415 8.159,46.415 l -11.819,-1.32 c -0.382,-6.24 -1.144,-17.836 -6.635,-24.371 3.584,1.84 6.635,3.865 9.99,6.908 0,-5.666 -1.754,-12.341 -8.007,-17.93 z"
id="path26" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
static/img/apps/ivory.webp Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
static/img/apps/tusky.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -5,6 +5,7 @@ import signal
import time
import traceback
import uuid
from collections.abc import Callable
from asgiref.sync import async_to_sync, sync_to_async
from django.conf import settings
@ -21,7 +22,7 @@ class LoopingTask:
copy running at a time.
"""
def __init__(self, callable):
def __init__(self, callable: Callable):
self.callable = callable
self.task: asyncio.Task | None = None

View file

@ -1 +1 @@
__version__ = "0.8.0"
__version__ = "0.9.0-dev"

View file

@ -194,6 +194,7 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.postgres",
"corsheaders",
"django_htmx",
"hatchway",
@ -220,7 +221,7 @@ MIDDLEWARE = [
"core.middleware.HeadersMiddleware",
"core.middleware.ConfigLoadingMiddleware",
"api.middleware.ApiTokenMiddleware",
"users.middleware.IdentityMiddleware",
"users.middleware.DomainMiddleware",
]
ROOT_URLCONF = "takahe.urls"

View file

@ -2,49 +2,18 @@ from django.conf import settings as djsettings
from django.contrib import admin as djadmin
from django.urls import include, path, re_path
from activities.views import (
compose,
debug,
explore,
follows,
hashtags,
posts,
search,
timelines,
)
from activities.views import compose, debug, posts, timelines
from api.views import oauth
from core import views as core
from mediaproxy import views as mediaproxy
from stator import views as stator
from users.views import (
activitypub,
admin,
announcements,
auth,
identity,
report,
settings,
)
from users.views import activitypub, admin, announcements, auth, identity, settings
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"),
path("federated/", timelines.Federated.as_view(), name="federated"),
path("search/", search.Search.as_view(), name="search"),
path("tags/<hashtag>/", timelines.Tag.as_view(), name="tag"),
path("tags/<hashtag>/follow/", hashtags.HashtagFollow.as_view()),
path("tags/<hashtag>/unfollow/", hashtags.HashtagFollow.as_view(undo=True)),
path("explore/", explore.Explore.as_view(), name="explore"),
path("explore/tags/", explore.ExploreTag.as_view(), name="explore-tag"),
path(
"follows/",
follows.Follows.as_view(),
name="follows",
),
# Settings views
path(
"settings/",
@ -56,31 +25,66 @@ urlpatterns = [
settings.SecurityPage.as_view(),
name="settings_security",
),
path(
"settings/profile/",
settings.ProfilePage.as_view(),
name="settings_profile",
),
path(
"settings/interface/",
settings.InterfacePage.as_view(),
name="settings_interface",
),
path(
"settings/import_export/",
"@<handle>/settings/",
settings.SettingsRoot.as_view(),
name="settings",
),
path(
"@<handle>/settings/profile/",
settings.ProfilePage.as_view(),
name="settings_profile",
),
path(
"@<handle>/settings/posting/",
settings.PostingPage.as_view(),
name="settings_posting",
),
path(
"@<handle>/settings/follows/",
settings.FollowsPage.as_view(),
name="settings_follows",
),
path(
"@<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",
),
path(
"@<handle>/settings/tokens/",
settings.TokensRoot.as_view(),
name="settings_tokens",
),
path(
"@<handle>/settings/tokens/create/",
settings.TokenCreate.as_view(),
name="settings_token_create",
),
path(
"@<handle>/settings/tokens/<pk>/",
settings.TokenEdit.as_view(),
name="settings_token_edit",
),
path(
"@<handle>/settings/delete/",
settings.DeleteIdentity.as_view(),
name="settings_delete",
),
path(
"admin/",
admin.AdminRoot.as_view(),
@ -236,30 +240,18 @@ urlpatterns = [
path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/outbox/", activitypub.Outbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()),
path("@<handle>/rss/", identity.IdentityFeed()),
path("@<handle>/report/", report.SubmitReport.as_view()),
path("@<handle>/following/", identity.IdentityFollows.as_view(inbound=False)),
path("@<handle>/followers/", identity.IdentityFollows.as_view(inbound=True)),
path("@<handle>/search/", identity.IdentitySearch.as_view()),
path(
"@<handle>/notifications/",
timelines.Notifications.as_view(),
name="notifications",
),
# Posts
path("compose/", compose.Compose.as_view(), name="compose"),
path(
"compose/image_upload/",
compose.ImageUpload.as_view(),
name="compose_image_upload",
),
path("@<handle>/compose/", compose.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)),
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()),
# Authentication
path("auth/login/", auth.Login.as_view(), name="login"),
path("auth/logout/", auth.Logout.as_view(), name="logout"),
@ -267,26 +259,29 @@ 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"),
path(
"pages/privacy/",
core.FlatPage.as_view(title="Privacy Policy", config_option="policy_privacy"),
name="privacy",
name="policy_privacy",
),
path(
"pages/terms/",
core.FlatPage.as_view(title="Terms of Service", config_option="policy_terms"),
name="terms",
name="policy_terms",
),
path(
"pages/rules/",
core.FlatPage.as_view(title="Server Rules", config_option="policy_rules"),
name="rules",
name="policy_rules",
),
path(
"pages/issues/",
core.FlatPage.as_view(title="Report a Problem", config_option="policy_issues"),
name="policy_issues",
),
# Annoucements
path("announcements/<id>/dismiss/", announcements.AnnouncementDismiss.as_view()),

View file

@ -4,3 +4,13 @@
{{ announcement.html }}
</div>
{% endfor %}
{% if messages %}
<ul class="messages">
{% for message in messages %}
<div class="message {{ message.tags }}">
<a class="dismiss" title="Dismiss" _="on click remove closest <div/>"><i class="fa-solid fa-xmark"></i></a>
{{ message }}
</div>
{% endfor %}
</ul>
{% endif %}

View file

@ -1,7 +1,8 @@
<footer>
{% if config.site_about %}<a href="{% url "about" %}">About</a>{% endif %}
{% if config.policy_rules %}<a href="{% url "rules" %}">Server&nbsp;Rules</a>{% endif %}
{% if config.policy_terms %}<a href="{% url "terms" %}">Terms&nbsp;of&nbsp;Service</a>{% endif %}
{% if config.policy_privacy %}<a href="{% url "privacy" %}">Privacy&nbsp;Policy</a>{% endif %}
{% if config.policy_rules %}<a href="{% url "policy_rules" %}">Server&nbsp;Rules</a>{% endif %}
{% if config.policy_terms %}<a href="{% url "policy_terms" %}">Terms&nbsp;of&nbsp;Service</a>{% endif %}
{% if config.policy_privacy %}<a href="{% url "policy_privacy" %}">Privacy&nbsp;Policy</a>{% endif %}
{% if config.policy_issues %}<a href="{% url "policy_issues" %}">Report a Problem</a>{% endif %}
<a href="https://jointakahe.org">Takahē&nbsp;{{ config.version }}</a>
</footer>

View file

@ -1,9 +0,0 @@
{% if post.pk in bookmarks %}
<a title="Unbookmark" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_unbookmark }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-solid fa-bookmark"></i>
</a>
{% else %}
<a title="Bookmark" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_bookmark }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-regular fa-bookmark"></i>
</a>
{% endif %}

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 @@
<a class="option" href="{{ hashtag.urls.timeline }}">
<i class="fa-solid fa-hashtag"></i>
<span class="handle">
{{ hashtag.display_name }}
</span>
{% if not hide_stats %}
<span>
Post count: {{ hashtag.stats.total }}
</span>
{% endif %}
</a>

View file

@ -1,9 +0,0 @@
{% if follow %}
<button title="Unfollow" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ hashtag.urls.unfollow }}" hx-swap="outerHTML" tabindex="0">
Unfollow
</button>
{% else %}
<button title="Follow" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ hashtag.urls.follow }}" hx-swap="outerHTML" tabindex="0">
Follow
</button>
{% endif %}

View file

@ -1,15 +0,0 @@
<form
hx-encoding='multipart/form-data'
hx-post='{% url "compose_image_upload" %}'
hx-target="this"
hx-swap="outerHTML"
_="on htmx:xhr:progress(loaded, total)
set #attachmentProgress.value to (loaded/total)*100">
{% csrf_token %}
{% include "forms/_field.html" with field=form.image %}
{% include "forms/_field.html" with field=form.description %}
<div class="buttons">
<button id="upload" _="on click show #attachmentProgress with display:block then hide me">Upload</button>
<progress id="attachmentProgress" value="0" max="100"></progress>
</div>
</form>

View file

@ -1,19 +0,0 @@
<div class="uploaded-image">
<input type="hidden" name="attachment" value="{{ attachment.pk }}">
<img src="{{ attachment.thumbnail_url.relative }}">
<p>
{{ attachment.name|default:"(no description)" }}
</p>
<div class="buttons">
<button class="button delete left" _="on click remove closest .uploaded-image">Remove</button>
</div>
</div>
{% if request.htmx %}
<button class="add-image"
hx-get='{% url "compose_image_upload" %}'
hx-target="this"
hx-swap="outerHTML"
_="on load if length of <.uploaded-image/> > 3 then hide me">
Add Image
</button>
{% 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

@ -1,86 +0,0 @@
<nav>
<a href="/" {% if current_page == "home" %}class="selected"{% endif %} title="Home">
<i class="fa-solid fa-home"></i>
<span>Home</span>
</a>
{% if request.user.is_authenticated %}
<a href="{% url "notifications" %}" {% if current_page == "notifications" %}class="selected"{% endif %} title="Notifications">
<i class="fa-solid fa-at"></i>
<span>Notifications</span>
</a>
{% comment %}
Not sure we want to show this quite yet
<a href="{% url "explore" %}" {% if current_page == "explore" %}class="selected"{% endif %} title="Explore">
<i class="fa-solid fa-hashtag"></i>
<span>Explore</span>
</a>
{% endcomment %}
<a href="{% url "local" %}" {% if current_page == "local" %}class="selected"{% endif %} title="Local">
<i class="fa-solid fa-city"></i>
<span>Local</span>
</a>
<a href="{% url "federated" %}" {% if current_page == "federated" %}class="selected"{% endif %} title="Federated">
<i class="fa-solid fa-globe"></i>
<span>Federated</span>
</a>
<a href="{% url "follows" %}" {% if section == "follows" %}class="selected"{% endif %} title="Follows">
<i class="fa-solid fa-arrow-right-arrow-left"></i>
<span>Follows</span>
</a>
<hr>
<h3></h3>
<a href="{% url "compose" %}" {% if top_section == "compose" %}class="selected"{% endif %} title="Compose">
<i class="fa-solid fa-feather"></i>
<span>Compose</span>
</a>
<a href="{% url "search" %}" {% if top_section == "search" %}class="selected"{% endif %} title="Search">
<i class="fa-solid fa-search"></i>
<span>Search</span>
</a>
{% if current_page == "tag" %}
<a href="{% url "tag" hashtag.hashtag %}" class="selected" title="Tag {{ hashtag.display_name }}">
<i class="fa-solid fa-hashtag"></i>
<span>{{ hashtag.display_name }}</span>
</a>
{% endif %}
<a href="{% url "settings" %}" {% if top_section == "settings" %}class="selected"{% endif %} title="Settings">
<i class="fa-solid fa-gear"></i>
<span>Settings</span>
</a>
<a href="{% url "identity_select" %}" title="Select Identity">
<i class="fa-solid fa-users-viewfinder"></i>
<span>Select Identity</span>
</a>
{% else %}
<a href="{% url "local" %}" {% if current_page == "local" %}class="selected"{% endif %} title="Local Posts">
<i class="fa-solid fa-city"></i>
<span>Local Posts</span>
</a>
<a href="{% url "explore" %}" {% if current_page == "explore" %}class="selected"{% endif %} title="Explore">
<i class="fa-solid fa-hashtag"></i>
<span>Explore</span>
</a>
<h3></h3>
{% if config.signup_allowed %}
<a href="{% url "signup" %}" {% if current_page == "signup" %}class="selected"{% endif %} title="Create Account">
<i class="fa-solid fa-user-plus"></i>
<span>Create Account</span>
</a>
{% endif %}
{% endif %}
</nav>
{% if current_page == "home" %}
<h2>Compose</h2>
<form action="{% url "compose" %}" method="POST" class="compose">
{% csrf_token %}
{{ form.text }}
{{ form.content_warning }}
<input type="hidden" name="visibility" value="{{ config_identity.default_post_visibility }}">
<div class="buttons">
<span id="character-counter">{{ config.post_length }}</span>
<button class="toggle" _="on click or keyup[key is 'Enter'] toggle .enabled then toggle .hidden on #id_content_warning then halt">CW</button>
<button id="post-button">{% if config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
</div>
</form>
{% endif %}

View file

@ -27,12 +27,8 @@
</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 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">
{{ post.summary }}
</div>
{% endif %}
@ -77,41 +73,37 @@
</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" %}
<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>
<div class="actions">
<a title="Replies" href="{% if not post.local and post.url %}{{ post.url }}{% else %}{{ post.urls.view }}{% endif %}">
<i class="fa-solid fa-reply"></i>
<span class="like-count">{{ post.stats_with_defaults.replies|default:"0" }}</span>
</a>
<a title="Likes" class="no-action">
<i class="fa-solid fa-star"></i>
<span class="like-count">{{ post.stats_with_defaults.likes|default:"0" }}</span>
</a>
<a title="Boosts" class="no-action">
<i class="fa-solid fa-retweet"></i>
<span class="like-count">{{ post.stats_with_defaults.boosts|default:"0" }}</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>
<menu>
<a href="{{ post.urls.view }}" role="menuitem">
<i class="fa-solid fa-comment"></i> View Post &amp; Replies
</a>
<menu>
<a href="{{ post.urls.view }}" role="menuitem">
<i class="fa-solid fa-comment"></i> View Post &amp; Replies
{% if not post.local and post.url %}
<a href="{{ post.url }}" role="menuitem">
<i class="fa-solid fa-arrow-up-right-from-square"></i> See Original
</a>
<a href="{{ post.urls.action_report }}" role="menuitem">
<i class="fa-solid fa-flag"></i> Report
{% endif %}
{% if request.user.admin %}
<a href="{{ post.urls.admin_edit }}" role="menuitem">
<i class="fa-solid fa-gear"></i> View In Admin
</a>
{% if post.author == request.identity %}
<a href="{{ post.urls.action_edit }}" role="menuitem">
<i class="fa-solid fa-pen-to-square"></i> Edit
</a>
<a href="{{ post.urls.action_delete }}" role="menuitem">
<i class="fa-solid fa-trash"></i> Delete
</a>
{% elif not post.local and post.url %}
<a href="{{ post.url }}" role="menuitem">
<i class="fa-solid fa-arrow-up-right-from-square"></i> See Original
</a>
{% endif %}
{% if request.user.admin %}
<a href="{{ post.urls.admin_edit }}" role="menuitem">
<i class="fa-solid fa-gear"></i> View In Admin
</a>
{% endif %}
</menu>
</div>
{% endif %}
{% endif %}
</menu>
</div>
</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

@ -1,41 +1,26 @@
{% extends "base.html" %}
{% extends "settings/base.html" %}
{% block title %}Compose{% endblock %}
{% block content %}
<form action="." method="POST">
{% block settings_content %}
<form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %}
<fieldset>
<legend>Content</legend>
{% if reply_to %}
<label>Replying to</label>
{% include "activities/_mini_post.html" with post=reply_to %}
{% endif %}
{{ form.reply_to }}
<legend>Compose</legend>
<p><i>For more advanced posting options, like editing and multiple image uploads, please use an app.</i></p>
{{ form.id }}
{% include "forms/_field.html" with field=form.text %}
{% include "forms/_field.html" with field=form.content_warning %}
{% include "forms/_field.html" with field=form.visibility %}
{% include "forms/_field.html" with field=form.content_warning %}
</fieldset>
<fieldset>
<legend>Images</legend>
{% if post %}
{% for attachment in post.attachments.all %}
{% include "activities/_image_uploaded.html" %}
{% endfor %}
{% endif %}
{% if not post or post.attachments.count < 4 %}
<button class="add-image"
hx-get='{% url "compose_image_upload" %}'
hx-target="this"
hx-swap="outerHTML">
Add Image
</button>
{% endif %}
<legend>Image</legend>
{% include "forms/_field.html" with field=form.image %}
{% include "forms/_field.html" with field=form.image_caption %}
</fieldset>
<div class="buttons">
<span id="character-counter">{{ config.post_length }}</span>
<button id="post-button">{% if post %}Save Edits{% elif config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
<button id="post-button">{% if post %}Save Edits{% else %}Post{% endif %}</button>
</div>
</form>
{% endblock %}

View file

@ -2,8 +2,6 @@
{% block title %}Debug JSON{% endblock %}
{% block body_class %}no-sidebar{% endblock %}
{% block content %}
<form action="." method="POST">
{% csrf_token %}

View file

@ -1,16 +0,0 @@
{% extends "base.html" %}
{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %}
{% block content %}
<div class="timeline-name">Explore Trending Tags</div>
<section class="icon-menu">
{% for hashtag in page_obj %}
{% include "activities/_hashtag.html" %}
{% empty %}
No tags are trending yet.
{% endfor %}
</section>
{% endblock %}

View file

@ -1,3 +0,0 @@
{% extends "activities/local.html" %}
{% block title %}Federated Timeline{% endblock %}

View file

@ -1,60 +0,0 @@
{% extends "base.html" %}
{% load activity_tags %}
{% block subtitle %}Follows{% endblock %}
{% block content %}
<div class="view-options">
{% if inbound %}
<a href=".">Following ({{ num_outbound }})</a>
<a href="." class="selected">Followers ({{ num_inbound }})</a>
{% else %}
<a href=".?inbound=true" class="selected">Following ({{ num_outbound }})</a>
<a href=".?inbound=true">Followers ({{ num_inbound }})</a>
{% endif %}
</div>
<section class="icon-menu">
{% for identity in page_obj %}
<a class="option " href="{{ identity.urls.view }}">
<div class="option-content">
{% include "identity/_identity_banner.html" with identity=identity link_avatar=False link_handle=False %}
</div>
<div class="option-actions">
{% if identity.id in outbound_ids %}
<span class="pill">Following</span>
{% endif %}
{% if identity.id in inbound_ids %}
<span class="pill">Follows You</span>
{% endif %}
{% if inbound %}
<form action="{{ identity.urls.action }}" method="POST" class="follow">
{% csrf_token %}
{% if identity.id in outbound_ids %}
<input type="hidden" name="action" value="unfollow">
<button class="destructive">Unfollow</button>
{% else %}
<input type="hidden" name="action" value="follow">
<button>Follow</button>
{% endif %}
</form>
{% endif %}
<time>{{ identity.follow_date | timedeltashort }} ago</time>
</div>
</a>
{% empty %}
<p class="option empty">You {% if inbound %}have no followers{% else %}are not following anyone{% endif %}.</p>
{% endfor %}
</section>
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}{% if inbound %}&amp;inbound=true{% endif %}">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}{% if inbound %}&amp;inbound=true{% endif %}">Next Page</a>
{% endif %}
</div>
{% endblock %}

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 %}
{% empty %}
Nothing to show yet.
{% 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 %}
</div>
<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 %}
<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>
<section>
<h1 class="above">Apps</h1>
<p>
To see your timelines, compose and edit messages, and follow people,
you will need to use a Mastodon-compatible app. Our favourites
are listed below, but there's lots more options out there!
</p>
<div class="flex-icons">
<a href="https://elk.zone/">
<img src="{% static "img/apps/elk.svg" %}" alt="Elk logo">
<h2>Elk</h2>
<i>Web/Mobile (Free)</i>
</a>
<a href="https://tusky.app/">
<img src="{% static "img/apps/tusky.png" %}" alt="Tusky logo">
<h2>Tusky</h2>
<i>Android (Free)</i>
</a>
<a href="https://tapbots.com/ivory/">
<img src="{% static "img/apps/ivory.webp" %}" alt="Ivory logo">
<h2>Ivory</h2>
<i>iOS (Paid)</i>
</a>
</div>
</section>
{% endblock %}

View file

@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}Local Timeline{% endblock %}
{% block content %}
{% if page_obj.number == 1 %}
{% include "_announcements.html" %}
{% endif %}
{% for post in page_obj %}
{% include "activities/_post.html" with feedindex=forloop.counter %}
{% empty %}
No posts yet.
{% 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 %}
</div>
{% endblock %}

View file

@ -1,55 +1,38 @@
{% extends "base.html" %}
{% extends "settings/base.html" %}
{% block title %}Notifications{% endblock %}
{% block content %}
{% if page_obj.number == 1 %}
{% include "_announcements.html" %}
{% endif %}
{% block settings_content %}
<div class="view-options">
{% if notification_options.followed %}
<a href=".?followed=false" class="selected"><i class="fa-solid fa-check"></i> Followers</a>
{% else %}
<a href=".?followed=true"><i class="fa-solid fa-xmark"></i> Followers</a>
{% endif %}
{% if notification_options.boosted %}
<a href=".?boosted=false" class="selected"><i class="fa-solid fa-check"></i> Boosts</a>
{% else %}
<a href=".?boosted=true"><i class="fa-solid fa-xmark"></i> Boosts</a>
{% endif %}
{% if notification_options.liked %}
<a href=".?liked=false" class="selected"><i class="fa-solid fa-check"></i> Likes</a>
{% else %}
<a href=".?liked=true"><i class="fa-solid fa-xmark"></i> Likes</a>
{% endif %}
{% if notification_options.mentioned %}
<a href=".?mentioned=false" class="selected"><i class="fa-solid fa-check"></i> Mentions</a>
{% else %}
<a href=".?mentioned=true"><i class="fa-solid fa-xmark"></i> Mentions</a>
{% endif %}
{% if request.user.admin %}
{% if notification_options.identity_created %}
<a href=".?identity_created=false" class="selected"><i class="fa-solid fa-check"></i> New Identities</a>
<section class="invisible">
<div class="view-options">
{% if notification_options.followed %}
<a href=".?followed=false" class="selected"><i class="fa-solid fa-check"></i> Followers</a>
{% else %}
<a href=".?identity_created=true"><i class="fa-solid fa-xmark"></i> New Identities</a>
<a href=".?followed=true"><i class="fa-solid fa-xmark"></i> Followers</a>
{% endif %}
{% endif %}
</div>
{% if notification_options.boosted %}
<a href=".?boosted=false" class="selected"><i class="fa-solid fa-check"></i> Boosts</a>
{% else %}
<a href=".?boosted=true"><i class="fa-solid fa-xmark"></i> Boosts</a>
{% endif %}
{% if notification_options.liked %}
<a href=".?liked=false" class="selected"><i class="fa-solid fa-check"></i> Likes</a>
{% else %}
<a href=".?liked=true"><i class="fa-solid fa-xmark"></i> Likes</a>
{% endif %}
{% if notification_options.mentioned %}
<a href=".?mentioned=false" class="selected"><i class="fa-solid fa-check"></i> Mentions</a>
{% else %}
<a href=".?mentioned=true"><i class="fa-solid fa-xmark"></i> Mentions</a>
{% endif %}
</div>
{% for event in events %}
{% include "activities/_event.html" %}
{% empty %}
No notifications yet.
{% endfor %}
{% for event in events %}
{% include "activities/_event.html" %}
{% empty %}
No notifications yet.
{% 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 %}
</div>
</section>
{% endblock %}

View file

@ -7,11 +7,13 @@
{% endblock %}
{% block content %}
{% for ancestor in ancestors reversed %}
{% include "activities/_post.html" with post=ancestor reply=True link_original=False %}
{% endfor %}
{% include "activities/_post.html" %}
{% for descendant in descendants %}
{% include "activities/_post.html" with post=descendant reply=True link_original=False %}
{% endfor %}
<section class="invisible">
{% for ancestor in ancestors reversed %}
{% include "activities/_post.html" with post=ancestor reply=True link_original=False %}
{% endfor %}
{% include "activities/_post.html" %}
{% for descendant in descendants %}
{% include "activities/_post.html" with post=descendant reply=True link_original=False %}
{% endfor %}
</section>
{% endblock %}

View file

@ -1,48 +0,0 @@
{% 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 %}
{% if results.posts %}
<h2>Posts</h2>
<section class="icon-menu">
{% for post in results.posts %}
{% include "activities/_post.html" %}
{% endfor %}
</section>
{% endif %}
{% if results.hashtags %}
<h2>Hashtags</h2>
<section class="icon-menu">
{% for hashtag in results.hashtags %}
{% include "activities/_hashtag.html" with hide_stats=True %}
{% endfor %}
</section>
{% endif %}
{% if results and not results.identities and not results.hashtags and not results.posts %}
<h2>No results</h2>
<p>
We could not find anything matching your query.
</p>
<p>
If you're trying to find a post or profile on another server,
try again in a few moments - if the other end is overloaded, it
can take some time to fetch the details.
</p>
{% endif %}
{% endblock %}

View file

@ -3,27 +3,26 @@
{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %}
{% block content %}
<div class="timeline-name">
<div class="inline follow follow-hashtag">
{% include "activities/_hashtag_follow.html" %}
<section class="invisible">
<div class="timeline-name">
<div class="hashtag">
<i class="fa-solid fa-hashtag"></i>{{ hashtag.display_name }}
</div>
</div>
<div class="hashtag">
<i class="fa-solid fa-hashtag"></i>{{ hashtag.display_name }}
{% for post in page_obj %}
{% include "activities/_post.html" %}
{% empty %}
No posts yet.
{% endfor %}
<div class="pagination">
{% if page_obj.has_previous %}
<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 }}">Next Page</a>
{% endif %}
</div>
</div>
{% for post in page_obj %}
{% include "activities/_post.html" %}
{% empty %}
No posts yet.
{% endfor %}
<div class="pagination">
{% if page_obj.has_previous %}
<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 }} {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}">Next Page</a>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,61 @@
<nav>
{% if request.user.moderator or request.user.admin %}
<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

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base_main.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_main.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_main.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

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}{% block subtitle %}{% endblock %} - Administration{% endblock %}
{% block body_class %}wide{% endblock %}
{% block content %}
<div class="settings">
{% include "admin/_menu.html" %}
<div class="settings-content">
{% block settings_content %}
{% endblock %}
</div>
</div>
{% endblock %}

View file

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

View file

@ -1,9 +1,9 @@
{% extends "settings/base.html" %}
{% extends "admin/base_main.html" %}
{% block subtitle %}{{ domain.domain }}{% endblock %}
{% block content %}
<form action="." method="POST">
{% block settings_content %}
<form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %}
<fieldset>
<legend>Domain Details</legend>
@ -16,6 +16,14 @@
{% include "forms/_field.html" with field=form.default %}
{% include "forms/_field.html" with field=form.users %}
</fieldset>
<fieldset>
<legend>Appearance</legend>
{% include "forms/_field.html" with field=form.site_name %}
{% include "forms/_field.html" with field=form.site_icon %}
{% include "forms/_field.html" with field=form.hide_login %}
{% include "forms/_field.html" with field=form.single_user %}
{% include "forms/_field.html" with field=form.custom_css %}
</fieldset>
<fieldset>
<legend>Admin Notes</legend>
{% include "forms/_field.html" with field=form.notes %}

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base_main.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_main.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_main.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_main.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_main.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_main.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_main.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_main.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_main.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_main.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_main.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_main.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_main.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_main.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

@ -0,0 +1,20 @@
{% extends "admin/base_main.html" %}
{% block subtitle %}{{ section.title }}{% endblock %}
{% block settings_content %}
<form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %}
{% for title, fields in fieldsets.items %}
<fieldset>
<legend>{{ title }}</legend>
{% for field in fields %}
{% include "forms/_field.html" %}
{% endfor %}
</fieldset>
{% endfor %}
<div class="buttons">
<button>Save</button>
</div>
</form>
{% endblock %}

View file

@ -1,8 +1,8 @@
{% extends "settings/base.html" %}
{% extends "admin/base_main.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_main.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_main.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>
@ -18,14 +17,15 @@
--color-highlight: {{ config.highlight_color }};
--color-text-link: {{ config.highlight_color }};
}
{% if not config_identity.visible_reaction_counts %}
.like-count {
display: none;
}
{% endif %}
</style>
{% if config_identity.custom_css %}
<style>{{ config_identity.custom_css|safe }}</style>
{% if identity and public_styling %}
{% if identity.domain.config_domain.custom_css %}
<style>{{ identity.domain.config_domain.custom_css|safe }}</style>
{% endif %}
{% else %}
{% if request.domain.config_domain.custom_css %}
<style>{{ request.domain.config_domain.custom_css|safe }}</style>
{% endif %}
{% endif %}
{% block opengraph %}
{% include "_opengraph.html" with opengraph_local=opengraph_defaults %}
@ -33,67 +33,51 @@
{% block extra_head %}{% endblock %}
{% block custom_head %}{% if config.custom_head %}{{ config.custom_head|safe }}{% endif %}{% endblock %}
</head>
<body class="{% if config_identity.light_theme %}light-theme {% endif %}{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<body class="{% if user.config_user.light_theme %}theme-light {% endif %}{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<a id="skip-to-main" class="screenreader-text" href="#main-content">Skip to Content</a>
<a id="skip-to-nav" class="screenreader-text" href="#side-navigation">Skip to Navigation</a>
<main>
{% block body_main %}
<header>
<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>
<a class="logo" href="/">
{% if identity and public_styling %}
<img src="{{ identity.domain.config_domain.site_icon|default:config.site_icon }}" width="32">
{{ identity.domain.config_domain.site_name|default:config.site_name }}
{% else %}
<img src="{{ request.domain.config_domain.site_icon|default:config.site_icon }}" width="32">
{{ request.domain.config_domain.site_name|default:config.site_name }}
{% 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">
{% 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>
{% if user.is_authenticated %}
<a href="/" title="My Account"><i class="fa-solid fa-user"></i></a>
{% if identity %}
<a href="{{ identity.urls.settings }}" title="Settings"><i class="fa-solid fa-gear"></i></a>
{% else %}
<a href="/settings" title="Settings"><i class="fa-solid fa-gear"></i></a>
{% endif %}
{% if user.admin %}
<a href="/admin" title="Administration"><i class="fa-solid fa-screwdriver-wrench"></i></a>
{% endif %}
{% elif not request.domain.config_domain.hide_login %}
<a href="{% url "login" %}" title="Login"><i class="fa-solid fa-right-to-bracket"></i></a>
{% endif %}
</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>
{% block announcements %}
{% include "_announcements.html" %}
{% endblock %}
{% include "activities/_image_viewer.html" %}
{% block content %}
{% 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

@ -28,55 +28,65 @@
{# fmt:on #}
</script>
{% include "forms/_field.html" %}
<div class="field">
<div class="label-input">
<label for="{{ field.id_for_label }}">
{{ field.label }}
{% if field.field.required %}<small>(Required)</small>{% endif %}
</label>
{% if field.help_text %}
<p class="help">
{{ field.help_text|safe|linebreaksbr }}
</p>
{% endif %}
{{ field.errors }}
{{ field }}
<div class="field multi-option">
<section class="icon-menu">
{# fmt:off #}
<span id="new_{{ field.name }}" stlye="display: none"
_="on load
get the (value of #id_{{ field.name }}) as Object
set items to it
for item in items
set f to {{ field.name }}.addEmptyField()
set one to the first <input.{{ field.name }}-{{ name_one }}/> in f then
set one@value to item.{{ name_one }}
<div class="multi-option">
{# fmt:off #}
<span id="new_{{ field.name }}" style="display: none"
_="on load
get the (value of #id_{{ field.name }}) as Object
set items to it
for item in items
set f to {{ field.name }}.addEmptyField()
set one to the first <input.{{ field.name }}-{{ name_one }}/> in f then
set one@value to item.{{ name_one }}
set two to the first <input.{{ field.name }}-{{ name_two }}/> in f then
set two@value to item.{{ name_two }}
end
get the (@data-min-empty of #id_{{ field.name }})
set min_empty to it
if items.length < min_empty then
repeat (min_empty - items.length) times
call {{ field.name }}.addEmptyField()
end
"></span>
{# fmt:on #}
set two to the first <input.{{ field.name }}-{{ name_two }}/> in f then
set two@value to item.{{ name_two }}
end
get the (@data-min-empty of #id_{{ field.name }})
set min_empty to it
if items.length < min_empty then
repeat (min_empty - items.length) times
call {{ field.name }}.addEmptyField()
end
"></span>
{# fmt:on #}
<div class="option">
<span class="option-field">
<button class="fa-solid fa-add" title="Add Row" _="on click {{ field.name }}.addEmptyField() then halt"></button>
</span>
</div>
<div class="option">
<span class="option-field">
<button class="fa-solid fa-add" title="Add Row" _="on click {{ field.name }}.addEmptyField() then halt"></button>
</span>
</div>
<div id="blank_{{ field.name }}" class="option option-row hidden">
<span class="option-field">
<input type=text class="{{ field.name }}-{{ name_one }}" name="{{ field.name }}_{{ name_one }}" value="">
</span>
<span class="option-field">
<input type=text class="{{ field.name }}-{{ name_two }}" name="{{ field.name }}_{{ name_two }}" value="">
</span>
<div class="right">
<button class="fa-solid fa-trash delete" title="Delete Row"
_="on click remove (closest parent .option)
then {{ field.name }}.collect{{ field.name|title }}Fields()
then halt" />
<div id="blank_{{ field.name }}" class="option option-row hidden">
<span class="option-field">
<input type=text class="{{ field.name }}-{{ name_one }}" name="{{ field.name }}_{{ name_one }}" value="">
</span>
<span class="option-field">
<input type=text class="{{ field.name }}-{{ name_two }}" name="{{ field.name }}_{{ name_two }}" value="">
</span>
<div class="right">
<button class="fa-solid fa-trash delete" title="Delete Row"
_="on click remove (closest parent .option)
then {{ field.name }}.collect{{ field.name|title }}Fields()
then halt" />
</div>
</div>
</div>
</section>
</div>
</div>
{% endwith %}

View file

@ -24,7 +24,7 @@
<a href="{{ identity.urls.view }}" class="handle">
{% endif %}
<div class="link">{{ identity.html_name_or_handle }}</div>
<small>@{{ identity.handle }}</small>
<small>@{{ identity.username }}@{{ identity.domain_id }}</small>
{% if link_handle is False %}
</div>
{% else %}

View file

@ -1,22 +0,0 @@
<nav>
<a href="{% url "identity_select" %}" {% if identities %}class="selected"{% endif %} title="Select Identity">
<i class="fa-solid fa-users-viewfinder"></i>
<span>Select Identity</span>
</a>
<a href="{% url "identity_create" %}" {% if form %}class="selected"{% endif %} title="Create Identity">
<i class="fa-solid fa-plus"></i>
<span>Create Identity</span>
</a>
<a href="{% url "logout" %}" title="Logout">
<i class="fa-solid fa-right-from-bracket"></i>
<span>Logout</span>
</a>
{% if request.user.admin %}
<hr>
<h3>Administration</h3>
<a href="{% url "admin_domains" %}" {% if section == "domains" %}class="selected"{% endif %} title="Domains">
<i class="fa-solid fa-globe"></i>
<span>Domains</span>
</a>
{% endif %}
</nav>

View file

@ -0,0 +1,10 @@
<section class="view-options">
<a href="{{ identity.urls.view }}" {% if not section %}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 section == "follows" %}class="selected"{% endif %}><strong>{{ following_count }}</strong> Following</a>
<a href="{{ identity.urls.followers }}" {% if inbound and section == "follows" %}class="selected"{% endif %}><strong>{{ followers_count }}</strong> Follower{{ followers_count|pluralize }}</a>
{% endif %}
{% if identity.local and identity.config_identity.search_enabled %}
<a href="{{ identity.urls.search }}" {% if section == "search" %}class="selected"{% endif %}>Search</a>
{% endif %}
</section>

View file

@ -1,78 +0,0 @@
<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">
<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 %}
<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>
</div>
</div>

View file

@ -1,7 +0,0 @@
{% extends "base.html" %}
{% block title %}{% block subtitle %}{% endblock %} - Settings{% endblock %}
{% block right_content %}
{% include "identity/_menu.html" %}
{% endblock %}

View file

@ -1,12 +1,15 @@
{% extends "identity/base.html" %}
{% extends "base.html" %}
{% block title %}Create Identity{% endblock %}
{% block content %}
<form action="." method="POST">
<h1>Create New Identity</h1>
<p>You can have multiple identities - they are totally separate, and share
nothing apart from your login details. Use them for alternates, projects, and more.</p>
<p>
You can have multiple identities - they are totally separate, and share
nothing apart from your login details. They can also be shared among multiple
users, so you can use them for company or project accounts.
</p>
{% csrf_token %}
<fieldset>
<legend>Identity Details</legend>

View file

@ -1,6 +1,6 @@
{% extends "identity/view.html" %}
{% block title %}{% if self.inbound %}Followers{% else %}Following{% endif %} - {{ identity }}{% endblock %}
{% block title %}{% if inbound %}Followers{% else %}Following{% endif %} - {{ identity }}{% endblock %}
{% block subcontent %}
@ -8,7 +8,7 @@
{% include "activities/_identity.html" %}
{% empty %}
<span class="empty">
This person has no {% if self.inbound %}followers{% else %}follows{% endif %} yet.
This person has no {% if inbound %}followers{% else %}follows{% endif %} yet.
</span>
{% endfor %}

View file

@ -0,0 +1,23 @@
{% extends "identity/view.html" %}
{% block title %}Search - {{ identity }}{% endblock %}
{% block subcontent %}
<form action="." method="post">
{% csrf_token %}
<fieldset>
{% include "forms/_field.html" with field=form.query %}
</fieldset>
<div class="buttons">
<button>Search</button>
</div>
</form>
{% for post in results %}
{% include "activities/_post.html" %}
{% empty %}
<p class="empty">No posts were found that match your search.</p>
{% endfor %}
{% endblock %}

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,88 +12,80 @@
{% endif %}
{% endblock %}
{% block body_class %}has-banner{% endblock %}
{% block content %}
{% if not request.htmx %}
<h1 class="identity">
{% if identity.local_image_url %}
<img src="{{ identity.local_image_url.relative }}" class="banner">
{% endif %}
<section class="identity">
{% if identity.local_image_url %}
<img src="{{ identity.local_image_url.relative }}" class="banner">
{% endif %}
<span
_="on click halt the event then call imageviewer.show(me)"
<span
_="on click halt the event then call imageviewer.show(me)"
>
<img src="{{ identity.local_icon_url.relative }}" class="icon"
data-original-url="{{ identity.local_icon_url.relative }}"
alt="Profile image for {{ identity.name }}"
>
<img src="{{ identity.local_icon_url.relative }}" class="icon"
data-original-url="{{ identity.local_icon_url.relative }}"
alt="Profile image for {{ identity.name }}"
>
</span>
</span>
{% if request.identity %}{% include "identity/_view_menu.html" %}{% endif %}
{{ identity.html_name_or_handle }}
<small>
@{{ identity.handle }}
<a title="Copy handle"
class="copy"
tabindex="0"
_="on click or keyup[key is 'Enter']
writeText('@{{ identity.handle }}') into the navigator's clipboard
then add .copied
wait 2s
then remove .copied">
<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 %}
{% 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 %}
<div class="view-options follows">
<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>
<div class="inline follow">
<div class="actions" role="menubar">
{% 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> Settings
</a>
{% endif %}
{% if request.user.admin or request.user.moderator %}
<a href="{% url "settings_profile" handle=identity.handle %}" class="button danger" title="View in Admin">
<i class="fa-solid fa-screwdriver-wrench"></i> Admin
</a>
{% endif %}
</div>
</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 %}
<h1>{{ identity.html_name_or_handle }}</h1>
<small>
@{{ identity.handle }}
<a title="Copy handle"
class="copy"
tabindex="0"
_="on click or keyup[key is 'Enter']
writeText('@{{ identity.handle }}') into the navigator's clipboard
then add .copied
wait 2s
then remove .copied">
<i class="fa-solid fa-copy"></i>
</a>
</small>
{% if identity.summary %}
<div class="bio">
{{ identity.safe_summary }}
</div>
{% endif %}
</section>
<section class="invisible identity-metadata">
{% if 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 %}
{% endif %}
</section>
{% 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 %}
{% include "identity/_tabs.html" %}
{% block subcontent %}
@ -104,10 +96,10 @@
{% if identity.local %}
No posts yet.
{% else %}
No posts have been received/retrieved by this server yet.
No posts have been received by this server yet.
{% if identity.profile_uri %}
You might find historical posts at
You may find historical posts at
<a href="{{ identity.profile_uri }}">their original profile ➔</a>
{% endif %}
{% endif %}
@ -120,7 +112,7 @@
{% 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>
<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">Next Page</a>
{% endif %}
</div>

View file

@ -1,85 +1,62 @@
<nav>
<h3>Identity</h3>
<a href="{% url "settings_profile" %}" {% 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">
<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">
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>Import/Export</span>
</a>
<hr>
<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>
<span>Login &amp; Security</span>
</a>
<a href="{% url "logout" %}">
<i class="fa-solid fa-right-from-bracket" title="Logout"></i>
<span>Logout</span>
</a>
{% if request.user.moderator or request.user.admin %}
{% if identity %}
{% include "identity/_identity_banner.html" %}
<h3>Identity Settings</h3>
<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_posting" handle=identity.handle %}" {% if section == "posting" %}class="selected"{% endif %} title="Posting">
<i class="fa-solid fa-message"></i>
<span>Posting</span>
</a>
<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>
<a href="{% url "settings_tokens" handle=identity.handle %}" {% if section == "tokens" %}class="selected"{% endif %} title="Authorized Apps">
<i class="fa-solid fa-window-restore"></i>
<span>Authorized Apps</span>
</a>
<a href="{% url "settings_delete" handle=identity.handle %}" {% if section == "delete" %}class="selected"{% endif %} title="Delete Identity">
<i class="fa-solid fa-user-slash"></i>
<span>Delete Identity</span>
</a>
<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>
<h3>Tools</h3>
<a href="{% url "settings_follows" handle=identity.handle %}" {% if section == "follows" %}class="selected"{% endif %} title="Follows">
<i class="fa-solid fa-arrow-right-arrow-left"></i>
<span>Follows</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 href="{% url "compose" handle=identity.handle %}" {% if section == "compose" %}class="selected"{% endif %} title="Compose">
<i class="fa-solid fa-pen-to-square"></i>
<span>Compose</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>
<h3>User Settings</h3>
<a href="{% url "settings" %}" title="Switch to User Settings">
<i class="fa-solid fa-arrow-right"></i>
<span>Go to User Settings</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>
{% else %}
<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>
<span>Login &amp; Security</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 href="{% url "settings_interface" %}" {% if section == "interface" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-display"></i>
<span>Interface</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 href="{% url "logout" %}">
<i class="fa-solid fa-right-from-bracket" title="Logout"></i>
<span>Logout</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>
<hr>
<h3>Identity Settings</h3>
<a href="/" title="Go to Identity Select">
<i class="fa-solid fa-arrow-right"></i>
<span>Go to Identity Select</span>
</a>
{% endif %}
</nav>

View file

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

View file

@ -0,0 +1,31 @@
{% extends "settings/base.html" %}
{% block subtitle %}Token{% endblock %}
{% block settings_content %}
<h1>Deleting {{ identity.handle }}</h1>
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Confirmation</legend>
<p>
Deleting this account is <i>permanent and irreversible</i>. Once you
start the deletion process, all your posts, interactions and profile
data will be gone, and other servers will be contacted to remove
your data as well.
</p>
<p>
Some other servers in the Fediverse may maintain a copy of your
profile and posts; we do not control them, and cannot speed up
or affect your data's removal. Most data will disappear from
the network after a couple of weeks.
</p>
{% include "forms/_field.html" with field=form.confirmation %}
</fieldset>
<div class="buttons">
<a href="{% url "settings" handle=identity.handle %}" class="button secondary left">Cancel</a>
<button class="danger">Delete {{ identity.handle }}</button>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends "settings/base.html" %}
{% block subtitle %}Follows{% endblock %}
{% block settings_content %}
<div class="view-options">
{% if inbound %}
<a href=".">Following ({{ num_outbound }})</a>
<a href="." class="selected">Followers ({{ num_inbound }})</a>
{% else %}
<a href=".?inbound=true" class="selected">Following ({{ num_outbound }})</a>
<a href=".?inbound=true">Followers ({{ num_inbound }})</a>
{% endif %}
</div>
<table class="items">
{% for other_identity in page_obj %}
<tr>
<td class="icon">
<a href="{{ other_identity.urls.view }}" class="overlay"></a>
<img
src="{{ other_identity.local_icon_url.relative }}"
class="icon"
alt="Avatar for {{ other_identity.name_or_handle }}"
loading="lazy"
data-handle="{{ other_identity.name_or_handle }}"
_="on error set my.src to generate_avatar(@data-handle)"
>
</td>
<td class="name">
<a href="{{ other_identity.urls.view }}" class="overlay"></a>
{{ other_identity.html_name_or_handle }}
<small>@{{ other_identity.handle }}</small>
</td>
<td class="stat">
{% if other_identity.id in outbound_ids %}
<span class="pill">Following</span>
{% endif %}
{% if other_identity.id in inbound_ids %}
<span class="pill">Follows You</span>
{% endif %}
</td>
<td class="actions">
<a href="{{ other_identity.urls.view }}" title="View"><i class="fa-solid fa-eye"></i></a>
</td>
</tr>
{% empty %}
<tr class="empty"><td>You {% if inbound %}have no followers{% else %}are not following anyone{% endif %}.</td></tr>
{% endfor %}
</table>
{% if inbound %}
{% include "admin/_pagination.html" with nouns="follower,followers" %}
{% else %}
{% include "admin/_pagination.html" with nouns="follow,follows" %}
{% endif %}
{% 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 %}
@ -32,7 +32,7 @@
<small>{{ numbers.outbound_follows }} {{ numbers.outbound_follows|pluralize:"follow,follows" }}</small>
</td>
<td>
<a href="{% url "settings_export_following_csv" %}">Download CSV</a>
<a href="{% url "settings_export_following_csv" handle=identity.handle %}">Download CSV</a>
</td>
</tr>
<tr>
@ -41,7 +41,7 @@
<small>{{ numbers.inbound_follows }} {{ numbers.inbound_follows|pluralize:"follower,followers" }}</small>
</td>
<td>
<a href="{% url "settings_export_followers_csv" %}">Download CSV</a>
<a href="{% url "settings_export_followers_csv" handle=identity.handle %}">Download CSV</a>
</td>
</tr>
<tr>

View file

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

Some files were not shown because too many files have changed in this diff Show more