Skip to content

<goma-live-chat> ​

Per-match live chat room. Renders a chat surface scoped to one sporting event (a join prompt, a live message feed with reactions and moderation notices, a composer, and an optional "share a bet" screen) backed by a GomaChat service over REST plus Socket.IO. It is half-aligned with the conventions the sports widgets share: its theme tokens are the house --goma-* set, but its events keep the gc:* namespace rather than goma:*.

Element tag ​

html
<goma-live-chat></goma-live-chat>

Installation ​

bash
pnpm add @gomagaming/live-chat vue pinia vue-router vue-i18n @vueuse/core

The package was renamed

The published name is @gomagaming/live-chat, with a hyphen. The old @gomagaming/livechat is a different, superseded package: installing it gets you the pre-migration widget, with --gc-* tokens and a bundled Vue.

@gomagaming/live-chat is a private scoped package, so the one-time .npmrc token setup in Installation applies here too: without it the install 404s.

The package is ESM-only: the exports map declares an import condition and nothing else, so there is no require() entry and no UMD/IIFE build for a <script src> tag.

The framework peer set is required, and for two reasons at once. vue, pinia and vue-i18n are peerDependencies of this package: its own code externalises them the same way every other shipped widget does, rather than bundling a private copy. vue-router and @vueuse/core are declared peers even though this package imports neither: they are needed by the embedded <goma-my-bets>, which pulls in @gomagaming/core and @gomagaming/sports-domain. Skip any of the five and the module graph fails to resolve at load time, bet sharing enabled or not.

@gomagaming/my-bets installs itself

Unlike the pre-migration package, this one declares @gomagaming/my-bets as a regular dependency, so your package manager installs it for you and you never import or register it yourself. <goma-my-bets> registers through its own customElements.define() side effect.

It is still externalised from the bundle and still reached through a static import (ShareTickets.vue does import '@gomagaming/my-bets', which the ESM build hoists to the top of dist/live-chat.js), so it loads even when bet sharing is disabled. See goma-my-bets.

Import once at your application entry: the module registers the element as a side effect:

js
import '@gomagaming/live-chat'

There is no exported define() function. Registration is guarded by a customElements.get() check, so importing more than once is safe.

No TypeScript declarations ship. The package is plain JavaScript with JSDoc; there is no .d.ts in the tarball and no types condition in the exports map. In a TypeScript host the element is an untyped custom element: declare its properties yourself if you need them.

There is no stylesheet to import, and nothing to import it from. All widget CSS is inlined into the JS and injected into the shadow root via adoptedStyleSheets; the build emits no dist/style.css at all, and the published tarball carries only dist/live-chat.js, dist/playground.js, custom-elements.json, the README and the CHANGELOG. clientstyling / clientstylingurl are the supported channels for extra CSS: see Theming.

Configuration ​

The widget takes both of gomawt's configuration patterns at once. Every key below can be set as an HTML attribute or as a key on the config object property: both write into the same reactive state, so they compose rather than compete:

js
el.setAttribute('api-url', '/goma-chat')
el.config = { gameId: 'game_67890', locale: 'pt' }   // api-url survives

Assigning config is a shallow merge: only the keys named in the object you assign are touched, and whichever path ran last wins for that key. Reading el.config returns the live resolved state. Three values stay JS-property-only, because a callback or an object cannot round-trip through a string attribute: myBets, onDemoSend, and the vestigial getTickets. There are no per-key accessors: el.apiUrl = … does nothing.

The minimum viable configuration is five attributes:

html
<goma-live-chat
  api-url="/goma-chat"
  socket-url="/"
  auth-token="<chat access token>"
  socket-auth-token="<chat access token>"
  game-id="<event id>"
></goma-live-chat>

Both URLs point at the chat service. In the reference deployment the host proxies it same-origin, so api-url is the relative /goma-chat and socket-url is /: Socket.IO parses a URL's path component as a namespace, so it cannot be given the REST prefix and must be proxied separately at /socket.io/. Every example on this page uses that form; see Local development and Deployment notes for the proxy on each side.

If any one of api-url, socket-url, auth-token or game-id is missing or empty, the widget fails silently: it stays in its idle phase, renders nothing, and emits no error. See Empty / loading / error states.

Authentication ​

The widget takes two opaque token strings and validates neither. It has no login provider, no async callback, no sessionId property, and no built-in integration with any identity provider. Obtaining a chat token is entirely the host's job.

REST calls carry Authorization: Bearer <auth-token>, plus x-api-key: <api-key> when that attribute is set. The socket handshake sends both tokens, in both the query string and the auth payload:

js
io(socketUrl, {
  query: { EIO: '4', authToken },
  auth:  { token: socketAuthToken, authToken },
  transports: ['polling', 'websocket'],
  path: '/socket.io/',
})

In practice both attributes receive the same JWT.

Getting a token ​

The widget accepts a token; it does not tell you how to mint one. The chat service exposes one endpoint for that (POST /auth/exchange) which trades a platform session for a chat identity:

js
const res = await fetch('/goma-chat/auth/exchange', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ provider: 'everymatrix', sessionId, username }),
})
const { accessToken, refreshToken, user } = await res.json()

el.setAttribute('auth-token', accessToken)
el.setAttribute('socket-auth-token', accessToken)
el.setAttribute('user-id', user.id)
el.setAttribute('username', user.username)

No secret is involved: the chat service verifies sessionId against the platform itself, server-side, so this call is safe from a browser. username is a display seed only: it names a brand-new chat user and is ignored for an existing one.

Read Chat identity and the provider model before integrating. It covers the two exchange paths and when each needs a shared key, the full request/response and error contract, the EveryMatrix provider end to end with its configuration, and what changes if you run a different platform or your own backend.

Run the exchange once per player, not once per widget. If you also embed goma-messenger, exchange once and feed both widgets the same result. A second exchange mints a second chat identity, and the same player would author messages as two strangers across the two surfaces.

Expiry and re-auth ​

There is no session-expiry event and no 401/403 handling. An expired token surfaces only as a generic failure:

FailureSignal
REST rejects the joingc:error { code: 'JOIN_FAILED', message: 'Failed to join game chat: HTTP 401: …' }
Socket handshake rejectedgc:error { code: 'SOCKET_CONNECT_ERROR' }
Socket droppedgc:disconnected { reason }
10 reconnects exhaustedgc:disconnected { reason: 'reconnect_failed' }, then gc:error { code: 'SOCKET_RECONNECT_FAILED' }

The host owns re-auth. Get a new access token (POST /auth/refresh with the refresh token from the exchange avoids a full re-exchange) then setAttribute('auth-token', …). That triggers a full teardown and rebuild of the REST client and socket. The rebuild deliberately preserves the ended-game latch and final score, so rotating a token mid-match does not reopen a closed room.

Data sources ​

All chat data comes from one GomaChat service. Point api-url and socket-url at it; in the reference deployment they are the same origin.

REST ​

Base URL is api-url. Every call carries the auth headers above and aborts after 10 seconds. A non-2xx response throws HTTP {status}: {statusText}.

MethodPathReturns
POST/game-chat/{gameId}/join{ room_id, participant_count }
POST/game-chat/{gameId}/leave-
GET/game-chat/{gameId}/messages?before=&limit={ messages }: newest-first; the widget re-sorts ascending
GET/game-chat/{gameId}/participants/count{ count }

Backfill pages are 100 messages. The client trims to 500 messages as new ones arrive: the initial backfill and the reconnect merge assign without trimming, so the list can sit above 500 until the next message lands.

None of these are configurable: they are compiled into the widget:

Compiled-in limits

Messages held in memory
500
Trimmed on new messages, not on backfill
Messages per backfill page
100
Newest-first from REST
Distinct emoji per message
20
Local toggle path only
REST request timeout
10s (caution)
Send-acknowledgement timeout
10s (caution)
Then the message is rolled back
Reconnect attempts
10 (caution)
1s backoff rising to 30s

How a join actually happens ​

Join sequence

  1. Socket connect
    At bootstrap, before any REST call. Handshake carries both tokens; gc:connected fires.
  2. REST join
    POST /game-chat/{gameId}/join returns room_id and participant_count.
  3. Room join (note)
    game_chat_join emitted over the socket.
  4. Backfill, then gc:joined (good)
    Up to 100 messages from REST, re-sorted oldest-first. gc:joined fires only after they arrive.

