fix: init robuste avant <body>, pas de repli tardif au-dessus de l'écran, README

- écoute freshrss:load-more sur document (script async possiblement exécuté
  avant le parsing de <body>)
- ne replie jamais après coup un article qui commence au-dessus de l'écran :
  la page sauterait et des articles non vus seraient marqués lus
- README : l'image officielle n'a pas git, installation depuis l'hôte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vincent BourdonandClaude Opus 5.5 committed 2026-11-06 19:15:54 +01:00
1 parent ccb230d514
commit 66d997cc6e
4 files changed
+66 -10

No files matched your search

+20 -7
View File
@@ -17,21 +17,34 @@ replié est simplement plus court, il est donc marqué lu plus tôt.
## Installation (Docker) ## Installation (Docker)
Le dépôt est directement le dossier de l'extension. Dans le volume monté sur Le dépôt est directement le dossier de l'extension. L'image officielle
`/var/www/FreshRSS/extensions` : `freshrss/freshrss` ne contient pas `git` : on clone **depuis l'hôte** dans le
volume monté sur `/var/www/FreshRSS/extensions`.
**Volume monté depuis un dossier de l'hôte** (`./extensions:/var/www/FreshRSS/extensions`) :
```sh ```sh
docker exec -it <conteneur-freshrss> sh -c \ git clone https://git.siteop.biz/vincentb99/freshrss-read-more.git ./extensions/freshrss-read-more
'cd /var/www/FreshRSS/extensions && git clone https://git.siteop.biz/vincentb99/freshrss-read-more.git'
``` ```
(ou cloner depuis l'hôte dans le dossier du volume). Puis dans FreshRSS : **Volume Docker nommé** (ex. `freshrss_extensions`) :
*Configuration → Extensions → Read More → activer*.
```sh
docker run --rm -v freshrss_extensions:/ext alpine/git \
clone https://git.siteop.biz/vincentb99/freshrss-read-more.git /ext/freshrss-read-more
```
Les fichiers doivent être lisibles par le serveur web du conteneur (c'est le cas par
défaut d'un clone). Puis dans FreshRSS : *Configuration → Extensions → Read More → activer*.
### Mise à jour ### Mise à jour
Même principe, depuis l'hôte :
```sh ```sh
cd /var/www/FreshRSS/extensions/freshrss-read-more && git pull git -C ./extensions/freshrss-read-more pull
# ou, volume nommé :
docker run --rm -v freshrss_extensions:/ext alpine/git -C /ext/freshrss-read-more pull
``` ```
puis recharger la page dans le navigateur. puis recharger la page dans le navigateur.
@@ -68,7 +68,7 @@ ci-dessous valent donc pour 1.30.4.
- **Vue courante côté JS** — `context.current_view === 'reader'`. - **Vue courante côté JS** — `context.current_view === 'reader'`.
- **Chargement de nouveaux articles** — événement `freshrss:load-more` dispatché sur - **Chargement de nouveaux articles** — événement `freshrss:load-more` dispatché sur
`document.body` (bubbles) à chaque lot chargé. `document.body` (bubbles) à chaque lot chargé.
- **Passage de config au JS** — hook `Minz_HookType::JsVars` ; les données retournées - **Passage de config au JS** — hook `Minz_HookType::JsVars` (clé `read_more.collapsed_height`, `read_more.label`) ; les données retournées
apparaissent dans `context.extensions`. apparaissent dans `context.extensions`.
- **Découverte des extensions** — tout dossier de `extensions/` (`THIRDPARTY_EXTENSIONS_PATH`) - **Découverte des extensions** — tout dossier de `extensions/` (`THIRDPARTY_EXTENSIONS_PATH`)
contenant un `metadata.json` valide ; pas de préfixe de nom imposé. En Docker : contenant un `metadata.json` valide ; pas de préfixe de nom imposé. En Docker :
@@ -99,7 +99,7 @@ Extension de type `user` (activable par utilisateur), sans base de données ni b
metadata.json name "Read More", entrypoint "ReadMore", type "user" metadata.json name "Read More", entrypoint "ReadMore", type "user"
extension.php class ReadMoreExtension extends Minz_Extension extension.php class ReadMoreExtension extends Minz_Extension
init(): registerTranslates(), appendScript/appendStyle (static/) init(): registerTranslates(), appendScript/appendStyle (static/)
registerHook(JsVars) -> ['read_more' => ['height' => …, libellés]] registerHook(JsVars) -> ['read_more' => ['collapsed_height' => …, 'label' => …]]
handleConfigureAction(): valide et enregistre `collapsed_height` (int, bornes raisonnables) handleConfigureAction(): valide et enregistre `collapsed_height` (int, bornes raisonnables)
configure.phtml formulaire : hauteur repliée (px) configure.phtml formulaire : hauteur repliée (px)
i18n/fr/ext.php, i18n/en/ext.php libellés (« Lire plus », config) i18n/fr/ext.php, i18n/en/ext.php libellés (« Lire plus », config)
+7 -1
View File
@@ -42,6 +42,11 @@
if (state !== undefined && state !== STATE.short) { if (state !== undefined && state !== STATE.short) {
return; return;
} }
// Re-evaluating a short entry: never shrink one that starts above the viewport,
// the page would jump up and unseen articles could be marked read on next scroll.
if (state === STATE.short && flux.getBoundingClientRect().top < 0) {
return;
}
const text = flux.querySelector('.text'); const text = flux.querySelector('.text');
if (!text) { if (!text) {
return; return;
@@ -71,7 +76,8 @@
}; };
const doc = win.document; const doc = win.document;
processEntries(doc, opts); processEntries(doc, opts);
doc.body.addEventListener('freshrss:load-more', () => processEntries(doc, opts)); // Listen on document: async scripts may run before <body> exists; the event bubbles.
doc.addEventListener('freshrss:load-more', () => processEntries(doc, opts));
// Late-loading images can make a "short" article long enough to collapse. // Late-loading images can make a "short" article long enough to collapse.
doc.addEventListener('load', function (ev) { doc.addEventListener('load', function (ev) {
const target = ev.target; const target = ev.target;
+37
View File
@@ -143,3 +143,40 @@ test('init handles load-more', () => {
assert.equal(document.querySelectorAll('.read-more-btn').length, 2); assert.equal(document.querySelectorAll('.read-more-btn').length, 2);
assert.equal(document.getElementById('flux_2').dataset.readMore, 'collapsed'); assert.equal(document.getElementById('flux_2').dataset.readMore, 'collapsed');
}); });
test('init works before body is parsed', () => {
const { window, document, ReadMore } = setup([]);
document.documentElement.removeChild(document.body);
setContext(window, 'reader');
assert.doesNotThrow(() => ReadMore.init(window, measure));
const body = document.createElement('body');
body.innerHTML = entry(1, 1000);
document.documentElement.appendChild(body);
body.dispatchEvent(new window.Event('freshrss:load-more', { bubbles: true }));
assert.equal(document.getElementById('flux_1').dataset.readMore, 'collapsed');
});
test('late image load does not collapse entry already above viewport top', () => {
const { window, document, ReadMore } = setup([entry(1, 300)]);
setContext(window, 'reader');
ReadMore.init(window, measure);
const flux = document.getElementById('flux_1');
flux.getBoundingClientRect = () => ({ top: -100, bottom: 200 });
const text = flux.querySelector('.text');
text.dataset.h = '1000';
text.querySelector('img').dispatchEvent(new window.Event('load'));
assert.ok(!text.classList.contains('read-more-collapsed'));
assert.equal(flux.querySelectorAll('.read-more-btn').length, 0);
});
test('load-more does not collapse short entry already above viewport top', () => {
const { window, document, ReadMore } = setup([entry(1, 300)]);
setContext(window, 'reader');
ReadMore.init(window, measure);
const flux = document.getElementById('flux_1');
flux.getBoundingClientRect = () => ({ top: -100, bottom: 200 });
flux.querySelector('.text').dataset.h = '1000';
document.body.dispatchEvent(new window.Event('freshrss:load-more', { bubbles: true }));
assert.equal(flux.dataset.readMore, 'short');
assert.equal(flux.querySelectorAll('.read-more-btn').length, 0);
});