From b24af98f9e6860ecf78e3faf80122e81161a91a5 Mon Sep 17 00:00:00 2001
From: William Jacoby
Date: Sat, 7 Nov 2026 22:19:44 -0600
Subject: [PATCH 01/10] Build See Notes 1.0.0-dev
Staff-only reasons with dated notes, as described in docs/design.md:
ACP reasons and settings, MCP Moderator notes tab, ACP Manage users
page, the reason line on profiles, posts and MCP post pages, separate
moderator and admin permissions, moderator logging, and clean-up on
member deletion and renames. Notes use BBCode with the board's private
message settings, as asked for in the design review.
Closes #2
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/lint.yml | 38 +
.github/workflows/tests.yml | 45 +
README.md | 31 +-
acp/main_info.php | 37 +
acp/main_module.php | 363 ++++++++
adm/style/acp_seenotes_reasons.html | 104 +++
adm/style/acp_seenotes_settings.html | 31 +
adm/style/acp_users_seenotes.html | 188 ++++
.../event/acp_overall_header_head_append.html | 1 +
adm/style/event/acp_users_mode_add.html | 3 +
adm/style/seenotes_acp.css | 21 +
adm/style/seenotes_delete.js | 23 +
composer.json | 33 +
config/services.yml | 43 +
docs/design.md | 12 +-
event/listener.php | 338 +++++++
ext.php | 60 ++
language/en/acp_seenotes.php | 82 ++
language/en/common.php | 47 +
language/en/info_acp_seenotes.php | 29 +
language/en/info_mcp_seenotes.php | 27 +
language/en/install_seenotes.php | 26 +
language/en/permissions_seenotes.php | 39 +
language/en/seenotes.php | 83 ++
manager.php | 825 ++++++++++++++++++
mcp/main_info.php | 32 +
mcp/main_module.php | 67 ++
migrations/v10x/install.php | 208 +++++
notes_page.php | 642 ++++++++++++++
phpunit.xml.dist | 30 +
.../template/event/mcp_post_text_before.html | 6 +
.../mcp_topic_post_author_full_append.html | 1 +
..._view_non_contact_custom_fields_after.html | 6 +
.../event/overall_header_head_append.html | 3 +
...opic_body_postrow_custom_fields_after.html | 3 +
styles/all/template/mcp_seenotes.html | 214 +++++
styles/all/template/seenotes_reason.html | 5 +
styles/all/theme/seenotes.css | 60 ++
tests/event/listener_test.php | 160 ++++
tests/fixtures/seenotes.xml | 159 ++++
tests/functional/.gitkeep | 0
tests/manager_test.php | 223 +++++
42 files changed, 4333 insertions(+), 15 deletions(-)
create mode 100644 .github/workflows/lint.yml
create mode 100644 .github/workflows/tests.yml
create mode 100644 acp/main_info.php
create mode 100644 acp/main_module.php
create mode 100644 adm/style/acp_seenotes_reasons.html
create mode 100644 adm/style/acp_seenotes_settings.html
create mode 100644 adm/style/acp_users_seenotes.html
create mode 100644 adm/style/event/acp_overall_header_head_append.html
create mode 100644 adm/style/event/acp_users_mode_add.html
create mode 100644 adm/style/seenotes_acp.css
create mode 100644 adm/style/seenotes_delete.js
create mode 100644 composer.json
create mode 100644 config/services.yml
create mode 100644 event/listener.php
create mode 100644 ext.php
create mode 100644 language/en/acp_seenotes.php
create mode 100644 language/en/common.php
create mode 100644 language/en/info_acp_seenotes.php
create mode 100644 language/en/info_mcp_seenotes.php
create mode 100644 language/en/install_seenotes.php
create mode 100644 language/en/permissions_seenotes.php
create mode 100644 language/en/seenotes.php
create mode 100644 manager.php
create mode 100644 mcp/main_info.php
create mode 100644 mcp/main_module.php
create mode 100644 migrations/v10x/install.php
create mode 100644 notes_page.php
create mode 100644 phpunit.xml.dist
create mode 100644 styles/all/template/event/mcp_post_text_before.html
create mode 100644 styles/all/template/event/mcp_topic_post_author_full_append.html
create mode 100644 styles/all/template/event/memberlist_view_non_contact_custom_fields_after.html
create mode 100644 styles/all/template/event/overall_header_head_append.html
create mode 100644 styles/all/template/event/viewtopic_body_postrow_custom_fields_after.html
create mode 100644 styles/all/template/mcp_seenotes.html
create mode 100644 styles/all/template/seenotes_reason.html
create mode 100644 styles/all/theme/seenotes.css
create mode 100644 tests/event/listener_test.php
create mode 100644 tests/fixtures/seenotes.xml
create mode 100644 tests/functional/.gitkeep
create mode 100644 tests/manager_test.php
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..1cd5e40
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,38 @@
+name: Lint
+
+# PHP syntax check on the lowest PHP version this extension supports and the
+# newest one phpBB 3.3 supports. tests.yml runs the tests, code sniffer and
+# EPV on phpBB 3.3 and 4.0.
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ lint:
+ name: PHP ${{ matrix.php }} lint
+ runs-on: ubuntu-24.04
+ strategy:
+ matrix:
+ php: ['7.4', '8.4']
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up PHP ${{ matrix.php }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: none
+ tools: composer:v2
+
+ - name: PHP syntax check
+ run: find . -name '*.php' -not -path './.git/*' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l
+
+ - name: Validate composer.json
+ run: composer validate --no-check-all --no-check-publish
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..28c15ce
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,45 @@
+name: Tests
+
+on:
+ push:
+ branches:
+ - main
+
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ # phpBB 3.3.x compatibility
+ call-tests-3-3-x:
+ name: Extension tests (phpBB 3.3.x)
+ uses: phpbb-extensions/test-framework/.github/workflows/tests.yml@3.3.x
+ with:
+ EXTNAME: phpbbmodders/seenotes
+ # EPV runs in the epv job below instead
+ EPV: 0
+ PHPBB_BRANCH: '3.3.x'
+ RUN_FUNCTIONAL_TESTS: 0
+ PRIMARY_PHP_VERSION: '7.4'
+ PHP_VERSION_MATRIX: '["7.4", "8.0", "8.1", "8.2"]'
+
+ # phpBB 4.0 (master) compatibility
+ call-tests-master:
+ name: Extension tests (phpBB master / 4.0)
+ uses: phpbb-extensions/test-framework/.github/workflows/tests.yml@master
+ with:
+ EXTNAME: phpbbmodders/seenotes
+ # EPV runs in the epv job below instead
+ EPV: 0
+ PHPBB_BRANCH: 'master'
+ RUN_FUNCTIONAL_TESTS: 0
+ PRIMARY_PHP_VERSION: '8.2'
+ PHP_VERSION_MATRIX: '["8.2", "8.3", "8.4"]'
+
+ # EPV (Extension Pre Validator), failing on any Fatal or Error finding not
+ # listed in .github/epv-allowed.txt
+ epv:
+ uses: phpbbmodders/.github/.github/workflows/epv.yml@v1
+ with:
+ EXTNAME: phpbbmodders/seenotes
+ PHPBB_BRANCHES: '["3.3.x", "master"]'
diff --git a/README.md b/README.md
index ae0cdee..d6f262c 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,33 @@
# See Notes
+[](https://github.com/phpbbmodders/seenotes/actions/workflows/tests.yml) [](https://github.com/phpbbmodders/seenotes/actions/workflows/lint.yml)
+
Lets moderators mark a member with a staff-only reason from a list the administrators set, and keep dated notes under each reason.
-> **Status: design stage.** There is no code yet. The planned behavior is in [`docs/design.md`](docs/design.md).
+## Features
-## Planned features
+- **ACP → Extensions → See Notes → Reasons**: administrators set up the list of reasons, each with a description, a color, and an on/off switch. 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.
+- 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.
+- A member's entries and notes are deleted with the member.
-- Administrators set up the list of reasons in the ACP, and can switch each one on or off.
-- Moderators give a member one or more reason entries, each with its own notes, and choose which one is current.
-- The current reason shows in the member's profile and mini-profile, only to staff with permission.
-- Separate permissions for seeing, managing, editing notes, deleting notes and deleting entries.
-- Every change goes into the moderator log.
+The full design is in [`docs/design.md`](docs/design.md).
## Requirements
- phpBB 3.3.19 or later, including phpBB 4.0
-- PHP 7.4 or later (phpBB 4.0 itself needs PHP 8.2 or later)
+- PHP 8.3.36 or later
+
+## Installation
+
+1. Copy the extension to `/ext/phpbbmodders/seenotes`
+2. In the Administration Control Panel, go to **Customise → Manage extensions**
+3. Enable the **See Notes** extension
+4. Add your reasons under **Extensions → See Notes → Reasons**
## Contributing
@@ -27,7 +39,8 @@ Contributions are welcome!
## Acknowledgments
-- Design written with [Claude](https://www.anthropic.com/claude).
+- Design reviewed by [kaileymsnay](https://github.com/kaileymsnay), who asked for BBCode in notes.
+- Code, tests and documentation written with [Claude](https://www.anthropic.com/claude).
## License
diff --git a/acp/main_info.php b/acp/main_info.php
new file mode 100644
index 0000000..9e81c0e
--- /dev/null
+++ b/acp/main_info.php
@@ -0,0 +1,37 @@
+ '\phpbbmodders\seenotes\acp\main_module',
+ 'title' => 'ACP_SEENOTES',
+ 'modes' => [
+ 'reasons' => [
+ 'title' => 'ACP_SEENOTES_REASONS',
+ 'auth' => 'ext_phpbbmodders/seenotes && acl_a_seenotes_settings',
+ 'cat' => ['ACP_SEENOTES'],
+ ],
+ 'settings' => [
+ 'title' => 'ACP_SEENOTES_SETTINGS',
+ 'auth' => 'ext_phpbbmodders/seenotes && acl_a_seenotes_settings',
+ 'cat' => ['ACP_SEENOTES'],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/acp/main_module.php b/acp/main_module.php
new file mode 100644
index 0000000..82c301b
--- /dev/null
+++ b/acp/main_module.php
@@ -0,0 +1,363 @@
+add_lang('acp_seenotes', 'phpbbmodders/seenotes');
+ $this->manager = $phpbb_container->get('phpbbmodders.seenotes.manager');
+
+ add_form_key(self::FORM_KEY);
+
+ if ($mode === 'settings')
+ {
+ $this->settings();
+ return;
+ }
+
+ $this->reasons();
+ }
+
+ /**
+ * The label and the settings
+ *
+ * @return void
+ */
+ protected function settings()
+ {
+ global $config, $language, $request, $template;
+
+ $this->tpl_name = '@phpbbmodders_seenotes/acp_seenotes_settings';
+ $this->page_title = 'ACP_SEENOTES_SETTINGS';
+
+ if ($request->is_set_post('submit'))
+ {
+ $this->check_form();
+
+ $label = trim($request->variable('seenotes_label', '', true));
+ if ($label === '' || utf8_strlen($label) > self::LABEL_MAX_LENGTH)
+ {
+ $this->error($language->lang('SEENOTES_LABEL_INVALID', self::LABEL_MAX_LENGTH));
+ }
+
+ $config->set('seenotes_label', $label);
+ $config->set('seenotes_require_description', (int) $request->variable('seenotes_require_description', false));
+ $config->set('seenotes_allow_hard_delete', (int) $request->variable('seenotes_allow_hard_delete', false));
+
+ $this->manager->log_admin('LOG_SEENOTES_SETTINGS');
+ trigger_error($language->lang('CONFIG_UPDATED') . adm_back_link($this->u_action));
+ }
+
+ $template->assign_vars([
+ 'U_ACTION' => $this->u_action,
+ 'SEENOTES_LABEL' => $this->manager->get_label(),
+ 'SEENOTES_LABEL_MAX_LENGTH' => self::LABEL_MAX_LENGTH,
+ 'S_SEENOTES_REQUIRE_DESCRIPTION'=> !empty($config['seenotes_require_description']),
+ 'S_SEENOTES_ALLOW_HARD_DELETE' => !empty($config['seenotes_allow_hard_delete']),
+ ]);
+ }
+
+ /**
+ * The list of reasons, and the forms to add, change, move, switch and
+ * delete them
+ *
+ * @return void
+ */
+ protected function reasons()
+ {
+ global $config, $language, $request, $template;
+
+ $this->tpl_name = '@phpbbmodders_seenotes/acp_seenotes_reasons';
+ $this->page_title = 'ACP_SEENOTES_REASONS';
+
+ $action = $request->variable('action', '');
+ $reason_id = $request->variable('id', 0);
+
+ switch ($action)
+ {
+ case 'add':
+ case 'edit':
+ $this->edit_reason($action === 'edit' ? $reason_id : 0);
+ return;
+
+ case 'move_up':
+ case 'move_down':
+ case 'enable':
+ case 'disable':
+ $this->quick_action($action, $reason_id);
+ break;
+
+ case 'delete':
+ case 'delete_confirm':
+ case 'delete_final':
+ $this->delete_reason($action, $reason_id);
+ return;
+ }
+
+ $hash = generate_link_hash('seenotes_reasons');
+ foreach ($this->manager->get_reasons() as $id => $reason)
+ {
+ $template->assign_block_vars('reasons', [
+ 'TITLE' => $reason['reason_title'],
+ 'DESCRIPTION' => $reason['reason_description'],
+ 'COLOR' => $this->manager->clean_color($reason['reason_color']),
+ 'S_ACTIVE' => (bool) $reason['reason_active'],
+ 'U_EDIT' => $this->u_action . '&action=edit&id=' . $id,
+ 'U_MOVE_UP' => $this->u_action . '&action=move_up&id=' . $id . '&hash=' . $hash,
+ 'U_MOVE_DOWN' => $this->u_action . '&action=move_down&id=' . $id . '&hash=' . $hash,
+ 'U_TOGGLE' => $this->u_action . '&action=' . ($reason['reason_active'] ? 'disable' : 'enable') . '&id=' . $id . '&hash=' . $hash,
+ 'U_DELETE' => $this->u_action . '&action=delete&id=' . $id,
+ ]);
+ }
+
+ $template->assign_vars([
+ 'S_SEENOTES_LIST' => true,
+ 'S_SEENOTES_HARD_DELETE' => !empty($config['seenotes_allow_hard_delete']),
+ 'U_ACTION' => $this->u_action,
+ 'U_SEENOTES_ADD' => $this->u_action . '&action=add',
+ ]);
+ }
+
+ /**
+ * The form to add or change a reason
+ *
+ * @param int $reason_id 0 to add a reason
+ * @return void
+ */
+ protected function edit_reason($reason_id)
+ {
+ global $config, $language, $request, $template;
+
+ $reason = [
+ 'reason_title' => '',
+ 'reason_description' => '',
+ 'reason_color' => \phpbbmodders\seenotes\manager::DEFAULT_COLOR,
+ ];
+
+ if ($reason_id)
+ {
+ $reason = $this->get_reason_or_error($reason_id);
+ }
+
+ $errors = [];
+ if ($request->is_set_post('submit'))
+ {
+ $this->check_form();
+
+ $reason['reason_title'] = trim($request->variable('reason_title', '', true));
+ $reason['reason_description'] = trim($request->variable('reason_description', '', true));
+ $color = $request->variable('reason_color', '');
+
+ if ($reason['reason_title'] === '')
+ {
+ $errors[] = $language->lang('SEENOTES_TITLE_EMPTY');
+ }
+
+ $max = \phpbbmodders\seenotes\manager::TEXT_MAX_LENGTH;
+ if (utf8_strlen(htmlspecialchars_decode($reason['reason_title'], ENT_COMPAT)) > $max || utf8_strlen(htmlspecialchars_decode($reason['reason_description'], ENT_COMPAT)) > $max)
+ {
+ $errors[] = $language->lang('SEENOTES_TEXT_TOO_LONG', $max);
+ }
+
+ if ($reason['reason_description'] === '' && !empty($config['seenotes_require_description']))
+ {
+ $errors[] = $language->lang('SEENOTES_DESCRIPTION_REQUIRED');
+ }
+
+ if (!preg_match('/\A#?[0-9A-Fa-f]{6}\z/', $color))
+ {
+ $errors[] = $language->lang('SEENOTES_COLOR_INVALID');
+ }
+ $reason['reason_color'] = $this->manager->clean_color($color);
+
+ if (!$errors)
+ {
+ $this->manager->save_reason($reason, $reason_id);
+ $this->manager->log_admin($reason_id ? 'LOG_SEENOTES_REASON_EDITED' : 'LOG_SEENOTES_REASON_ADDED', [$reason['reason_title']]);
+
+ trigger_error($language->lang($reason_id ? 'SEENOTES_REASON_UPDATED' : 'SEENOTES_REASON_ADDED') . adm_back_link($this->u_action));
+ }
+ }
+
+ $template->assign_vars([
+ 'S_SEENOTES_EDIT' => true,
+ 'S_SEENOTES_ADDING' => !$reason_id,
+ 'S_SEENOTES_REQUIRE_DESCRIPTION'=> !empty($config['seenotes_require_description']),
+ 'SEENOTES_ERROR' => implode(' ', $errors),
+ 'REASON_TITLE' => $reason['reason_title'],
+ 'REASON_DESCRIPTION' => $reason['reason_description'],
+ 'REASON_COLOR' => $this->manager->clean_color($reason['reason_color']),
+ 'REASON_MAX_LENGTH' => \phpbbmodders\seenotes\manager::TEXT_MAX_LENGTH,
+ 'U_ACTION' => $this->u_action . '&action=' . ($reason_id ? 'edit&id=' . $reason_id : 'add'),
+ 'U_BACK' => $this->u_action,
+ ]);
+ }
+
+ /**
+ * Move a reason, or switch it on or off, from a link in the list
+ *
+ * @param string $action move_up, move_down, enable or disable
+ * @param int $reason_id
+ * @return void
+ */
+ protected function quick_action($action, $reason_id)
+ {
+ global $language, $request;
+
+ if (!check_link_hash($request->variable('hash', ''), 'seenotes_reasons'))
+ {
+ $this->error($language->lang('FORM_INVALID'));
+ }
+
+ $reason = $this->get_reason_or_error($reason_id);
+
+ switch ($action)
+ {
+ case 'move_up':
+ case 'move_down':
+ $this->manager->move_reason($reason_id, $action === 'move_up');
+ break;
+
+ case 'enable':
+ case 'disable':
+ $this->manager->set_reason_active($reason_id, $action === 'enable');
+ $this->manager->log_admin($action === 'enable' ? 'LOG_SEENOTES_REASON_ENABLED' : 'LOG_SEENOTES_REASON_DISABLED', [$reason['reason_title']]);
+ break;
+ }
+ }
+
+ /**
+ * Delete a reason with all its entries: a screen with what will be
+ * removed, a screen where the reason's name is typed, then the deletion
+ *
+ * @param string $action delete, delete_confirm or delete_final
+ * @param int $reason_id
+ * @return void
+ */
+ protected function delete_reason($action, $reason_id)
+ {
+ global $config, $language, $request, $template;
+
+ if (empty($config['seenotes_allow_hard_delete']))
+ {
+ $this->error($language->lang('SEENOTES_HARD_DELETE_OFF'));
+ }
+
+ $reason = $this->get_reason_or_error($reason_id);
+ $usage = $this->manager->count_reason_usage($reason_id);
+
+ if ($request->is_set_post('cancel'))
+ {
+ redirect(str_replace('&', '&', $this->u_action));
+ }
+
+ if ($action !== 'delete')
+ {
+ $this->check_form();
+ }
+
+ if ($action === 'delete_final')
+ {
+ $typed = trim($request->variable('confirm_name', '', true));
+ if ($typed !== $reason['reason_title'])
+ {
+ $this->error($language->lang('SEENOTES_DELETE_NAME_MISMATCH'));
+ }
+
+ $this->manager->delete_reason($reason_id);
+ $this->manager->log_admin('LOG_SEENOTES_REASON_DELETED', [$reason['reason_title'], $usage['users'], $usage['entries'], $usage['notes']]);
+
+ trigger_error($language->lang('SEENOTES_REASON_DELETED') . adm_back_link($this->u_action));
+ }
+
+ $template->assign_vars([
+ 'S_SEENOTES_DELETE_STEP1' => $action === 'delete',
+ 'S_SEENOTES_DELETE_STEP2' => $action === 'delete_confirm',
+ 'REASON_TITLE' => $reason['reason_title'],
+ 'SEENOTES_DELETE_SUMMARY' => $language->lang('SEENOTES_DELETE_SUMMARY',
+ $reason['reason_title'],
+ $language->lang('SEENOTES_COUNT_USERS', $usage['users']),
+ $language->lang('SEENOTES_COUNT_ENTRIES', $usage['entries']),
+ $language->lang('SEENOTES_COUNT_NOTES', $usage['notes'])
+ ),
+ 'U_ACTION' => $this->u_action . '&action=' . ($action === 'delete' ? 'delete_confirm' : 'delete_final') . '&id=' . $reason_id,
+ ]);
+ }
+
+ /**
+ * A reason, or an error
+ *
+ * @param int $reason_id
+ * @return array
+ */
+ protected function get_reason_or_error($reason_id)
+ {
+ global $language;
+
+ $reason = $this->manager->get_reason($reason_id);
+ if ($reason === null)
+ {
+ $this->error($language->lang('SEENOTES_REASON_NOT_FOUND'));
+ }
+
+ return $reason;
+ }
+
+ /**
+ * Stop when the form key is not valid
+ *
+ * @return void
+ */
+ protected function check_form()
+ {
+ global $language;
+
+ if (!check_form_key(self::FORM_KEY))
+ {
+ $this->error($language->lang('FORM_INVALID'));
+ }
+ }
+
+ /**
+ * Show an error with a link back to the list
+ *
+ * @param string $message
+ * @return void
+ */
+ protected function error($message)
+ {
+ trigger_error($message . adm_back_link($this->u_action), E_USER_WARNING);
+ }
+}
diff --git a/adm/style/acp_seenotes_reasons.html b/adm/style/acp_seenotes_reasons.html
new file mode 100644
index 0000000..b973f22
--- /dev/null
+++ b/adm/style/acp_seenotes_reasons.html
@@ -0,0 +1,104 @@
+{% include 'overall_header.html' %}
+
+{{ lang('ACP_SEENOTES_REASONS') }}
+
+{% if S_SEENOTES_EDIT %}
+
+ « {{ lang('BACK') }}
+ {% if S_SEENOTES_ADDING %}{{ lang('SEENOTES_ADD_REASON') }}{% else %}{{ lang('SEENOTES_EDIT_REASON') }}{% endif %}
+
+ {% if SEENOTES_ERROR %}{% endif %}
+
+
+
+{% elseif S_SEENOTES_DELETE_STEP1 or S_SEENOTES_DELETE_STEP2 %}
+
+ {{ lang('SEENOTES_DELETE_REASON') }}
+
+
{{ SEENOTES_DELETE_SUMMARY }}
+
+
+
+ {% if S_SEENOTES_DELETE_STEP2 %}{% INCLUDEJS '@phpbbmodders_seenotes/seenotes_delete.js' %}{% endif %}
+
+{% else %}
+
+ {{ lang('ACP_SEENOTES_REASONS_EXPLAIN') }}
+
+ {% if loops.reasons|length %}
+
+ {% else %}
+ {{ lang('SEENOTES_NO_REASONS_YET') }}
+ {% endif %}
+
+ {{ lang('SEENOTES_ADD_REASON') }}
+
+{% endif %}
+
+{% include 'overall_footer.html' %}
diff --git a/adm/style/acp_seenotes_settings.html b/adm/style/acp_seenotes_settings.html
new file mode 100644
index 0000000..8a155cb
--- /dev/null
+++ b/adm/style/acp_seenotes_settings.html
@@ -0,0 +1,31 @@
+{% include 'overall_header.html' %}
+
+{{ lang('ACP_SEENOTES') }}
+{{ lang('ACP_SEENOTES_SETTINGS_EXPLAIN') }}
+
+
+
+{% include 'overall_footer.html' %}
diff --git a/adm/style/acp_users_seenotes.html b/adm/style/acp_users_seenotes.html
new file mode 100644
index 0000000..43073b1
--- /dev/null
+++ b/adm/style/acp_users_seenotes.html
@@ -0,0 +1,188 @@
+{% if SEENOTES_ERROR %}
+
+{% endif %}
+
+
+ {{ lang('SEENOTES_ENTRIES') }}
+
+ {% for entry in loops.seenotes_entries %}
+
+
+ {{ entry.REASON }}
+ {% if entry.S_CURRENT %} ({{ lang('SEENOTES_CURRENT') }}) {% endif %}
+ {% if not entry.S_ACTIVE %} ({{ lang('SEENOTES_INACTIVE') }}) {% endif %}
+
+ {% if entry.DESCRIPTION %}{{ entry.DESCRIPTION }}
{% endif %}
+ {{ lang('SEENOTES_ADDED_BY', entry.AUTHOR, entry.TIME) }} • {{ entry.NOTE_COUNT }}
+
+ {% if S_SEENOTES_CAN_MANAGE or S_SEENOTES_CAN_DELETE_ENTRY %}
+
+ {% if S_SEENOTES_CAN_MANAGE %}
+ {{ lang('SEENOTES_ADD_NOTE') }}{{ lang('COLON') }} {{ entry.REASON }}
+ {% endif %}
+ {% if S_SEENOTES_CAN_DELETE_ENTRY %}
+ • {{ lang('SEENOTES_DELETE_ENTRY') }}{{ lang('COLON') }} {{ entry.REASON }}
+ {% endif %}
+
+ {% if S_SEENOTES_CAN_MANAGE and not entry.S_CURRENT %}
+
+ {% endif %}
+ {% endif %}
+
+ {% if entry.notes|length %}
+
+
+
+ {{ lang('SEENOTES_NOTE_TEXT') }}
+ {{ lang('REPORT_BY') }}
+ {% if S_SEENOTES_CAN_EDIT or S_SEENOTES_CAN_DELETE_NOTE %}{{ lang('ACTION') }} {% endif %}
+
+
+
+ {% for note in entry.notes %}
+
+
+ {{ note.TEXT }}
+ {% if note.EDITED %}{{ note.EDITED }}
{% endif %}
+
+ {{ note.AUTHOR }} {{ note.TIME }}
+ {% if S_SEENOTES_CAN_EDIT or S_SEENOTES_CAN_DELETE_NOTE %}
+
+ {% if S_SEENOTES_CAN_EDIT %}{{ lang('SEENOTES_EDIT_NOTE') }}, {{ note.TIME }} {% endif %}
+ {% if S_SEENOTES_CAN_DELETE_NOTE %}{{ lang('SEENOTES_DELETE_NOTE') }}, {{ note.TIME }} {% endif %}
+
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% else %}
+ {{ lang('SEENOTES_NO_NOTES') }}
+ {% endif %}
+
+
+ {% else %}
+ {{ lang('SEENOTES_NO_ENTRIES') }}
+ {% endfor %}
+
+ {% if S_SEENOTES_CAN_MANAGE and S_SEENOTES_HAS_CURRENT %}
+
+ {% endif %}
+
+
+{% if S_SEENOTES_EDITOR %}
+
+
+{% endif %}
+
+{% if S_SEENOTES_CAN_MANAGE %}
+
+
+ {{ lang('SEENOTES_ADD_ENTRY') }}
+ {% if loops.seenotes_reasons|length %}
+ {{ lang('SEENOTES_ADD_ENTRY_EXPLAIN') }}
+
+ {{ lang('SEENOTES_REASON') }}{{ lang('COLON') }}
+
+
+ {{ lang('SEENOTES_SELECT_REASON') }}
+ {% for reason in loops.seenotes_reasons %}
+ {{ reason.TITLE }}
+ {% endfor %}
+
+
+
+
+ {{ lang('SEENOTES_MAKE_IT_CURRENT') }}{{ lang('COLON') }}
+
+
+
+
+ {{ S_SEENOTES_HIDDEN_USER }}
+ {{ S_FORM_TOKEN }}
+
+
+ {% else %}
+ {{ lang('SEENOTES_NO_REASONS') }}
+ {% endif %}
+
+
+{% endif %}
+
+
+ {{ lang('SEENOTES_USERNOTES') }}
+ {{ lang('SEENOTES_USERNOTES_EXPLAIN') }}
+ {% if loops.seenotes_usernotes|length %}
+
+
+
+ {{ lang('REPORT_BY') }}
+ {{ lang('TIME') }}
+ {{ lang('ACTION') }}
+
+
+
+ {% for usernote in loops.seenotes_usernotes %}
+
+ {{ usernote.REPORT_BY }}
+ {{ usernote.REPORT_AT }}
+ {{ usernote.ACTION }}
+
+ {% endfor %}
+
+
+ {{ SEENOTES_USERNOTES_TOTAL }} • {{ lang('SEENOTES_USERNOTES_ALL') }}
+ {% else %}
+ {{ lang('SEENOTES_NO_USERNOTES') }}
+ {% endif %}
+
diff --git a/adm/style/event/acp_overall_header_head_append.html b/adm/style/event/acp_overall_header_head_append.html
new file mode 100644
index 0000000..e638549
--- /dev/null
+++ b/adm/style/event/acp_overall_header_head_append.html
@@ -0,0 +1 @@
+{% INCLUDECSS '@phpbbmodders_seenotes/seenotes_acp.css' %}
diff --git a/adm/style/event/acp_users_mode_add.html b/adm/style/event/acp_users_mode_add.html
new file mode 100644
index 0000000..e71256d
--- /dev/null
+++ b/adm/style/event/acp_users_mode_add.html
@@ -0,0 +1,3 @@
+{% if S_SEENOTES_PAGE %}
+ {% include '@phpbbmodders_seenotes/acp_users_seenotes.html' %}
+{% endif %}
diff --git a/adm/style/seenotes_acp.css b/adm/style/seenotes_acp.css
new file mode 100644
index 0000000..a772523
--- /dev/null
+++ b/adm/style/seenotes_acp.css
@@ -0,0 +1,21 @@
+/* See Notes: ACP helpers. The ACP has no screen-reader-only class of its own. */
+
+.sr-only {
+ border: 0 !important;
+ clip: rect(0, 0, 0, 0) !important;
+ height: 1px !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ padding: 0 !important;
+ position: absolute !important;
+ white-space: nowrap !important;
+ width: 1px !important;
+}
+
+.seenotes-entry h3 small {
+ font-weight: normal;
+}
+
+.seenotes-entry .content {
+ overflow-wrap: anywhere;
+}
diff --git a/adm/style/seenotes_delete.js b/adm/style/seenotes_delete.js
new file mode 100644
index 0000000..39739b5
--- /dev/null
+++ b/adm/style/seenotes_delete.js
@@ -0,0 +1,23 @@
+/**
+ * See Notes: keep the final delete button disabled until the reason's name
+ * is typed exactly. The server checks the name again, so this only helps.
+ */
+(function () {
+ 'use strict';
+
+ var input = document.getElementById('confirm_name');
+ var button = document.getElementById('seenotes_delete_final');
+
+ if (!input || !button) {
+ return;
+ }
+
+ var name = input.getAttribute('data-seenotes-name');
+
+ function update() {
+ button.disabled = input.value.trim() !== name;
+ }
+
+ input.addEventListener('input', update);
+ update();
+})();
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..ad1f4fb
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,33 @@
+{
+ "name": "phpbbmodders/seenotes",
+ "type": "phpbb-extension",
+ "description": "Lets moderators mark a member with a staff-only reason from a list the administrators set, and keep dated notes under each reason.",
+ "homepage": "https://www.phpbbmodders.com/",
+ "version": "1.0.0-dev",
+ "keywords": [
+ "phpbb",
+ "extension",
+ "moderation",
+ "moderator",
+ "user notes"
+ ],
+ "license": "GPL-2.0-only",
+ "authors": [
+ {
+ "name": "phpBB Modders",
+ "email": "board@phpbbmodders.com",
+ "homepage": "https://www.phpbbmodders.com/",
+ "role": "Extension Developer"
+ }
+ ],
+ "require": {
+ "php": ">=8.3.36",
+ "composer/installers": "^1.0 || ^2.0"
+ },
+ "extra": {
+ "display-name": "See Notes",
+ "soft-require": {
+ "phpbb/phpbb": ">=3.3.19,<4.1.0@dev"
+ }
+ }
+}
diff --git a/config/services.yml b/config/services.yml
new file mode 100644
index 0000000..3409fff
--- /dev/null
+++ b/config/services.yml
@@ -0,0 +1,43 @@
+parameters:
+ tables.phpbbmodders.seenotes.reasons: '%core.table_prefix%seenotes_reasons'
+ tables.phpbbmodders.seenotes.entries: '%core.table_prefix%seenotes_entries'
+ tables.phpbbmodders.seenotes.notes: '%core.table_prefix%seenotes_notes'
+
+services:
+ phpbbmodders.seenotes.manager:
+ class: phpbbmodders\seenotes\manager
+ arguments:
+ - '@auth'
+ - '@config'
+ - '@dbal.conn'
+ - '@log'
+ - '@user'
+ - '%tables.phpbbmodders.seenotes.reasons%'
+ - '%tables.phpbbmodders.seenotes.entries%'
+ - '%tables.phpbbmodders.seenotes.notes%'
+
+ phpbbmodders.seenotes.notes_page:
+ class: phpbbmodders\seenotes\notes_page
+ arguments:
+ - '@language'
+ - '@log'
+ - '@request'
+ - '@template'
+ - '@user'
+ - '@phpbbmodders.seenotes.manager'
+ - '%core.root_path%'
+ - '%core.php_ext%'
+
+ phpbbmodders.seenotes.listener:
+ class: phpbbmodders\seenotes\event\listener
+ arguments:
+ - '@auth'
+ - '@language'
+ - '@template'
+ - '@user'
+ - '@phpbbmodders.seenotes.manager'
+ - '@phpbbmodders.seenotes.notes_page'
+ - '%core.root_path%'
+ - '%core.php_ext%'
+ tags:
+ - { name: event.listener }
diff --git a/docs/design.md b/docs/design.md
index 5955d0d..ec51cba 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -1,6 +1,6 @@
# See Notes: design
-Status: **design stage, no code yet.** This document records what has been agreed so far, so it can be reviewed before work starts. Feedback goes in the design issue.
+This document records the agreed design. The extension follows it; changes to the design go in an issue or discussion first.
## What it does
@@ -43,10 +43,10 @@ The display still looks like a profile field.
## Entries and notes
-- A member can have **any number of reason entries**. Each entry records its reason, the date it was added and who added it.
+- A member can have **any number of reason entries**. Each entry records its reason, the date it was added and who added it (the name is saved, like a note author's).
- Entries are kept until someone deletes them.
- **At most one entry is current.** Staff can switch which entry is current, or set none. When none is current, nothing shows on the member's profile, but the entries and notes are still there in the MCP.
-- Each entry has its own list of **notes**, like phpBB's user notes: author, date and text. Notes are plain text, shown escaped. Notes can be edited and deleted; an edited note shows who last edited it and when.
+- Each entry has its own list of **notes**, like phpBB's user notes: author, date and text. Notes use the board's normal BBCode handling, the same way private messages do: standard and custom BBCodes, smilies and automatic links, each following the board's own on/off setting. The note form has the usual BBCode buttons, so a private message sent to the member can be pasted in a quote for other staff to read. Notes can be edited and deleted; an edited note shows who last edited it and when.
- Deleting an entry deletes its notes.
- The author's and last editor's usernames are saved with each note. While they are members, their name links to their profile. If one of them is deleted, the note keeps their name as plain text with no link, the way phpBB keeps a deleted member's name on their posts. Saved names are updated when a member is renamed (`core.update_username`).
- When the member the notes are about is deleted, their entries and notes are deleted too.
@@ -104,8 +104,8 @@ Moderator permissions can be set globally or per forum. Having one in any forum
| Table | Columns |
|---|---|
| `seenotes_reasons` | `reason_id`, `reason_title`, `reason_description`, `reason_color`, `reason_order`, `reason_active` |
-| `seenotes_entries` | `entry_id`, `user_id`, `reason_id`, `entry_time`, `entry_author_id`, `entry_current` |
-| `seenotes_notes` | `note_id`, `entry_id`, `note_author_id`, `note_author_name`, `note_time`, `note_text`, `note_edit_time`, `note_edit_user_id`, `note_edit_user_name` |
+| `seenotes_entries` | `entry_id`, `user_id`, `reason_id`, `entry_time`, `entry_author_id`, `entry_author_name`, `entry_current` |
+| `seenotes_notes` | `note_id`, `entry_id`, `note_author_id`, `note_author_name`, `note_time`, `note_text`, `note_bbcode_uid`, `note_bbcode_bitfield`, `note_bbcode_options`, `note_edit_time`, `note_edit_user_id`, `note_edit_user_name` |
Setting an entry as current clears the flag on the member's other entries in the same transaction.
@@ -115,7 +115,7 @@ Config: `seenotes_label` (the label), `seenotes_require_description` (off) and `
Other events: `core.permissions`, `core.delete_user_after`, `core.update_username`. No new phpBB events are needed.
-Same as the other phpbbmodders extensions: `ext.php` minimum version checks (phpBB 3.3.19, PHP 7.4), `extra.version-check` once a phpBB.com listing exists, the org's strict EPV workflow plus lint and tests, screen-reader text on icon-only controls, and language strings for everything shown.
+Same as the other phpbbmodders extensions: `ext.php` minimum version checks (phpBB 3.3.19, PHP 8.3.36), `extra.version-check` once a phpBB.com listing exists, the org's strict EPV workflow plus lint and tests, screen-reader text on icon-only controls, and language strings for everything shown.
## Privacy
diff --git a/event/listener.php b/event/listener.php
new file mode 100644
index 0000000..9cd6c58
--- /dev/null
+++ b/event/listener.php
@@ -0,0 +1,338 @@
+auth = $auth;
+ $this->language = $language;
+ $this->template = $template;
+ $this->user = $user;
+ $this->manager = $manager;
+ $this->notes_page = $notes_page;
+ $this->root_path = $root_path;
+ $this->php_ext = $php_ext;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getSubscribedEvents()
+ {
+ return [
+ 'core.user_setup' => 'load_language',
+ 'core.permissions' => 'add_permissions',
+ 'core.page_header' => 'load_stylesheet',
+ 'core.memberlist_view_profile' => 'show_on_profile',
+ 'core.viewtopic_modify_post_data' => 'load_viewtopic_posters',
+ 'core.viewtopic_modify_post_row' => 'show_on_post',
+ 'core.mcp_post_template_data' => 'show_on_mcp_post',
+ 'core.mcp_report_template_data' => 'show_on_mcp_post',
+ 'core.mcp_queue_approve_details_template' => 'show_on_mcp_post',
+ 'core.mcp_topic_modify_post_data' => 'load_mcp_topic_posters',
+ 'core.mcp_topic_review_modify_row' => 'show_on_mcp_topic_post',
+ 'core.acp_users_mode_add' => 'acp_users_page',
+ 'core.delete_user_after' => 'delete_users',
+ 'core.update_username' => 'update_username',
+ ];
+ }
+
+ /**
+ * Load the strings used across the board: log entries and the reason line
+ *
+ * @param data $event
+ * @return void
+ */
+ public function load_language(data $event)
+ {
+ $lang_set_ext = $event['lang_set_ext'];
+ $lang_set_ext[] = [
+ 'ext_name' => 'phpbbmodders/seenotes',
+ 'lang_set' => 'common',
+ ];
+ $event['lang_set_ext'] = $lang_set_ext;
+ }
+
+ /**
+ * Add the permissions to ACP > Permissions, in their own category
+ *
+ * @param data $event
+ * @return void
+ */
+ public function add_permissions(data $event)
+ {
+ $this->language->add_lang('permissions_seenotes', 'phpbbmodders/seenotes');
+
+ $categories = $event['categories'];
+ $categories['seenotes'] = 'ACL_CAT_SEENOTES';
+ $event['categories'] = $categories;
+
+ $permissions = $event['permissions'];
+ foreach (manager::ACTIONS as $action)
+ {
+ $permissions['m_seenotes_' . $action] = ['lang' => 'ACL_M_SEENOTES_' . strtoupper($action), 'cat' => 'seenotes'];
+ $permissions['a_seenotes_' . $action] = ['lang' => 'ACL_A_SEENOTES_' . strtoupper($action), 'cat' => 'seenotes'];
+ }
+ $permissions['a_seenotes_settings'] = ['lang' => 'ACL_A_SEENOTES_SETTINGS', 'cat' => 'seenotes'];
+ $event['permissions'] = $permissions;
+ }
+
+ /**
+ * Load the stylesheet for staff who may see reasons
+ *
+ * @return void
+ */
+ public function load_stylesheet()
+ {
+ $this->template->assign_var('S_SEENOTES_CSS', $this->manager->can('view'));
+ }
+
+ /**
+ * Show the member's current reason with the profile details
+ *
+ * @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']));
+ }
+
+ /**
+ * Read the current reasons of everyone who posted on a topic page in one
+ * query
+ *
+ * @param data $event
+ * @return void
+ */
+ public function load_viewtopic_posters(data $event)
+ {
+ if ($this->manager->can('view') && !empty($event['user_cache']))
+ {
+ $this->manager->get_current_for_users(array_keys($event['user_cache']));
+ }
+ }
+
+ /**
+ * Show the poster's current reason in their mini-profile
+ *
+ * @param data $event
+ * @return void
+ */
+ public function show_on_post(data $event)
+ {
+ $vars = $this->get_display_vars((int) $event['poster_id']);
+ if ($vars)
+ {
+ $event['post_row'] = array_merge($event['post_row'], $vars);
+ }
+ }
+
+ /**
+ * Show the poster's current reason above the post on the MCP post,
+ * report and approval queue details pages
+ *
+ * @param data $event
+ * @return void
+ */
+ public function show_on_mcp_post(data $event)
+ {
+ if (!empty($event['post_info']['poster_id']))
+ {
+ $this->template->assign_vars($this->get_display_vars((int) $event['post_info']['poster_id']));
+ }
+ }
+
+ /**
+ * Read the current reasons of everyone who posted on an MCP topic page in
+ * one query
+ *
+ * @param data $event
+ * @return void
+ */
+ public function load_mcp_topic_posters(data $event)
+ {
+ if ($this->manager->can('view') && !empty($event['rowset']))
+ {
+ $this->manager->get_current_for_users(array_column($event['rowset'], 'poster_id'));
+ }
+ }
+
+ /**
+ * Show the poster's current reason after their name in the MCP topic view
+ *
+ * @param data $event
+ * @return void
+ */
+ public function show_on_mcp_topic_post(data $event)
+ {
+ if (empty($event['row']['poster_id']))
+ {
+ return;
+ }
+
+ $vars = $this->get_display_vars((int) $event['row']['poster_id']);
+ if ($vars)
+ {
+ $event['post_row'] = array_merge($event['post_row'], $vars);
+ }
+ }
+
+ /**
+ * Show the Moderator notes page in ACP > Manage users
+ *
+ * @param data $event
+ * @return void
+ */
+ public function acp_users_page(data $event)
+ {
+ if ($event['mode'] !== 'seenotes')
+ {
+ return;
+ }
+
+ $this->notes_page->handle($event['user_row'], $event['u_action'] . '&u=' . (int) $event['user_id'], true);
+ }
+
+ /**
+ * Delete the entries and notes about deleted members
+ *
+ * @param data $event
+ * @return void
+ */
+ public function delete_users(data $event)
+ {
+ $this->manager->delete_users($event['user_ids']);
+ }
+
+ /**
+ * Keep saved author names up to date when a member is renamed
+ *
+ * @param data $event
+ * @return void
+ */
+ public function update_username(data $event)
+ {
+ $this->manager->update_username($event['old_name'], $event['new_name']);
+ }
+
+ /**
+ * Template variables for a member's current reason, for viewers who may
+ * see it
+ *
+ * @param int $user_id
+ * @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'))
+ {
+ return [];
+ }
+
+ $current = $this->manager->get_current_for_users([$user_id]);
+ if (!isset($current[$user_id]))
+ {
+ return [];
+ }
+
+ $entry = $current[$user_id];
+
+ return [
+ 'S_SEENOTES' => true,
+ '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']),
+ 'U_SEENOTES' => $this->get_notes_url($user_id),
+ ];
+ }
+
+ /**
+ * Where the viewer can read the member's notes: the MCP for moderators,
+ * ACP > Manage users for administrators without moderator access
+ *
+ * @param int $user_id
+ * @return string Empty when the viewer can reach neither
+ */
+ protected function get_notes_url($user_id)
+ {
+ if ($this->auth->acl_getf_global('m_'))
+ {
+ return append_sid("{$this->root_path}mcp.{$this->php_ext}", 'i=' . self::MCP_MODULE . '&mode=notes&u=' . $user_id);
+ }
+
+ if ($this->auth->acl_get('a_') && $this->auth->acl_get('a_user') && $this->auth->acl_get('a_seenotes_view'))
+ {
+ return append_sid("{$this->root_path}adm/index.{$this->php_ext}", 'i=users&mode=seenotes&u=' . $user_id, true, $this->user->session_id);
+ }
+
+ return '';
+ }
+}
diff --git a/ext.php b/ext.php
new file mode 100644
index 0000000..f099110
--- /dev/null
+++ b/ext.php
@@ -0,0 +1,60 @@
+check_phpbb_version() && $this->check_php_version();
+
+ if (!$enableable)
+ {
+ $language = $this->container->get('language');
+ $language->add_lang('install_seenotes', 'phpbbmodders/seenotes');
+
+ return $language->lang('SEENOTES_NOT_ENABLEABLE');
+ }
+
+ return $enableable;
+ }
+
+ /**
+ * Require phpBB 3.3.19 due to several security fixes in the phpBB software.
+ *
+ * @return bool
+ */
+ public function check_phpbb_version()
+ {
+ return phpbb_version_compare(PHPBB_VERSION, '3.3.19', '>=');
+ }
+
+ /**
+ * Requires PHP 8.3.36 due to security fixes.
+ *
+ * @return bool
+ */
+ public function check_php_version()
+ {
+ return PHP_VERSION_ID >= 80336;
+ }
+}
diff --git a/language/en/acp_seenotes.php b/language/en/acp_seenotes.php
new file mode 100644
index 0000000..edd53d9
--- /dev/null
+++ b/language/en/acp_seenotes.php
@@ -0,0 +1,82 @@
+ 'Moderators give members one of these reasons and keep notes under it. A switched-off reason can no longer be given, but members who have it keep it and its notes.',
+ 'ACP_SEENOTES_SETTINGS_EXPLAIN' => 'The label shown before a member’s reason, and how reasons are managed.',
+
+ 'SEENOTES_ADD_REASON' => 'Add reason',
+ 'SEENOTES_EDIT_REASON' => 'Edit reason',
+ 'SEENOTES_NO_REASONS_YET' => 'There are no reasons yet.',
+ 'SEENOTES_REASON_TITLE' => 'Name',
+ 'SEENOTES_REASON_TITLE_EXPLAIN' => 'Shown after the label, for example “Moderation queue”.',
+ 'SEENOTES_REASON_DESCRIPTION' => 'Description',
+ 'SEENOTES_REASON_DESCRIPTION_EXPLAIN' => 'Shown to staff when they hover over the reason.',
+ 'SEENOTES_REASON_DESCRIPTION_REQUIRED' => 'Required on this board.',
+ 'SEENOTES_REASON_COLOR' => 'Color',
+ 'SEENOTES_REASON_COLOR_EXPLAIN' => 'Used for the line next to the member’s posts.',
+ 'SEENOTES_STATUS' => 'Status',
+ 'SEENOTES_STATUS_ON' => 'On',
+ 'SEENOTES_STATUS_OFF' => 'Off',
+ 'SEENOTES_SWITCH_ON' => 'Switch on',
+ 'SEENOTES_SWITCH_OFF' => 'Switch off',
+ 'SEENOTES_MOVE_UP' => 'Move up',
+ 'SEENOTES_MOVE_DOWN' => 'Move down',
+ 'SEENOTES_DELETE_REASON' => 'Delete with all entries',
+
+ 'SEENOTES_TITLE_EMPTY' => 'Enter a name for the reason.',
+ 'SEENOTES_TEXT_TOO_LONG' => 'The name and description can each be at most %d characters long.',
+ 'SEENOTES_DESCRIPTION_REQUIRED' => 'Enter a description. This board requires one for each reason.',
+ 'SEENOTES_COLOR_INVALID' => 'The color must be a hex color such as #AA0000.',
+ 'SEENOTES_REASON_ADDED' => 'The reason has been added.',
+ 'SEENOTES_REASON_UPDATED' => 'The reason has been changed.',
+ 'SEENOTES_REASON_NOT_FOUND' => 'That reason does not exist.',
+
+ 'SEENOTES_HARD_DELETE_OFF' => 'Deleting reasons is switched off in the See Notes settings.',
+ 'SEENOTES_DELETE_SUMMARY' => 'Delete reason “%1$s”? This will permanently remove it from %2$s, along with %3$s and %4$s.',
+ 'SEENOTES_COUNT_USERS' => [
+ 1 => '%d member',
+ 2 => '%d members',
+ ],
+ 'SEENOTES_COUNT_ENTRIES' => [
+ 1 => '%d entry',
+ 2 => '%d entries',
+ ],
+ 'SEENOTES_COUNT_NOTES' => [
+ 1 => '%d note',
+ 2 => '%d notes',
+ ],
+ 'SEENOTES_DELETE_CONTINUE' => 'Continue',
+ 'SEENOTES_DELETE_TYPE_NAME' => 'To confirm, type the reason’s name: %s',
+ 'SEENOTES_DELETE_FINAL' => 'Delete this reason and all its entries',
+ 'SEENOTES_DELETE_NAME_MISMATCH' => 'The name you typed does not match the reason’s name. Nothing was deleted.',
+ 'SEENOTES_REASON_DELETED' => 'The reason and all its entries and notes have been deleted.',
+
+ 'SEENOTES_LABEL' => 'Label',
+ 'SEENOTES_LABEL_EXPLAIN' => 'Shown before the reason, for example “SEE NOTES: Moderation queue”. One name for the whole board.',
+ 'SEENOTES_LABEL_INVALID' => 'Enter a label of at most %d characters.',
+ 'SEENOTES_REQUIRE_DESCRIPTION' => 'Require a description for each reason',
+ 'SEENOTES_REQUIRE_DESCRIPTION_EXPLAIN' => 'Existing reasons without one must get one the next time they are edited.',
+ 'SEENOTES_ALLOW_HARD_DELETE' => 'Allow deleting a reason with all its entries',
+ 'SEENOTES_ALLOW_HARD_DELETE_EXPLAIN' => 'Adds a delete button to each reason. Deleting permanently removes the reason from every member, with all their entries and notes under it, and asks you to type the reason’s name first.',
+]);
diff --git a/language/en/common.php b/language/en/common.php
new file mode 100644
index 0000000..333b81a
--- /dev/null
+++ b/language/en/common.php
@@ -0,0 +1,47 @@
+ [
+ 0 => 'No notes',
+ 1 => '%d note',
+ 2 => '%d notes',
+ ],
+ 'SEENOTES_OPEN_NOTES' => 'Read the moderator notes about this member',
+
+ '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',
+ 'LOG_SEENOTES_CURRENT_CLEARED' => 'Cleared a member’s current See Notes reason » %s',
+ 'LOG_SEENOTES_ENTRY_DELETED' => 'Deleted a member’s See Notes reason with its notes » %1$s, for %2$s',
+ 'LOG_SEENOTES_NOTE_ADDED' => 'Added a See Notes note » %1$s, for %2$s',
+ 'LOG_SEENOTES_NOTE_EDITED' => 'Edited a See Notes note » %1$s, for %2$s',
+ 'LOG_SEENOTES_NOTE_DELETED' => 'Deleted a See Notes note » %1$s, for %2$s',
+
+ 'LOG_SEENOTES_REASON_ADDED' => 'Added a See Notes reason » %s',
+ 'LOG_SEENOTES_REASON_EDITED' => 'Changed a See Notes reason » %s',
+ 'LOG_SEENOTES_REASON_ENABLED' => 'Switched on a See Notes reason » %s',
+ 'LOG_SEENOTES_REASON_DISABLED' => 'Switched off a See Notes reason » %s',
+ 'LOG_SEENOTES_REASON_DELETED' => 'Deleted a See Notes reason with all its entries » %1$s (members: %2$d, entries: %3$d, notes: %4$d)',
+ 'LOG_SEENOTES_SETTINGS' => 'Changed the See Notes settings ',
+]);
diff --git a/language/en/info_acp_seenotes.php b/language/en/info_acp_seenotes.php
new file mode 100644
index 0000000..495f4d4
--- /dev/null
+++ b/language/en/info_acp_seenotes.php
@@ -0,0 +1,29 @@
+ 'See Notes',
+ 'ACP_SEENOTES_REASONS' => 'Reasons',
+ 'ACP_SEENOTES_SETTINGS' => 'Settings',
+ 'ACP_USER_SEENOTES' => 'Moderator notes',
+]);
diff --git a/language/en/info_mcp_seenotes.php b/language/en/info_mcp_seenotes.php
new file mode 100644
index 0000000..6dd904f
--- /dev/null
+++ b/language/en/info_mcp_seenotes.php
@@ -0,0 +1,27 @@
+ 'Moderator notes',
+ 'MCP_SEENOTES_NOTES' => 'Moderator notes',
+]);
diff --git a/language/en/install_seenotes.php b/language/en/install_seenotes.php
new file mode 100644
index 0000000..4088ef8
--- /dev/null
+++ b/language/en/install_seenotes.php
@@ -0,0 +1,26 @@
+ 'See Notes could not be enabled. It needs phpBB 3.3.19 or later and PHP 8.3.36 or later.',
+]);
diff --git a/language/en/permissions_seenotes.php b/language/en/permissions_seenotes.php
new file mode 100644
index 0000000..149a989
--- /dev/null
+++ b/language/en/permissions_seenotes.php
@@ -0,0 +1,39 @@
+ 'Moderator notes',
+
+ 'ACL_M_SEENOTES_VIEW' => 'Can see members’ moderator notes reasons and notes',
+ 'ACL_M_SEENOTES_MANAGE' => 'Can give members a reason, set the current reason and add notes',
+ 'ACL_M_SEENOTES_NOTE_DELETE' => 'Can delete moderator notes',
+ 'ACL_M_SEENOTES_NOTE_EDIT' => 'Can edit moderator notes',
+ 'ACL_M_SEENOTES_ENTRY_DELETE' => 'Can delete a member’s reason with its notes',
+
+ 'ACL_A_SEENOTES_VIEW' => 'Can see members’ moderator notes reasons and notes',
+ 'ACL_A_SEENOTES_MANAGE' => 'Can give members a reason, set the current reason and add notes',
+ 'ACL_A_SEENOTES_NOTE_DELETE' => 'Can delete moderator notes',
+ 'ACL_A_SEENOTES_NOTE_EDIT' => 'Can edit moderator notes',
+ 'ACL_A_SEENOTES_ENTRY_DELETE' => 'Can delete a member’s reason with its notes',
+ 'ACL_A_SEENOTES_SETTINGS' => 'Can manage See Notes reasons and settings',
+]);
diff --git a/language/en/seenotes.php b/language/en/seenotes.php
new file mode 100644
index 0000000..bf40dd8
--- /dev/null
+++ b/language/en/seenotes.php
@@ -0,0 +1,83 @@
+ 'Enter a member’s username to see the reasons and notes staff have recorded about them.',
+ 'SEENOTES_ENTRIES' => 'Reasons',
+ 'SEENOTES_NO_ENTRIES' => 'This member has no reasons.',
+ 'SEENOTES_NO_NOTES' => 'No notes under this reason yet.',
+ 'SEENOTES_CURRENT' => 'Current',
+ 'SEENOTES_INACTIVE' => 'Switched off',
+ 'SEENOTES_INACTIVE_EXPLAIN' => 'This reason is switched off. It can no longer be given, but this entry and its notes are kept.',
+ 'SEENOTES_ADDED_BY' => 'Added by %1$s on %2$s',
+ 'SEENOTES_NOTE_BY' => 'By %1$s on %2$s',
+ 'SEENOTES_NOTE_EDITED_BY' => 'Last edited by %1$s on %2$s',
+ 'SEENOTES_MAKE_CURRENT' => 'Make current',
+ 'SEENOTES_CLEAR_CURRENT' => 'Show no reason',
+ 'SEENOTES_CLEAR_CURRENT_EXPLAIN' => 'Keeps all reasons and notes, but shows nothing on the member’s profile and posts.',
+ 'SEENOTES_ADD_NOTE' => 'Add a note',
+ 'SEENOTES_EDIT_NOTE' => 'Edit note',
+ 'SEENOTES_DELETE_NOTE' => 'Delete note',
+ 'SEENOTES_DELETE_ENTRY' => 'Delete reason and notes',
+ 'SEENOTES_DELETE_ENTRY_CONFIRM' => 'Delete the reason “%s” from this member, with all its notes?',
+ 'SEENOTES_DELETE_NOTE_CONFIRM' => 'Delete this note?',
+
+ 'SEENOTES_ADD_ENTRY' => 'Give a reason',
+ 'SEENOTES_ADD_ENTRY_EXPLAIN' => 'Reasons are set up by administrators. Add notes under the reason once it is given.',
+ 'SEENOTES_REASON' => 'Reason',
+ 'SEENOTES_SELECT_REASON' => 'Select a reason',
+ 'SEENOTES_MAKE_IT_CURRENT' => 'Make it the current reason',
+ 'SEENOTES_NO_REASONS' => 'There are no reasons to give. Administrators can add them in ACP > Extensions > See Notes > Reasons.',
+
+ 'SEENOTES_NOTE_FOR' => 'Under reason',
+ 'SEENOTES_NOTE_TEXT' => 'Note',
+ 'SEENOTES_ENTRY_OPTION' => '%1$s (%2$s)',
+ 'SEENOTES_FORMAT_STATUS' => 'BBCode is %1$s, smilies are %2$s, links are %3$s.',
+ 'SEENOTES_ON' => 'on',
+ 'SEENOTES_OFF' => 'off',
+ 'SEENOTES_SAVE_NOTE' => 'Save note',
+
+ 'SEENOTES_USERNOTES' => 'phpBB user notes',
+ 'SEENOTES_USERNOTES_EXPLAIN' => 'The latest user notes, including warnings and bans phpBB records itself. They are not changed here.',
+ 'SEENOTES_USERNOTES_TOTAL' => [
+ 0 => 'No user notes',
+ 1 => '%d user note',
+ 2 => '%d user notes',
+ ],
+ 'SEENOTES_USERNOTES_ALL' => 'See all user notes',
+ 'SEENOTES_NO_USERNOTES' => 'This member has no user notes.',
+
+ 'SEENOTES_ENTRY_ADDED' => 'The reason has been given.',
+ 'SEENOTES_CURRENT_SET' => 'The current reason has been changed.',
+ 'SEENOTES_CURRENT_CLEARED' => 'The member now has no current reason.',
+ 'SEENOTES_ENTRY_DELETED' => 'The reason and its notes have been deleted.',
+ 'SEENOTES_NOTE_ADDED' => 'The note has been added.',
+ 'SEENOTES_NOTE_EDITED' => 'The note has been changed.',
+ 'SEENOTES_NOTE_DELETED' => 'The note has been deleted.',
+
+ 'SEENOTES_REASON_NOT_SELECTED' => 'Select a reason that is switched on.',
+ 'SEENOTES_ENTRY_NOT_FOUND' => 'That reason entry does not exist for this member.',
+ 'SEENOTES_NOTE_NOT_FOUND' => 'That note does not exist for this member.',
+ 'SEENOTES_NOTE_EMPTY' => 'The note is empty.',
+]);
diff --git a/manager.php b/manager.php
new file mode 100644
index 0000000..9fae90d
--- /dev/null
+++ b/manager.php
@@ -0,0 +1,825 @@
+auth = $auth;
+ $this->config = $config;
+ $this->db = $db;
+ $this->log = $log;
+ $this->user = $user;
+ $this->reasons_table = $reasons_table;
+ $this->entries_table = $entries_table;
+ $this->notes_table = $notes_table;
+ }
+
+ /**
+ * Whether the current user may take an action: the moderator permission
+ * in any forum, or the matching administrator permission
+ *
+ * @param string $action One of self::ACTIONS
+ * @return bool
+ */
+ public function can($action)
+ {
+ if (!in_array($action, self::ACTIONS, true) || empty($this->user->data['is_registered']))
+ {
+ return false;
+ }
+
+ return (bool) $this->auth->acl_getf_global('m_seenotes_' . $action) || (bool) $this->auth->acl_get('a_seenotes_' . $action);
+ }
+
+ /**
+ * The label shown before the reason
+ *
+ * @return string
+ */
+ public function get_label()
+ {
+ $label = (string) $this->config['seenotes_label'];
+
+ return $label !== '' ? $label : 'SEE NOTES';
+ }
+
+ /**
+ * A reason color as six hex digits, or the default when it is not valid
+ *
+ * @param string $color
+ * @return string
+ */
+ public function clean_color($color)
+ {
+ $color = strtoupper(ltrim(trim((string) $color), '#'));
+
+ return preg_match('/\A[0-9A-F]{6}\z/', $color) ? $color : self::DEFAULT_COLOR;
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Reasons */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * All reasons in their set order
+ *
+ * @param bool $active_only Leave out reasons that are switched off
+ * @return array Rows by reason ID
+ */
+ public function get_reasons($active_only = false)
+ {
+ $sql = 'SELECT *
+ FROM ' . $this->reasons_table .
+ ($active_only ? ' WHERE reason_active = 1' : '') . '
+ ORDER BY reason_order ASC, reason_id ASC';
+ $result = $this->db->sql_query($sql);
+
+ $reasons = [];
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $reasons[(int) $row['reason_id']] = $row;
+ }
+ $this->db->sql_freeresult($result);
+
+ return $reasons;
+ }
+
+ /**
+ * One reason
+ *
+ * @param int $reason_id
+ * @return array|null
+ */
+ public function get_reason($reason_id)
+ {
+ $sql = 'SELECT *
+ FROM ' . $this->reasons_table . '
+ WHERE reason_id = ' . (int) $reason_id;
+ $result = $this->db->sql_query($sql);
+ $row = $this->db->sql_fetchrow($result);
+ $this->db->sql_freeresult($result);
+
+ return $row ?: null;
+ }
+
+ /**
+ * Add a reason at the end of the list, or change one
+ *
+ * @param array $data reason_title, reason_description, reason_color
+ * @param int $reason_id 0 to add a reason
+ * @return int The reason's ID
+ */
+ public function save_reason(array $data, $reason_id = 0)
+ {
+ $sql_ary = [
+ 'reason_title' => (string) $data['reason_title'],
+ 'reason_description' => (string) $data['reason_description'],
+ 'reason_color' => $this->clean_color($data['reason_color']),
+ ];
+
+ if ($reason_id)
+ {
+ $sql = 'UPDATE ' . $this->reasons_table . '
+ SET ' . $this->db->sql_build_array('UPDATE', $sql_ary) . '
+ WHERE reason_id = ' . (int) $reason_id;
+ $this->db->sql_query($sql);
+
+ return (int) $reason_id;
+ }
+
+ $sql = 'SELECT MAX(reason_order) AS max_order
+ FROM ' . $this->reasons_table;
+ $result = $this->db->sql_query($sql);
+ $max_order = (int) $this->db->sql_fetchfield('max_order');
+ $this->db->sql_freeresult($result);
+
+ $sql_ary['reason_order'] = $max_order + 1;
+ $sql_ary['reason_active'] = 1;
+
+ $this->db->sql_query('INSERT INTO ' . $this->reasons_table . ' ' . $this->db->sql_build_array('INSERT', $sql_ary));
+
+ return (int) $this->db->sql_nextid();
+ }
+
+ /**
+ * Move a reason one place up or down the list
+ *
+ * @param int $reason_id
+ * @param bool $up
+ * @return bool Whether it moved
+ */
+ public function move_reason($reason_id, $up)
+ {
+ $ids = array_keys($this->get_reasons());
+ $position = array_search((int) $reason_id, $ids, true);
+ if ($position === false)
+ {
+ return false;
+ }
+
+ $swap = $up ? $position - 1 : $position + 1;
+ if (!isset($ids[$swap]))
+ {
+ return false;
+ }
+
+ list($ids[$position], $ids[$swap]) = [$ids[$swap], $ids[$position]];
+
+ // Renumber the whole list, which also repairs equal order values
+ $this->db->sql_transaction('begin');
+ foreach ($ids as $order => $id)
+ {
+ $sql = 'UPDATE ' . $this->reasons_table . '
+ SET reason_order = ' . ($order + 1) . '
+ WHERE reason_id = ' . (int) $id;
+ $this->db->sql_query($sql);
+ }
+ $this->db->sql_transaction('commit');
+
+ return true;
+ }
+
+ /**
+ * Switch a reason on or off
+ *
+ * @param int $reason_id
+ * @param bool $active
+ * @return void
+ */
+ public function set_reason_active($reason_id, $active)
+ {
+ $sql = 'UPDATE ' . $this->reasons_table . '
+ SET reason_active = ' . ($active ? 1 : 0) . '
+ WHERE reason_id = ' . (int) $reason_id;
+ $this->db->sql_query($sql);
+ }
+
+ /**
+ * How many members, entries and notes use a reason
+ *
+ * @param int $reason_id
+ * @return array users, entries, notes
+ */
+ public function count_reason_usage($reason_id)
+ {
+ $sql = 'SELECT COUNT(DISTINCT user_id) AS users, COUNT(entry_id) AS entries
+ FROM ' . $this->entries_table . '
+ WHERE reason_id = ' . (int) $reason_id;
+ $result = $this->db->sql_query($sql);
+ $row = $this->db->sql_fetchrow($result);
+ $this->db->sql_freeresult($result);
+
+ $sql = 'SELECT COUNT(n.note_id) AS notes
+ FROM ' . $this->notes_table . ' n, ' . $this->entries_table . ' e
+ WHERE n.entry_id = e.entry_id
+ AND e.reason_id = ' . (int) $reason_id;
+ $result = $this->db->sql_query($sql);
+ $notes = (int) $this->db->sql_fetchfield('notes');
+ $this->db->sql_freeresult($result);
+
+ return [
+ 'users' => (int) $row['users'],
+ 'entries' => (int) $row['entries'],
+ 'notes' => $notes,
+ ];
+ }
+
+ /**
+ * Delete a reason with all its entries and their notes
+ *
+ * @param int $reason_id
+ * @return void
+ */
+ public function delete_reason($reason_id)
+ {
+ $entry_ids = $this->get_entry_ids('reason_id = ' . (int) $reason_id);
+
+ $this->db->sql_transaction('begin');
+ $this->delete_entry_rows($entry_ids);
+ $this->db->sql_query('DELETE FROM ' . $this->reasons_table . ' WHERE reason_id = ' . (int) $reason_id);
+ $this->db->sql_transaction('commit');
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Entries */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * A member's entries, newest first, with their reason and author
+ *
+ * @param int $user_id
+ * @return array Rows by entry ID
+ */
+ public function get_entries($user_id)
+ {
+ $sql_ary = [
+ 'SELECT' => 'e.*, r.reason_title, r.reason_description, r.reason_color, r.reason_active, u.username, u.user_colour',
+ 'FROM' => [$this->entries_table => 'e'],
+ 'LEFT_JOIN' => [
+ [
+ 'FROM' => [$this->reasons_table => 'r'],
+ 'ON' => 'r.reason_id = e.reason_id',
+ ],
+ [
+ 'FROM' => [USERS_TABLE => 'u'],
+ 'ON' => 'u.user_id = e.entry_author_id',
+ ],
+ ],
+ 'WHERE' => 'e.user_id = ' . (int) $user_id,
+ 'ORDER_BY' => 'e.entry_time DESC, e.entry_id DESC',
+ ];
+ $result = $this->db->sql_query($this->db->sql_build_query('SELECT', $sql_ary));
+
+ $entries = [];
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $entries[(int) $row['entry_id']] = $row;
+ }
+ $this->db->sql_freeresult($result);
+
+ return $entries;
+ }
+
+ /**
+ * One entry
+ *
+ * @param int $entry_id
+ * @return array|null
+ */
+ public function get_entry($entry_id)
+ {
+ $sql = 'SELECT e.*, r.reason_title
+ FROM ' . $this->entries_table . ' e
+ LEFT JOIN ' . $this->reasons_table . ' r
+ ON (r.reason_id = e.reason_id)
+ WHERE e.entry_id = ' . (int) $entry_id;
+ $result = $this->db->sql_query($sql);
+ $row = $this->db->sql_fetchrow($result);
+ $this->db->sql_freeresult($result);
+
+ return $row ?: null;
+ }
+
+ /**
+ * Give a member a reason
+ *
+ * @param int $user_id
+ * @param int $reason_id
+ * @param bool $current Make it the member's current entry
+ * @return int The entry's ID
+ */
+ public function add_entry($user_id, $reason_id, $current)
+ {
+ $sql_ary = [
+ 'user_id' => (int) $user_id,
+ 'reason_id' => (int) $reason_id,
+ 'entry_time' => time(),
+ 'entry_author_id' => (int) $this->user->data['user_id'],
+ 'entry_author_name' => (string) $this->user->data['username'],
+ 'entry_current' => 0,
+ ];
+
+ $this->db->sql_transaction('begin');
+ $this->db->sql_query('INSERT INTO ' . $this->entries_table . ' ' . $this->db->sql_build_array('INSERT', $sql_ary));
+ $entry_id = (int) $this->db->sql_nextid();
+
+ if ($current)
+ {
+ $this->set_current($user_id, $entry_id);
+ }
+ $this->db->sql_transaction('commit');
+
+ return $entry_id;
+ }
+
+ /**
+ * Make one of a member's entries current, or none
+ *
+ * @param int $user_id
+ * @param int $entry_id 0 for none
+ * @return void
+ */
+ public function set_current($user_id, $entry_id)
+ {
+ $this->db->sql_transaction('begin');
+
+ $sql = 'UPDATE ' . $this->entries_table . '
+ SET entry_current = 0
+ WHERE user_id = ' . (int) $user_id;
+ $this->db->sql_query($sql);
+
+ if ($entry_id)
+ {
+ $sql = 'UPDATE ' . $this->entries_table . '
+ SET entry_current = 1
+ WHERE user_id = ' . (int) $user_id . '
+ AND entry_id = ' . (int) $entry_id;
+ $this->db->sql_query($sql);
+ }
+
+ $this->db->sql_transaction('commit');
+
+ unset($this->current_cache[(int) $user_id]);
+ }
+
+ /**
+ * Delete an entry with its notes
+ *
+ * @param int $entry_id
+ * @return void
+ */
+ public function delete_entry($entry_id)
+ {
+ $this->db->sql_transaction('begin');
+ $this->delete_entry_rows([(int) $entry_id]);
+ $this->db->sql_transaction('commit');
+ }
+
+ /**
+ * The current entry of each member that has one, with its reason and
+ * number of notes; read once per member
+ *
+ * @param array $user_ids
+ * @return array Rows by user ID, members without a current entry left out
+ */
+ public function get_current_for_users(array $user_ids)
+ {
+ $user_ids = array_unique(array_map('intval', $user_ids));
+ $missing = array_values(array_diff($user_ids, array_keys($this->current_cache)));
+
+ if ($missing)
+ {
+ foreach ($missing as $user_id)
+ {
+ $this->current_cache[$user_id] = false;
+ }
+
+ $sql = 'SELECT e.entry_id, e.user_id, r.reason_title, r.reason_description, r.reason_color
+ FROM ' . $this->entries_table . ' e, ' . $this->reasons_table . ' r
+ WHERE r.reason_id = e.reason_id
+ AND e.entry_current = 1
+ AND ' . $this->db->sql_in_set('e.user_id', $missing);
+ $result = $this->db->sql_query($sql);
+
+ $by_entry = [];
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $row['note_count'] = 0;
+ $by_entry[(int) $row['entry_id']] = (int) $row['user_id'];
+ $this->current_cache[(int) $row['user_id']] = $row;
+ }
+ $this->db->sql_freeresult($result);
+
+ if ($by_entry)
+ {
+ $sql = 'SELECT entry_id, COUNT(note_id) AS note_count
+ FROM ' . $this->notes_table . '
+ WHERE ' . $this->db->sql_in_set('entry_id', array_keys($by_entry)) . '
+ GROUP BY entry_id';
+ $result = $this->db->sql_query($sql);
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $this->current_cache[$by_entry[(int) $row['entry_id']]]['note_count'] = (int) $row['note_count'];
+ }
+ $this->db->sql_freeresult($result);
+ }
+ }
+
+ $current = [];
+ foreach ($user_ids as $user_id)
+ {
+ if ($this->current_cache[$user_id] !== false)
+ {
+ $current[$user_id] = $this->current_cache[$user_id];
+ }
+ }
+
+ return $current;
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Notes */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * The notes of some entries, newest first, with their author and editor
+ *
+ * @param array $entry_ids
+ * @return array Lists of rows by entry ID
+ */
+ public function get_notes(array $entry_ids)
+ {
+ if (!$entry_ids)
+ {
+ return [];
+ }
+
+ $sql_ary = [
+ 'SELECT' => 'n.*, a.username AS author_username, a.user_colour AS author_colour, ed.username AS editor_username, ed.user_colour AS editor_colour',
+ 'FROM' => [$this->notes_table => 'n'],
+ 'LEFT_JOIN' => [
+ [
+ 'FROM' => [USERS_TABLE => 'a'],
+ 'ON' => 'a.user_id = n.note_author_id',
+ ],
+ [
+ 'FROM' => [USERS_TABLE => 'ed'],
+ 'ON' => 'ed.user_id = n.note_edit_user_id',
+ ],
+ ],
+ 'WHERE' => $this->db->sql_in_set('n.entry_id', array_map('intval', $entry_ids)),
+ 'ORDER_BY' => 'n.note_time DESC, n.note_id DESC',
+ ];
+ $result = $this->db->sql_query($this->db->sql_build_query('SELECT', $sql_ary));
+
+ $notes = [];
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $notes[(int) $row['entry_id']][] = $row;
+ }
+ $this->db->sql_freeresult($result);
+
+ return $notes;
+ }
+
+ /**
+ * One note, with the member its entry is about
+ *
+ * @param int $note_id
+ * @return array|null
+ */
+ public function get_note($note_id)
+ {
+ $sql = 'SELECT n.*, e.user_id, r.reason_title
+ FROM ' . $this->notes_table . ' n
+ INNER JOIN ' . $this->entries_table . ' e
+ ON (e.entry_id = n.entry_id)
+ LEFT JOIN ' . $this->reasons_table . ' r
+ ON (r.reason_id = e.reason_id)
+ WHERE n.note_id = ' . (int) $note_id;
+ $result = $this->db->sql_query($sql);
+ $row = $this->db->sql_fetchrow($result);
+ $this->db->sql_freeresult($result);
+
+ return $row ?: null;
+ }
+
+ /**
+ * Parse note text with the board's private message settings
+ *
+ * @param string $text Text as typed, from request->variable(..., true)
+ * @return array text, uid, bitfield, options, errors (untranslated keys or messages)
+ */
+ public function parse_note($text)
+ {
+ $uid = $bitfield = '';
+ $options = 0;
+
+ $allow_bbcode = $this->config['allow_bbcode'] && $this->config['auth_bbcode_pm'];
+ $allow_smilies = $this->config['allow_smilies'] && $this->config['auth_smilies_pm'];
+ $allow_urls = (bool) $this->config['allow_post_links'];
+
+ $errors = [];
+ if (trim($text) === '')
+ {
+ $errors[] = 'SEENOTES_NOTE_EMPTY';
+ }
+
+ $warnings = generate_text_for_storage(
+ $text,
+ $uid,
+ $bitfield,
+ $options,
+ $allow_bbcode,
+ $allow_urls,
+ $allow_smilies,
+ (bool) $this->config['auth_img_pm'],
+ (bool) $this->config['auth_flash_pm'],
+ true,
+ $allow_urls,
+ 'pm'
+ );
+
+ return [
+ 'text' => $text,
+ 'uid' => $uid,
+ 'bitfield' => $bitfield,
+ 'options' => (int) $options,
+ 'errors' => array_merge($errors, (array) $warnings),
+ ];
+ }
+
+ /**
+ * The BBCode, smilies and links settings notes are written with
+ *
+ * @return array bbcode, smilies, urls, img, flash, quote
+ */
+ public function get_editor_settings()
+ {
+ $bbcode = $this->config['allow_bbcode'] && $this->config['auth_bbcode_pm'];
+
+ return [
+ 'bbcode' => (bool) $bbcode,
+ 'smilies' => $this->config['allow_smilies'] && $this->config['auth_smilies_pm'],
+ 'urls' => (bool) $this->config['allow_post_links'],
+ 'img' => $bbcode && $this->config['auth_img_pm'],
+ 'flash' => $bbcode && $this->config['auth_flash_pm'],
+ 'quote' => (bool) $bbcode,
+ ];
+ }
+
+ /**
+ * Add a note under an entry
+ *
+ * @param int $entry_id
+ * @param array $parsed From parse_note()
+ * @return int The note's ID
+ */
+ public function add_note($entry_id, array $parsed)
+ {
+ $sql_ary = [
+ 'entry_id' => (int) $entry_id,
+ 'note_author_id' => (int) $this->user->data['user_id'],
+ 'note_author_name' => (string) $this->user->data['username'],
+ 'note_time' => time(),
+ 'note_text' => $parsed['text'],
+ 'note_bbcode_uid' => $parsed['uid'],
+ 'note_bbcode_bitfield' => $parsed['bitfield'],
+ 'note_bbcode_options' => (int) $parsed['options'],
+ ];
+
+ $this->db->sql_query('INSERT INTO ' . $this->notes_table . ' ' . $this->db->sql_build_array('INSERT', $sql_ary));
+ $this->current_cache = [];
+
+ return (int) $this->db->sql_nextid();
+ }
+
+ /**
+ * Change a note's text, recording who edited it and when
+ *
+ * @param int $note_id
+ * @param array $parsed From parse_note()
+ * @return void
+ */
+ public function edit_note($note_id, array $parsed)
+ {
+ $sql_ary = [
+ 'note_text' => $parsed['text'],
+ 'note_bbcode_uid' => $parsed['uid'],
+ 'note_bbcode_bitfield' => $parsed['bitfield'],
+ 'note_bbcode_options' => (int) $parsed['options'],
+ 'note_edit_time' => time(),
+ 'note_edit_user_id' => (int) $this->user->data['user_id'],
+ 'note_edit_user_name' => (string) $this->user->data['username'],
+ ];
+
+ $sql = 'UPDATE ' . $this->notes_table . '
+ SET ' . $this->db->sql_build_array('UPDATE', $sql_ary) . '
+ WHERE note_id = ' . (int) $note_id;
+ $this->db->sql_query($sql);
+ }
+
+ /**
+ * Delete a note
+ *
+ * @param int $note_id
+ * @return void
+ */
+ public function delete_note($note_id)
+ {
+ $this->db->sql_query('DELETE FROM ' . $this->notes_table . ' WHERE note_id = ' . (int) $note_id);
+ $this->current_cache = [];
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Members */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * Delete the entries and notes about deleted members
+ *
+ * @param array $user_ids
+ * @return void
+ */
+ public function delete_users(array $user_ids)
+ {
+ if (!$user_ids)
+ {
+ return;
+ }
+
+ $entry_ids = $this->get_entry_ids($this->db->sql_in_set('user_id', array_map('intval', $user_ids)));
+
+ $this->db->sql_transaction('begin');
+ $this->delete_entry_rows($entry_ids);
+ $this->db->sql_transaction('commit');
+ }
+
+ /**
+ * Keep the saved names of a renamed member up to date
+ *
+ * @param string $old_name
+ * @param string $new_name
+ * @return void
+ */
+ public function update_username($old_name, $new_name)
+ {
+ $updates = [
+ [$this->entries_table, 'entry_author_name'],
+ [$this->notes_table, 'note_author_name'],
+ [$this->notes_table, 'note_edit_user_name'],
+ ];
+
+ foreach ($updates as $update)
+ {
+ list($table, $column) = $update;
+
+ $sql = 'UPDATE ' . $table . "
+ SET $column = '" . $this->db->sql_escape($new_name) . "'
+ WHERE $column = '" . $this->db->sql_escape($old_name) . "'";
+ $this->db->sql_query($sql);
+ }
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Logs */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * Add an entry to the moderator log
+ *
+ * @param string $operation Language key
+ * @param array $data Values for the language key
+ * @return void
+ */
+ public function log_moderator($operation, array $data)
+ {
+ $this->log->add('mod', (int) $this->user->data['user_id'], $this->user->ip, $operation, false, array_merge(['forum_id' => 0, 'topic_id' => 0], array_values($data)));
+ }
+
+ /**
+ * Add an entry to the admin log
+ *
+ * @param string $operation Language key
+ * @param array $data Values for the language key
+ * @return void
+ */
+ public function log_admin($operation, array $data = [])
+ {
+ $this->log->add('admin', (int) $this->user->data['user_id'], $this->user->ip, $operation, false, array_values($data));
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Helpers */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * Entry IDs matching a condition
+ *
+ * @param string $where SQL condition on the entries table
+ * @return array
+ */
+ protected function get_entry_ids($where)
+ {
+ $sql = 'SELECT entry_id
+ FROM ' . $this->entries_table . '
+ WHERE ' . $where;
+ $result = $this->db->sql_query($sql);
+
+ $entry_ids = [];
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $entry_ids[] = (int) $row['entry_id'];
+ }
+ $this->db->sql_freeresult($result);
+
+ return $entry_ids;
+ }
+
+ /**
+ * Delete entries and their notes; the caller holds the transaction
+ *
+ * @param array $entry_ids
+ * @return void
+ */
+ protected function delete_entry_rows(array $entry_ids)
+ {
+ if (!$entry_ids)
+ {
+ return;
+ }
+
+ $this->db->sql_query('DELETE FROM ' . $this->notes_table . ' WHERE ' . $this->db->sql_in_set('entry_id', $entry_ids));
+ $this->db->sql_query('DELETE FROM ' . $this->entries_table . ' WHERE ' . $this->db->sql_in_set('entry_id', $entry_ids));
+ $this->current_cache = [];
+ }
+}
diff --git a/mcp/main_info.php b/mcp/main_info.php
new file mode 100644
index 0000000..bfdbf4f
--- /dev/null
+++ b/mcp/main_info.php
@@ -0,0 +1,32 @@
+ '\phpbbmodders\seenotes\mcp\main_module',
+ 'title' => 'MCP_SEENOTES',
+ 'modes' => [
+ 'notes' => [
+ 'title' => 'MCP_SEENOTES_NOTES',
+ 'auth' => 'ext_phpbbmodders/seenotes && (aclf_m_seenotes_view || acl_a_seenotes_view)',
+ 'cat' => ['MCP_SEENOTES'],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/mcp/main_module.php b/mcp/main_module.php
new file mode 100644
index 0000000..c733374
--- /dev/null
+++ b/mcp/main_module.php
@@ -0,0 +1,67 @@
+add_lang('seenotes', 'phpbbmodders/seenotes');
+ $this->tpl_name = '@phpbbmodders_seenotes/mcp_seenotes';
+ $this->page_title = 'MCP_SEENOTES_NOTES';
+
+ $user_id = $request->variable('u', 0);
+ $username = $request->variable('username', '', true);
+
+ if (!$user_id && $username === '')
+ {
+ $template->assign_vars([
+ 'S_SEENOTES_FRONT' => true,
+ 'U_SEENOTES_FIND' => $this->u_action,
+ 'U_FIND_USERNAME' => append_sid("{$phpbb_root_path}memberlist.$phpEx", 'mode=searchuser&form=mcp&field=username&select_single=true'),
+ ]);
+
+ return;
+ }
+
+ $where = $user_id ? 'user_id = ' . (int) $user_id : "username_clean = '" . $db->sql_escape(utf8_clean_string($username)) . "'";
+ $sql = 'SELECT user_id, username, user_colour
+ FROM ' . USERS_TABLE . '
+ WHERE ' . $where . '
+ AND user_id <> ' . ANONYMOUS;
+ $result = $db->sql_query($sql);
+ $member = $db->sql_fetchrow($result);
+ $db->sql_freeresult($result);
+
+ if (!$member)
+ {
+ trigger_error($language->lang('NO_USER') . ' ' . $language->lang('RETURN_PAGE', '', ' '), E_USER_WARNING);
+ }
+
+ /** @var \phpbbmodders\seenotes\notes_page $page */
+ $page = $phpbb_container->get('phpbbmodders.seenotes.notes_page');
+ $page->handle($member, $this->u_action . '&u=' . (int) $member['user_id'], false);
+ }
+}
diff --git a/migrations/v10x/install.php b/migrations/v10x/install.php
new file mode 100644
index 0000000..ce7ac32
--- /dev/null
+++ b/migrations/v10x/install.php
@@ -0,0 +1,208 @@
+db_tools->sql_table_exists($this->table_prefix . 'seenotes_reasons');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function depends_on()
+ {
+ return ['\phpbb\db\migration\data\v330\v330'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function update_schema()
+ {
+ return [
+ 'add_tables' => [
+ // The reasons administrators set up
+ $this->table_prefix . 'seenotes_reasons' => [
+ 'COLUMNS' => [
+ 'reason_id' => ['UINT', null, 'auto_increment'],
+ 'reason_title' => ['VCHAR_UNI', ''],
+ 'reason_description' => ['VCHAR_UNI', ''],
+ 'reason_color' => ['VCHAR:6', 'AA0000'],
+ 'reason_order' => ['UINT', 0],
+ 'reason_active' => ['BOOL', 1],
+ ],
+ 'PRIMARY_KEY' => 'reason_id',
+ 'KEYS' => [
+ 'reason_order' => ['INDEX', 'reason_order'],
+ ],
+ ],
+ // A reason given to a member; at most one per member is current
+ $this->table_prefix . 'seenotes_entries' => [
+ 'COLUMNS' => [
+ 'entry_id' => ['UINT', null, 'auto_increment'],
+ 'user_id' => ['UINT', 0],
+ 'reason_id' => ['UINT', 0],
+ 'entry_time' => ['TIMESTAMP', 0],
+ 'entry_author_id' => ['UINT', 0],
+ 'entry_author_name' => ['VCHAR_UNI', ''],
+ 'entry_current' => ['BOOL', 0],
+ ],
+ 'PRIMARY_KEY' => 'entry_id',
+ 'KEYS' => [
+ 'user_id' => ['INDEX', 'user_id'],
+ 'reason_id' => ['INDEX', 'reason_id'],
+ 'user_current' => ['INDEX', ['user_id', 'entry_current']],
+ ],
+ ],
+ // Notes under an entry, stored the way phpBB stores private messages
+ $this->table_prefix . 'seenotes_notes' => [
+ 'COLUMNS' => [
+ 'note_id' => ['UINT', null, 'auto_increment'],
+ 'entry_id' => ['UINT', 0],
+ 'note_author_id' => ['UINT', 0],
+ 'note_author_name' => ['VCHAR_UNI', ''],
+ 'note_time' => ['TIMESTAMP', 0],
+ 'note_text' => ['MTEXT_UNI', ''],
+ 'note_bbcode_uid' => ['VCHAR:8', ''],
+ 'note_bbcode_bitfield' => ['VCHAR:255', ''],
+ 'note_bbcode_options' => ['UINT:11', 7],
+ 'note_edit_time' => ['TIMESTAMP', 0],
+ 'note_edit_user_id' => ['UINT', 0],
+ 'note_edit_user_name' => ['VCHAR_UNI', ''],
+ ],
+ 'PRIMARY_KEY' => 'note_id',
+ 'KEYS' => [
+ 'entry_id' => ['INDEX', 'entry_id'],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function revert_schema()
+ {
+ return [
+ 'drop_tables' => [
+ $this->table_prefix . 'seenotes_notes',
+ $this->table_prefix . 'seenotes_entries',
+ $this->table_prefix . 'seenotes_reasons',
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function update_data()
+ {
+ $data = [
+ // The label shown before the reason
+ ['config.add', ['seenotes_label', 'SEE NOTES']],
+ // Reasons must have a description (0 no, 1 yes)
+ ['config.add', ['seenotes_require_description', 0]],
+ // Reasons can be deleted with all their entries (0 no, 1 yes)
+ ['config.add', ['seenotes_allow_hard_delete', 0]],
+ ];
+
+ // Moderator permissions, global and per forum like phpBB's own; having
+ // one in any forum is enough
+ $moderator_permissions = [
+ 'm_seenotes_view' => ['ROLE_MOD_STANDARD', 'ROLE_MOD_FULL'],
+ 'm_seenotes_manage' => ['ROLE_MOD_STANDARD', 'ROLE_MOD_FULL'],
+ 'm_seenotes_note_delete' => ['ROLE_MOD_STANDARD', 'ROLE_MOD_FULL'],
+ 'm_seenotes_note_edit' => ['ROLE_MOD_FULL'],
+ 'm_seenotes_entry_delete' => ['ROLE_MOD_FULL'],
+ ];
+
+ foreach ($moderator_permissions as $permission => $roles)
+ {
+ $data[] = ['permission.add', [$permission, true]];
+ $data[] = ['permission.add', [$permission, false]];
+ $data = array_merge($data, $this->set_in_roles($permission, $roles));
+ }
+
+ // Matching administrator permissions, since the Administrators group
+ // has no moderator role by default
+ $admin_permissions = [
+ 'a_seenotes_view',
+ 'a_seenotes_manage',
+ 'a_seenotes_note_delete',
+ 'a_seenotes_note_edit',
+ 'a_seenotes_entry_delete',
+ 'a_seenotes_settings',
+ ];
+
+ foreach ($admin_permissions as $permission)
+ {
+ $data[] = ['permission.add', [$permission, true]];
+ $data = array_merge($data, $this->set_in_roles($permission, ['ROLE_ADMIN_STANDARD', 'ROLE_ADMIN_FULL']));
+ }
+
+ return array_merge($data, [
+ ['module.add', ['acp', 'ACP_CAT_DOT_MODS', 'ACP_SEENOTES']],
+ ['module.add', ['acp', 'ACP_SEENOTES', [
+ 'module_basename' => '\phpbbmodders\seenotes\acp\main_module',
+ 'modes' => ['reasons', 'settings'],
+ ]]],
+
+ // A page in ACP > Manage users' form list, handled through
+ // core.acp_users_mode_add
+ ['module.add', ['acp', 'ACP_CAT_USERS', [
+ 'module_basename' => 'acp_users',
+ 'module_langname' => 'ACP_USER_SEENOTES',
+ 'module_mode' => 'seenotes',
+ 'module_auth' => 'ext_phpbbmodders/seenotes && acl_a_user && acl_a_seenotes_view',
+ 'module_display' => 0,
+ ]]],
+
+ ['module.add', ['mcp', 0, 'MCP_SEENOTES']],
+ ['module.add', ['mcp', 'MCP_SEENOTES', [
+ 'module_basename' => '\phpbbmodders\seenotes\mcp\main_module',
+ 'modes' => ['notes'],
+ ]]],
+ ]);
+ }
+
+ /**
+ * Steps that give a permission to roles that exist on this board
+ *
+ * @param string $permission
+ * @param array $roles
+ * @return array
+ */
+ protected function set_in_roles($permission, array $roles)
+ {
+ $steps = [];
+
+ foreach ($roles as $role)
+ {
+ $steps[] = ['if', [
+ ['permission.role_exists', [$role]],
+ ['permission.permission_set', [$role, $permission]],
+ ]];
+ }
+
+ return $steps;
+ }
+}
diff --git a/notes_page.php b/notes_page.php
new file mode 100644
index 0000000..1974112
--- /dev/null
+++ b/notes_page.php
@@ -0,0 +1,642 @@
+language = $language;
+ $this->log = $log;
+ $this->request = $request;
+ $this->template = $template;
+ $this->user = $user;
+ $this->manager = $manager;
+ $this->root_path = $root_path;
+ $this->php_ext = $php_ext;
+ }
+
+ /**
+ * Run the requested action, then show the member's entries and notes
+ *
+ * @param array $member Users table row of the member
+ * @param string $u_action Page URL, including the member's ID
+ * @param bool $in_acp Shown in the ACP rather than the MCP
+ * @return void
+ */
+ public function handle(array $member, $u_action, $in_acp)
+ {
+ $this->member = $member;
+ $this->u_action = $u_action;
+ $this->in_acp = $in_acp;
+
+ $this->language->add_lang(['posting', 'mcp']);
+ $this->language->add_lang('seenotes', 'phpbbmodders/seenotes');
+
+ if (!$this->manager->can('view'))
+ {
+ $this->error('NOT_AUTHORISED');
+ }
+
+ add_form_key(self::FORM_KEY);
+
+ $errors = [];
+ $editor_text = null;
+ $action = $this->request->variable('action', '');
+
+ switch ($action)
+ {
+ case 'add_entry':
+ $this->add_entry();
+ break;
+
+ case 'set_current':
+ $this->set_current();
+ break;
+
+ case 'delete_entry':
+ $this->delete_entry();
+ break;
+
+ case 'add_note':
+ case 'edit_note':
+ $editor_text = $this->save_note($action === 'edit_note', $errors);
+ break;
+
+ case 'delete_note':
+ $this->delete_note();
+ break;
+ }
+
+ $this->display($errors, $editor_text);
+ }
+
+ /**
+ * Give the member a reason
+ *
+ * @return void
+ */
+ protected function add_entry()
+ {
+ $this->require_post_permission('manage');
+
+ $reason_id = $this->request->variable('reason_id', 0);
+ $reasons = $this->manager->get_reasons(true);
+ if (!isset($reasons[$reason_id]))
+ {
+ $this->error('SEENOTES_REASON_NOT_SELECTED');
+ }
+
+ $current = (bool) $this->request->variable('entry_current', false);
+ $this->manager->add_entry((int) $this->member['user_id'], $reason_id, $current);
+ $this->manager->log_moderator('LOG_SEENOTES_ENTRY_ADDED', [$reasons[$reason_id]['reason_title'], $this->member['username']]);
+
+ $this->success('SEENOTES_ENTRY_ADDED');
+ }
+
+ /**
+ * Make one of the member's entries current, or none
+ *
+ * @return void
+ */
+ protected function set_current()
+ {
+ $this->require_post_permission('manage');
+
+ $entry_id = $this->request->variable('entry_id', 0);
+ $entry = null;
+ if ($entry_id)
+ {
+ $entry = $this->get_member_entry($entry_id);
+ }
+
+ $this->manager->set_current((int) $this->member['user_id'], $entry_id);
+
+ if ($entry)
+ {
+ $this->manager->log_moderator('LOG_SEENOTES_CURRENT_SET', [$entry['reason_title'], $this->member['username']]);
+ }
+ else
+ {
+ $this->manager->log_moderator('LOG_SEENOTES_CURRENT_CLEARED', [$this->member['username']]);
+ }
+
+ $this->success($entry ? 'SEENOTES_CURRENT_SET' : 'SEENOTES_CURRENT_CLEARED');
+ }
+
+ /**
+ * Delete one of the member's entries with its notes, after confirming
+ *
+ * @return void
+ */
+ protected function delete_entry()
+ {
+ $this->require_permission('entry_delete');
+
+ $entry_id = $this->request->variable('entry_id', 0);
+ $entry = $this->get_member_entry($entry_id);
+
+ if (confirm_box(true))
+ {
+ $this->manager->delete_entry($entry_id);
+ $this->manager->log_moderator('LOG_SEENOTES_ENTRY_DELETED', [$entry['reason_title'], $this->member['username']]);
+
+ $this->success('SEENOTES_ENTRY_DELETED');
+ }
+
+ confirm_box(false, $this->language->lang('SEENOTES_DELETE_ENTRY_CONFIRM', $entry['reason_title']), build_hidden_fields([
+ 'u' => (int) $this->member['user_id'],
+ 'action' => 'delete_entry',
+ 'entry_id' => $entry_id,
+ ]));
+
+ redirect($this->u_action_raw());
+ }
+
+ /**
+ * Add a note under one of the member's entries, or change a note
+ *
+ * @param bool $edit Change an existing note
+ * @param array $errors Filled with errors to show above the form
+ * @return string|null Text to put back in the form, null to use the default
+ */
+ protected function save_note($edit, array &$errors)
+ {
+ $this->require_permission($edit ? 'note_edit' : 'manage');
+
+ $note = null;
+ if ($edit)
+ {
+ $note = $this->get_member_note($this->request->variable('note_id', 0));
+ }
+
+ if (!$this->request->is_set_post('submit_note'))
+ {
+ // Opening the form to edit a note
+ return null;
+ }
+
+ if (!check_form_key(self::FORM_KEY))
+ {
+ $this->error('FORM_INVALID');
+ }
+
+ $text = $this->request->variable('message', '', true);
+
+ if ($edit)
+ {
+ $entry = ['reason_title' => $note['reason_title']];
+ }
+ else
+ {
+ $entry = $this->get_member_entry($this->request->variable('entry_id', 0));
+ }
+
+ $parsed = $this->manager->parse_note($text);
+ if ($parsed['errors'])
+ {
+ $errors = array_map([$this->language, 'lang'], $parsed['errors']);
+
+ return $text;
+ }
+
+ if ($edit)
+ {
+ $this->manager->edit_note((int) $note['note_id'], $parsed);
+ $this->manager->log_moderator('LOG_SEENOTES_NOTE_EDITED', [$entry['reason_title'], $this->member['username']]);
+
+ $this->success('SEENOTES_NOTE_EDITED');
+ }
+
+ $this->manager->add_note((int) $entry['entry_id'], $parsed);
+ $this->manager->log_moderator('LOG_SEENOTES_NOTE_ADDED', [$entry['reason_title'], $this->member['username']]);
+
+ $this->success('SEENOTES_NOTE_ADDED');
+ }
+
+ /**
+ * Delete one of the member's notes, after confirming
+ *
+ * @return void
+ */
+ protected function delete_note()
+ {
+ $this->require_permission('note_delete');
+
+ $note_id = $this->request->variable('note_id', 0);
+ $note = $this->get_member_note($note_id);
+
+ if (confirm_box(true))
+ {
+ $this->manager->delete_note($note_id);
+ $this->manager->log_moderator('LOG_SEENOTES_NOTE_DELETED', [$note['reason_title'], $this->member['username']]);
+
+ $this->success('SEENOTES_NOTE_DELETED');
+ }
+
+ confirm_box(false, $this->language->lang('SEENOTES_DELETE_NOTE_CONFIRM'), build_hidden_fields([
+ 'u' => (int) $this->member['user_id'],
+ 'action' => 'delete_note',
+ 'note_id' => $note_id,
+ ]));
+
+ redirect($this->u_action_raw());
+ }
+
+ /**
+ * Show the member's entries and notes, the forms, and the latest phpBB
+ * user notes
+ *
+ * @param array $errors Errors to show above the note form
+ * @param string|null $editor_text Text to put back in the note form
+ * @return void
+ */
+ protected function display(array $errors, $editor_text)
+ {
+ $user_id = (int) $this->member['user_id'];
+ $entries = $this->manager->get_entries($user_id);
+ $notes = $this->manager->get_notes(array_keys($entries));
+
+ $can_manage = $this->manager->can('manage');
+ $can_edit = $this->manager->can('note_edit');
+ $can_delete_note = $this->manager->can('note_delete');
+ $can_delete_entry = $this->manager->can('entry_delete');
+
+ $current_entry_id = 0;
+ foreach ($entries as $entry_id => $entry)
+ {
+ $entry_notes = isset($notes[$entry_id]) ? $notes[$entry_id] : [];
+ if ($entry['entry_current'])
+ {
+ $current_entry_id = $entry_id;
+ }
+
+ $this->template->assign_block_vars('seenotes_entries', [
+ 'ID' => $entry_id,
+ 'REASON' => $entry['reason_title'],
+ 'DESCRIPTION' => $entry['reason_description'],
+ 'COLOR' => $this->manager->clean_color($entry['reason_color']),
+ 'AUTHOR' => $this->username($entry['entry_author_id'], $entry['username'], $entry['user_colour'], $entry['entry_author_name']),
+ 'TIME' => $this->user->format_date($entry['entry_time']),
+ 'NOTE_COUNT' => $this->language->lang('SEENOTES_NOTE_COUNT', count($entry_notes)),
+ 'S_CURRENT' => (bool) $entry['entry_current'],
+ 'S_ACTIVE' => (bool) $entry['reason_active'],
+ 'U_ADD_NOTE' => $this->u_action . '&entry_id=' . $entry_id . '#seenotes-editor',
+ 'U_DELETE' => $this->u_action . '&action=delete_entry&entry_id=' . $entry_id,
+ ]);
+
+ foreach ($entry_notes as $note)
+ {
+ $this->template->assign_block_vars('seenotes_entries.notes', [
+ 'ID' => (int) $note['note_id'],
+ 'TEXT' => generate_text_for_display($note['note_text'], $note['note_bbcode_uid'], $note['note_bbcode_bitfield'], (int) $note['note_bbcode_options']),
+ 'AUTHOR' => $this->username($note['note_author_id'], $note['author_username'], $note['author_colour'], $note['note_author_name']),
+ 'TIME' => $this->user->format_date($note['note_time']),
+ 'EDITED' => $note['note_edit_time'] ? $this->language->lang(
+ 'SEENOTES_NOTE_EDITED_BY',
+ $this->username($note['note_edit_user_id'], $note['editor_username'], $note['editor_colour'], $note['note_edit_user_name']),
+ $this->user->format_date($note['note_edit_time'])
+ ) : '',
+ 'U_EDIT' => $this->u_action . '&action=edit_note¬e_id=' . (int) $note['note_id'] . '#seenotes-editor',
+ 'U_DELETE' => $this->u_action . '&action=delete_note¬e_id=' . (int) $note['note_id'],
+ ]);
+ }
+ }
+
+ $this->assign_editor($entries, $current_entry_id, $editor_text, $can_manage, $can_edit);
+
+ if ($can_manage)
+ {
+ foreach ($this->manager->get_reasons(true) as $reason_id => $reason)
+ {
+ $this->template->assign_block_vars('seenotes_reasons', [
+ 'ID' => $reason_id,
+ 'TITLE' => $reason['reason_title'],
+ 'DESCRIPTION' => $reason['reason_description'],
+ ]);
+ }
+ }
+
+ $this->assign_user_notes($user_id);
+
+ $this->template->assign_vars([
+ 'S_SEENOTES_PAGE' => true,
+ 'SEENOTES_MEMBER' => get_username_string('full', $user_id, $this->member['username'], $this->member['user_colour']),
+ 'SEENOTES_MEMBER_NAME' => $this->member['username'],
+ 'SEENOTES_ERROR' => implode(' ', $errors),
+ 'S_SEENOTES_CAN_MANAGE' => $can_manage,
+ 'S_SEENOTES_CAN_EDIT' => $can_edit,
+ 'S_SEENOTES_CAN_DELETE_NOTE' => $can_delete_note,
+ 'S_SEENOTES_CAN_DELETE_ENTRY' => $can_delete_entry,
+ 'S_SEENOTES_HAS_CURRENT' => $current_entry_id > 0,
+ 'U_SEENOTES_ACTION' => $this->u_action,
+ 'S_SEENOTES_HIDDEN_USER' => build_hidden_fields(['u' => $user_id]),
+ ]);
+ }
+
+ /**
+ * Fill the note form: a new note under a chosen entry, or a note being
+ * edited
+ *
+ * @param array $entries The member's entries
+ * @param int $current_entry_id
+ * @param string|null $editor_text Text sent back after an error
+ * @param bool $can_manage
+ * @param bool $can_edit
+ * @return void
+ */
+ protected function assign_editor(array $entries, $current_entry_id, $editor_text, $can_manage, $can_edit)
+ {
+ $edit_note = null;
+ if ($this->request->variable('action', '') === 'edit_note' && $can_edit)
+ {
+ $edit_note = $this->get_member_note($this->request->variable('note_id', 0));
+ }
+
+ if (!$entries || (!$edit_note && !$can_manage))
+ {
+ return;
+ }
+
+ if ($edit_note)
+ {
+ $text = $editor_text !== null ? $editor_text : generate_text_for_edit($edit_note['note_text'], $edit_note['note_bbcode_uid'], (int) $edit_note['note_bbcode_options'])['text'];
+ $selected = (int) $edit_note['entry_id'];
+ }
+ else
+ {
+ $text = $editor_text !== null ? $editor_text : '';
+ $selected = $this->request->variable('entry_id', 0);
+ if (!isset($entries[$selected]))
+ {
+ $selected = $current_entry_id ?: (int) key($entries);
+ }
+
+ foreach ($entries as $entry_id => $entry)
+ {
+ $this->template->assign_block_vars('seenotes_editor_entries', [
+ 'ID' => $entry_id,
+ 'TITLE' => $this->language->lang('SEENOTES_ENTRY_OPTION', $entry['reason_title'], $this->user->format_date($entry['entry_time'], false, true)),
+ 'S_SELECTED'=> $entry_id === $selected,
+ ]);
+ }
+ }
+
+ $settings = $this->manager->get_editor_settings();
+ if ($settings['bbcode'])
+ {
+ if (!function_exists('display_custom_bbcodes'))
+ {
+ include($this->root_path . 'includes/functions_display.' . $this->php_ext);
+ }
+ display_custom_bbcodes();
+ }
+
+ $this->template->assign_vars([
+ 'S_SEENOTES_EDITOR' => true,
+ 'S_SEENOTES_EDITING' => $edit_note !== null,
+ 'SEENOTES_EDIT_NOTE_ID' => $edit_note ? (int) $edit_note['note_id'] : 0,
+ 'SEENOTES_EDITOR_TEXT' => $text,
+ 'S_BBCODE_ALLOWED' => $settings['bbcode'],
+ 'S_BBCODE_QUOTE' => $settings['quote'],
+ 'S_BBCODE_IMG' => $settings['img'],
+ 'S_BBCODE_FLASH' => $settings['flash'],
+ 'S_LINKS_ALLOWED' => $settings['urls'],
+ 'SEENOTES_FORMAT_STATUS'=> $this->language->lang('SEENOTES_FORMAT_STATUS',
+ $this->language->lang($settings['bbcode'] ? 'SEENOTES_ON' : 'SEENOTES_OFF'),
+ $this->language->lang($settings['smilies'] ? 'SEENOTES_ON' : 'SEENOTES_OFF'),
+ $this->language->lang($settings['urls'] ? 'SEENOTES_ON' : 'SEENOTES_OFF')
+ ),
+ ]);
+ }
+
+ /**
+ * The member's latest phpBB user notes, read-only
+ *
+ * @param int $user_id
+ * @return void
+ */
+ protected function assign_user_notes($user_id)
+ {
+ $rows = $this->log->get_logs('user', true, self::USER_NOTES_LIMIT, 0, 0, 0, $user_id);
+ $total = (int) $this->log->get_log_count();
+
+ foreach ($rows as $row)
+ {
+ $this->template->assign_block_vars('seenotes_usernotes', [
+ 'REPORT_BY' => $row['username_full'],
+ 'REPORT_AT' => $this->user->format_date($row['time']),
+ 'ACTION' => $row['action'],
+ ]);
+ }
+
+ if ($this->in_acp)
+ {
+ $u_user_notes = append_sid("{$this->root_path}adm/index.{$this->php_ext}", 'i=users&mode=feedback&u=' . $user_id);
+ }
+ else
+ {
+ $u_user_notes = append_sid("{$this->root_path}mcp.{$this->php_ext}", 'i=notes&mode=user_notes&u=' . $user_id);
+ }
+
+ $this->template->assign_vars([
+ 'SEENOTES_USERNOTES_TOTAL' => $this->language->lang('SEENOTES_USERNOTES_TOTAL', $total),
+ 'U_SEENOTES_USERNOTES' => $u_user_notes,
+ ]);
+ }
+
+ /**
+ * A username that links to the profile while the member exists, or the
+ * saved name as plain text after they were deleted
+ *
+ * @param int $user_id
+ * @param string|null $username Current username, null when deleted
+ * @param string|null $colour
+ * @param string $saved_name Name saved with the record (escaped)
+ * @return string
+ */
+ protected function username($user_id, $username, $colour, $saved_name)
+ {
+ if ($username === null || $username === '')
+ {
+ return $saved_name;
+ }
+
+ return get_username_string('full', (int) $user_id, $username, (string) $colour);
+ }
+
+ /**
+ * An entry of this member, or an error
+ *
+ * @param int $entry_id
+ * @return array
+ */
+ protected function get_member_entry($entry_id)
+ {
+ $entry = $entry_id ? $this->manager->get_entry($entry_id) : null;
+ if (!$entry || (int) $entry['user_id'] !== (int) $this->member['user_id'])
+ {
+ $this->error('SEENOTES_ENTRY_NOT_FOUND');
+ }
+
+ return $entry;
+ }
+
+ /**
+ * A note under one of this member's entries, or an error
+ *
+ * @param int $note_id
+ * @return array
+ */
+ protected function get_member_note($note_id)
+ {
+ $note = $note_id ? $this->manager->get_note($note_id) : null;
+ if (!$note || (int) $note['user_id'] !== (int) $this->member['user_id'])
+ {
+ $this->error('SEENOTES_NOTE_NOT_FOUND');
+ }
+
+ return $note;
+ }
+
+ /**
+ * Stop unless the current user has the permission
+ *
+ * @param string $action
+ * @return void
+ */
+ protected function require_permission($action)
+ {
+ if (!$this->manager->can($action))
+ {
+ $this->error('NOT_AUTHORISED');
+ }
+ }
+
+ /**
+ * Stop unless the request is a valid form post and the current user has
+ * the permission
+ *
+ * @param string $action
+ * @return void
+ */
+ protected function require_post_permission($action)
+ {
+ $this->require_permission($action);
+
+ if (!$this->request->is_set_post('action') || !check_form_key(self::FORM_KEY))
+ {
+ $this->error('FORM_INVALID');
+ }
+ }
+
+ /**
+ * The page URL without HTML-escaped ampersands, for redirects
+ *
+ * @return string
+ */
+ protected function u_action_raw()
+ {
+ return str_replace('&', '&', $this->u_action);
+ }
+
+ /**
+ * Show a success message with a link back to the page
+ *
+ * @param string $message Language key
+ * @return void
+ */
+ protected function success($message)
+ {
+ if ($this->in_acp)
+ {
+ trigger_error($this->language->lang($message) . adm_back_link($this->u_action));
+ }
+
+ meta_refresh(3, $this->u_action);
+ trigger_error($this->language->lang($message) . ' ' . $this->language->lang('RETURN_PAGE', '', ' '));
+ }
+
+ /**
+ * Show an error with a link back to the page
+ *
+ * @param string $message Language key
+ * @return void
+ */
+ protected function error($message)
+ {
+ if ($this->in_acp)
+ {
+ trigger_error($this->language->lang($message) . adm_back_link($this->u_action), E_USER_WARNING);
+ }
+
+ trigger_error($this->language->lang($message) . ' ' . $this->language->lang('RETURN_PAGE', '', ' '), E_USER_WARNING);
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..67ba99f
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,30 @@
+
+
+
+
+
+ ./tests
+
+
+
+
+
+ ./
+
+ ./language/
+ ./migrations/
+ ./tests/
+
+
+
+
diff --git a/styles/all/template/event/mcp_post_text_before.html b/styles/all/template/event/mcp_post_text_before.html
new file mode 100644
index 0000000..850329f
--- /dev/null
+++ b/styles/all/template/event/mcp_post_text_before.html
@@ -0,0 +1,6 @@
+{% if S_SEENOTES %}
+
+
+ {% include '@phpbbmodders_seenotes/seenotes_reason.html' with {'sn': _context} only %}
+
+{% endif %}
diff --git a/styles/all/template/event/mcp_topic_post_author_full_append.html b/styles/all/template/event/mcp_topic_post_author_full_append.html
new file mode 100644
index 0000000..b3165b3
--- /dev/null
+++ b/styles/all/template/event/mcp_topic_post_author_full_append.html
@@ -0,0 +1 @@
+{% if postrow.S_SEENOTES %} • {% include '@phpbbmodders_seenotes/seenotes_reason.html' with {'sn': postrow} only %}{% endif %}
diff --git a/styles/all/template/event/memberlist_view_non_contact_custom_fields_after.html b/styles/all/template/event/memberlist_view_non_contact_custom_fields_after.html
new file mode 100644
index 0000000..5283de5
--- /dev/null
+++ b/styles/all/template/event/memberlist_view_non_contact_custom_fields_after.html
@@ -0,0 +1,6 @@
+{% if S_SEENOTES %}
+ {{ SEENOTES_LABEL }}{{ lang('COLON') }}
+
+ {% if U_SEENOTES %}{% else %}{% endif %}{{ SEENOTES_REASON }} ({{ SEENOTES_COUNT }}) , {{ SEENOTES_COUNT_TEXT }}{% if SEENOTES_DESCRIPTION %}: {{ SEENOTES_DESCRIPTION }}{% endif %}{% if U_SEENOTES %}. {{ lang('SEENOTES_OPEN_NOTES') }}{% endif %} {% if U_SEENOTES %} {% else %}{% endif %}
+
+{% endif %}
diff --git a/styles/all/template/event/overall_header_head_append.html b/styles/all/template/event/overall_header_head_append.html
new file mode 100644
index 0000000..30c11ec
--- /dev/null
+++ b/styles/all/template/event/overall_header_head_append.html
@@ -0,0 +1,3 @@
+{% if S_SEENOTES_CSS %}
+ {% INCLUDECSS '@phpbbmodders_seenotes/seenotes.css' %}
+{% endif %}
diff --git a/styles/all/template/event/viewtopic_body_postrow_custom_fields_after.html b/styles/all/template/event/viewtopic_body_postrow_custom_fields_after.html
new file mode 100644
index 0000000..007d12d
--- /dev/null
+++ b/styles/all/template/event/viewtopic_body_postrow_custom_fields_after.html
@@ -0,0 +1,3 @@
+{% if postrow.S_SEENOTES %}
+ {% include '@phpbbmodders_seenotes/seenotes_reason.html' with {'sn': postrow} only %}
+{% endif %}
diff --git a/styles/all/template/mcp_seenotes.html b/styles/all/template/mcp_seenotes.html
new file mode 100644
index 0000000..0f46c11
--- /dev/null
+++ b/styles/all/template/mcp_seenotes.html
@@ -0,0 +1,214 @@
+{% include 'mcp_header.html' %}
+
+{{ lang('MCP_SEENOTES_NOTES') }}
+
+{% if S_SEENOTES_FRONT %}
+
+
+
+
+
+
+
+
+
+{% else %}
+
+
+
+
{{ SEENOTES_MEMBER }}
+
+ {% if SEENOTES_ERROR %}
{{ SEENOTES_ERROR }}
{% endif %}
+
+
{{ lang('SEENOTES_ENTRIES') }}
+
+ {% for entry in loops.seenotes_entries %}
+
+
+
+ {{ entry.REASON }}
+ {% if entry.S_CURRENT %}{{ lang('SEENOTES_CURRENT') }} {% endif %}
+ {% if not entry.S_ACTIVE %}{{ lang('SEENOTES_INACTIVE') }} {% endif %}
+ {{ entry.NOTE_COUNT }}
+
+ {% if entry.DESCRIPTION %}
{{ entry.DESCRIPTION }}
{% endif %}
+
{{ lang('SEENOTES_ADDED_BY', entry.AUTHOR, entry.TIME) }}
+
+ {% if S_SEENOTES_CAN_MANAGE or S_SEENOTES_CAN_DELETE_ENTRY %}
+
+ {% if S_SEENOTES_CAN_MANAGE %}
+
{{ lang('SEENOTES_ADD_NOTE') }}{{ lang('COLON') }} {{ entry.REASON }}
+ {% if not entry.S_CURRENT %}
+
+ {{ lang('SEENOTES_MAKE_CURRENT') }}{{ lang('COLON') }} {{ entry.REASON }}
+
+ {{ S_SEENOTES_HIDDEN_USER }}
+ {{ S_FORM_TOKEN }}
+
+ {% endif %}
+ {% endif %}
+ {% if S_SEENOTES_CAN_DELETE_ENTRY %}
+
{{ lang('SEENOTES_DELETE_ENTRY') }}{{ lang('COLON') }} {{ entry.REASON }}
+ {% endif %}
+
+ {% endif %}
+
+ {% for note in entry.notes %}
+
+
+ {{ note.TEXT }}
+ {% if note.EDITED %}{{ note.EDITED }}
{% endif %}
+
+ {% else %}
+
{{ lang('SEENOTES_NO_NOTES') }}
+ {% endfor %}
+
+
+ {% else %}
+
{{ lang('SEENOTES_NO_ENTRIES') }}
+ {% endfor %}
+
+ {% if S_SEENOTES_CAN_MANAGE and S_SEENOTES_HAS_CURRENT %}
+
+
+ {{ lang('SEENOTES_CLEAR_CURRENT') }}
+ {{ lang('SEENOTES_CLEAR_CURRENT_EXPLAIN') }}
+
+ {{ S_SEENOTES_HIDDEN_USER }}
+ {{ S_FORM_TOKEN }}
+
+
+ {% endif %}
+
+
+
+{% if S_SEENOTES_EDITOR %}
+
+
+
+
{% if S_SEENOTES_EDITING %}{{ lang('SEENOTES_EDIT_NOTE') }}{% else %}{{ lang('SEENOTES_ADD_NOTE') }}{% endif %}
+
+ {% if not S_SEENOTES_EDITING %}
+
+ {{ lang('SEENOTES_NOTE_FOR') }}{{ lang('COLON') }}
+
+
+ {% for option in loops.seenotes_editor_entries %}
+ {{ option.TITLE }}
+ {% endfor %}
+
+
+
+ {% endif %}
+ {{ lang('SEENOTES_NOTE_TEXT') }}
+ {% include 'posting_buttons.html' %}
+
+ {{ SEENOTES_EDITOR_TEXT }}
+
+ {{ SEENOTES_FORMAT_STATUS }}
+
+
+
+
+
+ {% if S_SEENOTES_EDITING %} {% endif %}
+ {{ S_SEENOTES_HIDDEN_USER }}
+ {{ S_FORM_TOKEN }}
+ {% if S_SEENOTES_EDITING %}{{ lang('CANCEL') }} {% endif %}
+
+
+
+{% endif %}
+
+{% if S_SEENOTES_CAN_MANAGE %}
+
+
+
+
{{ lang('SEENOTES_ADD_ENTRY') }}
+ {% if loops.seenotes_reasons|length %}
+
{{ lang('SEENOTES_ADD_ENTRY_EXPLAIN') }}
+
+
+ {{ lang('SEENOTES_REASON') }}{{ lang('COLON') }}
+
+
+ {{ lang('SEENOTES_SELECT_REASON') }}
+ {% for reason in loops.seenotes_reasons %}
+ {{ reason.TITLE }}
+ {% endfor %}
+
+
+
+
+
+ {{ lang('SEENOTES_MAKE_IT_CURRENT') }}
+
+
+ {% else %}
+
{{ lang('SEENOTES_NO_REASONS') }}
+ {% endif %}
+
+
+ {% if loops.seenotes_reasons|length %}
+
+
+ {{ S_SEENOTES_HIDDEN_USER }}
+ {{ S_FORM_TOKEN }}
+
+
+ {% endif %}
+
+{% endif %}
+
+
+
+
{{ lang('SEENOTES_USERNOTES') }}
+
{{ lang('SEENOTES_USERNOTES_EXPLAIN') }}
+ {% if loops.seenotes_usernotes|length %}
+
+
+
+ {{ lang('REPORT_BY') }}
+ {{ lang('TIME') }}
+ {{ lang('ACTION_NOTE') }}
+
+
+
+ {% for usernote in loops.seenotes_usernotes %}
+
+ {{ usernote.REPORT_BY }}
+ {{ usernote.REPORT_AT }}
+ {{ usernote.ACTION }}
+
+ {% endfor %}
+
+
+
{{ SEENOTES_USERNOTES_TOTAL }} • {{ lang('SEENOTES_USERNOTES_ALL') }}
+ {% else %}
+
{{ lang('SEENOTES_NO_USERNOTES') }}
+ {% endif %}
+
+
+
+{% endif %}
+
+{% include 'mcp_footer.html' %}
diff --git a/styles/all/template/seenotes_reason.html b/styles/all/template/seenotes_reason.html
new file mode 100644
index 0000000..6a3d457
--- /dev/null
+++ b/styles/all/template/seenotes_reason.html
@@ -0,0 +1,5 @@
+{# The reason line. Pass the row with the SEENOTES_* values as "sn". #}
+
+ {{ sn.SEENOTES_LABEL }}{{ lang('COLON') }}
+ {% if sn.U_SEENOTES %}{% else %}{% endif %}{{ 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
new file mode 100644
index 0000000..c7f620e
--- /dev/null
+++ b/styles/all/theme/seenotes.css
@@ -0,0 +1,60 @@
+/* See Notes: the reason line and the Moderator notes page */
+
+.seenotes-line,
+.seenotes-line a.seenotes-reason {
+ font-weight: bold;
+}
+
+.seenotes-notice {
+ border-left: 4px solid currentColor;
+ padding: 4px 8px;
+}
+
+.seenotes-entry {
+ margin-bottom: 10px;
+}
+
+.seenotes-entry-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 12px;
+ margin-bottom: 6px;
+}
+
+.seenotes-entry-title {
+ font-size: 1.2em;
+ font-weight: bold;
+}
+
+.seenotes-badge {
+ border: 1px solid currentColor;
+ border-radius: 3px;
+ font-size: 0.9em;
+ padding: 0 4px;
+}
+
+.seenotes-entry-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 6px 0;
+}
+
+.seenotes-entry-actions form {
+ display: inline;
+}
+
+.seenotes-note {
+ border-top: 1px solid rgba(128, 128, 128, 0.3);
+ padding: 6px 0;
+}
+
+.seenotes-note-meta {
+ font-size: 0.9em;
+ margin-bottom: 4px;
+}
+
+.seenotes-note-actions a {
+ margin-left: 8px;
+}
diff --git a/tests/event/listener_test.php b/tests/event/listener_test.php
new file mode 100644
index 0000000..2116008
--- /dev/null
+++ b/tests/event/listener_test.php
@@ -0,0 +1,160 @@
+auth = $this->getMockBuilder('\phpbb\auth\auth')->disableOriginalConstructor()->getMock();
+ $this->template = $this->getMockBuilder('\phpbb\template\template')->getMock();
+ $this->manager = $this->getMockBuilder(manager::class)->disableOriginalConstructor()->getMock();
+ $this->manager->method('get_label')->willReturn('SEE NOTES');
+ $this->manager->method('clean_color')->willReturnArgument(0);
+ $this->notes_page = $this->getMockBuilder(notes_page::class)->disableOriginalConstructor()->getMock();
+ }
+
+ /**
+ * @return listener
+ */
+ protected function get_listener()
+ {
+ global $phpbb_root_path, $phpEx;
+
+ $language = $this->getMockBuilder('\phpbb\language\language')->disableOriginalConstructor()->getMock();
+ $language->method('lang')->willReturnArgument(0);
+ $user = $this->getMockBuilder('\phpbb\user')->disableOriginalConstructor()->getMock();
+ $user->session_id = '';
+
+ return new listener($this->auth, $language, $this->template, $user, $this->manager, $this->notes_page, $phpbb_root_path, $phpEx);
+ }
+
+ public function test_construct()
+ {
+ $this->assertInstanceOf('\Symfony\Component\EventDispatcher\EventSubscriberInterface', $this->get_listener());
+ }
+
+ public function test_subscribed_events()
+ {
+ $this->assertEquals([
+ 'core.user_setup',
+ 'core.permissions',
+ 'core.page_header',
+ 'core.memberlist_view_profile',
+ 'core.viewtopic_modify_post_data',
+ 'core.viewtopic_modify_post_row',
+ 'core.mcp_post_template_data',
+ 'core.mcp_report_template_data',
+ 'core.mcp_queue_approve_details_template',
+ 'core.mcp_topic_modify_post_data',
+ 'core.mcp_topic_review_modify_row',
+ 'core.acp_users_mode_add',
+ 'core.delete_user_after',
+ 'core.update_username',
+ ], array_keys(listener::getSubscribedEvents()));
+ }
+
+ public function test_permissions_are_added_in_their_own_category()
+ {
+ $event = new data(['categories' => [], 'permissions' => []]);
+ $this->get_listener()->add_permissions($event);
+
+ $this->assertArrayHasKey('seenotes', $event['categories']);
+ $this->assertCount(11, $event['permissions']);
+ $this->assertSame('seenotes', $event['permissions']['m_seenotes_note_edit']['cat']);
+ $this->assertArrayHasKey('a_seenotes_settings', $event['permissions']);
+ }
+
+ public function test_post_row_unchanged_for_viewers_without_permission()
+ {
+ $this->manager->method('can')->willReturn(false);
+ $this->manager->expects($this->never())->method('get_current_for_users');
+
+ $event = new data(['poster_id' => 3, 'post_row' => ['POST_ID' => 1]]);
+ $this->get_listener()->show_on_post($event);
+
+ $this->assertSame(['POST_ID' => 1], $event['post_row']);
+ }
+
+ public function test_post_row_gets_the_current_reason()
+ {
+ $this->manager->method('can')->willReturn(true);
+ $this->manager->method('get_current_for_users')->willReturn([
+ 3 => ['reason_title' => 'Moderation queue', 'reason_description' => 'Approve first', 'reason_color' => 'AA0000', 'note_count' => 2],
+ ]);
+ $this->auth->method('acl_getf_global')->willReturn(true);
+
+ // append_sid() runs its event
+ global $phpbb_dispatcher;
+ $phpbb_dispatcher = new \phpbb_mock_event_dispatcher();
+
+ $event = new data(['poster_id' => 3, 'post_row' => []]);
+ $this->get_listener()->show_on_post($event);
+
+ $row = $event['post_row'];
+ $this->assertTrue($row['S_SEENOTES']);
+ $this->assertSame('SEE NOTES', $row['SEENOTES_LABEL']);
+ $this->assertSame('Moderation queue', $row['SEENOTES_REASON']);
+ $this->assertSame(2, $row['SEENOTES_COUNT']);
+ $this->assertStringContainsString('mode=notes&u=3', $row['U_SEENOTES']);
+ }
+
+ public function test_post_row_unchanged_without_a_current_reason()
+ {
+ $this->manager->method('can')->willReturn(true);
+ $this->manager->method('get_current_for_users')->willReturn([]);
+
+ $event = new data(['poster_id' => 3, 'post_row' => []]);
+ $this->get_listener()->show_on_post($event);
+
+ $this->assertSame([], $event['post_row']);
+ }
+
+ public function test_other_acp_users_modes_are_ignored()
+ {
+ $this->notes_page->expects($this->never())->method('handle');
+
+ $event = new data(['mode' => 'feedback', 'user_id' => 3, 'user_row' => [], 'u_action' => 'x']);
+ $this->get_listener()->acp_users_page($event);
+ }
+
+ public function test_acp_users_mode_shows_the_page()
+ {
+ $this->notes_page->expects($this->once())
+ ->method('handle')
+ ->with(['user_id' => 3], 'index.php?i=users&mode=seenotes&u=3', true);
+
+ $event = new data(['mode' => 'seenotes', 'user_id' => 3, 'user_row' => ['user_id' => 3], 'u_action' => 'index.php?i=users&mode=seenotes']);
+ $this->get_listener()->acp_users_page($event);
+ }
+}
diff --git a/tests/fixtures/seenotes.xml b/tests/fixtures/seenotes.xml
new file mode 100644
index 0000000..c079e1d
--- /dev/null
+++ b/tests/fixtures/seenotes.xml
@@ -0,0 +1,159 @@
+
+
+
+ user_id
+ username
+ username_clean
+ user_colour
+ user_permissions
+ user_sig
+
+ 2
+ admin
+ admin
+ AA0000
+
+
+
+
+ 3
+ member
+ member
+
+
+
+
+
+ 4
+ other
+ other
+
+
+
+
+
+
+ reason_id
+ reason_title
+ reason_description
+ reason_color
+ reason_order
+ reason_active
+
+ 1
+ Moderation queue
+ Posts must be approved
+ AA0000
+ 1
+ 1
+
+
+ 2
+ Spam watch
+
+ 0000AA
+ 2
+ 1
+
+
+ 3
+ Retired
+
+ 00AA00
+ 3
+ 0
+
+
+
+ entry_id
+ user_id
+ reason_id
+ entry_time
+ entry_author_id
+ entry_author_name
+ entry_current
+
+ 1
+ 3
+ 1
+ 1000
+ 2
+ admin
+ 1
+
+
+ 2
+ 3
+ 3
+ 900
+ 2
+ admin
+ 0
+
+
+ 3
+ 4
+ 1
+ 1100
+ 2
+ admin
+ 0
+
+
+
+ note_id
+ entry_id
+ note_author_id
+ note_author_name
+ note_time
+ note_text
+ note_bbcode_uid
+ note_bbcode_bitfield
+ note_bbcode_options
+ note_edit_time
+ note_edit_user_id
+ note_edit_user_name
+
+ 1
+ 1
+ 2
+ admin
+ 1000
+ <t>First note</t>
+
+
+ 7
+ 0
+ 0
+
+
+
+ 2
+ 1
+ 99
+ gone
+ 1001
+ <t>Note by a deleted member</t>
+
+
+ 7
+ 0
+ 0
+
+
+
+ 3
+ 3
+ 2
+ admin
+ 1100
+ <t>Other member</t>
+
+
+ 7
+ 0
+ 0
+
+
+
+
diff --git a/tests/functional/.gitkeep b/tests/functional/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tests/manager_test.php b/tests/manager_test.php
new file mode 100644
index 0000000..029906c
--- /dev/null
+++ b/tests/manager_test.php
@@ -0,0 +1,223 @@
+createXMLDataSet(__DIR__ . '/fixtures/seenotes.xml');
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->db = $this->new_dbal();
+ $this->granted = [];
+
+ $this->auth = $this->getMockBuilder('\phpbb\auth\auth')->disableOriginalConstructor()->getMock();
+ $this->auth->method('acl_get')->willReturnCallback(function ($option) {
+ return in_array($option, $this->granted, true);
+ });
+ $this->auth->method('acl_getf_global')->willReturnCallback(function ($option) {
+ return in_array($option, $this->granted, true);
+ });
+
+ $this->log = $this->getMockBuilder('\phpbb\log\log_interface')->getMock();
+
+ $this->user = $this->getMockBuilder('\phpbb\user')->disableOriginalConstructor()->getMock();
+ $this->user->data = ['user_id' => 2, 'username' => 'admin', 'is_registered' => true];
+ $this->user->ip = '127.0.0.1';
+ }
+
+ /**
+ * @return manager
+ */
+ protected function get_manager()
+ {
+ $config = new \phpbb\config\config(['seenotes_label' => 'SEE NOTES']);
+
+ return new manager($this->auth, $config, $this->db, $this->log, $this->user, 'phpbb_seenotes_reasons', 'phpbb_seenotes_entries', 'phpbb_seenotes_notes');
+ }
+
+ public function test_moderator_or_admin_permission_allows_an_action()
+ {
+ $manager = $this->get_manager();
+ $this->assertFalse($manager->can('view'));
+
+ $this->granted = ['m_seenotes_view'];
+ $this->assertTrue($manager->can('view'));
+ $this->assertFalse($manager->can('manage'));
+
+ $this->granted = ['a_seenotes_manage'];
+ $this->assertTrue($manager->can('manage'));
+ }
+
+ public function test_guests_and_unknown_actions_are_refused()
+ {
+ $this->granted = ['m_seenotes_view', 'm_seenotes_settings'];
+ $manager = $this->get_manager();
+ $this->assertFalse($manager->can('settings'));
+
+ $this->user->data['is_registered'] = false;
+ $this->assertFalse($manager->can('view'));
+ }
+
+ public function test_clean_color()
+ {
+ $manager = $this->get_manager();
+
+ $this->assertSame('00AAFF', $manager->clean_color('#00aaff'));
+ $this->assertSame(manager::DEFAULT_COLOR, $manager->clean_color('red'));
+ $this->assertSame(manager::DEFAULT_COLOR, $manager->clean_color('#00aaff;background:url(x)'));
+ }
+
+ public function test_active_reasons_leave_out_switched_off_ones()
+ {
+ $manager = $this->get_manager();
+
+ $this->assertSame([1, 2, 3], array_keys($manager->get_reasons()));
+ $this->assertSame([1, 2], array_keys($manager->get_reasons(true)));
+ }
+
+ public function test_new_reason_goes_last_and_moves()
+ {
+ $manager = $this->get_manager();
+ $id = $manager->save_reason(['reason_title' => 'New', 'reason_description' => '', 'reason_color' => '#123abc']);
+
+ $this->assertSame([1, 2, 3, $id], array_keys($manager->get_reasons()));
+ $this->assertSame('123ABC', $manager->get_reason($id)['reason_color']);
+
+ $this->assertTrue($manager->move_reason($id, true));
+ $this->assertSame([1, 2, $id, 3], array_keys($manager->get_reasons()));
+ $this->assertFalse($manager->move_reason(1, true));
+ }
+
+ public function test_current_entry_with_note_count()
+ {
+ $current = $this->get_manager()->get_current_for_users([3, 4]);
+
+ $this->assertSame([3], array_keys($current));
+ $this->assertSame('Moderation queue', $current[3]['reason_title']);
+ $this->assertSame(2, $current[3]['note_count']);
+ }
+
+ public function test_set_current_keeps_one_current_entry()
+ {
+ $manager = $this->get_manager();
+
+ $manager->set_current(3, 2);
+ $entries = $manager->get_entries(3);
+ $this->assertSame(0, (int) $entries[1]['entry_current']);
+ $this->assertSame(1, (int) $entries[2]['entry_current']);
+
+ $manager->set_current(3, 0);
+ $this->assertSame([], $manager->get_current_for_users([3]));
+ }
+
+ public function test_add_entry_as_current()
+ {
+ $manager = $this->get_manager();
+ $entry_id = $manager->add_entry(4, 2, true);
+
+ $current = $manager->get_current_for_users([4]);
+ $this->assertSame($entry_id, (int) $current[4]['entry_id']);
+ $this->assertSame('admin', $manager->get_entry($entry_id)['entry_author_name']);
+ }
+
+ public function test_notes_newest_first_with_saved_name_for_deleted_author()
+ {
+ $notes = $this->get_manager()->get_notes([1]);
+
+ $this->assertSame([2, 1], array_map('intval', array_column($notes[1], 'note_id')));
+ $this->assertNull($notes[1][0]['author_username']);
+ $this->assertSame('gone', $notes[1][0]['note_author_name']);
+ $this->assertSame('admin', $notes[1][1]['author_username']);
+ }
+
+ public function test_delete_entry_removes_its_notes()
+ {
+ $manager = $this->get_manager();
+ $manager->delete_entry(1);
+
+ $this->assertNull($manager->get_entry(1));
+ $this->assertNull($manager->get_note(1));
+ $this->assertNotNull($manager->get_note(3));
+ }
+
+ public function test_reason_usage_and_hard_delete()
+ {
+ $manager = $this->get_manager();
+
+ $this->assertSame(['users' => 2, 'entries' => 2, 'notes' => 3], $manager->count_reason_usage(1));
+
+ $manager->delete_reason(1);
+ $this->assertNull($manager->get_reason(1));
+ $this->assertSame([2], array_keys($manager->get_entries(3)));
+ $this->assertSame([], $manager->get_entries(4));
+ $this->assertNull($manager->get_note(3));
+ }
+
+ public function test_deleting_a_member_deletes_their_records()
+ {
+ $manager = $this->get_manager();
+ $manager->delete_users([3]);
+
+ $this->assertSame([], $manager->get_entries(3));
+ $this->assertNull($manager->get_note(1));
+ $this->assertSame([3], array_keys($manager->get_entries(4)));
+ }
+
+ public function test_renaming_updates_saved_names()
+ {
+ $manager = $this->get_manager();
+ $manager->update_username('admin', 'boss');
+
+ $this->assertSame('boss', $manager->get_entry(1)['entry_author_name']);
+ $this->assertSame('boss', $manager->get_note(1)['note_author_name']);
+ $this->assertSame('gone', $manager->get_note(2)['note_author_name']);
+ }
+
+ public function test_moderator_log_has_no_forum()
+ {
+ $this->log->expects($this->once())
+ ->method('add')
+ ->with('mod', 2, '127.0.0.1', 'LOG_SEENOTES_NOTE_ADDED', false, ['forum_id' => 0, 'topic_id' => 0, 0 => 'Moderation queue', 1 => 'member']);
+
+ $this->get_manager()->log_moderator('LOG_SEENOTES_NOTE_ADDED', ['Moderation queue', 'member']);
+ }
+}
From e7607ebfdbdbb1f5ec630a6d4da47493d781964e Mon Sep 17 00:00:00 2001
From: William Jacoby
Date: Sat, 7 Nov 2026 22:42:12 -0600
Subject: [PATCH 02/10] Show NONE without a current reason, and fix review
findings
- Members without a current reason show "SEE NOTES: NONE" to staff,
linking to the Give a reason form.
- phpBB 4.0 dropped generate_text_for_storage()'s flash argument;
call it with the right arguments on each branch, so quotes work
in notes on 4.0.
- Check reason name and description length as stored (escaped), so
they always fit their columns.
- Renames only update names saved by the renamed member.
- Escape language strings in attributes, enlarge link targets,
give the notes-page forms ids, drop unused template variables.
Co-Authored-By: Claude Opus 5.5
---
README.md | 3 +-
acp/main_module.php | 4 +-
adm/style/acp_seenotes_reasons.html | 8 +--
adm/style/acp_seenotes_settings.html | 4 +-
adm/style/acp_users_seenotes.html | 14 ++---
adm/style/seenotes_acp.css | 7 +++
docs/design.md | 1 +
event/listener.php | 24 +++++++--
language/en/common.php | 3 ++
manager.php | 54 ++++++++++++-------
notes_page.php | 1 -
.../template/event/mcp_post_text_before.html | 2 +-
..._view_non_contact_custom_fields_after.html | 4 +-
styles/all/template/mcp_seenotes.html | 14 ++---
styles/all/template/seenotes_reason.html | 4 +-
styles/all/template/seenotes_reason_link.html | 2 +
styles/all/theme/seenotes.css | 13 +++++
tests/event/listener_test.php | 12 ++++-
tests/manager_test.php | 10 +++-
19 files changed, 125 insertions(+), 59 deletions(-)
create mode 100644 styles/all/template/seenotes_reason_link.html
diff --git a/README.md b/README.md
index d6f262c..e815b73 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
+admin KbTest1234!
# See Notes
[](https://github.com/phpbbmodders/seenotes/actions/workflows/tests.yml) [](https://github.com/phpbbmodders/seenotes/actions/workflows/lint.yml)
@@ -9,7 +10,7 @@ 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. 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.
+- **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.
- 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.
diff --git a/acp/main_module.php b/acp/main_module.php
index 82c301b..cf54100 100644
--- a/acp/main_module.php
+++ b/acp/main_module.php
@@ -143,7 +143,6 @@ protected function reasons()
}
$template->assign_vars([
- 'S_SEENOTES_LIST' => true,
'S_SEENOTES_HARD_DELETE' => !empty($config['seenotes_allow_hard_delete']),
'U_ACTION' => $this->u_action,
'U_SEENOTES_ADD' => $this->u_action . '&action=add',
@@ -186,7 +185,8 @@ protected function edit_reason($reason_id)
}
$max = \phpbbmodders\seenotes\manager::TEXT_MAX_LENGTH;
- if (utf8_strlen(htmlspecialchars_decode($reason['reason_title'], ENT_COMPAT)) > $max || utf8_strlen(htmlspecialchars_decode($reason['reason_description'], ENT_COMPAT)) > $max)
+ // Checked as stored: request input is HTML-escaped, so "&" takes five characters of the column
+ if (utf8_strlen($reason['reason_title']) > $max || utf8_strlen($reason['reason_description']) > $max)
{
$errors[] = $language->lang('SEENOTES_TEXT_TOO_LONG', $max);
}
diff --git a/adm/style/acp_seenotes_reasons.html b/adm/style/acp_seenotes_reasons.html
index b973f22..ae27996 100644
--- a/adm/style/acp_seenotes_reasons.html
+++ b/adm/style/acp_seenotes_reasons.html
@@ -27,7 +27,7 @@ {% if S_SEENOTES_ADDING %}{{ lang('SEENOTES_ADD_REASON') }}{% else %}{{ lang
{{ lang('SUBMIT') }}
-
+
{{ S_FORM_TOKEN }}
@@ -52,11 +52,11 @@ {{ lang('SEENOTES_DELETE_REASON') }}
{{ lang('SUBMIT') }}
{% if S_SEENOTES_DELETE_STEP2 %}
-
+
{% else %}
-
+
{% endif %}
-
+
{{ S_FORM_TOKEN }}
diff --git a/adm/style/acp_seenotes_settings.html b/adm/style/acp_seenotes_settings.html
index 8a155cb..6840a94 100644
--- a/adm/style/acp_seenotes_settings.html
+++ b/adm/style/acp_seenotes_settings.html
@@ -22,8 +22,8 @@ {{ lang('ACP_SEENOTES') }}
{{ lang('SUBMIT') }}
-
-
+
+
{{ S_FORM_TOKEN }}
diff --git a/adm/style/acp_users_seenotes.html b/adm/style/acp_users_seenotes.html
index 43073b1..cb8dea1 100644
--- a/adm/style/acp_users_seenotes.html
+++ b/adm/style/acp_users_seenotes.html
@@ -10,7 +10,7 @@
{{ entry.REASON }}
{% if entry.S_CURRENT %} ({{ lang('SEENOTES_CURRENT') }}) {% endif %}
- {% if not entry.S_ACTIVE %} ({{ lang('SEENOTES_INACTIVE') }}) {% endif %}
+ {% if not entry.S_ACTIVE %} ({{ lang('SEENOTES_INACTIVE') }}) {% endif %}
{% if entry.DESCRIPTION %}{{ entry.DESCRIPTION }}
{% endif %}
{{ lang('SEENOTES_ADDED_BY', entry.AUTHOR, entry.TIME) }} • {{ entry.NOTE_COUNT }}
@@ -25,7 +25,7 @@
{% endif %}
{% if S_SEENOTES_CAN_MANAGE and not entry.S_CURRENT %}
-
+
{{ lang('SEENOTES_MAKE_CURRENT') }}{{ lang('COLON') }} {{ entry.REASON }}
@@ -54,7 +54,7 @@
{{ note.AUTHOR }} {{ note.TIME }}
{% if S_SEENOTES_CAN_EDIT or S_SEENOTES_CAN_DELETE_NOTE %}
-
+
{% if S_SEENOTES_CAN_EDIT %}{{ lang('SEENOTES_EDIT_NOTE') }}, {{ note.TIME }} {% endif %}
{% if S_SEENOTES_CAN_DELETE_NOTE %}{{ lang('SEENOTES_DELETE_NOTE') }}, {{ note.TIME }} {% endif %}
@@ -73,7 +73,7 @@
{% endfor %}
{% if S_SEENOTES_CAN_MANAGE and S_SEENOTES_HAS_CURRENT %}
-
+
{{ lang('SEENOTES_CLEAR_CURRENT') }}
{{ lang('SEENOTES_CLEAR_CURRENT_EXPLAIN') }}
@@ -120,13 +120,13 @@
{{ S_SEENOTES_HIDDEN_USER }}
{{ S_FORM_TOKEN }}
{% if S_SEENOTES_EDITING %}{{ lang('CANCEL') }} {% endif %}
-
+
{% endif %}
{% if S_SEENOTES_CAN_MANAGE %}
-
+
{{ lang('SEENOTES_ADD_ENTRY') }}
{% if loops.seenotes_reasons|length %}
@@ -150,7 +150,7 @@
{{ S_SEENOTES_HIDDEN_USER }}
{{ S_FORM_TOKEN }}
-
+
{% else %}
{{ lang('SEENOTES_NO_REASONS') }}
diff --git a/adm/style/seenotes_acp.css b/adm/style/seenotes_acp.css
index a772523..665f684 100644
--- a/adm/style/seenotes_acp.css
+++ b/adm/style/seenotes_acp.css
@@ -19,3 +19,10 @@
.seenotes-entry .content {
overflow-wrap: anywhere;
}
+
+/* Links large enough to tap (WCAG 2.2 target size) */
+.seenotes-note-actions a {
+ display: inline-block;
+ min-height: 24px;
+ line-height: 24px;
+}
diff --git a/docs/design.md b/docs/design.md
index ec51cba..4c8c35f 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -59,6 +59,7 @@ 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.
Events used, all present on both 3.3.x and master:
diff --git a/event/listener.php b/event/listener.php
index 9cd6c58..8cf6425 100644
--- a/event/listener.php
+++ b/event/listener.php
@@ -281,11 +281,11 @@ public function update_username(data $event)
}
/**
- * Template variables for a member's current reason, for viewers who may
- * see it
+ * Template variables for a member's current reason, or "NONE" when they
+ * have none, for viewers who may see it
*
* @param int $user_id
- * @return array Empty when there is nothing to show
+ * @return array Empty when the viewer may not see reasons
*/
protected function get_display_vars($user_id)
{
@@ -295,22 +295,36 @@ protected function get_display_vars($user_id)
}
$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]))
{
- return [];
+ $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,
+ ];
}
$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']),
- 'U_SEENOTES' => $this->get_notes_url($user_id),
+ 'SEENOTES_LINK_TEXT' => $this->language->lang('SEENOTES_OPEN_NOTES'),
+ 'U_SEENOTES' => $u_notes,
];
}
diff --git a/language/en/common.php b/language/en/common.php
index 333b81a..af95791 100644
--- a/language/en/common.php
+++ b/language/en/common.php
@@ -29,6 +29,9 @@
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',
'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/manager.php b/manager.php
index 9fae90d..5fc447d 100644
--- a/manager.php
+++ b/manager.php
@@ -594,20 +594,18 @@ public function parse_note($text)
$errors[] = 'SEENOTES_NOTE_EMPTY';
}
- $warnings = generate_text_for_storage(
- $text,
- $uid,
- $bitfield,
- $options,
- $allow_bbcode,
- $allow_urls,
- $allow_smilies,
- (bool) $this->config['auth_img_pm'],
- (bool) $this->config['auth_flash_pm'],
- true,
- $allow_urls,
- 'pm'
- );
+ $allow_img = (bool) $this->config['auth_img_pm'];
+
+ // phpBB 4.0 removed the allow_flash_bbcode argument, so the later
+ // arguments sit one place earlier there
+ if (phpbb_version_compare(PHPBB_VERSION, '4.0.0-a1', '>='))
+ {
+ $warnings = generate_text_for_storage($text, $uid, $bitfield, $options, $allow_bbcode, $allow_urls, $allow_smilies, $allow_img, true, $allow_urls, 'pm');
+ }
+ else
+ {
+ $warnings = generate_text_for_storage($text, $uid, $bitfield, $options, $allow_bbcode, $allow_urls, $allow_smilies, $allow_img, (bool) $this->config['auth_flash_pm'], true, $allow_urls, 'pm');
+ }
return [
'text' => $text,
@@ -727,25 +725,41 @@ public function delete_users(array $user_ids)
/**
* Keep the saved names of a renamed member up to date
*
+ * Only rows written by the renamed member change, so a deleted member who
+ * had the same name keeps it.
+ *
* @param string $old_name
* @param string $new_name
* @return void
*/
public function update_username($old_name, $new_name)
{
+ $sql = 'SELECT user_id
+ FROM ' . USERS_TABLE . "
+ WHERE username_clean = '" . $this->db->sql_escape(utf8_clean_string($new_name)) . "'";
+ $result = $this->db->sql_query($sql);
+ $user_id = (int) $this->db->sql_fetchfield('user_id');
+ $this->db->sql_freeresult($result);
+
+ if (!$user_id)
+ {
+ return;
+ }
+
$updates = [
- [$this->entries_table, 'entry_author_name'],
- [$this->notes_table, 'note_author_name'],
- [$this->notes_table, 'note_edit_user_name'],
+ [$this->entries_table, 'entry_author_name', 'entry_author_id'],
+ [$this->notes_table, 'note_author_name', 'note_author_id'],
+ [$this->notes_table, 'note_edit_user_name', 'note_edit_user_id'],
];
foreach ($updates as $update)
{
- list($table, $column) = $update;
+ list($table, $name_column, $id_column) = $update;
$sql = 'UPDATE ' . $table . "
- SET $column = '" . $this->db->sql_escape($new_name) . "'
- WHERE $column = '" . $this->db->sql_escape($old_name) . "'";
+ SET $name_column = '" . $this->db->sql_escape($new_name) . "'
+ WHERE $id_column = " . $user_id . "
+ AND $name_column = '" . $this->db->sql_escape($old_name) . "'";
$this->db->sql_query($sql);
}
}
diff --git a/notes_page.php b/notes_page.php
index 1974112..6b414f7 100644
--- a/notes_page.php
+++ b/notes_page.php
@@ -389,7 +389,6 @@ protected function display(array $errors, $editor_text)
$this->template->assign_vars([
'S_SEENOTES_PAGE' => true,
'SEENOTES_MEMBER' => get_username_string('full', $user_id, $this->member['username'], $this->member['user_colour']),
- 'SEENOTES_MEMBER_NAME' => $this->member['username'],
'SEENOTES_ERROR' => implode(' ', $errors),
'S_SEENOTES_CAN_MANAGE' => $can_manage,
'S_SEENOTES_CAN_EDIT' => $can_edit,
diff --git a/styles/all/template/event/mcp_post_text_before.html b/styles/all/template/event/mcp_post_text_before.html
index 850329f..5e0015a 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_non_contact_custom_fields_after.html b/styles/all/template/event/memberlist_view_non_contact_custom_fields_after.html
index 5283de5..6526d27 100644
--- a/styles/all/template/event/memberlist_view_non_contact_custom_fields_after.html
+++ b/styles/all/template/event/memberlist_view_non_contact_custom_fields_after.html
@@ -1,6 +1,4 @@
{% if S_SEENOTES %}
{{ SEENOTES_LABEL }}{{ lang('COLON') }}
-
- {% if U_SEENOTES %}{% else %}{% endif %}{{ SEENOTES_REASON }} ({{ SEENOTES_COUNT }}) , {{ SEENOTES_COUNT_TEXT }}{% if SEENOTES_DESCRIPTION %}: {{ SEENOTES_DESCRIPTION }}{% endif %}{% if U_SEENOTES %}. {{ lang('SEENOTES_OPEN_NOTES') }}{% endif %} {% if U_SEENOTES %} {% else %}{% endif %}
-
+ {% include '@phpbbmodders_seenotes/seenotes_reason_link.html' with {'sn': _context} only %}
{% endif %}
diff --git a/styles/all/template/mcp_seenotes.html b/styles/all/template/mcp_seenotes.html
index 0f46c11..bae06ec 100644
--- a/styles/all/template/mcp_seenotes.html
+++ b/styles/all/template/mcp_seenotes.html
@@ -19,7 +19,7 @@ {{ lang('MCP_SEENOTES_NOTES') }}
-
+
@@ -39,7 +39,7 @@ {{ lang('SEENOTES_ENTRIES') }}
{{ entry.REASON }}
{% if entry.S_CURRENT %}{{ lang('SEENOTES_CURRENT') }} {% endif %}
- {% if not entry.S_ACTIVE %}{{ lang('SEENOTES_INACTIVE') }} {% endif %}
+ {% if not entry.S_ACTIVE %}{{ lang('SEENOTES_INACTIVE') }} {% endif %}
{{ entry.NOTE_COUNT }}
{% if entry.DESCRIPTION %}{{ entry.DESCRIPTION }}
{% endif %}
@@ -50,7 +50,7 @@ {{ lang('SEENOTES_ENTRIES') }}
{% if S_SEENOTES_CAN_MANAGE %}
{{ lang('SEENOTES_ADD_NOTE') }}{{ lang('COLON') }} {{ entry.REASON }}
{% if not entry.S_CURRENT %}
-
+
{{ lang('SEENOTES_MAKE_CURRENT') }}{{ lang('COLON') }} {{ entry.REASON }}
{{ S_SEENOTES_HIDDEN_USER }}
@@ -88,7 +88,7 @@ {{ lang('SEENOTES_ENTRIES') }}
{% endfor %}
{% if S_SEENOTES_CAN_MANAGE and S_SEENOTES_HAS_CURRENT %}
-
+
{{ lang('SEENOTES_CLEAR_CURRENT') }}
{{ lang('SEENOTES_CLEAR_CURRENT_EXPLAIN') }}
@@ -134,13 +134,13 @@
{% if S_SEENOTES_EDITING %}{{ lang('SEENOTES_EDIT_NOTE') }}{% else %}{{ lang
{{ S_SEENOTES_HIDDEN_USER }}
{{ S_FORM_TOKEN }}
{% if S_SEENOTES_EDITING %}{{ lang('CANCEL') }} {% endif %}
-
+
{% endif %}
{% if S_SEENOTES_CAN_MANAGE %}
-
+
{{ lang('SEENOTES_ADD_ENTRY') }}
@@ -173,7 +173,7 @@
{{ lang('SEENOTES_ADD_ENTRY') }}
{{ S_SEENOTES_HIDDEN_USER }}
{{ S_FORM_TOKEN }}
-
+
{% endif %}
diff --git a/styles/all/template/seenotes_reason.html b/styles/all/template/seenotes_reason.html
index 6a3d457..8278e93 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') }}
- {% if sn.U_SEENOTES %}{% else %}{% endif %}{{ 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 %}
+ {% 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
new file mode 100644
index 0000000..73edf0c
--- /dev/null
+++ b/styles/all/template/seenotes_reason_link.html
@@ -0,0 +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 %}
diff --git a/styles/all/theme/seenotes.css b/styles/all/theme/seenotes.css
index c7f620e..c417a8b 100644
--- a/styles/all/theme/seenotes.css
+++ b/styles/all/theme/seenotes.css
@@ -5,6 +5,14 @@
font-weight: bold;
}
+/* Links large enough to tap (WCAG 2.2 target size) */
+a.seenotes-reason,
+.seenotes-note-actions a {
+ display: inline-block;
+ min-height: 24px;
+ line-height: 24px;
+}
+
.seenotes-notice {
border-left: 4px solid currentColor;
padding: 4px 8px;
@@ -58,3 +66,8 @@
.seenotes-note-actions a {
margin-left: 8px;
}
+
+/* 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 2116008..73d0edf 100644
--- a/tests/event/listener_test.php
+++ b/tests/event/listener_test.php
@@ -129,15 +129,23 @@ public function test_post_row_gets_the_current_reason()
$this->assertStringContainsString('mode=notes&u=3', $row['U_SEENOTES']);
}
- public function test_post_row_unchanged_without_a_current_reason()
+ public function test_post_row_shows_none_without_a_current_reason()
{
$this->manager->method('can')->willReturn(true);
$this->manager->method('get_current_for_users')->willReturn([]);
+ $this->auth->method('acl_getf_global')->willReturn(true);
+
+ // append_sid() runs its event
+ global $phpbb_dispatcher;
+ $phpbb_dispatcher = new \phpbb_mock_event_dispatcher();
$event = new data(['poster_id' => 3, 'post_row' => []]);
$this->get_listener()->show_on_post($event);
- $this->assertSame([], $event['post_row']);
+ $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']);
}
public function test_other_acp_users_modes_are_ignored()
diff --git a/tests/manager_test.php b/tests/manager_test.php
index 029906c..a75d574 100644
--- a/tests/manager_test.php
+++ b/tests/manager_test.php
@@ -202,14 +202,20 @@ public function test_deleting_a_member_deletes_their_records()
$this->assertSame([3], array_keys($manager->get_entries(4)));
}
- public function test_renaming_updates_saved_names()
+ public function test_renaming_updates_only_the_renamed_members_names()
{
$manager = $this->get_manager();
+
+ // A deleted member (99) who had the same name as the renamed one
+ $this->db->sql_query("UPDATE phpbb_seenotes_notes SET note_author_name = 'admin' WHERE note_id = 2");
+ // phpBB renames the users row before core.update_username runs
+ $this->db->sql_query("UPDATE phpbb_users SET username = 'boss', username_clean = 'boss' WHERE user_id = 2");
+
$manager->update_username('admin', 'boss');
$this->assertSame('boss', $manager->get_entry(1)['entry_author_name']);
$this->assertSame('boss', $manager->get_note(1)['note_author_name']);
- $this->assertSame('gone', $manager->get_note(2)['note_author_name']);
+ $this->assertSame('admin', $manager->get_note(2)['note_author_name']);
}
public function test_moderator_log_has_no_forum()
From c3ab3a6256b288e4a1dac82df46e1e373570f961 Mon Sep 17 00:00:00 2001
From: William Jacoby
Date: Sat, 7 Nov 2026 22:49:46 -0600
Subject: [PATCH 03/10] Line up the reason with its label, and size note text
like posts
The tap-target rule made the profile's reason link an inline block,
which dropped it below its label; keep it for note action links only.
Notes in the MCP now use the size of post text.
Co-Authored-By: Claude Opus 5.5
---
styles/all/theme/seenotes.css | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/styles/all/theme/seenotes.css b/styles/all/theme/seenotes.css
index c417a8b..1a2c357 100644
--- a/styles/all/theme/seenotes.css
+++ b/styles/all/theme/seenotes.css
@@ -5,8 +5,9 @@
font-weight: bold;
}
-/* Links large enough to tap (WCAG 2.2 target size) */
-a.seenotes-reason,
+/* Note actions large enough to tap (WCAG 2.2 target size). The reason
+ link stays inline, like the profile's other links, so it lines up with
+ its label. */
.seenotes-note-actions a {
display: inline-block;
min-height: 24px;
@@ -23,6 +24,7 @@ a.seenotes-reason,
}
.seenotes-entry-head {
+ font-size: 1.2em;
display: flex;
flex-wrap: wrap;
align-items: baseline;
@@ -31,7 +33,7 @@ a.seenotes-reason,
}
.seenotes-entry-title {
- font-size: 1.2em;
+ font-size: 1.15em;
font-weight: bold;
}
@@ -59,7 +61,7 @@ a.seenotes-reason,
}
.seenotes-note-meta {
- font-size: 0.9em;
+ font-size: 1.1em;
margin-bottom: 4px;
}
@@ -67,6 +69,13 @@ a.seenotes-reason,
margin-left: 8px;
}
+/* Note text the size of post text (phpBB sizes that through .postbody) */
+.seenotes-note .content {
+ font-size: 1.3em;
+ 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);
From 4414b1f146a91b95bab1e2bef1de3619582fd3ff Mon Sep 17 00:00:00 2001
From: William Jacoby
Date: Sat, 7 Nov 2026 22:51:33 -0600
Subject: [PATCH 04/10] Show phpBB user-note texts, and tidy notes in the ACP
The read-only user notes box printed raw log keys such as
{LOG USER WARNING BODY}: load acp/common, as core's notes page does.
In the ACP, quotes in notes get a frame and words no longer break
mid-word.
Co-Authored-By: Claude Opus 5.5
---
adm/style/seenotes_acp.css | 20 +++++++++++++++++-
.../screenshots/seenotes-acp-reasons.png | Bin 0 -> 85391 bytes
.../screenshots/seenotes-acp-settings.png | Bin 0 -> 78259 bytes
.../images/screenshots/seenotes-acp-users.png | Bin 0 -> 204574 bytes
.../images/screenshots/seenotes-mcp-notes.png | Bin 0 -> 150830 bytes
docs/images/screenshots/seenotes-mcp-post.png | Bin 0 -> 71550 bytes
.../screenshots/seenotes-profile-none.png | Bin 0 -> 41736 bytes
docs/images/screenshots/seenotes-profile.png | Bin 0 -> 42617 bytes
.../images/screenshots/seenotes-viewtopic.png | Bin 0 -> 22370 bytes
notes_page.php | 3 ++-
10 files changed, 21 insertions(+), 2 deletions(-)
create mode 100644 docs/images/screenshots/seenotes-acp-reasons.png
create mode 100644 docs/images/screenshots/seenotes-acp-settings.png
create mode 100644 docs/images/screenshots/seenotes-acp-users.png
create mode 100644 docs/images/screenshots/seenotes-mcp-notes.png
create mode 100644 docs/images/screenshots/seenotes-mcp-post.png
create mode 100644 docs/images/screenshots/seenotes-profile-none.png
create mode 100644 docs/images/screenshots/seenotes-profile.png
create mode 100644 docs/images/screenshots/seenotes-viewtopic.png
diff --git a/adm/style/seenotes_acp.css b/adm/style/seenotes_acp.css
index 665f684..8579585 100644
--- a/adm/style/seenotes_acp.css
+++ b/adm/style/seenotes_acp.css
@@ -17,10 +17,28 @@
}
.seenotes-entry .content {
- overflow-wrap: anywhere;
+ overflow-wrap: break-word;
+}
+
+/* The ACP has no BBCode styles, so give quotes in notes a frame */
+.seenotes-entry .content blockquote {
+ background-color: #F6F6F0;
+ border: 1px solid #DBDBCE;
+ margin: 6px 1px 6px 12px;
+ padding: 5px 8px;
+}
+
+.seenotes-entry .content blockquote cite {
+ display: block;
+ font-style: normal;
+ font-weight: bold;
}
/* Links large enough to tap (WCAG 2.2 target size) */
+.seenotes-note-actions {
+ white-space: nowrap;
+}
+
.seenotes-note-actions a {
display: inline-block;
min-height: 24px;
diff --git a/docs/images/screenshots/seenotes-acp-reasons.png b/docs/images/screenshots/seenotes-acp-reasons.png
new file mode 100644
index 0000000000000000000000000000000000000000..a45f668ed72e745015451b32ae87b1f09bb410e9
GIT binary patch
literal 85391
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