v0.6.2 added help window

This commit is contained in:
2026-03-10 14:43:25 -04:00
parent 605d10ae02
commit 85cfad6318
18 changed files with 435 additions and 172 deletions

View File

@@ -7,7 +7,7 @@ TZ=UTC
# Copy this file to .env and customize # Copy this file to .env and customize
# Image version to run (set by build.sh, or use 'latest') # Image version to run (set by build.sh, or use 'latest')
JAMA_VERSION=0.5.1 JAMA_VERSION=0.6.2
# Default admin credentials (used on FIRST RUN only) # Default admin credentials (used on FIRST RUN only)
ADMIN_NAME=Admin User ADMIN_NAME=Admin User
@@ -19,7 +19,7 @@ USER_PASS=user@1234
# Set to true to reset admin password to ADMIN_PASS on every restart # Set to true to reset admin password to ADMIN_PASS on every restart
# WARNING: Leave false in production - shows a warning on login page when true # WARNING: Leave false in production - shows a warning on login page when true
PW_RESET=false ADMPW_RESET=false
# JWT secret - change this to a random string in production! # JWT secret - change this to a random string in production!
JWT_SECRET=changeme_super_secret_jwt_key_change_in_production JWT_SECRET=changeme_super_secret_jwt_key_change_in_production

View File

@@ -42,6 +42,9 @@ COPY --from=builder /app/frontend/dist ./public
# Create data and uploads directories # Create data and uploads directories
RUN mkdir -p /app/data /app/uploads/avatars /app/uploads/logos /app/uploads/images RUN mkdir -p /app/data /app/uploads/avatars /app/uploads/logos /app/uploads/images
# Copy default help.md (can be overridden by mounting /app/data/help.md)
COPY data/help.md /app/data/help.md
EXPOSE 3000 EXPOSE 3000
CMD ["node", "src/index.js"] CMD ["node", "src/index.js"]

283
README.md
View File

