From 8f57aa5f3707eb4ff743a1e102adf8e8bd5c0e7b Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Wed, 3 May 2023 22:42:37 -0600 Subject: [PATCH] UI/Domains Refactor Redoes the UI to remove timelines, promote domains, and a lot of other things to support the refactor. --- .github/workflows/test.yml | 4 - .../0014_post_content_vector_gin.py | 24 + activities/models/post.py | 5 + activities/services/post.py | 8 +- activities/services/search.py | 6 + activities/services/timeline.py | 5 +- activities/views/compose.py | 284 +++------ activities/views/explore.py | 26 - activities/views/hashtags.py | 38 -- activities/views/posts.py | 143 +---- activities/views/search.py | 22 - activities/views/timelines.py | 119 +--- api/decorators.py | 3 +- api/models/application.py | 22 + api/models/token.py | 4 + api/views/apps.py | 12 +- core/context.py | 3 - core/decorators.py | 10 - core/migrations/0002_domain_config.py | 36 ++ core/models/config.py | 75 ++- core/views.py | 48 +- docs/releases/0.9.rst | 102 +++ requirements.txt | 2 +- static/css/style.css | 581 +++++++----------- static/img/apps/elk.svg | 42 ++ static/img/apps/ivory.webp | Bin 0 -> 12544 bytes static/img/apps/tusky.png | Bin 0 -> 14698 bytes stator/runner.py | 3 +- takahe/__init__.py | 2 +- takahe/settings.py | 3 +- takahe/urls.py | 127 ++-- templates/_announcements.html | 10 + templates/_footer.html | 7 +- templates/activities/_bookmark.html | 9 - templates/activities/_boost.html | 11 - templates/activities/_hashtag.html | 11 - templates/activities/_hashtag_follow.html | 9 - templates/activities/_image_upload.html | 15 - templates/activities/_image_uploaded.html | 19 - templates/activities/_like.html | 11 - templates/activities/_menu.html | 86 --- templates/activities/_post.html | 70 +-- templates/activities/_reply.html | 5 - templates/activities/compose.html | 35 +- templates/activities/debug_json.html | 2 - templates/activities/explore_tag.html | 16 - templates/activities/federated.html | 3 - templates/activities/follows.html | 60 -- templates/activities/home.html | 75 ++- templates/activities/local.html | 25 - templates/activities/notifications.html | 75 +-- templates/activities/post.html | 16 +- templates/activities/search.html | 48 -- templates/activities/tag.html | 41 +- templates/admin/_menu.html | 61 ++ templates/admin/announcement_create.html | 4 +- templates/admin/announcement_delete.html | 2 +- templates/admin/announcement_edit.html | 4 +- templates/admin/announcements.html | 4 +- templates/admin/base_main.html | 15 + templates/admin/domain_create.html | 4 +- templates/admin/domain_delete.html | 4 +- templates/admin/domain_edit.html | 14 +- templates/admin/domains.html | 4 +- templates/admin/emoji.html | 4 +- templates/admin/emoji_create.html | 4 +- templates/admin/federation.html | 4 +- templates/admin/federation_edit.html | 4 +- templates/admin/hashtag_edit.html | 4 +- templates/admin/hashtags.html | 4 +- templates/admin/identities.html | 4 +- templates/admin/identity_edit.html | 4 +- templates/admin/invite_create.html | 4 +- templates/admin/invite_view.html | 4 +- templates/admin/invites.html | 4 +- templates/admin/report_view.html | 4 +- templates/admin/reports.html | 4 +- templates/admin/settings.html | 20 + templates/admin/stator.html | 4 +- templates/admin/user_edit.html | 4 +- templates/admin/users.html | 4 +- templates/base.html | 90 ++- templates/base_plain.html | 33 - templates/forms/_json_name_value_list.html | 96 +-- templates/identity/_identity_banner.html | 2 +- templates/identity/_menu.html | 22 - templates/identity/_tabs.html | 10 + templates/identity/_view_menu.html | 78 --- templates/identity/base.html | 7 - templates/identity/create.html | 9 +- templates/identity/follows.html | 4 +- templates/identity/search.html | 23 + templates/identity/select.html | 23 - templates/identity/view.html | 146 +++-- templates/settings/_menu.html | 123 ++-- templates/settings/base.html | 12 +- templates/settings/delete.html | 31 + templates/settings/follows.html | 56 ++ templates/settings/import_export.html | 6 +- templates/settings/login_security.html | 2 +- templates/settings/profile.html | 15 +- templates/settings/settings.html | 2 +- templates/settings/token_create.html | 27 + templates/settings/token_edit.html | 49 ++ templates/settings/tokens.html | 30 + tests/activities/views/test_compose.py | 43 +- tests/activities/views/test_posts.py | 20 - tests/activities/views/test_timelines.py | 12 - tests/conftest.py | 11 +- tests/users/views/settings/test_privacy.py | 6 +- tests/users/views/test_import_export.py | 18 +- users/decorators.py | 28 - users/middleware.py | 34 +- users/models/domain.py | 8 + users/models/identity.py | 20 +- users/models/user.py | 8 + users/shortcuts.py | 11 + users/views/admin/domains.py | 47 ++ users/views/admin/identities.py | 3 +- users/views/admin/reports.py | 4 +- users/views/admin/settings.py | 7 + users/views/announcements.py | 4 +- users/views/base.py | 25 + users/views/identity.py | 96 ++- users/views/report.py | 76 --- users/views/settings/__init__.py | 23 +- users/views/settings/delete.py | 32 + .../views => users/views/settings}/follows.py | 21 +- users/views/settings/import_export.py | 31 +- users/views/settings/interface.py | 45 +- users/views/settings/posting.py | 18 + users/views/settings/profile.py | 68 +- users/views/settings/security.py | 5 +- users/views/settings/settings_page.py | 32 +- users/views/settings/tokens.py | 77 +++ 135 files changed, 1966 insertions(+), 2381 deletions(-) create mode 100644 activities/migrations/0014_post_content_vector_gin.py delete mode 100644 activities/views/explore.py delete mode 100644 activities/views/hashtags.py delete mode 100644 activities/views/search.py create mode 100644 core/migrations/0002_domain_config.py create mode 100644 docs/releases/0.9.rst create mode 100755 static/img/apps/elk.svg create mode 100755 static/img/apps/ivory.webp create mode 100755 static/img/apps/tusky.png delete mode 100644 templates/activities/_bookmark.html delete mode 100644 templates/activities/_boost.html delete mode 100644 templates/activities/_hashtag.html delete mode 100644 templates/activities/_hashtag_follow.html delete mode 100644 templates/activities/_image_upload.html delete mode 100644 templates/activities/_image_uploaded.html delete mode 100644 templates/activities/_like.html delete mode 100644 templates/activities/_menu.html delete mode 100644 templates/activities/_reply.html delete mode 100644 templates/activities/explore_tag.html delete mode 100644 templates/activities/federated.html delete mode 100644 templates/activities/follows.html delete mode 100644 templates/activities/local.html delete mode 100644 templates/activities/search.html create mode 100644 templates/admin/_menu.html create mode 100644 templates/admin/base_main.html create mode 100644 templates/admin/settings.html delete mode 100644 templates/identity/_menu.html create mode 100644 templates/identity/_tabs.html delete mode 100644 templates/identity/_view_menu.html delete mode 100644 templates/identity/base.html create mode 100644 templates/identity/search.html delete mode 100644 templates/identity/select.html create mode 100644 templates/settings/delete.html create mode 100644 templates/settings/follows.html create mode 100644 templates/settings/token_create.html create mode 100644 templates/settings/token_edit.html create mode 100644 templates/settings/tokens.html delete mode 100644 tests/activities/views/test_posts.py delete mode 100644 tests/activities/views/test_timelines.py create mode 100644 users/views/base.py delete mode 100644 users/views/report.py create mode 100644 users/views/settings/delete.py rename {activities/views => users/views/settings}/follows.py (83%) create mode 100644 users/views/settings/posting.py create mode 100644 users/views/settings/tokens.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e1c5de2..3ac8960 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/activities/migrations/0014_post_content_vector_gin.py b/activities/migrations/0014_post_content_vector_gin.py new file mode 100644 index 0000000..1473e1e --- /dev/null +++ b/activities/migrations/0014_post_content_vector_gin.py @@ -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", + ), + ), + ] diff --git a/activities/models/post.py b/activities/models/post.py index 13e4fc2..aaf2a83 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -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", diff --git a/activities/services/post.py b/activities/services/post.py index f225fdd..f7e0b31 100644 --- a/activities/services/post.py +++ b/activities/services/post.py @@ -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] = [] diff --git a/activities/services/search.py b/activities/services/search.py index e9a6920..b8dcae6 100644 --- a/activities/services/search.py +++ b/activities/services/search.py @@ -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 diff --git a/activities/services/timeline.py b/activities/services/timeline.py index 734fa74..a065b73 100644 --- a/activities/services/timeline.py +++ b/activities/services/timeline.py @@ -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 ( diff --git a/activities/views/compose.py b/activities/views/compose.py index 8cd378d..4da4154 100644 --- a/activities/views/compose.py +++ b/activities/views/compose.py @@ -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 + make called errorlist + make
  • 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 + 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 - make called errorlist - make
  • 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 - 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} - ) diff --git a/activities/views/explore.py b/activities/views/explore.py deleted file mode 100644 index ddb1e6c..0000000 --- a/activities/views/explore.py +++ /dev/null @@ -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 diff --git a/activities/views/hashtags.py b/activities/views/hashtags.py deleted file mode 100644 index e5d5c44..0000000 --- a/activities/views/hashtags.py +++ /dev/null @@ -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) diff --git a/activities/views/posts.py b/activities/views/posts.py index d8e6fdc..797a6df 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -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("/") diff --git a/activities/views/search.py b/activities/views/search.py deleted file mode 100644 index 4c709e0..0000000 --- a/activities/views/search.py +++ /dev/null @@ -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) diff --git a/activities/views/timelines.py b/activities/views/timelines.py index 2e7b710..20ba613 100644 --- a/activities/views/timelines.py +++ b/activities/views/timelines.py @@ -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 diff --git a/api/decorators.py b/api/decorators.py index 09550ee..411e314 100644 --- a/api/decorators.py +++ b/api/decorators.py @@ -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) diff --git a/api/models/application.py b/api/models/application.py index 89bea5f..4a17a60 100644 --- a/api/models/application.py +++ b/api/models/application.py @@ -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", + ) diff --git a/api/models/token.py b/api/models/token.py index d070a01..fc1f2ff 100644 --- a/api/models/token.py +++ b/api/models/token.py @@ -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. diff --git a/api/views/apps.py b/api/views/apps.py index 758aa49..e20038d 100644 --- a/api/views/apps.py +++ b/api/views/apps.py @@ -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) diff --git a/core/context.py b/core/context.py index d94e645..14e02bb 100644 --- a/core/context.py +++ b/core/context.py @@ -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, diff --git a/core/decorators.py b/core/decorators.py index dc8d4d2..dd4fc2f 100644 --- a/core/decorators.py +++ b/core/decorators.py @@ -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", *, diff --git a/core/migrations/0002_domain_config.py b/core/migrations/0002_domain_config.py new file mode 100644 index 0000000..e5305a2 --- /dev/null +++ b/core/migrations/0002_domain_config.py @@ -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")}, + ), + ] diff --git a/core/models/config.py b/core/models/config.py index 0c79f80..3c46947 100644 --- a/core/models/config.py +++ b/core/models/config.py @@ -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 = "" diff --git a/core/views.py b/core/views.py index c782728..1b4f348 100644 --- a/core/views.py +++ b/core/views.py @@ -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, diff --git a/docs/releases/0.9.rst b/docs/releases/0.9.rst new file mode 100644 index 0000000..50c8e21 --- /dev/null +++ b/docs/releases/0.9.rst @@ -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 `_. + +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 `_, +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. diff --git a/requirements.txt b/requirements.txt index 2ad2603..7ad2dc1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/static/css/style.css b/static/css/style.css index c8c5740..aad6a20 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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, diff --git a/static/img/apps/elk.svg b/static/img/apps/elk.svg new file mode 100755 index 0000000..aea8c9d --- /dev/null +++ b/static/img/apps/elk.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/static/img/apps/ivory.webp b/static/img/apps/ivory.webp new file mode 100755 index 0000000000000000000000000000000000000000..6f37fcacc31eddccf961efffd0b9b236f3e64b86 GIT binary patch literal 12544 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 literal 0 HcmV?d00001 diff --git a/stator/runner.py b/stator/runner.py index 5525be7..758d09b 100644 --- a/stator/runner.py +++ b/stator/runner.py @@ -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 diff --git a/takahe/__init__.py b/takahe/__init__.py index 777f190..8ea1e34 100644 --- a/takahe/__init__.py +++ b/takahe/__init__.py @@ -1 +1 @@ -__version__ = "0.8.0" +__version__ = "0.9.0-dev" diff --git a/takahe/settings.py b/takahe/settings.py index 58a8730..ff50e18 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -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" diff --git a/takahe/urls.py b/takahe/urls.py index e78ebd5..6ee5444 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -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//", timelines.Tag.as_view(), name="tag"), - path("tags//follow/", hashtags.HashtagFollow.as_view()), - path("tags//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/", + "@/settings/", + settings.SettingsRoot.as_view(), + name="settings", + ), + path( + "@/settings/profile/", + settings.ProfilePage.as_view(), + name="settings_profile", + ), + path( + "@/settings/posting/", + settings.PostingPage.as_view(), + name="settings_posting", + ), + path( + "@/settings/follows/", + settings.FollowsPage.as_view(), + name="settings_follows", + ), + path( + "@/settings/import_export/", settings.ImportExportPage.as_view(), name="settings_import_export", ), path( - "settings/import_export/following.csv", + "@/settings/import_export/following.csv", settings.CsvFollowing.as_view(), name="settings_export_following_csv", ), path( - "settings/import_export/followers.csv", + "@/settings/import_export/followers.csv", settings.CsvFollowers.as_view(), name="settings_export_followers_csv", ), + path( + "@/settings/tokens/", + settings.TokensRoot.as_view(), + name="settings_tokens", + ), + path( + "@/settings/tokens/create/", + settings.TokenCreate.as_view(), + name="settings_token_create", + ), + path( + "@/settings/tokens//", + settings.TokenEdit.as_view(), + name="settings_token_edit", + ), + path( + "@/settings/delete/", + settings.DeleteIdentity.as_view(), + name="settings_delete", + ), path( "admin/", admin.AdminRoot.as_view(), @@ -236,30 +240,18 @@ urlpatterns = [ path("@/", identity.ViewIdentity.as_view()), path("@/inbox/", activitypub.Inbox.as_view()), path("@/outbox/", activitypub.Outbox.as_view()), - path("@/action/", identity.ActionIdentity.as_view()), path("@/rss/", identity.IdentityFeed()), - path("@/report/", report.SubmitReport.as_view()), path("@/following/", identity.IdentityFollows.as_view(inbound=False)), path("@/followers/", identity.IdentityFollows.as_view(inbound=True)), + path("@/search/", identity.IdentitySearch.as_view()), + path( + "@/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("@/compose/", compose.Compose.as_view(), name="compose"), path("@/posts//", posts.Individual.as_view()), - path("@/posts//like/", posts.Like.as_view()), - path("@/posts//unlike/", posts.Like.as_view(undo=True)), - path("@/posts//boost/", posts.Boost.as_view()), - path("@/posts//unboost/", posts.Boost.as_view(undo=True)), - path("@/posts//bookmark/", posts.Bookmark.as_view()), - path( - "@/posts//unbookmark/", posts.Bookmark.as_view(undo=True) - ), - path("@/posts//delete/", posts.Delete.as_view()), - path("@/posts//report/", report.SubmitReport.as_view()), - path("@/posts//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//", auth.Signup.as_view(), name="signup"), path("auth/reset/", auth.TriggerReset.as_view(), name="trigger_reset"), path("auth/reset//", auth.PerformReset.as_view(), name="password_reset"), - # Identity selection - path("@/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//dismiss/", announcements.AnnouncementDismiss.as_view()), diff --git a/templates/_announcements.html b/templates/_announcements.html index fd60116..0201564 100644 --- a/templates/_announcements.html +++ b/templates/_announcements.html @@ -4,3 +4,13 @@ {{ announcement.html }} {% endfor %} +{% if messages %} +
      + {% for message in messages %} +
      + + {{ message }} +
      + {% endfor %} +
    +{% endif %} diff --git a/templates/_footer.html b/templates/_footer.html index d5526d3..7f2ac32 100644 --- a/templates/_footer.html +++ b/templates/_footer.html @@ -1,7 +1,8 @@ diff --git a/templates/activities/_bookmark.html b/templates/activities/_bookmark.html deleted file mode 100644 index ab9731d..0000000 --- a/templates/activities/_bookmark.html +++ /dev/null @@ -1,9 +0,0 @@ -{% if post.pk in bookmarks %} - - - -{% else %} - - - -{% endif %} diff --git a/templates/activities/_boost.html b/templates/activities/_boost.html deleted file mode 100644 index 5f10856..0000000 --- a/templates/activities/_boost.html +++ /dev/null @@ -1,11 +0,0 @@ -{% if post.pk in interactions.boost %} - - - - -{% else %} - - - - -{% endif %} diff --git a/templates/activities/_hashtag.html b/templates/activities/_hashtag.html deleted file mode 100644 index 02d107a..0000000 --- a/templates/activities/_hashtag.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - {{ hashtag.display_name }} - - {% if not hide_stats %} - - Post count: {{ hashtag.stats.total }} - - {% endif %} - diff --git a/templates/activities/_hashtag_follow.html b/templates/activities/_hashtag_follow.html deleted file mode 100644 index cdf00c5..0000000 --- a/templates/activities/_hashtag_follow.html +++ /dev/null @@ -1,9 +0,0 @@ -{% if follow %} - -{% else %} - -{% endif %} diff --git a/templates/activities/_image_upload.html b/templates/activities/_image_upload.html deleted file mode 100644 index dec9160..0000000 --- a/templates/activities/_image_upload.html +++ /dev/null @@ -1,15 +0,0 @@ -
    - {% csrf_token %} - {% include "forms/_field.html" with field=form.image %} - {% include "forms/_field.html" with field=form.description %} -
    - - -
    -
    diff --git a/templates/activities/_image_uploaded.html b/templates/activities/_image_uploaded.html deleted file mode 100644 index e2424ce..0000000 --- a/templates/activities/_image_uploaded.html +++ /dev/null @@ -1,19 +0,0 @@ -
    - - -

    - {{ attachment.name|default:"(no description)" }} -

    -
    - -
    -
    -{% if request.htmx %} - -{% endif %} diff --git a/templates/activities/_like.html b/templates/activities/_like.html deleted file mode 100644 index 6c45710..0000000 --- a/templates/activities/_like.html +++ /dev/null @@ -1,11 +0,0 @@ -{% if post.pk in interactions.like %} - - - - -{% else %} - - - - -{% endif %} diff --git a/templates/activities/_menu.html b/templates/activities/_menu.html deleted file mode 100644 index 2be00cc..0000000 --- a/templates/activities/_menu.html +++ /dev/null @@ -1,86 +0,0 @@ - - -{% if current_page == "home" %} -

    Compose

    -
    - {% csrf_token %} - {{ form.text }} - {{ form.content_warning }} - -
    - {{ config.post_length }} - - -
    -
    -{% endif %} diff --git a/templates/activities/_post.html b/templates/activities/_post.html index a983004..6834ffb 100644 --- a/templates/activities/_post.html +++ b/templates/activities/_post.html @@ -27,12 +27,8 @@ {% if post.summary %} - {% if config_identity.expand_linked_cws %} -
    - {% else %} -
    - {% endif %} - {{ post.summary }} +
    + {{ post.summary }}
    {% endif %} @@ -77,41 +73,37 @@
    {% endif %} - {% if request.identity %} -
    diff --git a/templates/activities/_reply.html b/templates/activities/_reply.html deleted file mode 100644 index 7ae3f88..0000000 --- a/templates/activities/_reply.html +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/templates/activities/compose.html b/templates/activities/compose.html index e587f16..2f2eaed 100644 --- a/templates/activities/compose.html +++ b/templates/activities/compose.html @@ -1,41 +1,26 @@ -{% extends "base.html" %} +{% extends "settings/base.html" %} {% block title %}Compose{% endblock %} -{% block content %} -
    +{% block settings_content %} + {% csrf_token %}
    - Content - {% if reply_to %} - - {% include "activities/_mini_post.html" with post=reply_to %} - {% endif %} - {{ form.reply_to }} + Compose +

    For more advanced posting options, like editing and multiple image uploads, please use an app.

    {{ 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 %}
    - Images - {% if post %} - {% for attachment in post.attachments.all %} - {% include "activities/_image_uploaded.html" %} - {% endfor %} - {% endif %} - {% if not post or post.attachments.count < 4 %} - - {% endif %} + Image + {% include "forms/_field.html" with field=form.image %} + {% include "forms/_field.html" with field=form.image_caption %}
    {{ config.post_length }} - +
    {% endblock %} diff --git a/templates/activities/debug_json.html b/templates/activities/debug_json.html index bc72019..387ed13 100644 --- a/templates/activities/debug_json.html +++ b/templates/activities/debug_json.html @@ -2,8 +2,6 @@ {% block title %}Debug JSON{% endblock %} -{% block body_class %}no-sidebar{% endblock %} - {% block content %}
    {% csrf_token %} diff --git a/templates/activities/explore_tag.html b/templates/activities/explore_tag.html deleted file mode 100644 index ae268ef..0000000 --- a/templates/activities/explore_tag.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html" %} - -{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %} - -{% block content %} -
    Explore Trending Tags
    - -
    - {% for hashtag in page_obj %} - {% include "activities/_hashtag.html" %} - {% empty %} - No tags are trending yet. - {% endfor %} - -
    -{% endblock %} diff --git a/templates/activities/federated.html b/templates/activities/federated.html deleted file mode 100644 index 9e61b6b..0000000 --- a/templates/activities/federated.html +++ /dev/null @@ -1,3 +0,0 @@ -{% extends "activities/local.html" %} - -{% block title %}Federated Timeline{% endblock %} diff --git a/templates/activities/follows.html b/templates/activities/follows.html deleted file mode 100644 index c6bc486..0000000 --- a/templates/activities/follows.html +++ /dev/null @@ -1,60 +0,0 @@ -{% extends "base.html" %} -{% load activity_tags %} - -{% block subtitle %}Follows{% endblock %} - -{% block content %} - - -
    - {% for identity in page_obj %} - -
    - {% include "identity/_identity_banner.html" with identity=identity link_avatar=False link_handle=False %} -
    -
    - {% if identity.id in outbound_ids %} - Following - {% endif %} - {% if identity.id in inbound_ids %} - Follows You - {% endif %} - - {% if inbound %} - - {% csrf_token %} - {% if identity.id in outbound_ids %} - - - {% else %} - - - {% endif %} - - {% endif %} - -
    -
    - {% empty %} -

    You {% if inbound %}have no followers{% else %}are not following anyone{% endif %}.

    - {% endfor %} -
    - - -{% endblock %} diff --git a/templates/activities/home.html b/templates/activities/home.html index 6c20661..3194798 100644 --- a/templates/activities/home.html +++ b/templates/activities/home.html @@ -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" %} -
    - - {{ event.subject_identity.html_name_or_handle }} - boosted - -
    - {% include "activities/_post.html" with post=event.subject_post %} - {% endif %} - {% empty %} - Nothing to show yet. - {% endfor %} - - +
    +

    Identities

    + {% for identity in identities %} + + + + {{ identity.html_name_or_handle }} + @{{ identity.handle }} + + + + + {% empty %} +

    You have no identities.

    + {% endfor %} + + Create a new identity + +
    +
    +

    Apps

    +

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

    + +
    {% endblock %} diff --git a/templates/activities/local.html b/templates/activities/local.html deleted file mode 100644 index 5a9b6a4..0000000 --- a/templates/activities/local.html +++ /dev/null @@ -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 %} - - -{% endblock %} diff --git a/templates/activities/notifications.html b/templates/activities/notifications.html index d4116dd..8a5926b 100644 --- a/templates/activities/notifications.html +++ b/templates/activities/notifications.html @@ -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 %} -
    - {% if notification_options.followed %} - Followers - {% else %} - Followers - {% endif %} - {% if notification_options.boosted %} - Boosts - {% else %} - Boosts - {% endif %} - {% if notification_options.liked %} - Likes - {% else %} - Likes - {% endif %} - {% if notification_options.mentioned %} - Mentions - {% else %} - Mentions - {% endif %} - {% if request.user.admin %} - {% if notification_options.identity_created %} - New Identities +
    - {% 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 %} - + {% endblock %} diff --git a/templates/activities/post.html b/templates/activities/post.html index cb7e60e..5fb8554 100644 --- a/templates/activities/post.html +++ b/templates/activities/post.html @@ -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 %} + {% endblock %} diff --git a/templates/activities/search.html b/templates/activities/search.html deleted file mode 100644 index f1cf717..0000000 --- a/templates/activities/search.html +++ /dev/null @@ -1,48 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Search{% endblock %} - -{% block content %} -
    - {% csrf_token %} -
    - {% include "forms/_field.html" with field=form.query %} -
    -
    - -
    -
    - {% if results.identities %} -

    People

    - {% for identity in results.identities %} - {% include "activities/_identity.html" %} - {% endfor %} - {% endif %} - {% if results.posts %} -

    Posts

    -
    - {% for post in results.posts %} - {% include "activities/_post.html" %} - {% endfor %} -
    - {% endif %} - {% if results.hashtags %} -

    Hashtags

    -
    - {% for hashtag in results.hashtags %} - {% include "activities/_hashtag.html" with hide_stats=True %} - {% endfor %} -
    - {% endif %} - {% if results and not results.identities and not results.hashtags and not results.posts %} -

    No results

    -

    - We could not find anything matching your query. -

    -

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

    - {% endif %} -{% endblock %} diff --git a/templates/activities/tag.html b/templates/activities/tag.html index e0e35bf..85dcb52 100644 --- a/templates/activities/tag.html +++ b/templates/activities/tag.html @@ -3,27 +3,26 @@ {% block title %}#{{ hashtag.display_name }} Timeline{% endblock %} {% block content %} -
    -