Socket.IO ​

Path is /socket.io/, transports ['polling', 'websocket'], 10 reconnect attempts with a 1 s → 30 s backoff.

Client → server:

EventPayload
game_chat_join{ game_id }
game_chat_leave{ game_id }
game_chat_send_message{ room_id, body, idempotency_key, attachment? } + ack
game_chat_react{ game_id, message_id, emoji }: no ack callback is passed, so a rejection is never surfaced

Server → client:

EventPayload
game_chat_message{ message }
game_chat_bet_card{ message }
game_chat_closed{ game_id, room_id }
participant_count_update{ game_id, count }
moderation_notice{ message_id, category, reason }
escalation_state_change{ state, violation_count, muted_until? }
game_chat_reaction_updated{ game_id, room_id, message_id, reactions }

Messages are sent over the socket, not over REST. The send ack carries status: 'ok' | 'blocked' | 'error'; only 'ok' keeps the message. Anything else (including a malformed ack or the 10-second timeout) rolls the optimistic message back and fires gc:error.

gc:message-sent is not a delivery receipt. It fires optimistically, before the server acknowledges. Treat delivery as confirmed only if no matching gc:error follows.

Demo mode ​

demo bypasses REST and the socket entirely. Unlike goma-messenger, whose demo mode runs its own scripted intervals, this one generates nothing on its own: every message comes from the host via injectMessage(). Assign the onDemoSend JS property to observe composer sends; a throw inside it is caught and re-emitted as gc:error with code DEMO_SEND_ERROR.

Props / attributes ​

PropertyAttributeDefaultDescription
-api-url''REST base URL. Required.
-socket-url''Socket.IO origin. Required.
-auth-token''Bearer token for REST and the socket. Required.
-socket-auth-token''Token sent as the socket auth.token. Required in practice.
-game-id''Opaque room key: the event this chat belongs to. Required.
-user-id-Identifies the viewer's own messages.
-username-Display name stamped on optimistic messages.
-locale'en'One of en, pt, fr, tr, da.
-layout'inline''inline' or 'collapsible'. Note the attribute is layout, not position.
-expanded-Host-owned fullscreen flag. The widget never sets this itself.
-enable-bet-sharingfalseEnables the share-a-bet screen. Also requires myBets.
-ticket-share-cooldown-seconds600Cooldown between shared bets.
themetheme{}The attribute takes a JSON string of --goma-* overrides; the property takes the object directly and routes through applyTheme(). Reading the property reports what the widget actually painted. See Theming.
-icon-base-url-Accepted and stored, but currently inert: every glyph in the shipped widget is an inline SVG, and the one component that reads this value is not referenced anywhere. Treat it as reserved.
-api-key-Sent as x-api-key.
-font-urlExo 2 on Google FontsStylesheet URL, loaded via a <link> in document.head. Set to 'none' to disable font injection. See Font loading is document-level.
-demo-Bypasses REST and the socket.
-clientstyling-Raw CSS appended to the shadow root.
-clientstylingurl-URL fetched for the same purpose.
-translationurl-URL of a { locale: messages } JSON bundle.
config-see aboveGetter/setter. Reading returns the live resolved config; assigning shallow-merges the named keys into it (and applies theme if present). See Configuration.
myBets-undefined{ config?, sessionId?, theme? }, forwarded verbatim to the embedded <goma-my-bets>. Object value, so it must be a property.
onDemoSend-undefinedDemo-mode composer callback.
config.demoParticipantCount-42Seeds the participant count in demo mode. Config-key only: there is no attribute for it, so set it through el.config.

Boolean attributes are true only when the value is exactly "true" or "". So expanded="" is true, while expanded="1" and expanded="yes" are both false.

Removing an attribute resets booleans to false and everything else to undefined. Removing theme clears exactly the custom properties it had set. Three attributes are exempt: removing clientstyling clears the stylesheet it applied, while removing clientstylingurl or translationurl does nothing at all. CSS already fetched and locale messages already merged stay in place.

Not every attribute is live. Changing api-url, socket-url, auth-token, game-id or demo re-bootstraps the widget. But socket-auth-token, api-key, user-id and username are read only at connect or send time, and font-url is honoured once at mount. To apply a change to the connect- or send-time attributes, also touch one of the re-bootstrapping attributes. font-url is the exception: it is read in the same step that adopts the base stylesheet, which a re-bootstrap never repeats. Only removing the element and re-inserting it picks up a new value.

Font loading is document-level ​

The font stylesheet is a <link> appended to document.head, not to the shadow root. font-url names a stylesheet (Google Fonts by default) so what it delivers is an @font-face rule, and Firefox has a long-standing bug where an @font-face declared inside a shadow root is ignored. A document-level one applies inside shadow DOM everywhere. (goma-messenger does the same thing, so the two match.)

Three consequences for a host:

  • The link carries data-goma-font="<url>" and is deduped by URL. Ten <goma-live-chat> instances sharing a font-url produce one link.
  • It is not removed on unmount: deliberately, because it is shared across instances and cheap to keep. Removing the last <goma-live-chat> from the page still leaves the link in document.head.
  • Re-inserting the element with a different font-url appends a second link and leaves the first. Nothing ever removes a link once added; if your CSP or your own teardown cares, remove link[data-goma-font] yourself.

Set font-url="none" and no link is created at all.

Reflected attribute ​

With layout="collapsible" the widget writes dock-state="sliver" | "open" | "full" back onto the host element and owns its own height and resize animation. See Sizing.

Methods ​

MethodSignatureBehaviour
applyTheme(theme: Record<string, string | null | undefined>) => voidWrites each key as an inline custom property on the host. Keys accept a bare name (backgroundPrimary), a ---prefixed name (--backgroundPrimary), or the fully-qualified --goma-backgroundPrimary: all three normalise to the same property. A key still carrying the retired --gc-* prefix is dropped, not normalised, and warned about. A null, undefined or '' value removes that property.
applyClientStyling(css: string) => voidReplaces the client stylesheet in the shadow root. Target :host in your rules.
endGame(context?: { homeTeam?, awayTeam?, homeScore?, awayScore? }) => voidFlips the room to its ended state. See Match end.
injectMessage(msg: GameChatMessage) => voidAdds a message to the feed. Emits gc:message-received unless the sender is the viewer.
removeMessage(messageId: string) => voidRemoves a message. Emits nothing.
addReaction(messageId, emoji, userId) => voidToggles: calling twice with the same triple removes the reaction.
emitEvent(name: string, detail: object) => voidThe public dispatch primitive.
setDemoModerationState(partial: Partial<ModerationState>) => voidDemo mode. Emits gc:moderation-state-changed.
emitDemoBlocked({ messageId?, category }) => voidDemo mode. Emits gc:message-blocked. messageId is accepted and ignored.

There is no sendMessage(), getMessages(), setPhase(), clearMessages() or reconnect().

getTickets is declared but dead. The element still exposes a getTickets property (part of the published surface, kept for one more major) but nothing in the package reads it. Bet sharing is driven by the myBets property and the embedded <goma-my-bets> widget. Do not build against getTickets on this widget: note that goma-messenger is different, where assigning it is still required to enable the composer's share button.

Events ​

Every event is dispatched with bubbles: true and composed: true, so it crosses the shadow boundary and reaches document. None are cancelable.

js
el.addEventListener('gc:tail-bet', (e) => {
  console.log('Copy this bet:', e.detail.selections)
})
EventDetailEmitted when
gc:connected{ socketId: string }Socket connected.
gc:disconnected{ reason: string }Socket dropped, or reason: 'reconnect_failed' after 10 attempts.
gc:joined{ roomId: string, participantCount: number }Room join succeeded.
gc:left{}Viewer left the room.
gc:message-received{ message: GameChatMessage }A message arrived from someone else.
gc:message-sent{ message: GameChatMessage }Composer send, fired optimistically before the ack.
gc:message-blocked{ category: string }Moderation rejected a message.
gc:moderation-state-changed{ state: ModerationState }Viewer was warned, muted or banned.
gc:game-ended{ homeTeam?, awayTeam?, homeScore?, awayScore? }Match ended. Detail is empty {} when the end came from the server.
gc:tail-bet{ betId: string, selections: BetSelection[] }"Bet the same" tapped in the bet-detail modal.
gc:phase-changed{ phase: 'idle' | 'loaded' | 'join' | 'chat' | 'ended' }Phase transition.
gc:expand{}The widget requests fullscreen.
gc:collapse{}The widget requests collapse.
gc:share-tickets-opened{}Share-a-bet screen opened.
gc:share-tickets-closed{}Share-a-bet screen closed.
gc:error{ code, message }See the code table below.

