From ef08013541d43f204d6fe287960a0a5d6121c7e5 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sat, 29 Apr 2023 14:53:53 -0600 Subject: [PATCH] Even more refactor --- .../0014_post_content_vector_gin.py | 24 ++ activities/models/post.py | 5 + activities/services/search.py | 6 + activities/views/compose.py | 7 +- activities/views/search.py | 22 -- core/migrations/0002_domain_config.py | 36 +++ core/models/config.py | 73 ++++-- static/css/style.css | 247 ++++-------------- takahe/settings.py | 2 + takahe/urls.py | 24 +- templates/activities/_hashtag.html | 11 - templates/activities/_menu.html | 86 ------ templates/activities/_post.html | 9 +- templates/activities/debug_json.html | 2 - templates/activities/explore_tag.html | 16 -- templates/activities/follows.html | 60 ----- templates/activities/home.html | 8 +- templates/activities/notifications.html | 12 - templates/activities/search.html | 48 ---- templates/activities/tag.html | 2 +- templates/admin/base.html | 2 + templates/admin/domain_edit.html | 7 + templates/base.html | 25 +- templates/forms/_json_name_value_list.html | 96 ++++--- templates/identity/_identity_banner.html | 2 +- templates/identity/_menu.html | 22 -- templates/identity/_tabs.html | 10 + templates/identity/_view_menu.html | 19 -- templates/identity/base.html | 7 - templates/identity/create.html | 9 +- templates/identity/search.html | 23 ++ templates/identity/view.html | 31 ++- templates/settings/_menu.html | 84 +++--- templates/settings/base.html | 2 + templates/settings/follows.html | 20 +- templates/settings/profile.html | 12 +- users/middleware.py | 15 ++ users/models/domain.py | 8 + users/models/identity.py | 1 + users/models/user.py | 6 + users/views/admin/domains.py | 32 +++ users/views/identity.py | 46 +++- users/views/settings/__init__.py | 3 +- users/views/settings/interface.py | 27 +- users/views/settings/posting.py | 18 ++ users/views/settings/profile.py | 15 +- users/views/settings/settings_page.py | 14 + 47 files changed, 566 insertions(+), 690 deletions(-) create mode 100644 activities/migrations/0014_post_content_vector_gin.py delete mode 100644 activities/views/search.py create mode 100644 core/migrations/0002_domain_config.py delete mode 100644 templates/activities/_hashtag.html delete mode 100644 templates/activities/_menu.html delete mode 100644 templates/activities/explore_tag.html delete mode 100644 templates/activities/follows.html delete mode 100644 templates/activities/search.html delete mode 100644 templates/identity/_menu.html create mode 100644 templates/identity/_tabs.html delete mode 100644 templates/identity/_view_menu.html delete mode 100644 templates/identity/base.html create mode 100644 templates/identity/search.html create mode 100644 users/middleware.py create mode 100644 users/views/settings/posting.py diff --git a/activities/migrations/0014_post_content_vector_gin.py b/activities/migrations/0014_post_content_vector_gin.py new file mode 100644 index 0000000..1473e1e --- /dev/null +++ b/activities/migrations/0014_post_content_vector_gin.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2 on 2023-04-29 18:49 + +import django.contrib.postgres.indexes +import django.contrib.postgres.search +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("activities", "0013_postattachment_author"), + ] + + operations = [ + migrations.AddIndex( + model_name="post", + index=django.contrib.postgres.indexes.GinIndex( + django.contrib.postgres.search.SearchVector( + "content", config="english" + ), + name="content_vector_gin", + ), + ), + ] diff --git a/activities/models/post.py b/activities/models/post.py index 13e4fc2..aaf2a83 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -11,6 +11,7 @@ import httpx import urlman from asgiref.sync import async_to_sync, sync_to_async from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.search import SearchVector from django.db import models, transaction from django.template import loader from django.template.defaultfilters import linebreaks_filter @@ -312,6 +313,10 @@ class Post(StatorModel): class Meta: indexes = [ GinIndex(fields=["hashtags"], name="hashtags_gin"), + GinIndex( + SearchVector("content", config="english"), + name="content_vector_gin", + ), models.Index( fields=["visibility", "local", "published"], name="ix_post_local_public_published", diff --git a/activities/services/search.py b/activities/services/search.py index e9a6920..b8dcae6 100644 --- a/activities/services/search.py +++ b/activities/services/search.py @@ -123,6 +123,12 @@ class SearchService: results.add(hashtag) return results + def search_post_content(self): + """ + Searches for posts on an identity via full text search + """ + return self.identity.posts.filter(content__search=self.query)[:50] + def search_all(self): """ Returns all possible results for a search diff --git a/activities/views/compose.py b/activities/views/compose.py index 0b81e82..d8cfb41 100644 --- a/activities/views/compose.py +++ b/activities/views/compose.py @@ -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} diff --git a/activities/views/search.py b/activities/views/search.py deleted file mode 100644 index afff395..0000000 --- a/activities/views/search.py +++ /dev/null @@ -1,22 +0,0 @@ -from django import forms -from django.views.generic import FormView - -from activities.services import SearchService - - -class Search(FormView): - - template_name = "activities/search.html" - - class form_class(forms.Form): - query = forms.CharField( - help_text="Search for:\nA user by @username@domain or their profile URL\nA hashtag by #tagname\nA post by its URL", - widget=forms.TextInput(attrs={"type": "search", "autofocus": "autofocus"}), - ) - - def form_valid(self, form): - searcher = SearchService(form.cleaned_data["query"], identity=None) - # Render results - context = self.get_context_data(form=form) - context["results"] = searcher.search_all() - return self.render_to_response(context) diff --git a/core/migrations/0002_domain_config.py b/core/migrations/0002_domain_config.py new file mode 100644 index 0000000..3d220b4 --- /dev/null +++ b/core/migrations/0002_domain_config.py @@ -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")}, + ), + ] diff --git a/core/models/config.py b/core/models/config.py index 0c79f80..674dc84 100644 --- a/core/models/config.py +++ b/core/models/config.py @@ -43,6 +43,14 @@ class Config(models.Model): on_delete=models.CASCADE, ) + domain = models.ForeignKey( + "users.domain", + blank=True, + null=True, + related_name="configs", + on_delete=models.CASCADE, + ) + json = models.JSONField(blank=True, null=True) image = models.ImageField( blank=True, @@ -52,7 +60,7 @@ class Config(models.Model): class Meta: unique_together = [ - ("key", "user", "identity"), + ("key", "user", "identity", "domain"), ] system: ClassVar["Config.ConfigOptions"] # type: ignore @@ -86,7 +94,7 @@ class Config(models.Model): """ return cls.load_values( cls.SystemOptions, - {"identity__isnull": True, "user__isnull": True}, + {"identity__isnull": True, "user__isnull": True, "domain__isnull": True}, ) @classmethod @@ -96,7 +104,7 @@ class Config(models.Model): """ return await sync_to_async(cls.load_values)( cls.SystemOptions, - {"identity__isnull": True, "user__isnull": True}, + {"identity__isnull": True, "user__isnull": True, "domain__isnull": True}, ) @classmethod @@ -106,7 +114,7 @@ class Config(models.Model): """ return cls.load_values( cls.UserOptions, - {"identity__isnull": True, "user": user}, + {"identity__isnull": True, "user": user, "domain__isnull": True}, ) @classmethod @@ -116,7 +124,7 @@ class Config(models.Model): """ return await sync_to_async(cls.load_values)( cls.UserOptions, - {"identity__isnull": True, "user": user}, + {"identity__isnull": True, "user": user, "domain__isnull": True}, ) @classmethod @@ -126,7 +134,7 @@ class Config(models.Model): """ return cls.load_values( cls.IdentityOptions, - {"identity": identity, "user__isnull": True}, + {"identity": identity, "user__isnull": True, "domain__isnull": True}, ) @classmethod @@ -136,7 +144,27 @@ class Config(models.Model): """ return await sync_to_async(cls.load_values)( cls.IdentityOptions, - {"identity": identity, "user__isnull": True}, + {"identity": identity, "user__isnull": True, "domain__isnull": True}, + ) + + @classmethod + def load_domain(cls, domain): + """ + Loads an domain config options object + """ + return cls.load_values( + cls.DomainOptions, + {"domain": domain, "user__isnull": True, "identity__isnull": True}, + ) + + @classmethod + async def aload_domain(cls, domain): + """ + Async loads an domain config options object + """ + return await sync_to_async(cls.load_values)( + cls.DomainOptions, + {"domain": domain, "user__isnull": True, "identity__isnull": True}, ) @classmethod @@ -170,7 +198,7 @@ class Config(models.Model): key, value, cls.SystemOptions, - {"identity__isnull": True, "user__isnull": True}, + {"identity__isnull": True, "user__isnull": True, "domain__isnull": True}, ) @classmethod @@ -179,7 +207,7 @@ class Config(models.Model): key, value, cls.UserOptions, - {"identity__isnull": True, "user": user}, + {"identity__isnull": True, "user": user, "domain__isnull": True}, ) @classmethod @@ -188,7 +216,16 @@ class Config(models.Model): key, value, cls.IdentityOptions, - {"identity": identity, "user__isnull": True}, + {"identity": identity, "user__isnull": True, "domain__isnull": True}, + ) + + @classmethod + def set_domain(cls, domain, key, value): + cls.set_value( + key, + value, + cls.DomainOptions, + {"domain": domain, "user__isnull": True, "identity__isnull": True}, ) class SystemOptions(pydantic.BaseModel): @@ -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 = "" diff --git a/static/css/style.css b/static/css/style.css index f2345d2..407b8ff 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -82,13 +82,14 @@ td a { /* Base template styling */ :root { + --color-highlight: #449c8c; + --color-bg-main: #26323c; --color-bg-menu: #2e3e4c; --color-bg-box: #1a2631; --color-bg-error: rgb(87, 32, 32); - --color-highlight: #449c8c; --color-delete: #8b2821; - --color-header-menu: rgba(0, 0, 0, 0.5); + --color-header-menu: rgba(255, 255, 255, 0.8); --color-main-shadow: rgba(0, 0, 0, 0.6); --size-main-shadow: 50px; @@ -96,40 +97,36 @@ td a { --color-text-dull: #99a; --color-text-main: #fff; --color-text-link: var(--color-highlight); - --color-text-in-highlight: #fff; + --color-text-in-highlight: var(--color-text-main); - --color-input-background: #26323c; + --color-input-background: var(--color-bg-main); --color-input-border: #000; --color-input-border-active: #444b5d; --color-button-secondary: #2e3e4c; --color-button-disabled: #7c9c97; - --sm-header-height: 50px; - --sm-sidebar-width: 50px; - - --md-sidebar-width: 250px; - --md-header-height: 50px; + --width-sidebar-small: 200px; + --width-sidebar-medium: 250px; --emoji-height: 1.1em; } -body.light-theme { - --color-bg-main: #dfe3e7; - --color-bg-menu: #cfd6dd; - --color-bg-box: #f2f5f8; +body.theme-light { + --color-bg-main: #d4dee7; + --color-bg-menu: #c0ccd8; + --color-bg-box: #f0f3f5; --color-bg-error: rgb(219, 144, 144); - --color-highlight: #449c8c; --color-delete: #884743; - --color-header-menu: rgba(0, 0, 0, 0.1); + --color-header-menu: rgba(0, 0, 0, 0.7); --color-main-shadow: rgba(0, 0, 0, 0.1); --size-main-shadow: 20px; - --color-text-duller: #3a3b3d; - --color-text-dull: rgb(44, 44, 48); + --color-text-duller: #4f5157; + --color-text-dull: rgb(62, 62, 68); --color-text-main: rgb(0, 0, 0); --color-text-link: var(--color-highlight); - --color-input-background: #fff; + --color-input-background: var(--color-bg-main); --color-input-border: rgb(109, 109, 109); --color-input-border-active: #464646; --color-button-secondary: #5c6770; @@ -145,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, diff --git a/takahe/settings.py b/takahe/settings.py index 71465ca..ff50e18 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -194,6 +194,7 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.contrib.postgres", "corsheaders", "django_htmx", "hatchway", @@ -220,6 +221,7 @@ MIDDLEWARE = [ "core.middleware.HeadersMiddleware", "core.middleware.ConfigLoadingMiddleware", "api.middleware.ApiTokenMiddleware", + "users.middleware.DomainMiddleware", ] ROOT_URLCONF = "takahe.urls" diff --git a/takahe/urls.py b/takahe/urls.py index 32607a7..46c7042 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -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( - "@/notifications/", - timelines.Notifications.as_view(), - name="notifications", - ), - path("search/", search.Search.as_view(), name="search"), path("tags//", 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( "@/settings/", settings.SettingsRoot.as_view(), @@ -56,9 +54,9 @@ urlpatterns = [ name="settings_profile", ), path( - "@/settings/interface/", - settings.InterfacePage.as_view(), - name="settings_interface", + "@/settings/posting/", + settings.PostingPage.as_view(), + name="settings_posting", ), path( "@/settings/follows/", @@ -238,6 +236,12 @@ urlpatterns = [ path("@/rss/", identity.IdentityFeed()), path("@/following/", identity.IdentityFollows.as_view(inbound=False)), path("@/followers/", identity.IdentityFollows.as_view(inbound=True)), + path("@/search/", identity.IdentitySearch.as_view()), + path( + "@/notifications/", + timelines.Notifications.as_view(), + name="notifications", + ), # Posts path("@/compose/", compose.Compose.as_view(), name="compose"), path("@/posts//", posts.Individual.as_view()), diff --git a/templates/activities/_hashtag.html b/templates/activities/_hashtag.html deleted file mode 100644 index 02d107a..0000000 --- a/templates/activities/_hashtag.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - {{ hashtag.display_name }} - - {% if not hide_stats %} - - Post count: {{ hashtag.stats.total }} - - {% endif %} - diff --git a/templates/activities/_menu.html b/templates/activities/_menu.html deleted file mode 100644 index 2be00cc..0000000 --- a/templates/activities/_menu.html +++ /dev/null @@ -1,86 +0,0 @@ - - -{% if current_page == "home" %} -

Compose

-
- {% csrf_token %} - {{ form.text }} - {{ form.content_warning }} - -
- {{ config.post_length }} - - -
-
-{% endif %} diff --git a/templates/activities/_post.html b/templates/activities/_post.html index 9fc52b3..f3219ac 100644 --- a/templates/activities/_post.html +++ b/templates/activities/_post.html @@ -74,15 +74,15 @@ {% endif %}
- + - + - + @@ -93,9 +93,6 @@ View Post & Replies - - Report - {% if not post.local and post.url %} See Original diff --git a/templates/activities/debug_json.html b/templates/activities/debug_json.html index bc72019..387ed13 100644 --- a/templates/activities/debug_json.html +++ b/templates/activities/debug_json.html @@ -2,8 +2,6 @@ {% block title %}Debug JSON{% endblock %} -{% block body_class %}no-sidebar{% endblock %} - {% block content %}
{% csrf_token %} diff --git a/templates/activities/explore_tag.html b/templates/activities/explore_tag.html deleted file mode 100644 index ae268ef..0000000 --- a/templates/activities/explore_tag.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html" %} - -{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %} - -{% block content %} -
Explore Trending Tags
- -
- {% for hashtag in page_obj %} - {% include "activities/_hashtag.html" %} - {% empty %} - No tags are trending yet. - {% endfor %} - -
-{% endblock %} diff --git a/templates/activities/follows.html b/templates/activities/follows.html deleted file mode 100644 index c6bc486..0000000 --- a/templates/activities/follows.html +++ /dev/null @@ -1,60 +0,0 @@ -{% extends "base.html" %} -{% load activity_tags %} - -{% block subtitle %}Follows{% endblock %} - -{% block content %} -
- -
- {% for identity in page_obj %} - -
- {% include "identity/_identity_banner.html" with identity=identity link_avatar=False link_handle=False %} -
-
- {% if identity.id in outbound_ids %} - Following - {% endif %} - {% if identity.id in inbound_ids %} - Follows You - {% endif %} - - {% if inbound %} - - {% csrf_token %} - {% if identity.id in outbound_ids %} - - - {% else %} - - - {% endif %} - - {% endif %} - -
-
- {% empty %} -

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

- {% endfor %} -
- - -{% endblock %} diff --git a/templates/activities/home.html b/templates/activities/home.html index d65a364..016fd6b 100644 --- a/templates/activities/home.html +++ b/templates/activities/home.html @@ -27,22 +27,22 @@

Apps

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

- + Elk logo

Elk

Web/Mobile (Free)
- + Tusky logo

Tusky

Android (Free)
- + Ivory logo

Ivory

iOS (Paid) diff --git a/templates/activities/notifications.html b/templates/activities/notifications.html index 96ffd9a..8a5926b 100644 --- a/templates/activities/notifications.html +++ b/templates/activities/notifications.html @@ -3,9 +3,6 @@ {% block title %}Notifications{% endblock %} {% block settings_content %} - {% if page_obj.number == 1 %} - {% include "_announcements.html" %} - {% endif %}
{% endblock %} diff --git a/templates/activities/search.html b/templates/activities/search.html deleted file mode 100644 index f1cf717..0000000 --- a/templates/activities/search.html +++ /dev/null @@ -1,48 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Search{% endblock %} - -{% block content %} -
- {% csrf_token %} -
- {% include "forms/_field.html" with field=form.query %} -
-
- -
-
- {% if results.identities %} -

People

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

Posts

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

Hashtags

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

No results

-

- We could not find anything matching your query. -

-

- If you're trying to find a post or profile on another server, - try again in a few moments - if the other end is overloaded, it - can take some time to fetch the details. -

- {% endif %} -{% endblock %} diff --git a/templates/activities/tag.html b/templates/activities/tag.html index b084cb3..85dcb52 100644 --- a/templates/activities/tag.html +++ b/templates/activities/tag.html @@ -21,7 +21,7 @@ {% endif %} {% if page_obj.has_next %} - Next Page + Next Page {% endif %}
diff --git a/templates/admin/base.html b/templates/admin/base.html index 283c031..a331c0e 100644 --- a/templates/admin/base.html +++ b/templates/admin/base.html @@ -2,6 +2,8 @@ {% block title %}{% block subtitle %}{% endblock %} - Administration{% endblock %} +{% block body_class %}wide{% endblock %} + {% block content %}
{% include "admin/_menu.html" %} diff --git a/templates/admin/domain_edit.html b/templates/admin/domain_edit.html index 24f7302..fbaa46b 100644 --- a/templates/admin/domain_edit.html +++ b/templates/admin/domain_edit.html @@ -16,6 +16,13 @@ {% include "forms/_field.html" with field=form.default %} {% include "forms/_field.html" with field=form.users %} +
+ Appearance + {% 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 %} +
Admin Notes {% include "forms/_field.html" with field=form.notes %} diff --git a/templates/base.html b/templates/base.html index eb28a49..2d88ffa 100644 --- a/templates/base.html +++ b/templates/base.html @@ -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 %} {% if config_identity.custom_css %} @@ -32,7 +27,7 @@ {% block extra_head %}{% endblock %} {% block custom_head %}{% if config.custom_head %}{{ config.custom_head|safe }}{% endif %}{% endblock %} - + Skip to Content Skip to Navigation
@@ -41,18 +36,28 @@ {% if user.is_authenticated %} - {% else %} + {% if identity %} + + {% else %} + + {% endif %} + {% if user.admin %} + + {% endif %} + {% elif not request.domain.config_domain.hide_login %} {% endif %} - - + {% block announcements %} + {% include "_announcements.html" %} + {% endblock %} + {% block content %} {% endblock %} {% endblock %} diff --git a/templates/forms/_json_name_value_list.html b/templates/forms/_json_name_value_list.html index 8ad9b79..dac8459 100644 --- a/templates/forms/_json_name_value_list.html +++ b/templates/forms/_json_name_value_list.html @@ -28,55 +28,65 @@ {# fmt:on #} - {% include "forms/_field.html" %} +
+
+ + {% if field.help_text %} +

+ {{ field.help_text|safe|linebreaksbr }} +

+ {% endif %} + {{ field.errors }} + {{ field }} -
-
- {# fmt:off #} - + {# fmt:off #} + - {# fmt:on #} + set two to the first 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 + "> + {# fmt:on #} -
- - - -
+
+ + + +
-
- - - +
{% endwith %} diff --git a/templates/identity/_identity_banner.html b/templates/identity/_identity_banner.html index 4be7f31..dcaa973 100644 --- a/templates/identity/_identity_banner.html +++ b/templates/identity/_identity_banner.html @@ -24,7 +24,7 @@ {% endif %} - @{{ identity.handle }} + @{{ identity.username }}​@{{ identity.domain_id }} {% if link_handle is False %}
{% else %} diff --git a/templates/identity/_menu.html b/templates/identity/_menu.html deleted file mode 100644 index 3a52800..0000000 --- a/templates/identity/_menu.html +++ /dev/null @@ -1,22 +0,0 @@ - diff --git a/templates/identity/_tabs.html b/templates/identity/_tabs.html new file mode 100644 index 0000000..2551bf4 --- /dev/null +++ b/templates/identity/_tabs.html @@ -0,0 +1,10 @@ +
+ {{ post_count }} Posts + {% if identity.local and identity.config_identity.visible_follows %} + {{ following_count }} Following + {{ followers_count }} Follower{{ followers_count|pluralize }} + {% endif %} + {% if identity.local and identity.config_identity.search_enabled %} + Search + {% endif %} +
diff --git a/templates/identity/_view_menu.html b/templates/identity/_view_menu.html deleted file mode 100644 index a9f5ac2..0000000 --- a/templates/identity/_view_menu.html +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/templates/identity/base.html b/templates/identity/base.html deleted file mode 100644 index baff37f..0000000 --- a/templates/identity/base.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{% block subtitle %}{% endblock %} - Settings{% endblock %} - -{% block right_content %} - {% include "identity/_menu.html" %} -{% endblock %} diff --git a/templates/identity/create.html b/templates/identity/create.html index 56cb455..9d8dd55 100644 --- a/templates/identity/create.html +++ b/templates/identity/create.html @@ -1,12 +1,15 @@ -{% extends "identity/base.html" %} +{% extends "base.html" %} {% block title %}Create Identity{% endblock %} {% block content %}

Create New Identity

-

You can have multiple identities - they are totally separate, and share - nothing apart from your login details. Use them for alternates, projects, and more.

+

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

{% csrf_token %}
Identity Details diff --git a/templates/identity/search.html b/templates/identity/search.html new file mode 100644 index 0000000..bc6d791 --- /dev/null +++ b/templates/identity/search.html @@ -0,0 +1,23 @@ +{% extends "identity/view.html" %} + +{% block title %}Search - {{ identity }}{% endblock %} + +{% block subcontent %} + + + {% csrf_token %} +
+ {% include "forms/_field.html" with field=form.query %} +
+
+ +
+ + + {% for post in results %} + {% include "activities/_post.html" %} + {% empty %} +

No posts were found that match your search.

+ {% endfor %} + +{% endblock %} diff --git a/templates/identity/view.html b/templates/identity/view.html index 71b5242..d0b64f2 100644 --- a/templates/identity/view.html +++ b/templates/identity/view.html @@ -27,7 +27,20 @@ > - {% if request.user.moderator or request.user.admin %}{% include "identity/_view_menu.html" %}{% endif %} +

{{ identity.html_name_or_handle }}

@@ -72,15 +85,8 @@ {% else %} -
- {{ post_count }} posts - {% if identity.local and identity.config_identity.visible_follows %} - {{ following_count }} following - {{ followers_count }} follower{{ followers_count|pluralize }} - {% endif %} -
+ {% include "identity/_tabs.html" %} -
{% endblock %} - {% endif %} {% endblock %} diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html index e84bddd..317ad45 100644 --- a/templates/settings/_menu.html +++ b/templates/settings/_menu.html @@ -1,38 +1,54 @@ diff --git a/templates/settings/base.html b/templates/settings/base.html index 5d7596d..b429720 100644 --- a/templates/settings/base.html +++ b/templates/settings/base.html @@ -2,6 +2,8 @@ {% block title %}{% block subtitle %}{% endblock %} - Settings{% endblock %} +{% block body_class %}wide{% endblock %} + {% block content %}
{% include "settings/_menu.html" %} diff --git a/templates/settings/follows.html b/templates/settings/follows.html index 8c7c7c5..21998dd 100644 --- a/templates/settings/follows.html +++ b/templates/settings/follows.html @@ -17,7 +17,7 @@ {% for other_identity in page_obj %} - + - - {{ other_identity.handle }} + + + {{ other_identity.html_name_or_handle }} + @{{ other_identity.handle }} - {% if identity.id in outbound_ids %} + {% if other_identity.id in outbound_ids %} Following {% endif %} - {% if identity.id in inbound_ids %} + {% if other_identity.id in inbound_ids %} Follows You {% endif %} + + + {% empty %} You {% if inbound %}have no followers{% else %}are not following anyone{% endif %}. {% endfor %} + {% if inbound %} + {% include "admin/_pagination.html" with nouns="follower,followers" %} + {% else %} + {% include "admin/_pagination.html" with nouns="follow,follows" %} + {% endif %} {% endblock %} diff --git a/templates/settings/profile.html b/templates/settings/profile.html index d9c45a2..2e371a8 100644 --- a/templates/settings/profile.html +++ b/templates/settings/profile.html @@ -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 %}
- Images + Appearance {% include "forms/_field.html" with field=form.icon %} {% include "forms/_field.html" with field=form.image %} + {% include "forms/_field.html" with field=form.theme %} +
+ +
+ Privacy + {% 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 %}
diff --git a/users/middleware.py b/users/middleware.py new file mode 100644 index 0000000..54ada7c --- /dev/null +++ b/users/middleware.py @@ -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 diff --git a/users/models/domain.py b/users/models/domain.py index f4de1e4..c8d3e61 100644 --- a/users/models/domain.py +++ b/users/models/domain.py @@ -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) diff --git a/users/models/identity.py b/users/models/identity.py index d98e6f0..f3c6708 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -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}/" diff --git a/users/models/user.py b/users/models/user.py index 8e3dc59..82d77c9 100644 --- a/users/models/user.py +++ b/users/models/user.py @@ -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) diff --git a/users/views/admin/domains.py b/users/views/admin/domains.py index 36e5454..bd06b8c 100644 --- a/users/views/admin/domains.py +++ b/users/views/admin/domains.py @@ -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 documentation for help on common changes\nWARNING: Do not put untrusted code in here!", + 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, } diff --git a/users/views/identity.py b/users/views/identity.py index 305469b..fbc8d43 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -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" diff --git a/users/views/settings/__init__.py b/users/views/settings/__init__.py index 0b1acec..80f278a 100644 --- a/users/views/settings/__init__.py +++ b/users/views/settings/__init__.py @@ -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") diff --git a/users/views/settings/interface.py b/users/views/settings/interface.py index ecf420b..51e5a51 100644 --- a/users/views/settings/interface.py +++ b/users/views/settings/interface.py @@ -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"], } diff --git a/users/views/settings/posting.py b/users/views/settings/posting.py new file mode 100644 index 0000000..84862e1 --- /dev/null +++ b/users/views/settings/posting.py @@ -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"], + } diff --git a/users/views/settings/profile.py b/users/views/settings/profile.py index 2e5c91f..287e6e8 100644 --- a/users/views/settings/profile.py +++ b/users/views/settings/profile.py @@ -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(".") diff --git a/users/views/settings/settings_page.py b/users/views/settings/settings_page.py index 7f6fcf8..e0eeaf4 100644 --- a/users/views/settings/settings_page.py +++ b/users/views/settings/settings_page.py @@ -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)