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 0000000..6f37fca
Binary files /dev/null and b/static/img/apps/ivory.webp differ
diff --git a/static/img/apps/tusky.png b/static/img/apps/tusky.png
new file mode 100755
index 0000000..8879519
Binary files /dev/null and b/static/img/apps/tusky.png differ
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 %}
+
+ {% 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 %}
-
-
- {{ post.stats_with_defaults.boosts }}
-
-{% else %}
-
-
- {{ post.stats_with_defaults.boosts }}
-
-{% 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 %}
-
- Unfollow
-
-{% else %}
-
- Follow
-
-{% 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 @@
-
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)" }}
-
-
- Remove
-
-
-{% if request.htmx %}
-
- Add Image
-
-{% 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 %}
-
-
- {{ post.stats_with_defaults.likes }}
-
-{% else %}
-
-
- {{ post.stats_with_defaults.likes }}
-
-{% 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 @@
-
-
-
- Home
-
- {% if request.user.is_authenticated %}
-
-
- Notifications
-
- {% comment %}
- Not sure we want to show this quite yet
-
-
- Explore
-
- {% endcomment %}
-
-
- Local
-
-
-
- Federated
-
-
-
- Follows
-
-
-
-
-
- Compose
-
-
-
- Search
-
- {% if current_page == "tag" %}
-
-
- {{ hashtag.display_name }}
-
- {% endif %}
-
-
- Settings
-
-
-
- Select Identity
-
- {% else %}
-
-
- Local Posts
-
-
-
- Explore
-
-
- {% if config.signup_allowed %}
-
-
- Create Account
-
- {% endif %}
- {% endif %}
-
-
-{% if current_page == "home" %}
- Compose
-
-{% 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 %}
-
- {% include "activities/_reply.html" %}
- {% include "activities/_like.html" %}
- {% include "activities/_boost.html" %}
- {% include "activities/_bookmark.html" %}
-
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 @@
-
-
-
- {{ post.stats_with_defaults.replies }}
-
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 %}
-
{% 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 %}
- {% if results.identities %}
- People
- {% for identity in results.identities %}
- {% include "activities/_identity.html" %}
- {% endfor %}
- {% endif %}
- {% if results.posts %}
- Posts
-
- {% endif %}
- {% if results.hashtags %}
- Hashtags
-
- {% 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 %}
-
-
- {% include "activities/_hashtag_follow.html" %}
+
+
+
+ {{ hashtag.display_name }}
+
-
- {{ hashtag.display_name }}
+ {% for post in page_obj %}
+ {% include "activities/_post.html" %}
+ {% empty %}
+ No posts yet.
+ {% endfor %}
+
+
-
- {% for post in page_obj %}
- {% include "activities/_post.html" %}
- {% empty %}
- No posts yet.
- {% endfor %}
-
-
+
{% endblock %}
diff --git a/templates/admin/_menu.html b/templates/admin/_menu.html
new file mode 100644
index 0000000..0b0ec10
--- /dev/null
+++ b/templates/admin/_menu.html
@@ -0,0 +1,61 @@
+
+ {% if request.user.moderator or request.user.admin %}
+ Moderation
+
+
+ Identities
+
+
+
+ Invites
+
+
+
+ Hashtags
+
+
+
+ Emoji
+
+
+
+ Reports
+
+ {% endif %}
+ {% if request.user.admin %}
+
+ Administration
+
+
+ Basic
+
+
+
+ Policies
+
+
+
+ Announcements
+
+
+
+ Domains
+
+
+
+ Federation
+
+
+
+ Users
+
+
+
+ Stator
+
+
+
+ Django Admin
+
+ {% endif %}
+
diff --git a/templates/admin/announcement_create.html b/templates/admin/announcement_create.html
index c89e521..9430760 100644
--- a/templates/admin/announcement_create.html
+++ b/templates/admin/announcement_create.html
@@ -1,8 +1,8 @@
-{% extends "settings/base.html" %}
+{% extends "admin/base_main.html" %}
{% block subtitle %}Create Announcement{% endblock %}
-{% block content %}
+{% block settings_content %}