Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b7e76c1
Add initial implementation of react-voice-recorder-kit
mohamad-fallah Dec 5, 2025
27a6e3b
Update README.md to enhance documentation and add new features
mohamad-fallah Dec 5, 2025
15c9c58
Update version in package.json to 1.0.3
mohamad-fallah Dec 5, 2025
40e642d
Update version to 1.0.4 and add repository metadata in package.json
mohamad-fallah Dec 5, 2025
329b28a
chore: bump version to 1.0.5
mohamad-fallah Dec 5, 2025
b3bf321
Refactor voice recorder functionality and enhance icon components
mohamad-fallah Dec 5, 2025
ef6cfab
Enhance VoiceRecorder component with new icons and customizable styles
mohamad-fallah Dec 5, 2025
0eab98f
Update README.md for improved clarity and feature documentation
mohamad-fallah Dec 5, 2025
6910f7c
Add screenshots to README.md for better visual representation of reco…
mohamad-fallah Dec 5, 2025
8b04c73
Bump version to 1.1.1 in package.json
mohamad-fallah Dec 5, 2025
9a12761
Update version to 1.1.2 and expand keywords in package.json for impro…
mohamad-fallah Dec 5, 2025
ec78228
1.1.3
mohamad-fallah Dec 5, 2025
a8a1337
added stubs
dozro Mar 10, 2026
b48c557
add a simple voice message functionality (uses external library)
dozro Mar 11, 2026
664b3c5
add onRequestClose prop to AudioMessageRecorder and handle closure in…
dozro Mar 11, 2026
04e9af1
removed unused stub files
dozro Mar 11, 2026
51a70fe
ignoring files, using the git-ignore tool
dozro Mar 12, 2026
c5b259d
adjusted info in package.json to reflect it being a fork
dozro Mar 12, 2026
b259874
removed unused dev dependencies
dozro Mar 12, 2026
a3adf2a
refactor: update audio blob type from webm to ogg for better compatib…
dozro Mar 12, 2026
bb2212e
enhance voice recorder with waveform data and audio length tracking
dozro Mar 12, 2026
56c9327
add original author attribution in README
dozro Mar 12, 2026
36a952e
moved into subdir for integration in parent project
dozro Mar 12, 2026
afa23c3
Merge branch 'dev' into feat/audio-recordings
dozro Mar 12, 2026
06cd86c
Merge remote-tracking branch 'sable-audio-kit/prepare-for-integration…
dozro Mar 12, 2026
a2065d0
moved voice recording kit
dozro Mar 12, 2026
ed906c6
removed dependency and fmt
dozro Mar 12, 2026
230d410
ignoring files, using the git-ignore tool
dozro Mar 12, 2026
cd90375
Enhance audio message recorder with waveform and audio length updates
dozro Mar 12, 2026
226b4a3
update lockfile
dozro Mar 12, 2026
fedbfa5
adjusted code styling to match ours
dozro Mar 12, 2026
8a3cfc3
removed unusual method calls
dozro Mar 12, 2026
d5ede0b
removed unused import
dozro Mar 12, 2026
3d26970
removed file mistakingly commited
dozro Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add_voice_message_func.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
sable: minor
---

add voice message composing
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ devAssets
*.tfbackend
!*.tfbackend.example
crash.log

# the following line was added with the "git ignore" tool by itsrye.dev, version 0.1.0
.lh
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 7 additions & 8 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
engineStrict: true
minimumReleaseAge: 1440

allowBuilds:
'@swc/core': true
esbuild: true
sharp: true
unrs-resolver: true
workerd: true
engineStrict: true
minimumReleaseAge: 1440

overrides:
serialize-javascript: '>=7.0.3'
rollup: '>=4.59.0'
minimatch: '>=10.2.3'
lodash: '>=4.17.23'
esbuild: '>=0.25.0'
brace-expansion: '>=1.1.12'
esbuild: '>=0.25.0'
lodash: '>=4.17.23'
minimatch: '>=10.2.3'
rollup: '>=4.59.0'
serialize-javascript: '>=7.0.3'

peerDependencyRules:
allowedVersions:
Expand Down
85 changes: 85 additions & 0 deletions src/app/features/room/AudioMessageRecorder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { VoiceRecorder } from '$plugins/voice-recorder-kit';
import FocusTrap from 'focus-trap-react';
import { Box, Text, color, config } from 'folds';
import { useRef } from 'react';

type AudioMessageRecorderProps = {
onRecordingComplete: (audioBlob: Blob) => void;
onRequestClose: () => void;
onWaveformUpdate: (waveform: number[]) => void;
onAudioLengthUpdate: (length: number) => void;
};

