We got feedback from a self-hoster about two friction points in the Library overflow menu. Both were things we'd built quickly and shipped without a second thought. Reading the report, we recognized the patterns immediately — not as bugs, but as design choices we'd never actually questioned.
The fixes were small. The lessons were worth writing down.
The Cycle-Click Problem
Comment mode on a video controls who can leave comments: off, anonymous, name required, or name + email required. We had built a button that cycled through those four states in order.
Click once: anonymous. Click again: name required. Click again: name + email. Click again: off.
The problem is obvious in hindsight. You can't see all the options at once. If you want "name required" but you're currently at "anonymous", you click once. Fine. But if you're at "name + email" and want "off", you click once, get "off", and hope you didn't overshoot. There's no undo. There's no visibility. There's just clicking and checking.
The original handler looked like this:
async function cycleCommentMode(video: Video) {
const currentIndex = commentModeOrder.indexOf(video.commentMode);
const nextMode = commentModeOrder[(currentIndex + 1) % commentModeOrder.length];
await apiFetch(`/api/videos/${video.id}/comment-mode`, {
method: "PUT",
body: JSON.stringify({ commentMode: nextMode }),
});
setVideos((prev) => prev.map((v) => (v.id === video.id ? { ...v, commentMode: nextMode } : v)));
}
Clever, compact, and completely wrong for the user. The modular arithmetic is fun to write. It is not fun to use.
We already had a better pattern in the same menu: the notifications setting uses a <select> dropdown. You open it, see all options at once, pick the one you want. No overshooting. No mystery.
We replaced the cycle button with the same pattern:
async function changeCommentMode(video: Video, mode: string) {
try {
await apiFetch(`/api/videos/${video.id}/comment-mode`, {
method: "PUT",
body: JSON.stringify({ commentMode: mode }),
});
setVideos((prev) => prev.map((v) => (v.id === video.id ? { ...v, commentMode: mode } : v)));
} catch {
// select stays at previous value since state is only updated on success
}
}
The catch block deserves a note. We only update local state after a successful API call. If the request fails, the select element appears to revert — because it does. The React state didn't change, so the component re-renders with the old value. No explicit rollback needed.
The JSX for the dropdown:
<select
aria-label="Comment mode"
value={video.commentMode}
onChange={(e) => changeCommentMode(video, e.target.value)}
style={{
color: video.commentMode !== "disabled"
? "var(--color-accent)"
: "var(--color-text-secondary)",
}}
>
<option value="disabled">Off</option>
<option value="anonymous">Anonymous</option>
<option value="name_required">Name required</option>
<option value="name_email_required">Name + email</option>
</select>
The color change is a small touch. When comments are disabled, the select uses the secondary text color. When any comment mode is active, it uses the accent color as a visual signal. Users notice this without being told to.
The Closing-Menu Problem
This one took us longer to see because we'd built it deliberately. Every action in the overflow menu — toggling downloads, setting or clearing an expiry, adding or removing a password — closed the menu and showed a toast notification.
Our reasoning at the time: the action succeeded, the menu is no longer relevant, close it.
Our user's experience: open menu, toggle download, menu closes, toast flashes, open menu again, toggle expiry, menu closes, toast flashes, open menu again.
Three actions meant three full open-close-toast cycles.
The original download toggle looked like this:
<button
onClick={() => { toggleDownload(video); setOpenMenuId(null); }}
className="action-link"
style={{ color: video.downloadEnabled ? "var(--color-accent)" : undefined }}
>
{video.downloadEnabled ? "Downloads on" : "Downloads off"}
</button>
We were explicitly calling setOpenMenuId(null) on every toggle. The menu didn't close because of some side effect — we told it to close. Once we saw it written out, the fix was obvious.
<button
onClick={() => toggleDownload(video)}
className="action-link"
style={{ color: video.downloadEnabled ? "var(--color-accent)" : undefined }}
>
{video.downloadEnabled ? "Downloads on" : "Downloads off"}
</button>
Remove the close call. That's the entire fix for the closing behavior.
We also removed the toast for toggle actions. A toast makes sense for one-shot actions — copying an embed code, starting a trim — where something happened and there's nothing visible in the UI to confirm it. For toggles, the button label changes in place. "Downloads on" becomes "Downloads off". That is the confirmation. A toast on top of that is noise.
The simplified toggle handler:
async function toggleDownload(video: Video) {
const newValue = !video.downloadEnabled;
await apiFetch(`/api/videos/${video.id}/download-enabled`, {
method: "PUT",
body: JSON.stringify({ downloadEnabled: newValue }),
});
setVideos((prev) => prev.map((v) => (v.id === video.id ? { ...v, downloadEnabled: newValue } : v)));
}
We applied the same change to expiry, password, and notification toggles. The menu now stays open through multiple toggle actions. If you want to turn off downloads, remove the expiry, and clear the password in one session, you can do all three without reopening the menu.
One-shot actions still close the menu. Copying an embed code, opening the trim editor, navigating to analytics — those close because the action takes you somewhere else or the task is complete. The distinction is: did you change a setting (stay open) or did you trigger a workflow (close)?
What We Didn't Build
Library folders and multi-select came up in the same feedback. Both would help with bulk operations. We didn't build them because they require a real organizational model — what happens when you move a video between folders, does it affect the share link, how does search interact with folder scope. That needs design work before code.
We also looked at merging Trim and Extend into a single edit tool instead of two separate menu items. Unifying them is cleaner but it shifts where the user makes a decision. We want to observe more usage patterns before committing to that change.
Both are on the list. Neither was ready.
Try It
SendRec is open source — an EU-native async video messaging platform, self-hostable on your own infrastructure. The changes described here are live in v1.34.0.
You can try it at app.sendrec.eu or read the source and self-host from github.com/sendrec/sendrec.
If you hit something that feels like friction, open an issue. That's how this fix happened.
Top comments (0)