UNPKG

@hellotext/hellotext

Version:
152 lines (124 loc) 4.28 kB
import ApplicationChannel from './application_channel' class WebchatChannel extends ApplicationChannel { constructor(id, session, conversation) { super() this.id = id this.session = session this.conversation = conversation // Keep our own subscription intent instead of trusting the socket state. // The shared WebSocket can reconnect independently, but an explicit // unsubscribe means this channel should not silently join again. this.subscribed = false this.awaitingReconnectConfirmation = false this.reconnectCallbacks = new Set() // ActionCable confirms subscriptions at the socket level. This channel // listens for those confirmations so the controller can wait until Rails has // accepted this exact WebchatChannel subscription before fetching missed // messages from the REST catch-up endpoint. this.onSubscriptionConfirmed(identifier => this.handleSubscriptionConfirmed(identifier)) this.subscribe() } subscribe() { this.subscribed = true const params = { channel: 'WebchatChannel', id: this.id, session: this.session, conversation: this.conversation, } this.send({ command: 'subscribe', identifier: params }) } unsubscribe() { this.subscribed = false const params = { channel: 'WebchatChannel', id: this.id, session: this.session, conversation: this.conversation, } this.send({ command: 'unsubscribe', identifier: params }) } resubscribe() { if (this.subscribed === false) return // Reconnect recovery has two phases: send the subscription command first, // then wait for ActionCable to confirm it. Catch-up work should run after // that confirmation so broadcasts and REST backfill are pointed at the same // live conversation subscription. this.awaitingReconnectConfirmation = true this.subscribe() } onReconnect(callback) { this.reconnectCallbacks.add(callback) } handleSubscriptionConfirmed(identifier) { if (!this.awaitingReconnectConfirmation || !this.matchesIdentifier(identifier)) return // Only the first matching confirmation completes the reconnect cycle. This // prevents unrelated subscription confirmations on the shared socket from // triggering duplicate catch-up requests. this.awaitingReconnectConfirmation = false this.reconnectCallbacks.forEach(callback => callback()) } matchesIdentifier(identifier) { let params try { params = typeof identifier === 'string' ? JSON.parse(identifier) : identifier } catch { return false } // ActionCable sends the same identifier payload we used when subscribing. // Matching every routing key keeps a confirmation for another webchat, // session, or conversation from being treated as this channel's reconnect. return ( params.channel === 'WebchatChannel' && params.id === this.id && params.session === this.session && params.conversation === this.conversation ) } startTypingIndicator() { const params = { channel: 'WebchatChannel', id: this.id, session: this.session, conversation: this.conversation, } this.send({ command: 'message', identifier: params, data: { action: 'started_typing' } }) } stopTypingIndicator() { const params = { channel: 'WebchatChannel', id: this.id, session: this.session, conversation: this.conversation, } this.send({ command: 'typing:stop', identifier: params, data: { action: 'stopped_typing' } }) } onMessage(callback) { super.onMessage(message => { if (message.type !== 'message') return callback(message) }) } onReaction(callback) { super.onMessage(message => { if (message.type === 'reaction.create' || message.type === 'reaction.destroy') { callback(message) } }) } onTypingStart(callback) { super.onMessage(message => { if (message.type === 'started_typing') { callback(message) } }) } updateSubscriptionWith(conversation) { this.unsubscribe() setTimeout(() => { this.conversation = conversation this.subscribe() }, 1000) } } export default WebchatChannel