openvibers/openvibe.chat Commits
openvibe.party: Your parties, back in one tap from the home (#395)
Changes
@@@ -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); }@@@ -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,};@@@ -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>@@@ -271,6 +271,14 @@ t('the home offers OpenVibe.Video\'s latest, and a pasted watch page plays its fassert.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);
@@ -15,8 +15,8 @@
@@ -15,8 +15,8 @@
.pty-hint { margin: 0; font-size: 13px; color: var(--oc-muted); }
.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-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)); }
.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; } }
@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; }
.pty-card a { display: grid; gap: 6px; color: var(--oc-text); text-decoration: none; min-width: 0; }
@@ -46,3 +46,10 @@
@@ -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-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 { 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; }
.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); }
@@ -229,6 +229,23 @@ function keyMatches(party, key) {
@@ -229,6 +229,23 @@ function keyMatches(party, key) {
return a.length === b.length && crypto.timingSafeEqual(a, b);
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. */
/** 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) {
async function mayView(row, party, user, key) {
const access = await rooms.access(row, user || null);
const access = await rooms.access(row, user || null);
@@ -280,5 +297,5 @@ async function end(row, party, user) {
@@ -280,5 +297,5 @@ async function end(row, party, user) {
module.exports = {
module.exports = {
AGENT_STREAM_RE, MAX_TITLE, PartyError,
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,
};
};
@@ -139,6 +139,15 @@ function createPartyRoutes({ config }) {
@@ -139,6 +139,15 @@ function createPartyRoutes({ config }) {
const signedIn = actor.kind === 'user';
const signedIn = actor.kind === 'user';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const picks = await latestWatchable();
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 startHref = (x) => `/new?media=${encodeURIComponent(x.page_url)}&title=${encodeURIComponent(x.title)}`;
const start = signedIn
const start = signedIn
? `<form class="pty-paste" method="get" action="/new">
? `<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
@@ -166,6 +175,7 @@ ${x.channel ? `<span class="pty-by">@${esc(String(x.channel).slice(0, 40))}</spa
${error}
${error}
${start}
${start}
</header>
</header>
+${yours}
${grid}
${grid}
<section class="pty-how" aria-labelledby="pty-how-h">
<section class="pty-how" aria-labelledby="pty-how-h">
<h2 id="pty-how-h">How it works</h2>
<h2 id="pty-how-h">How it works</h2>
@@ -271,6 +271,14 @@ t('the home offers OpenVibe.Video\'s latest, and a pasted watch page plays its f
@@ -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.strictEqual(data.party.source_url, PAGE, 'and keeps the page it came from');
assert.ok(slug);
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.
// A private (or unknown) page is not made into a party.
const refused = await raw('GET', `/new?media=${encodeURIComponent(PRIVATE)}`, { cookie: cookieOf(ann) });
const refused = await raw('GET', `/new?media=${encodeURIComponent(PRIVATE)}`, { cookie: cookieOf(ann) });
assert.strictEqual(refused.status, 303);
assert.strictEqual(refused.status, 303);