diff --git a/README.md b/README.md
index 65b21da..b8a9dc2 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,8 @@ Lets moderators mark a member with a staff-only reason from a list the administr
- **ACP → Extensions → See Notes → Reasons**: administrators set up the list of reasons, each with a description, a color, and an on/off switch. Seven starter reasons (Moderation queue, Spam watch, Warez, Conduct, Possible returning member, Staff in contact, General) are added on first enable, ready to edit or remove. A switched-off reason can't be given any more, but members who have it keep it and its notes.
- **MCP → Moderator notes** and **ACP → Manage users → Moderator notes**: give a member a reason, choose which reason is current (or none), and add notes under each reason.
- **Notes use BBCode**, smilies and links, following the board's private message settings, so a PM sent to the member can be pasted into a quote for other staff.
-- **Only staff see it.** The current reason shows as "SEE NOTES: Moderation queue (3)" on the member's profile, next to their posts, on the MCP post, report and approval queue pages, and in the MCP topic view. It links to the notes. A member with no current reason shows "SEE NOTES: NONE", which links straight to the form that gives one.
+- **Only staff see it.** The current reason shows as "SEE NOTES: Moderation queue (3)" on the member's profile, next to their posts, on the MCP post, report and approval queue pages, and in the MCP topic view. It links to the notes, and shows only while the member has a current reason.
+- **A notes button** next to every post (beside phpBB's Warn button) and a **Moderator notes** link on every profile take staff straight to a member's notes, or to the form that gives a reason when the member has none.
- The member's latest **phpBB user notes** (warnings, bans and so on) show read-only on the same page.
- Separate permissions for seeing, adding, editing notes, deleting notes and deleting a reason entry, with matching administrator permissions.
- Every change goes into the moderator log. Deleting a reason with all its entries is off by default, and needs the reason's name typed to confirm.
@@ -23,7 +24,7 @@ The full design is in [`docs/design.md`](docs/design.md).
diff --git a/docs/design.md b/docs/design.md
index 43f4188..3ea49fa 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -59,7 +59,8 @@ To staff with the "see" permission only:
- **Mini-profile** next to posts (viewtopic): the same line next to the custom profile fields, in bold, in the reason's color (red by default).
- **MCP pages where moderators act on a post:** the post details, report details and approval queue details pages, as a notice above the post (where phpBB shows "This post was reported"), and the MCP topic view, after each poster's name. The queue and reports *lists* are left out: their templates have no events to add anything to, and every item in them links to its details page.
- All of these show the reason's description on hover (when it has one) and link to the member's Moderator notes tab in the MCP. The number in brackets is how many notes the current entry has.
-- When a member has no current reason, the same places show ": NONE" in the normal text color. NONE links straight to the "Give a reason" form on the member's notes page, so staff can add one in one click.
+- When a member has no current reason, nothing shows in these places.
+- Staff with the "see" permission also get a way into any member's notes, whether or not they have a reason: a **Moderator notes** button with the post buttons (next to phpBB's Warn button, template event `viewtopic_body_post_buttons_after`) and a **Moderator notes** link with the profile's user statistics (`memberlist_view_user_statistics_after`). Without a current reason, staff who may give one land on the "Give a reason" form. phpBB 4.0 draws post button icons with its `Icon()` Twig function, which phpBB 3.3 cannot compile, so the button has one template per branch.
Events used, all present on both 3.3.x and master:
diff --git a/docs/images/screenshots/README.md b/docs/images/screenshots/README.md
index b932437..6b2b463 100644
--- a/docs/images/screenshots/README.md
+++ b/docs/images/screenshots/README.md
@@ -10,15 +10,15 @@ The current reason in the poster's mini-profile, in the reason's color, with the
## Profile
-The reason with the other profile details.
+The reason with the other profile details, and the Moderator notes link with the user statistics.
-
+
-## No current reason
+## The notes button
-A member without a current reason shows NONE, which links straight to the form that gives one.
+Every post gets a Moderator notes button for staff, next to phpBB's Warn button. Here the member has no current reason, so no SEE NOTES line shows, and the button opens the form that gives one.
-
+
## MCP post details
diff --git a/docs/images/screenshots/seenotes-acp-users.png b/docs/images/screenshots/seenotes-acp-users.png
index e4a6efa..e8f4dea 100644
Binary files a/docs/images/screenshots/seenotes-acp-users.png and b/docs/images/screenshots/seenotes-acp-users.png differ
diff --git a/docs/images/screenshots/seenotes-mcp-notes.png b/docs/images/screenshots/seenotes-mcp-notes.png
index 79707e8..2f4b501 100644
Binary files a/docs/images/screenshots/seenotes-mcp-notes.png and b/docs/images/screenshots/seenotes-mcp-notes.png differ
diff --git a/docs/images/screenshots/seenotes-mcp-post.png b/docs/images/screenshots/seenotes-mcp-post.png
index 8570dbd..aecb906 100644
Binary files a/docs/images/screenshots/seenotes-mcp-post.png and b/docs/images/screenshots/seenotes-mcp-post.png differ
diff --git a/docs/images/screenshots/seenotes-post-button.png b/docs/images/screenshots/seenotes-post-button.png
new file mode 100644
index 0000000..8df8bc3
Binary files /dev/null and b/docs/images/screenshots/seenotes-post-button.png differ
diff --git a/docs/images/screenshots/seenotes-profile-none.png b/docs/images/screenshots/seenotes-profile-none.png
deleted file mode 100644
index 4d4e2a7..0000000
Binary files a/docs/images/screenshots/seenotes-profile-none.png and /dev/null differ
diff --git a/docs/images/screenshots/seenotes-profile.png b/docs/images/screenshots/seenotes-profile.png
index 6b1be0c..2d6db07 100644
Binary files a/docs/images/screenshots/seenotes-profile.png and b/docs/images/screenshots/seenotes-profile.png differ
diff --git a/docs/images/screenshots/seenotes-viewtopic.png b/docs/images/screenshots/seenotes-viewtopic.png
index 6a055d7..d7b8976 100644
Binary files a/docs/images/screenshots/seenotes-viewtopic.png and b/docs/images/screenshots/seenotes-viewtopic.png differ
diff --git a/docs/update/2026-11-08/README.md b/docs/update/2026-11-08/README.md
new file mode 100644
index 0000000..cbb0469
--- /dev/null
+++ b/docs/update/2026-11-08/README.md
@@ -0,0 +1,10 @@
+# See Notes update screenshots — 11/08/2026
+
+Staff get a Moderator notes button next to every post, beside phpBB's Warn button. This member has no current reason, so no SEE NOTES line shows, and the button opens the form that gives one.
+
+[ ](notes-button-next-to-post.png)
+
+Profiles get a Moderator notes link with the user statistics. The SEE NOTES line still shows with the profile details while the member has a current reason.
+
+[ ](moderator-notes-link-on-profile.png)
+
diff --git a/docs/update/2026-11-08/moderator-notes-link-on-profile.png b/docs/update/2026-11-08/moderator-notes-link-on-profile.png
new file mode 100644
index 0000000..2d6db07
Binary files /dev/null and b/docs/update/2026-11-08/moderator-notes-link-on-profile.png differ
diff --git a/docs/update/2026-11-08/notes-button-next-to-post.png b/docs/update/2026-11-08/notes-button-next-to-post.png
new file mode 100644
index 0000000..8df8bc3
Binary files /dev/null and b/docs/update/2026-11-08/notes-button-next-to-post.png differ
diff --git a/event/listener.php b/event/listener.php
index 8cf6425..cabfa23 100644
--- a/event/listener.php
+++ b/event/listener.php
@@ -142,24 +142,31 @@ public function add_permissions(data $event)
}
/**
- * Load the stylesheet for staff who may see reasons
+ * Load the stylesheet for staff who may see reasons, and tell the
+ * templates which phpBB branch draws the post buttons
*
* @return void
*/
public function load_stylesheet()
{
- $this->template->assign_var('S_SEENOTES_CSS', $this->manager->can('view'));
+ $this->template->assign_vars([
+ 'S_SEENOTES_CSS' => $this->manager->can('view'),
+ // phpBB 4.0 draws post button icons with its Icon() function
+ 'S_SEENOTES_PHPBB4' => phpbb_version_compare(PHPBB_VERSION, '4.0.0-a1', '>='),
+ ]);
}
/**
- * Show the member's current reason with the profile details
+ * Show the member's current reason with the profile details, and the
+ * link to their notes with the user statistics
*
* @param data $event
* @return void
*/
public function show_on_profile(data $event)
{
- $this->template->assign_vars($this->get_display_vars((int) $event['member']['user_id']));
+ $user_id = (int) $event['member']['user_id'];
+ $this->template->assign_vars(array_merge($this->get_display_vars($user_id), $this->get_button_vars($user_id)));
}
/**
@@ -178,14 +185,16 @@ public function load_viewtopic_posters(data $event)
}
/**
- * Show the poster's current reason in their mini-profile
+ * Show the poster's current reason in their mini-profile, and the notes
+ * button with the post buttons
*
* @param data $event
* @return void
*/
public function show_on_post(data $event)
{
- $vars = $this->get_display_vars((int) $event['poster_id']);
+ $poster_id = (int) $event['poster_id'];
+ $vars = array_merge($this->get_display_vars($poster_id), $this->get_button_vars($poster_id));
if ($vars)
{
$event['post_row'] = array_merge($event['post_row'], $vars);
@@ -281,53 +290,82 @@ public function update_username(data $event)
}
/**
- * Template variables for a member's current reason, or "NONE" when they
- * have none, for viewers who may see it
+ * Template variables for a member's current reason, for viewers who may
+ * see it
*
* @param int $user_id
- * @return array Empty when the viewer may not see reasons
+ * @return array Empty when there is nothing to show
*/
protected function get_display_vars($user_id)
{
- if (!$user_id || $user_id == ANONYMOUS || !$this->manager->can('view'))
+ if (!$this->can_view_member($user_id))
{
return [];
}
$current = $this->manager->get_current_for_users([$user_id]);
- $u_notes = $this->get_notes_url($user_id);
-
- // No current reason: "NONE", linking to the form that gives one
if (!isset($current[$user_id]))
{
- $can_manage = $this->manager->can('manage');
-
- return [
- 'S_SEENOTES' => true,
- 'S_SEENOTES_NONE' => true,
- 'SEENOTES_LABEL' => $this->manager->get_label(),
- 'SEENOTES_REASON' => $this->language->lang('SEENOTES_NONE'),
- 'SEENOTES_LINK_TEXT' => $this->language->lang($can_manage ? 'SEENOTES_NONE_GIVE' : 'SEENOTES_NONE_EXPLAIN'),
- 'U_SEENOTES' => ($u_notes && $can_manage) ? $u_notes . '#seenotes-add-entry' : $u_notes,
- ];
+ return [];
}
$entry = $current[$user_id];
return [
'S_SEENOTES' => true,
- 'S_SEENOTES_NONE' => false,
'SEENOTES_LABEL' => $this->manager->get_label(),
'SEENOTES_REASON' => $entry['reason_title'],
'SEENOTES_DESCRIPTION' => $entry['reason_description'],
'SEENOTES_COLOR' => $this->manager->clean_color($entry['reason_color']),
'SEENOTES_COUNT' => (int) $entry['note_count'],
'SEENOTES_COUNT_TEXT' => $this->language->lang('SEENOTES_NOTE_COUNT', (int) $entry['note_count']),
- 'SEENOTES_LINK_TEXT' => $this->language->lang('SEENOTES_OPEN_NOTES'),
- 'U_SEENOTES' => $u_notes,
+ 'U_SEENOTES' => $this->get_notes_url($user_id),
+ ];
+ }
+
+ /**
+ * Template variables for the notes button next to posts and the link on
+ * the profile, shown to staff whether or not the member has a reason.
+ * Without a current reason, staff who may give one land on that form.
+ *
+ * @param int $user_id
+ * @return array Empty when the viewer may not see notes or reach them
+ */
+ protected function get_button_vars($user_id)
+ {
+ if (!$this->can_view_member($user_id))
+ {
+ return [];
+ }
+
+ $u_notes = $this->get_notes_url($user_id);
+ if ($u_notes === '')
+ {
+ return [];
+ }
+
+ $current = $this->manager->get_current_for_users([$user_id]);
+ if (!isset($current[$user_id]) && $this->manager->can('manage'))
+ {
+ $u_notes .= '#seenotes-add-entry';
+ }
+
+ return [
+ 'U_SEENOTES_NOTES' => $u_notes,
];
}
+ /**
+ * Whether the viewer may see See Notes for this member
+ *
+ * @param int $user_id
+ * @return bool
+ */
+ protected function can_view_member($user_id)
+ {
+ return $user_id && $user_id != ANONYMOUS && $this->manager->can('view');
+ }
+
/**
* Where the viewer can read the member's notes: the MCP for moderators,
* ACP > Manage users for administrators without moderator access
diff --git a/language/en/common.php b/language/en/common.php
index af95791..38df592 100644
--- a/language/en/common.php
+++ b/language/en/common.php
@@ -29,9 +29,8 @@
2 => '%d notes',
],
'SEENOTES_OPEN_NOTES' => 'Read the moderator notes about this member',
- 'SEENOTES_NONE' => 'NONE',
- 'SEENOTES_NONE_GIVE' => 'This member has no current reason. Give them one',
- 'SEENOTES_NONE_EXPLAIN' => 'This member has no current reason. Read the moderator notes about them',
+ 'SEENOTES_NOTES_BUTTON' => 'Moderator notes',
+ 'SEENOTES_VIEW_NOTES' => 'View or add moderator notes',
'LOG_SEENOTES_ENTRY_ADDED' => 'Gave a member a See Notes reason » %1$s, to %2$s',
'LOG_SEENOTES_CURRENT_SET' => 'Changed a member’s current See Notes reason » %1$s, for %2$s',
diff --git a/styles/all/template/event/mcp_post_text_before.html b/styles/all/template/event/mcp_post_text_before.html
index 5e0015a..850329f 100644
--- a/styles/all/template/event/mcp_post_text_before.html
+++ b/styles/all/template/event/mcp_post_text_before.html
@@ -1,5 +1,5 @@
{% if S_SEENOTES %}
-
+
{% include '@phpbbmodders_seenotes/seenotes_reason.html' with {'sn': _context} only %}
diff --git a/styles/all/template/event/memberlist_view_user_statistics_after.html b/styles/all/template/event/memberlist_view_user_statistics_after.html
new file mode 100644
index 0000000..3224a8d
--- /dev/null
+++ b/styles/all/template/event/memberlist_view_user_statistics_after.html
@@ -0,0 +1,4 @@
+{% if U_SEENOTES_NOTES %}
+ {{ lang('SEENOTES_NOTES_BUTTON') }}{{ lang('COLON') }}
+ {{ lang('SEENOTES_VIEW_NOTES') }}
+{% endif %}
diff --git a/styles/all/template/event/viewtopic_body_post_buttons_after.html b/styles/all/template/event/viewtopic_body_post_buttons_after.html
new file mode 100644
index 0000000..3199d57
--- /dev/null
+++ b/styles/all/template/event/viewtopic_body_post_buttons_after.html
@@ -0,0 +1,4 @@
+{% if postrow.U_SEENOTES_NOTES %}
+ {# phpBB 4.0 draws post button icons with Icon(), which phpBB 3.3 cannot compile, so each branch has its own button #}
+ {% include S_SEENOTES_PHPBB4 ? '@phpbbmodders_seenotes/seenotes_post_button_40.html' : '@phpbbmodders_seenotes/seenotes_post_button_33.html' %}
+{% endif %}
diff --git a/styles/all/template/seenotes_post_button_33.html b/styles/all/template/seenotes_post_button_33.html
new file mode 100644
index 0000000..b3b2cce
--- /dev/null
+++ b/styles/all/template/seenotes_post_button_33.html
@@ -0,0 +1,5 @@
+
+
+ {{ lang('SEENOTES_NOTES_BUTTON') }}
+
+
diff --git a/styles/all/template/seenotes_post_button_40.html b/styles/all/template/seenotes_post_button_40.html
new file mode 100644
index 0000000..fadde10
--- /dev/null
+++ b/styles/all/template/seenotes_post_button_40.html
@@ -0,0 +1,5 @@
+
+
+ {{ Icon('font', 'note-sticky', lang('SEENOTES_NOTES_BUTTON'), post_buttons_hide_text, 'fas c-button-icon') }}
+
+
diff --git a/styles/all/template/seenotes_reason.html b/styles/all/template/seenotes_reason.html
index 8278e93..9db2688 100644
--- a/styles/all/template/seenotes_reason.html
+++ b/styles/all/template/seenotes_reason.html
@@ -1,5 +1,5 @@
{# The reason line. Pass the row with the SEENOTES_* values as "sn". #}
-
+
{{ sn.SEENOTES_LABEL }}{{ lang('COLON') }}
{% include '@phpbbmodders_seenotes/seenotes_reason_link.html' with {'sn': sn} only %}
diff --git a/styles/all/template/seenotes_reason_link.html b/styles/all/template/seenotes_reason_link.html
index 73edf0c..2301cb7 100644
--- a/styles/all/template/seenotes_reason_link.html
+++ b/styles/all/template/seenotes_reason_link.html
@@ -1,2 +1,2 @@
-{# The reason, or NONE, as a link to the notes. Pass the row with the SEENOTES_* values as "sn". #}
-{% if sn.U_SEENOTES %}{{ sn.SEENOTES_REASON }}{% if not sn.S_SEENOTES_NONE %} ({{ sn.SEENOTES_COUNT }}) {% endif %}{% if not sn.S_SEENOTES_NONE %}, {{ sn.SEENOTES_COUNT_TEXT }}{% if sn.SEENOTES_DESCRIPTION %}: {{ sn.SEENOTES_DESCRIPTION }}{% endif %}{% endif %}{% if sn.U_SEENOTES %}. {{ sn.SEENOTES_LINK_TEXT }}{% endif %} {% if sn.U_SEENOTES %} {% else %} {% endif %}
+{# The reason as a link to the notes. Pass the row with the SEENOTES_* values as "sn". #}
+{% if sn.U_SEENOTES %}{{ sn.SEENOTES_REASON }} ({{ sn.SEENOTES_COUNT }}) , {{ sn.SEENOTES_COUNT_TEXT }}{% if sn.SEENOTES_DESCRIPTION %}: {{ sn.SEENOTES_DESCRIPTION }}{% endif %}{% if sn.U_SEENOTES %}. {{ lang('SEENOTES_OPEN_NOTES') }}{% endif %} {% if sn.U_SEENOTES %} {% else %}{% endif %}
diff --git a/styles/all/theme/seenotes.css b/styles/all/theme/seenotes.css
index 1a2c357..4eb7d0a 100644
--- a/styles/all/theme/seenotes.css
+++ b/styles/all/theme/seenotes.css
@@ -75,8 +75,3 @@
min-height: 0;
overflow-x: auto;
}
-
-/* NONE: no current reason, so a quieter line in the normal text color */
-.seenotes-notice-none {
- border-left-color: rgba(128, 128, 128, 0.5);
-}
diff --git a/tests/event/listener_test.php b/tests/event/listener_test.php
index 73d0edf..582341b 100644
--- a/tests/event/listener_test.php
+++ b/tests/event/listener_test.php
@@ -127,9 +127,10 @@ public function test_post_row_gets_the_current_reason()
$this->assertSame('Moderation queue', $row['SEENOTES_REASON']);
$this->assertSame(2, $row['SEENOTES_COUNT']);
$this->assertStringContainsString('mode=notes&u=3', $row['U_SEENOTES']);
+ $this->assertStringEndsWith('u=3', $row['U_SEENOTES_NOTES']);
}
- public function test_post_row_shows_none_without_a_current_reason()
+ public function test_post_row_without_a_current_reason_gets_only_the_button()
{
$this->manager->method('can')->willReturn(true);
$this->manager->method('get_current_for_users')->willReturn([]);
@@ -143,9 +144,8 @@ public function test_post_row_shows_none_without_a_current_reason()
$this->get_listener()->show_on_post($event);
$row = $event['post_row'];
- $this->assertTrue($row['S_SEENOTES_NONE']);
- $this->assertSame('SEENOTES_NONE', $row['SEENOTES_REASON']);
- $this->assertStringEndsWith('u=3#seenotes-add-entry', $row['U_SEENOTES']);
+ $this->assertArrayNotHasKey('S_SEENOTES', $row);
+ $this->assertStringEndsWith('u=3#seenotes-add-entry', $row['U_SEENOTES_NOTES']);
}
public function test_other_acp_users_modes_are_ignored()