Skip to content

Commit 8ea0c73

Browse files
committed
Improve compose/edit UX and i18n for media and delete prompts
Move compose content label to visually-hidden, use unicode emoji as data attributes instead of rendered img tags, size emoji buttons for readability, and add `.visually-hidden` helper class. Refactor media_preflight.js to pull user-facing strings from template data attributes so translations work. Hoist "More options" and "URL alias" into a collapsible details section on edit forms. Translate error page titles and delete confirmation prompts context-sensitively per the visitor's locale.
1 parent 6a636c4 commit 8ea0c73

15 files changed

Lines changed: 1059 additions & 489 deletions

File tree

app/main.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@
8080
from app.database import AsyncSession
8181
from app.database import async_session
8282
from app.database import get_db_session
83+
from app.i18n import get_translations
84+
from app.i18n import resolve_locale
8385
from app.mastodon.errors import MastodonError
8486
from app.mastodon.errors import mastodon_error_handler
8587
from app.mastodon.oauth import router as mastodon_oauth_router
@@ -356,10 +358,15 @@ async def custom_http_exception_handler(
356358
and 400 <= exc.status_code < 600
357359
):
358360
async with async_session() as db_session:
361+
# Translated against the visitor's negotiated locale, like the
362+
# page it's rendered into -- not `gettext_default`, which is
363+
# pinned to the instance locale. `exc.detail` is passed through
364+
# untouched: it already arrives translated from its raiser.
365+
translations = get_translations(resolve_locale(request))
359366
title = (
360367
{
361-
404: "Oops, nothing to see here",
362-
500: "Oops, something went wrong",
368+
404: translations.gettext("Oops, nothing to see here"),
369+
500: translations.gettext("Oops, something went wrong"),
363370
}
364371
).get(exc.status_code, exc.detail)
365372
try:

app/scss/main.scss

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,31 @@ body.htmx-request::after {
198198
padding: 2px;
199199
cursor: pointer;
200200
border-radius: 4px;
201+
// Unicode emoji are rendered as text, so this needs a readable size of its
202+
// own -- `.form { input, select, textarea }` doesn't cover <button>.
203+
font-size: 1.3rem;
204+
line-height: 1;
205+
vertical-align: middle;
201206

202207
&:hover, &:focus-visible {
203208
outline: 1px solid $secondary-color;
204209
}
205210
}
206211