gc:expand and gc:collapse are requests, not state. The widget never sets its own expanded attribute. If the host does not echo the value back, the share screen and the bet-detail modal stay cramped.

js
el.addEventListener('gc:expand', () => el.setAttribute('expanded', 'true'))
el.addEventListener('gc:collapse', () => el.setAttribute('expanded', 'false'))

Opening the share screen or the bet-detail modal emits gc:expand, and closing it emits gc:collapse: both only if the widget was not already expanded.

gc:error codes ​

The codes emitted at runtime are below. The widget produces SOCKET_CONNECT_ERROR, SOCKET_RECONNECT_FAILED, JOIN_FAILED, SEND_TIMEOUT, SEND_FAILED and DEMO_SEND_ERROR itself. The remaining five are whatever the server put in the ack's error.code, forwarded verbatim, so treat that part of the list as open-ended rather than exhaustive:

CodeCause
SOCKET_CONNECT_ERRORHandshake failed.
SOCKET_RECONNECT_FAILED10 reconnect attempts exhausted.
JOIN_FAILEDThe REST join call failed; the HTTP status is in the message.
SEND_TIMEOUTNo ack within 10 s; the message was rolled back.
SEND_FAILEDThe ack reported an error with no specific code.
TICKET_COOLDOWNA bet was shared inside the cooldown window.
RATE_LIMITEDServer-side rate limit.
ROOM_NOT_FOUNDThe room no longer exists.
INVALID_PAYLOADThe server rejected the message shape.
INTERNAL_ERRORServer-side failure.
DEMO_SEND_ERRORAn onDemoSend callback threw.

Treat code as a string, not a closed union. No type declarations ship (see Installation), and the package's own internal GcErrorPayload JSDoc typedef is stale anyway: it names MISSING_ATTRIBUTE and API_LOAD_FAILED, neither of which is ever emitted, and omits SEND_TIMEOUT, SEND_FAILED and every server-forwarded code, all of which are. Handle the runtime set above and fall through on anything else.

Integrates with ​

This widget is not part of the goma:* cross-widget graph: it emits gc:* events and the wiring is host-mediated. Two pairings are worth setting up:

  • goma-game-details: listen for its goma:game-ended and call endGame(), as shown under Match end. Nothing happens automatically.
  • goma-my-bets: embedded inside the share-a-bet screen. It is a hard dependency of the bundle regardless, and the share screen needs it configured.

Bet sharing requires both enable-bet-sharing and a myBets bridge. With the flag set but no bridge, the composer's share button is not rendered at all, so the share screen cannot be opened. (A "not configured" state exists in the source but is unreachable for that reason.)

Note the attribute name differs from the sister widget: this one is enable-bet-sharing, goma-messenger uses enable-ticket-sharing. Copying one onto the other silently disables the feature.

js
el.setAttribute('enable-bet-sharing', 'true')
el.myBets = { config: sportsbookConfig, sessionId, theme: myBetsTheme }

The myBets object is opaque: the chat widget never inspects it, it forwards the three fields verbatim onto the embedded <goma-my-bets> element. See that widget's page for the shapes.

To feed a tailed bet into a betslip, listen for gc:tail-bet and re-dispatch into the betslip envelope. Note the live-chat payload carries outcomeId, eventId, names and odds, but not bettingTypeId or eventPartId: the two fields <goma-messenger> does carry, and which a provider needs to accept a slip. A host that tails a bet from live chat must resolve them before placing. See Shared state for the goma:betslip:v4 contract.

gc:tail-bet is emitted by both chat widgets. If you mount <goma-live-chat> and <goma-messenger> on the same page and listen on window, one handler receives both, and the two payloads are not equally complete. Bind on the element, or disambiguate on event.target.

Room binding and match end ​

The room is scoped by one opaque game-id. There is no eventId, matchId or roomId input: roomId is derived, returned by the REST join and surfaced only on gc:joined.

Changing game-id resets the store and rebuilds the connection. Joins are remembered in localStorage under gc-joined-{gameId}, so a page refresh rejoins automatically instead of re-showing the join prompt. Bet-share cooldowns persist under gc-ticket-cooldown-{gameId} and expire on read. Every storage access is guarded, so blocked or private-mode storage degrades gracefully rather than throwing.

Match end ​

The widget has no sports feed of its own, so it cannot know a match has finished. Two paths flip it to the ended state:

Host-driven. Call endGame() with an optional final snapshot:

js
el.endGame({ homeTeam: 'Arsenal', awayTeam: 'Chelsea', homeScore: 2, awayScore: 1 })

Server-driven. The game_chat_closed socket event does the same thing, and emits gc:game-ended with an empty detail.

Both paths are idempotent and both emit gc:game-ended. endGame() is a no-op if the widget is still idle or already ended. The final score renders only when both team names and both scores are present: 0 counts as present.

The ended state is a latch, not a phase. isGameEnded is tracked separately from the current phase, because collapsing the header flips the phase back to loaded. Phase changes are clamped against the latch, so an in-flight join that resolves after the match ended cannot reopen the composer. The latch clears when game-id changes, and on an explicit leave, though the Leave control is hidden once a match has ended, so in practice game-id is the route that matters.

If you are pairing this widget with goma-game-details, relay its match-end signal:

js
window.addEventListener('goma:game-ended', (e) => {
  const d = e.detail
  if (String(d.eventId) === currentGameId) {
    el.endGame({
      homeTeam: d.homeTeam, awayTeam: d.awayTeam,
      homeScore: d.homeScore, awayScore: d.awayScore,
    })
  }
})

Empty / loading / error states ​

The widget broadcasts phase transitions as gc:phase-changed: except the first one. The idle → loaded transition at bootstrap happens before the emitter is wired, so no listener ever sees it; the first event you receive is the one after it:

PhaseRendersEntered when
idleAn empty background box: 64 px tall when layout="collapsible". The header is not rendered eitherMounted but not configured
loadedThe header only (chat icon, title, participant count, the action buttons, and an unread +N badge) over an empty bodyConfigured. Every bootstrap lands here, in both layouts, and it is re-entered whenever the header collapses
joinThe join promptThe user tapped the caret from loaded and is not yet in a room
chatThe live feed and composerThe join succeeded, or an auto-rejoin restored a previous session
endedThe closed-match hero over blurred historyendGame() or game_chat_closed. Latched: see Match end

These are states, not stages: loaded is re-entered on collapse, and ended is reachable from any of them.

A correctly configured widget does not open itself

bootstrap() sets loaded on every successful bootstrap, with no check on layout. That is obvious in collapsible layout: loaded is the collapsed sliver the dock is designed around. It is much less obvious inline, where loaded renders the header across the full box you sized and leaves the rest of it blank, with no join prompt and no feed, until the user taps the caret-up control (or an auto-rejoin from a previous visit promotes it straight to chat).

So a first-time visitor sees a header and empty space, not a Join Chat button. This is the widget's designed entry point in both layouts, and it applies to every example on this page. If you want the join prompt visible immediately in an inline embed, you have to drive it from the host: the element exposes no open() method, so the only supported nudges are layout="collapsible" (where the sliver reads as deliberate) or sizing the inline box to the header until the user opens it.

A dropped socket renders a reconnecting banner, but only in the chat and ended phases: the banner lives inside the chat panel, so a drop while the widget is in loaded (header only) or showing the join prompt surfaces nothing. A failed initial participant-count call is swallowed silently.

Logging out ​

The chat access token cannot be revoked, so signing out is entirely host-side: stop feeding the widget and let the token expire on its own.

Clear auth-token, the attribute handler resets a removed attribute to undefined, so removeAttribute('auth-token') works here, or unmount the element altogether, which is the simpler answer when the dock lives on one route.

The two chat widgets tear down differently

This widget honours attribute removal; goma-messenger does not. Removing an attribute there leaves the previous value in place, so its logout needs setAttribute('auth-token', '') instead. A single teardown helper written for both will half-work. See its logout section.

