diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx
index 6de3991b5..234cddff3 100644
--- a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx
+++ b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx
@@ -1,8 +1,9 @@
import { LatLng } from "leaflet";
import { uniqBy } from "lodash-es";
-import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
+import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MicIcon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useContext, useState } from "react";
+import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -13,12 +14,14 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { attachmentStore } from "@/store";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { MemoEditorContext } from "../types";
import { LinkMemoDialog } from "./InsertMenu/LinkMemoDialog";
import { LocationDialog } from "./InsertMenu/LocationDialog";
+import { useAudioRecorder } from "./InsertMenu/useAudioRecorder";
import { useFileUpload } from "./InsertMenu/useFileUpload";
import { useLinkMemo } from "./InsertMenu/useLinkMemo";
import { useLocation } from "./InsertMenu/useLocation";
@@ -52,6 +55,7 @@ const InsertMenu = observer((props: Props) => {
});
const location = useLocation(props.location);
+ const audioRecorder = useAudioRecorder();
const isUploading = uploadingFlag || props.isUploading;
@@ -112,41 +116,91 @@ const InsertMenu = observer((props: Props) => {
});
};
+ const handleStopRecording = async () => {
+ try {
+ const blob = await audioRecorder.stopRecording();
+ const filename = `recording-${Date.now()}.webm`;
+ const file = new File([blob], filename, { type: "audio/webm" });
+ const { name, size, type } = file;
+ const buffer = new Uint8Array(await file.arrayBuffer());
+
+ const attachment = await attachmentStore.createAttachment({
+ attachment: Attachment.fromPartial({
+ filename: name,
+ size,
+ type,
+ content: buffer,
+ }),
+ attachmentId: "",
+ });
+ context.setAttachmentList([...context.attachmentList, attachment]);
+ } catch (error: any) {
+ console.error("Failed to upload audio recording:", error);
+ toast.error(error.details || "Failed to upload audio recording");
+ }
+ };
+
return (
<>
-
+ {audioRecorder.isRecording ? (
+
+ ) : (
+
+ )}
-
-
- {t("common.upload")}
-
- setLinkDialogOpen(true)}>
-
- {t("tooltip.link-memo")}
-
-
-
- {t("tooltip.select-location")}
-
- {/* View submenu with Focus Mode */}
-
-
-
- {t("common.more")}
-
-
-
-
- {t("editor.focus-mode")}
- ⌘⇧F
+ {audioRecorder.isRecording ? (
+ <>
+
+
+ Stop Recording
-
-
+
+
+ Cancel Recording
+
+ >
+ ) : (
+ <>
+
+
+ {t("common.upload")}
+
+ setLinkDialogOpen(true)}>
+
+ {t("tooltip.link-memo")}
+
+
+
+ {t("tooltip.select-location")}
+
+
+
+ Record Audio
+
+ {/* View submenu with Focus Mode */}
+
+
+
+ {t("common.more")}
+
+
+
+
+ {t("editor.focus-mode")}
+ ⌘⇧F
+
+
+
+ >
+ )}
diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/useAudioRecorder.ts b/web/src/components/MemoEditor/ActionButton/InsertMenu/useAudioRecorder.ts
new file mode 100644
index 000000000..b8da4f39c
--- /dev/null
+++ b/web/src/components/MemoEditor/ActionButton/InsertMenu/useAudioRecorder.ts
@@ -0,0 +1,103 @@
+import { useRef, useState } from "react";
+
+interface AudioRecorderState {
+ isRecording: boolean;
+ isPaused: boolean;
+ recordingTime: number;
+ mediaRecorder: MediaRecorder | null;
+}
+
+export const useAudioRecorder = () => {
+ const [state, setState] = useState({
+ isRecording: false,
+ isPaused: false,
+ recordingTime: 0,
+ mediaRecorder: null,
+ });
+ const chunksRef = useRef([]);
+ const timerRef = useRef(null);
+
+ const startRecording = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const mediaRecorder = new MediaRecorder(stream);
+ chunksRef.current = [];
+
+ mediaRecorder.ondataavailable = (e: BlobEvent) => {
+ if (e.data.size > 0) {
+ chunksRef.current.push(e.data);
+ }
+ };
+
+ mediaRecorder.start();
+ setState((prev: AudioRecorderState) => ({ ...prev, isRecording: true, mediaRecorder }));
+
+ timerRef.current = window.setInterval(() => {
+ setState((prev: AudioRecorderState) => ({ ...prev, recordingTime: prev.recordingTime + 1 }));
+ }, 1000);
+ } catch (error) {
+ console.error("Error accessing microphone:", error);
+ throw error;
+ }
+ };
+
+ const stopRecording = (): Promise => {
+ return new Promise((resolve, reject) => {
+ const { mediaRecorder } = state;
+ if (!mediaRecorder) {
+ reject(new Error("No active recording"));
+ return;
+ }
+
+ mediaRecorder.onstop = () => {
+ const blob = new Blob(chunksRef.current, { type: "audio/webm" });
+ chunksRef.current = [];
+ resolve(blob);
+ };
+
+ mediaRecorder.stop();
+ mediaRecorder.stream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
+
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+
+ setState({
+ isRecording: false,
+ isPaused: false,
+ recordingTime: 0,
+ mediaRecorder: null,
+ });
+ });
+ };
+
+ const cancelRecording = () => {
+ const { mediaRecorder } = state;
+ if (mediaRecorder) {
+ mediaRecorder.stop();
+ mediaRecorder.stream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
+ }
+
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+
+ chunksRef.current = [];
+ setState({
+ isRecording: false,
+ isPaused: false,
+ recordingTime: 0,
+ mediaRecorder: null,
+ });
+ };
+
+ return {
+ isRecording: state.isRecording,
+ recordingTime: state.recordingTime,
+ startRecording,
+ stopRecording,
+ cancelRecording,
+ };
+};