openvibers/openvibe.chat Commits

openvibe.party: Your parties, back in one tap from the home (#395)

OP OpenVibers committed 6 days ago · a1a1a14c1a · 4 files, +45 −3

Changes

ddiff --git a/public/web/party-home.css b/public/web/party-home.css
iindex 26a557e..3b7eb9d 100644
---- a/public/web/party-home.css
++++ b/public/web/party-home.css
@@@ -15,8 +15,8 @@
.pty-hint { margin: 0; font-size: 13px; color: var(--oc-muted); }
.pty-cta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 8px 0 0; }
--.pty-pick, .pty-how { margin-top: 40px; }
--.pty-pick h2, .pty-how h2 { margin: 0 0 14px; font-size: 1.15rem; letter-spacing: -.01em; }
++.pty-mine, .pty-pick, .pty-how { margin-top: 40px; }
++.pty-mine h2, .pty-pick h2, .pty-how h2 { margin: 0 0 14px; font-size: 1.15rem; letter-spacing: -.01em; }
.pty-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 18px 14px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (min-width: 720px) { .pty-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 22px 18px; } }
.pty-card a { display: grid; gap: 6px; color: var(--oc-text); text-decoration: none; min-width: 0; }
@@@ -46,3 +46,10 @@
.pty-confirm-thumb { width: 100%; max-width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 14px; border: 1px solid var(--oc-line); background: var(--oc-panel-2); }
.pty-confirm form { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 4px; }
.pty-confirm form .oc-button { min-height: 48px; padding: 0 22px; border-radius: 12px; }
++
++/* Your parties: the ones you are in, back in one tap (a member needs no link). */
++.pty-mine-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); }
++.pty-mine-list a { display: grid; gap: 2px; min-height: 56px; padding: 12px 14px; border-radius: 12px; background: var(--oc-panel); border: 1px solid var(--oc-line); color: var(--oc-text); text-decoration: none; min-width: 0; }
++.pty-mine-list a:hover, .pty-mine-list a:focus-visible { border-color: var(--oc-accent); }
++.pty-mine-list strong { font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.pty-mine-list span { font-size: 13px; color: var(--oc-muted); }
ddiff --git a/server/rooms/parties.js b/server/rooms/parties.js
iindex 8b3c01c..46d344e 100644
---- a/server/rooms/parties.js
++++ b/server/rooms/parties.js
@@@ -229,6 +229,23 @@ function keyMatches(party, key) {
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
++/**
++ * The media parties a person is in that have not ended (started in the last two weeks), newest first: the party home's
++ * "Your parties", so a person gets back into a party without its link. Membership is what lets them in (rooms.access).
++ */
++async function mine(user, { limit = 6 } = {}) {
++ if (!user || !user.id) return [];
++ const since = new Date(Date.now() - 14 * 86400_000).toISOString();
++ const rows = await db.all(`SELECT r.slug, r.name, p.host_user_id, p.created_at
++ FROM room_members m JOIN rooms r ON r.id = m.room_id JOIN room_parties p ON p.room_id = r.id
++ WHERE m.user_id = ? AND p.party_kind = 'media' AND p.ended_at IS NULL AND r.archived_at IS NULL AND p.created_at > ?
++ ORDER BY p.created_at DESC LIMIT ?`, [user.id, since, Math.max(1, Math.min(12, Number(limit) || 6))]);
++ return (rows || []).map((x) => ({
++ id: x.slug, title: x.name, hosting: Number(x.host_user_id) === Number(user.id),
++ started_at: new Date(x.created_at instanceof Date ? x.created_at : String(x.created_at)).toISOString(),
++ }));
++}
++
/** Whether this caller may read the party: a member, the host, chat staff, or the holder of the link's secret. */
async function mayView(row, party, user, key) {
const access = await rooms.access(row, user || null);
@@@ -280,5 +297,5 @@ async function end(row, party, user) {
module.exports = {
AGENT_STREAM_RE, MAX_TITLE, PartyError,
-- create, createMedia, createAgentStream, bySlug, partyOf, view, detail, syncOf, mayView, joinWithLink, setSync, count, end, keyMatches,
++ create, createMedia, createAgentStream, bySlug, partyOf, view, detail, syncOf, mayView, joinWithLink, setSync, count, end, keyMatches, mine,
};
ddiff --git a/server/web/party-pages.js b/server/web/party-pages.js
iindex 7c0867d..3d9d5b9 100644
---- a/server/web/party-pages.js
++++ b/server/web/party-pages.js
@@@ -139,6 +139,15 @@ function createPartyRoutes({ config }) {
const signedIn = actor.kind === 'user';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const picks = await latestWatchable();
++ const mine = signedIn ? await parties.mine(actor.user, { limit: 6 }).catch(() => []) : [];
++ const since = (iso) => {
++ const days = Math.floor((Date.now() - Date.parse(iso)) / 86400_000);
++ return days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`;
++ };
++ const yours = mine.length ? `<section class="pty-mine" aria-labelledby="pty-mine-h">
++<h2 id="pty-mine-h">Your parties</h2>
++<ul class="pty-mine-list">${mine.map((x) => `<li><a href="/p/${encodeURIComponent(x.id)}"><strong>${esc(x.title)}</strong><span>${x.hosting ? 'You started it' : 'You joined'} · ${esc(since(x.started_at))}</span></a></li>`).join('')}</ul>
++</section>` : '';
const startHref = (x) => `/new?media=${encodeURIComponent(x.page_url)}&title=${encodeURIComponent(x.title)}`;
const start = signedIn
? `<form class="pty-paste" method="get" action="/new">
@@@ -166,6 +175,7 @@ ${x.channel ? `<span class="pty-by">@${esc(String(x.channel).slice(0, 40))}</spa
${error}
${start}
</header>
++${yours}
${grid}
<section class="pty-how" aria-labelledby="pty-how-h">
<h2 id="pty-how-h">How it works</h2>
ddiff --git a/test/party-media.test.js b/test/party-media.test.js
iindex 2188c86..4c29a6c 100644
---- a/test/party-media.test.js
++++ b/test/party-media.test.js
@@@ -271,6 +271,14 @@ t('the home offers OpenVibe.Video\'s latest, and a pasted watch page plays its f
assert.strictEqual(data.party.source_url, PAGE, 'and keeps the page it came from');
assert.ok(slug);
++ // Your parties: Ann gets back in from the home without the link; Cat, who is not in it, never sees it.
++ const back = await raw('GET', '/', { cookie: cookieOf(ann) });
++ assert.match(back.text, /Your parties/);
++ assert.ok(back.text.includes(`href="/p/${slug}"`), 'the party, by its page, no key in it');
++ assert.match(back.text, /You started it/);
++ const other = await raw('GET', '/', { cookie: cookieOf(cat) });
++ assert.ok(!other.text.includes(`/p/${slug}`), 'someone who is not in it never sees it');
++
// A private (or unknown) page is not made into a party.
const refused = await raw('GET', `/new?media=${encodeURIComponent(PRIVATE)}`, { cookie: cookieOf(ann) });
assert.strictEqual(refused.status, 303);