Analytics and instrumentation ​

Every gc:* event is composed: true, so a single delegated listener on document captures every instance on the page without holding element references:

js
// A useful subset, not the full catalogue: see Events for all 16.
const CHAT_EVENTS = [
  'gc:phase-changed', 'gc:joined', 'gc:left', 'gc:message-sent',
  'gc:message-blocked', 'gc:expand', 'gc:collapse',
  'gc:share-tickets-opened', 'gc:game-ended', 'gc:error',
]
for (const type of CHAT_EVENTS) {
  document.addEventListener(type, (e) => track(e.type, e.detail))
}

The funnel ​

gc:phase-changed is a ready-made funnel: each transition is one stage:

Engagement funnel

  1. Configured and bootstrapped
    gc:phase-changed. Phase: 'loaded'. The header-only state, in either layout.
  2. Offered the join prompt
    gc:phase-changed. Phase: 'join'
  3. Joined the room (note)
    gc:joined. participantCount sizes the room they walked into
  4. Opened the composer
    gc:phase-changed. Phase: 'chat'
  5. Sent a first message (good)
    gc:message-sent
  6. Stayed to the end (good)
    gc:game-ended

Layer gc:expand / gc:collapse over that for engagement depth, gc:share-tickets-opened for the bet-sharing sub-funnel, and gc:message-blocked for the moderation rate.

Three traps in the data ​

gc:message-sent is optimistic. It fires before the server acknowledges, so a naive "messages sent" count includes messages that were rolled back moments later. Correlate each one with any gc:error that follows and count only the survivors, or your send volume will read high and your failure rate will read zero.

Errors correlate with engagement, not just failure. A session that produced a gc:error is a session where someone was actually typing. Do not read error presence as a cause of abandonment: measure the cost instead, by asking how many error sessions never produced a subsequent gc:message-sent or returned at all.

The most likely failure emits nothing at all. A widget missing any required attribute stays in its idle phase silently: no event and no gc:error. It is not visually absent, though: it still paints an empty themed box, 64 px tall in collapsible layout, so the page looks intact. That is invisible to analytics, so the failure mode most likely to hit you in production is the one you cannot measure.

Instrument the silent-idle case yourself. Nothing is dispatched when the widget is mounted but unconfigured. Detect it host-side: if no gc:phase-changed has arrived shortly after mount, the widget never started:

js
let started = false
el.addEventListener('gc:phase-changed', () => { started = true })
setTimeout(() => { if (!started) track('chat_never_started', { gameId }) }, 5000)

Without something like this, an unconfigured widget and an empty room look identical in your data.

Detecting dead clicks ​

gc:expand and gc:collapse are requests: the widget never sets its own expanded attribute. If the host does not echo it back, the control appears to do nothing and users tap it repeatedly. That registers as dead clicks in session-replay tools, and it is the most common way a correct integration still feels broken.

Assert the round trip in your own instrumentation: every gc:expand should be followed by the attribute actually changing. If it isn't, the wiring is missing.

Theming ​

Tokens use the house --goma-* prefix, so a theme object written for this widget looks like every other gomawt theme. The token names are its own, though: the theming guide documents the sports-domain family's set, and this widget declares a chat-shaped set of its own (--goma-chatComposerBackground, --goma-chatModerationMute, the --goma-dock* family, …). The overlap is the generic half: backgrounds, text, buttons, inputs, icons, borders. src/themeTokens.js is the authoritative registry and matches src/styles/variables.css token for token; the full list is below.

Four paths all converge on writing inline custom properties onto the host element:

js
// 1. Imperative
el.applyTheme({ '--goma-backgroundPrimary': '#1b1d2b', '--goma-highlightPrimary': '#29f2ff' })

// 2. The JS property: routes straight through applyTheme()
el.theme = { backgroundPrimary: '#1b1d2b' }

// 3. Declarative, as a JSON string
el.setAttribute('theme', JSON.stringify(theme))

// 4. A shipped preset
import { exclusivebetTheme } from '@gomagaming/live-chat'
el.applyTheme(exclusivebetTheme)

applyTheme() normalises its keys: backgroundPrimary, --backgroundPrimary and --goma-backgroundPrimary all set the same property, so you can hand it a bare-name theme object without prefixing every key yourself. Reading el.theme back reports the properties the widget actually painted, read off the host's own inline style, not an echo of the last object you assigned.

--gc-* keys are dropped, not translated

A key still carrying the retired --gc-* prefix is invalid CSS under the new token set, so applyTheme() skips it rather than applying it under a mangled name, and logs one console.warn per call listing every key it dropped. The warning fires in every build, not just development: the shared build preset hard-defines NODE_ENV as 'production', so a dev-only guard would be stripped from every published bundle and never reach the consumer it exists to help. If you are porting a theme from the pre-migration package, the console is your migration checklist.

Precedence: a host theme always wins. This widget applies its theme as inline custom properties on the host element, and an inline declaration beats the :host rule in the widget's own adopted stylesheet, so anything you pass to applyTheme() (or the theme attribute/property) overrides the shipped default for that token, unconditionally. The trade runs the other way for ordinary host-page CSS: a rule like