// We use a react voice recorder library to handle the recording of audio messages, as it provides a simple API and handles the complexities of recording audio in the browser.
// The component is wrapped in a focus trap to ensure that keyboard users can easily navigate and interact with the recorder without accidentally losing focus or interacting with other parts of the UI.
// The styling is kept simple and consistent with the rest of the app, using Folds' design tokens for colors, spacing, and typography.
// we use a modified version of https://www.npmjs.com/package/react-voice-recorder-kit for the recording
export function AudioMessageRecorder({
onRecordingComplete,
onRequestClose,
onWaveformUpdate,
onAudioLengthUpdate,
}: AudioMessageRecorderProps) {
const containerRef = useRef<HTMLDivElement>(null);
const isDismissedRef = useRef(false);

// uses default styling, we use at other places
return (
<FocusTrap
focusTrapOptions={{
returnFocusOnDeactivate: false,
initialFocus: false,
onDeactivate: () => {
isDismissedRef.current = true;
onRequestClose();
},
clickOutsideDeactivates: true,
allowOutsideClick: true,
fallbackFocus: () => containerRef.current!,
}}
>
<div ref={containerRef} tabIndex={-1} style={{ outline: 'none' }}>
<Box
direction="Column"
gap="200"
alignItems="Center"
style={{
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R400,
boxShadow: config.shadow.E200,
padding: config.space.S400,
minWidth: 300,
}}
>
<Text size="H4">Audio Message Recorder</Text>
<VoiceRecorder
autoStart
onStop={({
audioFile,
waveform,
audioLength,
}: {
audioFile: Blob;
waveform: number[];
audioLength: number;
}) => {
if (isDismissedRef.current) return;
// closes the recorder and sends the audio file back to the parent component to be uploaded and sent as a message
onRecordingComplete(audioFile);
onWaveformUpdate(waveform);
onAudioLengthUpdate(audioLength);
}}
buttonBackgroundColor={color.SurfaceVariant.Container}
buttonHoverBackgroundColor={color.SurfaceVariant.ContainerHover}
iconColor={color.Primary.Main}
style={{
backgroundColor: color.Surface.ContainerActive,
}}
/>
</Box>
</div>
</FocusTrap>
);
}
48 changes: 47 additions & 1 deletion src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ import {
getVideoMsgContent,
} from './msgContent';
import { CommandAutocomplete } from './CommandAutocomplete';
import { AudioMessageRecorder } from './AudioMessageRecorder';

const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => {
if (!replyDraft) return {};
Expand Down Expand Up @@ -195,6 +196,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const commands = useCommands(mx, room);
const emojiBtnRef = useRef<HTMLButtonElement>(null);
const micBtnRef = useRef<HTMLButtonElement>(null);
const roomToParents = useAtomValue(roomToParentsAtom);
const nicknames = useAtomValue(nicknamesAtom);

Expand Down Expand Up @@ -225,6 +227,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);

const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [showAudioRecorder, setShowAudioRecorder] = useState(false);
const [audioMsgWaveform, setAudioMsgWaveform] = useState<number[] | undefined>(undefined);
const [audioMsgLength, setAudioMsgLength] = useState<number | undefined>(undefined);
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
const [isQuickTextReact, setQuickTextReact] = useState(false);
Expand Down Expand Up @@ -406,7 +411,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return getVideoMsgContent(mx, fileItem, upload.mxc);
}
if (fileItem.file.type.startsWith('audio')) {
return getAudioMsgContent(fileItem, upload.mxc);
return getAudioMsgContent(fileItem, upload.mxc, audioMsgWaveform, audioMsgLength);
}
return getFileMsgContent(fileItem, upload.mxc);
});
Expand Down Expand Up @@ -948,6 +953,47 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<IconButton
ref={micBtnRef}
variant="SurfaceVariant"
size="300"
radii="300"
title="record audio message"
aria-pressed={showAudioRecorder}
onClick={() => setShowAudioRecorder(!showAudioRecorder)}
>
<Icon src={Icons.Mic} />
</IconButton>
{showAudioRecorder && (
<PopOut
anchor={micBtnRef.current?.getBoundingClientRect() ?? undefined}
offset={8}
position="Top"
align="End"
alignOffset={-44}
content={
<AudioMessageRecorder
onRequestClose={() => {
setShowAudioRecorder(false);
}}
onRecordingComplete={(audioBlob) => {
const file = new File(
[audioBlob],
`sable-audio-message-${Date.now()}.ogg`,
{
type: audioBlob.type,
}
);
handleFiles([file]);
// Close the recorder after handling the file, to give some feedback that the recording was successful
setShowAudioRecorder(false);
}}
onAudioLengthUpdate={(len) => setAudioMsgLength(len)}
onWaveformUpdate={(w) => setAudioMsgWaveform(w)}
/>
}
/>
)}
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
<PopOut
Expand Down
16 changes: 15 additions & 1 deletion src/app/features/room/msgContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,17 @@ export const getVideoMsgContent = async (
return content;
};

export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => {
export type AudioMsgContent = IContent & {
waveform?: number[];
audioLength?: number;
};

export const getAudioMsgContent = (
item: TUploadItem,
mxc: string,
waveform?: number[],
audioLength?: number
): AudioMsgContent => {
const { file, encInfo } = item;
const content: IContent = {
msgtype: MsgType.Audio,
Expand All @@ -155,6 +165,10 @@ export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent =>
mimetype: file.type,
size: file.size,
},
'org.matrix.msc1767.audio': {
waveform: waveform?.map((v) => Math.round(v * 1024)), // scale waveform values to fit in 10 bits (0-1024) for more efficient storage, as per MSC1767 spec
duration: item.metadata.markedAsSpoiler || !audioLength ? 0 : audioLength * 1000, // if marked as spoiler, set duration to 0 to hide it in clients that support msc1767
},
};
if (encInfo) {
content.file = {
Expand Down
Loading
Loading