Continue to refactor

pull/565/head
Andrew Godwin 2023-04-27 19:09:16 -06:00
rodzic 5d68e3aaac
commit 0225c6e8ba
39 zmienionych plików z 364 dodań i 603 usunięć

Wyświetl plik

@ -72,7 +72,12 @@ class PostService:
def unboost_as(self, identity: Identity):
self.uninteract_as(identity, PostInteraction.Types.boost)
def context(self, identity: Identity | None) -> tuple[list[Post], list[Post]]:
def context(
self,
identity: Identity | None,
num_ancestors: int = 10,
num_descendants: int = 50,
) -> tuple[list[Post], list[Post]]:
"""
Returns ancestor/descendant information.
@ -82,7 +87,6 @@ class PostService:
If identity is provided, includes mentions/followers-only posts they
can see. Otherwise, shows unlisted and above only.
"""
num_ancestors = 10
num_descendants = 50
# Retrieve ancestors via parent walk
ancestors: list[Post] = []

Wyświetl plik

@ -16,7 +16,7 @@ from activities.models import (
from core.files import blurhash_image, resize_image
from core.html import FediverseHtmlParser
from core.models import Config
from users.shortcuts import by_handle_or_404
from users.shortcuts import by_handle_for_user_or_404
from django.contrib.auth.decorators import login_required
@ -167,10 +167,10 @@ class Compose(FormView):
)
# Add their own timeline event for immediate visibility
TimelineEvent.add_post(self.identity, post)
return redirect("/")
return redirect(self.identity.urls.view)
def dispatch(self, request, handle=None, post_id=None, *args, **kwargs):
self.identity = by_handle_or_404(self.request, handle, local=True, fetch=False)
self.identity = by_handle_for_user_or_404(self.request, handle)
self.post_obj = None
if handle and post_id:
self.post_obj = get_object_or_404(self.identity.posts, pk=post_id)
@ -190,94 +190,7 @@ class Compose(FormView):
context = super().get_context_data(**kwargs)
context["reply_to"] = self.reply_to
context["identity"] = self.identity
context["section"] = "compose"
if self.post_obj:
context["post"] = self.post_obj
return context
@method_decorator(login_required, name="dispatch")
class ImageUpload(FormView):
"""
Handles image upload - returns a new input type hidden to embed in
the main form that references an orphaned PostAttachment
"""
template_name = "activities/_image_upload.html"
class form_class(forms.Form):
image = forms.ImageField(
widget=forms.FileInput(
attrs={
"_": f"""
on change
if me.files[0].size > {settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB * 1024 ** 2}
add [@disabled=] to #upload
remove <ul.errorlist/>
make <ul.errorlist/> called errorlist
make <li/> called error
set size_in_mb to (me.files[0].size / 1024 / 1024).toFixed(2)
put 'File must be {settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB}MB or less (actual: ' + size_in_mb + 'MB)' into error
put error into errorlist
put errorlist before me
else
remove @disabled from #upload
remove <ul.errorlist/>
end
end
"""
}
)
)
description = forms.CharField(required=False)
def clean_image(self):
value = self.cleaned_data["image"]
max_mb = settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB
max_bytes = max_mb * 1024 * 1024
if value.size > max_bytes:
# Erase the file from our data to stop trying to show it again
self.files = {}
raise forms.ValidationError(
f"File must be {max_mb}MB or less (actual: {value.size / 1024 ** 2:.2f})"
)
return value
def form_invalid(self, form):
return super().form_invalid(form)
def form_valid(self, form):
# Make a PostAttachment
main_file = resize_image(
form.cleaned_data["image"],
size=(2000, 2000),
cover=False,
)
thumbnail_file = resize_image(
form.cleaned_data["image"],
size=(400, 225),
cover=True,
)
attachment = PostAttachment.objects.create(
blurhash=blurhash_image(thumbnail_file),
mimetype="image/webp",
width=main_file.image.width,
height=main_file.image.height,
name=form.cleaned_data.get("description"),
state=PostAttachmentStates.fetched,
author=self.identity,
)
attachment.file.save(
main_file.name,
main_file,
)
attachment.thumbnail.save(
thumbnail_file.name,
thumbnail_file,
)
attachment.save()
# Return the response, with a hidden input plus a note
return render(
self.request, "activities/_image_uploaded.html", {"attachment": attachment}
)

Wyświetl plik

@ -1,26 +0,0 @@
from django.views.generic import ListView
from activities.models import Hashtag
class ExploreTag(ListView):
template_name = "activities/explore_tag.html"
extra_context = {
"current_page": "explore",
"allows_refresh": True,
}
paginate_by = 20
def get_queryset(self):
return (
Hashtag.objects.public()
.filter(
stats__total__gt=0,
)
.order_by("-stats__total")
)[:20]
class Explore(ExploreTag):
pass

Wyświetl plik

@ -1,38 +0,0 @@
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator
from django.views.generic import View
from activities.models.hashtag import Hashtag
from django.contrib.auth.decorators import login_required
@method_decorator(login_required, name="dispatch")
class HashtagFollow(View):
"""
Follows/unfollows a hashtag with the current identity
"""
undo = False
def post(self, request: HttpRequest, hashtag):
hashtag = get_object_or_404(
Hashtag,
pk=hashtag,
)
follow = None
if self.undo:
request.identity.hashtag_follows.filter(hashtag=hashtag).delete()
else:
follow = request.identity.hashtag_follows.get_or_create(hashtag=hashtag)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
request,
"activities/_hashtag_follow.html",
{
"hashtag": hashtag,
"follow": follow,
},
)
return redirect(hashtag.urls.view)

