mirror of https://github.com/usememos/memos.git
feat(editor): add voice note recording to the memo composer (#5801)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
This commit is contained in:
parent
a0d83e1a9e
commit
c0d5854f67
|
|
@ -0,0 +1,47 @@
|
|||
## Background & Context
|
||||
|
||||
Memos is a self-hosted note-taking product whose main write path is the React memo composer in `web/src/components/MemoEditor`. Memo content is stored as Markdown text, attachments are uploaded through the v1 attachment API, and the server already has dedicated file-serving behavior for media playback. The most recent relevant change in this area was commit `63a17d89`, which refactored audio attachment rendering into reusable playback components. That change improved how audio files are displayed after upload; it did not add a microphone-driven input path inside the compose flow.
|
||||
|
||||
## Issue Statement
|
||||
|
||||
Memo creation currently starts from typed text plus file upload and metadata pickers, while audio support in the product begins only after an audio file already exists as an attachment. Users who want to capture memo content by speaking must leave the compose flow to record elsewhere, then upload or manually transcribe the result, because the editor has no direct path from microphone input to memo text or an in-progress audio attachment.
|
||||
|
||||
## Current State
|
||||
|
||||
- `web/src/components/MemoEditor/index.tsx:26-154` assembles the compose flow from `EditorContent`, `EditorMetadata`, and `EditorToolbar`, and persists drafts through `memoService.save`.
|
||||
- `web/src/components/MemoEditor/Editor/index.tsx:27-214` implements the editor surface as a `<textarea>` with slash commands and tag suggestions. It has no microphone entrypoint, recording lifecycle, or transcript state.
|
||||
- `web/src/components/MemoEditor/components/EditorToolbar.tsx:10-54` renders the bottom toolbar with `InsertMenu`, visibility, cancel, and save actions. There is no first-class voice action in the primary control row.
|
||||
- `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx:40-189` exposes upload, link-memo, location, and focus-mode actions, and uses a hidden `<input type="file">` for attachments. It does not expose microphone capture or dictation.
|
||||
- `web/src/components/MemoEditor/components/EditorContent.tsx:12-54` handles drag-and-drop and paste for binary files only, and `web/src/components/MemoEditor/hooks/useFileUpload.ts:4-33` handles file-picker selection only.
|
||||
- `web/src/components/MemoEditor/state/types.ts:8-30`, `web/src/components/MemoEditor/state/actions.ts:6-78`, and `web/src/components/MemoEditor/state/reducer.ts:4-130` track memo text, metadata, local files, and loading flags. There is no state for microphone permission, recording mode, partial transcript, cleanup review, or a pending audio blob.
|
||||
- `web/src/components/MemoEditor/hooks/useAutoSave.ts:4-8` saves only the current `content` string to local storage. There is no draft persistence model for an in-progress voice session.
|
||||
- `web/src/components/MemoEditor/services/validationService.ts:9-30` allows save when the draft has text, saved attachments, or local files, and `web/src/components/MemoEditor/services/uploadService.ts:8-26` uploads local files to `AttachmentService`. This means the existing save path can already persist an audio blob if one is present as a `LocalFile`.
|
||||
- `web/src/components/MemoEditor/types/attachment.ts:4-28` classifies editor-side files only as `image`, `video`, or `document`, so an unsaved audio recording would currently fall into the generic document path in the editor draft surface.
|
||||
- `web/src/utils/attachment.ts:15-38` recognizes `audio/*`, `web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx:98-130` groups persisted attachments into visual/audio/docs sections, and `web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx:48-173` renders the dedicated audio playback card added by the last commit.
|
||||
- `server/server.go:71-74` and `server/router/fileserver/fileserver.go:120-149,187-214` already treat video/audio attachments as native HTTP media streams once an attachment exists.
|
||||
- `proto/api/v1/attachment_service.proto:48-90` and `server/router/api/v1/attachment_service.go:64-167` define binary attachment upload and metadata only. There is no transcription request/response shape, language hint, transcript cleanup option, or voice-session metadata in the API.
|
||||
- `proto/api/v1/memo_service.proto:176-245` defines memo content as a single Markdown string plus optional attachments and relations. There is no separate speech transcript field or audio-note abstraction in the memo resource.
|
||||
- `proto/api/v1/instance_service.proto:56-90` and `server/router/api/v1/instance_service.go:36-139` expose instance settings for `GENERAL`, `STORAGE`, `MEMO_RELATED`, `TAGS`, and `NOTIFICATION` only. There is no speech-provider or transcription-retention configuration surface.
|
||||
- No existing implementation found for `getUserMedia`, `MediaRecorder`, browser speech recognition, or server-side transcription anywhere under `web/src`, `server`, `proto`, `plugin`, or `store`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Redesigning the current persisted audio attachment playback UI introduced in commit `63a17d89`.
|
||||
- Building a full duplex spoken assistant or chatbot response loop inside Memos.
|
||||
- Replacing the Markdown textarea editor with a different editor architecture.
|
||||
- Shipping native desktop or mobile OS integrations such as global system-wide hotkeys.
|
||||
- Redesigning attachment storage backends or the general file upload pipeline beyond voice-related usage.
|
||||
- Adding broad AI rewrite/edit commands unrelated to capturing spoken memo text into the current draft.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Which client surfaces are in scope for the first rollout? (default: the existing React memo composer in the web app, including touch-friendly mobile-browser behavior)
|
||||
- Is the first release a conversational voice mode or a dictation workflow? (default: dictation-first voice capture that inserts text into the current memo draft rather than opening a separate assistant session)
|
||||
- Should Memos retain the raw recording after transcription? (default: no by default; keeping the recording is an explicit user choice that stores it as a normal attachment)
|
||||
- Where does transcription execute? (default: behind a server-owned API so behavior, provider choice, and privacy copy are instance-controlled rather than browser-vendor specific)
|
||||
- How much transcript cleanup is in scope? (default: punctuation plus limited filler/self-correction cleanup, with a review step before insertion)
|
||||
- Does this issue include spoken edit commands such as “rewrite this shorter”? (default: no, only spoken text capture and insertion or replacement)
|
||||
|
||||
## Scope
|
||||
|
||||
**L** — The current gap spans the memo composer UI, editor state model, local file preview behavior, attachment save path, public API surface, and instance settings. There is no existing microphone or transcription implementation to extend, and a complete voice-input workflow would introduce both a new client interaction model and a new server contract rather than a single local edit.
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
## References
|
||||
|
||||
- [OpenAI Help: ChatGPT Release Notes](https://help.openai.com/en/articles/6825453-chatgpt-release-notes%3F.ejs)
|
||||
- [Anthropic Support: Using voice mode on Claude mobile apps](https://support.anthropic.com/en/articles/11101966-using-voice-mode-on-claude-mobile-apps)
|
||||
- [Typeless](https://www.typeless.com/)
|
||||
- [Typeless FAQ](https://www.typeless.com/help/faqs)
|
||||
- [DeltaCircuit/react-media-recorder README](https://github.com/DeltaCircuit/react-media-recorder/blob/master/README.md)
|
||||
|
||||
## Industry Baseline
|
||||
|
||||
ChatGPT, Claude, and Typeless all treat voice capture as a first-class entrypoint near the main compose surface rather than as a secondary attachment action. The common pattern is immediate access to the microphone, visible recording state, and explicit stop/discard control.
|
||||
|
||||
Those products also keep the capture loop short. The user starts recording, sees a clear recording state, and either keeps or cancels the result. Even when the product supports richer voice features, the initial interaction cost stays low.
|
||||
|
||||
The `react-media-recorder` reference reflects the common browser implementation pattern behind that interaction: explicit recorder states, start/stop commands, generated blob URLs, and preview playback of the recorded media. That maps well to the current Memos editor because the editor already knows how to persist local files through the attachment upload path.
|
||||
|
||||
## Research Summary
|
||||
|
||||
The current Memos composer already has the downstream path needed for recorded audio files: local draft files can be attached in the editor, `uploadService` can persist them through `AttachmentService`, and persisted audio attachments already have dedicated playback UI. What is missing is the upstream capture step inside the composer.
|
||||
|
||||
Given the revised scope, the right fit is not dictation or voice chat. The immediate problem is only that users cannot create an audio file from the memo composer itself. That means the smallest useful design is a browser voice recorder that produces a normal draft attachment.
|
||||
|
||||
Because the scope is now frontend-only, the design should not introduce new server contracts, new instance settings, or any transcription workflow. The recorded clip should flow through the existing `LocalFile -> uploadService -> attachment` path exactly like other draft files.
|
||||
|
||||
## Design Goals
|
||||
|
||||
- A user can start recording from the memo composer in one explicit action without opening the attachment menu.
|
||||
- Recording state is visible and explicit: idle, requesting permission, recording, recorded, unsupported, or error.
|
||||
- A completed recording can be kept as a draft audio file or discarded before memo save.
|
||||
- Kept recordings reuse the existing local-file and attachment-save flow with no backend changes.
|
||||
- The draft attachment surface renders recorded audio as playable audio, not as a generic document row.
|
||||
- Save is blocked while recording is actively in progress, but succeeds once a recording has been stopped and kept.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Transcribing audio to text.
|
||||
- Adding any proto, store, server, or instance-settings changes.
|
||||
- Building a spoken assistant or voice-chat mode.
|
||||
- Redesigning persisted audio playback beyond the minimum draft preview needed for local recordings.
|
||||
- Adding background recording, global hotkeys, or native device integrations.
|
||||
|
||||
## Proposed Design
|
||||
|
||||
Add a `Voice note` entry to `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx` rather than keeping a separate always-visible mic button in `web/src/components/MemoEditor/components/EditorToolbar.tsx`. This keeps the recorder close to the existing file and metadata insertion actions, reduces toolbar clutter, and still gives the user a direct path to create an audio attachment from the composer.
|
||||
|
||||
Introduce a `VoiceRecorderPanel` inside the memo editor layout, rendered between the editor body and the bottom metadata/toolbar area in `web/src/components/MemoEditor/index.tsx`. The panel is responsible for showing recorder state and actions, but it does not alter memo content. Its job is only to create or discard a draft audio file.
|
||||
|
||||
Add a dedicated `voiceRecorder` slice to `web/src/components/MemoEditor/state/types.ts` and `web/src/components/MemoEditor/state/reducer.ts`. The slice should hold support state, permission state, recorder status, elapsed time, pending error, and the most recent recorded draft clip before the user decides to keep or discard it. Keeping this state separate from `content`, `metadata`, and `localFiles` prevents recorder lifecycle state from leaking into unrelated editor behaviors.
|
||||
|
||||
Implement the browser media lifecycle in a dedicated hook such as `useVoiceRecorder`, following the `MediaRecorder` state pattern shown in `react-media-recorder`. The hook owns capability detection, `getUserMedia` requests, recorder start/stop, elapsed-time updates, blob assembly, and cleanup of tracks and blob URLs. The editor UI consumes only the hook output and dispatches reducer actions from it.
|
||||
|
||||
When the user stops a recording, convert the captured blob into a `File` and then into the existing `LocalFile` shape already used by file upload, paste, and drag-and-drop flows. The user can then either keep that `LocalFile`, which appends it to `state.localFiles`, or discard it, which revokes the blob URL and clears the recorder state. This keeps the design aligned with the existing upload path and avoids introducing a parallel attachment model.
|
||||
|
||||
Extend `web/src/components/MemoEditor/types/attachment.ts` so local `audio/*` files are classified as audio rather than falling back to `document`. Then update `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx` so draft audio files render with playable audio controls and normal remove behavior. This reuses the recent product investment in better audio presentation without requiring persisted attachments before preview is possible.
|
||||
|
||||
Update `web/src/components/MemoEditor/services/validationService.ts` so save remains allowed for normal local files and kept audio drafts, but not while `voiceRecorder.status` is actively `recording` or `requesting_permission`. That avoids saving a memo in the middle of a live recording session while preserving the existing rule that a memo may be saved with attachments and no text.
|
||||
|
||||
Do not introduce transcription, transcript review, speech-provider configuration, or server upload-before-save behavior in this design. Those alternatives were intentionally rejected because they expand the problem from “create an audio file quickly” into a larger speech-input subsystem. The current narrowed issue only requires fast recording and clean integration with the existing attachment flow.
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
## Execution Log
|
||||
|
||||
### T1: Add recorder state and browser capture hook
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- `web/src/components/MemoEditor/hooks/useVoiceRecorder.ts`
|
||||
- `web/src/components/MemoEditor/hooks/index.ts`
|
||||
- `web/src/components/MemoEditor/state/types.ts`
|
||||
- `web/src/components/MemoEditor/state/actions.ts`
|
||||
- `web/src/components/MemoEditor/state/reducer.ts`
|
||||
- `web/src/components/MemoEditor/services/memoService.ts`
|
||||
**Validation**: `cd web && pnpm lint` — PASS
|
||||
**Path Corrections**: Added `web/src/components/MemoEditor/services/memoService.ts` after plan update because `memoService.fromMemo()` also constructs `EditorState`.
|
||||
**Deviations**: None after the approved plan correction.
|
||||
|
||||
Implemented a dedicated `voiceRecorder` editor state slice, reducer/actions for recorder lifecycle updates, a browser `MediaRecorder` hook that produces a `LocalFile` preview, and the matching `fromMemo()` defaults needed to keep the editor state shape valid for existing memo edit flows.
|
||||
|
||||
### T2: Add composer recorder UI and draft audio handling
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- `web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx`
|
||||
- `web/src/components/MemoEditor/components/EditorToolbar.tsx`
|
||||
- `web/src/components/MemoEditor/components/index.ts`
|
||||
- `web/src/components/MemoEditor/index.tsx`
|
||||
- `web/src/components/MemoEditor/types/components.ts`
|
||||
- `web/src/components/MemoEditor/types/attachment.ts`
|
||||
- `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx`
|
||||
- `web/src/components/MemoEditor/services/validationService.ts`
|
||||
- `web/src/locales/en.json`
|
||||
**Validation**: `cd web && pnpm lint` — PASS
|
||||
**Path Corrections**: None
|
||||
**Deviations**: None
|
||||
|
||||
Added a `Voice note` action to the editor tool dropdown, wired the memo editor to start recording and render an inline recorder/review panel, let users keep a completed clip as a normal draft `LocalFile`, rendered local audio drafts with playable controls in the attachment editor, and blocked save only while permission is pending or recording is live.
|
||||
|
||||
## Completion Declaration
|
||||
|
||||
**Execution completed successfully** — the frontend memo composer now has a tool-dropdown voice recorder entrypoint that creates draft audio files through the existing attachment flow, with no backend or transcription changes.
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
## Task List
|
||||
|
||||
Task Index
|
||||
|
||||
T1: Add recorder state and browser capture hook [M] — T2: Add composer recorder UI and draft audio handling [L]
|
||||
|
||||
### T1: Add recorder state and browser capture hook [M]
|
||||
|
||||
**Objective**: Introduce the frontend-only state and hook needed to record audio in the browser and convert the finished clip into the existing `LocalFile` draft format.
|
||||
**Size**: M (2-3 files, moderate logic)
|
||||
**Files**:
|
||||
- Create: `web/src/components/MemoEditor/hooks/useVoiceRecorder.ts`
|
||||
- Modify: `web/src/components/MemoEditor/state/types.ts`
|
||||
- Modify: `web/src/components/MemoEditor/state/actions.ts`
|
||||
- Modify: `web/src/components/MemoEditor/state/reducer.ts`
|
||||
- Modify: `web/src/components/MemoEditor/hooks/index.ts`
|
||||
- Modify: `web/src/components/MemoEditor/services/memoService.ts`
|
||||
**Implementation**:
|
||||
1. In `web/src/components/MemoEditor/state/types.ts`, add a `voiceRecorder` state slice for recorder support, permission, status, elapsed seconds, pending error, and the latest temporary recording preview.
|
||||
2. In `web/src/components/MemoEditor/state/actions.ts`, add actions for support/permission updates, recorder status changes, timer updates, temporary recording storage, and recorder reset.
|
||||
3. In `web/src/components/MemoEditor/state/reducer.ts`, implement the new voice-recorder actions without changing existing content, metadata, or save behavior.
|
||||
4. In new `web/src/components/MemoEditor/hooks/useVoiceRecorder.ts`, implement browser capability detection, `getUserMedia`, `MediaRecorder` setup, start/stop lifecycle, blob collection, cleanup, and conversion of the stopped recording into a `File` plus preview URL compatible with `LocalFile`.
|
||||
5. In `web/src/components/MemoEditor/services/memoService.ts`, update `fromMemo()` so loaded memo state includes the new `voiceRecorder` defaults required by `EditorState`.
|
||||
6. In `web/src/components/MemoEditor/hooks/index.ts`, export the new hook for editor integration.
|
||||
**Boundaries**: This task must not add any toolbar/panel UI, attachment rendering updates, or transcription/network behavior.
|
||||
**Dependencies**: None
|
||||
**Expected Outcome**: The memo editor has a recorder state model and a reusable browser recording hook that can produce a draft audio file.
|
||||
**Validation**: `cd web && pnpm lint` — expected output: TypeScript and Biome checks pass.
|
||||
|
||||
### T2: Add composer recorder UI and draft audio handling [L]
|
||||
|
||||
**Objective**: Add a voice-recorder entry inside the composer tool dropdown and make kept recordings behave like draft audio attachments in the existing save flow.
|
||||
**Size**: L (multiple files, coordinated UI/state integration)
|
||||
**Files**:
|
||||
- Create: `web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/index.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/components/EditorToolbar.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/components/index.ts`
|
||||
- Modify: `web/src/components/MemoEditor/types/components.ts`
|
||||
- Modify: `web/src/components/MemoEditor/types/attachment.ts`
|
||||
- Modify: `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/services/validationService.ts`
|
||||
- Modify: `web/src/locales/en.json`
|
||||
**Implementation**:
|
||||
1. In `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx` and `web/src/components/MemoEditor/components/EditorToolbar.tsx`, add a `Voice note` action to the existing compose tool dropdown instead of a separate toolbar button.
|
||||
2. In new `web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx`, render the recorder states `unsupported`, `idle`, `requesting_permission`, `recording`, `recorded`, and `error`, with explicit `Start`, `Stop`, `Keep`, and `Discard` actions.
|
||||
3. In `web/src/components/MemoEditor/index.tsx`, render the recorder panel between editor content and the metadata/toolbar group, wire it to the editor context, and on `Keep` append the produced `LocalFile` to `state.localFiles`.
|
||||
4. In `web/src/components/MemoEditor/types/attachment.ts`, classify local `audio/*` files as audio instead of generic documents.
|
||||
5. In `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx`, render local draft audio items with playable audio controls while preserving existing remove behavior and existing attachment reordering rules.
|
||||
6. In `web/src/components/MemoEditor/services/validationService.ts`, block save while a recording is actively running or permission is still being requested, but continue to allow save for kept draft audio files.
|
||||
7. In `web/src/components/MemoEditor/components/index.ts`, `web/src/components/MemoEditor/types/components.ts`, and `web/src/locales/en.json`, add the exports, prop types, and English labels needed for the recorder UI.
|
||||
**Boundaries**: This task must not add transcription, backend/API calls, settings UI, or redesign persisted audio playback beyond local draft preview.
|
||||
**Dependencies**: T1
|
||||
**Expected Outcome**: A user can choose `Voice note` from the memo composer tool dropdown, record audio in the browser, keep or discard the clip, preview a kept clip as a draft audio attachment, and save it through the existing attachment upload path.
|
||||
**Validation**: `cd web && pnpm lint` — expected output: TypeScript and Biome checks pass with the new recorder workflow.
|
||||
|
||||
## Out-of-Scope Tasks
|
||||
|
||||
- Any transcription or speech-to-text behavior.
|
||||
- Any proto, store, server, or instance-settings changes.
|
||||
- Any speech provider configuration.
|
||||
- Assistant-style voice conversations or spoken edit commands.
|
||||
- Full locale backfill beyond the required English copy for this feature.
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
import { LatLng } from "leaflet";
|
||||
import { uniqBy } from "lodash-es";
|
||||
import { FileIcon, LinkIcon, LoaderIcon, type LucideIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
|
||||
import {
|
||||
FileIcon,
|
||||
LinkIcon,
|
||||
LoaderIcon,
|
||||
type LucideIcon,
|
||||
MapPinIcon,
|
||||
Maximize2Icon,
|
||||
MicIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useDebounce } from "react-use";
|
||||
import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata";
|
||||
|
|
@ -141,8 +151,14 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
icon: MapPinIcon,
|
||||
onClick: handleLocationClick,
|
||||
},
|
||||
{
|
||||
key: "voice-note",
|
||||
label: t("editor.voice-recorder.trigger"),
|
||||
icon: MicIcon,
|
||||
onClick: () => props.onVoiceRecorderClick?.(),
|
||||
},
|
||||
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
|
||||
[handleLocationClick, handleOpenLinkDialog, handleUploadClick, t],
|
||||
[handleLocationClick, handleOpenLinkDialog, handleUploadClick, props, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu";
|
|||
import VisibilitySelector from "../Toolbar/VisibilitySelector";
|
||||
import type { EditorToolbarProps } from "../types";
|
||||
|
||||
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName }) => {
|
||||
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName, onVoiceRecorderClick }) => {
|
||||
const t = useTranslate();
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { valid } = validationService.canSave(state);
|
||||
|
|
@ -35,6 +35,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
|
|||
onLocationChange={handleLocationChange}
|
||||
onToggleFocusMode={handleToggleFocusMode}
|
||||
memoName={memoName}
|
||||
onVoiceRecorderClick={onVoiceRecorderClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
import { AudioLinesIcon, LoaderCircleIcon, MicIcon, RotateCcwIcon, SquareIcon, Trash2Icon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { AudioAttachmentItem } from "@/components/MemoMetadata/Attachment";
|
||||
import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentViewHelpers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import type { VoiceRecorderPanelProps } from "../types/components";
|
||||
|
||||
export const VoiceRecorderPanel: FC<VoiceRecorderPanelProps> = ({
|
||||
voiceRecorder,
|
||||
onStart,
|
||||
onStop,
|
||||
onKeep,
|
||||
onDiscard,
|
||||
onRecordAgain,
|
||||
onClose,
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
const { status, elapsedSeconds, error, recording } = voiceRecorder;
|
||||
|
||||
const isRecording = status === "recording";
|
||||
const isRequestingPermission = status === "requesting_permission";
|
||||
const isUnsupported = status === "unsupported";
|
||||
const hasRecording = status === "recorded" && recording;
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-xl border border-border/60 bg-muted/25 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background/80 text-muted-foreground",
|
||||
isRecording && "border-destructive/30 bg-destructive/10 text-destructive",
|
||||
hasRecording && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{isRequestingPermission ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : hasRecording ? (
|
||||
<AudioLinesIcon className="size-4" />
|
||||
) : (
|
||||
<MicIcon className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{isRecording
|
||||
? t("editor.voice-recorder.recording")
|
||||
: isRequestingPermission
|
||||
? t("editor.voice-recorder.requesting-permission")
|
||||
: hasRecording
|
||||
? t("editor.voice-recorder.ready")
|
||||
: isUnsupported
|
||||
? t("editor.voice-recorder.unsupported")
|
||||
: error
|
||||
? t("editor.voice-recorder.error")
|
||||
: t("editor.voice-recorder.title")}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{isRecording
|
||||
? t("editor.voice-recorder.recording-description", { duration: formatAudioTime(elapsedSeconds) })
|
||||
: isRequestingPermission
|
||||
? t("editor.voice-recorder.requesting-permission-description")
|
||||
: hasRecording
|
||||
? t("editor.voice-recorder.ready-description")
|
||||
: isUnsupported
|
||||
? t("editor.voice-recorder.unsupported-description")
|
||||
: error
|
||||
? error
|
||||
: t("editor.voice-recorder.idle-description")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRecording && (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-destructive/20 bg-destructive/[0.08] px-2.5 py-1 text-xs font-medium text-destructive">
|
||||
<span className="size-2 rounded-full bg-destructive" />
|
||||
{formatAudioTime(elapsedSeconds)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasRecording && (
|
||||
<div className="mt-3">
|
||||
<AudioAttachmentItem
|
||||
filename={recording.localFile.file.name}
|
||||
displayName="Voice note"
|
||||
sourceUrl={recording.localFile.previewUrl}
|
||||
mimeType={recording.mimeType}
|
||||
size={recording.localFile.file.size}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-end gap-2">
|
||||
{hasRecording ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onDiscard}>
|
||||
<Trash2Icon />
|
||||
{t("editor.voice-recorder.discard")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onRecordAgain}>
|
||||
<RotateCcwIcon />
|
||||
{t("editor.voice-recorder.record-again")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onKeep}>
|
||||
<AudioLinesIcon />
|
||||
{t("editor.voice-recorder.keep")}
|
||||
</Button>
|
||||
</>
|
||||
) : isRecording ? (
|
||||
<Button size="sm" onClick={onStop}>
|
||||
<SquareIcon />
|
||||
{t("editor.voice-recorder.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
{!isUnsupported && (
|
||||
<Button size="sm" onClick={onStart} disabled={isRequestingPermission}>
|
||||
{isRequestingPermission ? <LoaderCircleIcon className="animate-spin" /> : <MicIcon />}
|
||||
{isRequestingPermission ? t("editor.voice-recorder.requesting") : t("editor.voice-recorder.start")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,3 +5,4 @@ export * from "./EditorMetadata";
|
|||
export * from "./EditorToolbar";
|
||||
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
|
||||
export { TimestampPopover } from "./TimestampPopover";
|
||||
export * from "./VoiceRecorderPanel";
|
||||
|
|
|
|||
|
|
@ -8,3 +8,4 @@ export { useKeyboard } from "./useKeyboard";
|
|||
export { useLinkMemo } from "./useLinkMemo";
|
||||
export { useLocation } from "./useLocation";
|
||||
export { useMemoInit } from "./useMemoInit";
|
||||
export { useVoiceRecorder } from "./useVoiceRecorder";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import type { LocalFile } from "../types/attachment";
|
||||
import { useBlobUrls } from "./useBlobUrls";
|
||||
|
||||
const FALLBACK_AUDIO_MIME_TYPE = "audio/webm";
|
||||
|
||||
interface VoiceRecorderActions {
|
||||
setVoiceRecorderSupport: (value: boolean) => void;
|
||||
setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => void;
|
||||
setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") => void;
|
||||
setVoiceRecorderElapsed: (value: number) => void;
|
||||
setVoiceRecorderError: (value?: string) => void;
|
||||
setVoiceRecording: (value?: { localFile: LocalFile; durationSeconds: number; mimeType: string }) => void;
|
||||
}
|
||||
|
||||
const AUDIO_MIME_TYPE_CANDIDATES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"] as const;
|
||||
|
||||
function getSupportedAudioMimeType(): string | undefined {
|
||||
if (typeof window === "undefined" || typeof MediaRecorder === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const candidate of AUDIO_MIME_TYPE_CANDIDATES) {
|
||||
if (MediaRecorder.isTypeSupported(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getFileExtension(mimeType: string): string {
|
||||
if (mimeType.includes("ogg")) return "ogg";
|
||||
if (mimeType.includes("mp4")) return "m4a";
|
||||
return "webm";
|
||||
}
|
||||
|
||||
function createRecordedFile(blob: Blob, mimeType: string): File {
|
||||
const extension = getFileExtension(mimeType);
|
||||
const now = new Date();
|
||||
const datePart = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join("");
|
||||
const timePart = [String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0")].join("");
|
||||
return new File([blob], `voice-note-${datePart}-${timePart}.${extension}`, { type: mimeType });
|
||||
}
|
||||
|
||||
export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const elapsedTimerRef = useRef<number | null>(null);
|
||||
const recorderMimeTypeRef = useRef<string>(FALLBACK_AUDIO_MIME_TYPE);
|
||||
const { createBlobUrl } = useBlobUrls();
|
||||
|
||||
const cleanupTimer = () => {
|
||||
if (elapsedTimerRef.current !== null) {
|
||||
window.clearInterval(elapsedTimerRef.current);
|
||||
elapsedTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupStream = () => {
|
||||
mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
};
|
||||
|
||||
const resetRecorderRefs = () => {
|
||||
cleanupTimer();
|
||||
cleanupStream();
|
||||
mediaRecorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
startedAtRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isSupported =
|
||||
typeof window !== "undefined" &&
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof navigator.mediaDevices?.getUserMedia === "function" &&
|
||||
typeof MediaRecorder !== "undefined";
|
||||
|
||||
actions.setVoiceRecorderSupport(isSupported);
|
||||
if (!isSupported) {
|
||||
actions.setVoiceRecorderStatus("unsupported");
|
||||
actions.setVoiceRecorderError("Voice recording is not supported in this browser.");
|
||||
return;
|
||||
}
|
||||
|
||||
actions.setVoiceRecorderStatus("idle");
|
||||
actions.setVoiceRecorderError(undefined);
|
||||
|
||||
return () => {
|
||||
resetRecorderRefs();
|
||||
};
|
||||
}, [actions]);
|
||||
|
||||
const startRecording = async () => {
|
||||
if (
|
||||
typeof navigator === "undefined" ||
|
||||
typeof navigator.mediaDevices?.getUserMedia !== "function" ||
|
||||
typeof MediaRecorder === "undefined"
|
||||
) {
|
||||
actions.setVoiceRecorderSupport(false);
|
||||
actions.setVoiceRecorderStatus("unsupported");
|
||||
actions.setVoiceRecorderError("Voice recording is not supported in this browser.");
|
||||
return;
|
||||
}
|
||||
|
||||
actions.setVoiceRecorderError(undefined);
|
||||
actions.setVoiceRecorderStatus("requesting_permission");
|
||||
actions.setVoiceRecorderElapsed(0);
|
||||
actions.setVoiceRecording(undefined);
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mimeType = getSupportedAudioMimeType() ?? FALLBACK_AUDIO_MIME_TYPE;
|
||||
const mediaRecorder = new MediaRecorder(stream, getSupportedAudioMimeType() ? { mimeType } : undefined);
|
||||
|
||||
recorderMimeTypeRef.current = mimeType;
|
||||
mediaStreamRef.current = stream;
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorder.addEventListener("dataavailable", (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
mediaRecorder.addEventListener("stop", () => {
|
||||
const durationSeconds = startedAtRef.current ? Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1000)) : 0;
|
||||
const blob = new Blob(chunksRef.current, { type: recorderMimeTypeRef.current });
|
||||
const file = createRecordedFile(blob, recorderMimeTypeRef.current);
|
||||
const previewUrl = createBlobUrl(file);
|
||||
|
||||
actions.setVoiceRecording({
|
||||
localFile: {
|
||||
file,
|
||||
previewUrl,
|
||||
},
|
||||
durationSeconds,
|
||||
mimeType: recorderMimeTypeRef.current,
|
||||
});
|
||||
actions.setVoiceRecorderElapsed(durationSeconds);
|
||||
actions.setVoiceRecorderStatus("recorded");
|
||||
resetRecorderRefs();
|
||||
});
|
||||
|
||||
mediaRecorder.start();
|
||||
startedAtRef.current = Date.now();
|
||||
actions.setVoiceRecorderPermission("granted");
|
||||
actions.setVoiceRecorderStatus("recording");
|
||||
|
||||
elapsedTimerRef.current = window.setInterval(() => {
|
||||
if (startedAtRef.current) {
|
||||
actions.setVoiceRecorderElapsed(Math.max(0, Math.floor((Date.now() - startedAtRef.current) / 1000)));
|
||||
}
|
||||
}, 250);
|
||||
} catch (error) {
|
||||
const permissionDenied =
|
||||
error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "PermissionDeniedError");
|
||||
|
||||
actions.setVoiceRecorderPermission(permissionDenied ? "denied" : "unknown");
|
||||
actions.setVoiceRecorderStatus("error");
|
||||
actions.setVoiceRecorderError(permissionDenied ? "Microphone permission was denied." : "Failed to start voice recording.");
|
||||
resetRecorderRefs();
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupTimer();
|
||||
mediaRecorderRef.current.stop();
|
||||
};
|
||||
|
||||
const resetRecording = () => {
|
||||
resetRecorderRefs();
|
||||
actions.setVoiceRecorderElapsed(0);
|
||||
actions.setVoiceRecorderError(undefined);
|
||||
actions.setVoiceRecording(undefined);
|
||||
actions.setVoiceRecorderStatus("idle");
|
||||
};
|
||||
|
||||
return {
|
||||
startRecording,
|
||||
stopRecording,
|
||||
resetRecording,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
|
|
@ -9,10 +9,18 @@ import { handleError } from "@/lib/error";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityFromString } from "@/utils/memo";
|
||||
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay, TimestampPopover } from "./components";
|
||||
import {
|
||||
EditorContent,
|
||||
EditorMetadata,
|
||||
EditorToolbar,
|
||||
FocusModeExitButton,
|
||||
FocusModeOverlay,
|
||||
TimestampPopover,
|
||||
VoiceRecorderPanel,
|
||||
} from "./components";
|
||||
import { FOCUS_MODE_STYLES } from "./constants";
|
||||
import type { EditorRefActions } from "./Editor";
|
||||
import { useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks";
|
||||
import { useAutoSave, useFocusMode, useKeyboard, useMemoInit, useVoiceRecorder } from "./hooks";
|
||||
import { cacheService, errorService, memoService, validationService } from "./services";
|
||||
import { EditorProvider, useEditorContext } from "./state";
|
||||
import type { MemoEditorProps } from "./types";
|
||||
|
|
@ -39,6 +47,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
const editorRef = useRef<EditorRefActions>(null);
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const [isVoiceRecorderOpen, setIsVoiceRecorderOpen] = useState(false);
|
||||
|
||||
const memoName = memo?.name;
|
||||
|
||||
|
|
@ -53,10 +62,74 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
// Focus mode management with body scroll lock
|
||||
useFocusMode(state.ui.isFocusMode);
|
||||
|
||||
const voiceRecorderActions = useMemo(
|
||||
() => ({
|
||||
setVoiceRecorderSupport: (value: boolean) => dispatch(actions.setVoiceRecorderSupport(value)),
|
||||
setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setVoiceRecorderPermission(value)),
|
||||
setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") =>
|
||||
dispatch(actions.setVoiceRecorderStatus(value)),
|
||||
setVoiceRecorderElapsed: (value: number) => dispatch(actions.setVoiceRecorderElapsed(value)),
|
||||
setVoiceRecorderError: (value?: string) => dispatch(actions.setVoiceRecorderError(value)),
|
||||
setVoiceRecording: (value?: typeof state.voiceRecorder.recording) => dispatch(actions.setVoiceRecording(value)),
|
||||
}),
|
||||
[actions, dispatch],
|
||||
);
|
||||
|
||||
const voiceRecorder = useVoiceRecorder(voiceRecorderActions);
|
||||
|
||||
const handleToggleFocusMode = () => {
|
||||
dispatch(actions.toggleFocusMode());
|
||||
};
|
||||
|
||||
const handleStartVoiceRecording = async () => {
|
||||
setIsVoiceRecorderOpen(true);
|
||||
await voiceRecorder.startRecording();
|
||||
};
|
||||
|
||||
const handleVoiceRecorderClick = () => {
|
||||
setIsVoiceRecorderOpen(true);
|
||||
|
||||
if (
|
||||
state.voiceRecorder.status === "recording" ||
|
||||
state.voiceRecorder.status === "requesting_permission" ||
|
||||
state.voiceRecorder.status === "recorded"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleStartVoiceRecording();
|
||||
};
|
||||
|
||||
const handleKeepVoiceRecording = () => {
|
||||
const recording = state.voiceRecorder.recording;
|
||||
if (!recording) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(actions.addLocalFile(recording.localFile));
|
||||
voiceRecorder.resetRecording();
|
||||
setIsVoiceRecorderOpen(false);
|
||||
};
|
||||
|
||||
const handleDiscardVoiceRecording = () => {
|
||||
voiceRecorder.resetRecording();
|
||||
setIsVoiceRecorderOpen(false);
|
||||
};
|
||||
|
||||
const handleCloseVoiceRecorder = () => {
|
||||
if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") {
|
||||
return;
|
||||
}
|
||||
|
||||
voiceRecorder.resetRecording();
|
||||
setIsVoiceRecorderOpen(false);
|
||||
};
|
||||
|
||||
const handleRecordAgain = async () => {
|
||||
voiceRecorder.resetRecording();
|
||||
await handleStartVoiceRecording();
|
||||
};
|
||||
|
||||
useKeyboard(editorRef, handleSave);
|
||||
|
||||
async function handleSave() {
|
||||
|
|
@ -147,10 +220,22 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
{/* Editor content grows to fill available space in focus mode */}
|
||||
<EditorContent ref={editorRef} placeholder={placeholder} />
|
||||
|
||||
{isVoiceRecorderOpen && (
|
||||
<VoiceRecorderPanel
|
||||
voiceRecorder={state.voiceRecorder}
|
||||
onStart={() => void handleStartVoiceRecording()}
|
||||
onStop={voiceRecorder.stopRecording}
|
||||
onKeep={handleKeepVoiceRecording}
|
||||
onDiscard={handleDiscardVoiceRecording}
|
||||
onRecordAgain={() => void handleRecordAgain()}
|
||||
onClose={handleCloseVoiceRecorder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Metadata and toolbar grouped together at bottom */}
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<EditorMetadata memoName={memoName} />
|
||||
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} />
|
||||
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} onVoiceRecorderClick={handleVoiceRecorderClick} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -142,6 +142,14 @@ export const memoService = {
|
|||
updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined,
|
||||
},
|
||||
localFiles: [],
|
||||
voiceRecorder: {
|
||||
isSupported: true,
|
||||
permission: "unknown",
|
||||
status: "idle",
|
||||
elapsedSeconds: 0,
|
||||
error: undefined,
|
||||
recording: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ export const validationService = {
|
|||
return { valid: false, reason: "Wait for upload to complete" };
|
||||
}
|
||||
|
||||
// Cannot save while voice recorder is active
|
||||
if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") {
|
||||
return { valid: false, reason: "Finish voice recording before saving" };
|
||||
}
|
||||
|
||||
// Cannot save while already saving
|
||||
if (state.ui.isLoading.saving) {
|
||||
return { valid: false, reason: "Save in progress" };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { LocalFile } from "../types/attachment";
|
||||
import type { EditorAction, EditorState, LoadingKey } from "./types";
|
||||
import type { EditorAction, EditorState, LoadingKey, VoiceRecorderPermission, VoiceRecorderStatus, VoiceRecordingPreview } from "./types";
|
||||
|
||||
export const editorActions = {
|
||||
initMemo: (payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] }): EditorAction => ({
|
||||
|
|
@ -72,6 +72,36 @@ export const editorActions = {
|
|||
payload: timestamps,
|
||||
}),
|
||||
|
||||
setVoiceRecorderSupport: (value: boolean): EditorAction => ({
|
||||
type: "SET_VOICE_RECORDER_SUPPORT",
|
||||
payload: value,
|
||||
}),
|
||||
|
||||
setVoiceRecorderPermission: (value: VoiceRecorderPermission): EditorAction => ({
|
||||
type: "SET_VOICE_RECORDER_PERMISSION",
|
||||
payload: value,
|
||||
}),
|
||||
|
||||
setVoiceRecorderStatus: (value: VoiceRecorderStatus): EditorAction => ({
|
||||
type: "SET_VOICE_RECORDER_STATUS",
|
||||
payload: value,
|
||||
}),
|
||||
|
||||
setVoiceRecorderElapsed: (value: number): EditorAction => ({
|
||||
type: "SET_VOICE_RECORDER_ELAPSED",
|
||||
payload: value,
|
||||
}),
|
||||
|
||||
setVoiceRecorderError: (value?: string): EditorAction => ({
|
||||
type: "SET_VOICE_RECORDER_ERROR",
|
||||
payload: value,
|
||||
}),
|
||||
|
||||
setVoiceRecording: (value?: VoiceRecordingPreview): EditorAction => ({
|
||||
type: "SET_VOICE_RECORDING",
|
||||
payload: value,
|
||||
}),
|
||||
|
||||
reset: (): EditorAction => ({
|
||||
type: "RESET",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -119,6 +119,61 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
|||
},
|
||||
};
|
||||
|
||||
case "SET_VOICE_RECORDER_SUPPORT":
|
||||
return {
|
||||
...state,
|
||||
voiceRecorder: {
|
||||
...state.voiceRecorder,
|
||||
isSupported: action.payload,
|
||||
status: action.payload ? state.voiceRecorder.status : "unsupported",
|
||||
},
|
||||
};
|
||||
|
||||
case "SET_VOICE_RECORDER_PERMISSION":
|
||||
return {
|
||||
...state,
|
||||
voiceRecorder: {
|
||||
...state.voiceRecorder,
|
||||
permission: action.payload,
|
||||
},
|
||||
};
|
||||
|
||||
case "SET_VOICE_RECORDER_STATUS":
|
||||
return {
|
||||
...state,
|
||||
voiceRecorder: {
|
||||
...state.voiceRecorder,
|
||||
status: action.payload,
|
||||
},
|
||||
};
|
||||
|
||||
case "SET_VOICE_RECORDER_ELAPSED":
|
||||
return {
|
||||
...state,
|
||||
voiceRecorder: {
|
||||
...state.voiceRecorder,
|
||||
elapsedSeconds: action.payload,
|
||||
},
|
||||
};
|
||||
|
||||
case "SET_VOICE_RECORDER_ERROR":
|
||||
return {
|
||||
...state,
|
||||
voiceRecorder: {
|
||||
...state.voiceRecorder,
|
||||
error: action.payload,
|
||||
},
|
||||
};
|
||||
|
||||
case "SET_VOICE_RECORDING":
|
||||
return {
|
||||
...state,
|
||||
voiceRecorder: {
|
||||
...state.voiceRecorder,
|
||||
recording: action.payload,
|
||||
},
|
||||
};
|
||||
|
||||
case "RESET":
|
||||
return {
|
||||
...initialState,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
|||
import type { LocalFile } from "../types/attachment";
|
||||
|
||||
export type LoadingKey = "saving" | "uploading" | "loading";
|
||||
export type VoiceRecorderPermission = "unknown" | "granted" | "denied";
|
||||
export type VoiceRecorderStatus = "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported";
|
||||
|
||||
export interface VoiceRecordingPreview {
|
||||
localFile: LocalFile;
|
||||
durationSeconds: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface EditorState {
|
||||
content: string;
|
||||
|
|
@ -27,6 +35,14 @@ export interface EditorState {
|
|||
updateTime?: Date;
|
||||
};
|
||||
localFiles: LocalFile[];
|
||||
voiceRecorder: {
|
||||
isSupported: boolean;
|
||||
permission: VoiceRecorderPermission;
|
||||
status: VoiceRecorderStatus;
|
||||
elapsedSeconds: number;
|
||||
error?: string;
|
||||
recording?: VoiceRecordingPreview;
|
||||
};
|
||||
}
|
||||
|
||||
export type EditorAction =
|
||||
|
|
@ -44,6 +60,12 @@ export type EditorAction =
|
|||
| { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } }
|
||||
| { type: "SET_COMPOSING"; payload: boolean }
|
||||
| { type: "SET_TIMESTAMPS"; payload: Partial<EditorState["timestamps"]> }
|
||||
| { type: "SET_VOICE_RECORDER_SUPPORT"; payload: boolean }
|
||||
| { type: "SET_VOICE_RECORDER_PERMISSION"; payload: VoiceRecorderPermission }
|
||||
| { type: "SET_VOICE_RECORDER_STATUS"; payload: VoiceRecorderStatus }
|
||||
| { type: "SET_VOICE_RECORDER_ELAPSED"; payload: number }
|
||||
| { type: "SET_VOICE_RECORDER_ERROR"; payload?: string }
|
||||
| { type: "SET_VOICE_RECORDING"; payload?: VoiceRecordingPreview }
|
||||
| { type: "RESET" };
|
||||
|
||||
export const initialState: EditorState = {
|
||||
|
|
@ -68,4 +90,12 @@ export const initialState: EditorState = {
|
|||
updateTime: undefined,
|
||||
},
|
||||
localFiles: [],
|
||||
voiceRecorder: {
|
||||
isSupported: true,
|
||||
permission: "unknown",
|
||||
status: "idle",
|
||||
elapsedSeconds: 0,
|
||||
error: undefined,
|
||||
recording: undefined,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
||||
|
||||
export type FileCategory = "image" | "video" | "document";
|
||||
export type FileCategory = "image" | "video" | "audio" | "document";
|
||||
|
||||
// Unified view model for rendering attachments and local files
|
||||
export interface AttachmentItem {
|
||||
|
|
@ -24,6 +24,7 @@ export interface LocalFile {
|
|||
function categorizeFile(mimeType: string): FileCategory {
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("audio/")) return "audio";
|
||||
return "document";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Location, Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { EditorRefActions } from "../Editor";
|
||||
import type { Command } from "../Editor/commands";
|
||||
import type { EditorState } from "../state";
|
||||
|
||||
export interface MemoEditorProps {
|
||||
className?: string;
|
||||
|
|
@ -22,12 +23,23 @@ export interface EditorToolbarProps {
|
|||
onSave: () => void;
|
||||
onCancel?: () => void;
|
||||
memoName?: string;
|
||||
onVoiceRecorderClick: () => void;
|
||||
}
|
||||
|
||||
export interface EditorMetadataProps {
|
||||
memoName?: string;
|
||||
}
|
||||
|
||||
export interface VoiceRecorderPanelProps {
|
||||
voiceRecorder: EditorState["voiceRecorder"];
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onKeep: () => void;
|
||||
onDiscard: () => void;
|
||||
onRecordAgain: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface FocusModeOverlayProps {
|
||||
isActive: boolean;
|
||||
onToggle: () => void;
|
||||
|
|
@ -45,6 +57,7 @@ export interface InsertMenuProps {
|
|||
onLocationChange: (location?: Location) => void;
|
||||
onToggleFocusMode?: () => void;
|
||||
memoName?: string;
|
||||
onVoiceRecorderClick?: () => void;
|
||||
}
|
||||
|
||||
export interface TagSuggestionsProps {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { LocalFile } from "@/components/MemoEditor/types/attachment";
|
||||
import type { AttachmentItem, LocalFile } from "@/components/MemoEditor/types/attachment";
|
||||
import { toAttachmentItems } from "@/components/MemoEditor/types/attachment";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
|
||||
import SectionHeader from "../SectionHeader";
|
||||
import AudioAttachmentItem from "./AudioAttachmentItem";
|
||||
|
||||
interface AttachmentListEditorProps {
|
||||
attachments: Attachment[];
|
||||
|
|
@ -15,87 +16,117 @@ interface AttachmentListEditorProps {
|
|||
}
|
||||
|
||||
const AttachmentItemCard: FC<{
|
||||
item: ReturnType<typeof toAttachmentItems>[0];
|
||||
item: AttachmentItem;
|
||||
onRemove?: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
canMoveUp?: boolean;
|
||||
canMoveDown?: boolean;
|
||||
}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
|
||||
const { category, filename, thumbnailUrl, mimeType, size } = item;
|
||||
const { category, filename, thumbnailUrl, mimeType, size, sourceUrl } = item;
|
||||
const fileTypeLabel = getFileTypeLabel(mimeType);
|
||||
const fileSizeLabel = size ? formatFileSize(size) : undefined;
|
||||
const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename;
|
||||
|
||||
if (category === "audio") {
|
||||
return (
|
||||
<div className="rounded border border-transparent transition-all hover:border-border hover:bg-accent/20">
|
||||
<AudioAttachmentItem
|
||||
filename={filename}
|
||||
displayName={displayName}
|
||||
sourceUrl={sourceUrl}
|
||||
mimeType={mimeType}
|
||||
size={size}
|
||||
actionSlot={
|
||||
onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="inline-flex size-6.5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Remove"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all">
|
||||
<div className="shrink-0 w-6 h-6 rounded overflow-hidden bg-muted/40 flex items-center justify-center">
|
||||
{category === "image" && thumbnailUrl ? (
|
||||
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<FileIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col sm:flex-row sm:items-baseline gap-0.5 sm:gap-1.5">
|
||||
<span className="text-xs truncate" title={filename}>
|
||||
{filename}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0">
|
||||
<span>{fileTypeLabel}</span>
|
||||
{fileSizeLabel && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50 hidden sm:inline">•</span>
|
||||
<span className="hidden sm:inline">{fileSizeLabel}</span>
|
||||
</>
|
||||
<div className="relative rounded border border-transparent px-1.5 py-1 transition-all hover:border-border hover:bg-accent/20">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40">
|
||||
{category === "image" && thumbnailUrl ? (
|
||||
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-0.5">
|
||||
{onMoveUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
disabled={!canMoveUp}
|
||||
className={cn(
|
||||
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation",
|
||||
!canMoveUp && "opacity-20 cursor-not-allowed hover:bg-transparent",
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-1.5">
|
||||
<span className="truncate text-xs" title={filename}>
|
||||
{displayName}
|
||||
</span>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span>{fileTypeLabel}</span>
|
||||
{fileSizeLabel && (
|
||||
<>
|
||||
<span className="hidden text-muted-foreground/50 sm:inline">•</span>
|
||||
<span className="hidden sm:inline">{fileSizeLabel}</span>
|
||||
</>
|
||||
)}
|
||||
title="Move up"
|
||||
aria-label="Move attachment up"
|
||||
>
|
||||
<ChevronUpIcon className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onMoveDown && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveDown}
|
||||
disabled={!canMoveDown}
|
||||
className={cn(
|
||||
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation",
|
||||
!canMoveDown && "opacity-20 cursor-not-allowed hover:bg-transparent",
|
||||
)}
|
||||
title="Move down"
|
||||
aria-label="Move attachment down"
|
||||
>
|
||||
<ChevronDownIcon className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<div className="shrink-0 flex items-center gap-0.5">
|
||||
{onMoveUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
disabled={!canMoveUp}
|
||||
className={cn(
|
||||
"touch-manipulation rounded p-0.5 transition-colors hover:bg-accent active:bg-accent",
|
||||
!canMoveUp && "cursor-not-allowed opacity-20 hover:bg-transparent",
|
||||
)}
|
||||
title="Move up"
|
||||
aria-label="Move attachment up"
|
||||
>
|
||||
<ChevronUpIcon className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors ml-0.5 touch-manipulation"
|
||||
title="Remove"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<XIcon className="w-3 h-3 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
)}
|
||||
{onMoveDown && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveDown}
|
||||
disabled={!canMoveDown}
|
||||
className={cn(
|
||||
"touch-manipulation rounded p-0.5 transition-colors hover:bg-accent active:bg-accent",
|
||||
!canMoveDown && "cursor-not-allowed opacity-20 hover:bg-transparent",
|
||||
)}
|
||||
title="Move down"
|
||||
aria-label="Move attachment down"
|
||||
>
|
||||
<ChevronDownIcon className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ml-0.5 touch-manipulation rounded p-0.5 transition-colors hover:bg-destructive/10 active:bg-destructive/10"
|
||||
title="Remove"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<XIcon className="h-3 w-3 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import { FileAudioIcon, PauseIcon, PlayIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { getAttachmentUrl } from "@/utils/attachment";
|
||||
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
|
||||
import { formatAudioTime, getAttachmentMetadata } from "./attachmentViewHelpers";
|
||||
|
||||
const AUDIO_PLAYBACK_RATES = [1, 1.5, 2] as const;
|
||||
|
||||
interface AudioProgressBarProps {
|
||||
attachment: Attachment;
|
||||
filename: string;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
progressPercent: number;
|
||||
onSeek: (value: string) => void;
|
||||
}
|
||||
|
||||
const AudioProgressBar = ({ attachment, currentTime, duration, progressPercent, onSeek }: AudioProgressBarProps) => (
|
||||
const AudioProgressBar = ({ filename, currentTime, duration, progressPercent, onSeek }: AudioProgressBarProps) => (
|
||||
<div className="mt-2 flex items-center gap-2.5">
|
||||
<div className="relative flex h-4 min-w-0 flex-1 items-center">
|
||||
<div className="absolute inset-x-0 h-1 rounded-full bg-muted/75" />
|
||||
|
|
@ -26,7 +27,7 @@ const AudioProgressBar = ({ attachment, currentTime, duration, progressPercent,
|
|||
step={0.1}
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
onChange={(e) => onSeek(e.target.value)}
|
||||
aria-label={`Seek ${attachment.filename}`}
|
||||
aria-label={`Seek ${filename}`}
|
||||
className="relative z-10 h-4 w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-default
|
||||
[&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full
|
||||
[&::-webkit-slider-runnable-track]:bg-transparent
|
||||
|
|
@ -45,14 +46,31 @@ const AudioProgressBar = ({ attachment, currentTime, duration, progressPercent,
|
|||
</div>
|
||||
);
|
||||
|
||||
const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => {
|
||||
const sourceUrl = getAttachmentUrl(attachment);
|
||||
interface AudioAttachmentItemProps {
|
||||
attachment?: Attachment;
|
||||
filename?: string;
|
||||
displayName?: string;
|
||||
sourceUrl?: string;
|
||||
mimeType?: string;
|
||||
size?: number;
|
||||
actionSlot?: ReactNode;
|
||||
}
|
||||
|
||||
const AudioAttachmentItem = ({ attachment, filename, displayName, sourceUrl, mimeType, size, actionSlot }: AudioAttachmentItemProps) => {
|
||||
const resolvedFilename = attachment?.filename ?? filename ?? "audio";
|
||||
const resolvedDisplayName = displayName ?? resolvedFilename;
|
||||
const resolvedSourceUrl = attachment ? getAttachmentUrl(attachment) : (sourceUrl ?? "");
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [playbackRate, setPlaybackRate] = useState<(typeof AUDIO_PLAYBACK_RATES)[number]>(1);
|
||||
const { fileTypeLabel, fileSizeLabel } = getAttachmentMetadata(attachment);
|
||||
const { fileTypeLabel, fileSizeLabel } = attachment
|
||||
? getAttachmentMetadata(attachment)
|
||||
: {
|
||||
fileTypeLabel: getFileTypeLabel(mimeType ?? ""),
|
||||
fileSizeLabel: size ? formatFileSize(size) : undefined,
|
||||
};
|
||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -113,8 +131,8 @@ const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => {
|
|||
|
||||
<div className="flex min-w-0 flex-1 items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium leading-5 text-foreground" title={attachment.filename}>
|
||||
{attachment.filename}
|
||||
<div className="truncate text-sm font-medium leading-5 text-foreground" title={resolvedFilename}>
|
||||
{resolvedDisplayName}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs leading-4 text-muted-foreground">
|
||||
<span>{fileTypeLabel}</span>
|
||||
|
|
@ -128,11 +146,12 @@ const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => {
|
|||
</div>
|
||||
|
||||
<div className="mt-0.5 flex shrink-0 items-center gap-1">
|
||||
{actionSlot}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlaybackRateChange}
|
||||
className="inline-flex h-6 items-center justify-center px-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={`Playback speed ${playbackRate}x for ${attachment.filename}`}
|
||||
aria-label={`Playback speed ${playbackRate}x for ${resolvedDisplayName}`}
|
||||
>
|
||||
{playbackRate}x
|
||||
</button>
|
||||
|
|
@ -140,7 +159,7 @@ const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => {
|
|||
type="button"
|
||||
onClick={togglePlayback}
|
||||
className="inline-flex size-6.5 items-center justify-center rounded-md border border-border/45 bg-background/85 text-foreground transition-colors hover:bg-muted/45"
|
||||
aria-label={isPlaying ? `Pause ${attachment.filename}` : `Play ${attachment.filename}`}
|
||||
aria-label={isPlaying ? `Pause ${resolvedDisplayName}` : `Play ${resolvedDisplayName}`}
|
||||
>
|
||||
{isPlaying ? <PauseIcon className="size-3" /> : <PlayIcon className="size-3 translate-x-[0.5px]" />}
|
||||
</button>
|
||||
|
|
@ -149,7 +168,7 @@ const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => {
|
|||
</div>
|
||||
|
||||
<AudioProgressBar
|
||||
attachment={attachment}
|
||||
filename={resolvedFilename}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
progressPercent={progressPercent}
|
||||
|
|
@ -158,7 +177,7 @@ const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => {
|
|||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={sourceUrl}
|
||||
src={resolvedSourceUrl}
|
||||
preload="metadata"
|
||||
className="hidden"
|
||||
onLoadedMetadata={(e) => handleDuration(e.currentTarget.duration)}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { default as AttachmentCard } from "./AttachmentCard";
|
||||
export { default as AttachmentListEditor } from "./AttachmentListEditor";
|
||||
export { default as AttachmentListView } from "./AttachmentListView";
|
||||
export { default as AudioAttachmentItem } from "./AudioAttachmentItem";
|
||||
|
|
|
|||
|
|
@ -124,7 +124,28 @@
|
|||
"no-changes-detected": "No changes detected",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"slash-commands": "Type `/` for commands"
|
||||
"slash-commands": "Type `/` for commands",
|
||||
"voice-recorder": {
|
||||
"discard": "Discard",
|
||||
"error": "Microphone unavailable",
|
||||
"error-description": "Try again after checking microphone access for this site.",
|
||||
"idle-description": "Start recording to add a voice note as an audio attachment.",
|
||||
"keep": "Keep recording",
|
||||
"ready": "Recording ready",
|
||||
"ready-description": "Preview the clip, then keep it as an audio attachment or discard it.",
|
||||
"record-again": "Record again",
|
||||
"recording": "Recording voice note",
|
||||
"recording-description": "Capture a quick audio attachment. Current length: {{duration}}",
|
||||
"requesting": "Requesting access...",
|
||||
"requesting-permission": "Requesting microphone access",
|
||||
"requesting-permission-description": "Allow microphone access in your browser to start recording.",
|
||||
"start": "Start recording",
|
||||
"stop": "Stop recording",
|
||||
"title": "Voice recorder",
|
||||
"trigger": "Voice note",
|
||||
"unsupported": "Voice recording unsupported",
|
||||
"unsupported-description": "This browser cannot record audio from the memo composer."
|
||||
}
|
||||
},
|
||||
"inbox": {
|
||||
"failed-to-load": "Failed to load inbox item",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export function formatFileSize(bytes: number): string {
|
|||
export function getFileTypeLabel(mimeType: string): string {
|
||||
if (!mimeType) return "File";
|
||||
|
||||
const [category, subtype] = mimeType.split("/");
|
||||
const normalizedMimeType = mimeType.toLowerCase().split(";")[0].trim();
|
||||
const [category = "", subtype = ""] = normalizedMimeType.split("/");
|
||||
|
||||
const specialCases: Record<string, string> = {
|
||||
"application/pdf": "PDF",
|
||||
|
|
@ -29,8 +30,8 @@ export function getFileTypeLabel(mimeType: string): string {
|
|||
"application/javascript": "JS",
|
||||
};
|
||||
|
||||
if (specialCases[mimeType]) {
|
||||
return specialCases[mimeType];
|
||||
if (specialCases[normalizedMimeType]) {
|
||||
return specialCases[normalizedMimeType];
|
||||
}
|
||||
|
||||
if (category === "image") {
|
||||
|
|
@ -51,7 +52,7 @@ export function getFileTypeLabel(mimeType: string): string {
|
|||
if (category === "video") {
|
||||
const videoTypes: Record<string, string> = {
|
||||
mp4: "MP4",
|
||||
webm: "WebM",
|
||||
webm: "WEBM",
|
||||
ogg: "OGG",
|
||||
avi: "AVI",
|
||||
mov: "MOV",
|
||||
|
|
@ -66,7 +67,7 @@ export function getFileTypeLabel(mimeType: string): string {
|
|||
mpeg: "MP3",
|
||||
wav: "WAV",
|
||||
ogg: "OGG",
|
||||
webm: "WebM",
|
||||
webm: "WEBM",
|
||||
};
|
||||
return audioTypes[subtype] || subtype.toUpperCase();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue