Even more refactor

This commit is contained in:
Andrew Godwin 2023-04-29 14:53:53 -06:00
parent 0225c6e8ba
commit ef08013541
47 changed files with 566 additions and 690 deletions

View file

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

View file

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

View file

@ -123,6 +123,12 @@ class SearchService:
results.add(hashtag) results.add(hashtag)
return results 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): def search_all(self):
""" """
Returns all possible results for a search Returns all possible results for a search

View file

@ -122,12 +122,7 @@ class Compose(FormView):
] = self.identity.config_identity.default_post_visibility ] = self.identity.config_identity.default_post_visibility
if self.reply_to: if self.reply_to:
initial["reply_to"] = self.reply_to.pk initial["reply_to"] = self.reply_to.pk
if self.reply_to.visibility == Post.Visibilities.public: initial["visibility"] = self.reply_to.visibility
initial[
"visibility"
] = self.identity.config_identity.default_reply_visibility
else:
initial["visibility"] = self.reply_to.visibility
initial["content_warning"] = self.reply_to.summary initial["content_warning"] = self.reply_to.summary
# Build a set of mentions for the content to start as # Build a set of mentions for the content to start as
mentioned = {self.reply_to.author} mentioned = {self.reply_to.author}

View file

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

View file

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

View file

@ -43,6 +43,14 @@ class Config(models.Model):
on_delete=models.CASCADE, 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) json = models.JSONField(blank=True, null=True)
image = models.ImageField( image = models.ImageField(
blank=True, blank=True,
@ -52,7 +60,7 @@ class Config(models.Model):
class Meta: class Meta:
unique_together = [ unique_together = [
("key", "user", "identity"), ("key", "user", "identity", "domain"),
] ]
system: ClassVar["Config.ConfigOptions"] # type: ignore system: ClassVar["Config.ConfigOptions"] # type: ignore
@ -86,7 +94,7 @@ class Config(models.Model):
""" """
return cls.load_values( return cls.load_values(
cls.SystemOptions, cls.SystemOptions,
{"identity__isnull": True, "user__isnull": True}, {"identity__isnull": True, "user__isnull": True, "domain__isnull": True},
) )
@classmethod @classmethod
@ -96,7 +104,7 @@ class Config(models.Model):
""" """
return await sync_to_async(cls.load_values)( return await sync_to_async(cls.load_values)(
cls.SystemOptions, cls.SystemOptions,
{"identity__isnull": True, "user__isnull": True}, {"identity__isnull": True, "user__isnull": True, "domain__isnull": True},
) )
@classmethod @classmethod
@ -106,7 +114,7 @@ class Config(models.Model):
""" """
return cls.load_values( return cls.load_values(
cls.UserOptions, cls.UserOptions,
{"identity__isnull": True, "user": user}, {"identity__isnull": True, "user": user, "domain__isnull": True},
) )
@classmethod @classmethod
@ -116,7 +124,7 @@ class Config(models.Model):
""" """
return await sync_to_async(cls.load_values)( return await sync_to_async(cls.load_values)(
cls.UserOptions, cls.UserOptions,
{"identity__isnull": True, "user": user}, {"identity__isnull": True, "user": user, "domain__isnull": True},
) )
@classmethod @classmethod
@ -126,7 +134,7 @@ class Config(models.Model):
""" """
return cls.load_values( return cls.load_values(
cls.IdentityOptions, cls.IdentityOptions,
{"identity": identity, "user__isnull": True}, {"identity": identity, "user__isnull": True, "domain__isnull": True},
) )
@classmethod @classmethod
@ -136,7 +144,27 @@ class Config(models.Model):
""" """
return await sync_to_async(cls.load_values)( return await sync_to_async(cls.load_values)(
cls.IdentityOptions, 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 @classmethod
@ -170,7 +198,7 @@ class Config(models.Model):
key, key,
value, value,
cls.SystemOptions, cls.SystemOptions,
{"identity__isnull": True, "user__isnull": True}, {"identity__isnull": True, "user__isnull": True, "domain__isnull": True},
) )
@classmethod @classmethod
@ -179,7 +207,7 @@ class Config(models.Model):
key, key,
value, value,
cls.UserOptions, cls.UserOptions,
{"identity__isnull": True, "user": user}, {"identity__isnull": True, "user": user, "domain__isnull": True},
) )
@classmethod @classmethod
@ -188,7 +216,16 @@ class Config(models.Model):
key, key,
value, value,
cls.IdentityOptions, 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): class SystemOptions(pydantic.BaseModel):
@ -239,20 +276,22 @@ class Config(models.Model):
custom_head: str | None custom_head: str | None
class UserOptions(pydantic.BaseModel): class UserOptions(pydantic.BaseModel):
light_theme: bool = False
pass
class IdentityOptions(pydantic.BaseModel): class IdentityOptions(pydantic.BaseModel):
toot_mode: bool = False toot_mode: bool = False
default_post_visibility: int = 0 # Post.Visibilities.public default_post_visibility: int = 0 # Post.Visibilities.public
default_reply_visibility: int = 1 # Post.Visibilities.unlisted
visible_follows: bool = True visible_follows: bool = True
light_theme: bool = False search_enabled: bool = True
# wellness Options # Wellness Options
visible_reaction_counts: bool = True visible_reaction_counts: bool = True
expand_linked_cws: 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 # type: ignore
hide_login: bool = False
custom_css: str = ""

View file

