008: Classroom Chat / Discussion (Mac guide)
008: Classroom Chat / Discussion (Mac guide)
Prepared only — application code and database have not been changed. Apply this yourself after 001b, 002c, 003b, 007, 004, 005, and 006. This follows 006: named insertions and section replacements in existing files, complete blocks for new files, and checks after edits. Do not replace whole existing app files with snapshots.
008 adds text messages, teacher announcements, student questions, replies to questions, teacher hide controls, Open / Questions only / Read-only / Off modes, and student slow mode. Superadmin/admin monitoring stays read-only, including forged POST requests. The class transcript persists across sessions, with 50 messages per page. Hidden messages become placeholders; hiding a question blocks new replies, but existing replies remain visible.
Polling refreshes the message list every 10 seconds while the classroom tab is visible. It preserves the composer and history cursor, does not write presence or attendance, and stops on access failures. It is a first PHP implementation for the current app, not the Node/WebSocket scale target in files/docs/architecture-and-cost.md. Load testing and a later transport upgrade remain necessary before large concurrent deployment.
The existing class_messages, chat_mutes, classes.chat_locked, and schema_updates tables are reused. One separate, sync-safe table stores chat controls. The wider 002b class settings/defaults, 002d notification bell/browser alerts/push, and 009 report/mute administration remain separate. The notification spec mentions push with 008, but its 002d foundation is still planned: this package does not claim to deliver notifications or closed-browser push. Existing mutes are enforced now; new mute/report management belongs to 009.
Students can read their enrolled active class, and post once its current heartbeat confirms they are inside it. The existing 002c waiting-class behavior allows discussion before/after a live session; attendance is still counted only during a live session. No new join/password rules are introduced here.
Before you start: Mac location and backup
Open Terminal with Command+Space → Terminal → Enter. Run:
cd ~/Desktop/RCG/PMFTCI/PMFTCI-LMS
pwd
php -v
Use your actual folder if different. Every target below is relative to this LMS root. In VS Code, Command+P opens a target path; Command+F finds its anchor; Command+S saves. Keep each existing file's final class brace. For a new PHP file, include the opening <?php from its block, and do not add a closing PHP tag.
Copy the new files/ materials to your Mac first. That alone does not install 008. Leave env.php as it is. Make a local backup before editing:
mkdir -p files/local-backups
backup_name="008-before-$(date +%Y%m%d-%H%M%S).tar.gz"
tar -czf "files/local-backups/$backup_name" application backend/lms
Keep that backup on your Mac: it includes your local configuration. Also export the LMS database in phpMyAdmin (select LMS → Export → Quick → SQL). Do not select the SMS database. No command in this guide installs the feature automatically.
| Step | Target | What you do |
|---|---|---|
| 1 | application/sql/updates/2026-10-05_classroom_chat.sql | Create new migration file, then run it on LMS |
| 2 | application/sql/lms_schema.sql | Append new table/update marker for future installs |
| 3 | application/helpers/discussion_helper.php | Create new helper |
| 4 | application/models/Discussion_model.php | Create new model |
| 5 | application/controllers/Discussion.php | Create new controller |
| 6 | application/controllers/Classroom.php | Four small insertions |
| 7 | application/views/classroom/discussion_messages.php | Create message-list partial |
| 8 | application/views/classroom/discussion.php | Create discussion box |
| 9 | application/views/classroom/view.php | Replace chat placeholder only |
| 10 | backend/lms/discussion.js | Create polling/reply script |
| 11 | backend/lms/lms.css | Append chat styles |
Snapshots in code/ show the expected completed files for comparison. The three existing PHP/CSS files and schema are snapshots, not instructions to overwrite your files.
Step 1: Create and run the migration
New file: application/sql/updates/2026-10-05_classroom_chat.sql.
Create this file and paste the complete block:
-- Run only against the LMS database. Existing class_messages and chat_mutes are reused.
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `class_chat_controls` (
`class_id` int(11) NOT NULL,
`mode` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'open',
`slow_seconds` int(11) NOT NULL DEFAULT 0,
`updated_by` int(11) DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`class_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO `schema_updates` (`filename`) VALUES ('2026-10-05_classroom_chat.sql');
In phpMyAdmin, choose the LMS database named by db_database in your local application/config/env.php. Open SQL and first run this read-only check:
SHOW COLUMNS FROM class_messages;
SHOW COLUMNS FROM chat_mutes;
SHOW COLUMNS FROM classes LIKE 'chat_locked';
SHOW TABLES LIKE 'schema_updates';
class_messages must have parent_id, kind, body, is_hidden, hidden_by, created_at, and updated_at. If any table/column is missing, finish the foundation schema setup in files/GUIDE.md Part 5 first; do not reimport the whole foundation schema over an existing database.
Now paste the migration block above into SQL → Go. It creates the control table and records the migration. Repeating it is safe (IF NOT EXISTS and INSERT IGNORE); it does not reset messages or controls. class_id references follow the existing LMS schema convention; there are no foreign keys.
Verify:
SHOW COLUMNS FROM class_chat_controls;
SELECT filename FROM schema_updates WHERE filename = '2026-10-05_classroom_chat.sql';
You should see class_id, mode, slow_seconds, updated_by, updated_at, plus the filename row. Do not run the school SQL dump or any SMS sync command for this feature.
Step 2: Update the fresh-install schema
File: application/sql/lms_schema.sql.
Go to the very bottom, after the existing INSERT INTO schema_updates statement ending with ('2026-09-26_presence_states.sql');. Append the following block once. If the chat table definition is already there, do not append it again. This file is for future empty database installs; you already updated the existing database in Step 1. Do not run the whole schema now.
-- Run only against the LMS database. Existing class_messages and chat_mutes are reused.
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `class_chat_controls` (
`class_id` int(11) NOT NULL,
`mode` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'open',
`slow_seconds` int(11) NOT NULL DEFAULT 0,
`updated_by` int(11) DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`class_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO `schema_updates` (`filename`) VALUES ('2026-10-05_classroom_chat.sql');
Step 3: Create the discussion helper
New file: application/helpers/discussion_helper.php. Create it and paste this complete block. No config/autoload/routes changes are required; the new controller loads the helper/model itself.
<?php
defined('BASEPATH') or exit('No direct script access allowed');
function discussion_id($value)
{
return is_scalar($value) && ctype_digit((string) $value) ? (int) $value : 0;
}
function discussion_payload($body, $kind, $parent)
{
$body = is_string($body) ? trim($body) : '';
if ($body === '' || !mb_check_encoding($body, 'UTF-8') || mb_strlen($body) > 1000) {
return ['error' => 'Write 1–1000 characters of text.'];
}
if (!is_string($kind) || !in_array($kind, ['message', 'question', 'announcement', 'reply'], true)) {
return ['error' => 'Choose a valid message type.'];
}
$parent_id = discussion_id($parent);
if (($kind === 'reply' && $parent_id < 1) || ($kind !== 'reply' && $parent_id !== 0)) {
return ['error' => 'Replies need a question; other messages cannot have a reply target.'];
}
return ['body' => $body, 'kind' => $kind, 'parent_id' => $parent_id ?: null];
}
function discussion_write_error($role, $mode, $kind, $inside, $muted)
{
if (!in_array($role, ['student', 'teacher'], true)) {
return 'Classroom monitoring is read-only.';
}
if (!in_array($mode, ['open', 'questions', 'read_only', 'off'], true)) {
return 'Discussion settings are unavailable.';
}
if ($mode === 'off') {
return 'Discussion is turned off.';
}
if ($role === 'teacher') {
return null;
}
if (!$inside) {
return 'Open this classroom and wait for its heartbeat before posting.';
}
if ($muted) {
return 'You are muted in this discussion.';
}
if ($kind === 'announcement') {
return 'Only your teacher can post announcements.';
}
if ($mode === 'read_only') {
return 'Only your teacher can post while discussion is read-only.';
}
if ($mode === 'questions' && !in_array($kind, ['question', 'reply'], true)) {
return 'Questions-only mode accepts questions and replies.';
}
return null;
}
Check: php -l application/helpers/discussion_helper.php
Step 4: Create the discussion model
New file: application/models/Discussion_model.php. Create it and paste this complete block. No config/autoload/routes changes are required; the new controller loads the helper/model itself.
<?php
defined('BASEPATH') or exit('No direct script access allowed');
class Discussion_model extends MY_Model
{
public function controls($class)
{
$row = $this->db->where('class_id', (int) $class['id'])->get('class_chat_controls')->row_array();
$mode = $row ? $row['mode'] : 'open';
if (!in_array($mode, ['open', 'questions', 'read_only', 'off'], true)) {
$mode = 'off';
}
if (!empty($class['chat_locked']) && $mode !== 'off') {
$mode = 'read_only';
}
return ['mode' => $mode, 'slow_seconds' => $row ? (int) $row['slow_seconds'] : 0];
}
public function page($class_id, $before = 0)
{
$this->db
->select('m.id, m.user_id, m.parent_id, m.kind, m.body, m.is_hidden, m.created_at, u.firstname, u.lastname, u.role')
->from('class_messages m')
->join('users u', 'u.id = m.user_id', 'left')
->where('m.class_id', (int) $class_id);
if ($before > 0) {
$this->db->where('m.id <', (int) $before);
}
$rows = $this->db->order_by('m.id', 'desc')->limit(51)->get()->result_array();
$more = count($rows) > 50;
$rows = array_slice($rows, 0, 50);
foreach ($rows as &$row) {
if ($row['is_hidden']) {
$row['body'] = '';
}
}
unset($row);
return ['rows' => array_reverse($rows), 'next_id' => $more ? (int) end($rows)['id'] : null];
}
public function muted($class_id, $user_id)
{
return $this->db->from('chat_mutes')->where('user_id', (int) $user_id)
->group_start()->where('class_id', (int) $class_id)->or_where('class_id', null)->group_end()
->group_start()->where('muted_until', null)->or_where('muted_until >', date('Y-m-d H:i:s'))->group_end()
->count_all_results() > 0;
}
// Serialize sends and controls on the existing class row, including across tabs/devices.
public function post_message($class_id, $user_id, $payload)
{
$this->db->trans_begin();
$class = $this->db->query('SELECT * FROM classes WHERE id = ? FOR UPDATE', [(int) $class_id])->row_array();
$user = $this->db->where('id', (int) $user_id)->get('users')->row_array();
$error = null;
if (!$class || $class['is_active'] !== 'yes' || !$user || $user['is_active'] !== 'yes') {
$error = 'This class or account is no longer active.';
} elseif ($user['role'] === 'teacher' && (int) $class['teacher_id'] !== (int) $user_id) {
$error = 'You can only post in your own classes.';
} elseif ($user['role'] === 'student' && !$this->enrollment_model->is_enrolled($class_id, $user_id)) {
$error = 'You are no longer enrolled in this class.';
}
if ($error === null) {
$controls = $this->controls($class);
$inside = $this->presence_model->class_state($this->presence_model->get($user_id), $class_id) !== null;
$error = discussion_write_error($user['role'], $controls['mode'], $payload['kind'], $inside,
$user['role'] === 'student' && $this->muted($class_id, $user_id));
}
if ($error === null && $payload['parent_id'] !== null) {
$parent = $this->db->where(['id' => $payload['parent_id'], 'class_id' => (int) $class_id,
'kind' => 'question', 'is_hidden' => 0, 'parent_id' => null])->get('class_messages')->row_array();
if (!$parent) {
$error = 'That question is unavailable in this class.';
}
}
if ($error === null) {
$last = $this->db->where(['class_id' => (int) $class_id, 'user_id' => (int) $user_id])
->order_by('id', 'desc')->limit(1)->get('class_messages')->row_array();
$wait = $user['role'] === 'student' ? max(2, $controls['slow_seconds']) : 2;
if ($last && time() - strtotime($last['created_at']) < $wait) {
$error = 'Please wait ' . $wait . ' seconds between messages.';
}
}
if ($error !== null) {
$this->db->trans_rollback();
return $error;
}
$this->db->insert('class_messages', ['class_id' => (int) $class_id, 'user_id' => (int) $user_id,
'parent_id' => $payload['parent_id'], 'kind' => $payload['kind'], 'body' => $payload['body'],
'created_at' => date('Y-m-d H:i:s')]);
$id = $this->db->insert_id();
$this->log('Posted classroom ' . $payload['kind'], $id, 'chat_post', $class_id);
if (!$this->db->trans_status()) {
$this->db->trans_rollback();
return 'Message could not be saved. Reload and try again.';
}
$this->db->trans_commit();
return null;
}
public function teacher_change($class_id, $user_id, $mode = null, $slow = 0, $message_id = 0)
{
$this->db->trans_begin();
$class = $this->db->query('SELECT * FROM classes WHERE id = ? FOR UPDATE', [(int) $class_id])->row_array();
$user = $this->db->where('id', (int) $user_id)->get('users')->row_array();
if (!$class || $class['is_active'] !== 'yes' || (int) $class['teacher_id'] !== (int) $user_id ||
!$user || $user['is_active'] !== 'yes' || $user['role'] !== 'teacher') {
$this->db->trans_rollback();
return 'Only the assigned teacher can change this discussion.';
}
$now = date('Y-m-d H:i:s');
if ($mode !== null) {
if (!in_array($mode, ['open', 'questions', 'read_only', 'off'], true) || $slow < 0 || $slow > 60) {
$this->db->trans_rollback();
return 'Choose a valid mode and 0–60 seconds of slow mode.';
}
$this->db->query('INSERT INTO class_chat_controls (class_id, mode, slow_seconds, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE mode = VALUES(mode),
slow_seconds = VALUES(slow_seconds), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at)',
[(int) $class_id, $mode, (int) $slow, (int) $user_id, $now]);
$this->db->where('id', (int) $class_id)->update('classes', ['chat_locked' => $mode === 'read_only' ? 1 : 0]);
$this->log('Discussion mode ' . $mode . '; slow mode ' . $slow . 's', $class_id, 'chat_controls', $class_id);
} else {
$message = $this->db->where(['id' => (int) $message_id, 'class_id' => (int) $class_id,
'is_hidden' => 0])->get('class_messages')->row_array();
if (!$message) {
$this->db->trans_rollback();
return 'Message is unavailable in this class.';
}
$this->db->where('id', (int) $message_id)->update('class_messages',
['is_hidden' => 1, 'hidden_by' => (int) $user_id, 'updated_at' => $now]);
$this->log('Teacher hid classroom message', $message_id, 'chat_hide', $class_id);
}
if (!$this->db->trans_status()) {
$this->db->trans_rollback();
return 'Change could not be saved.';
}
$this->db->trans_commit();
return null;
}
}
Check: php -l application/models/Discussion_model.php
Step 5: Create the discussion controller
New file: application/controllers/Discussion.php. Create it and paste this complete block. No config/autoload/routes changes are required; the new controller loads the helper/model itself.
<?php
defined('BASEPATH') or exit('No direct script access allowed');
class Discussion extends Member_Controller
{
public function __construct()
{
parent::__construct();
$this->load->helper('discussion');
$this->load->model(['class_model', 'enrollment_model', 'presence_model', 'discussion_model']);
}
private function allowed($id)
{
$class = $this->class_model->get_details(discussion_id($id));
$user = $this->db->where('id', $this->auth->user_id())->get('users')->row_array();
if (!$user || $user['is_active'] !== 'yes' || $user['role'] !== $this->auth->role()) {
show_error('Account access changed. Sign in again.', 403);
}
$role = $this->auth->role();
if (!$class || $class['is_active'] !== 'yes' || !(
$this->auth->is_superadmin() ||
($role === 'teacher' && (int) $class['teacher_id'] === $this->auth->user_id()) ||
($role === 'student' && $this->enrollment_model->is_enrolled($class['id'], $this->auth->user_id()))
)) {
show_404();
}
return $class;
}
public function feed($id = 0)
{
if ($this->input->method() !== 'get') {
show_404();
}
$class = $this->allowed($id);
$controls = $this->discussion_model->controls($class);
$data = $controls['mode'] === 'off' ? ['rows' => [], 'next_id' => null] :
$this->discussion_model->page($class['id'], discussion_id($this->input->get('before')));
$data['class'] = $class;
$data['chat_teacher'] = $this->auth->role() === 'teacher';
$data['chat_reader'] = $this->auth->is_superadmin();
$data['chat_controls'] = $controls;
$html = $this->load->view('classroom/discussion_messages', $data, true);
$this->output->set_header('Cache-Control: no-store');
json_output(200, ['html' => $html, 'mode' => $controls['mode']]);
}
public function send($id = 0)
{
$this->require_post();
$class = $this->allowed($id);
$payload = discussion_payload($this->input->post('body'), $this->input->post('kind'), $this->input->post('parent_id'));
$error = $payload['error'] ?? $this->discussion_model->post_message($class['id'], $this->auth->user_id(), $payload);
if ($error !== null) {
$body = $this->input->post('body');
$this->session->set_flashdata('chat_draft', is_string($body) ? mb_substr($body, 0, 1000) : '');
}
$this->finish($class['id'], $error, 'Message sent.');
}
public function controls($id = 0)
{
$this->require_post();
$class = $this->allowed($id);
$mode = $this->input->post('mode');
$slow = $this->input->post('slow_seconds');
if (!is_string($mode) || !in_array($mode, ['open', 'questions', 'read_only', 'off'], true) ||
!is_string($slow) || !ctype_digit($slow) || (int) $slow > 60) {
$error = 'Choose a valid mode and 0–60 whole seconds of slow mode.';
} else {
$error = $this->discussion_model->teacher_change($class['id'], $this->auth->user_id(), $mode, (int) $slow);
}
$this->finish($class['id'], $error, 'Discussion controls saved.');
}
public function hide($id = 0)
{
$this->require_post();
$class = $this->allowed($id);
$error = $this->discussion_model->teacher_change($class['id'], $this->auth->user_id(), null, 0,
discussion_id($this->input->post('message_id')));
$this->finish($class['id'], $error, 'Message hidden.');
}
private function require_post()
{
if ($this->input->method() !== 'post') {
show_404();
}
}
private function finish($id, $error, $success)
{
$this->session->set_flashdata('chat_notice', $error ?? $success);
redirect('class/' . (int) $id . '#classroom-chat');
}
}
Check: php -l application/controllers/Discussion.php
Step 6: Insert classroom data and access checks
File: application/controllers/Classroom.php.
6A: Add one model in the constructor
Find $this->load->model([ in __construct(). At the end of its list, replace just these last two lines:
'attendance_model',
]);
with:
'attendance_model',
'discussion_model',
]);
Keep the other model names and the whole existing constructor.
6B: Load the helper inside view()
Find the first $role = $this->auth->role(); inside public function view($id = 0). Insert this one line immediately before it:
$this->load->helper('discussion');
6C: Add discussion data
Find this exact line near the end of view():
$data['heartbeat_class_id'] = (int) $class['id'];
Immediately after it, before $this->load->view('layout/header', $data);, insert:
$data['chat_teacher'] = $role === 'teacher';
$data['chat_reader'] = $this->auth->is_superadmin();
$data['chat_controls'] = $this->discussion_model->controls($class);
$data['chat_before'] = discussion_id($this->input->get('chat_before'));
$data['chat_page'] = $data['chat_controls']['mode'] === 'off'
? ['rows' => [], 'next_id' => null]
: $this->discussion_model->page($class['id'], $data['chat_before']);
6D: Reject inactive classes/accounts on the classroom page
Find private function can_view($class). Immediately inside its opening {, before the existing if ($this->auth->is_superadmin()), insert:
$user = $this->db->where('id', $this->auth->user_id())->get('users')->row_array();
if (!$user || $user['is_active'] !== 'yes' || $user['role'] !== $this->auth->role() || $class['is_active'] !== 'yes') {
return false;
}
Keep the existing superadmin/teacher/student branches below this. The new discussion endpoint repeats access checks on every poll/send and queries the current account/enrollment, so a stale login session does not grant discussion access.
Check: php -l application/controllers/Classroom.php
Step 7: Create the message list
New file: application/views/classroom/discussion_messages.php. Create it and paste this entire block:
<?php if ($chat_controls['mode'] === 'off') { ?>
<p class="text-muted">Discussion is turned off. Saved messages remain available when the teacher turns it on.</p>
<?php } else { ?>
<?php if (empty($rows)) { ?><p class="text-muted">No messages on this page yet.</p><?php } ?>
<?php foreach ($rows as $message) { ?>
<article class="lms-chat-message <?php echo $message['kind'] === 'announcement' ? 'lms-chat-announcement' : ''; ?>">
<div><b>#<?php echo (int) $message['id']; ?> · <?php echo html_escape(trim($message['firstname'] . ' ' . $message['lastname']) ?: 'Former user'); ?></b>
<span class="label label-default"><?php echo html_escape($message['role'] ?? 'former'); ?></span>
<small><?php echo html_escape($message['kind'] . ' · ' . $message['created_at']); ?></small>
</div>
<?php if ($message['parent_id']) { ?><div class="text-muted small">Reply to question #<?php echo (int) $message['parent_id']; ?></div><?php } ?>
<p class="lms-chat-text"><?php echo $message['is_hidden'] ? 'Message hidden by the teacher.' : html_escape($message['body']); ?></p>
<?php if (!$message['is_hidden'] && !$chat_reader && $message['kind'] === 'question' && !$message['parent_id']) { ?>
<button type="button" class="btn btn-xs btn-default chat-reply" data-question="<?php echo (int) $message['id']; ?>">Reply</button>
<?php } ?>
<?php if ($chat_teacher && !$message['is_hidden']) { ?>
<form method="post" action="<?php echo site_url('discussion/hide/' . (int) $class['id']); ?>" class="lms-inline-form">
<?php echo $this->customlib->getCSRF(); ?>
<input type="hidden" name="message_id" value="<?php echo (int) $message['id']; ?>">
<button type="submit" class="btn btn-xs btn-warning">Hide</button>
</form>
<?php } ?>
</article>
<?php } ?>
<div class="lms-actions">
<a href="<?php echo site_url('class/' . (int) $class['id']) . '#classroom-chat'; ?>">Newest</a>
<?php if ($next_id !== null) { ?><a href="<?php echo site_url('class/' . (int) $class['id']) . '?chat_before=' . (int) $next_id . '#classroom-chat'; ?>">Older messages</a><?php } ?>
</div>
<?php } ?>
Check: php -l application/views/classroom/discussion_messages.php
Step 8: Create the discussion box
New file: application/views/classroom/discussion.php. Create it and paste this entire block:
<div class="box" id="classroom-chat">
<div class="box-header with-border"><h3 class="box-title">Classroom Chat / Discussion</h3></div>
<div class="box-body">
<p class="text-muted">Text only · 1–1000 characters · Mode: <b id="chat-mode"><?php echo html_escape($chat_controls['mode']); ?></b></p>
<?php if ($notice = $this->session->flashdata('chat_notice')) { ?>
<div class="alert alert-info" role="status"><?php echo html_escape($notice); ?></div>
<?php } ?>
<?php if ($chat_teacher) { ?>
<form action="<?php echo site_url('discussion/controls/' . (int) $class['id']); ?>" method="post" class="lms-chat-controls">
<?php echo $this->customlib->getCSRF(); ?>
<label for="chat-mode-select">Discussion mode</label>
<select name="mode" id="chat-mode-select" class="form-control">
<?php foreach (['open' => 'Open', 'questions' => 'Questions only', 'read_only' => 'Read-only (teacher posts)', 'off' => 'Off'] as $value => $label) { ?>
<option value="<?php echo $value; ?>" <?php echo $chat_controls['mode'] === $value ? 'selected' : ''; ?>><?php echo $label; ?></option>
<?php } ?>
</select>
<label for="chat-slow">Student slow mode (seconds)</label>
<input type="number" id="chat-slow" name="slow_seconds" min="0" max="60" value="<?php echo (int) $chat_controls['slow_seconds']; ?>" class="form-control" required>
<button class="btn btn-default" type="submit">Save controls</button>
</form>
<?php } ?>
<p id="chat-refresh-status" class="text-muted small" role="status">Latest messages refresh every 10 seconds while this tab is visible.</p>
<div id="chat-messages" aria-label="Classroom messages">
<?php $this->load->view('classroom/discussion_messages', ['rows' => $chat_page['rows'], 'next_id' => $chat_page['next_id']]); ?>
</div>
<?php if (!$chat_reader) { ?>
<form id="chat-compose" action="<?php echo site_url('discussion/send/' . (int) $class['id']); ?>" method="post">
<?php echo $this->customlib->getCSRF(); ?>
<label for="chat-kind">Message type</label>
<select name="kind" id="chat-kind" class="form-control">
<option value="message">Message</option><option value="question">Question</option>
<option value="reply">Reply to a question</option>
<?php if ($chat_teacher) { ?><option value="announcement">Teacher announcement</option><?php } ?>
</select>
<label for="chat-parent">Question number (replies only)</label>
<input type="number" name="parent_id" id="chat-parent" min="1" class="form-control" placeholder="Use a question's Reply button">
<label for="chat-body">Your text</label>
<textarea id="chat-body" name="body" rows="3" maxlength="1000" class="form-control" required><?php echo html_escape($this->session->flashdata('chat_draft') ?? ''); ?></textarea>
<p class="help-block">Students must keep this classroom open for the heartbeat. Minimum 2 seconds between posts. Replies link to a visible question in this class.</p>
<button class="btn btn-primary" type="submit">Send</button>
</form>
<?php } else { ?>
<p class="help-block">Read-only classroom monitoring.</p>
<?php } ?>
</div>
</div>
<script>
window.lmsDiscussion = <?php echo json_encode(['feed' => site_url('discussion/feed/' . (int) $class['id']), 'before' => (int) $chat_before], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>;
</script>
<script src="<?php echo base_url('backend/lms/discussion.js'); ?>"></script>
Check: php -l application/views/classroom/discussion.php
Step 9: Replace only the classroom chat placeholder
File: application/views/classroom/view.php.
Use Command+F to find Coming next: text-only chat. Select exactly this surrounding box (four lines), including its opening/closing <div>:
<div class="box">
<div class="box-header with-border"><h3 class="box-title">Classroom Chat</h3></div>
<div class="box-body text-muted">Coming next: text-only chat, announcements, questions / replies<?php echo $this->auth->is_superadmin() ? ', read-only monitoring for superadmin' : ''; ?>.</div>
</div>
Replace only that box with:
<?php $this->load->view('classroom/discussion'); ?>
Keep the outer col-md-8 and row closing tags, roster, lessons, time counter, and existing bottom script.
Check: php -l application/views/classroom/view.php
Step 10: Create the browser script
New file: backend/lms/discussion.js. Paste:
(function ($) {
'use strict';
var config = window.lmsDiscussion;
if (!config || !$('#classroom-chat').length) return;
function refresh() {
if (document.hidden) return setTimeout(refresh, 10000);
$.ajax({url: config.feed, data: {before: config.before}, dataType: 'json', cache: false, timeout: 8000})
.done(function (response) {
$('#chat-messages').html(response.html);
$('#chat-mode').text(response.mode);
$('#chat-refresh-status').text(config.before ? 'History page refreshed. Choose Newest to see new posts.' : 'Messages refreshed.');
})
.fail(function (xhr) {
if (xhr.status === 401 || xhr.status === 403 || xhr.status === 404) {
$('#chat-refresh-status').text('Access changed. Reload this classroom or sign in again.');
config.stopped = true;
return;
}
$('#chat-refresh-status').text('Messages could not refresh. Your draft is kept; retrying shortly.');
})
.always(function () { if (!config.stopped) setTimeout(refresh, 10000); });
}
$(document).on('click', '.chat-reply', function () {
$('#chat-kind').val('reply');
$('#chat-parent').val($(this).attr('data-question'));
$('#chat-body').trigger('focus');
});
$('#chat-kind').on('change', function () { if (this.value !== 'reply') $('#chat-parent').val(''); });
setTimeout(refresh, 10000);
})(jQuery);
The discussion partial loads this script. No footer/header or heartbeat edits are needed. If Node is already installed, optional syntax check: node --check backend/lms/discussion.js.
Step 11: Append the discussion styles
File: backend/lms/lms.css.
Go to the end, after the existing final closing }. Append this block once; keep all existing styles:
/* Feature 008: classroom discussion */
.lms-chat-message { padding: 12px 0; border-bottom: 1px solid #eee; overflow-wrap: anywhere; }
.lms-chat-announcement { border-left: 3px solid #3c8dbc; padding-left: 12px; background: #f5faff; }
.lms-chat-text { white-space: pre-wrap; margin: 8px 0; }
.lms-chat-controls { padding-bottom: 15px; border-bottom: 1px solid #eee; margin-bottom: 15px; }
.lms-chat-controls .form-control, #chat-compose .form-control { margin-bottom: 10px; }
#chat-messages { max-height: 480px; overflow-y: auto; margin-bottom: 15px; }
Final code checks
From the Mac LMS root, run these after you have edited the files:
php -l application/helpers/discussion_helper.php
php -l application/models/Discussion_model.php
php -l application/controllers/Discussion.php
php -l application/controllers/Classroom.php
php -l application/views/classroom/discussion_messages.php
php -l application/views/classroom/discussion.php
php -l application/views/classroom/view.php
php files/tools/test-feature-008.php --app
php files/tools/compare-app.php files/features/008-classroom-chat-discussion/checksums.json
The tests must report passing policy/model/pagination/escaping and controller access checks. The checksum must report all 11 target files match. It ignores whitespace/line endings and compares to staged expected files; its stock “tested version” wording does not mean a live database/browser test passed. If a file differs, compare its named edits above with its code/ snapshot. Do not overwrite custom changes just to force a checksum match; inspect the difference first. No changes to 006 activity code are required. 008 supersedes the 003b/007 CSS snapshot, and the 002c classroom snapshots.
You can test the prepared package before applying with php files/tools/test-feature-008.php (no --app). It loads staged snapshots and a database double, writes no app files, and connects to no database. php files/tools/build-feature-008-guide.php regenerates this guide from verified staged snapshots only; it never installs the feature.
Live Mac check: three roles
Start MySQL as you normally do. Stop the existing PHP server with Control+C, then restart from the LMS root:
php -S 127.0.0.1:8080 router.php
Open your usual browser at http://127.0.0.1:8080/login. Use the existing test accounts in files/features/000-test-accounts.md. Use different browser profiles or a private window to keep the teacher/student/admin sessions separate. Get the real class ID by clicking Join/Open/View Class; do not assume a particular ID exists.
- Student A: Join an enrolled active class from My Live Classes. Wait for the heartbeat, send a message and a question. Reply using the question's Reply button. The question number should appear on the reply. Sending HTML such as
<b>Hello</b>must display literal text. Empty text or more than 1000 characters must fail; the error should keep your draft (up to 1000 characters). Reload before posting if the CSRF token has expired. - Teacher A: Open that teacher's own class. Post an announcement and reply to Student A. Confirm Student A sees them within a visible-tab polling interval. Announcement styling differs. Switch to Questions only: student ordinary messages fail, questions/replies work. Switch to Read-only: students cannot send; teacher still can. Switch to Off: no transcript is shown and neither role can send. Switch back to Open: saved messages reappear. Save a slow interval such as 15 seconds: a second student post inside that interval fails, including another tab. All roles also have a 2-second minimum interval.
- Hide: Teacher hides a message/question. The body is replaced by a placeholder on the next refresh; it remains stored for later 009 review. A new reply to a hidden question must fail. Existing replies remain visible. Try changing a question number to one from another class: it must fail.
- Teacher B / Student B: Another teacher cannot load or change A's class. An unenrolled student cannot read/send in it, including
discussion/feed/<id>and a forged POST. Students cannot post announcements or save controls/hide messages. Account deactivation, class deactivation, enrollment removal, or teacher reassignment must remove discussion access on the next request. A student whose presence belongs to another class or has expired cannot post here. - Superadmin/admin: Dashboard → View/Open Class. Read announcements, questions and replies; no composer, Reply, Hide, or controls form appears. Forged POSTs to send/controls/hide must fail without changing data. The existing configuration treats both admin and superadmin as monitoring roles.
- History: Generate more than 50 messages (respect the interval). Older messages must give the next 50, without duplicates/gaps. Newest resets the cursor. While reading older history, polling refreshes that history page, without moving you to newest. On the newest page, type a draft and wait for refresh: your typed text/type/question number must stay. Hidden bodies must never appear in the feed response HTML.
- Heartbeat: Time in Class still ticks and active/idle/away updates normally. Chat polling alone must not add attendance or clear presence. Leave Class then try posting from a stale tab before its next valid class heartbeat: posting should fail. Discussion is class-wide and can be used in the existing waiting-class flow; ending a session does not erase chat.
- Network/CSRF: Disconnect temporarily. The refresh status should show retrying and preserve the composer. Reconnect and confirm recovery. Sending a POST without the normal CSRF field/cookie must be rejected by CI. Mutation GET URLs must return 404. A 401/403/404 feed stops refreshing and asks you to reload/sign in. Reload if the page has an expired CSRF token.
For a read-only database check in phpMyAdmin, choose LMS and run:
SELECT id, class_id, user_id, parent_id, kind, is_hidden, hidden_by, created_at
FROM class_messages ORDER BY id DESC LIMIT 20;
SELECT class_id, mode, slow_seconds, updated_by FROM class_chat_controls;
SELECT action, class_id, record_id, time FROM logs
WHERE action IN ('chat_post', 'chat_controls', 'chat_hide') ORDER BY id DESC LIMIT 20;
Verify message, control, and hide audit records. No transcript body is copied into the activity log. Feature 006's student activity filter list remains unchanged; chat audit events are stored separately in logs for later moderation work.
If something fails / rollback
- Table
class_chat_controlsnot found: Step 1 was not run in the database your LMSdb_databasepoints to. - Unable to locate model/helper/controller: check exact filename case (
Discussion.php,Discussion_model.php,discussion_helper.php) and paths; confirm Step 6A/6B. - Blank discussion / undefined chat variables: check Step 6C, Step 7, and Step 8; keep the whole existing
view()method around your inserts. - “Open this classroom … heartbeat”: wait for a valid class heartbeat. Check the network connection and whether you are in another class. Polling is not a replacement for the heartbeat.
- CSRF error: reload the classroom, use the built-in forms, and keep CI's protection enabled.
- Styles/script stale: hard refresh with Command+Shift+R and confirm the new JS file is present.
- Need to undo: stop PHP, restore the three edited existing app files (
Classroom.php,classroom/view.php,lms.css) andlms_schema.sqlfrom your local backup, then remove only the six newly created target files listed here (helper, model, Discussion controller, two partials, JS), plus the new migration file. Keep the database control table and messages for review; the old app does not use that table. There is no need to drop data. Restart PHP. Restore your LMS SQL export only if you intentionally want to revert database data too.
Verification record
Prepared on October 5, 2026. PHP 8.2 syntax checks, JS syntax checks, staged policy/model/controller/pagination/escaping tests, and a replay of this guide's targeted edits passed. SHA-256 comparison confirms the actual app files stayed unchanged. A temporary isolated MySQL 8.4 test instance crashed during initialization on this machine, before schema execution; live MySQL transactions, simultaneous writes, CSRF integration, and browser/UI workflow remain pending the live Mac checklist. See REVIEW.md for source review and checksums.json for expected manually applied files.