212+
// Available to assistive tech, but not shown -- for a label whose field is
213+
// already self-evident on screen (the compose box).
214+
.visually-hidden {
215+
position: absolute;
216+
width: 1px;
217+
height: 1px;
218+
margin: -1px;
219+
padding: 0;
220+
overflow: hidden;
221+
clip: rect(0, 0, 0, 0);
222+
white-space: nowrap;
223+
border: 0;
224+
}
225+
207226
.show-more-wrapper {
208227
.p-summary {
209228
display: inline-block;

app/static/common-admin.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
document.addEventListener('DOMContentLoaded', (ev) => {
2-
// Add confirm to "delete" button next to outbox objects
2+
// Add confirm to "delete" button next to outbox objects. The prompt is
3+
// rendered (and translated) by the template on the form's `data-confirm`,
4+
// so this file holds no English of its own -- a federated delete and a
5+
// local-only one say different things. The fallback only covers a template
6+
// that predates the attribute.
37
var forms = document.getElementsByClassName("object-delete-form")
48
for (var i = 0; i < forms.length; i++) {
59
forms[i].addEventListener('submit', (ev) => {
6-
if (!confirm('Do you really want to delete this object?')) {
10+
var message = ev.currentTarget.getAttribute('data-confirm')
11+
|| 'Do you really want to delete this object?';
12+
if (!confirm(message)) {
713
ev.preventDefault();
814
};
915
});

app/static/media_preflight.js

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
return;
1313
}
1414

15+
// User-facing strings are rendered (and translated) by the template and
16+
// handed over on the input's data attributes, so this file holds no
17+
// English of its own. The defaults only cover a template that predates
18+
// them.
19+
function msg(name, fallback) {
20+
return fileInput.getAttribute("data-msg-" + name) || fallback;
21+
}
22+
1523
function formatDuration(seconds) {
1624
var total = Math.round(seconds);
1725
var minutes = Math.floor(total / 60);
@@ -38,7 +46,9 @@
3846
}
3947

4048
var row = document.createElement("p");
41-
row.textContent = file.name + ": checking playback in this browser…";
49+
row.textContent = msg(
50+
"checking", "__FILENAME__: checking playback in this browser…"
51+
).replace("__FILENAME__", file.name);
4252
container.appendChild(row);
4353

4454
var probe = document.createElement(isVideo ? "video" : "audio");
@@ -61,12 +71,14 @@
6171
}
6272

6373
probe.addEventListener("loadedmetadata", function () {
64-
var details = file.name + ": ";
65-
if (isVideo) {
66-
details += probe.videoWidth + "x" + probe.videoHeight + ", ";
67-
}
68-
details += formatDuration(probe.duration) + " — plays in this browser";
69-
row.textContent = details;
74+
var dimensions = isVideo
75+
? probe.videoWidth + "x" + probe.videoHeight + ", "
76+
: "";
77+
row.textContent = msg(
78+
"plays", "__FILENAME__: __DETAILS__ — plays in this browser"
79+
)
80+
.replace("__FILENAME__", file.name)
81+
.replace("__DETAILS__", dimensions + formatDuration(probe.duration));
7082
cleanup();
7183
});
7284

@@ -79,17 +91,19 @@
7991
// the error) is more likely a local blob-loading quirk than a
8092
// real codec/container problem, so say that instead of guessing.
8193
if (errorCode === 4 && !canPlayGuess) {
82-
row.textContent = (
83-
file.name + ": this browser could not play this file " +
94+
row.textContent = msg(
95+
"unsupported",
96+
"__FILENAME__: this browser could not play this file " +
8497
"(unsupported codec/container) — it may be rejected on " +
8598
"upload. Re-encoding as H.264/AAC in an MP4 is the safest bet."
86-
);
99+
).replace("__FILENAME__", file.name);
87100
} else {
88-
row.textContent = (
89-
file.name + ": couldn't generate a local preview in this " +
101+
row.textContent = msg(
102+
"nopreview",
103+
"__FILENAME__: couldn't generate a local preview in this " +
90104
"browser (this check isn't authoritative — it may still " +
91105
"upload and play fine; the server has the final say)."
92-
);
106+
).replace("__FILENAME__", file.name);
93107
}
94108
cleanup();
95109
});

app/static/new.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@
2424
ta.selectionStart = ta.selectionEnd = start + textToInsert.length;
2525
}
2626
}
27-
// Emoji click callback func
27+
// Emoji click callback func. The value comes from the button's own
28+
// `data-emoji` rather than a nested <img alt>: unicode emoji render as
29+
// plain text here (no twemoji asset needed), so there is no image to read.
2830
var ji = function (ev) {
29-
var emojiEl = ev.target.closest("[alt]");
30-
if (emojiEl == null) {
31+
var value = ev.currentTarget.getAttribute("data-emoji");
32+
if (!value) {
3133
return;
3234
}
33-
insertAtCursor(emojiEl.getAttribute("alt") + " ");
35+
insertAtCursor(value + " ");
3436
ta.focus()
3537
}
3638
// Enable the click for each emojis

app/templates/admin_edit_text.html

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,39 +19,47 @@
1919
{% if outbox_object.ap_type == "Article" %}
2020
<p class="form-field">
2121
<label for="name">{{ _("Title") }}</label>
22-
<input type="text" class="width-95" id="name" name="name" placeholder="{{ _("Title") }}" value="{{ outbox_object.name }}">
22+
<input type="text" class="width-95" id="name" name="name" value="{{ outbox_object.name or "" }}">
2323
</p>
2424
{% endif %}
2525

2626
<p class="form-field">
27-
<label for="alias">{{ _("URL alias") }}</label>
28-
<input type="text" class="width-95" id="alias" name="alias"
29-
placeholder="{{ _("URL alias") }}" value="{{ outbox_object.alias or "" }}">
30-
<span class="muted">{{ _("Leave empty to remove the alias.") }}</span>
31-
</p>
32-
33-
<p class="form-field">
34-
<label for="content">{{ _("Content") }}</label>
27+
<label for="content" class="visually-hidden">{{ _("Content") }}</label>
3528
{% if emojis or custom_emojis %}
3629
<div class="emoji-picker">
30+
{# Unicode emoji are rendered as plain characters, not via `emojify`:
31+
the picker shows exactly what gets inserted, and it keeps working
32+
when `inv download-twemoji` hasn't populated app/static/twemoji. #}
3733
{% for emoji in emojis %}
38-
<button type="button" class="ji">{{ emoji | emojify(True) | safe }}</button>
34+
<button type="button" class="ji" data-emoji="{{ emoji }}" title="{{ emoji }}">{{ emoji }}</button>
3935
{% endfor %}
4036
{% for emoji in custom_emojis %}
41-
<button type="button" class="ji"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" title="{{ emoji.name }}" class="custom-emoji" loading="lazy"></button>
37+
<button type="button" class="ji" data-emoji="{{ emoji.name }}" title="{{ emoji.name }}"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" class="custom-emoji" loading="lazy"></button>
4238
{% endfor %}
4339
</div>
4440
{% endif %}
4541
<textarea id="content" name="content" rows="10" cols="50" autofocus="autofocus">{{ content }}</textarea>
42+
<span class="muted">{{ _("Markdown is supported. #hashtags, @mentions and :custom_emoji: work too.") }}</span>
4643
</p>
4744

4845
<p class="form-field">
4946
<label for="content_warning">{{ _("Content warning") }}</label>
5047
<input type="text" id="content_warning" name="content_warning" placeholder="{{ _("will mark the post as sensitive") }}"{% if content_warning %} value="{{ content_warning }}"{% endif %} class="width-95">
5148
</p>
49+
50+
<details class="more-options" {% if outbox_object.alias or is_sensitive or error %}open{% endif %}>
51+
<summary>{{ _("More options") }}</summary>
52+
53+
<p class="form-field">
54+
<label for="alias">{{ _("URL alias") }}</label>
55+
<input type="text" class="width-95" id="alias" name="alias" placeholder="{{ _("optional") }}" value="{{ outbox_object.alias or "" }}">
56+
<span class="muted">{{ _("A short custom URL for this post, instead of its permalink.") }} {{ _("Leave empty to remove the alias.") }}</span>
57+
</p>
58+
5259
<p class="form-field-inline">
5360
<input type="checkbox" name="is_sensitive" id="is_sensitive" {% if is_sensitive %}checked{% endif %}> <label for="is_sensitive">{{ _("Mark attachment(s) as sensitive") }}</label>
5461
</p>
62+
</details>
5563

5664
<p>
5765
<input type="submit" value="{{ _("Submit") }}">

app/templates/admin_new.html

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,15 @@
3333
</ul>
3434
</nav>
3535
{% if False %}
36-
{# dynamic `_(ap_type_choice)` lookup above isn't statically extractable; these
37-
calls exist only so `pybabel extract` picks up each label. #}
36+
{# dynamic `_(...)` lookups (the type labels above, the visibility names
37+
below) aren't statically extractable; these calls exist only so
38+
`pybabel extract` picks up each label. The visibility strings must stay
39+
byte-identical to `VisibilityEnum.get_display_name`. #}
3840
{{ _("Note") }}{{ _("Article") }}{{ _("Question") }}
41+
{{ _("Public - sent to followers and visible on the homepage") }}
42+
{{ _("Unlisted - like public, but hidden from the homepage") }}
43+
{{ _("Followers only") }}
44+
{{ _("Direct - only visible for mentioned actors") }}
3945
{% endif %}
4046

4147
{% if error %}
@@ -50,7 +56,7 @@
5056
<label for="visibility">{{ _("Visibility") }}</label>
5157
<select name="visibility" id="visibility">
5258
{% for (k, v) in visibility_choices %}
53-
<option value="{{ k }}" {% if visibility == k or in_reply_to_object and in_reply_to_object.visibility.name == k %}selected{% endif %}>{{ v }}</option>
59+
<option value="{{ k }}" {% if visibility == k or in_reply_to_object and in_reply_to_object.visibility.name == k %}selected{% endif %}>{{ _(v) }}</option>
5460
{% endfor %}
5561
</select>
5662
</p>
@@ -63,14 +69,17 @@
6369
{% endif %}
6470

6571
<p class="form-field">
66-
<label for="content">{{ _("Content") }}</label>
72+
<label for="content" class="visually-hidden">{{ _("Content") }}</label>
6773
{% if emojis or custom_emojis %}
6874
<div class="emoji-picker">
75+
{# Unicode emoji are rendered as plain characters, not via `emojify`:
76+
the picker shows exactly what gets inserted, and it keeps working
77+
when `inv download-twemoji` hasn't populated app/static/twemoji. #}
6978
{% for emoji in emojis %}
70-
<button type="button" class="ji">{{ emoji | emojify(True) | safe }}</button>
79+
<button type="button" class="ji" data-emoji="{{ emoji }}" title="{{ emoji }}">{{ emoji }}</button>
7180
{% endfor %}
7281
{% for emoji in custom_emojis %}
73-
<button type="button" class="ji"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" title="{{ emoji.name }}" class="custom-emoji" loading="lazy"></button>
82+
<button type="button" class="ji" data-emoji="{{ emoji.name }}" title="{{ emoji.name }}"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" class="custom-emoji" loading="lazy"></button>
7483
{% endfor %}
7584
</div>
7685
{% endif %}
@@ -115,6 +124,7 @@
115124
<p class="form-field">
116125
<label for="alias">{{ _("URL alias") }}</label>
117126
<input type="text" class="width-95" id="alias" name="alias" placeholder="{{ _("optional") }}" value="{{ alias or "" }}">
127+
<span class="muted">{{ _("A short custom URL for this post, instead of its permalink.") }}</span>
118128
</p>
119129

120130
<p class="form-field">
@@ -131,7 +141,11 @@
131141
<input type="hidden" name="quote_of" value="{{ quote_of or "" }}">
132142
<p class="form-field">
133143
<label for="files">{{ _("Attachments") }}</label>
134-
<input id="files" name="files" type="file" class="width-95" multiple accept="image/*,video/*,audio/*">
144+
<input id="files" name="files" type="file" class="width-95" multiple accept="image/*,video/*,audio/*"
145+
data-msg-checking="{{ _("%(filename)s: checking playback in this browser…", filename="__FILENAME__") }}"
146+
data-msg-plays="{{ _("%(filename)s: %(details)s plays in this browser", filename="__FILENAME__", details="__DETAILS__") }}"
147+
data-msg-unsupported="{{ _("%(filename)s: this browser could not play this file (unsupported codec/container) — it may be rejected on upload. Re-encoding as H.264/AAC in an MP4 is the safest bet.", filename="__FILENAME__") }}"
148+
data-msg-nopreview="{{ _("%(filename)s: couldn't generate a local preview in this browser (this check isn't authoritative — it may still upload and play fine; the server has the final say).", filename="__FILENAME__") }}">
135149
</p>
136150
<div id="alts" data-alt-placeholder="{{ _("Alt text for %(filename)s", filename="__FILENAME__") }}"></div>
137151
<p>

app/templates/components/admin_buttons.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@
183183
{% endmacro %}
184184

185185
{% macro admin_delete_button(request, csrf_token, ap_object) %}
186-
<form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST">
186+
<form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST" data-confirm="{{ _("Do you really want to delete this object? A Delete will be sent to the other servers that have it.") }}">
187187
{{ hidden_inputs.embed_csrf_token(csrf_token) }}
188188
<input type="hidden" name="redirect_url" value="{% if request.url.path.endswith("/" + ap_object.public_id) or (request.url.path == "/admin/object" and request.query_params.ap_id.endswith("/" + ap_object.public_id)) %}{{ request.base_url}}{% else %}{{ request.url }}{% endif %}">
189189
<input type="hidden" name="ap_object_id" value="{{ ap_object.ap_id }}">
@@ -192,7 +192,7 @@
192192
{% endmacro %}
193193

194194
{% macro admin_force_delete_button(request, csrf_token, ap_object_id, permalink_id=None) %}
195-
<form action="{{ request.url_for("admin_actions_force_delete") }}" class="object-delete-form" method="POST">
195+
<form action="{{ request.url_for("admin_actions_force_delete") }}" class="object-delete-form" method="POST" data-confirm="{{ _("Do you really want to delete this object? It will only be removed from this instance.") }}">
196196
{{ hidden_inputs.embed_csrf_token(csrf_token) }}
197197
{{ hidden_inputs.embed_redirect_url(request, permalink_id) }}
198198
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
@@ -201,7 +201,7 @@
201201
{% endmacro %}
202202

203203
{% macro admin_force_delete_webmention_button(request, csrf_token, webmention_id, permalink_id=None) %}
204-
<form action="{{ request.url_for("admin_actions_force_delete_webmention") }}" class="object-delete-form" method="POST">
204+
<form action="{{ request.url_for("admin_actions_force_delete_webmention") }}" class="object-delete-form" method="POST" data-confirm="{{ _("Do you really want to delete this webmention? It will only be removed from this instance.") }}">
205205
{{ hidden_inputs.embed_csrf_token(csrf_token) }}
206206
{{ hidden_inputs.embed_redirect_url(request, permalink_id) }}
207207
<input type="hidden" name="webmention_id" value="{{ webmention_id }}">

0 commit comments

Comments
 (0)