Skip to content

Commit 75595de

Browse files
Merge branch 'master' of github.com:GetStream/stream-chat-js into fix-submit-action-types
2 parents 786bee3 + 876599b commit 75595de

File tree

11 files changed

+708
-42
lines changed

11 files changed

+708
-42
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## [9.30.0](https://github.com/GetStream/stream-chat-js/compare/v9.29.0...v9.30.0) (2026-01-26)
2+
3+
### Features
4+
5+
* add CooldownTimer to Channel ([#1678](https://github.com/GetStream/stream-chat-js/issues/1678)) ([6013fa1](https://github.com/GetStream/stream-chat-js/commit/6013fa1954d283f07d24e44b0fa3538bcf010f86))
6+
* add previewUri to all the upload file attachments ([#1679](https://github.com/GetStream/stream-chat-js/issues/1679)) ([5a70981](https://github.com/GetStream/stream-chat-js/commit/5a709817362d6e63152b5df4750b301bc65e89f8))
7+
18
## [9.29.0](https://github.com/GetStream/stream-chat-js/compare/v9.28.0...v9.29.0) (2026-01-20)
29

310
### Bug Fixes

src/CooldownTimer.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { StateStore } from './store';
2+
import type { ChannelResponse, LocalMessage } from './types';
3+
import { WithSubscriptions } from './utils/WithSubscriptions';
4+
import type { Channel } from './channel';
5+
6+
export type CooldownTimerState = {
7+
/**
8+
* Slow mode cooldown interval in seconds. Change reported via channel.updated WS event.
9+
*/
10+
cooldownConfigSeconds: number;
11+
/**
12+
* Whether the current user can skip slow mode. Change is not reported via WS.
13+
*/
14+
canSkipCooldown: boolean;
15+
/**
16+
* Latest message creation date authored by the current user in this channel. Change reported via message.new WS event.
17+
*/
18+
ownLatestMessageDate?: Date;
19+
/**
20+
* Remaining cooldown in whole seconds (rounded).
21+
*/
22+
cooldownRemaining: number;
23+
};
24+
25+
const toDateOrUndefined = (value: unknown): Date | undefined => {
26+
if (value instanceof Date) return value;
27+
if (typeof value === 'string' || typeof value === 'number') {
28+
const parsed = new Date(value);
29+
if (!Number.isNaN(parsed.getTime())) return parsed;
30+
}
31+
return undefined;
32+
};
33+
34+
export class CooldownTimer extends WithSubscriptions {
35+
public readonly state: StateStore<CooldownTimerState>;
36+
private timeout: ReturnType<typeof setTimeout> | null = null;
37+
private channel: Channel;
38+
39+
constructor({ channel }: { channel: Channel }) {
40+
super();
41+
this.channel = channel;
42+
this.state = new StateStore<CooldownTimerState>({
43+
cooldownConfigSeconds: 0,
44+
cooldownRemaining: 0,
45+
ownLatestMessageDate: undefined,
46+
canSkipCooldown: false,
47+
});
48+
this.refresh();
49+
}
50+
51+
get cooldownConfigSeconds() {
52+
return this.state.getLatestValue().cooldownConfigSeconds;
53+
}
54+
55+
get cooldownRemaining() {
56+
return this.state.getLatestValue().cooldownRemaining;
57+
}
58+
59+
get canSkipCooldown() {
60+
return this.state.getLatestValue().canSkipCooldown;
61+
}
62+
63+
get ownLatestMessageDate() {
64+
return this.state.getLatestValue().ownLatestMessageDate;
65+
}
66+
67+
public registerSubscriptions = () => {
68+
this.incrementRefCount();
69+
if (this.hasSubscriptions) return;
70+
71+
this.addUnsubscribeFunction(
72+
this.channel.on('message.new', (event) => {
73+
const isOwnMessage =
74+
event.message?.user?.id && event.message.user.id === this.getOwnUserId();
75+
if (!isOwnMessage) return;
76+
this.setOwnLatestMessageDate(toDateOrUndefined(event.message?.created_at));
77+
}).unsubscribe,
78+
);
79+
80+
this.addUnsubscribeFunction(
81+
this.channel.on('channel.updated', (event) => {
82+
const cooldownChanged = event.channel?.cooldown !== this.cooldownConfigSeconds;
83+
if (!cooldownChanged) return;
84+
this.refresh();
85+
}).unsubscribe,
86+
);
87+
};
88+
89+
public setCooldownRemaining = (cooldownRemaining: number) => {
90+
this.state.partialNext({ cooldownRemaining });
91+
};
92+
93+
public clearTimeout = () => {
94+
if (!this.timeout) return;
95+
clearTimeout(this.timeout);
96+
this.timeout = null;
97+
};
98+
99+
public refresh = () => {
100+
const { cooldown: cooldownConfigSeconds = 0, own_capabilities } = (this.channel
101+
.data ?? {}) as Partial<ChannelResponse>;
102+
const canSkipCooldown = (own_capabilities ?? []).includes('skip-slow-mode');
103+
104+
const ownLatestMessageDate = this.findOwnLatestMessageDate({
105+
messages: this.channel.state.latestMessages,
106+
});
107+
108+
if (
109+
cooldownConfigSeconds !== this.cooldownConfigSeconds ||
110+
ownLatestMessageDate?.getTime() !== this.ownLatestMessageDate?.getTime() ||
111+
canSkipCooldown !== this.canSkipCooldown
112+
) {
113+
this.state.partialNext({
114+
cooldownConfigSeconds,
115+
ownLatestMessageDate,
116+
canSkipCooldown,
117+
});
118+
}
119+
120+
if (this.canSkipCooldown || this.cooldownConfigSeconds === 0) {
121+
this.clearTimeout();
122+
if (this.cooldownRemaining !== 0) {
123+
this.setCooldownRemaining(0);
124+
}
125+
return;
126+
}
127+
128+
this.recalculate();
129+
};
130+
131+
/**
132+
* Updates the known latest own message date and recomputes remaining time.
133+
* Prefer calling this when you already know the message date (e.g. from an event).
134+
*/
135+
public setOwnLatestMessageDate = (date: Date | undefined) => {
136+
this.state.partialNext({ ownLatestMessageDate: date });
137+
this.recalculate();
138+
};
139+
140+
private getOwnUserId() {
141+
const client = this.channel.getClient();
142+
return client.userID ?? client.user?.id;
143+
}
144+
145+
private findOwnLatestMessageDate({
146+
messages,
147+
}: {
148+
messages: LocalMessage[];
149+
}): Date | undefined {
150+
const ownUserId = this.getOwnUserId();
151+
if (!ownUserId) return undefined;
152+
153+
let latest: Date | undefined;
154+
for (let i = messages.length - 1; i >= 0; i -= 1) {
155+
const message = messages[i];
156+
if (message.user?.id !== ownUserId) continue;
157+
const createdAt = toDateOrUndefined(message.created_at);
158+
if (!createdAt) continue;
159+
if (!latest || createdAt.getTime() > latest.getTime()) {
160+
latest = createdAt;
161+
}
162+
if (latest.getTime() > createdAt.getTime()) break;
163+
}
164+
return latest;
165+
}
166+
167+
private recalculate = () => {
168+
this.clearTimeout();
169+
170+
const { cooldownConfigSeconds, ownLatestMessageDate, canSkipCooldown } =
171+
this.state.getLatestValue();
172+
173+
const timeSinceOwnLastMessage =
174+
ownLatestMessageDate != null
175+
? // prevent negative values
176+
Math.max(0, (Date.now() - ownLatestMessageDate.getTime()) / 1000)
177+
: undefined;
178+
179+
const remaining =
180+
!canSkipCooldown &&
181+
typeof timeSinceOwnLastMessage !== 'undefined' &&
182+
cooldownConfigSeconds > timeSinceOwnLastMessage
183+
? Math.round(cooldownConfigSeconds - timeSinceOwnLastMessage)
184+
: 0;
185+
186+
if (remaining !== this.cooldownRemaining) {
187+
this.setCooldownRemaining(remaining);
188+
}
189+
190+
if (remaining <= 0) return;
191+
192+
this.timeout = setTimeout(() => {
193+
this.recalculate();
194+
}, 1000);
195+
};
196+
}

src/channel.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ChannelState } from './channel_state';
2+
import { CooldownTimer } from './CooldownTimer';
23
import { MessageComposer } from './messageComposer';
34
import { MessageReceiptsTracker } from './messageDelivery';
45
import {
@@ -113,6 +114,7 @@ export class Channel {
113114
push_preferences?: PushPreference;
114115
public readonly messageComposer: MessageComposer;
115116
public readonly messageReceiptsTracker: MessageReceiptsTracker;
117+
public readonly cooldownTimer: CooldownTimer;
116118

117119
/**
118120
* constructor - Create a channel
@@ -168,6 +170,8 @@ export class Channel {
168170
return msg && { timestampMs, msgId: msg.id };
169171
},
170172
});
173+
174+
this.cooldownTimer = new CooldownTimer({ channel: this });
171175
}
172176

173177
/**
@@ -1581,6 +1585,7 @@ export class Channel {
15811585
.join();
15821586
this.data = state.channel;
15831587
this.offlineMode = false;
1588+
this.cooldownTimer.refresh();
15841589

15851590
if (areCapabilitiesChanged) {
15861591
this.getClient().dispatchEvent({
@@ -2035,6 +2040,9 @@ export class Channel {
20352040
// 1. the message is mine
20362041
// 2. the message is a thread reply from any user
20372042
const preventUnreadCountUpdate = ownMessage || isThreadMessage;
2043+
if (ownMessage) {
2044+
this.cooldownTimer.refresh();
2045+
}
20382046
if (preventUnreadCountUpdate) break;
20392047

20402048
if (event.user?.id) {
@@ -2195,6 +2203,7 @@ export class Channel {
21952203
event.channel?.own_capabilities ?? channel.data?.own_capabilities,
21962204
};
21972205
channel.data = newChannelData;
2206+
this.cooldownTimer.refresh();
21982207
}
21992208
break;
22002209
case 'reaction.new':
@@ -2457,6 +2466,7 @@ export class Channel {
24572466
);
24582467

24592468
this.disconnected = true;
2469+
this.cooldownTimer.clearTimeout();
24602470
this.state.setIsUpToDate(false);
24612471
}
24622472
}

src/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2046,7 +2046,7 @@ export class StreamChat {
20462046
}
20472047

20482048
c.messageComposer.initStateFromChannelResponse(channelState);
2049-
2049+
c.cooldownTimer.refresh();
20502050
channels.push(c);
20512051
}
20522052
this.syncDeliveredCandidates(channels);

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './client_state';
66
export * from './channel';
77
export * from './channel_state';
88
export * from './connection';
9+
export { type CooldownTimerState } from './CooldownTimer';
910
export * from './events';
1011
export * from './insights';
1112
export * from './messageComposer';

src/messageComposer/attachmentManager.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -368,15 +368,18 @@ export class AttachmentManager {
368368

369369
localAttachment[isImageFile(file) ? 'fallback' : 'title'] = file.name;
370370

371-
if (isImageFile(file)) {
372-
localAttachment.localMetadata.previewUri = isFileReference(fileLike)
373-
? fileLike.uri
374-
: URL.createObjectURL?.(fileLike);
375-
376-
if (isFileReference(fileLike) && fileLike.height && fileLike.width) {
377-
localAttachment.original_height = fileLike.height;
378-
localAttachment.original_width = fileLike.width;
379-
}
371+
localAttachment.localMetadata.previewUri = isFileReference(fileLike)
372+
? fileLike.uri
373+
: URL.createObjectURL?.(fileLike);
374+
375+
if (
376+
isFileReference(fileLike) &&
377+
fileLike.height &&
378+
fileLike.width &&
379+
isImageFile(file)
380+
) {
381+
localAttachment.original_height = fileLike.height;
382+
localAttachment.original_width = fileLike.width;
380383
}
381384

382385
if (isFileReference(fileLike) && fileLike.thumb_url) {
@@ -561,11 +564,13 @@ export class AttachmentManager {
561564
},
562565
};
563566

567+
const previewUri = uploadedAttachment.localMetadata.previewUri;
568+
if (previewUri) {
569+
if (previewUri.startsWith('blob:')) URL.revokeObjectURL(previewUri);
570+
delete uploadedAttachment.localMetadata.previewUri;
571+
}
572+
564573
if (isLocalImageAttachment(uploadedAttachment)) {
565-
if (uploadedAttachment.localMetadata.previewUri) {
566-
URL.revokeObjectURL(uploadedAttachment.localMetadata.previewUri);
567-
delete uploadedAttachment.localMetadata.previewUri;
568-
}
569574
uploadedAttachment.image_url = response.file;
570575
} else {
571576
(uploadedAttachment as LocalNotImageAttachment).asset_url = response.file;

src/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ export const createPostUploadAttachmentEnrichmentMiddleware =
2121
if (!attachment || !response) return discard();
2222

2323
const enrichedAttachment = { ...attachment };
24+
const previewUri = attachment.localMetadata.previewUri;
25+
if (previewUri) {
26+
if (previewUri.startsWith('blob:')) URL.revokeObjectURL(previewUri);
27+
delete enrichedAttachment.localMetadata.previewUri;
28+
}
2429
if (isLocalImageAttachment(attachment)) {
25-
if (attachment.localMetadata.previewUri) {
26-
URL.revokeObjectURL(attachment.localMetadata.previewUri);
27-
delete enrichedAttachment.localMetadata.previewUri;
28-
}
2930
enrichedAttachment.image_url = response.file;
3031
} else {
3132
(enrichedAttachment as LocalNotImageAttachment).asset_url = response.file;

src/messageComposer/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,19 @@ export type BaseLocalAttachmentMetadata = {
105105

106106
export type LocalAttachmentUploadMetadata = {
107107
file: File | FileReference;
108+
/**
109+
* Local preview URI, typically a Blob URL from `URL.createObjectURL(file)`
110+
* or (for React Native `FileReference`) the provided `uri`.
111+
*/
112+
previewUri?: string;
108113
uploadState: AttachmentLoadingState;
109114
uploadPermissionCheck?: UploadPermissionCheckResult; // added new
110115
};
111116

112117
export type LocalImageAttachmentUploadMetadata = LocalAttachmentUploadMetadata & {
118+
/**
119+
* @deprecated `previewUri` is now available on `LocalAttachmentUploadMetadata`.
120+
*/
113121
previewUri?: string;
114122
};
115123

0 commit comments

Comments
 (0)