diff --git a/src/components/icon.jsx b/src/components/icon.jsx
index 8b39f789..96d5cebf 100644
--- a/src/components/icon.jsx
+++ b/src/components/icon.jsx
@@ -104,6 +104,7 @@ export const ICONS = {
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
month: () => import('@iconify-icons/mingcute/calendar-month-line'),
media: () => import('@iconify-icons/mingcute/photo-album-line'),
+ speak: () => import('@iconify-icons/mingcute/radar-line'),
};
function Icon({
diff --git a/src/components/media-alt-modal.jsx b/src/components/media-alt-modal.jsx
index 5c99d925..4b313343 100644
--- a/src/components/media-alt-modal.jsx
+++ b/src/components/media-alt-modal.jsx
@@ -4,6 +4,7 @@ import { useSnapshot } from 'valtio';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeMatch from '../utils/locale-match';
+import { speak, supportsTTS } from '../utils/speech';
import states from '../utils/states';
import Icon from './icon';
@@ -51,6 +52,16 @@ export default function MediaAltModal({ alt, lang, onClose }) {
Translate
+ {supportsTTS && (
+
+ )}
diff --git a/src/components/status.jsx b/src/components/status.jsx
index dd5c981d..71b00e6e 100644
--- a/src/components/status.jsx
+++ b/src/components/status.jsx
@@ -63,6 +63,7 @@ import Media from './media';
import { isMediaCaptionLong } from './media';
import MenuLink from './menu-link';
import RelativeTime from './relative-time';
+import { speak, supportsTTS } from '../utils/speech';
import TranslationBlock from './translation-block';
const SHOW_COMMENT_COUNT_LIMIT = 280;
@@ -90,6 +91,26 @@ const isIOS =
const REACTIONS_LIMIT = 80;
+function getPollText(poll) {
+ if (!poll?.options?.length) return '';
+ return `📊:\n${poll.options
+ .map(
+ (option) =>
+ `- ${option.title}${
+ option.votesCount >= 0 ? ` (${option.votesCount})` : ''
+ }`,
+ )
+ .join('\n')}`;
+}
+function getPostText(status) {
+ const { spoilerText, content, poll } = status;
+ return (
+ (spoilerText ? `${spoilerText}\n\n` : '') +
+ getHTMLText(content) +
+ getPollText(poll)
+ );
+}
+
function Status({
statusID,
status,
@@ -782,23 +803,53 @@ function Status({
>
)}
{enableTranslate ? (
-
- ) : (
- (!language || differentLanguage) && (
-
+
+
+ {supportsTTS && (
+
+ )}
+
+ ) : (
+ (!language || differentLanguage) && (
+
+
+
+ Translate
+
+ {supportsTTS && (
+
+ )}
+
)
)}
{((!isSizeLarge && sameInstance) || enableTranslate) && }
@@ -1578,22 +1629,7 @@ function Status({
forceTranslate={forceTranslate || inlineTranslate}
mini={!isSizeLarge && !withinContext}
sourceLanguage={language}
- text={
- (spoilerText ? `${spoilerText}\n\n` : '') +
- getHTMLText(content) +
- (poll?.options?.length
- ? `\n\nPoll:\n${poll.options
- .map(
- (option) =>
- `- ${option.title}${
- option.votesCount >= 0
- ? ` (${option.votesCount})`
- : ''
- }`,
- )
- .join('\n')}`
- : '')
- }
+ text={getPostText(status)}
/>
)}
{!spoilerText && sensitive && !!mediaAttachments.length && (
diff --git a/src/utils/speech.js b/src/utils/speech.js
new file mode 100644
index 00000000..eefc546f
--- /dev/null
+++ b/src/utils/speech.js
@@ -0,0 +1,15 @@
+export const supportsTTS = 'speechSynthesis' in window;
+
+export function speak(text, lang) {
+ if (!supportsTTS) return;
+ try {
+ if (speechSynthesis.speaking) {
+ speechSynthesis.cancel();
+ }
+ const utterance = new SpeechSynthesisUtterance(text);
+ if (lang) utterance.lang = lang;
+ speechSynthesis.speak(utterance);
+ } catch (e) {
+ alert(e);
+ }
+}