diff --git a/.gitattributes b/.gitattributes
index dc41a13..7eafcaf 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -13,4 +13,5 @@ tests/ export-ignore
.gitattributes export-ignore
phpunit.xml.* export-ignore
README.md export-ignore
-docs/images/ export-ignore
+docs/ export-ignore
+.editorconfig export-ignore
diff --git a/docs/events/forumlist_body_forum_desc_after.txt b/docs/events/forumlist_body_forum_desc_after.txt
index 868ca4e..57ec0fd 100644
--- a/docs/events/forumlist_body_forum_desc_after.txt
+++ b/docs/events/forumlist_body_forum_desc_after.txt
@@ -1,3 +1,13 @@
+STATUS
+======
+Filed: https://tracker.phpbb.com/browse/PHPBB-17748
+Merged: https://github.com/phpbb/phpbb/pull/7095 (3.3.x) and
+ https://github.com/phpbb/phpbb/pull/7096 (master), 11/03/2026
+Since: 3.3.20-RC1
+Feed Links uses the event when the user's style has it, and falls back to
+appending to FORUM_DESC for styles without it (phpBB 3.3.19 Prosilver and
+older custom styles).
+
PROJECT: phpBB (PHPBB)
ISSUE TYPE: Improvement
SUMMARY: New template event forumlist_body_forum_desc_after
diff --git a/event/listener.php b/event/listener.php
index 576e45b..2ae553e 100644
--- a/event/listener.php
+++ b/event/listener.php
@@ -52,6 +52,9 @@ class listener implements EventSubscriberInterface
/** @var bool Whether the front-end language file is loaded */
protected $language_loaded = false;
+ /** @var bool|null Whether the user's style has the forumlist_body_forum_desc_after event, once looked up */
+ protected $forumlist_event;
+
/**
* @param config $config
* @param language $language
@@ -102,8 +105,11 @@ public function add_rss_head_switch()
/**
* Add a forum's feed links below its description on forum lists
*
- * phpBB has no template event after the description of a forum row (see
- * docs/events/), so the rendered links are appended to FORUM_DESC.
+ * Styles with the forumlist_body_forum_desc_after template event (phpBB
+ * 3.3.20 and 4.0 Prosilver, or a style that adds it) get the links in
+ * their own forumrow variable, shown by that event. Older styles have no
+ * place after the description, so there the links are appended to
+ * FORUM_DESC as before.
*
* @param data $event
* @return void
@@ -118,10 +124,50 @@ public function add_forumlist_links(data $event)
}
$forum_row = $event['forum_row'];
- $forum_row['FORUM_DESC'] .= $this->render_links($urls, $event['row']['forum_name'], true, $forum_row['FORUM_DESC'] !== '');
+
+ if ($this->style_has_forumlist_event())
+ {
+ $forum_row['FEEDLINKS_FORUMLIST_LINKS'] = $this->render_links($urls, $event['row']['forum_name'], true, true);
+ }
+ else
+ {
+ $forum_row['FORUM_DESC'] .= $this->render_links($urls, $event['row']['forum_name'], true, $forum_row['FORUM_DESC'] !== '');
+ }
+
$event['forum_row'] = $forum_row;
}
+ /**
+ * Whether the forum list template of the user's style has the
+ * forumlist_body_forum_desc_after event
+ *
+ * Looks at the first forumlist_body.html in the style and its parents,
+ * the one phpBB uses, once per request.
+ *
+ * @return bool
+ */
+ protected function style_has_forumlist_event()
+ {
+ if ($this->forumlist_event === null)
+ {
+ $this->forumlist_event = false;
+ $root = $this->twig->get_phpbb_root_path();
+
+ foreach ($this->template->get_user_style() as $style)
+ {
+ $file = $root . 'styles/' . $style . '/template/forumlist_body.html';
+
+ if (is_file($file))
+ {
+ $this->forumlist_event = strpos((string) file_get_contents($file), 'forumlist_body_forum_desc_after') !== false;
+ break;
+ }
+ }
+ }
+
+ return $this->forumlist_event;
+ }
+
/**
* Add a forum's feed links to its own page, and its RSS link to the head
*
diff --git a/styles/all/template/event/forumlist_body_forum_desc_after.html b/styles/all/template/event/forumlist_body_forum_desc_after.html
new file mode 100644
index 0000000..310a754
--- /dev/null
+++ b/styles/all/template/event/forumlist_body_forum_desc_after.html
@@ -0,0 +1 @@
+{% if forumrow.FEEDLINKS_FORUMLIST_LINKS %}{{ forumrow.FEEDLINKS_FORUMLIST_LINKS }}{% endif %}
diff --git a/tests/event/listener_test.php b/tests/event/listener_test.php
index 717e9d5..cce2a0c 100644
--- a/tests/event/listener_test.php
+++ b/tests/event/listener_test.php
@@ -26,9 +26,12 @@ class listener_test extends \phpbb_test_case
/** @var \PHPUnit\Framework\MockObject\MockObject|\phpbb\template\template */
protected $template;
- /** @var \PHPUnit\Framework\MockObject\MockObject|\Twig\Environment */
+ /** @var \PHPUnit\Framework\MockObject\MockObject|\phpbb\template\twig\environment */
protected $twig;
+ /** @var string A throwaway board root holding the styles a test needs */
+ protected $style_root = '';
+
/** @var \PHPUnit\Framework\MockObject\MockObject|links */
protected $links;
@@ -44,11 +47,57 @@ protected function setUp(): void
$this->request = $this->getMockBuilder('\phpbb\request\request_interface')->getMock();
$this->template = $this->getMockBuilder('\phpbb\template\template')->getMock();
- $this->twig = $this->getMockBuilder('\Twig\Environment')->disableOriginalConstructor()->getMock();
+ $this->twig = $this->getMockBuilder('\phpbb\template\twig\environment')->disableOriginalConstructor()->getMock();
$this->links = $this->getMockBuilder(links::class)->disableOriginalConstructor()->getMock();
$this->config = new config(['feed_topic' => 1, 'feedlinks_rss_head' => 1]);
}
+ protected function tearDown(): void
+ {
+ if ($this->style_root !== '')
+ {
+ foreach (glob($this->style_root . 'styles/*/template/forumlist_body.html') as $file)
+ {
+ unlink($file);
+ rmdir(dirname($file));
+ rmdir(dirname($file, 2));
+ }
+ foreach (glob($this->style_root . 'styles/*', GLOB_ONLYDIR) as $dir)
+ {
+ rmdir($dir);
+ }
+ rmdir($this->style_root . 'styles');
+ rmdir($this->style_root);
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Give the user a style tree, and a forumlist_body.html to the styles named
+ * in $templates (style name => whether it has the forumlist_body_forum_desc_after event)
+ *
+ * @param array $tree Style names, the user's style first, then its parents
+ * @param array $templates
+ * @return void
+ */
+ protected function set_styles(array $tree, array $templates)
+ {
+ $this->style_root = sys_get_temp_dir() . '/feedlinks_test_' . uniqid() . '/';
+ foreach ($tree as $style)
+ {
+ mkdir($this->style_root . 'styles/' . $style, 0777, true);
+ }
+ foreach ($templates as $style => $has_event)
+ {
+ mkdir($this->style_root . 'styles/' . $style . '/template');
+ file_put_contents($this->style_root . 'styles/' . $style . '/template/forumlist_body.html',
+ '' . ($has_event ? '{% EVENT forumlist_body_forum_desc_after %}' : ''));
+ }
+ $this->twig->method('get_phpbb_root_path')->willReturn($this->style_root);
+ $this->template->method('get_user_style')->willReturn($tree);
+ }
+
/**
* Record the arguments of every call to a template method, in order
* (withConsecutive() is gone from PHPUnit 10)
@@ -103,6 +152,7 @@ public function test_rss_head_switch()
public function test_forumlist_links()
{
+ $this->set_styles(['prosilver'], ['prosilver' => false]);
$this->links->method('get_urls')->willReturn(['atom' => 'atom-url', 'rss' => '']);
$this->twig->expects($this->once())
->method('render')
@@ -124,6 +174,39 @@ public function test_forumlist_links()
$this->assertSame(['FORUM_DESC' => 'Description'], $event['forum_row']);
}
+ /**
+ * Styles with the forumlist_body_forum_desc_after event, directly or from
+ * a parent style, get the links in their own forumrow variable
+ */
+ public static function forumlist_event_styles()
+ {
+ return [
+ 'the style has the event' => [['prosilver'], ['prosilver' => true]],
+ 'a child style inherits it' => [['child', 'prosilver'], ['prosilver' => true]],
+ 'the child overrides it without the event' => [['child', 'prosilver'], ['child' => false, 'prosilver' => true]],
+ ];
+ }
+
+ /** @dataProvider forumlist_event_styles */
+ public function test_forumlist_links_with_event(array $tree, array $templates)
+ {
+ $this->set_styles($tree, $templates);
+ $uses_event = $templates[array_values(array_filter($tree, function ($s) use ($templates) { return isset($templates[$s]); }))[0]];
+ $this->links->method('get_urls')->willReturn(['atom' => 'atom-url', 'rss' => 'rss-url']);
+ $this->twig->method('render')->willReturn('');
+
+ $event = new data([
+ 'row' => ['forum_name' => 'Forum'],
+ 'forum_row' => ['FORUM_DESC' => 'Description'],
+ ]);
+ $this->get_listener()->add_forumlist_links($event);
+
+ $expected = $uses_event
+ ? ['FORUM_DESC' => 'Description', 'FEEDLINKS_FORUMLIST_LINKS' => '']
+ : ['FORUM_DESC' => 'Description'];
+ $this->assertSame($expected, $event['forum_row']);
+ }
+
public function test_forumlist_without_links()
{
$this->links->method('get_urls')->willReturn(['atom' => '', 'rss' => '']);