| 1 | <div class="container prutter-conv-page" data-conversation-id="<%= conversation.id %>" data-me="<%= user.id %>">
|
|---|
| 2 | <header class="prutter-conv-header">
|
|---|
| 3 | <p class="prutter-back"><a href="<%= siteUrlBase %>/prutter">← Inbox</a></p>
|
|---|
| 4 | <div class="prutter-conv-headline">
|
|---|
| 5 | <div class="prutter-conv-avatar prutter-conv-avatar--lg">
|
|---|
| 6 | <% if (other && other.avatar_url) { %>
|
|---|
| 7 | <img src="<%= other.avatar_url %>" alt="">
|
|---|
| 8 | <% } else { %>
|
|---|
| 9 | <span><%= (other && other.username || '?').charAt(0).toUpperCase() %></span>
|
|---|
| 10 | <% } %>
|
|---|
| 11 | </div>
|
|---|
| 12 | <div>
|
|---|
| 13 | <h1><%= other ? other.username : 'Unknown' %></h1>
|
|---|
| 14 | <% if (other) { %>
|
|---|
| 15 | <p class="prutter-conv-sub"><a href="/users/<%= encodeURIComponent(other.username) %>">View profile</a></p>
|
|---|
| 16 | <% } %>
|
|---|
| 17 | </div>
|
|---|
| 18 | </div>
|
|---|
| 19 | </header>
|
|---|
| 20 |
|
|---|
| 21 | <ol class="prutter-thread" id="prutter-thread">
|
|---|
| 22 | <% messages.forEach(function(m) { %>
|
|---|
| 23 | <li class="prutter-msg <%= m.author_id === user.id ? 'prutter-msg--mine' : 'prutter-msg--theirs' %>" data-msg-id="<%= m.id %>">
|
|---|
| 24 | <div class="prutter-msg-bubble"><%= m.content %></div>
|
|---|
| 25 | <div class="prutter-msg-time" title="<%= m.created_at %>"><%= formatDateTime(m.created_at) %></div>
|
|---|
| 26 | </li>
|
|---|
| 27 | <% }); %>
|
|---|
| 28 | </ol>
|
|---|
| 29 |
|
|---|
| 30 | <form class="prutter-composer"
|
|---|
| 31 | method="post"
|
|---|
| 32 | action="<%= siteUrlBase %>/prutter/<%= conversation.id %>/send"
|
|---|
| 33 | hx-post="<%= siteUrlBase %>/prutter/<%= conversation.id %>/send"
|
|---|
| 34 | hx-target="#prutter-thread"
|
|---|
| 35 | hx-swap="beforeend"
|
|---|
| 36 | hx-on::after-request="this.querySelector('textarea').value=''; window.__prutterScroll && window.__prutterScroll();">
|
|---|
| 37 | <textarea name="content" rows="2" maxlength="2000" placeholder="Type a message…" required></textarea>
|
|---|
| 38 | <button type="submit" class="btn btn-primary">Send</button>
|
|---|
| 39 | </form>
|
|---|
| 40 | </div>
|
|---|
| 41 |
|
|---|
| 42 | <script>
|
|---|
| 43 | (function() {
|
|---|
| 44 | // Auto-scroll to bottom on load and after appends.
|
|---|
| 45 | var thread = document.getElementById('prutter-thread');
|
|---|
| 46 | function scrollToBottom() { thread.scrollTop = thread.scrollHeight; }
|
|---|
| 47 | scrollToBottom();
|
|---|
| 48 | window.__prutterScroll = scrollToBottom;
|
|---|
| 49 |
|
|---|
| 50 | // Live updates via WebSocket.
|
|---|
| 51 | // Browser auto-resolves protocol/host; same origin as the page.
|
|---|
| 52 | try {
|
|---|
| 53 | var page = document.querySelector('.prutter-conv-page');
|
|---|
| 54 | var convId = page.dataset.conversationId;
|
|---|
| 55 | var me = page.dataset.me;
|
|---|
| 56 | var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|---|
| 57 | var ws = new WebSocket(proto + '//' + location.host + '/ws/prutter');
|
|---|
| 58 |
|
|---|
| 59 | ws.addEventListener('message', function(ev) {
|
|---|
| 60 | try {
|
|---|
| 61 | var data = JSON.parse(ev.data);
|
|---|
| 62 | if (data.type !== 'new_message' || data.conversationId !== convId) return;
|
|---|
| 63 | var m = data.message;
|
|---|
| 64 | // Don't double-render our own messages (already added via HTMX response)
|
|---|
| 65 | if (m.author_id === me) return;
|
|---|
| 66 |
|
|---|
| 67 | var li = document.createElement('li');
|
|---|
| 68 | li.className = 'prutter-msg prutter-msg--theirs';
|
|---|
| 69 | li.dataset.msgId = m.id;
|
|---|
| 70 | var bubble = document.createElement('div');
|
|---|
| 71 | bubble.className = 'prutter-msg-bubble';
|
|---|
| 72 | bubble.textContent = m.content; // text content = automatic escaping
|
|---|
| 73 | var time = document.createElement('div');
|
|---|
| 74 | time.className = 'prutter-msg-time';
|
|---|
| 75 | time.textContent = new Date(m.created_at).toLocaleString();
|
|---|
| 76 | li.appendChild(bubble);
|
|---|
| 77 | li.appendChild(time);
|
|---|
| 78 | thread.appendChild(li);
|
|---|
| 79 | scrollToBottom();
|
|---|
| 80 | } catch (e) { /* ignore malformed */ }
|
|---|
| 81 | });
|
|---|
| 82 | } catch (e) {
|
|---|
| 83 | console.warn('Prutter WS unavailable:', e);
|
|---|
| 84 | }
|
|---|
| 85 | })();
|
|---|
| 86 | </script>
|
|---|
| 87 |
|
|---|
| 88 | <style>
|
|---|
| 89 | .prutter-conv-page { max-width: 720px; margin: 2rem auto; padding: 0 1rem; display: flex; flex-direction: column; gap: 1rem; }
|
|---|
| 90 | .prutter-back { margin: 0; }
|
|---|
| 91 | .prutter-back a { color: var(--accent); text-decoration: none; }
|
|---|
| 92 |
|
|---|
| 93 | .prutter-conv-headline {
|
|---|
| 94 | display: flex; align-items: center; gap: 0.85rem;
|
|---|
| 95 | padding-bottom: 1rem; border-bottom: 1px solid var(--rule);
|
|---|
| 96 | }
|
|---|
| 97 | .prutter-conv-headline h1 {
|
|---|
| 98 | font-family: var(--font-display, serif);
|
|---|
| 99 | font-size: 1.5rem; margin: 0;
|
|---|
| 100 | }
|
|---|
| 101 | .prutter-conv-sub { margin: 0; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
|
|---|
| 102 | .prutter-conv-sub a { color: var(--accent); }
|
|---|
| 103 |
|
|---|
| 104 | .prutter-conv-avatar--lg { width: 56px; height: 56px; }
|
|---|
| 105 | .prutter-conv-avatar--lg span { font-size: 1.2rem; }
|
|---|
| 106 |
|
|---|
| 107 | .prutter-thread {
|
|---|
| 108 | list-style: none; padding: 0; margin: 0;
|
|---|
| 109 | display: flex; flex-direction: column; gap: 0.5rem;
|
|---|
| 110 | background: var(--paper-2);
|
|---|
| 111 | border: 1px solid var(--rule);
|
|---|
| 112 | border-radius: 8px;
|
|---|
| 113 | padding: 1rem;
|
|---|
| 114 | height: 60vh;
|
|---|
| 115 | overflow-y: auto;
|
|---|
| 116 | }
|
|---|
| 117 |
|
|---|
| 118 | .prutter-msg { display: flex; flex-direction: column; max-width: 80%; }
|
|---|
| 119 | .prutter-msg-bubble {
|
|---|
| 120 | padding: 0.55rem 0.85rem;
|
|---|
| 121 | border-radius: 14px;
|
|---|
| 122 | white-space: pre-wrap;
|
|---|
| 123 | word-wrap: break-word;
|
|---|
| 124 | line-height: 1.4;
|
|---|
| 125 | font-size: 0.95rem;
|
|---|
| 126 | }
|
|---|
| 127 | .prutter-msg-time {
|
|---|
| 128 | font-size: 0.7rem;
|
|---|
| 129 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 130 | margin-top: 0.15rem;
|
|---|
| 131 | }
|
|---|
| 132 |
|
|---|
| 133 | .prutter-msg--theirs { align-self: flex-start; }
|
|---|
| 134 | .prutter-msg--theirs .prutter-msg-bubble {
|
|---|
| 135 | background: var(--paper);
|
|---|
| 136 | border: 1px solid var(--rule);
|
|---|
| 137 | border-bottom-left-radius: 4px;
|
|---|
| 138 | }
|
|---|
| 139 | .prutter-msg--theirs .prutter-msg-time { padding-left: 0.5rem; }
|
|---|
| 140 |
|
|---|
| 141 | .prutter-msg--mine { align-self: flex-end; align-items: flex-end; }
|
|---|
| 142 | .prutter-msg--mine .prutter-msg-bubble {
|
|---|
| 143 | background: var(--accent);
|
|---|
| 144 | color: white;
|
|---|
| 145 | border-bottom-right-radius: 4px;
|
|---|
| 146 | }
|
|---|
| 147 | .prutter-msg--mine .prutter-msg-time { padding-right: 0.5rem; }
|
|---|
| 148 |
|
|---|
| 149 | .prutter-composer {
|
|---|
| 150 | display: flex; gap: 0.5rem; align-items: flex-end;
|
|---|
| 151 | padding-bottom: env(safe-area-inset-bottom, 0);
|
|---|
| 152 | }
|
|---|
| 153 | .prutter-composer textarea {
|
|---|
| 154 | flex: 1;
|
|---|
| 155 | padding: 0.6rem 0.85rem;
|
|---|
| 156 | border: 1px solid var(--rule);
|
|---|
| 157 | border-radius: 8px;
|
|---|
| 158 | background: var(--paper);
|
|---|
| 159 | color: var(--ink);
|
|---|
| 160 | font-family: inherit;
|
|---|
| 161 | font-size: 0.95rem;
|
|---|
| 162 | resize: vertical;
|
|---|
| 163 | min-height: 44px;
|
|---|
| 164 | }
|
|---|
| 165 | </style>
|
|---|