@foony/chat 0.0.1

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.
Files changed (95) hide show
  1. package/LICENSE +215 -0
  2. package/README.md +67 -0
  3. package/lib/chatClient.d.ts +22 -0
  4. package/lib/chatClient.d.ts.map +1 -0
  5. package/lib/chatClient.js +28 -0
  6. package/lib/chatClient.js.map +1 -0
  7. package/lib/index.d.ts +19 -0
  8. package/lib/index.d.ts.map +1 -0
  9. package/lib/index.js +18 -0
  10. package/lib/index.js.map +1 -0
  11. package/lib/messages.d.ts +45 -0
  12. package/lib/messages.d.ts.map +1 -0
  13. package/lib/messages.js +146 -0
  14. package/lib/messages.js.map +1 -0
  15. package/lib/occupancy.d.ts +30 -0
  16. package/lib/occupancy.d.ts.map +1 -0
  17. package/lib/occupancy.js +65 -0
  18. package/lib/occupancy.js.map +1 -0
  19. package/lib/presence.d.ts +0 -0
  20. package/lib/presence.d.ts.map +1 -0
  21. package/lib/presence.js +0 -0
  22. package/lib/presence.js.map +1 -0
  23. package/lib/protocol.d.ts +52 -0
  24. package/lib/protocol.d.ts.map +1 -0
  25. package/lib/protocol.js +72 -0
  26. package/lib/protocol.js.map +1 -0
  27. package/lib/reactions.d.ts +26 -0
  28. package/lib/reactions.d.ts.map +1 -0
  29. package/lib/reactions.js +56 -0
  30. package/lib/reactions.js.map +1 -0
  31. package/lib/reconciler.d.ts +29 -0
  32. package/lib/reconciler.d.ts.map +1 -0
  33. package/lib/reconciler.js +149 -0
  34. package/lib/reconciler.js.map +1 -0
  35. package/lib/room.d.ts +53 -0
  36. package/lib/room.d.ts.map +1 -0
  37. package/lib/room.js +81 -0
  38. package/lib/room.js.map +1 -0
  39. package/lib/rooms.d.ts +20 -0
  40. package/lib/rooms.d.ts.map +1 -0
  41. package/lib/rooms.js +38 -0
  42. package/lib/rooms.js.map +1 -0
  43. package/lib/types.d.ts +139 -0
  44. package/lib/types.d.ts.map +1 -0
  45. package/lib/types.js +9 -0
  46. package/lib/types.js.map +1 -0
  47. package/lib/typing.d.ts +43 -0
  48. package/lib/typing.d.ts.map +1 -0
  49. package/lib/typing.js +108 -0
  50. package/lib/typing.js.map +1 -0
  51. package/lib/util.d.ts +9 -0
  52. package/lib/util.d.ts.map +1 -0
  53. package/lib/util.js +12 -0
  54. package/lib/util.js.map +1 -0
  55. package/lib-cjs/chatClient.js +32 -0
  56. package/lib-cjs/chatClient.js.map +1 -0
  57. package/lib-cjs/index.js +30 -0
  58. package/lib-cjs/index.js.map +1 -0
  59. package/lib-cjs/messages.js +150 -0
  60. package/lib-cjs/messages.js.map +1 -0
  61. package/lib-cjs/occupancy.js +69 -0
  62. package/lib-cjs/occupancy.js.map +1 -0
  63. package/lib-cjs/package.json +3 -0
  64. package/lib-cjs/presence.js +0 -0
  65. package/lib-cjs/presence.js.map +1 -0
  66. package/lib-cjs/protocol.js +79 -0
  67. package/lib-cjs/protocol.js.map +1 -0
  68. package/lib-cjs/reactions.js +60 -0
  69. package/lib-cjs/reactions.js.map +1 -0
  70. package/lib-cjs/reconciler.js +153 -0
  71. package/lib-cjs/reconciler.js.map +1 -0
  72. package/lib-cjs/room.js +85 -0
  73. package/lib-cjs/room.js.map +1 -0
  74. package/lib-cjs/rooms.js +42 -0
  75. package/lib-cjs/rooms.js.map +1 -0
  76. package/lib-cjs/types.js +10 -0
  77. package/lib-cjs/types.js.map +1 -0
  78. package/lib-cjs/typing.js +112 -0
  79. package/lib-cjs/typing.js.map +1 -0
  80. package/lib-cjs/util.js +15 -0
  81. package/lib-cjs/util.js.map +1 -0
  82. package/package.json +56 -0
  83. package/src/chatClient.ts +31 -0
  84. package/src/index.ts +32 -0
  85. package/src/messages.ts +159 -0
  86. package/src/occupancy.ts +78 -0
  87. package/src/presence.ts +0 -0
  88. package/src/protocol.ts +90 -0
  89. package/src/reactions.ts +64 -0
  90. package/src/reconciler.ts +169 -0
  91. package/src/room.ts +97 -0
  92. package/src/rooms.ts +42 -0
  93. package/src/types.ts +141 -0
  94. package/src/typing.ts +123 -0
  95. package/src/util.ts +12 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Per-room reactions — ephemeral, fire-and-forget broadcasts (e.g. a floating
3
+ * emoji). Not stored or aggregated; distinct from per-message reactions (which
4
+ * are out of scope for v1). Pure pub/sub over the channel.
5
+ */
6
+
7
+ import type { Channel, UnsubscribeFn } from '@foony/realtime';
8
+ import { parseReactionPayload, REACTION_EVENT, type ReactionPayload } from './protocol.js';
9
+ import type { RoomReaction } from './types.js';
10
+
11
+ /** Listener invoked for every room reaction. */
12
+ export type ReactionListener = (reaction: RoomReaction) => void;
13
+
14
+ /** The room-reaction feature of a {@link Room}. */
15
+ export class Reactions {
16
+ private readonly listeners = new Set<ReactionListener>();
17
+ private channelUnsubscribe: UnsubscribeFn | null = null;
18
+
19
+ constructor(
20
+ private readonly channel: Channel,
21
+ private readonly getClientId: () => string | null,
22
+ ) {}
23
+
24
+ /** Broadcast a room reaction. */
25
+ async send(params: { name: string; metadata?: unknown }): Promise<void> {
26
+ const payload: ReactionPayload = {
27
+ name: params.name,
28
+ ...(params.metadata === undefined ? {} : { metadata: params.metadata }),
29
+ };
30
+ await this.channel.publish(REACTION_EVENT, payload);
31
+ }
32
+
33
+ /** Subscribe to room reactions. Attaches the channel on first listener. */
34
+ subscribe(listener: ReactionListener): UnsubscribeFn {
35
+ this.listeners.add(listener);
36
+ this.ensureChannelSubscription();
37
+ return () => {
38
+ this.listeners.delete(listener);
39
+ };
40
+ }
41
+
42
+ private ensureChannelSubscription(): void {
43
+ if (this.channelUnsubscribe) {
44
+ return;
45
+ }
46
+ this.channelUnsubscribe = this.channel.subscribe(REACTION_EVENT, (frame) => {
47
+ const payload = parseReactionPayload(frame.data);
48
+ if (payload === null) {
49
+ return;
50
+ }
51
+ const clientId = frame.clientId ?? '';
52
+ const reaction: RoomReaction = {
53
+ name: payload.name,
54
+ clientId,
55
+ ...(payload.metadata === undefined ? {} : { metadata: payload.metadata }),
56
+ createdAt: new Date(frame.timestamp),
57
+ isSelf: clientId !== '' && clientId === this.getClientId(),
58
+ };
59
+ for (const subscriber of [...this.listeners]) {
60
+ subscriber(reaction);
61
+ }
62
+ });
63
+ }
64
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Message reconciler: folds a stream of create/update/delete frames into
3
+ * materialized {@link Message} state, keyed by message id. The same reconciler
4
+ * runs over the live subscription and over replayed history, so both paths
5
+ * converge on identical state regardless of delivery order or duplicates.
6
+ *
7
+ * Version ordering uses the transport `(timestamp, messageId)` pair: a later
8
+ * mutation wins, and a re-delivered frame (same messageId) is ignored.
9
+ */
10
+
11
+ import type { MessageFrame } from '@foony/realtime';
12
+ import { parseMessagePayload, type MessagePayload } from './protocol.js';
13
+ import type { ChatMessageEvent, Message } from './types.js';
14
+
15
+ /** Internal per-message bookkeeping. */
16
+ type Entry = {
17
+ message: Message;
18
+ /** True once the original `create` has been folded in. */
19
+ createSeen: boolean;
20
+ /** Transport timestamp of the version that set the current text. */
21
+ versionTs: number;
22
+ /** Transport messageId of that version. */
23
+ versionId: string;
24
+ /** Every transport messageId already applied, for idempotent replay. */
25
+ readonly applied: Set<string>;
26
+ };
27
+
28
+ /** Stateful reconciler for a single room's messages. */
29
+ export class MessageReconciler {
30
+ private readonly byId = new Map<string, Entry>();
31
+
32
+ constructor(private readonly roomName: string) {}
33
+
34
+ /**
35
+ * Fold one channel frame into state. Returns the event a subscriber should
36
+ * see, or null when the frame is malformed, a duplicate, or stale.
37
+ */
38
+ apply(frame: MessageFrame): ChatMessageEvent | null {
39
+ const payload = parseMessagePayload(frame.data);
40
+ if (payload === null) {
41
+ return null;
42
+ }
43
+ const timestamp = frame.timestamp;
44
+ const versionId = frame.messageId;
45
+ if (payload.action === 'create') {
46
+ return this.applyCreate(payload, frame.clientId ?? '', timestamp, versionId);
47
+ }
48
+ return this.applyMutation(payload, frame.clientId ?? '', timestamp, versionId);
49
+ }
50
+
51
+ /** The current materialized state of one message, if known. */
52
+ get(id: string): Message | undefined {
53
+ return this.byId.get(id)?.message;
54
+ }
55
+
56
+ /** Current materialized messages, oldest-first by creation then id. */
57
+ snapshot(): Message[] {
58
+ return [...this.byId.values()]
59
+ .map((entry) => entry.message)
60
+ .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
61
+ }
62
+
63
+ private applyCreate(payload: Extract<MessagePayload, { action: 'create' }>, clientId: string, timestamp: number, versionId: string): ChatMessageEvent | null {
64
+ const existing = this.byId.get(payload.id);
65
+ if (existing) {
66
+ if (existing.applied.has(versionId)) {
67
+ return null;
68
+ }
69
+ existing.applied.add(versionId);
70
+ // A mutation arrived before its create: backfill author/createdAt, and
71
+ // adopt the create's body only if it is newer than the winning mutation
72
+ // (i.e. no later edit/delete has superseded it).
73
+ const createdAt = new Date(timestamp);
74
+ const adoptBody = isNewer(existing.versionTs, existing.versionId, timestamp, versionId);
75
+ existing.createSeen = true;
76
+ existing.message = {
77
+ ...existing.message,
78
+ clientId,
79
+ createdAt,
80
+ ...(adoptBody
81
+ ? {
82
+ text: payload.text,
83
+ metadata: payload.metadata ?? {},
84
+ headers: payload.headers ?? {},
85
+ updatedAt: createdAt,
86
+ action: 'create',
87
+ deleted: false,
88
+ }
89
+ : {}),
90
+ };
91
+ if (adoptBody) {
92
+ existing.versionTs = timestamp;
93
+ existing.versionId = versionId;
94
+ }
95
+ return { type: 'created', message: existing.message };
96
+ }
97
+ const createdAt = new Date(timestamp);
98
+ const message: Message = {
99
+ id: payload.id,
100
+ clientId,
101
+ roomName: this.roomName,
102
+ text: payload.text,
103
+ metadata: payload.metadata ?? {},
104
+ headers: payload.headers ?? {},
105
+ createdAt,
106
+ updatedAt: createdAt,
107
+ action: 'create',
108
+ deleted: false,
109
+ };
110
+ this.byId.set(payload.id, { message, createSeen: true, versionTs: timestamp, versionId, applied: new Set([versionId]) });
111
+ return { type: 'created', message };
112
+ }
113
+
114
+ private applyMutation(payload: Extract<MessagePayload, { action: 'update' | 'delete' }>, clientId: string, timestamp: number, versionId: string): ChatMessageEvent | null {
115
+ let entry = this.byId.get(payload.id);
116
+ if (!entry) {
117
+ // Mutation before create: stand up a placeholder the create can complete.
118
+ const createdAt = new Date(timestamp);
119
+ const placeholder: Message = {
120
+ id: payload.id,
121
+ clientId,
122
+ roomName: this.roomName,
123
+ text: '',
124
+ metadata: {},
125
+ headers: {},
126
+ createdAt,
127
+ updatedAt: createdAt,
128
+ action: 'create',
129
+ deleted: false,
130
+ };
131
+ entry = { message: placeholder, createSeen: false, versionTs: -1, versionId: '', applied: new Set() };
132
+ this.byId.set(payload.id, entry);
133
+ }
134
+ if (entry.applied.has(versionId)) {
135
+ return null;
136
+ }
137
+ entry.applied.add(versionId);
138
+ if (!isNewer(entry.versionTs, entry.versionId, timestamp, versionId)) {
139
+ return null;
140
+ }
141
+ const updatedAt = new Date(timestamp);
142
+ if (payload.action === 'delete') {
143
+ entry.message = { ...entry.message, text: '', metadata: {}, headers: {}, updatedAt, action: 'delete', deleted: true };
144
+ entry.versionTs = timestamp;
145
+ entry.versionId = versionId;
146
+ return { type: 'deleted', message: entry.message };
147
+ }
148
+ entry.message = {
149
+ ...entry.message,
150
+ text: payload.text,
151
+ metadata: payload.metadata ?? {},
152
+ headers: payload.headers ?? {},
153
+ updatedAt,
154
+ action: 'update',
155
+ deleted: false,
156
+ };
157
+ entry.versionTs = timestamp;
158
+ entry.versionId = versionId;
159
+ return { type: 'updated', message: entry.message };
160
+ }
161
+ }
162
+
163
+ /** True when `(ts, id)` is strictly newer than `(priorTs, priorId)`. */
164
+ function isNewer(priorTs: number, priorId: string, ts: number, id: string): boolean {
165
+ if (ts !== priorTs) {
166
+ return ts > priorTs;
167
+ }
168
+ return id > priorId;
169
+ }
package/src/room.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A chat room: one realtime channel (`chat:<name>`) carrying messages,
3
+ * presence, typing, reactions and occupancy. Lifecycle (`attach`/`detach`/
4
+ * status) delegates to the underlying channel rather than duplicating a state
5
+ * machine.
6
+ */
7
+
8
+ import type { Channel, ChannelStateChange, UnsubscribeFn } from '@foony/realtime';
9
+ import { Messages } from './messages.js';
10
+ import { Occupancy } from './occupancy.js';
11
+ import { Presence } from './presence.js';
12
+ import { Reactions } from './reactions.js';
13
+ import { Typing } from './typing.js';
14
+ import type { RoomOptions } from './types.js';
15
+
16
+ /** Room lifecycle status — the underlying channel's state. */
17
+ export type RoomStatus = Channel['state'];
18
+
19
+ /** Listener invoked when a continuity gap is detected on (re)attach. */
20
+ export type DiscontinuityListener = (reason?: Error) => void;
21
+
22
+ /** A chat room and its features. Obtain via `chatClient.rooms.get(name)`. */
23
+ export class Room {
24
+ /** Messages: send, edit, delete, subscribe, history. */
25
+ readonly messages: Messages;
26
+ /** Presence: enter/update/leave plus a local member snapshot. */
27
+ readonly presence: Presence;
28
+ /** Typing indicators. */
29
+ readonly typing: Typing;
30
+ /** Ephemeral room-level reactions. */
31
+ readonly reactions: Reactions;
32
+ /** Occupancy counts derived from presence. */
33
+ readonly occupancy: Occupancy;
34
+
35
+ private readonly discontinuityListeners = new Set<DiscontinuityListener>();
36
+ private hadBeenAttached = false;
37
+
38
+ constructor(
39
+ /** Room name (without the `chat:` channel prefix). */
40
+ readonly name: string,
41
+ private readonly channel: Channel,
42
+ getClientId: () => string | null,
43
+ options?: RoomOptions,
44
+ ) {
45
+ this.messages = new Messages(channel, name, getClientId);
46
+ this.presence = new Presence(channel);
47
+ this.typing = new Typing(channel, getClientId, options?.typing?.heartbeatThrottleMs);
48
+ this.reactions = new Reactions(channel, getClientId);
49
+ this.occupancy = new Occupancy(this.presence, options?.occupancy?.debounceMs);
50
+ channel.on((change) => this.onChannelState(change));
51
+ }
52
+
53
+ /** Current room status. */
54
+ get status(): RoomStatus {
55
+ return this.channel.state;
56
+ }
57
+
58
+ /** Ensure the room is attached so messages and presence flow. */
59
+ async attach(): Promise<void> {
60
+ await this.channel.attach();
61
+ }
62
+
63
+ /** Detach from the room (stop receiving). Listeners are preserved. */
64
+ async detach(): Promise<void> {
65
+ await this.channel.detach();
66
+ }
67
+
68
+ /** Subscribe to room status changes. Returns an unsubscribe function. */
69
+ onStatusChange(listener: (change: ChannelStateChange) => void): UnsubscribeFn {
70
+ return this.channel.on(listener);
71
+ }
72
+
73
+ /**
74
+ * Subscribe to continuity gaps — best-effort: fires when the room re-attaches
75
+ * without a resume, so the app can backfill via `messages.history()`.
76
+ */
77
+ onDiscontinuity(listener: DiscontinuityListener): UnsubscribeFn {
78
+ this.discontinuityListeners.add(listener);
79
+ return () => {
80
+ this.discontinuityListeners.delete(listener);
81
+ };
82
+ }
83
+
84
+ private onChannelState(change: ChannelStateChange): void {
85
+ if (change.current === 'attached') {
86
+ if (this.hadBeenAttached && !change.resumed) {
87
+ for (const listener of [...this.discontinuityListeners]) {
88
+ listener(change.reason);
89
+ }
90
+ }
91
+ this.hadBeenAttached = true;
92
+ } else if (change.current === 'detached') {
93
+ // A deliberate detach/re-attach is not a continuity gap.
94
+ this.hadBeenAttached = false;
95
+ }
96
+ }
97
+ }
package/src/rooms.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Room registry. Mirrors `Realtime.channels` — `get(name)` returns a stable
3
+ * {@link Room} per name (creating its `chat:<name>` channel on first use), and
4
+ * `release(name)` detaches and drops it.
5
+ */
6
+
7
+ import type { Realtime } from '@foony/realtime';
8
+ import { roomChannelName } from './protocol.js';
9
+ import { Room } from './room.js';
10
+ import type { RoomOptions } from './types.js';
11
+
12
+ /** Factory and cache for {@link Room} instances on a {@link ChatClient}. */
13
+ export class Rooms {
14
+ private readonly byName = new Map<string, Room>();
15
+
16
+ constructor(
17
+ private readonly realtime: Realtime,
18
+ private readonly getClientId: () => string | null,
19
+ ) {}
20
+
21
+ /** Get (or create) the room named `name`. Stable instance per name. */
22
+ get(name: string, options?: RoomOptions): Room {
23
+ let existing = this.byName.get(name);
24
+ if (!existing) {
25
+ const channel = this.realtime.channels.get(roomChannelName(name));
26
+ existing = new Room(name, channel, this.getClientId, options);
27
+ this.byName.set(name, existing);
28
+ }
29
+ return existing;
30
+ }
31
+
32
+ /** Detach and forget the room named `name`. No-op if not present. */
33
+ release(name: string): void {
34
+ const room = this.byName.get(name);
35
+ if (!room) {
36
+ return;
37
+ }
38
+ this.byName.delete(name);
39
+ room.detach().catch(() => {});
40
+ this.realtime.channels.release(roomChannelName(name));
41
+ }
42
+ }
package/src/types.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Public types for the @foony/chat API.
3
+ *
4
+ * Foony-native shapes inspired by Ably Chat: a {@link Message} is keyed by a
5
+ * stable `id` (so edit/delete reference it directly), and presence/typing/
6
+ * reactions/occupancy mirror the concepts without copying Ably's exact names.
7
+ */
8
+
9
+ /** Latest action that produced a message's current state. */
10
+ export type MessageAction = 'create' | 'update' | 'delete';
11
+
12
+ /**
13
+ * A materialized chat message. Edits replace `text`/`metadata`/`headers`;
14
+ * deletes blank them and set {@link Message.deleted}. Reconcile by `id`.
15
+ */
16
+ export type Message = {
17
+ /** Stable, sender-assigned message id. Pass it to `update`/`delete`. */
18
+ readonly id: string;
19
+ /** Client id of the author. */
20
+ readonly clientId: string;
21
+ /** Room this message belongs to. */
22
+ readonly roomName: string;
23
+ /** Message body; `''` once the message is deleted. */
24
+ readonly text: string;
25
+ /** Arbitrary application metadata; `{}` once deleted. */
26
+ readonly metadata: Readonly<Record<string, unknown>>;
27
+ /** Arbitrary application headers; `{}` once deleted. */
28
+ readonly headers: Readonly<Record<string, unknown>>;
29
+ /** When the message was created (server publish time). */
30
+ readonly createdAt: Date;
31
+ /** When the message last changed; equals `createdAt` until edited or deleted. */
32
+ readonly updatedAt: Date;
33
+ /** The latest applied action. */
34
+ readonly action: MessageAction;
35
+ /** True once a delete has been applied. */
36
+ readonly deleted: boolean;
37
+ };
38
+
39
+ /** Event delivered to message subscribers as the room's history materializes. */
40
+ export type ChatMessageEvent =
41
+ | { readonly type: 'created'; readonly message: Message }
42
+ | { readonly type: 'updated'; readonly message: Message }
43
+ | { readonly type: 'deleted'; readonly message: Message };
44
+
45
+ /** Fields a caller may set when sending a new message. */
46
+ export type SendMessageParams = {
47
+ /** Message body. */
48
+ readonly text: string;
49
+ /** Optional application metadata. */
50
+ readonly metadata?: Record<string, unknown>;
51
+ /** Optional application headers. */
52
+ readonly headers?: Record<string, unknown>;
53
+ };
54
+
55
+ /** Fields a caller may change when editing a message. Omitted fields are cleared. */
56
+ export type UpdateMessageParams = {
57
+ /** New message body. */
58
+ readonly text: string;
59
+ /** Replacement metadata (defaults to `{}`). */
60
+ readonly metadata?: Record<string, unknown>;
61
+ /** Replacement headers (defaults to `{}`). */
62
+ readonly headers?: Record<string, unknown>;
63
+ };
64
+
65
+ /** A page of materialized messages returned by `messages.history`. */
66
+ export type MessagePage = {
67
+ /** Messages in the page, oldest-first. */
68
+ readonly messages: readonly Message[];
69
+ /** True when older messages remain beyond this page. */
70
+ readonly hasMore: boolean;
71
+ /** Cursor (oldest message id in the page) to pass back for the next page. */
72
+ readonly nextCursor?: string;
73
+ };
74
+
75
+ /** A single presence member in a room. */
76
+ export type PresenceMember = {
77
+ /** Member's client id. */
78
+ readonly clientId: string;
79
+ /** Member's connection id (a user on two devices appears as two members). */
80
+ readonly connectionId: string;
81
+ /** Presence payload supplied on enter/update, if any. */
82
+ readonly data?: unknown;
83
+ /** When this member's presence last changed. */
84
+ readonly updatedAt: Date;
85
+ };
86
+
87
+ /** Normalized presence transition delivered to presence subscribers. */
88
+ export type PresenceEvent = {
89
+ /** Which transition occurred. */
90
+ readonly type: 'enter' | 'update' | 'leave';
91
+ /** The member the transition is about. */
92
+ readonly member: PresenceMember;
93
+ };
94
+
95
+ /** Snapshot of who is currently typing in a room (excludes the local client). */
96
+ export type TypingEvent = {
97
+ /** Client ids currently typing. */
98
+ readonly currentlyTyping: ReadonlySet<string>;
99
+ /** The change that produced this event. */
100
+ readonly change: { readonly type: 'started' | 'stopped'; readonly clientId: string };
101
+ };
102
+
103
+ /** An ephemeral, room-level reaction (fire-and-forget; not stored). */
104
+ export type RoomReaction = {
105
+ /** Reaction name, e.g. an emoji or short code. */
106
+ readonly name: string;
107
+ /** Client id that sent the reaction. */
108
+ readonly clientId: string;
109
+ /** Optional reaction metadata. */
110
+ readonly metadata?: unknown;
111
+ /** When the reaction was sent (server publish time). */
112
+ readonly createdAt: Date;
113
+ /** True when the local client sent this reaction. */
114
+ readonly isSelf: boolean;
115
+ };
116
+
117
+ /** Room occupancy counts derived from the presence set. */
118
+ export type Occupancy = {
119
+ /** Distinct (clientId, connectionId) pairs present — i.e. connections in presence. */
120
+ readonly connections: number;
121
+ /** Distinct client ids present. */
122
+ readonly presenceMembers: number;
123
+ };
124
+
125
+ /** Per-room configuration. */
126
+ export type RoomOptions = {
127
+ /** Typing config. */
128
+ readonly typing?: {
129
+ /**
130
+ * How often a continuously-typing client re-broadcasts that it is typing,
131
+ * and the basis for receiver-side expiry. Must be uniform across clients
132
+ * in a room. Defaults to 10000ms.
133
+ */
134
+ readonly heartbeatThrottleMs?: number;
135
+ };
136
+ /** Occupancy config. */
137
+ readonly occupancy?: {
138
+ /** Debounce window for occupancy change events. Defaults to 1000ms. */
139
+ readonly debounceMs?: number;
140
+ };
141
+ };
package/src/typing.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Per-room typing indicators — ephemeral, pure client-side logic over the
3
+ * channel's pub/sub (no persistence, no edge support needed).
4
+ *
5
+ * Sender: the first `keystroke()` broadcasts `started` immediately; further
6
+ * keystrokes within `heartbeatThrottleMs` are no-ops, and one after the window
7
+ * re-broadcasts. `stop()` broadcasts `stopped`. As long as a client keeps
8
+ * typing it keeps heartbeating; when it stops, receivers expire it.
9
+ *
10
+ * Receiver: tracks who is typing and auto-expires a typer after
11
+ * `heartbeatThrottleMs + GRACE_MS` with no heartbeat, emitting a synthetic
12
+ * `stopped`. The local client is excluded from the typing set.
13
+ */
14
+
15
+ import type { Channel, UnsubscribeFn } from '@foony/realtime';
16
+ import { parseTypingPayload, TYPING_EVENT, type TypingPayload } from './protocol.js';
17
+ import type { TypingEvent } from './types.js';
18
+
19
+ /** Listener invoked whenever the set of typers changes. */
20
+ export type TypingListener = (event: TypingEvent) => void;
21
+
22
+ /** Default heartbeat throttle; must be uniform across clients in a room. */
23
+ const DEFAULT_HEARTBEAT_THROTTLE_MS = 10_000;
24
+ /** Extra time a receiver waits past the throttle before expiring a typer. */
25
+ const GRACE_MS = 2_000;
26
+
27
+ /** The typing feature of a {@link Room}. */
28
+ export class Typing {
29
+ private readonly heartbeatThrottleMs: number;
30
+ private readonly listeners = new Set<TypingListener>();
31
+ /** Active typers (excluding self) → expiry timer. */
32
+ private readonly typers = new Map<string, ReturnType<typeof setTimeout>>();
33
+ /** When the local client last broadcast a heartbeat; -Infinity means "never / send next". */
34
+ private lastSentAt = Number.NEGATIVE_INFINITY;
35
+ private channelUnsubscribe: UnsubscribeFn | null = null;
36
+
37
+ constructor(
38
+ private readonly channel: Channel,
39
+ private readonly getClientId: () => string | null,
40
+ heartbeatThrottleMs?: number,
41
+ ) {
42
+ this.heartbeatThrottleMs = heartbeatThrottleMs ?? DEFAULT_HEARTBEAT_THROTTLE_MS;
43
+ }
44
+
45
+ /** Signal the local client is typing. Throttled to one heartbeat per window. */
46
+ async keystroke(): Promise<void> {
47
+ const now = Date.now();
48
+ if (now - this.lastSentAt < this.heartbeatThrottleMs) {
49
+ return;
50
+ }
51
+ this.lastSentAt = now;
52
+ const payload: TypingPayload = { state: 'started' };
53
+ await this.channel.publish(TYPING_EVENT, payload);
54
+ }
55
+
56
+ /** Signal the local client has stopped typing. */
57
+ async stop(): Promise<void> {
58
+ this.lastSentAt = Number.NEGATIVE_INFINITY;
59
+ const payload: TypingPayload = { state: 'stopped' };
60
+ await this.channel.publish(TYPING_EVENT, payload);
61
+ }
62
+
63
+ /** Client ids currently typing (excludes the local client). */
64
+ get currentlyTyping(): ReadonlySet<string> {
65
+ return new Set(this.typers.keys());
66
+ }
67
+
68
+ /** Subscribe to typing changes. Attaches the channel on first listener. */
69
+ subscribe(listener: TypingListener): UnsubscribeFn {
70
+ this.listeners.add(listener);
71
+ this.ensureChannelSubscription();
72
+ return () => {
73
+ this.listeners.delete(listener);
74
+ };
75
+ }
76
+
77
+ private ensureChannelSubscription(): void {
78
+ if (this.channelUnsubscribe) {
79
+ return;
80
+ }
81
+ this.channelUnsubscribe = this.channel.subscribe(TYPING_EVENT, (frame) => {
82
+ const payload = parseTypingPayload(frame.data);
83
+ const clientId = frame.clientId;
84
+ if (payload === null || clientId === undefined || clientId === this.getClientId()) {
85
+ return;
86
+ }
87
+ if (payload.state === 'started') {
88
+ this.markTyping(clientId);
89
+ } else {
90
+ this.markStopped(clientId);
91
+ }
92
+ });
93
+ }
94
+
95
+ private markTyping(clientId: string): void {
96
+ const existing = this.typers.get(clientId);
97
+ if (existing) {
98
+ clearTimeout(existing);
99
+ }
100
+ const timer = setTimeout(() => this.markStopped(clientId), this.heartbeatThrottleMs + GRACE_MS);
101
+ this.typers.set(clientId, timer);
102
+ if (!existing) {
103
+ this.emit({ type: 'started', clientId });
104
+ }
105
+ }
106
+
107
+ private markStopped(clientId: string): void {
108
+ const existing = this.typers.get(clientId);
109
+ if (!existing) {
110
+ return;
111
+ }
112
+ clearTimeout(existing);
113
+ this.typers.delete(clientId);
114
+ this.emit({ type: 'stopped', clientId });
115
+ }
116
+
117
+ private emit(change: TypingEvent['change']): void {
118
+ const event: TypingEvent = { currentlyTyping: new Set(this.typers.keys()), change };
119
+ for (const listener of [...this.listeners]) {
120
+ listener(event);
121
+ }
122
+ }
123
+ }
package/src/util.ts ADDED
@@ -0,0 +1,12 @@
1
+ /** Small internal helpers shared across chat features. */
2
+
3
+ /**
4
+ * A sender-assigned, roughly time-sortable message id: `<unixMillis>-<random>`.
5
+ * Mirrors the realtime SDK's transport id format, but the chat id is decoupled
6
+ * from transport — it lives in the payload so `send` can return it immediately
7
+ * and `update`/`delete` can reference it.
8
+ */
9
+ export function newMessageId(): string {
10
+ const random = Math.floor(Math.random() * 0x1_0000_0000).toString(16).padStart(8, '0');
11
+ return `${Date.now()}-${random}`;
12
+ }