@@ -9,19 +9,26 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
### Messaging ### Messaging
- **Real-time messaging** — WebSocket-powered (Socket.io); messages appear instantly across all clients - **Real-time messaging** — WebSocket-powered (Socket.io); messages appear instantly across all clients
- **Image attachments** — Attach and send images; auto-compressed client-side before upload - **Image attachments** — Attach and send images via the + menu; auto-compressed client-side before upload
- **Camera capture** — Take a photo directly from the + menu on mobile devices
- **Emoji picker** — Send standalone emoji messages at large size via the + menu
- **Message replies** — Quote and reply to any message with an inline preview - **Message replies** — Quote and reply to any message with an inline preview
- **Emoji reactions** — Quick-react with common emojis or open the full emoji picker; one reaction per user, replaceable - **Emoji reactions** — Quick-react with common emojis or open the full emoji picker; one reaction per user, replaceable
- **@Mentions** — Type `@` to search and tag users with autocomplete; mentioned users receive a notification - **@Mentions** — Type `@` to search and tag users using `@[Display Name]` syntax; autocomplete scoped to group members; mentioned users receive a notification
- **Link previews** — URLs are automatically expanded with Open Graph metadata (title, image, site name) - **Link previews** — URLs are automatically expanded with Open Graph metadata (title, image, site name)
- **Typing indicators** — See when others are composing a message - **Typing indicators** — See when others are composing a message
- **Image lightbox** — Tap any image to open it full-screen with pinch-to-zoom support - **Image lightbox** — Tap any image to open it full-screen with pinch-to-zoom support
- **Message grouping** — Consecutive messages from the same user are visually grouped; avatar and name shown only on first message
- **Last message preview** — Sidebar shows "You:" prefix when the current user sent the last message
### Channels & Groups ### Channels & Groups
- **Public channels** — Admin-created; all users are automatically added - **Public channels** — Admin-created; all users are automatically added
- **Private groups / DMs** — Any user can create; membership is invite-only by the owner - **Private groups / DMs** — Any user can create; membership is invite-only by the owner
- **Direct messages** — One-to-one private conversations; sidebar title always shows the other user's real name
- **Duplicate group prevention** — Creating a private group with the same member set as an existing group redirects to the existing group automatically
- **Read-only channels** — Admin-configurable announcement-style channels; only admins can post - **Read-only channels** — Admin-configurable announcement-style channels; only admins can post
- **Support group** — A private admin-only group that receives submissions from the login page contact form - **Support group** — A private admin-only group that receives submissions from the login page contact form
- **Custom group names** — Each user can set a personal display name for any group, visible only to them
### Users & Profiles ### Users & Profiles
- **Authentication** — Email/password login with optional Remember Me (30-day session) - **Authentication** — Email/password login with optional Remember Me (30-day session)
@@ -38,18 +45,25 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
### Admin & Settings ### Admin & Settings
- **User Manager** — Create, suspend, activate, delete users; reset passwords; change roles - **User Manager** — Create, suspend, activate, delete users; reset passwords; change roles
- **Bulk CSV import** — Import multiple users at once from a CSV file - **Bulk CSV import** — Import multiple users at once from a CSV file
- **App branding** — Customize app name, logo, New Chat icon, and Group Info icon via the Settings panel - **App branding** — Customize app name and logo via the Settings panel
- **Reset to defaults** — One-click reset of all branding customizations - **Reset to defaults** — One-click reset of all branding customizations
- **Version display** — Current app version shown in the Settings panel - **Version display** — Current app version shown in the Settings panel
- **Default user password** — Configurable via `USER_PASS` env var; shown live in User Manager
### Help & Onboarding
- **Getting Started modal** — Appears automatically on first login; users can dismiss permanently with "Do not show again"
- **Help menu item** — Always accessible from the user menu regardless of dismissed state
- **Editable help content** — `data/help.md` is edited before build and baked into the image at build time
### PWA ### PWA
- **Installable** — Install to home screen on mobile and desktop via the browser install prompt - **Installable** — Install to home screen on mobile and desktop via the browser install prompt
- **Dynamic app icon** — Uploaded logo is automatically resized to 192×192 and 512×512 and used as the PWA shortcut icon - **Adaptive icons** — Separate `any` and `maskable` icon entries; maskable icons sized for Android circular crop
- **Dynamic manifest** — App name and icons in the PWA manifest update live when changed in Settings - **Dynamic app icon** — Uploaded logo is automatically resized and used as the PWA shortcut icon
- **Offline fallback** — Basic offline support via service worker caching - **Dynamic manifest** — App name and icons update live when changed in Settings
- **Pull-to-refresh disabled** — In PWA standalone mode, pull-to-refresh is disabled to prevent a layout shift bug on mobile
### Contact Form ### Contact Form
- **Login page contact form** — A "Contact Support" button on the login page opens a form (name, email, message, math captcha) that posts directly into the admin Support group - **Login page contact form** — A "Contact Support" button on the login page opens a form that posts directly into the admin Support group
--- ---
@@ -60,6 +74,8 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
| Backend | Node.js, Express, Socket.io | | Backend | Node.js, Express, Socket.io |
| Database | SQLite (better-sqlite3) | | Database | SQLite (better-sqlite3) |
| Frontend | React 18, Vite | | Frontend | React 18, Vite |
| Markdown rendering | marked |
| Emoji picker | emoji-mart |
| Image processing | sharp | | Image processing | sharp |
| Push notifications | web-push (VAPID) | | Push notifications | web-push (VAPID) |
| Containerization | Docker, Docker Compose | | Containerization | Docker, Docker Compose |
@@ -77,24 +93,18 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
## Building the Image ## Building the Image
All builds use `build.sh`. No host Node.js installation is required`npm install` and the Vite build run inside Docker. All builds use `build.sh`. No host Node.js installation is required.
> **Tip:** Edit `data/help.md` before running `build.sh` to customise the Getting Started help content baked into the image.
```bash ```bash
# Build and tag as :latest only # Build and tag as :latest only
./build.sh ./build.sh
# Build and tag as a specific version (also tags :latest) # Build and tag as a specific version
./build.sh 1.0.0 ./build.sh 1.0.0
# Build and push to Docker Hub
REGISTRY=yourdockerhubuser ./build.sh 1.0.0 push
# Build and push to GitHub Container Registry
REGISTRY=ghcr.io/yourorg ./build.sh 1.0.0 push
``` ```
After a successful build the script prints the exact `.env` and `docker compose` commands needed to deploy.
--- ---
## Installation ## Installation
@@ -102,7 +112,7 @@ After a successful build the script prints the exact `.env` and `docker compose`
### 1. Clone the repository ### 1. Clone the repository
```bash ```bash
git clone https://github.com/yourorg/jama.git git clone https://your-gitea/youruser/jama.git
cd jama cd jama
``` ```
@@ -119,33 +129,32 @@ cp .env.example .env
nano .env nano .env
``` ```
At minimum, change `ADMIN_EMAIL`, `ADMIN_PASS`, and `JWT_SECRET`. See [Environment Variables](#environment-variables) for all options. At minimum, change `ADMIN_EMAIL`, `ADMIN_PASS`, and `JWT_SECRET`.
### 4. Start the container ### 4. Start the container
```bash ```bash
docker compose up -d docker compose up -d
# Follow startup logs
docker compose logs -f jama docker compose logs -f jama
``` ```
On first startup you should see:
```
[DB] Default admin created: admin@yourdomain.com
[DB] Default jama group created
[DB] Support group created
```
### 5. Log in ### 5. Log in
Open `http://your-server:3000` in a browser, log in with your `ADMIN_EMAIL` and `ADMIN_PASS`, and change your password when prompted. Open `http://your-server:3000`, log in with your `ADMIN_EMAIL` and `ADMIN_PASS`, and change your password when prompted.
--- ---
## HTTPS & SSL (Required for Web Push and PWA install prompt) ## HTTPS & SSL
jama does not manage SSL itself. Use **Caddy** as a reverse proxy — it obtains and renews Let's Encrypt certificates automatically. jama does not manage SSL itself. Use **Caddy** as a reverse proxy.
### Caddyfile
```
chat.yourdomain.com {
reverse_proxy jama:3000
}
```
### docker-compose.yaml (with Caddy) ### docker-compose.yaml (with Caddy)
@@ -157,24 +166,20 @@ services:
container_name: jama container_name: jama
restart: unless-stopped restart: unless-stopped
expose: expose:
- "3000" # internal only — Caddy is the sole entry point - "3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- ADMIN_NAME=${ADMIN_NAME:-Admin User} - ADMIN_NAME=${ADMIN_NAME:-Admin User}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234} - ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- PW_RESET=${PW_RESET:-false} - USER_PASS=${USER_PASS:-user@1234}
- ADMPW_RESET=${ADMPW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:-changeme} - JWT_SECRET=${JWT_SECRET:-changeme}
- APP_NAME=${APP_NAME:-jama} - APP_NAME=${APP_NAME:-jama}
- JAMA_VERSION=${JAMA_VERSION:-latest} - JAMA_VERSION=${JAMA_VERSION:-latest}
volumes: volumes:
- jama_db:/app/data - jama_db:/app/data
- jama_uploads:/app/uploads - jama_uploads:/app/uploads
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
caddy: caddy:
image: caddy:alpine image: caddy:alpine
@@ -183,7 +188,7 @@ services:
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
- "443:443/udp" # HTTP/3 - "443:443/udp"
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro - ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data - caddy_data:/data
@@ -198,103 +203,55 @@ volumes:
caddy_certs: caddy_certs:
``` ```
### Caddyfile
Create a `Caddyfile` in the same directory as `docker-compose.yaml`:
```
chat.yourdomain.com {
reverse_proxy jama:3000
}
```
> **Prerequisites:** Your domain's DNS A record must point to your server's public IP *before* starting Caddy, so the Let's Encrypt HTTP challenge can complete.
---
## docker-compose.yaml Reference (without Caddy)
The default `docker-compose.yaml` exposes jama directly on a host port:
```yaml
version: '3.8'
services:
jama:
image: jama:${JAMA_VERSION:-latest}
container_name: jama
restart: unless-stopped
ports:
- "${PORT:-3000}:3000" # change PORT in .env to use a different host port
environment:
- NODE_ENV=production
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- PW_RESET=${PW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
- APP_NAME=${APP_NAME:-jama}
volumes:
- jama_db:/app/data # SQLite database
- jama_uploads:/app/uploads # avatars, logos, message images
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
volumes:
jama_db:
driver: local
jama_uploads:
driver: local
```
--- ---
## Environment Variables ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `JAMA_VERSION` | `latest` | Docker image tag to run. Set by `build.sh` or manually. | | `JAMA_VERSION` | `latest` | Docker image tag to run |
| `TZ` | `UTC` | Container timezone (e.g. `America/Toronto`) |
| `ADMIN_NAME` | `Admin User` | Display name of the default admin account | | `ADMIN_NAME` | `Admin User` | Display name of the default admin account |
| `ADMIN_EMAIL` | `admin@jama.local` | Login email for the default admin account | | `ADMIN_EMAIL` | `admin@jama.local` | Login email for the default admin account |
| `ADMIN_PASS` | `Admin@1234` | Initial password for the default admin account | | `ADMIN_PASS` | `Admin@1234` | Initial password for the default admin account |
| `PW_RESET` | `false` | If `true`, resets the admin password to `ADMIN_PASS` on every container restart. Shows a warning banner on the login page. For emergency access recovery only. | | `USER_PASS` | `user@1234` | Default temporary password for bulk-imported users when no password is specified in CSV |
| `JWT_SECRET` | *(insecure default)* | Secret used to sign auth tokens. **Must be changed in production.** Use a long random string. | | `ADMPW_RESET` | `false` | If `true`, resets the **admin** password to `ADMIN_PASS` on every restart. Emergency access recovery only. Shows a warning banner when active. |
| `PORT` | `3000` | Host port to bind (only applies when not using Caddy's `expose` setup) | | `JWT_SECRET` | *(insecure default)* | Secret used to sign auth tokens. **Must be changed in production.** |
| `APP_NAME` | `jama` | Initial application name. Can also be changed at any time in the Settings UI. | | `PORT` | `3000` | Host port to bind (without Caddy) |
| `APP_NAME` | `jama` | Initial application name (can also be changed in Settings UI) |
| `DEFCHAT_NAME` | `General Chat` | Name of the default public group created on first run |
> **Note:** `ADMIN_EMAIL` and `ADMIN_PASS` are only used on the **very first run** to seed the admin account. Once the database exists these values are ignored — unless `PW_RESET=true`. > `ADMIN_EMAIL` and `ADMIN_PASS` are only used on the **first run**. Once the database exists they are ignored — unless `ADMPW_RESET=true`.
### Example `.env` ### Example `.env`
```env ```env
JAMA_VERSION=1.0.0 JAMA_VERSION=1.0.0
TZ=America/Toronto
ADMIN_NAME=Your Name ADMIN_NAME=Your Name
ADMIN_EMAIL=admin@yourdomain.com ADMIN_EMAIL=admin@yourdomain.com
ADMIN_PASS=ChangeThisNow! ADMIN_PASS=ChangeThisNow!
PW_RESET=false USER_PASS=Welcome@123
ADMPW_RESET=false
JWT_SECRET=replace-this-with-a-long-random-string-at-least-32-chars JWT_SECRET=replace-this-with-a-long-random-string-at-least-32-chars
PORT=3000 PORT=3000
APP_NAME=jama APP_NAME=jama
DEFCHAT_NAME=General Chat
``` ```
--- ---
## First Login & Setup Checklist ## First Login & Setup Checklist
1. Open your app URL and log in with `ADMIN_EMAIL` / `ADMIN_PASS` 1. Log in with `ADMIN_EMAIL` / `ADMIN_PASS`
2. Change your password when prompted 2. Change your password when prompted
3. Open ⚙️ **Settings** (bottom-left menu → Settings): 3. Read the **Getting Started** guide that appears on first login
- Upload a custom logo 4. Open ⚙️ **Settings** → upload a logo and set the app name
- Set the app name 5. Open 👥 **User Manager** to create accounts for your team
- Optionally upload custom New Chat and Group Info icons
4. Open 👥 **User Manager** to create accounts for your team
5. Create public channels or let users create private groups
--- ---
@@ -317,25 +274,55 @@ Accessible from the bottom-left menu (admin only).
```csv ```csv
name,email,password,role name,email,password,role
John Doe,john@example.com,TempPass123,member John Doe,john@example.com,TempPass123,member
Jane Smith,jane@example.com,Admin@456,admin Jane Smith,jane@example.com,,admin
``` ```
- `role` must be `member` or `admin` - `password` is optional — defaults to the value of `USER_PASS` if omitted
- `password` is optional — defaults to `TempPass@123` if omitted
- All imported users must change their password on first login - All imported users must change their password on first login
--- ---
## Group Types ## Group Types
| | Public Channels | Private Groups | | | Public Channels | Private Groups | Direct Messages |
|---|---|---| |---|---|---|---|
| Who can create | Admin only | Any user | | Who can create | Admin only | Any user | Any user |
| Membership | All users (automatic) | Invite-only by owner | | Membership | All users (automatic) | Invite-only by owner | Two users only |
| Visible to admins | ✅ Yes | ❌ No | | Sidebar title | Group name | Group name (customisable per user) | Other user's real name |
| Leave | ❌ Not allowed | ✅ Yes | | Rename | Admin only | Owner only | ❌ Not allowed |
| Rename | Admin only | Owner only | | Read-only mode | ✅ Optional | ❌ N/A | ❌ N/A |
| Read-only mode | ✅ Optional | ❌ N/A | | Duplicate prevention | N/A | ✅ Redirects to existing | ✅ Redirects to existing |
### @Mention Scoping
- **Public channels** — all active users appear in the `@` autocomplete
- **Private groups** — only members of that group appear
- **Direct messages** — only the other participant appears
---
## Custom Group Names
Any user can set a personal display name for any group:
1. Open the group and tap the **ⓘ info** icon
2. Enter a name under **Your custom name** and tap **Save**
3. The custom name appears in your sidebar and chat header only
4. Message Info shows: `Custom Name (Owner's Name)`
5. Clear the field and tap **Save** to revert to the owner's name
---
## Help Content
The Getting Started guide is sourced from `data/help.md`. Edit before running `build.sh` — it is baked into the image at build time.
```bash
nano data/help.md
./build.sh 1.0.0
```
Users can access the guide at any time via **User menu → Help**.
--- ---
@@ -343,11 +330,9 @@ Jane Smith,jane@example.com,Admin@456,admin
| Volume | Container path | Contents | | Volume | Container path | Contents |
|---|---|---| |---|---|---|
| `jama_db` | `/app/data` | SQLite database (`jama.db`) | | `jama_db` | `/app/data` | SQLite database (`jama.db`), `help.md` |
| `jama_uploads` | `/app/uploads` | Avatars, logos, PWA icons, message images | | `jama_uploads` | `/app/uploads` | Avatars, logos, PWA icons, message images |
Both volumes survive container restarts, image upgrades, and rollbacks.
### Backup ### Backup
```bash ```bash
@@ -366,71 +351,55 @@ docker run --rm \
--- ---
## Versioning, Upgrades & Rollbacks ## Upgrades & Rollbacks
jama uses a build-once, deploy-anywhere pattern:
```
Build machine Server
./build.sh 1.1.0 → JAMA_VERSION=1.1.0 → docker compose up -d
```
### Upgrade
```bash ```bash
# 1. Build new version # Upgrade
./build.sh 1.1.0 ./build.sh 1.1.0
# Set JAMA_VERSION=1.1.0 in .env
docker compose up -d
# 2. Update .env # Rollback
JAMA_VERSION=1.1.0 # Set JAMA_VERSION=1.0.0 in .env
# 3. Redeploy (data volumes untouched)
docker compose up -d docker compose up -d
``` ```
### Rollback Data volumes are untouched in both cases.
```bash
# 1. Set previous version in .env
JAMA_VERSION=1.0.0
# 2. Redeploy
docker compose up -d
```
--- ---
## PWA Installation ## PWA Icons
HTTPS is required for the browser install prompt to appear. | File | Purpose |
| Platform | How to install |
|---|---| |---|---|
| Android (Chrome) | Tap the install banner, or Menu → Add to Home Screen | | `icon-192.png` / `icon-512.png` | Standard icons — PC PWA shortcuts (`purpose: any`) |
| iOS (Safari) | Share → Add to Home Screen | | `icon-192-maskable.png` / `icon-512-maskable.png` | Adaptive icons — Android home screen (`purpose: maskable`); logo at 75% scale on solid background |
| Desktop Chrome/Edge | Click the install icon (⊕) in the address bar |
After uploading a custom logo in Settings, the PWA shortcut icon updates automatically on the next app load.
--- ---
## PW_RESET Flag ## ADMPW_RESET Flag
Setting `PW_RESET=true` resets the default admin password to `ADMIN_PASS` on **every container restart**. Use only for emergency access recovery. Resets the **admin account** password to `ADMIN_PASS` on every container restart. Use only when the admin password has been lost.
When active, a ⚠️ warning banner is shown on the login page and in the Settings panel. ```env
# Enable for recovery
ADMPW_RESET=true
**Always set `PW_RESET=false` and redeploy after recovering access.** # Disable after recovering access
ADMPW_RESET=false
```
A ⚠️ warning banner is shown on the login page and in Settings when active.
--- ---
## Development ## Development
```bash ```bash
# Start backend (port 3000) # Backend (port 3000)
cd backend && npm install && npm run dev cd backend && npm install && npm run dev
# Start frontend in a separate terminal (port 5173) # Frontend (port 5173)
cd frontend && npm install && npm run dev cd frontend && npm install && npm run dev
``` ```

View File

@@ -1,6 +1,6 @@
{ {
"name": "jama-backend", "name": "jama-backend",
"version": "0.5.1", "version": "0.6.2",
"description": "TeamChat backend server", "description": "TeamChat backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -36,6 +36,7 @@ app.use('/api/groups', require('./routes/groups')(io));
app.use('/api/messages', require('./routes/messages')); app.use('/api/messages', require('./routes/messages'));
app.use('/api/settings', require('./routes/settings')); app.use('/api/settings', require('./routes/settings'));
app.use('/api/about', require('./routes/about')); app.use('/api/about', require('./routes/about'));
app.use('/api/help', require('./routes/help'));
app.use('/api/push', pushRouter); app.use('/api/push', pushRouter);
// Link preview proxy // Link preview proxy

View File

@@ -134,7 +134,7 @@ function initDb() {
const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
insertSetting.run('app_name', process.env.APP_NAME || 'jama'); insertSetting.run('app_name', process.env.APP_NAME || 'jama');
insertSetting.run('logo_url', ''); insertSetting.run('logo_url', '');
insertSetting.run('pw_reset_active', process.env.PW_RESET === 'true' ? 'true' : 'false'); insertSetting.run('pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false');
insertSetting.run('icon_newchat', ''); insertSetting.run('icon_newchat', '');
insertSetting.run('icon_groupinfo', ''); insertSetting.run('icon_groupinfo', '');
insertSetting.run('pwa_icon_192', ''); insertSetting.run('pwa_icon_192', '');
@@ -182,6 +182,12 @@ function initDb() {
console.log('[DB] Migration: added direct_peer2_id column'); console.log('[DB] Migration: added direct_peer2_id column');
} catch (e) { /* column already exists */ } } catch (e) { /* column already exists */ }
// Migration: help_dismissed preference per user
try {
db.exec("ALTER TABLE users ADD COLUMN help_dismissed INTEGER NOT NULL DEFAULT 0");
console.log('[DB] Migration: added help_dismissed column');
} catch (e) { /* column already exists */ }
// Migration: user-customised group display names (per-user, per-group) // Migration: user-customised group display names (per-user, per-group)
try { try {
db.exec(` db.exec(`
@@ -206,7 +212,7 @@ function seedAdmin() {
const adminEmail = (process.env.ADMIN_EMAIL || 'admin@jama.local').replace(/^["']|["']$/g, '').trim(); const adminEmail = (process.env.ADMIN_EMAIL || 'admin@jama.local').replace(/^["']|["']$/g, '').trim();
const adminName = (process.env.ADMIN_NAME || 'Admin User').replace(/^["']|["']$/g, '').trim(); const adminName = (process.env.ADMIN_NAME || 'Admin User').replace(/^["']|["']$/g, '').trim();
const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim(); const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim();
const pwReset = process.env.PW_RESET === 'true'; const pwReset = process.env.ADMPW_RESET === 'true';
console.log(`[DB] Checking for default admin (${adminEmail})...`); console.log(`[DB] Checking for default admin (${adminEmail})...`);
@@ -242,7 +248,7 @@ function seedAdmin() {
console.log(`[DB] Default admin already exists (id=${existing.id})`); console.log(`[DB] Default admin already exists (id=${existing.id})`);
// Handle PW_RESET // Handle ADMPW_RESET
if (pwReset) { if (pwReset) {
const hash = bcrypt.hashSync(adminPass, 10); const hash = bcrypt.hashSync(adminPass, 10);
db.prepare(` db.prepare(`
@@ -250,7 +256,7 @@ function seedAdmin() {
WHERE is_default_admin = 1 WHERE is_default_admin = 1
`).run(hash); `).run(hash);
db.prepare("UPDATE settings SET value = 'true', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run(); db.prepare("UPDATE settings SET value = 'true', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
console.log('[DB] Admin password reset via PW_RESET=true'); console.log('[DB] Admin password reset via ADMPW_RESET=true');
} else { } else {
db.prepare("UPDATE settings SET value = 'false', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run(); db.prepare("UPDATE settings SET value = 'false', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
} }

View File

@@ -0,0 +1,39 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const { getDb } = require('../models/db');
const { authMiddleware } = require('../middleware/auth');
const HELP_FILE = '/app/data/help.md';
const HELP_FALLBACK = path.join(__dirname, '../../data/help.md');
// GET /api/help — returns markdown content
router.get('/', authMiddleware, (req, res) => {
let content = '';
const filePath = fs.existsSync(HELP_FILE) ? HELP_FILE : HELP_FALLBACK;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (e) {
content = '# Getting Started\n\nHelp content is not available yet.';
}
res.json({ content });
});
// GET /api/help/status — returns whether user has dismissed help
router.get('/status', authMiddleware, (req, res) => {
const db = getDb();
const user = db.prepare('SELECT help_dismissed FROM users WHERE id = ?').get(req.user.id);
res.json({ dismissed: !!user?.help_dismissed });
});
// POST /api/help/dismiss — set help_dismissed for current user
router.post('/dismiss', authMiddleware, (req, res) => {
const { dismissed } = req.body;
const db = getDb();
db.prepare("UPDATE users SET help_dismissed = ? WHERE id = ?")
.run(dismissed ? 1 : 0, req.user.id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.5.1}" VERSION="${1:-0.6.2}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama" IMAGE_NAME="jama"

105
data/help.md Normal file
View File

@@ -0,0 +1,105 @@
# Getting Started with Jama
Welcome to **Jama** — your private, self-hosted team messaging app.
---
## Navigating Jama
### Message List (Left Sidebar)
The sidebar shows all your message groups and direct conversations. Tap or click any group to open it.
- **#** prefix indicates a **Public** group — visible to all users
- **Lock** icon indicates a **Private** group — invite only
- **Bold** group names have unread messages
- The last message preview shows **You:** if you sent it
---
## Sending Messages
Type your message in the input box at the bottom and press **Enter** to send.
- **Shift + Enter** adds a new line without sending
- Tap the **+** button to attach a photo or emoji
- Use the **camera** icon to take a photo directly (mobile)
### Mentioning Someone
Type **@** followed by the person's name to mention them. Select from the dropdown that appears. Mentioned users receive a notification.
Example: `@[John Smith]` will notify John.
### Replying to a Message
Hover over any message and click the **reply arrow** to quote and reply to it.
### Reacting to a Message
Hover over any message and click the **emoji** button to react with an emoji.
---
## Direct Messages
To start a private conversation with one person:
1. Click the **pencil / new chat** icon in the sidebar
2. Select one user from the list
3. Click **Start Conversation**
---
## Group Messages
To create a group conversation:
1. Click the **pencil / new chat** icon
2. Select two or more users
3. Enter a **Group Name**
4. Click **Create**
> If a group with the exact same members already exists, you will be redirected to it automatically.
---
## Your Profile
Click your name or avatar at the bottom of the sidebar to:
- Update your **display name** (shown to others instead of your username)
- Add an **about me** note
- Upload a **profile photo**
- Change your **password**
---
## Customising Group Names
You can set a personal display name for any group that only you will see:
1. Open the group
2. Click the **ⓘ info** icon in the top bar
3. Enter your custom name under **Your custom name**
4. Click **Save**
Other members still see the original group name.
---
## Settings
Admins can access **Settings** from the user menu to configure:
- App name and logo
- Default user password
- Notification preferences
---
## Tips
- 🌙 Toggle **dark mode** from the user menu
- 🔔 Enable **push notifications** when prompted to receive alerts when the app is closed
- 📱 Install Jama as a **PWA** on your device — tap *Add to Home Screen* in your browser menu for an app-like experience
---
*This help file can be updated by your administrator at any time.*

View File

@@ -14,7 +14,7 @@ services:
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234} - ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- USER_PASS=${USER_PASS:-user@1234} - USER_PASS=${USER_PASS:-user@1234}
- PW_RESET=${PW_RESET:-false} - ADMPW_RESET=${ADMPW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024} - JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
- APP_NAME=${APP_NAME:-jama} - APP_NAME=${APP_NAME:-jama}
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat} - DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}

View File

@@ -1,6 +1,6 @@
{ {
"name": "jama-frontend", "name": "jama-frontend",
"version": "0.5.1", "version": "0.6.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -16,7 +16,8 @@
"@emoji-mart/data": "^1.1.2", "@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"date-fns": "^3.3.1" "date-fns": "^3.3.1",
"marked": "^12.0.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",

View File

@@ -0,0 +1,71 @@
import { useState, useEffect } from 'react';
import { marked } from 'marked';
import { api } from '../utils/api.js';
// Configure marked for safe rendering
marked.setOptions({ breaks: true, gfm: true });
export default function HelpModal({ onClose, dismissed: initialDismissed }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [dismissed, setDismissed] = useState(!!initialDismissed);
useEffect(() => {
api.getHelp()
.then(({ content }) => setContent(content))
.catch(() => setContent('# Getting Started\n\nHelp content could not be loaded.'))
.finally(() => setLoading(false));
}, []);
const handleDismissToggle = async (e) => {
const val = e.target.checked;
setDismissed(val);
try {
await api.dismissHelp(val);
if (val) onClose(); // immediately close when "do not show again" checked
} catch (_) {}
};
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal help-modal">
{/* Header */}
<div className="flex items-center justify-between" style={{ marginBottom: 16 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Getting Started</h2>
<button className="btn-icon" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{/* Scrollable markdown content */}
<div className="help-content">
{loading ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-tertiary)' }}>Loading</div>
) : (
<div
className="help-markdown"
dangerouslySetInnerHTML={{ __html: marked.parse(content) }}
/>
)}
</div>
{/* Footer */}
<div className="help-footer">
<label className="flex items-center gap-2 text-sm" style={{ cursor: 'pointer', color: 'var(--text-secondary)' }}>
<input
type="checkbox"
checked={dismissed}
onChange={handleDismissToggle}
/>
Do not show again at login
</label>
<button className="btn btn-primary btn-sm" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}

View File

@@ -148,7 +148,7 @@ export default function SettingsModal({ onClose }) {
{settings.pw_reset_active === 'true' && ( {settings.pw_reset_active === 'true' && (
<div className="warning-banner"> <div className="warning-banner">
<span></span> <span></span>
<span><strong>PW_RESET is active.</strong> The default admin password is being reset on every restart. Set PW_RESET=false in your environment variables to stop this.</span> <span><strong>ADMPW_RESET is active.</strong> The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this.</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -43,7 +43,7 @@ function useAppSettings() {
return settings; return settings;
} }
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated, isMobile, onAbout }) { export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated, isMobile, onAbout, onHelp }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { connected } = useSocket(); const { connected } = useSocket();
const toast = useToast(); const toast = useToast();
@@ -219,6 +219,10 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
</> </>
)} )}
<hr className="divider" style={{ margin: '4px 0' }} /> <hr className="divider" style={{ margin: '4px 0' }} />
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onHelp && onHelp(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Help
</button>
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onAbout && onAbout(); }}> <button className="footer-menu-item" onClick={() => { setShowMenu(false); onAbout && onAbout(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
About About

View File

@@ -399,3 +399,50 @@ a { color: inherit; text-decoration: none; }
background: var(--primary-light); background: var(--primary-light);
} }
/* ── Help Modal ─────────────────────────────────────────────── */
.help-modal {
width: min(720px, 94vw);
max-height: 85vh;
display: flex;
flex-direction: column;
}
.help-content {
flex: 1;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
background: var(--surface-variant);
margin-bottom: 16px;
}
.help-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 4px;
border-top: 1px solid var(--border);
padding-top: 12px;
}
/* Markdown typography */
.help-markdown h1 { font-size: 1.5rem; font-weight: 700; margin: 0 0 16px; color: var(--text-primary); }
.help-markdown h2 { font-size: 1.15rem; font-weight: 700; margin: 24px 0 10px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.help-markdown h3 { font-size: 1rem; font-weight: 600; margin: 16px 0 6px; color: var(--text-primary); }
.help-markdown p { margin: 0 0 12px; line-height: 1.65; color: var(--text-secondary); font-size: 14px; }
.help-markdown ul, .help-markdown ol { margin: 0 0 12px 20px; color: var(--text-secondary); font-size: 14px; }
.help-markdown li { margin-bottom: 4px; line-height: 1.6; }
.help-markdown strong { font-weight: 600; color: var(--text-primary); }
.help-markdown em { font-style: italic; }
.help-markdown code { font-family: monospace; font-size: 13px; background: var(--background); padding: 1px 5px; border-radius: 4px; color: var(--primary); }
.help-markdown pre { background: var(--background); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; overflow-x: auto; margin: 0 0 12px; }
.help-markdown pre code { background: none; padding: 0; color: var(--text-primary); }
.help-markdown blockquote { border-left: 3px solid var(--primary); margin: 0 0 12px; padding: 6px 14px; background: var(--primary-light); border-radius: 0 var(--radius) var(--radius) 0; }
.help-markdown blockquote p { margin: 0; color: var(--text-secondary); }
.help-markdown hr { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
.help-markdown a { color: var(--primary); text-decoration: underline; }
[data-theme="dark"] .help-markdown code { background: var(--surface); }
[data-theme="dark"] .help-markdown pre { background: var(--surface); }
[data-theme="dark"] .help-markdown blockquote { background: rgba(99,102,241,0.1); }

View File

@@ -11,6 +11,7 @@ import SettingsModal from '../components/SettingsModal.jsx';
import NewChatModal from '../components/NewChatModal.jsx'; import NewChatModal from '../components/NewChatModal.jsx';
import GlobalBar from '../components/GlobalBar.jsx'; import GlobalBar from '../components/GlobalBar.jsx';
import AboutModal from '../components/AboutModal.jsx'; import AboutModal from '../components/AboutModal.jsx';
import HelpModal from '../components/HelpModal.jsx';
import './Chat.css'; import './Chat.css';
function urlBase64ToUint8Array(base64String) { function urlBase64ToUint8Array(base64String) {
@@ -31,10 +32,21 @@ export default function Chat() {
const [activeGroupId, setActiveGroupId] = useState(null); const [activeGroupId, setActiveGroupId] = useState(null);
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [unreadGroups, setUnreadGroups] = useState(new Map()); const [unreadGroups, setUnreadGroups] = useState(new Map());
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help'
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidebar, setShowSidebar] = useState(true); const [showSidebar, setShowSidebar] = useState(true);
// Check if help should be shown on login
useEffect(() => {
api.getHelpStatus()
.then(({ dismissed }) => {
setHelpDismissed(dismissed);
if (!dismissed) setModal('help');
})
.catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
const handle = () => { const handle = () => {
const mobile = window.innerWidth < 768; const mobile = window.innerWidth < 768;
@@ -210,6 +222,7 @@ export default function Chat() {
onGroupsUpdated={loadGroups} onGroupsUpdated={loadGroups}
isMobile={isMobile} isMobile={isMobile}
onAbout={() => setModal('about')} onAbout={() => setModal('about')}
onHelp={() => setModal('help')}
/> />
)} )}
@@ -228,6 +241,7 @@ export default function Chat() {
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />} {modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />} {modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />} {modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
</div> </div>
); );
} }

View File

@@ -85,7 +85,7 @@ export default function Login() {
{settings.pw_reset_active === 'true' && ( {settings.pw_reset_active === 'true' && (
<div className="warning-banner" style={{ marginBottom: 16 }}> <div className="warning-banner" style={{ marginBottom: 16 }}>
<span></span> <span></span>
<span><strong>PW_RESET is enabled.</strong> The admin password is being reset on each restart. Disable PW_RESET in your environment to stop this behavior.</span> <span><strong>ADMPW_RESET is enabled.</strong> The admin password is being reset on each restart. Disable ADMPW_RESET in your environment to stop this behavior.</span>
</div> </div>
)} )}

View File

@@ -74,6 +74,9 @@ export const api = {
createGroup: (body) => req('POST', '/groups', body), createGroup: (body) => req('POST', '/groups', body),
renameGroup: (id, name) => req('PATCH', `/groups/${id}/rename`, { name }), renameGroup: (id, name) => req('PATCH', `/groups/${id}/rename`, { name }),
setCustomGroupName: (id, name) => req('PATCH', `/groups/${id}/custom-name`, { name }), setCustomGroupName: (id, name) => req('PATCH', `/groups/${id}/custom-name`, { name }),
getHelp: () => req('GET', '/help'),
getHelpStatus: () => req('GET', '/help/status'),
dismissHelp: (dismissed) => req('POST', '/help/dismiss', { dismissed }),
getMembers: (id) => req('GET', `/groups/${id}/members`), getMembers: (id) => req('GET', `/groups/${id}/members`),
addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }), addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }),
removeMember: (groupId, userId) => req('DELETE', `/groups/${groupId}/members/${userId}`), removeMember: (groupId, userId) => req('DELETE', `/groups/${groupId}/members/${userId}`),