@natoe/colab 0.1.19 → 0.1.20

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/dist/index.mjs CHANGED
@@ -3088,6 +3088,121 @@ var styles6 = {
3088
3088
  animation: "natoe-colab-message-in 180ms ease-out"
3089
3089
  }
3090
3090
  };
3091
+ function useAudioRecorder() {
3092
+ const [isRecording, setIsRecording] = useState(false);
3093
+ const [duration, setDuration] = useState(0);
3094
+ const [error, setError] = useState(null);
3095
+ const mediaRecorderRef = useRef(null);
3096
+ const chunksRef = useRef([]);
3097
+ const streamRef = useRef(null);
3098
+ const timerRef = useRef(null);
3099
+ const startTimeRef = useRef(0);
3100
+ const resolveRef = useRef(null);
3101
+ const isSupported = typeof window !== "undefined" && typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && !!window.MediaRecorder;
3102
+ const cleanup = useCallback(() => {
3103
+ if (timerRef.current) {
3104
+ clearInterval(timerRef.current);
3105
+ timerRef.current = null;
3106
+ }
3107
+ if (streamRef.current) {
3108
+ streamRef.current.getTracks().forEach((track) => track.stop());
3109
+ streamRef.current = null;
3110
+ }
3111
+ mediaRecorderRef.current = null;
3112
+ chunksRef.current = [];
3113
+ setIsRecording(false);
3114
+ setDuration(0);
3115
+ }, []);
3116
+ const start = useCallback(async () => {
3117
+ if (!isSupported) {
3118
+ setError("Audio recording is not supported in this browser");
3119
+ return;
3120
+ }
3121
+ try {
3122
+ setError(null);
3123
+ chunksRef.current = [];
3124
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
3125
+ streamRef.current = stream;
3126
+ const mimeType = MediaRecorder.isTypeSupported(AUDIO_MIME_TYPE) ? AUDIO_MIME_TYPE : MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "audio/mp4";
3127
+ const recorder = new MediaRecorder(stream, { mimeType });
3128
+ mediaRecorderRef.current = recorder;
3129
+ recorder.ondataavailable = (event) => {
3130
+ if (event.data.size > 0) {
3131
+ chunksRef.current.push(event.data);
3132
+ }
3133
+ };
3134
+ recorder.onerror = () => {
3135
+ setError("Recording failed");
3136
+ cleanup();
3137
+ resolveRef.current?.(null);
3138
+ resolveRef.current = null;
3139
+ };
3140
+ recorder.onstop = () => {
3141
+ const finalDuration = Math.round((Date.now() - startTimeRef.current) / 1e3);
3142
+ const blob = new Blob(chunksRef.current, { type: mimeType });
3143
+ cleanup();
3144
+ resolveRef.current?.({ blob, duration: finalDuration });
3145
+ resolveRef.current = null;
3146
+ };
3147
+ recorder.start(250);
3148
+ startTimeRef.current = Date.now();
3149
+ setIsRecording(true);
3150
+ timerRef.current = setInterval(() => {
3151
+ const elapsed = Math.round((Date.now() - startTimeRef.current) / 1e3);
3152
+ setDuration(elapsed);
3153
+ }, 1e3);
3154
+ } catch (err) {
3155
+ if (err instanceof DOMException && err.name === "NotAllowedError") {
3156
+ setError("Microphone access denied. Please allow microphone permissions.");
3157
+ } else {
3158
+ setError("Failed to start recording");
3159
+ }
3160
+ cleanup();
3161
+ }
3162
+ }, [isSupported, cleanup]);
3163
+ const stop = useCallback(async () => {
3164
+ return new Promise((resolve) => {
3165
+ if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") {
3166
+ resolve(null);
3167
+ return;
3168
+ }
3169
+ resolveRef.current = resolve;
3170
+ mediaRecorderRef.current.stop();
3171
+ });
3172
+ }, []);
3173
+ const cancel = useCallback(() => {
3174
+ if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
3175
+ mediaRecorderRef.current.onstop = null;
3176
+ mediaRecorderRef.current.stop();
3177
+ }
3178
+ cleanup();
3179
+ resolveRef.current?.(null);
3180
+ resolveRef.current = null;
3181
+ }, [cleanup]);
3182
+ return {
3183
+ isRecording,
3184
+ duration,
3185
+ start,
3186
+ stop,
3187
+ cancel,
3188
+ isSupported,
3189
+ error
3190
+ };
3191
+ }
3192
+ function MicIcon({ size = 20, color = "currentColor" }) {
3193
+ return /* @__PURE__ */ jsx(
3194
+ "svg",
3195
+ {
3196
+ xmlns: "http://www.w3.org/2000/svg",
3197
+ width: size,
3198
+ height: size,
3199
+ viewBox: "0 0 24 24",
3200
+ fill: color,
3201
+ "aria-hidden": "true",
3202
+ children: /* @__PURE__ */ jsx("path", { d: "M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z" })
3203
+ }
3204
+ );
3205
+ }
3091
3206
  function ReplyPreview({ message, onCancel, className }) {
3092
3207
  const preview = renderQuotedPreview(message);
3093
3208
  return /* @__PURE__ */ jsxs("div", { className, style: styles7.container, children: [
@@ -3224,8 +3339,7 @@ function CloseIcon({ size = 18, color = "currentColor" }) {
3224
3339
  }
3225
3340
  function MessageInput({
3226
3341
  onSendText,
3227
- // MIC DISABLED — onSendAudio still accepted but unused.
3228
- // onSendAudio,
3342
+ onSendAudio,
3229
3343
  onSendFile,
3230
3344
  onTyping,
3231
3345
  replyTo,
@@ -3242,6 +3356,15 @@ function MessageInput({
3242
3356
  const typingTimeoutRef = useRef(null);
3243
3357
  const isTypingRef = useRef(false);
3244
3358
  const sendingRef = useRef(false);
3359
+ const {
3360
+ isRecording,
3361
+ duration,
3362
+ start: startRecording,
3363
+ stop: stopRecording,
3364
+ cancel: cancelRecording,
3365
+ isSupported: micSupported,
3366
+ error: micError
3367
+ } = useAudioRecorder();
3245
3368
  const handleTyping = useCallback(() => {
3246
3369
  if (!isTypingRef.current) {
3247
3370
  isTypingRef.current = true;
@@ -3290,6 +3413,16 @@ function MessageInput({
3290
3413
  },
3291
3414
  [handleSendText]
3292
3415
  );
3416
+ const handleAudioStop = useCallback(async () => {
3417
+ const result = await stopRecording();
3418
+ if (!result || !onSendAudio) return;
3419
+ setIsSending(true);
3420
+ try {
3421
+ await onSendAudio(result.blob, result.duration);
3422
+ } finally {
3423
+ setIsSending(false);
3424
+ }
3425
+ }, [stopRecording, onSendAudio]);
3293
3426
  const handleFileSelect = useCallback(
3294
3427
  async (e) => {
3295
3428
  const file = e.target.files?.[0];
@@ -3330,7 +3463,18 @@ function MessageInput({
3330
3463
  }
3331
3464
  )
3332
3465
  ] }),
3333
- /* @__PURE__ */ jsxs("div", { style: styles8.inputBar, children: [
3466
+ micError && /* @__PURE__ */ jsx("div", { style: styles8.error, role: "alert", children: /* @__PURE__ */ jsx("span", { style: styles8.errorText, children: micError }) }),
3467
+ isRecording ? /* @__PURE__ */ jsxs("div", { style: styles8.recordingBar, children: [
3468
+ /* @__PURE__ */ jsxs("div", { style: styles8.recordingIndicator, children: [
3469
+ /* @__PURE__ */ jsx("span", { style: styles8.recordingDot }),
3470
+ "Recording ",
3471
+ formatDuration2(duration)
3472
+ ] }),
3473
+ /* @__PURE__ */ jsxs("div", { style: styles8.recordingActions, children: [
3474
+ /* @__PURE__ */ jsx("button", { onClick: cancelRecording, style: styles8.cancelButton, type: "button", children: "Cancel" }),
3475
+ /* @__PURE__ */ jsx("button", { onClick: handleAudioStop, style: styles8.stopButton, type: "button", children: "Send" })
3476
+ ] })
3477
+ ] }) : /* @__PURE__ */ jsxs("div", { style: styles8.inputBar, children: [
3334
3478
  /* @__PURE__ */ jsx(
3335
3479
  "button",
3336
3480
  {
@@ -3349,6 +3493,7 @@ function MessageInput({
3349
3493
  ref: fileInputRef,
3350
3494
  type: "file",
3351
3495
  onChange: handleFileSelect,
3496
+ "aria-label": "Choose a file to upload",
3352
3497
  style: { display: "none" },
3353
3498
  accept: [
3354
3499
  ...SUPPORTED_IMAGE_TYPES,
@@ -3379,7 +3524,19 @@ function MessageInput({
3379
3524
  style: styles8.textarea
3380
3525
  }
3381
3526
  ),
3382
- /* @__PURE__ */ jsx(
3527
+ onSendAudio && micSupported && !text.trim() && /* @__PURE__ */ jsx(
3528
+ "button",
3529
+ {
3530
+ onClick: startRecording,
3531
+ disabled: disabled || isSending,
3532
+ "aria-label": "Record voice message",
3533
+ title: "Record voice message",
3534
+ style: styles8.micButton,
3535
+ type: "button",
3536
+ children: /* @__PURE__ */ jsx(MicIcon, { size: 22, color: COLOR.neutral700 })
3537
+ }
3538
+ ),
3539
+ text.trim() && /* @__PURE__ */ jsx(
3383
3540
  "button",
3384
3541
  {
3385
3542
  onClick: handleSendText,
@@ -3397,6 +3554,11 @@ function MessageInput({
3397
3554
  ] })
3398
3555
  ] });
3399
3556
  }
3557
+ function formatDuration2(seconds) {
3558
+ const m = Math.floor(seconds / 60);
3559
+ const s = seconds % 60;
3560
+ return `${m}:${s.toString().padStart(2, "0")}`;
3561
+ }
3400
3562
  var styles8 = {
3401
3563
  container: {
3402
3564
  borderTop: `1px solid ${COLOR.neutral200}`,
@@ -3473,6 +3635,23 @@ var styles8 = {
3473
3635
  color: COLOR.neutral700,
3474
3636
  transition: "background-color 120ms ease, color 120ms ease, border-color 120ms ease"
3475
3637
  },
3638
+ // Same 48-square chrome as attach. Sits between the textarea and send
3639
+ // button when there's no draft text — replaces send while idle so the
3640
+ // input row stays one row tall.
3641
+ micButton: {
3642
+ width: "48px",
3643
+ height: "48px",
3644
+ display: "flex",
3645
+ alignItems: "center",
3646
+ justifyContent: "center",
3647
+ backgroundColor: COLOR.neutral100,
3648
+ border: `1.5px solid ${COLOR.neutral200}`,
3649
+ borderRadius: "14px",
3650
+ cursor: "pointer",
3651
+ flexShrink: 0,
3652
+ color: COLOR.neutral700,
3653
+ transition: "background-color 120ms ease, color 120ms ease, border-color 120ms ease"
3654
+ },
3476
3655
  textarea: {
3477
3656
  flex: 1,
3478
3657
  minHeight: "48px",
@@ -3505,7 +3684,53 @@ var styles8 = {
3505
3684
  cursor: "pointer",
3506
3685
  flexShrink: 0,
3507
3686
  transition: "opacity 120ms ease, background-color 120ms ease"
3508
- }};
3687
+ },
3688
+ recordingBar: {
3689
+ display: "flex",
3690
+ justifyContent: "space-between",
3691
+ alignItems: "center",
3692
+ padding: `${SPACE.S3} ${SPACE.S4}`,
3693
+ backgroundColor: COLOR.dangerBg
3694
+ },
3695
+ recordingIndicator: {
3696
+ display: "flex",
3697
+ alignItems: "center",
3698
+ gap: SPACE.S2,
3699
+ fontSize: FONT_SIZE.sm,
3700
+ color: COLOR.danger,
3701
+ fontWeight: FONT_WEIGHT.medium
3702
+ },
3703
+ recordingDot: {
3704
+ width: SPACE.S2,
3705
+ height: SPACE.S2,
3706
+ borderRadius: RADIUS.full,
3707
+ backgroundColor: COLOR.danger,
3708
+ animation: "pulse 1.5s infinite"
3709
+ },
3710
+ recordingActions: {
3711
+ display: "flex",
3712
+ gap: SPACE.S2
3713
+ },
3714
+ cancelButton: {
3715
+ padding: `${SPACE.S1} ${SPACE.S4}`,
3716
+ fontSize: FONT_SIZE.sm,
3717
+ color: COLOR.neutral500,
3718
+ backgroundColor: COLOR.white,
3719
+ border: `1px solid ${COLOR.neutral300}`,
3720
+ borderRadius: RADIUS.md,
3721
+ cursor: "pointer"
3722
+ },
3723
+ stopButton: {
3724
+ padding: `${SPACE.S1} ${SPACE.S4}`,
3725
+ fontSize: FONT_SIZE.sm,
3726
+ fontWeight: FONT_WEIGHT.medium,
3727
+ color: COLOR.white,
3728
+ backgroundColor: COLOR.danger,
3729
+ border: "none",
3730
+ borderRadius: RADIUS.md,
3731
+ cursor: "pointer"
3732
+ }
3733
+ };
3509
3734
  var ROLE_AVATAR_BG = {
3510
3735
  radiologist: "#4f46e5",
3511
3736
  // indigo
@@ -5188,7 +5413,7 @@ function InlineAudioPlayer({
5188
5413
  /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "70%", backgroundColor: pal.audioFg } }),
5189
5414
  /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "40%", backgroundColor: pal.audioFg } })
5190
5415
  ] }),
5191
- /* @__PURE__ */ jsx("span", { style: { ...styles14.audioDuration, color: pal.audioFg }, children: formatDuration2(displayTime) }),
5416
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.audioDuration, color: pal.audioFg }, children: formatDuration3(displayTime) }),
5192
5417
  /* @__PURE__ */ jsx("audio", { ref: audioRef, src: url, preload: "metadata" })
5193
5418
  ] });
5194
5419
  }
@@ -5204,7 +5429,7 @@ function renderMessagePreview(message) {
5204
5429
  return message.body;
5205
5430
  }
5206
5431
  }
5207
- function formatDuration2(seconds) {
5432
+ function formatDuration3(seconds) {
5208
5433
  const safe = Math.max(0, Math.floor(seconds));
5209
5434
  const m = Math.floor(safe / 60);
5210
5435
  const s = safe % 60;
@@ -6360,107 +6585,6 @@ function useMessages({
6360
6585
  reset
6361
6586
  };
6362
6587
  }
6363
- function useAudioRecorder() {
6364
- const [isRecording, setIsRecording] = useState(false);
6365
- const [duration, setDuration] = useState(0);
6366
- const [error, setError] = useState(null);
6367
- const mediaRecorderRef = useRef(null);
6368
- const chunksRef = useRef([]);
6369
- const streamRef = useRef(null);
6370
- const timerRef = useRef(null);
6371
- const startTimeRef = useRef(0);
6372
- const resolveRef = useRef(null);
6373
- const isSupported = typeof window !== "undefined" && typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && !!window.MediaRecorder;
6374
- const cleanup = useCallback(() => {
6375
- if (timerRef.current) {
6376
- clearInterval(timerRef.current);
6377
- timerRef.current = null;
6378
- }
6379
- if (streamRef.current) {
6380
- streamRef.current.getTracks().forEach((track) => track.stop());
6381
- streamRef.current = null;
6382
- }
6383
- mediaRecorderRef.current = null;
6384
- chunksRef.current = [];
6385
- setIsRecording(false);
6386
- setDuration(0);
6387
- }, []);
6388
- const start = useCallback(async () => {
6389
- if (!isSupported) {
6390
- setError("Audio recording is not supported in this browser");
6391
- return;
6392
- }
6393
- try {
6394
- setError(null);
6395
- chunksRef.current = [];
6396
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
6397
- streamRef.current = stream;
6398
- const mimeType = MediaRecorder.isTypeSupported(AUDIO_MIME_TYPE) ? AUDIO_MIME_TYPE : MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "audio/mp4";
6399
- const recorder = new MediaRecorder(stream, { mimeType });
6400
- mediaRecorderRef.current = recorder;
6401
- recorder.ondataavailable = (event) => {
6402
- if (event.data.size > 0) {
6403
- chunksRef.current.push(event.data);
6404
- }
6405
- };
6406
- recorder.onerror = () => {
6407
- setError("Recording failed");
6408
- cleanup();
6409
- resolveRef.current?.(null);
6410
- resolveRef.current = null;
6411
- };
6412
- recorder.onstop = () => {
6413
- const finalDuration = Math.round((Date.now() - startTimeRef.current) / 1e3);
6414
- const blob = new Blob(chunksRef.current, { type: mimeType });
6415
- cleanup();
6416
- resolveRef.current?.({ blob, duration: finalDuration });
6417
- resolveRef.current = null;
6418
- };
6419
- recorder.start(250);
6420
- startTimeRef.current = Date.now();
6421
- setIsRecording(true);
6422
- timerRef.current = setInterval(() => {
6423
- const elapsed = Math.round((Date.now() - startTimeRef.current) / 1e3);
6424
- setDuration(elapsed);
6425
- }, 1e3);
6426
- } catch (err) {
6427
- if (err instanceof DOMException && err.name === "NotAllowedError") {
6428
- setError("Microphone access denied. Please allow microphone permissions.");
6429
- } else {
6430
- setError("Failed to start recording");
6431
- }
6432
- cleanup();
6433
- }
6434
- }, [isSupported, cleanup]);
6435
- const stop = useCallback(async () => {
6436
- return new Promise((resolve) => {
6437
- if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") {
6438
- resolve(null);
6439
- return;
6440
- }
6441
- resolveRef.current = resolve;
6442
- mediaRecorderRef.current.stop();
6443
- });
6444
- }, []);
6445
- const cancel = useCallback(() => {
6446
- if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
6447
- mediaRecorderRef.current.onstop = null;
6448
- mediaRecorderRef.current.stop();
6449
- }
6450
- cleanup();
6451
- resolveRef.current?.(null);
6452
- resolveRef.current = null;
6453
- }, [cleanup]);
6454
- return {
6455
- isRecording,
6456
- duration,
6457
- start,
6458
- stop,
6459
- cancel,
6460
- isSupported,
6461
- error
6462
- };
6463
- }
6464
6588
  function useUnreadCount() {
6465
6589
  const { socket, totalUnread } = useCollab();
6466
6590
  const [counts, setCounts] = useState({});