@ -82,13 +82,14 @@ td a {
/* Base template styling */ /* Base template styling */
:root { :root {
--color-highlight: #449c8c;
--color-bg-main: #26323c; --color-bg-main: #26323c;
--color-bg-menu: #2e3e4c; --color-bg-menu: #2e3e4c;
--color-bg-box: #1a2631; --color-bg-box: #1a2631;
--color-bg-error: rgb(87, 32, 32); --color-bg-error: rgb(87, 32, 32);
--color-highlight: #449c8c;
--color-delete: #8b2821; --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); --color-main-shadow: rgba(0, 0, 0, 0.6);
--size-main-shadow: 50px; --size-main-shadow: 50px;
@ -96,40 +97,36 @@ td a {
--color-text-dull: #99a; --color-text-dull: #99a;
--color-text-main: #fff; --color-text-main: #fff;
--color-text-link: var(--color-highlight); --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: #000;
--color-input-border-active: #444b5d; --color-input-border-active: #444b5d;
--color-button-secondary: #2e3e4c; --color-button-secondary: #2e3e4c;
--color-button-disabled: #7c9c97; --color-button-disabled: #7c9c97;
--sm-header-height: 50px; --width-sidebar-small: 200px;
--sm-sidebar-width: 50px; --width-sidebar-medium: 250px;
--md-sidebar-width: 250px;
--md-header-height: 50px;
--emoji-height: 1.1em; --emoji-height: 1.1em;
} }
body.light-theme { body.theme-light {
--color-bg-main: #dfe3e7; --color-bg-main: #d4dee7;
--color-bg-menu: #cfd6dd; --color-bg-menu: #c0ccd8;
--color-bg-box: #f2f5f8; --color-bg-box: #f0f3f5;
--color-bg-error: rgb(219, 144, 144); --color-bg-error: rgb(219, 144, 144);
--color-highlight: #449c8c;
--color-delete: #884743; --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); --color-main-shadow: rgba(0, 0, 0, 0.1);
--size-main-shadow: 20px; --size-main-shadow: 20px;
--color-text-duller: #3a3b3d; --color-text-duller: #4f5157;
--color-text-dull: rgb(44, 44, 48); --color-text-dull: rgb(62, 62, 68);
--color-text-main: rgb(0, 0, 0); --color-text-main: rgb(0, 0, 0);
--color-text-link: var(--color-highlight); --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: rgb(109, 109, 109);
--color-input-border-active: #464646; --color-input-border-active: #464646;
--color-button-secondary: #5c6770; --color-button-secondary: #5c6770;
@ -145,12 +142,16 @@ body {
} }
main { main {
width: 1100px; width: 800px;
margin: 20px auto; margin: 20px auto;
box-shadow: none; box-shadow: none;
border-radius: 5px; border-radius: 5px;
} }
body.wide main {
width: 1100px;
}
footer { footer {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
@ -168,7 +169,7 @@ footer a {
header { header {
display: flex; display: flex;
height: 42px; height: 42px;
margin-top: -20px; margin: -20px 0 20px 0;
justify-content: center; justify-content: center;
} }
@ -209,7 +210,7 @@ header menu {
header menu a { header menu a {
padding: 6px 10px 4px 10px; padding: 6px 10px 4px 10px;
color: var(--color-text-main); color: var(--color-header-menu);
line-height: 30px; line-height: 30px;
border-bottom: 3px solid rgba(0, 0, 0, 0); border-bottom: 3px solid rgba(0, 0, 0, 0);
margin: 0 2px; margin: 0 2px;
@ -374,7 +375,7 @@ nav .identity-banner a:hover {
} }
.settings nav { .settings nav {
width: var(--md-sidebar-width); width: var(--width-sidebar-medium);
background: var(--color-bg-menu); background: var(--color-bg-menu);
border-radius: 0 0 5px 0; border-radius: 0 0 5px 0;
} }
@ -400,7 +401,6 @@ img.emoji {
/* Generic styling and sections */ /* Generic styling and sections */
section { section {
max-width: 700px;
background: var(--color-bg-box); background: var(--color-bg-box);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1); box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1);
margin: 0 auto 45px auto; margin: 0 auto 45px auto;
@ -742,24 +742,6 @@ div.follow {
text-align: center; 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 { div.follow .actions {
/* display: flex; */ /* display: flex; */
position: relative; position: relative;
@ -769,69 +751,8 @@ div.follow .actions {
align-content: center; align-content: center;
} }
div.follow .actions a { .follow .actions button {
border-radius: 4px;
min-width: 40px;
text-align: center; 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 { form.inline-menu {
@ -1139,12 +1060,9 @@ button i:first-child,
padding: 2px 6px; padding: 2px 6px;
} }
form .field.multi-option { form .multi-option {
margin-bottom: 10px;
}
form .field.multi-option {
margin-bottom: 10px; margin-bottom: 10px;
display: block;
} }
form .option.option-row { form .option.option-row {
@ -1388,7 +1306,7 @@ table.metadata td .emoji {
.announcement { .announcement {
background-color: var(--color-highlight); background-color: var(--color-highlight);
border-radius: 5px; border-radius: 5px;
margin: 0 0 20px 0; margin: 10px 0 0 0;
padding: 5px 30px 5px 8px; padding: 5px 30px 5px 8px;
position: relative; position: relative;
} }
@ -1607,7 +1525,7 @@ form .post {
.post .actions { .post .actions {
display: flex; display: flex;
position: relative; position: relative;
justify-content: space-between; justify-content: right;
padding: 8px 0 0 0; padding: 8px 0 0 0;
align-items: center; align-items: center;
align-content: center; align-content: center;
@ -1623,6 +1541,12 @@ form .post {
color: var(--color-text-dull); 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 { .post .actions a:hover {
background-color: var(--color-bg-main); background-color: var(--color-bg-main);
} }
@ -1780,37 +1704,30 @@ form .post {
color: var(--color-text-dull); color: var(--color-text-dull);
} }
@media (max-width: 1100px) { @media (max-width: 1120px),
main { (display-mode: standalone) {
max-width: 900px;
body.wide main {
width: 100%;
padding: 0 10px;
} }
} }
@media (max-width: 920px), @media (max-width: 850px),
(display-mode: standalone) { (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;
}
main { main {
width: 100%; width: 100%;
margin: 20px auto 20px auto; margin: 20px auto 20px auto;
box-shadow: none; box-shadow: none;
border-radius: 0; border-radius: 0;
padding: 0 10px;
}
.settings nav {
width: var(--width-sidebar-small);
} }
.post .attachments a.image img { .post .attachments a.image img {
@ -1820,72 +1737,6 @@ form .post {
@media (max-width: 750px) { @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 { .post {
margin-bottom: 15px; margin-bottom: 15px;
@ -1896,6 +1747,10 @@ form .post {
@media (max-width: 550px) { @media (max-width: 550px) {
main {
padding: 0;
}
.post .content, .post .content,
.post .summary, .post .summary,
.post .edited, .post .edited,

View file

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

View file

@ -6,7 +6,6 @@ from activities.views import (
compose, compose,
debug, debug,
posts, posts,
search,
timelines, timelines,
) )
from api.views import oauth from api.views import oauth
@ -27,12 +26,6 @@ urlpatterns = [
path("", core.homepage), path("", core.homepage),
path("robots.txt", core.RobotsTxt.as_view()), path("robots.txt", core.RobotsTxt.as_view()),
# Activity views # Activity views
path(
"@<handle>/notifications/",
timelines.Notifications.as_view(),
name="notifications",
),
path("search/", search.Search.as_view(), name="search"),
path("tags/<hashtag>/", timelines.Tag.as_view(), name="tag"), path("tags/<hashtag>/", timelines.Tag.as_view(), name="tag"),
# Settings views # Settings views
path( path(
@ -45,6 +38,11 @@ urlpatterns = [
settings.SecurityPage.as_view(), settings.SecurityPage.as_view(),
name="settings_security", name="settings_security",
), ),
path(
"settings/interface/",
settings.InterfacePage.as_view(),
name="settings_interface",
),
path( path(
"@<handle>/settings/", "@<handle>/settings/",
settings.SettingsRoot.as_view(), settings.SettingsRoot.as_view(),
@ -56,9 +54,9 @@ urlpatterns = [
name="settings_profile", name="settings_profile",
), ),
path( path(
"@<handle>/settings/interface/", "@<handle>/settings/posting/",
settings.InterfacePage.as_view(), settings.PostingPage.as_view(),
name="settings_interface", name="settings_posting",
), ),
path( path(
"@<handle>/settings/follows/", "@<handle>/settings/follows/",
@ -238,6 +236,12 @@ urlpatterns = [
path("@<handle>/rss/", identity.IdentityFeed()), path("@<handle>/rss/", identity.IdentityFeed()),
path("@<handle>/following/", identity.IdentityFollows.as_view(inbound=False)), path("@<handle>/following/", identity.IdentityFollows.as_view(inbound=False)),
path("@<handle>/followers/", identity.IdentityFollows.as_view(inbound=True)), path("@<handle>/followers/", identity.IdentityFollows.as_view(inbound=True)),
path("@<handle>/search/", identity.IdentitySearch.as_view()),
path(
"@<handle>/notifications/",
timelines.Notifications.as_view(),
name="notifications",
),
# Posts # Posts
path("@<handle>/compose/", compose.Compose.as_view(), name="compose"), path("@<handle>/compose/", compose.Compose.as_view(), name="compose"),
path("@<handle>/posts/<int:post_id>/", posts.Individual.as_view()), path("@<handle>/posts/<int:post_id>/", posts.Individual.as_view()),

View file

@ -1,11 +0,0 @@
<a class="option" href="{{ hashtag.urls.timeline }}">
<i class="fa-solid fa-hashtag"></i>
<span class="handle">
{{ hashtag.display_name }}
</span>
{% if not hide_stats %}
<span>
Post count: {{ hashtag.stats.total }}
</span>
{% endif %}
</a>

View file

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

View file

@ -74,15 +74,15 @@
{% endif %} {% endif %}
<div class="actions"> <div class="actions">
<a title="Replies"> <a title="Replies" href="{% if not post.local and post.url %}{{ post.url }}{% else %}{{ post.urls.view }}{% endif %}">
<i class="fa-solid fa-reply"></i> <i class="fa-solid fa-reply"></i>
<span class="like-count">{{ post.stats_with_defaults.replies|default:"0" }}</span> <span class="like-count">{{ post.stats_with_defaults.replies|default:"0" }}</span>
</a> </a>
<a title="Likes"> <a title="Likes" class="no-action">
<i class="fa-solid fa-star"></i> <i class="fa-solid fa-star"></i>
<span class="like-count">{{ post.stats_with_defaults.likes|default:"0" }}</span> <span class="like-count">{{ post.stats_with_defaults.likes|default:"0" }}</span>
</a> </a>
<a title="Boosts"> <a title="Boosts" class="no-action">
<i class="fa-solid fa-retweet"></i> <i class="fa-solid fa-retweet"></i>
<span class="like-count">{{ post.stats_with_defaults.boosts|default:"0" }}</span> <span class="like-count">{{ post.stats_with_defaults.boosts|default:"0" }}</span>
</a> </a>
@ -93,9 +93,6 @@
<a href="{{ post.urls.view }}" role="menuitem"> <a href="{{ post.urls.view }}" role="menuitem">
<i class="fa-solid fa-comment"></i> View Post &amp; Replies <i class="fa-solid fa-comment"></i> View Post &amp; Replies
</a> </a>
<a href="{{ post.urls.action_report }}" role="menuitem">
<i class="fa-solid fa-flag"></i> Report
</a>
{% if not post.local and post.url %} {% if not post.local and post.url %}
<a href="{{ post.url }}" role="menuitem"> <a href="{{ post.url }}" role="menuitem">
<i class="fa-solid fa-arrow-up-right-from-square"></i> See Original <i class="fa-solid fa-arrow-up-right-from-square"></i> See Original

View file

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

View file

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

View file

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

View file

@ -27,22 +27,22 @@
<section> <section>
<h1 class="above">Apps</h1> <h1 class="above">Apps</h1>
<p> <p>
To see your timelines, compose new messages, and follow people, To see your timelines, compose and edit messages, and follow people,
you will need to use a Mastodon-compatible app. Our favourites you will need to use a Mastodon-compatible app. Our favourites
are listed below. are listed below.
</p> </p>
<div class="flex-icons"> <div class="flex-icons">
<a href="#"> <a href="https://elk.zone/">
<img src="{% static "img/apps/elk.svg" %}" alt="Elk logo"> <img src="{% static "img/apps/elk.svg" %}" alt="Elk logo">
<h2>Elk</h2> <h2>Elk</h2>
<i>Web/Mobile (Free)</i> <i>Web/Mobile (Free)</i>
</a> </a>
<a href="#"> <a href="https://tusky.app/">
<img src="{% static "img/apps/tusky.png" %}" alt="Tusky logo"> <img src="{% static "img/apps/tusky.png" %}" alt="Tusky logo">
<h2>Tusky</h2> <h2>Tusky</h2>
<i>Android (Free)</i> <i>Android (Free)</i>
</a> </a>
<a href="#"> <a href="https://tapbots.com/ivory/">
<img src="{% static "img/apps/ivory.webp" %}" alt="Ivory logo"> <img src="{% static "img/apps/ivory.webp" %}" alt="Ivory logo">
<h2>Ivory</h2> <h2>Ivory</h2>
<i>iOS (Paid)</i> <i>iOS (Paid)</i>

View file

@ -3,9 +3,6 @@
{% block title %}Notifications{% endblock %} {% block title %}Notifications{% endblock %}
{% block settings_content %} {% block settings_content %}
{% if page_obj.number == 1 %}
{% include "_announcements.html" %}
{% endif %}
<section class="invisible"> <section class="invisible">
<div class="view-options"> <div class="view-options">
@ -37,14 +34,5 @@
No notifications yet. No notifications yet.
{% endfor %} {% endfor %}
<div class="pagination">
{% if page_obj.has_previous and not request.htmx %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}" hx-get=".?page={{ page_obj.next_page_number }}" hx-select=".left-column > *:not(.view-options)" hx-target=".pagination" hx-swap="outerHTML" {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}>Next Page</a>
{% endif %}
</div>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -1,48 +0,0 @@
{% extends "base.html" %}
{% block title %}Search{% endblock %}
{% block content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>
{% include "forms/_field.html" with field=form.query %}
</fieldset>
<div class="buttons">
<button>Search</button>
</div>
</form>
{% if results.identities %}
<h2>People</h2>
{% for identity in results.identities %}
{% include "activities/_identity.html" %}
{% endfor %}
{% endif %}
{% if results.posts %}
<h2>Posts</h2>
<section class="icon-menu">
{% for post in results.posts %}
{% include "activities/_post.html" %}
{% endfor %}
</section>
{% endif %}
{% if results.hashtags %}
<h2>Hashtags</h2>
<section class="icon-menu">
{% for hashtag in results.hashtags %}
{% include "activities/_hashtag.html" with hide_stats=True %}
{% endfor %}
</section>
{% endif %}
{% if results and not results.identities and not results.hashtags and not results.posts %}
<h2>No results</h2>
<p>
We could not find anything matching your query.
</p>
<p>
If you're trying to find a post or profile on another server,
try again in a few moments - if the other end is overloaded, it
can take some time to fetch the details.
</p>
{% endif %}
{% endblock %}

View file

@ -21,7 +21,7 @@
{% endif %} {% endif %}
{% if page_obj.has_next %} {% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }} {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}">Next Page</a> <a class="button" href=".?page={{ page_obj.next_page_number }}">Next Page</a>
{% endif %} {% endif %}
</div> </div>
</section> </section>

View file

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

View file

@ -16,6 +16,13 @@
{% include "forms/_field.html" with field=form.default %} {% include "forms/_field.html" with field=form.default %}
{% include "forms/_field.html" with field=form.users %} {% include "forms/_field.html" with field=form.users %}
</fieldset> </fieldset>
<fieldset>
<legend>Appearance</legend>
{% include "forms/_field.html" with field=form.site_name %}
{% include "forms/_field.html" with field=form.site_icon %}
{% include "forms/_field.html" with field=form.hide_login %}
{% include "forms/_field.html" with field=form.custom_css %}
</fieldset>
<fieldset> <fieldset>
<legend>Admin Notes</legend> <legend>Admin Notes</legend>
{% include "forms/_field.html" with field=form.notes %} {% include "forms/_field.html" with field=form.notes %}

View file

@ -17,11 +17,6 @@
--color-highlight: {{ config.highlight_color }}; --color-highlight: {{ config.highlight_color }};
--color-text-link: {{ config.highlight_color }}; --color-text-link: {{ config.highlight_color }};
} }
{% if not config_identity.visible_reaction_counts %}
.like-count {
display: none;
}
{% endif %}
</style> </style>
{% if config_identity.custom_css %} {% if config_identity.custom_css %}
<style>{{ config_identity.custom_css|safe }}</style> <style>{{ config_identity.custom_css|safe }}</style>
@ -32,7 +27,7 @@
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
{% block custom_head %}{% if config.custom_head %}{{ config.custom_head|safe }}{% endif %}{% endblock %} {% block custom_head %}{% if config.custom_head %}{{ config.custom_head|safe }}{% endif %}{% endblock %}
</head> </head>
<body class="{% if config_identity.light_theme %}light-theme {% endif %}{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> <body class="{% if user.config_user.light_theme %}theme-light {% endif %}{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<a id="skip-to-main" class="screenreader-text" href="#main-content">Skip to Content</a> <a id="skip-to-main" class="screenreader-text" href="#main-content">Skip to Content</a>
<a id="skip-to-nav" class="screenreader-text" href="#side-navigation">Skip to Navigation</a> <a id="skip-to-nav" class="screenreader-text" href="#side-navigation">Skip to Navigation</a>
<main> <main>
@ -41,18 +36,28 @@
<menu> <menu>
<a class="logo" href="/"> <a class="logo" href="/">
<img src="{{ config.site_icon }}" width="32"> <img src="{{ config.site_icon }}" width="32">
{{ config.site_name }} {{ request.domain.config_domain.site_name|default:config.site_name }}
</a> </a>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="/" title="My Account"><i class="fa-solid fa-user"></i></a> <a href="/" title="My Account"><i class="fa-solid fa-user"></i></a>
{% else %} {% if identity %}
<a href="{{ identity.urls.settings }}" title="Settings"><i class="fa-solid fa-gear"></i></a>
{% else %}
<a href="/settings" title="Settings"><i class="fa-solid fa-gear"></i></a>
{% endif %}
{% if user.admin %}
<a href="/admin" title="Administration"><i class="fa-solid fa-screwdriver-wrench"></i></a>
{% endif %}
{% elif not request.domain.config_domain.hide_login %}
<a href="{% url "login" %}" title="Login"><i class="fa-solid fa-right-to-bracket"></i></a> <a href="{% url "login" %}" title="Login"><i class="fa-solid fa-right-to-bracket"></i></a>
{% endif %} {% endif %}
<a href="/settings" title="Settings"><i class="fa-solid fa-gear"></i></a>
<a href="/admin" title="Administration"><i class="fa-solid fa-screwdriver-wrench"></i></a>
</menu> </menu>
</header> </header>
{% block announcements %}
{% include "_announcements.html" %}
{% endblock %}
{% block content %} {% block content %}
{% endblock %} {% endblock %}
{% endblock %} {% endblock %}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,19 +0,0 @@
<div class="inline follow {% if inbound_follow %}has-reverse{% endif %}">
<div class="actions" role="menubar">
{% if request.user in identity.users.all %}
<a href="{% url "settings_profile" handle=identity.handle %}" class="button" title="Edit Profile">
<i class="fa-solid fa-user-edit"></i> Edit
</a>
{% endif %}
{% if request.user.admin or request.user.moderator %}
<a title="Menu" class="menu button" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" aria-haspopup="menu" tabindex="0">
<i class="fa-solid fa-bars"></i>
</a>
<menu>
<a href="{{ identity.urls.admin_edit }}" role="menuitem">
<i class="fa-solid fa-user-gear"></i> View in Admin
</a>
</menu>
{% endif %}
</div>
</div>

View file

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

View file

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

View file

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

View file

@ -27,7 +27,20 @@
> >
</span> </span>
{% if request.user.moderator or request.user.admin %}{% include "identity/_view_menu.html" %}{% endif %} <div class="inline follow">
<div class="actions" role="menubar">
{% if request.user in identity.users.all %}
<a href="{% url "settings_profile" handle=identity.handle %}" class="button" title="Edit Profile">
<i class="fa-solid fa-user-edit"></i> Settings
</a>
{% endif %}
{% if request.user.admin or request.user.moderator %}
<a href="{% url "settings_profile" handle=identity.handle %}" class="button danger" title="View in Admin">
<i class="fa-solid fa-screwdriver-wrench"></i> Admin
</a>
{% endif %}
</div>
</div>
<h1>{{ identity.html_name_or_handle }}</h1> <h1>{{ identity.html_name_or_handle }}</h1>
<small> <small>
@ -72,15 +85,8 @@
</section> </section>
{% else %} {% else %}
<section class="view-options"> {% include "identity/_tabs.html" %}
<a href="{{ identity.urls.view }}" {% if not follows_page %}class="selected"{% endif %}><strong>{{ post_count }}</strong> posts</a>
{% if identity.local and identity.config_identity.visible_follows %}
<a href="{{ identity.urls.following }}" {% if not inbound and follows_page %}class="selected"{% endif %}><strong>{{ following_count }}</strong> following</a>
<a href="{{ identity.urls.followers }}" {% if inbound and follows_page %}class="selected"{% endif %}><strong>{{ followers_count }}</strong> follower{{ followers_count|pluralize }}</a>
{% endif %}
</section>
<section class="invisible">
{% block subcontent %} {% block subcontent %}
{% for post in page_obj %} {% for post in page_obj %}
@ -90,10 +96,10 @@
{% if identity.local %} {% if identity.local %}
No posts yet. No posts yet.
{% else %} {% else %}
No posts have been received/retrieved by this server yet. No posts have been received by this server yet.
{% if identity.profile_uri %} {% if identity.profile_uri %}
You might find historical posts at You may find historical posts at
<a href="{{ identity.profile_uri }}">their original profile ➔</a> <a href="{{ identity.profile_uri }}">their original profile ➔</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
@ -106,11 +112,10 @@
{% endif %} {% endif %}
{% if page_obj.has_next %} {% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}" hx-get=".?page={{ page_obj.next_page_number }}" hx-select=".left-column > *:not(.view-options)" hx-target=".pagination" hx-swap="outerHTML" {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}>Next Page</a> <a class="button" href=".?page={{ page_obj.next_page_number }}" hx-get=".?page={{ page_obj.next_page_number }}" hx-select=".left-column > *:not(.view-options)" hx-target=".pagination" hx-swap="outerHTML">Next Page</a>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
</section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -1,38 +1,54 @@
<nav> <nav>
{% if identity %} {% if identity %}
{% include "identity/_identity_banner.html" %} {% include "identity/_identity_banner.html" %}
<h3>Settings</h3> <h3>Identity Settings</h3>
<a href="{% url "settings_profile" handle=identity.handle %}" {% if section == "profile" %}class="selected"{% endif %} title="Profile"> <a href="{% url "settings_profile" handle=identity.handle %}" {% if section == "profile" %}class="selected"{% endif %} title="Profile">
<i class="fa-solid fa-user"></i> <i class="fa-solid fa-user"></i>
<span>Profile</span> <span>Profile</span>
</a> </a>
<a href="{% url "settings_interface" handle=identity.handle %}" {% if section == "interface" %}class="selected"{% endif %} title="Interface"> <a href="{% url "settings_posting" handle=identity.handle %}" {% if section == "posting" %}class="selected"{% endif %} title="Posting">
<i class="fa-solid fa-display"></i> <i class="fa-solid fa-message"></i>
<span>Interface</span> <span>Posting</span>
</a> </a>
<a href="{% url "settings_follows" handle=identity.handle %}" {% if section == "follows" %}class="selected"{% endif %} title="Follows"> <a href="{% url "settings_import_export" handle=identity.handle %}" {% if section == "importexport" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-arrow-right-arrow-left"></i> <i class="fa-solid fa-cloud-arrow-up"></i>
<span>Follows</span> <span>Import/Export</span>
</a> </a>
<a href="{% url "settings_import_export" handle=identity.handle %}" {% if section == "importexport" %}class="selected"{% endif %} title="Interface"> <hr>
<i class="fa-solid fa-cloud-arrow-up"></i> <h3>Tools</h3>
<span>Import/Export</span> <a href="{% url "settings_follows" handle=identity.handle %}" {% if section == "follows" %}class="selected"{% endif %} title="Follows">
</a> <i class="fa-solid fa-arrow-right-arrow-left"></i>
<hr> <span>Follows</span>
<h3>Tools</h3> </a>
<a href="{% url "compose" handle=identity.handle %}" {% if section == "compose" %}class="selected"{% endif %} title="Compose"> <a href="{% url "compose" handle=identity.handle %}" {% if section == "compose" %}class="selected"{% endif %} title="Compose">
<i class="fa-solid fa-pen-to-square"></i> <i class="fa-solid fa-pen-to-square"></i>
<span>Compose</span> <span>Compose</span>
</a> </a>
<hr> <hr>
<h3>User Settings</h3>
<a href="{% url "settings" %}" title="Switch to User Settings">
<i class="fa-solid fa-arrow-right"></i>
<span>Go to User Settings</span>
</a>
{% else %}
<h3>Account</h3>
<a href="{% url "settings_security" %}" {% if section == "security" %}class="selected"{% endif %} title="Login &amp; Security">
<i class="fa-solid fa-key"></i>
<span>Login &amp; Security</span>
</a>
<a href="{% url "settings_interface" %}" {% if section == "interface" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-display"></i>
<span>Interface</span>
</a>
<a href="{% url "logout" %}">
<i class="fa-solid fa-right-from-bracket" title="Logout"></i>
<span>Logout</span>
</a>
<hr>
<h3>Identity Settings</h3>
<a href="/" title="Go to Identity Select">
<i class="fa-solid fa-arrow-right"></i>
<span>Go to Identity Select</span>
</a>
{% endif %} {% endif %}
<h3>Account</h3>
<a href="{% url "settings_security" %}" {% if section == "security" %}class="selected"{% endif %} title="Login &amp; Security">
<i class="fa-solid fa-key"></i>
<span>Login &amp; Security</span>
</a>
<a href="{% url "logout" %}">
<i class="fa-solid fa-right-from-bracket" title="Logout"></i>
<span>Logout</span>
</a>
</nav> </nav>

View file

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

View file

@ -17,7 +17,7 @@
{% for other_identity in page_obj %} {% for other_identity in page_obj %}
<tr> <tr>
<td class="icon"> <td class="icon">
<a href="{{ domain.urls.edit }}" class="overlay"></a> <a href="{{ other_identity.urls.view }}" class="overlay"></a>
<img <img
src="{{ other_identity.local_icon_url.relative }}" src="{{ other_identity.local_icon_url.relative }}"
class="icon" class="icon"
@ -27,20 +27,30 @@
_="on error set my.src to generate_avatar(@data-handle)" _="on error set my.src to generate_avatar(@data-handle)"
> >
</td> </td>
<td> <td class="name">
<a href="{{ other_identity.urls.view }}" class="overlay">{{ other_identity.handle }}</a> <a href="{{ other_identity.urls.view }}" class="overlay"></a>
{{ other_identity.html_name_or_handle }}
<small>@{{ other_identity.handle }}</small>
</td> </td>
<td class="stat"> <td class="stat">
{% if identity.id in outbound_ids %} {% if other_identity.id in outbound_ids %}
<span class="pill">Following</span> <span class="pill">Following</span>
{% endif %} {% endif %}
{% if identity.id in inbound_ids %} {% if other_identity.id in inbound_ids %}
<span class="pill">Follows You</span> <span class="pill">Follows You</span>
{% endif %} {% endif %}
</td> </td>
<td class="actions">
<a href="{{ other_identity.urls.view }}" title="View"><i class="fa-solid fa-eye"></i></a>
</td>
</tr> </tr>
{% empty %} {% empty %}
<tr class="empty"><td>You {% if inbound %}have no followers{% else %}are not following anyone{% endif %}.</td></tr> <tr class="empty"><td>You {% if inbound %}have no followers{% else %}are not following anyone{% endif %}.</td></tr>
{% endfor %} {% endfor %}
</table> </table>
{% if inbound %}
{% include "admin/_pagination.html" with nouns="follower,followers" %}
{% else %}
{% include "admin/_pagination.html" with nouns="follow,follows" %}
{% endif %}
{% endblock %} {% endblock %}

View file

@ -12,14 +12,20 @@
{% include "forms/_field.html" with field=form.name %} {% include "forms/_field.html" with field=form.name %}
{% include "forms/_field.html" with field=form.summary %} {% include "forms/_field.html" with field=form.summary %}
{% include "forms/_json_name_value_list.html" with field=form.metadata %} {% include "forms/_json_name_value_list.html" with field=form.metadata %}
{% include "forms/_field.html" with field=form.discoverable %}
{% include "forms/_field.html" with field=form.visible_follows %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Images</legend> <legend>Appearance</legend>
{% include "forms/_field.html" with field=form.icon %} {% include "forms/_field.html" with field=form.icon %}
{% include "forms/_field.html" with field=form.image %} {% include "forms/_field.html" with field=form.image %}
{% include "forms/_field.html" with field=form.theme %}
</fieldset>
<fieldset>
<legend>Privacy</legend>
{% include "forms/_field.html" with field=form.discoverable %}
{% include "forms/_field.html" with field=form.visible_follows %}
{% include "forms/_field.html" with field=form.search_enabled %}
</fieldset> </fieldset>
<div class="buttons"> <div class="buttons">
<button>Save</button> <button>Save</button>

15
users/middleware.py Normal file
View file

@ -0,0 +1,15 @@
from users.models import Domain
class DomainMiddleware:
"""
Tries to attach a Domain object to every incoming request, if one matches.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request.domain = Domain.get_domain(request.META["HTTP_HOST"])
response = self.get_response(request)
return response

View file

@ -1,6 +1,7 @@
import json import json
import ssl import ssl
from typing import Optional from typing import Optional
from functools import cached_property
import httpx import httpx
import pydantic import pydantic
@ -12,6 +13,7 @@ from django.db import models
from core.exceptions import capture_message from core.exceptions import capture_message
from stator.models import State, StateField, StateGraph, StatorModel from stator.models import State, StateField, StateGraph, StatorModel
from users.schemas import NodeInfo from users.schemas import NodeInfo
from core.models import Config
class DomainStates(StateGraph): class DomainStates(StateGraph):
@ -230,3 +232,9 @@ class Domain(StatorModel):
version = software.get("version", "unknown") version = software.get("version", "unknown")
return f"{name:.10} - {version:.10}" return f"{name:.10} - {version:.10}"
return None return None
### Config ###
@cached_property
def config_domain(self) -> Config.DomainOptions:
return Config.load_domain(self)

View file

@ -234,6 +234,7 @@ class Identity(StatorModel):
action = "{view}action/" action = "{view}action/"
followers = "{view}followers/" followers = "{view}followers/"
following = "{view}following/" following = "{view}following/"
search = "{view}search/"
activate = "{view}activate/" activate = "{view}activate/"
admin = "/admin/identities/" admin = "/admin/identities/"
admin_edit = "{admin}{self.pk}/" admin_edit = "{admin}{self.pk}/"

View file

@ -1,4 +1,6 @@
import urlman import urlman
from functools import cached_property
from core.models import Config
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
from django.db import models from django.db import models
@ -66,3 +68,7 @@ class User(AbstractBaseUser):
def has_perm(self, perm): def has_perm(self, perm):
return self.admin return self.admin
@cached_property
def config_user(self) -> Config.UserOptions:
return Config.load_user(self)

View file

@ -5,6 +5,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import FormView, TemplateView from django.views.generic import FormView, TemplateView
from core.models import Config
from users.decorators import admin_required from users.decorators import admin_required
from users.models import Domain, User from users.models import Domain, User
@ -158,6 +159,29 @@ class DomainEdit(FormView):
extra_context = {"section": "domains"} extra_context = {"section": "domains"}
class form_class(DomainCreate.form_class): class form_class(DomainCreate.form_class):
site_name = forms.CharField(
help_text="Override the site name for this domain",
required=False,
)
site_icon = forms.ImageField(
required=False,
help_text="Override the site icon for this domain",
)
hide_login = forms.BooleanField(
help_text="If the login button should appear in the header for this domain",
widget=forms.Select(choices=[(False, "Show"), (True, "Hide")]),
initial=True,
required=False,
)
custom_css = forms.CharField(
help_text="Customise the appearance of this domain\nSee our <a href='#'>documentation</a> for help on common changes\n<b>WARNING: Do not put untrusted code in here!</b>",
widget=forms.Textarea,
required=False,
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["domain"].disabled = True self.fields["domain"].disabled = True
@ -188,6 +212,10 @@ class DomainEdit(FormView):
self.domain.users.set(form.cleaned_data["users"]) self.domain.users.set(form.cleaned_data["users"])
if self.domain.default: if self.domain.default:
Domain.objects.exclude(pk=self.domain.pk).update(default=False) Domain.objects.exclude(pk=self.domain.pk).update(default=False)
Config.set_domain(self.domain, "hide_login", form.cleaned_data["hide_login"])
Config.set_domain(self.domain, "site_name", form.cleaned_data["site_name"])
Config.set_domain(self.domain, "site_icon", form.cleaned_data["site_icon"])
Config.set_domain(self.domain, "custom_css", form.cleaned_data["custom_css"])
return redirect(Domain.urls.root) return redirect(Domain.urls.root)
def get_initial(self): def get_initial(self):
@ -198,6 +226,10 @@ class DomainEdit(FormView):
"public": self.domain.public, "public": self.domain.public,
"default": self.domain.default, "default": self.domain.default,
"users": "\n".join(sorted(user.email for user in self.domain.users.all())), "users": "\n".join(sorted(user.email for user in self.domain.users.all())),
"site_name": self.domain.config_domain.site_name,
"site_icon": self.domain.config_domain.site_icon,
"hide_login": self.domain.config_domain.hide_login,
"custom_css": self.domain.config_domain.custom_css,
} }

View file

@ -20,6 +20,7 @@ from core.models import Config
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from users.models import Domain, FollowStates, Identity, IdentityStates from users.models import Domain, FollowStates, Identity, IdentityStates
from users.services import IdentityService from users.services import IdentityService
from activities.services import SearchService
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
@ -213,17 +214,11 @@ class IdentityFollows(ListView):
raise Http404("Hidden follows") raise Http404("Hidden follows")
return super().get(request, identity=self.identity) return super().get(request, identity=self.identity)
def get_queryset(self):
if self.inbound:
return IdentityService(self.identity).followers()
else:
return IdentityService(self.identity).following()
def get_context_data(self): def get_context_data(self):
context = super().get_context_data() context = super().get_context_data()
context["identity"] = self.identity context["identity"] = self.identity
context["inbound"] = self.inbound context["inbound"] = self.inbound
context["follows_page"] = True context["section"] = "follows"
context["followers_count"] = self.identity.inbound_follows.filter( context["followers_count"] = self.identity.inbound_follows.filter(
state__in=FollowStates.group_active() state__in=FollowStates.group_active()
).count() ).count()
@ -234,6 +229,43 @@ class IdentityFollows(ListView):
return context return context
class IdentitySearch(FormView):
"""
Allows an identity's posts to be searched.
"""
template_name = "identity/search.html"
class form_class(forms.Form):
query = forms.CharField(help_text="The text to search for")
def dispatch(self, request, handle):
self.identity = by_handle_or_404(self.request, handle)
if not Config.load_identity(self.identity).search_enabled:
raise Http404("Search not enabled")
return super().dispatch(request, identity=self.identity)
def form_valid(self, form):
self.results = SearchService(
query=form.cleaned_data["query"], identity=self.identity
).search_post_content()
return self.form_invalid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["identity"] = self.identity
context["section"] = "search"
context["followers_count"] = self.identity.inbound_follows.filter(
state__in=FollowStates.group_active()
).count()
context["following_count"] = self.identity.outbound_follows.filter(
state__in=FollowStates.group_active()
).count()
context["post_count"] = self.identity.posts.count()
context["results"] = getattr(self, "results", None)
return context
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")
class CreateIdentity(FormView): class CreateIdentity(FormView):
template_name = "identity/create.html" template_name = "identity/create.html"

View file

@ -8,11 +8,12 @@ from users.views.settings.import_export import ( # noqa
CsvFollowing, CsvFollowing,
ImportExportPage, ImportExportPage,
) )
from users.views.settings.interface import InterfacePage # noqa from users.views.settings.posting import PostingPage # noqa
from users.views.settings.profile import ProfilePage # noqa from users.views.settings.profile import ProfilePage # noqa
from users.views.settings.security import SecurityPage # noqa from users.views.settings.security import SecurityPage # noqa
from users.views.settings.settings_page import SettingsPage # noqa from users.views.settings.settings_page import SettingsPage # noqa
from users.views.settings.follows import FollowsPage # noqa from users.views.settings.follows import FollowsPage # noqa
from users.views.settings.interface import InterfacePage # noqa
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")

View file

@ -1,33 +1,16 @@
from activities.models.post import Post from users.views.settings.settings_page import UserSettingsPage
from users.views.settings.settings_page import SettingsPage
class InterfacePage(SettingsPage): class InterfacePage(UserSettingsPage):
section = "interface" section = "interface"
options = { options = {
"default_post_visibility": {
"title": "Default Post Visibility",
"help_text": "Visibility to use as default for new posts.",
"choices": Post.Visibilities.choices,
},
"default_reply_visibility": {
"title": "Default Reply Visibility",
"help_text": "Visibility to use as default for replies.",
"choices": Post.Visibilities.choices,
},
"custom_css": {
"title": "Custom CSS",
"help_text": "Theme the website however you'd like, just for you. You should probably not use this unless you know what you're doing.",
"display": "textarea",
},
"light_theme": { "light_theme": {
"title": "Light Mode", "title": "Light Theme",
"help_text": "Use a light theme rather than the default dark theme.", "help_text": "Use a light theme when you are logged in to the web interface",
}, },
} }
layout = { layout = {
"Posting": ["default_post_visibility", "default_reply_visibility"], "Appearance": ["light_theme"],
"Appearance": ["light_theme", "custom_css"],
} }

View file

@ -0,0 +1,18 @@
from activities.models.post import Post
from users.views.settings.settings_page import SettingsPage
class PostingPage(SettingsPage):
section = "posting"
options = {
"default_post_visibility": {
"title": "Default Post Visibility",
"help_text": "Visibility to use as default for new posts.",
"choices": Post.Visibilities.choices,
},
}
layout = {
"Posting": ["default_post_visibility"],
}

View file

@ -36,20 +36,25 @@ class ProfilePage(FormView):
required=False, help_text="Shown at the top of your profile" required=False, help_text="Shown at the top of your profile"
) )
discoverable = forms.BooleanField( discoverable = forms.BooleanField(
help_text="If this user is visible on the frontpage and in user directories.", help_text="If this user is visible on the frontpage and in user directories",
widget=forms.Select( widget=forms.Select(
choices=[(True, "Discoverable"), (False, "Not Discoverable")] choices=[(True, "Discoverable"), (False, "Not Discoverable")]
), ),
required=False, required=False,
) )
visible_follows = forms.BooleanField( visible_follows = forms.BooleanField(
help_text="Whether or not to show your following and follower counts in your profile.", help_text="Whether or not to show your following and follower counts in your profile",
widget=forms.Select(choices=[(True, "Visible"), (False, "Hidden")]), widget=forms.Select(choices=[(True, "Visible"), (False, "Hidden")]),
required=False, required=False,
) )
search_enabled = forms.BooleanField(
help_text="If a search feature is provided for your posts on the profile page\n(Disabling this will not prevent third-party search crawlers from indexing your posts)",
widget=forms.Select(choices=[(True, "Enabled"), (False, "Disabled")]),
required=False,
)
metadata = forms.JSONField( metadata = forms.JSONField(
label="Profile Metadata Fields", label="Profile Metadata Fields",
help_text="These values will appear on your profile below your Bio", help_text="These values will appear on your profile below your bio",
widget=forms.HiddenInput(attrs={"data-min-empty": 2}), widget=forms.HiddenInput(attrs={"data-min-empty": 2}),
required=False, required=False,
) )
@ -84,6 +89,7 @@ class ProfilePage(FormView):
"discoverable": self.identity.discoverable, "discoverable": self.identity.discoverable,
"visible_follows": self.identity.config_identity.visible_follows, "visible_follows": self.identity.config_identity.visible_follows,
"metadata": self.identity.metadata or [], "metadata": self.identity.metadata or [],
"search_enabled": self.identity.config_identity.search_enabled,
} }
def form_valid(self, form): def form_valid(self, form):
@ -115,4 +121,7 @@ class ProfilePage(FormView):
Config.set_identity( Config.set_identity(
self.identity, "visible_follows", form.cleaned_data["visible_follows"] self.identity, "visible_follows", form.cleaned_data["visible_follows"]
) )
Config.set_identity(
self.identity, "search_enabled", form.cleaned_data["search_enabled"]
)
return redirect(".") return redirect(".")

View file

@ -114,3 +114,17 @@ class SettingsPage(FormView):
form.cleaned_data[field.name], form.cleaned_data[field.name],
) )
return redirect(".") return redirect(".")
class UserSettingsPage(SettingsPage):
"""
User-option oriented version of the settings page.
"""
options_class = Config.UserOptions
def load_config(self):
return Config.load_user(self.request.user)
def save_config(self, key, value):
Config.set_user(self.request.user, key, value)