Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
285e3b4abe | ||
|
|
9b4869812f |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '0.9.4');
|
define('APP_VERSION', '0.9.6');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||||
|
|||||||
@@ -2042,6 +2042,9 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<button id="select-group" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
<button id="select-group" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||||
<i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">Group</span>
|
<i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">Group</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="select-join" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||||
|
<i class="ph ph-arrows-merge text-sm"></i><span class="font-pixel text-[10px]">Join</span>
|
||||||
|
</button>
|
||||||
<button id="select-bucket" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
<button id="select-bucket" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||||
<i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">Bucket</span>
|
<i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">Bucket</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ function toggleSelect(id) {
|
|||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selectedIds.clear();
|
selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
board.querySelectorAll('.card-selected').forEach(el => {
|
board.querySelectorAll('.card-selected').forEach(el => {
|
||||||
el.classList.remove('card-selected');
|
el.classList.remove('card-selected');
|
||||||
el.querySelector('.card-select-check')?.remove();
|
el.querySelector('.card-select-check')?.remove();
|
||||||
@@ -117,6 +118,16 @@ $('select-group')?.addEventListener('click', async () => {
|
|||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('select-join')?.addEventListener('click', async () => {
|
||||||
|
if (selectedIds.size < 2) return;
|
||||||
|
// Dynamic import keeps the board → context-menu edge lazy and avoids
|
||||||
|
// circular import surprises at module load time. joinSelectedCards
|
||||||
|
// reads state.selectedIds + state.selectionWasBulk and handles its
|
||||||
|
// own reload + selection clear.
|
||||||
|
const { joinSelectedCards } = await import('./context-menu.js');
|
||||||
|
await joinSelectedCards();
|
||||||
|
});
|
||||||
|
|
||||||
$('select-bucket')?.addEventListener('click', () => {
|
$('select-bucket')?.addEventListener('click', () => {
|
||||||
if (!selectedIds.size) return;
|
if (!selectedIds.size) return;
|
||||||
// Remove existing picker
|
// Remove existing picker
|
||||||
@@ -1193,6 +1204,9 @@ board.addEventListener('mousedown', e => {
|
|||||||
if (!boxActive) {
|
if (!boxActive) {
|
||||||
boxActive = true;
|
boxActive = true;
|
||||||
if (!additive) clearSelection();
|
if (!additive) clearSelection();
|
||||||
|
// Mark the selection as bulk so the join action knows to use
|
||||||
|
// the longest-title strategy instead of first-clicked.
|
||||||
|
state.selectionWasBulk = true;
|
||||||
boxEl = document.createElement('div');
|
boxEl = document.createElement('div');
|
||||||
boxEl.className = 'box-select';
|
boxEl.className = 'box-select';
|
||||||
board.appendChild(boxEl);
|
board.appendChild(boxEl);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ── Bucket management ──
|
// ── Bucket management ──
|
||||||
|
|
||||||
import { $, esc, api } from './util.js';
|
import { $, esc, api } from './util.js';
|
||||||
import { state, CARD_COLORS } from './state.js';
|
import { state, CARD_COLORS, setActiveBucket, ensureActiveBucketValid } from './state.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
import { pushUndo } from './undo.js';
|
import { pushUndo } from './undo.js';
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ async function showBucketCtx(x, y, bucket) {
|
|||||||
const ok = await showDialog({ title: 'delete_bucket', message: msg, danger: true, okLabel: bucket.collab_uid ? 'Leave / delete' : 'Delete' });
|
const ok = await showDialog({ title: 'delete_bucket', message: msg, danger: true, okLabel: bucket.collab_uid ? 'Leave / delete' : 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
|
if (state.activeBucket == bucket.id) setActiveBucket('all');
|
||||||
// Collab bucket: route through plugin so the relay is notified
|
// Collab bucket: route through plugin so the relay is notified
|
||||||
// (owner = delete for everyone; member = just leave).
|
// (owner = delete for everyone; member = just leave).
|
||||||
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
|
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
|
||||||
@@ -155,7 +155,7 @@ async function showBucketCtx(x, y, bucket) {
|
|||||||
const ok = await showDialog({ title: 'delete_bucket', message: `Delete "${bucket.name}" and ${cards.length} card(s)?`, danger: true, okLabel: 'Delete all' });
|
const ok = await showDialog({ title: 'delete_bucket', message: `Delete "${bucket.name}" and ${cards.length} card(s)?`, danger: true, okLabel: 'Delete all' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||||
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
|
if (state.activeBucket == bucket.id) setActiveBucket('all');
|
||||||
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
|
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
|
||||||
const res = await window._collabBucketsDelete(bucket.collab_uid);
|
const res = await window._collabBucketsDelete(bucket.collab_uid);
|
||||||
if (!res?.ok) { alert(res?.error || 'delete failed'); return; }
|
if (!res?.ok) { alert(res?.error || 'delete failed'); return; }
|
||||||
@@ -188,6 +188,10 @@ const tasksFilter = $('tasks-bucket-filter');
|
|||||||
|
|
||||||
export async function loadBuckets() {
|
export async function loadBuckets() {
|
||||||
state.buckets = await api('api/buckets');
|
state.buckets = await api('api/buckets');
|
||||||
|
// The persisted activeBucket may point to a bucket that no longer
|
||||||
|
// exists (deleted in another tab, removed via import, etc.). Reset
|
||||||
|
// to 'all' in that case before rendering.
|
||||||
|
ensureActiveBucketValid();
|
||||||
renderBar();
|
renderBar();
|
||||||
populateSelects();
|
populateSelects();
|
||||||
}
|
}
|
||||||
@@ -366,7 +370,7 @@ function renderBar() {
|
|||||||
pop.querySelectorAll('.bucket-tab[data-bucket]').forEach(entry => {
|
pop.querySelectorAll('.bucket-tab[data-bucket]').forEach(entry => {
|
||||||
const ebid = entry.dataset.bucket;
|
const ebid = entry.dataset.bucket;
|
||||||
entry.addEventListener('click', () => {
|
entry.addEventListener('click', () => {
|
||||||
state.activeBucket = ebid;
|
setActiveBucket(ebid);
|
||||||
state.activeGroup = null;
|
state.activeGroup = null;
|
||||||
pop.remove();
|
pop.remove();
|
||||||
renderBar();
|
renderBar();
|
||||||
@@ -420,7 +424,7 @@ function renderBar() {
|
|||||||
openSuperDropdown(t, parseInt(bid, 10));
|
openSuperDropdown(t, parseInt(bid, 10));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
t.addEventListener('click', () => { state.activeBucket = bid; state.activeGroup = null; renderBar(); window._renderBoard(); });
|
t.addEventListener('click', () => { setActiveBucket(bid); state.activeGroup = null; renderBar(); window._renderBoard(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { $, esc, api } from './util.js';
|
import { $, esc, api } from './util.js';
|
||||||
import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
|
import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
import { pushCardEdit, pushCardDelete, pushBatch, snapshotCard } from './undo.js';
|
import { pushCardEdit, pushCardDelete, pushBatch, pushUndo, snapshotCard } from './undo.js';
|
||||||
import { stripMd } from './markdown.js';
|
import { stripMd } from './markdown.js';
|
||||||
|
|
||||||
const menu = $('ctx-menu');
|
const menu = $('ctx-menu');
|
||||||
@@ -32,12 +32,89 @@ menu.addEventListener('mouseenter', () => {
|
|||||||
document.addEventListener('click', hideContextMenu);
|
document.addEventListener('click', hideContextMenu);
|
||||||
document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); });
|
document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge every currently-selected card into a single card. Title strategy:
|
||||||
|
* if the selection involved a bulk gesture (rubber-band) we use the
|
||||||
|
* longest title — there's no meaningful "first" in that case. Otherwise
|
||||||
|
* the first ctrl-clicked card wins (state.selectedIds is a Set, which
|
||||||
|
* preserves insertion order).
|
||||||
|
*
|
||||||
|
* Body format: list of all titles as `# heading` lines at the top, then
|
||||||
|
* each card's body separated by `---`. The winner keeps its color, tags,
|
||||||
|
* bucket, due_date, parent_id; the others are deleted.
|
||||||
|
*
|
||||||
|
* The whole operation is bundled into a single undo entry that recreates
|
||||||
|
* the deleted cards verbatim and restores the winner's original body.
|
||||||
|
*/
|
||||||
|
export async function joinSelectedCards() {
|
||||||
|
const ids = [...state.selectedIds].map(String);
|
||||||
|
const cards = ids
|
||||||
|
.map(id => state.cards.find(c => String(c.id) === id))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (cards.length < 2) return;
|
||||||
|
|
||||||
|
// We need bodies for the merge. /api/cards GET excludes body for
|
||||||
|
// performance, so fetch the full rows for the selected ids in one shot.
|
||||||
|
const fullRes = await fetch(`api/cards?ids=${cards.map(c => c.id).join(',')}`).then(r => r.json());
|
||||||
|
const fullById = new Map(fullRes.map(c => [String(c.id), c]));
|
||||||
|
const full = cards.map(c => fullById.get(String(c.id)) || c);
|
||||||
|
|
||||||
|
// Pick winner
|
||||||
|
let winner;
|
||||||
|
if (state.selectionWasBulk) {
|
||||||
|
winner = full.reduce((a, b) => (b.title || '').length > (a.title || '').length ? b : a);
|
||||||
|
} else {
|
||||||
|
winner = full[0]; // first in Set iteration order = first selected
|
||||||
|
}
|
||||||
|
const losers = full.filter(c => c.id !== winner.id);
|
||||||
|
|
||||||
|
// Build merged body: title list, then bodies separated by `---`.
|
||||||
|
// Order: winner first (its body stays at the top after the title list),
|
||||||
|
// then losers in their selection order so the result is deterministic.
|
||||||
|
const ordered = [winner, ...losers];
|
||||||
|
const titleList = ordered.map(c => `## ${c.title || ''}`).join('\n');
|
||||||
|
const bodyChunks = ordered.map(c => (c.body || '').trim()).filter(Boolean);
|
||||||
|
const mergedBody = titleList + '\n\n---\n\n' + bodyChunks.join('\n\n---\n\n');
|
||||||
|
|
||||||
|
// Snapshot for undo BEFORE we mutate anything
|
||||||
|
const winnerPrev = { title: winner.title, body: winner.body || '' };
|
||||||
|
const loserSnapshots = losers.map(c => snapshotCard(c));
|
||||||
|
|
||||||
|
// Apply: PATCH winner with the merged body, DELETE losers.
|
||||||
|
pushUndo(`join ${ordered.length} cards`, async () => {
|
||||||
|
await api(`api/cards/${winner.id}`, { method: 'PATCH', body: winnerPrev });
|
||||||
|
for (const snap of loserSnapshots) {
|
||||||
|
const created = await api('api/cards', { method: 'POST', body: {
|
||||||
|
title: snap.title, body: snap.body, color: snap.color,
|
||||||
|
bucket_id: snap.bucket_id, parent_id: snap.parent_id, position: snap.position,
|
||||||
|
}});
|
||||||
|
if (created?.id && snap.due_date) {
|
||||||
|
await api(`api/cards/${created.id}`, { method: 'PATCH', body: { due_date: snap.due_date } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await api(`api/cards/${winner.id}`, { method: 'PATCH', body: { body: mergedBody } });
|
||||||
|
for (const c of losers) {
|
||||||
|
await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
|
await window._reloadAll();
|
||||||
|
}
|
||||||
|
|
||||||
export function showContextMenu(x, y, card) {
|
export function showContextMenu(x, y, card) {
|
||||||
const children = getChildren(card.id);
|
const children = getChildren(card.id);
|
||||||
const isParent = children.length > 0;
|
const isParent = children.length > 0;
|
||||||
const isChild = !!card.parent_id;
|
const isChild = !!card.parent_id;
|
||||||
|
// Multi-select join: only offered when the right-clicked card is part
|
||||||
|
// of a selection of ≥2 cards. The action merges them into one.
|
||||||
|
const selected = state.selectedIds;
|
||||||
|
const inMultiSelect = selected.size >= 2 && selected.has(String(card.id));
|
||||||
|
|
||||||
items.innerHTML = `
|
items.innerHTML = `
|
||||||
|
${inMultiSelect ? `<button class="ctx-item" data-action="join"><i class="ph ph-arrows-merge text-sm"></i><span class="font-pixel text-[10px]">Join ${selected.size} cards</span></button><div class="ctx-sep"></div>` : ''}
|
||||||
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">Open</span></button>
|
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">Open</span></button>
|
||||||
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Edit</span></button>
|
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Edit</span></button>
|
||||||
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">Duplicate</span></button>
|
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">Duplicate</span></button>
|
||||||
@@ -176,6 +253,11 @@ export function showContextMenu(x, y, card) {
|
|||||||
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
|
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const a = btn.dataset.action;
|
const a = btn.dataset.action;
|
||||||
|
if (a === 'join') {
|
||||||
|
hideContextMenu();
|
||||||
|
await joinSelectedCards();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
|
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
|
||||||
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
|
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
|
||||||
else if (a === 'duplicate') {
|
else if (a === 'duplicate') {
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ export const state = {
|
|||||||
cards: [],
|
cards: [],
|
||||||
buckets: [],
|
buckets: [],
|
||||||
|
|
||||||
// Filters & view
|
// Filters & view — activeBucket is persisted across reloads via
|
||||||
activeBucket: 'all',
|
// localStorage. Numeric ids are stringified; the special filters
|
||||||
|
// ('all', 'current', 'favorites', 'journal', 'none') are stored
|
||||||
|
// as-is. Validity is re-checked after buckets load — see
|
||||||
|
// ensureActiveBucketValid() below.
|
||||||
|
activeBucket: localStorage.getItem('hyprcrd-active-bucket') || 'all',
|
||||||
activeTags: [],
|
activeTags: [],
|
||||||
activeGroup: null,
|
activeGroup: null,
|
||||||
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
||||||
@@ -23,6 +27,11 @@ export const state = {
|
|||||||
|
|
||||||
// Multi-select
|
// Multi-select
|
||||||
selectedIds: new Set(),
|
selectedIds: new Set(),
|
||||||
|
// True when at least one bulk gesture (rubber-band) participated in
|
||||||
|
// building the current selection. Used by the join/merge action to
|
||||||
|
// pick the title strategy: false → first selected wins, true →
|
||||||
|
// longest title wins (since "first" isn't meaningful for bulk).
|
||||||
|
selectionWasBulk: false,
|
||||||
|
|
||||||
// Detail / editor
|
// Detail / editor
|
||||||
editingId: null,
|
editingId: null,
|
||||||
@@ -47,6 +56,28 @@ export const state = {
|
|||||||
export const SORT_MODES = ['manual', 'newest', 'oldest', 'alpha', 'tag'];
|
export const SORT_MODES = ['manual', 'newest', 'oldest', 'alpha', 'tag'];
|
||||||
export const SORT_ICONS = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa', tag: 'ph-hash' };
|
export const SORT_ICONS = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa', tag: 'ph-hash' };
|
||||||
|
|
||||||
|
const SPECIAL_BUCKETS = new Set(['all', 'current', 'favorites', 'journal', 'none']);
|
||||||
|
|
||||||
|
/** Set state.activeBucket and persist it to localStorage so it survives
|
||||||
|
* page reloads. Use this instead of `state.activeBucket = …` everywhere
|
||||||
|
* except tests (tests reset directly to avoid touching localStorage). */
|
||||||
|
export function setActiveBucket(value) {
|
||||||
|
state.activeBucket = value;
|
||||||
|
if (value == null) localStorage.removeItem('hyprcrd-active-bucket');
|
||||||
|
else localStorage.setItem('hyprcrd-active-bucket', String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from loadBuckets after the fresh bucket list arrives. If the
|
||||||
|
* persisted activeBucket points to a bucket id that no longer exists
|
||||||
|
* (deleted from another tab, removed in import, etc.), fall back to
|
||||||
|
* 'all'. Special filters are always valid. */
|
||||||
|
export function ensureActiveBucketValid() {
|
||||||
|
const cur = state.activeBucket;
|
||||||
|
if (!cur || SPECIAL_BUCKETS.has(cur)) return;
|
||||||
|
const exists = state.buckets.some(b => String(b.id) === String(cur));
|
||||||
|
if (!exists) setActiveBucket('all');
|
||||||
|
}
|
||||||
|
|
||||||
export const CARD_COLORS = [
|
export const CARD_COLORS = [
|
||||||
{ name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' },
|
{ name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' },
|
||||||
{ name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' },
|
{ name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' },
|
||||||
|
|||||||
@@ -32,6 +32,17 @@
|
|||||||
");
|
");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer, sent_at)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer, sent_at)");
|
||||||
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_relay_id ON chat_messages(relay_id) WHERE relay_id IS NOT NULL");
|
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_relay_id ON chat_messages(relay_id) WHERE relay_id IS NOT NULL");
|
||||||
|
// Read receipts for outgoing messages: `delivered` = recipient polled
|
||||||
|
// (single check), `seen` = recipient opened the thread (double check).
|
||||||
|
// Both filled in via /api/chat/sent receipt-poll on the relay side.
|
||||||
|
$cols = $db->query("PRAGMA table_info(chat_messages)")->fetchAll();
|
||||||
|
$colNames = array_column($cols, 'name');
|
||||||
|
if (!in_array('delivered', $colNames)) {
|
||||||
|
$db->exec("ALTER TABLE chat_messages ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
if (!in_array('seen', $colNames)) {
|
||||||
|
$db->exec("ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
// Track the largest relay msg id we've ever seen (so the next poll uses since=that)
|
// Track the largest relay msg id we've ever seen (so the next poll uses since=that)
|
||||||
$db->exec("
|
$db->exec("
|
||||||
CREATE TABLE IF NOT EXISTS chat_state (
|
CREATE TABLE IF NOT EXISTS chat_state (
|
||||||
@@ -115,7 +126,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
$peer = trim((string)($_GET['peer'] ?? ''));
|
$peer = trim((string)($_GET['peer'] ?? ''));
|
||||||
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$stmt = $db->prepare('SELECT id, direction, body, sent_at, is_read FROM chat_messages WHERE peer = :p ORDER BY id ASC LIMIT 500');
|
$stmt = $db->prepare('SELECT id, direction, body, sent_at, is_read, delivered, seen FROM chat_messages WHERE peer = :p ORDER BY id ASC LIMIT 500');
|
||||||
$stmt->execute(['p' => $peer]);
|
$stmt->execute(['p' => $peer]);
|
||||||
chatJson(['messages' => $stmt->fetchAll()]);
|
chatJson(['messages' => $stmt->fetchAll()]);
|
||||||
}
|
}
|
||||||
@@ -164,10 +175,12 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
$resData = json_decode($response, true);
|
$resData = json_decode($response, true);
|
||||||
$relayId = isset($resData['id']) ? (int)$resData['id'] : null;
|
$relayId = isset($resData['id']) ? (int)$resData['id'] : null;
|
||||||
|
|
||||||
// Store the outgoing message locally so the UI can render it immediately
|
// Store the outgoing message locally so the UI can render it
|
||||||
|
// immediately. relay_id is the relay's autoincrement id; the
|
||||||
|
// receipt-poll matches against it to update delivered/seen state.
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read) VALUES (:p, "out", :b, NULL, datetime("now"), 1)')
|
$db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, delivered, seen) VALUES (:p, "out", :b, :rid, datetime("now"), 1, 0, 0)')
|
||||||
->execute(['p' => $peer, 'b' => $body]);
|
->execute(['p' => $peer, 'b' => $body, 'rid' => $relayId]);
|
||||||
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +231,58 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
}
|
}
|
||||||
if ($maxId > $since) chatSetState('last_relay_id', $maxId);
|
if ($maxId > $since) chatSetState('last_relay_id', $maxId);
|
||||||
|
|
||||||
chatJson(['ok' => true, 'new' => $newCount]);
|
// ── Receipt poll for our SENT messages ──────────────────
|
||||||
|
// Pulls fetched/seen state for everything we've sent. Updates
|
||||||
|
// local outgoing rows by relay_id. Uses an independent `since`
|
||||||
|
// cursor so we keep getting state changes even on rows we
|
||||||
|
// already saw before.
|
||||||
|
$sentSince = (int)chatGetState('last_sent_relay_id', 0);
|
||||||
|
$ch2 = curl_init(rtrim($r['url'], '/') . '/api/chat/sent?since=' . $sentSince);
|
||||||
|
curl_setopt_array($ch2, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
$resp2 = curl_exec($ch2);
|
||||||
|
$st2 = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch2);
|
||||||
|
$receiptUpdates = 0;
|
||||||
|
if ($st2 === 200) {
|
||||||
|
$sentData = json_decode($resp2, true) ?: [];
|
||||||
|
$receipts = $sentData['receipts'] ?? [];
|
||||||
|
$sentMaxId = (int)($sentData['max_id'] ?? $sentSince);
|
||||||
|
$upd = $db->prepare('UPDATE chat_messages SET delivered = :d, seen = :s WHERE relay_id = :rid AND direction = "out"');
|
||||||
|
foreach ($receipts as $rcpt) {
|
||||||
|
$upd->execute([
|
||||||
|
'd' => (int)($rcpt['fetched'] ?? 0),
|
||||||
|
's' => (int)($rcpt['seen'] ?? 0),
|
||||||
|
'rid' => (int)$rcpt['id'],
|
||||||
|
]);
|
||||||
|
$receiptUpdates += $upd->rowCount();
|
||||||
|
}
|
||||||
|
// Cursor only advances when EVERY row's state is final (seen=1).
|
||||||
|
// Otherwise we need to keep re-polling those rows for updates.
|
||||||
|
// Cheap implementation: if every row in this batch is seen, bump
|
||||||
|
// cursor to maxId; else keep polling from the lowest non-seen id.
|
||||||
|
$allSeen = true;
|
||||||
|
$minPending = $sentMaxId;
|
||||||
|
foreach ($receipts as $rcpt) {
|
||||||
|
if ((int)($rcpt['seen'] ?? 0) !== 1) {
|
||||||
|
$allSeen = false;
|
||||||
|
if ((int)$rcpt['id'] - 1 < $minPending) $minPending = (int)$rcpt['id'] - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($allSeen && $sentMaxId > $sentSince) {
|
||||||
|
chatSetState('last_sent_relay_id', $sentMaxId);
|
||||||
|
} else if (!$allSeen && $minPending > $sentSince) {
|
||||||
|
chatSetState('last_sent_relay_id', $minPending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatJson(['ok' => true, 'new' => $newCount, 'receipts' => $receiptUpdates]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($route === '/api/chat/read' && $method === 'POST') {
|
if ($route === '/api/chat/read' && $method === 'POST') {
|
||||||
@@ -228,6 +292,28 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND direction = "in" AND is_read = 0')
|
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND direction = "in" AND is_read = 0')
|
||||||
->execute(['p' => $peer]);
|
->execute(['p' => $peer]);
|
||||||
|
|
||||||
|
// Tell the relay this peer's messages have been seen by us so the
|
||||||
|
// peer's next /api/chat/sent poll picks up the double-check state.
|
||||||
|
$r = chatRelaySettings();
|
||||||
|
if ($r['url'] && $r['user'] && $r['key']) {
|
||||||
|
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/seen');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode(['peer' => $peer]),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
// Failures are silent — the user already saw the messages
|
||||||
|
// locally, the relay state is just lagging.
|
||||||
|
}
|
||||||
chatJson(['ok' => true]);
|
chatJson(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,9 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
border: 1px solid var(--t-border);
|
border: 1px solid var(--t-border);
|
||||||
|
/* Pick up the user's card-radius design token so chat bubbles look
|
||||||
|
consistent with the rest of the surface (sharp / rounded / pill). */
|
||||||
|
border-radius: var(--ui-radius-card, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-bubble.in {
|
.chat-bubble.in {
|
||||||
@@ -192,11 +195,25 @@
|
|||||||
color: var(--t-dim);
|
color: var(--t-dim);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-family: var(--t-font-pixel);
|
font-family: var(--t-font-pixel);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-bubble.out + .chat-bubble-time { align-self: flex-end; }
|
.chat-bubble.out + .chat-bubble-time { align-self: flex-end; }
|
||||||
.chat-bubble.in + .chat-bubble-time { align-self: flex-start; }
|
.chat-bubble.in + .chat-bubble-time { align-self: flex-start; }
|
||||||
|
|
||||||
|
/* Read receipts: clock = sent (relay not yet acked), single check =
|
||||||
|
delivered (recipient polled), double check = seen (recipient opened
|
||||||
|
the thread). Single/double share the same icon size as the timestamp. */
|
||||||
|
.chat-receipt {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.chat-receipt.pending { color: var(--t-dim); }
|
||||||
|
.chat-receipt.delivered { color: var(--t-muted); }
|
||||||
|
.chat-receipt.seen { color: var(--t-accent); }
|
||||||
|
|
||||||
#chat-thread-form {
|
#chat-thread-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -231,9 +231,18 @@ function renderThread() {
|
|||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
wrap.innerHTML = currentMessages.map(m => {
|
wrap.innerHTML = currentMessages.map(m => {
|
||||||
const cls = m.direction === 'out' ? 'out' : 'in';
|
const cls = m.direction === 'out' ? 'out' : 'in';
|
||||||
|
// Read receipts for outgoing messages: single check = recipient
|
||||||
|
// polled (delivered), double check = recipient opened the thread
|
||||||
|
// (seen). Incoming messages get no icon.
|
||||||
|
let receipt = '';
|
||||||
|
if (m.direction === 'out') {
|
||||||
|
if (Number(m.seen)) receipt = '<i class="ph ph-checks chat-receipt seen"></i>';
|
||||||
|
else if (Number(m.delivered)) receipt = '<i class="ph ph-check chat-receipt delivered"></i>';
|
||||||
|
else receipt = '<i class="ph ph-clock chat-receipt pending"></i>';
|
||||||
|
}
|
||||||
return `
|
return `
|
||||||
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
|
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
|
||||||
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}</div>
|
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}${receipt}</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
// Scroll to bottom
|
// Scroll to bottom
|
||||||
@@ -330,6 +339,7 @@ async function pollOnce() {
|
|||||||
const res = await hyprcrd.api('api/chat/poll', { method: 'POST' }).catch(() => null);
|
const res = await hyprcrd.api('api/chat/poll', { method: 'POST' }).catch(() => null);
|
||||||
if (!res?.ok) return;
|
if (!res?.ok) return;
|
||||||
const newCount = res.new || 0;
|
const newCount = res.new || 0;
|
||||||
|
const receiptUpdates = res.receipts || 0;
|
||||||
if (newCount > 0) {
|
if (newCount > 0) {
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
// If we're inside a thread for any peer, refresh + auto-read it
|
// If we're inside a thread for any peer, refresh + auto-read it
|
||||||
@@ -340,6 +350,10 @@ async function pollOnce() {
|
|||||||
}
|
}
|
||||||
// Blink if the flyout is closed
|
// Blink if the flyout is closed
|
||||||
if (!isOpen()) startBlink();
|
if (!isOpen()) startBlink();
|
||||||
|
} else if (receiptUpdates > 0 && isOpen() && currentPeer) {
|
||||||
|
// No new incoming, but receipts (delivered/seen) changed for
|
||||||
|
// our outgoing — refresh the thread so the check icons update.
|
||||||
|
await loadThread(currentPeer);
|
||||||
} else if (initialPollDone === false) {
|
} else if (initialPollDone === false) {
|
||||||
// First poll: refresh badges in case we have unread from a previous session
|
// First poll: refresh badges in case we have unread from a previous session
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js?v=2';
|
||||||
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
|
async function makeCard(title, body = '') {
|
||||||
|
return fetch('api/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title, body, color: '#38bdf8' }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCard(id) {
|
||||||
|
const all = await fetch(`api/cards?ids=${id}`).then(r => r.json());
|
||||||
|
return all.find(c => c.id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectIds(ids, { bulk = false } = {}) {
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = bulk;
|
||||||
|
for (const id of ids) state.selectedIds.add(String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickJoin(rightClickTargetEl) {
|
||||||
|
rightClick(rightClickTargetEl);
|
||||||
|
await sleep(150);
|
||||||
|
const joinBtn = $$('#ctx-items [data-action="join"]')[0];
|
||||||
|
if (!joinBtn) return false;
|
||||||
|
click(joinBtn);
|
||||||
|
await sleep(400);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Card: Join — context menu visibility', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Join action when ≥2 cards are selected and right-clicked card is one of them', async () => {
|
||||||
|
const a = await makeCard('__join_vis_a__');
|
||||||
|
const b = await makeCard('__join_vis_b__');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const aEl = document.querySelector(`[data-id="${a.id}"]`);
|
||||||
|
if (!aEl) return;
|
||||||
|
selectIds([a.id, b.id]);
|
||||||
|
|
||||||
|
rightClick(aEl);
|
||||||
|
await sleep(150);
|
||||||
|
const joinBtn = $$('#ctx-items [data-action="join"]')[0];
|
||||||
|
expect(joinBtn).toExist();
|
||||||
|
expect(joinBtn.textContent.toLowerCase()).toContain('join');
|
||||||
|
expect(joinBtn.textContent).toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT show Join with only one card selected', async () => {
|
||||||
|
const a = await makeCard('__join_vis_solo__');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const aEl = document.querySelector(`[data-id="${a.id}"]`);
|
||||||
|
if (!aEl) return;
|
||||||
|
selectIds([a.id]);
|
||||||
|
|
||||||
|
rightClick(aEl);
|
||||||
|
await sleep(150);
|
||||||
|
const joinBtn = $$('#ctx-items [data-action="join"]')[0];
|
||||||
|
expect(joinBtn).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT show Join when right-clicked card is not part of the selection', async () => {
|
||||||
|
const a = await makeCard('__join_vis_outside_a__');
|
||||||
|
const b = await makeCard('__join_vis_outside_b__');
|
||||||
|
const c = await makeCard('__join_vis_outside_c__');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const cEl = document.querySelector(`[data-id="${c.id}"]`);
|
||||||
|
if (!cEl) return;
|
||||||
|
selectIds([a.id, b.id]);
|
||||||
|
|
||||||
|
rightClick(cEl);
|
||||||
|
await sleep(150);
|
||||||
|
const joinBtn = $$('#ctx-items [data-action="join"]')[0];
|
||||||
|
expect(joinBtn).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Card: Join — sequential selection (first wins)', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('first ctrl-clicked card becomes the winner; others are deleted', async () => {
|
||||||
|
const first = await makeCard('__join_seq_first__', 'body of first');
|
||||||
|
const second = await makeCard('__join_seq_second__', 'body of second');
|
||||||
|
const third = await makeCard('__join_seq_third__', 'body of third');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const firstEl = document.querySelector(`[data-id="${first.id}"]`);
|
||||||
|
if (!firstEl) return;
|
||||||
|
selectIds([first.id, second.id, third.id], { bulk: false });
|
||||||
|
|
||||||
|
const ok = await clickJoin(firstEl);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
// Winner survives
|
||||||
|
const survivor = await getCard(first.id);
|
||||||
|
expect(survivor).toExist();
|
||||||
|
expect(survivor.title).toBe('__join_seq_first__');
|
||||||
|
// Losers deleted
|
||||||
|
expect(await getCard(second.id)).toBe(undefined);
|
||||||
|
expect(await getCard(third.id)).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merged body lists every original title at the top', async () => {
|
||||||
|
const a = await makeCard('__join_titles_a__', 'aaa');
|
||||||
|
const b = await makeCard('__join_titles_b__', 'bbb');
|
||||||
|
const c = await makeCard('__join_titles_c__', 'ccc');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const aEl = document.querySelector(`[data-id="${a.id}"]`);
|
||||||
|
if (!aEl) return;
|
||||||
|
selectIds([a.id, b.id, c.id], { bulk: false });
|
||||||
|
|
||||||
|
const ok = await clickJoin(aEl);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const survivor = await getCard(a.id);
|
||||||
|
expect(survivor.body).toContain('__join_titles_a__');
|
||||||
|
expect(survivor.body).toContain('__join_titles_b__');
|
||||||
|
expect(survivor.body).toContain('__join_titles_c__');
|
||||||
|
// All three bodies preserved too
|
||||||
|
expect(survivor.body).toContain('aaa');
|
||||||
|
expect(survivor.body).toContain('bbb');
|
||||||
|
expect(survivor.body).toContain('ccc');
|
||||||
|
// Title list comes before the body content
|
||||||
|
const titlePos = survivor.body.indexOf('__join_titles_b__');
|
||||||
|
const bodyPos = survivor.body.indexOf('bbb');
|
||||||
|
expect(titlePos).toBeLessThan(bodyPos);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selection is cleared after a successful join', async () => {
|
||||||
|
const a = await makeCard('__join_clearsel_a__');
|
||||||
|
const b = await makeCard('__join_clearsel_b__');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const aEl = document.querySelector(`[data-id="${a.id}"]`);
|
||||||
|
if (!aEl) return;
|
||||||
|
selectIds([a.id, b.id], { bulk: false });
|
||||||
|
|
||||||
|
const ok = await clickJoin(aEl);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
expect(state.selectedIds.size).toBe(0);
|
||||||
|
expect(state.selectionWasBulk).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Card: Join — bulk selection (longest title wins)', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('longest-title card becomes the winner regardless of insertion order', async () => {
|
||||||
|
const short = await makeCard('__jbulk_x__', 'x body');
|
||||||
|
const longer = await makeCard('__jbulk_loooooong_winner_title__', 'l body');
|
||||||
|
const medium = await makeCard('__jbulk_med_xx__', 'm body');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const shortEl = document.querySelector(`[data-id="${short.id}"]`);
|
||||||
|
if (!shortEl) return;
|
||||||
|
// Insert in order short, longer, medium — but mark as bulk so the
|
||||||
|
// join code uses longest-title strategy.
|
||||||
|
selectIds([short.id, longer.id, medium.id], { bulk: true });
|
||||||
|
|
||||||
|
const ok = await clickJoin(shortEl);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
// The longest-titled card should be the survivor
|
||||||
|
const survivor = await getCard(longer.id);
|
||||||
|
expect(survivor).toExist();
|
||||||
|
expect(survivor.title).toBe('__jbulk_loooooong_winner_title__');
|
||||||
|
// The other two are deleted
|
||||||
|
expect(await getCard(short.id)).toBe(undefined);
|
||||||
|
expect(await getCard(medium.id)).toBe(undefined);
|
||||||
|
// Survivor body still has all titles
|
||||||
|
expect(survivor.body).toContain('__jbulk_x__');
|
||||||
|
expect(survivor.body).toContain('__jbulk_med_xx__');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Card: Join — undo', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Z reverts a join: winner body restored, losers recreated', async () => {
|
||||||
|
const winner = await makeCard('__join_undo_winner__', 'original winner body');
|
||||||
|
const loser = await makeCard('__join_undo_loser__', 'original loser body');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const winnerEl = document.querySelector(`[data-id="${winner.id}"]`);
|
||||||
|
if (!winnerEl) return;
|
||||||
|
selectIds([winner.id, loser.id], { bulk: false });
|
||||||
|
|
||||||
|
const ok = await clickJoin(winnerEl);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
// Sanity: loser is gone, winner has merged body
|
||||||
|
expect(await getCard(loser.id)).toBe(undefined);
|
||||||
|
let survivor = await getCard(winner.id);
|
||||||
|
expect(survivor.body).toContain('__join_undo_loser__');
|
||||||
|
|
||||||
|
// Undo via global Ctrl+Z
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }));
|
||||||
|
await sleep(400);
|
||||||
|
|
||||||
|
// Winner body restored to original
|
||||||
|
survivor = await getCard(winner.id);
|
||||||
|
expect(survivor.body).toBe('original winner body');
|
||||||
|
|
||||||
|
// A loser card was recreated with the same title + body
|
||||||
|
// (its id will differ — find by title since the original is gone)
|
||||||
|
const all = await fetch('api/cards').then(r => r.json());
|
||||||
|
const restored = all.find(c => c.title === '__join_undo_loser__');
|
||||||
|
expect(restored).toExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ const FILES = [
|
|||||||
'./super-buckets.test.js',
|
'./super-buckets.test.js',
|
||||||
'./context-menu.test.js',
|
'./context-menu.test.js',
|
||||||
'./card-interactions.test.js',
|
'./card-interactions.test.js',
|
||||||
|
'./card-join.test.js',
|
||||||
'./filtering.test.js',
|
'./filtering.test.js',
|
||||||
'./sorting.test.js',
|
'./sorting.test.js',
|
||||||
'./state-functions.test.js',
|
'./state-functions.test.js',
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, type, rightClick, pressKey, sleep, closeAll, csrf } from './runner.js?v=2';
|
import { describe, it, afterEach, expect, $, $$, click, type, rightClick, pressKey, sleep, closeAll, csrf } from './runner.js?v=2';
|
||||||
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
function ctrlZ() {
|
function ctrlZ() {
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }));
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll `state.cards` until the card with the given id satisfies the
|
||||||
|
* predicate. Lets a test wait for an async click handler's _reloadAll
|
||||||
|
* to land in the in-memory state before triggering the next user
|
||||||
|
* action that reads from it. Use this in place of fixed `sleep(300)`
|
||||||
|
* waits between chained actions — fixed sleeps race against handler
|
||||||
|
* speed when the run executes faster than the original tuning.
|
||||||
|
*/
|
||||||
|
async function waitForStateCard(id, predicate, timeout = 2000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
const card = state.cards.find(c => c.id == id);
|
||||||
|
if (card && predicate(card)) return card;
|
||||||
|
await sleep(30);
|
||||||
|
}
|
||||||
|
throw new Error(`waitForStateCard: card ${id} did not satisfy predicate within ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
describe('Undo: Color Change', () => {
|
describe('Undo: Color Change', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
|
|
||||||
@@ -112,7 +131,10 @@ describe('Undo: Editor Save', () => {
|
|||||||
body: JSON.stringify({ due_date: '2099-06-15' }),
|
body: JSON.stringify({ due_date: '2099-06-15' }),
|
||||||
});
|
});
|
||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
await sleep(200);
|
// Make sure state.cards has the new due_date before opening the
|
||||||
|
// editor — the editor's snapshot for the undo entry reads from
|
||||||
|
// state.cards.find() and would otherwise capture due_date=null.
|
||||||
|
await waitForStateCard(res.id, c => c.due_date === '2099-06-15');
|
||||||
|
|
||||||
// Edit and change due date via editor
|
// Edit and change due date via editor
|
||||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||||
@@ -287,7 +309,12 @@ describe('Undo: Multiple Undos', () => {
|
|||||||
click($$('#ctx-items [data-action="color"]')[0]);
|
click($$('#ctx-items [data-action="color"]')[0]);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
click(document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]'));
|
click(document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]'));
|
||||||
await sleep(300);
|
// Wait for state.cards to reflect the new color before doing the
|
||||||
|
// *second* rightClick — otherwise the second action's contextmenu
|
||||||
|
// handler reads the stale card (#38bdf8) from state.cards and
|
||||||
|
// pushes the wrong undo snapshot, collapsing both undo entries
|
||||||
|
// onto the original color.
|
||||||
|
await waitForStateCard(res.id, c => c.color === '#fb7185');
|
||||||
|
|
||||||
// Change 2: color to green
|
// Change 2: color to green
|
||||||
const cardEl2 = document.querySelector(`[data-id="${res.id}"]`);
|
const cardEl2 = document.querySelector(`[data-id="${res.id}"]`);
|
||||||
@@ -296,7 +323,7 @@ describe('Undo: Multiple Undos', () => {
|
|||||||
click($$('#ctx-items [data-action="color"]')[0]);
|
click($$('#ctx-items [data-action="color"]')[0]);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
click(document.querySelector('#ctx-color-grid [data-ctx-color="#4ade80"]'));
|
click(document.querySelector('#ctx-color-grid [data-ctx-color="#4ade80"]'));
|
||||||
await sleep(300);
|
await waitForStateCard(res.id, c => c.color === '#4ade80');
|
||||||
|
|
||||||
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||||
expect(card.color).toBe('#4ade80');
|
expect(card.color).toBe('#4ade80');
|
||||||
|
|||||||
@@ -180,6 +180,16 @@ $db->exec('CREATE TABLE IF NOT EXISTS chat_messages (
|
|||||||
FOREIGN KEY (sender_id) REFERENCES tenants(id) ON DELETE SET NULL
|
FOREIGN KEY (sender_id) REFERENCES tenants(id) ON DELETE SET NULL
|
||||||
)');
|
)');
|
||||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_recipient ON chat_messages(recipient_id, fetched, id)');
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_recipient ON chat_messages(recipient_id, fetched, id)');
|
||||||
|
// Read receipts: `fetched` (already present) is "delivered" — set when the
|
||||||
|
// recipient polls /api/chat. `seen` is "seen" — set when the recipient
|
||||||
|
// opens the thread with the sender via POST /api/chat/seen. The sender
|
||||||
|
// learns both states by polling GET /api/chat/sent.
|
||||||
|
$chatCols = $db->query('PRAGMA table_info(chat_messages)')->fetchAll();
|
||||||
|
$chatColNames = array_column($chatCols, 'name');
|
||||||
|
if (!in_array('seen', $chatColNames)) {
|
||||||
|
$db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)');
|
||||||
|
|
||||||
// Collaborative buckets: shared among multiple tenants. Each bucket has one
|
// Collaborative buckets: shared among multiple tenants. Each bucket has one
|
||||||
// owner and a membership set. Buckets persist (not ephemeral) since they are
|
// owner and a membership set. Buckets persist (not ephemeral) since they are
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
// Chat routes (client API only):
|
// Chat routes (client API only):
|
||||||
// POST /api/chat — send a chat message to {recipient}
|
// POST /api/chat — send a chat message to {recipient}
|
||||||
// GET /api/chat?since=N — pull all chat messages addressed to me with id > N
|
// GET /api/chat?since=N — pull all messages addressed to me (id > N)
|
||||||
|
// GET /api/chat/sent?since=N — pull state updates (delivered/seen) for
|
||||||
|
// *my* sent messages with id > N. The
|
||||||
|
// sender uses this to learn read receipts
|
||||||
|
// without needing a backchannel.
|
||||||
|
// POST /api/chat/seen {peer} — mark every message FROM peer TO me as
|
||||||
|
// seen. The sender's next /api/chat/sent
|
||||||
|
// poll picks the new state up.
|
||||||
//
|
//
|
||||||
// Same decrypt-reencrypt hop as messages.php: arrives encrypted with the
|
// Same decrypt-reencrypt hop as messages.php: arrives encrypted with the
|
||||||
// senders client secret, stored at-rest under the inter-tenant key, returned
|
// senders client secret, stored at-rest under the inter-tenant key, returned
|
||||||
@@ -80,3 +87,46 @@ if ($route === '/api/chat' && $method === 'GET') {
|
|||||||
}
|
}
|
||||||
jsonResponse(['messages' => $out, 'max_id' => $maxId]);
|
jsonResponse(['messages' => $out, 'max_id' => $maxId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET /api/chat/sent ─────────────────────────────────────────
|
||||||
|
// Returns the receipt state (fetched/seen) for messages the caller has
|
||||||
|
// sent. Body is intentionally NOT included — the sender already has the
|
||||||
|
// plaintext locally; the relay only echoes the state flags so the sender
|
||||||
|
// can render check / double-check on the right messages.
|
||||||
|
|
||||||
|
if ($route === '/api/chat/sent' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$since = (int)($_GET['since'] ?? 0);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, recipient_id, fetched, seen, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE sender_id = :s AND id > :since
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1000
|
||||||
|
');
|
||||||
|
$stmt->execute(['s' => $clientTenant['id'], 'since' => $since]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
$maxId = $since;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ((int)$r['id'] > $maxId) $maxId = (int)$r['id'];
|
||||||
|
}
|
||||||
|
jsonResponse(['receipts' => $rows, 'max_id' => $maxId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/chat/seen ────────────────────────────────────────
|
||||||
|
// Body: { peer: "<sender_username>" }. Marks every message that the
|
||||||
|
// peer sent to the caller as seen=1. Idempotent — safe to call on
|
||||||
|
// every thread open.
|
||||||
|
|
||||||
|
if ($route === '/api/chat/seen' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$peer = trim((string)($input['peer'] ?? ''));
|
||||||
|
if ($peer === '') jsonResponse(['error' => 'peer required'], 400);
|
||||||
|
$db->prepare('
|
||||||
|
UPDATE chat_messages
|
||||||
|
SET seen = 1
|
||||||
|
WHERE recipient_id = :r AND sender_username = :p AND seen = 0
|
||||||
|
')->execute(['r' => $clientTenant['id'], 'p' => $peer]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user