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 && ( + { + speak(alt, lang); + }} + > + + Speak + + )} 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 ? ( - { - setForceTranslate(true); - }} - > - - Translate - - ) : ( - (!language || differentLanguage) && ( - + { + setForceTranslate(true); + }} > Translate - + + {supportsTTS && ( + { + const postText = getPostText(status); + if (postText) { + speak(postText, language); + } + }} + > + + Speak + + )} + + ) : ( + (!language || differentLanguage) && ( +
+ + + Translate + + {supportsTTS && ( + { + const postText = getPostText(status); + if (postText) { + speak(postText, language); + } + }} + > + + Speak + + )} +
) )} {((!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); + } +}