source: Klonkt/src/views/pages/messages.ejs@ ea138c0

main
Last change on this file since ea138c0 was ea138c0, checked in by roboburr <roboburr@…>, 5 weeks ago

Gesprekken in- en uitklappen door op de tegenpartij te tikken

Barts aanwijzing: de uitsparing met de naam is ook de knop. Dat is waar je van
nature naar wijst als je "dit gesprek" bedoelt, en het scheelt een extra
bedieningselement in een lijst die juist rustiger moest worden.

Een BUTTON en geen span met een klikhandler: dan werkt hij met het toetsenbord en
leest een schermlezer de stand voor (aria-expanded). Zonder JavaScript doet hij
niets, en dan staat het gesprek open -- wat ook de beginstand is. Standaard
uitgeklapt, zoals gevraagd.

Ingeklapt blijven de naam en het AANTAL staan. Dat aantal is juist ingeklapt het
meest waard: het is dan het enige wat zegt hoe groot het gesprek is.

Twee dingen die eromheen moesten:

RAAKVLAK de uitsparing is 18px hoog en dat is op een telefoon niet te raken.

De knop krijgt een minimumhoogte; de uitsparing blijft klein, het
doel wordt groter.

ZOEKEN een treffer in een dichtgeklapt gesprek liet alleen de naam zien: je

zoekt iets, het staat er, en je ziet het niet. Zolang er gezocht
wordt gaat zo'n gesprek open en daarna keert hij terug naar de stand
die de lezer zelf koos.

De stand leeft alleen in de pagina en niet in opslag. Een gesprek dat je gisteren
dichtklapte stilhouden is iets anders dan het opruimen van je scherm van nu.

De nieuwe CSS staat NA .msg-item, om dezelfde reden als de rest van dat blok: bij
gelijke specificiteit wint de laatste regel.

Templates compileren, suite 551/551. De pagina zelf staat achter een login, dus
het gedrag in beeld is aan Bart.

  • Property mode set to 100644
