mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-11 14:30:35 +00:00
Merge commit '75f78244d5df52cc8242b6a7c6b8d1531963aa63' into glitch-soc/merge-upstream
This commit is contained in:
@@ -308,8 +308,8 @@ GEM
|
||||
highline (3.1.2)
|
||||
reline
|
||||
hiredis (0.6.3)
|
||||
hiredis-client (0.25.2)
|
||||
redis-client (= 0.25.2)
|
||||
hiredis-client (0.25.3)
|
||||
redis-client (= 0.25.3)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.3.1)
|
||||
@@ -725,7 +725,7 @@ GEM
|
||||
reline
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
redis-client (0.25.2)
|
||||
redis-client (0.25.3)
|
||||
connection_pool
|
||||
regexp_parser (2.11.2)
|
||||
reline (0.6.2)
|
||||
|
||||
@@ -186,13 +186,16 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = items?.[i];
|
||||
const isItemDisabled = Boolean(
|
||||
item && typeof item === 'object' && 'disabled' in item && item.disabled,
|
||||
);
|
||||
|
||||
onClose();
|
||||
|
||||
if (!item) {
|
||||
if (!item || isItemDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
if (typeof onItemClick === 'function') {
|
||||
e.preventDefault();
|
||||
onItemClick(item, i);
|
||||
@@ -233,7 +236,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<DropdownMenuItemContent item={option} />
|
||||
</button>
|
||||
@@ -320,7 +323,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownProps<Item = MenuItem> {
|
||||
interface DropdownProps<Item extends object | null = MenuItem> {
|
||||
children?: React.ReactElement;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
@@ -348,7 +351,7 @@ interface DropdownProps<Item = MenuItem> {
|
||||
const offset = [5, 5] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
export const Dropdown = <Item = MenuItem,>({
|
||||
export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
children,
|
||||
icon,
|
||||
iconComponent,
|
||||
|
||||
@@ -180,7 +180,7 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||
<button
|
||||
{...handlers}
|
||||
ref={focusRefCallback}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
data-index={index}
|
||||
>
|
||||
<DropdownMenuItemContent item={item} />
|
||||
|
||||
@@ -239,6 +239,10 @@
|
||||
"confirmations.missing_alt_text.secondary": "Postio beth bynnag",
|
||||
"confirmations.missing_alt_text.title": "Ychwanegu testun amgen?",
|
||||
"confirmations.mute.confirm": "Tewi",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "Peidio fy atgoff eto",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Iawn",
|
||||
"confirmations.quiet_post_quote_info.message": "Wrth ddyfynnu postiad cyhoeddus tawel, bydd eich postiad yn cael ei guddio rhag llinellau amser sy'n trendio.",
|
||||
"confirmations.quiet_post_quote_info.title": "Yn dyfynnu postiadau cyhoeddus tawel",
|
||||
"confirmations.redraft.confirm": "Dileu ac ailddrafftio",
|
||||
"confirmations.redraft.message": "Ydych chi wir eisiau'r dileu'r postiad hwn a'i ail lunio? Bydd ffefrynnau a hybiau'n cael eu colli, a bydd atebion i'r postiad gwreiddiol yn mynd yn amddifad.",
|
||||
"confirmations.redraft.title": "Dileu ac ail lunio'r postiad?",
|
||||
|
||||
@@ -239,10 +239,10 @@
|
||||
"confirmations.missing_alt_text.secondary": "Enviar de todos modos",
|
||||
"confirmations.missing_alt_text.title": "¿Agregar texto alternativo?",
|
||||
"confirmations.mute.confirm": "Silenciar",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "No me lo vuelvas a recordar",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "No recordar de nuevo",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Entendido",
|
||||
"confirmations.quiet_post_quote_info.message": "Cuando cites una publicación pública silenciosa, tu publicación se ocultará de las cronologías de tendencias.",
|
||||
"confirmations.quiet_post_quote_info.title": "Citar publicaciones públicas silenciosas",
|
||||
"confirmations.quiet_post_quote_info.message": "Al citar un mensaje público pero silencioso, tu mensaje se ocultará de las líneas temporales de tendencias.",
|
||||
"confirmations.quiet_post_quote_info.title": "Citado de mensajes públicos pero silenciosos",
|
||||
"confirmations.redraft.confirm": "Eliminar mensaje original y editarlo",
|
||||
"confirmations.redraft.message": "¿Estás seguro que querés eliminar este mensaje y volver a editarlo? Se perderán las veces marcadas como favorito y sus adhesiones, y las respuestas al mensaje original quedarán huérfanas.",
|
||||
"confirmations.redraft.title": "¿Eliminar y volver a redactar mensaje?",
|
||||
|
||||
@@ -239,6 +239,10 @@
|
||||
"confirmations.missing_alt_text.secondary": "Posta allíkavæl",
|
||||
"confirmations.missing_alt_text.title": "Legg alternativan tekst afturat?",
|
||||
"confirmations.mute.confirm": "Doyv",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "Ikki minna meg á tað aftur",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Eg skilji",
|
||||
"confirmations.quiet_post_quote_info.message": "Tá tú siterar ein stillan almennan post, verður posturin hjá tær fjaldur frá tíðarlinjum, ið vísa vælumtóktar postar.",
|
||||
"confirmations.quiet_post_quote_info.title": "Sitera stillar almennar postar",
|
||||
"confirmations.redraft.confirm": "Sletta og skriva umaftur",
|
||||
"confirmations.redraft.message": "Vilt tú veruliga strika hendan postin og í staðin gera hann til eina nýggja kladdu? Yndisfrámerki og framhevjanir blíva burtur, og svar til upprunapostin missa tilknýtið.",
|
||||
"confirmations.redraft.title": "Strika & ger nýtt uppskot um post?",
|
||||
@@ -745,6 +749,7 @@
|
||||
"privacy.quote.disabled": "{visibility}, siteringar óvirknar",
|
||||
"privacy.quote.limited": "{visibility}, siteringar avmarkaðar",
|
||||
"privacy.unlisted.additional": "Hetta er júst sum almenn, tó verður posturin ikki vístur í samtíðarrásum ella frámerkjum, rannsakan ella Mastodon leitingum, sjálvt um valið er galdandi fyri alla kontuna.",
|
||||
"privacy.unlisted.long": "Fjalt frá Mastodon leitiúrslitum og vælumtóktum og almennum tíðarlinjum",
|
||||
"privacy.unlisted.short": "Stillur almenningur",
|
||||
"privacy_policy.last_updated": "Seinast dagført {date}",
|
||||
"privacy_policy.title": "Privatlívspolitikkur",
|
||||
@@ -912,6 +917,7 @@
|
||||
"status.read_more": "Les meira",
|
||||
"status.reblog": "Stimbra",
|
||||
"status.reblog_or_quote": "Stimbra ella sitera",
|
||||
"status.reblog_private": "Deil aftur við tínum fylgjarum",
|
||||
"status.reblogged_by": "{name} stimbrað",
|
||||
"status.reblogs": "{count, plural, one {stimbran} other {stimbranir}}",
|
||||
"status.reblogs.empty": "Eingin hevur stimbrað hendan postin enn. Tá onkur stimbrar postin, verður hann sjónligur her.",
|
||||
@@ -990,6 +996,7 @@
|
||||
"visibility_modal.header": "Sýni og samvirkni",
|
||||
"visibility_modal.helper.direct_quoting": "Privatar umrøður, sum eru skrivaðar á Mastodon, kunnu ikki siterast av øðrum.",
|
||||
"visibility_modal.helper.privacy_editing": "Sýni kann ikki broytast eftir, at ein postur er útgivin.",
|
||||
"visibility_modal.helper.privacy_private_self_quote": "Sjálv-sitering av privatum postum kann ikki gerast almenn.",
|
||||
"visibility_modal.helper.private_quoting": "Postar, sum einans eru fyri fylgjarar á Mastodon, kunnu ikki siterast av øðrum.",
|
||||
"visibility_modal.helper.unlisted_quoting": "Tá fólk sitera teg, so vera teirra postar eisini fjaldir frá tíðarlinjum við ráki.",
|
||||
"visibility_modal.instructions": "Stýr hvør samvirka við hendan postin. Tú kanst eisini áseta stillingar til allar framtíðar postar við at fara til <link>Stillingar > Postingarstillingar</link>.",
|
||||
|
||||
@@ -885,13 +885,13 @@
|
||||
"status.mute_conversation": "Masquer la conversation",
|
||||
"status.open": "Afficher la publication entière",
|
||||
"status.pin": "Épingler sur profil",
|
||||
"status.quote": "Citation",
|
||||
"status.quote": "Citer",
|
||||
"status.quote.cancel": "Annuler la citation",
|
||||
"status.quote_error.filtered": "Caché en raison de l'un de vos filtres",
|
||||
"status.quote_error.not_available": "Publication non disponible",
|
||||
"status.quote_error.pending_approval": "Publication en attente",
|
||||
"status.quote_error.pending_approval_popout.title": "Publication en attente ? Restez calme",
|
||||
"status.quote_followers_only": "Seul·e·s mes abonné·e·s peuvent citer cette publication",
|
||||
"status.quote_followers_only": "Seul·e·s les abonné·e·s peuvent citer cette publication",
|
||||
"status.quote_manual_review": "L'auteur va vérifier manuellement",
|
||||
"status.quote_policy_change": "Changer qui peut vous citer",
|
||||
"status.quote_private": "Les publications privées ne peuvent pas être citées",
|
||||
@@ -908,6 +908,7 @@
|
||||
"status.reply": "Répondre",
|
||||
"status.replyAll": "Répondre à cette discussion",
|
||||
"status.report": "Signaler @{name}",
|
||||
"status.request_quote": "Demander à citer",
|
||||
"status.sensitive_warning": "Contenu sensible",
|
||||
"status.share": "Partager",
|
||||
"status.show_less_all": "Tout replier",
|
||||
|
||||
@@ -885,13 +885,13 @@
|
||||
"status.mute_conversation": "Masquer la conversation",
|
||||
"status.open": "Afficher le message entier",
|
||||
"status.pin": "Épingler sur le profil",
|
||||
"status.quote": "Citation",
|
||||
"status.quote": "Citer",
|
||||
"status.quote.cancel": "Annuler la citation",
|
||||
"status.quote_error.filtered": "Caché en raison de l'un de vos filtres",
|
||||
"status.quote_error.not_available": "Publication non disponible",
|
||||
"status.quote_error.pending_approval": "Publication en attente",
|
||||
"status.quote_error.pending_approval_popout.title": "Publication en attente ? Restez calme",
|
||||
"status.quote_followers_only": "Seul·e·s mes abonné·e·s peuvent citer cette publication",
|
||||
"status.quote_followers_only": "Seul·e·s les abonné·e·s peuvent citer cette publication",
|
||||
"status.quote_manual_review": "L'auteur va vérifier manuellement",
|
||||
"status.quote_policy_change": "Changer qui peut vous citer",
|
||||
"status.quote_private": "Les publications privées ne peuvent pas être citées",
|
||||
@@ -908,6 +908,7 @@
|
||||
"status.reply": "Répondre",
|
||||
"status.replyAll": "Répondre au fil",
|
||||
"status.report": "Signaler @{name}",
|
||||
"status.request_quote": "Demander à citer",
|
||||
"status.sensitive_warning": "Contenu sensible",
|
||||
"status.share": "Partager",
|
||||
"status.show_less_all": "Tout replier",
|
||||
|
||||
@@ -239,6 +239,10 @@
|
||||
"confirmations.missing_alt_text.secondary": "Post ar aon nós",
|
||||
"confirmations.missing_alt_text.title": "Cuir téacs alt leis?",
|
||||
"confirmations.mute.confirm": "Balbhaigh",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "Ná cuir i gcuimhne dom arís",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Tuigim é",
|
||||
"confirmations.quiet_post_quote_info.message": "Agus post poiblí ciúin á lua, beidh do phost i bhfolach ó amlínte treochta.",
|
||||
"confirmations.quiet_post_quote_info.title": "Ag lua poist phoiblí chiúine",
|
||||
"confirmations.redraft.confirm": "Scrios ⁊ athdhréachtaigh",
|
||||
"confirmations.redraft.message": "An bhfuil tú cinnte gur mhaith leat an postáil seo a scriosadh agus é a athdhréachtú? Caillfear ceanáin agus treisithe, agus dílleachtaí freagraí ar an mbunphostála.",
|
||||
"confirmations.redraft.title": "Scrios & athdhréachtú postáil?",
|
||||
|
||||
@@ -239,6 +239,10 @@
|
||||
"confirmations.missing_alt_text.secondary": "Publicar igualmente",
|
||||
"confirmations.missing_alt_text.title": "Engadir texto descritivo?",
|
||||
"confirmations.mute.confirm": "Acalar",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "Non lembrarmo máis",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Entendido",
|
||||
"confirmations.quiet_post_quote_info.message": "Ao citar unha publicación pública limitada a túa publicación non aparecerá nas cronoloxías de tendencias.",
|
||||
"confirmations.quiet_post_quote_info.title": "Citar publicacións públicas limitadas",
|
||||
"confirmations.redraft.confirm": "Eliminar e reescribir",
|
||||
"confirmations.redraft.message": "Tes a certeza de querer eliminar esta publicación e reescribila? Perderás as promocións e favorecementos, e as respostas á publicación orixinal ficarán orfas.",
|
||||
"confirmations.redraft.title": "Eliminar e reescribir a publicación?",
|
||||
@@ -746,7 +750,7 @@
|
||||
"privacy.quote.limited": "{visibility}, citas limitadas",
|
||||
"privacy.unlisted.additional": "Do mesmo xeito que público, menos que a publicación non aparecerá nas cronoloxías en directo ou nos cancelos, en descubrir ou nas buscas de Mastodon, incluso se estivese establecido nas opcións xerais da conta.",
|
||||
"privacy.unlisted.long": "Non aparece nos resultados de buscas en Mastodon, nas tendencias e cronoloxías públicas",
|
||||
"privacy.unlisted.short": "Público limitado",
|
||||
"privacy.unlisted.short": "Pública limitada",
|
||||
"privacy_policy.last_updated": "Actualizado por última vez no {date}",
|
||||
"privacy_policy.title": "Política de Privacidade",
|
||||
"quote_error.poll": "Non se permite citar as enquisas.",
|
||||
|
||||
@@ -241,6 +241,8 @@
|
||||
"confirmations.mute.confirm": "Némítás",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "Ne emlékeztessen újra",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Rendben",
|
||||
"confirmations.quiet_post_quote_info.message": "Ha csendes nyilvános bejegyzést idézel, akkor a bejegyzés el lesz rejtve a felkapottak idővonalairól.",
|
||||
"confirmations.quiet_post_quote_info.title": "Csendes nyilvános bejegyzések idézése",
|
||||
"confirmations.redraft.confirm": "Törlés és újraírás",
|
||||
"confirmations.redraft.message": "Biztos, hogy ezt a bejegyzést szeretnéd törölni és újraírni? Minden megtolást és kedvencnek jelölést elvesztesz, az eredetire adott válaszok pedig elárvulnak.",
|
||||
"confirmations.redraft.title": "Törlöd és újraírod a bejegyzést?",
|
||||
|
||||
@@ -239,6 +239,8 @@
|
||||
"confirmations.missing_alt_text.secondary": "Yine de gönder",
|
||||
"confirmations.missing_alt_text.title": "Alternatif metin ekle?",
|
||||
"confirmations.mute.confirm": "Sessize al",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "Bana bir daha hatırlatma",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Anladım",
|
||||
"confirmations.redraft.confirm": "Sil Düzenle ve yeniden paylaş",
|
||||
"confirmations.redraft.message": "Bu gönderiyi silip taslak haline getirmek istediğinize emin misiniz? Mevcut favoriler ve boostlar silinecek ve gönderiye verilen yanıtlar başıboş kalacak.",
|
||||
"confirmations.redraft.title": "Gönderiyi sil veya taslağa dönüştür?",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`html > htmlStringToComponents > copies attributes to props 1`] = `
|
||||
[
|
||||
<a
|
||||
href="https://example.com"
|
||||
rel="nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
link
|
||||
</a>,
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`html > htmlStringToComponents > handles nested elements 1`] = `
|
||||
[
|
||||
<p>
|
||||
lorem
|
||||
<strong>
|
||||
ipsum
|
||||
</strong>
|
||||
</p>,
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`html > htmlStringToComponents > ignores empty text nodes 1`] = `
|
||||
[
|
||||
<p>
|
||||
<span>
|
||||
lorem ipsum
|
||||
</span>
|
||||
</p>,
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`html > htmlStringToComponents > respects allowedTags option 1`] = `
|
||||
[
|
||||
<p>
|
||||
lorem
|
||||
<em>
|
||||
dolor
|
||||
</em>
|
||||
</p>,
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`html > htmlStringToComponents > respects maxDepth option 1`] = `
|
||||
[
|
||||
<p>
|
||||
<span />
|
||||
</p>,
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`html > htmlStringToComponents > returns converted nodes from string 1`] = `
|
||||
[
|
||||
<p>
|
||||
lorem ipsum
|
||||
</p>,
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`html > htmlStringToComponents > uses default parsing if onElement returns undefined 1`] = `
|
||||
[
|
||||
<p>
|
||||
lorem ipsum
|
||||
</p>,
|
||||
]
|
||||
`;
|
||||
@@ -1,3 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import * as html from '../html';
|
||||
|
||||
describe('html', () => {
|
||||
@@ -9,4 +11,104 @@ describe('html', () => {
|
||||
expect(output).toEqual('lorem\n\nipsum\n<br>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlStringToComponents', () => {
|
||||
it('returns converted nodes from string', () => {
|
||||
const input = '<p>lorem ipsum</p>';
|
||||
const output = html.htmlStringToComponents(input);
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('handles nested elements', () => {
|
||||
const input = '<p>lorem <strong>ipsum</strong></p>';
|
||||
const output = html.htmlStringToComponents(input);
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('ignores empty text nodes', () => {
|
||||
const input = '<p> <span>lorem ipsum</span> </p>';
|
||||
const output = html.htmlStringToComponents(input);
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('copies attributes to props', () => {
|
||||
const input =
|
||||
'<a href="https://example.com" target="_blank" rel="nofollow">link</a>';
|
||||
const output = html.htmlStringToComponents(input);
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('respects maxDepth option', () => {
|
||||
const input = '<p><span>lorem <strong>ipsum</strong></span></p>';
|
||||
const output = html.htmlStringToComponents(input, { maxDepth: 2 });
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onText callback', () => {
|
||||
const input = '<p>lorem ipsum</p>';
|
||||
const onText = vi.fn((text: string) => text);
|
||||
html.htmlStringToComponents(input, { onText });
|
||||
expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum');
|
||||
});
|
||||
|
||||
it('calls onElement callback', () => {
|
||||
const input = '<p>lorem ipsum</p>';
|
||||
const onElement = vi.fn(
|
||||
(element: HTMLElement, children: React.ReactNode[]) =>
|
||||
React.createElement(element.tagName.toLowerCase(), {}, ...children),
|
||||
);
|
||||
html.htmlStringToComponents(input, { onElement });
|
||||
expect(onElement).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ tagName: 'P' }),
|
||||
expect.arrayContaining(['lorem ipsum']),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses default parsing if onElement returns undefined', () => {
|
||||
const input = '<p>lorem ipsum</p>';
|
||||
const onElement = vi.fn(() => undefined);
|
||||
const output = html.htmlStringToComponents(input, { onElement });
|
||||
expect(onElement).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ tagName: 'P' }),
|
||||
expect.arrayContaining(['lorem ipsum']),
|
||||
);
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onAttribute callback', () => {
|
||||
const input =
|
||||
'<a href="https://example.com" target="_blank" rel="nofollow">link</a>';
|
||||
const onAttribute = vi.fn(
|
||||
(name: string, value: string) =>
|
||||
[name, value] satisfies [string, string],
|
||||
);
|
||||
html.htmlStringToComponents(input, { onAttribute });
|
||||
expect(onAttribute).toHaveBeenCalledTimes(3);
|
||||
expect(onAttribute).toHaveBeenCalledWith(
|
||||
'href',
|
||||
'https://example.com',
|
||||
'a',
|
||||
);
|
||||
expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a');
|
||||
expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', 'a');
|
||||
});
|
||||
|
||||
it('respects allowedTags option', () => {
|
||||
const input = '<p>lorem <strong>ipsum</strong> <em>dolor</em></p>';
|
||||
const output = html.htmlStringToComponents(input, {
|
||||
allowedTags: new Set(['p', 'em']),
|
||||
});
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('ensure performance is acceptable with large input', () => {
|
||||
const input = '<p>' + '<span>lorem</span>'.repeat(1_000) + '</p>';
|
||||
const start = performance.now();
|
||||
html.htmlStringToComponents(input);
|
||||
const duration = performance.now() - start;
|
||||
// Arbitrary threshold of 200ms for this test.
|
||||
// Normally it's much less (<50ms), but the GH Action environment can be slow.
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// NB: This function can still return unsafe HTML
|
||||
export const unescapeHTML = (html: string) => {
|
||||
const wrapper = document.createElement('div');
|
||||
@@ -7,3 +9,177 @@ export const unescapeHTML = (html: string) => {
|
||||
.replace(/<[^>]*>/g, '');
|
||||
return wrapper.textContent;
|
||||
};
|
||||
|
||||
interface QueueItem {
|
||||
node: Node;
|
||||
parent: React.ReactNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
maxDepth?: number;
|
||||
onText?: (text: string) => React.ReactNode;
|
||||
onElement?: (
|
||||
element: HTMLElement,
|
||||
children: React.ReactNode[],
|
||||
) => React.ReactNode;
|
||||
onAttribute?: (
|
||||
name: string,
|
||||
value: string,
|
||||
tagName: string,
|
||||
) => [string, unknown] | null;
|
||||
allowedTags?: Set<string>;
|
||||
}
|
||||
const DEFAULT_ALLOWED_TAGS: ReadonlySet<string> = new Set([
|
||||
'a',
|
||||
'abbr',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'cite',
|
||||
'code',
|
||||
'del',
|
||||
'dfn',
|
||||
'dl',
|
||||
'dt',
|
||||
'em',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'i',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
'small',
|
||||
'span',
|
||||
'strong',
|
||||
'sub',
|
||||
'sup',
|
||||
'time',
|
||||
'u',
|
||||
'ul',
|
||||
]);
|
||||
|
||||
export function htmlStringToComponents(
|
||||
htmlString: string,
|
||||
options: Options = {},
|
||||
) {
|
||||
const wrapper = document.createElement('template');
|
||||
wrapper.innerHTML = htmlString;
|
||||
|
||||
const rootChildren: React.ReactNode[] = [];
|
||||
const queue: QueueItem[] = [
|
||||
{ node: wrapper.content, parent: rootChildren, depth: 0 },
|
||||
];
|
||||
|
||||
const {
|
||||
maxDepth = 10,
|
||||
allowedTags = DEFAULT_ALLOWED_TAGS,
|
||||
onAttribute,
|
||||
onElement,
|
||||
onText,
|
||||
} = options;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) {
|
||||
break;
|
||||
}
|
||||
|
||||
const { node, parent, depth } = item;
|
||||
// If maxDepth is exceeded, skip processing this node.
|
||||
if (depth > maxDepth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (node.nodeType) {
|
||||
// Just process children for fragments.
|
||||
case Node.DOCUMENT_FRAGMENT_NODE: {
|
||||
for (const child of node.childNodes) {
|
||||
queue.push({ node: child, parent, depth: depth + 1 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Text can be added directly if it has any non-whitespace content.
|
||||
case Node.TEXT_NODE: {
|
||||
const text = node.textContent;
|
||||
if (text && text.trim() !== '') {
|
||||
if (onText) {
|
||||
parent.push(onText(text));
|
||||
} else {
|
||||
parent.push(text);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Process elements with attributes and then their children.
|
||||
case Node.ELEMENT_NODE: {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
console.warn('Expected HTMLElement, got', node);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the tag is not allowed, skip it and its children.
|
||||
if (!allowedTags.has(node.tagName.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the element and add it to the parent.
|
||||
const children: React.ReactNode[] = [];
|
||||
let element: React.ReactNode = undefined;
|
||||
|
||||
// If onElement is provided, use it to create the element.
|
||||
if (onElement) {
|
||||
const component = onElement(node, children);
|
||||
// Check for undefined to allow returning null.
|
||||
if (component !== undefined) {
|
||||
element = component;
|
||||
}
|
||||
}
|
||||
|
||||
// If the element wasn't created, use the default conversion.
|
||||
if (element === undefined) {
|
||||
const props: Record<string, unknown> = {};
|
||||
for (const attr of node.attributes) {
|
||||
if (onAttribute) {
|
||||
const result = onAttribute(
|
||||
attr.name,
|
||||
attr.value,
|
||||
node.tagName.toLowerCase(),
|
||||
);
|
||||
if (result) {
|
||||
const [name, value] = result;
|
||||
props[name] = value;
|
||||
}
|
||||
} else {
|
||||
props[attr.name] = attr.value;
|
||||
}
|
||||
}
|
||||
element = React.createElement(
|
||||
node.tagName.toLowerCase(),
|
||||
props,
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
// Push the element to the parent.
|
||||
parent.push(element);
|
||||
|
||||
// Iterate over the node children with the newly created component.
|
||||
for (const child of node.childNodes) {
|
||||
queue.push({ node: child, parent: children, depth: depth + 1 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rootChildren;
|
||||
}
|
||||
|
||||
@@ -2908,16 +2908,23 @@ a.account__display-name {
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active {
|
||||
&:not(:disabled) {
|
||||
&:not(:disabled, [aria-disabled='true']) {
|
||||
background: var(--dropdown-border-color);
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
button:disabled,
|
||||
button[aria-disabled='true'] {
|
||||
color: $dark-text-color;
|
||||
cursor: default;
|
||||
|
||||
&:focus {
|
||||
color: rgb(from $dark-text-color r g b / 70%);
|
||||
background: var(--dropdown-border-color);
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dimension::BaseDimension
|
||||
include Redisable
|
||||
include Admin::Metrics::Dimension::StoreHelper
|
||||
|
||||
def key
|
||||
'software_versions'
|
||||
@@ -45,13 +45,11 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
|
||||
end
|
||||
|
||||
def redis_version
|
||||
value = redis_info['redis_version']
|
||||
|
||||
{
|
||||
key: 'redis',
|
||||
human_key: 'Redis',
|
||||
value: value,
|
||||
human_value: value,
|
||||
human_key: store_name,
|
||||
value: store_version,
|
||||
human_value: store_version,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -117,8 +115,4 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
|
||||
rescue Terrapin::CommandNotFoundError, Terrapin::ExitStatusError, Oj::ParseError
|
||||
nil
|
||||
end
|
||||
|
||||
def redis_info
|
||||
@redis_info ||= redis.info
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Admin::Metrics::Dimension::SpaceUsageDimension < Admin::Metrics::Dimension::BaseDimension
|
||||
include Redisable
|
||||
include ActionView::Helpers::NumberHelper
|
||||
include Admin::Metrics::Dimension::StoreHelper
|
||||
|
||||
def key
|
||||
'space_usage'
|
||||
@@ -27,14 +27,12 @@ class Admin::Metrics::Dimension::SpaceUsageDimension < Admin::Metrics::Dimension
|
||||
end
|
||||
|
||||
def redis_size
|
||||
value = redis_info['used_memory']
|
||||
|
||||
{
|
||||
key: 'redis',
|
||||
human_key: 'Redis',
|
||||
value: value.to_s,
|
||||
human_key: store_name,
|
||||
value: store_size.to_s,
|
||||
unit: 'bytes',
|
||||
human_value: number_to_human_size(value),
|
||||
human_value: number_to_human_size(store_size),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -57,10 +55,6 @@ class Admin::Metrics::Dimension::SpaceUsageDimension < Admin::Metrics::Dimension
|
||||
}
|
||||
end
|
||||
|
||||
def redis_info
|
||||
@redis_info ||= redis.info
|
||||
end
|
||||
|
||||
def search_size
|
||||
return unless Chewy.enabled?
|
||||
|
||||
|
||||
26
app/lib/admin/metrics/dimension/store_helper.rb
Normal file
26
app/lib/admin/metrics/dimension/store_helper.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin::Metrics::Dimension::StoreHelper
|
||||
include Redisable
|
||||
|
||||
private
|
||||
|
||||
def store_name
|
||||
return 'Valkey' if redis_info.key?('valkey_version')
|
||||
return 'Dragonfly' if redis_info.key?('dragonfly_version')
|
||||
|
||||
'Redis'
|
||||
end
|
||||
|
||||
def store_version
|
||||
redis_info['valkey_version'] || redis_info['dragonfly_version'] || redis_info['redis_version']
|
||||
end
|
||||
|
||||
def store_size
|
||||
redis_info['used_memory']
|
||||
end
|
||||
|
||||
def redis_info
|
||||
@redis_info ||= redis.info
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,7 @@ class ActivityPub::QuoteAuthorizationSerializer < ActivityPub::Serializer
|
||||
attributes :id, :type, :attributed_to, :interacting_object, :interaction_target
|
||||
|
||||
def id
|
||||
ActivityPub::TagManager.instance.approval_uri_for(object)
|
||||
ActivityPub::TagManager.instance.approval_uri_for(object, check_approval: !instance_options[:force_approval_id])
|
||||
end
|
||||
|
||||
def type
|
||||
|
||||
@@ -34,6 +34,6 @@ class RevokeQuoteService < BaseService
|
||||
end
|
||||
|
||||
def signed_activity_json
|
||||
@signed_activity_json ||= Oj.dump(serialize_payload(@quote, ActivityPub::DeleteQuoteAuthorizationSerializer, signer: @account, always_sign: true))
|
||||
@signed_activity_json ||= Oj.dump(serialize_payload(@quote, ActivityPub::DeleteQuoteAuthorizationSerializer, signer: @account, always_sign: true, force_approval_id: true))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1922,6 +1922,7 @@ fo:
|
||||
private_long: Vís einans fyri fylgjarum
|
||||
public: Alment
|
||||
public_long: Øll í og uttanfyri Mastodon
|
||||
unlisted: Stillur almenningur
|
||||
unlisted_long: Fjalt frá leitiúrslitum, rákum og almennum tíðarlinjum í Mastodon
|
||||
statuses_cleanup:
|
||||
enabled: Strika gamlar postar sjálvvirkandi
|
||||
|
||||
@@ -9,14 +9,44 @@ RSpec.describe Admin::Metrics::Dimension::SoftwareVersionsDimension do
|
||||
let(:end_at) { Time.now.utc }
|
||||
let(:limit) { 10 }
|
||||
let(:params) { ActionController::Parameters.new }
|
||||
let(:redis_human_key) { 'Redis' }
|
||||
let(:redis_version) { '7.4.5' }
|
||||
let(:redis_info) { { 'redis_version' => redis_version } }
|
||||
|
||||
describe '#data' do
|
||||
it 'reports on the running software' do
|
||||
expect(subject.data.map(&:symbolize_keys))
|
||||
.to include(
|
||||
include(key: 'mastodon', value: Mastodon::Version.to_s),
|
||||
include(key: 'ruby', value: include(RUBY_VERSION))
|
||||
)
|
||||
shared_examples 'shared behavior' do
|
||||
before do
|
||||
allow(subject).to receive(:redis_info).and_return(redis_info) # rubocop:disable RSpec/SubjectStub
|
||||
end
|
||||
|
||||
it 'reports on the running software' do
|
||||
expect(subject.data.map(&:symbolize_keys))
|
||||
.to include(
|
||||
include(key: 'mastodon', value: Mastodon::Version.to_s),
|
||||
include(key: 'ruby', value: include(RUBY_VERSION)),
|
||||
include(key: 'redis', human_key: redis_human_key, value: redis_version)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when using redis' do
|
||||
it_behaves_like 'shared behavior'
|
||||
end
|
||||
|
||||
context 'when using valkey' do
|
||||
let(:redis_human_key) { 'Valkey' }
|
||||
let(:redis_version) { '8.1.3' }
|
||||
let(:redis_info) { { 'valkey_version' => redis_version } }
|
||||
|
||||
it_behaves_like 'shared behavior'
|
||||
end
|
||||
|
||||
context 'when using dragonfly' do
|
||||
let(:redis_human_key) { 'Dragonfly' }
|
||||
let(:redis_version) { 'df-v1.32.0' }
|
||||
let(:redis_info) { { 'dragonfly_version' => redis_version } }
|
||||
|
||||
it_behaves_like 'shared behavior'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,14 +10,41 @@ RSpec.describe Admin::Metrics::Dimension::SpaceUsageDimension do
|
||||
let(:limit) { 10 }
|
||||
let(:params) { ActionController::Parameters.new }
|
||||
|
||||
let(:redis_human_key) { 'Redis' }
|
||||
let(:redis_info) { { 'redis_version' => '7.4.5', 'used_memory' => 1_024 } }
|
||||
|
||||
describe '#data' do
|
||||
it 'reports on used storage space' do
|
||||
expect(subject.data.map(&:symbolize_keys))
|
||||
.to include(
|
||||
include(key: 'media', value: /\d/),
|
||||
include(key: 'postgresql', value: /\d/),
|
||||
include(key: 'redis', value: /\d/)
|
||||
)
|
||||
shared_examples 'shared behavior' do
|
||||
before do
|
||||
allow(subject).to receive(:redis_info).and_return(redis_info) # rubocop:disable RSpec/SubjectStub
|
||||
end
|
||||
|
||||
it 'reports on used storage space' do
|
||||
expect(subject.data.map(&:symbolize_keys))
|
||||
.to include(
|
||||
include(key: 'media', value: /\d/),
|
||||
include(key: 'postgresql', value: /\d/),
|
||||
include(key: 'redis', human_key: redis_human_key, value: /\d/)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when using redis' do
|
||||
it_behaves_like 'shared behavior'
|
||||
end
|
||||
|
||||
context 'when using valkey' do
|
||||
let(:redis_human_key) { 'Valkey' }
|
||||
let(:redis_info) { { 'valkey_version' => '8.1.3', 'used_memory' => 1_024 } }
|
||||
|
||||
it_behaves_like 'shared behavior'
|
||||
end
|
||||
|
||||
context 'when using dragonfly' do
|
||||
let(:redis_human_key) { 'Dragonfly' }
|
||||
let(:redis_info) { { 'dragonfly_version' => 'df-v1.32.0', 'used_memory' => 1_024 } }
|
||||
|
||||
it_behaves_like 'shared behavior'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,7 +20,22 @@ RSpec.describe RevokeQuoteService do
|
||||
it 'revokes the quote and sends a Delete activity' do
|
||||
expect { described_class.new.call(quote) }
|
||||
.to change { quote.reload.state }.from('accepted').to('revoked')
|
||||
.and enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(/Delete/, alice.id, hank.inbox_url)
|
||||
.and enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(
|
||||
match_json_values(
|
||||
type: 'Delete',
|
||||
id: %r{https://.*},
|
||||
object: include(
|
||||
type: 'QuoteAuthorization',
|
||||
id: %r{https://.*},
|
||||
attributedTo: ActivityPub::TagManager.instance.uri_for(alice),
|
||||
interactionTarget: ActivityPub::TagManager.instance.uri_for(status),
|
||||
interactingObject: ActivityPub::TagManager.instance.uri_for(quote.status)
|
||||
),
|
||||
actor: ActivityPub::TagManager.instance.uri_for(alice)
|
||||
),
|
||||
alice.id,
|
||||
hank.inbox_url
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user