kopia lustrzana https://github.com/cheeaun/phanpy
commit
3f4b1a6394
|
@ -13,4 +13,4 @@ jobs:
|
|||
with:
|
||||
node-version: 20
|
||||
- run: npm ci
|
||||
- run: npx biome check
|
||||
- run: npm run formatting-check
|
||||
|
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps webkit --only-shell
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test
|
||||
run: npm run test
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
|
|
|
@ -392,6 +392,7 @@ Costs involved in running and developing this web app:
|
|||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/16538605/medium/bcdb6e3286b7d6237923f3a9383eed29.png" alt="" width="16" height="16" /> SadmL (Russian)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/16539171/medium/0ce95ef6b3b0566136191fbedc1563d0.png" alt="" width="16" height="16" /> SadmL_AI (Russian)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/16121928/medium/b1dd34dc3e93b64b93b94aedca0c5b7d.jpg" alt="" width="16" height="16" /> Schishka71 (Russian)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/17206524/medium/1b0a8f9eafe7326be6968c6aed14c872.png" alt="" width="16" height="16" /> seizeheures (Esperanto)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/12381015/medium/35e3557fd61d85f9a5b84545d9e3feb4.png" alt="" width="16" height="16" /> shuuji3 (Japanese)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/14565190/medium/79100599131b7776e9803e4b696915a3_default.png" alt="" width="16" height="16" /> Sky_NiniKo (French)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/13143526/medium/30871da23d51d7e41bb02f3c92d7f104.png" alt="" width="16" height="16" /> Steffo99 (Italian)
|
||||
|
@ -400,6 +401,7 @@ Costs involved in running and developing this web app:
|
|||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/16530049/medium/683f3581620c6b4a5c753b416ed695a7.jpeg" alt="" width="16" height="16" /> tferrermo (Spanish)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/15752199/medium/7e9efd828c4691368d063b19d19eb894.png" alt="" width="16" height="16" /> tkbremnes (Norwegian Bokmal)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/16527851/medium/649e5a9a8a8cc61ced670d89e9cca082.png" alt="" width="16" height="16" /> tux93 (German)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/16236470/medium/315b1ebbd38e0f7e41d44bee752afa33.jpg" alt="" width="16" height="16" /> Usia (Ukrainian)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/16791511/medium/321c72613cd27efc3005e7c3bf383578.jpeg" alt="" width="16" height="16" /> uzaylul (Turkish)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/14427566/medium/ab733b5044c21867fc5a9d1b22cd2c03.png" alt="" width="16" height="16" /> Vac31. (Lithuanian)
|
||||
- <img src="https://crowdin-static.cf-downloads.crowdin.com/avatar/16026914/medium/4f2a96210b76cbc330584cfdd01fabc4_default.png" alt="" width="16" height="16" /> valtlai (Finnish)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
|
Plik diff jest za duży
Load Diff
41
package.json
41
package.json
|
@ -13,7 +13,11 @@
|
|||
"messages:extract:clean": "lingui extract --locale en --clean",
|
||||
"messages:compile": "lingui compile",
|
||||
"fetch-i18n-volunteers": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-i18n-volunteers.js",
|
||||
"readme:i18n-volunteers": "node scripts/update-i18n-volunteers-readme.js"
|
||||
"readme:i18n-volunteers": "node scripts/update-i18n-volunteers-readme.js",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed",
|
||||
"formatting-check": "npx biome check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "~0.6.1",
|
||||
|
@ -22,9 +26,9 @@
|
|||
"@github/text-expander-element": "~2.9.2",
|
||||
"@iconify-icons/mingcute": "~1.2.9",
|
||||
"@justinribeiro/lite-youtube": "~1.8.2",
|
||||
"@lingui/detect-locale": "~5.3.2",
|
||||
"@lingui/macro": "~5.3.2",
|
||||
"@lingui/react": "~5.3.2",
|
||||
"@lingui/detect-locale": "~5.3.3",
|
||||
"@lingui/macro": "~5.3.3",
|
||||
"@lingui/react": "~5.3.3",
|
||||
"@szhsin/react-menu": "~4.4.1",
|
||||
"chroma-js": "~3.1.2",
|
||||
"compare-versions": "~6.1.1",
|
||||
|
@ -37,11 +41,11 @@
|
|||
"js-cookie": "~3.0.5",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"lz-string": "~1.5.0",
|
||||
"masto": "~7.1.0",
|
||||
"masto": "~7.2.0",
|
||||
"moize": "~6.1.6",
|
||||
"p-retry": "~6.2.1",
|
||||
"p-throttle": "~7.0.0",
|
||||
"preact": "10.26.8",
|
||||
"preact": "10.26.9",
|
||||
"punycode": "~2.3.1",
|
||||
"react-hotkeys-hook": "~5.1.0",
|
||||
"react-intersection-observer": "~9.16.0",
|
||||
|
@ -49,6 +53,7 @@
|
|||
"react-router-dom": "6.6.2",
|
||||
"string-length": "6.0.0",
|
||||
"swiped-events": "~1.2.0",
|
||||
"temml": "~0.11.9",
|
||||
"tinyld": "~1.3.4",
|
||||
"toastify-js": "~1.12.0",
|
||||
"uid": "~2.0.2",
|
||||
|
@ -58,22 +63,22 @@
|
|||
"valtio": "2.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.0",
|
||||
"@lingui/babel-plugin-lingui-macro": "~5.3.2",
|
||||
"@lingui/cli": "~5.3.2",
|
||||
"@lingui/vite-plugin": "~5.3.2",
|
||||
"@playwright/test": "~1.53.0",
|
||||
"@preact/preset-vite": "~2.10.1",
|
||||
"@types/node": "~24.0.2",
|
||||
"postcss": "~8.5.5",
|
||||
"@biomejs/biome": "2.1.2",
|
||||
"@lingui/babel-plugin-lingui-macro": "~5.3.3",
|
||||
"@lingui/cli": "~5.3.3",
|
||||
"@lingui/vite-plugin": "~5.3.3",
|
||||
"@playwright/test": "~1.54.1",
|
||||
"@preact/preset-vite": "~2.10.2",
|
||||
"@types/node": "~24.0.14",
|
||||
"postcss": "~8.5.6",
|
||||
"postcss-dark-theme-class": "~1.3.0",
|
||||
"postcss-preset-env": "~10.2.3",
|
||||
"sonda": "~0.8.0",
|
||||
"postcss-preset-env": "~10.2.4",
|
||||
"sonda": "~0.9.0",
|
||||
"twitter-text": "~3.1.0",
|
||||
"vite": "~6.3.5",
|
||||
"vite": "~7.0.5",
|
||||
"vite-plugin-generate-file": "~0.3.1",
|
||||
"vite-plugin-html-config": "~2.0.2",
|
||||
"vite-plugin-pwa": "~1.0.0",
|
||||
"vite-plugin-pwa": "~1.0.1",
|
||||
"vite-plugin-remove-console": "~2.2.0",
|
||||
"vite-plugin-run": "~0.6.1",
|
||||
"workbox-cacheable-response": "~7.3.0",
|
||||
|
|
12
src/app.css
12
src/app.css
|
@ -448,7 +448,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
var(--line-radius), var(--line-radius); */
|
||||
--curves-radius: calc(var(--curves-width) / 2);
|
||||
height: calc(var(--curves-width) - var(--line-width));
|
||||
background-image: radial-gradient(
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at bottom var(--forward),
|
||||
transparent calc(var(--curves-radius) - var(--line-width)),
|
||||
var(--comment-line-color) calc(var(--curves-radius) - var(--line-width))
|
||||
|
@ -479,7 +480,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
transparent
|
||||
);
|
||||
&.hero:not(:has(+ .thread), :first-child, :only-child, :last-child) {
|
||||
background-image: linear-gradient(
|
||||
background-image:
|
||||
linear-gradient(
|
||||
var(--line-dir),
|
||||
transparent,
|
||||
transparent var(--indent-small-start),
|
||||
|
@ -1083,7 +1085,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(
|
||||
background-image:
|
||||
radial-gradient(
|
||||
ellipse 50% 32px at bottom center,
|
||||
var(--carousel-faded-color),
|
||||
transparent
|
||||
|
@ -2358,7 +2361,8 @@ body > .szh-menu-container {
|
|||
var(--bg-color) var(--middle-circle-radius),
|
||||
transparent var(--middle-circle-radius)
|
||||
);
|
||||
background-image: var(--middle-circle),
|
||||
background-image:
|
||||
var(--middle-circle),
|
||||
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
|
||||
transform: scale(0.7);
|
||||
&:dir(rtl) {
|
||||
|
|
|
@ -183,4 +183,5 @@ export const ICONS = {
|
|||
module: () => import('@iconify-icons/mingcute/user-star-line'),
|
||||
rtl: true,
|
||||
},
|
||||
formula: () => import('@iconify-icons/mingcute/formula-line'),
|
||||
};
|
||||
|
|
|
@ -845,11 +845,9 @@
|
|||
overflow: hidden;
|
||||
border: 1px solid var(--outline-color);
|
||||
/* checkerboard background */
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
var(--img-bg-color) 25%,
|
||||
transparent 25%
|
||||
), linear-gradient(-45deg, var(--img-bg-color) 25%, transparent 25%),
|
||||
background-image:
|
||||
linear-gradient(45deg, var(--img-bg-color) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--img-bg-color) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--img-bg-color) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%);
|
||||
background-size: 10px 10px;
|
||||
|
|
|
@ -417,11 +417,9 @@
|
|||
width: 80px;
|
||||
height: 80px;
|
||||
/* checkerboard background */
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
var(--img-bg-color) 25%,
|
||||
transparent 25%
|
||||
), linear-gradient(-45deg, var(--img-bg-color) 25%, transparent 25%),
|
||||
background-image:
|
||||
linear-gradient(45deg, var(--img-bg-color) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--img-bg-color) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--img-bg-color) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%);
|
||||
background-size: 10px 10px;
|
||||
|
@ -687,11 +685,9 @@
|
|||
overflow: hidden;
|
||||
box-shadow: 0 2px 16px var(--img-bg-color);
|
||||
/* checkerboard background */
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
var(--img-bg-color) 25%,
|
||||
transparent 25%
|
||||
), linear-gradient(-45deg, var(--img-bg-color) 25%, transparent 25%),
|
||||
background-image:
|
||||
linear-gradient(45deg, var(--img-bg-color) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--img-bg-color) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--img-bg-color) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%);
|
||||
background-size: 20px 20px;
|
||||
|
|
|
@ -2324,7 +2324,11 @@ const Textarea = forwardRef((props, ref) => {
|
|||
// Get line before cursor position after pressing 'Enter'
|
||||
const { key, target } = e;
|
||||
const hasTextExpander = hasTextExpanderRef.current;
|
||||
if (key === 'Enter' && !(e.ctrlKey || e.metaKey || hasTextExpander)) {
|
||||
if (
|
||||
key === 'Enter' &&
|
||||
!(e.ctrlKey || e.metaKey || hasTextExpander) &&
|
||||
!e.isComposing
|
||||
) {
|
||||
try {
|
||||
const { value, selectionStart } = target;
|
||||
const textBeforeCursor = value.slice(0, selectionStart);
|
||||
|
|
|
@ -289,11 +289,21 @@ function Media({
|
|||
onClick(e);
|
||||
} else {
|
||||
e.preventDefault();
|
||||
el.style.viewTransitionName = mediaVTN;
|
||||
document.startViewTransition(() => {
|
||||
el.style.viewTransitionName = '';
|
||||
if (el.dataset.viewTransitioned) {
|
||||
el.style.viewTransitionName = mediaVTN;
|
||||
try {
|
||||
document.startViewTransition(() => {
|
||||
el.style.viewTransitionName = '';
|
||||
location.hash = `#${to}`;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
el.style.viewTransitionName = '';
|
||||
location.hash = `#${to}`;
|
||||
}
|
||||
} else {
|
||||
location.hash = `#${to}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onClick?.(e);
|
||||
|
|
|
@ -51,7 +51,8 @@
|
|||
}
|
||||
}
|
||||
.nav-menu section:last-child {
|
||||
background-image: linear-gradient(
|
||||
background-image:
|
||||
linear-gradient(
|
||||
var(--to-forward),
|
||||
var(--divider-color) 1px,
|
||||
transparent 1px
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Fragment } from 'preact';
|
|||
import { useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import localeMatch from '../utils/locale-match';
|
||||
import showToast from '../utils/show-toast';
|
||||
import { getCurrentInstance } from '../utils/store-utils';
|
||||
|
||||
|
@ -17,7 +18,7 @@ import Status from './status';
|
|||
// NOTE: `dislike` hidden for now, it's actually not used for reporting
|
||||
// Mastodon shows another screen for unfollowing, muting or blocking instead of reporting
|
||||
|
||||
const CATEGORIES = [/*'dislike'*/ , 'spam', 'legal', 'violation', 'other'];
|
||||
const CATEGORIES = [/*'dislike' ,*/ 'spam', 'legal', 'violation', 'other'];
|
||||
// `violation` will be set if there are `rule_ids[]`
|
||||
|
||||
const CATEGORIES_INFO = {
|
||||
|
@ -45,16 +46,58 @@ const CATEGORIES_INFO = {
|
|||
},
|
||||
};
|
||||
|
||||
function findMatchingLanguage(rule, currentLang) {
|
||||
if (!rule.translations || !currentLang) return null;
|
||||
const availableLanguages = Object.keys(rule.translations);
|
||||
if (!availableLanguages?.length) return null;
|
||||
|
||||
let matchedLang = localeMatch([currentLang], availableLanguages, null);
|
||||
if (!matchedLang) {
|
||||
// localeMatch fails if there are keys like zhCn, zhTw
|
||||
// Convert them something like zh-CN first, try again
|
||||
// Detect uppercase, then split by dash
|
||||
const normalizedLanguages = availableLanguages.map((lang) => {
|
||||
const parts = lang.split(/(?=[A-Z])/);
|
||||
return parts
|
||||
.map((part, i) => (i === 0 ? part : part.toLowerCase()))
|
||||
.join('-');
|
||||
});
|
||||
matchedLang = localeMatch([currentLang], normalizedLanguages, null);
|
||||
}
|
||||
|
||||
// If matchedLang has dash, convert back to original format
|
||||
// E.g. zh-cn to zhCn
|
||||
if (matchedLang && matchedLang.includes('-')) {
|
||||
const [lang, region] = matchedLang.split('-');
|
||||
matchedLang = lang + region.charAt(0).toUpperCase() + region.slice(1);
|
||||
}
|
||||
|
||||
return matchedLang;
|
||||
}
|
||||
|
||||
function translateRules(rules, currentLang) {
|
||||
if (!rules?.length) return [];
|
||||
if (!currentLang) return rules;
|
||||
return rules.map((rule) => {
|
||||
const matchedLang = findMatchingLanguage(rule, currentLang);
|
||||
return {
|
||||
...rule,
|
||||
_translatedText: rule.translations?.[matchedLang]?.text || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function ReportModal({ account, post, onClose }) {
|
||||
const { _, t } = useLingui();
|
||||
const { _, t, i18n } = useLingui();
|
||||
const { masto } = api();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [username, domain] = account.acct.split('@');
|
||||
|
||||
const [rules, currentDomain] = useMemo(() => {
|
||||
const [translatedRules, currentDomain] = useMemo(() => {
|
||||
const { rules, domain } = getCurrentInstance();
|
||||
return [rules || [], domain];
|
||||
});
|
||||
const rawRules = rules || [];
|
||||
return [translateRules(rawRules, i18n.locale), domain];
|
||||
}, [i18n.locale]);
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
|
@ -165,7 +208,7 @@ function ReportModal({ account, post, onClose }) {
|
|||
</p>
|
||||
<section class="report-categories">
|
||||
{CATEGORIES.map((category) =>
|
||||
category === 'violation' && !rules?.length ? null : (
|
||||
category === 'violation' && !translatedRules?.length ? null : (
|
||||
<Fragment key={category}>
|
||||
<label class="report-category">
|
||||
<input
|
||||
|
@ -186,14 +229,14 @@ function ReportModal({ account, post, onClose }) {
|
|||
</small>
|
||||
</span>
|
||||
</label>
|
||||
{category === 'violation' && !!rules?.length && (
|
||||
{category === 'violation' && !!translatedRules?.length && (
|
||||
<div
|
||||
class="shazam-container no-animation"
|
||||
hidden={!showRules}
|
||||
>
|
||||
<div class="shazam-container-inner">
|
||||
<div class="report-rules" ref={rulesRef}>
|
||||
{rules.map((rule, i) => (
|
||||
{translatedRules.map((rule, i) => (
|
||||
<label class="report-rule" key={rule.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
@ -216,7 +259,7 @@ function ReportModal({ account, post, onClose }) {
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<span>{rule.text}</span>
|
||||
<span>{rule._translatedText || rule.text}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -280,6 +280,12 @@
|
|||
.icon {
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
|
||||
&.status-card-ghost {
|
||||
color: var(--text-insignificant-color);
|
||||
border: var(--hairline-width) dashed var(--text-insignificant-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton-breathe {
|
||||
|
@ -967,6 +973,11 @@
|
|||
> :is(div, blockquote) > :is(ul, ol) li > :is(ul, ol) {
|
||||
padding-inline-start: 1.5em;
|
||||
}
|
||||
|
||||
/* Hide inline quote (RE: [LINK]) when there's a native quote */
|
||||
&:has(~ .quote-post-native) .quote-inline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.status .content ul {
|
||||
list-style-type: disc;
|
||||
|
@ -1407,11 +1418,8 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: var(--min-dimension);
|
||||
background-image: radial-gradient(
|
||||
circle at center center,
|
||||
transparent,
|
||||
var(--bg-faded-color)
|
||||
),
|
||||
background-image:
|
||||
radial-gradient(circle at center center, transparent, var(--bg-faded-color)),
|
||||
repeating-radial-gradient(
|
||||
circle at center center,
|
||||
transparent,
|
||||
|
@ -2933,3 +2941,51 @@ a.card:is(:hover, :focus):visited {
|
|||
transition-duration: 0.1s;
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
/* MATH */
|
||||
|
||||
.math-block {
|
||||
color: var(--text-insignificant-color);
|
||||
margin: 8px 0;
|
||||
border-top: 1px dashed var(--outline-color);
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 90%;
|
||||
}
|
||||
.status .inner-content math[display='block'] {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
var(--divider-color) var(--hairline-width),
|
||||
transparent var(--hairline-width)
|
||||
),
|
||||
linear-gradient(
|
||||
to right,
|
||||
var(--divider-color) var(--hairline-width),
|
||||
transparent var(--hairline-width)
|
||||
);
|
||||
background-size: var(--text-size) var(--text-size);
|
||||
overflow: auto;
|
||||
|
||||
--padding: 8px;
|
||||
background-position: center var(--padding);
|
||||
&:has(> mrow) {
|
||||
/* 0.5ex is for mrow from Temml-Local.css */
|
||||
background-position-y: calc(var(--padding) + 0.5ex);
|
||||
}
|
||||
padding: var(--padding);
|
||||
/* mask the padding with gradients */
|
||||
mask-image:
|
||||
linear-gradient(to top, transparent, black var(--padding)),
|
||||
linear-gradient(to bottom, transparent, black var(--padding)),
|
||||
linear-gradient(to left, transparent, black var(--padding)),
|
||||
linear-gradient(to right, transparent, black var(--padding));
|
||||
mask-composite: intersect;
|
||||
|
||||
animation: appear-smooth 0.3s ease-out;
|
||||
*:nth-child(even):not(:has(*)) {
|
||||
/* sprinkle a bit more gradual magical feel */
|
||||
animation: appear 1s ease-out;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import './status.css';
|
||||
import 'temml/dist/Temml-Local.css';
|
||||
|
||||
import '@justinribeiro/lite-youtube';
|
||||
|
||||
import { msg, plural } from '@lingui/core/macro';
|
||||
|
@ -22,6 +24,7 @@ import {
|
|||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
|
@ -222,6 +225,161 @@ function getHTMLTextForDetectLang(content, emojis) {
|
|||
}
|
||||
|
||||
const HTTP_REGEX = /^http/i;
|
||||
|
||||
// Follow https://mathstodon.xyz/about
|
||||
// > You can use LaTeX in toots here! Use \( and \) for inline, and \[ and \] for display mode.
|
||||
const DELIMITERS_PATTERNS = [
|
||||
// '\\$\\$[\\s\\S]*?\\$\\$', // $$...$$
|
||||
'\\\\\\[[\\s\\S]*?\\\\\\]', // \[...\]
|
||||
'\\\\\\([\\s\\S]*?\\\\\\)', // \(...\)
|
||||
// '\\\\begin\\{(?:equation\\*?|align\\*?|alignat\\*?|gather\\*?|CD)\\}[\\s\\S]*?\\\\end\\{(?:equation\\*?|align\\*?|alignat\\*?|gather\\*?|CD)\\}', // AMS environments
|
||||
// '\\\\(?:ref|eqref)\\{[^}]*\\}', // \ref{...}, \eqref{...}
|
||||
];
|
||||
const DELIMITERS_REGEX = new RegExp(DELIMITERS_PATTERNS.join('|'), 'g');
|
||||
|
||||
function cleanDOMForTemml(dom) {
|
||||
// Define start and end delimiter patterns
|
||||
const START_DELIMITERS = ['\\\\\\[', '\\\\\\(']; // \[ and \(
|
||||
const startRegex = new RegExp(`(${START_DELIMITERS.join('|')})`);
|
||||
|
||||
// Walk through all text nodes
|
||||
const walker = document.createTreeWalker(dom, NodeFilter.SHOW_TEXT);
|
||||
const textNodes = [];
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
const text = textNode.textContent;
|
||||
const startMatch = text.match(startRegex);
|
||||
|
||||
if (!startMatch) continue; // No start delimiter in this text node
|
||||
|
||||
// Find the matching end delimiter
|
||||
const startDelimiter = startMatch[0];
|
||||
const endDelimiter = startDelimiter === '\\[' ? '\\]' : '\\)';
|
||||
|
||||
// Collect nodes from start delimiter until end delimiter
|
||||
const nodesToCombine = [textNode];
|
||||
let currentNode = textNode;
|
||||
let foundEnd = false;
|
||||
let combinedText = text;
|
||||
|
||||
// Check if end delimiter is in the same text node
|
||||
if (text.includes(endDelimiter)) {
|
||||
foundEnd = true;
|
||||
} else {
|
||||
// Look through sibling nodes
|
||||
while (currentNode.nextSibling && !foundEnd) {
|
||||
const nextSibling = currentNode.nextSibling;
|
||||
|
||||
if (nextSibling.nodeType === Node.TEXT_NODE) {
|
||||
nodesToCombine.push(nextSibling);
|
||||
combinedText += nextSibling.textContent;
|
||||
if (nextSibling.textContent.includes(endDelimiter)) {
|
||||
foundEnd = true;
|
||||
}
|
||||
} else if (
|
||||
nextSibling.nodeType === Node.ELEMENT_NODE &&
|
||||
nextSibling.tagName === 'BR'
|
||||
) {
|
||||
nodesToCombine.push(nextSibling);
|
||||
combinedText += '\n';
|
||||
} else {
|
||||
// Found a non-BR element, stop and don't process
|
||||
break;
|
||||
}
|
||||
|
||||
currentNode = nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
// Only process if we found the end delimiter and have nodes to combine
|
||||
if (foundEnd && nodesToCombine.length > 1) {
|
||||
// Replace the first text node with combined text
|
||||
textNode.textContent = combinedText;
|
||||
|
||||
// Remove the other nodes
|
||||
for (let i = 1; i < nodesToCombine.length; i++) {
|
||||
nodesToCombine[i].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MathBlock = ({ content, contentRef, onRevert }) => {
|
||||
DELIMITERS_REGEX.lastIndex = 0; // Reset index to prevent g trap
|
||||
const hasLatexContent = DELIMITERS_REGEX.test(content);
|
||||
|
||||
if (!hasLatexContent) return null;
|
||||
|
||||
const { t } = useLingui();
|
||||
const [mathRendered, setMathRendered] = useState(false);
|
||||
const toggleMathRendering = useCallback(
|
||||
async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (mathRendered) {
|
||||
// Revert to original content by refreshing PostContent
|
||||
setMathRendered(false);
|
||||
onRevert();
|
||||
} else {
|
||||
// Render math
|
||||
try {
|
||||
// This needs global because the codebase inside temml is calling a function from global.temml 🤦♂️
|
||||
const temml =
|
||||
window.temml || (window.temml = (await import('temml'))?.default);
|
||||
|
||||
cleanDOMForTemml(contentRef.current);
|
||||
const originalContentRefHTML = contentRef.current.innerHTML;
|
||||
temml.renderMathInElement(contentRef.current, {
|
||||
fences: '(', // This should sync with DELIMITERS_REGEX
|
||||
annotate: true,
|
||||
throwOnError: true,
|
||||
errorCallback: (err) => {
|
||||
console.warn('Failed to render LaTeX:', err);
|
||||
},
|
||||
});
|
||||
|
||||
const hasMath = contentRef.current.querySelector('math.tml-display');
|
||||
const htmlChanged =
|
||||
contentRef.current.innerHTML !== originalContentRefHTML;
|
||||
if (hasMath && htmlChanged) {
|
||||
setMathRendered(true);
|
||||
} else {
|
||||
showToast(t`Unable to format math`);
|
||||
setMathRendered(false);
|
||||
onRevert(); // Revert because DOM modified by cleanDOMForTemml
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to LaTeX:', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
[mathRendered],
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="math-block">
|
||||
<Icon icon="formula" size="s" /> <span>{t`Math expressions found.`}</span>{' '}
|
||||
<button type="button" class="light small" onClick={toggleMathRendering}>
|
||||
{mathRendered
|
||||
? t({
|
||||
comment:
|
||||
'Action to switch from rendered math back to raw (LaTeX) markup',
|
||||
message: 'Show markup',
|
||||
})
|
||||
: t({
|
||||
comment:
|
||||
'Action to render math expressions from raw (LaTeX) markup',
|
||||
message: 'Format math',
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PostContent =
|
||||
/*memo(*/
|
||||
({ post, instance, previewMode }) => {
|
||||
|
@ -709,6 +867,10 @@ function Status({
|
|||
const mediaContainerRef = useTruncated();
|
||||
|
||||
const statusRef = useRef(null);
|
||||
const [reloadPostContentCount, reloadPostContent] = useReducer(
|
||||
(c) => c + 1,
|
||||
0,
|
||||
);
|
||||
|
||||
const unauthInteractionErrorMessage = t`Sorry, your current logged-in instance can't interact with this post from another instance.`;
|
||||
|
||||
|
@ -2201,6 +2363,7 @@ function Status({
|
|||
inert={!!spoilerText && !showSpoiler ? true : undefined}
|
||||
>
|
||||
<PostContent
|
||||
key={reloadPostContentCount}
|
||||
post={status}
|
||||
instance={instance}
|
||||
previewMode={previewMode}
|
||||
|
@ -2208,6 +2371,13 @@ function Status({
|
|||
<QuoteStatuses id={id} instance={instance} level={quoted} />
|
||||
</div>
|
||||
)}
|
||||
{!!content && (
|
||||
<MathBlock
|
||||
content={content}
|
||||
contentRef={contentRef}
|
||||
onRevert={reloadPostContent}
|
||||
/>
|
||||
)}
|
||||
{!!poll && (
|
||||
<Poll
|
||||
lang={language}
|
||||
|
@ -3875,6 +4045,22 @@ function FilteredStatus({
|
|||
);
|
||||
}
|
||||
|
||||
const handledUnfulfilledStates = [
|
||||
'deleted',
|
||||
'unauthorized',
|
||||
'pending',
|
||||
'rejected',
|
||||
'revoked',
|
||||
];
|
||||
const unfulfilledText = {
|
||||
filterHidden: msg`Post hidden by your filters`,
|
||||
deleted: msg`Post removed by author.`,
|
||||
unauthorized: msg`You’re not authorized to view this post.`,
|
||||
pending: msg`Post pending author approval.`,
|
||||
rejected: msg`Quoting not allowed by the author.`,
|
||||
revoked: msg`Quoting not allowed by the author.`,
|
||||
};
|
||||
|
||||
const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
|
||||
if (!id || !instance) return;
|
||||
const { _ } = useLingui();
|
||||
|
@ -3888,43 +4074,43 @@ const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
|
|||
if (!uniqueQuotes?.length) return;
|
||||
if (level > 2) return;
|
||||
|
||||
const filterContext = useContext(FilterContext);
|
||||
const currentAccount = getCurrentAccID();
|
||||
|
||||
return uniqueQuotes.map((q) => {
|
||||
if (q.state === 'deleted')
|
||||
let unfulfilledState;
|
||||
|
||||
const quoteStatus = snapStates.statuses[statusKey(q.id, q.instance)];
|
||||
if (quoteStatus) {
|
||||
const isSelf =
|
||||
currentAccount && currentAccount === quoteStatus.account?.id;
|
||||
const filterInfo =
|
||||
!isSelf && isFiltered(quoteStatus.filtered, filterContext);
|
||||
|
||||
if (filterInfo?.action === 'hide') {
|
||||
unfulfilledState = 'filterHidden';
|
||||
}
|
||||
}
|
||||
|
||||
if (!unfulfilledState) {
|
||||
unfulfilledState = handledUnfulfilledStates.find(
|
||||
(state) => q.state === state,
|
||||
);
|
||||
}
|
||||
|
||||
if (unfulfilledState) {
|
||||
return (
|
||||
<div class="status-card-unfulfilled">
|
||||
<div
|
||||
class={`status-card-unfulfilled ${
|
||||
unfulfilledState === 'filterHidden' ? 'status-card-ghost' : ''
|
||||
}`}
|
||||
>
|
||||
<Icon icon="quote" />
|
||||
<i>
|
||||
<Trans>Post removed by author.</Trans>
|
||||
</i>
|
||||
</div>
|
||||
);
|
||||
if (q.state === 'unauthorized')
|
||||
return (
|
||||
<div class="status-card-unfulfilled">
|
||||
<Icon icon="quote" />
|
||||
<i>
|
||||
<Trans>You’re not authorized to view this post.</Trans>
|
||||
</i>
|
||||
</div>
|
||||
);
|
||||
if (q.state === 'pending')
|
||||
return (
|
||||
<div class="status-card-unfulfilled">
|
||||
<Icon icon="quote" />
|
||||
<i>
|
||||
<Trans>Post pending author approval.</Trans>
|
||||
</i>
|
||||
</div>
|
||||
);
|
||||
if (q.state === 'rejected' || q.state === 'revoked')
|
||||
return (
|
||||
<div class="status-card-unfulfilled">
|
||||
<Icon icon="quote" />
|
||||
<i>
|
||||
<Trans>Quoting not allowed by the author.</Trans>
|
||||
</i>
|
||||
<i>{_(unfulfilledText[unfulfilledState])}</i>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Parent = q.native ? Fragment : LazyShazam;
|
||||
return (
|
||||
<Parent id={q.instance + q.id} key={q.instance + q.id}>
|
||||
|
|
|
@ -60,7 +60,9 @@ function _translangTranslate(text, source, target) {
|
|||
} else {
|
||||
// GET
|
||||
fetchPromise = fetch(
|
||||
`https://${instance}/api/v1/translate?sl=${encodeURIComponent(source)}&tl=${encodeURIComponent(target)}&text=${encodeURIComponent(text)}`,
|
||||
`https://${instance}/api/v1/translate?sl=${encodeURIComponent(
|
||||
source,
|
||||
)}&tl=${encodeURIComponent(target)}&text=${encodeURIComponent(text)}`,
|
||||
{
|
||||
priority: 'low',
|
||||
referrerPolicy: 'no-referrer',
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
"code": "ca-ES",
|
||||
"nativeName": "català",
|
||||
"name": "Catalan",
|
||||
"completion": 100,
|
||||
"completion": 101,
|
||||
"listed": true
|
||||
},
|
||||
{
|
||||
|
@ -38,14 +38,14 @@
|
|||
"code": "es-ES",
|
||||
"nativeName": "español",
|
||||
"name": "Spanish",
|
||||
"completion": 100,
|
||||
"completion": 101,
|
||||
"listed": true
|
||||
},
|
||||
{
|
||||
"code": "eu-ES",
|
||||
"nativeName": "euskara",
|
||||
"name": "Basque",
|
||||
"completion": 99,
|
||||
"completion": 100,
|
||||
"listed": true
|
||||
},
|
||||
{
|
||||
|
@ -73,21 +73,21 @@
|
|||
"code": "gl-ES",
|
||||
"nativeName": "galego",
|
||||
"name": "Galician",
|
||||
"completion": 100,
|
||||
"completion": 101,
|
||||
"listed": true
|
||||
},
|
||||
{
|
||||
"code": "he-IL",
|
||||
"nativeName": "עברית",
|
||||
"name": "Hebrew",
|
||||
"completion": 9,
|
||||
"completion": 23,
|
||||
"listed": false
|
||||
},
|
||||
{
|
||||
"code": "it-IT",
|
||||
"nativeName": "italiano",
|
||||
"name": "Italian",
|
||||
"completion": 100,
|
||||
"completion": 101,
|
||||
"listed": true
|
||||
},
|
||||
{
|
||||
|
@ -136,21 +136,21 @@
|
|||
"code": "pl-PL",
|
||||
"nativeName": "polski",
|
||||
"name": "Polish",
|
||||
"completion": 100,
|
||||
"completion": 101,
|
||||
"listed": true
|
||||
},
|
||||
{
|
||||
"code": "pt-BR",
|
||||
"nativeName": "português",
|
||||
"name": "Portuguese",
|
||||
"completion": 100,
|
||||
"completion": 101,
|
||||
"listed": true
|
||||
},
|
||||
{
|
||||
"code": "pt-PT",
|
||||
"nativeName": "português",
|
||||
"name": "Portuguese",
|
||||
"completion": 100,
|
||||
"completion": 101,
|
||||
"listed": true
|
||||
},
|
||||
{
|
||||
|
@ -178,7 +178,7 @@
|
|||
"code": "uk-UA",
|
||||
"nativeName": "українська",
|
||||
"name": "Ukrainian",
|
||||
"completion": 90,
|
||||
"completion": 94,
|
||||
"listed": true
|
||||
},
|
||||
{
|
||||
|
|
|
@ -22,8 +22,9 @@
|
|||
--main-width: 40em;
|
||||
text-size-adjust: none;
|
||||
--hairline-width: 1px;
|
||||
--monospace-font: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono',
|
||||
Menlo, Courier, monospace;
|
||||
--monospace-font:
|
||||
ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier,
|
||||
monospace;
|
||||
|
||||
--blue-color: royalblue;
|
||||
--purple-color: blueviolet;
|
||||
|
|
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
|
@ -45,7 +45,8 @@
|
|||
transform: translateY(-10vh);
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
background-image: radial-gradient(
|
||||
background-image:
|
||||
radial-gradient(
|
||||
farthest-corner at 25% 0,
|
||||
transparent 80%,
|
||||
var(--bg-faded-color) 95%,
|
||||
|
|
|
@ -93,7 +93,11 @@ function Login() {
|
|||
setUIState('loading');
|
||||
try {
|
||||
let credentialApplication = getCredentialApplication(instanceURL);
|
||||
if (!credentialApplication) {
|
||||
if (
|
||||
!credentialApplication ||
|
||||
!credentialApplication.client_id ||
|
||||
!credentialApplication.client_secret
|
||||
) {
|
||||
credentialApplication = await registerApplication({
|
||||
instanceURL,
|
||||
});
|
||||
|
|
|
@ -35,10 +35,9 @@
|
|||
padding: 16px;
|
||||
background-color: var(--bg-color);
|
||||
/* checkered background */
|
||||
background-image: linear-gradient(
|
||||
var(--bg-faded-color) 2px,
|
||||
transparent 2px
|
||||
), linear-gradient(90deg, var(--bg-faded-color) 2px, transparent 2px),
|
||||
background-image:
|
||||
linear-gradient(var(--bg-faded-color) 2px, transparent 2px),
|
||||
linear-gradient(90deg, var(--bg-faded-color) 2px, transparent 2px),
|
||||
linear-gradient(var(--bg-faded-color) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--bg-faded-color) 1px, transparent 1px);
|
||||
background-size:
|
||||
|
|
|
@ -45,18 +45,20 @@ const MOCK_STATUS = ({ toggles = {} } = {}) => {
|
|||
showCard,
|
||||
size,
|
||||
filters,
|
||||
quoteFilters,
|
||||
userPreferences,
|
||||
} = toggles;
|
||||
|
||||
const shortContent = 'This is a test status with short text content.';
|
||||
const longContent = `<p>This is a test status with long text content. It contains multiple paragraphs and spans several lines to demonstrate how longer content appears.</p>
|
||||
|
||||
|
||||
<p>Second paragraph goes here with more sample text. The Status component will render this appropriately based on the current size setting.</p>
|
||||
|
||||
|
||||
<p>Third paragraph adds even more content to ensure we have a properly long post that might get truncated depending on the view settings.</p>`;
|
||||
const linksContent = `<p>This is a test status with links. Check out <a href="https://example.com">this website</a> and <a href="https://google.com">Google</a>. Links should be clickable and properly styled.</p>`;
|
||||
const hashtagsContent = `<p>This is a test status with hashtags. <a href="https://example.social/tags/coding" class="hashtag" rel="tag">#coding</a> <a href="https://example.social/tags/webdev" class="hashtag" rel="tag">#webdev</a> <a href="https://example.social/tags/javascript" class="hashtag" rel="tag">#javascript</a> <a href="https://example.social/tags/reactjs" class="hashtag" rel="tag">#reactjs</a> <a href="https://example.social/tags/preact" class="hashtag" rel="tag">#preact</a></p><p>Hashtags should be formatted and clickable.</p>`;
|
||||
const mentionsContent = `<p>This is a test status with mentions. Hello <a href="https://example.social/@cheeaun" class="u-url mention">@cheeaun</a> and <a href="https://example.social/@test" class="u-url mention">@test</a>! What do you think about this <a href="https://example.social/@another_user" class="u-url mention">@another_user</a>?</p><p>Mentions should be highlighted and clickable.</p>`;
|
||||
const mathContent = `<p>This is a test status with mathematical expressions. Here's an inline formula \\( E = mc^2 \\) and a display formula:</p><p>\\[ \\frac{\\left(n!\\right)^2}{2}\\sum _{k=0}^m\\frac{1}{n-k}{n-k \\choose k}^2 \\]</p><p>The MathBlock component should detect and offer to render these LaTeX expressions.</p>`;
|
||||
|
||||
const base = {
|
||||
// Random ID to un-memoize Status
|
||||
|
@ -79,7 +81,9 @@ const MOCK_STATUS = ({ toggles = {} } = {}) => {
|
|||
? hashtagsContent
|
||||
: contentType === 'mentions'
|
||||
? mentionsContent
|
||||
: shortContent
|
||||
: contentType === 'math'
|
||||
? mathContent
|
||||
: shortContent
|
||||
: '',
|
||||
visibility: toggles.visibility || 'public',
|
||||
createdAt: new Date().toISOString(),
|
||||
|
@ -310,8 +314,10 @@ const INITIAL_STATE = {
|
|||
showQuotes: false,
|
||||
quotesCount: '1',
|
||||
quoteNestingLevel: '0',
|
||||
quoteState: 'accepted', // State for all quote posts
|
||||
size: 'medium',
|
||||
filters: [false, false, false], // hide, blur, warn
|
||||
quoteFilters: [false, false, false], // hide, blur, warn for quotes
|
||||
mediaPreference: 'default',
|
||||
expandWarnings: false,
|
||||
contextType: 'none', // Default context type
|
||||
|
@ -396,8 +402,10 @@ export default function Sandbox() {
|
|||
showQuotes: toggleState.showQuotes,
|
||||
quotesCount: toggleState.quotesCount,
|
||||
quoteNestingLevel: toggleState.quoteNestingLevel,
|
||||
quoteState: toggleState.quoteState,
|
||||
size: toggleState.size,
|
||||
filters: toggleState.filters,
|
||||
quoteFilters: toggleState.quoteFilters,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -525,136 +533,173 @@ export default function Sandbox() {
|
|||
id: quoteId,
|
||||
instance: DEFAULT_INSTANCE,
|
||||
url: `https://example.social/s/${quoteId}`, // Include URL to ensure uniqueness check works
|
||||
state:
|
||||
toggleState.quoteState === 'accepted'
|
||||
? undefined
|
||||
: toggleState.quoteState, // Only set state if not accepted
|
||||
};
|
||||
|
||||
// First, delete any existing status with this ID to avoid duplicates
|
||||
delete states.statuses[quoteId];
|
||||
const quoteStatusKey = statusKey(quoteId, DEFAULT_INSTANCE);
|
||||
delete states.statuses[quoteStatusKey];
|
||||
|
||||
// Create the actual status object that will be retrieved by QuoteStatuses
|
||||
states.statuses[quoteId] = {
|
||||
id: quoteId,
|
||||
content: `<p>This is quote post ${i + 1}${i % 2 === 0 ? '' : ' with some extra text'}</p>`,
|
||||
account: {
|
||||
id: `quote-account-${i}`,
|
||||
username: `quote${i}`,
|
||||
name: `Quote User ${i}`,
|
||||
avatar: '/logo-192.png',
|
||||
acct: `quote${i}@example.social`,
|
||||
url: `https://example.social/@quote${i}`,
|
||||
},
|
||||
visibility: 'public',
|
||||
createdAt: new Date(Date.now() - i * 3600000).toISOString(), // Each post 1 hour older
|
||||
emojis: [],
|
||||
// First quote post should be plain (no media, no poll)
|
||||
mediaAttachments:
|
||||
i > 0 && i % 2 === 0
|
||||
? [
|
||||
{
|
||||
// Only non-first posts can have media (every 3rd post after the 1st)
|
||||
id: `quote-media-${i}`,
|
||||
type: 'image',
|
||||
url: `https://picsum.photos/seed/quote-${i}/600/400`,
|
||||
previewUrl: `https://picsum.photos/seed/quote-${i}/300/200`,
|
||||
meta: {
|
||||
original: { width: 600, height: 400 },
|
||||
small: { width: 300, height: 200 },
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
poll:
|
||||
i > 0 && i % 3 === 0
|
||||
? {
|
||||
// Only non-first posts can have polls (every 4th post after the 1st)
|
||||
id: `quote-poll-${i}`,
|
||||
options: [
|
||||
{
|
||||
title: 'Option A',
|
||||
votesCount: Math.floor(Math.random() * 50),
|
||||
},
|
||||
{
|
||||
title: 'Option B',
|
||||
votesCount: Math.floor(Math.random() * 50),
|
||||
},
|
||||
],
|
||||
expiresAt: new Date(
|
||||
Date.now() + 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
multiple: false,
|
||||
votesCount: Math.floor(Math.random() * 100),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
// If nesting level > 0, add nested quotes to each quote post
|
||||
if (nestingLevel > 0 && i % 2 === 0) {
|
||||
// Add nested quotes to every other quote - use stable ID
|
||||
const nestedQuoteId = `nested-quote-${i}-12345`;
|
||||
|
||||
// Add the nested quote post to states.statuses
|
||||
states.statuses[nestedQuoteId] = {
|
||||
id: nestedQuoteId,
|
||||
content: `<p>This is a nested quote inside quote ${i + 1}</p>`,
|
||||
// Create the actual status object for all quote states
|
||||
// This allows filtering logic to run even for non-accepted states
|
||||
{
|
||||
// Create the actual status object that will be retrieved by QuoteStatuses
|
||||
const quoteStatus = {
|
||||
id: quoteId,
|
||||
content: `<p>This is quote post ${i + 1}${i % 2 === 0 ? '' : ' with some extra text'}</p>`,
|
||||
account: {
|
||||
id: `nested-account-${i}`,
|
||||
username: `nested${i}`,
|
||||
name: `Nested User ${i}`,
|
||||
id: `quote-account-${i}`,
|
||||
username: `quote${i}`,
|
||||
name: `Quote User ${i}`,
|
||||
avatar: '/logo-192.png',
|
||||
acct: `nested${i}@example.social`,
|
||||
url: `https://example.social/@nested${i}`,
|
||||
acct: `quote${i}@example.social`,
|
||||
url: `https://example.social/@quote${i}`,
|
||||
},
|
||||
visibility: 'public',
|
||||
createdAt: new Date(Date.now() - (i + 1) * 3600000).toISOString(),
|
||||
createdAt: new Date(Date.now() - i * 3600000).toISOString(), // Each post 1 hour older
|
||||
emojis: [],
|
||||
mediaAttachments: [], // No media in nested quotes for simplicity
|
||||
// First quote post should be plain (no media, no poll)
|
||||
mediaAttachments:
|
||||
i > 0 && i % 2 === 0
|
||||
? [
|
||||
{
|
||||
// Only non-first posts can have media (every 3rd post after the 1st)
|
||||
id: `quote-media-${i}`,
|
||||
type: 'image',
|
||||
url: `https://picsum.photos/seed/quote-${i}/600/400`,
|
||||
previewUrl: `https://picsum.photos/seed/quote-${i}/300/200`,
|
||||
meta: {
|
||||
original: { width: 600, height: 400 },
|
||||
small: { width: 300, height: 200 },
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
poll:
|
||||
i > 0 && i % 3 === 0
|
||||
? {
|
||||
// Only non-first posts can have polls (every 4th post after the 1st)
|
||||
id: `quote-poll-${i}`,
|
||||
options: [
|
||||
{
|
||||
title: 'Option A',
|
||||
votesCount: Math.floor(Math.random() * 50),
|
||||
},
|
||||
{
|
||||
title: 'Option B',
|
||||
votesCount: Math.floor(Math.random() * 50),
|
||||
},
|
||||
],
|
||||
expiresAt: new Date(
|
||||
Date.now() + 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
multiple: false,
|
||||
votesCount: Math.floor(Math.random() * 100),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
// Create reference object for nested quote - critical for proper rendering
|
||||
const nestedQuoteRef = {
|
||||
id: nestedQuoteId,
|
||||
instance: DEFAULT_INSTANCE,
|
||||
url: `https://example.social/s/${nestedQuoteId}`,
|
||||
};
|
||||
// Add filtering to quote posts if enabled
|
||||
if (
|
||||
toggleState.quoteFilters &&
|
||||
toggleState.quoteFilters.some((f) => f)
|
||||
) {
|
||||
quoteStatus.filtered = toggleState.quoteFilters
|
||||
.map((enabled, filterIndex) => {
|
||||
if (!enabled) return null;
|
||||
const filterTypes = ['hide', 'blur', 'warn'];
|
||||
return {
|
||||
filter: {
|
||||
id: `quote-filter-${i}-${filterIndex}`,
|
||||
title: `Quote ${filterTypes[filterIndex]} filter`,
|
||||
context: ['home', 'public', 'thread', 'account'],
|
||||
filterAction: filterTypes[filterIndex],
|
||||
},
|
||||
keywordMatches: [],
|
||||
statusMatches: [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Add another level of nesting if specified
|
||||
if (nestingLevel > 1 && i === 0) {
|
||||
// Only add deepest nesting to first quote
|
||||
const deepNestedId = `deep-nested-${i}-12345`;
|
||||
// Assign the quote status to the states using the correct key format
|
||||
states.statuses[quoteStatusKey] = quoteStatus;
|
||||
|
||||
states.statuses[deepNestedId] = {
|
||||
id: deepNestedId,
|
||||
content: `<p>This is a deeply nested quote (level 2)</p>`,
|
||||
// If nesting level > 0, add nested quotes to each quote post
|
||||
if (nestingLevel > 0 && i % 2 === 0) {
|
||||
// Add nested quotes to every other quote - use stable ID
|
||||
const nestedQuoteId = `nested-quote-${i}-12345`;
|
||||
|
||||
// Add the nested quote post to states.statuses
|
||||
states.statuses[nestedQuoteId] = {
|
||||
id: nestedQuoteId,
|
||||
content: `<p>This is a nested quote inside quote ${i + 1}</p>`,
|
||||
account: {
|
||||
id: `deep-account-${i}`,
|
||||
username: `deep${i}`,
|
||||
name: `Deep User ${i}`,
|
||||
id: `nested-account-${i}`,
|
||||
username: `nested${i}`,
|
||||
name: `Nested User ${i}`,
|
||||
avatar: '/logo-192.png',
|
||||
acct: `deep${i}@example.social`,
|
||||
url: `https://example.social/@deep${i}`,
|
||||
acct: `nested${i}@example.social`,
|
||||
url: `https://example.social/@nested${i}`,
|
||||
},
|
||||
visibility: 'public',
|
||||
createdAt: new Date(
|
||||
Date.now() - (i + 2) * 3600000,
|
||||
Date.now() - (i + 1) * 3600000,
|
||||
).toISOString(),
|
||||
emojis: [],
|
||||
mediaAttachments: [], // No media in nested quotes for simplicity
|
||||
};
|
||||
|
||||
// Create deep nested reference
|
||||
const deepNestedRef = {
|
||||
id: deepNestedId,
|
||||
// Create reference object for nested quote - critical for proper rendering
|
||||
const nestedQuoteRef = {
|
||||
id: nestedQuoteId,
|
||||
instance: DEFAULT_INSTANCE,
|
||||
url: `https://example.social/s/${deepNestedId}`,
|
||||
url: `https://example.social/s/${nestedQuoteId}`,
|
||||
};
|
||||
|
||||
// Important: Use the proper key format for the nested quote
|
||||
const nestedKey = statusKey(nestedQuoteId, DEFAULT_INSTANCE);
|
||||
states.statusQuotes[nestedKey] = [deepNestedRef];
|
||||
}
|
||||
// Add another level of nesting if specified
|
||||
if (nestingLevel > 1 && i === 0) {
|
||||
// Only add deepest nesting to first quote
|
||||
const deepNestedId = `deep-nested-${i}-12345`;
|
||||
|
||||
// Add nested quote to the quote's quotes using the proper key format
|
||||
const quoteKey = statusKey(quoteId, DEFAULT_INSTANCE);
|
||||
states.statusQuotes[quoteKey] = [nestedQuoteRef];
|
||||
}
|
||||
states.statuses[deepNestedId] = {
|
||||
id: deepNestedId,
|
||||
content: `<p>This is a deeply nested quote (level 2)</p>`,
|
||||
account: {
|
||||
id: `deep-account-${i}`,
|
||||
username: `deep${i}`,
|
||||
name: `Deep User ${i}`,
|
||||
avatar: '/logo-192.png',
|
||||
acct: `deep${i}@example.social`,
|
||||
url: `https://example.social/@deep${i}`,
|
||||
},
|
||||
visibility: 'public',
|
||||
createdAt: new Date(
|
||||
Date.now() - (i + 2) * 3600000,
|
||||
).toISOString(),
|
||||
emojis: [],
|
||||
};
|
||||
|
||||
// Create deep nested reference
|
||||
const deepNestedRef = {
|
||||
id: deepNestedId,
|
||||
instance: DEFAULT_INSTANCE,
|
||||
url: `https://example.social/s/${deepNestedId}`,
|
||||
};
|
||||
|
||||
// Important: Use the proper key format for the nested quote
|
||||
const nestedKey = statusKey(nestedQuoteId, DEFAULT_INSTANCE);
|
||||
states.statusQuotes[nestedKey] = [deepNestedRef];
|
||||
}
|
||||
|
||||
// Add nested quote to the quote's quotes using the proper key format
|
||||
const quoteKey = statusKey(quoteId, DEFAULT_INSTANCE);
|
||||
states.statusQuotes[quoteKey] = [nestedQuoteRef];
|
||||
}
|
||||
} // Close the quote status creation block
|
||||
|
||||
return quoteRef;
|
||||
});
|
||||
|
@ -670,6 +715,8 @@ export default function Sandbox() {
|
|||
toggleState.showQuotes,
|
||||
toggleState.quotesCount,
|
||||
toggleState.quoteNestingLevel,
|
||||
toggleState.quoteState,
|
||||
toggleState.quoteFilters,
|
||||
]);
|
||||
|
||||
// Handler for filter checkboxes
|
||||
|
@ -679,6 +726,13 @@ export default function Sandbox() {
|
|||
updateToggles({ filters: newFilters });
|
||||
};
|
||||
|
||||
// Handler for quote filter checkboxes
|
||||
const handleQuoteFilterChange = (index) => {
|
||||
const newQuoteFilters = [...toggleState.quoteFilters];
|
||||
newQuoteFilters[index] = !newQuoteFilters[index];
|
||||
updateToggles({ quoteFilters: newQuoteFilters });
|
||||
};
|
||||
|
||||
// Function to check if the current state is different from the initial state
|
||||
const hasChanges = () => {
|
||||
return Object.keys(INITIAL_STATE).some((key) => {
|
||||
|
@ -713,7 +767,7 @@ export default function Sandbox() {
|
|||
class={`sandbox-preview ${toggleState.displayStyle}`}
|
||||
onClickCapture={(e) => {
|
||||
const isAllowed = e.target.closest(
|
||||
'.media, .media-caption, .spoiler-button, .spoiler-media-button',
|
||||
'.media, .media-caption, .spoiler-button, .spoiler-media-button, .math-block button',
|
||||
);
|
||||
if (isAllowed) return;
|
||||
e.preventDefault();
|
||||
|
@ -938,6 +992,18 @@ export default function Sandbox() {
|
|||
<span>With mentions</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="contentType"
|
||||
checked={toggleState.contentType === 'math'}
|
||||
onChange={() => updateToggles({ contentType: 'math' })}
|
||||
disabled={!toggleState.hasContent}
|
||||
/>
|
||||
<span>With math</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -1214,6 +1280,133 @@ export default function Sandbox() {
|
|||
/>
|
||||
</label>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span>Quote state</span>
|
||||
<ul>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="quoteState"
|
||||
value="accepted"
|
||||
checked={toggleState.quoteState === 'accepted'}
|
||||
onChange={(e) => {
|
||||
updateToggles({ quoteState: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<span>Accepted</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="quoteState"
|
||||
value="deleted"
|
||||
checked={toggleState.quoteState === 'deleted'}
|
||||
onChange={(e) => {
|
||||
updateToggles({ quoteState: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<span>Deleted</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="quoteState"
|
||||
value="unauthorized"
|
||||
checked={
|
||||
toggleState.quoteState === 'unauthorized'
|
||||
}
|
||||
onChange={(e) => {
|
||||
updateToggles({ quoteState: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<span>Unauthorized</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="quoteState"
|
||||
value="pending"
|
||||
checked={toggleState.quoteState === 'pending'}
|
||||
onChange={(e) => {
|
||||
updateToggles({ quoteState: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<span>Pending</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="quoteState"
|
||||
value="rejected"
|
||||
checked={toggleState.quoteState === 'rejected'}
|
||||
onChange={(e) => {
|
||||
updateToggles({ quoteState: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<span>Rejected</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="quoteState"
|
||||
value="revoked"
|
||||
checked={toggleState.quoteState === 'revoked'}
|
||||
onChange={(e) => {
|
||||
updateToggles({ quoteState: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<span>Revoked</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<b>Quote Filters</b>
|
||||
<ul>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleState.quoteFilters[0]}
|
||||
onChange={() => handleQuoteFilterChange(0)}
|
||||
/>
|
||||
<span>Hide</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleState.quoteFilters[1]}
|
||||
onChange={() => handleQuoteFilterChange(1)}
|
||||
/>
|
||||
<span>Blur</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleState.quoteFilters[2]}
|
||||
onChange={() => handleQuoteFilterChange(2)}
|
||||
/>
|
||||
<span>Warn</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
|
|
|
@ -193,6 +193,7 @@ function StatusPage(params) {
|
|||
});
|
||||
transition.ready.finally(() => {
|
||||
el.style.viewTransitionName = '';
|
||||
el.dataset.viewTransitioned = mediaVTN;
|
||||
});
|
||||
} else {
|
||||
mediaClose();
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
#welcome {
|
||||
text-align: center;
|
||||
background-image: radial-gradient(
|
||||
circle at center,
|
||||
var(--bg-color),
|
||||
transparent 16em
|
||||
), radial-gradient(circle at center, var(--bg-color), transparent 8em);
|
||||
background-image:
|
||||
radial-gradient(circle at center, var(--bg-color), transparent 16em),
|
||||
radial-gradient(circle at center, var(--bg-color), transparent 8em);
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
cursor: default;
|
||||
|
|
|
@ -37,12 +37,17 @@ export const translate = async (text, source, target) => {
|
|||
let detectedSourceLanguage;
|
||||
const originalSource = source;
|
||||
if (source === 'auto') {
|
||||
if (!langDetector?.detect) {
|
||||
return {
|
||||
error: 'No language detector',
|
||||
};
|
||||
}
|
||||
try {
|
||||
const results = await langDetector.detect(text);
|
||||
source = results[0].detectedLanguage;
|
||||
detectedSourceLanguage = source;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.warn(e);
|
||||
return {
|
||||
error: e,
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ import { lingui } from '@lingui/vite-plugin';
|
|||
import preact from '@preact/preset-vite';
|
||||
import Sonda from 'sonda/vite';
|
||||
import { uid } from 'uid/single';
|
||||
import { defineConfig, loadEnv, splitVendorChunkPlugin } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import generateFile from 'vite-plugin-generate-file';
|
||||
import htmlPlugin from 'vite-plugin-html-config';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
@ -83,7 +83,6 @@ export default defineConfig({
|
|||
// },
|
||||
],
|
||||
}),
|
||||
splitVendorChunkPlugin(),
|
||||
removeConsole({
|
||||
includes: ['log', 'debug', 'info', 'warn', 'error'],
|
||||
}),
|
||||
|
@ -177,10 +176,26 @@ export default defineConfig({
|
|||
compose: resolve(__dirname, 'compose/index.html'),
|
||||
},
|
||||
output: {
|
||||
manualChunks: {
|
||||
// 'intl-segmenter-polyfill': ['@formatjs/intl-segmenter/polyfill'],
|
||||
'tinyld-light': ['tinyld/light'],
|
||||
},
|
||||
// NOTE: Comment this for now. This messes up async imports.
|
||||
// Without SplitVendorChunkPlugin, pushing everything to vendor is not "smart" enough
|
||||
// manualChunks: (id, { getModuleInfo }) => {
|
||||
// // if (id.includes('@formatjs/intl-segmenter/polyfill')) return 'intl-segmenter-polyfill';
|
||||
// if (/tiny.*light/.test(id)) return 'tinyld-light';
|
||||
|
||||
// // Implement logic similar to splitVendorChunkPlugin
|
||||
// if (id.includes('node_modules')) {
|
||||
// // Check if this module is dynamically imported
|
||||
// const moduleInfo = getModuleInfo(id);
|
||||
// if (moduleInfo) {
|
||||
// // If it's imported dynamically, don't put in vendor
|
||||
// const isDynamicOnly =
|
||||
// moduleInfo.importers.length === 0 &&
|
||||
// moduleInfo.dynamicImporters.length > 0;
|
||||
// if (isDynamicOnly) return null;
|
||||
// }
|
||||
// return 'vendor';
|
||||
// }
|
||||
// },
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
const { facadeModuleId } = chunkInfo;
|
||||
if (facadeModuleId && facadeModuleId.includes('icon')) {
|
||||
|
|
Ładowanie…
Reference in New Issue