css
goma-live-chat { --goma-backgroundPrimary: #1b1d2b; }

works for any token applyTheme() has not set, and loses to applyTheme() for any token it has. Pick one channel per token. Note that goma-messenger resolves this differently (it themes through an adopted stylesheet) so precedence reasoning does not carry between the two widgets.

Three presets ship, all exported from the package entry: exclusivebetTheme, betssonfranceTheme and betathomeTheme: the same three goma-messenger exports.

For arbitrary CSS beyond the tokens, use applyClientStyling(css) or the clientstyling / clientstylingurl attributes, targeting :host. Apply it after the element has mounted: a clientstyling attribute present in the initial HTML is applied during upgrade and then discarded when the base stylesheet replaces the adopted sheet list. Only post-mount applications survive.

A built-in light theme is available by putting class="gc-light" on the element itself. There is no appearance concept and no prefers-color-scheme support; the default is dark.

Tokens used by this widget ​

The registry declares 57 tokens, and every one of them is consumed by the widget's own CSS: there are no declared-but-dead tokens left. Defaults below are the dark palette; the gc-light class redefines the colour half of the set (dimensions, fonts, timings and the dock family are shared by both).

TokenDefaultUsed for
--goma-backgroundPrimary#2a2d60Chat panel background
--goma-backgroundSecondary#1a1b3aHeader / footer background
--goma-backgroundTertiary#1f2147Secondary panel background
--goma-chatComposerBackground#161732Composer background
--goma-highlightPrimary#ff6600Highlight: primary (accent)
--goma-highlightSecondary#404cffHighlight: secondary
--goma-highlightTertiary#01b72eHighlight: tertiary
--goma-alertSuccess#21ba45Success alert
--goma-alertError#ed4f63Error alert
--goma-alertWarning#f2c037Warning alert
--goma-chatModerationWarning#f2c037Moderation: warning banner
--goma-chatModerationMute#ed4f63Moderation: mute banner
--goma-chatModerationBan#d93751Moderation: ban banner
--goma-chatModerationWarningBackground#353849Moderation: warning banner background
--goma-textPrimary#FFFFFFText: primary
--goma-textSecondary#939dffText: secondary
--goma-chatNameYou#ff6600The viewer's own name
--goma-chatNameOther#ff9d5cOther participants' names
--goma-allDark#000114All-dark text (inverse), on accent surfaces
--goma-inputTextTitle#8c92ffMuted text: labels, hints
--goma-textDisablePrimary#434799Section / disabled text
--goma-buttonBackgroundPrimary#ff6600Button: primary background (Join, Send)
--goma-buttonTextPrimary#FFFFFFButton: primary text
--goma-buttonActiveHoverPrimary#ff7e29Button: primary hover / active
--goma-buttonDisablePrimary#5c2500Button: primary disabled
--goma-buttonBackgroundSecondary#2a2d60Button: secondary background
--goma-buttonTextSecondary#FFFFFFButton: secondary text
--goma-buttonTextTertiary#939dffButton: ghost text
--goma-buttonBorderTertiary#2a2d60Button: ghost border
--goma-inputBackgroundSecondary#03061bInput background
--goma-inputBorderActive#ff6600Input border: active / focus
--goma-inputText#FFFFFFInput text
--goma-iconPrimary#FFFFFFIcon: primary
--goma-iconSecondary#5559b4Icon: secondary
--goma-separatorLine#2a2d60Separator / border
--goma-separatorLineSecondary#393d83Separator: secondary
--goma-radiusSurface8pxCorner radius: surfaces
--goma-radiusPill22pxCorner radius: pills
--goma-scroll#393d83Scrollbar thumb
--goma-fontFamily'Exo 2', system-ui, -apple-system, sans-serifTypeface
--goma-fontSize2xs9pxFont size: 2xs
--goma-fontSizeXs10pxFont size: xs
--goma-fontSizeSm12pxFont size: sm
--goma-fontSize13pxFont size: base
--goma-fontSizeLg15pxFont size: lg
--goma-fontBold600Font weight: bold
--goma-emoteSize24pxEmoji reaction size
--goma-transitionFast150ms easeTransition: fast
--goma-transitionNormal250ms easeTransition: normal
--goma-dockSliverHeight64pxDock: collapsed (sliver) height
--goma-dockOpenHeightmin(520px, calc(100dvh - 32px))Dock: open height
--goma-dockFullHeightcalc(100dvh - 32px)Dock: full height
--goma-dockAnimDuration250msDock: animation duration
--goma-liveTag#c0ce00LIVE tag background
--goma-liveTagText#030b1dLIVE tag text
--goma-chatProfit#84ff4aProfit / won-state figures
--goma-chatBetCardSubtitle#ade4ffBet-card subtitle

There is no bubble token, and no avatar, header-height or composer-height token. GameMessage.vue renders a flat, IRC-style line per message (sender name then body) not a chat bubble, so nothing paints a "mine vs. other" fill. The pre-migration package declared a bubble set (plus spacing and shadow scales) that its own CSS never read; those tokens were deleted rather than renamed, so a theme object carrying them now hits the --gc-* drop path described above.

The four --goma-dock* tokens only do anything in layout="collapsible": see Sizing.

Animations and reduced motion ​

Three pieces of styling live in src/styles/main.css alongside everything else the shadow root adopts, so they render like any other rule in the widget:

WhereSelectorEffect
Scrollable regions (GcScrollable.vue).gc-scrollable::-webkit-scrollbar / -thumbA 4 px WebKit scrollbar with a --goma-scroll thumb: narrower than the widget's 6 px global default, which it wins on selector specificity.
Quick-react bar (QuickReactBar.vue).gc-quick-react + @keyframes gc-react-popA 150 ms scale-and-fade pop-in when the bar appears.
Message list (GameChatPanel.vue).gc-msg-enter-active / .gc-msg-enter-fromA 300 ms fade-and-rise on each newly arriving message, driven by a TransitionGroup name="gc-msg".

The two motion rules honour @media (prefers-reduced-motion: reduce): the quick-react pop-in and the message-enter transition are both dropped entirely under it. The scrollbar rule is not motion and is unaffected.

The two timings are literals, not tokens. --goma-transitionFast and --goma-transitionNormal tune other surfaces, and --goma-dockAnimDuration tunes the collapsible dock, but the 150 ms pop-in and the 300 ms message transition are hardcoded. Overriding a timing token will not change either. clientstyling is the channel if you need to.

Sizing ​

By default the host is display: block; width: 100%; height: 100%, so the parent must have a resolved height or the widget collapses to zero.

With layout="collapsible" the widget instead owns its own height and its open/close animation, driven by the dock-state attribute it reflects onto itself. Give it a height: auto container and tune the sizes with custom properties:

TokenDefault
--goma-dockSliverHeight64px
--goma-dockOpenHeightmin(520px, calc(100dvh - 32px))
--goma-dockFullHeightcalc(100dvh - 32px)
--goma-dockAnimDuration250ms

Never set height on the element in collapsible mode. A height declaration in the light DOM overrides the widget's own :host([dock-state]) rules (outer styles beat :host for normal declarations) which suppresses the resize animation entirely. Size the container, not the element.

css
.chat-dock {
  position: fixed;
  right: 16px;
  bottom: 16px;
  width: 380px;
  height: auto;              /* wraps the widget, which sizes itself */
  --goma-dockOpenHeight: min(520px, calc(100dvh - 32px));
  --goma-dockFullHeight: calc(100dvh - 32px);
}
@media (max-width: 767px) {
  .chat-dock {
    right: 0; left: 0; bottom: 0; width: 100%;
    --goma-dockOpenHeight: 60dvh;
  }
}

Reduced-motion preferences are respected.

i18n / message overrides ​

Five locales ship in the bundle: en, pt, fr, tr, da. Set the active one with the locale attribute, which is fully reactive. An unknown locale falls back to en.

Message namespaces are gameChat, moderation, emoji, ticket, aria and common.

To add or override strings, point translationurl at a JSON bundle shaped as { "<locale>": { …messages } }. Fetch failures log a warning and nothing else.

The translation merge cannot be undone. Set the attribute after the element is in the DOM. The fetch handler returns early if the widget's i18n has not been created yet, which is the case while the element is still upgrading, so a translationurl present in the initial HTML is silently dropped and never retried. Removal is also a no-op once applied: the merged messages stay. Treat it as write-once, applied late.

goma-messenger behaves the same way here, and for the same reason.

Composer limits ​

Messages are capped at 500 characters. The limit is enforced with the input's own maxlength, so text is truncated as it is typed rather than rejected on send, and the send control disables at the ceiling.

A character counter appears once the message passes 80% of the limit: visible before it bites, but not before the writer has committed to a long message. If your audience writes long messages, consider surfacing the ceiling earlier in your own surrounding UI.

Note the asymmetry with goma-messenger, which caps at 150 characters: see its composer limits for the comparison. Do not assume the two chat surfaces accept the same message length.

Embedding examples ​

Vanilla HTML ​

html
<div class="chat-dock">
  <goma-live-chat id="chat"></goma-live-chat>
</div>
<script type="module">
  import '@gomagaming/live-chat'

  const el = document.getElementById('chat')
  el.applyTheme({ '--goma-backgroundPrimary': '#1b1d2b' })
  el.setAttribute('layout', 'collapsible')
  el.setAttribute('api-url', '/goma-chat')
  el.setAttribute('socket-url', '/')
  el.setAttribute('auth-token', chatToken)
  el.setAttribute('socket-auth-token', chatToken)
  el.setAttribute('user-id', chatUserId)
  el.setAttribute('username', username)
  el.setAttribute('game-id', eventId)

  el.addEventListener('gc:expand', () => el.setAttribute('expanded', 'true'))
  el.addEventListener('gc:collapse', () => el.setAttribute('expanded', 'false'))
</script>

React ​

jsx
import '@gomagaming/live-chat'

function MatchChat({ token, userId, username, gameId, theme }) {
  // Do not render until the token exists: an unconfigured widget renders
  // nothing and reports nothing, so a premature mount looks like an outage.
  if (!token) return null

  return (
    <goma-live-chat
      ref={(el) => {
        if (!el) return
        // The widget requests fullscreen but never sets `expanded` itself.
        el.addEventListener('gc:expand', () => el.setAttribute('expanded', 'true'))
        el.addEventListener('gc:collapse', () => el.setAttribute('expanded', 'false'))

        el.applyTheme(theme)
        el.setAttribute('layout', 'collapsible')
        el.setAttribute('api-url', '/goma-chat')
        el.setAttribute('socket-url', '/')
        el.setAttribute('user-id', userId)
        el.setAttribute('username', username)
        el.setAttribute('socket-auth-token', token)
        el.setAttribute('auth-token', token)
        el.setAttribute('game-id', gameId)
      }}
    />
  )
}

Vue 3 ​

Tell the compiler the tag is a custom element, in vite.config.js:

js
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('goma-') } } })
vue
<script setup>
import '@gomagaming/live-chat'
import { ref, watch } from 'vue'

const el = ref(null)

// Identity is applied imperatively and re-applied whenever it changes, because
// the token arrives asynchronously and may resolve after the element mounts.
function applyIdentity(node) {
  if (!node || !session.chatToken) return
  node.setAttribute('api-url', '/goma-chat')
  node.setAttribute('socket-url', '/')
  node.setAttribute('user-id', session.chatUserId)
  node.setAttribute('username', session.username)
  node.setAttribute('socket-auth-token', session.chatToken)
  node.setAttribute('auth-token', session.chatToken)
  if (eventId.value) node.setAttribute('game-id', eventId.value)
}

