stream-chat 9.43.1 → 9.44.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.
- package/README.md +1 -1
- package/dist/cjs/index.browser.js +156 -7
- package/dist/cjs/index.browser.js.map +3 -3
- package/dist/cjs/index.node.js +166 -8
- package/dist/cjs/index.node.js.map +3 -3
- package/dist/esm/index.mjs +156 -7
- package/dist/esm/index.mjs.map +3 -3
- package/dist/types/client.d.ts +36 -4
- package/dist/types/messageComposer/middleware/textComposer/index.d.ts +1 -0
- package/dist/types/signing.d.ts +99 -5
- package/dist/types/types.d.ts +121 -15
- package/package.json +2 -1
- package/src/channel.ts +3 -1
- package/src/client.ts +78 -11
- package/src/messageComposer/messageComposer.ts +2 -0
- package/src/messageComposer/middleware/textComposer/index.ts +1 -0
- package/src/signing.ts +195 -7
- package/src/types.ts +133 -15
- package/src/utils.ts +1 -1
package/src/signing.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import jwt from 'jsonwebtoken';
|
|
2
2
|
import crypto from 'crypto';
|
|
3
|
+
import zlib from 'zlib';
|
|
3
4
|
import { decodeBase64, encodeBase64 } from './base64';
|
|
4
|
-
import type { UR } from './types';
|
|
5
|
+
import type { Event, UR } from './types';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Creates the JWT token that can be used for a UserSession
|
|
@@ -84,19 +85,206 @@ export function DevToken(userId: string) {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
/**
|
|
88
|
+
* Constant-time HMAC-SHA256 verification of `signature` against the
|
|
89
|
+
* digest of `body` using `secret` as the key. The signature is always
|
|
90
|
+
* computed over the **uncompressed** JSON bytes, so callers that
|
|
91
|
+
* decoded a gzipped or base64-wrapped payload must pass the inflated
|
|
92
|
+
* bytes here.
|
|
87
93
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
94
|
+
* The legacy `client.verifyWebhook` helper wraps this function, so
|
|
95
|
+
* callers that have already migrated to `verifyAndParseWebhook`,
|
|
96
|
+
* `parseSqs`, or `parseSns` rarely need to invoke this
|
|
97
|
+
* directly.
|
|
92
98
|
*/
|
|
93
|
-
export function
|
|
99
|
+
export function verifySignature(
|
|
100
|
+
body: string | Buffer,
|
|
101
|
+
signature: string,
|
|
102
|
+
secret: string,
|
|
103
|
+
): boolean {
|
|
94
104
|
const key = Buffer.from(secret, 'utf8');
|
|
95
105
|
const hash = crypto.createHmac('sha256', key).update(body).digest('hex');
|
|
96
|
-
|
|
97
106
|
try {
|
|
98
107
|
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature));
|
|
99
108
|
} catch {
|
|
100
109
|
return false;
|
|
101
110
|
}
|
|
102
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @deprecated Use {@link verifySignature} - same logic, parameters
|
|
115
|
+
* reordered to match the cross-SDK contract
|
|
116
|
+
* (`verifySignature(body, signature, secret)`).
|
|
117
|
+
*/
|
|
118
|
+
export function CheckSignature(body: string | Buffer, secret: string, signature: string) {
|
|
119
|
+
return verifySignature(body, signature, secret);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Canonical failure-mode messages for {@link InvalidWebhookError}.
|
|
124
|
+
*
|
|
125
|
+
* Customers that prefer exact-match filtering (security logging, retry
|
|
126
|
+
* policy) over substring matches can compare `err.message` to these
|
|
127
|
+
* constants instead of pattern-matching free-form text.
|
|
128
|
+
*/
|
|
129
|
+
export const InvalidWebhookErrorMessages = {
|
|
130
|
+
signatureMismatch: 'signature mismatch',
|
|
131
|
+
invalidBase64: 'invalid base64 encoding',
|
|
132
|
+
gzipFailed: 'gzip decompression failed',
|
|
133
|
+
invalidJson: 'invalid JSON payload',
|
|
134
|
+
} as const;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Thrown by {@link verifyAndParseWebhook} when the supplied `x-signature` does not
|
|
138
|
+
* match the HMAC of the uncompressed payload, and by all webhook helpers (including
|
|
139
|
+
* {@link parseSqs} / {@link parseSns}) when a gzip / base64 / JSON envelope is malformed.
|
|
140
|
+
*
|
|
141
|
+
* The message identifies which failure mode fired. See
|
|
142
|
+
* {@link InvalidWebhookErrorMessages} for the canonical strings.
|
|
143
|
+
*/
|
|
144
|
+
export class InvalidWebhookError extends Error {
|
|
145
|
+
public name = 'InvalidWebhookError';
|
|
146
|
+
|
|
147
|
+
constructor(message: string = InvalidWebhookErrorMessages.signatureMismatch) {
|
|
148
|
+
super(message);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Returns `body` as a `Buffer`, gzip-decompressed when its first two
|
|
154
|
+
* bytes match the gzip magic (`1f 8b`, per RFC 1952). When the body is
|
|
155
|
+
* plain JSON (no compression, or middleware already decompressed), the
|
|
156
|
+
* bytes are returned unchanged.
|
|
157
|
+
*
|
|
158
|
+
* Magic-byte detection (rather than relying on a header) keeps the
|
|
159
|
+
* same handler correct when middleware - Express, Next.js, AWS Lambda
|
|
160
|
+
* - auto-decompresses the request before your code sees it.
|
|
161
|
+
*/
|
|
162
|
+
export function gunzipPayload(rawBody: string | Buffer): Buffer {
|
|
163
|
+
const GZIP_MAGIC = Buffer.from([0x1f, 0x8b]);
|
|
164
|
+
|
|
165
|
+
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody);
|
|
166
|
+
if (body.length >= 2 && body.subarray(0, 2).equals(GZIP_MAGIC)) {
|
|
167
|
+
try {
|
|
168
|
+
return zlib.gunzipSync(body);
|
|
169
|
+
} catch {
|
|
170
|
+
throw new InvalidWebhookError(InvalidWebhookErrorMessages.gzipFailed);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return body;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Reverses the SQS firehose envelope: the message `Body` is
|
|
178
|
+
* base64-decoded, then the result is gzip-decompressed when it begins
|
|
179
|
+
* with the gzip magic. Returns the raw JSON `Buffer` Stream signed.
|
|
180
|
+
*
|
|
181
|
+
* SQS bodies are always base64-encoded so they remain valid UTF-8 over
|
|
182
|
+
* the queue. The same call works whether or not Stream is currently
|
|
183
|
+
* compressing payloads for this app.
|
|
184
|
+
*/
|
|
185
|
+
export function decodeSqsPayload(body: string): Buffer {
|
|
186
|
+
// Reject anything that isn't canonical base64 up front. Node's base64
|
|
187
|
+
// decoder is permissive (silently strips characters outside the
|
|
188
|
+
// alphabet, accepts both standard and URL-safe variants), so we have
|
|
189
|
+
// to be strict here to avoid silently corrupting the body before the
|
|
190
|
+
// signature check runs.
|
|
191
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(body) || body.length % 4 !== 0) {
|
|
192
|
+
throw new InvalidWebhookError(InvalidWebhookErrorMessages.invalidBase64);
|
|
193
|
+
}
|
|
194
|
+
const decoded = Buffer.from(body, 'base64');
|
|
195
|
+
if (decoded.toString('base64').length !== body.length) {
|
|
196
|
+
throw new InvalidWebhookError(InvalidWebhookErrorMessages.invalidBase64);
|
|
197
|
+
}
|
|
198
|
+
return gunzipPayload(decoded);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Reverses an SNS HTTP notification envelope. When `notificationBody`
|
|
203
|
+
* is a JSON envelope (`{"Type":"Notification","Message":"..."}`), the
|
|
204
|
+
* inner `Message` field is extracted and run through the SQS pipeline
|
|
205
|
+
* (base64-decode, then gzip-if-magic). When the input is not a JSON
|
|
206
|
+
* envelope it is treated as the already-extracted `Message` string,
|
|
207
|
+
* so call sites that pre-unwrap continue to work.
|
|
208
|
+
*/
|
|
209
|
+
export function decodeSnsPayload(notificationBody: string): Buffer {
|
|
210
|
+
const inner = extractSnsMessage(notificationBody);
|
|
211
|
+
return decodeSqsPayload(inner ?? notificationBody);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function extractSnsMessage(notificationBody: string): string | null {
|
|
215
|
+
const trimmed = notificationBody.replace(/^[\s\uFEFF]+/, '');
|
|
216
|
+
if (!trimmed.startsWith('{')) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
let parsed: unknown;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(trimmed);
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
if (
|
|
226
|
+
parsed === null ||
|
|
227
|
+
typeof parsed !== 'object' ||
|
|
228
|
+
Array.isArray(parsed) ||
|
|
229
|
+
typeof (parsed as { Message?: unknown }).Message !== 'string'
|
|
230
|
+
) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return (parsed as { Message: string }).Message;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Parse a JSON-encoded webhook event into a typed {@link Event}. New
|
|
238
|
+
* event types Stream introduces still parse successfully - the runtime
|
|
239
|
+
* shape is the JSON Stream sent and the `type` field stays preserved.
|
|
240
|
+
*/
|
|
241
|
+
export function parseEvent(payload: Buffer | string): Event {
|
|
242
|
+
const text = Buffer.isBuffer(payload) ? payload.toString('utf8') : payload;
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(text) as Event;
|
|
245
|
+
} catch {
|
|
246
|
+
throw new InvalidWebhookError(InvalidWebhookErrorMessages.invalidJson);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function verifyAndParse(payload: Buffer, signature: string, secret: string): Event {
|
|
251
|
+
if (!verifySignature(payload, signature, secret)) {
|
|
252
|
+
throw new InvalidWebhookError(InvalidWebhookErrorMessages.signatureMismatch);
|
|
253
|
+
}
|
|
254
|
+
return parseEvent(payload);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Decompress (when gzipped), verify the HMAC `signature`, and return
|
|
259
|
+
* the parsed {@link Event}.
|
|
260
|
+
*
|
|
261
|
+
* @param rawBody Raw HTTP request body bytes Stream signed
|
|
262
|
+
* @param signature Value of the `X-Signature` header
|
|
263
|
+
* @param secret Your app's API secret
|
|
264
|
+
* @throws {InvalidWebhookError} When the signature does not match or
|
|
265
|
+
* the gzip envelope is malformed.
|
|
266
|
+
*/
|
|
267
|
+
export function verifyAndParseWebhook(
|
|
268
|
+
rawBody: string | Buffer,
|
|
269
|
+
signature: string,
|
|
270
|
+
secret: string,
|
|
271
|
+
): Event {
|
|
272
|
+
return verifyAndParse(gunzipPayload(rawBody), signature, secret);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Decode the SQS message `Body` (base64, then gzip-if-magic) and return
|
|
277
|
+
* the parsed {@link Event}. Stream does not attach an application-level HMAC
|
|
278
|
+
* to SQS deliveries — use {@link verifyAndParseWebhook} for HTTP webhooks.
|
|
279
|
+
*/
|
|
280
|
+
export function parseSqs(messageBody: string): Event {
|
|
281
|
+
return parseEvent(decodeSqsPayload(messageBody));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Decode an SNS notification (unwrap the JSON envelope when needed; same
|
|
286
|
+
* inner format as SQS). No application-level HMAC verification.
|
|
287
|
+
*/
|
|
288
|
+
export function parseSns(notificationBody: string): Event {
|
|
289
|
+
return parseEvent(decodeSnsPayload(notificationBody));
|
|
290
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1060,13 +1060,29 @@ export type ChannelOptions = {
|
|
|
1060
1060
|
user_id?: string;
|
|
1061
1061
|
watch?: boolean;
|
|
1062
1062
|
/**
|
|
1063
|
-
* Name of a predefined filter to use instead of
|
|
1064
|
-
*
|
|
1063
|
+
* Name of a predefined filter to use instead of sending raw
|
|
1064
|
+
* `filter_conditions`.
|
|
1065
|
+
*
|
|
1066
|
+
* The backend resolves the filter template by name and interpolates it using
|
|
1067
|
+
* `filter_values`.
|
|
1068
|
+
*
|
|
1069
|
+
* A regular `sort` can still be passed to `queryChannels()`, but backend
|
|
1070
|
+
* precedence rules apply:
|
|
1071
|
+
*
|
|
1072
|
+
* - if the predefined filter has its own stored sort template, that stored
|
|
1073
|
+
* sort takes precedence and the request `sort` is ignored
|
|
1074
|
+
* - if the predefined filter does not define a sort template, the request
|
|
1075
|
+
* `sort` can still be used
|
|
1065
1076
|
*/
|
|
1066
1077
|
predefined_filter?: string;
|
|
1067
1078
|
/**
|
|
1068
|
-
* Values to interpolate
|
|
1069
|
-
*
|
|
1079
|
+
* Values used to interpolate placeholders inside the predefined filter's
|
|
1080
|
+
* `filter` template.
|
|
1081
|
+
*
|
|
1082
|
+
* Example: a template value like `{{user_id}}` can be resolved with
|
|
1083
|
+
* `{ user_id: 'alice' }`.
|
|
1084
|
+
*
|
|
1085
|
+
* Only used when `predefined_filter` is provided.
|
|
1070
1086
|
*/
|
|
1071
1087
|
filter_values?: Record<string, unknown>;
|
|
1072
1088
|
/**
|
|
@@ -4755,38 +4771,129 @@ export type UpdateChannelsBatchResponse = {
|
|
|
4755
4771
|
export type PredefinedFilterOperation = 'QueryChannels';
|
|
4756
4772
|
|
|
4757
4773
|
export type PredefinedFilterSortParam = {
|
|
4774
|
+
/**
|
|
4775
|
+
* Field name to sort by.
|
|
4776
|
+
*
|
|
4777
|
+
* This may be a literal field name such as `created_at`, or a placeholder
|
|
4778
|
+
* template such as `{{sort_field}}` that will be interpolated server-side.
|
|
4779
|
+
*/
|
|
4758
4780
|
field: string;
|
|
4781
|
+
/**
|
|
4782
|
+
* Sort direction. `1` means ascending and `-1` means descending.
|
|
4783
|
+
*
|
|
4784
|
+
* The backend defaults this to `1` when omitted.
|
|
4785
|
+
*/
|
|
4759
4786
|
direction?: AscDesc;
|
|
4787
|
+
/**
|
|
4788
|
+
* Optional server-side hint describing how the sort field value should be
|
|
4789
|
+
* interpreted.
|
|
4790
|
+
*
|
|
4791
|
+
* This is mainly relevant for predefined-filter sort templates and is not
|
|
4792
|
+
* part of the regular `queryChannels()` sort shape. Omitting it uses the
|
|
4793
|
+
* backend default string behavior. Known backend values include:
|
|
4794
|
+
*
|
|
4795
|
+
* - `number`: cast custom-field values to numeric before sorting
|
|
4796
|
+
* - `boolean`: cast custom-field values to boolean before sorting
|
|
4797
|
+
*
|
|
4798
|
+
* Other values are backend-defined. In most cases this should be omitted
|
|
4799
|
+
* unless you are sorting by a custom field whose stored JSON value is not
|
|
4800
|
+
* string-like.
|
|
4801
|
+
*/
|
|
4760
4802
|
type?: string;
|
|
4761
4803
|
};
|
|
4762
4804
|
|
|
4763
|
-
|
|
4805
|
+
/**
|
|
4806
|
+
* Stored predefined filter definition as returned by the server.
|
|
4807
|
+
*
|
|
4808
|
+
* `F` represents the raw filter template shape. It defaults to a generic record
|
|
4809
|
+
* because predefined filters are server-managed templates and may include
|
|
4810
|
+
* placeholders or app-specific structures.
|
|
4811
|
+
*/
|
|
4812
|
+
export type PredefinedFilter<
|
|
4813
|
+
F extends Record<string, unknown> = Record<string, unknown>,
|
|
4814
|
+
> = {
|
|
4815
|
+
/**
|
|
4816
|
+
* Unique predefined filter name within the app.
|
|
4817
|
+
*/
|
|
4764
4818
|
name: string;
|
|
4819
|
+
/**
|
|
4820
|
+
* Operation this predefined filter is valid for.
|
|
4821
|
+
*/
|
|
4765
4822
|
operation: PredefinedFilterOperation;
|
|
4766
|
-
|
|
4823
|
+
/**
|
|
4824
|
+
* Filter template stored on the server.
|
|
4825
|
+
*
|
|
4826
|
+
* This is not necessarily the fully interpolated runtime filter; placeholder
|
|
4827
|
+
* values such as `{{user_id}}` may still be present.
|
|
4828
|
+
*/
|
|
4829
|
+
filter: F;
|
|
4830
|
+
/**
|
|
4831
|
+
* Server creation timestamp in ISO-8601 format.
|
|
4832
|
+
*/
|
|
4767
4833
|
created_at: string;
|
|
4834
|
+
/**
|
|
4835
|
+
* Server update timestamp in ISO-8601 format.
|
|
4836
|
+
*/
|
|
4768
4837
|
updated_at: string;
|
|
4838
|
+
/**
|
|
4839
|
+
* Optional human-readable description.
|
|
4840
|
+
*/
|
|
4769
4841
|
description?: string;
|
|
4842
|
+
/**
|
|
4843
|
+
* Optional sort template stored with the predefined filter.
|
|
4844
|
+
*/
|
|
4770
4845
|
sort?: PredefinedFilterSortParam[];
|
|
4846
|
+
/**
|
|
4847
|
+
* Query identifier generated by the backend for the filter/sort pattern.
|
|
4848
|
+
*
|
|
4849
|
+
* The exact value is backend-generated and primarily useful for correlating
|
|
4850
|
+
* predefined filters with query analysis / query performance data.
|
|
4851
|
+
*/
|
|
4771
4852
|
query_id?: number;
|
|
4772
4853
|
};
|
|
4773
4854
|
|
|
4774
|
-
export type CreatePredefinedFilterOptions
|
|
4855
|
+
export type CreatePredefinedFilterOptions<
|
|
4856
|
+
F extends Record<string, unknown> = Record<string, unknown>,
|
|
4857
|
+
> = {
|
|
4858
|
+
/**
|
|
4859
|
+
* Unique predefined filter name.
|
|
4860
|
+
*/
|
|
4775
4861
|
name: string;
|
|
4862
|
+
/**
|
|
4863
|
+
* Operation this predefined filter will be used with.
|
|
4864
|
+
*/
|
|
4776
4865
|
operation: PredefinedFilterOperation;
|
|
4777
|
-
|
|
4866
|
+
/**
|
|
4867
|
+
* Filter template to store on the server.
|
|
4868
|
+
*/
|
|
4869
|
+
filter: F;
|
|
4870
|
+
/**
|
|
4871
|
+
* Optional human-readable description.
|
|
4872
|
+
*/
|
|
4778
4873
|
description?: string;
|
|
4874
|
+
/**
|
|
4875
|
+
* Optional sort template stored with the predefined filter.
|
|
4876
|
+
*/
|
|
4779
4877
|
sort?: PredefinedFilterSortParam[];
|
|
4780
4878
|
};
|
|
4781
4879
|
|
|
4782
|
-
export type UpdatePredefinedFilterOptions
|
|
4880
|
+
export type UpdatePredefinedFilterOptions<
|
|
4881
|
+
F extends Record<string, unknown> = Record<string, unknown>,
|
|
4882
|
+
> = Omit<CreatePredefinedFilterOptions<F>, 'name'>;
|
|
4783
4883
|
|
|
4784
|
-
export type PredefinedFilterResponse
|
|
4785
|
-
|
|
4884
|
+
export type PredefinedFilterResponse<
|
|
4885
|
+
F extends Record<string, unknown> = Record<string, unknown>,
|
|
4886
|
+
> = APIResponse & {
|
|
4887
|
+
predefined_filter: PredefinedFilter<F>;
|
|
4786
4888
|
};
|
|
4787
4889
|
|
|
4788
|
-
|
|
4789
|
-
|
|
4890
|
+
/**
|
|
4891
|
+
* Paginated response returned when listing predefined filters.
|
|
4892
|
+
*/
|
|
4893
|
+
export type ListPredefinedFiltersResponse<
|
|
4894
|
+
F extends Record<string, unknown> = Record<string, unknown>,
|
|
4895
|
+
> = APIResponse & {
|
|
4896
|
+
predefined_filters: PredefinedFilter<F>[];
|
|
4790
4897
|
next?: string;
|
|
4791
4898
|
prev?: string;
|
|
4792
4899
|
};
|
|
@@ -4795,9 +4902,20 @@ export type ListPredefinedFiltersResponse = APIResponse & {
|
|
|
4795
4902
|
* Contains the interpolated filter and sort from a predefined filter.
|
|
4796
4903
|
* This is returned in the QueryChannels response when using a predefined filter.
|
|
4797
4904
|
*/
|
|
4798
|
-
export type ParsedPredefinedFilterResponse
|
|
4905
|
+
export type ParsedPredefinedFilterResponse<
|
|
4906
|
+
F extends Record<string, unknown> = Record<string, unknown>,
|
|
4907
|
+
> = {
|
|
4908
|
+
/**
|
|
4909
|
+
* Name of the predefined filter that was resolved.
|
|
4910
|
+
*/
|
|
4799
4911
|
name: string;
|
|
4800
|
-
|
|
4912
|
+
/**
|
|
4913
|
+
* Fully interpolated filter that the backend executed.
|
|
4914
|
+
*/
|
|
4915
|
+
filter: F;
|
|
4916
|
+
/**
|
|
4917
|
+
* Fully interpolated sort parameters resolved from the predefined filter.
|
|
4918
|
+
*/
|
|
4801
4919
|
sort?: PredefinedFilterSortParam[];
|
|
4802
4920
|
};
|
|
4803
4921
|
|
package/src/utils.ts
CHANGED
|
@@ -479,7 +479,7 @@ export const deleteUserMessages = ({
|
|
|
479
479
|
: (toDeletedMessage({ message, hardDelete, deletedAt }) as LocalMessage);
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
-
if (message.quoted_message?.user?.id === user.id) {
|
|
482
|
+
if (messages[i].quoted_message && message.quoted_message?.user?.id === user.id) {
|
|
483
483
|
messages[i].quoted_message =
|
|
484
484
|
message.quoted_message.type === 'deleted'
|
|
485
485
|
? message.quoted_message
|