diff --git a/src/css/style.css b/src/css/style.css
index a79a9a77..7c04a952 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -7569,6 +7569,33 @@ sidebar-search {
color: var(--highlight-color);
}
+.post-composer-language-button {
+ display: flex;
+ align-items: center;
+ padding: 6px 8px;
+ margin-right: 4px;
+ border: none;
+ border-radius: 8px;
+ background: none;
+ color: var(--highlight-color);
+ cursor: pointer;
+}
+
+@media (hover: hover) {
+ .post-composer-language-button:hover {
+ background-color: var(--background-color-secondary);
+ }
+}
+
+.post-composer-language-button-label {
+ max-width: 100px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ font-size: 14px;
+ font-weight: 500;
+}
+
.post-composer .rich-text-input {
width: 100%;
min-height: 140px;
@@ -10517,6 +10544,86 @@ label:has(toggle-switch[disabled]) {
width: 20px;
}
+/* Post Language Dialog */
+
+.post-language-dialog::before {
+ z-index: 1;
+}
+
+.post-language-dialog[open] {
+ display: flex;
+ flex-direction: column;
+}
+
+@media (min-width: 800px) {
+ .post-language-dialog {
+ max-width: 440px;
+ height: min(640px, 85dvh);
+ }
+}
+
+.post-language-dialog-content {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-height: 0;
+ padding-top: 28px;
+}
+
+@media (min-width: 800px) {
+ .post-language-dialog-content {
+ padding-top: 24px;
+ }
+}
+
+.post-language-dialog-header {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 0 20px 12px;
+}
+
+.post-language-dialog-title {
+ font-size: 1.3rem;
+ font-weight: 700;
+ margin: 0;
+}
+
+.post-language-dialog-subtitle {
+ margin: 0 0 8px;
+ color: var(--text-color-secondary);
+ font-size: 0.95rem;
+}
+
+.post-language-dialog-list {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 0 20px;
+}
+
+.post-language-section-title {
+ font-size: 0.95rem;
+ font-weight: 600;
+ margin: 8px 0;
+}
+
+.post-language-row {
+ margin-bottom: 8px;
+}
+
+.post-language-row-disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.post-language-dialog-footer {
+ display: flex;
+ flex-direction: column;
+ padding: 12px 20px calc(16px + env(safe-area-inset-bottom));
+ border-top: var(--hair) solid var(--post-border-color);
+}
+
/* Post Interaction Settings Dialog */
.post-interaction-settings-dialog::before {
diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js
index 74ef8c9f..f7af1a9e 100644
--- a/src/js/components/post-composer.js
+++ b/src/js/components/post-composer.js
@@ -4,11 +4,15 @@ import { avatarTemplate } from "/js/templates/avatar.template.js";
import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js";
import { richTextTemplate } from "/js/templates/richText.template.js";
import {
+ arePostLanguagesEqual,
classnames,
+ getPrimaryPostLanguage,
+ getNameForLanguageCode,
graphemeCount,
readFileAsDataUrl,
sanitizeUri,
} from "/js/utils.js";
+import { POST_LANGUAGE_CODES } from "/js/languages.js";
import { externalLinkTemplate } from "/js/templates/externalLink.template.js";
import { confirmModal } from "/js/modals/confirm.modal.js";
import { scrollLocks } from "/js/scrollLocks.js";
@@ -59,6 +63,10 @@ import "/js/components/image-alt-text-dialog.js";
import "/js/components/emoji-picker-dialog.js";
import "/js/components/gif-picker-dialog.js";
import "/js/components/drafts-dialog.js";
+import "/js/components/post-language-dialog.js";
+import "/js/components/context-menu.js";
+import "/js/components/context-menu-item.js";
+import "/js/components/context-menu-item-group.js";
const MAX_DRAFT_GRAPHEME_LENGTH = 1000;
// Threshold for detecting pasted text, since InputEvent.inputType is unreliable
@@ -130,6 +138,57 @@ function errorMessageBannerTemplate({ message, onDismiss }) {
`;
}
+function postLanguageLabel(languages, separator) {
+ return languages.map(getNameForLanguageCode).join(separator);
+}
+
+function postLanguageButtonTemplate({ currentLanguages, onClick }) {
+ const label = postLanguageLabel(currentLanguages, ", ");
+ return html``;
+}
+
+function postLanguageMenuTemplate({
+ menuEntries,
+ currentLanguages,
+ onSelect,
+ onMoreLanguages,
+}) {
+ return html``;
+}
+
function isVideoUploadPending(video) {
return (
!!video && (video.status === "uploading" || video.status === "processing")
@@ -572,6 +631,8 @@ class PostComposer extends Component {
this.state.$isDraggingFiles = new Signal.State(false);
this.state.$threadgateAllow = new Signal.State(null);
this.state.$postgateEmbeddingRules = new Signal.State(null);
+ // null uses the primary language
+ this.state.$selectedPostLanguages = new Signal.State(null);
this._dragAndDropObserver = null;
this._disposers = [
effect(() => {
@@ -720,6 +781,15 @@ class PostComposer extends Component {
const postgateEmbeddingRules = this.state.$postgateEmbeddingRules.get();
const isInteractionLimited =
threadgateAllow !== null || postgateEmbeddingRules?.length > 0;
+ const currentPostLanguages = this._getCurrentPostLanguages();
+ const postLanguageHistory =
+ this.dataLayer.derived.$postLanguageHistory.get();
+ const primaryPostLanguages = [getPrimaryPostLanguage()];
+ const postLanguageMenuEntries = postLanguageHistory.some((entry) =>
+ arePostLanguagesEqual(entry, primaryPostLanguages),
+ )
+ ? postLanguageHistory
+ : [...postLanguageHistory, primaryPostLanguages];
const currentCharCount = graphemeCount(activePost.text);
const charCountPercentage = Math.min(
@@ -959,6 +1029,20 @@ class PostComposer extends Component {
+ ${postLanguageButtonTemplate({
+ currentLanguages: currentPostLanguages,
+ onClick: () => this.openPostLanguageMenu(),
+ })}
+ ${postLanguageMenuTemplate({
+ menuEntries: postLanguageMenuEntries,
+ currentLanguages: currentPostLanguages,
+ onSelect: (entry) => this.setPostLanguages(entry),
+ onMoreLanguages: () => {
+ requestAnimationFrame(() =>
+ this.openPostLanguageDialog(),
+ );
+ },
+ })}
${canAddPost
? html`