function setEl(node) {
  el.value = node || null
  if (!node) return
  node.addEventListener('gc:expand', () => node.setAttribute('expanded', 'true'))
  node.addEventListener('gc:collapse', () => node.setAttribute('expanded', 'false'))

  node.applyTheme({ '--goma-backgroundPrimary': '#1b1d2b' })
  node.setAttribute('layout', 'collapsible')
  node.setAttribute('enable-bet-sharing', 'true')
  node.myBets = { config, sessionId: session.sessionId, theme: myBetsTheme }
  applyIdentity(node)
}

watch(() => session.chatToken, () => applyIdentity(el.value))
watch(eventId, (id) => { if (el.value && id) el.value.setAttribute('game-id', id) })
</script>

<template>
  <!-- Gate on the token: mounting before it exists is the silent-idle case. -->
  <div v-if="session.chatToken" class="chat-dock">
    <goma-live-chat :ref="setEl" />
  </div>
</template>

Three things in that example are the difference between a working integration and a silent one, and all three are easy to leave out:

  • The v-if on the token. The widget renders and reports nothing when unconfigured, so a premature mount is indistinguishable from an outage.
  • A function ref rather than onMounted. Identity has to be re-applied when the exchange resolves late, which a one-shot mount hook cannot do.
  • Echoing expanded. Without it the fullscreen control does nothing: see Detecting dead clicks.

Local development ​

Because api-url is a relative path, a dev server needs to proxy it. In vite.config.js:

js
server: {
  proxy: {
    // The prefix is stripped: the chat service serves /auth/exchange, not
    // /goma-chat/auth/exchange.
    '/goma-chat': {
      target: process.env.CHAT_API_TARGET,
      changeOrigin: true,
      secure: true,
      rewrite: (p) => p.replace(/^\/goma-chat/, ''),
    },
    // Separate entry: Socket.IO serves its transport from the origin's
    // /socket.io/ path, never under the REST prefix. `ws: true` is required
    // or the upgrade never completes.
    '/socket.io': { target: process.env.CHAT_API_TARGET, changeOrigin: true, secure: true, ws: true },
  },
}

Miss the rewrite and every call 404s; miss ws: true and the socket silently falls back to failing long-polls.

Deployment notes ​

The reference host proxies the chat service same-origin so the widget's api-url is a relative path and the browser never faces the chat host's CORS.

Point the socket at the origin, not at the REST path

Socket.IO parses the path component of the URL you give it as a namespace, so the widget cannot be pointed at /goma-chat for its socket. It takes socket-url="/" and connects to the well-known /socket.io/ path at the origin, which must be proxied separately.

Two nginx details are easy to get wrong and fail in confusing ways:

  • Do not hardcode Connection: upgrade. Socket.IO opens with HTTP long-polling and only then upgrades. Map the header so it echoes upgrade only when the client actually asked for one, and raise proxy_read_timeout well above the 60 s default so idle chats are not dropped.
  • A missing REST location fails invisibly. Without a /goma-chat/ block the path falls through to an SPA catch-all, which answers index.html with a 200. The client's JSON.parse then fails and the widget sits silently signed out, which looks like a chat outage rather than a routing mistake.

Content-Security-Policy needs style-src 'unsafe-inline' for the inline custom properties and the <style> fallback, and connect-src for the REST and socket origins. For the default font you need both style-src https://fonts.googleapis.com and font-src https://fonts.gstatic.com: the default font-url is a Google Fonts stylesheet, so font-src alone leaves it blocked and no font is ever fetched. The <link> is added to document.head rather than the shadow root: see Font loading is document-level, so it is your page's CSP that governs it, not anything scoped to the widget. Set font-url="none" to avoid both.

Accessibility ​

Verified against the shipped bundle. Moderation notices and toasts use role="alert"; the closed-match hero and the loading spinner use role="status". The bet-detail modal is a role="dialog" with aria-modal; the emoji picker is a role="dialog" without it. Interactive controls carry aria-label, and decorative icons are aria-hidden.

Not covered by that pass, and worth testing yourself before you rely on it: the share-a-bet screen has no dialog semantics; the quick-reaction bar is not exposed as a group and its emoji buttons carry no labels; and reaction pills do not expose pressed state. The modals implement no focus trap, no Escape-to-close and no focus restoration: this is absent behaviour rather than untested behaviour, so keyboard users are not contained within a modal once it opens.

Most accessible names live under the aria namespace in each locale file and translate with the locale attribute. Several bypass it and stay English at any locale: the emoji picker (its dialog label, close button and tab label), the loading spinner, the share-ticket button, and the unread-message badge.

Integration Caveats ​

  • SSR/webview. This package doesn't use the shared createWidgetElement factory (it declares its own HTMLElement subclass directly), but had the identical hazard: importing it outside a browser previously threw immediately. Fixed with the same guard pattern.

  • Misc hygiene: custom-elements.json's members array omitted theme and config, both real accessors on the element; added, with an accurate description of the attribute/property relationship.

  • Tokens/Theming. Flagged, not fixed: two hardcoded colors (the emoji-picker strip, the inactive send button) bypass theming; the source already flags a planned follow-up PR for this. See known issues being addressed separately.

  • Configuring before the definition loads. Assigning config (or any property) while the element is still an unknown element is safe: the value is replayed through the widget when it upgrades. That matters when loading from the CDN, whose loader imports widget modules asynchronously, so your script usually runs first. On release sets at 0.34.0 and earlier the value was silently discarded and the widget mounted with no configuration and a clean console: there, await window.__gomaCdn.ready first, or configure through attributes. See Setting a property before the widget's definition has loaded.

See Integration Caveats for the cross-widget picture.

Changelog ​

Current release: 0.37.11. Every GomaPulse package shares one version number and they are published together, so a release that changes nothing here still bumps this widget. Only the releases that changed it are listed below.

Every release that changed this widget, newest first. The latest is open; the rest expand.