Wyświetl plik

@ -47,7 +47,9 @@ class Individual(TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
ancestors, descendants = PostService(self.post_obj).context(None)
ancestors, descendants = PostService(self.post_obj).context(
identity=None, num_ancestors=2
)
context.update(
{
@ -69,27 +71,3 @@ class Individual(TemplateView):
canonicalise(self.post_obj.to_ap(), include_security=True),
content_type="application/activity+json",
)
@method_decorator(login_required, name="dispatch")
class Delete(TemplateView):
"""
Deletes a post
"""
template_name = "activities/post_delete.html"
def dispatch(self, request, handle, post_id):
# Make sure the request identity owns the post!
if handle != request.identity.handle:
raise PermissionDenied("Post author is not requestor")
self.identity = by_handle_or_404(self.request, handle, local=False)
self.post_obj = get_object_or_404(self.identity.posts, pk=post_id)
return super().dispatch(request)
def get_context_data(self):
return {"post": self.post_obj}
def post(self, request):
PostService(self.post_obj).delete()
return redirect("/")

Wyświetl plik

@ -15,7 +15,7 @@ class Search(FormView):
)
def form_valid(self, form):
searcher = SearchService(form.cleaned_data["query"], self.request.identity)
searcher = SearchService(form.cleaned_data["query"], identity=None)
# Render results
context = self.get_context_data(form=form)
context["results"] = searcher.search_all()

Wyświetl plik

@ -1,3 +1,5 @@
from typing import Any
from django.http import Http404
from django.core.paginator import Paginator
from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator
@ -8,6 +10,7 @@ from activities.services import TimelineService
from core.decorators import cache_page
from django.contrib.auth.decorators import login_required
from users.models import Bookmark, HashtagFollow, Identity
from users.views.base import IdentityViewMixin
@method_decorator(login_required, name="dispatch")
@ -46,74 +49,15 @@ class Tag(ListView):
return super().get(request, *args, **kwargs)
def get_queryset(self):
return TimelineService(self.request.identity).hashtag(self.hashtag)
return TimelineService(None).hashtag(self.hashtag)
def get_context_data(self):
context = super().get_context_data()
context["hashtag"] = self.hashtag
context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
context["follow"] = HashtagFollow.maybe_get(
self.request.identity,
self.hashtag,
)
return context
@method_decorator(
cache_page("cache_timeout_page_timeline", public_only=True), name="dispatch"
)
class Local(ListView):
template_name = "activities/local.html"
extra_context = {
"current_page": "local",
"allows_refresh": True,
}
paginate_by = 25
def get_queryset(self):
return TimelineService(self.request.identity).local()
def get_context_data(self):
context = super().get_context_data()
context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
return context
@method_decorator(login_required, name="dispatch")
class Federated(ListView):
template_name = "activities/federated.html"
extra_context = {
"current_page": "federated",
"allows_refresh": True,
}
paginate_by = 25
def get_queryset(self):
return TimelineService(self.request.identity).federated()
def get_context_data(self):
context = super().get_context_data()
context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
return context
@method_decorator(login_required, name="dispatch")
class Notifications(ListView):
class Notifications(IdentityViewMixin, ListView):
template_name = "activities/notifications.html"
extra_context = {
"current_page": "notifications",
@ -125,7 +69,6 @@ class Notifications(ListView):
"boosted": TimelineEvent.Types.boosted,
"mentioned": TimelineEvent.Types.mentioned,
"liked": TimelineEvent.Types.liked,
"identity_created": TimelineEvent.Types.identity_created,
}
def get_queryset(self):
@ -143,7 +86,7 @@ class Notifications(ListView):
for type_name, type in self.notification_types.items():
if notification_options.get(type_name, True):
types.append(type)
return TimelineService(self.request.identity).notifications(types)
return TimelineService(self.identity).notifications(types)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -164,12 +107,6 @@ class Notifications(ListView):
events.append(event)
# Retrieve what kinds of things to show
context["events"] = events
context["identity"] = self.identity
context["notification_options"] = self.request.session["notification_options"]
context["interactions"] = PostInteraction.get_event_interactions(
context["page_obj"],
self.request.identity,
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"], "subject_post_id"
)
return context

Wyświetl plik

@ -20,16 +20,6 @@ def vary_by_ap_json(request, *args, **kwargs) -> str:
return "not_ap"
def vary_by_identity(request, *args, **kwargs) -> str:
"""
Return a cache usable string token that is different based upon the
request.identity
"""
if request.identity:
return f"ident{request.identity.pk}"
return "identNone"
def cache_page(
timeout: int | str = "cache_timeout_page_default",
*,

Wyświetl plik

@ -30,7 +30,7 @@ class About(TemplateView):
template_name = "about.html"
def get_context_data(self):
service = TimelineService(self.request.identity)
service = TimelineService(None)
return {
"current_page": "about",
"content": mark_safe(

Wyświetl plik

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="250"
height="250"
fill="none"
version="1.1"
id="svg30"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs34" />
<mask
id="a"
width="240"
height="234"
x="4"
y="1"
maskUnits="userSpaceOnUse"
style="mask-type:alpha">
<path
fill="#D9D9D9"
d="M244 123c0 64.617-38.383 112-103 112-64.617 0-103-30.883-103-95.5C38 111.194-8.729 36.236 8 16 29.46-9.959 88.689 6 125 6c64.617 0 119 52.383 119 117Z"
id="path19" />
</mask>
<g
mask="url(#a)"
id="g28"
transform="matrix(0.90923731,0,0,1.0049564,13.520015,-3.1040835)">
<path
fill="#ea9e44"
d="m 116.94,88.1 c -13.344,1.552 -20.436,-2.019 -24.706,10.71 0,0 14.336,21.655 52.54,21.112 -2.135,8.848 -1.144,15.368 -1.144,23.207 0,26.079 -20.589,48.821 -65.961,48.821 -23.03,0 -51.015,4.191 -72.367,15.911 -15.175,8.305 -27.048,20.336 -32.302,37.023 l 5.956,8.461 11.4,0.155 v 47.889 l -13.91,21.966 3.998,63.645 H -6.364 L -5.22,335.773 C 1.338,331.892 16.36,321.802 29.171,306.279 46.557,285.4 59.902,255.052 44.193,217.486 l 11.744,-5.045 c 12.887,30.814 8.388,57.514 -2.898,79.013 21.58,-0.698 40.11,-2.095 55.819,-4.734 l -3.584,-43.698 12.659,-1.087 L 129.98,387 h 13.116 l 2.212,-94.459 c 10.447,-4.502 34.239,-21.034 45.372,-78.47 1.372,-6.986 2.135,-12.885 2.516,-17.93 1.754,-12.806 2.745,-27.243 3.051,-43.698 l -18.683,-5.976 h 57.42 l 5.567,-12.807 c -5.414,0.233 -11.896,-2.639 -11.896,-2.639 l 1.297,-6.209 H 242 L 176.801,90.428 c -7.244,2.794 -14.87,6.442 -20.208,10.866 -4.27,-3.105 -19.063,-12.807 -39.653,-13.195 z"
id="path22" />
<path
fill="#c16929"
d="M 6.217,24.493 18.494,21 c 5.948,21.577 13.345,33.375 22.648,39.352 8.388,5.099 19.75,5.239 31.799,4.579 C 69.433,63.767 66.154,62.137 63.104,59.886 56.317,54.841 50.522,46.458 46.175,31.246 l 12.201,-3.649 c 3.279,11.488 7.092,18.085 12.201,21.888 5.11,3.726 11.286,4.657 18.606,5.433 13.726,1.553 30.884,2.174 52.312,12.264 2.898,1.086 5.872,2.483 8.769,4.036 -0.381,-0.776 -0.762,-1.553 -1.296,-2.406 -3.66,-5.822 -10.828,-11.953 -24.097,-16.92 l 4.27,-12.109 c 21.581,7.917 30.121,19.171 33.553,28.097 3.965,10.168 1.525,18.124 1.525,18.124 -3.05,1.009 -6.1,2.406 -9.608,3.492 -6.634,-4.579 -12.887,-8.033 -18.835,-10.75 C 113.814,70.442 92.31,76.108 73.246,77.893 58.91,79.213 45.794,78.591 34.432,71.295 23.222,64.155 13.385,50.495 6.217,24.493 Z"
id="path24" />
<path
fill="#c16929"
d="M 90.098,45.294 C 87.582,39.55 86.057,32.487 86.743,23.794 l 12.659,0.932 c -0.763,10.555 2.897,17.696 7.015,22.353 -5.338,-0.931 -10.447,-1.04 -16.319,-1.785 z m 80.069,-1.32 8.312,-9.702 c 21.58,19.094 8.159,46.415 8.159,46.415 l -11.819,-1.32 c -0.382,-6.24 -1.144,-17.836 -6.635,-24.371 3.584,1.84 6.635,3.865 9.99,6.908 0,-5.666 -1.754,-12.341 -8.007,-17.93 z"
id="path26" />
</g>
</svg>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.9 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 12 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 14 KiB

Wyświetl plik

@ -5,9 +5,6 @@ from django.urls import include, path, re_path
from activities.views import (
compose,
debug,
explore,
follows,
hashtags,
posts,
search,
timelines,
@ -22,28 +19,21 @@ from users.views import (
announcements,
auth,
identity,
report,
settings,
)
from users.views.settings import follows
urlpatterns = [
path("", core.homepage),
path("robots.txt", core.RobotsTxt.as_view()),
# Activity views
path("notifications/", timelines.Notifications.as_view(), name="notifications"),
path("local/", timelines.Local.as_view(), name="local"),
path("federated/", timelines.Federated.as_view(), name="federated"),
path(
"@<handle>/notifications/",
timelines.Notifications.as_view(),
name="notifications",
),
path("search/", search.Search.as_view(), name="search"),
path("tags/<hashtag>/", timelines.Tag.as_view(), name="tag"),
path("tags/<hashtag>/follow/", hashtags.HashtagFollow.as_view()),
path("tags/<hashtag>/unfollow/", hashtags.HashtagFollow.as_view(undo=True)),
path("explore/", explore.Explore.as_view(), name="explore"),
path("explore/tags/", explore.ExploreTag.as_view(), name="explore-tag"),
path(
"follows/",
follows.Follows.as_view(),
name="follows",
),
# Settings views
path(
"settings/",
@ -70,6 +60,11 @@ urlpatterns = [
settings.InterfacePage.as_view(),
name="settings_interface",
),
path(
"@<handle>/settings/follows/",
settings.FollowsPage.as_view(),
name="settings_follows",
),
path(
"@<handle>/settings/import_export/",
settings.ImportExportPage.as_view(),
@ -240,22 +235,12 @@ urlpatterns = [
path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/outbox/", activitypub.Outbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()),
path("@<handle>/rss/", identity.IdentityFeed()),
path("@<handle>/report/", report.SubmitReport.as_view()),
path("@<handle>/following/", identity.IdentityFollows.as_view(inbound=False)),
path("@<handle>/followers/", identity.IdentityFollows.as_view(inbound=True)),
# Posts
path("@<handle>/compose/", compose.Compose.as_view(), name="compose"),
path(
"@<handle>/compose/image_upload/",
compose.ImageUpload.as_view(),
name="compose_image_upload",
),
path("@<handle>/posts/<int:post_id>/", posts.Individual.as_view()),
path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()),
path("@<handle>/posts/<int:post_id>/report/", report.SubmitReport.as_view()),
path("@<handle>/posts/<int:post_id>/edit/", compose.Compose.as_view()),
# Authentication
path("auth/login/", auth.Login.as_view(), name="login"),
path("auth/logout/", auth.Logout.as_view(), name="logout"),

Wyświetl plik

@ -1,9 +0,0 @@
{% if post.pk in bookmarks %}
<a title="Unbookmark" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_unbookmark }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-solid fa-bookmark"></i>
</a>
{% else %}
<a title="Bookmark" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_bookmark }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-regular fa-bookmark"></i>
</a>
{% endif %}

Wyświetl plik

@ -1,9 +0,0 @@
{% if follow %}
<button title="Unfollow" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ hashtag.urls.unfollow }}" hx-swap="outerHTML" tabindex="0">
Unfollow
</button>
{% else %}
<button title="Follow" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ hashtag.urls.follow }}" hx-swap="outerHTML" tabindex="0">
Follow
</button>
{% endif %}

Wyświetl plik

@ -1,15 +0,0 @@
<form
hx-encoding='multipart/form-data'
hx-post='{% url "compose_image_upload" %}'
hx-target="this"
hx-swap="outerHTML"
_="on htmx:xhr:progress(loaded, total)
set #attachmentProgress.value to (loaded/total)*100">
{% csrf_token %}
{% include "forms/_field.html" with field=form.image %}
{% include "forms/_field.html" with field=form.description %}
<div class="buttons">
<button id="upload" _="on click show #attachmentProgress with display:block then hide me">Upload</button>
<progress id="attachmentProgress" value="0" max="100"></progress>
</div>
</form>

Wyświetl plik

@ -1,19 +0,0 @@
<div class="uploaded-image">
<input type="hidden" name="attachment" value="{{ attachment.pk }}">
<img src="{{ attachment.thumbnail_url.relative }}">
<p>
{{ attachment.name|default:"(no description)" }}
</p>
<div class="buttons">
<button class="button delete left" _="on click remove closest .uploaded-image">Remove</button>
</div>
</div>
{% if request.htmx %}
<button class="add-image"
hx-get='{% url "compose_image_upload" %}'
hx-target="this"
hx-swap="outerHTML"
_="on load if length of <.uploaded-image/> > 3 then hide me">
Add Image
</button>
{% endif %}

Wyświetl plik

@ -76,15 +76,15 @@
<div class="actions">
<a title="Replies">
<i class="fa-solid fa-reply"></i>
<span class="like-count">{{ post.stats_with_defaults.replies }}</span>
<span class="like-count">{{ post.stats_with_defaults.replies|default:"0" }}</span>
</a>
<a title="Likes">
<i class="fa-solid fa-star"></i>
<span class="like-count">{{ post.stats_with_defaults.likes }}</span>
<span class="like-count">{{ post.stats_with_defaults.likes|default:"0" }}</span>
</a>
<a title="Boosts">
<i class="fa-solid fa-retweet"></i>
<span class="like-count">{{ post.stats_with_defaults.boosts }}</span>
<span class="like-count">{{ post.stats_with_defaults.boosts|default:"0" }}</span>
</a>
<a title="Menu" class="menu" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" role="menuitem" aria-haspopup="menu" tabindex="0">
<i class="fa-solid fa-bars"></i>
@ -96,14 +96,7 @@
<a href="{{ post.urls.action_report }}" role="menuitem">
<i class="fa-solid fa-flag"></i> Report
</a>
{% if post.author == request.identity %}
<a href="{{ post.urls.action_edit }}" role="menuitem">
<i class="fa-solid fa-pen-to-square"></i> Edit
</a>
<a href="{{ post.urls.action_delete }}" role="menuitem">
<i class="fa-solid fa-trash"></i> Delete
</a>
{% elif not post.local and post.url %}
{% 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
</a>

Wyświetl plik

@ -1,41 +1,26 @@
{% extends "base.html" %}
{% extends "settings/base.html" %}
{% block title %}Compose{% endblock %}
{% block content %}
{% block settings_content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Content</legend>
<legend>Compose</legend>
{% if reply_to %}
<label>Replying to</label>
{% include "activities/_mini_post.html" with post=reply_to %}
{% endif %}
<p><i>For more advanced posting options, like editing and image uploads, please use an app.</i></p>
{{ form.reply_to }}
{{ form.id }}
{% include "forms/_field.html" with field=form.text %}
{% include "forms/_field.html" with field=form.content_warning %}
{% include "forms/_field.html" with field=form.visibility %}
</fieldset>
<fieldset>
<legend>Images</legend>
{% if post %}
{% for attachment in post.attachments.all %}
{% include "activities/_image_uploaded.html" %}
{% endfor %}
{% endif %}
{% if not post or post.attachments.count < 4 %}
<button class="add-image"
hx-get='{% url "compose_image_upload" handle=identity.handle %}'
hx-target="this"
hx-swap="outerHTML">
Add Image
</button>
{% endif %}
</fieldset>
<div class="buttons">
<span id="character-counter">{{ config.post_length }}</span>
<button id="post-button">{% if post %}Save Edits{% elif config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
<button id="post-button">{% if post %}Save Edits{% else %}Post{% endif %}</button>
</div>
</form>
{% endblock %}

Wyświetl plik

@ -1,3 +0,0 @@
{% extends "activities/local.html" %}
{% block title %}Federated Timeline{% endblock %}

Wyświetl plik

@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}Local Timeline{% endblock %}
{% block content %}
{% if page_obj.number == 1 %}
{% include "_announcements.html" %}
{% endif %}
{% for post in page_obj %}
{% include "activities/_post.html" with feedindex=forloop.counter %}
{% empty %}
No posts yet.
{% endfor %}
<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>
{% endblock %}

Wyświetl plik

@ -1,55 +1,50 @@
{% extends "base.html" %}
{% extends "settings/base.html" %}
{% block title %}Notifications{% endblock %}
{% block content %}
{% block settings_content %}
{% if page_obj.number == 1 %}
{% include "_announcements.html" %}
{% endif %}
<div class="view-options">
{% if notification_options.followed %}
<a href=".?followed=false" class="selected"><i class="fa-solid fa-check"></i> Followers</a>
{% else %}
<a href=".?followed=true"><i class="fa-solid fa-xmark"></i> Followers</a>
{% endif %}
{% if notification_options.boosted %}
<a href=".?boosted=false" class="selected"><i class="fa-solid fa-check"></i> Boosts</a>
{% else %}
<a href=".?boosted=true"><i class="fa-solid fa-xmark"></i> Boosts</a>
{% endif %}
{% if notification_options.liked %}
<a href=".?liked=false" class="selected"><i class="fa-solid fa-check"></i> Likes</a>
{% else %}
<a href=".?liked=true"><i class="fa-solid fa-xmark"></i> Likes</a>
{% endif %}
{% if notification_options.mentioned %}
<a href=".?mentioned=false" class="selected"><i class="fa-solid fa-check"></i> Mentions</a>
{% else %}
<a href=".?mentioned=true"><i class="fa-solid fa-xmark"></i> Mentions</a>
{% endif %}
{% if request.user.admin %}
{% if notification_options.identity_created %}
<a href=".?identity_created=false" class="selected"><i class="fa-solid fa-check"></i> New Identities</a>
<section class="invisible">
<div class="view-options">
{% if notification_options.followed %}
<a href=".?followed=false" class="selected"><i class="fa-solid fa-check"></i> Followers</a>
{% else %}
<a href=".?identity_created=true"><i class="fa-solid fa-xmark"></i> New Identities</a>
<a href=".?followed=true"><i class="fa-solid fa-xmark"></i> Followers</a>
{% endif %}
{% endif %}
</div>
{% if notification_options.boosted %}
<a href=".?boosted=false" class="selected"><i class="fa-solid fa-check"></i> Boosts</a>
{% else %}
<a href=".?boosted=true"><i class="fa-solid fa-xmark"></i> Boosts</a>
{% endif %}
{% if notification_options.liked %}
<a href=".?liked=false" class="selected"><i class="fa-solid fa-check"></i> Likes</a>
{% else %}
<a href=".?liked=true"><i class="fa-solid fa-xmark"></i> Likes</a>
{% endif %}
{% if notification_options.mentioned %}
<a href=".?mentioned=false" class="selected"><i class="fa-solid fa-check"></i> Mentions</a>
{% else %}
<a href=".?mentioned=true"><i class="fa-solid fa-xmark"></i> Mentions</a>
{% endif %}
</div>
{% for event in events %}
{% include "activities/_event.html" %}
{% empty %}
No notifications yet.
{% endfor %}
{% for event in events %}
{% include "activities/_event.html" %}
{% empty %}
No notifications yet.
{% endfor %}
<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 %}
<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>
{% 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 %}

Wyświetl plik

@ -7,11 +7,13 @@
{% endblock %}
{% block content %}
{% for ancestor in ancestors reversed %}
{% include "activities/_post.html" with post=ancestor reply=True link_original=False %}
{% endfor %}
{% include "activities/_post.html" %}
{% for descendant in descendants %}
{% include "activities/_post.html" with post=descendant reply=True link_original=False %}
{% endfor %}
<section class="invisible">
{% for ancestor in ancestors reversed %}
{% include "activities/_post.html" with post=ancestor reply=True link_original=False %}
{% endfor %}
{% include "activities/_post.html" %}
{% for descendant in descendants %}
{% include "activities/_post.html" with post=descendant reply=True link_original=False %}
{% endfor %}
</section>
{% endblock %}

Wyświetl plik

@ -3,27 +3,26 @@
{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %}
{% block content %}
<div class="timeline-name">
<div class="inline follow follow-hashtag">
{% include "activities/_hashtag_follow.html" %}
<section class="invisible">
<div class="timeline-name">
<div class="hashtag">
<i class="fa-solid fa-hashtag"></i>{{ hashtag.display_name }}
</div>
</div>
<div class="hashtag">
<i class="fa-solid fa-hashtag"></i>{{ hashtag.display_name }}
{% for post in page_obj %}
{% include "activities/_post.html" %}
{% empty %}
No posts yet.
{% endfor %}
<div class="pagination">
{% if page_obj.has_previous %}
<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 }} {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}">Next Page</a>
{% endif %}
</div>
</div>
{% for post in page_obj %}
{% include "activities/_post.html" %}
{% empty %}
No posts yet.
{% endfor %}
<div class="pagination">
{% if page_obj.has_previous %}
<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 }} {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}">Next Page</a>
{% endif %}
</div>
</section>
{% endblock %}

