diff --git a/README.md b/README.md index 7ca4d1e..15a98b3 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,10 @@ sudo DEPLOY_NGINX=1 ./scripts/deploy-vps.sh For current `config.js`, branding, and asset changes, an nginx reload is enough. JWT/auth and Prosody plugin changes require restarting Prosody and Jicofo, and usually Jitsi Videobridge. The `politictalk_roles` Prosody module should be configured with the PgApi -inactive-room callback from `templates/prosody-token-auth.cfg.lua.example`. -That callback clears the event `meetingCode` when the last host leaves, so -participants remain on the platform waiting screen instead of reaching a -blocked Jitsi room. +inactive-room and occupancy callbacks from +`templates/prosody-token-auth.cfg.lua.example`. The inactive callback clears the +event `meetingCode` when the last host leaves, and the occupancy callback keeps +the platform room cards updated with the current participant count. ## Token Auth Rollout diff --git a/assets/public/politictalk/branding.json b/assets/public/politictalk/branding.json index 708dfac..d27cb71 100644 --- a/assets/public/politictalk/branding.json +++ b/assets/public/politictalk/branding.json @@ -1,6 +1,7 @@ { "inviteDomain": "politictalk.parallelglobe.io", "backgroundColor": "#101820", + "pollCreationRequiresPermission": true, "logoClickUrl": "https://parallelglobe.io/politictalk", "logoImageUrl": "/images/politictalk/pgLogo.svg", "premeetingBackground": "url(/images/politictalk/pg_bg.png)", diff --git a/local/docker-compose.override.yml b/local/docker-compose.override.yml index aae75e8..0850a8a 100644 --- a/local/docker-compose.override.yml +++ b/local/docker-compose.override.yml @@ -7,4 +7,5 @@ services: prosody: environment: - POLITICTALK_ROOM_INACTIVE_CALLBACK_URL=${POLITICTALK_ROOM_INACTIVE_CALLBACK_URL:-http://host.docker.internal:9000/events/politictalk/jitsi/room-inactive} + - POLITICTALK_ROOM_OCCUPANCY_CALLBACK_URL=${POLITICTALK_ROOM_OCCUPANCY_CALLBACK_URL:-http://host.docker.internal:9000/events/politictalk/jitsi/room-occupancy} - POLITICTALK_ROOM_INACTIVE_CALLBACK_SECRET=${POLITICTALK_ROOM_INACTIVE_CALLBACK_SECRET:-politictalk-local-lifecycle-secret} diff --git a/prosody-plugins/mod_politictalk_roles.lua b/prosody-plugins/mod_politictalk_roles.lua index f1d40fe..7904268 100644 --- a/prosody-plugins/mod_politictalk_roles.lua +++ b/prosody-plugins/mod_politictalk_roles.lua @@ -10,6 +10,10 @@ local ROOM_INACTIVE_CALLBACK_URL = module:get_option_string( "politictalk_room_inactive_callback_url", os.getenv("POLITICTALK_ROOM_INACTIVE_CALLBACK_URL") ); +local ROOM_OCCUPANCY_CALLBACK_URL = module:get_option_string( + "politictalk_room_occupancy_callback_url", + os.getenv("POLITICTALK_ROOM_OCCUPANCY_CALLBACK_URL") +); local ROOM_INACTIVE_CALLBACK_SECRET = module:get_option_string( "politictalk_room_inactive_callback_secret", os.getenv("POLITICTALK_ROOM_INACTIVE_CALLBACK_SECRET") @@ -26,6 +30,18 @@ local function get_user_id(session) return user and user.id or nil; end +local function get_participant_limit(session) + local user = get_user(session); + local limit = user and (user.participantLimit or user.participant_limit) or nil; + local numeric_limit = tonumber(limit); + + if numeric_limit and numeric_limit > 0 then + return math.floor(numeric_limit); + end + + return nil; +end + local function is_moderator_session(session) local user = get_user(session); local moderator = user and user.moderator; @@ -77,6 +93,16 @@ local function remove_value(values, value) return filtered; end +local function table_count(values) + local count = 0; + + for _ in ipairs(values or {}) do + count = count + 1; + end + + return count; +end + local function ensure_room_data(room) if not room then return false; @@ -86,6 +112,7 @@ local function ensure_room_data(room) room._data.moderators = room._data.moderators or {}; room._data.participants = room._data.participants or {}; room._data.politictalk_host_jids = room._data.politictalk_host_jids or {}; + room._data.politictalk_participant_jids = room._data.politictalk_participant_jids or {}; room._data.politictalk_jid_user_ids = room._data.politictalk_jid_user_ids or {}; -- Enables Jitsi AV moderation automatically: hosts can speak, participants -- must be explicitly allowed by a host before they can unmute. @@ -119,6 +146,7 @@ local function mark_host(room, occupant, user_id) ensure_room_data(room); room._data.politictalk_host_jids = add_unique(room._data.politictalk_host_jids, occupant.bare_jid); + room._data.politictalk_participant_jids = remove_value(room._data.politictalk_participant_jids, occupant.bare_jid); room._data.politictalk_jid_user_ids[occupant.bare_jid] = user_id; end @@ -128,6 +156,7 @@ local function mark_participant(room, occupant, user_id) end ensure_room_data(room); + room._data.politictalk_participant_jids = add_unique(room._data.politictalk_participant_jids, occupant.bare_jid); room._data.politictalk_jid_user_ids[occupant.bare_jid] = user_id; end @@ -141,6 +170,7 @@ local function unmark_occupant(room, occupant) local user_id = room._data.politictalk_jid_user_ids[occupant.bare_jid]; room._data.politictalk_jid_user_ids[occupant.bare_jid] = nil; room._data.politictalk_host_jids = remove_value(room._data.politictalk_host_jids, occupant.bare_jid); + room._data.politictalk_participant_jids = remove_value(room._data.politictalk_participant_jids, occupant.bare_jid); return user_id; end @@ -161,6 +191,36 @@ local function has_active_host(room) return false; end +local function get_active_host_count(room) + if not room then + return 0; + end + + ensure_room_data(room); + + local count = 0; + for _, occupant in room:each_occupant() do + if table_contains(room._data.politictalk_host_jids, occupant.bare_jid) then + count = count + 1; + end + end + + return count; +end + +local function get_occupant_count(room) + if not room then + return 0; + end + + local count = 0; + for _ in room:each_occupant() do + count = count + 1; + end + + return count; +end + local function deny_join(event, reason) if event.origin and event.stanza then event.origin.send(st.error_reply(event.stanza, "cancel", "not-allowed", reason)); @@ -225,6 +285,66 @@ local function notify_room_inactive(room, reason) end); end +local function notify_room_occupancy(room, reason) + if + not ROOM_OCCUPANCY_CALLBACK_URL + or ROOM_OCCUPANCY_CALLBACK_URL == "" + or not ROOM_INACTIVE_CALLBACK_SECRET + or ROOM_INACTIVE_CALLBACK_SECRET == "" + then + module:log("warn", "Skipping PoliticTalk room-occupancy callback because callback URL or secret is missing"); + return; + end + + local meeting_code = get_room_meeting_code(room); + if not meeting_code then + module:log("warn", "Skipping PoliticTalk room-occupancy callback because room meeting code is missing"); + return; + end + + ensure_room_data(room); + + local participant_count = table_count(room._data.politictalk_participant_jids); + local moderator_count = table_count(room._data.moderators); + local occupant_count = get_occupant_count(room); + local host_count = get_active_host_count(room); + + http.request(ROOM_OCCUPANCY_CALLBACK_URL, { + method = "POST"; + headers = { + ["Content-Type"] = "application/json"; + ["X-PoliticTalk-Jitsi-Secret"] = ROOM_INACTIVE_CALLBACK_SECRET; + }; + body = json.encode({ + meetingCode = meeting_code; + reason = reason or "occupancy_changed"; + roomJid = room.jid; + participantCount = participant_count; + moderatorCount = moderator_count; + occupantCount = occupant_count; + hostCount = host_count; + }); + }, function(_, code) + if code and code >= 200 and code < 300 then + module:log( + "info", + "Updated PoliticTalk room occupancy in PgApi: room=%s participants=%s occupants=%s hosts=%s", + tostring(meeting_code), + tostring(participant_count), + tostring(occupant_count), + tostring(host_count) + ); + else + module:log( + "warn", + "Failed to update PoliticTalk room occupancy in PgApi: room=%s status=%s", + tostring(meeting_code), + tostring(code) + ); + end + end); +end + module:hook("muc-room-pre-create", function(event) if event.stanza and event.stanza.attr and is_admin(event.stanza.attr.from) then return; @@ -260,6 +380,26 @@ module:hook("muc-occupant-pre-join", function(event) return deny_join(event, "PoliticTalk room is waiting for the host"); end + if not is_moderator then + ensure_room_data(room); + local participant_limit = get_participant_limit(session); + local active_participant_count = table_count(room._data.politictalk_participant_jids); + if + participant_limit + and active_participant_count >= participant_limit + then + module:log( + "warn", + "Blocking participant %s because PoliticTalk room is full: room=%s active=%s limit=%s", + tostring(user_id), + tostring(room and room.jid), + tostring(active_participant_count), + tostring(participant_limit) + ); + return deny_join(event, "PoliticTalk room is full"); + end + end + ROLE_JOINING[occupant_jid] = { is_moderator = is_moderator; user_id = user_id; @@ -298,6 +438,8 @@ module:hook("muc-occupant-joined", function(event) room:set_affiliation(true, occupant.bare_jid, "member"); module:log("info", "PoliticTalk participant joined as member: %s room=%s", tostring(role.user_id), tostring(room.jid)); end + + notify_room_occupancy(room, "occupant_joined"); end, 1); module:hook("muc-occupant-left", function(event) @@ -317,6 +459,8 @@ module:hook("muc-occupant-left", function(event) module:log("info", "Destroying PoliticTalk room after host left: %s", tostring(room.jid)); notify_room_inactive(room, "host_left"); room:destroy(nil, "The host has left the PoliticTalk room"); + else + notify_room_occupancy(room, "occupant_left"); end end, -20); diff --git a/templates/prosody-token-auth.cfg.lua.example b/templates/prosody-token-auth.cfg.lua.example index a51a35e..c0494fc 100644 --- a/templates/prosody-token-auth.cfg.lua.example +++ b/templates/prosody-token-auth.cfg.lua.example @@ -14,6 +14,7 @@ VirtualHost "politictalk.parallelglobe.io" Component "conference.politictalk.parallelglobe.io" "muc" politictalk_room_inactive_callback_url = "https://api.parallelglobe.is/events/politictalk/jitsi/room-inactive" + politictalk_room_occupancy_callback_url = "https://api.parallelglobe.is/events/politictalk/jitsi/room-occupancy" politictalk_room_inactive_callback_secret = "POLITICTALK_JITSI_LIFECYCLE_SECRET" modules_enabled = { diff --git a/web/plugin.head.html b/web/plugin.head.html index 4cfdaaf..b2d51de 100644 --- a/web/plugin.head.html +++ b/web/plugin.head.html @@ -11,7 +11,7 @@ text-decoration: none; top: max(20px, env(safe-area-inset-top)); width: auto; - z-index: 2147483000; + z-index: 20; } .politictalk-room-logo img { @@ -42,6 +42,10 @@ display: none !important; } + html.politictalk-direct-access-blocked body > *:not(.politictalk-direct-access):not(.politictalk-room-logo) { + visibility: hidden !important; + } + .politictalk-direct-access { align-items: center; background: rgba(13, 15, 16, 0.48); @@ -114,24 +118,33 @@ @media (max-width: 640px) { .politictalk-room-logo { gap: 10px; - height: 52px; - left: max(12px, env(safe-area-inset-left)); - top: max(12px, env(safe-area-inset-top)); + height: 48px; + left: max(14px, env(safe-area-inset-left)); + top: max(18px, env(safe-area-inset-top)); + max-width: calc(100vw - 28px); } .politictalk-room-logo img { - flex-basis: 52px; - height: 52px; - width: 52px; + flex-basis: 48px; + height: 48px; + width: 48px; } .politictalk-room-logo__text { - font-size: 20px; + display: block; + font-size: 21px; + max-width: calc(100vw - 86px); + overflow: hidden; + text-overflow: ellipsis; + } + + .details-container { + top: calc(max(18px, env(safe-area-inset-top)) + 58px) !important; } .politictalk-direct-access { align-items: flex-start; - padding: 132px 18px 18px; + padding: 150px 18px 18px; } .politictalk-direct-access__dialog {