Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/desktop/src/main/ipc/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,5 +295,32 @@ export function registerSessionHandlers(): void {
}
);

// Keep this viewer's plan seat: a row that stops pinging frees its seat after
// 3 minutes, so a closed app never holds a Free room's second seat.
ipcMain.handle(
'session:presence',
async (
_event,
args: { sessionId: string }
): Promise<{ success: true; seat: string } | { success: false; error: string }> => {
try {
const headers = await getAuthHeaders();
if (!headers) return { success: false, error: 'Not authenticated' };
const response = await apiFetch(`${API_BASE_URL}/api/sessions/${args.sessionId}/presence`, {
method: 'POST',
headers,
body: JSON.stringify({}),
});
const data = (await response.json()) as ApiResponse<{ seat: string }>;
if (!response.ok || !data.data) {
return { success: false, error: data.error ?? 'Presence ping failed' };
}
return { success: true, seat: data.data.seat };
} catch (err) {
return { success: false, error: formatNetworkError(err) };
}
}
);

console.log('[Session] Session IPC handlers registered');
}
6 changes: 6 additions & 0 deletions apps/desktop/src/preload/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,12 @@ export interface IPCChannels {
return: { success: true; participant: SessionParticipant } | { success: false; error: string };
};

// "Still here": keeps this viewer's plan seat (see /api/sessions/<id>/presence)
'session:presence': {
args: { sessionId: string };
return: { success: true; seat: string } | { success: false; error: string };
};

// Scheduled meeting channels
'meetings:list': {
args: undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { SessionParticipant } from '@pairux/shared-types';
import { PlanCapBanner } from './PlanCapBanner';

const HOST = 'host-user';

function row(id: string, userId: string | null, over: Partial<SessionParticipant> = {}) {
return {
id,
session_id: 's1',
user_id: userId,
display_name: id,
role: userId === HOST ? 'host' : 'viewer',
control_state: 'view-only',
is_backup_host: false,
connection_status: 'connected',
joined_at: new Date().toISOString(),
last_seen_at: new Date().toISOString(),
left_at: null,
kind: 'human',
...over,
} as SessionParticipant;
}

const host = row('host', HOST);

describe('PlanCapBanner', () => {
it('stays hidden while the Free host is alone', () => {
render(<PlanCapBanner cap={2} hostUserId={HOST} mode="p2p" participants={[host]} />);
expect(screen.queryByTestId('plan-cap-banner')).toBeNull();
});

it('fires at 2 participants on Free, counting a P2P guest', () => {
render(
<PlanCapBanner cap={2} hostUserId={HOST} mode="p2p" participants={[host, row('g', null)]} />
);
expect(screen.getByTestId('plan-cap-banner').textContent).toContain(
'Your Free plan allows 2 participants, you included'
);
expect(screen.getByText('Upgrade to Plus: $1/mo')).toBeTruthy();
});

it('treats SFU guests as listeners: full at 20 on Free', () => {
const guests = Array.from({ length: 20 }, (_, i) => row(`g${String(i)}`, null));
render(
<PlanCapBanner
cap={2}
listenerCap={20}
hostUserId={HOST}
mode="sfu"
participants={[host, ...guests]}
/>
);
expect(screen.getByTestId('plan-cap-banner').textContent).toContain(
'All 20 listener seats on your Free plan are taken'
);
});

it('does not count a guest who went quiet', () => {
const quiet = row('g', null, { last_seen_at: new Date(Date.now() - 600_000).toISOString() });
render(<PlanCapBanner cap={2} hostUserId={HOST} mode="p2p" participants={[host, quiet]} />);
expect(screen.queryByTestId('plan-cap-banner')).toBeNull();
});
});
69 changes: 53 additions & 16 deletions apps/desktop/src/renderer/components/billing/PlanCapBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,82 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Users, X } from 'lucide-react';
import type { SessionParticipant } from '@pairux/shared-types';
import { LISTENER_CAP, PLAN_PRICE_USD, nextPlan, planForCap } from '@pairux/shared-types';
import {
LISTENER_CAP,
PARTICIPANT_CAP,
PLAN_PRICE_USD,
maxListeners,
nextPlan,
planForCap,
seatUsage,
} from '@pairux/shared-types';
import { Button } from '@/components/ui/button';
import { getElectronAPI, isElectron } from '@/lib/ipc';

