← back to Dw Launches
Add unified Inbox (Hootsuite parity): triage all mentions/comments/DMs across accounts in one feed, filter chips with counts, per-account filter, inline reply + Open-in-Compose + mark-read, unread pill + persisted read/replied state; banned-word gate enforced on replies; simulated/local-only
b51421c5ce0e147d386aa0b3a7b2f147f5a1094b · 2026-06-19 14:16:59 -0700 · Steve
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M public/hoot-extras.jsM public/index.htmlA screenshot-inbox.pngM server.js
Diff
commit b51421c5ce0e147d386aa0b3a7b2f147f5a1094b
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jun 19 14:16:59 2026 -0700
Add unified Inbox (Hootsuite parity): triage all mentions/comments/DMs across accounts in one feed, filter chips with counts, per-account filter, inline reply + Open-in-Compose + mark-read, unread pill + persisted read/replied state; banned-word gate enforced on replies; simulated/local-only
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
public/hoot-extras.js | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++
public/index.html | 17 ++++++-
screenshot-inbox.png | Bin 0 -> 355729 bytes
server.js | 20 ++++++++
4 files changed, 160 insertions(+), 1 deletion(-)
diff --git a/public/hoot-extras.js b/public/hoot-extras.js
index be3990f..aa10644 100644
--- a/public/hoot-extras.js
+++ b/public/hoot-extras.js
@@ -105,3 +105,127 @@ async function renderAnalytics(){
<td>${c.followers.toLocaleString()}</td><td>${c.reach.toLocaleString()}</td><td>${c.engagement.toLocaleString()}</td>
<td>${c.engagementRate}%</td><td>${esc(c.bestTime)}</td><td>${c.postsLive}</td></tr>`).join('')+`</tbody>`;
}
+
+// ====================================================================
+// UNIFIED INBOX — Hootsuite "Inbox" parity (simulated, local-only).
+// Triage all mentions / comments / DMs across accounts; reply drafts
+// locally (nothing is sent). Read/replied state persists per-browser.
+// ====================================================================
+
+// Inject inbox CSS at runtime so we never race index.html's <style> block.
+(function injectInboxCSS(){
+ if(document.getElementById('hoot-inbox-css')) return;
+ const css=`
+ .inbox-wrap{max-width:900px;margin:0 auto;padding:18px 28px}
+ .inbox-bar{display:flex;align-items:center;gap:12px}
+ .inbox-filters{display:flex;gap:6px;flex-wrap:wrap}
+ .inbox-spacer{margin-left:auto}
+ .ibf{font-size:12px;padding:6px 13px;border-radius:999px;cursor:pointer;font-family:inherit;
+ background:var(--panel2);border:1px solid var(--line);color:var(--ink-2);transition:all .12s}
+ .ibf:hover{border-color:var(--gold-dim);color:var(--ink)}
+ .ibf.sel{background:var(--gold-faint2);border-color:var(--gold);color:var(--gold)}
+ .ibf .n{opacity:.65;margin-left:5px;font-size:11px}
+ .inbox-item{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
+ padding:13px 15px;margin-bottom:9px;transition:border-color .12s}
+ .inbox-item.unread{border-left:3px solid var(--gold)}
+ .inbox-item.replied{opacity:.72}
+ .inbox-item .ii-top{display:flex;align-items:center;gap:10px}
+ .inbox-item .ii-av{width:34px;height:34px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;
+ justify-content:center;font-size:13px;color:#0d0b06;font-weight:700}
+ .inbox-item .ii-name{font-size:13px;color:var(--ink);font-weight:600;line-height:1.15}
+ .inbox-item .ii-sub{font-size:11px;color:var(--muted)}
+ .inbox-item .ii-kind{font-size:9px;text-transform:uppercase;letter-spacing:.6px;padding:2px 7px;border-radius:999px;
+ border:1px solid var(--line);color:var(--ink-2);background:var(--panel2)}
+ .inbox-item .ii-kind.mentions{color:#6aacda;border-color:rgba(58,106,160,.3)}
+ .inbox-item .ii-kind.comments{color:var(--gold);border-color:var(--gold-dim)}
+ .inbox-item .ii-kind.dms{color:#5ab880;border-color:rgba(58,138,96,.3)}
+ .inbox-item .ii-when{margin-left:auto;font-size:11px;color:var(--muted);white-space:nowrap}
+ .inbox-item .ii-text{font-size:13px;color:var(--ink-2);margin:9px 0 0;line-height:1.5}
+ .inbox-item .ii-actions{display:flex;gap:14px;margin-top:10px;color:var(--muted);font-size:12px}
+ .inbox-item .ii-actions span{cursor:pointer;transition:color .12s}
+ .inbox-item .ii-actions span:hover{color:var(--gold)}
+ .inbox-item .ii-reply{display:flex;gap:8px;margin-top:10px}
+ .inbox-item .ii-reply textarea{min-height:60px}
+ .inbox-item .ii-reply button{flex:0 0 auto;align-self:flex-end}
+ .inbox-item .ii-done{font-size:11px;color:#5ab880;margin-top:8px}`;
+ const s=document.createElement('style'); s.id='hoot-inbox-css'; s.textContent=css; document.head.appendChild(s);
+})();
+
+let inboxFilter='all';
+function inboxState(){ try{ return JSON.parse(localStorage.getItem('dwlaunch:inboxState'))||{}; }catch(e){ return {}; } }
+function inboxSet(id,patch){ const s=inboxState(); s[id]=Object.assign({},s[id],patch); localStorage.setItem('dwlaunch:inboxState',JSON.stringify(s)); }
+
+async function renderInbox(){
+ // populate channel select once
+ const csel=document.getElementById('inbox-channel');
+ if(csel && !csel.options.length){
+ csel.innerHTML='<option value="all">All accounts</option>'+CHANNELS.map(c=>`<option value="${c.id}">${esc(c.name)}</option>`).join('');
+ }
+ const ch=(csel&&csel.value)||'all';
+ const kindParam=inboxFilter==='all'||inboxFilter==='unread'?'':inboxFilter;
+ let data;
+ try{ data=await api(`/api/inbox?channel=${encodeURIComponent(ch)}${kindParam?`&kind=${kindParam}`:''}`); }
+ catch(e){ document.getElementById('inbox-list').innerHTML='<span class="muted">Could not load inbox.</span>'; return; }
+ const st=inboxState();
+ // filter chips
+ const c=data.counts||{};
+ const localUnread=data.items.filter(it=>it.unread && !(st[it.id]&&st[it.id].read)).length;
+ const chips=[['all','All',c.all],['mentions','Mentions',c.mentions],['comments','Comments',c.comments],['dms','DMs',c.dms],['unread','Unread',localUnread]];
+ document.getElementById('inbox-filters').innerHTML=chips.map(([k,l,n])=>
+ `<button class="ibf${inboxFilter===k?' sel':''}" data-f="${k}">${l}<span class="n">${n||0}</span></button>`).join('');
+ document.querySelectorAll('#inbox-filters .ibf').forEach(b=>b.onclick=()=>{ inboxFilter=b.getAttribute('data-f'); renderInbox(); });
+ updateInboxPill(localUnread);
+
+ let items=data.items;
+ if(inboxFilter==='unread') items=items.filter(it=>it.unread && !(st[it.id]&&st[it.id].read));
+ const list=document.getElementById('inbox-list');
+ if(!items.length){ list.innerHTML='<span class="muted">Nothing here — inbox zero. 🎉</span>'; return; }
+ list.innerHTML=items.map(it=>inboxItem(it,st[it.id]||{})).join('');
+ // wire actions
+ list.querySelectorAll('.inbox-item').forEach(el=>{
+ const id=el.getAttribute('data-id'), ch=el.getAttribute('data-ch'), handle=el.getAttribute('data-handle');
+ const reply=el.querySelector('[data-act=reply]'), read=el.querySelector('[data-act=read]'), like=el.querySelector('[data-act=like]');
+ const box=el.querySelector('.ii-reply');
+ if(reply) reply.onclick=()=>{ box.classList.toggle('hide'); inboxSet(id,{read:true}); const ta=box.querySelector('textarea'); if(ta){ta.focus();} };
+ if(read) read.onclick=()=>{ inboxSet(id,{read:true}); renderInbox(); };
+ if(like) like.onclick=()=>{ like.textContent='♥ Liked'; like.style.color='var(--gold)'; toast('Simulated like — nothing sent.'); };
+ const send=el.querySelector('[data-act=send]');
+ if(send) send.onclick=()=>{
+ const ta=box.querySelector('textarea'); const txt=(ta.value||'').trim();
+ if(/wallpaper/i.test(txt)){ toast('Blocked: "Wallpaper" is banned — use "Wallcoverings".',true); return; }
+ inboxSet(id,{read:true,replied:true});
+ toast(`Reply to @${handle} drafted (simulated — nothing sent).`); renderInbox();
+ };
+ const compose=el.querySelector('[data-act=compose]');
+ if(compose) compose.onclick=()=>{ inboxSet(id,{read:true}); replyToFeed(id,ch,handle); };
+ });
+}
+function inboxItem(it,s){
+ const initials=(it.author.name||'?').split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase();
+ const unread=it.unread && !s.read;
+ const when=(()=>{ try{ return new Date(it.datetime).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return ''; } })();
+ return `<div class="inbox-item${unread?' unread':''}${s.replied?' replied':''}" data-id="${esc(it.id)}" data-ch="${esc(it.channel)}" data-handle="${esc(it.author.handle)}">
+ <div class="ii-top">
+ <span class="ii-av" style="background:${it.author.color}">${initials}</span>
+ <span><span class="ii-name">${esc(it.author.name)}</span><br><span class="ii-sub">@${esc(it.author.handle)} · ${chanName(it.channel)}</span></span>
+ <span class="ii-kind ${it.kind}">${it.kind}</span>
+ <span class="ii-when">${when}</span>
+ </div>
+ <div class="ii-text">${esc(it.text)}</div>
+ <div class="ii-actions">
+ <span data-act="reply">↩ Reply</span>
+ <span data-act="compose">↗ Open in Compose</span>
+ <span data-act="like">♥ ${it.likes||0}</span>
+ ${unread?'<span data-act="read">✓ Mark read</span>':''}
+ </div>
+ <div class="ii-reply hide">
+ <textarea placeholder="Write a reply to @${esc(it.author.handle)}… (simulated — nothing sent)"></textarea>
+ <button class="btn" data-act="send">Send (sim)</button>
+ </div>
+ ${s.replied?'<div class="ii-done">✓ Replied (simulated)</div>':''}
+ </div>`;
+}
+function updateInboxPill(n){
+ const p=document.getElementById('inbox-pill'); if(!p) return;
+ if(n>0){ p.textContent=n; p.classList.remove('hide'); } else p.classList.add('hide');
+}
diff --git a/public/index.html b/public/index.html
index b8f922d..9cd42a5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -942,6 +942,7 @@
<button id="nav-compose" class="active" onclick="showView('compose')">Compose</button>
<button id="nav-media" onclick="showView('media')">Media</button>
<button id="nav-streams" onclick="showView('streams')">Streams</button>
+ <button id="nav-inbox" onclick="showView('inbox')">Inbox<span class="pill hide" id="inbox-pill">0</span></button>
<button id="nav-bulk" onclick="showView('bulk')">Bulk</button>
<button id="nav-calendar" onclick="showView('calendar')">Calendar</button>
<button id="nav-analytics" onclick="showView('analytics')">Analytics</button>
@@ -1137,6 +1138,19 @@
<div class="hoot-board" id="hoot-board"></div>
</div>
+<!-- ============ INBOX VIEW (unified social inbox) ============ -->
+<div id="view-inbox" class="hide">
+ <div class="inbox-wrap">
+ <div class="inbox-bar">
+ <div class="inbox-filters" id="inbox-filters"></div>
+ <div class="inbox-spacer"></div>
+ <select id="inbox-channel" onchange="renderInbox()" style="width:auto;min-width:150px"></select>
+ </div>
+ <div class="ana-note" style="margin:14px 0 4px">⚠ Simulated inbox — conversations are sample data. Replies are drafted locally; nothing is sent.</div>
+ <div id="inbox-list"></div>
+ </div>
+</div>
+
<!-- ============ BULK COMPOSER VIEW ============ -->
<div id="view-bulk" class="hide">
<div class="bulk-wrap">
@@ -1500,7 +1514,7 @@ async function publish(){
}
// ---------- views ----------
-const VIEWS=['dashboard','compose','media','streams','bulk','calendar','analytics','drafts','approvals'];
+const VIEWS=['dashboard','compose','media','streams','inbox','bulk','calendar','analytics','drafts','approvals'];
function showView(v){
if(!VIEWS.includes(v)) v='compose';
VIEWS.forEach(x=>{
@@ -1511,6 +1525,7 @@ function showView(v){
if(v==='dashboard') renderDashboard();
if(v==='media') renderMedia();
if(v==='streams') renderStreams();
+ if(v==='inbox') renderInbox();
if(v==='bulk') {/* static */}
if(v==='calendar') renderCalendar();
if(v==='analytics') renderAnalytics();
diff --git a/screenshot-inbox.png b/screenshot-inbox.png
new file mode 100644
index 0000000..5667dc5
Binary files /dev/null and b/screenshot-inbox.png differ
diff --git a/server.js b/server.js
index c361019..e66c974 100644
--- a/server.js
+++ b/server.js
@@ -457,6 +457,26 @@ app.get('/api/stream', (req, res) => {
res.json({ channel, type, kind: 'inbound', items });
});
+// ---- Unified INBOX — Hootsuite "Inbox" equivalent (simulated) ------------
+// Merges every inbound conversation item (mentions + comments + DMs) into one
+// triage feed. ?kind=mention|comment|dm &channel= — both optional.
+app.get('/api/inbox', (req, res) => {
+ const kindMap = { mentions: 'mentions', mention: 'mentions', comments: 'comments', comment: 'comments', dms: 'dms', dm: 'dms' };
+ const wantKind = kindMap[req.query.kind];
+ const channel = req.query.channel || 'all';
+ const items = FEED
+ .filter(f => ['mentions', 'comments', 'dms'].includes(f.kind))
+ .filter(f => !wantKind || f.kind === wantKind)
+ .filter(f => channel === 'all' || f.channel === channel)
+ .sort((a, b) => new Date(b.datetime) - new Date(a.datetime));
+ const counts = { all: 0, mentions: 0, comments: 0, dms: 0, unread: 0 };
+ FEED.forEach(f => {
+ if (!['mentions', 'comments', 'dms'].includes(f.kind)) return;
+ counts.all++; counts[f.kind]++; if (f.unread) counts.unread++;
+ });
+ res.json({ items, counts, simulated: true });
+});
+
// ---- Analytics (SIMULATED) — Hootsuite "Analyze" equivalent --------------
app.get('/api/analytics', (_req, res) => {
const db = loadStore();
← e5a2c9b Streams → true Hootsuite model: user-addable columns (accoun
·
back to Dw Launches
·
Media Library: vendor-IG store + ingest (Graph/permalink), r 29df203 →