Refactored polls module into layered architecture with typed domain objects, isolated SSE handling, and comprehensive tests.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (Python 3.12) (push) Successful in 2m54s
CI / Tests (Python 3.13) (push) Successful in 2m54s
CI / Tests (Python 3.14) (push) Successful in 2m52s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (Python 3.12) (push) Successful in 2m54s
CI / Tests (Python 3.13) (push) Successful in 2m54s
CI / Tests (Python 3.14) (push) Successful in 2m52s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
This commit is contained in:
@@ -43,17 +43,16 @@ from .commands import (
|
||||
vote_command,
|
||||
)
|
||||
from .manager import PollManager, get_manager
|
||||
from .repository import PollRepository
|
||||
from .routes import (
|
||||
create_page,
|
||||
create_submit,
|
||||
current_page,
|
||||
events_stream,
|
||||
mod_cancel,
|
||||
mod_end,
|
||||
results_page,
|
||||
static_create_js,
|
||||
static_polls_css,
|
||||
static_polls_js,
|
||||
vote_page,
|
||||
static_asset,
|
||||
vote_submit,
|
||||
)
|
||||
|
||||
@@ -61,6 +60,7 @@ __all__ = [
|
||||
"cancel_poll_command",
|
||||
"create_page",
|
||||
"create_submit",
|
||||
"current_page",
|
||||
"end_poll_command",
|
||||
"events_stream",
|
||||
"handle_bare_vote",
|
||||
@@ -72,12 +72,9 @@ __all__ = [
|
||||
"poll_command",
|
||||
"results_page",
|
||||
"setup",
|
||||
"static_create_js",
|
||||
"static_polls_css",
|
||||
"static_polls_js",
|
||||
"static_asset",
|
||||
"teardown",
|
||||
"vote_command",
|
||||
"vote_page",
|
||||
"vote_submit",
|
||||
]
|
||||
|
||||
@@ -88,13 +85,17 @@ async def setup(ctx: ModuleContext) -> None:
|
||||
|
||||
:param ctx: Module context with config, storage, and other services.
|
||||
"""
|
||||
ctx.state["manager"] = PollManager(ctx)
|
||||
repo = PollRepository()
|
||||
ctx.state["manager"] = PollManager(ctx, repo)
|
||||
|
||||
|
||||
@on_event(EventType.STREAM_STOPPED)
|
||||
async def on_stream_stopped(ctx: EventContext[StreamStoppedEvent]) -> None:
|
||||
"""Begin a grace period when the stream goes offline.
|
||||
|
||||
If the stream does not restart before the grace period expires, any active
|
||||
poll is cancelled.
|
||||
|
||||
:param ctx: The event context.
|
||||
"""
|
||||
get_manager(ctx.module).begin_stream_grace()
|
||||
@@ -113,8 +114,8 @@ async def on_stream_started(ctx: EventContext[StreamStartedEvent]) -> None:
|
||||
async def on_user_joined(ctx: EventContext[UserJoinedEvent]) -> None:
|
||||
"""Notify a user joining chat that a poll is active.
|
||||
|
||||
Sends a private message with the poll question, options, and voting
|
||||
instructions.
|
||||
Sends a private message with the poll question, options, and a protected
|
||||
current-poll link.
|
||||
|
||||
:param ctx: The event context.
|
||||
"""
|
||||
@@ -128,11 +129,14 @@ async def on_user_joined(ctx: EventContext[UserJoinedEvent]) -> None:
|
||||
f"<strong>{i}.</strong> {escape(opt)}<br>"
|
||||
for i, opt in enumerate(poll.options, 1)
|
||||
)
|
||||
|
||||
if poll.allow_multiple:
|
||||
instructions = "Use <strong>!vote</strong> to get a voting link."
|
||||
selections = manager.get_selections(ctx.event.user)
|
||||
vote_url = ctx.session_url_for("/current")
|
||||
if selections is not None:
|
||||
instructions = (
|
||||
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.'
|
||||
)
|
||||
else:
|
||||
instructions = "Type a number in chat or use <strong>!vote</strong> to vote."
|
||||
instructions = f'<a href="{vote_url}"><u>Click here to vote</u></a>.'
|
||||
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.event.client_id,
|
||||
@@ -151,3 +155,4 @@ async def teardown(ctx: ModuleContext) -> None:
|
||||
"""
|
||||
manager = get_manager(ctx)
|
||||
await manager.teardown()
|
||||
ctx.state["manager"] = None
|
||||
|
||||
@@ -27,7 +27,41 @@ from owlbot.api import (
|
||||
on_event,
|
||||
)
|
||||
|
||||
from .manager import PollError, get_manager
|
||||
from .manager import get_manager
|
||||
from .types import (
|
||||
DuplicateVoteError,
|
||||
NoActivePollError,
|
||||
PollAuthenticationRequiredError,
|
||||
PollVoteError,
|
||||
)
|
||||
|
||||
|
||||
def _vote_error_response(
|
||||
error: NoActivePollError | PollVoteError,
|
||||
*,
|
||||
vote_url: str,
|
||||
) -> tuple[str, bool]:
|
||||
"""Return user-facing text and unsanitized flag for a vote failure.
|
||||
|
||||
:param error: The vote failure raised by the manager.
|
||||
:param vote_url: Protected URL for the current poll page.
|
||||
:return: Message text and whether it contains trusted HTML.
|
||||
"""
|
||||
if isinstance(error, NoActivePollError):
|
||||
return "There is no active poll.", False
|
||||
if isinstance(error, PollAuthenticationRequiredError):
|
||||
return (
|
||||
"This poll requires an authenticated account to vote. "
|
||||
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.',
|
||||
True,
|
||||
)
|
||||
if isinstance(error, DuplicateVoteError):
|
||||
return (
|
||||
"You have already voted in this poll. "
|
||||
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.',
|
||||
True,
|
||||
)
|
||||
return str(error), False
|
||||
|
||||
|
||||
@on_command("poll", requires_moderator=True, cooldown=3)
|
||||
@@ -64,9 +98,10 @@ async def end_poll_command(ctx: CommandContext) -> None:
|
||||
|
||||
try:
|
||||
await manager.end()
|
||||
except PollError as e:
|
||||
except NoActivePollError:
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id, str(e)
|
||||
ctx.chat_event.client_id,
|
||||
"There is no active poll.",
|
||||
)
|
||||
|
||||
|
||||
@@ -80,9 +115,10 @@ async def cancel_poll_command(ctx: CommandContext) -> None:
|
||||
|
||||
try:
|
||||
await manager.cancel()
|
||||
except PollError as e:
|
||||
except NoActivePollError:
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id, str(e)
|
||||
ctx.chat_event.client_id,
|
||||
"There is no active poll.",
|
||||
)
|
||||
|
||||
|
||||
@@ -110,30 +146,7 @@ async def vote_command(ctx: CommandContext) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
user_id = ctx.user.id
|
||||
vote_url = ctx.session_url_for("/vote")
|
||||
|
||||
# Check authentication requirement.
|
||||
if poll.requires_auth and not ctx.user.is_authenticated:
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id,
|
||||
"This poll requires an authenticated account to vote. "
|
||||
f'<a href="{vote_url}">'
|
||||
"<u>Click here to view live results</u></a>.",
|
||||
unsanitized=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Check if the user has already voted.
|
||||
if user_id in poll.votes:
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id,
|
||||
"You have already voted in this poll. "
|
||||
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.',
|
||||
unsanitized=True,
|
||||
)
|
||||
return
|
||||
|
||||
vote_url = ctx.session_url_for("/current")
|
||||
args = ctx.args_list
|
||||
|
||||
if args and not poll.allow_multiple:
|
||||
@@ -157,10 +170,22 @@ async def vote_command(ctx: CommandContext) -> None:
|
||||
# Convert 1-based to 0-based index.
|
||||
selection = choice - 1
|
||||
|
||||
await manager.record_vote(
|
||||
ctx.user,
|
||||
{selection},
|
||||
)
|
||||
try:
|
||||
await manager.record_vote(
|
||||
ctx.user,
|
||||
{selection},
|
||||
)
|
||||
except (
|
||||
NoActivePollError,
|
||||
PollVoteError,
|
||||
) as e:
|
||||
message, unsanitized = _vote_error_response(e, vote_url=vote_url)
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id,
|
||||
message,
|
||||
unsanitized=unsanitized,
|
||||
)
|
||||
return
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id,
|
||||
f"Your vote for #{choice} ({escape(poll.options[selection])})"
|
||||
@@ -189,11 +214,12 @@ async def vote_command(ctx: CommandContext) -> None:
|
||||
|
||||
@on_event(EventType.CHAT)
|
||||
async def handle_bare_vote(ctx: EventContext[ChatEvent]) -> None:
|
||||
"""Allow voting by typing a bare number in chat during active polls.
|
||||
"""Allow voting by typing an in-range bare option number during active polls.
|
||||
|
||||
For single-select polls, records the vote directly. For multi-select
|
||||
polls, sends the user a voting page link instead. The message is hidden
|
||||
to keep votes private and prevent chat spam.
|
||||
to keep votes private and prevent chat spam. Non-numeric and out-of-range
|
||||
messages are ignored.
|
||||
|
||||
:param ctx: The event context.
|
||||
"""
|
||||
@@ -219,28 +245,7 @@ async def handle_bare_vote(ctx: EventContext[ChatEvent]) -> None:
|
||||
await ctx.owncast_client.set_message_visibility([event.message_id], visible=False)
|
||||
ctx.stop_propagation("bare number vote in active poll")
|
||||
|
||||
vote_url = ctx.session_url_for("/vote")
|
||||
|
||||
# Check authentication requirement.
|
||||
if poll.requires_auth and not event.user.is_authenticated:
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
event.client_id,
|
||||
"This poll requires an authenticated account to vote. "
|
||||
f'<a href="{vote_url}">'
|
||||
"<u>Click here to view live results</u></a>.",
|
||||
unsanitized=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Check if the user has already voted.
|
||||
if event.user.id in poll.votes:
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
event.client_id,
|
||||
"You have already voted in this poll. "
|
||||
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.',
|
||||
unsanitized=True,
|
||||
)
|
||||
return
|
||||
vote_url = ctx.session_url_for("/current")
|
||||
|
||||
# Multi-select polls can't use bare number voting; send a link instead.
|
||||
if poll.allow_multiple:
|
||||
@@ -257,10 +262,22 @@ async def handle_bare_vote(ctx: EventContext[ChatEvent]) -> None:
|
||||
# Record the vote.
|
||||
selection = choice - 1
|
||||
|
||||
await manager.record_vote(
|
||||
event.user,
|
||||
{selection},
|
||||
)
|
||||
try:
|
||||
await manager.record_vote(
|
||||
event.user,
|
||||
{selection},
|
||||
)
|
||||
except (
|
||||
NoActivePollError,
|
||||
PollVoteError,
|
||||
) as e:
|
||||
message, unsanitized = _vote_error_response(e, vote_url=vote_url)
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
event.client_id,
|
||||
message,
|
||||
unsanitized=unsanitized,
|
||||
)
|
||||
return
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
event.client_id,
|
||||
f"Your vote for #{choice} ({escape(poll.options[selection])})"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Synchronous in-memory repository for active poll data and recent results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .types import (
|
||||
ActivePoll,
|
||||
CompletedPoll,
|
||||
DuplicateVoteError,
|
||||
NoActivePollError,
|
||||
PollAlreadyActiveError,
|
||||
)
|
||||
|
||||
|
||||
class PollRepository:
|
||||
"""Store active poll state and the most recent completed result in memory."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty repository."""
|
||||
self._active_poll: ActivePoll | None = None
|
||||
self._last_result: CompletedPoll | None = None
|
||||
|
||||
def get_active_poll(self) -> ActivePoll | None:
|
||||
"""Return the active poll, if one exists.
|
||||
|
||||
:return: The active poll, or None if no poll is active.
|
||||
"""
|
||||
return self._active_poll
|
||||
|
||||
def create_active_poll(self, poll: ActivePoll) -> ActivePoll:
|
||||
"""Store a new active poll.
|
||||
|
||||
:param poll: The poll to activate.
|
||||
:return: The stored poll.
|
||||
:raises PollAlreadyActiveError: If a poll is already active.
|
||||
"""
|
||||
if self._active_poll is not None:
|
||||
raise PollAlreadyActiveError
|
||||
self._active_poll = poll
|
||||
return poll
|
||||
|
||||
def pop_active_poll(self) -> ActivePoll | None:
|
||||
"""Clear and return the active poll, if one exists.
|
||||
|
||||
:return: The previously active poll, or None if no poll was active.
|
||||
"""
|
||||
poll = self._active_poll
|
||||
if poll is None:
|
||||
return None
|
||||
self._active_poll = None
|
||||
return poll
|
||||
|
||||
def get_vote(self, user_id: str) -> set[int] | None:
|
||||
"""Return a copy of a user's selected options for the active poll.
|
||||
|
||||
:param user_id: ID of the user whose vote should be read.
|
||||
:return: Selected option indices, or None if the user has not voted.
|
||||
:raises NoActivePollError: If no poll is active.
|
||||
"""
|
||||
poll = self._require_active_poll()
|
||||
selections = poll.votes.get(user_id)
|
||||
return None if selections is None else set(selections)
|
||||
|
||||
def set_vote(self, user_id: str, selections: set[int]) -> None:
|
||||
"""Store a user's selected options for the active poll.
|
||||
|
||||
:param user_id: ID of the user casting the vote.
|
||||
:param selections: Selected option indices.
|
||||
:raises NoActivePollError: If no poll is active.
|
||||
:raises DuplicateVoteError: If the user already voted.
|
||||
"""
|
||||
poll = self._require_active_poll()
|
||||
if user_id in poll.votes:
|
||||
raise DuplicateVoteError(user_id)
|
||||
poll.votes[user_id] = set(selections)
|
||||
|
||||
def list_votes(self) -> dict[str, set[int]]:
|
||||
"""Return a copy of all user selections for the active poll.
|
||||
|
||||
:return: Mapping of user IDs to selected option indices.
|
||||
:raises NoActivePollError: If no poll is active.
|
||||
"""
|
||||
poll = self._require_active_poll()
|
||||
return {user_id: set(selections) for user_id, selections in poll.votes.items()}
|
||||
|
||||
def get_last_result(self) -> CompletedPoll | None:
|
||||
"""Return the most recent completed result, if one exists.
|
||||
|
||||
:return: Most recent completed poll, or None if no result is available.
|
||||
"""
|
||||
return self._last_result
|
||||
|
||||
def set_last_result(self, result: CompletedPoll) -> None:
|
||||
"""Store the most recent completed result.
|
||||
|
||||
:param result: Completed poll snapshot to expose on the results page.
|
||||
"""
|
||||
self._last_result = result
|
||||
|
||||
def clear_last_result(self) -> None:
|
||||
"""Clear the most recent completed result."""
|
||||
self._last_result = None
|
||||
|
||||
def _require_active_poll(self) -> ActivePoll:
|
||||
"""Return the active poll or raise the domain no-active error.
|
||||
|
||||
:return: The currently active poll.
|
||||
:raises NoActivePollError: If no poll is active.
|
||||
"""
|
||||
poll = self._active_poll
|
||||
if poll is None:
|
||||
raise NoActivePollError
|
||||
return poll
|
||||
@@ -16,8 +16,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -26,7 +24,7 @@ from aiohttp import web
|
||||
|
||||
from owlbot.api import RouteContext, on_route
|
||||
|
||||
from .manager import PollError, get_manager
|
||||
from .manager import get_manager
|
||||
from .types import (
|
||||
DEFAULT_DURATION,
|
||||
MAX_DURATION,
|
||||
@@ -34,17 +32,31 @@ from .types import (
|
||||
MAX_OPTIONS,
|
||||
MAX_QUESTION_LENGTH,
|
||||
MIN_DURATION,
|
||||
SSEEvent,
|
||||
sse_payload,
|
||||
MIN_OPTIONS,
|
||||
NoActivePollError,
|
||||
PollAlreadyActiveError,
|
||||
PollAuthenticationRequiredError,
|
||||
PollCreateValidationError,
|
||||
PollVoteError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owlbot.sessions import BrowserSession
|
||||
|
||||
from .manager import PollManager
|
||||
|
||||
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
_POLLS_CSS_PATH = _STATIC_DIR / "polls.css"
|
||||
_POLLS_JS_PATH = _STATIC_DIR / "polls.js"
|
||||
_CREATE_JS_PATH = _STATIC_DIR / "create.js"
|
||||
_STATIC_FILES = {
|
||||
"polls.css": _POLLS_CSS_PATH,
|
||||
"polls.js": _POLLS_JS_PATH,
|
||||
"create.js": _CREATE_JS_PATH,
|
||||
"shared/config.js": _STATIC_DIR / "shared" / "config.js",
|
||||
"shared/dom.js": _STATIC_DIR / "shared" / "dom.js",
|
||||
"shared/poll-events.js": _STATIC_DIR / "shared" / "poll-events.js",
|
||||
}
|
||||
|
||||
|
||||
def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
|
||||
@@ -60,27 +72,18 @@ def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
|
||||
|
||||
|
||||
def _require_session(ctx: RouteContext) -> BrowserSession:
|
||||
"""Return the browser session for a protected polls route."""
|
||||
"""Return the browser session for a protected polls route.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: The resolved browser session.
|
||||
:raises RuntimeError: If a protected route was called without a session.
|
||||
"""
|
||||
session = ctx.session
|
||||
if session is None:
|
||||
raise RuntimeError("Protected polls route called without a browser session.")
|
||||
return session
|
||||
|
||||
|
||||
def _default_form() -> dict[str, Any]:
|
||||
"""Return default form values for the poll creation template."""
|
||||
return {
|
||||
"question": "",
|
||||
"options": ["", ""],
|
||||
"duration": DEFAULT_DURATION,
|
||||
"hidden": False,
|
||||
"requires_auth": False,
|
||||
"allow_multiple": False,
|
||||
"min_selections": "",
|
||||
"max_selections": "",
|
||||
}
|
||||
|
||||
|
||||
def _create_form_page(
|
||||
ctx: RouteContext,
|
||||
*,
|
||||
@@ -99,6 +102,7 @@ def _create_form_page(
|
||||
constraints = {
|
||||
"min_duration": MIN_DURATION,
|
||||
"max_duration": MAX_DURATION,
|
||||
"min_options": MIN_OPTIONS,
|
||||
"max_options": MAX_OPTIONS,
|
||||
"max_question_length": MAX_QUESTION_LENGTH,
|
||||
"max_option_length": MAX_OPTION_LENGTH,
|
||||
@@ -114,38 +118,104 @@ def _create_form_page(
|
||||
return web.Response(status=status, text=page, content_type="text/html")
|
||||
|
||||
|
||||
def _current_poll_page(
|
||||
ctx: RouteContext,
|
||||
manager: PollManager,
|
||||
session: BrowserSession,
|
||||
*,
|
||||
error: str | None = None,
|
||||
status: int = HTTPStatus.OK,
|
||||
show_current_selections: bool = True,
|
||||
) -> web.Response:
|
||||
"""Render the current poll page from the latest active-poll state.
|
||||
|
||||
This helper keeps route handlers from holding an ``ActivePoll`` reference
|
||||
across awaits and then mixing it with fresh manager state. If the poll has
|
||||
ended before rendering, the caller gets the normal no-active-poll response.
|
||||
|
||||
:param ctx: The route context.
|
||||
:param manager: Poll manager used to read the latest active poll state.
|
||||
:param session: Browser session for the viewer rendering the page.
|
||||
:param error: Optional inline voting error to display.
|
||||
:param status: HTTP status code for the rendered vote page.
|
||||
:param show_current_selections: Whether to load and show the viewer's
|
||||
existing selections.
|
||||
:return: HTML response for the active poll, or a no-active-poll error page.
|
||||
"""
|
||||
poll = manager.active_poll
|
||||
if poll is None:
|
||||
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
|
||||
|
||||
current_selections: frozenset[int] | None = None
|
||||
if show_current_selections:
|
||||
try:
|
||||
current_selections = manager.get_selections(session.user)
|
||||
except NoActivePollError:
|
||||
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
|
||||
|
||||
has_voted = current_selections is not None
|
||||
page = ctx.templates.render(
|
||||
"vote.html",
|
||||
poll=poll,
|
||||
viewer_is_authenticated=session.is_authenticated,
|
||||
viewer_is_moderator=session.is_moderator,
|
||||
has_voted=has_voted,
|
||||
current_selections=current_selections or frozenset(),
|
||||
error=error,
|
||||
vote_url=ctx.routes.url_for("/current/vote"),
|
||||
events_url=ctx.routes.url_for("/current/events"),
|
||||
end_url=ctx.routes.url_for("/current/end"),
|
||||
cancel_url=ctx.routes.url_for("/current/cancel"),
|
||||
polls_css_url=ctx.routes.url_for("/static/polls.css"),
|
||||
polls_js_url=ctx.routes.url_for("/static/polls.js"),
|
||||
)
|
||||
return web.Response(status=status, text=page, content_type="text/html")
|
||||
|
||||
|
||||
@on_route("/create", methods=["GET"], requires_moderator=True)
|
||||
async def create_page(ctx: RouteContext) -> web.Response:
|
||||
"""Serve the poll creation form.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: HTML response with the creation form.
|
||||
:return: HTML response with the creation form, or an error response if a
|
||||
poll is already active.
|
||||
"""
|
||||
manager = get_manager(ctx.module)
|
||||
|
||||
if manager.active_poll is not None:
|
||||
return _error_page(ctx, HTTPStatus.CONFLICT, "A poll is already active.")
|
||||
|
||||
return _create_form_page(ctx, form=_default_form())
|
||||
return _create_form_page(
|
||||
ctx,
|
||||
form={
|
||||
"question": "",
|
||||
"options": ["", ""],
|
||||
"duration": DEFAULT_DURATION,
|
||||
"hidden": False,
|
||||
"requires_auth": False,
|
||||
"allow_multiple": False,
|
||||
"min_selections": "",
|
||||
"max_selections": "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@on_route("/create", methods=["POST"], requires_moderator=True)
|
||||
async def create_submit(ctx: RouteContext) -> web.Response:
|
||||
"""Handle poll creation form submission.
|
||||
|
||||
Validates inputs, delegates to the manager to create the poll,
|
||||
and redirects to the voting page.
|
||||
Parses form inputs, delegates normalized fields to the manager, and
|
||||
redirects to the current poll page.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: Redirect to the voting page, or error response.
|
||||
:return: Redirect to the current poll page, or error response.
|
||||
"""
|
||||
data = await ctx.request.post()
|
||||
|
||||
# Parse form inputs.
|
||||
question = str(data.get("question", "")).strip()
|
||||
|
||||
options_raw = str(data.get("options", "")).strip()
|
||||
options = [o.strip() for o in options_raw.splitlines() if o.strip()]
|
||||
options = [str(option).strip() for option in data.getall("options", [])]
|
||||
|
||||
try:
|
||||
duration = int(str(data.get("duration", "0")))
|
||||
@@ -195,80 +265,51 @@ async def create_submit(ctx: RouteContext) -> web.Response:
|
||||
max_selections=max_selections,
|
||||
duration=duration,
|
||||
)
|
||||
except PollError as e:
|
||||
except PollAlreadyActiveError:
|
||||
return _create_form_page(
|
||||
ctx,
|
||||
form=form,
|
||||
error="A poll is already active.",
|
||||
status=HTTPStatus.CONFLICT,
|
||||
)
|
||||
except PollCreateValidationError as e:
|
||||
return _create_form_page(
|
||||
ctx, form=form, error=str(e), status=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
# Redirect to the mod's voting page.
|
||||
vote_url = ctx.routes.url_for("/vote")
|
||||
raise web.HTTPFound(vote_url)
|
||||
# Redirect to the active poll page.
|
||||
current_url = ctx.routes.url_for("/current")
|
||||
raise web.HTTPFound(current_url)
|
||||
|
||||
|
||||
@on_route("/vote", methods=["GET"], requires_session=True)
|
||||
async def vote_page(ctx: RouteContext) -> web.Response:
|
||||
"""Serve the voting page.
|
||||
@on_route("/current", methods=["GET"], requires_session=True)
|
||||
async def current_page(ctx: RouteContext) -> web.Response:
|
||||
"""Serve the current poll page.
|
||||
|
||||
Renders the voting page with appropriate visibility and permission controls
|
||||
Renders the poll page with appropriate visibility and permission controls
|
||||
based on the connected viewer's session.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: HTML response with the voting page.
|
||||
:return: HTML response with the current poll page, or an error response if
|
||||
no poll is active.
|
||||
"""
|
||||
manager = get_manager(ctx.module)
|
||||
poll = manager.active_poll
|
||||
|
||||
if poll is None:
|
||||
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
|
||||
|
||||
session = _require_session(ctx)
|
||||
user_id = session.user.id
|
||||
has_voted = user_id in poll.votes
|
||||
current_votes = poll.votes.get(user_id, set())
|
||||
vote_url = ctx.routes.url_for("/vote")
|
||||
|
||||
page = ctx.templates.render(
|
||||
"vote.html",
|
||||
poll=poll,
|
||||
viewer_is_authenticated=session.is_authenticated,
|
||||
viewer_is_moderator=session.is_moderator,
|
||||
has_voted=has_voted,
|
||||
current_votes=current_votes,
|
||||
vote_url=vote_url,
|
||||
events_url=ctx.routes.url_for("/vote/events"),
|
||||
end_url=ctx.routes.url_for("/vote/end"),
|
||||
cancel_url=ctx.routes.url_for("/vote/cancel"),
|
||||
polls_css_url=ctx.routes.url_for("/static/polls.css"),
|
||||
polls_js_url=ctx.routes.url_for("/static/polls.js"),
|
||||
)
|
||||
return web.Response(text=page, content_type="text/html")
|
||||
return _current_poll_page(ctx, manager, session)
|
||||
|
||||
|
||||
@on_route("/vote", methods=["POST"], requires_session=True)
|
||||
@on_route("/current/vote", methods=["POST"], requires_session=True)
|
||||
async def vote_submit(ctx: RouteContext) -> web.Response:
|
||||
"""Handle vote form submission.
|
||||
|
||||
Parses selections, delegates to the manager, and redirects back to the
|
||||
voting page.
|
||||
current poll page.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: Redirect back to the voting page, or error response.
|
||||
:return: Redirect back to the current poll page, or error response.
|
||||
"""
|
||||
manager = get_manager(ctx.module)
|
||||
poll = manager.active_poll
|
||||
|
||||
if poll is None:
|
||||
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
|
||||
|
||||
session = _require_session(ctx)
|
||||
|
||||
if poll.requires_auth and not session.is_authenticated:
|
||||
return _error_page(
|
||||
ctx,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
"This poll requires an authenticated account to vote.",
|
||||
)
|
||||
|
||||
data = await ctx.request.post()
|
||||
|
||||
# Parse selections from form checkboxes/radio buttons.
|
||||
@@ -276,29 +317,50 @@ async def vote_submit(ctx: RouteContext) -> web.Response:
|
||||
try:
|
||||
selections = {int(str(s)) for s in raw_selections}
|
||||
except ValueError:
|
||||
return _error_page(ctx, HTTPStatus.BAD_REQUEST, "Invalid selection.")
|
||||
return _current_poll_page(
|
||||
ctx,
|
||||
manager,
|
||||
session=session,
|
||||
error="Invalid selection.",
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
await manager.record_vote(
|
||||
session.user,
|
||||
selections,
|
||||
)
|
||||
except PollError as e:
|
||||
return _error_page(ctx, HTTPStatus.BAD_REQUEST, str(e))
|
||||
except NoActivePollError:
|
||||
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
|
||||
except PollAuthenticationRequiredError:
|
||||
return _current_poll_page(
|
||||
ctx,
|
||||
manager,
|
||||
session=session,
|
||||
show_current_selections=False,
|
||||
error="This poll requires an authenticated account to vote.",
|
||||
status=HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
except PollVoteError as e:
|
||||
return _current_poll_page(
|
||||
ctx,
|
||||
manager,
|
||||
session=session,
|
||||
error=str(e),
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Redirect back to the voting page.
|
||||
vote_url = ctx.routes.url_for("/vote")
|
||||
raise web.HTTPFound(vote_url)
|
||||
# Redirect back to the active poll page.
|
||||
current_url = ctx.routes.url_for("/current")
|
||||
raise web.HTTPFound(current_url)
|
||||
|
||||
|
||||
@on_route("/vote/events", streaming=True, requires_session=True)
|
||||
@on_route("/current/events", streaming=True, requires_session=True)
|
||||
async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
||||
"""SSE endpoint for live poll updates.
|
||||
|
||||
Sends keepalive events every 15 seconds with time remaining.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: SSE stream response.
|
||||
:return: SSE stream response, or a 404 response if no poll is active.
|
||||
"""
|
||||
manager = get_manager(ctx.module)
|
||||
poll = manager.active_poll
|
||||
@@ -307,7 +369,6 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND, text="No active poll.")
|
||||
|
||||
session = _require_session(ctx)
|
||||
user_id = session.user.id
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=HTTPStatus.OK,
|
||||
@@ -320,73 +381,45 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
||||
)
|
||||
await response.prepare(ctx.request)
|
||||
|
||||
await manager.register_sse_client(
|
||||
session.user,
|
||||
response=response,
|
||||
)
|
||||
|
||||
try:
|
||||
while manager.active_poll is poll:
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(poll.done_event.wait(), timeout=15)
|
||||
# If the poll ended, exit immediately.
|
||||
if manager.active_poll is not poll:
|
||||
break
|
||||
try:
|
||||
remaining = poll.time_remaining
|
||||
payload = sse_payload(SSEEvent.KEEPALIVE, {"time_remaining": remaining})
|
||||
await response.write(payload)
|
||||
except Exception: # noqa: BLE001 # SSE disconnect; no single exception covers all transport failures
|
||||
ctx.logger.debug("SSE keepalive failed for %s.", user_id)
|
||||
break
|
||||
finally:
|
||||
# Only remove ourselves if we're still the registered client.
|
||||
# A page reload creates a new SSE connection for the same user,
|
||||
# so the old handler must not evict the replacement.
|
||||
client = poll.sse_clients.get(user_id)
|
||||
if client is not None and client.response is response:
|
||||
poll.sse_clients.pop(user_id, None)
|
||||
ctx.logger.debug(
|
||||
"SSE client disconnected: %s (%d remaining).",
|
||||
user_id,
|
||||
len(poll.sse_clients),
|
||||
)
|
||||
await manager.serve_sse_stream(session.user, response)
|
||||
except NoActivePollError:
|
||||
ctx.logger.debug("SSE stream ended before client registration.")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@on_route("/vote/end", methods=["POST"], requires_moderator=True)
|
||||
@on_route("/current/end", methods=["POST"], requires_moderator=True)
|
||||
async def mod_end(ctx: RouteContext) -> web.Response:
|
||||
"""End the poll via the moderator web UI.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: Redirect to the results page.
|
||||
:return: Redirect to the results page, or an error response if no poll is
|
||||
active.
|
||||
"""
|
||||
manager = get_manager(ctx.module)
|
||||
poll = manager.active_poll
|
||||
|
||||
if poll is None:
|
||||
try:
|
||||
await manager.end()
|
||||
except NoActivePollError:
|
||||
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
|
||||
|
||||
await manager.end()
|
||||
results_url = ctx.routes.url_for("/results")
|
||||
raise web.HTTPFound(results_url)
|
||||
|
||||
|
||||
@on_route("/vote/cancel", methods=["POST"], requires_moderator=True)
|
||||
@on_route("/current/cancel", methods=["POST"], requires_moderator=True)
|
||||
async def mod_cancel(ctx: RouteContext) -> web.Response:
|
||||
"""Cancel the poll via the moderator web UI.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: Redirect response or error page.
|
||||
:return: Cancellation confirmation or error page.
|
||||
"""
|
||||
manager = get_manager(ctx.module)
|
||||
poll = manager.active_poll
|
||||
|
||||
if poll is None:
|
||||
try:
|
||||
await manager.cancel()
|
||||
except NoActivePollError:
|
||||
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
|
||||
|
||||
await manager.cancel()
|
||||
return _error_page(ctx, HTTPStatus.OK, "This poll has been cancelled.")
|
||||
|
||||
|
||||
@@ -394,8 +427,7 @@ async def mod_cancel(ctx: RouteContext) -> web.Response:
|
||||
async def results_page(ctx: RouteContext) -> web.Response:
|
||||
"""Serve the results page for the most recently completed poll.
|
||||
|
||||
Shows contextual error messages if a poll is active or no
|
||||
results are available.
|
||||
Shows contextual messages if a poll is active or no results are available.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: HTML response with the results page.
|
||||
@@ -422,40 +454,19 @@ async def results_page(ctx: RouteContext) -> web.Response:
|
||||
return web.Response(text=page, content_type="text/html")
|
||||
|
||||
|
||||
@on_route("/static/polls.css", methods=["GET"])
|
||||
async def static_polls_css(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
|
||||
"""Serve the polls stylesheet.
|
||||
@on_route("/static/{filename:.+}", methods=["GET"])
|
||||
async def static_asset(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
||||
"""Serve allow-listed poll module static assets.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: The polls.css file with caching headers.
|
||||
:param ctx: The route context containing the requested filename.
|
||||
:return: Static file response, or 404 for unknown asset names.
|
||||
"""
|
||||
filename = ctx.match_info["filename"]
|
||||
path = _STATIC_FILES.get(filename)
|
||||
if path is None:
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
cache_control = "no-cache" if filename.startswith("shared/") else "max-age=86400"
|
||||
return web.FileResponse(
|
||||
_POLLS_CSS_PATH,
|
||||
headers={"Cache-Control": "max-age=86400"},
|
||||
)
|
||||
|
||||
|
||||
@on_route("/static/polls.js", methods=["GET"])
|
||||
async def static_polls_js(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
|
||||
"""Serve the polls JavaScript file.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: The polls.js file with caching headers.
|
||||
"""
|
||||
return web.FileResponse(
|
||||
_POLLS_JS_PATH,
|
||||
headers={"Cache-Control": "max-age=86400"},
|
||||
)
|
||||
|
||||
|
||||
@on_route("/static/create.js", methods=["GET"])
|
||||
async def static_create_js(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
|
||||
"""Serve the create form JavaScript file.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: The create.js file with caching headers.
|
||||
"""
|
||||
return web.FileResponse(
|
||||
_CREATE_JS_PATH,
|
||||
headers={"Cache-Control": "max-age=86400"},
|
||||
path,
|
||||
headers={"Cache-Control": cache_control},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Server-Sent Event connection tracking for the polls module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import itertools
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import orjson
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from logging import Logger
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from owlbot.api import User
|
||||
|
||||
|
||||
class SSEEvent(StrEnum):
|
||||
"""Server-Sent Event types for the polls module."""
|
||||
|
||||
KEEPALIVE = "keepalive"
|
||||
POLL_CANCELLED = "poll_cancelled"
|
||||
POLL_ENDED = "poll_ended"
|
||||
TALLY = "tally"
|
||||
|
||||
|
||||
def sse_payload(event_type: SSEEvent, data: dict[str, Any]) -> bytes:
|
||||
"""Serialize an SSE event into wire format.
|
||||
|
||||
:param event_type: The event type name.
|
||||
:param data: The JSON-serializable event data.
|
||||
:return: Encoded SSE frame ready for ``StreamResponse.write()``.
|
||||
"""
|
||||
return b"event: " + event_type.encode() + b"\ndata: " + orjson.dumps(data) + b"\n\n"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PollSSEConnection:
|
||||
"""Runtime metadata for one connected poll event stream.
|
||||
|
||||
:param id: Internal connection ID.
|
||||
:param user_id: Owncast user ID associated with the stream.
|
||||
:param is_moderator: Whether the connected viewer has moderator privileges.
|
||||
:param response: Prepared response used for SSE writes.
|
||||
:param write_lock: Per-connection lock that serializes writes.
|
||||
"""
|
||||
|
||||
id: int
|
||||
user_id: str
|
||||
is_moderator: bool
|
||||
response: web.StreamResponse
|
||||
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
|
||||
class PollSSERegistry:
|
||||
"""Maintain live poll SSE connections and serialize writes per client."""
|
||||
|
||||
def __init__(self, logger: Logger) -> None:
|
||||
"""Initialize an empty SSE connection registry.
|
||||
|
||||
:param logger: Logger for connection lifecycle diagnostics.
|
||||
"""
|
||||
self._logger = logger
|
||||
self._connection_ids = itertools.count()
|
||||
self._connections: dict[int, PollSSEConnection] = {}
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of currently tracked connections.
|
||||
|
||||
:return: Active SSE connection count.
|
||||
"""
|
||||
return len(self._connections)
|
||||
|
||||
def register(
|
||||
self,
|
||||
user: User,
|
||||
*,
|
||||
response: web.StreamResponse,
|
||||
) -> PollSSEConnection:
|
||||
"""Register an SSE client and return its connection.
|
||||
|
||||
:param user: Viewer associated with the event stream.
|
||||
:param response: Prepared streaming response for the client.
|
||||
:return: Registered connection metadata.
|
||||
"""
|
||||
connection = PollSSEConnection(
|
||||
id=next(self._connection_ids),
|
||||
user_id=user.id,
|
||||
is_moderator=user.is_moderator,
|
||||
response=response,
|
||||
)
|
||||
self._connections[connection.id] = connection
|
||||
return connection
|
||||
|
||||
def unregister(self, connection: PollSSEConnection) -> None:
|
||||
"""Remove one SSE connection.
|
||||
|
||||
:param connection: Connection to remove from the registry.
|
||||
"""
|
||||
removed = self._connections.pop(connection.id, None)
|
||||
if removed is not None:
|
||||
self._logger.debug(
|
||||
"SSE client disconnected: %s (%d remaining).",
|
||||
removed.user_id,
|
||||
len(self._connections),
|
||||
)
|
||||
|
||||
async def send(
|
||||
self,
|
||||
connection: PollSSEConnection,
|
||||
event_type: SSEEvent,
|
||||
data: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Send one event to one connection, removing it if the write fails.
|
||||
|
||||
:param connection: Target SSE connection.
|
||||
:param event_type: Event type to send.
|
||||
:param data: JSON-serializable event payload.
|
||||
:return: True if the event was written successfully.
|
||||
"""
|
||||
if self._connections.get(connection.id) is not connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._write_connection(connection, sse_payload(event_type, data))
|
||||
except Exception: # noqa: BLE001 # transport failures vary by server/client state
|
||||
self._connections.pop(connection.id, None)
|
||||
self._logger.debug(
|
||||
"Removing disconnected SSE client: %s", connection.user_id
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def broadcast(
|
||||
self,
|
||||
event_type: SSEEvent,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
moderators_only: bool = False,
|
||||
) -> None:
|
||||
"""Broadcast one event to currently tracked connections.
|
||||
|
||||
:param event_type: Event type to send.
|
||||
:param data: JSON-serializable event payload.
|
||||
:param moderators_only: Whether to limit delivery to moderator clients.
|
||||
"""
|
||||
targets = [
|
||||
connection
|
||||
for connection in self._connections.values()
|
||||
if not moderators_only or connection.is_moderator
|
||||
]
|
||||
if not targets:
|
||||
return
|
||||
|
||||
payload = sse_payload(event_type, data)
|
||||
results = await asyncio.gather(
|
||||
*(self._write_connection(connection, payload) for connection in targets),
|
||||
return_exceptions=True,
|
||||
)
|
||||
for connection, result in zip(targets, results, strict=True):
|
||||
if isinstance(result, BaseException):
|
||||
self._logger.debug(
|
||||
"Removing disconnected SSE client: %s", connection.user_id
|
||||
)
|
||||
self._connections.pop(connection.id, None)
|
||||
|
||||
async def close_all(self) -> None:
|
||||
"""Close and forget every tracked SSE response."""
|
||||
connections = list(self._connections.values())
|
||||
for connection in connections:
|
||||
with contextlib.suppress(Exception):
|
||||
async with connection.write_lock:
|
||||
await connection.response.write_eof()
|
||||
self._connections.clear()
|
||||
|
||||
async def _write_connection(
|
||||
self,
|
||||
connection: PollSSEConnection,
|
||||
payload: bytes,
|
||||
) -> None:
|
||||
"""Write one encoded SSE frame while holding the connection lock.
|
||||
|
||||
:param connection: Target SSE connection.
|
||||
:param payload: Encoded SSE frame.
|
||||
"""
|
||||
async with connection.write_lock:
|
||||
await connection.response.write(payload)
|
||||
@@ -12,95 +12,205 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
import { readJsonConfig } from "./shared/config.js";
|
||||
import {
|
||||
createTextElement,
|
||||
requireElement,
|
||||
setHidden,
|
||||
} from "./shared/dom.js";
|
||||
|
||||
const config = window.CREATE_CONFIG;
|
||||
if (!config) return;
|
||||
class CreatePollForm {
|
||||
constructor(root, config) {
|
||||
this.root = root;
|
||||
this.config = config;
|
||||
this.form = requireElement(root, "#poll-form", HTMLFormElement);
|
||||
this.container = requireElement(root, "#options-container", HTMLElement);
|
||||
this.addButton = requireElement(root, "#add-option", HTMLButtonElement);
|
||||
this.multiCheckbox = requireElement(
|
||||
root,
|
||||
"#allow_multiple",
|
||||
HTMLInputElement
|
||||
);
|
||||
this.multiOptions = requireElement(
|
||||
root,
|
||||
"#multi-select-options",
|
||||
HTMLElement
|
||||
);
|
||||
this.minSelectionsInput = requireElement(
|
||||
root,
|
||||
"#min_selections",
|
||||
HTMLInputElement
|
||||
);
|
||||
this.maxSelectionsInput = requireElement(
|
||||
root,
|
||||
"#max_selections",
|
||||
HTMLInputElement
|
||||
);
|
||||
}
|
||||
|
||||
const container = document.getElementById('options-container');
|
||||
const addBtn = document.getElementById('add-option');
|
||||
const multiCheck = document.getElementById('allow_multiple');
|
||||
const multiOpts = document.getElementById('multi-select-options');
|
||||
const form = document.getElementById('poll-form');
|
||||
const maxOptions = config.maxOptions;
|
||||
const maxOptionLength = config.maxOptionLength;
|
||||
const minSel = document.getElementById('min_selections');
|
||||
const maxSel = document.getElementById('max_selections');
|
||||
start() {
|
||||
this.ensureMinimumOptions();
|
||||
this.renumberOptions();
|
||||
this.setMultiSelectEnabled(this.multiCheckbox.checked);
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
const syncSelectionLimits = () => {
|
||||
const minVal = parseInt(minSel.value, 10);
|
||||
const maxVal = parseInt(maxSel.value, 10);
|
||||
maxSel.min = minVal >= 1 ? minVal : 1;
|
||||
minSel.max = maxVal >= 1 ? maxVal : minSel.max;
|
||||
};
|
||||
bindEvents() {
|
||||
this.addButton.addEventListener("click", () => this.addOption());
|
||||
this.container.addEventListener("click", (event) =>
|
||||
this.removeOption(event)
|
||||
);
|
||||
this.multiCheckbox.addEventListener("change", () =>
|
||||
this.setMultiSelectEnabled(this.multiCheckbox.checked)
|
||||
);
|
||||
this.minSelectionsInput.addEventListener("input", () =>
|
||||
this.updateSelectionConstraints()
|
||||
);
|
||||
this.maxSelectionsInput.addEventListener("input", () =>
|
||||
this.updateSelectionConstraints()
|
||||
);
|
||||
this.form.addEventListener("submit", () =>
|
||||
this.updateSelectionConstraints()
|
||||
);
|
||||
}
|
||||
|
||||
const updateSelectionDefaults = () => {
|
||||
const count = container.querySelectorAll('.option-row').length;
|
||||
minSel.max = count;
|
||||
maxSel.max = count;
|
||||
maxSel.placeholder = count;
|
||||
if (minSel.value && parseInt(minSel.value, 10) > count) {
|
||||
minSel.value = count;
|
||||
optionRows() {
|
||||
return Array.from(this.container.querySelectorAll(".option-row"));
|
||||
}
|
||||
|
||||
ensureMinimumOptions() {
|
||||
while (this.optionRows().length < this.config.minOptions) {
|
||||
this.container.appendChild(this.buildOptionRow());
|
||||
}
|
||||
if (maxSel.value && parseInt(maxSel.value, 10) > count) {
|
||||
maxSel.value = count;
|
||||
}
|
||||
|
||||
addOption() {
|
||||
if (this.optionRows().length >= this.config.maxOptions) {
|
||||
return;
|
||||
}
|
||||
syncSelectionLimits();
|
||||
};
|
||||
this.container.appendChild(this.buildOptionRow());
|
||||
this.renumberOptions();
|
||||
}
|
||||
|
||||
minSel.addEventListener('input', syncSelectionLimits);
|
||||
maxSel.addEventListener('input', syncSelectionLimits);
|
||||
|
||||
const renumber = () => {
|
||||
const rows = container.querySelectorAll('.option-row');
|
||||
for (const [i, row] of [...rows].entries()) {
|
||||
const num = i + 1;
|
||||
row.querySelector('.option-number').textContent = num;
|
||||
const input = row.querySelector('.option-input');
|
||||
input.name = `option_${num}`;
|
||||
input.placeholder = `Option ${num}`;
|
||||
removeOption(event) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
addBtn.disabled = rows.length >= maxOptions;
|
||||
updateSelectionDefaults();
|
||||
};
|
||||
|
||||
addBtn.addEventListener('click', () => {
|
||||
const rows = container.querySelectorAll('.option-row');
|
||||
if (rows.length >= maxOptions) return;
|
||||
const num = rows.length + 1;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'input-group mb-2 option-row';
|
||||
div.innerHTML =
|
||||
`<span class="input-group-text option-number">${num}</span>` +
|
||||
`<input type="text" class="form-control option-input" name="option_${num}" required maxlength="${maxOptionLength}" placeholder="Option ${num}">` +
|
||||
'<button type="button" class="btn btn-outline-danger remove-option">Remove</button>';
|
||||
container.appendChild(div);
|
||||
renumber();
|
||||
});
|
||||
|
||||
container.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('remove-option')) {
|
||||
e.target.closest('.option-row').remove();
|
||||
renumber();
|
||||
const button = target.closest(".remove-option");
|
||||
if (button === null || !this.container.contains(button)) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
multiCheck.addEventListener('change', () => {
|
||||
if (multiCheck.checked) {
|
||||
updateSelectionDefaults();
|
||||
multiOpts.classList.remove('d-none');
|
||||
} else {
|
||||
multiOpts.classList.add('d-none');
|
||||
minSel.value = '';
|
||||
maxSel.value = '';
|
||||
const row = button.closest(".option-row");
|
||||
if (row === null || this.optionRows().length <= this.config.minOptions) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', () => {
|
||||
const lines = [...container.querySelectorAll('.option-input')]
|
||||
.map(input => input.value.trim())
|
||||
.filter(Boolean);
|
||||
document.getElementById('options-hidden').value = lines.join('\n');
|
||||
});
|
||||
})();
|
||||
row.remove();
|
||||
this.renumberOptions();
|
||||
}
|
||||
|
||||
buildOptionRow() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "input-group mb-2 option-row";
|
||||
|
||||
const number = this.optionRows().length + 1;
|
||||
const numberLabel = createTextElement(
|
||||
"span",
|
||||
"input-group-text option-number",
|
||||
String(number)
|
||||
);
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control option-input";
|
||||
input.name = "options";
|
||||
input.required = true;
|
||||
input.maxLength = this.config.maxOptionLength;
|
||||
input.placeholder = `Option ${number}`;
|
||||
|
||||
row.append(numberLabel, input);
|
||||
|
||||
if (number > this.config.minOptions) {
|
||||
const removeButton = createTextElement(
|
||||
"button",
|
||||
"btn btn-outline-danger remove-option",
|
||||
"Remove"
|
||||
);
|
||||
removeButton.type = "button";
|
||||
row.appendChild(removeButton);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
renumberOptions() {
|
||||
const rows = this.optionRows();
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const number = index + 1;
|
||||
requireElement(row, ".option-number", HTMLElement).textContent =
|
||||
String(number);
|
||||
requireElement(row, ".option-input", HTMLInputElement).placeholder =
|
||||
`Option ${number}`;
|
||||
}
|
||||
|
||||
this.addButton.disabled = rows.length >= this.config.maxOptions;
|
||||
this.updateSelectionConstraints();
|
||||
}
|
||||
|
||||
setMultiSelectEnabled(enabled) {
|
||||
this.minSelectionsInput.disabled = !enabled;
|
||||
this.maxSelectionsInput.disabled = !enabled;
|
||||
setHidden(this.multiOptions, !enabled);
|
||||
|
||||
if (!enabled) {
|
||||
this.minSelectionsInput.value = "";
|
||||
this.maxSelectionsInput.value = "";
|
||||
this.minSelectionsInput.setCustomValidity("");
|
||||
this.maxSelectionsInput.setCustomValidity("");
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateSelectionConstraints();
|
||||
}
|
||||
|
||||
updateSelectionConstraints() {
|
||||
const optionCount = this.optionRows().length;
|
||||
const minValue = this.parseSelectionValue(this.minSelectionsInput);
|
||||
const maxValue = this.parseSelectionValue(this.maxSelectionsInput);
|
||||
|
||||
this.minSelectionsInput.max = String(optionCount);
|
||||
this.maxSelectionsInput.max = String(optionCount);
|
||||
this.maxSelectionsInput.placeholder = String(optionCount);
|
||||
this.maxSelectionsInput.min =
|
||||
Number.isInteger(minValue) && minValue >= 1 ? String(minValue) : "1";
|
||||
|
||||
this.minSelectionsInput.setCustomValidity("");
|
||||
this.maxSelectionsInput.setCustomValidity("");
|
||||
|
||||
if (
|
||||
!this.multiCheckbox.checked ||
|
||||
!Number.isInteger(minValue) ||
|
||||
!Number.isInteger(maxValue)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (minValue > maxValue) {
|
||||
this.maxSelectionsInput.setCustomValidity(
|
||||
"Maximum selections must be at least the minimum."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
parseSelectionValue(input) {
|
||||
if (input.value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
return Number(input.value);
|
||||
}
|
||||
}
|
||||
|
||||
const config = readJsonConfig("create-config");
|
||||
new CreatePollForm(document, config).start();
|
||||
|
||||
@@ -12,174 +12,195 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
import { readJsonConfig } from "./shared/config.js";
|
||||
import {
|
||||
disableControls,
|
||||
requireElement,
|
||||
showAlert,
|
||||
} from "./shared/dom.js";
|
||||
import {
|
||||
isKeepalivePayload,
|
||||
isPollEndedPayload,
|
||||
isTallyPayload,
|
||||
PollEventStream,
|
||||
} from "./shared/poll-events.js";
|
||||
|
||||
const config = window.POLL_CONFIG;
|
||||
const viewerInfo = window.VIEWER_INFO;
|
||||
if (!config || !viewerInfo) return;
|
||||
function normalizeSeconds(value) {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
||||
}
|
||||
|
||||
const timerEl = document.getElementById("timer");
|
||||
let remaining = config.timeRemaining;
|
||||
function formatTime(seconds) {
|
||||
const safeSeconds = normalizeSeconds(seconds);
|
||||
const minutes = Math.floor(safeSeconds / 60);
|
||||
const remainingSeconds = safeSeconds % 60;
|
||||
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// --- Countdown timer ---
|
||||
class CountdownTimer {
|
||||
constructor(element, initialSeconds) {
|
||||
this.element = element;
|
||||
this.remaining = normalizeSeconds(initialSeconds);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
start() {
|
||||
this.render();
|
||||
this.ensureInterval();
|
||||
}
|
||||
|
||||
const updateTimer = () => {
|
||||
if (remaining <= 0) {
|
||||
timerEl.textContent = "0:00";
|
||||
stop() {
|
||||
if (this.intervalId === null) {
|
||||
return;
|
||||
}
|
||||
timerEl.textContent = formatTime(remaining);
|
||||
remaining--;
|
||||
};
|
||||
window.clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
updateTimer();
|
||||
const timerInterval = setInterval(() => {
|
||||
updateTimer();
|
||||
if (remaining < 0) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// --- Update vote bars ---
|
||||
|
||||
const updateBars = (counts) => {
|
||||
const total = counts.reduce((a, b) => a + b, 0);
|
||||
for (const bar of document.querySelectorAll(".option-bar")) {
|
||||
const idx = Number(bar.dataset.index);
|
||||
const fill = bar.querySelector(".bar-fill");
|
||||
const countSpan = bar.querySelector(".vote-count");
|
||||
const pct = total > 0 ? Math.round((counts[idx] / total) * 100) : 0;
|
||||
if (fill && counts[idx] !== undefined) {
|
||||
fill.style.width = `${pct}%`;
|
||||
}
|
||||
if (countSpan) {
|
||||
const countEl = countSpan.querySelector(".count");
|
||||
const pctEl = countSpan.querySelector(".pct");
|
||||
if (countEl) countEl.textContent = counts[idx];
|
||||
if (pctEl) pctEl.textContent = pct;
|
||||
countSpan.classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Show vote counts immediately for non-hidden polls so the UI
|
||||
// doesn't look identical to a hidden poll before SSE delivers data.
|
||||
if (!config.hidden || viewerInfo.isModerator) {
|
||||
for (const el of document.querySelectorAll(".vote-count")) {
|
||||
el.classList.remove("d-none");
|
||||
setRemaining(seconds) {
|
||||
this.remaining = normalizeSeconds(seconds);
|
||||
this.render();
|
||||
if (this.remaining > 0) {
|
||||
this.ensureInterval();
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSE connection ---
|
||||
|
||||
const evtSource = new EventSource(viewerInfo.eventsUrl);
|
||||
|
||||
const parse = (e) => {
|
||||
try {
|
||||
return JSON.parse(e.data);
|
||||
} catch {
|
||||
return null;
|
||||
ensureInterval() {
|
||||
if (this.intervalId !== null || this.remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
this.intervalId = window.setInterval(() => this.tick(), 1000);
|
||||
}
|
||||
|
||||
evtSource.addEventListener("error", () => {
|
||||
if (evtSource.readyState === EventSource.CLOSED) {
|
||||
clearInterval(timerInterval);
|
||||
const status = document.getElementById("poll-status");
|
||||
if (status) {
|
||||
status.innerHTML =
|
||||
'<div class="alert alert-danger mb-0" role="alert">' +
|
||||
"Connection lost. Results are not live. Refresh to reconnect.</div>";
|
||||
tick() {
|
||||
if (this.remaining <= 0) {
|
||||
this.stop();
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
this.remaining -= 1;
|
||||
this.render();
|
||||
|
||||
if (this.remaining <= 0) {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.element.textContent = formatTime(this.remaining);
|
||||
}
|
||||
}
|
||||
|
||||
class ResultsBars {
|
||||
constructor(root) {
|
||||
this.rows = Array.from(root.querySelectorAll(".option-bar")).map(
|
||||
(row) => ({
|
||||
index: Number(row.dataset.index),
|
||||
fill: row.querySelector(".bar-fill"),
|
||||
countWrapper: row.querySelector(".vote-count"),
|
||||
count: row.querySelector(".count"),
|
||||
percent: row.querySelector(".pct"),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
showCounts() {
|
||||
for (const row of this.rows) {
|
||||
row.countWrapper?.classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
update(counts) {
|
||||
const total = counts.reduce((sum, count) => sum + count, 0);
|
||||
|
||||
for (const row of this.rows) {
|
||||
const count = counts[row.index] ?? 0;
|
||||
const percent = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
|
||||
if (row.fill !== null) {
|
||||
row.fill.style.width = `${percent}%`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
evtSource.addEventListener("keepalive", (e) => {
|
||||
const data = parse(e);
|
||||
if (data && typeof data.time_remaining === "number") {
|
||||
remaining = data.time_remaining;
|
||||
}
|
||||
});
|
||||
|
||||
evtSource.addEventListener("tally", (e) => {
|
||||
const data = parse(e);
|
||||
if (data) {
|
||||
updateBars(data.counts);
|
||||
}
|
||||
});
|
||||
|
||||
evtSource.addEventListener("poll_ended", (e) => {
|
||||
const data = parse(e);
|
||||
clearInterval(timerInterval);
|
||||
evtSource.close();
|
||||
if (data?.results_url) {
|
||||
window.location.href = data.results_url;
|
||||
}
|
||||
});
|
||||
|
||||
evtSource.addEventListener("poll_cancelled", () => {
|
||||
clearInterval(timerInterval);
|
||||
evtSource.close();
|
||||
const status = document.getElementById("poll-status");
|
||||
if (status) {
|
||||
status.innerHTML =
|
||||
'<div class="alert alert-danger mb-0" role="alert">' +
|
||||
"This poll has been cancelled.</div>";
|
||||
}
|
||||
const form = document.getElementById("vote-form");
|
||||
if (form) {
|
||||
for (const input of form.querySelectorAll(
|
||||
"input, button[type='submit']"
|
||||
)) {
|
||||
input.disabled = true;
|
||||
if (row.count !== null) {
|
||||
row.count.textContent = String(count);
|
||||
}
|
||||
if (row.percent !== null) {
|
||||
row.percent.textContent = String(percent);
|
||||
}
|
||||
row.countWrapper?.classList.remove("d-none");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-select validation ---
|
||||
class VotePage {
|
||||
constructor(root, config) {
|
||||
this.root = root;
|
||||
this.config = config;
|
||||
this.status = requireElement(root, "#poll-status", HTMLElement);
|
||||
this.timer = new CountdownTimer(
|
||||
requireElement(root, "#timer", HTMLElement),
|
||||
config.timeRemaining
|
||||
);
|
||||
this.bars = new ResultsBars(root);
|
||||
this.stream = null;
|
||||
}
|
||||
|
||||
const voteForm = document.getElementById("vote-form");
|
||||
if (voteForm && config.allowMultiple) {
|
||||
voteForm.addEventListener("submit", (e) => {
|
||||
const checked = voteForm.querySelectorAll(
|
||||
'input[name="option"]:checked'
|
||||
);
|
||||
const count = checked.length;
|
||||
start() {
|
||||
this.timer.start();
|
||||
|
||||
if (count === 0) {
|
||||
e.preventDefault();
|
||||
alert("Please select at least one option.");
|
||||
return;
|
||||
}
|
||||
if (!this.config.hidden || this.config.isModerator) {
|
||||
this.bars.showCounts();
|
||||
}
|
||||
|
||||
if (
|
||||
config.minSelections !== null &&
|
||||
count < config.minSelections
|
||||
) {
|
||||
e.preventDefault();
|
||||
alert(
|
||||
`Please select at least ${config.minSelections} option(s).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
config.maxSelections !== null &&
|
||||
count > config.maxSelections
|
||||
) {
|
||||
e.preventDefault();
|
||||
alert(
|
||||
`Please select at most ${config.maxSelections} option(s).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.stream = new PollEventStream(this.config.eventsUrl, {
|
||||
onClosed: () => this.handleConnectionClosed(),
|
||||
});
|
||||
this.stream.on(
|
||||
"keepalive",
|
||||
(data) => this.timer.setRemaining(data.time_remaining),
|
||||
isKeepalivePayload
|
||||
);
|
||||
this.stream.on(
|
||||
"tally",
|
||||
(data) => this.bars.update(data.counts),
|
||||
(data) => isTallyPayload(data, this.config.optionCount)
|
||||
);
|
||||
this.stream.on(
|
||||
"poll_ended",
|
||||
(data) => this.handlePollEnded(data),
|
||||
isPollEndedPayload
|
||||
);
|
||||
this.stream.on("poll_cancelled", () => this.handlePollCancelled());
|
||||
}
|
||||
})();
|
||||
|
||||
stop() {
|
||||
this.timer.stop();
|
||||
if (this.stream !== null) {
|
||||
this.stream.close();
|
||||
this.stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionClosed() {
|
||||
this.timer.stop();
|
||||
showAlert(
|
||||
this.status,
|
||||
"Connection lost. Results are not live. Refresh to reconnect."
|
||||
);
|
||||
}
|
||||
|
||||
handlePollEnded(data) {
|
||||
this.stop();
|
||||
window.location.href = data.results_url;
|
||||
}
|
||||
|
||||
handlePollCancelled() {
|
||||
this.stop();
|
||||
showAlert(this.status, "This poll has been cancelled.");
|
||||
disableControls(this.root);
|
||||
}
|
||||
}
|
||||
|
||||
const config = readJsonConfig("poll-config");
|
||||
new VotePage(document, config).start();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 Logan Fick
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export function readJsonConfig(id) {
|
||||
const script = document.getElementById(id);
|
||||
if (!(script instanceof HTMLScriptElement)) {
|
||||
throw new Error(`Missing JSON config script: #${id}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(script.textContent || "{}");
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON config script: #${id}`, { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2026 Logan Fick
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export function requireElement(root, selector, elementType = Element) {
|
||||
const element = root.querySelector(selector);
|
||||
if (element === null) {
|
||||
throw new Error(`Missing required element: ${selector}`);
|
||||
}
|
||||
if (!(element instanceof elementType)) {
|
||||
throw new Error(`Element ${selector} is not a ${elementType.name}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export function createTextElement(tagName, className, text) {
|
||||
const element = document.createElement(tagName);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
|
||||
export function setHidden(element, hidden) {
|
||||
element.classList.toggle("d-none", hidden);
|
||||
}
|
||||
|
||||
export function showAlert(container, message, variant = "danger") {
|
||||
const alert = document.createElement("div");
|
||||
alert.className = `alert alert-${variant} mb-0`;
|
||||
alert.setAttribute("role", "alert");
|
||||
alert.textContent = message;
|
||||
|
||||
container.replaceChildren(alert);
|
||||
}
|
||||
|
||||
export function disableControls(root) {
|
||||
for (const control of root.querySelectorAll("button, input, select, textarea")) {
|
||||
control.disabled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Logan Fick
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseEventData(event) {
|
||||
try {
|
||||
return JSON.parse(event.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isKeepalivePayload(value) {
|
||||
return isRecord(value) && Number.isFinite(value.time_remaining);
|
||||
}
|
||||
|
||||
export function isPollEndedPayload(value) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.results_url === "string" &&
|
||||
value.results_url.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function isTallyPayload(value, optionCount) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
Array.isArray(value.counts) &&
|
||||
value.counts.length === optionCount &&
|
||||
value.counts.every((count) => Number.isInteger(count) && count >= 0)
|
||||
);
|
||||
}
|
||||
|
||||
export class PollEventStream {
|
||||
constructor(url, { onClosed } = {}) {
|
||||
this.source = new EventSource(url);
|
||||
this.onClosed = onClosed;
|
||||
|
||||
this.source.addEventListener("error", () => {
|
||||
if (this.source.readyState === EventSource.CLOSED) {
|
||||
this.close();
|
||||
if (this.onClosed) {
|
||||
this.onClosed();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
on(eventName, handler, validator = () => true) {
|
||||
this.source.addEventListener(eventName, (event) => {
|
||||
const data = parseEventData(event);
|
||||
if (validator(data)) {
|
||||
handler(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.source.close();
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
{% for opt in form.options %}
|
||||
<div class="input-group mb-2 option-row">
|
||||
<span class="input-group-text option-number">{{ loop.index }}</span>
|
||||
<input type="text" class="form-control option-input" name="option_{{ loop.index }}" required maxlength="{{ constraints.max_option_length }}" placeholder="Option {{ loop.index }}" value="{{ opt }}">
|
||||
<input type="text" class="form-control option-input" name="options" required maxlength="{{ constraints.max_option_length }}" placeholder="Option {{ loop.index }}" value="{{ opt }}">
|
||||
{% if loop.index > 2 %}
|
||||
<button type="button" class="btn btn-outline-danger remove-option">Remove</button>
|
||||
{% endif %}
|
||||
@@ -52,20 +52,22 @@
|
||||
|
||||
<div class="mb-3 ms-4 {{ '' if form.allow_multiple else 'd-none' }} d-flex align-items-center gap-2 flex-wrap" id="multi-select-options">
|
||||
<span>Voters must select between</span>
|
||||
<input type="number" class="form-control form-control-sm" style="width: 5rem" id="min_selections" name="min_selections" min="1" placeholder="1" value="{{ form.min_selections }}">
|
||||
<input type="number" class="form-control form-control-sm" style="width: 5rem" id="min_selections" name="min_selections" min="1" placeholder="1" value="{{ form.min_selections }}" {% if not form.allow_multiple %}disabled{% endif %}>
|
||||
<span>and</span>
|
||||
<input type="number" class="form-control form-control-sm" style="width: 5rem" id="max_selections" name="max_selections" min="1" value="{{ form.max_selections }}">
|
||||
<input type="number" class="form-control form-control-sm" style="width: 5rem" id="max_selections" name="max_selections" min="1" value="{{ form.max_selections }}" {% if not form.allow_multiple %}disabled{% endif %}>
|
||||
<span>options.</span>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="options" id="options-hidden">
|
||||
<button type="submit" class="btn btn-primary">Create Poll</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>window.CREATE_CONFIG = {
|
||||
maxOptions: {{ constraints.max_options | tojson }},
|
||||
maxOptionLength: {{ constraints.max_option_length | tojson }}
|
||||
};</script>
|
||||
<script src="{{ create_js_url }}?v={{ owlbot_version }}"></script>
|
||||
<script id="create-config" type="application/json">
|
||||
{{ {
|
||||
"minOptions": constraints.min_options,
|
||||
"maxOptions": constraints.max_options,
|
||||
"maxOptionLength": constraints.max_option_length
|
||||
} | tojson }}
|
||||
</script>
|
||||
<script type="module" src="{{ create_js_url }}?v={{ owlbot_version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,15 +9,40 @@
|
||||
<p class="text-body-secondary mb-1">Time remaining: <strong id="timer">--:--</strong></p>
|
||||
{% if poll.hidden %}
|
||||
{% if viewer_is_moderator %}
|
||||
<p class="text-body-secondary mb-0">Results are hidden until this poll ends. As a moderator, you can still see the real-time results.</p>
|
||||
<p class="text-body-secondary mb-{{ 1 if poll.allow_multiple else 0 }}">Results are hidden until this poll ends. As a moderator, you can still see the real-time results.</p>
|
||||
{% else %}
|
||||
<p class="text-body-secondary mb-0">Results are hidden until this poll ends.</p>
|
||||
<p class="text-body-secondary mb-{{ 1 if poll.allow_multiple else 0 }}">Results are hidden until this poll ends.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-body-secondary mb-0">Results are updated in real time.</p>
|
||||
<p class="text-body-secondary mb-{{ 1 if poll.allow_multiple else 0 }}">Results are updated in real time.</p>
|
||||
{% endif %}
|
||||
{% if poll.allow_multiple %}
|
||||
{% set min_count = poll.min_selections or 1 %}
|
||||
{% set max_count = poll.max_selections %}
|
||||
{% set min_label = min_count ~ " option" ~ ("s" if min_count != 1 else "") %}
|
||||
<p class="text-body-secondary mb-0">
|
||||
{% if max_count is none %}
|
||||
{% if min_count == 1 %}
|
||||
Select one or more options.
|
||||
{% else %}
|
||||
Select at least {{ min_label }}.
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% set max_label = max_count ~ " option" ~ ("s" if max_count != 1 else "") %}
|
||||
{% if min_count == max_count %}
|
||||
Select exactly {{ min_label }}.
|
||||
{% else %}
|
||||
Select {{ min_count }} to {{ max_label }}.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ vote_url }}" id="vote-form">
|
||||
<div class="d-flex flex-column gap-2 mb-4">
|
||||
{% for option in poll.options %}
|
||||
@@ -26,11 +51,11 @@
|
||||
<label>
|
||||
{% if poll.allow_multiple %}
|
||||
<input type="checkbox" name="option" value="{{ loop.index0 }}" class="form-check-input me-2"
|
||||
{% if loop.index0 in current_votes %}checked{% endif %}
|
||||
{% if loop.index0 in current_selections %}checked{% endif %}
|
||||
{% if has_voted or (not viewer_is_authenticated and poll.requires_auth) %}disabled{% endif %}>
|
||||
{% else %}
|
||||
<input type="radio" name="option" value="{{ loop.index0 }}" class="form-check-input me-2" required
|
||||
{% if loop.index0 in current_votes %}checked{% endif %}
|
||||
{% if loop.index0 in current_selections %}checked{% endif %}
|
||||
{% if has_voted or (not viewer_is_authenticated and poll.requires_auth) %}disabled{% endif %}>
|
||||
{% endif %}
|
||||
{{ option }}
|
||||
@@ -66,18 +91,14 @@
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.POLL_CONFIG = {
|
||||
hidden: {{ poll.hidden | tojson }},
|
||||
timeRemaining: {{ poll.time_remaining | tojson }},
|
||||
allowMultiple: {{ poll.allow_multiple | tojson }},
|
||||
minSelections: {{ poll.min_selections | tojson }},
|
||||
maxSelections: {{ poll.max_selections | tojson }}
|
||||
};
|
||||
window.VIEWER_INFO = {
|
||||
isModerator: {{ viewer_is_moderator | tojson }},
|
||||
eventsUrl: {{ events_url | tojson }}
|
||||
};
|
||||
<script id="poll-config" type="application/json">
|
||||
{{ {
|
||||
"hidden": poll.hidden,
|
||||
"timeRemaining": poll.time_remaining,
|
||||
"isModerator": viewer_is_moderator,
|
||||
"eventsUrl": events_url,
|
||||
"optionCount": poll.options | length
|
||||
} | tojson }}
|
||||
</script>
|
||||
<script src="{{ polls_js_url }}?v={{ owlbot_version }}"></script>
|
||||
<script type="module" src="{{ polls_js_url }}?v={{ owlbot_version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,19 +16,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
import orjson
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
MAX_QUESTION_LENGTH = 200
|
||||
MAX_OPTION_LENGTH = 50
|
||||
@@ -42,37 +36,58 @@ STREAM_GRACE_PERIOD = 5 * 60
|
||||
RESULT_EXPIRY = 3600
|
||||
|
||||
|
||||
class SSEEvent(StrEnum):
|
||||
"""Server-Sent Event types for the polls module."""
|
||||
|
||||
KEEPALIVE = "keepalive"
|
||||
POLL_CANCELLED = "poll_cancelled"
|
||||
POLL_ENDED = "poll_ended"
|
||||
TALLY = "tally"
|
||||
class PollError(Exception):
|
||||
"""Base class for polls module domain errors."""
|
||||
|
||||
|
||||
def sse_payload(event_type: SSEEvent, data: dict[str, Any]) -> bytes:
|
||||
"""Serialize an SSE event into wire format.
|
||||
|
||||
:param event_type: The event type name.
|
||||
:param data: The JSON-serializable event data.
|
||||
:return: Encoded SSE frame ready for ``StreamResponse.write()``.
|
||||
"""
|
||||
return b"event: " + event_type.encode() + b"\ndata: " + orjson.dumps(data) + b"\n\n"
|
||||
class PollStateError(PollError):
|
||||
"""Raised when an operation is invalid for the current poll state."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PollSSEClient:
|
||||
"""A connected live-results stream for a poll viewer.
|
||||
class PollAlreadyActiveError(PollStateError):
|
||||
"""Raised when creating a poll while another one is active."""
|
||||
|
||||
:param user_id: Owncast user ID associated with the browser session.
|
||||
:param is_moderator: Whether this viewer can see hidden live tallies.
|
||||
:param response: The active SSE response.
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
"""Initialize with the user-facing active-poll message."""
|
||||
super().__init__("A poll is already active.")
|
||||
|
||||
user_id: str
|
||||
is_moderator: bool
|
||||
response: web.StreamResponse
|
||||
|
||||
class NoActivePollError(PollStateError):
|
||||
"""Raised when an operation requires an active poll."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize with the user-facing missing-poll message."""
|
||||
super().__init__("No active poll.")
|
||||
|
||||
|
||||
class PollCreateValidationError(PollError):
|
||||
"""Raised when poll creation input is invalid."""
|
||||
|
||||
|
||||
class PollVoteError(PollError):
|
||||
"""Raised when a vote cannot be accepted."""
|
||||
|
||||
|
||||
class PollVoteValidationError(PollVoteError):
|
||||
"""Raised when a submitted vote is invalid."""
|
||||
|
||||
|
||||
class PollAuthenticationRequiredError(PollVoteError):
|
||||
"""Raised when an unauthenticated user tries to vote in an auth-only poll."""
|
||||
|
||||
def __init__(self, user_id: str) -> None:
|
||||
"""Initialize with the denied user ID."""
|
||||
self.user_id = user_id
|
||||
super().__init__("This poll requires an authenticated account to vote.")
|
||||
|
||||
|
||||
class DuplicateVoteError(PollVoteError):
|
||||
"""Raised when the same user tries to vote more than once."""
|
||||
|
||||
def __init__(self, user_id: str) -> None:
|
||||
"""Initialize with the duplicate voter's user ID."""
|
||||
self.user_id = user_id
|
||||
super().__init__("You have already voted.")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -81,7 +96,8 @@ class ActivePoll:
|
||||
|
||||
:param question: The poll question.
|
||||
:param options: List of poll options.
|
||||
:param hidden: Whether vote results are hidden until the poll ends.
|
||||
:param hidden: Whether live result tallies are hidden from non-moderator
|
||||
viewers until the poll ends.
|
||||
:param requires_auth: Whether voters must be authenticated.
|
||||
:param allow_multiple: Whether voters can select multiple options.
|
||||
:param min_selections: Minimum number of selections required (multi-select).
|
||||
@@ -89,8 +105,6 @@ class ActivePoll:
|
||||
:param duration: Poll duration in seconds.
|
||||
:param created_at: Timestamp when the poll was created.
|
||||
:param votes: Mapping of user ID to set of selected option indices.
|
||||
:param sse_clients: Mapping of user ID to SSE client response metadata.
|
||||
:param timer_task: Asyncio task for the poll timer.
|
||||
"""
|
||||
|
||||
question: str
|
||||
@@ -103,23 +117,30 @@ class ActivePoll:
|
||||
duration: int
|
||||
created_at: datetime
|
||||
votes: dict[str, set[int]] = field(default_factory=dict)
|
||||
sse_clients: dict[str, PollSSEClient] = field(default_factory=dict)
|
||||
timer_task: asyncio.Task[None] | None = None
|
||||
done_event: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
_started_at_mono: float = field(default_factory=time.monotonic)
|
||||
|
||||
@property
|
||||
def time_remaining(self) -> int:
|
||||
"""Seconds remaining until the poll ends, minimum 0."""
|
||||
"""Seconds remaining until the poll ends, minimum 0.
|
||||
|
||||
:return: Remaining poll duration in whole seconds.
|
||||
"""
|
||||
elapsed = time.monotonic() - self._started_at_mono
|
||||
return max(0, int(self.duration - elapsed))
|
||||
|
||||
|
||||
class PollResults(TypedDict):
|
||||
"""Computed results of a poll."""
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResults:
|
||||
"""Computed results of a poll.
|
||||
|
||||
counts: list[int]
|
||||
winners: list[str]
|
||||
:param counts: Vote counts aligned with poll option order.
|
||||
:param winners: Option labels tied for the highest vote count.
|
||||
:param is_tie: Whether multiple options share the highest vote count.
|
||||
:param total_votes: Number of voters represented in the result.
|
||||
"""
|
||||
|
||||
counts: tuple[int, ...]
|
||||
winners: tuple[str, ...]
|
||||
is_tie: bool
|
||||
total_votes: int
|
||||
|
||||
@@ -131,7 +152,7 @@ def compute_results(options: list[str], votes: dict[str, set[int]]) -> PollResul
|
||||
|
||||
:param options: List of poll option strings.
|
||||
:param votes: Mapping of user ID to set of selected option indices.
|
||||
:return: Dict with counts, winners, is_tie, and total_votes.
|
||||
:return: Immutable results with counts, winners, tie state, and voter total.
|
||||
"""
|
||||
num_options = len(options)
|
||||
counts = [0] * num_options
|
||||
@@ -144,36 +165,70 @@ def compute_results(options: list[str], votes: dict[str, set[int]]) -> PollResul
|
||||
|
||||
max_count = max(counts) if counts else 0
|
||||
if total_votes == 0 or max_count == 0:
|
||||
winners = []
|
||||
winners: list[str] = []
|
||||
is_tie = False
|
||||
else:
|
||||
winners = [options[i] for i, c in enumerate(counts) if c == max_count]
|
||||
is_tie = len(winners) > 1
|
||||
|
||||
return {
|
||||
"counts": counts,
|
||||
"winners": winners,
|
||||
"is_tie": is_tie,
|
||||
"total_votes": total_votes,
|
||||
}
|
||||
return PollResults(
|
||||
counts=tuple(counts),
|
||||
winners=tuple(winners),
|
||||
is_tie=is_tie,
|
||||
total_votes=total_votes,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CompletedPoll:
|
||||
"""Snapshot of a completed poll's results.
|
||||
|
||||
Stored in memory on PollManager as the most recent result.
|
||||
Stored in memory as the most recent poll result.
|
||||
Ephemeral — not persisted to disk.
|
||||
|
||||
:param question: The poll question.
|
||||
:param options: Poll options sorted by vote count descending.
|
||||
:param options: Poll options sorted by vote count descending, then by label
|
||||
for ties.
|
||||
:param results: Computed results (counts, winners, tie status).
|
||||
:param created_at: When the poll was created.
|
||||
:param ended_at: When the poll ended.
|
||||
"""
|
||||
|
||||
question: str
|
||||
options: list[str]
|
||||
options: tuple[str, ...]
|
||||
results: PollResults
|
||||
created_at: datetime
|
||||
ended_at: datetime
|
||||
|
||||
|
||||
def complete_poll(poll: ActivePoll, ended_at: datetime) -> CompletedPoll:
|
||||
"""Build a completed poll snapshot with display-ready sorted results.
|
||||
|
||||
:param poll: Active poll to complete.
|
||||
:param ended_at: Timestamp when the poll ended.
|
||||
:return: Completed poll snapshot with options sorted by result count, then
|
||||
by label for ties.
|
||||
"""
|
||||
raw_results = compute_results(
|
||||
poll.options,
|
||||
{user_id: set(selections) for user_id, selections in poll.votes.items()},
|
||||
)
|
||||
counts = raw_results.counts
|
||||
sorted_indices = sorted(
|
||||
range(len(poll.options)),
|
||||
key=lambda i: (-counts[i], poll.options[i].lower()),
|
||||
)
|
||||
sorted_options = tuple(poll.options[i] for i in sorted_indices)
|
||||
|
||||
return CompletedPoll(
|
||||
question=poll.question,
|
||||
options=sorted_options,
|
||||
results=PollResults(
|
||||
counts=tuple(counts[i] for i in sorted_indices),
|
||||
winners=raw_results.winners,
|
||||
is_tie=raw_results.is_tie,
|
||||
total_votes=raw_results.total_votes,
|
||||
),
|
||||
created_at=poll.created_at,
|
||||
ended_at=ended_at,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user