const PLAN_NAME = { free: 'Free', plus: 'Plus', pro: 'Pro', team: 'Team' } as const;

/** Re-count every 30 s so rows that went quiet stop holding a seat. */
const RECOUNT_MS = 30_000;

interface PlanCapBannerProps {
/** settings.maxParticipants: the owner's plan cap, baked in at creation. */
/** settings.maxParticipants: the owner's participant cap, the host included. */
cap: number | undefined;
/** The room's participant rows (guests have no user_id and never count). */
/** settings.maxListeners: the owner's listener cap. */
listenerCap?: number | undefined;
/** The room owner: their seat always counts, never by row. */
hostUserId: string | null | undefined;
/** 'p2p' or 'sfu': an anonymous guest is a participant in P2P, a listener in SFU. */
mode: string | null | undefined;
participants: SessionParticipant[];
}

/**
* Host-only upgrade prompt when every signed-in seat in the room is taken: the
* next joiner will be turned away. Names the limit, the plan that lifts it and
* its price; the button opens /upgrade/<plan>, which starts the CoinPay
* checkout straight away. Mirrors the web HostCapPrompt.
* Host-only upgrade prompt when every seat of a kind is taken, so the next
* joiner of that kind will be turned away: participants (Free: you + 1, the
* signed-in joiners and P2P guests still here) or listeners (anonymous SFU
* guests; Free: 20). Names the limit, the plan that lifts it and its price; the
* button opens /upgrade/<plan>, which starts the CoinPay checkout straight
* away. Mirrors the web HostCapPrompt.
*/
export function PlanCapBanner({ cap, participants }: PlanCapBannerProps) {
export function PlanCapBanner({
cap,
listenerCap,
hostUserId,
mode,
participants,
}: PlanCapBannerProps) {
const [dismissed, setDismissed] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => {
setNow(Date.now());
}, RECOUNT_MS);
return () => {
clearInterval(timer);
};
}, []);

if (!cap || dismissed) return null;

const current = planForCap(cap);
const upgrade = nextPlan(current);
if (!upgrade) return null;

const occupied = participants.filter((p) => p.user_id && !p.left_at).length;
if (occupied < cap) return null;
const usage = seatUsage(participants, { hostUserId, mode }, now);
const listeners = listenerCap ?? maxListeners(current);
const participantsFull = usage.participants >= cap;
const listenersFull = usage.listeners >= listeners;
if (!participantsFull && !listenersFull) return null;

const openUpgrade = () => {
if (!isElectron()) return;
void getElectronAPI().invoke('auth:openExternal', `/upgrade/${upgrade}`);
};

const detail = participantsFull
? `Your ${PLAN_NAME[current]} plan allows ${String(cap)} participants, you included, and every seat is taken, so the next person to join will be turned away. ${PLAN_NAME[upgrade]} raises it to ${String(PARTICIPANT_CAP[upgrade])} participants for $${String(PLAN_PRICE_USD[upgrade])}/month.`
: `All ${String(listeners)} listener seats on your ${PLAN_NAME[current]} plan are taken, so the next guest will be turned away. ${PLAN_NAME[upgrade]} raises it to ${String(LISTENER_CAP[upgrade])} listeners for $${String(PLAN_PRICE_USD[upgrade])}/month.`;