File size: 19.2 KB
Line 
1<div class="msg-wrap">
2 <%- include('../partials/fedi-tabs', { active: 'berichten' }) %>
3 <h1 class="msg-title"><%= t('msg.title') %></h1>
4 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : (success === 'reply_sent' ? t('msg.reply_sent') : success))) %></div><% } %>
5 <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : (error === 'reply_empty' ? t('msg.reply_empty') : ((error === 'reply_failed' || error === 'reply_target') ? t('msg.reply_failed') : error)) %></div><% } %>
6
7 <%# FEP-633c: a pending guardianship offer is a special message: the kid
8 answers here (accept/reject travels the same C2S pipeline as the apps). %>
9 <% if (typeof guardianOffers !== 'undefined' && guardianOffers.length) { %>
10 <% guardianOffers.forEach(function(o){ %>
11 <div class="alert" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;border-left:3px solid #ff6b35;">
12 <span style="font-size:1.4em;">&#128735;</span>
13 <div style="flex:1;min-width:200px;">
14 <strong><%= o['shaer:candidateHandle'] || o['shaer:candidate'] %></strong><br>
15 <span><%= t('msg.guard_offer') %></span>
16 </div>
17 <form method="post" action="<%= (typeof moreBase !== 'undefined' ? moreBase : '') %>/messages/guardianship" style="display:flex;gap:8px;">
18 <input type="hidden" name="offer" value="<%= o.id %>">
19 <button class="btn" type="submit" name="answer" value="accept"><%= t('msg.guard_accept') %></button>
20 <button class="btn" type="submit" name="answer" value="reject" style="opacity:.7;"><%= t('msg.guard_reject') %></button>
21 </form>
22 </div>
23 <% }); %>
24 <% } %>
25
26 <div class="msg-filters" role="tablist" aria-label="<%= t('msg.title') %>">
27 <%# Vier chips, geen zes: Berichten, Gesprekken en Verzonden gingen op in
28 een enkele Gesprekken-view, waarin verzonden en ontvangen in dezelfde
29 draad staan. Activiteit en Moderatie blijven wat ze waren. %>
30 <button type="button" class="msg-chip is-on" data-show="all"><%= t('msg.filter_all') %></button>
31 <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button>
32 <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button>
33 <button type="button" class="msg-chip" data-show="mod"><%= t('msg.filter_mod') %></button>
34 </div>
35 <div class="msg-search">
36 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
37 <input type="search" id="msg-q" placeholder="<%= t('msg.search_ph') %>" autocomplete="off" spellcheck="false" aria-label="<%= t('msg.search_ph') %>">
38 </div>
39
40 <% var _seen = (typeof seenAt !== 'undefined' && seenAt) ? seenAt : 0; %>
41 <% if (!items || !items.length) { %>
42 <p class="msg-empty"><%= t('msg.empty') %></p>
43 <% } else { %>
44 <ul class="msg-list" data-show="all" id="msg-list">
45 <% items.forEach(function(n){ %><%- include('../partials/msg-item', { n: n, seen: _seen }) %><% }); %>
46 </ul>
47 <p class="msg-nomatch" hidden><%= t('msg.no_match') %></p>
48 <%- include('../partials/load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#msg-list', morePath: '/messages' }) %>
49 <% } %>
50
51 <%# Interact bookmarklet — moved here from the old Reacties page. %>
52 <details class="msg-bm">
53 <summary><%= t('fedi.bm_label') %></summary>
54 <p class="msg-bm-help"><%= t('fedi.bm_help') %></p>
55 <a class="fedi-bm-btn" id="fedi-bm-btn" href="#" draggable="true" title="<%= t('fedi.bm_help') %>">
56 <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
57 <%= t('fedi.bm_label') %>
58 </a>
59 </details>
60</div>
61
62<script>
63(function () {
64 if (window.__msgWired) return; window.__msgWired = true;
65
66 // Filtering: kind chip AND free-text search, combined in JS (search over the
67 // sender and the rendered message text). Empty search + "all" = show all.
68 var list = document.querySelector('.msg-list');
69 var noMatch = document.querySelector('.msg-nomatch');
70 var q = document.getElementById('msg-q');
71 var kind = 'all';
72 var items = [];
73 function indexItem(li) {
74 // Een draad indexeren op zijn eerste bubbel zou de rest onvindbaar maken,
75 // dus daar nemen we de hele tekst; losse regels blijven gericht geindexeerd.
76 if (li.classList.contains('msg-thread')) {
77 li._search = ((li.getAttribute('data-who') || '') + ' ' + li.textContent).toLowerCase();
78 return;
79 }
80 // Index once: sender + message body + linked post title + poll text.
81 var body = li.querySelector('.msg-content');
82 var post = li.querySelector('.msg-post');
83 var poll = li.querySelector('.msg-poll');
84 li._search = ((li.getAttribute('data-who') || '') + ' ' +
85 (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' +
86 (poll ? poll.textContent : '')).toLowerCase();
87 }
88 // Re-collect + index; called on load and after each "Load more" append so new
89 // items join the filter/search (and inherit the active chip via apply()).
90 function reindex() {
91 items = list ? Array.prototype.slice.call(list.querySelectorAll(':scope > .msg-item')) : [];
92 items.forEach(function (li) { if (!li._search) indexItem(li); });
93 }
94 reindex();
95 function apply() {
96 if (!list) return;
97 var term = (q && q.value || '').trim().toLowerCase();
98 var shown = 0;
99 items.forEach(function (li) {
100 var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) &&
101 (!term || li._search.indexOf(term) !== -1);
102 li.style.display = ok ? '' : 'none';
103 // Een treffer die in een dichtgeklapt gesprek zit, laat anders alleen de
104 // naam zien: je zoekt iets, het staat er, en je ziet het niet. Zolang er
105 // gezocht wordt gaat zo'n gesprek open; daarna keert hij terug naar de
106 // stand die de lezer zelf koos.
107 if (li.classList.contains('msg-thread')) {
108 li.classList.toggle('is-search-open', !!term && ok);
109 }
110 if (ok) shown++;
111 });
112 if (noMatch) noMatch.hidden = shown !== 0;
113 }
114 // In- en uitklappen door op de tegenpartij te tikken (Barts aanwijzing).
115 // Gedelegeerd, zodat draden die via "meer laden" binnenkomen het vanzelf ook
116 // doen. De stand leeft alleen in deze pagina: standaard uitgeklapt, en na een
117 // herlading weer. Dat is bewust -- een gesprek dat je gisteren dichtklapte
118 // stilhouden is niet hetzelfde als het opruimen van je scherm van nu.
119 document.addEventListener('click', function (e) {
120 var who = e.target.closest('.msg-thread-who'); if (!who) return;
121 var li = who.closest('.msg-thread'); if (!li) return;
122 var dicht = li.classList.toggle('is-collapsed');
123 who.setAttribute('aria-expanded', dicht ? 'false' : 'true');
124 });
125 document.addEventListener('click', function (e) {
126 var chip = e.target.closest('.msg-chip'); if (!chip) return;
127 kind = chip.getAttribute('data-show');
128 document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); });
129 apply();
130 });
131 if (q) q.addEventListener('input', apply);
132 // After a "Load more" append (htmx), index the new rows and re-apply the filter.
133 document.body.addEventListener('htmx:afterSettle', function (e) {
134 if (e.target && e.target.id === 'msg-list') { reindex(); apply(); }
135 });
136
137 var a = document.getElementById('fedi-bm-btn');
138 if (a) {
139 a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
140 a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); });
141 }
142})();
143</script>
144
145<style>
146 .msg-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
147 .msg-title { margin: 0 0 .9rem; }
148 .msg-empty { color: var(--ink-soft, #888); }
149
150 .msg-filters { display: flex; gap: .4rem; margin: 0 0 1.1rem; flex-wrap: wrap; }
151 .msg-chip { border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); background: none;
152 color: var(--ink-soft, #888); font: inherit; font-size: .84rem; font-weight: 600;
153 padding: .3rem .85rem; border-radius: 999px; cursor: pointer; }
154 .msg-chip.is-on { background: var(--accent, #06c); border-color: var(--accent, #06c); color: #fff; }
155
156 .msg-search { display: flex; align-items: center; gap: .5rem; margin: 0 0 1.1rem;
157 padding: .45rem .7rem; border-radius: 10px;
158 border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); }
159 .msg-search:focus-within { border-color: var(--accent, #06c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #06c) 18%, transparent); }
160 .msg-search svg { width: 16px; height: 16px; color: var(--ink-soft, #888); flex-shrink: 0; }
161 .msg-search input { flex: 1; border: none; background: none; color: inherit; font: inherit; outline: none; min-width: 0; }
162 .msg-nomatch { color: var(--ink-soft, #888); text-align: center; padding: 1.2rem 0; }
163
164 .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; }
165
166
167 .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start;
168 background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent);
169 border: 1px solid color-mix(in srgb, var(--ink, #000) 9%, transparent);
170 transition: border-color .15s ease, box-shadow .15s ease; }
171 .msg-item:hover { border-color: color-mix(in srgb, var(--accent, #888) 30%, transparent);
172 box-shadow: 0 2px 12px color-mix(in srgb, var(--ink, #000) 6%, transparent); }
173 .msg-item.is-new { border-color: color-mix(in srgb, var(--accent, #06c) 45%, transparent);
174 background: color-mix(in srgb, var(--accent, #06c) 4%, transparent); }
175 .msg-sent { border-left: 3px solid color-mix(in srgb, var(--accent, #06c) 55%, transparent); }
176
177 /* ── Gesprekken ──────────────────────────────────────────────────────
178 NA .msg-item, en dat is geen smaak: .msg-item zet display:flex, en bij
179 gelijke specificiteit wint de laatste regel. Stond dit ervoor, dan kwam de
180 draadkop NAAST de bubbels te staan in plaats van erboven. */
181 .msg-thread { display: block; }
182 .msg-thread-head { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-bottom: .4rem; }
183 /* De tegenpartij is een labeltje bij het gesprek, geen kop erboven: een
184 kleine verzonken uitsparing, zodat de bubbels eronder de ruimte krijgen.
185 Het aantal zit in dezelfde uitsparing in plaats van ernaast. */
186 /* Ook de knop die het gesprek in- en uitklapt. Een button erft geen font en
187 geen kleur, dus die staan hier expliciet; zonder dat wordt het ineens
188 systeemblauw in 13px. */
189 .msg-thread-who { display: inline-flex; align-items: center; gap: .3rem; min-width: 0;
190 font: inherit; font-size: .78em; line-height: 1.5; font-weight: 500;
191 padding: .05rem .5rem; border-radius: 999px; border: 0; cursor: pointer;
192 color: color-mix(in srgb, var(--ink, #000) 62%, transparent);
193 background: color-mix(in srgb, var(--ink, #000) 5%, transparent);
194 box-shadow: inset 0 1px 2px color-mix(in srgb, var(--ink, #000) 9%, transparent);
195 max-width: 100%; overflow: hidden; white-space: nowrap;
196 /* Ruimer aanraakvlak dan de tekst: een chip van 18px hoog is op een telefoon
197 niet te raken. De uitsparing blijft klein, het doel wordt groter. */
198 min-height: 32px; }
199 .msg-thread-who:hover { background: color-mix(in srgb, var(--ink, #000) 9%, transparent); }
200 .msg-thread-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
201 .msg-thread-chevron { width: 12px; height: 12px; flex: none; opacity: .55;
202 transition: transform .18s ease; }
203 /* Ingeklapt: de bubbels en het antwoordveld verdwijnen, de uitsparing met de
204 naam en het AANTAL blijft staan -- dat aantal is juist ingeklapt het meest
205 waard. */
206 .msg-thread.is-collapsed .msg-thread-msgs,
207 .msg-thread.is-collapsed .msg-thread-foot { display: none; }
208 /* Tijdens het zoeken wint de treffer van de dichtgeklapte stand. */
209 .msg-thread.is-collapsed.is-search-open .msg-thread-msgs { display: flex; }
210 .msg-thread.is-collapsed.is-search-open .msg-thread-foot { display: flex; }
211 .msg-thread.is-collapsed .msg-thread-chevron { transform: rotate(-90deg); }
212 .msg-thread.is-collapsed .msg-thread-head { margin-bottom: 0; }
213 @media (prefers-reduced-motion: reduce) { .msg-thread-chevron { transition: none; } }
214 .msg-thread-count { font-variant-numeric: tabular-nums; opacity: .75; flex: 0 0 auto;
215 padding-inline-start: .3rem; border-inline-start: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); }
216 .msg-thread-post { display: inline-flex; align-items: center; gap: .3rem; font-size: .85em;
217 margin-inline-start: auto; min-width: 0; max-width: 100%;
218 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
219 .msg-thread-post svg { width: 13px; height: 13px; flex: none; }
220 .msg-thread-msgs { list-style: none; margin: 0; padding: 0 0 0 .7rem; display: flex; flex-direction: column; gap: .5rem;
221 border-inline-start: 2px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
222 .msg-sub { display: flex; gap: .6rem; align-items: flex-start; min-width: 0; }
223 .msg-sub > .msg-body { min-width: 0; }
224 /* Jouw eigen bijdrage onderscheidt zich met een tint en de accentlijn die
225 .msg-sent al had -- niet met row-reverse: dat duwde de avatar buiten de
226 kaart zodra de regel smaller werd dan zijn inhoud. */
227 .msg-sub.msg-sent { border-radius: 10px; padding: .3rem .45rem;
228 background: color-mix(in srgb, var(--accent, #06c) 5%, transparent); }
229 /* Een lange naam of tijd mag de bubbel niet uit de kaart duwen. */
230 .msg-sub .msg-line { flex-wrap: wrap; }
231
232 /* De voet van een gesprek: antwoorden en zwaaien naast elkaar, ingeklapt.
233 Een open editor onder elk gesprek maakt de lijst weer onleesbaar -- precies
234 wat deze weergave moest oplossen. */
235 .msg-thread-foot { display: flex; align-items: flex-start; gap: .5rem; margin-top: .5rem; flex-wrap: wrap; }
236 .msg-reply { flex: 1 1 12rem; min-width: 0; }
237 .msg-reply > summary { cursor: pointer; font-size: .82rem; color: var(--ink-soft, #888); }
238 .msg-reply[open] > summary { margin-bottom: .4rem; }
239 .msg-reply-form { min-width: 0; }
240 .msg-wave button { line-height: 1.2; }
241
242 @media (max-width: 560px) {
243 /* Op een telefoon is 40px avatar per bubbel puur verlies: de naam staat er
244 al naast, en de inhoud heeft de breedte harder nodig. */
245 .msg-sub > .msg-av { display: none; }
246 .msg-thread-msgs { padding-inline-start: .55rem; }
247 .msg-thread-post { margin-inline-start: 0; }
248 .msg-item { padding: .7rem .6rem; gap: .6rem; }
249 }
250
251 .msg-av { position: relative; flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%;
252 display: inline-flex; align-items: center; justify-content: center; font-weight: 700;
253 background: color-mix(in srgb, var(--accent, #888) 16%, transparent); color: var(--accent, #06c); overflow: visible; }
254 .msg-av img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; background: #fff; }
255 .msg-av-sent svg { width: 17px; height: 17px; }
256 .msg-dot { position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%;
257 display: inline-flex; align-items: center; justify-content: center;
258 border: 2px solid var(--paper, #fff); background: var(--accent, #06c); color: #fff; }
259 .msg-dot svg { width: 10px; height: 10px; }
260 .msg-dot-like { background: #e8b04b; } .msg-dot-boost { background: #2fa85a; }
261 .msg-dot-report { background: #c0392b; } .msg-dot-mention { background: #8e6cf0; }
262
263 .msg-body { flex: 1; min-width: 0; }
264 .msg-line { display: flex; align-items: baseline; gap: .4rem; }
265 .msg-who { font-weight: 700; color: var(--ink, inherit); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
266 a.msg-who:hover { text-decoration: underline; }
267 .msg-handle { color: var(--ink-soft, #888); font-size: .82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
268 .msg-new { width: 8px; height: 8px; border-radius: 50%; background: var(--accent, #06c); flex: 0 0 auto; align-self: center; }
269 .msg-time { color: var(--ink-soft, #999); font-size: .76rem; margin-left: auto; flex: 0 0 auto; white-space: nowrap; }
270 .msg-what { color: var(--ink-soft, #aaa); margin-top: .12rem; line-height: 1.4; }
271 .msg-what .msg-post { color: var(--accent, #06c); font-weight: 600; margin-left: .25rem; }
272 .msg-priv { display: inline-block; margin-left: .3rem; padding: .05rem .5rem; border-radius: 999px; font-size: .72rem; font-weight: 700;
273 background: color-mix(in srgb, #8e6cf0 14%, transparent); color: #8e6cf0; }
274 .msg-content { margin: .45rem 0 0; padding: .55rem .75rem; border-radius: 10px; line-height: 1.5;
275 background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, inherit); overflow-wrap: anywhere; }
276 .msg-content p { margin: .2rem 0; } .msg-content p:first-child { margin-top: 0; } .msg-content p:last-child { margin-bottom: 0; }
277
278 .msg-poll { margin: .5rem 0 0; display: flex; flex-direction: column; gap: .4rem; }
279 .msg-poll-opt { display: flex; flex-direction: column; gap: .2rem; }
280 .msg-poll-top { display: flex; justify-content: space-between; gap: .5rem; font-size: .85rem; }
281 .msg-poll-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
282 .msg-poll-pct { font-variant-numeric: tabular-nums; color: var(--ink-soft, #888); flex-shrink: 0; }
283 .msg-poll-bar { height: 7px; border-radius: 999px; overflow: hidden; background: color-mix(in srgb, var(--ink, #000) 10%, transparent); }
284 .msg-poll-bar span { display: block; height: 100%; border-radius: 999px; background: var(--accent, #06c); }
285 .msg-poll-total { font-size: .78rem; color: var(--ink-soft, #888); margin-top: .1rem; }
286
287 .msg-actions { display: flex; gap: .7rem; align-items: center; margin-top: .45rem; }
288 .msg-edit summary { cursor: pointer; font-size: .8rem; color: var(--ink-soft, #888); font-weight: 600; }
289 .msg-edit-form { margin-top: .4rem; display: flex; flex-direction: column; gap: .4rem; }
290 .msg-edit-form textarea { width: 100%; font: inherit; padding: .5rem .65rem; border-radius: 8px;
291 border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); background: transparent; color: var(--ink, inherit); }
292 .msg-del { background: none; border: none; padding: 0; font: inherit; font-size: .8rem; font-weight: 600;
293 color: var(--ink-soft, #888); cursor: pointer; }
294 .msg-del:hover { color: #c0392b; }
295
296 .msg-bm { margin: 1.4rem 0 0; border-top: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); padding-top: .8rem; }
297 .msg-bm summary { cursor: pointer; color: var(--ink-soft, #888); font-weight: 600; font-size: .88rem; }
298 .msg-bm-help { color: var(--ink-soft, #888); font-size: .82rem; line-height: 1.5; margin: .5rem 0 .6rem; }
299</style>
Note: See TracBrowser for help on using the repository browser.