Compare commits

...

16 Commits

Author SHA1 Message Date
Thomas Steiner
ec386eba6a
Merge 796bcc701d into 3b52dca405 2025-07-11 10:29:05 -07:00
Thomas Steiner
796bcc701d
Merge branch 'main' into compose-language-detection 2025-07-11 19:29:01 +02:00
Thomas Steiner
9c9442ab84 Remove unused imports 2025-07-11 19:17:52 +02:00
Thomas Steiner
25b0143a12 Don't auto-change the language 2025-07-11 19:14:25 +02:00
Claire
3b52dca405
Fix quote attributes missing from Mastodon's context (#35354)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
2025-07-11 16:35:06 +00:00
Thomas Steiner
8f2834d02a Merge branch 'compose-language-detection' of github.com:tomayac/mastodon into compose-language-detection 2025-07-11 17:41:12 +02:00
Thomas Steiner
d59b6a7e12 Log debug 2025-07-11 17:40:09 +02:00
Thomas Steiner
4122b4f26b
Merge branch 'main' into compose-language-detection 2025-07-11 17:31:15 +02:00
Thomas Steiner
cdc956bba3 Merge branch 'compose-language-detection' of github.com:tomayac/mastodon into compose-language-detection 2025-07-11 17:27:11 +02:00
Thomas Steiner
bac259debf Restructure to use laude or LanguageDetector 2025-07-11 17:26:38 +02:00
Echo
853a0c466e
Make bio hashtags open the local page instead of the remote instance (#35349) 2025-07-11 15:18:34 +00:00
Thomas Steiner
3a232d9626
Merge branch 'main' into compose-language-detection 2025-07-11 12:47:47 +02:00
Thomas Steiner
3e1e6762aa Debounce language detection 2025-07-11 08:37:44 +02:00
Thomas Steiner
0733590c3b Remove log 2025-06-21 13:28:29 +02:00
Thomas Steiner
c50cf3b544 Use the UI language as default compose language 2025-06-21 13:27:03 +02:00
Thomas Steiner
429ab8d210 Add language detection to compose widget 2025-06-21 13:19:04 +02:00
11 changed files with 188 additions and 43 deletions

View File

@ -26,6 +26,12 @@ module ContextHelper
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
quotes: {
'quote' => 'https://w3id.org/fep/044f#quote',
'quoteUri' => 'http://fedibird.com/ns#quoteUri',
'_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote',
'quoteAuthorization' => 'https://w3id.org/fep/044f#quoteAuthorization',
},
interaction_policies: {
'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },

View File

@ -1,12 +1,30 @@
import { useCallback } from 'react';
import { useLinks } from 'mastodon/hooks/useLinks';
export const AccountBio: React.FC<{
interface AccountBioProps {
note: string;
className: string;
}> = ({ note, className }) => {
const handleClick = useLinks();
dropdownAccountId?: string;
}
if (note.length === 0 || note === '<p></p>') {
export const AccountBio: React.FC<AccountBioProps> = ({
note,
className,
dropdownAccountId,
}) => {
const handleClick = useLinks(!!dropdownAccountId);
const handleNodeChange = useCallback(
(node: HTMLDivElement | null) => {
if (!dropdownAccountId || !node || node.childNodes.length === 0) {
return;
}
addDropdownToHashtags(node, dropdownAccountId);
},
[dropdownAccountId],
);
if (note.length === 0) {
return null;
}
@ -15,6 +33,28 @@ export const AccountBio: React.FC<{
className={`${className} translate`}
dangerouslySetInnerHTML={{ __html: note }}
onClickCapture={handleClick}
ref={handleNodeChange}
/>
);
};
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
if (!node) {
return;
}
for (const childNode of node.childNodes) {
if (!(childNode instanceof HTMLElement)) {
continue;
}
if (
childNode instanceof HTMLAnchorElement &&
(childNode.classList.contains('hashtag') ||
childNode.innerText.startsWith('#')) &&
!childNode.dataset.menuHashtag
) {
childNode.dataset.menuHashtag = accountId;
} else if (childNode.childNodes.length > 0) {
addDropdownToHashtags(childNode, accountId);
}
}
}

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@ -773,7 +774,6 @@ export const AccountHeader: React.FC<{
);
}
const content = { __html: account.note_emojified };
const displayNameHtml = { __html: account.display_name_html };
const fields = account.fields;
const isLocal = !account.acct.includes('@');
@ -897,12 +897,11 @@ export const AccountHeader: React.FC<{
<AccountNote accountId={accountId} />
)}
{account.note.length > 0 && account.note !== '<p></p>' && (
<div
className='account__header__content translate'
dangerouslySetInnerHTML={content}
/>
)}
<AccountBio
note={account.note_emojified}
dropdownAccountId={accountId}
className='account__header__content'
/>
<div className='account__header__fields'>
<dl>

View File

@ -15,7 +15,6 @@ import { missingAltTextModal } from 'mastodon/initial_state';
import AutosuggestInput from 'mastodon/components/autosuggest_input';
import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea';
import { Button } from 'mastodon/components/button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
@ -310,7 +309,7 @@ class ComposeForm extends ImmutablePureComponent {
>
{intl.formatMessage(
this.props.isEditing ?
messages.saveChanges :
messages.saveChanges :
(this.props.isInReply ? messages.reply : messages.publish)
)}
</Button>

View File

@ -20,7 +20,7 @@ import { languages as preloadedLanguages } from 'mastodon/initial_state';
import type { RootState } from 'mastodon/store';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { debouncedGuess } from '../util/language_detection';
import { debouncedGuess, countLetters } from '../util/language_detection';
const messages = defineMessages({
changeLanguage: {
@ -375,12 +375,25 @@ export const LanguageDropdown: React.FC = () => {
);
useEffect(() => {
if (text.length > 20) {
debouncedGuess(text, setGuess);
let canceled = false;
if (countLetters(text) >= 5) {
debouncedGuess(text)
.then((lang) => {
if (!canceled) {
setGuess(lang ?? '');
}
})
.catch(() => {
setGuess('');
});
} else {
debouncedGuess.cancel();
setGuess('');
}
return () => {
canceled = true;
};
}, [text, setGuess]);
return (

View File

@ -0,0 +1,5 @@
export declare const debouncedGuess: (
text: string,
) => Promise<string | undefined>;
export declare const countLetters: (text: string) => number;

View File

@ -1,7 +1,5 @@
import lande from 'lande';
import { debounce } from 'lodash';
import { urlRegex } from './url_regex';
const languageDetectorInGlobalThis = 'LanguageDetector' in globalThis;
let languageDetectorSupportedAndReady = languageDetectorInGlobalThis && await globalThis.LanguageDetector.availability() === 'available';
const ISO_639_MAP = {
afr: 'af', // Afrikaans
@ -56,21 +54,23 @@ const ISO_639_MAP = {
vie: 'vi', // Vietnamese
};
const guessLanguage = (text) => {
text = text
.replace(urlRegex, '')
.replace(/(^|[^/\w])@(([a-z0-9_]+)@[a-z0-9.-]+[a-z0-9]+)/ig, '');
if (text.length > 20) {
const [lang, confidence] = lande(text)[0];
if (confidence > 0.8)
return ISO_639_MAP[lang];
}
return '';
const countLetters = (text) => {
const segmenter = new Intl.Segmenter('und', { granularity: 'grapheme' })
const letters = [...segmenter.segment(text)]
return letters.length
};
export const debouncedGuess = debounce((text, setGuess) => {
setGuess(guessLanguage(text));
}, 500, { maxWait: 1500, leading: true, trailing: true });
let module;
// If the API is supported, but the model not loaded yet…
if (languageDetectorInGlobalThis) {
if (!languageDetectorSupportedAndReady) {
// …trigger the model download
globalThis.LanguageDetector.create();
}
module = await import('./language_detection_with_languagedetector');
} else {
module = await import('./language_detection_with_laude');
}
const debouncedGuess = module.debouncedGuess;
export { debouncedGuess, countLetters, ISO_639_MAP };

View File

@ -0,0 +1,41 @@
import { debounce } from 'lodash';
import { urlRegex } from './url_regex';
const guessLanguage = async (text) => {
text = text
.replace(urlRegex, '')
.replace(/(^|[^/\w])@(([a-z0-9_]+)@[a-z0-9.-]+[a-z0-9]+)/ig, '');
try {
const languageDetector = await globalThis.LanguageDetector.create();
let {detectedLanguage, confidence} = (await languageDetector.detect(text))[0];
if (confidence > 0.8) {
detectedLanguage = detectedLanguage.split('-')[0];
return detectedLanguage;
}
} catch {
return '';
}
return '';
};
const debouncedGuess = (() => {
let resolver = null;
const debounced = debounce((text) => {
const result = guessLanguage(text);
if (resolver) {
resolver(result);
resolver = null;
}
}, 500, { maxWait: 1500, leading: true, trailing: true });
return (text) => new Promise((resolve) => {
resolver = resolve;
debounced(text);
});
})();
export { debouncedGuess };

View File

@ -0,0 +1,38 @@
import lande from 'lande';
import { debounce } from 'lodash';
import { countLetters, ISO_639_MAP } from './language_detection';
import { urlRegex } from './url_regex';
const guessLanguage = (text) => {
text = text
.replace(urlRegex, '')
.replace(/(^|[^/\w])@(([a-z0-9_]+)@[a-z0-9.-]+[a-z0-9]+)/ig, '');
if (countLetters(text) > 20) {
const [lang, confidence] = lande(text)[0];
if (confidence > 0.8)
return ISO_639_MAP[lang];
}
return '';
};
const debouncedGuess = (() => {
let resolver = null;
const debounced = debounce((text) => {
const result = guessLanguage(text);
if (resolver) {
resolver(result);
resolver = null;
}
}, 500, { maxWait: 1500, leading: true, trailing: true });
return (text) => new Promise((resolve) => {
resolver = resolve;
debounced(text);
});
})();
export { debouncedGuess };

View File

@ -8,13 +8,14 @@ import { openURL } from 'mastodon/actions/search';
import { useAppDispatch } from 'mastodon/store';
const isMentionClick = (element: HTMLAnchorElement) =>
element.classList.contains('mention');
element.classList.contains('mention') &&
!element.classList.contains('hashtag');
const isHashtagClick = (element: HTMLAnchorElement) =>
element.textContent?.[0] === '#' ||
element.previousSibling?.textContent?.endsWith('#');
export const useLinks = () => {
export const useLinks = (skipHashtags?: boolean) => {
const history = useHistory();
const dispatch = useAppDispatch();
@ -61,12 +62,12 @@ export const useLinks = () => {
if (isMentionClick(target)) {
e.preventDefault();
void handleMentionClick(target);
} else if (isHashtagClick(target)) {
} else if (isHashtagClick(target) && !skipHashtags) {
e.preventDefault();
handleHashtagClick(target);
}
},
[handleMentionClick, handleHashtagClick],
[skipHashtags, handleMentionClick, handleHashtagClick],
);
return handleClick;

View File

@ -126,6 +126,9 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
? accountJSON.username
: accountJSON.display_name;
const accountNote =
accountJSON.note && accountJSON.note !== '<p></p>' ? accountJSON.note : '';
return AccountFactory({
...accountJSON,
moved: moved?.id,
@ -142,8 +145,8 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
escapeTextContentForBrowser(displayName),
emojiMap,
),
note_emojified: emojify(accountJSON.note, emojiMap),
note_plain: unescapeHTML(accountJSON.note),
note_emojified: emojify(accountNote, emojiMap),
note_plain: unescapeHTML(accountNote),
url:
accountJSON.url.startsWith('http://') ||
accountJSON.url.startsWith('https://')