[object Object]

← 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

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 →