0.37.6
  • <goma-my-bets> now offers cash out on open bets. A player can cash out the whole bet or only part of the stake, and sees the cash out value update live. Cash out is on by default. Set cashoutEnabled (attribute cashout-enabled) to false to turn it off. The widget emits two new events: goma:cashout-success (with betId, cashoutType, cashoutAmount, pending and, for a partial cash out, partialCashoutStake) and goma:cashout-error (with betId, cashoutType, a reason of not-signed-in, invalid-stake, network, http-error, rejected or unknown, and, when known, the HTTP status and the provider's message). Three new image properties, myBetsCashoutWarningImage, myBetsCashoutSuccessImage and myBetsCashoutErrorImage, set the pictures on the confirm, success and error steps. When one is not set, that step shows no picture.

    A cash out counts as done only when the provider answers with success: true. cashoutAmount is the amount the provider paid, or the quoted value when it sends none. When the provider accepts a cash out but holds it for approval, pending is true, the bet stays in the Open list and the list is fetched again.

    <goma-betslip-sidebar> and <goma-betslip-floating> forward the new myBetsCashoutEnabled attribute (my-bets-cashout-enabled) and the three image properties to the My Bets tab. After a successful cash out, the betslip asks for a new balance at once and again after 2.5 seconds. In <goma-betslip-floating>, the cash out dialog can open over the betslip dialog, and closing it leaves the betslip open and does not move the page behind.

    <goma-live-chat> and <goma-messenger> turn cash out off in the My Bets list of their ticket share screens.

    @gomagaming/network: openSse accepts a method option and a body option, so a stream can be opened with a POST request. openSse also calls onClose when its retries run out after an error, so a consumer knows the stream is finished.

    @gomagaming/my-bets holds the cash out dialog logic and now depends on @gomagaming/ui. <goma-my-bets> has a fourth sub-tab, Cash Out, that lists fully cashed out bets only (a partly cashed out bet stays in Open). It shows only while cash out is on, a full cash out that is not pending moves the widget to it when the success step closes (a partial or pending one shows Open), a page that holds only hidden bets loads the next page by itself, and goma:my-bet-select can now report tab: 'cashout'. A typed partial amount is rounded down to cents, and Try again shows the current value before the player confirms. After a partial cash out, the card shows a Cashed Out tag next to the title and a box with the amount paid out and the stake still in play; the Bet Amount keeps the stake as placed. A fully cashed out card keeps the neutral look, shows no leg result and a box with the total paid out. Possible Winnings is always the provider's figure (the winnings without the stake on a free bet). The cash out dialog is centred on every screen size, up to 480px wide, the amount field shows the currency, and the ? opens a tooltip.

    @gomagaming/sports-domain has the cash out API calls and the live value stream. The stream keeps the last values while it retries and opens again when the browser goes back online or the tab becomes visible. It exports two new composables, useMyBetsCashoutValues and useMyBetsCashout, and a new requestBalanceRefresh action on the user store.

    @gomagaming/core and @gomagaming/ui carry the shared dialog and slider styles. GomaDialog in @gomagaming/ui takes an optional panelClass with extra classes for the panel; when set, it also replaces the default border colour. In @gomagaming/core, useScrollLock now does nothing for a lock that is taken while another lock is active.

    <goma-betslip-floating> now closes the dialogs that are open inside it (the bonus picker too) before it closes itself.

    <goma-my-bets> has a new subTab property (attribute sub-tab). The host sets which sub-tab opens, 'open', 'settled' (or 'closed'), 'won' or 'cashout', and can change it later. The player can still click any tab.

    <goma-betslip-sidebar> and <goma-betslip-floating> have a new myBetsSubTab property (attribute my-bets-sub-tab) that sets the sub-tab inside the My Bets tab.

    cashoutEnabled on <goma-my-bets> and myBetsCashoutEnabled on both betslips turn cash out off when the property is set to the string 'false', the same as the attribute.

0.37.4
    • <goma-suggested-bet>: custom-elements.json now lists the defaults for the icon-base-url attribute ('') and the flagBaseUrl property (the built-in flag CDN).
    • Playground theme-token lists (./playground export) now match what each widget paints: <goma-hero-card> now also lists --goma-favorites (the favourite star), alongside --goma-backgroundOdds (the odds buttons, painted by the shared odds button); <goma-top-competitions> drops --goma-iconSecondary, which it never paints (the sport glyph uses --goma-iconPrimary); <goma-highlight-card> and <goma-pros-choice> add --goma-backgroundBorder, the colour of their loading skeleton bars.
    • <goma-live-chat>: on a browser that cannot format compact numbers, the participant count now reads like the normal compact output (25K, 2M, and 1M for 999,999) instead of keeping a trailing .0 (25.0K).
0.37.3
  • Fix bet placement with the "Do not accept any changes in odds" setting. The betslip sent oddsValidationType: 'ACCEPT_NONE', which is not a value the provider accepts, so every bet failed with error 147 "Invalid Odds Validation Type". It now sends WITHIN_THRESHOLD, one of the three values the provider accepts. The provider then refuses any odds change when the account threshold is 0.

    Hosts in placeBetMode: 'event' see the change too: the goma:place-bet-request detail now carries oddsValidationType: 'WITHIN_THRESHOLD' for this setting, where it carried 'ACCEPT_NONE' before. A host that forwards the payload to its own backend, or checks this value, must accept WITHIN_THRESHOLD. The custom-elements.json type of that field now lists it.

    Two more place-bet changes ride in the same release, and one display fix.

    The place-bet payload now sends freeBet as a boolean: true only when the ubsWalletId on that payload belongs to a free-bet wallet (so the provider books a free bet and not a cash stake), false otherwise, where it sent null before; the goma:place-bet-request detail type changes from null|object to boolean. A betslip pick now sets ubsWalletId and freeBet together and wins over a host placeBetBody.freeBet; with no betslip pick a host that sends a free-bet wallet through placeBetBody.ubsWalletId must also send freeBet: true. The picked wallet's bonus type is now stored with the pick (and shared like the wallet id) as { walletId, type } in selectedBonusType and selectedBonusTypeBySelection of the goma:betslip:v4 envelope, and is used only for that same wallet id. A free bet pick on a Single selection stays flagged as a free bet, and is no longer cleared, when a refresh of that selection's wallets response leaves the wallet out. The two fields are additive and optional, so the envelope version does not change. In the same event detail type, ubsWalletId is now absent instead of null when no bonus is picked, and oddsBoostPercent and capAmount are listed.

    A Single bet can now be placed with an odds boost: the selection picker lists a boost that has reached a stair (it has a rate and a cap), leaves the typed stake alone, and the selection's payload carries the wallet id with the boost terms read from that selection's own wallets response. A boost with no usable stair is not offered on a Single selection, for fetched and host-fed wallets alike.

    The betslip, My Bets and the chat ticket share now show a free bet's winnings without the stake. My Bets reads potentialNetReturns (or stake times odds minus one) for every status except lost (0) and cashed out (unchanged), where it showed the stake as part of the winnings. The betslip shows the quote minus the stake for a Single selection or a Multiple slip carrying a free bet. The possibleWinnings of goma:bet-placed keeps the provider's figure, stake included. The chat ticket (possible_winnings) follows the same rule for a free bet.

    For hosts: in goma:place-bet-request and in the place-bet body, freeBet is now a boolean, and a betslip pick sets it. A host that used to put a free-bet token object in placeBetBody.freeBet must stop doing that. Send freeBet: true only when you send your own free-bet wallet id and the betslip has no pick.

    @gomagaming/sports-domain has two new exports: isFreeBetWallet (whether a wallet id belongs to a free-bet wallet) and freeBetNetWinnings (a free bet's winnings without the stake).

0.36.1
  • Playground manifests (@gomagaming/<widget>/playground) now offer a control for every setting each widget really has. Settings that cannot be a form field (raw provider data, read-only state mirrors, props a widget never reads) are recorded with a reason in the repo instead of being silently missing.

    Controls that never reached the widget are fixed:

    • The betslips' placeBetHeadersJson / placeBetBodyJson / quickAddAmountsJson are now placeBetHeaders / placeBetBody / quickAddAmounts.
    • The time-filter and filters-settings ticks editors set ticks.
    • live-chat's layout control writes position, the config key the element reads.
    • The sports-navigation widgets drop the unread sportsJson.
    • JSON-typed defaults are real objects and arrays instead of serialized strings. The top-competitions highlightedImages string default was indexed character by character and requested bad image URLs on every load. The missions-hub emptyStateImages illustrations now show.

    No widget runtime behaviour changes.

0.36.0
  • Locale strings no longer use the em dash. The login, place-bet and live chat messages end a clause with a full stop, in every language they ship in, and the time filter's empty-range header shows a plain hyphen as its no-value placeholder.
0.35.0
  • Match dates read day-first in English, so one match is no longer written two different ways across the suite.

    An event card writes the kick-off as a compact 11/10; <goma-game-details>'s header, <goma-hero-card>, <goma-multi-banners> and <goma-search>'s result rows write it in words through toLocaleDateString. With locale: 'en' (the bare tag, which Intl resolves to American ordering) that long form came out as October 11 next to the card's 11/10. English was the only shipped locale that disagreed with its own cards (fr and pt are already day-first, and the shared time formatter has always been pinned to en-GB), so the long form now resolves a bare en to en-GB. A host that explicitly configures en-US keeps American ordering.

    Two surfaces build their own Intl.DateTimeFormat rather than going through the shared formatters, and both showed the same inversion for the same match: the My Bets card's leg date (which also printed 07 October where the cards print 7 October, and sat directly under a hand-built dd/mm/yyyy "placed" line) and the live-chat bet modal's per-leg date. Both now resolve the locale the same way.

    Every package listed here renders a date that changes, which is why the note reaches all of their pages rather than only the two whose source moved: including the two betslips and <goma-messenger>, which render the same leg date through the <goma-my-bets> widget they embed.

    Not covered, and deliberately: the missions surfaces format challenge dates with the browser's locale rather than the widget's, and, in the same file, render the same instant as a deliberately locale-independent DD/MM/YY two functions below, so one view contradicts itself regardless of locale. That is a different defect from this one and wants its own ticket.

0.29.1
  • Make every text surface in the chat widgets honour --goma-fontFamily.

    Eight declarations across the two widgets hardcoded a typeface and so could not be reached by a host theme at all:

    • <goma-live-chat>: the three "Chat Closed" hero rules (final score, title, subtitle) named Inter. That face is not one the widget ships: its fontUrl loads Exo 2, so those three have been falling back to the generic sans-serif rather than rendering as intended.
    • <goma-messenger>: five inline styles across the friends screens named 'Exo 2' directly. Inline styles also outrank the adopted stylesheet, so a host that set --goma-fontFamily saw it apply everywhere except here.

    All eight now resolve through var(--goma-fontFamily), which both widgets already declared in their theme-token registries. A host that sets that token (alongside font-url="none" to suppress the widget's own font injection) now gets one typeface across the whole widget.

    Themes that relied on those surfaces staying Inter or Exo 2 while the rest of the widget changed will now follow the token; set --goma-fontFamily to the face you want.

0.29.0
  • Integration caveats: fixes from a cross-widget review of every shipped widget from an integrator's point of view.

    Cross-cutting fixes:

    • @gomagaming/core's createWidgetElement() factory, and live-chat's and messenger's own element classes, now guard against importing a widget package in a non-browser context (SSR): the HTMLElement subclass was previously defined at module top level in every widget package, so importing any of them under server-side rendering threw a ReferenceError immediately.

    Per-package fixes:

    • favorites: added src/themeTokens.js and a :host stylesheet (previously the package had neither, so it silently rendered core's base theme instead of its documented dark-navy/cyan/amber theme).
    • suggested-bet. Fixed a leaked WAMP subscription: switching a mounted widget's dataSource away from rpc/subscribe never released the open topic.
    • filters-settings: fixed a dead :host{display:inline-block} CSS rule (the shared element factory's inline display:block always won), so the widget always rendered full-width instead of the intended compact inline trigger.
    • game-details: added a min-height fallback for the inert/no-eventId state in an unsized container.
    • betslip-sidebar, my-bets, competition-filter, suggested-bet, events-horizontal, top-competitions, top-competitions-pills, competitions-list, multi-banners, casino-games, sports-navigation-horizontal. Corrected themeTokens.js registries that had drifted from actual token usage, added missing goma:retry Events-table rows, and other small doc/registry corrections.
    • region-filter: added a missing typeof window !== 'undefined' guard on a window listener.
    • live-chat: corrected custom-elements.json's members array (missing theme/config accessors, and an inaccurate description).
    • sports-domain: corrected a stale code comment that seeded a documentation error in two Missions widgets.

    Three fixes (favorites, game-details, filters-settings) change real rendering and are flagged in docs/guide/integration-caveats.md as needing human visual verification before release.

0.27.0
  • Fixes to both chat widgets, found while bringing them onto the monorepo's conventions. All of these are longstanding: they predate the move and were present in the standalone packages.

    Component CSS that never rendered now renders. Ten components carried Vue <style scoped> blocks. Scoped styles compile into a stylesheet the widgets never load, so none of it had ever reached a user: the message slide-in, the jump-to-latest pill, the chat bubble's hover and press feedback, its unread pulse ring, the unread badge bounce, the quick-react pop-in, the conversation-list loading dots, the modal fade-and-scale, the emoji toggle's hover and active states, and a scrollbar narrower than the default. These are now part of each widget's stylesheet and all of them honour prefers-reduced-motion. Neither package emits a dist/style.css any more: that file only ever contained the unreachable rules.

    Font sizes were being applied as colours. Ten Tailwind utilities used text-[var(--goma-fontSize…)], which compiles to color: rather than font-size:, so the size was silently dropped. On both text inputs the bogus rule also overrode the input's actual text colour. Buttons, inputs, the reconnecting banner and the jump-to-latest pill are affected.

    live-chat: the webfont now loads where Firefox can see it. The font-url stylesheet was injected into the shadow root, and Firefox ignores @font-face declared there. The widget silently fell back to a system font. It now goes to document.head, deduped and shared across instances, matching what messenger already did.

    live-chat: logging out closes the socket. Clearing auth-token emptied the UI and reset the store while leaving the Socket.IO connection open, still presenting the old token, until the element left the DOM.

    live-chat: bodyless requests no longer claim to carry JSON. The join and leave calls sent Content-Type: application/json with a zero-length body, obliging every backend to special-case an empty payload it had been told to parse.

    live-chat: betathomeTheme is now exported. The preset shipped in the package but nothing exported it, so it could not be reached from outside. Both widgets now export the same three presets.

    messenger: the conversation list loads in full. It read one page from a paginated endpoint and discarded the cursor, so anyone with more conversations than the server's page size simply never saw the rest. No error, and no way to reach them. This also fixes createGroup() and adding a friend by code returning null for those users: both look for the new conversation in a reloaded list, and it could be on a page that was never fetched.

    Both: the retired --gc-* theme-key warning actually fires. It was gated on a development check that the production build always compiles away, so the keys were dropped in silence. Exactly the behaviour the warning existed to prevent. It now reports every dropped key once per applyTheme() call.

  • Live chat and messenger are now part of the GomaPulse monorepo and release on the shared version line.

    @gomagaming/core's ambient type declarations now register goma-live-chat and goma-messenger alongside the rest of the custom elements, so consumers get the same JSX/Vue template type-checking for these two widgets that every other widget already has.

    @gomagaming/livechat has been renamed to @gomagaming/live-chat, so its directory, package name and <goma-live-chat> element tag all agree. Update the dependency name and the import specifier; no attribute, event or theming API has changed.

    @gomagaming/messenger no longer bundles @gomagaming/my-bets; it depends on the published package and imports it, so a page running several Goma widgets ships one copy of it instead of one per bundle. <goma-live-chat> already worked this way. No host action is needed to load or register <goma-my-bets> (it is a declared dependency and registers itself) but because it reaches @gomagaming/core and @gomagaming/sports-domain, both chat packages now require the usual peer install (vue, pinia, vue-router, vue-i18n, @vueuse/core) at the host-app level.

  • <goma-live-chat> is now built like every other GomaPulse widget.

    Theme tokens have been renamed from --gc-* to --goma-*. Any host passing theme overrides, through the theme attribute, applyTheme(), or the exported presets, must update its keys. The widget now warns, naming every key dropped, because an unrecognised custom property is otherwise silently ignored. Colours are unchanged: every value was pinned during the migration, so the widget looks identical.

    Vue, Pinia and vue-i18n are no longer bundled. Install them alongside the widget: pnpm add @gomagaming/live-chat vue pinia vue-router vue-i18n @vueuse/core. In return the bundle is far smaller, and a page running several GomaPulse widgets now loads one copy of the framework instead of one per widget.

    The package no longer ships TypeScript declarations, matching the rest of the widget family.

    @gomagaming/core gains --goma-fontFamily, defaulting to the font stack it previously hard-coded: no visual change to any existing widget.

  • <goma-messenger> is now built like every other GomaPulse widget.

    Theme tokens have been renamed from --gc-* to --goma-*. Any host passing theme overrides. Through the theme attribute, the .theme / .config.theme properties, applyTheme(), or the exported presets (exclusivebetTheme, betssonfranceTheme, betathomeTheme). Must update its keys. The widget now warns, naming every key dropped, because an unrecognised custom property is otherwise silently ignored. Colours are unchanged: every value was pinned during the migration, so the widget looks identical.

    Vue, Pinia and vue-i18n are no longer bundled. Install them alongside the widget: pnpm add @gomagaming/messenger vue pinia vue-router vue-i18n @vueuse/core. In return the bundle is far smaller, and a page running several GomaPulse widgets now loads one copy of the framework instead of one per widget.

    The package no longer ships TypeScript declarations, matching the rest of the widget family: its source and tests are now plain JavaScript.

    Internal layering flattened. The clean-architecture split (usecase/, data/mapper/, session-provider abstractions, …) has been collapsed into the same flat components/ / composables/ / stores/ / utils/ / api/ shape every other widget uses. The public surface (the <goma-messenger> tag, its attributes, the config / theme / myBets properties, and every gc:* event) is unchanged. Only a host reaching past the package entry point into one of the moved internal paths is affected.

    @gomagaming/live-chat gains a .theme property accessor alongside its existing .config: reading it returns the --goma-* custom properties the widget actually painted (not a mirror of config.theme); assigning routes through applyTheme(), with the same key-normalisation and retired---gc-* warning described above. No attribute or event changed.