return (
<div
role="alert"
Expand All @@ -46,11 +87,7 @@ export function PlanCapBanner({ cap, participants }: PlanCapBannerProps) {
<Users className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div>
<p className="text-sm font-medium">Your room is full</p>
<p className="text-xs text-muted-foreground">
All {cap} seats on your {PLAN_NAME[current]} plan are taken, so the next person to join
will be turned away. {PLAN_NAME[upgrade]} raises it to {LISTENER_CAP[upgrade]} listeners
for ${PLAN_PRICE_USD[upgrade]}/month.
</p>
<p className="text-xs text-muted-foreground">{detail}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1612,7 +1612,13 @@ export function CapturePreview({

{/* Plan cap reached: upgrade prompt for the room's owner (the payer) */}
{session && (!currentUserId || currentUserId === session.host_user_id) && (
<PlanCapBanner cap={session.settings.maxParticipants} participants={participants} />
<PlanCapBanner
cap={session.settings.maxParticipants}
listenerCap={session.settings.maxListeners}
hostUserId={session.host_user_id}
mode={session.mode}
participants={participants}
/>
)}

{/* Session info bar */}
Expand Down
18 changes: 13 additions & 5 deletions apps/desktop/src/renderer/routes/viewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -503,13 +503,14 @@ describe('ViewerPage', () => {
});

it('refreshes participant list immediately on presence change', async () => {
mockInvoke
.mockResolvedValueOnce({
// session:get answers in order; the 30 s seat ping (session:presence) has its own answer.
const sessionGets = [
{
success: true,
session: makeSession(),
participants: makeParticipants(),
})
.mockResolvedValueOnce({
},
{
success: true,
session: makeSession(),
participants: [
Expand All @@ -528,7 +529,14 @@ describe('ViewerPage', () => {
left_at: null,
} satisfies SessionParticipant,
],
});
},
];
let lastGet = sessionGets[0];
mockInvoke.mockImplementation((channel: string) => {
if (channel === 'session:presence') return Promise.resolve({ success: true, seat: 'ok' });
lastGet = sessionGets.shift() ?? lastGet;
return Promise.resolve(lastGet);
});

renderWithRouter();

Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/routes/viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,25 @@ export function ViewerPage() {

void loadSession();

// Hold this viewer's plan seat while the call is open (every 30 s).
const holdSeat = () => {
void getElectronAPI()
.invoke('session:presence', { sessionId })
.catch(() => {
// Best effort: the next tick tries again.
});
};
holdSeat();
const seatInterval = setInterval(holdSeat, 30_000);

// Poll for participant updates every 5 seconds
const interval = setInterval(() => {
void loadSession();
}, 5000);

return () => {
clearInterval(interval);
clearInterval(seatInterval);
};
}, [sessionId, navigate]);

Expand Down
20 changes: 9 additions & 11 deletions apps/web/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,6 @@
"yourPlan": "خطتك",
"freePlan": "الخطة المجانية",
"current": "الحالية",
"freePlanSummary": "مشاركان، P2P فقط",
"upgradePlan": "ترقية الخطة",
"comingSoon": "قريبًا",
"comingSoonButton": "قريبًا",
Expand Down Expand Up @@ -1183,8 +1182,6 @@
"subtitle": "ابدأ جلسة مشاركة شاشة للعرض فقط من متصفحك",
"sessionSettings": "إعدادات الجلسة",
"defaults": "الإعدادات الافتراضية",
"maxViewers": "الحد الأقصى للمشاهدين",
"viewers": "{count, plural, zero {# مشاهد} one {مشاهد واحد} two {مشاهدان} few {# مشاهدين} many {# مشاهدًا} other {# مشاهد}}",
"remoteControl": "التحكم عن بُعد",
"enableGuestControl": "تمكين تحكم الضيوف",
"notAvailableWeb": "غير متاح في المشاركة عبر الويب",
Expand Down Expand Up @@ -1339,7 +1336,6 @@
"cta": "ابدأ الآن",
"features": {
"participants": "مشاركان + حتى {count} مستمع",
"publicViewers": "مشاهدون مجانيون بلا حدود في البثوث المباشرة العامة",
"p2p": "اتصالات P2P",
"screenSharing": "مشاركة الشاشة",
"remoteControl": "التحكم عن بُعد",
Expand All @@ -1352,7 +1348,7 @@
"description": "استضف جمهورًا: تعليم، واجتماعات يومية، ومشاهدة جماعية",
"cta": "الترقية إلى Plus",
"features": {
"listeners": "حتى {count} مستمع لكل غرفة",
"listeners": "حتى {count} مشارك و{count} مستمع لكل غرفة",
"sfu": "خوادم ترحيل SFU",
"liveDirectory": "غرفة عامة في دليل /live",
"screenVoice": "مشاركة الشاشة + الصوت",
Expand All @@ -1365,7 +1361,7 @@
"description": "للمحترفين والفرق المتنامية",
"cta": "الترقية إلى Pro",
"features": {
"listeners": "حتى {count} مستمع لكل غرفة",
"listeners": "حتى {count} مشارك و{count} مستمع لكل غرفة",
"sfu": "خوادم ترحيل SFU",
"streaming": "جميع منصات البث (YouTube وTwitch و…)",
"hd": "مشاركة الشاشة بدقة عالية (1080p)",
Expand All @@ -1379,7 +1375,7 @@
"description": "للفرق والمؤسسات",
"cta": "الترقية إلى Team",
"features": {
"listeners": "حتى {count} مستمع لكل غرفة",
"listeners": "حتى {count} مشارك و{count} مستمع لكل غرفة",
"sfu": "خوادم SFU مخصصة",
"streaming": "جميع منصات البث",
"uhd": "مشاركة الشاشة بدقة 4K",
Expand Down Expand Up @@ -1461,13 +1457,13 @@
"title": "الأسئلة الشائعة",
"capacity": {
"question": "كم شخصًا يمكنه مشاهدة غرفتي؟",
"answer": "يتدرّج عدد المستمعين المتزامنين لكل غرفة حسب خطتك: Free {free}، وPlus {plus}، وPro {pro}، وTeam {team}. وفوق ذلك، يمكن لعدد غير محدود من الضيوف مشاهدة البثوث المباشرة العامة مجانًا دون حساب.",
"answerLong": "يتدرّج عدد المستمعين المتزامنين لكل غرفة حسب خطتك: Free {free}، وPlus {plus}، وPro {pro}، وTeam {team}. وفوق ذلك، يمكن لعدد غير محدود من الضيوف مشاهدة البثوث المباشرة العامة مجانًا دون حساب."
"answer": "تتسع الغرفة المجانية لمشاركَين (أنت وشخص آخر يمكنه التحدث أو تولي التحكم) وحتى {free} مستمع يشاهدون ويستمعون. ترفع Plus الحدّين إلى {plus}، وPro إلى {pro}، وTeam إلى {team}.",
"answerLong": "تتسع الغرفة المجانية لمشاركَين (أنت وشخص آخر يمكنه التحدث أو تولي التحكم) وحتى {free} مستمع يشاهدون ويستمعون. ترفع Plus الحدّين إلى {plus}، وPro إلى {pro}، وTeam إلى {team}."
},
"free": {
"question": "هل الباقة المجانية مجانية فعلًا؟",
"answer": "نعم! مشاركان + حتى {freeCap} مستمع مجانًا تمامًا وإلى الأبد، ويمكن لعدد غير محدود من الضيوف مشاهدة البثوث المباشرة العامة مجانًا دون حساب. لا حدود زمنية، ولا حاجة إلى بطاقة ائتمان.",
"answerLong": "نعم! مشاركان + حتى {freeCap} مستمع مجانًا تمامًا وإلى الأبد، ويمكن لعدد غير محدود من الضيوف مشاهدة البثوث المباشرة العامة مجانًا دون حساب. لا حدود زمنية، ولا حاجة إلى بطاقة ائتمان. رائع للبرمجة الثنائية والعروض الصغيرة."
"answer": "نعم! مشاركان + حتى {freeCap} مستمع مجانًا تمامًا وإلى الأبد. لا حدود زمنية، ولا حاجة إلى بطاقة ائتمان.",
"answerLong": "نعم! مشاركان + حتى {freeCap} مستمع مجانًا تمامًا وإلى الأبد. لا حدود زمنية، ولا حاجة إلى بطاقة ائتمان. رائع للبرمجة الثنائية والعروض الصغيرة."
},
"selfHost": {
"question": "هل يمكنني الاستضافة الذاتية لتجنّب التكاليف؟",
Expand Down Expand Up @@ -2245,6 +2241,8 @@
"roomFullBody": "جميع المقاعد ({cap}) في خطة {current} مشغولة.",
"turnedAway": "{count, plural, zero {لم يُرفض أحد.} one {رُفض شخص واحد.} two {رُفض شخصان.} few {رُفض # أشخاص.} many {رُفض # شخصًا.} other {رُفض # شخص.}}",
"roomFullLift": "ترفع {plan} الحد إلى {seats} مستمع مقابل {price} شهريًا.",
"participantsFullBody": "تسمح خطة {current} بـ {cap} مشاركين بمن فيهم أنت، وكل المقاعد مشغولة.",
"participantsFullLift": "ترفع {plan} الحد إلى {seats} مشارك مقابل {price} شهريًا.",
"proFeatureTitle": "تحليل المكالمات بالذكاء الاصطناعي ميزة Pro",
"proFeatureBody": "تضيف {plan} تحليل المكالمات بالذكاء الاصطناعي ودقة 1080p وحتى {seats} مستمع مقابل {price} شهريًا.",
"cta": "الترقية إلى {plan}: {price} شهريًا",
Expand Down
Loading
Loading