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..65b21da 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,45 @@ +admin KbTest1234! # See Notes +[![Tests](https://github.com/phpbbmodders/seenotes/actions/workflows/tests.yml/badge.svg)](https://github.com/phpbbmodders/seenotes/actions/workflows/tests.yml) [![Lint](https://github.com/phpbbmodders/seenotes/actions/workflows/lint.yml/badge.svg)](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 + +- **ACP → Extensions → See Notes → Reasons**: administrators set up the list of reasons, each with a description, a color, and an on/off switch. Seven starter reasons (Moderation queue, Spam watch, Warez, Conduct, Possible returning member, Staff in contact, General) are added on first enable, ready to edit or remove. A switched-off reason can't be given any more, but members who have it keep it and its notes. +- **MCP → Moderator notes** and **ACP → Manage users → Moderator notes**: give a member a reason, choose which reason is current (or none), and add notes under each reason. +- **Notes use BBCode**, smilies and links, following the board's private message settings, so a PM sent to the member can be pasted into a quote for other staff. +- **Only staff see it.** The current reason shows as "SEE NOTES: Moderation queue (3)" on the member's profile, next to their posts, on the MCP post, report and approval queue pages, and in the MCP topic view. It links to the notes. A member with no current reason shows "SEE NOTES: NONE", which links straight to the form that gives one. +- 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. + +The full design is in [`docs/design.md`](docs/design.md). -## Planned features +## Screenshots -- 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. + + + + + +
MCP Moderator notes page for a memberMCP post details with a SEE NOTES notice

The reason in a post's mini-profile

SEE NOTES: NONE on a profile
+ +Click an image for the full size. All screenshots, with captions: [docs/images/screenshots](docs/images/screenshots/README.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) +- phpBB 3.3.19 or later, including phpBB 4.0 (tested on its 4.0.0-a3 development version) +- 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. Review the starter reasons under **Extensions → See Notes → Reasons**, and add, rename or switch off any you like ## Contributing @@ -27,7 +51,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..cf54100 --- /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_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; + // 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); + } + + 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..ae27996 --- /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 %} + +
+
+ {% if S_SEENOTES_ADDING %}{{ lang('SEENOTES_ADD_REASON') }}{% else %}{{ lang('SEENOTES_EDIT_REASON') }}{% endif %} +
+

{{ lang('SEENOTES_REASON_TITLE_EXPLAIN') }}
+
+
+
+

{{ lang('SEENOTES_REASON_DESCRIPTION_EXPLAIN') }}{% if S_SEENOTES_REQUIRE_DESCRIPTION %} {{ lang('SEENOTES_REASON_DESCRIPTION_REQUIRED') }}{% endif %}
+
+
+
+

{{ lang('SEENOTES_REASON_COLOR_EXPLAIN') }}
+
+
+
+
+ {{ lang('SUBMIT') }} + + {{ S_FORM_TOKEN }} +
+
+ +{% elseif S_SEENOTES_DELETE_STEP1 or S_SEENOTES_DELETE_STEP2 %} + +

{{ lang('SEENOTES_DELETE_REASON') }}

+
+

{{ SEENOTES_DELETE_SUMMARY }}

+
+ +
+ {% if S_SEENOTES_DELETE_STEP2 %} +
+ {{ lang('SEENOTES_DELETE_REASON') }} +
+
+
+
+
+ {% endif %} +
+ {{ lang('SUBMIT') }} + {% if S_SEENOTES_DELETE_STEP2 %} + + {% else %} + + {% endif %} + + {{ S_FORM_TOKEN }} +
+
+ {% if S_SEENOTES_DELETE_STEP2 %}{% INCLUDEJS '@phpbbmodders_seenotes/seenotes_delete.js' %}{% endif %} + +{% else %} + +

{{ lang('ACP_SEENOTES_REASONS_EXPLAIN') }}