Wyświetl plik

@ -0,0 +1,61 @@
<nav>
{% if request.user.moderator or request.user.admin %}
<h3>Moderation</h3>
<a href="{% url "admin_identities" %}" {% if section == "identities" %}class="selected"{% endif %} title="Identities">
<i class="fa-solid fa-id-card"></i>
<span>Identities</span>
</a>
<a href="{% url "admin_invites" %}" {% if section == "invites" %}class="selected"{% endif %} title="Invites">
<i class="fa-solid fa-envelope"></i>
<span>Invites</span>
</a>
<a href="{% url "admin_hashtags" %}" {% if section == "hashtags" %}class="selected"{% endif %} title="Hashtags">
<i class="fa-solid fa-hashtag"></i>
<span>Hashtags</span>
</a>
<a href="{% url "admin_emoji" %}" {% if section == "emoji" %}class="selected"{% endif %} title="Emoji">
<i class="fa-solid fa-icons"></i>
<span>Emoji</span>
</a>
<a href="{% url "admin_reports" %}" {% if section == "reports" %}class="selected"{% endif %} title="Reports">
<i class="fa-solid fa-flag"></i>
<span>Reports</span>
</a>
{% endif %}
{% if request.user.admin %}
<hr>
<h3>Administration</h3>
<a href="{% url "admin_basic" %}" {% if section == "basic" %}class="selected"{% endif %} title="Basic">
<i class="fa-solid fa-book"></i>
<span>Basic</span>
</a>
<a href="{% url "admin_policies" %}" {% if section == "policies" %}class="selected"{% endif %} title="Policies">
<i class="fa-solid fa-file-lines"></i>
<span>Policies</span>
</a>
<a href="{% url "admin_announcements" %}" {% if section == "announcements" %}class="selected"{% endif %} title="Announcements">
<i class="fa-solid fa-bullhorn"></i>
<span>Announcements</span>
</a>
<a href="{% url "admin_domains" %}" {% if section == "domains" %}class="selected"{% endif %} title="Domains">
<i class="fa-solid fa-globe"></i>
<span>Domains</span>
</a>
<a href="{% url "admin_federation" %}" {% if section == "federation" %}class="selected"{% endif %} title="Federation">
<i class="fa-solid fa-diagram-project"></i>
<span>Federation</span>
</a>
<a href="{% url "admin_users" %}" {% if section == "users" %}class="selected"{% endif %} title="Users">
<i class="fa-solid fa-users"></i>
<span>Users</span>
</a>
<a href="{% url "admin_stator" %}" {% if section == "stator" %}class="selected"{% endif %} title="Stator">
<i class="fa-solid fa-clock-rotate-left"></i>
<span>Stator</span>
</a>
<a href="/djadmin" title="Django Admin" class="danger">
<i class="fa-solid fa-gear"></i>
<span>Django Admin</span>
</a>
{% endif %}
</nav>

