@escalated-dev/escalated 0.11.9 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,179 +1,135 @@
1
- import { ref, onUnmounted, readonly } from 'vue';
1
+ import { ref, onUnmounted, readonly, toValue, watch } from 'vue';
2
+
3
+ // Multiple components can listen to the same channel. A component leaving must
4
+ // not disconnect the ticket updates or presence owned by another component.
5
+ const leases = new WeakMap();
6
+ function retainChannel(echo, name, presence) {
7
+ const key = `${presence ? 'presence' : 'private'}-${name}`;
8
+ let counts = leases.get(echo);
9
+ if (!counts) leases.set(echo, (counts = new Map()));
10
+ counts.set(key, (counts.get(key) || 0) + 1);
11
+ return () => {
12
+ const count = counts.get(key) - 1;
13
+ if (count > 0) counts.set(key, count);
14
+ else {
15
+ counts.delete(key);
16
+ if (echo.leaveChannel) echo.leaveChannel(key);
17
+ else echo.leave(presence ? `presence-${name}` : name);
18
+ }
19
+ };
20
+ }
2
21
 
3
- /**
4
- * Composable for real-time WebSocket event listening via Laravel Echo.
5
- *
6
- * If `window.Echo` is available (provided by the host app), this composable
7
- * subscribes to private channels and listens for broadcast events. When Echo
8
- * is not present, it silently falls back - the caller can check `echoAvailable`
9
- * and implement polling as needed.
10
- *
11
- * Usage:
12
- * const { echoAvailable, subscribeToTicket, subscribeToTickets, subscribeToAgent } = useRealtime();
13
- *
14
- * All subscriptions are automatically cleaned up on component unmount.
15
- */
16
- export function useRealtime() {
22
+ /** Optional channelPrefix accepts a value, ref or getter. Older backends use escalated. */
23
+ export function useRealtime(options = {}) {
17
24
  const echoAvailable = ref(typeof window !== 'undefined' && !!window.Echo);
18
- const subscriptions = [];
25
+ const subscriptions = new Set();
26
+
27
+ function track(cleanup) {
28
+ let active = true;
29
+ const stop = () => {
30
+ if (!active) return;
31
+ active = false;
32
+ subscriptions.delete(stop);
33
+ cleanup();
34
+ };
35
+ subscriptions.add(stop);
36
+ return stop;
37
+ }
19
38
 
20
- /**
21
- * Subscribe to a private channel and listen for an event.
22
- * Returns an unsubscribe function.
23
- */
39
+ // Raw channel names remain literal for existing integrations.
24
40
  function listen(channelName, eventName, callback) {
25
41
  if (!echoAvailable.value) return () => {};
26
-
27
- const channel = window.Echo.private(channelName);
42
+ const echo = window.Echo;
43
+ const channel = echo.private(channelName);
44
+ const release = retainChannel(echo, channelName, false);
28
45
  channel.listen(eventName, callback);
29
-
30
- const unsub = () => {
46
+ return track(() => {
31
47
  channel.stopListening(eventName, callback);
32
- window.Echo.leave(channelName);
33
- };
34
- subscriptions.push(unsub);
35
- return unsub;
48
+ release();
49
+ });
36
50
  }
37
51
 
38
- /**
39
- * Subscribe to a specific ticket's channel for real-time updates.
40
- *
41
- * @param {number|string} ticketId - The ticket ID
42
- * @param {Object} handlers - Event handler callbacks
43
- * @param {Function} [handlers.onReplyCreated] - Called when a new reply is added
44
- * @param {Function} [handlers.onTicketUpdated] - Called when ticket fields are updated
45
- * @param {Function} [handlers.onStatusChanged] - Called when ticket status changes
46
- * @param {Function} [handlers.onTicketAssigned] - Called when ticket is assigned
47
- * @param {Function} [handlers.onTicketEscalated] - Called when ticket is escalated
48
- * @returns {Function} Unsubscribe function
49
- */
50
- function subscribeToTicket(ticketId, handlers = {}) {
51
- if (!echoAvailable.value) return () => {};
52
-
53
- const channelName = `escalated.tickets.${ticketId}`;
54
- const channel = window.Echo.private(channelName);
55
- const listeners = [];
56
-
57
- if (handlers.onReplyCreated) {
58
- channel.listen('.reply.created', handlers.onReplyCreated);
59
- listeners.push(['.reply.created', handlers.onReplyCreated]);
60
- }
61
- if (handlers.onTicketUpdated) {
62
- channel.listen('.ticket.updated', handlers.onTicketUpdated);
63
- listeners.push(['.ticket.updated', handlers.onTicketUpdated]);
64
- }
65
- if (handlers.onStatusChanged) {
66
- channel.listen('.ticket.status_changed', handlers.onStatusChanged);
67
- listeners.push(['.ticket.status_changed', handlers.onStatusChanged]);
68
- }
69
- if (handlers.onTicketAssigned) {
70
- channel.listen('.ticket.assigned', handlers.onTicketAssigned);
71
- listeners.push(['.ticket.assigned', handlers.onTicketAssigned]);
72
- }
73
- if (handlers.onTicketEscalated) {
74
- channel.listen('.ticket.escalated', handlers.onTicketEscalated);
75
- listeners.push(['.ticket.escalated', handlers.onTicketEscalated]);
76
- }
77
-
78
- const unsub = () => {
79
- listeners.forEach(([event, cb]) => channel.stopListening(event, cb));
80
- window.Echo.leave(channelName);
52
+ function scopedSubscription(suffix, handlers, presence = false) {
53
+ let currentChannel = null;
54
+ const stopWatch = watch(
55
+ () => {
56
+ const tail = toValue(suffix);
57
+ return tail == null ? null : `${toValue(options.channelPrefix) ?? 'escalated'}.${tail}`;
58
+ },
59
+ (name, previous, onCleanup) => {
60
+ if (!name || !echoAvailable.value) return;
61
+ const echo = window.Echo;
62
+ const channel = presence ? echo.join(name) : echo.private(name);
63
+ currentChannel = channel;
64
+ const release = retainChannel(echo, name, presence);
65
+ let active = true;
66
+ const listeners = Object.entries(handlers)
67
+ .filter(([, callback]) => typeof callback === 'function')
68
+ .map(([event, callback]) => {
69
+ // Ignore an old socket callback already queued during account navigation.
70
+ const guarded = (...args) => {
71
+ if (active) callback(...args);
72
+ };
73
+ if (presence) channel[event](guarded);
74
+ else channel.listen(event, guarded);
75
+ return [event, guarded];
76
+ });
77
+ onCleanup(() => {
78
+ active = false;
79
+ currentChannel = null;
80
+ if (!presence) listeners.forEach(([event, callback]) => channel.stopListening(event, callback));
81
+ release();
82
+ });
83
+ },
84
+ { immediate: true, flush: 'sync' },
85
+ );
86
+ return {
87
+ get channel() {
88
+ return currentChannel;
89
+ },
90
+ unsubscribe: track(stopWatch),
81
91
  };
82
- subscriptions.push(unsub);
83
- return unsub;
84
92
  }
85
93
 
86
- /**
87
- * Subscribe to the global tickets channel for new ticket notifications.
88
- *
89
- * @param {Object} handlers - Event handler callbacks
90
- * @param {Function} [handlers.onTicketCreated] - Called when a new ticket is created
91
- * @returns {Function} Unsubscribe function
92
- */
93
- function subscribeToTickets(handlers = {}) {
94
- if (!echoAvailable.value) return () => {};
94
+ function listenScoped(suffix, handlers) {
95
+ return scopedSubscription(suffix, handlers).unsubscribe;
96
+ }
95
97
 
96
- const channelName = 'escalated.tickets';
97
- const channel = window.Echo.private(channelName);
98
- const listeners = [];
98
+ function identitySuffix(kind, id) {
99
+ return () => (toValue(id) == null ? null : `${kind}.${toValue(id)}`);
100
+ }
99
101
 
100
- if (handlers.onTicketCreated) {
101
- channel.listen('.ticket.created', handlers.onTicketCreated);
102
- listeners.push(['.ticket.created', handlers.onTicketCreated]);
103
- }
102
+ function subscribeToTicket(ticketId, handlers = {}) {
103
+ return listenScoped(identitySuffix('tickets', ticketId), {
104
+ '.reply.created': handlers.onReplyCreated,
105
+ '.ticket.updated': handlers.onTicketUpdated,
106
+ '.ticket.status_changed': handlers.onStatusChanged,
107
+ '.ticket.assigned': handlers.onTicketAssigned,
108
+ '.ticket.escalated': handlers.onTicketEscalated,
109
+ });
110
+ }
104
111
 
105
- const unsub = () => {
106
- listeners.forEach(([event, cb]) => channel.stopListening(event, cb));
107
- window.Echo.leave(channelName);
108
- };
109
- subscriptions.push(unsub);
110
- return unsub;
112
+ function subscribeToTickets(handlers = {}) {
113
+ return listenScoped('tickets', { '.ticket.created': handlers.onTicketCreated });
111
114
  }
112
115
 
113
- /**
114
- * Subscribe to an agent's personal channel for assignment notifications.
115
- *
116
- * @param {number|string} agentId - The agent's user ID
117
- * @param {Object} handlers - Event handler callbacks
118
- * @param {Function} [handlers.onTicketAssigned] - Called when a ticket is assigned to this agent
119
- * @returns {Function} Unsubscribe function
120
- */
121
116
  function subscribeToAgent(agentId, handlers = {}) {
122
- if (!echoAvailable.value) return () => {};
123
-
124
- const channelName = `escalated.agents.${agentId}`;
125
- const channel = window.Echo.private(channelName);
126
- const listeners = [];
127
-
128
- if (handlers.onTicketAssigned) {
129
- channel.listen('.ticket.assigned', handlers.onTicketAssigned);
130
- listeners.push(['.ticket.assigned', handlers.onTicketAssigned]);
131
- }
132
-
133
- const unsub = () => {
134
- listeners.forEach(([event, cb]) => channel.stopListening(event, cb));
135
- window.Echo.leave(channelName);
136
- };
137
- subscriptions.push(unsub);
138
- return unsub;
117
+ return listenScoped(identitySuffix('agents', agentId), { '.ticket.assigned': handlers.onTicketAssigned });
139
118
  }
140
119
 
141
- /**
142
- * Get a presence channel for a ticket (if Echo supports it).
143
- * Returns the channel instance or null.
144
- */
145
120
  function joinTicketPresence(ticketId, callbacks = {}) {
146
121
  if (!echoAvailable.value) return null;
147
-
148
- const channelName = `escalated.tickets.${ticketId}`;
149
- const channel = window.Echo.join(channelName);
150
-
151
- if (callbacks.here) channel.here(callbacks.here);
152
- if (callbacks.joining) channel.joining(callbacks.joining);
153
- if (callbacks.leaving) channel.leaving(callbacks.leaving);
154
-
155
- const unsub = () => {
156
- window.Echo.leave(`presence-${channelName}`);
157
- };
158
- subscriptions.push(unsub);
159
- return { channel, unsubscribe: unsub };
122
+ return scopedSubscription(identitySuffix('tickets', ticketId), callbacks, true);
160
123
  }
161
124
 
162
- // Cleanup all subscriptions on component unmount
163
125
  onUnmounted(() => {
164
- subscriptions.forEach((unsub) => {
165
- try {
166
- unsub();
167
- } catch {
168
- /* ignore */
169
- }
170
- });
171
- subscriptions.length = 0;
126
+ [...subscriptions].forEach((unsubscribe) => unsubscribe());
172
127
  });
173
128
 
174
129
  return {
175
130
  echoAvailable: readonly(echoAvailable),
176
131
  listen,
132
+ listenScoped,
177
133
  subscribeToTicket,
178
134
  subscribeToTickets,
179
135
  subscribeToAgent,
@@ -88,7 +88,7 @@ function formatCount(value) {
88
88
  table is not affected — it stays wherever your application keeps it.
89
89
  </p>
90
90
 
91
- <dl v-if="active" class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
91
+ <dl v-if="active" class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-6 gap-y-3 text-sm">
92
92
  <dt class="text-[var(--esc-panel-text-muted)]">Connection</dt>
93
93
  <dd class="font-medium text-[var(--esc-panel-text)]">
94
94
  {{ active.name }}
@@ -160,7 +160,7 @@ function formatCount(value) {
160
160
  {{ statusLabel(connection) }}
161
161
  </span>
162
162
  </span>
163
- <span class="mt-0.5 block text-xs text-[var(--esc-panel-text-muted)]">
163
+ <span class="mt-0.5 block break-all text-xs text-[var(--esc-panel-text-muted)]">
164
164
  {{ connection.driver ?? 'unknown driver' }} ·
165
165
  {{ connection.database ?? 'no database configured' }}
166
166
  <template v-if="connection.ticket_count !== null">
@@ -217,7 +217,7 @@ function formatCount(value) {
217
217
  <button
218
218
  type="submit"
219
219
  :disabled="form.processing || !isChanging"
220
- class="rounded-lg bg-[var(--esc-accent)] px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
220
+ class="rounded-lg bg-[var(--esc-panel-accent)] px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
221
221
  >
222
222
  {{ form.processing ? 'Saving…' : 'Use this connection' }}
223
223
  </button>