+ + {% if loops.reasons|length %} + + + + + + + + + + + {% for reason in loops.reasons %} + + + + + + + {% endfor %} + +
{{ lang('SEENOTES_REASON_TITLE') }}{{ lang('SEENOTES_REASON_DESCRIPTION') }}{{ lang('SEENOTES_STATUS') }}{{ lang('ACTION') }}
{{ reason.TITLE }}{{ reason.DESCRIPTION }}{% if reason.S_ACTIVE %}{{ lang('SEENOTES_STATUS_ON') }}{% else %}{{ lang('SEENOTES_STATUS_OFF') }}{% endif %} + {% if loop.first %}{{ ICON_MOVE_UP_DISABLED }}{% else %}{{ ICON_MOVE_UP }}{{ lang('SEENOTES_MOVE_UP') }}{{ lang('COLON') }} {{ reason.TITLE }}{% endif %} + {% if loop.last %}{{ ICON_MOVE_DOWN_DISABLED }}{% else %}{{ ICON_MOVE_DOWN }}{{ lang('SEENOTES_MOVE_DOWN') }}{{ lang('COLON') }} {{ reason.TITLE }}{% endif %} + {{ ICON_EDIT }}{{ lang('EDIT') }}{{ lang('COLON') }} {{ reason.TITLE }} + {% if reason.S_ACTIVE %}{{ lang('SEENOTES_SWITCH_OFF') }}{% else %}{{ lang('SEENOTES_SWITCH_ON') }}{% endif %}{{ lang('COLON') }} {{ reason.TITLE }} + {% if S_SEENOTES_HARD_DELETE %}{{ ICON_DELETE }}{{ lang('SEENOTES_DELETE_REASON') }}{{ lang('COLON') }} {{ reason.TITLE }}{% endif %} +
+ {% 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..6840a94 --- /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') }}

+ +
+
+ {{ lang('ACP_SEENOTES_SETTINGS') }} +
+

{{ lang('SEENOTES_LABEL_EXPLAIN') }}
+
+
+
+

{{ lang('SEENOTES_REQUIRE_DESCRIPTION_EXPLAIN') }}
+
+
+
+

{{ lang('SEENOTES_ALLOW_HARD_DELETE_EXPLAIN') }}
+
+
+
+ +
+ {{ lang('SUBMIT') }} +   + + {{ S_FORM_TOKEN }} +
+
+ +{% 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..cb8dea1 --- /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 %} +
+

+ + + {{ S_SEENOTES_HIDDEN_USER }} + {{ S_FORM_TOKEN }} +

+
+ {% endif %} + {% endif %} + + {% if entry.notes|length %} + + + + + + {% if S_SEENOTES_CAN_EDIT or S_SEENOTES_CAN_DELETE_NOTE %}{% endif %} + + + + {% for note in entry.notes %} + + + + {% if S_SEENOTES_CAN_EDIT or S_SEENOTES_CAN_DELETE_NOTE %} + + {% endif %} + + {% endfor %} + +
{{ lang('SEENOTES_NOTE_TEXT') }}{{ lang('REPORT_BY') }}{{ lang('ACTION') }}
+
{{ note.TEXT }}
+ {% if note.EDITED %}

{{ note.EDITED }}

{% endif %} +
{{ note.AUTHOR }}
{{ note.TIME }}
+ {% 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 %} +
+ {% else %} +

{{ lang('SEENOTES_NO_NOTES') }}

+ {% endif %} +
+
+ {% else %} +

{{ lang('SEENOTES_NO_ENTRIES') }}

+ {% endfor %} + + {% if S_SEENOTES_CAN_MANAGE and S_SEENOTES_HAS_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 %} +
+
+
+ +
+
+ {% endif %} + {% include 'acp_posting_buttons.html' %} +
+
+
+ +

{{ 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') }}

+
+
+
+ +
+
+
+
+
+
+

+ + {{ 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 %} + + + + + + + + + + {% for usernote in loops.seenotes_usernotes %} + + + + + + {% endfor %} + +
{{ lang('REPORT_BY') }}{{ lang('TIME') }}{{ lang('ACTION') }}
{{ usernote.REPORT_BY }}{{ usernote.REPORT_AT }}{{ usernote.ACTION }}
+

{{ 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..8579585 --- /dev/null +++ b/adm/style/seenotes_acp.css @@ -0,0 +1,46 @@ +/* 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: 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; + line-height: 24px; +} 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..43f4188 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 @@ -24,7 +24,7 @@ The display still looks like a profile field. **ACP → Extensions → See Notes → Reasons**: -- Administrators add, edit and reorder reasons. The list starts empty. +- Administrators add, edit and reorder reasons. Enabling the extension the first time adds seven starter reasons in the board's default language: Moderation queue, Spam watch, Warez, Conduct, Possible returning member, Staff in contact and General, each with a description and color. They are ordinary reasons from then on. A board that already has reasons gets none. - Each reason has a **description** (for example "Posts must be approved before they appear"), shown to staff when they hover over the reason, and a **color**, red by default, used for the mini-profile line. The **Require a description for each reason** setting (off by default) makes the description required: when it is on, a reason cannot be saved without one. Turning it on does not change existing reasons, but one without a description must get one the next time it is edited. - Each reason has an **on/off switch**. Turning a reason off removes it from the list moderators pick from. Members who already have it keep their entries and notes, still shown with that reason's name. Turning it back on makes it selectable again. - Renaming a reason renames it everywhere it is used, since entries point to the reason rather than copying its name. @@ -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. @@ -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 "