Wyświetl plik

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}{% block subtitle %}{% endblock %} - Administration{% endblock %}
{% block content %}
<div class="settings">
{% include "admin/_menu.html" %}
<div class="settings-content">
{% block settings_content %}
{% endblock %}
</div>
</div>
{% endblock %}

Wyświetl plik

@ -0,0 +1,20 @@
{% extends "admin/base.html" %}
{% block subtitle %}{{ section.title }}{% endblock %}
{% block settings_content %}
<form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %}
{% for title, fields in fieldsets.items %}
<fieldset>
<legend>{{ title }}</legend>
{% for field in fields %}
{% include "forms/_field.html" %}
{% endfor %}
</fieldset>
{% endfor %}
<div class="buttons">
<button>Save</button>
</div>
</form>
{% endblock %}

Wyświetl plik

@ -1,6 +1,7 @@
<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>
@ -9,11 +10,21 @@
<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>
{% endif %}
<h3>Account</h3>
<a href="{% url "settings_security" %}" {% if section == "security" %}class="selected"{% endif %} title="Login &amp; Security">

Wyświetl plik

@ -0,0 +1,46 @@
{% extends "settings/base.html" %}
{% block subtitle %}Follows{% endblock %}
{% block settings_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>
<table class="items">
{% for other_identity in page_obj %}
<tr>
<td class="icon">
<a href="{{ domain.urls.edit }}" class="overlay"></a>
<img
src="{{ other_identity.local_icon_url.relative }}"
class="icon"
alt="Avatar for {{ other_identity.name_or_handle }}"
loading="lazy"
data-handle="{{ other_identity.name_or_handle }}"
_="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>
<td class="stat">
{% 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 %}
</td>
</tr>
{% empty %}
<tr class="empty"><td>You {% if inbound %}have no followers{% else %}are not following anyone{% endif %}.</td></tr>
{% endfor %}
</table>
{% endblock %}

Wyświetl plik

@ -32,7 +32,7 @@
<small>{{ numbers.outbound_follows }} {{ numbers.outbound_follows|pluralize:"follow,follows" }}</small>
</td>
<td>
<a href="{% url "settings_export_following_csv" %}">Download CSV</a>
<a href="{% url "settings_export_following_csv" handle=identity.handle %}">Download CSV</a>
</td>
</tr>
<tr>
@ -41,7 +41,7 @@
<small>{{ numbers.inbound_follows }} {{ numbers.inbound_follows|pluralize:"follower,followers" }}</small>
</td>
<td>
<a href="{% url "settings_export_followers_csv" %}">Download CSV</a>
<a href="{% url "settings_export_followers_csv" handle=identity.handle %}">Download CSV</a>
</td>
</tr>
<tr>

Wyświetl plik

@ -22,7 +22,6 @@
{% include "forms/_field.html" with field=form.image %}
</fieldset>
<div class="buttons">
<a href="{{ request.identity.urls.view }}" class="button secondary left">View Profile</a>
<button>Save</button>
</div>
</form>

Wyświetl plik

@ -34,3 +34,14 @@ def by_handle_or_404(request, handle, local=True, fetch=False) -> Identity:
if identity.blocked:
raise Http404("Blocked user")
return identity
def by_handle_for_user_or_404(request, handle):
"""
Retrieves an identity the local user can control via their handle, or
raises a 404.
"""
identity = by_handle_or_404(request, handle, local=True, fetch=False)
if not identity.users.filter(id=request.user.id).exists():
raise Http404("Current user does not own identity")
return identity

Wyświetl plik

@ -57,12 +57,12 @@ class ReportView(FormView):
if "valid" in request.POST:
self.report.resolved = timezone.now()
self.report.valid = True
self.report.moderator = self.request.identity
self.report.moderator = self.request.user.identities.all()[0]
self.report.save()
if "invalid" in request.POST:
self.report.resolved = timezone.now()
self.report.valid = False
self.report.moderator = self.request.identity
self.report.moderator = self.request.user.identities.all()[0]
self.report.save()
return super().post(request, *args, **kwargs)

Wyświetl plik

@ -0,0 +1,24 @@
from users.shortcuts import by_handle_for_user_or_404
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
@method_decorator(login_required, name="dispatch")
class IdentityViewMixin:
"""
A mixin that requires that the view has a "handle" kwarg that resolves
to a valid identity that the current user has.
"""
def dispatch(self, request, *args, **kwargs):
self.identity = by_handle_for_user_or_404(request, kwargs["handle"])
self.post_identity_setup()
return super().dispatch(request, *args, **kwargs)
def post_identity_setup(self):
pass
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["identity"] = self.identity
return context

Wyświetl plik

@ -234,33 +234,6 @@ class IdentityFollows(ListView):
return context
@method_decorator(login_required, name="dispatch")
class ActionIdentity(View):
def post(self, request, handle):
identity = by_handle_or_404(self.request, handle, local=False)
# See what action we should perform
action = self.request.POST["action"]
if action == "follow":
IdentityService(request.identity).follow(identity)
elif action == "unfollow":
IdentityService(request.identity).unfollow(identity)
elif action == "block":
IdentityService(request.identity).block(identity)
elif action == "unblock":
IdentityService(request.identity).unblock(identity)
elif action == "mute":
IdentityService(request.identity).mute(identity)
elif action == "unmute":
IdentityService(request.identity).unmute(identity)
elif action == "hide_boosts":
IdentityService(request.identity).follow(identity, boosts=False)
elif action == "show_boosts":
IdentityService(request.identity).follow(identity, boosts=True)
else:
raise ValueError(f"Cannot handle identity action {action}")
return redirect(identity.urls.view)
@method_decorator(login_required, name="dispatch")
class CreateIdentity(FormView):
template_name = "identity/create.html"

Wyświetl plik

@ -1,76 +0,0 @@
from django import forms
from django.shortcuts import get_object_or_404, render
from django.utils.decorators import method_decorator
from django.views.generic import FormView
from django.contrib.auth.decorators import login_required
from users.models import Report
from users.shortcuts import by_handle_or_404
@method_decorator(login_required, name="dispatch")
class SubmitReport(FormView):
"""
Submits a report on a user or a post
"""
template_name = "users/report.html"
class form_class(forms.Form):
type = forms.ChoiceField(
choices=[
("", "------"),
("spam", "Spam or inappropriate advertising"),
("hateful", "Hateful, abusive, or violent speech"),
("other", "Something else"),
],
label="Why are you reporting this?",
)
complaint = forms.CharField(
widget=forms.Textarea,
help_text="Please describe why you think this should be removed",
)
forward = forms.BooleanField(
widget=forms.Select(
choices=[
(False, "Do not send to other server"),
(True, "Send to other server"),
]
),
help_text="Should we also send an anonymous copy of this to their server?",
required=False,
)
def dispatch(self, request, handle, post_id=None):
self.identity = by_handle_or_404(self.request, handle, local=False)
if post_id:
self.post_obj = get_object_or_404(self.identity.posts, pk=post_id)
else:
self.post_obj = None
return super().dispatch(request)
def form_valid(self, form):
# Create the report
report = Report.objects.create(
type=form.cleaned_data["type"],
complaint=form.cleaned_data["complaint"],
subject_identity=self.identity,
subject_post=self.post_obj,
source_identity=self.request.identity,
source_domain=self.request.identity.domain,
forward=form.cleaned_data.get("forward", False),
)
# Show a thanks page
return render(
self.request,
"users/report_sent.html",
{"report": report},
)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context["identity"] = self.identity
context["post"] = self.post_obj
return context

Wyświetl plik

@ -12,6 +12,7 @@ from users.views.settings.interface import InterfacePage # 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
@method_decorator(login_required, name="dispatch")

Wyświetl plik

@ -4,15 +4,15 @@ from django.views.generic import ListView
from django.contrib.auth.decorators import login_required
from users.models import Follow, FollowStates, IdentityStates
from users.views.base import IdentityViewMixin
@method_decorator(login_required, name="dispatch")
class Follows(ListView):
class FollowsPage(IdentityViewMixin, ListView):
"""
Shows followers/follows.
"""
template_name = "activities/follows.html"
template_name = "settings/follows.html"
extra_context = {
"section": "follows",
}
@ -24,9 +24,9 @@ class Follows(ListView):
def get_queryset(self):
if self.inbound:
follow_dir = models.Q(target=self.request.identity)
follow_dir = models.Q(target=self.identity)
else:
follow_dir = models.Q(source=self.request.identity)
follow_dir = models.Q(source=self.identity)
return (
Follow.objects.filter(
@ -65,7 +65,7 @@ class Follows(ListView):
)
identity_ids = [identity.id for identity in context["page_obj"]]
context["outbound_ids"] = Follow.objects.filter(
source=self.request.identity,
source=self.identity,
target_id__in=identity_ids,
state__in=FollowStates.group_active(),
).values_list("target_id", flat=True)
@ -75,17 +75,18 @@ class Follows(ListView):
)
identity_ids = [identity.id for identity in context["page_obj"]]
context["inbound_ids"] = Follow.objects.filter(
target=self.request.identity,
target=self.identity,
source_id__in=identity_ids,
state__in=FollowStates.group_active(),
).values_list("source_id", flat=True)
context["inbound"] = self.inbound
context["num_inbound"] = Follow.objects.filter(
target=self.request.identity,
target=self.identity,
state__in=FollowStates.group_active(),
).count()
context["num_outbound"] = Follow.objects.filter(
source=self.request.identity,
source=self.identity,
state__in=FollowStates.group_active(),
).count()
context["identity"] = self.identity
return context

Wyświetl plik

@ -1,6 +1,7 @@
import csv
from typing import Any
from django import forms
from django import forms, http
from django.http import HttpResponse
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
@ -8,10 +9,11 @@ from django.views.generic import FormView, View
from django.contrib.auth.decorators import login_required
from users.models import Follow, InboxMessage
from users.views.base import IdentityViewMixin
@method_decorator(login_required, name="dispatch")
class ImportExportPage(FormView):
class ImportExportPage(IdentityViewMixin, FormView):
"""
Lets the identity's profile be edited
"""
@ -48,7 +50,7 @@ class ImportExportPage(FormView):
InboxMessage.create_internal(
{
"type": "AddFollow",
"source": self.request.identity.pk,
"source": self.identity.pk,
"target_handle": entry["handle"],
"boosts": entry["boosts"],
}
@ -58,14 +60,10 @@ class ImportExportPage(FormView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["numbers"] = {
"outbound_follows": self.request.identity.outbound_follows.active().count(),
"inbound_follows": self.request.identity.inbound_follows.active().count(),
"blocks": self.request.identity.outbound_blocks.active()
.filter(mute=False)
.count(),
"mutes": self.request.identity.outbound_blocks.active()
.filter(mute=True)
.count(),
"outbound_follows": self.identity.outbound_follows.active().count(),
"inbound_follows": self.identity.inbound_follows.active().count(),
"blocks": self.identity.outbound_blocks.active().filter(mute=False).count(),
"mutes": self.identity.outbound_blocks.active().filter(mute=True).count(),
}
context["bad_format"] = self.request.GET.get("bad_format")
context["success"] = self.request.GET.get("success")
@ -115,7 +113,7 @@ class CsvView(View):
return response
class CsvFollowing(CsvView):
class CsvFollowing(IdentityViewMixin, CsvView):
columns = {
"Account address": "get_handle",
"Show boosts": "boosts",
@ -126,7 +124,7 @@ class CsvFollowing(CsvView):
filename = "following.csv"
def get_queryset(self, request):
return self.request.identity.outbound_follows.active()
return self.identity.outbound_follows.active()
def get_handle(self, follow: Follow):
return follow.target.handle
@ -138,7 +136,7 @@ class CsvFollowing(CsvView):
return ""
class CsvFollowers(CsvView):
class CsvFollowers(IdentityViewMixin, CsvView):
columns = {
"Account address": "get_handle",
}
@ -146,7 +144,7 @@ class CsvFollowers(CsvView):
filename = "followers.csv"
def get_queryset(self, request):
return self.request.identity.inbound_follows.active()
return self.identity.inbound_follows.active()
def get_handle(self, follow: Follow):
return follow.source.handle