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

View file

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

View file

@ -122,12 +122,7 @@ class Compose(FormView):
] = self.identity.config_identity.default_post_visibility
if self.reply_to:
initial["reply_to"] = self.reply_to.pk
if self.reply_to.visibility == Post.Visibilities.public:
initial[
"visibility"
] = self.identity.config_identity.default_reply_visibility
else:
initial["visibility"] = self.reply_to.visibility
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}

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,
)
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):
@ -239,20 +276,22 @@ 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 # type: ignore
hide_login: bool = False
custom_css: str = ""

View file

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

View file

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

View file

@ -6,7 +6,6 @@ from activities.views import (
compose,
debug,
posts,
search,
timelines,
)
from api.views import oauth
@ -27,12 +26,6 @@ urlpatterns = [
path("", core.homepage),
path("robots.txt", core.RobotsTxt.as_view()),
# 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"),
# Settings views
path(
@ -45,6 +38,11 @@ urlpatterns = [
settings.SecurityPage.as_view(),
name="settings_security",
),
path(
"settings/interface/",
settings.InterfacePage.as_view(),
name="settings_interface",
),
path(
"@<handle>/settings/",
settings.SettingsRoot.as_view(),
@ -56,9 +54,9 @@ urlpatterns = [
name="settings_profile",
),
path(
"@<handle>/settings/interface/",
settings.InterfacePage.as_view(),
name="settings_interface",
"@<handle>/settings/posting/",
settings.PostingPage.as_view(),
name="settings_posting",
),
path(
"@<handle>/settings/follows/",
@ -238,6 +236,12 @@ urlpatterns = [
path("@<handle>/rss/", identity.IdentityFeed()),
path("@<handle>/following/", identity.IdentityFollows.as_view(inbound=False)),
path("@<handle>/followers/", identity.IdentityFollows.as_view(inbound=True)),
path("@<handle>/search/", identity.IdentitySearch.as_view()),
path(
"@<handle>/notifications/",
timelines.Notifications.as_view(),
name="notifications",
),
# Posts
path("@<handle>/compose/", compose.Compose.as_view(), name="compose"),
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 %}
<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>
<span class="like-count">{{ post.stats_with_defaults.replies|default:"0" }}</span>
</a>
<a title="Likes">
<a title="Likes" class="no-action">
<i class="fa-solid fa-star"></i>
<span class="like-count">{{ post.stats_with_defaults.likes|default:"0" }}</span>
</a>
<a title="Boosts">
<a title="Boosts" class="no-action">
<i class="fa-solid fa-retweet"></i>
<span class="like-count">{{ post.stats_with_defaults.boosts|default:"0" }}</span>
</a>
@ -93,9 +93,6 @@
<a href="{{ post.urls.view }}" role="menuitem">
<i class="fa-solid fa-comment"></i> View Post &amp; Replies
</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 %}
<a href="{{ post.url }}" role="menuitem">
<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 body_class %}no-sidebar{% endblock %}
{% block content %}
<form action="." method="POST">
{% csrf_token %}

View file

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

View file

@ -1,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>
<h1 class="above">Apps</h1>
<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
are listed below.
</p>
<div class="flex-icons">
<a href="#">
<a href="https://elk.zone/">
<img src="{% static "img/apps/elk.svg" %}" alt="Elk logo">
<h2>Elk</h2>
<i>Web/Mobile (Free)</i>
</a>
<a href="#">
<a href="https://tusky.app/">
<img src="{% static "img/apps/tusky.png" %}" alt="Tusky logo">
<h2>Tusky</h2>
<i>Android (Free)</i>
</a>
<a href="#">
<a href="https://tapbots.com/ivory/">
<img src="{% static "img/apps/ivory.webp" %}" alt="Ivory logo">
<h2>Ivory</h2>
<i>iOS (Paid)</i>

View file

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

View file

@ -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 %}
{% 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 %}
</div>
</section>

View file

@ -2,6 +2,8 @@
{% block title %}{% block subtitle %}{% endblock %} - Administration{% endblock %}
{% block body_class %}wide{% endblock %}
{% block content %}
<div class="settings">
{% 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.users %}
</fieldset>
<fieldset>
<legend>Appearance</legend>
{% include "forms/_field.html" with field=form.site_name %}
{% include "forms/_field.html" with field=form.site_icon %}
{% include "forms/_field.html" with field=form.hide_login %}
{% include "forms/_field.html" with field=form.custom_css %}
</fieldset>
<fieldset>
<legend>Admin Notes</legend>
{% include "forms/_field.html" with field=form.notes %}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
{% 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>
<small>
@ -72,15 +85,8 @@
</section>
{% else %}
<section class="view-options">
<a href="{{ identity.urls.view }}" {% if not follows_page %}class="selected"{% endif %}><strong>{{ post_count }}</strong> posts</a>
{% if identity.local and identity.config_identity.visible_follows %}
<a href="{{ identity.urls.following }}" {% if not inbound and follows_page %}class="selected"{% endif %}><strong>{{ following_count }}</strong> following</a>
<a href="{{ identity.urls.followers }}" {% if inbound and follows_page %}class="selected"{% endif %}><strong>{{ followers_count }}</strong> follower{{ followers_count|pluralize }}</a>
{% endif %}
</section>
{% include "identity/_tabs.html" %}
<section class="invisible">
{% block subcontent %}
{% for post in page_obj %}
@ -90,10 +96,10 @@
{% if identity.local %}
No posts yet.
{% else %}
No posts have been received/retrieved by this server yet.
No posts have been received by this server yet.
{% if identity.profile_uri %}
You might find historical posts at
You may find historical posts at
<a href="{{ identity.profile_uri }}">their original profile ➔</a>
{% endif %}
{% endif %}
@ -106,11 +112,10 @@
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}" hx-get=".?page={{ page_obj.next_page_number }}" hx-select=".left-column > *:not(.view-options)" hx-target=".pagination" hx-swap="outerHTML" {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}>Next Page</a>
<a class="button" href=".?page={{ page_obj.next_page_number }}" hx-get=".?page={{ page_obj.next_page_number }}" hx-select=".left-column > *:not(.view-options)" hx-target=".pagination" hx-swap="outerHTML">Next Page</a>
{% endif %}
</div>
{% endblock %}
</section>
{% endif %}
{% endblock %}

View file

@ -1,38 +1,54 @@
<nav>
{% if identity %}
{% include "identity/_identity_banner.html" %}
<h3>Settings</h3>
<a href="{% url "settings_profile" handle=identity.handle %}" {% if section == "profile" %}class="selected"{% endif %} title="Profile">
<i class="fa-solid fa-user"></i>
<span>Profile</span>
</a>
<a href="{% url "settings_interface" handle=identity.handle %}" {% if section == "interface" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-display"></i>
<span>Interface</span>
</a>
<a href="{% url "settings_follows" handle=identity.handle %}" {% if section == "follows" %}class="selected"{% endif %} title="Follows">
<i class="fa-solid fa-arrow-right-arrow-left"></i>
<span>Follows</span>
</a>
<a href="{% url "settings_import_export" handle=identity.handle %}" {% if section == "importexport" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>Import/Export</span>
</a>
<hr>
<h3>Tools</h3>
<a href="{% url "compose" handle=identity.handle %}" {% if section == "compose" %}class="selected"{% endif %} title="Compose">
<i class="fa-solid fa-pen-to-square"></i>
<span>Compose</span>
</a>
<hr>
{% include "identity/_identity_banner.html" %}
<h3>Identity Settings</h3>
<a href="{% url "settings_profile" handle=identity.handle %}" {% if section == "profile" %}class="selected"{% endif %} title="Profile">
<i class="fa-solid fa-user"></i>
<span>Profile</span>
</a>
<a href="{% url "settings_posting" handle=identity.handle %}" {% if section == "posting" %}class="selected"{% endif %} title="Posting">
<i class="fa-solid fa-message"></i>
<span>Posting</span>
</a>
<a href="{% url "settings_import_export" handle=identity.handle %}" {% if section == "importexport" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>Import/Export</span>
</a>
<hr>
<h3>Tools</h3>
<a href="{% url "settings_follows" handle=identity.handle %}" {% if section == "follows" %}class="selected"{% endif %} title="Follows">
<i class="fa-solid fa-arrow-right-arrow-left"></i>
<span>Follows</span>
</a>
<a href="{% url "compose" handle=identity.handle %}" {% if section == "compose" %}class="selected"{% endif %} title="Compose">
<i class="fa-solid fa-pen-to-square"></i>
<span>Compose</span>
</a>
<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 %}
<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>

View file

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

View file

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

View file

@ -12,14 +12,20 @@
{% include "forms/_field.html" with field=form.name %}
{% include "forms/_field.html" with field=form.summary %}
{% 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>
<legend>Images</legend>
<legend>Appearance</legend>
{% include "forms/_field.html" with field=form.icon %}
{% 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>
<div class="buttons">
<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 ssl
from typing import Optional
from functools import cached_property
import httpx
import pydantic
@ -12,6 +13,7 @@ from django.db import models
from core.exceptions import capture_message
from stator.models import State, StateField, StateGraph, StatorModel
from users.schemas import NodeInfo
from core.models import Config
class DomainStates(StateGraph):
@ -230,3 +232,9 @@ class Domain(StatorModel):
version = software.get("version", "unknown")
return f"{name:.10} - {version:.10}"
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/"
followers = "{view}followers/"
following = "{view}following/"
search = "{view}search/"
activate = "{view}activate/"
admin = "/admin/identities/"
admin_edit = "{admin}{self.pk}/"

View file

@ -1,4 +1,6 @@
import urlman
from functools import cached_property
from core.models import Config
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
from django.db import models
@ -66,3 +68,7 @@ class User(AbstractBaseUser):
def has_perm(self, perm):
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.views.generic import FormView, TemplateView
from core.models import Config
from users.decorators import admin_required
from users.models import Domain, User
@ -158,6 +159,29 @@ class DomainEdit(FormView):
extra_context = {"section": "domains"}
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):
super().__init__(*args, **kwargs)
self.fields["domain"].disabled = True
@ -188,6 +212,10 @@ class DomainEdit(FormView):
self.domain.users.set(form.cleaned_data["users"])
if self.domain.default:
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)
def get_initial(self):
@ -198,6 +226,10 @@ class DomainEdit(FormView):
"public": self.domain.public,
"default": self.domain.default,
"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 users.models import Domain, FollowStates, Identity, IdentityStates
from users.services import IdentityService
from activities.services import SearchService
from users.shortcuts import by_handle_or_404
@ -213,17 +214,11 @@ class IdentityFollows(ListView):
raise Http404("Hidden follows")
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):
context = super().get_context_data()
context["identity"] = self.identity
context["inbound"] = self.inbound
context["follows_page"] = True
context["section"] = "follows"
context["followers_count"] = self.identity.inbound_follows.filter(
state__in=FollowStates.group_active()
).count()
@ -234,6 +229,43 @@ class IdentityFollows(ListView):
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")
class CreateIdentity(FormView):
template_name = "identity/create.html"

View file

@ -8,11 +8,12 @@ from users.views.settings.import_export import ( # noqa
CsvFollowing,
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.security import SecurityPage # noqa
from users.views.settings.settings_page import SettingsPage # noqa
from users.views.settings.follows import FollowsPage # noqa
from users.views.settings.interface import InterfacePage # noqa
@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 SettingsPage
from users.views.settings.settings_page import UserSettingsPage
class InterfacePage(SettingsPage):
class InterfacePage(UserSettingsPage):
section = "interface"
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": {
"title": "Light Mode",
"help_text": "Use a light theme rather than the default dark theme.",
"title": "Light Theme",
"help_text": "Use a light theme when you are logged in to the web interface",
},
}
layout = {
"Posting": ["default_post_visibility", "default_reply_visibility"],
"Appearance": ["light_theme", "custom_css"],
"Appearance": ["light_theme"],
}

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"
)
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(
choices=[(True, "Discoverable"), (False, "Not Discoverable")]
),
required=False,
)
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")]),
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(
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}),
required=False,
)
@ -84,6 +89,7 @@ class ProfilePage(FormView):
"discoverable": self.identity.discoverable,
"visible_follows": self.identity.config_identity.visible_follows,
"metadata": self.identity.metadata or [],
"search_enabled": self.identity.config_identity.search_enabled,
}
def form_valid(self, form):
@ -115,4 +121,7 @@ class ProfilePage(FormView):
Config.set_identity(
self.identity, "visible_follows", form.cleaned_data["visible_follows"]
)
Config.set_identity(
self.identity, "search_enabled", form.cleaned_data["search_enabled"]
)
return redirect(".")

View file

@ -114,3 +114,17 @@ class SettingsPage(FormView):
form.cleaned_data[field.name],
)
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)